);
}, deepEqualsBlockProps);
// Registers the block as a draggable and a drop target, and hands the
// activator down to its handles: the ListMarker through ListDragHandleContext
// and, for blocks that aren't text editors, the body through
// BlockBodyDragContext (read by BlockLayout). Lives in its own leaf component
// because dnd-kit's hooks re-render their consumers on every `over` change
// during a drag: everything they touch here is memoized, so the block subtree
// (passed through as `children`) bails out and only this wiring re-renders.
// The measured node is an inset overlay div rather than the block wrapper
// itself for the same reason.
const BlockListDnd = (props: {
entityID: string;
isListItem: boolean;
bodyDraggable: boolean;
children: React.ReactNode;
}) => {
let draggable = useDraggable({
id: props.entityID,
disabled: !props.isListItem && !props.bodyDraggable,
});
let droppable = useDroppable({ id: props.entityID });
let setNodeRef = useCallback(
(el: HTMLElement | null) => {
draggable.setNodeRef(el);
droppable.setNodeRef(el);
},
[draggable.setNodeRef, droppable.setNodeRef],
);
let { attributes, listeners, setActivatorNodeRef } = draggable;
let dragHandle = useMemo(
() =>
props.isListItem ? { attributes, listeners, setActivatorNodeRef } : null,
[props.isListItem, attributes, listeners, setActivatorNodeRef],
);
return (
<>
{props.children}
>
);
};
// The line marking where a dragged list item will drop, drawn on the edge of
// the adjacent block and indented to the depth the item will land at.
const ListDropIndicator = (props: { indicator: string }) => {
let [edge, depth] = props.indicator.split(":");
return (
);
};
// Everything the Block render tree reads off its props. headingLevel /
// headingPath / listData.checked / listData.listStart are deliberately not
// compared: they're re-fetched via useEntity or unused by this subtree.
function deepEqualsBlockProps(
prevProps: BlockProps & { preview?: boolean },
nextProps: BlockProps & { preview?: boolean },
): boolean {
const scalarKeys = [
"pageType",
"entityID",
"parent",
"position",
"factID",
"type",
"nextPosition",
"headingFoldable",
"displayDepth",
"preview",
] as const;
if (scalarKeys.some((k) => prevProps[k] !== nextProps[k])) return false;
if (!listDataEquals(prevProps.listData, nextProps.listData)) return false;
return (
neighborEquals(prevProps.nextBlock, nextProps.nextBlock) &&
neighborEquals(prevProps.previousBlock, nextProps.previousBlock)
);
}
function listDataEquals(prev: Block["listData"], next: Block["listData"]) {
if (prev === next) return true;
if (!prev || !next) return false;
return (
prev.checklist === next.checklist &&
prev.parent === next.parent &&
prev.depth === next.depth &&
prev.displayNumber === next.displayNumber &&
prev.listStyle === next.listStyle &&
prev.path.length === next.path.length &&
prev.path.every(
(p, i) =>
p.depth === next.path[i].depth && p.entity === next.path[i].entity,
)
);
}
function neighborEquals(prev: Block | null, next: Block | null) {
if (prev === next) return true;
if (!prev || !next) return false;
return (
prev.factID === next.factID &&
prev.parent === next.parent &&
prev.position === next.position &&
prev.entityID === next.entityID &&
prev.type === next.type &&
deepEquals(prev.listData, next.listData)
);
}
export const BaseBlock = (
props: BlockProps & {
preview?: boolean;
areYouSure?: boolean;
setAreYouSure?: (value: boolean) => void;
},
) => {
// BaseBlock renders the actual block content, delete states, controls spacing between block and list markers
let BlockTypeComponent = BlockTypeComponents[props.type];
if (!BlockTypeComponent) return
unknown block
;
return (
<>
{props.headingFoldable && }
{props.listData && }
{props.areYouSure ? (
props.setAreYouSure && props.setAreYouSure(false)
}
type={props.type}
entityID={props.entityID}
/>
) : (
)}
>
);
};
const BlockTypeComponents: {
[K in Fact<"block/type">["data"]["value"]]: React.ComponentType<
BlockProps & { preview?: boolean }
>;
} = {
code: CodeBlock,
math: MathBlock,
card: PageLinkBlock,
text: TextBlock,
blockquote: TextBlock,
heading: TextBlock,
image: ImageBlock,
"image-gallery": ImageGalleryBlock,
link: ExternalLinkBlock,
embed: EmbedBlock,
html: HTMLBlock,
mailbox: MailboxBlock,
datetime: DateTimeBlock,
rsvp: RSVPBlock,
button: ButtonBlock,
poll: PollBlock,
"bluesky-post": BlueskyPostBlock,
"standard-site-post": StandardSitePostBlock,
"standard-site-publication": StandardSitePublicationBlock,
"horizontal-rule": HorizontalRule,
"members-only-delimiter": MembersOnlyDelimiterBlock,
"posts-list": PostsListBlock,
signup: SubscribeBlock,
};
const BlockMultiselectIndicator = (props: BlockProps) => {
let first = props.previousBlock === null;
// One packed primitive selector instead of three array scans: null when not
// multiselected, otherwise which neighbors are also selected (for corner
// rounding).
let multiselectState = useUIState((s) => {
if (
s.selectedBlocks.length <= 1 ||
!s.selectedBlocks.some((b) => b.entityID === props.entityID)
)
return null;
let next = s.selectedBlocks.some(
(b) => b.entityID === props.nextBlock?.entityID,
);
let prev = s.selectedBlocks.some(
(b) => b.entityID === props.previousBlock?.entityID,
);
return `${next ? "n" : ""}${prev ? "p" : ""}`;
});
if (multiselectState !== null)
return (
);
};
export const BlockLayout = (props: {
isSelected: boolean;
children: React.ReactNode;
className?: string;
optionsClassName?: string;
hasBackground?: "accent" | "page";
borderOnHover?: boolean;
hasAlignment?: boolean;
areYouSure?: boolean;
setAreYouSure?: (value: boolean) => void;
extraOptions?: React.ReactNode;
}) => {
// this is used to wrap non-text blocks in consistent selected styling, spacing, and top level options like delete
let bodyDrag = useBlockBodyDrag();
return (
{
// A hold on a control inside the block (a vote button, a caption
// input, the embed's resize handle) is meant for the control.
if (
(e.target as Element).closest(
"button, a, input, textarea, select, [contenteditable], [data-draggable]",
)
)
return;
bodyDrag.onPointerDown?.(e);
}
: undefined
}
>
{/* A block's own list marker sits beside its body, so any ListMarker
rendered inside (a page link's preview of its page's title) must
not inherit the handle and pick this block up. */}
{props.children}
{props.isSelected && (
)}
);
};
let debounced: null | number = null;
const NonTextBlockOptions = (props: {
areYouSure?: boolean;
setAreYouSure?: (value: boolean) => void;
optionsClassName?: string;
extraOptions?: React.ReactNode;
}) => {
let { rep, undoManager } = useReplicache();
let entity_set = useEntitySetContext();
let focusedEntity = useUIState((s) => s.focusedEntity);
let focusedEntityType = useEntity(
focusedEntity?.entityType === "page"
? focusedEntity.entityID
: focusedEntity?.parent || null,
"page/type",
);
let isMultiselected = useUIState((s) => s.selectedBlocks.length > 1);
if (focusedEntity?.entityType === "page") return;
if (isMultiselected) return;
if (!entity_set.permissions.write) return null;
return (