Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Docs: a workspace knowledge base people and agents write together | 1 | /** |
| 2 | * The page editor: BlockNote on the page's live Yjs document, with g1t's | |
| 3 | * own blocks (components/docs/blocks.tsx), live cursors and presence, | |
| 4 | * comments anchored to text, and agents' suggestions marked in place. | |
| 5 | * Loaded lazily and only in the browser (routes/workspace/docs/page.tsx), | |
| 6 | * so the page's server render stays light: until it loads, the reader | |
| 7 | * sees the page's Markdown. | |
| 8 | */ | |
| 9 | import "@blocknote/shadcn/style.css"; | |
| 10 | ||
| 11 | import { filterSuggestionItems, insertOrUpdateBlockForSlashMenu } from "@blocknote/core"; | |
| 12 | import { CommentsExtension, DefaultThreadStoreAuth, ThreadStoreAuth, type CommentData, type ThreadData } from "@blocknote/core/comments"; | |
| 13 | import { RESTYjsThreadStore, withCollaboration } from "@blocknote/core/yjs"; | |
| 14 | import { syntaxHighlighter } from "@blocknote/code-block"; | |
| 15 | import { BlockNoteViewEditor, SuggestionMenuController, ThreadsSidebar, getDefaultReactSlashMenuItems, useCreateBlockNote } from "@blocknote/react"; | |
| 16 | import { BlockNoteView } from "@blocknote/shadcn"; | |
| 17 | import type { DocFile, DocRole, DocSuggestion, DocsLiveEvent, Result } from "@g1t/contracts"; | |
| Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store | 18 | import { AlertTriangle, AtSign, Calendar, CheckCircle2, FileCode2, FileText, GitPullRequest, Info, Link2, Sigma, Workflow } from "lucide-react"; |
| Docs: a workspace knowledge base people and agents write together | 19 | import { type ReactNode, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; |
| 20 | ||
| 21 | import { canDo, cursorColour } from "../../lib/docs"; | |
| 22 | import { schema, type DocEditorInstance } from "./blocks"; | |
| Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store | 23 | import { CiteDialog } from "./code"; |
| Docs: a workspace knowledge base people and agents write together | 24 | import { EditorSkeleton } from "./editor-skeleton"; |
| 25 | import type { PageThread } from "./page-parts"; | |
| 26 | import { DocsProvider, type LiveStatus } from "./provider"; | |
| 27 | import { whoAre } from "./who"; | |
| 28 | ||
| 29 | /** Someone who can be mentioned: a person (by username) or an agent (by id). */ | |
| 30 | export type DocMentionable = { kind: "user" | "agent"; id: string; name: string; display_name: string; avatar: string | null; avatar_seed?: string | null }; | |
| 31 | ||
| 32 | /** Someone on the page now. */ | |
| 33 | export type Presence = { client: number; key: string; name: string; kind: "user" | "agent"; color: string; avatar: string | null; me: boolean }; | |
| 34 | ||
| 35 | export type DocEditorProps = { | |
| 36 | slug: string; | |
| 37 | pageId: string; | |
| 38 | role: DocRole; | |
| 39 | me: { key: string; name: string; display_name: string; avatar: string | null }; | |
| 40 | mentionables: DocMentionable[]; | |
| 41 | usercontent: string; | |
| 42 | suggestions: DocSuggestion[]; | |
| 43 | showComments: boolean; | |
| 44 | onPresence?: (people: Presence[]) => void; | |
| 45 | onStatus?: (status: LiveStatus) => void; | |
| 46 | onEvent?: (event: DocsLiveEvent) => void; | |
| 47 | /** The suggestion cards' own UI, drawn by the page beside the blocks each one changes. */ | |
| 48 | renderSuggestion?: (suggestion: DocSuggestion) => ReactNode; | |
| 49 | /** Comments on the whole page (not on a passage), live from the document. */ | |
| 50 | onPageThreads?: (threads: PageThread[]) => void; | |
| Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store | 51 | /** The projects the page and its space are about: Cite code offers them first. */ |
| 52 | projects?: string[]; | |
| Docs: a workspace knowledge base people and agents write together | 53 | }; |
| 54 | ||
| 55 | /** Nobody can do anything with threads: a reader's view. */ | |
| 56 | class ReadOnlyAuth extends ThreadStoreAuth { | |
| 57 | canCreateThread() { | |
| 58 | return false; | |
| 59 | } | |
| 60 | canAddComment(_t: ThreadData) { | |
| 61 | return false; | |
| 62 | } | |
| 63 | canUpdateComment(_c: CommentData) { | |
| 64 | return false; | |
| 65 | } | |
| 66 | canDeleteComment(_c: CommentData) { | |
| 67 | return false; | |
| 68 | } | |
| 69 | canDeleteThread(_t: ThreadData) { | |
| 70 | return false; | |
| 71 | } | |
| 72 | canResolveThread(_t: ThreadData) { | |
| 73 | return false; | |
| 74 | } | |
| 75 | canUnresolveThread(_t: ThreadData) { | |
| 76 | return false; | |
| 77 | } | |
| 78 | canAddReaction(_c: CommentData) { | |
| 79 | return false; | |
| 80 | } | |
| 81 | canDeleteReaction(_c: CommentData) { | |
| 82 | return false; | |
| 83 | } | |
| 84 | } | |
| 85 | ||
| 86 | export default function DocEditor(props: DocEditorProps) { | |
| 87 | const { slug, pageId } = props; | |
| 88 | const [provider, setProvider] = useState<DocsProvider | null>(null); | |
| 89 | const [synced, setSynced] = useState(false); | |
| 90 | ||
| 91 | useEffect(() => { | |
| 92 | const scheme = window.location.protocol === "https:" ? "wss" : "ws"; | |
| 93 | const live = new DocsProvider(`${scheme}://${window.location.host}/${slug}/-/docs/live?page=${encodeURIComponent(pageId)}`); | |
| 94 | setProvider(live); | |
| 95 | setSynced(false); | |
| 96 | // Mount the editor once the document is here, so it never starts from | |
| 97 | // an empty page of its own; offline, after a moment, from what we have. | |
| 98 | const fallback = setTimeout(() => setSynced(true), 4000); | |
| 99 | const off = live.onStatus((status) => { | |
| 100 | props.onStatus?.(status); | |
| 101 | if (status === "synced") setSynced(true); | |
| 102 | }); | |
| 103 | const offEvent = live.onEvent((event) => props.onEvent?.(event)); | |
| 104 | return () => { | |
| 105 | clearTimeout(fallback); | |
| 106 | off(); | |
| 107 | offEvent(); | |
| 108 | live.destroy(); | |
| 109 | }; | |
| 110 | // The callbacks are read through props each time; reconnect only for a new page. | |
| 111 | // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 112 | }, [slug, pageId]); | |
| 113 | ||
| 114 | if (!provider || !synced) return <EditorSkeleton />; | |
| 115 | return <LiveEditor {...props} provider={provider} />; | |
| 116 | } | |
| 117 | ||
| Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store | 118 | function LiveEditor({ slug, pageId, role, me, mentionables, usercontent, suggestions, showComments, onPresence, renderSuggestion, onPageThreads, projects, provider }: DocEditorProps & { provider: DocsProvider }) { |
| Docs: a workspace knowledge base people and agents write together | 119 | const editable = canDo(role, "edit"); |
| Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store | 120 | const [citing, setCiting] = useState(false); |
| Docs: a workspace knowledge base people and agents write together | 121 | const colour = cursorColour(me.name); |
| Phones reach every page: each mode's menu from the top bar or by tapping the tab you're on, Docs first in the avatar sheet, a project's pages as a swipeable strip; dialogs as bottom sheets above the keyboard, safe areas, menus kept on screen, 40px targets, touch-visible controls, save bars above the tab bar | 122 | // Below 1280px the comments sit under the page: opening them goes there. |
| 123 | const comments = useRef<HTMLElement>(null); | |
| 124 | useEffect(() => { | |
| 125 | if (showComments && window.matchMedia("(max-width: 1279px)").matches) comments.current?.scrollIntoView({ behavior: "smooth", block: "start" }); | |
| 126 | }, [showComments]); | |
| Docs: a workspace knowledge base people and agents write together | 127 | |
| 128 | const threadStore = useMemo(() => { | |
| 129 | const auth = canDo(role, "comment") ? new DefaultThreadStoreAuth(me.key, editable ? "editor" : "comment") : new ReadOnlyAuth(); | |
| 130 | return new RESTYjsThreadStore(`/${slug}/-/docs/threads/${pageId}`, {}, provider.doc.getMap("threads"), auth); | |
| 131 | }, [slug, pageId, role, me.key, editable, provider]); | |
| 132 | ||
| 133 | const editor = useCreateBlockNote( | |
| 134 | withCollaboration({ | |
| 135 | schema, | |
| 136 | collaboration: { | |
| 137 | fragment: provider.doc.getXmlFragment("document-store"), | |
| 138 | user: { name: me.display_name, color: colour, key: me.key, kind: "user", avatar: me.avatar ?? "" }, | |
| 139 | provider, | |
| 140 | showCursorLabels: "activity", | |
| 141 | }, | |
| 142 | extensions: [ | |
| 143 | CommentsExtension({ | |
| 144 | threadStore, | |
| 145 | resolveUsers: async (ids: string[]) => { | |
| 146 | const found = await whoAre(slug, ids); | |
| 147 | return ids.map((id) => { | |
| 148 | const p = found.get(id); | |
| 149 | const avatar = id === me.key ? me.avatar : (p?.avatar ?? null); | |
| 150 | return { id, username: id === me.key ? me.display_name : (p?.display_name ?? "Someone"), avatarUrl: avatar ? `${usercontent}/avatars/${avatar}` : "" }; | |
| 151 | }); | |
| 152 | }, | |
| 153 | }), | |
| 154 | syntaxHighlighter, | |
| 155 | ], | |
| 156 | uploadFile: async (file: File) => { | |
| 157 | const response = await fetch(`/${slug}/-/docs/upload?page=${encodeURIComponent(pageId)}&name=${encodeURIComponent(file.name)}`, { | |
| 158 | method: "POST", | |
| 159 | headers: { "content-type": file.type || "application/octet-stream" }, | |
| 160 | body: file, | |
| 161 | }); | |
| 162 | const result = (await response.json()) as Result<DocFile>; | |
| 163 | if (!result.ok) throw new Error(result.error.message); | |
| 164 | return `${usercontent}${result.value.url}`; | |
| 165 | }, | |
| 166 | }), | |
| 167 | [provider, threadStore], | |
| 168 | ) as unknown as DocEditorInstance; | |
| 169 | ||
| 170 | // Who is here: everyone's awareness state, people and agents. | |
| 171 | useEffect(() => { | |
| 172 | const read = () => { | |
| 173 | const out: Presence[] = []; | |
| 174 | provider.awareness.getStates().forEach((state, client) => { | |
| 175 | const user = (state as { user?: { name?: string; color?: string; key?: string; kind?: string; avatar?: string } }).user; | |
| 176 | if (!user?.name) return; | |
| 177 | out.push({ | |
| 178 | client, | |
| 179 | key: user.key ?? String(client), | |
| 180 | name: user.name, | |
| 181 | kind: user.kind === "agent" ? "agent" : "user", | |
| 182 | color: user.color ?? "#b8a6ff", | |
| 183 | avatar: user.avatar || null, | |
| 184 | me: client === provider.doc.clientID, | |
| 185 | }); | |
| 186 | }); | |
| 187 | // One face per person, however many tabs they have open. | |
| 188 | const seen = new Set<string>(); | |
| 189 | onPresence?.(out.filter((p) => !seen.has(p.key) && seen.add(p.key))); | |
| 190 | }; | |
| 191 | read(); | |
| 192 | provider.awareness.on("change", read); | |
| 193 | return () => provider.awareness.off("change", read); | |
| 194 | }, [provider, onPresence]); | |
| 195 | ||
| 196 | // Comments on the whole page, as the document holds them. | |
| 197 | useEffect(() => { | |
| 198 | if (!onPageThreads) return; | |
| 199 | const map = provider.doc.getMap("threads"); | |
| 200 | const read = () => { | |
| 201 | const out: PageThread[] = []; | |
| 202 | map.forEach((value) => { | |
| 203 | const t = value as { get(key: string): unknown }; | |
| 204 | if (typeof t?.get !== "function" || t.get("deletedAt")) return; | |
| 205 | const meta = (t.get("metadata") as { page_level?: boolean } | null) ?? {}; | |
| 206 | if (!meta.page_level) return; | |
| 207 | const comments = (t.get("comments") as { toArray(): { get(key: string): unknown }[] } | undefined)?.toArray() ?? []; | |
| 208 | out.push({ | |
| 209 | id: String(t.get("id")), | |
| 210 | resolved: !!t.get("resolved"), | |
| 211 | comments: comments | |
| 212 | .filter((c) => !c.get("deletedAt")) | |
| 213 | .map((c) => ({ id: String(c.get("id")), author: String(c.get("userId")), text: commentText(c.get("body")), created_at: Number(c.get("createdAt")) || 0 })), | |
| 214 | }); | |
| 215 | }); | |
| 216 | onPageThreads(out.sort((a, b) => (a.comments[0]?.created_at ?? 0) - (b.comments[0]?.created_at ?? 0))); | |
| 217 | }; | |
| 218 | read(); | |
| 219 | map.observeDeep(read); | |
| 220 | return () => map.unobserveDeep(read); | |
| 221 | }, [provider, onPageThreads]); | |
| 222 | ||
| 223 | // `[[` links a page, as `@` mentions someone. | |
| 224 | const searchPages = async (query: string) => { | |
| 225 | try { | |
| 226 | const r = await fetch(`/${slug}/-/docs/api?search=${encodeURIComponent(query)}`, { headers: { accept: "application/json" } }); | |
| 227 | const result = (await r.json()) as Result<{ id: string; title: string; icon: string | null; path: string; space_name: string }[]>; | |
| 228 | return result.ok ? result.value : []; | |
| 229 | } catch { | |
| 230 | return []; | |
| 231 | } | |
| 232 | }; | |
| 233 | ||
| 234 | const slashItems = (query: string) => { | |
| 235 | const insert = (block: { type: string; props?: Record<string, unknown> }) => () => insertOrUpdateBlockForSlashMenu(editor as never, block as never); | |
| 236 | const custom = [ | |
| 237 | { title: "Info callout", subtext: "A note set apart from the text", aliases: ["callout", "note", "info"], group: "Callouts", icon: <Info size={18} />, onItemClick: insert({ type: "callout", props: { kind: "info" } }) }, | |
| 238 | { title: "Warning callout", subtext: "Something to be careful about", aliases: ["callout", "warning", "caution"], group: "Callouts", icon: <AlertTriangle size={18} />, onItemClick: insert({ type: "callout", props: { kind: "warning" } }) }, | |
| 239 | { title: "Success callout", subtext: "A tip, or what good looks like", aliases: ["callout", "success", "tip"], group: "Callouts", icon: <CheckCircle2 size={18} />, onItemClick: insert({ type: "callout", props: { kind: "success" } }) }, | |
| 240 | { title: "Diagram", subtext: "A Mermaid diagram, drawn as you type", aliases: ["mermaid", "flowchart", "sequence", "chart"], group: "Advanced", icon: <Workflow size={18} />, onItemClick: insert({ type: "mermaid" }) }, | |
| 241 | { title: "Math", subtext: "A formula, typeset with KaTeX", aliases: ["math", "latex", "katex", "equation", "formula"], group: "Advanced", icon: <Sigma size={18} />, onItemClick: insert({ type: "math" }) }, | |
| 242 | { title: "Embed from g1t", subtext: "An issue, pull request, channel, project or page, live", aliases: ["embed", "issue", "pull", "pr", "channel", "project"], group: "g1t", icon: <GitPullRequest size={18} />, onItemClick: insert({ type: "embed" }) }, | |
| 243 | { | |
| 244 | title: "Mention", | |
| 245 | subtext: "A person or an agent", | |
| 246 | aliases: ["mention", "person", "agent", "@"], | |
| 247 | group: "g1t", | |
| 248 | icon: <AtSign size={18} />, | |
| 249 | onItemClick: () => editor.insertInlineContent("@" as never), | |
| 250 | }, | |
| 251 | { | |
| 252 | title: "Link to page", | |
| 253 | subtext: "Another page in Docs", | |
| 254 | aliases: ["link", "page", "[["], | |
| 255 | group: "g1t", | |
| 256 | icon: <Link2 size={18} />, | |
| 257 | onItemClick: () => editor.insertInlineContent("[[" as never), | |
| 258 | }, | |
| 259 | { | |
| Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store | 260 | title: "Cite code", |
| 261 | subtext: "A file, folder, symbol, endpoint or variable this page describes", | |
| 262 | aliases: ["cite", "code", "citation", "path", "file", "symbol", "endpoint", "env", "variable"], | |
| 263 | group: "g1t", | |
| 264 | icon: <FileCode2 size={18} />, | |
| 265 | onItemClick: () => setCiting(true), | |
| 266 | }, | |
| 267 | { | |
| Docs: a workspace knowledge base people and agents write together | 268 | title: "Date", |
| 269 | subtext: "Today's date, which you can change", | |
| 270 | aliases: ["date", "today", "when"], | |
| 271 | group: "g1t", | |
| 272 | icon: <Calendar size={18} />, | |
| 273 | onItemClick: () => editor.insertInlineContent([{ type: "date", props: { date: new Date().toISOString().slice(0, 10) } }, " "] as never), | |
| 274 | }, | |
| 275 | ]; | |
| 276 | return filterSuggestionItems([...getDefaultReactSlashMenuItems(editor as never), ...custom], query); | |
| 277 | }; | |
| 278 | ||
| 279 | const mentionItems = (query: string) => { | |
| 280 | const q = query.toLowerCase(); | |
| 281 | return mentionables | |
| 282 | .filter((p) => !q || p.name.toLowerCase().includes(q) || p.display_name.toLowerCase().includes(q)) | |
| 283 | .slice(0, 8) | |
| 284 | .map((p) => ({ | |
| 285 | title: p.display_name, | |
| 286 | subtext: p.kind === "agent" ? `@${p.name} · agent` : `@${p.name}`, | |
| 287 | icon: <AtSign size={16} />, | |
| 288 | onItemClick: () => editor.insertInlineContent([{ type: "mention", props: { kind: p.kind, id: p.id, name: p.name, href: "" } }, " "] as never), | |
| 289 | })); | |
| 290 | }; | |
| 291 | ||
| 292 | const pageItems = async (query: string) => { | |
| 293 | const pages = await searchPages(query); | |
| 294 | return pages.slice(0, 8).map((p) => ({ | |
| 295 | title: p.title || "Untitled", | |
| 296 | subtext: p.space_name, | |
| 297 | icon: p.icon ? <span className="text-base leading-none">{p.icon}</span> : <FileText size={16} />, | |
| 298 | onItemClick: () => editor.insertInlineContent([{ type: "mention", props: { kind: "page", id: p.id, name: p.title || "Untitled", href: p.path } }, " "] as never), | |
| 299 | })); | |
| 300 | }; | |
| 301 | ||
| 302 | // Suggestions: the blocks each one changes are struck through in place. | |
| 303 | const struck = suggestions | |
| 304 | .flatMap((s) => s.block_ids) | |
| 305 | .filter((id) => /^[0-9a-zA-Z-]+$/.test(id)) | |
| 306 | .map((id) => `.g1t-editor .bn-block-outer[data-id="${id}"] > .bn-block > .bn-block-content`) | |
| 307 | .join(",\n"); | |
| 308 | ||
| 309 | return ( | |
| 310 | <div className="g1t-editor relative"> | |
| 311 | {struck && ( | |
| 312 | <style>{`${struck} { text-decoration: line-through; text-decoration-color: color-mix(in srgb, var(--g1t-danger) 70%, transparent); background: color-mix(in srgb, var(--g1t-danger) 8%, transparent); border-radius: 4px; }`}</style> | |
| 313 | )} | |
| 314 | <BlockNoteView editor={editor as never} editable={editable} theme="dark" renderEditor={false} slashMenu={false} comments={canDo(role, "view")} className="g1t-bn"> | |
| 315 | <div className={`grid gap-8 ${showComments ? "xl:grid-cols-[minmax(0,1fr)_18rem]" : ""}`}> | |
| 316 | <div className="relative min-w-0"> | |
| 317 | <BlockNoteViewEditor /> | |
| 318 | {renderSuggestion && <SuggestionRail editor={editor} suggestions={suggestions} render={renderSuggestion} hidden={showComments} />} | |
| 319 | </div> | |
| 320 | {showComments && ( | |
| Phones reach every page: each mode's menu from the top bar or by tapping the tab you're on, Docs first in the avatar sheet, a project's pages as a swipeable strip; dialogs as bottom sheets above the keyboard, safe areas, menus kept on screen, 40px targets, touch-visible controls, save bars above the tab bar | 321 | <aside ref={comments} aria-label="Comments" className="scroll-mt-20 max-xl:border-t max-xl:border-line max-xl:pt-6"> |
| 322 | <div className="xl:sticky xl:top-20 xl:max-h-[calc(100dvh-6rem)] xl:overflow-y-auto xl:[scrollbar-width:thin]"> | |
| Docs: a workspace knowledge base people and agents write together | 323 | <ThreadsSidebar filter="all" sort="position" /> |
| 324 | </div> | |
| 325 | </aside> | |
| 326 | )} | |
| 327 | </div> | |
| 328 | <SuggestionMenuController triggerCharacter="/" getItems={async (query) => slashItems(query)} /> | |
| 329 | <SuggestionMenuController triggerCharacter="@" getItems={async (query) => mentionItems(query)} /> | |
| 330 | <SuggestionMenuController triggerCharacter="[[" getItems={pageItems} /> | |
| 331 | </BlockNoteView> | |
| Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store | 332 | {editable && ( |
| 333 | <CiteDialog | |
| 334 | slug={slug} | |
| 335 | open={citing} | |
| 336 | onOpenChange={setCiting} | |
| 337 | projects={projects} | |
| 338 | onCite={(c) => { | |
| 339 | editor.focus(); | |
| 340 | editor.insertInlineContent([{ type: "citation", props: c }, " "] as never); | |
| 341 | }} | |
| 342 | /> | |
| 343 | )} | |
| Docs: a workspace knowledge base people and agents write together | 344 | </div> |
| 345 | ); | |
| 346 | } | |
| 347 | ||
| 348 | /** | |
| 349 | * Each suggestion beside the blocks it changes, in the margin on a wide | |
| 350 | * screen (the page lists them above the editor on a narrow one). | |
| 351 | */ | |
| 352 | function SuggestionRail({ editor, suggestions, render, hidden }: { editor: DocEditorInstance; suggestions: DocSuggestion[]; render: (s: DocSuggestion) => ReactNode; hidden: boolean }) { | |
| 353 | const box = useRef<HTMLDivElement>(null); | |
| 354 | const [tops, setTops] = useState<Record<string, number>>({}); | |
| 355 | useLayoutEffect(() => { | |
| 356 | const place = () => { | |
| 357 | const root = box.current?.parentElement; | |
| 358 | if (!root) return; | |
| 359 | const base = root.getBoundingClientRect().top; | |
| 360 | const next: Record<string, number> = {}; | |
| 361 | let floor = 0; | |
| 362 | for (const s of suggestions) { | |
| 363 | const first = s.block_ids[0]; | |
| 364 | const el = first ? root.querySelector(`.bn-block-outer[data-id="${CSS.escape(first)}"]`) : null; | |
| 365 | const want = el ? el.getBoundingClientRect().top - base : root.getBoundingClientRect().height - 40; | |
| 366 | // Cards never overlap: each starts below the one before. | |
| 367 | const top = Math.max(want, floor); | |
| 368 | next[s.id] = top; | |
| 369 | floor = top + 190; | |
| 370 | } | |
| 371 | setTops(next); | |
| 372 | }; | |
| 373 | place(); | |
| 374 | const off = editor.onChange(() => requestAnimationFrame(place)); | |
| 375 | const observer = new ResizeObserver(place); | |
| 376 | if (box.current?.parentElement) observer.observe(box.current.parentElement); | |
| 377 | return () => { | |
| 378 | off?.(); | |
| 379 | observer.disconnect(); | |
| 380 | }; | |
| 381 | }, [editor, suggestions]); | |
| 382 | if (hidden || !suggestions.length) return null; | |
| 383 | return ( | |
| 384 | <div ref={box} className="pointer-events-none absolute inset-y-0 left-full ml-8 hidden w-72 2xl:block" aria-label="Suggestions"> | |
| 385 | {suggestions.map((s) => ( | |
| 386 | <div key={s.id} className="pointer-events-auto absolute inset-x-0" style={{ top: tops[s.id] ?? 0 }}> | |
| 387 | {render(s)} | |
| 388 | </div> | |
| 389 | ))} | |
| 390 | </div> | |
| 391 | ); | |
| 392 | } | |
| 393 | ||
| 394 | /** A comment's text: its blocks' inline text, mentions as @name. */ | |
| 395 | function commentText(body: unknown): string { | |
| 396 | const out: string[] = []; | |
| 397 | const walk = (v: unknown) => { | |
| 398 | if (Array.isArray(v)) v.forEach(walk); | |
| 399 | else if (v && typeof v === "object") { | |
| 400 | const o = v as Record<string, unknown>; | |
| 401 | if (o.type === "text" && typeof o.text === "string") out.push(o.text); | |
| 402 | else if (o.type === "mention") out.push(`@${(o.props as { name?: string } | undefined)?.name ?? ""}`); | |
| 403 | else { | |
| 404 | walk(o.content); | |
| 405 | walk(o.children); | |
| 406 | } | |
| 407 | } | |
| 408 | }; | |
| 409 | walk(body); | |
| 410 | return out.join(""); | |
| 411 | } |