"use client"; /** * Inline rich-text editor for text-ish blocks, backed by Tiptap 2. * * Custom nodes from `@factory/editor` (mention, equation, inline code * variants, …) are registered here — the package owns the node definitions, * this app only wires them into the editor. Tiptap owns the ProseMirror * state; the block document is our canonical `Block` JSON (ARCHITECTURE.md * §2), serialized with @factory/editor on blur/save. * * Persistence is delegated: `onChange` is debounced by the caller (the * block renderer queues a batch save), `onBlur` forces the final save. */ import { useCallback, useEffect, useRef, useState } from "react"; import { EditorContent, useEditor, type Editor } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import Placeholder from "@tiptap/extension-placeholder"; import CharacterCount from "@tiptap/extension-character-count"; import Link from "@tiptap/extension-link"; import Image from "@tiptap/extension-image"; import TaskList from "@tiptap/extension-task-list"; import TaskItem from "@tiptap/extension-task-item"; // Custom nodes (mention, equation, inline math, toggle-title, …) — owned by // packages/editor, consumed here. Import shape is fixed by that package. import { atomHtmlToText, atomTextToHtml, factoryEditorExtensions } from "@factory/editor"; import { cn } from "@/lib/utils"; import { registerPresenceEditor } from "@/lib/presence-editor-views"; import { usePresenceCaretStore } from "@/stores/presence-store"; import { SlashMenu, type SlashCommand } from "./slash-menu"; export interface InlineEditorProps { blockId: string; /** Canonical block JSON content (the serialized Tiptap doc object). */ content: unknown; placeholder?: string; className?: string; /** Called on every doc change (caller debounces persistence). */ onChange?: (blockId: string, content: unknown) => void; /** Called when the editor loses focus (final save). */ onBlur?: (blockId: string, content: unknown) => void; /** True while the block row is selected (ring highlight). */ selected?: boolean; readOnly?: boolean; /** Focus this instance when it becomes true (deep-link / Enter flow). */ requestFocus?: boolean; onFocusBlock?: (blockId: string) => void; /** * Block-level keyboard bridge (wired to ProseMirror handleKeyDown): * Enter at the top of the doc → new sibling block; ArrowUp/Down at the * doc boundary → previous/next block. Cmd/Ctrl+Z stays with Tiptap's * history extension. */ onEnterKey?: () => void; onNavigate?: (dir: -1 | 1) => void; /** * Slash-menu context: where the editor's block sits, so the parent can * insert the picked command as a sibling after it. */ slashContext?: { parentId: string | null; position: string }; /** * Invoked when the user types "/" in an otherwise empty block and picks a * command. The typed "/" text is cleared by this editor before the * callback fires. */ onSlashPick?: ( command: SlashCommand, context: { parentId: string | null; position: string }, ) => void; } /** Escape a stored plain-text value so it is DATA, never markup (untrusted). */ function escapeHtml(s: string): string { return s.replace(/&/g, "&").replace(//g, ">"); } function toHtml(content: unknown): string { if (!content) return ""; // Our canonical content is a serialized Tiptap JSON doc. The editor accepts // HTML — convert via a lightweight path: store the doc as HTML on write // (see page-view save path) so round-tripping stays lossless for MVP. if (typeof content === "string") return content; // Object content — the API's canonical `{ text }` shape (blocks created via // the API, import, or the AI apply path — which writes `{ text: newText }`) // — must still render: without this, every non-web-authored block displays // blank (TASK-0123 found the writing e2e hitting exactly this). The stored // text is plain text, so wrap it in a paragraph and escape it (it is // untrusted data, never HTML). const c = content as { text?: unknown; title?: unknown }; const raw = typeof c.text === "string" ? c.text : typeof c.title === "string" ? c.title : undefined; if (raw === undefined || raw === "") return ""; // T-148: atom tokens (@date/@status/@label) become styled atom nodes; the // bridge escapes all text segments (stored text is untrusted data). return `

${atomTextToHtml(raw)}

