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
- frontend/index.html +12 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +10 -5
- frontend/postcss.config.js +0 -5
- frontend/src/App.tsx +474 -194
- frontend/src/components/ChatPanel.tsx +145 -32
- frontend/src/components/CommentPopover.tsx +293 -0
- frontend/src/components/CommentsSidebar.tsx +0 -212
- frontend/src/components/EmbedStudio.tsx +346 -0
- frontend/src/components/MobileTocSidebar.tsx +66 -0
- frontend/src/components/PublishDialog.tsx +141 -0
- frontend/src/components/SyncIndicator.tsx +80 -0
- frontend/src/components/TableOfContents.tsx +94 -33
- frontend/src/components/TopBar.tsx +139 -0
- frontend/src/editor/BlockHandle.tsx +24 -25
- frontend/src/editor/CitationView.tsx +7 -102
- frontend/src/editor/Editor.tsx +118 -49
- frontend/src/editor/EditorFooter.tsx +166 -0
- frontend/src/editor/FloatingActions.tsx +0 -128
- frontend/src/editor/SlashMenu.tsx +49 -0
- frontend/src/editor/StackView.tsx +19 -47
- frontend/src/editor/comments.ts +24 -2
- frontend/src/editor/components/AtomicView.tsx +23 -14
- frontend/src/editor/components/MermaidView.tsx +39 -6
- frontend/src/editor/components/WrapperView.tsx +49 -18
- frontend/src/editor/components/factory.ts +44 -89
- frontend/src/editor/components/index.ts +1 -1
- frontend/src/editor/components/registry.ts +2 -2
- frontend/src/editor/default-content.ts +281 -95
- frontend/src/editor/embeds/HtmlEmbedView.tsx +274 -0
- frontend/src/editor/embeds/build-doc.ts +22 -0
- frontend/src/editor/embeds/embed-store.ts +99 -0
- frontend/src/editor/extensions/citation.ts +84 -0
- frontend/src/editor/extensions/code-block-shiki.tsx +300 -0
- frontend/src/editor/extensions/collaboration-cursor-v3.ts +5 -1
- frontend/src/editor/extensions/scroll-guard.ts +57 -0
- frontend/src/editor/extensions/stack.ts +37 -0
- frontend/src/editor/frontmatter/FrontmatterHero.tsx +299 -168
- frontend/src/editor/frontmatter/HueSlider.tsx +3 -5
- frontend/src/editor/frontmatter/SettingsDrawer.tsx +9 -0
- frontend/src/editor/frontmatter/frontmatter-store.ts +17 -13
- frontend/src/editor/frontmatter/seed-frontmatter.ts +15 -5
- frontend/src/hooks/useAgentChat.ts +39 -8
- frontend/src/hooks/useEmbedChat.ts +221 -0
- frontend/src/hooks/usePublishStatus.ts +49 -0
- frontend/src/hooks/useTheme.ts +67 -0
- frontend/src/main.tsx +12 -3
- frontend/src/styles/_editor-tokens.css +0 -105
- frontend/src/styles/_layout.css +20 -36
- frontend/src/styles/_print.css +143 -39
|
@@ -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>
|
|
The diff for this file is too large to render.
See raw diff
|
|
|
|
@@ -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 |
-
"
|
| 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 |
}
|
|
@@ -1,5 +0,0 @@
|
|
| 1 |
-
export default {
|
| 2 |
-
plugins: {
|
| 3 |
-
"postcss-custom-media": {},
|
| 4 |
-
},
|
| 5 |
-
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,89 +1,163 @@
|
|
| 1 |
-
import { useRef, useState, useCallback, useEffect
|
| 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 {
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 61 |
color: colorFromName(data.user.name),
|
| 62 |
avatarUrl: data.user.avatarUrl || undefined,
|
| 63 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
}
|
| 65 |
})
|
| 66 |
.catch(() => {});
|
| 67 |
}, []);
|
| 68 |
|
| 69 |
-
|
| 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 [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
const [publishError, setPublishError] = useState("");
|
|
|
|
|
|
|
| 83 |
const selectionRange = useRef<{ from: number; to: number } | null>(null);
|
| 84 |
-
|
| 85 |
-
const
|
| 86 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 95 |
-
|
| 96 |
-
document.documentElement.style.setProperty("--primary-
|
| 97 |
}
|
| 98 |
};
|
| 99 |
sync();
|
|
@@ -101,8 +175,17 @@ export default function App() {
|
|
| 101 |
return () => settingsMap.unobserve(sync);
|
| 102 |
}, [settingsMap]);
|
| 103 |
|
| 104 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 127 |
-
|
|
|
|
|
|
|
| 128 |
}, []);
|
| 129 |
|
| 130 |
-
const
|
| 131 |
-
|
| 132 |
-
|
|
|
|
| 133 |
|
| 134 |
-
|
| 135 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
</
|
| 227 |
-
</
|
| 228 |
-
|
| 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 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
<
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 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 |
-
{
|
| 354 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 355 |
ref={publishDialogRef}
|
| 356 |
-
|
|
|
|
|
|
|
|
|
|
| 357 |
onClose={closePublishDialog}
|
| 358 |
-
|
| 359 |
-
|
| 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 |
}
|
|
@@ -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 |
-
<
|
| 79 |
-
<
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 &&
|
| 98 |
-
<
|
| 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(
|
| 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
|
|
|
|
| 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 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
}
|
|
@@ -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 |
+
}
|
|
@@ -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 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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 |
+
}
|
|
@@ -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 |
+
}
|
|
@@ -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 |
+
});
|
|
@@ -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 |
+
}
|
|
@@ -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 =
|
| 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 |
-
|
| 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
|
| 86 |
-
|
| 87 |
-
editor.view.dom.querySelectorAll("h1, h2, h3, h4, h5, h6"),
|
| 88 |
-
).filter((el) => (el as HTMLElement).textContent?.trim());
|
| 89 |
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
}
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
|
| 100 |
-
const
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
|
| 105 |
-
|
| 106 |
-
|
| 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 |
-
|
| 140 |
-
|
| 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 |
-
|
| 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)}
|
|
@@ -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 |
+
}
|
|
@@ -60,35 +60,33 @@ export function BlockHandle({ editor, containerEl }: BlockHandleProps) {
|
|
| 60 |
[containerEl, editor, show],
|
| 61 |
);
|
| 62 |
|
| 63 |
-
// Find the nearest block
|
| 64 |
-
const
|
| 65 |
-
(clientY: number): HTMLElement | null => {
|
| 66 |
if (!containerEl) return null;
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 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
|
| 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
|
| 102 |
if (
|
| 103 |
handleRef.current?.contains(target) ||
|
| 104 |
target === containerEl ||
|
| 105 |
target.classList.contains("tiptap")
|
| 106 |
) {
|
| 107 |
-
const block =
|
| 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,
|
| 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="
|
| 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="
|
| 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" />
|
|
@@ -1,88 +1,17 @@
|
|
| 1 |
import { NodeViewWrapper } from "@tiptap/react";
|
| 2 |
-
import {
|
| 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
|
| 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 |
-
|
| 19 |
-
|
| 20 |
-
|
| 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 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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 {
|
| 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 {
|
| 36 |
-
import { COMPONENTS,
|
| 37 |
import { uploadImage } from "./upload";
|
| 38 |
-
import {
|
| 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
|
|
|
|
|
|
|
|
|
|
| 66 |
const undoManagerCallbackRef = useRef(onUndoManagerReady);
|
| 67 |
undoManagerCallbackRef.current = onUndoManagerReady;
|
| 68 |
|
| 69 |
-
const
|
|
|
|
| 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 |
-
|
| 79 |
url: wsUrl,
|
| 80 |
name: docName,
|
| 81 |
document: ydoc,
|
| 82 |
token: token || "",
|
| 83 |
});
|
| 84 |
-
}
|
|
|
|
| 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 |
-
|
| 107 |
-
|
|
|
|
|
|
|
| 108 |
};
|
| 109 |
-
}, [
|
| 110 |
|
| 111 |
const editor = useEditor(
|
| 112 |
{
|
|
@@ -160,9 +178,9 @@ export function Editor({
|
|
| 160 |
HTMLAttributes: { class: "editor-link" },
|
| 161 |
},
|
| 162 |
} as any),
|
| 163 |
-
|
| 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.
|
| 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
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
|
|
|
|
|
|
| 212 |
|
| 213 |
-
|
| 214 |
-
|
|
|
|
|
|
|
|
|
|
| 215 |
|
| 216 |
-
|
| 217 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
|
|
|
|
| 219 |
ydoc.transact(() => {
|
| 220 |
-
|
|
|
|
| 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 |
-
|
| 233 |
-
|
|
|
|
|
|
|
| 234 |
};
|
| 235 |
-
|
|
|
|
|
|
|
| 236 |
|
| 237 |
-
|
| 238 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
};
|
| 240 |
-
|
|
|
|
|
|
|
| 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 = {
|
| 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);
|
|
@@ -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 |
+
}
|
|
@@ -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 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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()];
|
|
@@ -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>) =>
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 33 |
-
|
| 34 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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: "
|
| 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" />
|
|
@@ -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));
|
|
@@ -21,12 +21,21 @@ function AtomicFieldRow({
|
|
| 21 |
}) {
|
| 22 |
if (field.type === "boolean") {
|
| 23 |
return (
|
| 24 |
-
<label
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
<input
|
| 26 |
type="checkbox"
|
| 27 |
checked={!!value}
|
| 28 |
onChange={(e) => onChange(e.target.checked)}
|
| 29 |
-
style={{ accentColor: "
|
| 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: "
|
| 40 |
<select
|
| 41 |
value={String(value ?? field.default ?? "")}
|
| 42 |
onChange={(e) => onChange(e.target.value)}
|
| 43 |
style={{
|
| 44 |
-
background: "
|
| 45 |
-
border: "1px solid
|
| 46 |
borderRadius: 4,
|
| 47 |
-
color: "
|
| 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: "
|
| 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: "
|
| 72 |
-
border: "1px solid
|
| 73 |
borderRadius: 4,
|
| 74 |
-
color: "
|
| 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
|
| 104 |
borderRadius: 8,
|
| 105 |
-
background: "
|
| 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: "
|
| 115 |
{def.label}
|
| 116 |
</span>
|
| 117 |
{primaryValue && (
|
| 118 |
-
<span style={{ fontSize: 12, color: "
|
| 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 |
)}
|
|
@@ -3,17 +3,24 @@ import { NodeViewWrapper } from "@tiptap/react";
|
|
| 3 |
import type { NodeViewProps } from "@tiptap/react";
|
| 4 |
import mermaid from "mermaid";
|
| 5 |
|
| 6 |
-
let
|
| 7 |
|
| 8 |
-
function
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
mermaid.initialize({
|
| 11 |
startOnLoad: false,
|
| 12 |
-
theme:
|
| 13 |
securityLevel: "loose",
|
| 14 |
fontFamily: "var(--default-font-family)",
|
| 15 |
});
|
| 16 |
-
|
| 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 |
-
|
| 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 }}>
|
|
@@ -32,12 +32,21 @@ function FieldInput({
|
|
| 32 |
}) {
|
| 33 |
if (field.type === "boolean") {
|
| 34 |
return (
|
| 35 |
-
<label
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
<input
|
| 37 |
type="checkbox"
|
| 38 |
checked={!!value}
|
| 39 |
onChange={(e) => onChange(e.target.checked)}
|
| 40 |
-
style={{ accentColor: "
|
| 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: "
|
| 54 |
-
border: "1px solid
|
| 55 |
borderRadius: 4,
|
| 56 |
-
color: "
|
| 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
|
| 80 |
-
color: "
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 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: "
|
| 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: "
|
| 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" :
|
| 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" />
|
|
@@ -1,7 +1,9 @@
|
|
| 1 |
// ---------------------------------------------------------------------------
|
| 2 |
-
// Extension
|
| 3 |
//
|
| 4 |
-
//
|
|
|
|
|
|
|
| 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 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 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 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
|
|
|
|
|
|
| 76 |
}
|
| 77 |
|
| 78 |
/**
|
| 79 |
-
* Build a TipTap Node for
|
| 80 |
*
|
| 81 |
-
*
|
| 82 |
-
* -
|
| 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
|
|
|
|
| 89 |
const commandName = `insert${def.tag}`;
|
| 90 |
|
| 91 |
return Node.create({
|
| 92 |
name: def.name,
|
| 93 |
group: "block",
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
|
| 98 |
addAttributes() {
|
| 99 |
-
|
| 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 |
-
|
| 112 |
-
|
| 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
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
}
|
| 126 |
-
return commands.insertContent({ type: def.name, attrs });
|
| 127 |
},
|
| 128 |
} as any;
|
| 129 |
},
|
| 130 |
|
| 131 |
addNodeView() {
|
| 132 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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;
|
|
@@ -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";
|
|
@@ -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
|
| 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 |
|
|
@@ -3,169 +3,294 @@
|
|
| 3 |
* Covers every block/mark type supported by the editor.
|
| 4 |
*/
|
| 5 |
export const DEFAULT_CONTENT = `
|
| 6 |
-
<h2>
|
| 7 |
|
| 8 |
-
<p>
|
| 9 |
|
| 10 |
-
<
|
| 11 |
|
| 12 |
-
<
|
| 13 |
|
| 14 |
-
<
|
| 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>
|
| 24 |
-
<li>
|
| 25 |
-
<li>
|
| 26 |
-
<li>Deploy to production</li>
|
| 27 |
</ol>
|
| 28 |
|
| 29 |
-
<h2>
|
| 30 |
|
| 31 |
-
<
|
| 32 |
-
<p>"The best way to predict the future is to invent it." — Alan Kay</p>
|
| 33 |
-
</blockquote>
|
| 34 |
|
| 35 |
-
<
|
| 36 |
|
| 37 |
-
<
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
|
|
|
| 47 |
|
| 48 |
-
|
| 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 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
|
| 55 |
<table>
|
| 56 |
<tr>
|
| 57 |
<th>Model</th>
|
| 58 |
<th>Parameters</th>
|
| 59 |
-
<th>
|
| 60 |
-
<th>
|
|
|
|
| 61 |
</tr>
|
| 62 |
<tr>
|
| 63 |
-
<td>
|
| 64 |
-
<td>
|
| 65 |
-
<td>
|
| 66 |
-
<td>
|
|
|
|
| 67 |
</tr>
|
| 68 |
<tr>
|
| 69 |
-
<td>
|
| 70 |
-
<td>
|
| 71 |
-
<td>
|
| 72 |
-
<td>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
</tr>
|
| 74 |
<tr>
|
| 75 |
-
<td>
|
| 76 |
-
<td>
|
| 77 |
-
<td>
|
| 78 |
-
<td>
|
|
|
|
| 79 |
</tr>
|
| 80 |
<tr>
|
| 81 |
-
<td>
|
| 82 |
-
<td>
|
| 83 |
-
<td>
|
| 84 |
-
<td>
|
|
|
|
| 85 |
</tr>
|
| 86 |
</table>
|
| 87 |
|
| 88 |
-
<
|
| 89 |
|
| 90 |
-
<p>
|
| 91 |
|
| 92 |
-
<p>
|
| 93 |
|
| 94 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
|
| 96 |
-
|
|
|
|
| 97 |
|
| 98 |
-
<div data-
|
| 99 |
|
| 100 |
-
<
|
| 101 |
|
| 102 |
-
<p>
|
| 103 |
|
| 104 |
-
<
|
| 105 |
|
| 106 |
-
<
|
| 107 |
|
| 108 |
-
<
|
| 109 |
|
| 110 |
-
<
|
| 111 |
|
| 112 |
-
<
|
| 113 |
|
| 114 |
-
<
|
| 115 |
|
| 116 |
-
<
|
| 117 |
|
| 118 |
-
<
|
| 119 |
-
|
| 120 |
-
</
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
|
| 122 |
-
<h3>
|
| 123 |
|
| 124 |
-
<p>
|
| 125 |
|
| 126 |
-
<
|
| 127 |
|
| 128 |
-
<p>The
|
| 129 |
|
| 130 |
-
<
|
| 131 |
|
| 132 |
-
<
|
| 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 |
-
<
|
| 137 |
|
| 138 |
-
<div data-component="
|
| 139 |
|
| 140 |
-
<
|
| 141 |
|
| 142 |
-
<
|
| 143 |
|
| 144 |
-
<div data-component="
|
| 145 |
|
| 146 |
-
<
|
| 147 |
|
| 148 |
-
<
|
| 149 |
|
| 150 |
-
<
|
| 151 |
|
| 152 |
-
<
|
| 153 |
|
| 154 |
-
<div data-component="
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
|
| 156 |
-
<
|
| 157 |
|
| 158 |
-
<p>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
|
| 160 |
<ul>
|
| 161 |
-
<li>
|
| 162 |
-
<li>
|
| 163 |
-
<li>
|
| 164 |
-
<li>
|
| 165 |
-
<li>Add a new section on a specific topic</li>
|
| 166 |
</ul>
|
| 167 |
|
| 168 |
-
<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 <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 |
};
|
|
@@ -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 |
+
}
|
|
@@ -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 |
+
}
|
|
@@ -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>;
|
|
@@ -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 |
},
|
|
@@ -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 |
+
});
|
|
@@ -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() {
|
|
@@ -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 |
+
});
|
|
@@ -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 |
|
|
@@ -1,18 +1,86 @@
|
|
| 1 |
-
import { useState, useRef, useEffect, type KeyboardEvent } from "react";
|
| 2 |
import { Tooltip } from "../../components/Tooltip";
|
| 3 |
-
import { Plus,
|
| 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 |
-
|
| 45 |
-
<
|
| 46 |
-
<div className="meta-container">
|
| 47 |
-
<
|
| 48 |
-
|
| 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 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
{
|
| 89 |
-
|
| 90 |
-
|
| 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>
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
</div>
|
|
|
|
| 125 |
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
</div>
|
| 135 |
</div>
|
| 136 |
-
</
|
| 137 |
-
|
| 138 |
|
| 139 |
-
<
|
| 140 |
-
{showAuthorForm
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 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 |
-
|
| 191 |
-
}, [value
|
| 192 |
|
| 193 |
useEffect(() => {
|
| 194 |
-
if (
|
| 195 |
-
inputRef.current
|
| 196 |
-
if (inputRef.current) {
|
| 197 |
-
const el = inputRef.current;
|
| 198 |
-
el.selectionStart = el.selectionEnd = el.value.length;
|
| 199 |
-
}
|
| 200 |
}
|
| 201 |
-
}, [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 212 |
}
|
| 213 |
if (e.key === "Escape") {
|
| 214 |
setDraft(value);
|
| 215 |
-
|
| 216 |
}
|
| 217 |
};
|
| 218 |
|
| 219 |
-
const
|
| 220 |
-
const
|
| 221 |
-
|
| 222 |
-
|
| 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 |
-
|
| 255 |
-
style={{
|
| 256 |
-
display: inline ? "inline" : "block",
|
| 257 |
-
opacity: isEmpty ? 0.35 : 1,
|
| 258 |
-
cursor: "text",
|
| 259 |
-
}}
|
| 260 |
>
|
| 261 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
<
|
| 304 |
-
<
|
| 305 |
{initial ? "Edit author" : "Add author"}
|
| 306 |
-
</
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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="
|
| 320 |
-
value={
|
| 321 |
-
onChange={(e) =>
|
| 322 |
onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
|
|
|
|
| 323 |
/>
|
| 324 |
</div>
|
| 325 |
-
|
| 326 |
-
|
| 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 |
-
</
|
| 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 |
}
|
|
@@ -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(
|
| 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
|
|
|
|
| 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 };
|
|
@@ -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"
|
|
@@ -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 |
-
|
| 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 |
}
|
|
@@ -1,12 +1,15 @@
|
|
| 1 |
import type { FrontmatterStore } from "./frontmatter-store";
|
| 2 |
|
| 3 |
/**
|
| 4 |
-
* Populate frontmatter with demo data
|
| 5 |
-
*
|
|
|
|
| 6 |
*/
|
| 7 |
-
export function seedFrontmatter(store: FrontmatterStore) {
|
| 8 |
-
if (
|
| 9 |
-
|
|
|
|
|
|
|
| 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 |
|
|
@@ -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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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,
|
|
@@ -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 |
+
}
|
|
@@ -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 |
+
}
|
|
@@ -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 |
+
}
|
|
@@ -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 |
-
//
|
| 25 |
-
import "./styles/
|
| 26 |
import "./styles/article.css";
|
| 27 |
import "./styles/toc.css";
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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>
|
|
@@ -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 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -3,13 +3,16 @@
|
|
| 3 |
/* ============================================================================ */
|
| 4 |
|
| 5 |
.content-grid {
|
| 6 |
-
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 |
-
|
|
|
|
|
|
|
|
|
|
| 13 |
align-items: start;
|
| 14 |
}
|
| 15 |
|
|
@@ -23,14 +26,11 @@
|
|
| 23 |
margin-top: 0;
|
| 24 |
}
|
| 25 |
|
| 26 |
-
@media (-
|
| 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 |
-
.
|
| 43 |
-
|
| 44 |
-
|
| 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 (-
|
| 113 |
-
|
| 114 |
.wide,
|
| 115 |
.full-width {
|
| 116 |
width: 100%;
|
|
@@ -149,7 +138,7 @@
|
|
| 149 |
transform: scale(0.92);
|
| 150 |
}
|
| 151 |
|
| 152 |
-
@media (-
|
| 153 |
#theme-toggle {
|
| 154 |
display: none;
|
| 155 |
}
|
|
@@ -157,9 +146,9 @@
|
|
| 157 |
|
| 158 |
/* ------------------------------------------------------------------------- */
|
| 159 |
/* Hero meta bar responsiveness */
|
| 160 |
-
/* Two columns at
|
| 161 |
/* ------------------------------------------------------------------------- */
|
| 162 |
-
@media (-
|
| 163 |
header.meta .meta-container {
|
| 164 |
display: flex;
|
| 165 |
flex-wrap: wrap;
|
|
@@ -175,13 +164,12 @@
|
|
| 175 |
}
|
| 176 |
}
|
| 177 |
|
| 178 |
-
@media (-
|
| 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 (-
|
| 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%;
|
|
@@ -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 |
-
/*
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
/*
|
| 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 |
-
/*
|
| 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 |
-
/*
|
| 96 |
.hero {
|
| 97 |
page-break-after: avoid;
|
|
|
|
| 98 |
}
|
| 99 |
|
| 100 |
-
/*
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
}
|