tfrere HF Staff commited on
Commit
76fc93a
·
1 Parent(s): f5f4567

feat(frontend): editor refresh (embed studio, comment popover, shiki, top bar, hooks, styles)

Browse files

- New top bar + publish dialog + sync indicator + mobile TOC sidebar replace the previous
floating actions and full sidebar stack
- Add EmbedStudio chat panel wired to the backend embed agent (embed-tools + stream handler),
with fullBleed iframe mode for banner rendering and double-buffered previews
- Swap legacy comments sidebar for a floating CommentPopover anchored to the active thread
- Shiki-based code block extension + scroll guard on top of TipTap v3; richer frontmatter
settings (hue slider, template variant, SEO) and Editor footer
- Shared hooks (useTheme, useEmbedChat, usePublishStatus) and a singleton MutationObserver
so all components subscribe to theme changes from the same source
- Reorganize CSS into scoped modules (_ui, _footer, _embed, _embed-studio, _hero, _mermaid,
_toc, _form, _code, tokens, layout, publisher, print) and drop the dead postcss config
- Add frontend vitest suite + tsconfig(+node) + vite-env typings + typed utils/types modules

Made-with: Cursor

This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. frontend/index.html +12 -0
  2. frontend/package-lock.json +0 -0
  3. frontend/package.json +10 -5
  4. frontend/postcss.config.js +0 -5
  5. frontend/src/App.tsx +474 -194
  6. frontend/src/components/ChatPanel.tsx +145 -32
  7. frontend/src/components/CommentPopover.tsx +293 -0
  8. frontend/src/components/CommentsSidebar.tsx +0 -212
  9. frontend/src/components/EmbedStudio.tsx +346 -0
  10. frontend/src/components/MobileTocSidebar.tsx +66 -0
  11. frontend/src/components/PublishDialog.tsx +141 -0
  12. frontend/src/components/SyncIndicator.tsx +80 -0
  13. frontend/src/components/TableOfContents.tsx +94 -33
  14. frontend/src/components/TopBar.tsx +139 -0
  15. frontend/src/editor/BlockHandle.tsx +24 -25
  16. frontend/src/editor/CitationView.tsx +7 -102
  17. frontend/src/editor/Editor.tsx +118 -49
  18. frontend/src/editor/EditorFooter.tsx +166 -0
  19. frontend/src/editor/FloatingActions.tsx +0 -128
  20. frontend/src/editor/SlashMenu.tsx +49 -0
  21. frontend/src/editor/StackView.tsx +19 -47
  22. frontend/src/editor/comments.ts +24 -2
  23. frontend/src/editor/components/AtomicView.tsx +23 -14
  24. frontend/src/editor/components/MermaidView.tsx +39 -6
  25. frontend/src/editor/components/WrapperView.tsx +49 -18
  26. frontend/src/editor/components/factory.ts +44 -89
  27. frontend/src/editor/components/index.ts +1 -1
  28. frontend/src/editor/components/registry.ts +2 -2
  29. frontend/src/editor/default-content.ts +281 -95
  30. frontend/src/editor/embeds/HtmlEmbedView.tsx +274 -0
  31. frontend/src/editor/embeds/build-doc.ts +22 -0
  32. frontend/src/editor/embeds/embed-store.ts +99 -0
  33. frontend/src/editor/extensions/citation.ts +84 -0
  34. frontend/src/editor/extensions/code-block-shiki.tsx +300 -0
  35. frontend/src/editor/extensions/collaboration-cursor-v3.ts +5 -1
  36. frontend/src/editor/extensions/scroll-guard.ts +57 -0
  37. frontend/src/editor/extensions/stack.ts +37 -0
  38. frontend/src/editor/frontmatter/FrontmatterHero.tsx +299 -168
  39. frontend/src/editor/frontmatter/HueSlider.tsx +3 -5
  40. frontend/src/editor/frontmatter/SettingsDrawer.tsx +9 -0
  41. frontend/src/editor/frontmatter/frontmatter-store.ts +17 -13
  42. frontend/src/editor/frontmatter/seed-frontmatter.ts +15 -5
  43. frontend/src/hooks/useAgentChat.ts +39 -8
  44. frontend/src/hooks/useEmbedChat.ts +221 -0
  45. frontend/src/hooks/usePublishStatus.ts +49 -0
  46. frontend/src/hooks/useTheme.ts +67 -0
  47. frontend/src/main.tsx +12 -3
  48. frontend/src/styles/_editor-tokens.css +0 -105
  49. frontend/src/styles/_layout.css +20 -36
  50. frontend/src/styles/_print.css +143 -39
frontend/index.html CHANGED
@@ -4,6 +4,18 @@
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
  <title>Research Article Template Editor</title>
 
 
 
 
 
 
 
 
 
 
 
 
7
  </head>
8
  <body>
9
  <div id="root"></div>
 
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
  <title>Research Article Template Editor</title>
7
+ <script>
8
+ (function() {
9
+ try {
10
+ var saved = localStorage.getItem('theme');
11
+ if (saved) {
12
+ document.documentElement.setAttribute('data-theme', saved);
13
+ } else {
14
+ localStorage.setItem('theme', 'dark');
15
+ }
16
+ } catch(e) {}
17
+ })();
18
+ </script>
19
  </head>
20
  <body>
21
  <div id="root"></div>
frontend/package-lock.json CHANGED
The diff for this file is too large to render. See raw diff
 
frontend/package.json CHANGED
@@ -6,14 +6,16 @@
6
  "scripts": {
7
  "dev": "vite",
8
  "build": "vite build",
9
- "preview": "vite preview"
 
 
 
10
  },
11
  "dependencies": {
12
  "@ai-sdk/react": "^3.0.160",
13
  "@floating-ui/dom": "^1.7.6",
14
  "@hocuspocus/provider": "^3.4.4",
15
  "@tiptap/core": "^3.22.3",
16
- "@tiptap/extension-code-block-lowlight": "^3.22.3",
17
  "@tiptap/extension-collaboration": "^3.22.3",
18
  "@tiptap/extension-image": "^3.22.3",
19
  "@tiptap/extension-link": "^3.22.3",
@@ -32,11 +34,13 @@
32
  "@types/katex": "^0.16.8",
33
  "ai": "^6.0.158",
34
  "katex": "^0.16.45",
35
- "lowlight": "^3.2.0",
36
  "lucide-react": "^1.8.0",
37
  "mermaid": "^11.14.0",
38
  "react": "^18.3.0",
39
  "react-dom": "^18.3.0",
 
 
 
40
  "tippy.js": "^6.3.7",
41
  "yjs": "^13.6.0",
42
  "zod": "^4.3.6"
@@ -45,7 +49,8 @@
45
  "@types/react": "^18.3.0",
46
  "@types/react-dom": "^18.3.0",
47
  "@vitejs/plugin-react": "^4.3.0",
48
- "postcss-custom-media": "^12.0.1",
49
- "vite": "^6.0.0"
 
50
  }
51
  }
 
6
  "scripts": {
7
  "dev": "vite",
8
  "build": "vite build",
9
+ "preview": "vite preview",
10
+ "test": "vitest run",
11
+ "test:watch": "vitest",
12
+ "typecheck": "tsc --noEmit"
13
  },
14
  "dependencies": {
15
  "@ai-sdk/react": "^3.0.160",
16
  "@floating-ui/dom": "^1.7.6",
17
  "@hocuspocus/provider": "^3.4.4",
18
  "@tiptap/core": "^3.22.3",
 
19
  "@tiptap/extension-collaboration": "^3.22.3",
20
  "@tiptap/extension-image": "^3.22.3",
21
  "@tiptap/extension-link": "^3.22.3",
 
34
  "@types/katex": "^0.16.8",
35
  "ai": "^6.0.158",
36
  "katex": "^0.16.45",
 
37
  "lucide-react": "^1.8.0",
38
  "mermaid": "^11.14.0",
39
  "react": "^18.3.0",
40
  "react-dom": "^18.3.0",
41
+ "react-markdown": "^10.1.0",
42
+ "remark-gfm": "^4.0.1",
43
+ "shiki": "^4.0.2",
44
  "tippy.js": "^6.3.7",
45
  "yjs": "^13.6.0",
46
  "zod": "^4.3.6"
 
49
  "@types/react": "^18.3.0",
50
  "@types/react-dom": "^18.3.0",
51
  "@vitejs/plugin-react": "^4.3.0",
52
+ "typescript": "^6.0.3",
53
+ "vite": "^6.0.0",
54
+ "vitest": "^4.1.4"
55
  }
56
  }
frontend/postcss.config.js DELETED
@@ -1,5 +0,0 @@
1
- export default {
2
- plugins: {
3
- "postcss-custom-media": {},
4
- },
5
- };
 
 
 
 
 
 
frontend/src/App.tsx CHANGED
@@ -1,89 +1,163 @@
1
- import { useRef, useState, useCallback, useEffect, useMemo } from "react";
2
  import { Editor as TiptapEditor } from "@tiptap/core";
3
  import { UndoManager } from "yjs";
4
  import type * as Y from "yjs";
 
 
5
  import { Tooltip } from "./components/Tooltip";
6
- import {
7
- Undo2,
8
- Redo2,
9
- Settings,
10
- Upload,
11
- MessageCircle,
12
- X,
13
- } from "lucide-react";
14
- import { oklchToHex, OKLCH_L, OKLCH_C } from "./editor/frontmatter/HueSlider";
15
  import { Editor } from "./editor/Editor";
16
- import { CommentsSidebar } from "./components/CommentsSidebar";
17
  import { CommentDialog } from "./components/CommentDialog";
18
- import { ChatPanel } from "./components/ChatPanel";
19
  import { TableOfContents } from "./components/TableOfContents";
 
 
 
20
  import { useAgentChat } from "./hooks/useAgentChat";
 
21
  import type { CommentStore } from "./editor/comments";
22
  import type { FrontmatterStore } from "./editor/frontmatter/frontmatter-store";
 
23
  import { FrontmatterHero } from "./editor/frontmatter/FrontmatterHero";
24
  import { SettingsDrawer } from "./editor/frontmatter/SettingsDrawer";
 
 
 
 
 
25
 
26
- const DEFAULT_HUE = 47;
27
-
28
- const COLORS = [
29
- "#958DF1", "#F98181", "#FBBC88", "#FAF594",
30
- "#70CFF8", "#94FADB", "#B9F18D", "#C4B5FD",
31
- ];
32
-
33
- function colorFromName(name: string): string {
34
- let hash = 0;
35
- for (let i = 0; i < name.length; i++) hash = (hash * 31 + name.charCodeAt(i)) | 0;
36
- return COLORS[Math.abs(hash) % COLORS.length];
37
- }
38
-
39
- function randomFallbackUser(): CollabUser {
40
- const names = ["Alice", "Bob", "Carol", "Dave", "Eve", "Frank", "Grace", "Heidi"];
41
- const name = names[Math.floor(Math.random() * names.length)];
42
- return { name, color: colorFromName(name) };
43
- }
44
-
45
- export interface CollabUser {
46
- name: string;
47
- color: string;
48
- avatarUrl?: string;
49
- }
50
 
51
  export default function App() {
52
- const [user, setUser] = useState<CollabUser>(randomFallbackUser);
 
 
 
 
 
53
 
54
  useEffect(() => {
55
  fetch("/api/auth/status", { credentials: "include" })
56
  .then((r) => r.json())
57
  .then((data) => {
 
58
  if (data.authenticated && data.user) {
 
59
  setUser({
60
- name: data.user.fullName || data.user.name,
61
  color: colorFromName(data.user.name),
62
  avatarUrl: data.user.avatarUrl || undefined,
63
  });
 
 
 
 
64
  }
65
  })
66
  .catch(() => {});
67
  }, []);
68
 
69
- const docName = "default";
70
 
 
71
  const editorRef = useRef<TiptapEditor | null>(null);
72
  const editorContainerRef = useRef<HTMLDivElement | null>(null);
 
73
  const publishDialogRef = useRef<HTMLDialogElement>(null);
 
74
  const [editorInstance, setEditorInstance] = useState<TiptapEditor | null>(null);
75
  const [containerEl, setContainerEl] = useState<HTMLElement | null>(null);
76
  const [commentStore, setCommentStore] = useState<CommentStore | null>(null);
77
  const [frontmatterStore, setFrontmatterStore] = useState<FrontmatterStore | null>(null);
 
78
  const [settingsMap, setSettingsMap] = useState<Y.Map<any> | null>(null);
 
 
 
 
 
 
 
79
  const [commentDialogOpen, setCommentDialogOpen] = useState(false);
80
  const [settingsOpen, setSettingsOpen] = useState(false);
81
- const [publishState, setPublishState] = useState<"idle" | "loading" | "success" | "error">("idle");
 
 
 
 
 
82
  const [publishError, setPublishError] = useState("");
 
 
83
  const selectionRange = useRef<{ from: number; to: number } | null>(null);
84
- const [hasSelection, setHasSelection] = useState(false);
85
- const [undoManager, setUndoManager] = useState<UndoManager | null>(null);
86
- const [chatOpen, setChatOpen] = useState(false);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
 
88
  // Sync primary hue from Yjs settings to CSS variables
89
  useEffect(() => {
@@ -91,9 +165,9 @@ export default function App() {
91
  const sync = () => {
92
  const h = settingsMap.get("primaryHue") as number | undefined;
93
  if (h !== undefined) {
94
- const oklch = `oklch(${OKLCH_L} ${OKLCH_C} ${h})`;
95
- document.documentElement.style.setProperty("--primary-color", oklch);
96
- document.documentElement.style.setProperty("--primary-color-hover", oklch);
97
  }
98
  };
99
  sync();
@@ -101,8 +175,17 @@ export default function App() {
101
  return () => settingsMap.unobserve(sync);
102
  }, [settingsMap]);
103
 
104
- const agentChat = useAgentChat({ editor: editorInstance, undoManager, frontmatterStore });
 
 
 
 
 
 
 
 
105
 
 
106
  useEffect(() => {
107
  if (!editorInstance) return;
108
  const handler = () => {
@@ -113,6 +196,189 @@ export default function App() {
113
  return () => { editorInstance.off("selectionUpdate", handler); };
114
  }, [editorInstance]);
115
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  const editorContainerCallback = useCallback((node: HTMLDivElement | null) => {
117
  editorContainerRef.current = node;
118
  setContainerEl(node);
@@ -123,21 +389,25 @@ export default function App() {
123
  setEditorInstance(editor);
124
  }, []);
125
 
126
- const onCommentStoreReady = useCallback((store: CommentStore) => {
127
- setCommentStore(store);
 
 
128
  }, []);
129
 
130
- const onFrontmatterStoreReady = useCallback((store: FrontmatterStore) => {
131
- setFrontmatterStore(store);
132
- }, []);
 
133
 
134
- const onSettingsMapReady = useCallback((map: Y.Map<any>) => {
135
- setSettingsMap(map);
136
- }, []);
137
 
138
  const openPublishDialog = useCallback(() => {
139
  setPublishState("idle");
140
  setPublishError("");
 
141
  publishDialogRef.current?.showModal();
142
  }, []);
143
 
@@ -145,9 +415,18 @@ export default function App() {
145
  publishDialogRef.current?.close();
146
  }, []);
147
 
 
 
 
 
 
 
 
148
  const handlePublish = useCallback(async () => {
149
  setPublishState("loading");
150
  setPublishError("");
 
 
151
  try {
152
  const res = await fetch("/api/publish", {
153
  method: "POST",
@@ -155,24 +434,78 @@ export default function App() {
155
  credentials: "include",
156
  body: JSON.stringify({ docName }),
157
  });
 
 
 
 
 
 
 
 
 
158
  if (!res.ok) {
159
  const data = await res.json().catch(() => ({ error: "Publish failed" }));
160
  throw new Error(data.error || "Publish failed");
161
  }
162
- setPublishState("success");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  } catch (err: any) {
164
  setPublishState("error");
165
  setPublishError(err.message || "Unknown error");
166
  }
167
  }, [docName]);
168
 
 
 
169
  const handleAddComment = useCallback(() => {
170
  const editor = editorRef.current;
171
  if (!editor) return;
172
-
173
  const { from, to } = editor.state.selection;
174
  if (from === to) return;
175
-
176
  selectionRange.current = { from, to };
177
  setCommentDialogOpen(true);
178
  }, []);
@@ -203,78 +536,47 @@ export default function App() {
203
  selectionRange.current = null;
204
  }, [commentStore, user]);
205
 
 
 
206
  return (
207
- <div
208
- className="editor-app"
209
- style={{
210
- height: "100vh",
211
- display: "flex",
212
- flexDirection: "column",
213
- background: "var(--ed-bg)",
214
- overflow: "hidden",
215
- }}
216
- >
217
- {/* Top bar */}
218
- <div className="top-bar">
219
- <Tooltip title="Undo">
220
- <button
221
- className="icon-btn"
222
- onClick={() => editorInstance?.commands.undo()}
223
- aria-label="Undo"
224
- >
225
- <Undo2 size={18} />
226
- </button>
227
- </Tooltip>
228
- <Tooltip title="Redo">
229
- <button
230
- className="icon-btn"
231
- onClick={() => editorInstance?.commands.redo()}
232
- aria-label="Redo"
233
- >
234
- <Redo2 size={18} />
235
- </button>
236
- </Tooltip>
237
- <span className="divider-v" />
238
- <Tooltip title="Article settings">
239
- <button
240
- className="icon-btn"
241
- onClick={() => setSettingsOpen(true)}
242
- aria-label="Article settings"
243
- >
244
- <Settings size={18} />
245
- </button>
246
- </Tooltip>
247
- <span className="divider-v" />
248
- <Tooltip title="Publish article">
249
- <button
250
- className="icon-btn icon-btn--primary"
251
- onClick={openPublishDialog}
252
- aria-label="Publish article"
253
- >
254
- <Upload size={18} />
255
- </button>
256
- </Tooltip>
257
- <span
258
- className="chip"
259
- style={{ backgroundColor: user.color, color: "#000", marginLeft: 4 }}
260
- >
261
- {user.avatarUrl && <img src={user.avatarUrl} alt="" />}
262
- {user.name}
263
- </span>
264
- </div>
265
 
266
- {/* Single scroll container */}
267
  <div
268
  ref={editorContainerCallback}
269
- style={{ flex: 1, overflowY: "auto", overflowX: "hidden" }}
270
  >
271
  <div className="content-grid">
272
  <div className="content-grid__hero">
273
- <FrontmatterHero store={frontmatterStore} />
274
  </div>
275
  <div className="content-grid__toc">
276
  <div className="table-of-contents--sticky">
277
- <TableOfContents editor={editorInstance} />
 
 
 
 
278
  </div>
279
  </div>
280
  <div className="content-grid__editor">
@@ -284,46 +586,41 @@ export default function App() {
284
  editorRef={editorRef}
285
  onCommentStoreReady={onCommentStoreReady}
286
  onFrontmatterStoreReady={onFrontmatterStoreReady}
 
287
  onSettingsMapReady={onSettingsMapReady}
288
  onEditorReady={onEditorReady}
289
  onUndoManagerReady={setUndoManager}
 
290
  onAddComment={handleAddComment}
291
  />
292
  </div>
293
- <div className="content-grid__comments">
294
- <CommentsSidebar
295
- editor={editorInstance}
296
- commentStore={commentStore}
297
- user={user}
298
- editorContainer={containerEl}
299
- />
300
- </div>
301
  </div>
 
 
 
 
 
 
302
  </div>
303
 
304
- {/* Floating chat */}
305
  {chatOpen ? (
306
  <div className="chat-floating">
307
- <div className="chat-floating__header">
308
- <span className="chat-floating__title">AI Assistant</span>
309
- <button className="icon-btn" onClick={() => setChatOpen(false)} aria-label="Close chat">
310
- <X size={16} />
311
- </button>
312
- </div>
313
- <div className="chat-floating__body">
314
- <ChatPanel
315
- messages={agentChat.messages}
316
- isLoading={agentChat.isLoading}
317
- error={agentChat.error}
318
- input={agentChat.input}
319
- hasSelection={hasSelection}
320
- onSend={agentChat.sendMessage}
321
- onQuickAction={agentChat.sendQuickAction}
322
- onSetInput={agentChat.setInput}
323
- onStop={agentChat.stop}
324
- onNewChat={() => window.location.reload()}
325
- />
326
- </div>
327
  </div>
328
  ) : (
329
  <Tooltip title="AI Assistant" placement="right">
@@ -350,53 +647,36 @@ export default function App() {
350
  settingsMap={settingsMap}
351
  />
352
 
353
- {/* Publish dialog (native <dialog>) */}
354
- <dialog
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
355
  ref={publishDialogRef}
356
- className="ed-dialog"
 
 
 
357
  onClose={closePublishDialog}
358
- >
359
- <h2 className="ed-dialog__title">
360
- {publishState === "success" ? "Published!" : "Publish article"}
361
- </h2>
362
- <div className="ed-dialog__body">
363
- {publishState === "idle" && (
364
- <p>
365
- This will generate a static HTML page, PDF, and thumbnail
366
- from the current document. Visitors without edit access
367
- will see the published version.
368
- </p>
369
- )}
370
- {publishState === "loading" && (
371
- <div style={{ display: "flex", alignItems: "center", gap: 12, padding: "8px 0" }}>
372
- <span className="spinner" />
373
- <p style={{ margin: 0 }}>Publishing...</p>
374
- </div>
375
- )}
376
- {publishState === "success" && (
377
- <p>
378
- Article published successfully. Visitors will now see
379
- the updated version.
380
- </p>
381
- )}
382
- {publishState === "error" && (
383
- <p style={{ color: "var(--ed-error)" }}>
384
- {publishError || "An error occurred while publishing."}
385
- </p>
386
- )}
387
- </div>
388
- <div className="ed-dialog__actions">
389
- {publishState === "idle" && (
390
- <>
391
- <button className="btn" onClick={closePublishDialog}>Cancel</button>
392
- <button className="btn btn--primary" onClick={handlePublish}>Publish</button>
393
- </>
394
- )}
395
- {(publishState === "success" || publishState === "error") && (
396
- <button className="btn" onClick={closePublishDialog}>Close</button>
397
- )}
398
- </div>
399
- </dialog>
400
  </div>
401
  );
402
  }
 
1
+ import { useRef, useState, useCallback, useEffect } from "react";
2
  import { Editor as TiptapEditor } from "@tiptap/core";
3
  import { UndoManager } from "yjs";
4
  import type * as Y from "yjs";
5
+ import type { HocuspocusProvider } from "@hocuspocus/provider";
6
+ import { MessageCircle } from "lucide-react";
7
  import { Tooltip } from "./components/Tooltip";
8
+ import { oklchFromHue } from "#shared/theme";
 
 
 
 
 
 
 
 
9
  import { Editor } from "./editor/Editor";
10
+ import { CommentMarginIcons } from "./components/CommentPopover";
11
  import { CommentDialog } from "./components/CommentDialog";
12
+ import { ChatPanel, type ModelOption } from "./components/ChatPanel";
13
  import { TableOfContents } from "./components/TableOfContents";
14
+ import { TopBar } from "./components/TopBar";
15
+ import { PublishDialog, type PublishState, type PublishStageEvent } from "./components/PublishDialog";
16
+ import { MobileTocSidebar } from "./components/MobileTocSidebar";
17
  import { useAgentChat } from "./hooks/useAgentChat";
18
+ import { usePublishStatus } from "./hooks/usePublishStatus";
19
  import type { CommentStore } from "./editor/comments";
20
  import type { FrontmatterStore } from "./editor/frontmatter/frontmatter-store";
21
+ import type { EmbedStore } from "./editor/embeds/embed-store";
22
  import { FrontmatterHero } from "./editor/frontmatter/FrontmatterHero";
23
  import { SettingsDrawer } from "./editor/frontmatter/SettingsDrawer";
24
+ import { EditorFooter } from "./editor/EditorFooter";
25
+ import { EmbedStudio } from "./components/EmbedStudio";
26
+ import type { UIMessage } from "ai";
27
+ import { loadMessages, saveMessages } from "./utils/chat-persistence";
28
+ import { stableFallbackUser, colorFromName, type CollabUser } from "./utils/user";
29
 
30
+ // Re-exported for any downstream code that still imports from App.tsx
31
+ export type { CollabUser } from "./utils/user";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
 
33
  export default function App() {
34
+ // --- Identity & auth ---------------------------------------------------
35
+
36
+ const [user, setUser] = useState<CollabUser>(stableFallbackUser);
37
+ const [loginUrl, setLoginUrl] = useState<string | null>(null);
38
+ const [isAuthenticated, setIsAuthenticated] = useState(true);
39
+ const [chatUserId, setChatUserId] = useState(() => user.name);
40
 
41
  useEffect(() => {
42
  fetch("/api/auth/status", { credentials: "include" })
43
  .then((r) => r.json())
44
  .then((data) => {
45
+ setIsAuthenticated(data.authenticated);
46
  if (data.authenticated && data.user) {
47
+ const name = data.user.fullName || data.user.name;
48
  setUser({
49
+ name,
50
  color: colorFromName(data.user.name),
51
  avatarUrl: data.user.avatarUrl || undefined,
52
  });
53
+ setChatUserId(data.user.name);
54
+ setLoginUrl(null);
55
+ } else if (data.loginUrl) {
56
+ setLoginUrl(data.loginUrl);
57
  }
58
  })
59
  .catch(() => {});
60
  }, []);
61
 
62
+ // --- Document / editor refs -------------------------------------------
63
 
64
+ const docName = "default";
65
  const editorRef = useRef<TiptapEditor | null>(null);
66
  const editorContainerRef = useRef<HTMLDivElement | null>(null);
67
+ const providerRef = useRef<HocuspocusProvider | null>(null);
68
  const publishDialogRef = useRef<HTMLDialogElement>(null);
69
+
70
  const [editorInstance, setEditorInstance] = useState<TiptapEditor | null>(null);
71
  const [containerEl, setContainerEl] = useState<HTMLElement | null>(null);
72
  const [commentStore, setCommentStore] = useState<CommentStore | null>(null);
73
  const [frontmatterStore, setFrontmatterStore] = useState<FrontmatterStore | null>(null);
74
+ const [embedStore, setEmbedStore] = useState<EmbedStore | null>(null);
75
  const [settingsMap, setSettingsMap] = useState<Y.Map<any> | null>(null);
76
+ const [undoManager, setUndoManager] = useState<UndoManager | null>(null);
77
+
78
+ const [isEditorReady, setIsEditorReady] = useState(false);
79
+ const [hasSelection, setHasSelection] = useState(false);
80
+
81
+ // --- UI state ---------------------------------------------------------
82
+
83
  const [commentDialogOpen, setCommentDialogOpen] = useState(false);
84
  const [settingsOpen, setSettingsOpen] = useState(false);
85
+ const [chatOpen, setChatOpen] = useState(false);
86
+ const [embedStudioSrc, setEmbedStudioSrc] = useState<string | null>(null);
87
+ const [tocSidebarOpen, setTocSidebarOpen] = useState(false);
88
+ const [tocAutoCollapse, setTocAutoCollapse] = useState(false);
89
+
90
+ const [publishState, setPublishState] = useState<PublishState>("idle");
91
  const [publishError, setPublishError] = useState("");
92
+ const [publishStage, setPublishStage] = useState<PublishStageEvent | null>(null);
93
+ const [yDoc, setYDoc] = useState<Y.Doc | null>(null);
94
  const selectionRange = useRef<{ from: number; to: number } | null>(null);
95
+
96
+ const publishStatus = usePublishStatus(yDoc);
97
+
98
+ // --- Theme ------------------------------------------------------------
99
+
100
+ const [theme, setTheme] = useState<"light" | "dark">(() => {
101
+ return (document.documentElement.getAttribute("data-theme") as "light" | "dark") || "dark";
102
+ });
103
+
104
+ useEffect(() => {
105
+ const saved = localStorage.getItem("theme") as "light" | "dark" | null;
106
+ if (saved) {
107
+ document.documentElement.setAttribute("data-theme", saved);
108
+ setTheme(saved);
109
+ }
110
+ }, []);
111
+
112
+ const toggleTheme = useCallback(() => {
113
+ const next = theme === "dark" ? "light" : "dark";
114
+ document.documentElement.setAttribute("data-theme", next);
115
+ localStorage.setItem("theme", next);
116
+ setTheme(next);
117
+ }, [theme]);
118
+
119
+ // --- Model list -------------------------------------------------------
120
+
121
+ const [models, setModels] = useState<ModelOption[]>([]);
122
+ const [selectedModel, setSelectedModel] = useState("");
123
+ const modelRef = useRef("");
124
+
125
+ useEffect(() => {
126
+ fetch("/api/models")
127
+ .then((r) => r.json())
128
+ .then((data: ModelOption[]) => {
129
+ setModels(data);
130
+ if (data.length > 0 && !modelRef.current) {
131
+ setSelectedModel(data[0].id);
132
+ modelRef.current = data[0].id;
133
+ }
134
+ })
135
+ .catch(() => {});
136
+ }, []);
137
+
138
+ const handleModelChange = useCallback((id: string) => {
139
+ setSelectedModel(id);
140
+ modelRef.current = id;
141
+ }, []);
142
+
143
+ // --- Effects tied to stores -------------------------------------------
144
+
145
+ // Sync TOC auto-collapse flag from frontmatter
146
+ useEffect(() => {
147
+ if (!frontmatterStore) return;
148
+ const sync = () => {
149
+ setTocAutoCollapse(frontmatterStore.get("tableOfContentsAutoCollapse") as boolean);
150
+ };
151
+ sync();
152
+ return frontmatterStore.observe(sync);
153
+ }, [frontmatterStore]);
154
+
155
+ // Fallback: dismiss spinner after 5s even if sync hasn't fired
156
+ useEffect(() => {
157
+ if (isEditorReady) return;
158
+ const id = setTimeout(() => setIsEditorReady(true), 5000);
159
+ return () => clearTimeout(id);
160
+ }, [isEditorReady]);
161
 
162
  // Sync primary hue from Yjs settings to CSS variables
163
  useEffect(() => {
 
165
  const sync = () => {
166
  const h = settingsMap.get("primaryHue") as number | undefined;
167
  if (h !== undefined) {
168
+ // Only override --primary-base: _variables.css derives --primary-color
169
+ // and --primary-color-hover from it via CSS relative-color syntax.
170
+ document.documentElement.style.setProperty("--primary-base", oklchFromHue(h));
171
  }
172
  };
173
  sync();
 
175
  return () => settingsMap.unobserve(sync);
176
  }, [settingsMap]);
177
 
178
+ // Listen for embed studio open events from HtmlEmbedView
179
+ useEffect(() => {
180
+ const handler = (e: Event) => {
181
+ const src = (e as CustomEvent).detail?.src;
182
+ if (src) setEmbedStudioSrc(src);
183
+ };
184
+ window.addEventListener("open-embed-studio", handler);
185
+ return () => window.removeEventListener("open-embed-studio", handler);
186
+ }, []);
187
 
188
+ // Track whether the editor has a non-empty selection (for chat quick actions)
189
  useEffect(() => {
190
  if (!editorInstance) return;
191
  const handler = () => {
 
196
  return () => { editorInstance.off("selectionUpdate", handler); };
197
  }, [editorInstance]);
198
 
199
+ // --- Chat / agent -----------------------------------------------------
200
+
201
+ const chatUserIdRef = useRef(chatUserId);
202
+ chatUserIdRef.current = chatUserId;
203
+
204
+ const handleArticleChatChange = useCallback((msgs: UIMessage[]) => {
205
+ saveMessages(chatUserIdRef.current, "article", msgs);
206
+ }, []);
207
+
208
+ const agentChat = useAgentChat({
209
+ editor: editorInstance,
210
+ undoManager,
211
+ frontmatterStore,
212
+ modelRef,
213
+ onMessagesChange: handleArticleChatChange,
214
+ });
215
+
216
+ // Restore persisted messages after mount and when user identity changes
217
+ const agentSetMessagesRef = useRef(agentChat.setMessages);
218
+ agentSetMessagesRef.current = agentChat.setMessages;
219
+
220
+ const chatUserIdLoadedRef = useRef<string | null>(null);
221
+ useEffect(() => {
222
+ if (chatUserIdLoadedRef.current === chatUserId) return;
223
+ chatUserIdLoadedRef.current = chatUserId;
224
+ const stored = loadMessages(chatUserId, "article");
225
+ if (stored && stored.length > 0) {
226
+ agentSetMessagesRef.current(stored);
227
+ }
228
+ }, [chatUserId]);
229
+
230
+ // Dev-only hook: let the demo recording script script-drive the main
231
+ // chat panel. The Playwright trio script dispatches `__demo-chat` to:
232
+ // - open the floating chat panel,
233
+ // - inject a fake user prompt + assistant reply,
234
+ // - optionally rewrite a paragraph in the editor body,
235
+ // all without waiting for a real LLM round-trip.
236
+ useEffect(() => {
237
+ if (!import.meta.env.DEV) return;
238
+ const handler = (event: Event) => {
239
+ const detail = (event as CustomEvent).detail as
240
+ | {
241
+ open?: boolean;
242
+ messages?: UIMessage[];
243
+ replace?: { from: string; to: string };
244
+ }
245
+ | undefined;
246
+ if (typeof detail?.open === "boolean") setChatOpen(detail.open);
247
+ if (Array.isArray(detail?.messages)) {
248
+ agentSetMessagesRef.current(detail.messages);
249
+ }
250
+ if (detail?.replace && editorRef.current) {
251
+ const editor = editorRef.current;
252
+ const target = detail.replace.from;
253
+ const replacement = detail.replace.to;
254
+
255
+ // Robust target lookup: ProseMirror text can be split across
256
+ // multiple text nodes within the same block (marks, Yjs splits),
257
+ // so we walk textblocks and search the joined textContent rather
258
+ // than individual text nodes. `textBetween` on the block gives
259
+ // us a char-accurate string whose offsets map directly to PM
260
+ // positions inside that block.
261
+ let from = -1;
262
+ let to = -1;
263
+ editor.state.doc.descendants((node, pos) => {
264
+ if (from !== -1) return false;
265
+ if (node.isTextblock) {
266
+ const text = node.textBetween(0, node.content.size, "\n", "\n");
267
+ const idx = text.indexOf(target);
268
+ if (idx !== -1) {
269
+ from = pos + 1 + idx;
270
+ to = from + target.length;
271
+ return false;
272
+ }
273
+ }
274
+ return true;
275
+ });
276
+
277
+ if (from === -1 || to === -1) {
278
+ console.warn(
279
+ "[__demo-chat] replace target not found:",
280
+ target.slice(0, 40) + (target.length > 40 ? "..." : ""),
281
+ );
282
+ return;
283
+ }
284
+
285
+ // Resolve the paragraph DOM element once so we can both scroll
286
+ // it into view AND flag it with a highlight class for the
287
+ // duration of the animation (see `.demo-agent-rewriting` in
288
+ // _ui.css). Without that visual cue the rewrite can be hard
289
+ // to spot when the chat panel is open and the viewer's eyes
290
+ // are in the chat.
291
+ //
292
+ // If the demo script previously marked this paragraph as
293
+ // `.demo-agent-pending` (badge + breathing border) we prefer
294
+ // that element: it's the exact node the viewer has been looking
295
+ // at while the chat was streaming. Falling back to a PM
296
+ // position lookup keeps the handler usable outside the demo.
297
+ let paragraphEl: HTMLElement | null =
298
+ document.querySelector<HTMLElement>(".demo-agent-pending");
299
+ if (!paragraphEl) {
300
+ try {
301
+ const domAt = editor.view.domAtPos(from);
302
+ let el: HTMLElement | null =
303
+ domAt.node instanceof HTMLElement
304
+ ? domAt.node
305
+ : domAt.node.parentElement;
306
+ while (el && el.nodeName !== "P" && !/^H[1-6]$/.test(el.nodeName)) {
307
+ el = el.parentElement;
308
+ }
309
+ paragraphEl = el;
310
+ } catch {
311
+ /* non-fatal */
312
+ }
313
+ }
314
+
315
+ // Animated rewrite: highlight target first so the viewer sees
316
+ // what the agent is about to change, then delete and retype the
317
+ // replacement character by character for a visible, live feel.
318
+ editor.chain().focus().setTextSelection({ from, to }).run();
319
+
320
+ if (paragraphEl) {
321
+ // Swap pending -> rewriting so the badge stays visible but
322
+ // the border animation ramps up from "thinking" to "writing".
323
+ paragraphEl.classList.remove("demo-agent-pending");
324
+ paragraphEl.classList.add("demo-agent-rewriting");
325
+ if (typeof paragraphEl.scrollIntoView === "function") {
326
+ paragraphEl.scrollIntoView({
327
+ behavior: "smooth",
328
+ block: "center",
329
+ });
330
+ }
331
+ }
332
+
333
+ const startPos = from;
334
+ const originalLength = to - from;
335
+ window.setTimeout(() => {
336
+ if (!editorRef.current) return;
337
+ editorRef.current
338
+ .chain()
339
+ .focus()
340
+ .setTextSelection({ from: startPos, to: startPos + originalLength })
341
+ .deleteSelection()
342
+ .run();
343
+ let i = 0;
344
+ const total = replacement.length;
345
+ const step = () => {
346
+ if (!editorRef.current) return;
347
+ if (i >= total) {
348
+ // Animation done: remove the highlight after a brief
349
+ // settle delay so the viewer can see the final result
350
+ // glow once before it fades back to normal.
351
+ if (paragraphEl) {
352
+ window.setTimeout(
353
+ () => paragraphEl?.classList.remove("demo-agent-rewriting"),
354
+ 700,
355
+ );
356
+ }
357
+ return;
358
+ }
359
+ const nextChunk = Math.min(
360
+ total - i,
361
+ 1 + Math.floor(Math.random() * 2),
362
+ );
363
+ const slice = replacement.slice(i, i + nextChunk);
364
+ editorRef.current
365
+ .chain()
366
+ .focus()
367
+ .insertContentAt(startPos + i, slice)
368
+ .run();
369
+ i += nextChunk;
370
+ window.setTimeout(step, 22 + Math.random() * 26);
371
+ };
372
+ step();
373
+ }, 520);
374
+ }
375
+ };
376
+ window.addEventListener("__demo-chat", handler);
377
+ return () => window.removeEventListener("__demo-chat", handler);
378
+ }, []);
379
+
380
+ // --- Editor lifecycle callbacks ---------------------------------------
381
+
382
  const editorContainerCallback = useCallback((node: HTMLDivElement | null) => {
383
  editorContainerRef.current = node;
384
  setContainerEl(node);
 
389
  setEditorInstance(editor);
390
  }, []);
391
 
392
+ const onProviderReady = useCallback((provider: HocuspocusProvider) => {
393
+ providerRef.current = provider;
394
+ setYDoc(provider.document);
395
+ provider.on("synced", () => setIsEditorReady(true));
396
  }, []);
397
 
398
+ const onCommentStoreReady = useCallback((store: CommentStore) => setCommentStore(store), []);
399
+ const onFrontmatterStoreReady = useCallback((store: FrontmatterStore) => setFrontmatterStore(store), []);
400
+ const onEmbedStoreReady = useCallback((store: EmbedStore) => setEmbedStore(store), []);
401
+ const onSettingsMapReady = useCallback((map: Y.Map<any>) => setSettingsMap(map), []);
402
 
403
+ // --- Publish flow -----------------------------------------------------
404
+
405
+ const publishEventSourceRef = useRef<EventSource | null>(null);
406
 
407
  const openPublishDialog = useCallback(() => {
408
  setPublishState("idle");
409
  setPublishError("");
410
+ setPublishStage(null);
411
  publishDialogRef.current?.showModal();
412
  }, []);
413
 
 
415
  publishDialogRef.current?.close();
416
  }, []);
417
 
418
+ useEffect(() => {
419
+ return () => {
420
+ publishEventSourceRef.current?.close();
421
+ publishEventSourceRef.current = null;
422
+ };
423
+ }, []);
424
+
425
  const handlePublish = useCallback(async () => {
426
  setPublishState("loading");
427
  setPublishError("");
428
+ setPublishStage(null);
429
+
430
  try {
431
  const res = await fetch("/api/publish", {
432
  method: "POST",
 
434
  credentials: "include",
435
  body: JSON.stringify({ docName }),
436
  });
437
+
438
+ if (res.status === 409) {
439
+ const data = await res.json().catch(() => ({ error: "Publish already in progress" }));
440
+ throw new Error(
441
+ data.userName
442
+ ? `${data.userName} is already publishing. Please wait.`
443
+ : "A publish is already in progress. Please wait."
444
+ );
445
+ }
446
  if (!res.ok) {
447
  const data = await res.json().catch(() => ({ error: "Publish failed" }));
448
  throw new Error(data.error || "Publish failed");
449
  }
450
+
451
+ const { jobId } = (await res.json()) as { jobId: string };
452
+ if (!jobId) throw new Error("No job id returned");
453
+
454
+ // Open the SSE stream and forward stage events to the dialog.
455
+ const es = new EventSource(`/api/publish/stream?jobId=${encodeURIComponent(jobId)}`, {
456
+ withCredentials: true,
457
+ });
458
+ publishEventSourceRef.current = es;
459
+
460
+ es.addEventListener("stage", (e: MessageEvent) => {
461
+ try {
462
+ const payload = JSON.parse(e.data) as PublishStageEvent;
463
+ setPublishStage(payload);
464
+ } catch {
465
+ // ignore malformed payloads
466
+ }
467
+ });
468
+
469
+ es.addEventListener("done", (e: MessageEvent) => {
470
+ try {
471
+ const payload = JSON.parse(e.data) as { success: boolean; error?: string };
472
+ if (payload.success) {
473
+ setPublishState("success");
474
+ } else {
475
+ setPublishState("error");
476
+ setPublishError(payload.error || "Publish failed");
477
+ }
478
+ } catch {
479
+ setPublishState("error");
480
+ setPublishError("Malformed server event");
481
+ } finally {
482
+ es.close();
483
+ publishEventSourceRef.current = null;
484
+ }
485
+ });
486
+
487
+ es.onerror = () => {
488
+ // Network blip or server closed the stream unexpectedly. If we haven't
489
+ // already reached a terminal state, mark the publish as errored.
490
+ if (es.readyState === EventSource.CLOSED) {
491
+ publishEventSourceRef.current = null;
492
+ setPublishState((prev) => (prev === "loading" ? "error" : prev));
493
+ setPublishError((prev) => prev || "Connection to publish stream lost");
494
+ }
495
+ };
496
  } catch (err: any) {
497
  setPublishState("error");
498
  setPublishError(err.message || "Unknown error");
499
  }
500
  }, [docName]);
501
 
502
+ // --- Comment flow -----------------------------------------------------
503
+
504
  const handleAddComment = useCallback(() => {
505
  const editor = editorRef.current;
506
  if (!editor) return;
 
507
  const { from, to } = editor.state.selection;
508
  if (from === to) return;
 
509
  selectionRange.current = { from, to };
510
  setCommentDialogOpen(true);
511
  }, []);
 
536
  selectionRange.current = null;
537
  }, [commentStore, user]);
538
 
539
+ // --- Render -----------------------------------------------------------
540
+
541
  return (
542
+ <div className="editor-app">
543
+ <TopBar
544
+ editorInstance={editorInstance}
545
+ providerRef={providerRef}
546
+ docName={docName}
547
+ theme={theme}
548
+ user={user}
549
+ loginUrl={loginUrl}
550
+ isAuthenticated={isAuthenticated}
551
+ isPublishing={publishStatus.active}
552
+ publishingUserName={publishStatus.userName}
553
+ onToggleTheme={toggleTheme}
554
+ onOpenSettings={() => setSettingsOpen(true)}
555
+ onOpenPublish={openPublishDialog}
556
+ onOpenMobileToc={() => setTocSidebarOpen(true)}
557
+ />
558
+
559
+ {!isEditorReady && (
560
+ <div className="editor-loading-overlay">
561
+ <span className="spinner spinner--lg" />
562
+ </div>
563
+ )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
564
 
 
565
  <div
566
  ref={editorContainerCallback}
567
+ className={isEditorReady ? "editor-scroll editor-scroll--ready" : "editor-scroll"}
568
  >
569
  <div className="content-grid">
570
  <div className="content-grid__hero">
571
+ <FrontmatterHero store={frontmatterStore} embedStore={embedStore} />
572
  </div>
573
  <div className="content-grid__toc">
574
  <div className="table-of-contents--sticky">
575
+ <TableOfContents
576
+ editor={editorInstance}
577
+ scrollContainer={containerEl}
578
+ autoCollapse={tocAutoCollapse}
579
+ />
580
  </div>
581
  </div>
582
  <div className="content-grid__editor">
 
586
  editorRef={editorRef}
587
  onCommentStoreReady={onCommentStoreReady}
588
  onFrontmatterStoreReady={onFrontmatterStoreReady}
589
+ onEmbedStoreReady={onEmbedStoreReady}
590
  onSettingsMapReady={onSettingsMapReady}
591
  onEditorReady={onEditorReady}
592
  onUndoManagerReady={setUndoManager}
593
+ onProviderReady={onProviderReady}
594
  onAddComment={handleAddComment}
595
  />
596
  </div>
 
 
 
 
 
 
 
 
597
  </div>
598
+ <EditorFooter store={frontmatterStore} editor={editorInstance} />
599
+ <CommentMarginIcons
600
+ editor={editorInstance}
601
+ commentStore={commentStore}
602
+ user={user}
603
+ />
604
  </div>
605
 
 
606
  {chatOpen ? (
607
  <div className="chat-floating">
608
+ <ChatPanel
609
+ messages={agentChat.messages}
610
+ isLoading={agentChat.isLoading}
611
+ error={agentChat.error}
612
+ input={agentChat.input}
613
+ hasSelection={hasSelection}
614
+ models={models}
615
+ selectedModel={selectedModel}
616
+ onModelChange={handleModelChange}
617
+ onSend={agentChat.sendMessage}
618
+ onQuickAction={agentChat.sendQuickAction}
619
+ onSetInput={agentChat.setInput}
620
+ onStop={agentChat.stop}
621
+ onNewChat={() => agentChat.clearMessages()}
622
+ onClose={() => setChatOpen(false)}
623
+ />
 
 
 
 
624
  </div>
625
  ) : (
626
  <Tooltip title="AI Assistant" placement="right">
 
647
  settingsMap={settingsMap}
648
  />
649
 
650
+ {embedStudioSrc && (
651
+ <EmbedStudio
652
+ key={embedStudioSrc}
653
+ src={embedStudioSrc}
654
+ embedStore={embedStore}
655
+ modelRef={modelRef}
656
+ userId={chatUserId}
657
+ onClose={() => setEmbedStudioSrc(null)}
658
+ />
659
+ )}
660
+
661
+ <MobileTocSidebar
662
+ open={tocSidebarOpen}
663
+ editor={editorInstance}
664
+ scrollContainer={containerEl}
665
+ autoCollapse={tocAutoCollapse}
666
+ theme={theme}
667
+ onClose={() => setTocSidebarOpen(false)}
668
+ onToggleTheme={toggleTheme}
669
+ />
670
+
671
+ <PublishDialog
672
  ref={publishDialogRef}
673
+ state={publishState}
674
+ error={publishError}
675
+ docName={docName}
676
+ stageEvent={publishStage}
677
  onClose={closePublishDialog}
678
+ onPublish={handlePublish}
679
+ />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
680
  </div>
681
  );
682
  }
frontend/src/components/ChatPanel.tsx CHANGED
@@ -1,17 +1,29 @@
1
  import { useState, useRef, useEffect, type KeyboardEvent } from "react";
 
 
2
  import { Tooltip } from "./Tooltip";
3
  import {
4
  Send,
5
  Square,
6
  Plus,
 
7
  Sparkles,
8
  AlignLeft,
9
  Expand,
10
  SpellCheck,
11
  Languages,
12
  Shrink,
 
13
  } from "lucide-react";
14
  import type { UIMessage } from "ai";
 
 
 
 
 
 
 
 
15
 
16
  interface ChatPanelProps {
17
  messages: UIMessage[];
@@ -19,11 +31,15 @@ interface ChatPanelProps {
19
  error: Error | undefined;
20
  input: string;
21
  hasSelection: boolean;
 
 
 
22
  onSend: (content: string) => void;
23
  onQuickAction: (action: string) => void;
24
  onSetInput: (value: string) => void;
25
  onStop: () => void;
26
  onNewChat: () => void;
 
27
  }
28
 
29
  const QUICK_ACTIONS = [
@@ -35,17 +51,49 @@ const QUICK_ACTIONS = [
35
  { id: "simplify", label: "Simplify", Icon: AlignLeft },
36
  ];
37
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  export function ChatPanel({
39
  messages,
40
  isLoading,
41
  error,
42
  input,
43
  hasSelection,
 
 
 
44
  onSend,
45
  onQuickAction,
46
  onSetInput,
47
  onStop,
48
  onNewChat,
 
49
  }: ChatPanelProps) {
50
  const scrollRef = useRef<HTMLDivElement>(null);
51
 
@@ -70,16 +118,51 @@ export function ChatPanel({
70
  }
71
  };
72
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  return (
74
  <div className="chat-panel">
75
  {/* Header */}
76
  <div className="chat-panel__header">
77
- <span className="chat-panel__label">Assistant</span>
78
- <Tooltip title="New conversation">
79
- <button className="icon-btn" onClick={onNewChat} aria-label="New conversation">
80
- <Plus size={16} />
81
- </button>
82
- </Tooltip>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  </div>
84
 
85
  {/* Messages */}
@@ -94,11 +177,8 @@ export function ChatPanel({
94
  <MessageBubble key={msg.id} message={msg} />
95
  ))}
96
 
97
- {isLoading && messages.length > 0 && !messages[messages.length - 1]?.content && (
98
- <div className="chat-panel__thinking">
99
- <span className="spinner" style={{ width: 12, height: 12, borderWidth: 2 }} />
100
- <span>Thinking...</span>
101
- </div>
102
  )}
103
 
104
  {error && (
@@ -162,7 +242,7 @@ function MessageBubble({ message }: { message: UIMessage }) {
162
 
163
  const parts = message.parts ?? [];
164
  const textParts = parts.filter((p) => p.type === "text");
165
- const toolParts = parts.filter((p) => p.type === "tool-invocation");
166
  const textContent = textParts.length > 0
167
  ? textParts.map((p) => (p as { type: "text"; text: string }).text).join("")
168
  : (message as any).content ?? "";
@@ -173,23 +253,19 @@ function MessageBubble({ message }: { message: UIMessage }) {
173
  <div className="chat-bubble">
174
  {textContent && (
175
  <div className={`chat-bubble__text ${isUser ? "chat-bubble__text--user" : ""}`}>
176
- {textContent}
 
 
177
  </div>
178
  )}
179
 
180
  {toolParts.map((part, i) => {
181
- const tool = part as { type: "tool-invocation"; toolInvocation: { toolName: string; state: string; result?: unknown } };
 
182
  return (
183
  <div key={i} className="chat-bubble__tool">
184
  <Sparkles size={12} />
185
- <span>
186
- {toolLabel(tool.toolInvocation.toolName)}
187
- {tool.toolInvocation.state === "result" && tool.toolInvocation.result
188
- ? ` - ${tool.toolInvocation.result}`
189
- : tool.toolInvocation.state === "call"
190
- ? " (executing...)"
191
- : ""}
192
- </span>
193
  </div>
194
  );
195
  })}
@@ -197,14 +273,51 @@ function MessageBubble({ message }: { message: UIMessage }) {
197
  );
198
  }
199
 
200
- function toolLabel(name: string): string {
201
- const labels: Record<string, string> = {
202
- replaceSelection: "Replaced selection",
203
- insertAtCursor: "Inserted text",
204
- applyDiff: "Applied edit",
205
- updateFrontmatter: "Updated metadata",
206
- addAuthor: "Added author",
207
- removeAuthor: "Removed author",
208
- };
209
- return labels[name] || name;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210
  }
 
1
  import { useState, useRef, useEffect, type KeyboardEvent } from "react";
2
+ import ReactMarkdown from "react-markdown";
3
+ import remarkGfm from "remark-gfm";
4
  import { Tooltip } from "./Tooltip";
5
  import {
6
  Send,
7
  Square,
8
  Plus,
9
+ X,
10
  Sparkles,
11
  AlignLeft,
12
  Expand,
13
  SpellCheck,
14
  Languages,
15
  Shrink,
16
+ Loader,
17
  } from "lucide-react";
18
  import type { UIMessage } from "ai";
19
+ import { isToolPart, normalizeToolPart } from "../utils/ai-tool-parts";
20
+
21
+ export interface ModelOption {
22
+ id: string;
23
+ label: string;
24
+ context: string;
25
+ cost: string;
26
+ }
27
 
28
  interface ChatPanelProps {
29
  messages: UIMessage[];
 
31
  error: Error | undefined;
32
  input: string;
33
  hasSelection: boolean;
34
+ models: ModelOption[];
35
+ selectedModel: string;
36
+ onModelChange: (modelId: string) => void;
37
  onSend: (content: string) => void;
38
  onQuickAction: (action: string) => void;
39
  onSetInput: (value: string) => void;
40
  onStop: () => void;
41
  onNewChat: () => void;
42
+ onClose?: () => void;
43
  }
44
 
45
  const QUICK_ACTIONS = [
 
51
  { id: "simplify", label: "Simplify", Icon: AlignLeft },
52
  ];
53
 
54
+ function TokenGauge({ tokens, ratio }: { tokens: number; ratio: number }) {
55
+ const r = 8;
56
+ const circ = 2 * Math.PI * r;
57
+ const offset = circ * (1 - ratio);
58
+ const color = ratio > 0.8 ? "var(--ed-error)" : ratio > 0.5 ? "#ff9800" : "var(--ed-text-disabled)";
59
+ const label = tokens < 1000 ? `${tokens}` : `${(tokens / 1000).toFixed(1)}k`;
60
+
61
+ if (tokens === 0) return null;
62
+
63
+ return (
64
+ <Tooltip title={`~${label} tokens used`}>
65
+ <div className="token-gauge" style={{ display: "inline-flex", alignItems: "center", cursor: "default" }}>
66
+ <svg width="18" height="18" viewBox="0 0 20 20" style={{ transform: "rotate(-90deg)" }}>
67
+ <circle cx="10" cy="10" r={r} fill="none" stroke="var(--ed-border)" strokeWidth="2" />
68
+ <circle
69
+ cx="10" cy="10" r={r}
70
+ fill="none" stroke={color} strokeWidth="2"
71
+ strokeDasharray={circ}
72
+ strokeDashoffset={offset}
73
+ strokeLinecap="round"
74
+ style={{ transition: "stroke-dashoffset 300ms ease, stroke 300ms ease" }}
75
+ />
76
+ </svg>
77
+ </div>
78
+ </Tooltip>
79
+ );
80
+ }
81
+
82
  export function ChatPanel({
83
  messages,
84
  isLoading,
85
  error,
86
  input,
87
  hasSelection,
88
+ models,
89
+ selectedModel,
90
+ onModelChange,
91
  onSend,
92
  onQuickAction,
93
  onSetInput,
94
  onStop,
95
  onNewChat,
96
+ onClose,
97
  }: ChatPanelProps) {
98
  const scrollRef = useRef<HTMLDivElement>(null);
99
 
 
118
  }
119
  };
120
 
121
+ const estimatedTokens = messages.reduce((sum, msg) => {
122
+ const text = msg.parts
123
+ ?.filter((p) => p.type === "text")
124
+ .map((p) => (p as { type: "text"; text: string }).text)
125
+ .join("") ?? "";
126
+ return sum + Math.ceil(text.length / 4);
127
+ }, 0);
128
+
129
+ const selectedModelInfo = models.find((m) => m.id === selectedModel);
130
+ const contextLimit = selectedModelInfo
131
+ ? parseFloat(selectedModelInfo.context) * 1000
132
+ : 200_000;
133
+ const usageRatio = Math.min(estimatedTokens / contextLimit, 1);
134
+
135
  return (
136
  <div className="chat-panel">
137
  {/* Header */}
138
  <div className="chat-panel__header">
139
+ <span className="chat-panel__label">AI Assistant</span>
140
+ <div className="chat-panel__header-right">
141
+ <TokenGauge tokens={estimatedTokens} ratio={usageRatio} />
142
+ {models.length > 0 && (
143
+ <select
144
+ className="chat-panel__model-select"
145
+ value={selectedModel}
146
+ onChange={(e) => onModelChange(e.target.value)}
147
+ >
148
+ {models.map((m) => (
149
+ <option key={m.id} value={m.id}>
150
+ {m.label} ({m.context}) {m.cost}
151
+ </option>
152
+ ))}
153
+ </select>
154
+ )}
155
+ <Tooltip title="New conversation">
156
+ <button className="icon-btn icon-btn--sm" onClick={onNewChat} aria-label="New conversation">
157
+ <Plus size={14} />
158
+ </button>
159
+ </Tooltip>
160
+ {onClose && (
161
+ <button className="icon-btn icon-btn--sm" onClick={onClose} aria-label="Close chat">
162
+ <X size={14} />
163
+ </button>
164
+ )}
165
+ </div>
166
  </div>
167
 
168
  {/* Messages */}
 
177
  <MessageBubble key={msg.id} message={msg} />
178
  ))}
179
 
180
+ {isLoading && messages.length > 0 && (
181
+ <AgentStatus messages={messages} />
 
 
 
182
  )}
183
 
184
  {error && (
 
242
 
243
  const parts = message.parts ?? [];
244
  const textParts = parts.filter((p) => p.type === "text");
245
+ const toolParts = parts.filter(isToolPart);
246
  const textContent = textParts.length > 0
247
  ? textParts.map((p) => (p as { type: "text"; text: string }).text).join("")
248
  : (message as any).content ?? "";
 
253
  <div className="chat-bubble">
254
  {textContent && (
255
  <div className={`chat-bubble__text ${isUser ? "chat-bubble__text--user" : ""}`}>
256
+ {isUser ? textContent : (
257
+ <ReactMarkdown remarkPlugins={[remarkGfm]}>{textContent}</ReactMarkdown>
258
+ )}
259
  </div>
260
  )}
261
 
262
  {toolParts.map((part, i) => {
263
+ const tool = normalizeToolPart(part);
264
+ if (!tool) return null;
265
  return (
266
  <div key={i} className="chat-bubble__tool">
267
  <Sparkles size={12} />
268
+ <span>{toolLabel(tool.toolName, tool.state)}</span>
 
 
 
 
 
 
 
269
  </div>
270
  );
271
  })}
 
273
  );
274
  }
275
 
276
+ const TOOL_LABELS: Record<string, [string, string]> = {
277
+ replaceSelection: ["Replacing selection...", "Replaced selection"],
278
+ insertAtCursor: ["Inserting text...", "Inserted text"],
279
+ applyDiff: ["Applying edit...", "Applied edit"],
280
+ updateFrontmatter: ["Updating metadata...", "Updated metadata"],
281
+ addAuthor: ["Adding author...", "Added author"],
282
+ removeAuthor: ["Removing author...", "Removed author"],
283
+ };
284
+
285
+ function toolLabel(name: string, state: string): string {
286
+ const pair = TOOL_LABELS[name];
287
+ if (!pair) return name;
288
+ return state === "result" ? pair[1] : pair[0];
289
+ }
290
+
291
+ function AgentStatus({ messages }: { messages: UIMessage[] }) {
292
+ const last = messages[messages.length - 1];
293
+ if (!last) return null;
294
+
295
+ if (last.role === "assistant") {
296
+ const parts = last.parts ?? [];
297
+ const runningTools = parts
298
+ .map((p) => normalizeToolPart(p))
299
+ .filter((t): t is NonNullable<typeof t> => t !== null && t.state !== "result")
300
+ .map((t) => t.toolName);
301
+
302
+ if (runningTools.length > 0) {
303
+ const label = TOOL_LABELS[runningTools[runningTools.length - 1]];
304
+ const text = label ? label[0] : runningTools[runningTools.length - 1];
305
+ return (
306
+ <div className="chat-panel__thinking">
307
+ <Loader size={13} className="spin" />
308
+ <span className="shimmer-text">{text}</span>
309
+ </div>
310
+ );
311
+ }
312
+
313
+ const hasText = parts.some((p) => p.type === "text" && (p as any).text?.trim());
314
+ if (hasText) return null;
315
+ }
316
+
317
+ return (
318
+ <div className="chat-panel__thinking">
319
+ <Loader size={13} className="spin" />
320
+ <span className="shimmer-text">Thinking...</span>
321
+ </div>
322
+ );
323
  }
frontend/src/components/CommentPopover.tsx ADDED
@@ -0,0 +1,293 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect, useCallback, useRef } from "react";
2
+ import { CheckCircle, Trash2, Send, MessageCircle } from "lucide-react";
3
+ import type { Editor } from "@tiptap/core";
4
+ import type { CommentStore, CommentData } from "../editor/comments";
5
+
6
+ interface Props {
7
+ editor: Editor | null;
8
+ commentStore: CommentStore | null;
9
+ user: { name: string; color: string; avatarUrl?: string };
10
+ }
11
+
12
+ interface CommentPosition {
13
+ id: string;
14
+ top: number;
15
+ }
16
+
17
+ function buildMarkPositions(editor: Editor): Map<string, number> {
18
+ const map = new Map<string, number>();
19
+ editor.state.doc.descendants((node, pos) => {
20
+ for (const mark of node.marks) {
21
+ if (mark.type.name === "comment" && !map.has(mark.attrs.commentId)) {
22
+ map.set(mark.attrs.commentId, pos);
23
+ }
24
+ }
25
+ });
26
+ return map;
27
+ }
28
+
29
+ export function CommentMarginIcons({ editor, commentStore, user }: Props) {
30
+ const [positions, setPositions] = useState<CommentPosition[]>([]);
31
+ const [activeId, setActiveId] = useState<string | null>(null);
32
+ const [activeComment, setActiveComment] = useState<CommentData | null>(null);
33
+ const [replyText, setReplyText] = useState("");
34
+ const popoverRef = useRef<HTMLDivElement>(null);
35
+ const replyInputRef = useRef<HTMLTextAreaElement>(null);
36
+
37
+ const activeIdRef = useRef<string | null>(null);
38
+ activeIdRef.current = activeId;
39
+
40
+ const updatePositions = useCallback(() => {
41
+ if (!editor || !commentStore) return;
42
+ if (activeIdRef.current) return; // freeze while popover open
43
+ const comments = commentStore.getAll().filter((c) => !c.resolved);
44
+ if (comments.length === 0) {
45
+ setPositions([]);
46
+ return;
47
+ }
48
+
49
+ const scrollParent = editor.view.dom.closest(".editor-scroll");
50
+ if (!scrollParent) return;
51
+ const scrollTop = scrollParent.scrollTop;
52
+ const scrollRect = scrollParent.getBoundingClientRect();
53
+
54
+ const markPos = buildMarkPositions(editor);
55
+ const result: CommentPosition[] = [];
56
+
57
+ for (const c of comments) {
58
+ const pos = markPos.get(c.id);
59
+ if (pos === undefined) continue;
60
+ try {
61
+ const coords = editor.view.coordsAtPos(pos);
62
+ const top = coords.top - scrollRect.top + scrollTop;
63
+ result.push({ id: c.id, top });
64
+ } catch { /* not mapped */ }
65
+ }
66
+
67
+ result.sort((a, b) => a.top - b.top);
68
+ const MIN_GAP = 32;
69
+ for (let i = 1; i < result.length; i++) {
70
+ if (result[i].top < result[i - 1].top + MIN_GAP) {
71
+ result[i].top = result[i - 1].top + MIN_GAP;
72
+ }
73
+ }
74
+ setPositions(result);
75
+ }, [editor, commentStore]);
76
+
77
+ useEffect(() => {
78
+ updatePositions();
79
+ if (!commentStore) return;
80
+ return commentStore.observe(updatePositions);
81
+ }, [commentStore, updatePositions]);
82
+
83
+ useEffect(() => {
84
+ if (!editor) return;
85
+ const scrollParent = editor.view.dom.closest(".editor-scroll");
86
+ if (!scrollParent) return;
87
+
88
+ let timer = 0;
89
+ const debounced = () => {
90
+ clearTimeout(timer);
91
+ timer = window.setTimeout(updatePositions, 120);
92
+ };
93
+ scrollParent.addEventListener("scroll", debounced, { passive: true });
94
+ editor.on("update", debounced);
95
+ return () => {
96
+ scrollParent.removeEventListener("scroll", debounced);
97
+ editor.off("update", debounced);
98
+ clearTimeout(timer);
99
+ };
100
+ }, [editor, updatePositions]);
101
+
102
+ // Also open popover when clicking on a comment-mark in the text
103
+ useEffect(() => {
104
+ if (!editor || !commentStore) return;
105
+ const handleSelection = () => {
106
+ const { from, to } = editor.state.selection;
107
+ if (from !== to) return;
108
+ const resolved = editor.state.doc.resolve(from);
109
+ for (const mark of resolved.marks()) {
110
+ if (mark.type.name === "comment" && mark.attrs.commentId) {
111
+ const id = mark.attrs.commentId as string;
112
+ const comment = commentStore.get(id);
113
+ if (comment && !comment.resolved) {
114
+ setActiveId(id);
115
+ setActiveComment(comment);
116
+ setReplyText("");
117
+ return;
118
+ }
119
+ }
120
+ }
121
+ };
122
+ editor.on("selectionUpdate", handleSelection);
123
+ return () => { editor.off("selectionUpdate", handleSelection); };
124
+ }, [editor, commentStore]);
125
+
126
+ // Refresh active comment data from store
127
+ useEffect(() => {
128
+ if (!activeId || !commentStore) return;
129
+ const refresh = () => {
130
+ const updated = commentStore.get(activeId);
131
+ if (updated) setActiveComment(updated);
132
+ else { setActiveComment(null); setActiveId(null); }
133
+ };
134
+ return commentStore.observe(refresh);
135
+ }, [activeId, commentStore]);
136
+
137
+ // Recalculate positions when popover closes (unfreeze)
138
+ const prevActiveId = useRef<string | null>(null);
139
+ useEffect(() => {
140
+ if (prevActiveId.current && !activeId) {
141
+ setTimeout(updatePositions, 50);
142
+ }
143
+ prevActiveId.current = activeId;
144
+ }, [activeId, updatePositions]);
145
+
146
+ // Close on outside click
147
+ useEffect(() => {
148
+ if (!activeId) return;
149
+ const handler = (e: MouseEvent) => {
150
+ if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
151
+ const target = e.target as HTMLElement;
152
+ if (target.closest(".comment-margin-icon")) return;
153
+ setActiveId(null);
154
+ setActiveComment(null);
155
+ }
156
+ };
157
+ document.addEventListener("mousedown", handler);
158
+ return () => document.removeEventListener("mousedown", handler);
159
+ }, [activeId]);
160
+
161
+ const handleIconClick = (id: string) => {
162
+ if (!commentStore) return;
163
+ if (activeId === id) {
164
+ setActiveId(null);
165
+ setActiveComment(null);
166
+ return;
167
+ }
168
+ const comment = commentStore.get(id);
169
+ if (!comment) return;
170
+ setActiveId(id);
171
+ setActiveComment(comment);
172
+ setReplyText("");
173
+ };
174
+
175
+ const handleReply = () => {
176
+ if (!replyText.trim() || !commentStore || !activeId) return;
177
+ commentStore.addReply(activeId, {
178
+ id: `r_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
179
+ author: user.name,
180
+ authorColor: user.color,
181
+ text: replyText.trim(),
182
+ createdAt: Date.now(),
183
+ });
184
+ setReplyText("");
185
+ setTimeout(() => replyInputRef.current?.focus(), 50);
186
+ };
187
+
188
+ const removeMarkAndData = useCallback((id: string) => {
189
+ if (!commentStore || !editor) return;
190
+ commentStore.remove(id);
191
+ const { doc, tr } = editor.state;
192
+ const markType = editor.schema.marks.comment;
193
+ doc.descendants((node, pos) => {
194
+ node.marks.forEach((mark) => {
195
+ if (mark.type === markType && mark.attrs.commentId === id) {
196
+ tr.removeMark(pos, pos + node.nodeSize, markType);
197
+ }
198
+ });
199
+ });
200
+ editor.view.dispatch(tr);
201
+ setActiveId(null);
202
+ setActiveComment(null);
203
+ }, [commentStore, editor]);
204
+
205
+ const activePos = positions.find((p) => p.id === activeId);
206
+
207
+ const formatTime = (ts: number) =>
208
+ new Date(ts).toLocaleString(undefined, {
209
+ month: "short", day: "numeric", hour: "2-digit", minute: "2-digit",
210
+ });
211
+
212
+ return (
213
+ <div className="comment-margin-strip">
214
+ {positions.map((p) => (
215
+ <div
216
+ key={p.id}
217
+ className={`comment-margin-icon ${activeId === p.id ? "comment-margin-icon--active" : ""}`}
218
+ style={{ top: p.top }}
219
+ onClick={() => handleIconClick(p.id)}
220
+ >
221
+ <MessageCircle size={14} />
222
+ </div>
223
+ ))}
224
+
225
+ {activeComment && activePos && (
226
+ <div
227
+ ref={popoverRef}
228
+ className="comment-popover"
229
+ style={{ top: activePos.top }}
230
+ >
231
+ <div className="comment-popover__thread">
232
+ <div className="comment-popover__message">
233
+ <div className="comment-popover__header">
234
+ <span className="comment-popover__author" style={{ color: activeComment.authorColor }}>
235
+ {activeComment.author}
236
+ </span>
237
+ <span className="comment-popover__time">{formatTime(activeComment.createdAt)}</span>
238
+ </div>
239
+ <div className="comment-popover__text">{activeComment.text}</div>
240
+ </div>
241
+
242
+ {activeComment.replies.map((reply) => (
243
+ <div key={reply.id} className="comment-popover__message comment-popover__reply">
244
+ <div className="comment-popover__header">
245
+ <span className="comment-popover__author" style={{ color: reply.authorColor }}>
246
+ {reply.author}
247
+ </span>
248
+ <span className="comment-popover__time">{formatTime(reply.createdAt)}</span>
249
+ </div>
250
+ <div className="comment-popover__text">{reply.text}</div>
251
+ </div>
252
+ ))}
253
+ </div>
254
+
255
+ <div className="comment-popover__input-row">
256
+ <textarea
257
+ ref={replyInputRef}
258
+ className="comment-popover__input"
259
+ rows={1}
260
+ placeholder="Reply..."
261
+ value={replyText}
262
+ onChange={(e) => setReplyText(e.target.value)}
263
+ onKeyDown={(e) => {
264
+ if (e.key === "Enter" && !e.shiftKey) {
265
+ e.preventDefault();
266
+ handleReply();
267
+ }
268
+ }}
269
+ />
270
+ <button
271
+ className="comment-popover__send"
272
+ onClick={handleReply}
273
+ disabled={!replyText.trim()}
274
+ aria-label="Send reply"
275
+ >
276
+ <Send size={14} />
277
+ </button>
278
+ </div>
279
+
280
+ <div className="comment-popover__actions">
281
+ <button className="comment-popover__resolve" onClick={() => removeMarkAndData(activeComment.id)}>
282
+ <CheckCircle size={14} />
283
+ Resolve
284
+ </button>
285
+ <button className="comment-popover__delete" onClick={() => removeMarkAndData(activeComment.id)}>
286
+ <Trash2 size={14} />
287
+ </button>
288
+ </div>
289
+ </div>
290
+ )}
291
+ </div>
292
+ );
293
+ }
frontend/src/components/CommentsSidebar.tsx DELETED
@@ -1,212 +0,0 @@
1
- import { useState, useEffect, useCallback, useRef } from "react";
2
- import { Tooltip } from "./Tooltip";
3
- import { CheckCircle, Trash2 } from "lucide-react";
4
- import type { Editor } from "@tiptap/core";
5
- import type { CommentStore, CommentData } from "../editor/comments";
6
-
7
- interface CommentsSidebarProps {
8
- editor: Editor | null;
9
- commentStore: CommentStore | null;
10
- user: { name: string; color: string; avatarUrl?: string };
11
- editorContainer: HTMLElement | null;
12
- }
13
-
14
- interface PositionedComment extends CommentData {
15
- top: number;
16
- }
17
-
18
- export function CommentsSidebar({ editor, commentStore, user, editorContainer }: CommentsSidebarProps) {
19
- const [comments, setComments] = useState<CommentData[]>([]);
20
- const [positioned, setPositioned] = useState<PositionedComment[]>([]);
21
- const [activeId, setActiveId] = useState<string | null>(null);
22
- const sidebarRef = useRef<HTMLDivElement>(null);
23
-
24
- const refresh = useCallback(() => {
25
- if (!commentStore) return;
26
- setComments(commentStore.getAll());
27
- }, [commentStore]);
28
-
29
- useEffect(() => {
30
- if (!commentStore) return;
31
- refresh();
32
- return commentStore.observe(refresh);
33
- }, [commentStore, refresh]);
34
-
35
- const updatePositions = useCallback(() => {
36
- if (!editor || !editorContainer) return;
37
-
38
- const view = editor.view;
39
- const containerRect = editorContainer.getBoundingClientRect();
40
- const active = comments.filter((c) => !c.resolved);
41
- const result: PositionedComment[] = [];
42
-
43
- for (const comment of active) {
44
- let markPos = -1;
45
-
46
- view.state.doc.descendants((node, pos) => {
47
- if (markPos >= 0) return false;
48
- for (const mark of node.marks) {
49
- if (mark.type.name === "comment" && mark.attrs.commentId === comment.id) {
50
- markPos = pos;
51
- return false;
52
- }
53
- }
54
- });
55
-
56
- if (markPos >= 0) {
57
- try {
58
- const coords = view.coordsAtPos(markPos);
59
- const top = coords.top - containerRect.top;
60
- result.push({ ...comment, top });
61
- } catch {
62
- // Position not available yet
63
- }
64
- }
65
- }
66
-
67
- result.sort((a, b) => a.top - b.top);
68
- const MIN_GAP = 80;
69
- for (let i = 1; i < result.length; i++) {
70
- if (result[i].top < result[i - 1].top + MIN_GAP) {
71
- result[i].top = result[i - 1].top + MIN_GAP;
72
- }
73
- }
74
-
75
- setPositioned(result);
76
- }, [editor, editorContainer, comments]);
77
-
78
- const posTimerRef = useRef(0);
79
-
80
- useEffect(() => {
81
- updatePositions();
82
-
83
- const debouncedPositions = () => {
84
- clearTimeout(posTimerRef.current);
85
- posTimerRef.current = window.setTimeout(updatePositions, 200);
86
- };
87
-
88
- const editorEl = editor?.view?.dom;
89
- const scrollTarget = editorContainer;
90
-
91
- if (editorEl) {
92
- if (scrollTarget) {
93
- scrollTarget.addEventListener("scroll", debouncedPositions, { passive: true });
94
- }
95
- const observer = new MutationObserver(debouncedPositions);
96
- observer.observe(editorEl, { childList: true, subtree: true, characterData: true });
97
- return () => {
98
- scrollTarget?.removeEventListener("scroll", debouncedPositions);
99
- observer.disconnect();
100
- clearTimeout(posTimerRef.current);
101
- };
102
- }
103
- }, [editor, editorContainer, updatePositions]);
104
-
105
- const handleResolve = (id: string) => {
106
- if (!commentStore) return;
107
- commentStore.resolve(id, user.name);
108
- };
109
-
110
- const handleDelete = (id: string) => {
111
- if (!commentStore || !editor) return;
112
- commentStore.remove(id);
113
-
114
- const { doc, tr } = editor.state;
115
- const markType = editor.schema.marks.comment;
116
- doc.descendants((node, pos) => {
117
- node.marks.forEach((mark) => {
118
- if (mark.type === markType && mark.attrs.commentId === id) {
119
- tr.removeMark(pos, pos + node.nodeSize, markType);
120
- }
121
- });
122
- });
123
- editor.view.dispatch(tr);
124
- };
125
-
126
- return (
127
- <div ref={sidebarRef} style={{ position: "relative", width: "100%", minHeight: "100%" }}>
128
- {positioned.map((c) => (
129
- <div
130
- key={c.id}
131
- style={{
132
- position: "absolute",
133
- top: c.top,
134
- left: 0,
135
- right: 0,
136
- transition: "top 0.2s ease",
137
- }}
138
- >
139
- <CommentCard
140
- comment={c}
141
- active={activeId === c.id}
142
- onResolve={() => handleResolve(c.id)}
143
- onDelete={() => handleDelete(c.id)}
144
- onClick={() => setActiveId(activeId === c.id ? null : c.id)}
145
- />
146
- </div>
147
- ))}
148
- </div>
149
- );
150
- }
151
-
152
- function CommentCard({
153
- comment,
154
- active,
155
- onResolve,
156
- onDelete,
157
- onClick,
158
- }: {
159
- comment: CommentData;
160
- active?: boolean;
161
- onResolve: () => void;
162
- onDelete: () => void;
163
- onClick: () => void;
164
- }) {
165
- const time = new Date(comment.createdAt).toLocaleString(undefined, {
166
- month: "short",
167
- day: "numeric",
168
- hour: "2-digit",
169
- minute: "2-digit",
170
- });
171
-
172
- return (
173
- <div
174
- className={`comment-card surface ${active ? "comment-card--active" : ""}`}
175
- style={{ borderLeftColor: comment.authorColor }}
176
- onClick={onClick}
177
- >
178
- <div className="comment-card__header">
179
- <span
180
- className="chip"
181
- style={{ backgroundColor: comment.authorColor, color: "#000" }}
182
- >
183
- {comment.author}
184
- </span>
185
- <span className="comment-card__time">{time}</span>
186
- </div>
187
-
188
- <div className="comment-card__text">{comment.text}</div>
189
-
190
- <div className="comment-card__actions">
191
- <Tooltip title="Resolve">
192
- <button
193
- className="icon-btn icon-btn--success"
194
- onClick={(e) => { e.stopPropagation(); onResolve(); }}
195
- aria-label="Resolve"
196
- >
197
- <CheckCircle size={16} />
198
- </button>
199
- </Tooltip>
200
- <Tooltip title="Delete">
201
- <button
202
- className="icon-btn icon-btn--danger"
203
- onClick={(e) => { e.stopPropagation(); onDelete(); }}
204
- aria-label="Delete"
205
- >
206
- <Trash2 size={16} />
207
- </button>
208
- </Tooltip>
209
- </div>
210
- </div>
211
- );
212
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/components/EmbedStudio.tsx ADDED
@@ -0,0 +1,346 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect, useRef, useMemo, useCallback, type KeyboardEvent } from "react";
2
+ import ReactMarkdown from "react-markdown";
3
+ import remarkGfm from "remark-gfm";
4
+ import { X, Send, Square, Code, Eye, Plus, Loader } from "lucide-react";
5
+ import type { UIMessage } from "ai";
6
+ import type { EmbedStore } from "../editor/embeds/embed-store";
7
+ import { buildDoc } from "../editor/embeds/build-doc";
8
+ import { useEmbedChat } from "../hooks/useEmbedChat";
9
+ import { useTheme } from "../hooks/useTheme";
10
+ import { isToolPart, normalizeToolPart } from "../utils/ai-tool-parts";
11
+
12
+ interface EmbedStudioProps {
13
+ src: string;
14
+ embedStore: EmbedStore | null;
15
+ modelRef: React.RefObject<string>;
16
+ userId: string;
17
+ onClose: () => void;
18
+ }
19
+
20
+ const EMBED_TOOL_LABELS: Record<string, [string, string]> = {
21
+ createEmbed: ["Creating chart...", "Created chart"],
22
+ patchEmbed: ["Updating chart...", "Updated chart"],
23
+ readEmbed: ["Reading chart...", "Read chart"],
24
+ };
25
+
26
+ function embedToolLabel(name: string, state: string): string {
27
+ const pair = EMBED_TOOL_LABELS[name];
28
+ if (!pair) return name;
29
+ return state === "result" ? pair[1] : pair[0];
30
+ }
31
+
32
+ function MessageBubble({ message }: { message: UIMessage }) {
33
+ const isUser = message.role === "user";
34
+ return (
35
+ <div className={`es-message ${isUser ? "es-message--user" : "es-message--ai"}`}>
36
+ {message.parts.map((part, i) => {
37
+ if (part.type === "text") {
38
+ return (
39
+ <div key={i} className="es-message__text">
40
+ {isUser ? part.text : (
41
+ <ReactMarkdown remarkPlugins={[remarkGfm]}>{part.text}</ReactMarkdown>
42
+ )}
43
+ </div>
44
+ );
45
+ }
46
+ if (isToolPart(part)) {
47
+ const tool = normalizeToolPart(part);
48
+ if (!tool) return null;
49
+ return (
50
+ <div key={i} className="es-message__tool">
51
+ <span className="es-message__tool-name">
52
+ {embedToolLabel(tool.toolName, tool.state)}
53
+ </span>
54
+ </div>
55
+ );
56
+ }
57
+ return null;
58
+ })}
59
+ </div>
60
+ );
61
+ }
62
+
63
+ function EmbedAgentStatus({ messages }: { messages: UIMessage[] }) {
64
+ const last = messages[messages.length - 1];
65
+ if (!last) return null;
66
+
67
+ if (last.role === "assistant") {
68
+ const parts = last.parts ?? [];
69
+ const runningTools = parts
70
+ .map((p) => normalizeToolPart(p))
71
+ .filter((t): t is NonNullable<typeof t> => t !== null && t.state !== "result")
72
+ .map((t) => t.toolName);
73
+
74
+ if (runningTools.length > 0) {
75
+ const pair = EMBED_TOOL_LABELS[runningTools[runningTools.length - 1]];
76
+ const text = pair ? pair[0] : runningTools[runningTools.length - 1];
77
+ return (
78
+ <div className="chat-panel__thinking">
79
+ <Loader size={13} className="spin" />
80
+ <span className="shimmer-text">{text}</span>
81
+ </div>
82
+ );
83
+ }
84
+
85
+ const hasText = parts.some((p: any) => p.type === "text" && p.text?.trim());
86
+ if (hasText) return null;
87
+ }
88
+
89
+ return (
90
+ <div className="chat-panel__thinking">
91
+ <Loader size={13} className="spin" />
92
+ <span className="shimmer-text">Thinking...</span>
93
+ </div>
94
+ );
95
+ }
96
+
97
+ export function EmbedStudio({ src, embedStore, modelRef, userId, onClose }: EmbedStudioProps) {
98
+ const { isDark, primaryColor } = useTheme();
99
+ const [html, setHtml] = useState("");
100
+ const [viewMode, setViewMode] = useState<"preview" | "code">("preview");
101
+ const messagesEndRef = useRef<HTMLDivElement>(null);
102
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
103
+
104
+ // Iframe double-buffer
105
+ const frameA = useRef<HTMLIFrameElement>(null);
106
+ const frameB = useRef<HTMLIFrameElement>(null);
107
+ const [activeFrame, setActiveFrame] = useState<"a" | "b">("a");
108
+ const activeRef = useRef<"a" | "b">("a");
109
+ activeRef.current = activeFrame;
110
+
111
+ const chat = useEmbedChat({ embedStore, src, modelRef, userId });
112
+
113
+ // Sync from embed store
114
+ useEffect(() => {
115
+ if (!embedStore || !src) return;
116
+ setHtml(embedStore.get(src));
117
+ return embedStore.observeKey(src, setHtml);
118
+ }, [embedStore, src]);
119
+
120
+ // Build srcdoc
121
+ const srcdoc = useMemo(() => {
122
+ if (!html) return "";
123
+ return buildDoc(html, { isDark, primaryColor });
124
+ }, [html, isDark, primaryColor]);
125
+
126
+ // Load srcdoc into inactive frame
127
+ useEffect(() => {
128
+ if (!srcdoc) return;
129
+ const next = activeRef.current === "a" ? "b" : "a";
130
+ const frame = next === "a" ? frameA.current : frameB.current;
131
+ if (frame) frame.srcdoc = srcdoc;
132
+ }, [srcdoc]);
133
+
134
+ const handleFrameLoad = useCallback((slot: "a" | "b") => {
135
+ if (slot !== activeRef.current) {
136
+ setActiveFrame(slot);
137
+ }
138
+ }, []);
139
+
140
+ // Auto-scroll messages
141
+ useEffect(() => {
142
+ messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
143
+ }, [chat.messages]);
144
+
145
+ // Dev-only hook: let demo scripts inject a fake assistant reply into
146
+ // the embed chat so the recording shows the agent "responding"
147
+ // without waiting for a real LLM round-trip.
148
+ useEffect(() => {
149
+ if (!import.meta.env.DEV) return;
150
+ const handler = (event: Event) => {
151
+ const detail = (event as CustomEvent).detail as
152
+ | { messages?: UIMessage[] }
153
+ | undefined;
154
+ if (Array.isArray(detail?.messages)) {
155
+ chat.setMessages(detail.messages);
156
+ }
157
+ };
158
+ window.addEventListener("__demo-embed-chat", handler);
159
+ return () => window.removeEventListener("__demo-embed-chat", handler);
160
+ }, [chat]);
161
+
162
+ const handleSend = useCallback(() => {
163
+ const text = chat.input.trim();
164
+ if (!text || chat.isLoading) return;
165
+ chat.sendMessage(text);
166
+ chat.setInput("");
167
+ }, [chat]);
168
+
169
+ const handleKeyDown = useCallback(
170
+ (e: KeyboardEvent<HTMLTextAreaElement>) => {
171
+ if (e.key === "Enter" && !e.shiftKey) {
172
+ e.preventDefault();
173
+ handleSend();
174
+ }
175
+ },
176
+ [handleSend],
177
+ );
178
+
179
+ const handleTextareaInput = useCallback(() => {
180
+ const ta = textareaRef.current;
181
+ if (ta) {
182
+ ta.style.height = "auto";
183
+ ta.style.height = Math.min(ta.scrollHeight, 160) + "px";
184
+ }
185
+ }, []);
186
+
187
+ const frameStyle = (slot: "a" | "b"): React.CSSProperties => ({
188
+ position: "absolute",
189
+ inset: 0,
190
+ border: "none",
191
+ width: "100%",
192
+ height: "100%",
193
+ transition: "opacity 120ms ease",
194
+ opacity: activeFrame === slot ? 1 : 0,
195
+ zIndex: activeFrame === slot ? 2 : 1,
196
+ });
197
+
198
+ return (
199
+ <div className="embed-studio">
200
+ {/* Header */}
201
+ <div className="es-header">
202
+ <div className="es-header__left">
203
+ <span className="es-header__icon">📊</span>
204
+ <span className="es-header__title">Embed Studio</span>
205
+ <code className="es-header__src">{src}</code>
206
+ </div>
207
+ <div className="es-header__actions">
208
+ <button
209
+ className="embed-btn"
210
+ onClick={() => chat.clearMessages()}
211
+ aria-label="New conversation"
212
+ title="New conversation"
213
+ >
214
+ <Plus size={16} />
215
+ </button>
216
+ <button
217
+ className="embed-btn"
218
+ onClick={onClose}
219
+ aria-label="Close Embed Studio"
220
+ >
221
+ <X size={16} />
222
+ </button>
223
+ </div>
224
+ </div>
225
+
226
+ {/* Body: split panel */}
227
+ <div className="es-body">
228
+ {/* Left: Chat */}
229
+ <div className="es-chat">
230
+ <div className="es-chat__messages">
231
+ {chat.messages.length === 0 && (
232
+ <div className="es-chat__empty">
233
+ <p>Describe the chart you want to create.</p>
234
+ <p className="es-chat__hint">
235
+ Example: "Create a bar chart showing model sizes with categorical colors"
236
+ </p>
237
+ </div>
238
+ )}
239
+ {chat.messages.map((msg) => (
240
+ <MessageBubble key={msg.id} message={msg} />
241
+ ))}
242
+ {chat.isLoading && chat.messages.length > 0 && (
243
+ <EmbedAgentStatus messages={chat.messages} />
244
+ )}
245
+ <div ref={messagesEndRef} />
246
+ </div>
247
+
248
+ {/* Input */}
249
+ <div className="es-chat__input-area">
250
+ {chat.error && (
251
+ <div className="es-chat__error">
252
+ {chat.error.message || "An error occurred"}
253
+ </div>
254
+ )}
255
+ <div className="es-chat__input-row">
256
+ <textarea
257
+ ref={textareaRef}
258
+ value={chat.input}
259
+ onChange={(e) => chat.setInput(e.target.value)}
260
+ onKeyDown={handleKeyDown}
261
+ onInput={handleTextareaInput}
262
+ placeholder="Describe your chart..."
263
+ rows={1}
264
+ className="es-chat__textarea"
265
+ />
266
+ {chat.isLoading ? (
267
+ <button
268
+ className="es-chat__send-btn"
269
+ onClick={chat.stop}
270
+ aria-label="Stop"
271
+ >
272
+ <Square size={16} />
273
+ </button>
274
+ ) : (
275
+ <button
276
+ className="es-chat__send-btn"
277
+ onClick={handleSend}
278
+ disabled={!chat.input.trim()}
279
+ aria-label="Send"
280
+ >
281
+ <Send size={16} />
282
+ </button>
283
+ )}
284
+ </div>
285
+ </div>
286
+ </div>
287
+
288
+ {/* Right: Preview */}
289
+ <div className="es-preview">
290
+ <div className="es-preview__toolbar">
291
+ <button
292
+ className={`es-preview__tab ${viewMode === "preview" ? "es-preview__tab--active" : ""}`}
293
+ onClick={() => setViewMode("preview")}
294
+ >
295
+ <Eye size={14} />
296
+ Preview
297
+ </button>
298
+ <button
299
+ className={`es-preview__tab ${viewMode === "code" ? "es-preview__tab--active" : ""}`}
300
+ onClick={() => setViewMode("code")}
301
+ >
302
+ <Code size={14} />
303
+ Code
304
+ </button>
305
+ <div style={{ flex: 1 }} />
306
+ <button className="embed-btn embed-btn-primary" onClick={onClose}>
307
+ Save & Close
308
+ </button>
309
+ </div>
310
+
311
+ {/* Both panels always mounted - toggle with hidden class to preserve iframe state */}
312
+ <div className={`es-preview__frame-container ${viewMode !== "preview" ? "es-hidden" : ""}`}>
313
+ {html ? (
314
+ <>
315
+ <iframe
316
+ ref={frameA}
317
+ title="Chart preview A"
318
+ sandbox="allow-scripts"
319
+ onLoad={() => handleFrameLoad("a")}
320
+ style={frameStyle("a")}
321
+ />
322
+ <iframe
323
+ ref={frameB}
324
+ title="Chart preview B"
325
+ sandbox="allow-scripts"
326
+ onLoad={() => handleFrameLoad("b")}
327
+ style={frameStyle("b")}
328
+ />
329
+ </>
330
+ ) : (
331
+ <div className="es-preview__empty">
332
+ <span style={{ fontSize: 32, opacity: 0.4 }}>📊</span>
333
+ <p>Chart preview will appear here</p>
334
+ </div>
335
+ )}
336
+ </div>
337
+ <div className={`es-preview__code ${viewMode !== "code" ? "es-hidden" : ""}`}>
338
+ <pre className="es-preview__pre">
339
+ <code>{html || "// No chart HTML yet"}</code>
340
+ </pre>
341
+ </div>
342
+ </div>
343
+ </div>
344
+ </div>
345
+ );
346
+ }
frontend/src/components/MobileTocSidebar.tsx ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Editor as TiptapEditor } from "@tiptap/core";
2
+ import { Sun, Moon, X } from "lucide-react";
3
+ import { TableOfContents } from "./TableOfContents";
4
+
5
+ interface Props {
6
+ open: boolean;
7
+ editor: TiptapEditor | null;
8
+ scrollContainer: HTMLElement | null;
9
+ autoCollapse: boolean;
10
+ theme: "light" | "dark";
11
+ onClose: () => void;
12
+ onToggleTheme: () => void;
13
+ }
14
+
15
+ /**
16
+ * Full-height drawer shown on narrow viewports when the user hits the
17
+ * hamburger button in the top bar. Hosts the table of contents and the
18
+ * theme toggle (there is no room for the desktop chrome at that width).
19
+ */
20
+ export function MobileTocSidebar({
21
+ open,
22
+ editor,
23
+ scrollContainer,
24
+ autoCollapse,
25
+ theme,
26
+ onClose,
27
+ onToggleTheme,
28
+ }: Props) {
29
+ return (
30
+ <>
31
+ <div
32
+ className={`toc-mobile-backdrop${open ? " open" : ""}`}
33
+ onClick={onClose}
34
+ />
35
+ <aside className={`toc-mobile-sidebar${open ? " open" : ""}`}>
36
+ <div className="toc-mobile-sidebar__header">
37
+ <span className="toc-mobile-sidebar__title">Contents</span>
38
+ <div className="toc-mobile-sidebar__actions">
39
+ <button
40
+ className="toc-mobile-sidebar__theme"
41
+ onClick={onToggleTheme}
42
+ aria-label="Toggle theme"
43
+ >
44
+ {theme === "dark" ? <Sun size={16} /> : <Moon size={16} />}
45
+ </button>
46
+ <button
47
+ className="toc-mobile-sidebar__close"
48
+ onClick={onClose}
49
+ aria-label="Close"
50
+ >
51
+ <X size={16} />
52
+ </button>
53
+ </div>
54
+ </div>
55
+ <div className="toc-mobile-sidebar__body">
56
+ <TableOfContents
57
+ editor={editor}
58
+ scrollContainer={scrollContainer}
59
+ autoCollapse={autoCollapse}
60
+ onNavigate={onClose}
61
+ />
62
+ </div>
63
+ </aside>
64
+ </>
65
+ );
66
+ }
frontend/src/components/PublishDialog.tsx ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { forwardRef } from "react";
2
+ import { Eye } from "lucide-react";
3
+
4
+ export type PublishState = "idle" | "loading" | "success" | "error";
5
+
6
+ /**
7
+ * Stage events emitted by the backend during publish. Kept in sync with
8
+ * `PublishStage` in `backend/src/publisher/index.ts` and the sub-stages
9
+ * emitted by `pdf-generator.ts`. We accept `string` so an unknown stage
10
+ * never breaks the UI.
11
+ */
12
+ export type PublishStage =
13
+ | "extract"
14
+ | "bibliography"
15
+ | "render"
16
+ | "load"
17
+ | "thumbnail"
18
+ | "pdf-setup"
19
+ | "pdf-toc"
20
+ | "rasterize"
21
+ | "pdf-render"
22
+ | "write"
23
+ | "upload"
24
+ | string;
25
+
26
+ export interface PublishStageEvent {
27
+ stage: PublishStage;
28
+ detail?: {
29
+ index?: number;
30
+ total?: number;
31
+ entries?: number;
32
+ };
33
+ }
34
+
35
+ interface Props {
36
+ state: PublishState;
37
+ error: string;
38
+ docName: string;
39
+ stageEvent: PublishStageEvent | null;
40
+ onClose: () => void;
41
+ onPublish: () => void;
42
+ }
43
+
44
+ const STAGE_LABELS: Record<string, string> = {
45
+ extract: "Extracting content",
46
+ bibliography: "Formatting references",
47
+ render: "Rendering HTML",
48
+ load: "Loading article",
49
+ thumbnail: "Capturing thumbnail",
50
+ "pdf-setup": "Preparing PDF layout",
51
+ "pdf-toc": "Building table of contents",
52
+ rasterize: "Rasterizing embeds",
53
+ "pdf-render": "Rendering PDF",
54
+ write: "Writing files",
55
+ upload: "Uploading to storage",
56
+ };
57
+
58
+ function formatStage(event: PublishStageEvent | null): string {
59
+ if (!event) return "Publishing...";
60
+ const base = STAGE_LABELS[event.stage] ?? "Publishing...";
61
+ if (event.stage === "rasterize" && event.detail?.index && event.detail?.total) {
62
+ return `${base} (${event.detail.index}/${event.detail.total})`;
63
+ }
64
+ return base;
65
+ }
66
+
67
+ /**
68
+ * Native `<dialog>` shown when the user clicks "Publish" in the top bar.
69
+ * Displays live stage progress during the long PDF pipeline (typically
70
+ * 15-25s) thanks to the Server-Sent Events stream from `/api/publish/stream`.
71
+ */
72
+ export const PublishDialog = forwardRef<HTMLDialogElement, Props>(function PublishDialog(
73
+ { state, error, docName, stageEvent, onClose, onPublish },
74
+ ref
75
+ ) {
76
+ const stageLabel = formatStage(stageEvent);
77
+
78
+ return (
79
+ <dialog ref={ref} className="ed-dialog" onClose={onClose}>
80
+ <h2 className="ed-dialog__title">
81
+ {state === "success" ? "Published!" : "Publish article"}
82
+ </h2>
83
+ <div className="ed-dialog__body">
84
+ {state === "idle" && (
85
+ <p>
86
+ This will generate a static HTML page, a print-optimized PDF,
87
+ and a thumbnail from the current document. The process
88
+ typically takes 15-25 seconds. Other collaborators can keep
89
+ editing while you publish.
90
+ </p>
91
+ )}
92
+ {state === "loading" && (
93
+ <div style={{ display: "flex", flexDirection: "column", gap: 8, padding: "8px 0" }}>
94
+ <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
95
+ <span className="spinner" />
96
+ <p style={{ margin: 0 }}>{stageLabel}</p>
97
+ </div>
98
+ <p style={{ margin: 0, fontSize: "0.85em", opacity: 0.7 }}>
99
+ Rasterizing embeds and rendering a print-ready PDF can take
100
+ a moment. Keep this tab open until it finishes.
101
+ </p>
102
+ </div>
103
+ )}
104
+ {state === "success" && (
105
+ <>
106
+ <p>
107
+ Article published successfully. Visitors will now see
108
+ the updated version.
109
+ </p>
110
+ <a
111
+ href={`/published/${docName}/index.html`}
112
+ target="_blank"
113
+ rel="noopener noreferrer"
114
+ className="btn btn--primary"
115
+ style={{ display: "inline-flex", alignItems: "center", gap: 6, textDecoration: "none", marginTop: 4 }}
116
+ >
117
+ <Eye size={16} />
118
+ View published article
119
+ </a>
120
+ </>
121
+ )}
122
+ {state === "error" && (
123
+ <p style={{ color: "var(--ed-error)" }}>
124
+ {error || "An error occurred while publishing."}
125
+ </p>
126
+ )}
127
+ </div>
128
+ <div className="ed-dialog__actions">
129
+ {state === "idle" && (
130
+ <>
131
+ <button className="btn" onClick={onClose}>Cancel</button>
132
+ <button className="btn btn--primary" onClick={onPublish}>Publish</button>
133
+ </>
134
+ )}
135
+ {(state === "success" || state === "error") && (
136
+ <button className="btn" onClick={onClose}>Close</button>
137
+ )}
138
+ </div>
139
+ </dialog>
140
+ );
141
+ });
frontend/src/components/SyncIndicator.tsx ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef, useState } from "react";
2
+ import type { Editor as TiptapEditor } from "@tiptap/core";
3
+ import type { HocuspocusProvider } from "@hocuspocus/provider";
4
+ import { Cloud, CloudOff, Loader2 } from "lucide-react";
5
+ import { Tooltip } from "./Tooltip";
6
+
7
+ type SyncStatus = "connected" | "syncing" | "saved" | "disconnected";
8
+
9
+ interface Props {
10
+ editorInstance: TiptapEditor | null;
11
+ providerRef: { current: HocuspocusProvider | null };
12
+ }
13
+
14
+ /**
15
+ * Tiny connection/saving indicator for the editor top bar. Listens to the
16
+ * Hocuspocus provider (`connect` / `disconnect` / `synced`) and the TipTap
17
+ * editor (`update`) to flip between "Saved", "Saving..." and "Offline".
18
+ */
19
+ export function SyncIndicator({ editorInstance, providerRef }: Props) {
20
+ const [status, setStatus] = useState<SyncStatus>("disconnected");
21
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
22
+
23
+ useEffect(() => {
24
+ const provider = providerRef.current;
25
+ if (!provider) return;
26
+ const onConnect = () => setStatus("saved");
27
+ const onDisconnect = () => setStatus("disconnected");
28
+ const onSynced = () => setStatus("saved");
29
+ provider.on("connect", onConnect);
30
+ provider.on("disconnect", onDisconnect);
31
+ provider.on("synced", onSynced);
32
+ const wsProvider = (provider as any).configuration?.websocketProvider;
33
+ setStatus(wsProvider?.status === "connected" ? "saved" : "disconnected");
34
+ return () => {
35
+ provider.off("connect", onConnect);
36
+ provider.off("disconnect", onDisconnect);
37
+ provider.off("synced", onSynced);
38
+ };
39
+ }, [providerRef]);
40
+
41
+ useEffect(() => {
42
+ const editor = editorInstance;
43
+ const provider = providerRef.current;
44
+ if (!editor || !provider) return;
45
+ const onUpdate = () => {
46
+ setStatus("syncing");
47
+ if (timerRef.current) clearTimeout(timerRef.current);
48
+ timerRef.current = setTimeout(() => {
49
+ const wsProvider = (provider as any).configuration?.websocketProvider;
50
+ setStatus(wsProvider?.status === "connected" ? "saved" : "disconnected");
51
+ }, 1500);
52
+ };
53
+ editor.on("update", onUpdate);
54
+ return () => { editor.off("update", onUpdate); };
55
+ }, [editorInstance, providerRef]);
56
+
57
+ const label =
58
+ status === "saved" ? "Saved" :
59
+ status === "syncing" ? "Saving..." :
60
+ status === "connected" ? "Connected" :
61
+ "Offline";
62
+
63
+ const tooltip =
64
+ status === "saved" ? "All changes saved" :
65
+ status === "syncing" ? "Saving..." :
66
+ status === "connected" ? "Connected" :
67
+ "Disconnected";
68
+
69
+ return (
70
+ <Tooltip title={tooltip}>
71
+ <span className={`sync-indicator sync-indicator--${status}`}>
72
+ {status === "syncing" && <Loader2 size={14} className="spin" />}
73
+ {status === "saved" && <Cloud size={14} />}
74
+ {status === "connected" && <Cloud size={14} />}
75
+ {status === "disconnected" && <CloudOff size={14} />}
76
+ <span className="sync-indicator__label">{label}</span>
77
+ </span>
78
+ </Tooltip>
79
+ );
80
+ }
frontend/src/components/TableOfContents.tsx CHANGED
@@ -1,7 +1,7 @@
1
  import { useState, useEffect, useCallback, useMemo, useRef } from "react";
2
  import type { Editor } from "@tiptap/core";
3
 
4
- const SCROLL_OFFSET_PX = 80;
5
 
6
  interface TocItem {
7
  id: string;
@@ -51,64 +51,120 @@ function buildTree(headings: TocItem[]): TocNode[] {
51
  return root;
52
  }
53
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  interface TableOfContentsProps {
55
  editor: Editor | null;
 
 
 
56
  }
57
 
58
- export function TableOfContents({ editor }: TableOfContentsProps) {
59
  const [headings, setHeadings] = useState<TocItem[]>([]);
60
  const [activePos, setActivePos] = useState<number | null>(null);
61
- const rafRef = useRef(0);
62
 
63
  const debounceRef = useRef(0);
64
 
65
  useEffect(() => {
66
  if (!editor) return;
67
  setHeadings(extractHeadings(editor));
 
 
 
68
  const debouncedUpdate = () => {
69
  clearTimeout(debounceRef.current);
70
  debounceRef.current = window.setTimeout(
71
  () => setHeadings(extractHeadings(editor)),
72
- 300,
73
  );
74
  };
75
  editor.on("update", debouncedUpdate);
 
 
 
76
  return () => {
77
  editor.off("update", debouncedUpdate);
 
78
  clearTimeout(debounceRef.current);
79
  };
80
  }, [editor]);
81
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  useEffect(() => {
83
  if (!editor || headings.length === 0) return;
84
 
85
- const detect = () => {
86
- const domHeadings = Array.from(
87
- editor.view.dom.querySelectorAll("h1, h2, h3, h4, h5, h6"),
88
- ).filter((el) => (el as HTMLElement).textContent?.trim());
89
 
90
- let activeIdx = -1;
91
- for (let i = domHeadings.length - 1; i >= 0; i--) {
92
- if (domHeadings[i].getBoundingClientRect().top <= SCROLL_OFFSET_PX) {
93
- activeIdx = i;
94
- break;
 
 
 
 
 
95
  }
96
- }
97
- setActivePos(activeIdx >= 0 && activeIdx < headings.length ? headings[activeIdx].pos : null);
98
- };
 
 
 
 
 
 
 
 
 
 
 
99
 
100
- const onScroll = () => {
101
- cancelAnimationFrame(rafRef.current);
102
- rafRef.current = requestAnimationFrame(detect);
103
- };
 
 
 
 
104
 
105
- detect();
106
- document.addEventListener("scroll", onScroll, { capture: true, passive: true });
107
- return () => {
108
- document.removeEventListener("scroll", onScroll, { capture: true });
109
- cancelAnimationFrame(rafRef.current);
110
- };
111
- }, [editor, headings]);
112
 
113
  const tree = useMemo(() => buildTree(headings), [headings]);
114
 
@@ -135,11 +191,13 @@ export function TableOfContents({ editor }: TableOfContentsProps) {
135
 
136
  const scrollTo = useCallback((pos: number) => {
137
  if (!editor) return;
 
 
 
 
138
  editor.chain().focus().setTextSelection(pos).run();
139
- const dom = editor.view.domAtPos(pos);
140
- const el = dom.node instanceof HTMLElement ? dom.node : dom.node.parentElement;
141
- el?.scrollIntoView({ behavior: "smooth", block: "start" });
142
- }, [editor]);
143
 
144
  if (!editor || headings.length === 0) {
145
  return (
@@ -170,9 +228,12 @@ export function TableOfContents({ editor }: TableOfContentsProps) {
170
  {hasChildren && (
171
  <div
172
  className="toc-children"
173
- style={{
174
- maxHeight: isExpanded ? 500 : 0,
175
  opacity: isExpanded ? 1 : 0,
 
 
 
176
  }}
177
  >
178
  {renderList(node.children, false)}
 
1
  import { useState, useEffect, useCallback, useMemo, useRef } from "react";
2
  import type { Editor } from "@tiptap/core";
3
 
4
+ const SCROLL_OFFSET_PX = 100;
5
 
6
  interface TocItem {
7
  id: string;
 
51
  return root;
52
  }
53
 
54
+ /**
55
+ * Resolve ProseMirror position to the closest heading DOM element.
56
+ */
57
+ function resolveHeadingElement(editor: Editor, pos: number): HTMLElement | null {
58
+ try {
59
+ const dom = editor.view.domAtPos(pos + 1);
60
+ let el: HTMLElement | null =
61
+ dom.node instanceof HTMLElement ? dom.node : dom.node.parentElement;
62
+ while (el && !/^H[1-6]$/i.test(el.tagName)) {
63
+ el = el.parentElement;
64
+ }
65
+ return el;
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
  interface TableOfContentsProps {
72
  editor: Editor | null;
73
+ scrollContainer?: HTMLElement | null;
74
+ autoCollapse?: boolean;
75
+ onNavigate?: () => void;
76
  }
77
 
78
+ export function TableOfContents({ editor, scrollContainer, autoCollapse = false, onNavigate }: TableOfContentsProps) {
79
  const [headings, setHeadings] = useState<TocItem[]>([]);
80
  const [activePos, setActivePos] = useState<number | null>(null);
 
81
 
82
  const debounceRef = useRef(0);
83
 
84
  useEffect(() => {
85
  if (!editor) return;
86
  setHeadings(extractHeadings(editor));
87
+ // 60ms debounce: short enough that headings typed by any collaborator
88
+ // (local or remote via Yjs) appear in the TOC with no perceptible lag
89
+ // while still coalescing bursts of per-keystroke updates.
90
  const debouncedUpdate = () => {
91
  clearTimeout(debounceRef.current);
92
  debounceRef.current = window.setTimeout(
93
  () => setHeadings(extractHeadings(editor)),
94
+ 60,
95
  );
96
  };
97
  editor.on("update", debouncedUpdate);
98
+ // Yjs remote transactions also fire "transaction" - listen to both so
99
+ // the TOC keeps pace with collaborators editing in other browsers.
100
+ editor.on("transaction", debouncedUpdate);
101
  return () => {
102
  editor.off("update", debouncedUpdate);
103
+ editor.off("transaction", debouncedUpdate);
104
  clearTimeout(debounceRef.current);
105
  };
106
  }, [editor]);
107
 
108
+ // Scroll-spy via IntersectionObserver - no per-frame scroll work.
109
+ //
110
+ // Semantics: active heading = the *topmost visible heading inside the scroll
111
+ // container's reading zone* (below the SCROLL_OFFSET_PX top margin).
112
+ // This is intentionally different from the publisher-side TOC which uses a
113
+ // "last heading scrolled past the offset line" rule (see
114
+ // `backend/src/publisher/html-renderer.ts`). Rationale:
115
+ // - Editor: users navigate non-linearly (click TOC, jump, split view). The
116
+ // topmost-visible rule reacts instantly when a heading enters the
117
+ // reading zone, so the sidebar always reflects what the user is looking
118
+ // at.
119
+ // - Publisher: readers scroll linearly through the article. The
120
+ // last-scrolled-past rule matches classic docs UX (MDN, Docusaurus) and
121
+ // keeps the active entry stable while reading body text between
122
+ // headings.
123
+ // Both use IntersectionObserver as the primitive; only the selection logic
124
+ // differs. Do not "unify" the two without considering the UX implications.
125
  useEffect(() => {
126
  if (!editor || headings.length === 0) return;
127
 
128
+ const root = scrollContainer ?? null;
129
+ const visibleSet = new Map<Element, number>();
 
 
130
 
131
+ const observer = new IntersectionObserver(
132
+ (entries) => {
133
+ for (const entry of entries) {
134
+ if (entry.isIntersecting) {
135
+ visibleSet.set(entry.target, headings.findIndex(
136
+ (h) => resolveHeadingElement(editor, h.pos) === entry.target,
137
+ ));
138
+ } else {
139
+ visibleSet.delete(entry.target);
140
+ }
141
  }
142
+ let bestIdx = -1;
143
+ for (const idx of visibleSet.values()) {
144
+ if (idx >= 0 && (bestIdx < 0 || idx < bestIdx)) bestIdx = idx;
145
+ }
146
+ if (visibleSet.size > 0) {
147
+ setActivePos(bestIdx >= 0 ? headings[bestIdx].pos : null);
148
+ }
149
+ },
150
+ {
151
+ root,
152
+ rootMargin: `-${SCROLL_OFFSET_PX}px 0px 0px 0px`,
153
+ threshold: 0,
154
+ },
155
+ );
156
 
157
+ const elements: HTMLElement[] = [];
158
+ for (const h of headings) {
159
+ const el = resolveHeadingElement(editor, h.pos);
160
+ if (el) {
161
+ observer.observe(el);
162
+ elements.push(el);
163
+ }
164
+ }
165
 
166
+ return () => observer.disconnect();
167
+ }, [editor, headings, scrollContainer]);
 
 
 
 
 
168
 
169
  const tree = useMemo(() => buildTree(headings), [headings]);
170
 
 
191
 
192
  const scrollTo = useCallback((pos: number) => {
193
  if (!editor) return;
194
+ const el = resolveHeadingElement(editor, pos);
195
+ if (el) {
196
+ el.scrollIntoView({ behavior: "smooth", block: "start" });
197
+ }
198
  editor.chain().focus().setTextSelection(pos).run();
199
+ onNavigate?.();
200
+ }, [editor, onNavigate]);
 
 
201
 
202
  if (!editor || headings.length === 0) {
203
  return (
 
228
  {hasChildren && (
229
  <div
230
  className="toc-children"
231
+ style={autoCollapse ? {
232
+ gridTemplateRows: isExpanded ? "1fr" : "0fr",
233
  opacity: isExpanded ? 1 : 0,
234
+ } : {
235
+ gridTemplateRows: "1fr",
236
+ opacity: 1,
237
  }}
238
  >
239
  {renderList(node.children, false)}
frontend/src/components/TopBar.tsx ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Editor as TiptapEditor } from "@tiptap/core";
2
+ import type { HocuspocusProvider } from "@hocuspocus/provider";
3
+ import { Undo2, Redo2, Settings, Upload, Eye, Sun, Moon, Menu, LogIn } from "lucide-react";
4
+ import { Tooltip } from "./Tooltip";
5
+ import { SyncIndicator } from "./SyncIndicator";
6
+ import type { CollabUser } from "../utils/user";
7
+
8
+ interface TopBarProps {
9
+ editorInstance: TiptapEditor | null;
10
+ providerRef: { current: HocuspocusProvider | null };
11
+ docName: string;
12
+ theme: "light" | "dark";
13
+ user: CollabUser;
14
+ loginUrl: string | null;
15
+ isAuthenticated: boolean;
16
+ /** True while any collaborator has a publish in progress. */
17
+ isPublishing?: boolean;
18
+ /** Name of the user who initiated the running publish, if known. */
19
+ publishingUserName?: string | null;
20
+ onToggleTheme: () => void;
21
+ onOpenSettings: () => void;
22
+ onOpenPublish: () => void;
23
+ onOpenMobileToc: () => void;
24
+ }
25
+
26
+ /**
27
+ * Top toolbar of the editor: TOC drawer button (mobile only via CSS),
28
+ * undo/redo, article settings, preview, theme toggle, publish action, sync
29
+ * indicator, and the current user chip / sign-in link.
30
+ */
31
+ export function TopBar({
32
+ editorInstance,
33
+ providerRef,
34
+ docName,
35
+ theme,
36
+ user,
37
+ loginUrl,
38
+ isAuthenticated,
39
+ isPublishing = false,
40
+ publishingUserName = null,
41
+ onToggleTheme,
42
+ onOpenSettings,
43
+ onOpenPublish,
44
+ onOpenMobileToc,
45
+ }: TopBarProps) {
46
+ const publishTooltip = isPublishing
47
+ ? publishingUserName
48
+ ? `${publishingUserName} is publishing...`
49
+ : "Publish in progress..."
50
+ : "Publish article";
51
+ return (
52
+ <div className="top-bar">
53
+ <button
54
+ className="icon-btn top-bar__toc-toggle"
55
+ onClick={onOpenMobileToc}
56
+ aria-label="Table of contents"
57
+ >
58
+ <Menu size={18} />
59
+ </button>
60
+ <Tooltip title="Undo">
61
+ <button
62
+ className="icon-btn"
63
+ onClick={() => editorInstance?.commands.undo()}
64
+ aria-label="Undo"
65
+ >
66
+ <Undo2 size={18} />
67
+ </button>
68
+ </Tooltip>
69
+ <Tooltip title="Redo">
70
+ <button
71
+ className="icon-btn"
72
+ onClick={() => editorInstance?.commands.redo()}
73
+ aria-label="Redo"
74
+ >
75
+ <Redo2 size={18} />
76
+ </button>
77
+ </Tooltip>
78
+ <span className="divider-v" />
79
+ <Tooltip title="Article settings">
80
+ <button
81
+ className="icon-btn"
82
+ onClick={onOpenSettings}
83
+ aria-label="Article settings"
84
+ >
85
+ <Settings size={18} />
86
+ </button>
87
+ </Tooltip>
88
+ <Tooltip title="Preview article">
89
+ <button
90
+ className="icon-btn"
91
+ onClick={() => window.open(`/api/preview/${docName}`, "_blank")}
92
+ aria-label="Preview article"
93
+ >
94
+ <Eye size={18} />
95
+ </button>
96
+ </Tooltip>
97
+ <Tooltip title={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}>
98
+ <button
99
+ className="icon-btn"
100
+ onClick={onToggleTheme}
101
+ aria-label="Toggle theme"
102
+ >
103
+ {theme === "dark" ? <Sun size={18} /> : <Moon size={18} />}
104
+ </button>
105
+ </Tooltip>
106
+ <span className="divider-v" />
107
+ <Tooltip title={publishTooltip}>
108
+ <button
109
+ className="icon-btn icon-btn--primary"
110
+ onClick={onOpenPublish}
111
+ aria-label="Publish article"
112
+ disabled={isPublishing}
113
+ aria-busy={isPublishing || undefined}
114
+ >
115
+ <Upload size={18} />
116
+ </button>
117
+ </Tooltip>
118
+ <SyncIndicator editorInstance={editorInstance} providerRef={providerRef} />
119
+ {loginUrl && !isAuthenticated ? (
120
+ <a
121
+ href={loginUrl}
122
+ className="chip chip--login"
123
+ style={{ marginLeft: 4, textDecoration: "none" }}
124
+ >
125
+ <LogIn size={14} />
126
+ Sign in with HF
127
+ </a>
128
+ ) : (
129
+ <span
130
+ className="chip"
131
+ style={{ backgroundColor: user.color, color: "#000", marginLeft: 4 }}
132
+ >
133
+ {user.avatarUrl && <img src={user.avatarUrl} alt="" />}
134
+ {user.name}
135
+ </span>
136
+ )}
137
+ </div>
138
+ );
139
+ }
frontend/src/editor/BlockHandle.tsx CHANGED
@@ -60,35 +60,33 @@ export function BlockHandle({ editor, containerEl }: BlockHandleProps) {
60
  [containerEl, editor, show],
61
  );
62
 
63
- // Find the nearest block for a given mouse Y position
64
- const findBlockAtY = useCallback(
65
- (clientY: number): HTMLElement | null => {
66
  if (!containerEl) return null;
67
- const blocks = containerEl.querySelectorAll<HTMLElement>(BLOCK_SELECTOR);
68
- let closest: HTMLElement | null = null;
69
- let closestDist = Infinity;
70
- for (const block of blocks) {
71
- const rect = block.getBoundingClientRect();
72
- if (clientY >= rect.top && clientY <= rect.bottom) return block;
73
- const dist = Math.min(
74
- Math.abs(clientY - rect.top),
75
- Math.abs(clientY - rect.bottom),
76
- );
77
- if (dist < closestDist) {
78
- closestDist = dist;
79
- closest = block;
80
- }
81
- }
82
- return closestDist < 40 ? closest : null;
83
  },
84
  [containerEl],
85
  );
86
 
87
- // Listen on the whole container (not just .tiptap) for wider hover zone
88
  useEffect(() => {
89
  if (!containerEl) return;
90
 
 
 
 
91
  const handleMouseMove = (e: MouseEvent) => {
 
 
 
92
  const target = e.target as HTMLElement;
93
 
94
  // Direct block hover (inside editor content)
@@ -98,13 +96,13 @@ export function BlockHandle({ editor, containerEl }: BlockHandleProps) {
98
  return;
99
  }
100
 
101
- // Hovering in the left margin area - find nearest block by Y
102
  if (
103
  handleRef.current?.contains(target) ||
104
  target === containerEl ||
105
  target.classList.contains("tiptap")
106
  ) {
107
- const block = findBlockAtY(e.clientY);
108
  if (block) {
109
  positionHandle(block);
110
  }
@@ -122,8 +120,9 @@ export function BlockHandle({ editor, containerEl }: BlockHandleProps) {
122
  containerEl.removeEventListener("mousemove", handleMouseMove);
123
  containerEl.removeEventListener("mouseleave", handleMouseLeave);
124
  clearTimeout(hideTimer.current);
 
125
  };
126
- }, [containerEl, positionHandle, findBlockAtY, scheduleHide]);
127
 
128
  const handleAdd = () => {
129
  if (!activeBlockRef.current || !editor) return;
@@ -269,7 +268,7 @@ export function BlockHandle({ editor, containerEl }: BlockHandleProps) {
269
  onClick={handleAdd}
270
  title="Add block below"
271
  >
272
- <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
273
  <path d="M7 1v12M1 7h12" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
274
  </svg>
275
  </button>
@@ -280,7 +279,7 @@ export function BlockHandle({ editor, containerEl }: BlockHandleProps) {
280
  onDragEnd={handleDragEnd}
281
  title="Drag to reorder"
282
  >
283
- <svg width="10" height="14" viewBox="0 0 10 14" fill="none">
284
  <circle cx="3" cy="2" r="1.2" fill="currentColor" />
285
  <circle cx="7" cy="2" r="1.2" fill="currentColor" />
286
  <circle cx="3" cy="7" r="1.2" fill="currentColor" />
 
60
  [containerEl, editor, show],
61
  );
62
 
63
+ // Find the nearest block using elementFromPoint (O(1) instead of O(n))
64
+ const findBlockAtPoint = useCallback(
65
+ (clientX: number, clientY: number): HTMLElement | null => {
66
  if (!containerEl) return null;
67
+ // Sample a few X positions to handle margin hover
68
+ const tiptapEl = containerEl.querySelector<HTMLElement>(".tiptap");
69
+ const centerX = tiptapEl
70
+ ? tiptapEl.getBoundingClientRect().left + 20
71
+ : clientX;
72
+ const el = document.elementFromPoint(centerX, clientY) as HTMLElement | null;
73
+ if (!el) return null;
74
+ return el.closest<HTMLElement>(BLOCK_SELECTOR);
 
 
 
 
 
 
 
 
75
  },
76
  [containerEl],
77
  );
78
 
79
+ // Listen on the whole container with throttle (~50ms)
80
  useEffect(() => {
81
  if (!containerEl) return;
82
 
83
+ let throttleId = 0;
84
+ const THROTTLE_MS = 50;
85
+
86
  const handleMouseMove = (e: MouseEvent) => {
87
+ if (throttleId) return;
88
+ throttleId = window.setTimeout(() => { throttleId = 0; }, THROTTLE_MS);
89
+
90
  const target = e.target as HTMLElement;
91
 
92
  // Direct block hover (inside editor content)
 
96
  return;
97
  }
98
 
99
+ // Hovering in the left margin area - find nearest block by point
100
  if (
101
  handleRef.current?.contains(target) ||
102
  target === containerEl ||
103
  target.classList.contains("tiptap")
104
  ) {
105
+ const block = findBlockAtPoint(e.clientX, e.clientY);
106
  if (block) {
107
  positionHandle(block);
108
  }
 
120
  containerEl.removeEventListener("mousemove", handleMouseMove);
121
  containerEl.removeEventListener("mouseleave", handleMouseLeave);
122
  clearTimeout(hideTimer.current);
123
+ clearTimeout(throttleId);
124
  };
125
+ }, [containerEl, positionHandle, findBlockAtPoint, scheduleHide]);
126
 
127
  const handleAdd = () => {
128
  if (!activeBlockRef.current || !editor) return;
 
268
  onClick={handleAdd}
269
  title="Add block below"
270
  >
271
+ <svg width="18" height="18" viewBox="0 0 14 14" fill="none">
272
  <path d="M7 1v12M1 7h12" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
273
  </svg>
274
  </button>
 
279
  onDragEnd={handleDragEnd}
280
  title="Drag to reorder"
281
  >
282
+ <svg width="14" height="18" viewBox="0 0 10 14" fill="none">
283
  <circle cx="3" cy="2" r="1.2" fill="currentColor" />
284
  <circle cx="7" cy="2" r="1.2" fill="currentColor" />
285
  <circle cx="3" cy="7" r="1.2" fill="currentColor" />
frontend/src/editor/CitationView.tsx CHANGED
@@ -1,88 +1,17 @@
1
  import { NodeViewWrapper } from "@tiptap/react";
2
- import { useEffect, useState, useRef, useCallback } from "react";
3
  import type { NodeViewProps } from "@tiptap/react";
4
 
5
- type CitationStyle = "apa" | "ieee" | "vancouver" | "chicago-author-date" | "harvard1";
6
-
7
- const NUMERIC_STYLES = new Set<CitationStyle>(["ieee", "vancouver"]);
8
-
9
  export function CitationView({ node, editor, getPos }: NodeViewProps) {
10
  const key = node.attrs.key as string;
11
- const [label, setLabel] = useState(`[${key}]`);
12
- const [style, setStyle] = useState<CitationStyle>("apa");
13
  const [showTooltip, setShowTooltip] = useState(false);
14
- const [entry, setEntry] = useState<any>(null);
15
  const tooltipTimer = useRef<ReturnType<typeof setTimeout>>();
16
  const wrapperRef = useRef<HTMLSpanElement>(null);
17
 
18
- // Observe citation data + settings
19
- useEffect(() => {
20
- const citationsMap = (editor.storage.citation as any)?.citationsMap;
21
- const settingsMap = (editor.storage.citation as any)?.settingsMap;
22
-
23
- const updateEntry = () => {
24
- if (!citationsMap) return;
25
- setEntry(citationsMap.get(key) || null);
26
- };
27
-
28
- const updateStyle = () => {
29
- if (!settingsMap) return;
30
- const val = settingsMap.get("citationStyle");
31
- if (val) setStyle(val as CitationStyle);
32
- };
33
-
34
- updateEntry();
35
- updateStyle();
36
-
37
- if (citationsMap) citationsMap.observe(updateEntry);
38
- if (settingsMap) settingsMap.observe(updateStyle);
39
-
40
- return () => {
41
- if (citationsMap) citationsMap.unobserve(updateEntry);
42
- if (settingsMap) settingsMap.unobserve(updateStyle);
43
- };
44
- }, [key, editor]);
45
-
46
- // Recompute label when entry or style changes
47
- useEffect(() => {
48
- let newLabel: string;
49
- if (!entry) {
50
- newLabel = `[${key}]`;
51
- } else if (NUMERIC_STYLES.has(style)) {
52
- const order = getDocumentCitationOrder(editor);
53
- const idx = order.indexOf(key);
54
- const num = idx >= 0 ? idx + 1 : "?";
55
- newLabel = style === "ieee" ? `[${num}]` : `(${num})`;
56
- } else {
57
- newLabel = formatAuthorDate(entry);
58
- }
59
- setLabel(newLabel);
60
-
61
- // Persist label into node attrs for server-side HTML generation
62
- const pos = getPos();
63
- if (typeof pos === "number" && node.attrs.label !== newLabel) {
64
- editor.commands.command(({ tr }) => {
65
- tr.setNodeMarkup(pos, undefined, { ...node.attrs, label: newLabel });
66
- return true;
67
- });
68
- }
69
- }, [entry, style, key, editor]);
70
-
71
- // Re-format when doc changes (for numeric order updates)
72
- useEffect(() => {
73
- if (!NUMERIC_STYLES.has(style)) return;
74
-
75
- const onUpdate = () => {
76
- if (!entry) return;
77
- const order = getDocumentCitationOrder(editor);
78
- const idx = order.indexOf(key);
79
- const num = idx >= 0 ? idx + 1 : "?";
80
- setLabel(style === "ieee" ? `[${num}]` : `(${num})`);
81
- };
82
-
83
- editor.on("update", onUpdate);
84
- return () => { editor.off("update", onUpdate); };
85
- }, [editor, style, key, entry]);
86
 
87
  const handleMouseEnter = useCallback(() => {
88
  tooltipTimer.current = setTimeout(() => setShowTooltip(true), 300);
@@ -101,6 +30,8 @@ export function CitationView({ node, editor, getPos }: NodeViewProps) {
101
  setShowTooltip(false);
102
  }, [editor, getPos]);
103
 
 
 
104
  return (
105
  <NodeViewWrapper
106
  as="span"
@@ -138,29 +69,3 @@ export function CitationView({ node, editor, getPos }: NodeViewProps) {
138
  </NodeViewWrapper>
139
  );
140
  }
141
-
142
- function getDocumentCitationOrder(editor: any): string[] {
143
- const seen: string[] = [];
144
- editor.state.doc.descendants((node: any) => {
145
- if (node.type.name === "citation" && node.attrs.key) {
146
- const k = node.attrs.key as string;
147
- if (!seen.includes(k)) seen.push(k);
148
- }
149
- });
150
- return seen;
151
- }
152
-
153
- function formatAuthorDate(entry: any): string {
154
- const authors = entry.author;
155
- if (!authors?.length) return `(${entry.id || "?"})`;
156
-
157
- const year = entry.issued?.["date-parts"]?.[0]?.[0] || "n.d.";
158
- const first = authors[0].family || authors[0].literal || "?";
159
-
160
- if (authors.length === 1) return `(${first}, ${year})`;
161
- if (authors.length === 2) {
162
- const second = authors[1].family || authors[1].literal || "?";
163
- return `(${first} & ${second}, ${year})`;
164
- }
165
- return `(${first} et al., ${year})`;
166
- }
 
1
  import { NodeViewWrapper } from "@tiptap/react";
2
+ import { useState, useRef, useCallback } from "react";
3
  import type { NodeViewProps } from "@tiptap/react";
4
 
 
 
 
 
5
  export function CitationView({ node, editor, getPos }: NodeViewProps) {
6
  const key = node.attrs.key as string;
7
+ const label = (node.attrs.label as string) || `[${key}]`;
 
8
  const [showTooltip, setShowTooltip] = useState(false);
 
9
  const tooltipTimer = useRef<ReturnType<typeof setTimeout>>();
10
  const wrapperRef = useRef<HTMLSpanElement>(null);
11
 
12
+ const getEntry = useCallback(() => {
13
+ return (editor.storage.citation as any)?.citationsMap?.get(key) ?? null;
14
+ }, [editor, key]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
  const handleMouseEnter = useCallback(() => {
17
  tooltipTimer.current = setTimeout(() => setShowTooltip(true), 300);
 
30
  setShowTooltip(false);
31
  }, [editor, getPos]);
32
 
33
+ const entry = showTooltip ? getEntry() : null;
34
+
35
  return (
36
  <NodeViewWrapper
37
  as="span"
 
69
  </NodeViewWrapper>
70
  );
71
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/editor/Editor.tsx CHANGED
@@ -4,11 +4,10 @@ import StarterKit from "@tiptap/starter-kit";
4
  import Collaboration from "@tiptap/extension-collaboration";
5
  import { CollaborationCursorV3 } from "./extensions/collaboration-cursor-v3";
6
  import Placeholder from "@tiptap/extension-placeholder";
7
- import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
8
  // Link is included in StarterKit v3, configured via StarterKit options
9
  import Mathematics from "@tiptap/extension-mathematics";
10
  import "katex/dist/katex.min.css";
11
- import { all, createLowlight } from "lowlight";
12
  import * as Y from "yjs";
13
  import { UndoManager } from "yjs";
14
  import { HocuspocusProvider } from "@hocuspocus/provider";
@@ -24,20 +23,19 @@ import { Comment } from "./extensions/comment";
24
  import { CollaborationUndo } from "./extensions/collaboration-undo";
25
  import { SlashCommands } from "./extensions/slash-commands";
26
  import { ImageUpload } from "./extensions/image-upload";
27
- import { Citation } from "./extensions/citation";
28
  import { Bibliography } from "./extensions/bibliography";
29
  import { Glossary } from "./extensions/glossary";
30
  import { Footnote } from "./extensions/footnote";
31
  import { Stack, StackColumn } from "./extensions/stack";
 
32
  import { CitationPanel } from "./CitationPanel";
33
  import { createCommentStore, CommentStore } from "./comments";
34
  import { createFrontmatterStore, FrontmatterStore } from "./frontmatter/frontmatter-store";
35
- import { seedFrontmatter } from "./frontmatter/seed-frontmatter";
36
- import { COMPONENTS, createWrapperExtension, createAtomicExtension } from "./components";
37
  import { uploadImage } from "./upload";
38
- import { DEFAULT_CONTENT, SEED_CITATIONS } from "./default-content";
39
-
40
- const lowlight = createLowlight(all);
41
 
42
  interface EditorProps {
43
  docName: string;
@@ -45,9 +43,11 @@ interface EditorProps {
45
  editorRef: MutableRefObject<TiptapEditor | null>;
46
  onCommentStoreReady: (store: CommentStore) => void;
47
  onFrontmatterStoreReady: (store: FrontmatterStore) => void;
 
48
  onSettingsMapReady?: (map: Y.Map<any>) => void;
49
  onEditorReady: (editor: TiptapEditor | null) => void;
50
  onUndoManagerReady?: (manager: UndoManager) => void;
 
51
  onAddComment?: () => void;
52
  }
53
 
@@ -57,16 +57,22 @@ export function Editor({
57
  editorRef,
58
  onCommentStoreReady,
59
  onFrontmatterStoreReady,
 
 
60
  onSettingsMapReady,
61
  onEditorReady,
62
  onUndoManagerReady,
63
  onAddComment,
64
  }: EditorProps) {
65
- const ydoc = useMemo(() => new Y.Doc(), []);
 
 
 
66
  const undoManagerCallbackRef = useRef(onUndoManagerReady);
67
  undoManagerCallbackRef.current = onUndoManagerReady;
68
 
69
- const provider = useMemo(() => {
 
70
  const wsUrl =
71
  window.location.protocol === "https:"
72
  ? `wss://${window.location.host}/collab`
@@ -75,18 +81,20 @@ export function Editor({
75
  const tokenMatch = document.cookie.match(/(?:^|;\s*)hf_access_token=([^;]*)/);
76
  const token = tokenMatch ? decodeURIComponent(tokenMatch[1]) : undefined;
77
 
78
- return new HocuspocusProvider({
79
  url: wsUrl,
80
  name: docName,
81
  document: ydoc,
82
  token: token || "",
83
  });
84
- }, [docName, ydoc]);
 
85
 
86
  const citationsMap = useMemo(() => ydoc.getMap("citations"), [ydoc]);
87
  const settingsMap = useMemo(() => ydoc.getMap("settings"), [ydoc]);
88
  const commentStore = useMemo(() => createCommentStore(ydoc), [ydoc]);
89
  const frontmatterStore = useMemo(() => createFrontmatterStore(ydoc), [ydoc]);
 
90
  const [showCitationPanel, setShowCitationPanel] = useState(false);
91
 
92
  useEffect(() => {
@@ -97,16 +105,26 @@ export function Editor({
97
  onFrontmatterStoreReady(frontmatterStore);
98
  }, [frontmatterStore, onFrontmatterStoreReady]);
99
 
 
 
 
 
100
  useEffect(() => {
101
  onSettingsMapReady?.(settingsMap);
102
  }, [settingsMap, onSettingsMapReady]);
103
 
 
 
 
 
104
  useEffect(() => {
105
  return () => {
106
- provider.destroy();
107
- ydoc.destroy();
 
 
108
  };
109
- }, [provider, ydoc]);
110
 
111
  const editor = useEditor(
112
  {
@@ -160,9 +178,9 @@ export function Editor({
160
  HTMLAttributes: { class: "editor-link" },
161
  },
162
  } as any),
163
- CodeBlockLowlight.configure({ lowlight }),
164
  Placeholder.configure({
165
- placeholder: 'Type "/" for commands...',
166
  }),
167
  Collaboration.configure({
168
  document: ydoc,
@@ -190,54 +208,84 @@ export function Editor({
190
  Footnote,
191
  Stack,
192
  StackColumn,
193
- ...COMPONENTS.filter((d) => d.kind === "wrapper").map(createWrapperExtension),
194
- ...COMPONENTS.filter((d) => d.kind === "atomic").map(createAtomicExtension),
195
  CollaborationUndo.configure({
196
  onUndoManagerReady: (um) => undoManagerCallbackRef.current?.(um),
197
  }),
198
  Comment,
 
199
  ],
200
  },
201
  [ydoc, provider],
202
  );
203
 
204
- const seededRef = useRef(false);
205
-
206
  useEffect(() => {
207
- if (!editor || seededRef.current) return;
208
-
209
- const seed = () => {
210
- if (seededRef.current) return;
211
- seededRef.current = true;
 
 
212
 
213
- const meta = ydoc.getMap("meta");
214
- if (meta.get("seeded")) return;
 
 
 
215
 
216
- const fragment = ydoc.getXmlFragment("default");
217
- if (fragment.length > 0) return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
 
 
219
  ydoc.transact(() => {
220
- meta.set("seeded", true);
 
221
  });
222
- editor.commands.setContent(DEFAULT_CONTENT);
223
- seedFrontmatter(frontmatterStore);
224
- for (const [key, entry] of Object.entries(SEED_CITATIONS)) {
225
- if (!citationsMap.has(key)) citationsMap.set(key, entry);
226
- }
227
- if (!settingsMap.has("citationStyle")) {
228
- settingsMap.set("citationStyle", "apa");
229
- }
230
- };
231
 
232
- const onSync = () => {
233
- setTimeout(seed, 150);
 
 
234
  };
235
- provider.on("synced", onSync);
 
 
236
 
237
- return () => {
238
- provider.off("synced", onSync);
 
 
 
 
 
 
 
 
 
 
 
 
239
  };
240
- }, [editor, provider, ydoc, frontmatterStore]);
 
 
241
 
242
  useEffect(() => {
243
  if (!editor) return;
@@ -247,12 +295,33 @@ export function Editor({
247
  useEffect(() => {
248
  if (!editor) return;
249
  if (!editor.storage.citation) {
250
- editor.storage.citation = {} as any;
251
  }
252
- editor.storage.citation.citationsMap = citationsMap;
253
- editor.storage.citation.settingsMap = settingsMap;
 
 
 
 
 
 
 
 
 
 
 
 
 
254
  }, [editor, citationsMap, settingsMap]);
255
 
 
 
 
 
 
 
 
 
256
  useEffect(() => {
257
  const handler = () => setShowCitationPanel(true);
258
  window.addEventListener("open-citation-panel", handler);
 
4
  import Collaboration from "@tiptap/extension-collaboration";
5
  import { CollaborationCursorV3 } from "./extensions/collaboration-cursor-v3";
6
  import Placeholder from "@tiptap/extension-placeholder";
 
7
  // Link is included in StarterKit v3, configured via StarterKit options
8
  import Mathematics from "@tiptap/extension-mathematics";
9
  import "katex/dist/katex.min.css";
10
+ import { CodeBlockShiki } from "./extensions/code-block-shiki";
11
  import * as Y from "yjs";
12
  import { UndoManager } from "yjs";
13
  import { HocuspocusProvider } from "@hocuspocus/provider";
 
23
  import { CollaborationUndo } from "./extensions/collaboration-undo";
24
  import { SlashCommands } from "./extensions/slash-commands";
25
  import { ImageUpload } from "./extensions/image-upload";
26
+ import { Citation, citationLabelKey } from "./extensions/citation";
27
  import { Bibliography } from "./extensions/bibliography";
28
  import { Glossary } from "./extensions/glossary";
29
  import { Footnote } from "./extensions/footnote";
30
  import { Stack, StackColumn } from "./extensions/stack";
31
+ import { ScrollGuard } from "./extensions/scroll-guard";
32
  import { CitationPanel } from "./CitationPanel";
33
  import { createCommentStore, CommentStore } from "./comments";
34
  import { createFrontmatterStore, FrontmatterStore } from "./frontmatter/frontmatter-store";
35
+ import { createEmbedStore, EmbedStore } from "./embeds/embed-store";
36
+ import { COMPONENTS, createComponentExtension } from "./components";
37
  import { uploadImage } from "./upload";
38
+ import { loadDemoContent } from "./load-demo";
 
 
39
 
40
  interface EditorProps {
41
  docName: string;
 
43
  editorRef: MutableRefObject<TiptapEditor | null>;
44
  onCommentStoreReady: (store: CommentStore) => void;
45
  onFrontmatterStoreReady: (store: FrontmatterStore) => void;
46
+ onEmbedStoreReady?: (store: EmbedStore) => void;
47
  onSettingsMapReady?: (map: Y.Map<any>) => void;
48
  onEditorReady: (editor: TiptapEditor | null) => void;
49
  onUndoManagerReady?: (manager: UndoManager) => void;
50
+ onProviderReady?: (provider: HocuspocusProvider) => void;
51
  onAddComment?: () => void;
52
  }
53
 
 
57
  editorRef,
58
  onCommentStoreReady,
59
  onFrontmatterStoreReady,
60
+ onEmbedStoreReady,
61
+ onProviderReady,
62
  onSettingsMapReady,
63
  onEditorReady,
64
  onUndoManagerReady,
65
  onAddComment,
66
  }: EditorProps) {
67
+ const ydocRef = useRef<Y.Doc | null>(null);
68
+ if (!ydocRef.current) ydocRef.current = new Y.Doc();
69
+ const ydoc = ydocRef.current;
70
+
71
  const undoManagerCallbackRef = useRef(onUndoManagerReady);
72
  undoManagerCallbackRef.current = onUndoManagerReady;
73
 
74
+ const providerRef = useRef<HocuspocusProvider | null>(null);
75
+ if (!providerRef.current) {
76
  const wsUrl =
77
  window.location.protocol === "https:"
78
  ? `wss://${window.location.host}/collab`
 
81
  const tokenMatch = document.cookie.match(/(?:^|;\s*)hf_access_token=([^;]*)/);
82
  const token = tokenMatch ? decodeURIComponent(tokenMatch[1]) : undefined;
83
 
84
+ providerRef.current = new HocuspocusProvider({
85
  url: wsUrl,
86
  name: docName,
87
  document: ydoc,
88
  token: token || "",
89
  });
90
+ }
91
+ const provider = providerRef.current;
92
 
93
  const citationsMap = useMemo(() => ydoc.getMap("citations"), [ydoc]);
94
  const settingsMap = useMemo(() => ydoc.getMap("settings"), [ydoc]);
95
  const commentStore = useMemo(() => createCommentStore(ydoc), [ydoc]);
96
  const frontmatterStore = useMemo(() => createFrontmatterStore(ydoc), [ydoc]);
97
+ const embedStore = useMemo(() => createEmbedStore(ydoc), [ydoc]);
98
  const [showCitationPanel, setShowCitationPanel] = useState(false);
99
 
100
  useEffect(() => {
 
105
  onFrontmatterStoreReady(frontmatterStore);
106
  }, [frontmatterStore, onFrontmatterStoreReady]);
107
 
108
+ useEffect(() => {
109
+ onEmbedStoreReady?.(embedStore);
110
+ }, [embedStore, onEmbedStoreReady]);
111
+
112
  useEffect(() => {
113
  onSettingsMapReady?.(settingsMap);
114
  }, [settingsMap, onSettingsMapReady]);
115
 
116
+ useEffect(() => {
117
+ onProviderReady?.(provider);
118
+ }, [provider, onProviderReady]);
119
+
120
  useEffect(() => {
121
  return () => {
122
+ providerRef.current?.destroy();
123
+ providerRef.current = null;
124
+ ydocRef.current?.destroy();
125
+ ydocRef.current = null;
126
  };
127
+ }, []);
128
 
129
  const editor = useEditor(
130
  {
 
178
  HTMLAttributes: { class: "editor-link" },
179
  },
180
  } as any),
181
+ CodeBlockShiki,
182
  Placeholder.configure({
183
+ placeholder: 'Type "/" for commands, or "/demo" to load sample content...',
184
  }),
185
  Collaboration.configure({
186
  document: ydoc,
 
208
  Footnote,
209
  Stack,
210
  StackColumn,
211
+ ...COMPONENTS.map(createComponentExtension),
 
212
  CollaborationUndo.configure({
213
  onUndoManagerReady: (um) => undoManagerCallbackRef.current?.(um),
214
  }),
215
  Comment,
216
+ ScrollGuard,
217
  ],
218
  },
219
  [ydoc, provider],
220
  );
221
 
 
 
222
  useEffect(() => {
223
+ if (!editor) return;
224
+ const handler = () => {
225
+ loadDemoContent(editor, frontmatterStore, citationsMap, settingsMap, embedStore);
226
+ };
227
+ window.addEventListener("load-demo-content", handler);
228
+ return () => window.removeEventListener("load-demo-content", handler);
229
+ }, [editor, frontmatterStore, citationsMap, settingsMap, embedStore]);
230
 
231
+ useEffect(() => {
232
+ if (!editor) return;
233
+ const handler = () => {
234
+ // Wipe body
235
+ editor.commands.setContent("<p></p>");
236
 
237
+ // Wipe frontmatter: keep template sane, empty everything else.
238
+ frontmatterStore.setAll({
239
+ title: "",
240
+ subtitle: "",
241
+ description: "",
242
+ authors: [],
243
+ affiliations: [],
244
+ published: "",
245
+ template: "article",
246
+ banner: "",
247
+ doi: "",
248
+ showPdf: true,
249
+ tableOfContentsAutoCollapse: false,
250
+ licence: "",
251
+ pdfProOnly: false,
252
+ seoThumbImage: "",
253
+ links: [],
254
+ });
255
 
256
+ // Wipe citations and settings
257
  ydoc.transact(() => {
258
+ citationsMap.forEach((_, key) => citationsMap.delete(key));
259
+ settingsMap.forEach((_, key) => settingsMap.delete(key));
260
  });
 
 
 
 
 
 
 
 
 
261
 
262
+ // Wipe all embed HTML (banner + charts)
263
+ for (const key of embedStore.keys()) {
264
+ embedStore.remove(key);
265
+ }
266
  };
267
+ window.addEventListener("reset-article", handler);
268
+ return () => window.removeEventListener("reset-article", handler);
269
+ }, [editor, frontmatterStore, citationsMap, settingsMap, embedStore, ydoc]);
270
 
271
+ // Dev-only hook used by the demo recording script (backend/demo/trio.ts).
272
+ // Lets the script swap the banner HTML deterministically, simulating a
273
+ // successful Embed Studio agent run without depending on the LLM backend.
274
+ useEffect(() => {
275
+ if (!import.meta.env.DEV) return;
276
+ const handler = (event: Event) => {
277
+ const detail = (event as CustomEvent).detail as
278
+ | { html?: string; key?: string }
279
+ | undefined;
280
+ const html = detail?.html;
281
+ if (!html || !embedStore) return;
282
+ const key = detail?.key || "banner.html";
283
+ embedStore.set(key, html);
284
+ frontmatterStore.set("banner", key);
285
  };
286
+ window.addEventListener("__demo-set-banner", handler);
287
+ return () => window.removeEventListener("__demo-set-banner", handler);
288
+ }, [embedStore, frontmatterStore]);
289
 
290
  useEffect(() => {
291
  if (!editor) return;
 
295
  useEffect(() => {
296
  if (!editor) return;
297
  if (!editor.storage.citation) {
298
+ editor.storage.citation = { citationsMap: null, settingsMap: null };
299
  }
300
+ editor.storage.citation!.citationsMap = citationsMap;
301
+ editor.storage.citation!.settingsMap = settingsMap;
302
+
303
+ // When Yjs citation data or style changes, signal the citation-label
304
+ // plugin to recompute all labels in a single batched transaction.
305
+ const signal = () => {
306
+ if (editor.isDestroyed) return;
307
+ editor.view.dispatch(editor.state.tr.setMeta(citationLabelKey, true));
308
+ };
309
+ citationsMap.observe(signal);
310
+ settingsMap.observe(signal);
311
+ return () => {
312
+ citationsMap.unobserve(signal);
313
+ settingsMap.unobserve(signal);
314
+ };
315
  }, [editor, citationsMap, settingsMap]);
316
 
317
+ useEffect(() => {
318
+ if (!editor) return;
319
+ if (!editor.storage.htmlEmbed) {
320
+ editor.storage.htmlEmbed = {} as any;
321
+ }
322
+ (editor.storage.htmlEmbed as any).embedStore = embedStore;
323
+ }, [editor, embedStore]);
324
+
325
  useEffect(() => {
326
  const handler = () => setShowCitationPanel(true);
327
  window.addEventListener("open-citation-panel", handler);
frontend/src/editor/EditorFooter.tsx ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState, useCallback, useRef } from "react";
2
+ import type { Editor as TiptapEditor } from "@tiptap/core";
3
+ import type { FrontmatterStore, FrontmatterData } from "./frontmatter/frontmatter-store";
4
+
5
+ function extractYear(dateStr?: string): number | undefined {
6
+ if (!dateStr) return undefined;
7
+ const d = new Date(dateStr);
8
+ if (!Number.isNaN(d.getTime())) return d.getFullYear();
9
+ const m = dateStr.match(/(19|20)\d{2}/);
10
+ return m ? Number(m[0]) : undefined;
11
+ }
12
+
13
+ function buildCitationText(data: FrontmatterData): string {
14
+ const names = data.authors.map((a) => a.name).filter(Boolean);
15
+ const year = extractYear(data.published);
16
+ const title = (data.title || "Untitled").replace(/\s+/g, " ").trim();
17
+ return `${names.join(", ")}${year ? ` (${year})` : ""}. "${title}".`;
18
+ }
19
+
20
+ function buildBibtex(data: FrontmatterData): string {
21
+ const names = data.authors.map((a) => a.name).filter(Boolean);
22
+ const title = (data.title || "Untitled").replace(/\s+/g, " ").trim();
23
+ const year = extractYear(data.published);
24
+ const keyAuthor = (names[0] || "article").split(/\s+/).slice(-1)[0].toLowerCase();
25
+ const keyTitle = title.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, "");
26
+ const bibKey = `${keyAuthor}${year ?? ""}_${keyTitle}`;
27
+ const parts = [` title={${title}}`, ` author={${names.join(" and ")}}`];
28
+ if (year) parts.push(` year={${year}}`);
29
+ if (data.doi) parts.push(` doi={${data.doi}}`);
30
+ return `@misc{${bibKey},\n${parts.join(",\n")}\n}`;
31
+ }
32
+
33
+ interface FootnoteItem {
34
+ index: number;
35
+ content: string;
36
+ }
37
+
38
+ function collectFootnotes(editor: TiptapEditor): FootnoteItem[] {
39
+ const items: FootnoteItem[] = [];
40
+ let i = 0;
41
+ editor.state.doc.descendants((node) => {
42
+ if (node.type.name === "footnote") {
43
+ i++;
44
+ items.push({ index: i, content: (node.attrs.content as string) || "" });
45
+ }
46
+ });
47
+ return items;
48
+ }
49
+
50
+ function getBibliographyHtml(editor: TiptapEditor): string {
51
+ let html = "";
52
+ editor.state.doc.descendants((node) => {
53
+ if (node.type.name === "bibliography" && node.attrs.renderedHtml) {
54
+ html = node.attrs.renderedHtml as string;
55
+ }
56
+ });
57
+ return html;
58
+ }
59
+
60
+ interface EditorFooterProps {
61
+ store: FrontmatterStore | null;
62
+ editor: TiptapEditor | null;
63
+ }
64
+
65
+ export function EditorFooter({ store, editor }: EditorFooterProps) {
66
+ const [data, setData] = useState<FrontmatterData | null>(null);
67
+ const [biblioHtml, setBiblioHtml] = useState("");
68
+ const [footnotes, setFootnotes] = useState<FootnoteItem[]>([]);
69
+
70
+ useEffect(() => {
71
+ if (!store) return;
72
+ const sync = () => setData(store.getAll());
73
+ sync();
74
+ return store.observe(sync);
75
+ }, [store]);
76
+
77
+ const refreshEditorData = useCallback(() => {
78
+ if (!editor) return;
79
+ setBiblioHtml(getBibliographyHtml(editor));
80
+ setFootnotes(collectFootnotes(editor));
81
+ }, [editor]);
82
+
83
+ const footerTimerRef = useRef(0);
84
+ useEffect(() => {
85
+ if (!editor) return;
86
+ refreshEditorData();
87
+ const debouncedRefresh = () => {
88
+ clearTimeout(footerTimerRef.current);
89
+ footerTimerRef.current = window.setTimeout(refreshEditorData, 500);
90
+ };
91
+ editor.on("update", debouncedRefresh);
92
+ return () => {
93
+ editor.off("update", debouncedRefresh);
94
+ clearTimeout(footerTimerRef.current);
95
+ };
96
+ }, [editor, refreshEditorData]);
97
+
98
+ if (!data) return null;
99
+
100
+ const citation = buildCitationText(data);
101
+ const bibtex = buildBibtex(data);
102
+
103
+ return (
104
+ <footer className="footer">
105
+ <div className="footer-inner">
106
+ <section className="citation-block">
107
+ <p className="footer-heading" role="heading" aria-level={2}>Citation</p>
108
+ <p>For attribution in academic contexts, please cite this work as</p>
109
+ <pre className="citation short">{citation}</pre>
110
+ <p>BibTeX citation</p>
111
+ <pre className="citation long">{bibtex}</pre>
112
+ </section>
113
+
114
+ {data.doi && (
115
+ <section className="doi-block">
116
+ <p className="footer-heading" role="heading" aria-level={2}>DOI</p>
117
+ <p>
118
+ <a href={`https://doi.org/${data.doi}`} target="_blank" rel="noopener noreferrer">
119
+ {data.doi}
120
+ </a>
121
+ </p>
122
+ </section>
123
+ )}
124
+
125
+ {data.licence && (
126
+ <section className="reuse-block">
127
+ <p className="footer-heading" role="heading" aria-level={2}>Reuse</p>
128
+ <p dangerouslySetInnerHTML={{ __html: data.licence }} />
129
+ </section>
130
+ )}
131
+
132
+ {biblioHtml && (
133
+ <section className="references-block">
134
+ <p className="footer-heading" role="heading" aria-level={2}>References</p>
135
+ <div
136
+ className="bibliography-content"
137
+ dangerouslySetInnerHTML={{ __html: biblioHtml }}
138
+ />
139
+ </section>
140
+ )}
141
+
142
+ {footnotes.length > 0 && (
143
+ <section className="references-block">
144
+ <p className="footer-heading" role="heading" aria-level={2}>Footnotes</p>
145
+ <ol className="footnotes-list">
146
+ {footnotes.map((fn) => (
147
+ <li key={fn.index} id={`fn-${fn.index}`}>
148
+ <p>{fn.content || "Empty footnote"}</p>
149
+ </li>
150
+ ))}
151
+ </ol>
152
+ </section>
153
+ )}
154
+
155
+ <div className="template-credit">
156
+ <p>
157
+ Made with ❤️ with{" "}
158
+ <a href="https://huggingface.co/spaces/tfrere/research-article-template" target="_blank" rel="noopener noreferrer">
159
+ research article template
160
+ </a>
161
+ </p>
162
+ </div>
163
+ </div>
164
+ </footer>
165
+ );
166
+ }
frontend/src/editor/FloatingActions.tsx DELETED
@@ -1,128 +0,0 @@
1
- import { FloatingMenu } from "@tiptap/react";
2
- import type { Editor } from "@tiptap/core";
3
- import { useState } from "react";
4
- import { Tooltip } from "../components/Tooltip";
5
- import {
6
- Plus,
7
- Heading2,
8
- Heading3,
9
- Quote,
10
- List,
11
- ListOrdered,
12
- Braces,
13
- Minus,
14
- Sigma,
15
- } from "lucide-react";
16
-
17
- interface FloatingActionsProps {
18
- editor: Editor;
19
- }
20
-
21
- export function FloatingActions({ editor }: FloatingActionsProps) {
22
- const [open, setOpen] = useState(false);
23
-
24
- const insert = (fn: () => void) => {
25
- fn();
26
- setOpen(false);
27
- };
28
-
29
- return (
30
- <FloatingMenu
31
- editor={editor}
32
- tippyOptions={{
33
- duration: 150,
34
- placement: "left-start",
35
- offset: [-4, 0],
36
- }}
37
- >
38
- <div className="floating-actions">
39
- <Tooltip title="Insert block" placement="left">
40
- <button
41
- className={`floating-actions__toggle ${open ? "floating-actions__toggle--open" : ""}`}
42
- onClick={() => setOpen(!open)}
43
- aria-label="Insert block"
44
- >
45
- <Plus />
46
- </button>
47
- </Tooltip>
48
-
49
- {open && (
50
- <div className="floating-actions__panel surface">
51
- <Tooltip title="Heading 2">
52
- <button
53
- className="icon-btn"
54
- onClick={() => insert(() => editor.chain().focus().toggleHeading({ level: 2 }).run())}
55
- aria-label="Heading 2"
56
- >
57
- <Heading2 size={18} />
58
- </button>
59
- </Tooltip>
60
- <Tooltip title="Heading 3">
61
- <button
62
- className="icon-btn"
63
- onClick={() => insert(() => editor.chain().focus().toggleHeading({ level: 3 }).run())}
64
- aria-label="Heading 3"
65
- >
66
- <Heading3 size={18} />
67
- </button>
68
- </Tooltip>
69
- <Tooltip title="Quote">
70
- <button
71
- className="icon-btn"
72
- onClick={() => insert(() => editor.chain().focus().toggleBlockquote().run())}
73
- aria-label="Quote"
74
- >
75
- <Quote size={18} />
76
- </button>
77
- </Tooltip>
78
- <Tooltip title="Bullet list">
79
- <button
80
- className="icon-btn"
81
- onClick={() => insert(() => editor.chain().focus().toggleBulletList().run())}
82
- aria-label="Bullet list"
83
- >
84
- <List size={18} />
85
- </button>
86
- </Tooltip>
87
- <Tooltip title="Numbered list">
88
- <button
89
- className="icon-btn"
90
- onClick={() => insert(() => editor.chain().focus().toggleOrderedList().run())}
91
- aria-label="Numbered list"
92
- >
93
- <ListOrdered size={18} />
94
- </button>
95
- </Tooltip>
96
- <Tooltip title="Code block">
97
- <button
98
- className="icon-btn"
99
- onClick={() => insert(() => editor.chain().focus().toggleCodeBlock().run())}
100
- aria-label="Code block"
101
- >
102
- <Braces size={18} />
103
- </button>
104
- </Tooltip>
105
- <Tooltip title="Equation">
106
- <button
107
- className="icon-btn"
108
- onClick={() => insert(() => editor.chain().focus().insertBlockMath({ latex: "E = mc^2" }).run())}
109
- aria-label="Equation"
110
- >
111
- <Sigma size={18} />
112
- </button>
113
- </Tooltip>
114
- <Tooltip title="Divider">
115
- <button
116
- className="icon-btn"
117
- onClick={() => insert(() => editor.chain().focus().setHorizontalRule().run())}
118
- aria-label="Divider"
119
- >
120
- <Minus size={18} />
121
- </button>
122
- </Tooltip>
123
- </div>
124
- )}
125
- </div>
126
- </FloatingMenu>
127
- );
128
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/editor/SlashMenu.tsx CHANGED
@@ -110,6 +110,55 @@ const BUILT_IN_ITEMS: SlashItem[] = [
110
  editor.chain().focus().insertStack(2).run();
111
  },
112
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  ];
114
 
115
  const ITEMS: SlashItem[] = [...BUILT_IN_ITEMS, ...getComponentSlashItems()];
 
110
  editor.chain().focus().insertStack(2).run();
111
  },
112
  },
113
+ {
114
+ title: "New Chart",
115
+ description: "Create a D3 chart with AI (Embed Studio)",
116
+ icon: "📊",
117
+ command: (editor) => {
118
+ const id = `d3-chart-${Date.now().toString(36)}`;
119
+ const src = `${id}.html`;
120
+ (editor.chain().focus() as any).inserthtmlEmbed().run();
121
+ // Find the just-inserted htmlEmbed node and set its src
122
+ setTimeout(() => {
123
+ const { doc } = editor.state;
124
+ let targetPos = -1;
125
+ doc.descendants((node, pos) => {
126
+ if (node.type.name === "htmlEmbed" && !node.attrs.src) {
127
+ targetPos = pos;
128
+ return false;
129
+ }
130
+ });
131
+ if (targetPos >= 0) {
132
+ editor.view.dispatch(
133
+ editor.state.tr.setNodeMarkup(targetPos, undefined, {
134
+ ...editor.state.doc.nodeAt(targetPos)?.attrs,
135
+ src,
136
+ title: "New chart",
137
+ }),
138
+ );
139
+ }
140
+ window.dispatchEvent(
141
+ new CustomEvent("open-embed-studio", { detail: { src } }),
142
+ );
143
+ }, 50);
144
+ },
145
+ },
146
+ {
147
+ title: "Load demo content",
148
+ description: "Replace document with a full demo article",
149
+ icon: "📄",
150
+ command: () => {
151
+ window.dispatchEvent(new CustomEvent("load-demo-content"));
152
+ },
153
+ },
154
+ {
155
+ title: "Reset article",
156
+ description: "Wipe document, banner, frontmatter and citations",
157
+ icon: "🗑",
158
+ command: () => {
159
+ window.dispatchEvent(new CustomEvent("reset-article"));
160
+ },
161
+ },
162
  ];
163
 
164
  const ITEMS: SlashItem[] = [...BUILT_IN_ITEMS, ...getComponentSlashItems()];
frontend/src/editor/StackView.tsx CHANGED
@@ -11,14 +11,21 @@ const GAP_MAP: Record<string, string> = {
11
  const LAYOUT_OPTIONS = ["2-column", "3-column", "4-column"];
12
  const GAP_OPTIONS = ["small", "medium", "large"];
13
 
14
- export function StackView({ node, updateAttributes }: NodeViewProps) {
15
  const layout = (node.attrs.layout as string) || "2-column";
16
  const gap = (node.attrs.gap as string) || "medium";
17
  const colCount = parseInt(layout) || 2;
18
 
19
  const handleLayoutChange = useCallback(
20
- (e: React.ChangeEvent<HTMLSelectElement>) => updateAttributes({ layout: e.target.value }),
21
- [updateAttributes],
 
 
 
 
 
 
 
22
  );
23
 
24
  const handleGapChange = useCallback(
@@ -27,42 +34,15 @@ export function StackView({ node, updateAttributes }: NodeViewProps) {
27
  );
28
 
29
  return (
30
- <NodeViewWrapper data-component="stack">
31
- <div
32
- style={{
33
- border: "1px solid rgba(255,255,255,0.1)",
34
- borderRadius: 8,
35
- background: "rgba(255,255,255,0.02)",
36
- margin: "0.75em 0",
37
- overflow: "hidden",
38
- }}
39
- >
40
- {/* Header */}
41
- <div
42
- contentEditable={false}
43
- style={{
44
- display: "flex",
45
- alignItems: "center",
46
- gap: 8,
47
- padding: "6px 12px",
48
- borderBottom: "1px solid rgba(255,255,255,0.1)",
49
- userSelect: "none",
50
- }}
51
- >
52
- <span style={{ fontSize: 14, lineHeight: 1 }}>▥</span>
53
- <span style={{ fontSize: 13, fontWeight: 600, color: "rgba(255,255,255,0.6)" }}>Stack</span>
54
  <select
55
  value={layout}
56
  onChange={handleLayoutChange}
57
- style={{
58
- background: "rgba(255,255,255,0.06)",
59
- border: "1px solid rgba(255,255,255,0.1)",
60
- borderRadius: 4,
61
- color: "rgba(255,255,255,0.8)",
62
- fontSize: 12,
63
- padding: "2px 6px",
64
- outline: "none",
65
- }}
66
  >
67
  {LAYOUT_OPTIONS.map((opt) => (
68
  <option key={opt} value={opt}>{opt}</option>
@@ -71,15 +51,7 @@ export function StackView({ node, updateAttributes }: NodeViewProps) {
71
  <select
72
  value={gap}
73
  onChange={handleGapChange}
74
- style={{
75
- background: "rgba(255,255,255,0.06)",
76
- border: "1px solid rgba(255,255,255,0.1)",
77
- borderRadius: 4,
78
- color: "rgba(255,255,255,0.8)",
79
- fontSize: 12,
80
- padding: "2px 6px",
81
- outline: "none",
82
- }}
83
  >
84
  {GAP_OPTIONS.map((opt) => (
85
  <option key={opt} value={opt}>{opt}</option>
@@ -87,13 +59,13 @@ export function StackView({ node, updateAttributes }: NodeViewProps) {
87
  </select>
88
  </div>
89
 
90
- {/* Columns rendered by ProseMirror via NodeViewContent */}
91
  <div
 
92
  style={{
93
  display: "grid",
94
  gridTemplateColumns: `repeat(${colCount}, 1fr)`,
95
  gap: GAP_MAP[gap] || "16px",
96
- padding: "8px 12px",
97
  }}
98
  >
99
  <NodeViewContent className="stack-columns" />
 
11
  const LAYOUT_OPTIONS = ["2-column", "3-column", "4-column"];
12
  const GAP_OPTIONS = ["small", "medium", "large"];
13
 
14
+ export function StackView({ node, updateAttributes, editor, getPos }: NodeViewProps) {
15
  const layout = (node.attrs.layout as string) || "2-column";
16
  const gap = (node.attrs.gap as string) || "medium";
17
  const colCount = parseInt(layout) || 2;
18
 
19
  const handleLayoutChange = useCallback(
20
+ (e: React.ChangeEvent<HTMLSelectElement>) => {
21
+ const newLayout = e.target.value;
22
+ const newColCount = parseInt(newLayout) || 2;
23
+ const pos = getPos();
24
+ if (typeof pos === "number") {
25
+ editor.commands.setStackColumns(pos, newColCount);
26
+ }
27
+ },
28
+ [editor, getPos],
29
  );
30
 
31
  const handleGapChange = useCallback(
 
34
  );
35
 
36
  return (
37
+ <NodeViewWrapper data-component="stack" className="stack-wrapper">
38
+ <div className="stack-container">
39
+ <div contentEditable={false} className="stack-header">
40
+ <span className="stack-header-icon">▥</span>
41
+ <span className="stack-header-label">Stack</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  <select
43
  value={layout}
44
  onChange={handleLayoutChange}
45
+ className="stack-header-select"
 
 
 
 
 
 
 
 
46
  >
47
  {LAYOUT_OPTIONS.map((opt) => (
48
  <option key={opt} value={opt}>{opt}</option>
 
51
  <select
52
  value={gap}
53
  onChange={handleGapChange}
54
+ className="stack-header-select"
 
 
 
 
 
 
 
 
55
  >
56
  {GAP_OPTIONS.map((opt) => (
57
  <option key={opt} value={opt}>{opt}</option>
 
59
  </select>
60
  </div>
61
 
 
62
  <div
63
+ className="stack-grid"
64
  style={{
65
  display: "grid",
66
  gridTemplateColumns: `repeat(${colCount}, 1fr)`,
67
  gap: GAP_MAP[gap] || "16px",
68
+ padding: "10px 12px",
69
  }}
70
  >
71
  <NodeViewContent className="stack-columns" />
frontend/src/editor/comments.ts CHANGED
@@ -1,5 +1,13 @@
1
  import * as Y from "yjs";
2
 
 
 
 
 
 
 
 
 
3
  export interface CommentData {
4
  id: string;
5
  author: string;
@@ -9,14 +17,24 @@ export interface CommentData {
9
  resolved: boolean;
10
  resolvedBy?: string;
11
  resolvedAt?: number;
 
12
  }
13
 
14
  export function createCommentStore(ydoc: Y.Doc) {
15
  const ymap = ydoc.getMap<CommentData>("comments");
16
 
17
  return {
18
- add(comment: CommentData) {
19
- ymap.set(comment.id, comment);
 
 
 
 
 
 
 
 
 
20
  },
21
 
22
  resolve(id: string, resolvedBy: string) {
@@ -45,6 +63,10 @@ export function createCommentStore(ydoc: Y.Doc) {
45
  ymap.delete(id);
46
  },
47
 
 
 
 
 
48
  getAll(): CommentData[] {
49
  const comments: CommentData[] = [];
50
  ymap.forEach((value) => comments.push(value));
 
1
  import * as Y from "yjs";
2
 
3
+ export interface ReplyData {
4
+ id: string;
5
+ author: string;
6
+ authorColor: string;
7
+ text: string;
8
+ createdAt: number;
9
+ }
10
+
11
  export interface CommentData {
12
  id: string;
13
  author: string;
 
17
  resolved: boolean;
18
  resolvedBy?: string;
19
  resolvedAt?: number;
20
+ replies: ReplyData[];
21
  }
22
 
23
  export function createCommentStore(ydoc: Y.Doc) {
24
  const ymap = ydoc.getMap<CommentData>("comments");
25
 
26
  return {
27
+ add(comment: Omit<CommentData, "replies">) {
28
+ ymap.set(comment.id, { ...comment, replies: [] });
29
+ },
30
+
31
+ addReply(commentId: string, reply: ReplyData) {
32
+ const comment = ymap.get(commentId);
33
+ if (!comment) return;
34
+ ymap.set(commentId, {
35
+ ...comment,
36
+ replies: [...comment.replies, reply],
37
+ });
38
  },
39
 
40
  resolve(id: string, resolvedBy: string) {
 
63
  ymap.delete(id);
64
  },
65
 
66
+ get(id: string): CommentData | undefined {
67
+ return ymap.get(id);
68
+ },
69
+
70
  getAll(): CommentData[] {
71
  const comments: CommentData[] = [];
72
  ymap.forEach((value) => comments.push(value));
frontend/src/editor/components/AtomicView.tsx CHANGED
@@ -21,12 +21,21 @@ function AtomicFieldRow({
21
  }) {
22
  if (field.type === "boolean") {
23
  return (
24
- <label style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 12, color: "rgba(255,255,255,0.5)", cursor: "pointer" }}>
 
 
 
 
 
 
 
 
 
25
  <input
26
  type="checkbox"
27
  checked={!!value}
28
  onChange={(e) => onChange(e.target.checked)}
29
- style={{ accentColor: "#7c3aed" }}
30
  />
31
  {field.label}
32
  </label>
@@ -36,15 +45,15 @@ function AtomicFieldRow({
36
  if (field.type === "select" && field.options) {
37
  return (
38
  <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
39
- <span style={{ fontSize: 12, color: "rgba(255,255,255,0.4)", minWidth: 60 }}>{field.label}</span>
40
  <select
41
  value={String(value ?? field.default ?? "")}
42
  onChange={(e) => onChange(e.target.value)}
43
  style={{
44
- background: "rgba(255,255,255,0.06)",
45
- border: "1px solid rgba(255,255,255,0.1)",
46
  borderRadius: 4,
47
- color: "rgba(255,255,255,0.8)",
48
  fontSize: 12,
49
  padding: "2px 6px",
50
  outline: "none",
@@ -61,17 +70,17 @@ function AtomicFieldRow({
61
 
62
  return (
63
  <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
64
- <span style={{ fontSize: 12, color: "rgba(255,255,255,0.4)", minWidth: 60 }}>{field.label}</span>
65
  <input
66
  type="text"
67
  value={String(value ?? "")}
68
  placeholder={field.placeholder || field.label}
69
  onChange={(e) => onChange(e.target.value)}
70
  style={{
71
- background: "rgba(255,255,255,0.04)",
72
- border: "1px solid rgba(255,255,255,0.08)",
73
  borderRadius: 4,
74
- color: "rgba(255,255,255,0.85)",
75
  fontSize: 13,
76
  padding: "4px 8px",
77
  outline: "none",
@@ -100,9 +109,9 @@ export function makeAtomicView(def: ComponentDef) {
100
  <div
101
  contentEditable={false}
102
  style={{
103
- border: "1px dashed rgba(255,255,255,0.15)",
104
  borderRadius: 8,
105
- background: "rgba(255,255,255,0.02)",
106
  margin: "0.75em 0",
107
  padding: "12px 16px",
108
  userSelect: "none",
@@ -111,11 +120,11 @@ export function makeAtomicView(def: ComponentDef) {
111
  {/* Header */}
112
  <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: def.fields.length > 0 ? 10 : 0 }}>
113
  <span style={{ fontSize: 16, lineHeight: 1 }}>{def.icon}</span>
114
- <span style={{ fontSize: 13, fontWeight: 600, color: "rgba(255,255,255,0.6)" }}>
115
  {def.label}
116
  </span>
117
  {primaryValue && (
118
- <span style={{ fontSize: 12, color: "rgba(255,255,255,0.35)", fontFamily: "monospace" }}>
119
  {primaryValue}
120
  </span>
121
  )}
 
21
  }) {
22
  if (field.type === "boolean") {
23
  return (
24
+ <label
25
+ style={{
26
+ display: "flex",
27
+ alignItems: "center",
28
+ gap: 6,
29
+ fontSize: 12,
30
+ color: "var(--muted-color)",
31
+ cursor: "pointer",
32
+ }}
33
+ >
34
  <input
35
  type="checkbox"
36
  checked={!!value}
37
  onChange={(e) => onChange(e.target.checked)}
38
+ style={{ accentColor: "var(--primary-color)" }}
39
  />
40
  {field.label}
41
  </label>
 
45
  if (field.type === "select" && field.options) {
46
  return (
47
  <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
48
+ <span style={{ fontSize: 12, color: "var(--muted-color)", minWidth: 60 }}>{field.label}</span>
49
  <select
50
  value={String(value ?? field.default ?? "")}
51
  onChange={(e) => onChange(e.target.value)}
52
  style={{
53
+ background: "var(--surface-bg)",
54
+ border: "1px solid var(--border-color)",
55
  borderRadius: 4,
56
+ color: "var(--text-color)",
57
  fontSize: 12,
58
  padding: "2px 6px",
59
  outline: "none",
 
70
 
71
  return (
72
  <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
73
+ <span style={{ fontSize: 12, color: "var(--muted-color)", minWidth: 60 }}>{field.label}</span>
74
  <input
75
  type="text"
76
  value={String(value ?? "")}
77
  placeholder={field.placeholder || field.label}
78
  onChange={(e) => onChange(e.target.value)}
79
  style={{
80
+ background: "var(--surface-bg)",
81
+ border: "1px solid var(--border-color)",
82
  borderRadius: 4,
83
+ color: "var(--text-color)",
84
  fontSize: 13,
85
  padding: "4px 8px",
86
  outline: "none",
 
109
  <div
110
  contentEditable={false}
111
  style={{
112
+ border: "1px dashed var(--border-color)",
113
  borderRadius: 8,
114
+ background: "var(--surface-bg)",
115
  margin: "0.75em 0",
116
  padding: "12px 16px",
117
  userSelect: "none",
 
120
  {/* Header */}
121
  <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: def.fields.length > 0 ? 10 : 0 }}>
122
  <span style={{ fontSize: 16, lineHeight: 1 }}>{def.icon}</span>
123
+ <span style={{ fontSize: 13, fontWeight: 600, color: "var(--text-color)" }}>
124
  {def.label}
125
  </span>
126
  {primaryValue && (
127
+ <span style={{ fontSize: 12, color: "var(--muted-color)", fontFamily: "monospace" }}>
128
  {primaryValue}
129
  </span>
130
  )}
frontend/src/editor/components/MermaidView.tsx CHANGED
@@ -3,17 +3,24 @@ import { NodeViewWrapper } from "@tiptap/react";
3
  import type { NodeViewProps } from "@tiptap/react";
4
  import mermaid from "mermaid";
5
 
6
- let mermaidInitialized = false;
7
 
8
- function ensureMermaidInit() {
9
- if (mermaidInitialized) return;
 
 
 
 
 
 
 
10
  mermaid.initialize({
11
  startOnLoad: false,
12
- theme: "neutral",
13
  securityLevel: "loose",
14
  fontFamily: "var(--default-font-family)",
15
  });
16
- mermaidInitialized = true;
17
  }
18
 
19
  export function MermaidNodeView({ node, updateAttributes }: NodeViewProps) {
@@ -24,6 +31,7 @@ export function MermaidNodeView({ node, updateAttributes }: NodeViewProps) {
24
  const [error, setError] = useState("");
25
  const renderIdRef = useRef(0);
26
  const textareaRef = useRef<HTMLTextAreaElement>(null);
 
27
 
28
  useEffect(() => {
29
  if (!editing) setDraft(code);
@@ -59,6 +67,31 @@ export function MermaidNodeView({ node, updateAttributes }: NodeViewProps) {
59
  renderDiagram(code);
60
  }, [code, renderDiagram]);
61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  const commit = useCallback(() => {
63
  setEditing(false);
64
  if (draft !== code) {
@@ -164,7 +197,7 @@ export function MermaidNodeView({ node, updateAttributes }: NodeViewProps) {
164
  ) : svgHtml ? (
165
  <div
166
  style={{ padding: "16px", display: "flex", justifyContent: "center" }}
167
- dangerouslySetInnerHTML={{ __html: svgHtml }}
168
  />
169
  ) : (
170
  <div style={{ padding: "24px", textAlign: "center", color: "var(--muted-color)", fontSize: 13 }}>
 
3
  import type { NodeViewProps } from "@tiptap/react";
4
  import mermaid from "mermaid";
5
 
6
+ let currentMermaidTheme: string | null = null;
7
 
8
+ function getMermaidTheme(): "dark" | "neutral" {
9
+ return document.documentElement.getAttribute("data-theme") === "dark"
10
+ ? "dark"
11
+ : "neutral";
12
+ }
13
+
14
+ function ensureMermaidInit(forceReinit = false) {
15
+ const desired = getMermaidTheme();
16
+ if (currentMermaidTheme === desired && !forceReinit) return;
17
  mermaid.initialize({
18
  startOnLoad: false,
19
+ theme: desired,
20
  securityLevel: "loose",
21
  fontFamily: "var(--default-font-family)",
22
  });
23
+ currentMermaidTheme = desired;
24
  }
25
 
26
  export function MermaidNodeView({ node, updateAttributes }: NodeViewProps) {
 
31
  const [error, setError] = useState("");
32
  const renderIdRef = useRef(0);
33
  const textareaRef = useRef<HTMLTextAreaElement>(null);
34
+ const previewRef = useRef<HTMLDivElement>(null);
35
 
36
  useEffect(() => {
37
  if (!editing) setDraft(code);
 
67
  renderDiagram(code);
68
  }, [code, renderDiagram]);
69
 
70
+ // Update shadow DOM only when svgHtml changes (not on every render)
71
+ useEffect(() => {
72
+ const el = previewRef.current;
73
+ if (!el || !svgHtml) return;
74
+ const shadow = el.shadowRoot ?? el.attachShadow({ mode: "open" });
75
+ shadow.innerHTML = `<div style="display:flex;justify-content:center">${svgHtml}</div>`;
76
+ }, [svgHtml]);
77
+
78
+ useEffect(() => {
79
+ const observer = new MutationObserver((mutations) => {
80
+ for (const m of mutations) {
81
+ if (m.attributeName === "data-theme") {
82
+ ensureMermaidInit(true);
83
+ renderDiagram(code);
84
+ break;
85
+ }
86
+ }
87
+ });
88
+ observer.observe(document.documentElement, {
89
+ attributes: true,
90
+ attributeFilter: ["data-theme"],
91
+ });
92
+ return () => observer.disconnect();
93
+ }, [code, renderDiagram]);
94
+
95
  const commit = useCallback(() => {
96
  setEditing(false);
97
  if (draft !== code) {
 
197
  ) : svgHtml ? (
198
  <div
199
  style={{ padding: "16px", display: "flex", justifyContent: "center" }}
200
+ ref={previewRef}
201
  />
202
  ) : (
203
  <div style={{ padding: "24px", textAlign: "center", color: "var(--muted-color)", fontSize: 13 }}>
frontend/src/editor/components/WrapperView.tsx CHANGED
@@ -32,12 +32,21 @@ function FieldInput({
32
  }) {
33
  if (field.type === "boolean") {
34
  return (
35
- <label style={{ display: "flex", alignItems: "center", gap: 4, fontSize: 12, color: "rgba(255,255,255,0.5)", cursor: "pointer" }}>
 
 
 
 
 
 
 
 
 
36
  <input
37
  type="checkbox"
38
  checked={!!value}
39
  onChange={(e) => onChange(e.target.checked)}
40
- style={{ accentColor: "#7c3aed" }}
41
  />
42
  {field.label}
43
  </label>
@@ -50,10 +59,10 @@ function FieldInput({
50
  value={String(value ?? field.default ?? "")}
51
  onChange={(e) => onChange(e.target.value)}
52
  style={{
53
- background: "rgba(255,255,255,0.06)",
54
- border: "1px solid rgba(255,255,255,0.1)",
55
  borderRadius: 4,
56
- color: "rgba(255,255,255,0.8)",
57
  fontSize: 12,
58
  padding: "2px 6px",
59
  outline: "none",
@@ -76,8 +85,8 @@ function FieldInput({
76
  style={{
77
  background: "transparent",
78
  border: "none",
79
- borderBottom: "1px solid rgba(255,255,255,0.1)",
80
- color: "rgba(255,255,255,0.9)",
81
  fontSize: 14,
82
  fontWeight: 600,
83
  padding: "2px 0",
@@ -121,8 +130,13 @@ export function makeWrapperView(def: ComponentDef) {
121
  const isNote = def.name === "note";
122
  const isQuote = def.name === "quoteBlock";
123
  const isSidenote = def.name === "sidenote";
 
124
 
125
- const wrapperStyle: React.CSSProperties = isNote
 
 
 
 
126
  ? {
127
  borderLeft: `2px solid ${borderColor}`,
128
  borderTopRightRadius: 8,
@@ -145,13 +159,29 @@ export function makeWrapperView(def: ComponentDef) {
145
  fontSize: "0.9rem",
146
  color: "var(--muted-color)",
147
  }
148
- : {
149
- border: `1px solid ${borderColor}`,
150
- borderRadius: "var(--table-border-radius, 8px)",
151
- background: bgColor,
152
- margin: "0.75em 0",
153
- overflow: "hidden",
154
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
155
 
156
  return (
157
  <NodeViewWrapper
@@ -187,7 +217,7 @@ export function makeWrapperView(def: ComponentDef) {
187
  />
188
  </div>
189
  ) : (
190
- <span style={{ flex: 1, fontSize: 13, fontWeight: 600, color: "rgba(255,255,255,0.6)" }}>
191
  {def.label}
192
  </span>
193
  )}
@@ -208,8 +238,9 @@ export function makeWrapperView(def: ComponentDef) {
208
  fontSize: 14,
209
  transition: "transform 200ms ease",
210
  transform: collapsed ? "rotate(-90deg)" : "rotate(0deg)",
211
- color: "rgba(255,255,255,0.4)",
212
  flexShrink: 0,
 
213
  }}
214
  >
215
 
@@ -221,7 +252,7 @@ export function makeWrapperView(def: ComponentDef) {
221
  <div
222
  style={{
223
  display: collapsed ? "none" : "block",
224
- padding: isNote ? "0 18px 12px" : isQuote ? "0" : isSidenote ? "0 30px" : "8px 12px",
225
  }}
226
  >
227
  <NodeViewContent className="component-content" />
 
32
  }) {
33
  if (field.type === "boolean") {
34
  return (
35
+ <label
36
+ style={{
37
+ display: "flex",
38
+ alignItems: "center",
39
+ gap: 4,
40
+ fontSize: 12,
41
+ color: "var(--muted-color)",
42
+ cursor: "pointer",
43
+ }}
44
+ >
45
  <input
46
  type="checkbox"
47
  checked={!!value}
48
  onChange={(e) => onChange(e.target.checked)}
49
+ style={{ accentColor: "var(--primary-color)" }}
50
  />
51
  {field.label}
52
  </label>
 
59
  value={String(value ?? field.default ?? "")}
60
  onChange={(e) => onChange(e.target.value)}
61
  style={{
62
+ background: "var(--surface-bg)",
63
+ border: "1px solid var(--border-color)",
64
  borderRadius: 4,
65
+ color: "var(--text-color)",
66
  fontSize: 12,
67
  padding: "2px 6px",
68
  outline: "none",
 
85
  style={{
86
  background: "transparent",
87
  border: "none",
88
+ borderBottom: "1px solid var(--border-color)",
89
+ color: "var(--text-color)",
90
  fontSize: 14,
91
  fontWeight: 600,
92
  padding: "2px 0",
 
130
  const isNote = def.name === "note";
131
  const isQuote = def.name === "quoteBlock";
132
  const isSidenote = def.name === "sidenote";
133
+ const isWideOrFull = def.name === "wide" || def.name === "fullWidth";
134
 
135
+ // NOTE: for wide/fullWidth we intentionally DO NOT set any inline margin.
136
+ // The `margin` shorthand would collapse to `margin-left: 0` and defeat the
137
+ // CSS viewport-breakout (see article.css `.node-wide` / `.node-fullWidth`).
138
+ // Vertical spacing for those two is applied from CSS via `margin-block`.
139
+ const wrapperStyle: React.CSSProperties | undefined = isNote
140
  ? {
141
  borderLeft: `2px solid ${borderColor}`,
142
  borderTopRightRadius: 8,
 
159
  fontSize: "0.9rem",
160
  color: "var(--muted-color)",
161
  }
162
+ : isWideOrFull
163
+ ? undefined
164
+ : {
165
+ border: `1px solid ${borderColor}`,
166
+ borderRadius: "var(--table-border-radius, 8px)",
167
+ background: bgColor,
168
+ margin: "0.75em 0",
169
+ overflow: "hidden",
170
+ };
171
+
172
+ if (isSidenote) {
173
+ return (
174
+ <NodeViewWrapper
175
+ data-component={def.name}
176
+ className="sidenote-wrapper-node"
177
+ >
178
+ <div className="sidenote-content-area">
179
+ <NodeViewContent className="component-content" />
180
+ </div>
181
+ <span contentEditable={false} className="sidenote-badge">¶</span>
182
+ </NodeViewWrapper>
183
+ );
184
+ }
185
 
186
  return (
187
  <NodeViewWrapper
 
217
  />
218
  </div>
219
  ) : (
220
+ <span style={{ flex: 1, fontSize: 13, fontWeight: 600, color: "var(--muted-color)" }}>
221
  {def.label}
222
  </span>
223
  )}
 
238
  fontSize: 14,
239
  transition: "transform 200ms ease",
240
  transform: collapsed ? "rotate(-90deg)" : "rotate(0deg)",
241
+ color: "var(--muted-color)",
242
  flexShrink: 0,
243
+ opacity: 0.6,
244
  }}
245
  >
246
 
 
252
  <div
253
  style={{
254
  display: collapsed ? "none" : "block",
255
+ padding: isNote ? "0 18px 12px" : isQuote ? "0" : "8px 12px",
256
  }}
257
  >
258
  <NodeViewContent className="component-content" />
frontend/src/editor/components/factory.ts CHANGED
@@ -1,7 +1,9 @@
1
  // ---------------------------------------------------------------------------
2
- // Extension factories
3
  //
4
- // Generate TipTap Node extensions from ComponentDef declarations.
 
 
5
  // ---------------------------------------------------------------------------
6
 
7
  import { Node, mergeAttributes } from "@tiptap/core";
@@ -10,97 +12,43 @@ import type { ComponentDef } from "./registry";
10
  import { makeWrapperView } from "./WrapperView";
11
  import { makeAtomicView } from "./AtomicView";
12
  import { MermaidNodeView } from "./MermaidView";
 
13
 
14
- /**
15
- * Build a TipTap Node for a "wrapper" component (has editable children).
16
- *
17
- * The resulting node:
18
- * - belongs to the "block" group
19
- * - accepts block+ content (paragraphs, lists, code, …)
20
- * - stores each field as a node attribute
21
- * - exposes an `insert<Tag>` command
22
- * - renders via a generic React NodeView parameterised by the def
23
- */
24
- export function createWrapperExtension(def: ComponentDef) {
25
- const commandName = `insert${def.tag}`;
26
-
27
- return Node.create({
28
- name: def.name,
29
- group: "block",
30
- content: def.content || "block+",
31
- defining: true,
32
- isolating: true,
33
-
34
- addAttributes() {
35
- const attrs: Record<string, { default: unknown }> = {};
36
- for (const f of def.fields) {
37
- attrs[f.name] = { default: f.default ?? null };
38
- }
39
- return attrs;
40
- },
41
-
42
- parseHTML() {
43
- return [{ tag: `div[data-component="${def.name}"]` }];
44
- },
45
-
46
- renderHTML({ HTMLAttributes }) {
47
- return [
48
- "div",
49
- mergeAttributes(HTMLAttributes, { "data-component": def.name }),
50
- 0, // ProseMirror content hole
51
- ];
52
- },
53
-
54
- addCommands() {
55
- return {
56
- [commandName]:
57
- () =>
58
- ({ commands }: { commands: any }) => {
59
- const attrs: Record<string, unknown> = {};
60
- for (const f of def.fields) {
61
- attrs[f.name] = f.default ?? null;
62
- }
63
- return commands.insertContent({
64
- type: def.name,
65
- attrs,
66
- content: [{ type: "paragraph" }],
67
- });
68
- },
69
- } as any;
70
- },
71
 
72
- addNodeView() {
73
- return ReactNodeViewRenderer(makeWrapperView(def));
74
- },
75
- });
 
 
76
  }
77
 
78
  /**
79
- * Build a TipTap Node for an "atomic" component (no editable children).
80
  *
81
- * The resulting node:
82
- * - belongs to the "block" group
83
- * - is an atom (no ProseMirror content inside)
84
- * - stores each field as a node attribute
85
- * - exposes an `insert<Tag>` command
86
- * - renders via a generic placeholder NodeView
87
  */
88
- export function createAtomicExtension(def: ComponentDef) {
 
89
  const commandName = `insert${def.tag}`;
90
 
91
  return Node.create({
92
  name: def.name,
93
  group: "block",
94
- atom: true,
95
- draggable: true,
96
- selectable: true,
97
 
98
  addAttributes() {
99
- const attrs: Record<string, { default: unknown }> = {};
100
- for (const f of def.fields) {
101
- attrs[f.name] = { default: f.default ?? null };
102
- }
103
- return attrs;
104
  },
105
 
106
  parseHTML() {
@@ -108,10 +56,8 @@ export function createAtomicExtension(def: ComponentDef) {
108
  },
109
 
110
  renderHTML({ HTMLAttributes }) {
111
- return [
112
- "div",
113
- mergeAttributes(HTMLAttributes, { "data-component": def.name }),
114
- ];
115
  },
116
 
117
  addCommands() {
@@ -119,18 +65,27 @@ export function createAtomicExtension(def: ComponentDef) {
119
  [commandName]:
120
  () =>
121
  ({ commands }: { commands: any }) => {
122
- const attrs: Record<string, unknown> = {};
123
- for (const f of def.fields) {
124
- attrs[f.name] = f.default ?? null;
125
- }
126
- return commands.insertContent({ type: def.name, attrs });
127
  },
128
  } as any;
129
  },
130
 
131
  addNodeView() {
132
- const View = def.name === "mermaid" ? MermaidNodeView : makeAtomicView(def);
 
 
 
 
 
 
133
  return ReactNodeViewRenderer(View);
134
  },
135
  });
136
  }
 
 
 
 
 
 
1
  // ---------------------------------------------------------------------------
2
+ // Extension factory
3
  //
4
+ // Generates TipTap Node extensions from ComponentDef declarations.
5
+ // A single factory handles both "wrapper" (editable children) and
6
+ // "atomic" (self-closing) components based on def.kind.
7
  // ---------------------------------------------------------------------------
8
 
9
  import { Node, mergeAttributes } from "@tiptap/core";
 
12
  import { makeWrapperView } from "./WrapperView";
13
  import { makeAtomicView } from "./AtomicView";
14
  import { MermaidNodeView } from "./MermaidView";
15
+ import { makeHtmlEmbedView } from "../embeds/HtmlEmbedView";
16
 
17
+ function buildAttrSchema(fields: ComponentDef["fields"]) {
18
+ const attrs: Record<string, { default: unknown }> = {};
19
+ for (const f of fields) {
20
+ attrs[f.name] = { default: f.default ?? null };
21
+ }
22
+ return attrs;
23
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
 
25
+ function buildDefaultAttrs(fields: ComponentDef["fields"]) {
26
+ const attrs: Record<string, unknown> = {};
27
+ for (const f of fields) {
28
+ attrs[f.name] = f.default ?? null;
29
+ }
30
+ return attrs;
31
  }
32
 
33
  /**
34
+ * Build a TipTap Node extension for any component definition.
35
  *
36
+ * - "wrapper" components have editable ProseMirror content (block+).
37
+ * - "atomic" components are self-closing atoms with no inner editing.
 
 
 
 
38
  */
39
+ export function createComponentExtension(def: ComponentDef) {
40
+ const isWrapper = def.kind === "wrapper";
41
  const commandName = `insert${def.tag}`;
42
 
43
  return Node.create({
44
  name: def.name,
45
  group: "block",
46
+ ...(isWrapper
47
+ ? { content: def.content || "block+", defining: true, isolating: true }
48
+ : { atom: true, draggable: true, selectable: true }),
49
 
50
  addAttributes() {
51
+ return buildAttrSchema(def.fields);
 
 
 
 
52
  },
53
 
54
  parseHTML() {
 
56
  },
57
 
58
  renderHTML({ HTMLAttributes }) {
59
+ const base = mergeAttributes(HTMLAttributes, { "data-component": def.name });
60
+ return isWrapper ? ["div", base, 0] : ["div", base];
 
 
61
  },
62
 
63
  addCommands() {
 
65
  [commandName]:
66
  () =>
67
  ({ commands }: { commands: any }) => {
68
+ const content: any = { type: def.name, attrs: buildDefaultAttrs(def.fields) };
69
+ if (isWrapper) content.content = [{ type: "paragraph" }];
70
+ return commands.insertContent(content);
 
 
71
  },
72
  } as any;
73
  },
74
 
75
  addNodeView() {
76
+ if (isWrapper) {
77
+ return ReactNodeViewRenderer(makeWrapperView(def));
78
+ }
79
+ let View;
80
+ if (def.name === "mermaid") View = MermaidNodeView;
81
+ else if (def.name === "htmlEmbed") View = makeHtmlEmbedView(def);
82
+ else View = makeAtomicView(def);
83
  return ReactNodeViewRenderer(View);
84
  },
85
  });
86
  }
87
+
88
+ /** @deprecated Use createComponentExtension instead */
89
+ export const createWrapperExtension = createComponentExtension;
90
+ /** @deprecated Use createComponentExtension instead */
91
+ export const createAtomicExtension = createComponentExtension;
frontend/src/editor/components/index.ts CHANGED
@@ -1,4 +1,4 @@
1
  export { COMPONENTS } from "./registry";
2
  export type { ComponentDef, ComponentField } from "./registry";
3
- export { createWrapperExtension, createAtomicExtension } from "./factory";
4
  export { getComponentSlashItems } from "./slash-items";
 
1
  export { COMPONENTS } from "./registry";
2
  export type { ComponentDef, ComponentField } from "./registry";
3
+ export { createComponentExtension, createWrapperExtension, createAtomicExtension } from "./factory";
4
  export { getComponentSlashItems } from "./slash-items";
frontend/src/editor/components/registry.ts CHANGED
@@ -79,7 +79,7 @@ const UI_META: Record<string, UIMeta> = {
79
  },
80
  htmlEmbed: {
81
  tag: "HtmlEmbed", icon: "📊", label: "HTML Embed", description: "Embed an external HTML visualization",
82
- fieldMeta: { src: { label: "Source file", placeholder: "d3-chart.html" }, title: { label: "Title", placeholder: "Chart title…" }, desc: { label: "Description", placeholder: "Chart description…" }, wide: { label: "Wide" }, downloadable: { label: "Downloadable" } },
83
  },
84
  hfUser: {
85
  tag: "HfUser", icon: "👤", label: "HF User card", description: "Hugging Face user profile card",
@@ -91,7 +91,7 @@ const UI_META: Record<string, UIMeta> = {
91
  },
92
  mermaid: {
93
  tag: "Mermaid", icon: "◈", label: "Mermaid diagram", description: "Flowchart, sequence, Gantt, etc.",
94
- fieldMeta: { code: { label: "Code", placeholder: "graph TD\\n A --> B" } },
95
  },
96
  };
97
 
 
79
  },
80
  htmlEmbed: {
81
  tag: "HtmlEmbed", icon: "📊", label: "HTML Embed", description: "Embed an external HTML visualization",
82
+ fieldMeta: { src: { label: "Source file", placeholder: "d3-chart.html" }, title: { label: "Title", placeholder: "Chart title…" }, desc: { label: "Description", placeholder: "Chart description…" }, wide: { label: "Wide" }, downloadable: { label: "Downloadable" }, height: { label: "Height (px)", placeholder: "400" } },
83
  },
84
  hfUser: {
85
  tag: "HfUser", icon: "👤", label: "HF User card", description: "Hugging Face user profile card",
 
91
  },
92
  mermaid: {
93
  tag: "Mermaid", icon: "◈", label: "Mermaid diagram", description: "Flowchart, sequence, Gantt, etc.",
94
+ fieldMeta: { code: { label: "Code", placeholder: "graph LR\\n A --> B" } },
95
  },
96
  };
97
 
frontend/src/editor/default-content.ts CHANGED
@@ -3,169 +3,294 @@
3
  * Covers every block/mark type supported by the editor.
4
  */
5
  export const DEFAULT_CONTENT = `
6
- <h2>Text formatting</h2>
7
 
8
- <p>You can make text <strong>bold</strong>, <em>italic</em>, or <s>strikethrough</s>. Combine them for <strong><em>bold italic</em></strong>. Use <code>inline code</code> for technical terms, and add <a href="https://huggingface.co">links</a> to external resources.</p>
9
 
10
- <h2>Lists</h2>
11
 
12
- <h3>Unordered list</h3>
13
 
14
- <ul>
15
- <li>First item with some context</li>
16
- <li>Second item — supports <strong>rich formatting</strong> inside</li>
17
- <li>Third item with <code>inline code</code></li>
18
- </ul>
19
-
20
- <h3>Ordered list</h3>
21
 
22
  <ol>
23
- <li>Prepare the dataset</li>
24
- <li>Fine-tune the model</li>
25
- <li>Evaluate on the test split</li>
26
- <li>Deploy to production</li>
27
  </ol>
28
 
29
- <h2>Blockquote</h2>
30
 
31
- <blockquote>
32
- <p>"The best way to predict the future is to invent it." — Alan Kay</p>
33
- </blockquote>
34
 
35
- <h2>Code block</h2>
36
 
37
- <pre><code class="language-python">import torch
38
- from transformers import AutoModelForCausalLM, AutoTokenizer
39
-
40
- model_id = "meta-llama/Llama-3-8B"
41
- tokenizer = AutoTokenizer.from_pretrained(model_id)
42
- model = AutoModelForCausalLM.from_pretrained(
43
- model_id,
44
- torch_dtype=torch.bfloat16,
45
- device_map="auto",
46
- )
 
47
 
48
- prompt = "The future of AI is"
49
- inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
50
- outputs = model.generate(**inputs, max_new_tokens=100)
51
- print(tokenizer.decode(outputs[0], skip_special_tokens=True))</code></pre>
52
 
53
- <h2>Table</h2>
 
 
 
 
 
 
 
 
 
 
54
 
55
  <table>
56
  <tr>
57
  <th>Model</th>
58
  <th>Parameters</th>
59
- <th>MMLU</th>
60
- <th>HellaSwag</th>
 
61
  </tr>
62
  <tr>
63
- <td>LLaMA 3 8B</td>
64
- <td>8B</td>
65
- <td>66.6</td>
66
- <td>82.0</td>
 
67
  </tr>
68
  <tr>
69
- <td>LLaMA 3 70B</td>
70
- <td>70B</td>
71
- <td>79.5</td>
72
- <td>87.3</td>
 
 
 
 
 
 
 
 
73
  </tr>
74
  <tr>
75
- <td>GPT-4</td>
76
- <td>~1.8T</td>
77
- <td>86.4</td>
78
- <td>95.3</td>
 
79
  </tr>
80
  <tr>
81
- <td>Mixtral 8x7B</td>
82
- <td>46.7B</td>
83
- <td>70.6</td>
84
- <td>84.4</td>
 
85
  </tr>
86
  </table>
87
 
88
- <h2>Mathematics</h2>
89
 
90
- <p>Inline math works with double dollars: the quadratic formula is <span data-type="inline-math" data-latex="x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}"></span> and Euler's identity is <span data-type="inline-math" data-latex="e^{i\\pi} + 1 = 0"></span>.</p>
91
 
92
- <p>Block equations use triple dollars. Here is a standard autoregressive loss:</p>
93
 
94
- <div data-type="block-math" data-latex="\\mathcal{L}(\\theta) = -\\sum_{t=1}^{T} \\log P(x_t \\mid x_{<t}; \\theta)"></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
 
96
- <p>And the full variational trajectory decomposition:</p>
 
97
 
98
- <div data-type="block-math" data-latex="\\begin{aligned} \\log p_\\theta(\\mathcal{D}) &amp;= \\log \\sum_{i=0}^N p_\\theta((o,a)_i) \\\\ &amp;= \\log \\sum_{i=0}^N \\int_{\\text{supp}(Z)} p_\\theta((o,a)_i \\mid z)\\, p(z) \\\\ &amp;= \\log \\sum_{i=0}^N \\int_{\\text{supp}(Z)} \\frac{q_\\theta(z \\mid (o,a)_i)}{q_\\theta(z \\mid (o,a)_i)} \\cdot p_\\theta((o,a)_i \\mid z)\\, p(z) \\\\ &amp;= \\log \\sum_{i=0}^N \\mathbb{E}_{z \\sim p_\\theta(\\bullet \\mid (o,a)_i)} \\left[ \\frac{p(z)}{q_\\theta(z \\mid (o,a)_i)} \\cdot p_\\theta((o,a)_i \\mid z) \\right] \\end{aligned}"></div>
99
 
100
- <h2>Scientific references</h2>
101
 
102
- <p>The editor supports academic citations. The Transformer architecture <span data-type="citation" key="vaswani2017"></span> revolutionized natural language processing by introducing the self-attention mechanism.<span data-type="footnote" content="The original Transformer paper was presented at NeurIPS 2017 and has since become the most cited paper in machine learning history."></span> Large-scale pretraining <span data-type="citation" key="devlin2019"></span> demonstrated that unsupervised objectives on massive corpora produce powerful general-purpose representations.</p>
103
 
104
- <p>More recently, scaling laws <span data-type="citation" key="kaplan2020"></span> have shown predictable relationships between model size, dataset size, and performance.<span data-type="footnote" content="The power-law relationships described in the scaling laws paper hold across several orders of magnitude, from models with 768 parameters to those with over 1.5 billion."></span></p>
105
 
106
- <hr />
107
 
108
- <h2>Horizontal rule</h2>
109
 
110
- <p>Use a horizontal rule to visually separate sections:</p>
111
 
112
- <hr />
113
 
114
- <h2>Nested content</h2>
115
 
116
- <p>Lists can contain multiple levels of formatting. Blockquotes can hold structured content:</p>
117
 
118
- <blockquote>
119
- <p><strong>Note:</strong> This editor supports real-time collaboration. Open this page in another tab to see cursors sync live.</p>
120
- </blockquote>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
 
122
- <h3>A deeper heading level</h3>
123
 
124
- <p>Heading levels go from H1 down to H3, giving you a clear document hierarchy. The floating menu on the left lets you insert any block type, and the bubble toolbar appears when you select text.</p>
125
 
126
- <h2>Custom components</h2>
127
 
128
- <p>The editor supports rich custom components from the research article template. Use the <code>/</code> slash menu to insert them.</p>
129
 
130
- <div data-component="accordion" title="Implementation details" open="false"><p>The model was fine-tuned using LoRA adapters with rank 16 on 4× A100 GPUs. Training took approximately 12 hours on the full dataset. We used a cosine learning rate schedule with warm-up over the first 10% of steps.</p></div>
131
 
132
- <div data-component="note" title="Important" emoji="💡" variant="info"><p>All experiments were conducted with mixed-precision (bf16) training. Results may vary slightly with different random seeds.</p></div>
133
 
134
  <div data-component="quoteBlock" author="Ilya Sutskever" source="NeurIPS 2024 keynote"><p>If you have a very large neural network and you train it on a very large dataset, you get very good results. It really is that simple.</p></div>
135
 
136
- <div data-component="note" title="Warning" emoji="⚠️" variant="danger"><p>Do not use these scaling estimates for production capacity planning without accounting for inference overhead and memory constraints.</p></div>
137
 
138
- <div data-component="wide"><p>This content stretches beyond the normal column width, useful for wide tables, figures or visualizations that need extra horizontal space.</p></div>
139
 
140
- <div data-component="fullWidth"><p>This content spans the entire page width. It is ideal for large figures, panoramic images, or full-bleed data visualizations.</p></div>
141
 
142
- <div data-component="sidenote"><p>Scaling laws were first systematically studied in the context of neural machine translation before being generalized to language models.</p></div>
143
 
144
- <div data-component="reference" id="fig-scaling" caption="Log-linear relationship between compute budget and model performance across three orders of magnitude."><p>[ Figure placeholder insert an image or chart here ]</p></div>
145
 
146
- <div data-component="htmlEmbed" src="d3-scaling-chart.html" title="Interactive scaling law visualization" desc="Explore how model size, data, and compute interact." wide="false" downloadable="true"></div>
147
 
148
- <div data-component="mermaid" code="graph TD\n A[Data Collection] --> B[Preprocessing]\n B --> C[Training]\n C --> D{Evaluation}\n D -->|Pass| E[Deploy]\n D -->|Fail| B"></div>
149
 
150
- <p>The concept of <span data-type="glossary" term="Scaling law" definition="A power-law relationship between model size, dataset size, compute budget, and performance."></span> has become central to modern AI research. Early work<span data-type="footnote" content="Hestness et al. (2017) observed similar scaling behavior in vision and translation tasks before the scaling laws paper formalized the framework."></span> suggested these relationships hold across modalities.</p>
151
 
152
- <div data-component="stack" layout="2-column" gap="medium"><div data-type="stack-column"><p><strong>Column A</strong></p><p>Multi-column layouts let you place content side by side. Each column is fully editable.</p></div><div data-type="stack-column"><p><strong>Column B</strong></p><p>Use the layout selector in the header to switch between 2, 3, or 4 columns.</p></div></div>
153
 
154
- <div data-component="hfUser" username="tfrere" name="Thibaud Frere" url="https://huggingface.co/tfrere"></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
155
 
156
- <h2>What's next?</h2>
157
 
158
- <p>Try asking the AI assistant to:</p>
 
 
 
 
159
 
160
  <ul>
161
- <li>Rewrite a paragraph in a different tone</li>
162
- <li>Expand a section with more detail</li>
163
- <li>Fix grammar or spelling errors</li>
164
- <li>Translate content to another language</li>
165
- <li>Add a new section on a specific topic</li>
166
  </ul>
167
 
168
- <p>Select some text and use the quick actions, or type a message in the chat panel. All AI edits can be undone with <code>Cmd+Z</code>.</p>
 
 
 
 
 
 
169
 
170
  <div data-type="bibliography"></div>
171
  `;
@@ -228,4 +353,65 @@ export const SEED_CITATIONS: Record<string, any> = {
228
  "container-title": "arXiv preprint arXiv:2001.08361",
229
  DOI: "10.48550/arXiv.2001.08361",
230
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
231
  };
 
3
  * Covers every block/mark type supported by the editor.
4
  */
5
  export const DEFAULT_CONTENT = `
6
+ <h2>Introduction</h2>
7
 
8
+ <p>Recent work has revealed remarkably predictable relationships between the scale of neural language models and their performance. As <span data-type="glossary" term="Scaling law" definition="A power-law relationship between model size, dataset size, compute budget, and performance."></span> research matures, practitioners increasingly rely on these empirical regularities to guide decisions about model architecture, training data, and compute allocation.</p>
9
 
10
+ <div data-component="sidenote"><p>The concept of scaling laws in deep learning predates the current LLM era. Similar power-law behaviors were observed in machine translation and image classification as early as 2017.</p></div>
11
 
12
+ <p>This paper presents an empirical study of <span data-type="glossary" term="Neural scaling" definition="The phenomenon where model performance improves as a power law with increasing model size, data, or compute."></span> behavior across three key axes: model size (number of parameters), dataset size (number of tokens), and compute budget (FLOPs). Building on the foundational work of <span data-type="citation" key="kaplan2020"></span> and subsequent refinements by <span data-type="citation" key="hoffmann2022"></span>, we investigate how these relationships hold across different architectural variants and training regimes.<span data-type="footnote" content="Our experiments span five orders of magnitude in compute, from single-GPU runs to multi-node clusters with 256 GPUs."></span></p>
13
 
14
+ <p>We make the following contributions:</p>
 
 
 
 
 
 
15
 
16
  <ol>
17
+ <li>A comprehensive benchmark of scaling behavior across <strong>five model families</strong> ranging from 125M to 70B parameters</li>
18
+ <li>An analysis of <em>compute-optimal training</em>, extending the Chinchilla framework to mixture-of-experts architectures</li>
19
+ <li>Practical guidelines for resource allocation in large-scale training campaigns</li>
 
20
  </ol>
21
 
22
+ <h2>Background</h2>
23
 
24
+ <h3>The Transformer architecture</h3>
 
 
25
 
26
+ <p>The <span data-type="glossary" term="Transformer" definition="A neural network architecture based entirely on self-attention mechanisms, introduced by Vaswani et al. (2017)."></span> architecture <span data-type="citation" key="vaswani2017"></span> forms the backbone of all models studied in this work. At its core, the self-attention mechanism computes a weighted combination of value vectors, where weights are determined by the compatibility between query and key vectors:</p>
27
 
28
+ <div data-type="block-math" data-latex="\\text{Attention}(Q, K, V) = \\text{softmax}\\!\\left(\\frac{QK^\\top}{\\sqrt{d_k}}\\right) V"></div>
29
+
30
+ <p>where <span data-type="inline-math" data-latex="Q \\in \\mathbb{R}^{n \\times d_k}"></span> are queries, <span data-type="inline-math" data-latex="K \\in \\mathbb{R}^{m \\times d_k}"></span> are keys, and <span data-type="inline-math" data-latex="V \\in \\mathbb{R}^{m \\times d_v}"></span> are values. The scaling factor <span data-type="inline-math" data-latex="\\sqrt{d_k}"></span> prevents the dot products from growing too large in magnitude.<span data-type="footnote" content="Without this scaling factor, the softmax function would be pushed into regions where it has extremely small gradients, making training unstable."></span></p>
31
+
32
+ <h3>Pre-training objectives</h3>
33
+
34
+ <p>All models in our study are trained with the standard autoregressive language modeling objective. Given a sequence of tokens <span data-type="inline-math" data-latex="x_1, x_2, \\ldots, x_T"></span>, the model maximizes:</p>
35
+
36
+ <div data-type="block-math" data-latex="\\mathcal{L}(\\theta) = -\\sum_{t=1}^{T} \\log P_\\theta(x_t \\mid x_{<t})"></div>
37
+
38
+ <p>This objective naturally decomposes across sequence positions, enabling efficient parallelization. Prior work <span data-type="citation" key="devlin2019"></span> demonstrated that bidirectional objectives can yield superior representations for downstream tasks, but autoregressive training remains the dominant paradigm for generative models.</p>
39
 
40
+ <h4>Loss decomposition</h4>
 
 
 
41
 
42
+ <p>For analysis purposes, we decompose the training loss into contributions from different frequency bins:</p>
43
+
44
+ <div data-type="block-math" data-latex="\\begin{aligned} \\mathcal{L}_{\\text{total}} &= \\mathcal{L}_{\\text{high-freq}} + \\mathcal{L}_{\\text{mid-freq}} + \\mathcal{L}_{\\text{low-freq}} \\\\ &= -\\sum_{t \\in H} \\log P(x_t \\mid x_{<t}) - \\sum_{t \\in M} \\log P(x_t \\mid x_{<t}) - \\sum_{t \\in L} \\log P(x_t \\mid x_{<t}) \\end{aligned}"></div>
45
+
46
+ <p>where <span data-type="inline-math" data-latex="H, M, L"></span> partition the vocabulary by token frequency. This decomposition reveals that scaling primarily benefits predictions on rare tokens.</p>
47
+
48
+ <h2>Experimental setup</h2>
49
+
50
+ <h3>Model configurations</h3>
51
+
52
+ <p>We train five model families spanning three orders of magnitude in parameter count. All models use the standard decoder-only Transformer architecture with rotary position embeddings (RoPE) and SwiGLU activations.</p>
53
 
54
  <table>
55
  <tr>
56
  <th>Model</th>
57
  <th>Parameters</th>
58
+ <th>Layers</th>
59
+ <th>Hidden dim</th>
60
+ <th>Heads</th>
61
  </tr>
62
  <tr>
63
+ <td>Small</td>
64
+ <td>125M</td>
65
+ <td>12</td>
66
+ <td>768</td>
67
+ <td>12</td>
68
  </tr>
69
  <tr>
70
+ <td>Medium</td>
71
+ <td>1.3B</td>
72
+ <td>24</td>
73
+ <td>2048</td>
74
+ <td>16</td>
75
+ </tr>
76
+ <tr>
77
+ <td>Large</td>
78
+ <td>6.7B</td>
79
+ <td>32</td>
80
+ <td>4096</td>
81
+ <td>32</td>
82
  </tr>
83
  <tr>
84
+ <td>XL</td>
85
+ <td>13B</td>
86
+ <td>40</td>
87
+ <td>5120</td>
88
+ <td>40</td>
89
  </tr>
90
  <tr>
91
+ <td>XXL</td>
92
+ <td>70B</td>
93
+ <td>80</td>
94
+ <td>8192</td>
95
+ <td>64</td>
96
  </tr>
97
  </table>
98
 
99
+ <h3>Training infrastructure</h3>
100
 
101
+ <div data-component="accordion" title="Hardware and software details" open="false"><p>All experiments were conducted on clusters of NVIDIA A100 80GB GPUs connected via NVLink and InfiniBand. We used a custom distributed training framework built on PyTorch FSDP with mixed-precision (bf16) training. Gradient checkpointing was enabled for models above 6.7B parameters to fit within GPU memory constraints.</p></div>
102
 
103
+ <p>The following code snippet illustrates our distributed training configuration:</p>
104
 
105
+ <pre><code class="language-python">import torch
106
+ from torch.distributed.fsdp import FullyShardedDataParallel as FSDP
107
+ from transformers import AutoModelForCausalLM, AutoConfig
108
+
109
+ config = AutoConfig.from_pretrained("meta-llama/Llama-3-8B")
110
+ config.use_cache = False
111
+
112
+ model = AutoModelForCausalLM.from_config(config)
113
+ model = FSDP(
114
+ model,
115
+ mixed_precision=MixedPrecision(
116
+ param_dtype=torch.bfloat16,
117
+ reduce_dtype=torch.float32,
118
+ buffer_dtype=torch.bfloat16,
119
+ ),
120
+ sharding_strategy=ShardingStrategy.FULL_SHARD,
121
+ activation_checkpointing=True,
122
+ )
123
 
124
+ optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=0.1)
125
+ scheduler = CosineAnnealingLR(optimizer, T_max=total_steps, eta_min=3e-5)</code></pre>
126
 
127
+ <div data-component="note" title="Reproducibility" emoji="💡" variant="info"><p>All experiments use a fixed random seed (42) for weight initialization and data shuffling. However, non-determinism in CUDA operations means exact reproducibility across hardware configurations is not guaranteed. We report means and standard deviations across three independent runs for each configuration.</p></div>
128
 
129
+ <h3>Dataset</h3>
130
 
131
+ <p>We train on a deduplicated mixture of web text, books, and scientific papers totaling 2.4 trillion tokens. The mixture weights were optimized following the approach of <span data-type="citation" key="brown2020"></span>, with upsampling of high-quality sources.</p>
132
 
133
+ <h2>Scaling behavior</h2>
134
 
135
+ <h3>Compute-optimal training</h3>
136
 
137
+ <p>Our central finding confirms and extends the <span data-type="glossary" term="Chinchilla scaling" definition="The observation by Hoffmann et al. (2022) that compute-optimal training requires scaling model size and data size in roughly equal proportions."></span> hypothesis: for a given compute budget <span data-type="inline-math" data-latex="C"></span>, the optimal model size <span data-type="inline-math" data-latex="N^*"></span> and token count <span data-type="inline-math" data-latex="D^*"></span> follow power laws:</p>
138
 
139
+ <div data-type="block-math" data-latex="N^* \\propto C^{0.50}, \\quad D^* \\propto C^{0.50}"></div>
140
 
141
+ <p>The interactive visualization below shows the scaling relationship across our model suite:</p>
142
 
143
+ <div data-component="htmlEmbed" src="d3-scaling-chart.html" title="Compute-performance scaling" desc="Validation loss as a function of training compute (FLOPs) for each model size." wide="false" downloadable="true"></div>
144
 
145
+ <h3>Memory requirements</h3>
146
 
147
+ <h4>Training memory breakdown</h4>
148
+
149
+ <p>Understanding memory consumption is critical for capacity planning. Training memory consists of four components: model parameters, gradients, optimizer states, and activations.<span data-type="footnote" content="With Adam optimizer, optimizer states include first and second moment estimates, each requiring the same memory as the model parameters. This means optimizer states alone consume 2× the parameter memory."></span> The chart below shows how these components scale across model sizes and sequence lengths:</p>
150
+
151
+ <div data-component="htmlEmbed" src="d3-memory-bar.html" title="Training memory breakdown" desc="Memory consumption by component across model sizes and sequence lengths. Use the dropdowns to explore different configurations." wide="false" downloadable="true"></div>
152
+
153
+ <div data-component="note" title="Practical constraint" emoji="⚠️" variant="danger"><p>The 70B model requires at minimum 4-way tensor parallelism at sequence length 4096 with selective recomputation. Without activation checkpointing, memory requirements exceed 1.5 TB, making training infeasible even on 8×A100 nodes.</p></div>
154
+
155
+ <h4>Activation checkpointing strategies</h4>
156
+
157
+ <p>We evaluate three recomputation strategies and find that <strong>selective recomputation</strong> (checkpointing only attention layers) provides the best trade-off between memory savings and compute overhead:</p>
158
+
159
+ <ul>
160
+ <li><strong>None</strong>: full activation storage, maximum memory, fastest wall-clock time</li>
161
+ <li><strong>Selective</strong>: checkpoint attention blocks only, ~75% memory reduction in activations with &lt;5% throughput loss</li>
162
+ <li><strong>Full</strong>: checkpoint every layer, ~94% activation memory reduction but 30-40% throughput penalty</li>
163
+ </ul>
164
+
165
+ <h2>Evaluation</h2>
166
+
167
+ <h3>Benchmark performance</h3>
168
+
169
+ <p>We evaluate all models on a suite of standard benchmarks. The table below summarizes key results:</p>
170
+
171
+ <table>
172
+ <tr>
173
+ <th>Model</th>
174
+ <th>MMLU</th>
175
+ <th>HellaSwag</th>
176
+ <th>ARC-C</th>
177
+ <th>TruthfulQA</th>
178
+ </tr>
179
+ <tr>
180
+ <td>Small (125M)</td>
181
+ <td>26.1</td>
182
+ <td>31.2</td>
183
+ <td>22.8</td>
184
+ <td>42.1</td>
185
+ </tr>
186
+ <tr>
187
+ <td>Medium (1.3B)</td>
188
+ <td>34.7</td>
189
+ <td>52.6</td>
190
+ <td>31.4</td>
191
+ <td>38.9</td>
192
+ </tr>
193
+ <tr>
194
+ <td>Large (6.7B)</td>
195
+ <td>52.3</td>
196
+ <td>74.1</td>
197
+ <td>45.6</td>
198
+ <td>35.2</td>
199
+ </tr>
200
+ <tr>
201
+ <td>XL (13B)</td>
202
+ <td>61.8</td>
203
+ <td>80.4</td>
204
+ <td>53.2</td>
205
+ <td>39.7</td>
206
+ </tr>
207
+ <tr>
208
+ <td>XXL (70B)</td>
209
+ <td>72.4</td>
210
+ <td>86.9</td>
211
+ <td>62.1</td>
212
+ <td>44.3</td>
213
+ </tr>
214
+ </table>
215
+
216
+ <div data-component="sidenote"><p>TruthfulQA shows a U-shaped pattern with model scale: medium-sized models perform worst as they are large enough to memorize common misconceptions but not large enough to reason past them.</p></div>
217
 
218
+ <h3>Classification analysis</h3>
219
 
220
+ <p>To understand where scaling helps most, we analyze per-class performance on a 10-class text classification task. The confusion matrices below compare our baseline (1.3B) and improved (6.7B) models:</p>
221
 
222
+ <div data-component="wide"><div data-component="htmlEmbed" src="d3-confusion-matrix.html" title="Confusion matrix comparison" desc="Per-class classification accuracy for baseline (1.3B, left) vs improved (6.7B, right). The delta matrix (right) highlights classes with the largest gains." wide="false"></div></div>
223
 
224
+ <p>The delta matrix reveals that scaling disproportionately benefits classes 2, 3, 5, and 8 (categories with high inter-class confusion at smaller scales), while already well-separated classes (0, 7, 9) see modest improvements.</p>
225
 
226
+ <h2>Analysis</h2>
227
 
228
+ <h3>Training dynamics</h3>
229
 
230
  <div data-component="quoteBlock" author="Ilya Sutskever" source="NeurIPS 2024 keynote"><p>If you have a very large neural network and you train it on a very large dataset, you get very good results. It really is that simple.</p></div>
231
 
232
+ <p>While the quote above captures the high-level intuition, the reality is more nuanced. Our experiments reveal several non-obvious phenomena during training:</p>
233
 
234
+ <div data-component="mermaid" code="graph LR\n A[Initialize model] --> B[Warmup phase]\n B --> C{Loss plateau?}\n C -->|No| D[Continue training]\n D --> C\n C -->|Yes| E[Increase learning rate]\n E --> F{Loss decreases?}\n F -->|Yes| D\n F -->|No| G[Reduce batch size]\n G --> D"></div>
235
 
236
+ <h4>Phase transitions</h4>
237
 
238
+ <p>We observe distinct <strong>phase transitions</strong> in model capabilities as training progresses. These transitions are characterized by sudden improvements in specific task categories, consistent with the "emergence" hypothesis <span data-type="citation" key="wei2022"></span>.<span data-type="footnote" content="The emergence hypothesis suggests that certain capabilities appear abruptly at specific scale thresholds, rather than improving gradually. This remains a topic of active debate in the field, with some researchers arguing that emergent abilities may be artifacts of evaluation metrics rather than genuine phase transitions in model behavior."></span></p>
239
 
240
+ <div data-component="stack" layout="2-column" gap="medium"><div data-type="stack-column"><p><strong>Early training (0-20% tokens)</strong></p><p>Models acquire basic syntax, word co-occurrence statistics, and simple factual associations. Loss decreases rapidly following a power law. All model sizes show similar learning curves during this phase.</p></div><div data-type="stack-column"><p><strong>Late training (80-100% tokens)</strong></p><p>Larger models continue improving on complex reasoning tasks while smaller models plateau. The gap between model sizes widens, with diminishing returns for the smallest models. Multi-step reasoning capabilities emerge only in models above 6.7B parameters.</p></div></div>
241
 
242
+ <h3>Compute efficiency</h3>
243
 
244
+ <p>A critical finding is the relationship between total compute <span data-type="inline-math" data-latex="C"></span> and achieved loss <span data-type="inline-math" data-latex="L"></span>:</p>
245
 
246
+ <div data-type="block-math" data-latex="L(C) = \\left(\\frac{C_0}{C}\\right)^{\\alpha_C} + L_\\infty"></div>
247
 
248
+ <p>where <span data-type="inline-math" data-latex="\\alpha_C \\approx 0.050"></span> is the compute scaling exponent and <span data-type="inline-math" data-latex="L_\\infty"></span> represents the irreducible loss.<span data-type="footnote" content="The irreducible loss corresponds to the entropy of natural language itself and cannot be reduced by any model regardless of scale. Estimates place it around 1.69 nats for English text."></span> This implies that reducing loss by 10% requires approximately <span data-type="inline-math" data-latex="7.2\\times"></span> more compute.</p>
249
 
250
+ <div data-component="note" title="Cost implications" emoji="💰" variant="info"><p>At current cloud GPU prices ($2/GPU-hour for A100), training a 70B parameter model for 2T tokens costs approximately <strong>$2.4M</strong>. Scaling to 400B parameters at compute-optimal token counts would cost an estimated <strong>$45M</strong>, highlighting the importance of getting scaling predictions right before committing resources.</p></div>
251
+
252
+ <h2>Discussion</h2>
253
+
254
+ <h3>Limitations</h3>
255
+
256
+ <div data-component="note" title="Important caveats" emoji="⚠️" variant="danger"><p>Our scaling laws are fit on models up to 70B parameters and may not extrapolate reliably beyond this range. The power-law fits assume a fixed architecture family; mixture-of-experts models, retrieval-augmented systems, and other architectural innovations may follow different scaling trajectories.</p></div>
257
+
258
+ <p>Several limitations should be considered when interpreting our results:</p>
259
+
260
+ <ol>
261
+ <li>All models share the same tokenizer. Tokenizer choice can significantly affect apparent scaling behavior, particularly for multilingual or code-heavy evaluations.</li>
262
+ <li>We focus exclusively on pre-training loss. The relationship between pre-training loss and downstream task performance is complex and task-dependent.</li>
263
+ <li>Our compute estimates do not include the cost of hyperparameter search, which can add 2-5× overhead for novel architectures.</li>
264
+ </ol>
265
+
266
+ <h3>Broader impact</h3>
267
+
268
+ <blockquote>
269
+ <p>"The ability to predict model performance before training enables more responsible allocation of computational resources and reduces wasteful experimentation."</p>
270
+ </blockquote>
271
 
272
+ <p>Scaling laws have implications beyond pure research. They enable organizations to make informed decisions about resource investment, potentially reducing the carbon footprint of unnecessary large-scale training runs.</p>
273
 
274
+ <div data-component="fullWidth"><p>This research was conducted as part of the Hugging Face Science initiative, which aims to advance open research in machine learning. All models, training code, and evaluation scripts are available under the Apache 2.0 license at <a href="https://huggingface.co">huggingface.co</a>.</p></div>
275
+
276
+ <h2>Conclusion</h2>
277
+
278
+ <p>We have presented a comprehensive empirical study of scaling laws for neural language models, confirming the power-law relationship between compute and performance across five model sizes. Our key findings include:</p>
279
 
280
  <ul>
281
+ <li>Compute-optimal training follows the <span data-type="inline-math" data-latex="N^* \\propto C^{0.50}"></span> scaling predicted by the Chinchilla framework</li>
282
+ <li>Memory requirements scale super-linearly with sequence length due to activation storage</li>
283
+ <li>Scaling disproportionately benefits rare tokens and high-confusion classes</li>
284
+ <li>Phase transitions in model capabilities emerge at predictable compute thresholds</li>
 
285
  </ul>
286
 
287
+ <p>These results provide actionable guidance for practitioners planning large-scale training campaigns. Future work should extend this analysis to multimodal models, investigate the interaction between scaling and alignment training, and develop theoretical frameworks that explain the observed power-law behavior.</p>
288
+
289
+ <div data-component="accordion" title="Acknowledgments" open="false"><p>We thank the Hugging Face compute team for providing access to GPU clusters, the open-source contributors who maintain the training infrastructure, and the anonymous reviewers for their insightful feedback. Special thanks to the BigScience and EleutherAI communities for inspiring this line of research.</p></div>
290
+
291
+ <hr />
292
+
293
+ <div data-component="hfUser" username="tfrere" name="Thibaud Frere" url="https://huggingface.co/tfrere"></div>
294
 
295
  <div data-type="bibliography"></div>
296
  `;
 
353
  "container-title": "arXiv preprint arXiv:2001.08361",
354
  DOI: "10.48550/arXiv.2001.08361",
355
  },
356
+ brown2020: {
357
+ id: "brown2020",
358
+ type: "paper-conference",
359
+ title: "Language models are few-shot learners",
360
+ author: [
361
+ { family: "Brown", given: "Tom B." },
362
+ { family: "Mann", given: "Benjamin" },
363
+ { family: "Ryder", given: "Nick" },
364
+ { family: "Subbiah", given: "Melanie" },
365
+ { family: "Kaplan", given: "Jared" },
366
+ { family: "Dhariwal", given: "Prafulla" },
367
+ { family: "Neelakantan", given: "Arvind" },
368
+ { family: "Shyam", given: "Pranav" },
369
+ { family: "Sastry", given: "Girish" },
370
+ { family: "Askell", given: "Amanda" },
371
+ ],
372
+ issued: { "date-parts": [[2020]] },
373
+ "container-title": "Advances in Neural Information Processing Systems",
374
+ volume: "33",
375
+ DOI: "10.48550/arXiv.2005.14165",
376
+ },
377
+ hoffmann2022: {
378
+ id: "hoffmann2022",
379
+ type: "article-journal",
380
+ title: "Training compute-optimal large language models",
381
+ author: [
382
+ { family: "Hoffmann", given: "Jordan" },
383
+ { family: "Borgeaud", given: "Sebastian" },
384
+ { family: "Mensch", given: "Arthur" },
385
+ { family: "Buchatskaya", given: "Elena" },
386
+ { family: "Cai", given: "Trevor" },
387
+ { family: "Rutherford", given: "Eliza" },
388
+ { family: "de Las Casas", given: "Diego" },
389
+ { family: "Hendricks", given: "Lisa Anne" },
390
+ { family: "Welbl", given: "Johannes" },
391
+ { family: "Clark", given: "Aidan" },
392
+ ],
393
+ issued: { "date-parts": [[2022]] },
394
+ "container-title": "arXiv preprint arXiv:2203.15556",
395
+ DOI: "10.48550/arXiv.2203.15556",
396
+ },
397
+ wei2022: {
398
+ id: "wei2022",
399
+ type: "article-journal",
400
+ title: "Emergent abilities of large language models",
401
+ author: [
402
+ { family: "Wei", given: "Jason" },
403
+ { family: "Tay", given: "Yi" },
404
+ { family: "Bommasani", given: "Rishi" },
405
+ { family: "Raffel", given: "Colin" },
406
+ { family: "Zoph", given: "Barret" },
407
+ { family: "Borgeaud", given: "Sebastian" },
408
+ { family: "Yogatama", given: "Dani" },
409
+ { family: "Bosma", given: "Maarten" },
410
+ { family: "Zhou", given: "Denny" },
411
+ { family: "Metzler", given: "Donald" },
412
+ ],
413
+ issued: { "date-parts": [[2022]] },
414
+ "container-title": "Transactions on Machine Learning Research",
415
+ DOI: "10.48550/arXiv.2206.07682",
416
+ },
417
  };
frontend/src/editor/embeds/HtmlEmbedView.tsx ADDED
@@ -0,0 +1,274 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect, useRef, useCallback, useMemo } from "react";
2
+ import { NodeViewWrapper } from "@tiptap/react";
3
+ import type { NodeViewProps } from "@tiptap/react";
4
+ import type { ComponentDef, ComponentField } from "../components/registry";
5
+ import { buildDoc, DEFAULT_EMBED_HEIGHT } from "./build-doc";
6
+ import type { EmbedStore } from "./embed-store";
7
+ import { useTheme } from "../../hooks/useTheme";
8
+
9
+ /**
10
+ * Resolve embed store from the editor's storage.
11
+ * The store is injected by Editor.tsx into editor.storage.htmlEmbed.embedStore.
12
+ */
13
+ function useEmbedStore(editor: NodeViewProps["editor"]): EmbedStore | null {
14
+ return (editor.storage.htmlEmbed as any)?.embedStore ?? null;
15
+ }
16
+
17
+ function FieldRow({
18
+ field,
19
+ value,
20
+ onChange,
21
+ }: {
22
+ field: ComponentField;
23
+ value: unknown;
24
+ onChange: (val: unknown) => void;
25
+ }) {
26
+ if (field.type === "boolean") {
27
+ return (
28
+ <label className="embed-field-row embed-field-checkbox">
29
+ <input
30
+ type="checkbox"
31
+ checked={!!value}
32
+ onChange={(e) => onChange(e.target.checked)}
33
+ />
34
+ {field.label}
35
+ </label>
36
+ );
37
+ }
38
+
39
+ return (
40
+ <div className="embed-field-row">
41
+ <span className="embed-field-label">{field.label}</span>
42
+ <input
43
+ type="text"
44
+ value={String(value ?? "")}
45
+ placeholder={field.placeholder || field.label}
46
+ onChange={(e) => onChange(e.target.value)}
47
+ className="embed-field-input"
48
+ />
49
+ </div>
50
+ );
51
+ }
52
+
53
+ /** Safely parse the stored height attribute (legacy: string; new: number). */
54
+ function parseStoredHeight(raw: unknown): number {
55
+ if (typeof raw === "number" && raw > 0) return Math.round(raw);
56
+ const n = parseInt(String(raw ?? ""), 10);
57
+ return Number.isFinite(n) && n > 0 ? n : DEFAULT_EMBED_HEIGHT;
58
+ }
59
+
60
+ export function makeHtmlEmbedView(def: ComponentDef) {
61
+ function HtmlEmbedNodeView({ node, updateAttributes, editor }: NodeViewProps) {
62
+ const src = (node.attrs.src as string) || "";
63
+ const title = (node.attrs.title as string) || "";
64
+ const storedHeight = parseStoredHeight(node.attrs.height);
65
+ const { isDark, primaryColor } = useTheme();
66
+ const embedStore = useEmbedStore(editor);
67
+
68
+ const [html, setHtml] = useState("");
69
+ const [iframeHeight, setIframeHeight] = useState(storedHeight);
70
+ const [showSettings, setShowSettings] = useState(false);
71
+ const iframeRef = useRef<HTMLIFrameElement>(null);
72
+
73
+ // Sync from embed store
74
+ useEffect(() => {
75
+ if (!embedStore || !src) return;
76
+ setHtml(embedStore.get(src));
77
+ return embedStore.observeKey(src, setHtml);
78
+ }, [embedStore, src]);
79
+
80
+ // Build full document for srcdoc.
81
+ // NOTE: only depends on `html` – theme changes are pushed via postMessage
82
+ // below so we don't reload the iframe (which would lose chart state).
83
+ const srcdoc = useMemo(() => {
84
+ if (!html) return "";
85
+ return buildDoc(html, { isDark, primaryColor });
86
+ // eslint-disable-next-line react-hooks/exhaustive-deps
87
+ }, [html]);
88
+
89
+ // Hot-swap theme inside the iframe without reload.
90
+ useEffect(() => {
91
+ const frame = iframeRef.current;
92
+ if (!frame || !html) return;
93
+ const win = frame.contentWindow;
94
+ if (!win) return;
95
+ const send = () => {
96
+ try {
97
+ win.postMessage(
98
+ {
99
+ type: "setTheme",
100
+ theme: isDark ? "dark" : "light",
101
+ primaryColor,
102
+ },
103
+ "*",
104
+ );
105
+ } catch {
106
+ /* cross-origin / not yet loaded – will retry on load */
107
+ }
108
+ };
109
+ send();
110
+ const onLoad = () => send();
111
+ frame.addEventListener("load", onLoad);
112
+ return () => frame.removeEventListener("load", onLoad);
113
+ }, [isDark, primaryColor, html]);
114
+
115
+ // Listen for height reports from the iframe.
116
+ // - Apply immediately to the visible iframe (React state) for smooth resize.
117
+ // - Debounce the PM transaction (node attribute) so document history isn't
118
+ // polluted with noisy updates during animations.
119
+ const lastPersistedRef = useRef<number>(storedHeight);
120
+ const persistTimerRef = useRef(0);
121
+ useEffect(() => {
122
+ const handler = (e: MessageEvent) => {
123
+ if (e.data?.type !== "embedResize") return;
124
+ const frame = iframeRef.current;
125
+ if (!frame || e.source !== frame.contentWindow) return;
126
+
127
+ const h = Math.max(0, Math.ceil(e.data.height));
128
+ if (!h) return;
129
+
130
+ setIframeHeight((prev) => (prev === h ? prev : h));
131
+
132
+ if (h !== lastPersistedRef.current) {
133
+ clearTimeout(persistTimerRef.current);
134
+ persistTimerRef.current = window.setTimeout(() => {
135
+ if (h !== lastPersistedRef.current) {
136
+ lastPersistedRef.current = h;
137
+ updateAttributes({ height: h });
138
+ }
139
+ }, 800);
140
+ }
141
+ };
142
+ window.addEventListener("message", handler);
143
+ return () => {
144
+ window.removeEventListener("message", handler);
145
+ clearTimeout(persistTimerRef.current);
146
+ };
147
+ }, [updateAttributes]);
148
+
149
+ // Reset persisted tracker when the underlying node attribute changes from
150
+ // the outside (undo/redo, collab remote update).
151
+ useEffect(() => {
152
+ lastPersistedRef.current = storedHeight;
153
+ }, [storedHeight]);
154
+
155
+ const handleFieldChange = useCallback(
156
+ (fieldName: string, value: unknown) => {
157
+ updateAttributes({ [fieldName]: value });
158
+ },
159
+ [updateAttributes],
160
+ );
161
+
162
+ const hasContent = !!html;
163
+
164
+ // Metadata fields (exclude height - managed automatically from iframe)
165
+ const editableFields = def.fields.filter((f) => f.name !== "height");
166
+
167
+ return (
168
+ <NodeViewWrapper data-component="htmlEmbed">
169
+ <div contentEditable={false} className="embed-view">
170
+ {/* Header */}
171
+ <div className="embed-header">
172
+ <div className="embed-header-left">
173
+ <span className="embed-header-icon">📊</span>
174
+ <span className="embed-header-label">
175
+ {title || src || "HTML Embed"}
176
+ </span>
177
+ {src && title && (
178
+ <span className="embed-header-src">{src}</span>
179
+ )}
180
+ </div>
181
+ <div className="embed-header-actions">
182
+ <button
183
+ className="embed-btn"
184
+ onClick={() => setShowSettings(!showSettings)}
185
+ title="Settings"
186
+ >
187
+ {showSettings ? "Close" : "Settings"}
188
+ </button>
189
+ {hasContent && (
190
+ <button
191
+ className="embed-btn embed-btn-primary"
192
+ onClick={() => {
193
+ window.dispatchEvent(
194
+ new CustomEvent("open-embed-studio", { detail: { src } }),
195
+ );
196
+ }}
197
+ title="Open Embed Studio"
198
+ >
199
+ Edit
200
+ </button>
201
+ )}
202
+ </div>
203
+ </div>
204
+
205
+ {/* Settings panel */}
206
+ {showSettings && (
207
+ <div className="embed-settings">
208
+ {editableFields.map((f) => (
209
+ <FieldRow
210
+ key={f.name}
211
+ field={f}
212
+ value={node.attrs[f.name]}
213
+ onChange={(v) => handleFieldChange(f.name, v)}
214
+ />
215
+ ))}
216
+ </div>
217
+ )}
218
+
219
+ {/* Preview */}
220
+ {hasContent ? (
221
+ <div className="embed-preview">
222
+ <iframe
223
+ ref={iframeRef}
224
+ srcDoc={srcdoc}
225
+ title={title || src || "Chart preview"}
226
+ sandbox="allow-scripts allow-same-origin"
227
+ className="embed-iframe"
228
+ style={{
229
+ height: iframeHeight,
230
+ minHeight: Math.min(storedHeight, 120),
231
+ }}
232
+ />
233
+ </div>
234
+ ) : (
235
+ <div className="embed-empty">
236
+ {src ? (
237
+ <>
238
+ <span className="embed-empty-icon">📊</span>
239
+ <span>
240
+ No content for <code>{src}</code>
241
+ </span>
242
+ <button
243
+ className="embed-btn embed-btn-primary"
244
+ onClick={() => {
245
+ window.dispatchEvent(
246
+ new CustomEvent("open-embed-studio", { detail: { src } }),
247
+ );
248
+ }}
249
+ >
250
+ Create Chart
251
+ </button>
252
+ </>
253
+ ) : (
254
+ <>
255
+ <span className="embed-empty-icon">📊</span>
256
+ <span>Set a source filename in settings to link an embed</span>
257
+ <button
258
+ className="embed-btn"
259
+ onClick={() => setShowSettings(true)}
260
+ >
261
+ Open Settings
262
+ </button>
263
+ </>
264
+ )}
265
+ </div>
266
+ )}
267
+ </div>
268
+ </NodeViewWrapper>
269
+ );
270
+ }
271
+
272
+ HtmlEmbedNodeView.displayName = "HtmlEmbedView";
273
+ return HtmlEmbedNodeView;
274
+ }
frontend/src/editor/embeds/build-doc.ts ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Editor-side wrapper around the shared embed srcdoc builder.
2
+ // Keeping this thin shim avoids churning every import site, and preserves
3
+ // the editor-specific call signature (isDark / primaryColor).
4
+
5
+ import { buildEmbedSrcdoc, DEFAULT_EMBED_HEIGHT } from "#shared/embed-doc";
6
+
7
+ export { DEFAULT_EMBED_HEIGHT };
8
+
9
+ interface BuildDocOptions {
10
+ isDark: boolean;
11
+ primaryColor?: string;
12
+ /** Edge-to-edge mode used for banner embeds. See buildEmbedSrcdoc. */
13
+ fullBleed?: boolean;
14
+ }
15
+
16
+ export function buildDoc(htmlFragment: string, opts: BuildDocOptions): string {
17
+ return buildEmbedSrcdoc(htmlFragment, {
18
+ isDark: opts.isDark,
19
+ primaryColor: opts.primaryColor,
20
+ fullBleed: opts.fullBleed,
21
+ });
22
+ }
frontend/src/editor/embeds/embed-store.ts ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as Y from "yjs";
2
+
3
+ /**
4
+ * Collaborative store for HTML embed content, backed by Y.Map("embeds").
5
+ *
6
+ * Each key is a filename (e.g. "d3-scaling-chart.html") and the value is
7
+ * the raw HTML fragment (not a full document - buildDoc wraps it for display).
8
+ *
9
+ * The ProseMirror htmlEmbed node only stores the `src` attribute as a
10
+ * reference key. The actual HTML lives here so multiple collaborators
11
+ * can edit chart content concurrently.
12
+ */
13
+ export function createEmbedStore(ydoc: Y.Doc) {
14
+ const ymap = ydoc.getMap<string>("embeds");
15
+
16
+ function get(src: string): string {
17
+ return ymap.get(src) ?? "";
18
+ }
19
+
20
+ function set(src: string, html: string) {
21
+ ymap.set(src, html);
22
+ }
23
+
24
+ function has(src: string): boolean {
25
+ return ymap.has(src);
26
+ }
27
+
28
+ function remove(src: string) {
29
+ ymap.delete(src);
30
+ }
31
+
32
+ function rename(oldSrc: string, newSrc: string) {
33
+ const html = ymap.get(oldSrc);
34
+ if (html !== undefined) {
35
+ ydoc.transact(() => {
36
+ ymap.set(newSrc, html);
37
+ ymap.delete(oldSrc);
38
+ });
39
+ }
40
+ }
41
+
42
+ /** Apply a search/replace patch to an existing embed. */
43
+ function patch(src: string, search: string, replace: string): boolean {
44
+ const html = ymap.get(src);
45
+ if (!html || !html.includes(search)) return false;
46
+ ymap.set(src, html.replace(search, replace));
47
+ return true;
48
+ }
49
+
50
+ function keys(): string[] {
51
+ return Array.from(ymap.keys());
52
+ }
53
+
54
+ function getAll(): Record<string, string> {
55
+ const result: Record<string, string> = {};
56
+ ymap.forEach((val, key) => {
57
+ result[key] = val;
58
+ });
59
+ return result;
60
+ }
61
+
62
+ /**
63
+ * Observe changes to any embed.
64
+ * Returns an unsubscribe function.
65
+ */
66
+ function observe(callback: () => void) {
67
+ ymap.observe(callback);
68
+ return () => ymap.unobserve(callback);
69
+ }
70
+
71
+ /**
72
+ * Observe changes to a specific embed key.
73
+ * Returns an unsubscribe function.
74
+ */
75
+ function observeKey(src: string, callback: (html: string) => void) {
76
+ const handler = (event: Y.YMapEvent<string>) => {
77
+ if (event.keysChanged.has(src)) {
78
+ callback(ymap.get(src) ?? "");
79
+ }
80
+ };
81
+ ymap.observe(handler);
82
+ return () => ymap.unobserve(handler);
83
+ }
84
+
85
+ return {
86
+ get,
87
+ set,
88
+ has,
89
+ remove,
90
+ rename,
91
+ patch,
92
+ keys,
93
+ getAll,
94
+ observe,
95
+ observeKey,
96
+ };
97
+ }
98
+
99
+ export type EmbedStore = ReturnType<typeof createEmbedStore>;
frontend/src/editor/extensions/citation.ts CHANGED
@@ -1,5 +1,6 @@
1
  import { Node, mergeAttributes } from "@tiptap/core";
2
  import { ReactNodeViewRenderer } from "@tiptap/react";
 
3
  import { CitationView } from "../CitationView";
4
 
5
  declare module "@tiptap/core" {
@@ -10,6 +11,24 @@ declare module "@tiptap/core" {
10
  }
11
  }
12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  export const Citation = Node.create({
14
  name: "citation",
15
  group: "inline",
@@ -54,6 +73,71 @@ export const Citation = Node.create({
54
  };
55
  },
56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  addNodeView() {
58
  return ReactNodeViewRenderer(CitationView);
59
  },
 
1
  import { Node, mergeAttributes } from "@tiptap/core";
2
  import { ReactNodeViewRenderer } from "@tiptap/react";
3
+ import { Plugin, PluginKey } from "@tiptap/pm/state";
4
  import { CitationView } from "../CitationView";
5
 
6
  declare module "@tiptap/core" {
 
11
  }
12
  }
13
 
14
+ export const citationLabelKey = new PluginKey("citation-labels");
15
+
16
+ type CitationStyle = "apa" | "ieee" | "vancouver" | "chicago-author-date" | "harvard1";
17
+ const NUMERIC_STYLES = new Set<CitationStyle>(["ieee", "vancouver"]);
18
+
19
+ function formatAuthorDate(entry: any): string {
20
+ const authors = entry.author;
21
+ if (!authors?.length) return `(${entry.id || "?"})`;
22
+ const year = entry.issued?.["date-parts"]?.[0]?.[0] || "n.d.";
23
+ const first = authors[0].family || authors[0].literal || "?";
24
+ if (authors.length === 1) return `(${first}, ${year})`;
25
+ if (authors.length === 2) {
26
+ const second = authors[1].family || authors[1].literal || "?";
27
+ return `(${first} & ${second}, ${year})`;
28
+ }
29
+ return `(${first} et al., ${year})`;
30
+ }
31
+
32
  export const Citation = Node.create({
33
  name: "citation",
34
  group: "inline",
 
73
  };
74
  },
75
 
76
+ addProseMirrorPlugins() {
77
+ const ext = this;
78
+
79
+ return [
80
+ new Plugin({
81
+ key: citationLabelKey,
82
+
83
+ appendTransaction(transactions, _oldState, newState) {
84
+ const docChanged = transactions.some((tr) => tr.docChanged);
85
+ const signaled = transactions.some((tr) => tr.getMeta(citationLabelKey));
86
+ if (!docChanged && !signaled) return null;
87
+
88
+ const storage = ext.editor.storage.citation;
89
+ const citationsMap = storage?.citationsMap;
90
+ const settingsMap = storage?.settingsMap;
91
+ const style = (settingsMap?.get("citationStyle") || "apa") as CitationStyle;
92
+
93
+ // Single pass: collect all citations with their positions
94
+ const order: string[] = [];
95
+ const nodes: { pos: number; key: string; currentLabel: string }[] = [];
96
+
97
+ newState.doc.descendants((node, pos) => {
98
+ if (node.type.name === "citation" && node.attrs.key) {
99
+ const k = node.attrs.key as string;
100
+ if (!order.includes(k)) order.push(k);
101
+ nodes.push({ pos, key: k, currentLabel: node.attrs.label as string });
102
+ }
103
+ });
104
+
105
+ if (nodes.length === 0) return null;
106
+
107
+ const updates: { pos: number; key: string; label: string }[] = [];
108
+
109
+ for (const { pos, key, currentLabel } of nodes) {
110
+ const entry = citationsMap?.get(key);
111
+ let newLabel: string;
112
+
113
+ if (!entry) {
114
+ newLabel = `[${key}]`;
115
+ } else if (NUMERIC_STYLES.has(style)) {
116
+ const idx = order.indexOf(key);
117
+ const num = idx >= 0 ? idx + 1 : "?";
118
+ newLabel = style === "ieee" ? `[${num}]` : `(${num})`;
119
+ } else {
120
+ newLabel = formatAuthorDate(entry);
121
+ }
122
+
123
+ if (newLabel !== currentLabel) {
124
+ updates.push({ pos, key, label: newLabel });
125
+ }
126
+ }
127
+
128
+ if (updates.length === 0) return null;
129
+
130
+ const tr = newState.tr;
131
+ for (let i = updates.length - 1; i >= 0; i--) {
132
+ const { pos, key, label } = updates[i];
133
+ tr.setNodeMarkup(pos, undefined, { key, label });
134
+ }
135
+ return tr;
136
+ },
137
+ }),
138
+ ];
139
+ },
140
+
141
  addNodeView() {
142
  return ReactNodeViewRenderer(CitationView);
143
  },
frontend/src/editor/extensions/code-block-shiki.tsx ADDED
@@ -0,0 +1,300 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { findChildren } from "@tiptap/core";
2
+ import { CodeBlock, type CodeBlockOptions } from "@tiptap/extension-code-block";
3
+ import { Plugin, PluginKey } from "@tiptap/pm/state";
4
+ import { Decoration, DecorationSet } from "@tiptap/pm/view";
5
+ import type { Node as ProsemirrorNode } from "@tiptap/pm/model";
6
+ import type { EditorView } from "@tiptap/pm/view";
7
+ import type { Element as HastElement, ElementContent, Root as HastRoot } from "hast";
8
+ import {
9
+ getSharedHighlighter,
10
+ isSupportedLang,
11
+ normalizeLang,
12
+ SHIKI_THEMES,
13
+ type ShikiHighlighter,
14
+ } from "#shared/shiki-config";
15
+
16
+ /**
17
+ * TipTap code block with Shiki-powered syntax highlighting.
18
+ *
19
+ * Architecture mirrors `@tiptap/extension-code-block-lowlight`:
20
+ * 1. Extends the base `CodeBlock` node (input rules, keymap, etc.).
21
+ * 2. Registers a ProseMirror plugin that produces a `DecorationSet` from
22
+ * a Shiki `codeToHast()` call for every `codeBlock` in the document.
23
+ * 3. Token coloring uses `Decoration.inline` with `style`/`class` (rendered
24
+ * by PM as wrapping spans). Line numbers use `Decoration.widget`: one
25
+ * `<span class="code-line-num">` inserted at each source line start.
26
+ *
27
+ * Why widgets for numbers (not CSS counters): PM merges overlapping inline
28
+ * decorations into a single flat span per text range, so an outer `.line`
29
+ * decoration would NOT produce a nesting parent usable by `counter-increment`.
30
+ * A widget at line start is a standalone DOM node, which the same CSS can
31
+ * style identically in the editor and in the published output (the publisher
32
+ * transformer injects the exact same `<span class="code-line-num">` markup).
33
+ *
34
+ * The language label (e.g. "python") is rendered by pure CSS using
35
+ * `pre::after { content: attr(data-lang) }` - no extra DOM.
36
+ *
37
+ * Because Shiki is async (WASM engine + grammars), decorations stay empty
38
+ * until the shared highlighter resolves. A view-level hook then dispatches a
39
+ * refresh transaction, causing the whole doc to re-decorate at once.
40
+ */
41
+
42
+ /**
43
+ * Walks Shiki's token tree inside a single `<span class="line">` and pushes
44
+ * one `Decoration.inline` per leaf text node, inheriting `style`/`class`
45
+ * from ancestors. The `line` class itself is stripped from class propagation
46
+ * because it only marks Shiki's line wrapper, not the tokens.
47
+ */
48
+ /**
49
+ * Read the class list off a hast element. Shiki emits a plain `class` string
50
+ * (not the JSX-style `className` array), so we inspect both to be robust
51
+ * against upstream changes.
52
+ */
53
+ function readHastClasses(node: HastElement): string[] {
54
+ const props = node.properties ?? {};
55
+ const raw = props.class ?? props.className;
56
+ if (Array.isArray(raw)) return raw.map(String).filter(Boolean);
57
+ if (typeof raw === "string") return raw.split(/\s+/).filter(Boolean);
58
+ return [];
59
+ }
60
+
61
+ function walkLineTokens(
62
+ nodes: readonly ElementContent[],
63
+ offset: number,
64
+ out: Decoration[],
65
+ styleStack: readonly string[] = [],
66
+ classStack: readonly string[] = [],
67
+ ): number {
68
+ for (const node of nodes) {
69
+ if (node.type === "text") {
70
+ const len = node.value.length;
71
+ if (len > 0 && (styleStack.length > 0 || classStack.length > 0)) {
72
+ const attrs: Record<string, string> = {};
73
+ if (styleStack.length > 0) attrs.style = styleStack.join(";");
74
+ if (classStack.length > 0) attrs.class = classStack.join(" ");
75
+ out.push(Decoration.inline(offset, offset + len, attrs));
76
+ }
77
+ offset += len;
78
+ continue;
79
+ }
80
+ if (node.type === "element") {
81
+ const rawStyle = node.properties?.style;
82
+ const style = typeof rawStyle === "string" ? rawStyle : "";
83
+ const classes = readHastClasses(node).filter((c) => c !== "line");
84
+ const nextStyleStack = style ? [...styleStack, style] : styleStack;
85
+ const nextClassStack = classes.length ? [...classStack, ...classes] : classStack;
86
+ offset = walkLineTokens(node.children, offset, out, nextStyleStack, nextClassStack);
87
+ }
88
+ }
89
+ return offset;
90
+ }
91
+
92
+ /**
93
+ * Extract the `<code>` node's children from a Shiki hast root, skipping the
94
+ * outer `<pre>` and `<code>` wrappers (their styles would leak onto tokens).
95
+ */
96
+ function getCodeChildren(hast: HastRoot): readonly ElementContent[] {
97
+ const pre = hast.children.find((c): c is HastElement => c.type === "element" && c.tagName === "pre");
98
+ if (!pre) return [];
99
+ const code = pre.children.find((c): c is HastElement => c.type === "element" && c.tagName === "code");
100
+ return code ? code.children : [];
101
+ }
102
+
103
+ function isLineElement(node: ElementContent): node is HastElement {
104
+ if (node.type !== "element") return false;
105
+ return readHastClasses(node).includes("line");
106
+ }
107
+
108
+ function buildLineNumberWidget(n: number): HTMLSpanElement {
109
+ const span = document.createElement("span");
110
+ span.className = "code-line-num";
111
+ span.textContent = String(n);
112
+ span.setAttribute("contenteditable", "false");
113
+ span.setAttribute("aria-hidden", "true");
114
+ return span;
115
+ }
116
+
117
+ interface DecorationBuildCtx {
118
+ doc: ProsemirrorNode;
119
+ name: string;
120
+ highlighter: ShikiHighlighter | null;
121
+ defaultLanguage: string | null;
122
+ }
123
+
124
+ function buildDecorations({ doc, name, highlighter, defaultLanguage }: DecorationBuildCtx): DecorationSet {
125
+ const decorations: Decoration[] = [];
126
+ const blocks = findChildren(doc, (node) => node.type.name === name);
127
+
128
+ for (const block of blocks) {
129
+ const text = block.node.textContent;
130
+
131
+ // Always emit line-number widgets, even before the highlighter resolves,
132
+ // so the gutter appears immediately on page load instead of flashing in
133
+ // ~100ms later when Shiki is ready. Each widget captures its own line
134
+ // number via a block-scoped const to avoid the classic let-in-closure
135
+ // pitfall (all widgets would otherwise render the final line number).
136
+ {
137
+ const n = 1;
138
+ const pos = block.pos + 1;
139
+ decorations.push(
140
+ Decoration.widget(pos, () => buildLineNumberWidget(n), {
141
+ side: -1,
142
+ key: `cln-${block.pos}-${n}`,
143
+ ignoreSelection: true,
144
+ }),
145
+ );
146
+ }
147
+ let lineNo = 1;
148
+ for (let i = 0; i < text.length; i++) {
149
+ if (text.charCodeAt(i) === 10 /* \n */) {
150
+ lineNo += 1;
151
+ const n = lineNo;
152
+ const pos = block.pos + 1 + i + 1;
153
+ decorations.push(
154
+ Decoration.widget(pos, () => buildLineNumberWidget(n), {
155
+ side: -1,
156
+ key: `cln-${block.pos}-${n}`,
157
+ ignoreSelection: true,
158
+ }),
159
+ );
160
+ }
161
+ }
162
+
163
+ if (!highlighter || !text) continue;
164
+
165
+ const raw = block.node.attrs.language || defaultLanguage || "";
166
+ const lang = normalizeLang(raw);
167
+
168
+ let hast: HastRoot;
169
+ try {
170
+ hast = highlighter.codeToHast(text, {
171
+ lang: isSupportedLang(lang) ? lang : "text",
172
+ themes: SHIKI_THEMES,
173
+ defaultColor: false,
174
+ }) as HastRoot;
175
+ } catch {
176
+ continue;
177
+ }
178
+
179
+ const children = getCodeChildren(hast);
180
+ let offset = block.pos + 1;
181
+ for (const child of children) {
182
+ if (child.type === "text") {
183
+ offset += child.value.length;
184
+ continue;
185
+ }
186
+ if (!isLineElement(child)) continue;
187
+ offset = walkLineTokens(child.children, offset, decorations);
188
+ }
189
+ }
190
+
191
+ return DecorationSet.create(doc, decorations);
192
+ }
193
+
194
+ const shikiPluginKey = new PluginKey<DecorationSet>("shiki");
195
+
196
+ function createShikiPlugin(name: string, defaultLanguage: string | null): Plugin<DecorationSet> {
197
+ let highlighter: ShikiHighlighter | null = null;
198
+
199
+ const build = (doc: ProsemirrorNode) =>
200
+ buildDecorations({ doc, name, highlighter, defaultLanguage });
201
+
202
+ return new Plugin<DecorationSet>({
203
+ key: shikiPluginKey,
204
+ state: {
205
+ init: (_cfg, { doc }) => build(doc),
206
+ apply(tr, decorationSet, oldState, newState) {
207
+ if (tr.getMeta(shikiPluginKey)?.refresh) return build(tr.doc);
208
+
209
+ const oldNodeName = oldState.selection.$head.parent.type.name;
210
+ const newNodeName = newState.selection.$head.parent.type.name;
211
+ const oldNodes = findChildren(oldState.doc, (node) => node.type.name === name);
212
+ const newNodes = findChildren(newState.doc, (node) => node.type.name === name);
213
+
214
+ const needsRebuild =
215
+ tr.docChanged &&
216
+ ([oldNodeName, newNodeName].includes(name) ||
217
+ newNodes.length !== oldNodes.length ||
218
+ tr.steps.some((step) => {
219
+ const s = step as unknown as { from?: number; to?: number };
220
+ return (
221
+ s.from !== undefined &&
222
+ s.to !== undefined &&
223
+ oldNodes.some((n) => n.pos >= (s.from as number) && n.pos + n.node.nodeSize <= (s.to as number))
224
+ );
225
+ }));
226
+
227
+ if (needsRebuild) return build(tr.doc);
228
+ return decorationSet.map(tr.mapping, tr.doc);
229
+ },
230
+ },
231
+ props: {
232
+ decorations(state) {
233
+ return shikiPluginKey.getState(state);
234
+ },
235
+ },
236
+ view(view: EditorView) {
237
+ let cancelled = false;
238
+ getSharedHighlighter().then((h) => {
239
+ if (cancelled) return;
240
+ highlighter = h;
241
+ if (!view.isDestroyed) {
242
+ view.dispatch(view.state.tr.setMeta(shikiPluginKey, { refresh: true }));
243
+ }
244
+ });
245
+ return {
246
+ destroy() {
247
+ cancelled = true;
248
+ },
249
+ };
250
+ },
251
+ });
252
+ }
253
+
254
+ export const CodeBlockShiki = CodeBlock.extend<CodeBlockOptions>({
255
+ addProseMirrorPlugins() {
256
+ const parentPlugins = this.parent?.() || [];
257
+ return [
258
+ ...parentPlugins,
259
+ createShikiPlugin(this.name, this.options.defaultLanguage ?? null),
260
+ ];
261
+ },
262
+ addNodeView() {
263
+ return ({ node, HTMLAttributes }) => {
264
+ const pre = document.createElement("pre");
265
+ pre.classList.add("shiki");
266
+ for (const [key, value] of Object.entries(HTMLAttributes)) {
267
+ if (value !== undefined && value !== null && value !== false) {
268
+ pre.setAttribute(key, String(value));
269
+ }
270
+ }
271
+
272
+ const code = document.createElement("code");
273
+ pre.append(code);
274
+
275
+ const applyLang = (n: ProsemirrorNode) => {
276
+ const raw = (n.attrs.language as string | null | undefined) || "";
277
+ const normalized = raw ? normalizeLang(raw) : "";
278
+ if (normalized) {
279
+ pre.dataset.lang = normalized;
280
+ code.className = `language-${normalized}`;
281
+ } else {
282
+ delete pre.dataset.lang;
283
+ code.className = "";
284
+ }
285
+ };
286
+
287
+ applyLang(node);
288
+
289
+ return {
290
+ dom: pre,
291
+ contentDOM: code,
292
+ update(updatedNode) {
293
+ if (updatedNode.type.name !== "codeBlock") return false;
294
+ applyLang(updatedNode);
295
+ return true;
296
+ },
297
+ };
298
+ };
299
+ },
300
+ });
frontend/src/editor/extensions/collaboration-cursor-v3.ts CHANGED
@@ -63,6 +63,10 @@ export const CollaborationCursorV3 = Extension.create<CollaborationCursorOptions
63
  },
64
 
65
  addCommands() {
 
 
 
 
66
  return {
67
  updateUser:
68
  (attributes: Record<string, any>) =>
@@ -71,7 +75,7 @@ export const CollaborationCursorV3 = Extension.create<CollaborationCursorOptions
71
  this.options.provider.awareness.setLocalStateField("user", this.options.user);
72
  return true;
73
  },
74
- };
75
  },
76
 
77
  addProseMirrorPlugins() {
 
63
  },
64
 
65
  addCommands() {
66
+ // In Tiptap v3, custom commands must be declared via module augmentation of
67
+ // `Commands<ReturnType>` to satisfy `Partial<RawCommands>`. We don't need
68
+ // that here: the command is used internally via `editor.commands.updateUser`
69
+ // with a runtime cast, so we widen the return type to keep typing simple.
70
  return {
71
  updateUser:
72
  (attributes: Record<string, any>) =>
 
75
  this.options.provider.awareness.setLocalStateField("user", this.options.user);
76
  return true;
77
  },
78
+ } as any;
79
  },
80
 
81
  addProseMirrorPlugins() {
frontend/src/editor/extensions/scroll-guard.ts ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Extension } from "@tiptap/core";
2
+ import { Plugin, PluginKey } from "@tiptap/pm/state";
3
+ import { ySyncPluginKey } from "@tiptap/y-tiptap";
4
+
5
+ /**
6
+ * Prevents ProseMirror's scrollIntoView from firing after remote Yjs sync
7
+ * transactions. The y-tiptap binding calls tr.scrollIntoView() whenever
8
+ * _isLocalCursorInView() is true, but that check uses window dimensions
9
+ * instead of the actual scroll container. This fights the user's manual
10
+ * scroll, causing a "stuck then resume" feeling.
11
+ *
12
+ * Strategy: a filterTransaction hook sets a module-level flag when it
13
+ * sees a remote-origin sync transaction, and handleScrollToSelection
14
+ * suppresses the scroll while the flag is set. The flag is cleared
15
+ * asynchronously after the synchronous dispatch cycle completes.
16
+ */
17
+
18
+ let _blockScroll = false;
19
+
20
+ export const ScrollGuard = Extension.create({
21
+ name: "scrollGuard",
22
+
23
+ addProseMirrorPlugins() {
24
+ return [
25
+ new Plugin({
26
+ key: new PluginKey("scroll-guard"),
27
+ filterTransaction(tr) {
28
+ const meta = tr.getMeta(ySyncPluginKey);
29
+ if (meta?.isChangeOrigin) {
30
+ _blockScroll = true;
31
+ queueMicrotask(() => { _blockScroll = false; });
32
+ }
33
+ return true;
34
+ },
35
+ }),
36
+ ];
37
+ },
38
+
39
+ addOptions() {
40
+ return {};
41
+ },
42
+
43
+ onBeforeCreate() {
44
+ const existing = this.editor.options.editorProps?.handleScrollToSelection;
45
+
46
+ this.editor.setOptions({
47
+ editorProps: {
48
+ ...this.editor.options.editorProps,
49
+ handleScrollToSelection(view) {
50
+ if (_blockScroll) return true;
51
+ if (existing) return existing.call(this, view);
52
+ return false;
53
+ },
54
+ },
55
+ });
56
+ },
57
+ });
frontend/src/editor/extensions/stack.ts CHANGED
@@ -6,6 +6,7 @@ declare module "@tiptap/core" {
6
  interface Commands<ReturnType> {
7
  stack: {
8
  insertStack: (columns?: number) => ReturnType;
 
9
  };
10
  }
11
  }
@@ -67,6 +68,42 @@ export const Stack = Node.create({
67
  content: cols,
68
  });
69
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  };
71
  },
72
 
 
6
  interface Commands<ReturnType> {
7
  stack: {
8
  insertStack: (columns?: number) => ReturnType;
9
+ setStackColumns: (pos: number, columns: number) => ReturnType;
10
  };
11
  }
12
  }
 
68
  content: cols,
69
  });
70
  },
71
+
72
+ setStackColumns:
73
+ (pos, columns) =>
74
+ ({ tr, state, dispatch }) => {
75
+ const target = Math.min(Math.max(columns, 2), 4);
76
+ const node = state.doc.nodeAt(pos);
77
+ if (!node || node.type.name !== "stack") return false;
78
+
79
+ const current = node.childCount;
80
+ if (current === target) {
81
+ tr.setNodeMarkup(pos, undefined, { ...node.attrs, layout: `${target}-column` });
82
+ dispatch?.(tr);
83
+ return true;
84
+ }
85
+
86
+ tr.setNodeMarkup(pos, undefined, { ...node.attrs, layout: `${target}-column` });
87
+
88
+ if (target > current) {
89
+ const colType = state.schema.nodes.stackColumn;
90
+ const pType = state.schema.nodes.paragraph;
91
+ const insertPos = pos + node.nodeSize - 1;
92
+ for (let i = 0; i < target - current; i++) {
93
+ tr.insert(insertPos + i, colType.create(null, pType.create()));
94
+ }
95
+ } else {
96
+ let removeFrom = pos + 1;
97
+ for (let i = 0; i < target; i++) {
98
+ removeFrom += node.child(i).nodeSize;
99
+ }
100
+ const removeTo = pos + node.nodeSize - 1;
101
+ tr.delete(removeFrom, removeTo);
102
+ }
103
+
104
+ dispatch?.(tr);
105
+ return true;
106
+ },
107
  };
108
  },
109
 
frontend/src/editor/frontmatter/FrontmatterHero.tsx CHANGED
@@ -1,18 +1,86 @@
1
- import { useState, useRef, useEffect, type KeyboardEvent } from "react";
2
  import { Tooltip } from "../../components/Tooltip";
3
- import { Plus, X } from "lucide-react";
4
  import { useFrontmatter } from "./useFrontmatter";
 
 
 
5
  import type { FrontmatterStore, Author, Affiliation } from "./frontmatter-store";
 
 
 
 
 
 
 
 
 
 
 
 
 
6
 
7
  interface FrontmatterHeroProps {
8
  store: FrontmatterStore | null;
 
9
  }
10
 
11
- export function FrontmatterHero({ store }: FrontmatterHeroProps) {
12
  const { data, update } = useFrontmatter(store);
13
  const [editingAuthorIdx, setEditingAuthorIdx] = useState<number | null>(null);
14
  const [showAuthorForm, setShowAuthorForm] = useState(false);
15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  if (!data) {
17
  return <div className="hero" />;
18
  }
@@ -20,7 +88,6 @@ export function FrontmatterHero({ store }: FrontmatterHeroProps) {
20
  const hasAuthors = data.authors.length > 0;
21
  const hasAffiliations = data.affiliations.length > 0;
22
  const multipleAffiliations = data.affiliations.length > 1;
23
- const hasMeta = hasAuthors || data.published || data.doi;
24
 
25
  return (
26
  <div>
@@ -33,19 +100,81 @@ export function FrontmatterHero({ store }: FrontmatterHeroProps) {
33
  multiline
34
  />
35
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  <EditableText
37
  value={data.subtitle}
38
  placeholder="Subtitle (optional)"
39
  onChange={(v) => update("subtitle", v)}
40
  className="hero-desc"
 
41
  />
42
  </section>
43
 
44
- {hasMeta && (
45
- <header className="meta" aria-label="Article meta information">
46
- <div className="meta-container">
47
- <div className="meta-container-cell">
48
- <h3>Authors</h3>
49
  <ul className="authors">
50
  {data.authors.map((author, i) => (
51
  <li key={`author-${i}`}>
@@ -80,80 +209,78 @@ export function FrontmatterHero({ store }: FrontmatterHeroProps) {
80
  </Tooltip>
81
  </li>
82
  </ul>
83
- </div>
84
-
85
- {hasAffiliations && (
86
- <div className="meta-container-cell">
87
- <h3>Affiliations</h3>
88
- {multipleAffiliations ? (
89
- <ol className="affiliations">
90
- {data.affiliations.map((aff, i) => (
91
- <li key={`aff-${i}`}>
92
- {aff.url ? (
93
- <a href={aff.url} target="_blank" rel="noopener noreferrer">
94
- {aff.name}
95
- </a>
96
- ) : (
97
- aff.name
98
- )}
99
- </li>
100
- ))}
101
- </ol>
102
- ) : (
103
- <p>
104
- {data.affiliations[0].url ? (
105
- <a href={data.affiliations[0].url} target="_blank" rel="noopener noreferrer">
106
- {data.affiliations[0].name}
107
- </a>
108
- ) : (
109
- data.affiliations[0].name
110
- )}
111
- </p>
112
- )}
113
- </div>
114
  )}
 
115
 
 
116
  <div className="meta-container-cell">
117
- <h3>Published</h3>
118
- <EditableText
119
- value={data.published}
120
- placeholder="Date"
121
- onChange={(v) => update("published", v)}
122
- inline
123
- />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
  </div>
 
125
 
126
- <div className="meta-container-cell">
127
- <h3>DOI</h3>
128
- <EditableText
129
- value={data.doi}
130
- placeholder="10.xxxx/xxxxx"
131
- onChange={(v) => update("doi", v)}
132
- inline
133
- />
134
- </div>
135
  </div>
136
- </header>
137
- )}
138
 
139
- <div style={{ maxWidth: 680, margin: "0 auto", padding: "0 16px" }}>
140
- {showAuthorForm && (
141
- <AuthorInlineForm
142
- affiliations={data.affiliations}
143
- onSubmit={(author, newAff) => {
144
- if (newAff) {
145
- const idx = store!.addAffiliation(newAff);
146
- author.affiliations = [...author.affiliations, idx];
147
- }
148
- store?.addAuthor(author);
149
- setShowAuthorForm(false);
150
- }}
151
- onCancel={() => setShowAuthorForm(false)}
152
- />
153
- )}
154
 
 
155
  {editingAuthorIdx !== null && data.authors[editingAuthorIdx] && (
156
  <AuthorInlineForm
 
157
  initial={data.authors[editingAuthorIdx]}
158
  affiliations={data.affiliations}
159
  onSubmit={(author, newAff) => {
@@ -182,93 +309,71 @@ interface EditableTextProps {
182
  }
183
 
184
  function EditableText({ value, placeholder, onChange, className, multiline, inline }: EditableTextProps) {
185
- const [editing, setEditing] = useState(false);
186
  const [draft, setDraft] = useState(value);
187
  const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null);
188
 
189
  useEffect(() => {
190
- if (!editing) setDraft(value);
191
- }, [value, editing]);
192
 
193
  useEffect(() => {
194
- if (editing) {
195
- inputRef.current?.focus();
196
- if (inputRef.current) {
197
- const el = inputRef.current;
198
- el.selectionStart = el.selectionEnd = el.value.length;
199
- }
200
  }
201
- }, [editing]);
 
 
 
 
 
202
 
203
  const commit = () => {
204
- setEditing(false);
205
  if (draft !== value) onChange(draft);
206
  };
207
 
208
  const handleKeyDown = (e: KeyboardEvent) => {
209
  if (e.key === "Enter" && !multiline) {
210
  e.preventDefault();
211
- commit();
212
  }
213
  if (e.key === "Escape") {
214
  setDraft(value);
215
- setEditing(false);
216
  }
217
  };
218
 
219
- const displayValue = value || placeholder;
220
- const isEmpty = !value;
221
-
222
- if (editing) {
223
- const Tag = multiline ? "textarea" : "input";
224
- return (
225
- <span className={className} style={{ display: inline ? "inline-flex" : "flex" }}>
226
- <Tag
227
- ref={inputRef as any}
228
- className="form-input"
229
- value={draft}
230
- onChange={(e) => setDraft(e.target.value)}
231
- onBlur={commit}
232
- onKeyDown={handleKeyDown}
233
- placeholder={placeholder}
234
- style={{
235
- font: "inherit",
236
- color: "inherit",
237
- textAlign: "inherit",
238
- background: "transparent",
239
- border: "none",
240
- padding: 0,
241
- margin: 0,
242
- width: "100%",
243
- outline: "none",
244
- resize: multiline ? "vertical" : "none",
245
- }}
246
- />
247
- </span>
248
- );
249
- }
250
 
251
  return (
252
  <span
253
  className={`editable-text ${className || ""}`}
254
- onClick={() => setEditing(true)}
255
- style={{
256
- display: inline ? "inline" : "block",
257
- opacity: isEmpty ? 0.35 : 1,
258
- cursor: "text",
259
- }}
260
  >
261
- {displayValue}
 
 
 
 
 
 
 
 
 
262
  </span>
263
  );
264
  }
265
 
266
  function AuthorInlineForm({
 
267
  initial,
268
  affiliations,
269
  onSubmit,
270
  onCancel,
271
  }: {
 
272
  initial?: Author;
273
  affiliations: Affiliation[];
274
  onSubmit: (author: Author, newAffiliation?: Affiliation) => void;
@@ -279,10 +384,30 @@ function AuthorInlineForm({
279
  const [affIndices, setAffIndices] = useState<number[]>(initial?.affiliations || []);
280
  const [newAffName, setNewAffName] = useState("");
281
  const nameRef = useRef<HTMLInputElement>(null);
 
282
 
283
  useEffect(() => {
284
- nameRef.current?.focus();
285
- }, []);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
286
 
287
  const handleSubmit = () => {
288
  if (!name.trim()) return;
@@ -293,6 +418,11 @@ function AuthorInlineForm({
293
  );
294
  };
295
 
 
 
 
 
 
296
  const toggleAff = (idx: number) => {
297
  setAffIndices((prev) =>
298
  prev.includes(idx) ? prev.filter((i) => i !== idx) : [...prev, idx],
@@ -300,54 +430,55 @@ function AuthorInlineForm({
300
  };
301
 
302
  return (
303
- <div className="author-form">
304
- <span className="author-form__title">
305
  {initial ? "Edit author" : "Add author"}
306
- </span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
307
 
308
- <div className="author-form__row">
309
- <input
310
- ref={nameRef}
311
- className="form-input"
312
- placeholder="Name"
313
- value={name}
314
- onChange={(e) => setName(e.target.value)}
315
- onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
316
- />
317
  <input
318
  className="form-input"
319
- placeholder="URL (optional)"
320
- value={url}
321
- onChange={(e) => setUrl(e.target.value)}
322
  onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
 
323
  />
324
  </div>
325
-
326
- {affiliations.length > 0 && (
327
- <div className="author-form__chips">
328
- {affiliations.map((aff, i) => (
329
- <span
330
- key={`aff-select-${i}`}
331
- className={`chip chip--clickable ${affIndices.includes(i + 1) ? "" : "chip--outlined"}`}
332
- style={affIndices.includes(i + 1) ? { background: "var(--primary-color)", color: "#000" } : {}}
333
- onClick={() => toggleAff(i + 1)}
334
- >
335
- {i + 1}. {aff.name}
336
- </span>
337
- ))}
338
- </div>
339
- )}
340
-
341
- <input
342
- className="form-input"
343
- placeholder="New affiliation (optional), e.g. Hugging Face"
344
- value={newAffName}
345
- onChange={(e) => setNewAffName(e.target.value)}
346
- onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
347
- />
348
-
349
- <div className="author-form__actions">
350
- <button className="btn" onClick={onCancel}>Cancel</button>
351
  <button
352
  className="btn btn--primary"
353
  onClick={handleSubmit}
@@ -356,6 +487,6 @@ function AuthorInlineForm({
356
  {initial ? "Save" : "Add"}
357
  </button>
358
  </div>
359
- </div>
360
  );
361
  }
 
1
+ import { useState, useRef, useEffect, useMemo, type KeyboardEvent } from "react";
2
  import { Tooltip } from "../../components/Tooltip";
3
+ import { Plus, ImagePlus, BarChart3, Trash2 } from "lucide-react";
4
  import { useFrontmatter } from "./useFrontmatter";
5
+ import { buildDoc } from "../embeds/build-doc";
6
+ import { useTheme } from "../../hooks/useTheme";
7
+ import { uploadImage } from "../upload";
8
  import type { FrontmatterStore, Author, Affiliation } from "./frontmatter-store";
9
+ import type { EmbedStore } from "../embeds/embed-store";
10
+
11
+ const BANNER_KEY = "banner.html";
12
+
13
+ const CHART_PLACEHOLDER_HTML = `<div class="d3-chart" style="display:flex;align-items:center;justify-content:center;width:100%;height:100%;color:var(--muted-color);font-family:system-ui;">
14
+ <p style="margin:0;font-size:14px;">Generating chart...</p>
15
+ </div>`;
16
+
17
+ function makeImageBannerHtml(url: string) {
18
+ return `<div style="width:100%;height:100%;overflow:hidden;">
19
+ <img src="${url}" alt="Banner" style="width:100%;height:100%;object-fit:cover;display:block;" />
20
+ </div>`;
21
+ }
22
 
23
  interface FrontmatterHeroProps {
24
  store: FrontmatterStore | null;
25
+ embedStore?: EmbedStore | null;
26
  }
27
 
28
+ export function FrontmatterHero({ store, embedStore }: FrontmatterHeroProps) {
29
  const { data, update } = useFrontmatter(store);
30
  const [editingAuthorIdx, setEditingAuthorIdx] = useState<number | null>(null);
31
  const [showAuthorForm, setShowAuthorForm] = useState(false);
32
 
33
+ const bannerSrc = data?.banner || "";
34
+ const [bannerHtml, setBannerHtml] = useState("");
35
+ const [uploadingBanner, setUploadingBanner] = useState(false);
36
+ const bannerFileRef = useRef<HTMLInputElement>(null);
37
+
38
+ useEffect(() => {
39
+ if (!embedStore || !bannerSrc) { setBannerHtml(""); return; }
40
+ setBannerHtml(embedStore.get(bannerSrc));
41
+ return embedStore.observeKey(bannerSrc, setBannerHtml);
42
+ }, [embedStore, bannerSrc]);
43
+
44
+ const { isDark, primaryColor } = useTheme();
45
+ const bannerSrcdoc = useMemo(() => {
46
+ if (!bannerHtml) return "";
47
+ // Banner iframes are size-constrained by the parent (5:2 aspect ratio),
48
+ // so we render them edge-to-edge: no body padding, full height chain.
49
+ // primaryColor must be forwarded so banners using `--primary-color`
50
+ // pick up the article's accent (otherwise they fall back to the
51
+ // generic #4e79a7 from buildEmbedSrcdoc).
52
+ return buildDoc(bannerHtml, { isDark, primaryColor, fullBleed: true });
53
+ }, [bannerHtml, isDark, primaryColor]);
54
+
55
+ const handleBannerUpload = async (file: File) => {
56
+ if (!embedStore || !file.type.startsWith("image/")) return;
57
+ setUploadingBanner(true);
58
+ try {
59
+ const url = await uploadImage(file);
60
+ embedStore.set(BANNER_KEY, makeImageBannerHtml(url));
61
+ update("banner", BANNER_KEY);
62
+ } catch (err) {
63
+ console.error("[banner-upload]", err);
64
+ } finally {
65
+ setUploadingBanner(false);
66
+ }
67
+ };
68
+
69
+ const handleCreateChart = () => {
70
+ if (!embedStore) return;
71
+ embedStore.set(BANNER_KEY, CHART_PLACEHOLDER_HTML);
72
+ update("banner", BANNER_KEY);
73
+ window.dispatchEvent(
74
+ new CustomEvent("open-embed-studio", { detail: { src: BANNER_KEY } }),
75
+ );
76
+ };
77
+
78
+ const handleRemoveBanner = () => {
79
+ if (!embedStore) return;
80
+ embedStore.remove(bannerSrc || BANNER_KEY);
81
+ update("banner", "");
82
+ };
83
+
84
  if (!data) {
85
  return <div className="hero" />;
86
  }
 
88
  const hasAuthors = data.authors.length > 0;
89
  const hasAffiliations = data.affiliations.length > 0;
90
  const multipleAffiliations = data.affiliations.length > 1;
 
91
 
92
  return (
93
  <div>
 
100
  multiline
101
  />
102
 
103
+ {bannerSrcdoc ? (
104
+ <div className="hero-banner hero-banner--filled">
105
+ <iframe
106
+ srcDoc={bannerSrcdoc}
107
+ title="Banner"
108
+ sandbox="allow-scripts allow-same-origin"
109
+ style={{ width: "100%", height: "100%", border: "none", display: "block", background: "transparent" }}
110
+ />
111
+ {embedStore && (
112
+ <Tooltip title="Remove banner">
113
+ <button
114
+ type="button"
115
+ className="hero-banner-remove"
116
+ onClick={handleRemoveBanner}
117
+ aria-label="Remove banner"
118
+ >
119
+ <Trash2 size={14} />
120
+ </button>
121
+ </Tooltip>
122
+ )}
123
+ </div>
124
+ ) : embedStore ? (
125
+ <div className="hero-banner hero-banner--empty" aria-label="Banner placeholder">
126
+ <input
127
+ ref={bannerFileRef}
128
+ type="file"
129
+ accept="image/*"
130
+ style={{ display: "none" }}
131
+ onChange={(e) => {
132
+ const file = e.target.files?.[0];
133
+ if (file) handleBannerUpload(file);
134
+ e.target.value = "";
135
+ }}
136
+ />
137
+ <div className="hero-banner-empty__inner">
138
+ <p className="hero-banner-empty__hint">
139
+ Add a banner to set the mood of your article.
140
+ </p>
141
+ <div className="hero-banner-empty__actions">
142
+ <button
143
+ type="button"
144
+ className="btn"
145
+ disabled={uploadingBanner}
146
+ onClick={() => bannerFileRef.current?.click()}
147
+ >
148
+ <ImagePlus size={16} />
149
+ <span>{uploadingBanner ? "Uploading..." : "Upload image"}</span>
150
+ </button>
151
+ <button
152
+ type="button"
153
+ className="btn"
154
+ onClick={handleCreateChart}
155
+ >
156
+ <BarChart3 size={16} />
157
+ <span>Create chart</span>
158
+ </button>
159
+ </div>
160
+ </div>
161
+ </div>
162
+ ) : null}
163
+
164
  <EditableText
165
  value={data.subtitle}
166
  placeholder="Subtitle (optional)"
167
  onChange={(v) => update("subtitle", v)}
168
  className="hero-desc"
169
+ multiline
170
  />
171
  </section>
172
 
173
+ <header className="meta" aria-label="Article meta information">
174
+ <div className="meta-container">
175
+ <div className="meta-container-cell">
176
+ <h3>Authors</h3>
177
+ {hasAuthors ? (
178
  <ul className="authors">
179
  {data.authors.map((author, i) => (
180
  <li key={`author-${i}`}>
 
209
  </Tooltip>
210
  </li>
211
  </ul>
212
+ ) : (
213
+ <button
214
+ className="meta-placeholder-btn"
215
+ onClick={() => setShowAuthorForm(true)}
216
+ >
217
+ <Plus size={14} />
218
+ <span>Add author</span>
219
+ </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
220
  )}
221
+ </div>
222
 
223
+ {hasAffiliations && (
224
  <div className="meta-container-cell">
225
+ <h3>Affiliations</h3>
226
+ {multipleAffiliations ? (
227
+ <ol className="affiliations">
228
+ {data.affiliations.map((aff, i) => (
229
+ <li key={`aff-${i}`}>
230
+ {aff.url ? (
231
+ <a href={aff.url} target="_blank" rel="noopener noreferrer">
232
+ {aff.name}
233
+ </a>
234
+ ) : (
235
+ aff.name
236
+ )}
237
+ </li>
238
+ ))}
239
+ </ol>
240
+ ) : (
241
+ <p>
242
+ {data.affiliations[0].url ? (
243
+ <a href={data.affiliations[0].url} target="_blank" rel="noopener noreferrer">
244
+ {data.affiliations[0].name}
245
+ </a>
246
+ ) : (
247
+ data.affiliations[0].name
248
+ )}
249
+ </p>
250
+ )}
251
  </div>
252
+ )}
253
 
254
+ <div className="meta-container-cell">
255
+ <h3>Published</h3>
256
+ <EditableText
257
+ value={data.published}
258
+ placeholder="Date"
259
+ onChange={(v) => update("published", v)}
260
+ inline
261
+ />
 
262
  </div>
263
+ </div>
264
+ </header>
265
 
266
+ <AuthorInlineForm
267
+ open={showAuthorForm}
268
+ affiliations={data.affiliations}
269
+ onSubmit={(author, newAff) => {
270
+ if (newAff) {
271
+ const idx = store!.addAffiliation(newAff);
272
+ author.affiliations = [...author.affiliations, idx];
273
+ }
274
+ store?.addAuthor(author);
275
+ setShowAuthorForm(false);
276
+ }}
277
+ onCancel={() => setShowAuthorForm(false)}
278
+ />
 
 
279
 
280
+ <div style={{ maxWidth: 680, margin: "0 auto", padding: "0 16px" }}>
281
  {editingAuthorIdx !== null && data.authors[editingAuthorIdx] && (
282
  <AuthorInlineForm
283
+ open={true}
284
  initial={data.authors[editingAuthorIdx]}
285
  affiliations={data.affiliations}
286
  onSubmit={(author, newAff) => {
 
309
  }
310
 
311
  function EditableText({ value, placeholder, onChange, className, multiline, inline }: EditableTextProps) {
 
312
  const [draft, setDraft] = useState(value);
313
  const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null);
314
 
315
  useEffect(() => {
316
+ setDraft(value);
317
+ }, [value]);
318
 
319
  useEffect(() => {
320
+ if (multiline && inputRef.current instanceof HTMLTextAreaElement) {
321
+ autoResize(inputRef.current);
 
 
 
 
322
  }
323
+ }, [draft, multiline]);
324
+
325
+ const autoResize = (el: HTMLTextAreaElement) => {
326
+ el.style.height = "0";
327
+ el.style.height = `${el.scrollHeight}px`;
328
+ };
329
 
330
  const commit = () => {
 
331
  if (draft !== value) onChange(draft);
332
  };
333
 
334
  const handleKeyDown = (e: KeyboardEvent) => {
335
  if (e.key === "Enter" && !multiline) {
336
  e.preventDefault();
337
+ (e.target as HTMLElement).blur();
338
  }
339
  if (e.key === "Escape") {
340
  setDraft(value);
341
+ (e.target as HTMLElement).blur();
342
  }
343
  };
344
 
345
+ const Tag = multiline ? "textarea" : "input";
346
+ const inputCls = inline
347
+ ? "editable-text-input editable-text-input--inline"
348
+ : "editable-text-input";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
349
 
350
  return (
351
  <span
352
  className={`editable-text ${className || ""}`}
353
+ style={{ display: inline ? "inline" : "block" }}
 
 
 
 
 
354
  >
355
+ <Tag
356
+ ref={inputRef as any}
357
+ className={inputCls}
358
+ value={draft}
359
+ onChange={(e) => setDraft(e.target.value)}
360
+ onBlur={commit}
361
+ onKeyDown={handleKeyDown}
362
+ placeholder={placeholder}
363
+ rows={multiline ? 1 : undefined}
364
+ />
365
  </span>
366
  );
367
  }
368
 
369
  function AuthorInlineForm({
370
+ open,
371
  initial,
372
  affiliations,
373
  onSubmit,
374
  onCancel,
375
  }: {
376
+ open: boolean;
377
  initial?: Author;
378
  affiliations: Affiliation[];
379
  onSubmit: (author: Author, newAffiliation?: Affiliation) => void;
 
384
  const [affIndices, setAffIndices] = useState<number[]>(initial?.affiliations || []);
385
  const [newAffName, setNewAffName] = useState("");
386
  const nameRef = useRef<HTMLInputElement>(null);
387
+ const dialogRef = useRef<HTMLDialogElement>(null);
388
 
389
  useEffect(() => {
390
+ const dialog = dialogRef.current;
391
+ if (!dialog) return;
392
+ if (open && !dialog.open) {
393
+ setName(initial?.name || "");
394
+ setUrl(initial?.url || "");
395
+ setAffIndices(initial?.affiliations || []);
396
+ setNewAffName("");
397
+ dialog.showModal();
398
+ setTimeout(() => nameRef.current?.focus(), 50);
399
+ } else if (!open && dialog.open) {
400
+ dialog.close();
401
+ }
402
+ }, [open, initial]);
403
+
404
+ useEffect(() => {
405
+ const dialog = dialogRef.current;
406
+ if (!dialog) return;
407
+ const onClose = () => onCancel();
408
+ dialog.addEventListener("close", onClose);
409
+ return () => dialog.removeEventListener("close", onClose);
410
+ }, [onCancel]);
411
 
412
  const handleSubmit = () => {
413
  if (!name.trim()) return;
 
418
  );
419
  };
420
 
421
+ const handleCancel = () => {
422
+ dialogRef.current?.close();
423
+ onCancel();
424
+ };
425
+
426
  const toggleAff = (idx: number) => {
427
  setAffIndices((prev) =>
428
  prev.includes(idx) ? prev.filter((i) => i !== idx) : [...prev, idx],
 
430
  };
431
 
432
  return (
433
+ <dialog ref={dialogRef} className="ed-dialog ed-dialog--author">
434
+ <h3 className="ed-dialog__title">
435
  {initial ? "Edit author" : "Add author"}
436
+ </h3>
437
+ <div className="ed-dialog__body">
438
+ <div className="author-form__row">
439
+ <input
440
+ ref={nameRef}
441
+ className="form-input"
442
+ placeholder="Name"
443
+ value={name}
444
+ onChange={(e) => setName(e.target.value)}
445
+ onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
446
+ />
447
+ <input
448
+ className="form-input"
449
+ placeholder="URL (optional)"
450
+ value={url}
451
+ onChange={(e) => setUrl(e.target.value)}
452
+ onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
453
+ />
454
+ </div>
455
+
456
+ {affiliations.length > 0 && (
457
+ <div className="author-form__chips" style={{ marginTop: 12 }}>
458
+ {affiliations.map((aff, i) => (
459
+ <span
460
+ key={`aff-select-${i}`}
461
+ className={`chip chip--clickable ${affIndices.includes(i + 1) ? "" : "chip--outlined"}`}
462
+ style={affIndices.includes(i + 1) ? { background: "var(--primary-color)", color: "#000" } : {}}
463
+ onClick={() => toggleAff(i + 1)}
464
+ >
465
+ {i + 1}. {aff.name}
466
+ </span>
467
+ ))}
468
+ </div>
469
+ )}
470
 
 
 
 
 
 
 
 
 
 
471
  <input
472
  className="form-input"
473
+ placeholder="New affiliation (optional), e.g. Hugging Face"
474
+ value={newAffName}
475
+ onChange={(e) => setNewAffName(e.target.value)}
476
  onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
477
+ style={{ marginTop: 12 }}
478
  />
479
  </div>
480
+ <div className="ed-dialog__actions">
481
+ <button className="btn" onClick={handleCancel}>Cancel</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
482
  <button
483
  className="btn btn--primary"
484
  onClick={handleSubmit}
 
487
  {initial ? "Save" : "Add"}
488
  </button>
489
  </div>
490
+ </dialog>
491
  );
492
  }
frontend/src/editor/frontmatter/HueSlider.tsx CHANGED
@@ -1,7 +1,5 @@
1
  import { useRef, useState, useCallback } from "react";
2
-
3
- const L = 0.75;
4
- const C = 0.12;
5
 
6
  const COLOR_NAMES = [
7
  { name: "Candy Apple Red", hue: 0 },
@@ -87,7 +85,7 @@ export function HueSlider({ hue, onChange }: HueSliderProps) {
87
  const sliderRef = useRef<HTMLDivElement>(null);
88
  const [dragging, setDragging] = useState(false);
89
 
90
- const hexColor = oklchToHex(L, C, hue);
91
  const colorName = getColorName(hue);
92
 
93
  const hueFromClientX = useCallback((clientX: number) => {
@@ -184,4 +182,4 @@ export function HueSlider({ hue, onChange }: HueSliderProps) {
184
  );
185
  }
186
 
187
- export { oklchToHex, L as OKLCH_L, C as OKLCH_C };
 
1
  import { useRef, useState, useCallback } from "react";
2
+ import { OKLCH_L, OKLCH_C } from "#shared/theme";
 
 
3
 
4
  const COLOR_NAMES = [
5
  { name: "Candy Apple Red", hue: 0 },
 
85
  const sliderRef = useRef<HTMLDivElement>(null);
86
  const [dragging, setDragging] = useState(false);
87
 
88
+ const hexColor = oklchToHex(OKLCH_L, OKLCH_C, hue);
89
  const colorName = getColorName(hue);
90
 
91
  const hueFromClientX = useCallback((clientX: number) => {
 
182
  );
183
  }
184
 
185
+ export { oklchToHex };
frontend/src/editor/frontmatter/SettingsDrawer.tsx CHANGED
@@ -130,6 +130,15 @@ export function SettingsDrawer({ open, onClose, store, settingsMap }: SettingsDr
130
 
131
  <hr className="divider-h" />
132
 
 
 
 
 
 
 
 
 
 
133
  <FieldGroup label="Licence">
134
  <textarea
135
  className="form-input"
 
130
 
131
  <hr className="divider-h" />
132
 
133
+ <FieldGroup label="DOI">
134
+ <input
135
+ className="form-input"
136
+ placeholder="10.xxxx/xxxxx"
137
+ value={data.doi}
138
+ onChange={(e) => update("doi", e.target.value)}
139
+ />
140
+ </FieldGroup>
141
+
142
  <FieldGroup label="Licence">
143
  <textarea
144
  className="form-input"
frontend/src/editor/frontmatter/frontmatter-store.ts CHANGED
@@ -95,21 +95,25 @@ export function createFrontmatterStore(ydoc: Y.Doc) {
95
 
96
  function setAll(data: Partial<FrontmatterData>) {
97
  ydoc.transact(() => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  for (const [key, value] of Object.entries(data)) {
99
  if (value === undefined) continue;
100
-
101
- if (key === "authors" && Array.isArray(value)) {
102
- yAuthors.delete(0, yAuthors.length);
103
- yAuthors.push(value);
104
- } else if (key === "affiliations" && Array.isArray(value)) {
105
- yAffiliations.delete(0, yAffiliations.length);
106
- yAffiliations.push(value);
107
- } else if (key === "links" && Array.isArray(value)) {
108
- yLinks.delete(0, yLinks.length);
109
- yLinks.push(value);
110
- } else {
111
- ymap.set(key, value);
112
- }
113
  }
114
  });
115
  }
 
95
 
96
  function setAll(data: Partial<FrontmatterData>) {
97
  ydoc.transact(() => {
98
+ // `Object.entries` widens the value type to the full union of fields, so
99
+ // we narrow manually per key before pushing into the typed Y.Array.
100
+ if (data.authors !== undefined) {
101
+ yAuthors.delete(0, yAuthors.length);
102
+ yAuthors.push(data.authors);
103
+ }
104
+ if (data.affiliations !== undefined) {
105
+ yAffiliations.delete(0, yAffiliations.length);
106
+ yAffiliations.push(data.affiliations);
107
+ }
108
+ if (data.links !== undefined) {
109
+ yLinks.delete(0, yLinks.length);
110
+ yLinks.push(data.links);
111
+ }
112
+
113
  for (const [key, value] of Object.entries(data)) {
114
  if (value === undefined) continue;
115
+ if (key === "authors" || key === "affiliations" || key === "links") continue;
116
+ ymap.set(key, value);
 
 
 
 
 
 
 
 
 
 
 
117
  }
118
  });
119
  }
frontend/src/editor/frontmatter/seed-frontmatter.ts CHANGED
@@ -1,12 +1,15 @@
1
  import type { FrontmatterStore } from "./frontmatter-store";
2
 
3
  /**
4
- * Populate frontmatter with demo data when a new document is created.
5
- * Only writes if the title is empty (avoids overwriting existing docs).
 
6
  */
7
- export function seedFrontmatter(store: FrontmatterStore) {
8
- if (store.get("title")) return;
9
- if ((store.get("authors") as any[]).length > 0) return;
 
 
10
 
11
  store.setAll({
12
  title: "Scaling Laws for Neural Language Models",
@@ -15,8 +18,15 @@ export function seedFrontmatter(store: FrontmatterStore) {
15
  published: "Apr. 13, 2026",
16
  doi: "10.1234/research-article-template-editor.2026",
17
  template: "article",
 
 
18
  });
19
 
 
 
 
 
 
20
  store.addAffiliation({ name: "Hugging Face", url: "https://huggingface.co" });
21
  store.addAffiliation({ name: "MIT", url: "https://mit.edu" });
22
 
 
1
  import type { FrontmatterStore } from "./frontmatter-store";
2
 
3
  /**
4
+ * Populate frontmatter with demo data.
5
+ * When force=true (explicit user action), overwrites existing data.
6
+ * When force=false, skips if the document already has content.
7
  */
8
+ export function seedFrontmatter(store: FrontmatterStore, force = false) {
9
+ if (!force) {
10
+ if (store.get("title")) return;
11
+ if ((store.get("authors") as any[]).length > 0) return;
12
+ }
13
 
14
  store.setAll({
15
  title: "Scaling Laws for Neural Language Models",
 
18
  published: "Apr. 13, 2026",
19
  doi: "10.1234/research-article-template-editor.2026",
20
  template: "article",
21
+ banner: "banner.html",
22
+ tableOfContentsAutoCollapse: true,
23
  });
24
 
25
+ if (force) {
26
+ store.set("authors", []);
27
+ store.set("affiliations", []);
28
+ }
29
+
30
  store.addAffiliation({ name: "Hugging Face", url: "https://huggingface.co" });
31
  store.addAffiliation({ name: "MIT", url: "https://mit.edu" });
32
 
frontend/src/hooks/useAgentChat.ts CHANGED
@@ -3,7 +3,7 @@ import {
3
  DefaultChatTransport,
4
  lastAssistantMessageIsCompleteWithToolCalls,
5
  } from "ai";
6
- import { useCallback, useRef, useState } from "react";
7
  import type { Editor } from "@tiptap/core";
8
  import type { UndoManager } from "yjs";
9
  import type { UIMessage } from "ai";
@@ -13,14 +13,19 @@ interface UseAgentChatOptions {
13
  editor: Editor | null;
14
  undoManager: UndoManager | null;
15
  frontmatterStore: FrontmatterStore | null;
 
 
 
16
  }
17
 
18
  const transport = new DefaultChatTransport({ api: "/api/chat" });
19
 
20
- export function useAgentChat({ editor, undoManager, frontmatterStore }: UseAgentChatOptions) {
21
  const pendingSelectionRef = useRef<{ from: number; to: number } | null>(null);
22
  const agentBatchActiveRef = useRef(false);
23
  const [input, setInput] = useState("");
 
 
24
 
25
  const getEditorContext = useCallback(() => {
26
  if (!editor) return {};
@@ -28,7 +33,7 @@ export function useAgentChat({ editor, undoManager, frontmatterStore }: UseAgent
28
  const { from, to } = editor.state.selection;
29
  const hasSelection = from !== to;
30
 
31
- const document = editor.getText("\n");
32
  const selection = hasSelection
33
  ? editor.state.doc.textBetween(from, to, "\n")
34
  : undefined;
@@ -255,6 +260,7 @@ export function useAgentChat({ editor, undoManager, frontmatterStore }: UseAgent
255
 
256
  const { addToolOutput, ...chat } = useChat({
257
  transport,
 
258
  sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
259
 
260
  async onToolCall({ toolCall }) {
@@ -266,7 +272,8 @@ export function useAgentChat({ editor, undoManager, frontmatterStore }: UseAgent
266
  toolCallId: toolCall.toolCallId,
267
  });
268
 
269
- addToolOutput({
 
270
  toolCallId: toolCall.toolCallId,
271
  output: result,
272
  });
@@ -282,13 +289,25 @@ export function useAgentChat({ editor, undoManager, frontmatterStore }: UseAgent
282
  },
283
  });
284
 
 
 
 
 
 
 
 
 
 
 
 
285
  const sendMessage = useCallback(
286
  (content: string) => {
287
  endAgentBatch();
288
  const context = getEditorContext();
289
- chat.sendMessage({ text: content }, { body: { context } });
 
290
  },
291
- [chat, getEditorContext, endAgentBatch],
292
  );
293
 
294
  const sendQuickAction = useCallback(
@@ -311,20 +330,32 @@ export function useAgentChat({ editor, undoManager, frontmatterStore }: UseAgent
311
  };
312
 
313
  const prompt = prompts[action] || action;
314
- chat.sendMessage({ text: prompt }, { body: { context } });
 
315
  },
316
- [chat, getEditorContext, endAgentBatch],
317
  );
318
 
319
  const isLoading =
320
  chat.status === "streaming" || chat.status === "submitted";
321
 
 
 
 
 
 
 
 
 
 
322
  return {
323
  messages: chat.messages as UIMessage[],
324
  isLoading,
325
  error: chat.error,
326
  sendMessage,
327
  sendQuickAction,
 
 
328
  input,
329
  setInput,
330
  stop: chat.stop,
 
3
  DefaultChatTransport,
4
  lastAssistantMessageIsCompleteWithToolCalls,
5
  } from "ai";
6
+ import { useCallback, useEffect, useRef, useState } from "react";
7
  import type { Editor } from "@tiptap/core";
8
  import type { UndoManager } from "yjs";
9
  import type { UIMessage } from "ai";
 
13
  editor: Editor | null;
14
  undoManager: UndoManager | null;
15
  frontmatterStore: FrontmatterStore | null;
16
+ modelRef: React.RefObject<string>;
17
+ initialMessages?: UIMessage[];
18
+ onMessagesChange?: (messages: UIMessage[]) => void;
19
  }
20
 
21
  const transport = new DefaultChatTransport({ api: "/api/chat" });
22
 
23
+ export function useAgentChat({ editor, undoManager, frontmatterStore, modelRef, initialMessages, onMessagesChange }: UseAgentChatOptions) {
24
  const pendingSelectionRef = useRef<{ from: number; to: number } | null>(null);
25
  const agentBatchActiveRef = useRef(false);
26
  const [input, setInput] = useState("");
27
+ const onMessagesChangeRef = useRef(onMessagesChange);
28
+ onMessagesChangeRef.current = onMessagesChange;
29
 
30
  const getEditorContext = useCallback(() => {
31
  if (!editor) return {};
 
33
  const { from, to } = editor.state.selection;
34
  const hasSelection = from !== to;
35
 
36
+ const document = editor.getText({ blockSeparator: "\n" });
37
  const selection = hasSelection
38
  ? editor.state.doc.textBetween(from, to, "\n")
39
  : undefined;
 
260
 
261
  const { addToolOutput, ...chat } = useChat({
262
  transport,
263
+ messages: initialMessages,
264
  sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
265
 
266
  async onToolCall({ toolCall }) {
 
272
  toolCallId: toolCall.toolCallId,
273
  });
274
 
275
+ (addToolOutput as (args: { tool: string; toolCallId: string; output: unknown }) => void)({
276
+ tool: toolCall.toolName as string,
277
  toolCallId: toolCall.toolCallId,
278
  output: result,
279
  });
 
289
  },
290
  });
291
 
292
+ // Persist when a conversation round completes (status back to "ready")
293
+ const prevStatusRef = useRef(chat.status);
294
+ useEffect(() => {
295
+ const wasActive = prevStatusRef.current === "streaming" || prevStatusRef.current === "submitted";
296
+ prevStatusRef.current = chat.status;
297
+
298
+ if (wasActive && chat.status === "ready" && chat.messages.length > 0) {
299
+ onMessagesChangeRef.current?.(chat.messages as UIMessage[]);
300
+ }
301
+ }, [chat.status, chat.messages]);
302
+
303
  const sendMessage = useCallback(
304
  (content: string) => {
305
  endAgentBatch();
306
  const context = getEditorContext();
307
+ const model = modelRef.current;
308
+ chat.sendMessage({ text: content }, { body: { context, model } });
309
  },
310
+ [chat, getEditorContext, endAgentBatch, modelRef],
311
  );
312
 
313
  const sendQuickAction = useCallback(
 
330
  };
331
 
332
  const prompt = prompts[action] || action;
333
+ const model = modelRef.current;
334
+ chat.sendMessage({ text: prompt }, { body: { context, model } });
335
  },
336
+ [chat, getEditorContext, endAgentBatch, modelRef],
337
  );
338
 
339
  const isLoading =
340
  chat.status === "streaming" || chat.status === "submitted";
341
 
342
+ const clearMessages = useCallback(() => {
343
+ chat.setMessages([]);
344
+ onMessagesChangeRef.current?.([]);
345
+ }, [chat]);
346
+
347
+ const setMessages = useCallback((msgs: UIMessage[]) => {
348
+ chat.setMessages(msgs);
349
+ }, [chat]);
350
+
351
  return {
352
  messages: chat.messages as UIMessage[],
353
  isLoading,
354
  error: chat.error,
355
  sendMessage,
356
  sendQuickAction,
357
+ clearMessages,
358
+ setMessages,
359
  input,
360
  setInput,
361
  stop: chat.stop,
frontend/src/hooks/useEmbedChat.ts ADDED
@@ -0,0 +1,221 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useChat } from "@ai-sdk/react";
2
+ import {
3
+ DefaultChatTransport,
4
+ lastAssistantMessageIsCompleteWithToolCalls,
5
+ } from "ai";
6
+ import { useCallback, useEffect, useRef, useState } from "react";
7
+ import type { UIMessage } from "ai";
8
+ import type { EmbedStore } from "../editor/embeds/embed-store";
9
+ import { loadMessages, saveMessages } from "../utils/chat-persistence";
10
+
11
+ interface UseEmbedChatOptions {
12
+ embedStore: EmbedStore | null;
13
+ /** Current embed src key (filename) */
14
+ src: string;
15
+ modelRef: React.RefObject<string>;
16
+ userId: string;
17
+ }
18
+
19
+ const MAX_EMBED_SIZE = 512_000;
20
+
21
+ const transport = new DefaultChatTransport({ api: "/api/embed-chat" });
22
+
23
+ function scopeKey(src: string): string {
24
+ return `embed:${src}`;
25
+ }
26
+
27
+ export function useEmbedChat({ embedStore, src, modelRef, userId }: UseEmbedChatOptions) {
28
+ const [input, setInput] = useState("");
29
+ const srcRef = useRef(src);
30
+ srcRef.current = src;
31
+ const userIdRef = useRef(userId);
32
+ userIdRef.current = userId;
33
+
34
+ // Guard: only allow sendAutomaticallyWhen AFTER the user has sent at
35
+ // least one message in this session. Prevents auto-send on mount when
36
+ // restored messages end with completed tool calls.
37
+ const userHasSentRef = useRef(false);
38
+
39
+ const getEmbedContext = useCallback(() => {
40
+ if (!embedStore || !srcRef.current) return {};
41
+ const embedHtml = embedStore.get(srcRef.current);
42
+ const isBanner = srcRef.current === "banner.html";
43
+ return {
44
+ embedHtml: embedHtml || undefined,
45
+ isBanner,
46
+ };
47
+ }, [embedStore]);
48
+
49
+ const executeToolCall = useCallback(
50
+ (toolCall: { toolName: string; args: unknown; toolCallId: string }) => {
51
+ if (!embedStore) return "Embed store not available";
52
+ const currentSrc = srcRef.current;
53
+ if (!currentSrc) return "No embed src specified";
54
+
55
+ switch (toolCall.toolName) {
56
+ case "createEmbed": {
57
+ const { html, title } = toolCall.args as {
58
+ html: string;
59
+ title?: string;
60
+ source?: string;
61
+ };
62
+ if (!html?.trim()) return "ERROR: html cannot be empty";
63
+ const stripped = html.trim();
64
+ if (stripped.length > MAX_EMBED_SIZE) {
65
+ return `ERROR: chart too large (${stripped.length} bytes, max ${MAX_EMBED_SIZE}). Simplify the code.`;
66
+ }
67
+ embedStore.set(currentSrc, stripped);
68
+ const lines = stripped.split("\n").length;
69
+ return `chart created (${lines} lines, ${stripped.length} chars)${title ? ` - "${title}"` : ""}`;
70
+ }
71
+
72
+ case "patchEmbed": {
73
+ const { search, replace } = toolCall.args as {
74
+ search: string;
75
+ replace: string;
76
+ };
77
+ const current = embedStore.get(currentSrc);
78
+ if (!current) {
79
+ return "ERROR: no chart exists yet. Use createEmbed first.";
80
+ }
81
+ if (!search) {
82
+ return "ERROR: search block cannot be empty";
83
+ }
84
+ if (!current.includes(search)) {
85
+ return (
86
+ "ERROR: search block not found in current chart. " +
87
+ "Call readEmbed to see the exact current content, then retry."
88
+ );
89
+ }
90
+ const patched = current.replace(search, replace);
91
+ if (patched.length > MAX_EMBED_SIZE) {
92
+ return `ERROR: patch would exceed size limit (${patched.length} bytes, max ${MAX_EMBED_SIZE}).`;
93
+ }
94
+ embedStore.set(currentSrc, patched);
95
+ return "patch applied successfully";
96
+ }
97
+
98
+ case "readEmbed": {
99
+ const current = embedStore.get(currentSrc);
100
+ if (!current) return "(no chart yet)";
101
+ return current;
102
+ }
103
+
104
+ default:
105
+ return `Unknown tool: ${toolCall.toolName}`;
106
+ }
107
+ },
108
+ [embedStore],
109
+ );
110
+
111
+ const { addToolOutput, setMessages: chatSetMessages, ...chat } = useChat({
112
+ transport,
113
+ // No initialMessages - we restore via setMessages below to match
114
+ // the proven pattern used by the global chat.
115
+ sendAutomaticallyWhen(ctx) {
116
+ if (!userHasSentRef.current) return false;
117
+ return lastAssistantMessageIsCompleteWithToolCalls(ctx);
118
+ },
119
+
120
+ async onToolCall({ toolCall }) {
121
+ if (toolCall.dynamic) return;
122
+
123
+ const result = executeToolCall({
124
+ toolName: toolCall.toolName as string,
125
+ args: toolCall.input,
126
+ toolCallId: toolCall.toolCallId,
127
+ });
128
+
129
+ (addToolOutput as (args: { tool: string; toolCallId: string; output: unknown }) => void)({
130
+ tool: toolCall.toolName as string,
131
+ toolCallId: toolCall.toolCallId,
132
+ output: result,
133
+ });
134
+ },
135
+
136
+ onError(error) {
137
+ console.error("[embed-chat] error:", error);
138
+ },
139
+ });
140
+
141
+ // --- Restore persisted messages on mount (same pattern as global chat) ---
142
+ const chatSetMessagesRef = useRef(chatSetMessages);
143
+ chatSetMessagesRef.current = chatSetMessages;
144
+
145
+ useEffect(() => {
146
+ const key = scopeKey(src);
147
+ const stored = loadMessages(userId, key);
148
+ if (stored && stored.length > 0) {
149
+ chatSetMessagesRef.current(stored);
150
+ }
151
+ // Only run once on mount (src/userId are stable per keyed instance)
152
+ // eslint-disable-next-line react-hooks/exhaustive-deps
153
+ }, []);
154
+
155
+ // --- Persistence: save to localStorage ---------------------------------
156
+ const messagesRef = useRef(chat.messages);
157
+ messagesRef.current = chat.messages;
158
+
159
+ const persist = useCallback(() => {
160
+ const msgs = messagesRef.current as UIMessage[];
161
+ const key = scopeKey(srcRef.current);
162
+ const uid = userIdRef.current;
163
+ if (msgs.length > 0) {
164
+ saveMessages(uid, key, msgs);
165
+ } else {
166
+ saveMessages(uid, key, []);
167
+ }
168
+ }, []);
169
+
170
+ // Save after each completed round (streaming/submitted -> ready)
171
+ const prevStatusRef = useRef(chat.status);
172
+ useEffect(() => {
173
+ const prev = prevStatusRef.current;
174
+ const curr = chat.status;
175
+ prevStatusRef.current = curr;
176
+
177
+ const wasActive = prev === "streaming" || prev === "submitted";
178
+ if (wasActive && curr === "ready" && chat.messages.length > 0) {
179
+ persist();
180
+ }
181
+ }, [chat.status, chat.messages, persist]);
182
+
183
+ // Save on unmount (covers close-while-streaming)
184
+ useEffect(() => () => persist(), [persist]);
185
+
186
+ const sendMessage = useCallback(
187
+ (content: string) => {
188
+ userHasSentRef.current = true;
189
+ const context = getEmbedContext();
190
+ const model = modelRef.current;
191
+ chat.sendMessage({ text: content }, { body: { context, model } });
192
+ },
193
+ [chat, getEmbedContext, modelRef],
194
+ );
195
+
196
+ const isLoading =
197
+ chat.status === "streaming" || chat.status === "submitted";
198
+
199
+ const clearMessages = useCallback(() => {
200
+ chatSetMessages([]);
201
+ saveMessages(userIdRef.current, scopeKey(srcRef.current), []);
202
+ }, [chatSetMessages]);
203
+
204
+ return {
205
+ messages: chat.messages as UIMessage[],
206
+ isLoading,
207
+ error: chat.error,
208
+ sendMessage,
209
+ clearMessages,
210
+ input,
211
+ setInput,
212
+ stop: chat.stop,
213
+ /**
214
+ * Imperative setter on the underlying chat. Exposed so dev-only demo
215
+ * scripts can inject a fake assistant reply (used to record the demo
216
+ * video without waiting for a real LLM round-trip). DO NOT use in
217
+ * production code paths.
218
+ */
219
+ setMessages: chatSetMessages,
220
+ };
221
+ }
frontend/src/hooks/usePublishStatus.ts ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState } from "react";
2
+ import type * as Y from "yjs";
3
+
4
+ export interface PublishStatus {
5
+ active: boolean;
6
+ userName: string | null;
7
+ startedAt: number | null;
8
+ jobId: string | null;
9
+ }
10
+
11
+ const INACTIVE: PublishStatus = {
12
+ active: false,
13
+ userName: null,
14
+ startedAt: null,
15
+ jobId: null,
16
+ };
17
+
18
+ /**
19
+ * Observe the `publish-status` Y.Map on a document. The backend populates
20
+ * this map at the start and end of a publish so that all connected editors
21
+ * can disable their Publish button while a publish is running.
22
+ *
23
+ * `doc` may be `null` while the provider is still connecting; the hook simply
24
+ * returns an inactive status in that case and re-subscribes when `doc` is set.
25
+ */
26
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
27
+ export function usePublishStatus(doc: Y.Doc | null): PublishStatus {
28
+ const [status, setStatus] = useState<PublishStatus>(INACTIVE);
29
+
30
+ useEffect(() => {
31
+ if (!doc) {
32
+ setStatus(INACTIVE);
33
+ return;
34
+ }
35
+ const map = doc.getMap("publish-status");
36
+ const read = (): PublishStatus => ({
37
+ active: (map.get("active") as boolean) === true,
38
+ userName: (map.get("userName") as string | null | undefined) ?? null,
39
+ startedAt: (map.get("startedAt") as number | null | undefined) ?? null,
40
+ jobId: (map.get("jobId") as string | null | undefined) ?? null,
41
+ });
42
+ setStatus(read());
43
+ const observer = () => setStatus(read());
44
+ map.observe(observer);
45
+ return () => map.unobserve(observer);
46
+ }, [doc]);
47
+
48
+ return status;
49
+ }
frontend/src/hooks/useTheme.ts ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from "react";
2
+
3
+ /**
4
+ * Shared singleton observer for data-theme + --primary-color.
5
+ * Only one MutationObserver runs regardless of how many components subscribe.
6
+ */
7
+
8
+ type ThemeListener = (isDark: boolean, primaryColor: string) => void;
9
+ const listeners = new Set<ThemeListener>();
10
+ let observer: MutationObserver | null = null;
11
+
12
+ let cachedDark = document.documentElement.getAttribute("data-theme") === "dark";
13
+ let cachedColor =
14
+ getComputedStyle(document.documentElement)
15
+ .getPropertyValue("--primary-color")
16
+ .trim() || "#4e79a7";
17
+
18
+ function notify() {
19
+ const dark = document.documentElement.getAttribute("data-theme") === "dark";
20
+ const color =
21
+ getComputedStyle(document.documentElement)
22
+ .getPropertyValue("--primary-color")
23
+ .trim() || "#4e79a7";
24
+ const changed = dark !== cachedDark || color !== cachedColor;
25
+ cachedDark = dark;
26
+ cachedColor = color;
27
+ if (changed) {
28
+ for (const fn of listeners) fn(dark, color);
29
+ }
30
+ }
31
+
32
+ function subscribe(fn: ThemeListener): () => void {
33
+ listeners.add(fn);
34
+ if (!observer) {
35
+ observer = new MutationObserver(notify);
36
+ observer.observe(document.documentElement, {
37
+ attributes: true,
38
+ attributeFilter: ["data-theme", "style"],
39
+ });
40
+ }
41
+ return () => {
42
+ listeners.delete(fn);
43
+ if (listeners.size === 0 && observer) {
44
+ observer.disconnect();
45
+ observer = null;
46
+ }
47
+ };
48
+ }
49
+
50
+ export function useTheme(): { isDark: boolean; primaryColor: string } {
51
+ const [isDark, setIsDark] = useState(cachedDark);
52
+ const [primaryColor, setPrimaryColor] = useState(cachedColor);
53
+
54
+ useEffect(() => {
55
+ return subscribe((dark, color) => {
56
+ setIsDark(dark);
57
+ setPrimaryColor(color);
58
+ });
59
+ }, []);
60
+
61
+ return { isDark, primaryColor };
62
+ }
63
+
64
+ export function useIsDark(): boolean {
65
+ const { isDark } = useTheme();
66
+ return isDark;
67
+ }
frontend/src/main.tsx CHANGED
@@ -13,19 +13,28 @@ import "./styles/components/_table.css";
13
  import "./styles/components/_tag.css";
14
  import "./styles/components/_card.css";
15
  import "./styles/components/_mermaid.css";
 
 
16
  import "./styles/components/_hero.css";
17
  import "./styles/components/_toc.css";
18
  import "./styles/components/_button.css";
19
  import "./styles/components/_form.css";
 
20
 
21
  // Editor chrome UI
22
  import "./styles/_ui.css";
23
 
24
- // Editor extensions
25
- import "./styles/_editor-tokens.css";
26
  import "./styles/article.css";
27
  import "./styles/toc.css";
28
- import "./styles/editing.css";
 
 
 
 
 
 
29
 
30
  ReactDOM.createRoot(document.getElementById("root")!).render(
31
  <React.StrictMode>
 
13
  import "./styles/components/_tag.css";
14
  import "./styles/components/_card.css";
15
  import "./styles/components/_mermaid.css";
16
+ import "./styles/components/_embed.css";
17
+ import "./styles/components/_embed-studio.css";
18
  import "./styles/components/_hero.css";
19
  import "./styles/components/_toc.css";
20
  import "./styles/components/_button.css";
21
  import "./styles/components/_form.css";
22
+ import "./styles/components/_footer.css";
23
 
24
  // Editor chrome UI
25
  import "./styles/_ui.css";
26
 
27
+ // Design tokens (light/dark theming for editor + article)
28
+ import "./styles/tokens.css";
29
  import "./styles/article.css";
30
  import "./styles/toc.css";
31
+
32
+ // Editor-only styles (split by concern - loaded after shared styles)
33
+ import "./styles/editor/_layout.css";
34
+ import "./styles/editor/_chrome.css";
35
+ import "./styles/editor/_block-tools.css";
36
+ import "./styles/editor/_panels.css";
37
+ import "./styles/editor/_hero-editable.css";
38
 
39
  ReactDOM.createRoot(document.getElementById("root")!).render(
40
  <React.StrictMode>
frontend/src/styles/_editor-tokens.css DELETED
@@ -1,105 +0,0 @@
1
- /* -----------------------------------------------------------------------
2
- Editor-specific design tokens
3
-
4
- Extends _variables.css with tokens needed only by the collaborative
5
- editor UI (not the published article). Syntax highlighting, hover
6
- states, accent variants, shadows, tooltips, etc.
7
- ----------------------------------------------------------------------- */
8
-
9
- :root {
10
- /* Text variants (editor chrome) */
11
- --text-heading: var(--neutral-900);
12
- --text-heading-secondary: #222;
13
- --text-strong: #000;
14
- --text-faint: #aaa;
15
-
16
- /* Background variants (editor chrome) */
17
- --bg-hover: rgba(0, 0, 0, 0.03);
18
- --bg-overlay: rgba(0, 0, 0, 0.5);
19
- --bg-tooltip: #ffffff;
20
- --bg-code-block: #fafafa;
21
-
22
- /* Border variants */
23
- --border-light: #eeeeee;
24
- --border-focus: rgba(124, 58, 237, 0.5);
25
-
26
- /* Accent (editor interactive elements - links, citations) */
27
- --accent: #7c3aed;
28
- --accent-light: #958DF1;
29
- --accent-bg: rgba(124, 58, 237, 0.08);
30
- --accent-bg-hover: rgba(124, 58, 237, 0.15);
31
- --accent-text: #6d28d9;
32
-
33
- /* Syntax highlighting (One Dark inspired) */
34
- --code-text: #d63384;
35
- --code-comment: #6a737d;
36
- --code-keyword: #8250df;
37
- --code-string: #0a6640;
38
- --code-number: #b35900;
39
- --code-variable: #d63384;
40
- --code-type: #b35900;
41
- --code-function: #0550ae;
42
- --code-tag: #d63384;
43
- --code-attr: #b35900;
44
- --code-symbol: #0a6640;
45
- --code-meta: #0550ae;
46
- --code-deletion: #d63384;
47
-
48
- /* Danger */
49
- --danger-color: #dc2626;
50
- --danger-bg: rgba(220, 38, 38, 0.08);
51
- --danger-bg-hover: rgba(220, 38, 38, 0.15);
52
-
53
- /* Shadows */
54
- --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08);
55
- --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.1);
56
- --shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.12);
57
-
58
- /* KaTeX */
59
- --katex-color: #1a1a1a;
60
- }
61
-
62
- [data-theme="dark"] {
63
- --text-heading: #fff;
64
- --text-heading-secondary: rgba(255, 255, 255, 0.9);
65
- --text-strong: rgba(255, 255, 255, 0.95);
66
- --text-faint: rgba(255, 255, 255, 0.25);
67
-
68
- --bg-hover: rgba(255, 255, 255, 0.03);
69
- --bg-overlay: rgba(0, 0, 0, 0.5);
70
- --bg-tooltip: #1e1e2e;
71
- --bg-code-block: rgba(255, 255, 255, 0.03);
72
-
73
- --border-light: rgba(255, 255, 255, 0.06);
74
- --border-focus: rgba(149, 141, 241, 0.5);
75
-
76
- --accent: #7c3aed;
77
- --accent-light: #958DF1;
78
- --accent-bg: rgba(149, 141, 241, 0.15);
79
- --accent-bg-hover: rgba(149, 141, 241, 0.25);
80
- --accent-text: #b4aef7;
81
-
82
- --code-text: #e06c75;
83
- --code-comment: #5c6370;
84
- --code-keyword: #c678dd;
85
- --code-string: #98c379;
86
- --code-number: #d19a66;
87
- --code-variable: #e06c75;
88
- --code-type: #e5c07b;
89
- --code-function: #61afef;
90
- --code-tag: #e06c75;
91
- --code-attr: #d19a66;
92
- --code-symbol: #56b6c2;
93
- --code-meta: #61afef;
94
- --code-deletion: #e06c75;
95
-
96
- --danger-color: #e06c75;
97
- --danger-bg: rgba(224, 108, 117, 0.12);
98
- --danger-bg-hover: rgba(224, 108, 117, 0.25);
99
-
100
- --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3);
101
- --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
102
- --shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.5);
103
-
104
- --katex-color: rgba(255, 255, 255, 0.85);
105
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/styles/_layout.css CHANGED
@@ -3,13 +3,16 @@
3
  /* ============================================================================ */
4
 
5
  .content-grid {
6
- max-width: 1280px;
7
  margin: 0 auto;
8
  padding: 0 var(--content-padding-x);
9
  margin-top: 40px;
10
  display: grid;
11
- grid-template-columns: 260px minmax(0, 680px) 260px;
12
- gap: 32px;
 
 
 
13
  align-items: start;
14
  }
15
 
@@ -23,14 +26,11 @@
23
  margin-top: 0;
24
  }
25
 
26
- @media (--bp-content-collapse) {
27
  .content-grid {
28
  overflow: hidden;
29
  display: block;
30
  margin-top: var(--spacing-2);
31
- }
32
-
33
- .content-grid {
34
  grid-template-columns: 1fr;
35
  }
36
 
@@ -39,20 +39,10 @@
39
  display: none;
40
  }
41
 
42
- .footer-inner {
43
- grid-template-columns: 1fr;
44
- gap: 16px;
45
- }
46
-
47
- .footer-inner>.footer-heading {
48
- grid-column: auto;
49
- margin-top: 16px;
50
- }
51
-
52
- .footer-inner {
53
- display: block;
54
- padding: 40px 16px;
55
- }
56
  }
57
 
58
 
@@ -109,8 +99,7 @@
109
  text-align: center !important;
110
  }
111
 
112
- @media (--bp-content-collapse) {
113
-
114
  .wide,
115
  .full-width {
116
  width: 100%;
@@ -149,7 +138,7 @@
149
  transform: scale(0.92);
150
  }
151
 
152
- @media (--bp-content-collapse) {
153
  #theme-toggle {
154
  display: none;
155
  }
@@ -157,9 +146,9 @@
157
 
158
  /* ------------------------------------------------------------------------- */
159
  /* Hero meta bar responsiveness */
160
- /* Two columns at collapse breakpoint, then one column below small screens */
161
  /* ------------------------------------------------------------------------- */
162
- @media (--bp-md) {
163
  header.meta .meta-container {
164
  display: flex;
165
  flex-wrap: wrap;
@@ -175,13 +164,12 @@
175
  }
176
  }
177
 
178
- @media (--bp-xxs) {
179
  header.meta .meta-container .meta-container-cell {
180
  flex-basis: 100%;
181
  text-align: center;
182
  }
183
 
184
- /* Center ordered list numbers within meta (e.g., affiliations) */
185
  header.meta .affiliations {
186
  list-style-position: inside;
187
  padding-left: 0;
@@ -195,19 +183,15 @@
195
 
196
 
197
  /* ------------------------------------------------------------------------- */
198
- /* D3 neural embed responsiveness */
199
- /* Stack canvas (left) over network (right) on small screens */
200
  /* ------------------------------------------------------------------------- */
201
- @media (--bp-md) {
202
  .d3-neural .panel {
203
  flex-direction: column;
204
  }
205
 
206
- .d3-neural .panel .left {
207
- flex: 0 0 auto;
208
- width: 100%;
209
- }
210
-
211
  .d3-neural .panel .right {
212
  flex: 0 0 auto;
213
  width: 100%;
 
3
  /* ============================================================================ */
4
 
5
  .content-grid {
6
+ max-width: var(--layout-max-width);
7
  margin: 0 auto;
8
  padding: 0 var(--content-padding-x);
9
  margin-top: 40px;
10
  display: grid;
11
+ grid-template-columns:
12
+ var(--layout-toc-width)
13
+ minmax(0, var(--layout-content-width))
14
+ var(--layout-sidenote-width);
15
+ gap: var(--layout-gap);
16
  align-items: start;
17
  }
18
 
 
26
  margin-top: 0;
27
  }
28
 
29
+ @media (max-width: 1100px) {
30
  .content-grid {
31
  overflow: hidden;
32
  display: block;
33
  margin-top: var(--spacing-2);
 
 
 
34
  grid-template-columns: 1fr;
35
  }
36
 
 
39
  display: none;
40
  }
41
 
42
+ /* Footer responsive styles live in components/_footer.css to keep all
43
+ footer-related selectors in one place (the heading selectors need to
44
+ descend past `display: contents` wrappers, so they cannot use a direct
45
+ child combinator). */
 
 
 
 
 
 
 
 
 
 
46
  }
47
 
48
 
 
99
  text-align: center !important;
100
  }
101
 
102
+ @media (max-width: 1100px) {
 
103
  .wide,
104
  .full-width {
105
  width: 100%;
 
138
  transform: scale(0.92);
139
  }
140
 
141
+ @media (max-width: 1100px) {
142
  #theme-toggle {
143
  display: none;
144
  }
 
146
 
147
  /* ------------------------------------------------------------------------- */
148
  /* Hero meta bar responsiveness */
149
+ /* Two columns at md breakpoint, then one column on very small screens. */
150
  /* ------------------------------------------------------------------------- */
151
+ @media (max-width: 768px) {
152
  header.meta .meta-container {
153
  display: flex;
154
  flex-wrap: wrap;
 
164
  }
165
  }
166
 
167
+ @media (max-width: 320px) {
168
  header.meta .meta-container .meta-container-cell {
169
  flex-basis: 100%;
170
  text-align: center;
171
  }
172
 
 
173
  header.meta .affiliations {
174
  list-style-position: inside;
175
  padding-left: 0;
 
183
 
184
 
185
  /* ------------------------------------------------------------------------- */
186
+ /* D3 neural embed responsiveness */
187
+ /* Stack canvas (left) over network (right) on small screens. */
188
  /* ------------------------------------------------------------------------- */
189
+ @media (max-width: 768px) {
190
  .d3-neural .panel {
191
  flex-direction: column;
192
  }
193
 
194
+ .d3-neural .panel .left,
 
 
 
 
195
  .d3-neural .panel .right {
196
  flex: 0 0 auto;
197
  width: 100%;
frontend/src/styles/_print.css CHANGED
@@ -1,6 +1,16 @@
1
  /* ============================================================================ */
2
- /* Print styles */
3
- /* ========================================================================= */
 
 
 
 
 
 
 
 
 
 
4
  @media print {
5
 
6
  html,
@@ -8,16 +18,49 @@
8
  background: #fff;
9
  }
10
 
11
- /* Margins handled by Playwright; avoid extra global margins */
12
  body {
13
  margin: 0;
14
  }
15
 
16
- /* Keep the banner (hero), hide non-essential UI elements */
17
- #theme-toggle {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  display: none !important;
19
  }
20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  /* Links: remove underline and background */
22
  .content-grid main a {
23
  text-decoration: none;
@@ -25,15 +68,6 @@
25
  border-bottom: 1px solid rgba(0, 0, 0, .2);
26
  }
27
 
28
- /* Avoid breaks inside complex blocks */
29
- .content-grid main pre,
30
- .content-grid main blockquote,
31
- .content-grid main table,
32
- .content-grid main figure {
33
- break-inside: avoid;
34
- page-break-inside: avoid;
35
- }
36
-
37
  /* Soft page breaks around main headings */
38
  .content-grid main h2 {
39
  page-break-before: auto;
@@ -41,12 +75,18 @@
41
  break-after: avoid-page;
42
  }
43
 
 
 
 
 
 
 
44
  /* Small icon labels not needed when printing */
45
  .code-lang-chip {
46
  display: none !important;
47
  }
48
 
49
- /* Adjust more contrasty colors for print */
50
  :root {
51
  --surface-bg: #fff;
52
  --border-color: rgba(0, 0, 0, .2);
@@ -54,27 +94,12 @@
54
  --link-underline-hover: rgba(0, 0, 0, .4);
55
  }
56
 
57
- /* Force single column to reduce widows/orphans and awkward breaks */
58
- .content-grid {
59
- grid-template-columns: 1fr !important;
60
- }
61
-
62
- .table-of-contents,
63
- .right-aside,
64
- .toc-mobile-toggle,
65
- .toc-mobile-backdrop,
66
- .toc-mobile-sidebar {
67
- display: none !important;
68
- }
69
-
70
- main>nav:first-of-type {
71
- display: none !important;
72
- }
73
-
74
- /* Avoid page breaks inside complex visual blocks */
75
  .hero,
76
  .hero-banner,
 
77
  .html-embed__card,
 
78
  .js-plotly-plot,
79
  figure,
80
  pre,
@@ -92,12 +117,13 @@
92
  page-break-inside: avoid;
93
  }
94
 
95
- /* Prefer keeping header+lead together */
96
  .hero {
97
  page-break-after: avoid;
 
98
  }
99
 
100
- /* Center the hero banner and constrain all its children generically */
101
  .hero-banner {
102
  width: 100% !important;
103
  max-width: 980px !important;
@@ -110,7 +136,8 @@
110
  .hero-banner svg,
111
  .hero-banner canvas,
112
  .hero-banner img,
113
- .hero-banner video {
 
114
  max-width: 100% !important;
115
  width: 100% !important;
116
  height: auto !important;
@@ -118,11 +145,88 @@
118
  margin-right: auto !important;
119
  display: block;
120
  }
121
- }
122
 
 
 
 
 
 
 
123
 
124
- @media print {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  .meta-container-cell--pdf {
126
  display: none !important;
127
  }
128
- }
 
 
 
 
 
 
1
  /* ============================================================================ */
2
+ /* Print styles */
3
+ /* ============================================================================ */
4
+
5
+ /*
6
+ * The .pdf-toc node is injected at print time by the PDF generator. It is
7
+ * hidden in the browser (web view of the published article) and shown only
8
+ * when emulating print media during PDF generation.
9
+ */
10
+ .pdf-toc {
11
+ display: none;
12
+ }
13
+
14
  @media print {
15
 
16
  html,
 
18
  background: #fff;
19
  }
20
 
21
+ /* Margins are handled by Playwright; avoid extra global margins */
22
  body {
23
  margin: 0;
24
  }
25
 
26
+ /* Paragraphs: balanced break behaviour for long-form reading */
27
+ p,
28
+ li,
29
+ blockquote {
30
+ orphans: 3;
31
+ widows: 3;
32
+ }
33
+
34
+ p {
35
+ hyphens: auto;
36
+ -webkit-hyphens: auto;
37
+ }
38
+
39
+ /* Hide non-essential UI */
40
+ #theme-toggle,
41
+ .toc-mobile-toggle,
42
+ .toc-mobile-backdrop,
43
+ .toc-mobile-sidebar,
44
+ .right-aside,
45
+ .sync-indicator,
46
+ .top-bar {
47
  display: none !important;
48
  }
49
 
50
+ /* Sidebar TOC is replaced by .pdf-toc in print */
51
+ .table-of-contents {
52
+ display: none !important;
53
+ }
54
+
55
+ main > nav:first-of-type {
56
+ display: none !important;
57
+ }
58
+
59
+ /* Force single column to reduce widows/orphans and awkward breaks */
60
+ .content-grid {
61
+ grid-template-columns: 1fr !important;
62
+ }
63
+
64
  /* Links: remove underline and background */
65
  .content-grid main a {
66
  text-decoration: none;
 
68
  border-bottom: 1px solid rgba(0, 0, 0, .2);
69
  }
70
 
 
 
 
 
 
 
 
 
 
71
  /* Soft page breaks around main headings */
72
  .content-grid main h2 {
73
  page-break-before: auto;
 
75
  break-after: avoid-page;
76
  }
77
 
78
+ .content-grid main h3,
79
+ .content-grid main h4 {
80
+ break-after: avoid-page;
81
+ page-break-after: avoid;
82
+ }
83
+
84
  /* Small icon labels not needed when printing */
85
  .code-lang-chip {
86
  display: none !important;
87
  }
88
 
89
+ /* More contrasty colors for print */
90
  :root {
91
  --surface-bg: #fff;
92
  --border-color: rgba(0, 0, 0, .2);
 
94
  --link-underline-hover: rgba(0, 0, 0, .4);
95
  }
96
 
97
+ /* Avoid breaks inside complex visual blocks */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  .hero,
99
  .hero-banner,
100
+ .html-embed,
101
  .html-embed__card,
102
+ .html-embed-container,
103
  .js-plotly-plot,
104
  figure,
105
  pre,
 
117
  page-break-inside: avoid;
118
  }
119
 
120
+ /* Keep hero + meta together at the top of page 1 */
121
  .hero {
122
  page-break-after: avoid;
123
+ break-after: avoid;
124
  }
125
 
126
+ /* Constrain hero banner after rasterization */
127
  .hero-banner {
128
  width: 100% !important;
129
  max-width: 980px !important;
 
136
  .hero-banner svg,
137
  .hero-banner canvas,
138
  .hero-banner img,
139
+ .hero-banner video,
140
+ .html-embed-container > img {
141
  max-width: 100% !important;
142
  width: 100% !important;
143
  height: auto !important;
 
145
  margin-right: auto !important;
146
  display: block;
147
  }
 
148
 
149
+ /* References / bibliography: start on a new page and stay together */
150
+ .references-block,
151
+ .bibliography-content {
152
+ break-before: page;
153
+ page-break-before: always;
154
+ }
155
 
156
+ .bibliography-title {
157
+ break-after: avoid;
158
+ page-break-after: avoid;
159
+ }
160
+
161
+ /* Footer: the web view uses a 3-column grid (~1160px wide) that does
162
+ not fit the ~673px printable width. Drop the grid so each block
163
+ flows naturally, headings stacked above their content. */
164
+ .footer-inner { display: block; }
165
+ .citation-block,
166
+ .references-block,
167
+ .reuse-block,
168
+ .doi-block,
169
+ .template-credit { display: block; }
170
+ .footer-heading {
171
+ text-align: left !important;
172
+ padding-right: 0 !important;
173
+ }
174
+
175
+ /* PDF TOC: visible only in print */
176
+ .pdf-toc {
177
+ display: block;
178
+ break-after: page;
179
+ page-break-after: always;
180
+ max-width: 780px;
181
+ margin: 0 auto 12mm;
182
+ padding: 0 16px;
183
+ }
184
+
185
+ .pdf-toc__title {
186
+ font-size: 1.3rem;
187
+ margin: 0 0 12px;
188
+ border-bottom: 1px solid rgba(0, 0, 0, .2);
189
+ padding-bottom: 8px;
190
+ }
191
+
192
+ .pdf-toc__list {
193
+ list-style: none;
194
+ margin: 0;
195
+ padding: 0;
196
+ }
197
+
198
+ .pdf-toc__item {
199
+ margin: 4px 0;
200
+ font-size: 0.95rem;
201
+ line-height: 1.4;
202
+ }
203
+
204
+ .pdf-toc__item--sub {
205
+ margin-left: 20px;
206
+ font-size: 0.88rem;
207
+ color: rgba(0, 0, 0, .7);
208
+ }
209
+
210
+ .pdf-toc__item a {
211
+ color: inherit;
212
+ text-decoration: none;
213
+ border-bottom: none;
214
+ }
215
+
216
+ /* Meta container (authors, affiliations, PDF link): keep tight under hero */
217
+ .meta-container,
218
+ .hero-meta {
219
+ break-after: avoid;
220
+ page-break-after: avoid;
221
+ }
222
+
223
+ /* Hide the PDF download cell in the generated PDF itself (self-referential) */
224
  .meta-container-cell--pdf {
225
  display: none !important;
226
  }
227
+
228
+ /* Hide published-side tooltips in print */
229
+ .pub-tooltip {
230
+ display: none !important;
231
+ }
232
+ }