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