Spaces:
Runtime error
Runtime error
| import React, { useRef, useState, useCallback, useEffect } from "react"; | |
| import { Editor as TiptapEditor } from "@tiptap/core"; | |
| import { UndoManager } from "yjs"; | |
| import type * as Y from "yjs"; | |
| import type { HocuspocusProvider } from "@hocuspocus/provider"; | |
| import { MessageCircle } from "lucide-react"; | |
| import { Tooltip } from "./components/Tooltip"; | |
| import { oklchFromHue } from "#shared/theme"; | |
| import { Editor } from "./editor/Editor"; | |
| import { CommentMarginIcons } from "./components/CommentPopover"; | |
| import { CommentDialog } from "./components/CommentDialog"; | |
| import { ChatPanel, type ModelOption } from "./components/ChatPanel"; | |
| import { TableOfContents } from "./components/TableOfContents"; | |
| import { TopBar } from "./components/TopBar"; | |
| import { PublishDialog, type PublishState, type PublishStageEvent } from "./components/PublishDialog"; | |
| import { MobileTocSidebar } from "./components/MobileTocSidebar"; | |
| import { useAgentChat } from "./hooks/useAgentChat"; | |
| import { usePublishStatus } from "./hooks/usePublishStatus"; | |
| import type { CommentStore } from "./editor/comments"; | |
| import type { FrontmatterStore } from "./editor/frontmatter/frontmatter-store"; | |
| import type { EmbedStore } from "./editor/embeds/embed-store"; | |
| import type { EmbedDataStore } from "./editor/embeds/embed-data-store"; | |
| import { FrontmatterHero } from "./editor/frontmatter/FrontmatterHero"; | |
| import { SettingsDrawer } from "./editor/frontmatter/SettingsDrawer"; | |
| import { EditorFooter } from "./editor/EditorFooter"; | |
| import { EmbedStudio } from "./components/EmbedStudio"; | |
| import type { UIMessage } from "ai"; | |
| import { loadMessages, saveMessages } from "./utils/chat-persistence"; | |
| import { stableFallbackUser, colorFromName, type CollabUser } from "./utils/user"; | |
| // Re-exported for any downstream code that still imports from App.tsx | |
| export type { CollabUser } from "./utils/user"; | |
| export default function App() { | |
| // --- Identity & auth --------------------------------------------------- | |
| const [user, setUser] = useState<CollabUser>(stableFallbackUser); | |
| const [loginUrl, setLoginUrl] = useState<string | null>(null); | |
| const [isAuthenticated, setIsAuthenticated] = useState(true); | |
| // Defaults to true so the UI is permissive until /api/auth/status answers. | |
| // When OAuth is disabled (local dev), the backend always returns canEdit=true. | |
| const [canEdit, setCanEdit] = useState(true); | |
| const [chatUserId, setChatUserId] = useState(() => user.name); | |
| useEffect(() => { | |
| fetch("/api/auth/status", { credentials: "include" }) | |
| .then((r) => r.json()) | |
| .then((data) => { | |
| setIsAuthenticated(data.authenticated); | |
| setCanEdit(Boolean(data.canEdit)); | |
| if (data.authenticated && data.user) { | |
| const name = data.user.fullName || data.user.name; | |
| setUser({ | |
| name, | |
| color: colorFromName(data.user.name), | |
| avatarUrl: data.user.avatarUrl || undefined, | |
| }); | |
| setChatUserId(data.user.name); | |
| setLoginUrl(null); | |
| } else if (data.loginUrl) { | |
| setLoginUrl(data.loginUrl); | |
| } | |
| }) | |
| .catch(() => {}); | |
| }, []); | |
| // --- Document / editor refs ------------------------------------------- | |
| const docName = "default"; | |
| const editorRef = useRef<TiptapEditor | null>(null); | |
| const editorContainerRef = useRef<HTMLDivElement | null>(null); | |
| const providerRef = useRef<HocuspocusProvider | null>(null); | |
| const publishDialogRef = useRef<HTMLDialogElement>(null); | |
| const [editorInstance, setEditorInstance] = useState<TiptapEditor | null>(null); | |
| const [containerEl, setContainerEl] = useState<HTMLElement | null>(null); | |
| const [commentStore, setCommentStore] = useState<CommentStore | null>(null); | |
| const [frontmatterStore, setFrontmatterStore] = useState<FrontmatterStore | null>(null); | |
| const [embedStore, setEmbedStore] = useState<EmbedStore | null>(null); | |
| const [embedDataStore, setEmbedDataStore] = useState<EmbedDataStore | null>(null); | |
| const [settingsMap, setSettingsMap] = useState<Y.Map<any> | null>(null); | |
| const [undoManager, setUndoManager] = useState<UndoManager | null>(null); | |
| const [isEditorReady, setIsEditorReady] = useState(false); | |
| // --- UI state --------------------------------------------------------- | |
| const [commentDialogOpen, setCommentDialogOpen] = useState(false); | |
| const [settingsOpen, setSettingsOpen] = useState(false); | |
| const [chatOpen, setChatOpen] = useState(false); | |
| const [embedStudioSrc, setEmbedStudioSrc] = useState<string | null>(null); | |
| // Stable session id for the currently-open EmbedStudio instance. | |
| // Used as the React key so the component does NOT remount when the | |
| // agent renames the chart file mid-session (rename flips | |
| // `embedStudioSrc`, but the same studio instance keeps its chat | |
| // state). | |
| const [embedStudioSession, setEmbedStudioSession] = useState<string | null>(null); | |
| const [tocSidebarOpen, setTocSidebarOpen] = useState(false); | |
| const [tocAutoCollapse, setTocAutoCollapse] = useState(false); | |
| const [publishState, setPublishState] = useState<PublishState>("idle"); | |
| const [publishError, setPublishError] = useState(""); | |
| const [publishStage, setPublishStage] = useState<PublishStageEvent | null>(null); | |
| const [yDoc, setYDoc] = useState<Y.Doc | null>(null); | |
| const selectionRange = useRef<{ from: number; to: number } | null>(null); | |
| const publishStatus = usePublishStatus(yDoc); | |
| // --- Theme ------------------------------------------------------------ | |
| const [theme, setTheme] = useState<"light" | "dark">(() => { | |
| return (document.documentElement.getAttribute("data-theme") as "light" | "dark") || "dark"; | |
| }); | |
| useEffect(() => { | |
| const saved = localStorage.getItem("theme") as "light" | "dark" | null; | |
| if (saved) { | |
| document.documentElement.setAttribute("data-theme", saved); | |
| setTheme(saved); | |
| } | |
| }, []); | |
| const toggleTheme = useCallback(() => { | |
| const next = theme === "dark" ? "light" : "dark"; | |
| document.documentElement.setAttribute("data-theme", next); | |
| localStorage.setItem("theme", next); | |
| setTheme(next); | |
| }, [theme]); | |
| // --- Model list ------------------------------------------------------- | |
| const [models, setModels] = useState<ModelOption[]>([]); | |
| const [selectedModel, setSelectedModel] = useState(""); | |
| const modelRef = useRef(""); | |
| useEffect(() => { | |
| fetch("/api/models") | |
| .then((r) => r.json()) | |
| .then((data: ModelOption[]) => { | |
| setModels(data); | |
| if (data.length > 0 && !modelRef.current) { | |
| setSelectedModel(data[0].id); | |
| modelRef.current = data[0].id; | |
| } | |
| }) | |
| .catch(() => {}); | |
| }, []); | |
| const handleModelChange = useCallback((id: string) => { | |
| setSelectedModel(id); | |
| modelRef.current = id; | |
| }, []); | |
| // --- Effects tied to stores ------------------------------------------- | |
| // Sync TOC auto-collapse flag from frontmatter | |
| useEffect(() => { | |
| if (!frontmatterStore) return; | |
| const sync = () => { | |
| setTocAutoCollapse(frontmatterStore.get("tableOfContentsAutoCollapse") as boolean); | |
| }; | |
| sync(); | |
| return frontmatterStore.observe(sync); | |
| }, [frontmatterStore]); | |
| // Fallback: dismiss spinner after 5s even if sync hasn't fired | |
| useEffect(() => { | |
| if (isEditorReady) return; | |
| const id = setTimeout(() => setIsEditorReady(true), 5000); | |
| return () => clearTimeout(id); | |
| }, [isEditorReady]); | |
| // Sync primary hue from Yjs settings to CSS variables | |
| useEffect(() => { | |
| if (!settingsMap) return; | |
| const sync = () => { | |
| const h = settingsMap.get("primaryHue") as number | undefined; | |
| if (h !== undefined) { | |
| // Only override --primary-base: _variables.css derives --primary-color | |
| // and --primary-color-hover from it via CSS relative-color syntax. | |
| document.documentElement.style.setProperty("--primary-base", oklchFromHue(h)); | |
| } else { | |
| // When the Yjs settings map has no primaryHue (fresh state or after | |
| // reset-article), drop the inline override so the CSS default from | |
| // _variables.css wins. Otherwise a stale hue from a previous session | |
| // survives the reset visually, and the demo has to re-paint it at | |
| // boot time (which looks like an unmotivated color jump). | |
| document.documentElement.style.removeProperty("--primary-base"); | |
| } | |
| }; | |
| sync(); | |
| settingsMap.observe(sync); | |
| return () => settingsMap.unobserve(sync); | |
| }, [settingsMap]); | |
| // Listen for embed studio open events from HtmlEmbedView | |
| useEffect(() => { | |
| const handler = (e: Event) => { | |
| const src = (e as CustomEvent).detail?.src; | |
| if (src) { | |
| setEmbedStudioSrc(src); | |
| setEmbedStudioSession(`es-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`); | |
| } | |
| }; | |
| window.addEventListener("open-embed-studio", handler); | |
| return () => window.removeEventListener("open-embed-studio", handler); | |
| }, []); | |
| // Rename every htmlEmbed node referencing `oldSrc` to `newSrc` in the | |
| // ProseMirror doc, then lift the new src into state so the | |
| // EmbedStudio props and the document stay consistent. The agent | |
| // triggers this from createEmbed({ filename }) inside useEmbedChat. | |
| const handleEmbedRename = useCallback( | |
| (oldSrc: string, newSrc: string) => { | |
| const editor = editorRef.current; | |
| if (editor) { | |
| const { state } = editor; | |
| const tr = state.tr; | |
| let changed = false; | |
| state.doc.descendants((node, pos) => { | |
| if (node.type.name === "htmlEmbed" && node.attrs.src === oldSrc) { | |
| tr.setNodeMarkup(pos, undefined, { ...node.attrs, src: newSrc }); | |
| changed = true; | |
| } | |
| }); | |
| if (changed) editor.view.dispatch(tr); | |
| } | |
| setEmbedStudioSrc((prev) => (prev === oldSrc ? newSrc : prev)); | |
| }, | |
| [], | |
| ); | |
| // Global entry point for the Embed Studio (TopBar button). Smart | |
| // behaviour: if charts already exist, open the studio on one of them | |
| // (browse/edit mode via the FilesSidebar) without touching the | |
| // document. Only when there is no chart at all do we fall back to | |
| // creating a fresh one - mirroring the slash menu's "New Chart" flow. | |
| // Both paths route through the existing `open-embed-studio` listener | |
| // so session/key handling stays in one place. | |
| const handleOpenEmbedStudio = useCallback(() => { | |
| const keys = embedStore ? embedStore.keys() : []; | |
| if (keys.length > 0) { | |
| // Prefer a real chart over the always-present banner so the | |
| // studio lands on content the user most likely wants to tweak. | |
| const preferred = keys.find((k) => k !== "banner.html") ?? keys[0]; | |
| window.dispatchEvent( | |
| new CustomEvent("open-embed-studio", { detail: { src: preferred } }), | |
| ); | |
| return; | |
| } | |
| const editor = editorRef.current; | |
| if (!editor) return; | |
| const id = `d3-chart-${Date.now().toString(36)}`; | |
| const src = `${id}.html`; | |
| (editor.chain().focus() as any).insertHtmlEmbed().run(); | |
| setTimeout(() => { | |
| const { doc } = editor.state; | |
| let targetPos = -1; | |
| doc.descendants((node, pos) => { | |
| if (node.type.name === "htmlEmbed" && !node.attrs.src) { | |
| targetPos = pos; | |
| return false; | |
| } | |
| }); | |
| if (targetPos >= 0) { | |
| editor.view.dispatch( | |
| editor.state.tr.setNodeMarkup(targetPos, undefined, { | |
| ...editor.state.doc.nodeAt(targetPos)?.attrs, | |
| src, | |
| title: "New chart", | |
| }), | |
| ); | |
| } | |
| window.dispatchEvent( | |
| new CustomEvent("open-embed-studio", { detail: { src } }), | |
| ); | |
| }, 50); | |
| }, [embedStore]); | |
| // When the user opens the chat with an active selection, broadcast it | |
| // via Yjs awareness so every collaborator (including us) sees a | |
| // persistent highlight "my agent is working on this range". The | |
| // AgentFocus extension handles CRDT-safe position tracking; we just | |
| // toggle it on/off based on chat state. Safe w.r.t. undo: the | |
| // extension only writes to awareness, it never emits a Yjs-tracked | |
| // transaction. | |
| // | |
| // We listen to `selectionUpdate` while the chat is open so that: | |
| // - if the selection was not yet readable at chat-open time | |
| // (focus/blur race when clicking the FAB), the first selection | |
| // update still syncs the highlight; | |
| // - if the user selects new text while the chat is open, the | |
| // highlight follows the new range. | |
| // On collapsed selection we leave the previously broadcast focus in | |
| // place - the user may have clicked in the chat textarea without | |
| // wanting to lose the agent's current target. | |
| useEffect(() => { | |
| if (!editorInstance) return; | |
| if (!chatOpen) { | |
| editorInstance.commands.clearAgentFocus(); | |
| return; | |
| } | |
| const syncAgentFocus = () => { | |
| const { from, to } = editorInstance.state.selection; | |
| if (from === to) return; | |
| editorInstance.commands.setAgentFocus({ from, to }); | |
| }; | |
| syncAgentFocus(); | |
| editorInstance.on("selectionUpdate", syncAgentFocus); | |
| return () => { | |
| editorInstance.off("selectionUpdate", syncAgentFocus); | |
| }; | |
| }, [chatOpen, editorInstance]); | |
| // --- Chat / agent ----------------------------------------------------- | |
| const chatUserIdRef = useRef(chatUserId); | |
| chatUserIdRef.current = chatUserId; | |
| const handleArticleChatChange = useCallback((msgs: UIMessage[]) => { | |
| saveMessages(chatUserIdRef.current, "article", msgs); | |
| }, []); | |
| const agentChat = useAgentChat({ | |
| editor: editorInstance, | |
| undoManager, | |
| frontmatterStore, | |
| modelRef, | |
| onMessagesChange: handleArticleChatChange, | |
| }); | |
| // Restore persisted messages after mount and when user identity changes | |
| const agentSetMessagesRef = useRef(agentChat.setMessages); | |
| agentSetMessagesRef.current = agentChat.setMessages; | |
| const chatUserIdLoadedRef = useRef<string | null>(null); | |
| useEffect(() => { | |
| if (chatUserIdLoadedRef.current === chatUserId) return; | |
| chatUserIdLoadedRef.current = chatUserId; | |
| const stored = loadMessages(chatUserId, "article"); | |
| if (stored && stored.length > 0) { | |
| agentSetMessagesRef.current(stored); | |
| } | |
| }, [chatUserId]); | |
| // Dev-only hook: let the demo recording script script-drive the main | |
| // chat panel. The Playwright showcase script dispatches `__demo-chat` to: | |
| // - open the floating chat panel, | |
| // - inject a fake user prompt + assistant reply, | |
| // - optionally rewrite a paragraph in the editor body, | |
| // all without waiting for a real LLM round-trip. | |
| useEffect(() => { | |
| if (!import.meta.env.DEV) return; | |
| const handler = (event: Event) => { | |
| const detail = (event as CustomEvent).detail as | |
| | { | |
| open?: boolean; | |
| messages?: UIMessage[]; | |
| replace?: { | |
| from: string; | |
| to: string; | |
| /** | |
| * Explicit PM positions to replace. When provided we use | |
| * them as-is and skip the (fragile) string search below. | |
| * Required for paragraphs that contain non-text leaf | |
| * nodes (inline math, mentions, images...) because their | |
| * textContent does NOT match the `from` plain-string. | |
| */ | |
| range?: { from: number; to: number }; | |
| /** | |
| * "none" -> atomic replace in one transaction. The | |
| * viewer sees the new phrase "snap in", | |
| * unambiguously an AI edit. | |
| * "typewriter" -> stream chars at human cadence. Looks | |
| * like the owner is typing (which is | |
| * NOT what we want for an agent action - | |
| * use "none" for rephrase actions so | |
| * they don't look like user input). | |
| * Default: "typewriter" for backwards compatibility. | |
| */ | |
| animation?: "none" | "typewriter"; | |
| /** | |
| * Keep an AgentFocus decoration on the replacement | |
| * range for `lingerAgentFocusMs` ms after the swap so | |
| * the viewer sees the "<Name> agent" label hover over | |
| * the new sentence - visual proof the agent owned that | |
| * edit. Ignored when 0 / undefined (caller will manage | |
| * focus clearing itself). | |
| */ | |
| lingerAgentFocusMs?: number; | |
| }; | |
| /** | |
| * Apply a formatting mark (bold/italic/strike/code) to an | |
| * explicit PM range. Used by the demo to showcase the | |
| * agent calling a simple "make this bold" tool - the same | |
| * path a real agent's `toggleMark` tool call would take. | |
| */ | |
| format?: { | |
| mark: "bold" | "italic" | "strike" | "code"; | |
| range: { from: number; to: number }; | |
| }; | |
| /** | |
| * Wrap a PM range in a Link mark. Same pipeline as `format` | |
| * (explicit PM range, go through Tiptap's chain) so the | |
| * new mark lands collaboratively via Yjs and participates | |
| * in the normal undo stack. | |
| */ | |
| link?: { | |
| url: string; | |
| range: { from: number; to: number }; | |
| }; | |
| } | |
| | undefined; | |
| if (typeof detail?.open === "boolean") setChatOpen(detail.open); | |
| if (Array.isArray(detail?.messages)) { | |
| agentSetMessagesRef.current(detail.messages); | |
| } | |
| if (detail?.replace && editorRef.current) { | |
| const editor = editorRef.current; | |
| const target = detail.replace.from; | |
| const replacement = detail.replace.to; | |
| const explicitRange = detail.replace.range; | |
| const animation = detail.replace.animation ?? "typewriter"; | |
| const lingerAgentFocusMs = detail.replace.lingerAgentFocusMs ?? 0; | |
| // Resolve the target range AT DISPATCH TIME, not here. We used | |
| // to do the string search on event receipt, cache from/to, | |
| // then setTimeout(520) into `agentRewriteRange`. Problem: the | |
| // assistant reply was still streaming AND remote peers kept | |
| // typing during those 520ms. Any insert before the cached | |
| // range would silently shift Alice's target by N chars, and | |
| // the typewriter would then eat Bob's sentence or the wrong | |
| // half of Alice's own paragraph. Running the lookup inside | |
| // the setTimeout reads the LIVE doc at the actual kickoff | |
| // moment, so concurrent edits can't invalidate it. | |
| window.setTimeout(() => { | |
| const ed = editorRef.current; | |
| if (!ed) return; | |
| let from = -1; | |
| let to = -1; | |
| // Preferred path: explicit PM range from the caller. Must | |
| // still be clamped against the LIVE doc size in case remote | |
| // edits shrank the doc since the event fired. | |
| if (explicitRange) { | |
| const size = ed.state.doc.content.size; | |
| const a = Math.max(0, Math.min(explicitRange.from, size)); | |
| const b = Math.max(0, Math.min(explicitRange.to, size)); | |
| if (b > a) { | |
| from = a; | |
| to = b; | |
| } else { | |
| console.error( | |
| "[__demo-chat] replace.range is empty or inverted:", | |
| explicitRange, | |
| ); | |
| } | |
| } | |
| // Fallback: walk the LIVE doc and find the first textblock | |
| // whose content contains `target`. Because this runs at | |
| // dispatch time (not at event-receipt time) no remote edit | |
| // can race the result. | |
| if (from === -1) { | |
| ed.state.doc.descendants((node, pos) => { | |
| if (from !== -1) return false; | |
| if (node.isTextblock) { | |
| const text = node.textBetween( | |
| 0, | |
| node.content.size, | |
| "\n", | |
| "\n", | |
| ); | |
| const idx = text.indexOf(target); | |
| if (idx !== -1) { | |
| from = pos + 1 + idx; | |
| to = from + target.length; | |
| return false; | |
| } | |
| } | |
| return true; | |
| }); | |
| } | |
| if (from === -1 || to === -1) { | |
| console.error( | |
| "[__demo-chat] replace target not found:", | |
| target.slice(0, 60) + (target.length > 60 ? "..." : ""), | |
| "- pass `replace.range` to target paragraphs with inline atoms", | |
| ); | |
| return; | |
| } | |
| // Opportunistic scroll: AgentRewrite maps its own cursor | |
| // through every subsequent tr, so this DOM lookup only | |
| // needs to be approximately right. | |
| try { | |
| const domAt = ed.view.domAtPos(from); | |
| let el: HTMLElement | null = | |
| domAt.node instanceof HTMLElement | |
| ? domAt.node | |
| : domAt.node.parentElement; | |
| while ( | |
| el && | |
| el.nodeName !== "P" && | |
| !/^H[1-6]$/.test(el.nodeName) | |
| ) { | |
| el = el.parentElement; | |
| } | |
| if (el && typeof el.scrollIntoView === "function") { | |
| el.scrollIntoView({ behavior: "smooth", block: "center" }); | |
| } | |
| } catch { | |
| /* non-fatal */ | |
| } | |
| ed.commands.agentRewriteRange({ | |
| from, | |
| to, | |
| text: replacement, | |
| animation, | |
| }); | |
| // Re-seed the AgentFocus on the REPLACEMENT range after the | |
| // swap so the "<Name> agent" label keeps hovering over the | |
| // new sentence - otherwise the decoration collapses during | |
| // the delete-then-insert, and viewers perceive the new | |
| // phrase as user typing instead of an agent edit. For the | |
| // atomic "none" animation the new text is present | |
| // immediately; for the typewriter we wait long enough for | |
| // the first chunk to land so PM positions are stable. | |
| if (lingerAgentFocusMs > 0) { | |
| const seedDelay = animation === "none" ? 0 : 80; | |
| window.setTimeout(() => { | |
| const live = editorRef.current; | |
| if (!live) return; | |
| const newFrom = from; | |
| const newTo = from + replacement.length; | |
| const size = live.state.doc.content.size; | |
| const a = Math.max(0, Math.min(newFrom, size)); | |
| const b = Math.max(0, Math.min(newTo, size)); | |
| if (b > a) { | |
| live.commands.setAgentFocus({ from: a, to: b }); | |
| } | |
| window.setTimeout(() => { | |
| const again = editorRef.current; | |
| again?.commands.clearAgentFocus(); | |
| }, lingerAgentFocusMs); | |
| }, seedDelay); | |
| } | |
| }, 520); | |
| } | |
| // ---- Format action: bold / italic / strike / code ---------------- | |
| // The agent's simplest possible tool call: "apply mark M to | |
| // range [from, to]". We scroll the target into view and route | |
| // through Tiptap's own `setMark` commands so the operation goes | |
| // through the real editor pipeline (Yjs syncs, undo step, mark | |
| // extension hooks) - same path a production agent tool would | |
| // take. No custom animation: the viewer sees the text turn | |
| // bold in a single frame, which reads as an instant agent edit. | |
| if (detail?.format && editorRef.current) { | |
| const editor = editorRef.current; | |
| const size = editor.state.doc.content.size; | |
| const from = Math.max(0, Math.min(detail.format.range.from, size)); | |
| const to = Math.max(0, Math.min(detail.format.range.to, size)); | |
| if (to <= from) { | |
| console.error( | |
| "[__demo-chat] format range empty or inverted:", | |
| detail.format.range, | |
| ); | |
| return; | |
| } | |
| try { | |
| const domAt = editor.view.domAtPos(from); | |
| let el: HTMLElement | null = | |
| domAt.node instanceof HTMLElement | |
| ? domAt.node | |
| : domAt.node.parentElement; | |
| while (el && el.nodeName !== "P" && !/^H[1-6]$/.test(el.nodeName)) { | |
| el = el.parentElement; | |
| } | |
| if (el && typeof el.scrollIntoView === "function") { | |
| el.scrollIntoView({ behavior: "smooth", block: "center" }); | |
| } | |
| } catch { | |
| /* non-fatal */ | |
| } | |
| const chain = editor | |
| .chain() | |
| .focus() | |
| .setTextSelection({ from, to }); | |
| switch (detail.format.mark) { | |
| case "bold": | |
| chain.setBold().run(); | |
| break; | |
| case "italic": | |
| chain.setItalic().run(); | |
| break; | |
| case "strike": | |
| chain.setStrike().run(); | |
| break; | |
| case "code": | |
| chain.setCode().run(); | |
| break; | |
| default: | |
| console.error( | |
| "[__demo-chat] unknown format mark:", | |
| detail.format.mark, | |
| ); | |
| } | |
| } | |
| // ---- Link action: wrap a PM range in a Link mark ------------------ | |
| // Goes through Tiptap's `setLink` command (same path the bubble | |
| // toolbar uses) so the mark syncs over Yjs, respects safe-URL | |
| // validation, and lands in the undo stack. Scrolls the target | |
| // into view before applying so the viewer sees the link underline | |
| // appear on the actual phrase. | |
| // | |
| // Drift-proof resolution | |
| // ---------------------- | |
| // The caller MAY pass an explicit PM `range`, but doc positions | |
| // are fragile under concurrent edits (another peer inserting | |
| // content earlier in the doc will shift every subsequent | |
| // position). So we prefer the substring-based path when the | |
| // caller provides `paragraphSnippet` + `substring`: walk the | |
| // LIVE doc at dispatch time, find the matching text node, and | |
| // resolve fresh PM positions. The explicit range only wins if | |
| // the text at [from..to] still matches `substring`. Otherwise | |
| // we fall back to the search, which is always correct as long | |
| // as the target text hasn't been edited away. | |
| const detailLink = (detail as unknown as { | |
| link?: { | |
| url: string; | |
| range?: { from: number; to: number }; | |
| paragraphSnippet?: string; | |
| substring?: string; | |
| }; | |
| }).link; | |
| if (detailLink && editorRef.current) { | |
| const editor = editorRef.current; | |
| const size = editor.state.doc.content.size; | |
| const url = (detailLink.url || "").trim(); | |
| if (!url) { | |
| console.error("[__demo-chat] link URL missing:", detailLink); | |
| return; | |
| } | |
| const resolveBySubstring = (): { from: number; to: number } | null => { | |
| if (!detailLink.paragraphSnippet || !detailLink.substring) return null; | |
| let found: { from: number; to: number } | null = null; | |
| editor.state.doc.descendants((node, pos) => { | |
| if (found) return false; | |
| if (!node.isTextblock) return true; | |
| const text = node.textBetween(0, node.content.size, "\n", "\n"); | |
| if (!text.includes(detailLink.paragraphSnippet!)) return true; | |
| const idx = text.indexOf(detailLink.substring!); | |
| if (idx === -1) return false; | |
| found = { | |
| from: pos + 1 + idx, | |
| to: pos + 1 + idx + detailLink.substring!.length, | |
| }; | |
| return false; | |
| }); | |
| return found; | |
| }; | |
| let from = -1; | |
| let to = -1; | |
| if (detailLink.range) { | |
| const tentativeFrom = Math.max(0, Math.min(detailLink.range.from, size)); | |
| const tentativeTo = Math.max(0, Math.min(detailLink.range.to, size)); | |
| if (tentativeTo > tentativeFrom) { | |
| const textAtRange = editor.state.doc.textBetween( | |
| tentativeFrom, | |
| tentativeTo, | |
| ); | |
| const rangeStillMatches = | |
| !detailLink.substring || textAtRange === detailLink.substring; | |
| if (rangeStillMatches) { | |
| from = tentativeFrom; | |
| to = tentativeTo; | |
| } | |
| } | |
| } | |
| if (from === -1) { | |
| const resolved = resolveBySubstring(); | |
| if (resolved) { | |
| from = resolved.from; | |
| to = resolved.to; | |
| } | |
| } | |
| if (from === -1 || to <= from) { | |
| console.error( | |
| "[__demo-chat] link target not resolvable:", | |
| detailLink, | |
| ); | |
| return; | |
| } | |
| try { | |
| const domAt = editor.view.domAtPos(from); | |
| let el: HTMLElement | null = | |
| domAt.node instanceof HTMLElement | |
| ? domAt.node | |
| : domAt.node.parentElement; | |
| while (el && el.nodeName !== "P" && !/^H[1-6]$/.test(el.nodeName)) { | |
| el = el.parentElement; | |
| } | |
| if (el && typeof el.scrollIntoView === "function") { | |
| el.scrollIntoView({ behavior: "smooth", block: "center" }); | |
| } | |
| } catch { | |
| /* non-fatal */ | |
| } | |
| editor | |
| .chain() | |
| .focus() | |
| .setTextSelection({ from, to }) | |
| .setLink({ href: url }) | |
| .run(); | |
| } | |
| // ---- Citation action: seed an entry in the shared citations map | |
| // then insert an inline citation node at a PM position, and make | |
| // sure a <bibliography> block exists at the end of the doc. | |
| // | |
| // Same pipeline as the real CitationPanel (see CitationPanel.tsx): | |
| // write a CSL-JSON entry into `citationsMap` with a key, dispatch | |
| // `insertCitation(key)` on the editor, then append a bibliography | |
| // section if the doc doesn't have one yet. This exercises the | |
| // production code path end to end - chip auto-labelling included. | |
| const detailCitation = (detail as unknown as { | |
| citation?: { | |
| key: string; | |
| entry: unknown; | |
| at?: number; | |
| paragraphSnippet?: string; | |
| anchor?: "end" | "start"; | |
| }; | |
| }).citation; | |
| if (detailCitation && editorRef.current) { | |
| const editor = editorRef.current; | |
| const size = editor.state.doc.content.size; | |
| // Drift-proof anchor resolution: if `paragraphSnippet` is | |
| // provided, walk the LIVE doc for the matching paragraph and | |
| // return its start or end position. Otherwise fall back to the | |
| // explicit `at` (which can be stale under concurrent upstream | |
| // edits). Same philosophy as the link resolver above. | |
| const resolveByParagraph = (): number | null => { | |
| if (!detailCitation.paragraphSnippet) return null; | |
| const anchor = detailCitation.anchor ?? "end"; | |
| let found: number | null = null; | |
| editor.state.doc.descendants((node, pos) => { | |
| if (found !== null) return false; | |
| if (!node.isTextblock) return true; | |
| const text = node.textBetween(0, node.content.size, "\n", "\n"); | |
| if (!text.includes(detailCitation.paragraphSnippet!)) return true; | |
| found = anchor === "end" ? pos + node.nodeSize - 1 : pos + 1; | |
| return false; | |
| }); | |
| return found; | |
| }; | |
| let at = -1; | |
| const resolved = resolveByParagraph(); | |
| if (resolved !== null) { | |
| at = resolved; | |
| } else if (typeof detailCitation.at === "number") { | |
| at = Math.max(0, Math.min(detailCitation.at, size)); | |
| } | |
| if (at < 0) { | |
| console.error( | |
| "[__demo-chat] citation target not resolvable:", | |
| detailCitation, | |
| ); | |
| return; | |
| } | |
| const citationsMap = (editor.storage as unknown as Record<string, unknown>) | |
| .citation as { citationsMap?: { set: (k: string, v: unknown) => void } } | undefined; | |
| if (!citationsMap?.citationsMap) { | |
| console.error( | |
| "[__demo-chat] citationsMap not attached to editor storage", | |
| ); | |
| return; | |
| } | |
| citationsMap.citationsMap.set(detailCitation.key, detailCitation.entry); | |
| try { | |
| const domAt = editor.view.domAtPos(at); | |
| let el: HTMLElement | null = | |
| domAt.node instanceof HTMLElement | |
| ? domAt.node | |
| : domAt.node.parentElement; | |
| while (el && el.nodeName !== "P" && !/^H[1-6]$/.test(el.nodeName)) { | |
| el = el.parentElement; | |
| } | |
| if (el && typeof el.scrollIntoView === "function") { | |
| el.scrollIntoView({ behavior: "smooth", block: "center" }); | |
| } | |
| } catch { | |
| /* non-fatal */ | |
| } | |
| editor | |
| .chain() | |
| .focus() | |
| .setTextSelection(at) | |
| .insertCitation(detailCitation.key) | |
| .run(); | |
| let hasBibliography = false; | |
| editor.state.doc.descendants((node) => { | |
| if (node.type.name === "bibliography") hasBibliography = true; | |
| }); | |
| if (!hasBibliography) { | |
| const endPos = editor.state.doc.content.size; | |
| editor | |
| .chain() | |
| .insertContentAt(endPos, [ | |
| { type: "paragraph" }, | |
| { type: "bibliography" }, | |
| ]) | |
| .run(); | |
| } | |
| } | |
| }; | |
| window.addEventListener("__demo-chat", handler); | |
| return () => window.removeEventListener("__demo-chat", handler); | |
| }, []); | |
| // Dev-only: imperative settings mutation for the demo (primary hue). | |
| // Writing straight to the shared Yjs `settings` map gives every peer | |
| // the updated colour via the normal observer path - exactly what | |
| // moving the HueSlider does, minus the UI detour. | |
| useEffect(() => { | |
| if (!import.meta.env.DEV) return; | |
| if (!settingsMap) return; | |
| const handler = (event: Event) => { | |
| const detail = (event as CustomEvent).detail as | |
| | { hue?: number } | |
| | undefined; | |
| if (typeof detail?.hue === "number") { | |
| const clamped = Math.max(0, Math.min(360, Math.round(detail.hue))); | |
| settingsMap.set("primaryHue", clamped); | |
| } | |
| }; | |
| window.addEventListener("__demo-settings", handler); | |
| return () => window.removeEventListener("__demo-settings", handler); | |
| }, [settingsMap]); | |
| // Dev-only: seed or clear data files in the EmbedDataStore. Lets the | |
| // showcase demo populate the FilesSidebar with a realistic CSV before the | |
| // agent "reads" it, without requiring a real drag-and-drop upload. | |
| useEffect(() => { | |
| if (!import.meta.env.DEV) return; | |
| if (!embedDataStore) return; | |
| const handler = (event: Event) => { | |
| const detail = (event as CustomEvent).detail as | |
| | { | |
| clear?: boolean; | |
| file?: { | |
| name: string; | |
| ext?: string; | |
| content: string; | |
| columns?: string[]; | |
| rowCount?: number; | |
| uploader?: string; | |
| }; | |
| } | |
| | undefined; | |
| if (!detail) return; | |
| if (detail.clear) { | |
| for (const key of embedDataStore.keys()) embedDataStore.remove(key); | |
| return; | |
| } | |
| if (detail.file) { | |
| const f = detail.file; | |
| const ext = | |
| (f.ext ?? f.name.split(".").pop() ?? "txt").toLowerCase(); | |
| embedDataStore.set({ | |
| meta: { | |
| name: f.name, | |
| ext, | |
| size: new Blob([f.content]).size, | |
| uploader: f.uploader ?? "demo", | |
| addedAt: Date.now(), | |
| rowCount: f.rowCount, | |
| columns: f.columns, | |
| }, | |
| content: f.content, | |
| }); | |
| } | |
| }; | |
| window.addEventListener("__demo-embed-data", handler); | |
| return () => window.removeEventListener("__demo-embed-data", handler); | |
| }, [embedDataStore]); | |
| // Dev-only: write arbitrary embed HTML (for charts beyond the banner) | |
| // and optionally rename an existing src so the demo can showcase the | |
| // agent-picked filename live. Mirrors what the real createEmbed tool | |
| // does via useEmbedChat when the agent provides a `filename`. | |
| useEffect(() => { | |
| if (!import.meta.env.DEV) return; | |
| if (!embedStore) return; | |
| const handler = (event: Event) => { | |
| const detail = (event as CustomEvent).detail as | |
| | { src?: string; html?: string; renameFrom?: string } | |
| | undefined; | |
| if (!detail?.src || typeof detail.html !== "string") return; | |
| const { src, html, renameFrom } = detail; | |
| if (renameFrom && renameFrom !== src) { | |
| embedStore.set(src, html); | |
| embedStore.remove(renameFrom); | |
| handleEmbedRename(renameFrom, src); | |
| } else { | |
| embedStore.set(src, html); | |
| } | |
| }; | |
| window.addEventListener("__demo-embed-set", handler); | |
| return () => window.removeEventListener("__demo-embed-set", handler); | |
| }, [embedStore, handleEmbedRename]); | |
| // --- Editor lifecycle callbacks --------------------------------------- | |
| const editorContainerCallback = useCallback((node: HTMLDivElement | null) => { | |
| editorContainerRef.current = node; | |
| setContainerEl(node); | |
| }, []); | |
| const onEditorReady = useCallback((editor: TiptapEditor | null) => { | |
| editorRef.current = editor; | |
| setEditorInstance(editor); | |
| // Dev-only: expose the Tiptap editor for the demo script so it can | |
| // drive PM selection natively (which the collaboration-cursor | |
| // extension then broadcasts via Yjs awareness so Bob and Carol see | |
| // Alice's selected range in her persona color). | |
| if (import.meta.env.DEV) { | |
| (window as unknown as { __demoEditor?: TiptapEditor | null }).__demoEditor = | |
| editor; | |
| } | |
| }, []); | |
| const onProviderReady = useCallback((provider: HocuspocusProvider) => { | |
| providerRef.current = provider; | |
| setYDoc(provider.document); | |
| provider.on("synced", () => setIsEditorReady(true)); | |
| }, []); | |
| const onCommentStoreReady = useCallback((store: CommentStore) => setCommentStore(store), []); | |
| const onFrontmatterStoreReady = useCallback((store: FrontmatterStore) => setFrontmatterStore(store), []); | |
| const onEmbedStoreReady = useCallback((store: EmbedStore) => setEmbedStore(store), []); | |
| const onEmbedDataStoreReady = useCallback( | |
| (store: EmbedDataStore) => setEmbedDataStore(store), | |
| [], | |
| ); | |
| const onSettingsMapReady = useCallback((map: Y.Map<any>) => setSettingsMap(map), []); | |
| // --- Publish flow ----------------------------------------------------- | |
| const publishEventSourceRef = useRef<EventSource | null>(null); | |
| const openPublishDialog = useCallback(() => { | |
| setPublishState("idle"); | |
| setPublishError(""); | |
| setPublishStage(null); | |
| publishDialogRef.current?.showModal(); | |
| }, []); | |
| const closePublishDialog = useCallback(() => { | |
| publishDialogRef.current?.close(); | |
| }, []); | |
| useEffect(() => { | |
| return () => { | |
| publishEventSourceRef.current?.close(); | |
| publishEventSourceRef.current = null; | |
| }; | |
| }, []); | |
| // Dev-only: demo hook that opens the Publish dialog without hitting | |
| // the real server. The viewer sees the modal slide in at the very | |
| // end of the scenario so the final frame is "hero agent ready to | |
| // ship the article" - without actually kicking off a publish job. | |
| useEffect(() => { | |
| if (!import.meta.env.DEV) return; | |
| const handler = (event: Event) => { | |
| const detail = (event as CustomEvent).detail as | |
| | { open?: boolean } | |
| | undefined; | |
| if (detail?.open) { | |
| openPublishDialog(); | |
| } else if (detail?.open === false) { | |
| publishDialogRef.current?.close(); | |
| } | |
| }; | |
| window.addEventListener("__demo-publish", handler); | |
| return () => window.removeEventListener("__demo-publish", handler); | |
| }, [openPublishDialog]); | |
| const handlePublish = useCallback(async () => { | |
| setPublishState("loading"); | |
| setPublishError(""); | |
| setPublishStage(null); | |
| try { | |
| const res = await fetch("/api/publish", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| credentials: "include", | |
| body: JSON.stringify({ docName }), | |
| }); | |
| if (res.status === 409) { | |
| const data = await res.json().catch(() => ({ error: "Publish already in progress" })); | |
| throw new Error( | |
| data.userName | |
| ? `${data.userName} is already publishing. Please wait.` | |
| : "A publish is already in progress. Please wait." | |
| ); | |
| } | |
| if (!res.ok) { | |
| const data = await res.json().catch(() => ({ error: "Publish failed" })); | |
| throw new Error(data.error || "Publish failed"); | |
| } | |
| const { jobId } = (await res.json()) as { jobId: string }; | |
| if (!jobId) throw new Error("No job id returned"); | |
| // Open the SSE stream and forward stage events to the dialog. | |
| const es = new EventSource(`/api/publish/stream?jobId=${encodeURIComponent(jobId)}`, { | |
| withCredentials: true, | |
| }); | |
| publishEventSourceRef.current = es; | |
| es.addEventListener("stage", (e: MessageEvent) => { | |
| try { | |
| const payload = JSON.parse(e.data) as PublishStageEvent; | |
| setPublishStage(payload); | |
| } catch { | |
| // ignore malformed payloads | |
| } | |
| }); | |
| es.addEventListener("done", (e: MessageEvent) => { | |
| try { | |
| const payload = JSON.parse(e.data) as { success: boolean; error?: string }; | |
| if (payload.success) { | |
| setPublishState("success"); | |
| } else { | |
| setPublishState("error"); | |
| setPublishError(payload.error || "Publish failed"); | |
| } | |
| } catch { | |
| setPublishState("error"); | |
| setPublishError("Malformed server event"); | |
| } finally { | |
| es.close(); | |
| publishEventSourceRef.current = null; | |
| } | |
| }); | |
| es.onerror = () => { | |
| // Network blip or server closed the stream unexpectedly. If we haven't | |
| // already reached a terminal state, mark the publish as errored. | |
| if (es.readyState === EventSource.CLOSED) { | |
| publishEventSourceRef.current = null; | |
| setPublishState((prev) => (prev === "loading" ? "error" : prev)); | |
| setPublishError((prev) => prev || "Connection to publish stream lost"); | |
| } | |
| }; | |
| } catch (err: any) { | |
| setPublishState("error"); | |
| setPublishError(err.message || "Unknown error"); | |
| } | |
| }, [docName]); | |
| // --- Comment flow ----------------------------------------------------- | |
| const handleAddComment = useCallback(() => { | |
| const editor = editorRef.current; | |
| if (!editor) return; | |
| const { from, to } = editor.state.selection; | |
| if (from === to) return; | |
| selectionRange.current = { from, to }; | |
| setCommentDialogOpen(true); | |
| }, []); | |
| const handleCommentSubmit = useCallback((text: string) => { | |
| const editor = editorRef.current; | |
| if (!editor || !commentStore || !selectionRange.current) return; | |
| const { from, to } = selectionRange.current; | |
| const id = `c_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`; | |
| commentStore.add({ | |
| id, | |
| author: user.name, | |
| authorColor: user.color, | |
| text, | |
| createdAt: Date.now(), | |
| resolved: false, | |
| }); | |
| editor | |
| .chain() | |
| .focus() | |
| .setTextSelection({ from, to }) | |
| .setComment(id) | |
| .run(); | |
| selectionRange.current = null; | |
| }, [commentStore, user]); | |
| // --- Render ----------------------------------------------------------- | |
| return ( | |
| <div | |
| className={`editor-app${chatOpen ? " editor-app--chat-open" : ""}`} | |
| style={ | |
| { | |
| // Expose the current user's color (with ~44% alpha) as a CSS | |
| // variable so the global `::selection` rule can recolor every | |
| // native selection in the app with the user's identity color. | |
| // Using the native selection keeps the full line-height of the | |
| // selected text (no more "shorter than native" custom tint). | |
| // | |
| // When `editor-app--chat-open` is active the native selection | |
| // is hidden *inside the editor only* (see `_ui.css`) so the | |
| // AgentFocus PM decoration can take over as the single source | |
| // of truth for the selected range - this avoids stacking two | |
| // tints when the editor still has DOM focus after opening the | |
| // chat panel. | |
| "--local-selection-bg": `${user.color}70`, | |
| } as React.CSSProperties | |
| } | |
| > | |
| <TopBar | |
| editorInstance={editorInstance} | |
| providerRef={providerRef} | |
| docName={docName} | |
| theme={theme} | |
| user={user} | |
| loginUrl={loginUrl} | |
| isAuthenticated={isAuthenticated} | |
| canEdit={canEdit} | |
| isPublishing={publishStatus.active} | |
| publishingUserName={publishStatus.userName} | |
| onToggleTheme={toggleTheme} | |
| onOpenSettings={() => setSettingsOpen(true)} | |
| onOpenPublish={openPublishDialog} | |
| onOpenMobileToc={() => setTocSidebarOpen(true)} | |
| onOpenEmbedStudio={handleOpenEmbedStudio} | |
| /> | |
| {!isEditorReady && ( | |
| <div className="editor-loading-overlay"> | |
| <span className="spinner spinner--lg" /> | |
| </div> | |
| )} | |
| <div | |
| ref={editorContainerCallback} | |
| className={isEditorReady ? "editor-scroll editor-scroll--ready" : "editor-scroll"} | |
| > | |
| <div className="content-grid"> | |
| <div className="content-grid__hero"> | |
| <FrontmatterHero store={frontmatterStore} embedStore={embedStore} /> | |
| </div> | |
| <div className="content-grid__toc"> | |
| <div className="table-of-contents--sticky"> | |
| <TableOfContents | |
| editor={editorInstance} | |
| scrollContainer={containerEl} | |
| autoCollapse={tocAutoCollapse} | |
| /> | |
| </div> | |
| </div> | |
| <div className="content-grid__editor"> | |
| <Editor | |
| docName={docName} | |
| user={user} | |
| editorRef={editorRef} | |
| onCommentStoreReady={onCommentStoreReady} | |
| onFrontmatterStoreReady={onFrontmatterStoreReady} | |
| onEmbedStoreReady={onEmbedStoreReady} | |
| onEmbedDataStoreReady={onEmbedDataStoreReady} | |
| onSettingsMapReady={onSettingsMapReady} | |
| onEditorReady={onEditorReady} | |
| onUndoManagerReady={setUndoManager} | |
| onProviderReady={onProviderReady} | |
| onAddComment={handleAddComment} | |
| /> | |
| </div> | |
| </div> | |
| <EditorFooter store={frontmatterStore} editor={editorInstance} /> | |
| <CommentMarginIcons | |
| editor={editorInstance} | |
| commentStore={commentStore} | |
| user={user} | |
| /> | |
| </div> | |
| {chatOpen ? ( | |
| <div className="chat-floating"> | |
| <ChatPanel | |
| messages={agentChat.messages} | |
| isLoading={agentChat.isLoading} | |
| error={agentChat.error} | |
| input={agentChat.input} | |
| models={models} | |
| selectedModel={selectedModel} | |
| onModelChange={handleModelChange} | |
| onSend={agentChat.sendMessage} | |
| onSetInput={agentChat.setInput} | |
| onStop={agentChat.stop} | |
| onNewChat={() => agentChat.clearMessages()} | |
| onClose={() => setChatOpen(false)} | |
| /> | |
| </div> | |
| ) : ( | |
| <Tooltip title="AI Assistant" placement="right"> | |
| <button | |
| className={`chat-fab ${agentChat.isLoading ? "badge-dot" : "badge-dot badge-dot--hidden"}`} | |
| onMouseDown={(e) => e.preventDefault()} | |
| onClick={() => setChatOpen(true)} | |
| aria-label="AI Assistant" | |
| > | |
| <MessageCircle size={22} /> | |
| </button> | |
| </Tooltip> | |
| )} | |
| <CommentDialog | |
| open={commentDialogOpen} | |
| onClose={() => setCommentDialogOpen(false)} | |
| onSubmit={handleCommentSubmit} | |
| /> | |
| <SettingsDrawer | |
| open={settingsOpen} | |
| onClose={() => setSettingsOpen(false)} | |
| store={frontmatterStore} | |
| settingsMap={settingsMap} | |
| /> | |
| {embedStudioSrc && ( | |
| <EmbedStudio | |
| key={embedStudioSession ?? embedStudioSrc} | |
| src={embedStudioSrc} | |
| embedStore={embedStore} | |
| dataStore={embedDataStore} | |
| modelRef={modelRef} | |
| userId={chatUserId} | |
| onClose={() => { | |
| setEmbedStudioSrc(null); | |
| setEmbedStudioSession(null); | |
| }} | |
| onRename={handleEmbedRename} | |
| onSelectChart={(name) => { | |
| if (name === embedStudioSrc) return; | |
| setEmbedStudioSrc(name); | |
| // Rotate the session key so the studio remounts with a | |
| // fresh chat scope (loadMessages picks up the persisted | |
| // history of the newly-selected file). | |
| setEmbedStudioSession( | |
| `es-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`, | |
| ); | |
| }} | |
| /> | |
| )} | |
| <MobileTocSidebar | |
| open={tocSidebarOpen} | |
| editor={editorInstance} | |
| scrollContainer={containerEl} | |
| autoCollapse={tocAutoCollapse} | |
| theme={theme} | |
| onClose={() => setTocSidebarOpen(false)} | |
| onToggleTheme={toggleTheme} | |
| /> | |
| <PublishDialog | |
| ref={publishDialogRef} | |
| state={publishState} | |
| error={publishError} | |
| docName={docName} | |
| stageEvent={publishStage} | |
| onClose={closePublishDialog} | |
| onPublish={handlePublish} | |
| /> | |
| </div> | |
| ); | |
| } | |