| 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"; |
| |
| 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; |
|
|
| |
| |
| |
| |
| |
| 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 = () => { |
| |
| editor.commands.setContent("<p></p>"); |
|
|
| |
| frontmatterStore.setAll({ |
| title: "", |
| subtitle: "", |
| description: "", |
| authors: [], |
| affiliations: [], |
| published: "", |
| template: "article", |
| banner: "", |
| doi: "", |
| showPdf: true, |
| tableOfContentsAutoCollapse: false, |
| licence: "", |
| pdfProOnly: false, |
| seoThumbImage: "", |
| links: [], |
| }); |
|
|
| |
| ydoc.transact(() => { |
| citationsMap.forEach((_, key) => citationsMap.delete(key)); |
| settingsMap.forEach((_, key) => settingsMap.delete(key)); |
| }); |
|
|
| |
| 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]); |
|
|
| |
| |
| |
| 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; |
|
|
| |
| |
| 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]); |
|
|
| |
| |
| |
| |
| 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)} |
| /> |
| )} |
| </> |
| ); |
| } |
|
|