// TipTap-backed narrative editor. // // After the pivot the text editor is the canonical surface. It loads its doc // from /api/projects/[id]/document and saves on a 600ms debounce. Chips remain // the rendered primitive but are no longer inserted via slash menu — the // ChipSuggestionDecorator (added separately) suggests chip-ifying terms that // match the project's taxonomy. "use client"; import { useEditor, EditorContent } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import { useEffect, useMemo, useRef, useState } from "react"; import { ChipNode } from "./ChipNode"; import { ChipFocusContext } from "./FocusContext"; import { fixtureToDoc } from "./fixtureToDoc"; import { ChipSuggestionExtension } from "./ChipSuggestionExtension"; import type { FixtureData } from "../../lib/fixtures/aristotle"; import type { MarkupStyle } from "./Chip"; import { useAnalysis } from "../../lib/workspace/analysisStore"; export type Density = "comfortable" | "compact"; interface TextCanvasProps { data: FixtureData; projectId: string; density?: Density; markupStyle?: MarkupStyle; } export function TextCanvas({ data, projectId, density = "comfortable", markupStyle = "color" }: TextCanvasProps) { const padY = density === "compact" ? 10 : 18; const padX = density === "compact" ? 22 : 36; const [focusBlockId, setFocusBlockId] = useState(null); const [initialDoc, setInitialDoc] = useState(null); const [loaded, setLoaded] = useState(false); const focusValue = useMemo(() => ({ focusBlockId, setFocusBlockId }), [focusBlockId, setFocusBlockId]); const { terms } = useAnalysis(); // Load the saved doc once on mount; fall back to the fixture if there is none. useEffect(() => { let cancelled = false; (async () => { try { const res = await fetch(`/api/projects/${encodeURIComponent(projectId)}/document`); if (!res.ok) throw new Error(`load doc: ${res.status}`); const body = (await res.json()) as { doc: unknown }; if (cancelled) return; setInitialDoc(body.doc ?? fixtureToDoc(data)); } catch { if (!cancelled) setInitialDoc(fixtureToDoc(data)); } finally { if (!cancelled) setLoaded(true); } })(); return () => { cancelled = true; }; }, [projectId, data]); const editor = useEditor( { immediatelyRender: false, extensions: [ StarterKit.configure({ heading: { levels: [1, 2] }, codeBlock: false, blockquote: false, horizontalRule: false, bulletList: false, orderedList: false, listItem: false, strike: false, code: false, link: false, }), ChipNode, ChipSuggestionExtension, ], content: initialDoc ?? null, editorProps: { attributes: { class: "text-canvas tiptap", style: `padding: ${padY}px ${padX}px;`, }, }, }, [initialDoc] ); // Push markup style + terms into the editor storage so node-views read them. useEffect(() => { if (!editor) return; const storage = editor.storage as unknown as Record; storage.markupStyle = markupStyle; storage.terms = terms; editor.view.dispatch(editor.state.tr.setMeta("force-update", true)); }, [editor, markupStyle, terms]); // Debounced save on document change. const saveTimer = useRef | null>(null); useEffect(() => { if (!editor) return; const onUpdate = () => { if (saveTimer.current) clearTimeout(saveTimer.current); saveTimer.current = setTimeout(() => { const doc = editor.getJSON(); void fetch(`/api/projects/${encodeURIComponent(projectId)}/document`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ doc }), }).catch(err => console.error("[TextCanvas] save failed:", err)); }, 600); }; editor.on("update", onUpdate); return () => { editor.off("update", onUpdate); if (saveTimer.current) clearTimeout(saveTimer.current); }; }, [editor, projectId]); if (!loaded || !editor) { return (
loading editor…
); } return ( ); }