tfrere's picture
tfrere HF Staff
test(editor): extract refocus re-sync into a unit-tested module
45d6656
Raw
History Blame Contribute Delete
14.4 kB
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)}
/>
)}
</>
);
}