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