import { useEditor, EditorContent } from "@tiptap/react"; import { Editor as TiptapEditor } from "@tiptap/core"; import StarterKit from "@tiptap/starter-kit"; import Collaboration from "@tiptap/extension-collaboration"; import { CollaborationCursorV3 } from "./extensions/collaboration-cursor-v3"; import Placeholder from "@tiptap/extension-placeholder"; // Link is included in StarterKit v3, configured via StarterKit options import Mathematics from "@tiptap/extension-mathematics"; import "katex/dist/katex.min.css"; import { CodeBlockShiki } from "./extensions/code-block-shiki"; import * as Y from "yjs"; import { UndoManager } from "yjs"; import { HocuspocusProvider } from "@hocuspocus/provider"; import { IndexeddbPersistence } from "y-indexeddb"; import { createRefocusSync } from "./refocus-sync"; import { useEffect, useMemo, useRef, useState, MutableRefObject } from "react"; import { BubbleToolbar } from "./BubbleToolbar"; import { BlockHandle } from "./BlockHandle"; import { Image } from "@tiptap/extension-image"; import { Table } from "@tiptap/extension-table"; import { TableRow } from "@tiptap/extension-table-row"; import { TableCell } from "@tiptap/extension-table-cell"; import { TableHeader } from "@tiptap/extension-table-header"; import { Comment } from "./extensions/comment"; import { CollaborationUndo } from "./extensions/collaboration-undo"; import { SlashCommands } from "./extensions/slash-commands"; import { ImageUpload } from "./extensions/image-upload"; import { Citation, citationLabelKey } from "./extensions/citation"; import { Bibliography } from "./extensions/bibliography"; import { Glossary } from "./extensions/glossary"; import { Footnote } from "./extensions/footnote"; import { Stack, StackColumn } from "./extensions/stack"; import { ScrollGuard } from "./extensions/scroll-guard"; import { AgentHighlight } from "./extensions/agent-highlight"; import { AgentFocus } from "./extensions/agent-focus"; import { AgentRewrite } from "./extensions/agent-rewrite"; import { CitationPanel } from "./CitationPanel"; import { createCommentStore, CommentStore } from "./comments"; import { createFrontmatterStore, FrontmatterStore } from "./frontmatter/frontmatter-store"; import { createEmbedStore, EmbedStore } from "./embeds/embed-store"; import { createEmbedDataStore, EmbedDataStore } from "./embeds/embed-data-store"; import { COMPONENTS, createComponentExtension } from "./components"; import { uploadImage } from "./upload"; import { loadDemoContent } from "./load-demo"; interface EditorProps { docName: string; user: { name: string; color: string; avatarUrl?: string }; editorRef: MutableRefObject; onCommentStoreReady: (store: CommentStore) => void; onFrontmatterStoreReady: (store: FrontmatterStore) => void; onEmbedStoreReady?: (store: EmbedStore) => void; onEmbedDataStoreReady?: (store: EmbedDataStore) => void; onSettingsMapReady?: (map: Y.Map) => void; onEditorReady: (editor: TiptapEditor | null) => void; onUndoManagerReady?: (manager: UndoManager) => void; onProviderReady?: (provider: HocuspocusProvider) => void; onAddComment?: () => void; } export function Editor({ docName, user, editorRef, onCommentStoreReady, onFrontmatterStoreReady, onEmbedStoreReady, onEmbedDataStoreReady, onProviderReady, onSettingsMapReady, onEditorReady, onUndoManagerReady, onAddComment, }: EditorProps) { const ydocRef = useRef(null); if (!ydocRef.current) ydocRef.current = new Y.Doc(); const ydoc = ydocRef.current; const undoManagerCallbackRef = useRef(onUndoManagerReady); undoManagerCallbackRef.current = onUndoManagerReady; // Local-first persistence layer. Mesh an IndexedDB provider onto the // same Y.Doc as the network provider: edits survive a refresh / closed // tab / offline window, and - critically - if the server ever loses or // resurrects a stale document, this client re-syncs its locally-cached // state back up. Yjs providers are meshable and dedupe automatically. const idbRef = useRef(null); if (!idbRef.current) { idbRef.current = new IndexeddbPersistence(`collab-editor:${docName}`, ydoc); } const providerRef = useRef(null); if (!providerRef.current) { const wsUrl = window.location.protocol === "https:" ? `wss://${window.location.host}/collab` : `ws://${window.location.host}/collab`; const tokenMatch = document.cookie.match(/(?:^|;\s*)hf_access_token=([^;]*)/); const token = tokenMatch ? decodeURIComponent(tokenMatch[1]) : undefined; providerRef.current = new HocuspocusProvider({ url: wsUrl, name: docName, document: ydoc, token: token || "", }); } const provider = providerRef.current; const citationsMap = useMemo(() => ydoc.getMap("citations"), [ydoc]); const settingsMap = useMemo(() => ydoc.getMap("settings"), [ydoc]); const commentStore = useMemo(() => createCommentStore(ydoc), [ydoc]); const frontmatterStore = useMemo(() => createFrontmatterStore(ydoc), [ydoc]); const embedStore = useMemo(() => createEmbedStore(ydoc), [ydoc]); const embedDataStore = useMemo(() => createEmbedDataStore(ydoc), [ydoc]); const [showCitationPanel, setShowCitationPanel] = useState(false); useEffect(() => { onCommentStoreReady(commentStore); }, [commentStore, onCommentStoreReady]); useEffect(() => { onFrontmatterStoreReady(frontmatterStore); }, [frontmatterStore, onFrontmatterStoreReady]); useEffect(() => { onEmbedStoreReady?.(embedStore); }, [embedStore, onEmbedStoreReady]); useEffect(() => { onEmbedDataStoreReady?.(embedDataStore); }, [embedDataStore, onEmbedDataStoreReady]); useEffect(() => { onSettingsMapReady?.(settingsMap); }, [settingsMap, onSettingsMapReady]); useEffect(() => { onProviderReady?.(provider); }, [provider, onProviderReady]); useEffect(() => { return () => { providerRef.current?.destroy(); providerRef.current = null; idbRef.current?.destroy(); idbRef.current = null; ydocRef.current?.destroy(); ydocRef.current = null; }; }, []); const editor = useEditor( { editorProps: { handleDrop: (view, event, _slice, moved) => { if (moved || !event.dataTransfer?.files?.length) return false; const images = Array.from(event.dataTransfer.files).filter((f) => f.type.startsWith("image/"), ); if (!images.length) return false; event.preventDefault(); const insertPos = view.posAtCoords({ left: event.clientX, top: event.clientY, }); images.forEach((file) => { uploadImage(file).then((url) => { const node = view.state.schema.nodes.image.create({ src: url }); const tr = view.state.tr.insert( insertPos?.pos ?? view.state.doc.content.size, node, ); view.dispatch(tr); }); }); return true; }, handlePaste: (view, event) => { const items = Array.from(event.clipboardData?.items ?? []); const imageItems = items.filter((i) => i.type.startsWith("image/")); if (!imageItems.length) return false; event.preventDefault(); imageItems.forEach((item) => { const file = item.getAsFile(); if (!file) return; uploadImage(file).then((url) => { const node = view.state.schema.nodes.image.create({ src: url }); const tr = view.state.tr.replaceSelectionWith(node); view.dispatch(tr); }); }); return true; }, }, extensions: [ StarterKit.configure({ codeBlock: false, undoRedo: false, link: { openOnClick: false, HTMLAttributes: { class: "editor-link" }, }, } as any), CodeBlockShiki, Placeholder.configure({ placeholder: 'Type "/" for commands, or "/demo" to load sample content...', }), Collaboration.configure({ document: ydoc, }), CollaborationCursorV3.configure({ provider, user, }), Mathematics.configure({ katexOptions: { throwOnError: false }, }), Image.configure({ allowBase64: true, HTMLAttributes: { class: "editor-image" }, }), Table.configure({ resizable: false }), TableRow, TableCell, TableHeader, SlashCommands, ImageUpload, Citation, Bibliography, Glossary, Footnote, Stack, StackColumn, ...COMPONENTS.map(createComponentExtension), CollaborationUndo.configure({ onUndoManagerReady: (um) => undoManagerCallbackRef.current?.(um), }), Comment, ScrollGuard, AgentHighlight, AgentFocus.configure({ provider, user, }), AgentRewrite, ], }, [ydoc, provider], ); useEffect(() => { if (!editor) return; const handler = () => { loadDemoContent(editor, frontmatterStore, citationsMap, settingsMap, embedStore); }; window.addEventListener("load-demo-content", handler); return () => window.removeEventListener("load-demo-content", handler); }, [editor, frontmatterStore, citationsMap, settingsMap, embedStore]); useEffect(() => { if (!editor) return; const handler = () => { // Wipe body editor.commands.setContent("

