"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}
);
}