import { IconPencil } from "@tabler/icons-react"; import { NodeSelection, Plugin, PluginKey, type EditorState, } from "@tiptap/pm/state"; import { Node, NodeViewWrapper, ReactNodeViewRenderer, mergeAttributes, type NodeViewProps, } from "@tiptap/react"; import { useEffect, useMemo, useState, type ReactNode, type MouseEvent as ReactMouseEvent, } from "react"; import { RegistryBlockDataProvider, useRegistryBlockData, type RegistryBlockDataChangeMeta, type RegistryBlockDataValue, type RegistryBlockEditSurfaceOptions, type RegistryBlockNestedBlock, type RegistryBlockRenderOptions, type RegistryBlockRenderResult, type RegistryBlockSideMapBlock, } from "./RegistryBlockContext.js"; export { RegistryBlockDataProvider, useRegistryBlockData, type RegistryBlockDataChangeMeta, type RegistryBlockDataValue, type RegistryBlockEditSurfaceOptions, type RegistryBlockNestedBlock, type RegistryBlockRenderOptions, type RegistryBlockRenderResult, type RegistryBlockSideMapBlock, } from "./RegistryBlockContext.js"; /* -------------------------------------------------------------------------- */ /* The generic registry-block side-map + Tiptap NodeView. */ /* */ /* This is the app-agnostic NodeView that renders registered block specs */ /* inside a `SharedRichEditor` document. Hosts mount the node produced by */ /* {@link createRegistryBlockNode} as an extra extension and wrap the editor */ /* in a {@link RegistryBlockDataProvider}, sourcing the typed block `data` */ /* from their own authoritative store (for example, PlanContent.blocks). The */ /* node itself carries only lightweight identity attrs (type/id/title/summary) */ /* plus an optional `__raw` verbatim-MDX attr for byte-stable source */ /* round-trips; the heavy typed `data` is threaded through the side-map */ /* context, keeping the doc small and the block data the single source of */ /* truth. */ /* -------------------------------------------------------------------------- */ function clickedInteractiveChild(target: HTMLElement) { if (target.closest("button,input,textarea,select,a,[role='textbox']")) { return true; } // The block drag-handle grip is a `role="button"` div that lives in the editor // wrapper, and nested container blocks (columns/tabs) render their own inner // editors — each its own grip. A container block's `onMouseDownCapture` // selection handler runs in a SEPARATE React root (Tiptap mounts every node // view as its own root), so its `stopPropagation()` halts the NATIVE mousedown // right there, before it can reach an inner grip or inner editor. Treat the // grip and anything inside a nested editor region as interactive so the outer // container never hijacks a mousedown meant for inner machinery — this is what // makes dragging a block OUT of / BETWEEN columns (a nested grip) work at all. if ( target.closest(".drag-handle") || target.closest(".plan-nested-document-editor-region") ) { return true; } const blockNode = target.closest(".plan-block-node"); const editable = target.closest("[contenteditable='true']"); return !!blockNode && !!editable && blockNode.contains(editable); } /* -------------------------------------------------------------------------- */ /* B. RegistryBlockNodeView (React) */ /* -------------------------------------------------------------------------- */ /** * Renders one registry-block atom. The block is non-editable as far as * ProseMirror is concerned (`contentEditable={false}`); all interaction happens * inside the registry-driven ``. Read vs edit is toggled by * `props.selected` (the node is "selected" in the editor) AND the document being * editable. `data-plan-interactive` keeps existing host click-guards from * treating clicks inside the block as document clicks. */ export function RegistryBlockNodeView(props: NodeViewProps) { const blockType = String(props.node.attrs.blockType ?? ""); const blockId = String(props.node.attrs.blockId ?? ""); const [panelOpen, setPanelOpen] = useState(false); const [shellHovered, setShellHovered] = useState(false); const sideMap = useRegistryBlockData(); // Optimistic edit override. `onBlockDataChange` commits into the host's own // store (a ref the side-map context can't observe), so an edit does NOT // re-render this node — the new data only reaches the view on the next full // document reconcile, which lands after the autosave round-trip (seconds // later) and is skipped entirely when the host treats the save as its own // echo. That left quick toggles like the callout tone buttons visually frozen // until reload. Holding the just-edited data locally re-renders this one node // immediately, then releases once the authoritative block catches up to (or // moves past) the value the edit was based on. const [pendingEdit, setPendingEdit] = useState<{ data: unknown; base: unknown; } | null>(null); const liveBlock = sideMap?.getBlock(blockId); const liveData = liveBlock?.data; useEffect(() => { if (pendingEdit && !Object.is(liveData, pendingEdit.base)) { setPendingEdit(null); } }, [liveData, pendingEdit]); const block = liveBlock && pendingEdit && Object.is(liveData, pendingEdit.base) ? { ...liveBlock, data: pendingEdit.data } : liveBlock; const commitBlockData = ( nextData: unknown, meta?: RegistryBlockDataChangeMeta, ) => { setPendingEdit({ data: nextData, base: liveData }); sideMap?.onBlockDataChange(blockId, nextData, meta); }; const editable = sideMap?.editable ?? false; // In Notion-sync mode, flag blocks that have no Notion (NFM) analog so the // author sees what won't push. Prose blocks aren't registry-block nodes, so // this only ever covers structured blocks. const incompatibleWithNotion = (sideMap?.notionSync ?? false) && (sideMap?.isNotionIncompatibleType?.(blockType) ?? false); // The block data isn't in the side-map yet (e.g. a freshly inserted node whose // store entry hasn't been seeded). Render a graceful placeholder. if (!block) { return (
{blockType ? `Loading ${blockType} block…` : "Loading block…"}
); } const selectNode = (event: ReactMouseEvent) => { if (!editable) return; const target = event.target; if (target instanceof HTMLElement && clickedInteractiveChild(target)) return; const pos = typeof props.getPos === "function" ? props.getPos() : null; if (typeof pos !== "number") return; try { event.preventDefault(); event.stopPropagation(); const { view } = props.editor; view.dispatch( view.state.tr.setSelection(NodeSelection.create(view.state.doc, pos)), ); view.focus(); } catch { // Ignore stale positions during React/ProseMirror reconciliation. } }; const updateShellHover = (event: ReactMouseEvent) => { const target = event.target; setShellHovered( target instanceof HTMLElement && target.closest(".plan-block-node__shell") === event.currentTarget, ); }; // Choose how to render the block body: // 1. A host registry adapter renders a registered block. // 2. No spec, but the side-map provides `renderLegacyBlock` → delegate to the // host's dispatcher (decision, legacy visual-questions, image, and any // other type rendered by a bespoke component rather than the registry), so // EVERY block type renders in the document exactly as it does in the // per-block reader — never a bare title fallback. // 3. Neither → a small non-crashing fallback. let body: ReactNode; let editSurface: ReactNode = null; const registered = sideMap?.renderRegisteredBlock?.(block, { blockType, editable, selected: props.selected, shellHovered, panelOpen, setPanelOpen, onChange: commitBlockData, }); if (registered) { body = registered.body; editSurface = registered.editSurface ?? null; } else if (sideMap?.renderLegacyBlock) { // Self-editing legacy blocks (e.g. image) render their own edit affordance // inside their overlay, so render them in edit mode and add NO separate // corner edit surface — the block owns a single, self-contained overlay. const selfEdits = editable && Boolean(sideMap.legacyBlockSelfEdits?.(blockType)); body = sideMap.renderLegacyBlock(block, { editing: selfEdits }); if (editable && !selfEdits) { // Prefer a host-provided schema/custom editor (a real form) over the raw // JSON fallback when the host knows how to edit this legacy block type. const customEditor = sideMap.renderLegacyBlockEditor?.(block, { onChange: (nextData) => commitBlockData(nextData), }); editSurface = ( commitBlockData(nextBlock)} selected={shellHovered} customEditor={customEditor} /> ); } } else { body = (
{block.title || blockType || "Unsupported block"}
); } return (
setShellHovered(false)} > {incompatibleWithNotion && ( Won't sync to Notion )} {body} {editSurface && (
{editSurface}
)}
); } export function LegacyJsonEditSurface({ block, blockType, open, onOpenChange, renderEditSurface, onChange, selected, customEditor, }: { block: RegistryBlockSideMapBlock; blockType?: string; open: boolean; onOpenChange: (open: boolean) => void; renderEditSurface?: (options: RegistryBlockEditSurfaceOptions) => ReactNode; onChange: (nextData: unknown) => void; selected: boolean; /** * A host-provided form editor. When present it * replaces the raw-JSON textarea + Save button; the form autosaves through its * own `onChange`. */ customEditor?: ReactNode; }) { const serializedBlockData = useMemo( () => JSON.stringify(block.data, null, 2), [block.data], ); const [draft, setDraft] = useState(serializedBlockData); const [parseError, setParseError] = useState(null); useEffect(() => { setDraft(serializedBlockData); setParseError(null); }, [block.id, serializedBlockData]); const saveDraft = () => { try { const nextData = JSON.parse(draft) as unknown; setParseError(null); onChange(nextData); onOpenChange(false); } catch (error) { setParseError( error instanceof Error ? error.message : "Invalid JSON data.", ); } }; const trigger = ( ); const jsonEditor = (