"); // Wipe frontmatter: keep template sane, empty everything else. frontmatterStore.setAll({ title: "", subtitle: "", description: "", authors: [], affiliations: [], published: "", template: "article", banner: "", doi: "", showPdf: true, tableOfContentsAutoCollapse: false, licence: "", pdfProOnly: false, seoThumbImage: "", links: [], }); // Wipe citations and settings ydoc.transact(() => { citationsMap.forEach((_, key) => citationsMap.delete(key)); settingsMap.forEach((_, key) => settingsMap.delete(key)); }); // Wipe all embed HTML (banner + charts) for (const key of embedStore.keys()) { embedStore.remove(key); } }; window.addEventListener("reset-article", handler); return () => window.removeEventListener("reset-article", handler); }, [editor, frontmatterStore, citationsMap, settingsMap, embedStore, ydoc]); // Dev-only hook used by the demo recording script (backend/demo/showcase.ts). // Lets the script swap the banner HTML deterministically, simulating a // successful Embed Studio agent run without depending on the LLM backend. useEffect(() => { if (!import.meta.env.DEV) return; const handler = (event: Event) => { const detail = (event as CustomEvent).detail as | { html?: string; key?: string } | undefined; const html = detail?.html; if (!html || !embedStore) return; const key = detail?.key || "banner.html"; embedStore.set(key, html); frontmatterStore.set("banner", key); }; window.addEventListener("__demo-set-banner", handler); return () => window.removeEventListener("__demo-set-banner", handler); }, [embedStore, frontmatterStore]); useEffect(() => { if (!editor) return; (editor.commands as any).updateUser(user); editor.commands.updateAgentFocusUser(user); }, [editor, user]); useEffect(() => { if (!editor) return; if (!editor.storage.citation) { editor.storage.citation = { citationsMap: null, settingsMap: null }; } editor.storage.citation!.citationsMap = citationsMap; editor.storage.citation!.settingsMap = settingsMap; // When Yjs citation data or style changes, signal the citation-label // plugin to recompute all labels in a single batched transaction. const signal = () => { if (editor.isDestroyed) return; editor.view.dispatch(editor.state.tr.setMeta(citationLabelKey, true)); }; citationsMap.observe(signal); settingsMap.observe(signal); return () => { citationsMap.unobserve(signal); settingsMap.unobserve(signal); }; }, [editor, citationsMap, settingsMap]); useEffect(() => { if (!editor) return; if (!editor.storage.htmlEmbed) { editor.storage.htmlEmbed = {} as any; } (editor.storage.htmlEmbed as any).embedStore = embedStore; }, [editor, embedStore]); useEffect(() => { const handler = () => setShowCitationPanel(true); window.addEventListener("open-citation-panel", handler); return () => window.removeEventListener("open-citation-panel", handler); }, []); useEffect(() => { editorRef.current = editor; onEditorReady(editor); }, [editor, editorRef, onEditorReady]); // Re-sync on tab refocus and hold the editor read-only until the // round-trip completes, so a stale never-closed tab can't clobber a // collaborator's newer content. The logic lives in createRefocusSync // (unit-tested); here we only wire the DOM trigger. useEffect(() => { if (!editor) return; const { onVisibility, dispose } = createRefocusSync({ provider, editor }); const handler = () => { if (document.visibilityState === "visible") onVisibility(); }; document.addEventListener("visibilitychange", handler); return () => { document.removeEventListener("visibilitychange", handler); dispose(); }; }, [editor, provider]); const [containerEl, setContainerEl] = useState(null); if (!editor) return null; return ( <>
{showCitationPanel && ( setShowCitationPanel(false)} /> )} ); }