Skip to content
387 linesCodeBlameRaw
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 */
9import "@blocknote/shadcn/style.css";
10
11import { filterSuggestionItems, insertOrUpdateBlockForSlashMenu } from "@blocknote/core";
12import { CommentsExtension, DefaultThreadStoreAuth, ThreadStoreAuth, type CommentData, type ThreadData } from "@blocknote/core/comments";
13import { RESTYjsThreadStore, withCollaboration } from "@blocknote/core/yjs";
14import { syntaxHighlighter } from "@blocknote/code-block";
15import { BlockNoteViewEditor, SuggestionMenuController, ThreadsSidebar, getDefaultReactSlashMenuItems, useCreateBlockNote } from "@blocknote/react";
16import { BlockNoteView } from "@blocknote/shadcn";
17import type { DocFile, DocRole, DocSuggestion, DocsLiveEvent, Result } from "@g1t/contracts";
18import { AlertTriangle, AtSign, Calendar, CheckCircle2, FileText, GitPullRequest, Info, Link2, Sigma, Workflow } from "lucide-react";
19import { type ReactNode, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
20
21import { canDo, cursorColour } from "../../lib/docs";
22import { schema, type DocEditorInstance } from "./blocks";
23import { EditorSkeleton } from "./editor-skeleton";
24import type { PageThread } from "./page-parts";
25import { DocsProvider, type LiveStatus } from "./provider";
26import { whoAre } from "./who";
27
28/** Someone who can be mentioned: a person (by username) or an agent (by id). */
29export type DocMentionable = { kind: "user" | "agent"; id: string; name: string; display_name: string; avatar: string | null; avatar_seed?: string | null };
30
31/** Someone on the page now. */
32export type Presence = { client: number; key: string; name: string; kind: "user" | "agent"; color: string; avatar: string | null; me: boolean };
33
34export type DocEditorProps = {
35 slug: string;
36 pageId: string;
37 role: DocRole;
38 me: { key: string; name: string; display_name: string; avatar: string | null };
39 mentionables: DocMentionable[];
40 usercontent: string;
41 suggestions: DocSuggestion[];
42 showComments: boolean;
43 onPresence?: (people: Presence[]) => void;
44 onStatus?: (status: LiveStatus) => void;
45 onEvent?: (event: DocsLiveEvent) => void;
46 /** The suggestion cards' own UI, drawn by the page beside the blocks each one changes. */
47 renderSuggestion?: (suggestion: DocSuggestion) => ReactNode;
48 /** Comments on the whole page (not on a passage), live from the document. */
49 onPageThreads?: (threads: PageThread[]) => void;
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, pageId } = props;
85 const [provider, setProvider] = useState<DocsProvider | null>(null);
86 const [synced, setSynced] = useState(false);
87
88 useEffect(() => {
89 const scheme = window.location.protocol === "https:" ? "wss" : "ws";
90 const live = new DocsProvider(`${scheme}://${window.location.host}/${slug}/-/docs/live?page=${encodeURIComponent(pageId)}`);
91 setProvider(live);
92 setSynced(false);
93 // Mount the editor once the document is here, so it never starts from
94 // an empty page 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 page.
108 // eslint-disable-next-line react-hooks/exhaustive-deps
109 }, [slug, pageId]);
110
111 if (!provider || !synced) return <EditorSkeleton />;
112 return <LiveEditor {...props} provider={provider} />;
113}
114
115function LiveEditor({ slug, pageId, role, me, mentionables, usercontent, suggestions, showComments, onPresence, renderSuggestion, onPageThreads, provider }: DocEditorProps & { provider: DocsProvider }) {
116 const editable = canDo(role, "edit");
117 const colour = cursorColour(me.name);
118 // Below 1280px the comments sit under the page: opening them goes there.
119 const comments = useRef<HTMLElement>(null);
120 useEffect(() => {
121 if (showComments && window.matchMedia("(max-width: 1279px)").matches) comments.current?.scrollIntoView({ behavior: "smooth", block: "start" });
122 }, [showComments]);
123
124 const threadStore = useMemo(() => {
125 const auth = canDo(role, "comment") ? new DefaultThreadStoreAuth(me.key, editable ? "editor" : "comment") : new ReadOnlyAuth();
126 return new RESTYjsThreadStore(`/${slug}/-/docs/threads/${pageId}`, {}, provider.doc.getMap("threads"), auth);
127 }, [slug, pageId, role, me.key, editable, provider]);
128
129 const editor = useCreateBlockNote(
130 withCollaboration({
131 schema,
132 collaboration: {
133 fragment: provider.doc.getXmlFragment("document-store"),
134 user: { name: me.display_name, color: colour, key: me.key, kind: "user", avatar: me.avatar ?? "" },
135 provider,
136 showCursorLabels: "activity",
137 },
138 extensions: [
139 CommentsExtension({
140 threadStore,
141 resolveUsers: async (ids: string[]) => {
142 const found = await whoAre(slug, ids);
143 return ids.map((id) => {
144 const p = found.get(id);
145 const avatar = id === me.key ? me.avatar : (p?.avatar ?? null);
146 return { id, username: id === me.key ? me.display_name : (p?.display_name ?? "Someone"), avatarUrl: avatar ? `${usercontent}/avatars/${avatar}` : "" };
147 });
148 },
149 }),
150 syntaxHighlighter,
151 ],
152 uploadFile: async (file: File) => {
153 const response = await fetch(`/${slug}/-/docs/upload?page=${encodeURIComponent(pageId)}&name=${encodeURIComponent(file.name)}`, {
154 method: "POST",
155 headers: { "content-type": file.type || "application/octet-stream" },
156 body: file,
157 });
158 const result = (await response.json()) as Result<DocFile>;
159 if (!result.ok) throw new Error(result.error.message);
160 return `${usercontent}${result.value.url}`;
161 },
162 }),
163 [provider, threadStore],
164 ) as unknown as DocEditorInstance;
165
166 // Who is here: everyone's awareness state, people and agents.
167 useEffect(() => {
168 const read = () => {
169 const out: Presence[] = [];
170 provider.awareness.getStates().forEach((state, client) => {
171 const user = (state as { user?: { name?: string; color?: string; key?: string; kind?: string; avatar?: string } }).user;
172 if (!user?.name) return;
173 out.push({
174 client,
175 key: user.key ?? String(client),
176 name: user.name,
177 kind: user.kind === "agent" ? "agent" : "user",
178 color: user.color ?? "#b8a6ff",
179 avatar: user.avatar || null,
180 me: client === provider.doc.clientID,
181 });
182 });
183 // One face per person, however many tabs they have open.
184 const seen = new Set<string>();
185 onPresence?.(out.filter((p) => !seen.has(p.key) && seen.add(p.key)));
186 };
187 read();
188 provider.awareness.on("change", read);
189 return () => provider.awareness.off("change", read);
190 }, [provider, onPresence]);
191
192 // Comments on the whole page, as the document holds them.
193 useEffect(() => {
194 if (!onPageThreads) return;
195 const map = provider.doc.getMap("threads");
196 const read = () => {
197 const out: PageThread[] = [];
198 map.forEach((value) => {
199 const t = value as { get(key: string): unknown };
200 if (typeof t?.get !== "function" || t.get("deletedAt")) return;
201 const meta = (t.get("metadata") as { page_level?: boolean } | null) ?? {};
202 if (!meta.page_level) return;
203 const comments = (t.get("comments") as { toArray(): { get(key: string): unknown }[] } | undefined)?.toArray() ?? [];
204 out.push({
205 id: String(t.get("id")),
206 resolved: !!t.get("resolved"),
207 comments: comments
208 .filter((c) => !c.get("deletedAt"))
209 .map((c) => ({ id: String(c.get("id")), author: String(c.get("userId")), text: commentText(c.get("body")), created_at: Number(c.get("createdAt")) || 0 })),
210 });
211 });
212 onPageThreads(out.sort((a, b) => (a.comments[0]?.created_at ?? 0) - (b.comments[0]?.created_at ?? 0)));
213 };
214 read();
215 map.observeDeep(read);
216 return () => map.unobserveDeep(read);
217 }, [provider, onPageThreads]);
218
219 // `[[` links a page, as `@` mentions someone.
220 const searchPages = async (query: string) => {
221 try {
222 const r = await fetch(`/${slug}/-/docs/api?search=${encodeURIComponent(query)}`, { headers: { accept: "application/json" } });
223 const result = (await r.json()) as Result<{ id: string; title: string; icon: string | null; path: string; space_name: string }[]>;
224 return result.ok ? result.value : [];
225 } catch {
226 return [];
227 }
228 };
229
230 const slashItems = (query: string) => {
231 const insert = (block: { type: string; props?: Record<string, unknown> }) => () => insertOrUpdateBlockForSlashMenu(editor as never, block as never);
232 const custom = [
233 { 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" } }) },
234 { 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" } }) },
235 { 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" } }) },
236 { 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" }) },
237 { title: "Math", subtext: "A formula, typeset with KaTeX", aliases: ["math", "latex", "katex", "equation", "formula"], group: "Advanced", icon: <Sigma size={18} />, onItemClick: insert({ type: "math" }) },
238 { 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" }) },
239 {
240 title: "Mention",
241 subtext: "A person or an agent",
242 aliases: ["mention", "person", "agent", "@"],
243 group: "g1t",
244 icon: <AtSign size={18} />,
245 onItemClick: () => editor.insertInlineContent("@" as never),
246 },
247 {
248 title: "Link to page",
249 subtext: "Another page in Docs",
250 aliases: ["link", "page", "[["],
251 group: "g1t",
252 icon: <Link2 size={18} />,
253 onItemClick: () => editor.insertInlineContent("[[" as never),
254 },
255 {
256 title: "Date",
257 subtext: "Today's date, which you can change",
258 aliases: ["date", "today", "when"],
259 group: "g1t",
260 icon: <Calendar size={18} />,
261 onItemClick: () => editor.insertInlineContent([{ type: "date", props: { date: new Date().toISOString().slice(0, 10) } }, " "] as never),
262 },
263 ];
264 return filterSuggestionItems([...getDefaultReactSlashMenuItems(editor as never), ...custom], query);
265 };
266
267 const mentionItems = (query: string) => {
268 const q = query.toLowerCase();
269 return mentionables
270 .filter((p) => !q || p.name.toLowerCase().includes(q) || p.display_name.toLowerCase().includes(q))
271 .slice(0, 8)
272 .map((p) => ({
273 title: p.display_name,
274 subtext: p.kind === "agent" ? `@${p.name} · agent` : `@${p.name}`,
275 icon: <AtSign size={16} />,
276 onItemClick: () => editor.insertInlineContent([{ type: "mention", props: { kind: p.kind, id: p.id, name: p.name, href: "" } }, " "] as never),
277 }));
278 };
279
280 const pageItems = async (query: string) => {
281 const pages = await searchPages(query);
282 return pages.slice(0, 8).map((p) => ({
283 title: p.title || "Untitled",
284 subtext: p.space_name,
285 icon: p.icon ? <span className="text-base leading-none">{p.icon}</span> : <FileText size={16} />,
286 onItemClick: () => editor.insertInlineContent([{ type: "mention", props: { kind: "page", id: p.id, name: p.title || "Untitled", href: p.path } }, " "] as never),
287 }));
288 };
289
290 // Suggestions: the blocks each one changes are struck through in place.
291 const struck = suggestions
292 .flatMap((s) => s.block_ids)
293 .filter((id) => /^[0-9a-zA-Z-]+$/.test(id))
294 .map((id) => `.g1t-editor .bn-block-outer[data-id="${id}"] > .bn-block > .bn-block-content`)
295 .join(",\n");
296
297 return (
298 <div className="g1t-editor relative">
299 {struck && (
300 <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>
301 )}
302 <BlockNoteView editor={editor as never} editable={editable} theme="dark" renderEditor={false} slashMenu={false} comments={canDo(role, "view")} className="g1t-bn">
303 <div className={`grid gap-8 ${showComments ? "xl:grid-cols-[minmax(0,1fr)_18rem]" : ""}`}>
304 <div className="relative min-w-0">
305 <BlockNoteViewEditor />
306 {renderSuggestion && <SuggestionRail editor={editor} suggestions={suggestions} render={renderSuggestion} hidden={showComments} />}
307 </div>
308 {showComments && (
309 <aside ref={comments} aria-label="Comments" className="scroll-mt-20 max-xl:border-t max-xl:border-line max-xl:pt-6">
310 <div className="xl:sticky xl:top-20 xl:max-h-[calc(100dvh-6rem)] xl:overflow-y-auto xl:[scrollbar-width:thin]">
311 <ThreadsSidebar filter="all" sort="position" />
312 </div>
313 </aside>
314 )}
315 </div>
316 <SuggestionMenuController triggerCharacter="/" getItems={async (query) => slashItems(query)} />
317 <SuggestionMenuController triggerCharacter="@" getItems={async (query) => mentionItems(query)} />
318 <SuggestionMenuController triggerCharacter="[[" getItems={pageItems} />
319 </BlockNoteView>
320 </div>
321 );
322}
323
324/**
325 * Each suggestion beside the blocks it changes, in the margin on a wide
326 * screen (the page lists them above the editor on a narrow one).
327 */
328function SuggestionRail({ editor, suggestions, render, hidden }: { editor: DocEditorInstance; suggestions: DocSuggestion[]; render: (s: DocSuggestion) => ReactNode; hidden: boolean }) {
329 const box = useRef<HTMLDivElement>(null);
330 const [tops, setTops] = useState<Record<string, number>>({});
331 useLayoutEffect(() => {
332 const place = () => {
333 const root = box.current?.parentElement;
334 if (!root) return;
335 const base = root.getBoundingClientRect().top;
336 const next: Record<string, number> = {};
337 let floor = 0;
338 for (const s of suggestions) {
339 const first = s.block_ids[0];
340 const el = first ? root.querySelector(`.bn-block-outer[data-id="${CSS.escape(first)}"]`) : null;
341 const want = el ? el.getBoundingClientRect().top - base : root.getBoundingClientRect().height - 40;
342 // Cards never overlap: each starts below the one before.
343 const top = Math.max(want, floor);
344 next[s.id] = top;
345 floor = top + 190;
346 }
347 setTops(next);
348 };
349 place();
350 const off = editor.onChange(() => requestAnimationFrame(place));
351 const observer = new ResizeObserver(place);
352 if (box.current?.parentElement) observer.observe(box.current.parentElement);
353 return () => {
354 off?.();
355 observer.disconnect();
356 };
357 }, [editor, suggestions]);
358 if (hidden || !suggestions.length) return null;
359 return (
360 <div ref={box} className="pointer-events-none absolute inset-y-0 left-full ml-8 hidden w-72 2xl:block" aria-label="Suggestions">
361 {suggestions.map((s) => (
362 <div key={s.id} className="pointer-events-auto absolute inset-x-0" style={{ top: tops[s.id] ?? 0 }}>
363 {render(s)}
364 </div>
365 ))}
366 </div>
367 );
368}
369
370/** A comment's text: its blocks' inline text, mentions as @name. */
371function commentText(body: unknown): string {
372 const out: string[] = [];
373 const walk = (v: unknown) => {
374 if (Array.isArray(v)) v.forEach(walk);
375 else if (v && typeof v === "object") {
376 const o = v as Record<string, unknown>;
377 if (o.type === "text" && typeof o.text === "string") out.push(o.text);
378 else if (o.type === "mention") out.push(`@${(o.props as { name?: string } | undefined)?.name ?? ""}`);
379 else {
380 walk(o.content);
381 walk(o.children);
382 }
383 }
384 };
385 walk(body);
386 return out.join("");
387}