Spaces:
Runtime error
Runtime error
| 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<TiptapEditor | null>; | |
| onCommentStoreReady: (store: CommentStore) => void; | |
| onFrontmatterStoreReady: (store: FrontmatterStore) => void; | |
| onEmbedStoreReady?: (store: EmbedStore) => void; | |
| onEmbedDataStoreReady?: (store: EmbedDataStore) => void; | |
| onSettingsMapReady?: (map: Y.Map<any>) => 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<Y.Doc | null>(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<IndexeddbPersistence | null>(null); | |
| if (!idbRef.current) { | |
| idbRef.current = new IndexeddbPersistence(`collab-editor:${docName}`, ydoc); | |
| } | |
| const providerRef = useRef<HocuspocusProvider | null>(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("<p></p>"); | |
| // 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<HTMLElement | null>(null); | |
| if (!editor) return null; | |
| return ( | |
| <> | |
| <BubbleToolbar editor={editor} onAddComment={onAddComment} /> | |
| <div ref={setContainerEl} style={{ position: "relative" }}> | |
| <BlockHandle editor={editor} containerEl={containerEl} /> | |
| <EditorContent editor={editor} /> | |
| </div> | |
| {showCitationPanel && ( | |
| <CitationPanel | |
| editor={editor} | |
| citationsMap={citationsMap} | |
| onClose={() => setShowCitationPanel(false)} | |
| /> | |
| )} | |
| </> | |
| ); | |
| } | |