| 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"; |
|
|
| |
| export type { CollabUser } from "./utils/user"; |
|
|
| export default function App() { |
| |
|
|
| const [user, setUser] = useState<CollabUser>(stableFallbackUser); |
| const [loginUrl, setLoginUrl] = useState<string | null>(null); |
| const [isAuthenticated, setIsAuthenticated] = useState(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(() => {}); |
| }, []); |
|
|
| |
|
|
| 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); |
|
|
| |
|
|
| const [commentDialogOpen, setCommentDialogOpen] = useState(false); |
| const [settingsOpen, setSettingsOpen] = useState(false); |
| const [chatOpen, setChatOpen] = useState(false); |
| const [embedStudioSrc, setEmbedStudioSrc] = useState<string | null>(null); |
| |
| |
| |
| |
| |
| 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); |
|
|
| |
|
|
| 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]); |
|
|
| |
|
|
| 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; |
| }, []); |
|
|
| |
|
|
| |
| useEffect(() => { |
| if (!frontmatterStore) return; |
| const sync = () => { |
| setTocAutoCollapse(frontmatterStore.get("tableOfContentsAutoCollapse") as boolean); |
| }; |
| sync(); |
| return frontmatterStore.observe(sync); |
| }, [frontmatterStore]); |
|
|
| |
| useEffect(() => { |
| if (isEditorReady) return; |
| const id = setTimeout(() => setIsEditorReady(true), 5000); |
| return () => clearTimeout(id); |
| }, [isEditorReady]); |
|
|
| |
| useEffect(() => { |
| if (!settingsMap) return; |
| const sync = () => { |
| const h = settingsMap.get("primaryHue") as number | undefined; |
| if (h !== undefined) { |
| |
| |
| document.documentElement.style.setProperty("--primary-base", oklchFromHue(h)); |
| } else { |
| |
| |
| |
| |
| |
| document.documentElement.style.removeProperty("--primary-base"); |
| } |
| }; |
| sync(); |
| settingsMap.observe(sync); |
| return () => settingsMap.unobserve(sync); |
| }, [settingsMap]); |
|
|
| |
| 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); |
| }, []); |
|
|
| |
| |
| |
| |
| 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)); |
| }, |
| [], |
| ); |
|
|
| |
| |
| |
| |
| |
| |
| |
| const handleOpenEmbedStudio = useCallback(() => { |
| const keys = embedStore ? embedStore.keys() : []; |
| if (keys.length > 0) { |
| |
| |
| 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]); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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]); |
|
|
| |
|
|
| 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, |
| }); |
|
|
| |
| 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]); |
|
|
| |
| |
| |
| |
| |
| |
| 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; |
| |
| |
| |
| |
| |
| |
| |
| range?: { from: number; to: number }; |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| animation?: "none" | "typewriter"; |
| |
| |
| |
| |
| |
| |
| |
| |
| lingerAgentFocusMs?: number; |
| }; |
| |
| |
| |
| |
| |
| |
| format?: { |
| mark: "bold" | "italic" | "strike" | "code"; |
| range: { from: number; to: number }; |
| }; |
| |
| |
| |
| |
| |
| |
| 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; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| window.setTimeout(() => { |
| const ed = editorRef.current; |
| if (!ed) return; |
|
|
| let from = -1; |
| let to = -1; |
|
|
| |
| |
| |
| 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, |
| ); |
| } |
| } |
|
|
| |
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| 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 { |
| |
| } |
|
|
| ed.commands.agentRewriteRange({ |
| from, |
| to, |
| text: replacement, |
| animation, |
| }); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| 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); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| 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 { |
| |
| } |
| 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, |
| ); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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 { |
| |
| } |
| editor |
| .chain() |
| .focus() |
| .setTextSelection({ from, to }) |
| .setLink({ href: url }) |
| .run(); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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; |
|
|
| |
| |
| |
| |
| |
| 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 { |
| |
| } |
| 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); |
| }, []); |
|
|
| |
| |
| |
| |
| 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]); |
|
|
| |
| |
| |
| 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]); |
|
|
| |
| |
| |
| |
| 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]); |
|
|
| |
|
|
| const editorContainerCallback = useCallback((node: HTMLDivElement | null) => { |
| editorContainerRef.current = node; |
| setContainerEl(node); |
| }, []); |
|
|
| const onEditorReady = useCallback((editor: TiptapEditor | null) => { |
| editorRef.current = editor; |
| setEditorInstance(editor); |
| |
| |
| |
| |
| 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), []); |
|
|
| |
|
|
| 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; |
| }; |
| }, []); |
|
|
| |
| |
| |
| |
| 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"); |
|
|
| |
| 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 { |
| |
| } |
| }); |
|
|
| 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 = () => { |
| |
| |
| 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]); |
|
|
| |
|
|
| 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]); |
|
|
| |
|
|
| 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> |
| ); |
| } |
|
|