`; } /** * T-148 save boundary: editor HTML → stored content. Atoms round-trip to * `@kind(value)` tokens in `{ text }` (the API canonical form). Atom-less * HTML is returned untouched — legacy blocks keep their exact content. */ function saveContent(editor: { getHTML(): string }): unknown { const html = editor.getHTML(); const text = atomHtmlToText(html); return text === null ? html : { text }; } export function InlineEditor({ blockId, content, placeholder = "Type '/' for commands", className, onChange, onBlur, selected, readOnly, requestFocus, onFocusBlock, onEnterKey, onNavigate, slashContext, onSlashPick, }: InlineEditorProps) { const editorRef = useRef(null); const initialContent = useRef(toHtml(content)); const [slash, setSlash] = useState<{ query: string; anchor: { top: number; left: number }; } | null>(null); // Keep the latest callbacks without re-creating the editor on every render. const handlers = useRef({ onEnterKey, onNavigate, slashContext, onSlashPick }); handlers.current = { onEnterKey, onNavigate, slashContext, onSlashPick }; const editor = useEditor({ extensions: [ StarterKit.configure({ heading: { levels: [1, 2, 3] }, // Lists/todos come from the dedicated extensions below. bulletList: false, orderedList: false, codeBlock: false, }), TaskList, TaskItem.configure({ nested: true }), Placeholder.configure({ placeholder }), CharacterCount, Link.configure({ openOnClick: false, autolink: true }), Image, // @factory/editor custom nodes — kept last so they can override. ...factoryEditorExtensions(), ], content: initialContent.current, editable: !readOnly, immediatelyRender: false, editorProps: { attributes: { class: "editor-content focus:outline-none", "data-block-id": blockId, }, handleKeyDown: (_view, event: KeyboardEvent) => { if (!editor) return false; const { $from } = editor.state.selection; // Enter on a top-level paragraph creates a sibling block below. // `$from.depth === 1`: the caret sits inside a top-level text block // (doc > paragraph > text). The old `depth === 0` guard was the DOC // root — unreachable for a text caret — so Enter never reached // onEnterKey (Tiptap split the block in place instead). (The // ArrowUp/Down guards below still check `depth === 0`; they are // equally dead and are left as-is, recorded in the Phase-15 notes.) if (event.key === "Enter" && !event.shiftKey && $from.depth === 1) { event.preventDefault(); handlers.current.onEnterKey?.(); return true; } if (event.key === "ArrowUp" && $from.depth === 0 && $from.parentOffset === 0) { event.preventDefault(); handlers.current.onNavigate?.(-1); return true; } if ( event.key === "ArrowDown" && $from.depth === 0 && $from.parentOffset >= $from.parent.nodeSize - 2 ) { event.preventDefault(); handlers.current.onNavigate?.(1); return true; } return false; }, }, onUpdate: ({ editor }) => { editorRef.current = editor; onChange?.(blockId, saveContent(editor)); // Slash trigger: block text starting with "/" (and a short query) // opens the command menu, anchored at the caret. const docText = editor.state.doc.textContent; if (!docText.startsWith("/")) { setSlash(null); return; } const query = docText.slice(1); if (query.trimStart().length > 24) { setSlash(null); return; } const pos = editor.state.selection.from; const rect = editor.view.coordsAtPos(pos); setSlash({ query, anchor: { top: rect.bottom + 4, left: rect.left } }); }, onBlur: ({ editor }) => { editorRef.current = editor; setSlash(null); onBlur?.(blockId, saveContent(editor)); // Presence (TASK-0093): this editor no longer owns the caret. If the // next focused editor is another block it re-asserts its own; if // focus left the page, the caret stays cleared. usePresenceCaretStore.getState().setCaret(null); }, onFocus: ({ editor }) => { onFocusBlock?.(blockId); reportPresenceCaret(editor); }, onSelectionUpdate: ({ editor }) => { // Presence (TASK-0093): the focused editor reports its caret (the // per-page usePresence hook throttles it into `presence` frames). reportPresenceCaret(editor); }, }); // Presence (TASK-0093): report this editor's caret to the presence store // (the page's usePresence hook turns it into throttled `presence` frames). // The offset is within THIS block's text (Tiptap doc coords — the page // has no shared document), which is exactly what the wire cursor shape // carries ({blockId, offset, selection?}). const reportPresenceCaret = (ed: Editor): void => { const { from, to, empty, $from } = ed.state.selection; usePresenceCaretStore.getState().setCaret({ blockId, offset: from, selection: empty ? undefined : { fromBlockId: blockId, fromOffset: $from.start(0), toBlockId: blockId, toOffset: to }, }); }; // Sync external content (realtime upsert from another user) only when this // editor is NOT focused, to avoid clobbering the local caret. useEffect(() => { if (!editor) return; const next = toHtml(content); if (editor.isFocused) return; const current = editor.getHTML(); if (next !== current) { editor.commands.setContent(next, false); } }, [content, editor]); // Presence (TASK-0093): register this block's editor view so the // CursorLayer can resolve a REMOTE cursor's {blockId, offset} → viewport // coordinates (per-block Tiptap — the page has no shared doc). Unregister // on unmount so a deleted block's stale cursor resolves to null (dropped). useEffect(() => { if (!editor) return; return registerPresenceEditor(blockId, editor); }, [blockId, editor]); const focus = useCallback(() => { if (!editor) return; editor.chain().focus("end").run(); }, [editor]); // Slash-menu pick: clear the typed "/query" (queues an empty save through // the normal update path), then hand the command to the parent for // sibling insertion. const pickSlash = useCallback((cmd: SlashCommand) => { // Close the menu explicitly: relying on the `setContent("")` update to // clear the slash trigger leaves the portal mounted for at least one // render (a `fixed z-[70]` overlay that intercepts clicks on the dialog // that opens beneath it — the "Slash commands lingers after pick" quirk // noted in the Phase-15 E2E). setSlash(null); const ed = editorRef.current; if (ed) ed.chain().setContent("").focus("start").run(); const ctx = handlers.current.slashContext ?? { parentId: null, position: "" }; handlers.current.onSlashPick?.(cmd, ctx); }, []); useEffect(() => { if (requestFocus) focus(); }, [requestFocus, focus]); if (!editor) { return (
); } return (
{slash ? ( setSlash(null)} /> ) : null}
); }