Skip to content
408 linesCodeBlameRaw
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 */
8import "@blocknote/shadcn/style.css";
9
10import { filterSuggestionItems, insertOrUpdateBlockForSlashMenu } from "@blocknote/core";
11import { CommentsExtension, DefaultThreadStoreAuth, ThreadStoreAuth, type CommentData, type ThreadData } from "@blocknote/core/comments";
12import { RESTYjsThreadStore, withCollaboration } from "@blocknote/core/yjs";
13import { syntaxHighlighter } from "@blocknote/code-block";
14import { BlockNoteViewEditor, SuggestionMenuController, ThreadsSidebar, getDefaultReactSlashMenuItems, useCreateBlockNote } from "@blocknote/react";
15import { BlockNoteView } from "@blocknote/shadcn";
16import type { DocFile, DocRole, FolioSearchHit, FolioSuggestion, FoliosLiveEvent, Result } from "@g1t/contracts";
17import { AlertTriangle, AtSign, Calendar, CheckCircle2, FileCode2, FileText, GitPullRequest, Info, Link2, Sigma, Workflow } from "lucide-react";
18import { type ReactNode, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
19
20import { canDo, cursorColour } from "../../../lib/folios";
21import { schema, type DocEditorInstance } from "./blocks";
22import { CiteDialog } from "./code";
23import { EditorSkeleton } from "./editor-skeleton";
24import type { PageThread } from "./page-parts";
25import { FolioProvider, type LiveStatus } from "../provider";
26import type { Presence } from "../shell";
27import { whoAre } from "../who";
28
29/** Someone who can be mentioned: a person (by username) or an agent (by id). */
30export type DocMentionable = { kind: "user" | "agent"; id: string; name: string; display_name: string; avatar: string | null; avatar_seed?: string | null };
31
32export 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. */
53class 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
83export 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
115function 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">
319 <div className="xl:sticky xl:top-20 xl:max-h-[calc(100dvh-6rem)] xl:overflow-y-auto xl:[scrollbar-width:thin]">
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 */
349function 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. */
392function 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}