1"use client";
2
3
18import { useCallback, useEffect, useMemo, useRef, useState } from "react";
19import { useParams } from "next/navigation";
20import {
21 DndContext,
22 PointerSensor,
23 KeyboardSensor,
24 closestCenter,
25 useSensor,
26 useSensors,
27 type DragEndEvent,
28} from "@dnd-kit/core";
29import {
30 SortableContext,
31 arrayMove,
32 sortableKeyboardCoordinates,
33 useSortable,
34 verticalListSortingStrategy,
35} from "@dnd-kit/sortable";
36import { CSS } from "@dnd-kit/utilities";
37import { Clock, FolderOpen, Image as ImageIcon, Link2, MessageSquare, Smile, Sparkles } from "lucide-react";
38import { useBacklinks, usePage, usePageBlocks, useUpdatePage } from "@/hooks/use-pages";
39import { useWorkspace } from "@/hooks/use-workspace";
40import { useDeleteBlock, useInsertBlock, useMoveBlock, useMergedBlocks } from "@/hooks/use-blocks";
41import { isEmptyTextBlock, isContentEmpty, isAutoCleanableType } from "@/lib/block-content";
42import { getBlockBatchQueue } from "@/lib/batch-queue";
43import { usePagePresence } from "@/hooks/use-presence";
44import { usePresenceAnnouncer } from "@/hooks/use-presence-announcer";
45import { usePresenceToasts } from "@/hooks/use-presence-toasts";
46import { useEditorStore } from "@/stores/editor-store";
47import { useCommentPillStore } from "@/stores/comment-pill-store";
48import { toast, useUiStore } from "@/stores/ui-store";
49import { useAuthStore } from "@/lib/auth-store";
50import { realtime } from "@/lib/realtime";
51import { CommentPanel } from "@/components/comments/comment-panel";
52import { RelatedPanel } from "./related-panel";
53import { cn, formatRelativeTime, sortByPosition } from "@/lib/utils";
54import { useFileUploads } from "@/lib/files/use-file-uploads";
55import { BLOCK_TYPE_LABELS } from "@/lib/constants";
56import { PresenceBar } from "@/components/collaboration/PresenceBar";
57import { CursorLayer } from "@/components/collaboration/CursorLayer";
58import { FilePicker } from "@/components/files/FilePicker";
59import { BlockRenderer } from "./block-renderer";
60import { BlockToolbar } from "./block-toolbar";
61import { ContextMenu } from "./context-menu";
62import { InsertDialog } from "./insert-dialog";
63import { TemplateDialog } from "./template-dialog";
64import { SelectionToolbar } from "./selection-toolbar";
65import { WritingDialog } from "@/components/ai/writing-diff";
66import { ReviewBanner } from "./review-banner";
67import { blockApi, templateApi, type BatchBlockOp } from "@/lib/api";
68import { useQueryClient } from "@tanstack/react-query";
69import {
70 htmlHasBlockStructure,
71 pasteHtmlToBlocks,
72 parseClipboardText,
73 type PastedBlockSpec
74} from "@/lib/clipboard-import";
75import type { WritingAction } from "@/lib/ai";
76import type { Block } from "@/lib/types";
77import type { SlashCommand } from "./slash-menu";
78
79interface PageViewProps {
80 pageId: string;
81 focusBlockId?: string | null;
82}
83
84export function PageView({ pageId, focusBlockId }: PageViewProps) {
85 const qc = useQueryClient();
86 const { data: page } = usePage(pageId);
87 const { data: serverBlocks } = usePageBlocks(pageId, null);
88 const blocks = useMergedBlocks(pageId, serverBlocks ?? []);
89 const sorted = useMemo(() => sortByPosition(blocks), [blocks]);
90
91
92
93 const contentBlockCount = useMemo(
94 () => sorted.filter((b) => b.properties?.isTitle !== true).length,
95 [sorted],
96 );
97
98
99
100
101
102 const wsParams = useParams<{ workspace: string }>();
103 const { data: wsRoleData } = useWorkspace(wsParams?.workspace ?? "");
104 const canGenerate = (wsRoleData?.role ?? "").toLowerCase() !== "guest";
105
106
107
108
109
110 const selfUserId = useAuthStore((s) => s.user?.id ?? null);
111 const presence = usePagePresence(pageId);
112 const others = useMemo(
113 () => (selfUserId ? presence.filter((p) => p.userId !== selfUserId) : presence),
114 [presence, selfUserId],
115 );
116 const announcement = usePresenceAnnouncer(pageId, selfUserId);
117 usePresenceToasts(pageId, selfUserId);
118 const blockTypeFor = useCallback(
119 (blockId: string) => {
120 const b = blocks.find((x) => x.id === blockId);
121 return b ? BLOCK_TYPE_LABELS[b.type] : null;
122 },
123 [blocks],
124 );
125 const blockIds = useMemo(() => sorted.map((b) => b.id), [sorted]);
126
127
128
129
130
131
132
133
134
135
136 const renderBlocks = useMemo(() => sorted.filter((b) => b.properties?.isTitle !== true), [sorted]);
137
138 const updatePage = useUpdatePage(page?.workspaceId ?? "");
139 const insertBlock = useInsertBlock(pageId);
140 const moveBlock = useMoveBlock(pageId);
141 const deleteBlock = useDeleteBlock(pageId);
142 const { setSelection } = useEditorStore();
143 const storeSelected = useEditorStore((s) => s.selectedBlockId);
144
145
146
147
148
149
150 const { start: startUpload } = useFileUploads(page?.workspaceId ?? null, pageId);
151 const [fileDragActive, setFileDragActive] = useState(false);
152 const fileDragDepth = useRef(0);
153 const lastDropPosition = useRef<string | null>(null);
154 const sortedRef = useRef(sorted);
155 sortedRef.current = sorted;
156
157 const dropTargetPosition = useCallback(
158 (clientY: number): string | null => {
159
160 const els = Array.from(
161 pageRef.current?.querySelectorAll<HTMLElement>("[data-block-id]") ?? [],
162 );
163 let best: string | null = null;
164 let bestDist = Infinity;
165 for (const el of els) {
166 const rect = el.getBoundingClientRect();
167 if (clientY < rect.top) continue;
168 const dist = clientY - rect.top;
169 if (dist < bestDist) {
170 bestDist = dist;
171 best = el.dataset.blockId ?? null;
172 }
173 }
174 if (!best) return null;
175 const above = sortedRef.current.find((b) => b.id === best);
176 return above ? above.position : null;
177 },
178
179 [],
180 );
181
182 const onFilesDropped = useCallback(
183 async (e: React.DragEvent) => {
184 e.preventDefault();
185 fileDragDepth.current = 0;
186 setFileDragActive(false);
187 const files = Array.from(e.dataTransfer?.files ?? []);
188 if (files.length === 0) return;
189 const position = dropTargetPosition(e.clientY);
190 lastDropPosition.current = position;
191 for (const file of files) {
192 await startUpload(file, { parentId: null, position });
193 }
194 },
195 [startUpload, dropTargetPosition],
196 );
197
198 const onDragEnter = useCallback((e: React.DragEvent) => {
199 if (!e.dataTransfer?.types?.includes("Files")) return;
200 fileDragDepth.current += 1;
201 setFileDragActive(true);
202 }, []);
203 const onDragOver = useCallback((e: React.DragEvent) => {
204 if (fileDragActive) e.preventDefault();
205 }, [fileDragActive]);
206 const onDragLeave = useCallback(() => {
207 fileDragDepth.current = Math.max(0, fileDragDepth.current - 1);
208 if (fileDragDepth.current === 0) setFileDragActive(false);
209 }, []);
210
211
217 const focusNewBlock = useCallback((id: string) => {
218 setFocusTarget(id);
219 pendingFocus.current = id;
220 hasScrolledFocus.current = true;
221 }, []);
222
223
224
225
226
227
228
229
230
231 const runPasteImport = useCallback(
232 async (specs: PastedBlockSpec[], anchorBlockId: string | null) => {
233
234
235
236
237 const sorted = sortedRef.current;
238 const anchor = anchorBlockId ? sorted.find((b) => b.id === anchorBlockId) ?? null : null;
239 let anchorAfter: string | null = null;
240 if (anchor) {
241 anchorAfter = anchor.id;
242 try {
243
244
245 let deepest = anchor.id;
246 for (let d = 0; d < 8; d += 1) {
247 const kids = await blockApi.list(pageId, { parentId: deepest });
248 const last = kids[kids.length - 1];
249 if (!last) break;
250 deepest = last.id;
251 if (deepest === anchor.id) break;
252 }
253 anchorAfter = deepest;
254 } catch {
255
256 }
257 }
258
259
260
261
262
263
264
265
266 const lastInGroup = new Map<string, string>();
267 const ops: BatchBlockOp[] = specs.map((s) => {
268 const groupKey = s.parentId ?? "__top__";
269 const chainFrom = lastInGroup.get(groupKey) ?? (s.parentId ?? anchorAfter);
270 lastInGroup.set(groupKey, s.id);
271 return {
272 op: "upsert" as const,
273 block: {
274 id: s.id,
275 type: s.type,
276 content: s.content,
277 ...(s.properties ? { properties: s.properties } : { properties: {} }),
278 parentId: s.parentId ?? null,
279 ...(chainFrom ? { afterBlockId: chainFrom } : {})
280 }
281 };
282 });
283
284
285 try {
286 for (let i = 0; i < ops.length; i += 200) {
287 await blockApi.batch(pageId, ops.slice(i, i + 200));
288 }
289 qc.invalidateQueries({ queryKey: ["pages", pageId, "blocks"] });
290 toast("success", `Pasted ${specs.length} block${specs.length === 1 ? "" : "s"}`);
291
292
293 const firstRoot = specs.find((s) => s.parentId === null);
294 if (firstRoot) focusNewBlock(firstRoot.id);
295 } catch (err) {
296 toast("error", "Paste failed", err instanceof Error ? err.message : undefined);
297 }
298 },
299 [pageId, focusNewBlock]
300 );
301
302 useEffect(() => {
303 const onPaste = (e: ClipboardEvent) => {
304 if (useEditorStore.getState().activePageId !== pageId) return;
305 const cd = e.clipboardData;
306 if (!cd) return;
307
308
309 const fileItem = Array.from(cd.items ?? []).find((i) => i.kind === "file" && i.type.startsWith("image/"));
310 if (fileItem) {
311 const file = fileItem.getAsFile();
312 if (file) {
313 e.preventDefault();
314 const selected = useEditorStore.getState().selectedBlockId;
315 const anchor = sortedRef.current.find((b) => b.id === selected);
316 void startUpload(file, { parentId: null, position: anchor ? anchor.position : null });
317 return;
318 }
319 }
320
321
322 const internal = parseClipboardText(cd.getData("text/plain"));
323 if (internal) {
324 e.preventDefault();
325 void runPasteImport(internal, useEditorStore.getState().selectedBlockId);
326 return;
327 }
328
329
330 const html = cd.getData("text/html");
331 if (html && htmlHasBlockStructure(html)) {
332 e.preventDefault();
333 const specs = pasteHtmlToBlocks(html);
334 if (specs.length > 0) void runPasteImport(specs, useEditorStore.getState().selectedBlockId);
335 return;
336 }
337
338 };
339 window.addEventListener("paste", onPaste);
340 return () => window.removeEventListener("paste", onPaste);
341 }, [pageId, startUpload, runPasteImport]);
342
343 const [filesPickerOpen, setFilesPickerOpen] = useState(false);
344 const [focusTarget, setFocusTarget] = useState<string | null>(null);
345 const [menu, setMenu] = useState<{ x: number; y: number; block: Block } | null>(null);
346
347 const [iconPickerOpen, setIconPickerOpen] = useState(false);
348 const [coverPickerOpen, setCoverPickerOpen] = useState(false);
349
350
351
352
353
354
355 const titleRef = useRef<HTMLDivElement>(null);
356 useEffect(() => {
357 const el = titleRef.current;
358 if (!el) return;
359 if (document.activeElement === el) return;
360 el.textContent = page?.title ?? "";
361 }, [page?.id, page?.title]);
362
363
364
365
366
367
368
369
370 const titleCommitTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
371 const commitTitleNow = () => {
372 if (titleCommitTimer.current) {
373 clearTimeout(titleCommitTimer.current);
374 titleCommitTimer.current = null;
375 }
376 const el = titleRef.current;
377 if (!el) return;
378 updatePage.mutate({ id: pageId, title: el.textContent ?? "" });
379 };
380 const commitTitleRef = useRef(commitTitleNow);
381 commitTitleRef.current = commitTitleNow;
382 useEffect(() => {
383
384
385 return () => {
386 if (titleCommitTimer.current) {
387 clearTimeout(titleCommitTimer.current);
388 titleCommitTimer.current = null;
389 }
390 const el = titleRef.current;
391 if (el && el.textContent) commitTitleRef.current();
392 };
393 }, []);
394 const [insert, setInsert] = useState<{
395 block: Block;
396 tab: "image" | "link" | "table" | "database";
397 } | null>(null);
398 const [templateOpen, setTemplateOpen] = useState(false);
399 const [commentsOpen, setCommentsOpen] = useState(false);
400
401 const [relatedOpen, setRelatedOpen] = useState(false);
402 const backlinks = useBacklinks(pageId);
403 const setAiPanel = useUiStore((s) => s.setAiPanel);
404 const setGenerate = useUiStore((s) => s.setGenerate);
405 const commentPill = useCommentPillStore((s) => s.byPage[pageId] ?? 0);
406 const pageRef = useRef<HTMLDivElement>(null);
407 const useCommentsOpenRef = useRef(commentsOpen);
408 useEffect(() => {
409 useCommentsOpenRef.current = commentsOpen;
410 }, [commentsOpen]);
411 const pendingFocus = useRef<string | null>(null);
412 const hasScrolledFocus = useRef(false);
413
414
415
416
417
418
419 useEffect(() => {
420 const off = realtime.on("comment.event", (frame) => {
421 if (frame.type !== "comment.event" || frame.pageId !== pageId) return;
422 if (frame.kind !== "created" && frame.kind !== "replied") return;
423 if (useCommentsOpenRef.current) return;
424 useCommentPillStore.getState().add(pageId);
425 });
426 return () => off();
427 }, [pageId]);
428
429
430
431
432 const selectedBlockId = storeSelected;
433
434
435
436
437
438
439 const selectedBlockIds = useEditorStore((s) => s.selectedBlockIds);
440 const toggleSelected = useEditorStore((s) => s.toggleSelected);
441 const setManySelected = useEditorStore((s) => s.setManySelected);
442 const shiftAnchorId = useRef<string | null>(null);
443 const [writing, setWriting] = useState<{
444 open: boolean;
445 action: WritingAction;
446 language?: string;
447 blockIds: string[];
448 }>({ open: false, action: "summarize", blockIds: [] });
449
450 const selectedBlocks = useMemo(() => {
451 const byId = new Map(sorted.map((b) => [b.id, b]));
452 return selectedBlockIds.map((id) => byId.get(id)).filter((b): b is Block => !!b);
453 }, [selectedBlockIds, sorted]);
454
455
456
457
458 const dialogBlocks = useMemo(
459 () => sorted.filter((b) => writing.blockIds.includes(b.id)),
460 [sorted, writing.blockIds]
461 );
462
463 const openWriting = useCallback(
464 (action: WritingAction, blockIds: string[], language?: string) => {
465 setWriting({ open: true, action, language, blockIds });
466 },
467 []
468 );
469
470
471 const selectRow = useCallback(
472 (id: string, e?: { metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean }) => {
473 if (e?.metaKey || e?.ctrlKey) {
474
475 const next = selectedBlockIds.includes(id)
476 ? selectedBlockIds.filter((x) => x !== id)
477 : [...selectedBlockIds, id];
478 setManySelected(next);
479 shiftAnchorId.current = id;
480 setSelection(id);
481 return;
482 }
483 if (e?.shiftKey && shiftAnchorId.current) {
484
485 const a = sorted.findIndex((b) => b.id === shiftAnchorId.current);
486 const b = sorted.findIndex((x) => x.id === id);
487 if (a !== -1 && b !== -1) {
488 const [lo, hi] = a < b ? [a, b] : [b, a];
489 setManySelected(sorted.slice(lo, hi + 1).map((x) => x.id));
490 } else {
491 setManySelected([id]);
492 }
493 setSelection(id);
494 return;
495 }
496
497 setSelection(id === selectedBlockId ? null : id);
498 shiftAnchorId.current = id;
499 },
500 [selectedBlockIds, selectedBlockId, sorted, setManySelected, setSelection]
501 );
502
503 const clearSelection = useCallback(() => {
504 setManySelected([]);
505 shiftAnchorId.current = null;
506 }, [setManySelected]);
507
508 const scrollAndFlash = useCallback((id: string) => {
509 const el = pageRef.current?.querySelector<HTMLElement>(`[data-block-id="${id}"]`);
510 if (!el) return false;
511 el.scrollIntoView({ block: "center", behavior: "smooth" });
512 el.classList.add("block-focus-flash");
513 setTimeout(() => el.classList.remove("block-focus-flash"), 1600);
514 return true;
515 }, []);
516
517
518
519
520 useEffect(() => {
521 const target = focusBlockId ?? useEditorStore.getState().focusBlockId;
522 if (!target) return;
523 if (hasScrolledFocus.current && target === pendingFocus.current) return;
524 hasScrolledFocus.current = true;
525 pendingFocus.current = target;
526 const t = setTimeout(() => {
527 if (scrollAndFlash(target)) {
528 pendingFocus.current = null;
529 setFocusTarget(target);
530 useEditorStore.getState().setFocus(null);
531 }
532 }, 80);
533 return () => clearTimeout(t);
534
535 }, [focusBlockId, page]);
536
537
538 useEffect(() => {
539 if (!pendingFocus.current) return;
540 if (!sorted.some((b) => b.id === pendingFocus.current)) return;
541 const t = setTimeout(() => {
542 if (scrollAndFlash(pendingFocus.current as string)) {
543 setFocusTarget(pendingFocus.current);
544 pendingFocus.current = null;
545 }
546 }, 40);
547 return () => clearTimeout(t);
548 }, [sorted, scrollAndFlash]);
549
550
551
552
553 const onSlashPick = useCallback(
554 async (command: SlashCommand, context: { parentId: string | null; position: string }) => {
555
556
557
558
559
560 const anchor = sorted.find(
561 (b) => b.position === context.position && b.parentId === context.parentId,
562 );
563 if (command.blockType) {
564 try {
565 const created = await insertBlock.mutateAsync({
566 parentId: context.parentId,
567 afterBlockId: anchor?.id ?? null,
568 type: command.blockType,
569 });
570 focusNewBlock(created.id);
571 } catch (e) {
572 toast("error", "Insert failed", e instanceof Error ? e.message : undefined);
573 }
574 return;
575 }
576 if (command.special === "image") {
577
578
579
580 if (anchor) setInsert({ block: anchor, tab: "image" });
581 } else if (command.special === "table") {
582
583
584 if (anchor) setInsert({ block: anchor, tab: "table" });
585 } else if (command.special === "database") {
586
587 if (anchor) setInsert({ block: anchor, tab: "database" });
588 } else if (command.special === "template") {
589 setTemplateOpen(true);
590 } else if (command.special === "synced") {
591
592
593 try {
594 await insertBlock.mutateAsync({
595 parentId: context.parentId,
596 afterBlockId: anchor?.id ?? null,
597 type: "synced_block",
598 properties: { targetPageId: null, targetBlockId: null },
599 });
600 } catch (e) {
601 toast("error", "Insert failed", e instanceof Error ? e.message : undefined);
602 }
603 }
604 },
605 [insertBlock, sorted, focusNewBlock],
606 );
607
608 const createSibling = useCallback(
609 (afterBlock: Block | null) => {
610
611
612 void insertBlock
613 .mutateAsync({
614 parentId: null,
615 afterBlockId: afterBlock?.id ?? null,
616 type: "paragraph",
617 })
618 .then((created) => focusNewBlock(created.id));
619 },
620 [insertBlock, focusNewBlock],
621 );
622
623
627 const focusBlockEditable = useCallback((blockId: string) => {
628 const el = pageRef.current?.querySelector<HTMLElement>(
629 `[data-block-id="${blockId}"] [contenteditable], [data-block-id="${blockId}"] input[type="checkbox"], [data-block-id="${blockId}"] button, [data-block-id="${blockId}"] a`,
630 );
631 el?.focus();
632 }, []);
633
634
645 const onTrailingAreaClick = useCallback(() => {
646 const last = sortedRef.current[sortedRef.current.length - 1] ?? null;
647 if (last && isEmptyTextBlock(last)) {
648 focusBlockEditable(last.id);
649 return;
650 }
651 createSibling(last);
652 }, [createSibling, focusBlockEditable]);
653
654 const focusSibling = useCallback(
655 (from: Block, dir: -1 | 1) => {
656 const idx = sorted.findIndex((b) => b.id === from.id);
657 const next = sorted[idx + dir];
658 if (next) {
659 focusBlockEditable(next.id);
660 } else if (dir === 1) {
661 createSibling(from);
662 }
663 },
664 [sorted, createSibling, focusBlockEditable],
665 );
666
667
685 const handleEmptyBlur = useCallback(
686 (block: Block, html: string) => {
687 if (block.properties?.isTitle === true) return;
688 if (!isAutoCleanableType(block.type)) return;
689
690
691
692
693
694
695
696 if (insert && insert.block.id === block.id) return;
697 const live = typeof html === "string" ? html : "";
698 if (live !== "" && !isContentEmpty(live)) return;
699
700
701
702
703 getBlockBatchQueue(pageId).dropBlock(block.id);
704 deleteBlock.mutate(block.id, {
705 onError: (e: Error) => toast("error", "Delete failed", e.message),
706 });
707 },
708 [pageId, deleteBlock, insert],
709 );
710
711 const onDragEnd = (event: DragEndEvent) => {
712 const { active, over } = event;
713 if (!over || active.id === over.id) return;
714 const oldIndex = sorted.findIndex((b) => b.id === active.id);
715 const newIndex = sorted.findIndex((b) => b.id === over.id);
716 if (oldIndex < 0 || newIndex < 0) return;
717 const moved = arrayMove(sorted, oldIndex, newIndex);
718 const block = moved[newIndex];
719 const prev = moved[newIndex - 1];
720 const next = moved[newIndex + 1];
721 if (!block) return;
722
723
724
725
726
727
728
729
730 moveBlock.mutate(
731 {
732 id: block.id,
733 parentId: null,
734 afterBlockId: prev?.id ?? null,
735 beforeBlockId: next?.id ?? null,
736 },
737 { onError: (e: Error) => toast("error", "Move failed", e.message) },
738 );
739 };
740
741 const sensors = useSensors(
742 useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
743
744
745
746 useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
747 );
748
749
750 const onKeyDown = (e: React.KeyboardEvent) => {
751 if (e.key !== "Delete" || !selectedBlockId) return;
752 const target = e.target as HTMLElement;
753 if (target.isContentEditable || target.tagName === "INPUT" || target.tagName === "TEXTAREA")
754 return;
755 e.preventDefault();
756 deleteBlock.mutate(selectedBlockId, {
757 onSuccess: () => setSelection(null),
758 });
759 };
760
761
762 const numberedIndices = useMemo(() => {
763 const map = new Map<string, number>();
764 let count = 0;
765 for (const b of sorted) {
766 if (b.type === "numbered_list") {
767 count += 1;
768 map.set(b.id, count);
769 } else {
770 count = 0;
771 }
772 }
773 return map;
774 }, [sorted]);
775
776 return (
777 <div
778 ref={pageRef}
779 data-testid="editor-root"
780 data-page-view
781 className="editor-page relative mx-auto w-full max-w-3xl px-12 pb-48"
782 onClick={(e) => {
783 if (e.target === e.currentTarget) {
784 setSelection(null);
785 setManySelected([]);
786 }
787 }}
788 onKeyDown={onKeyDown}
789 onDragEnter={onDragEnter}
790 onDragOver={onDragOver}
791 onDragLeave={onDragLeave}
792 onDrop={onFilesDropped}
793 data-file-drop={fileDragActive ? "active" : undefined}
794 >
795 {}
798 {fileDragActive ? (
799 <div
800 className="pointer-events-none fixed inset-0 z-40 flex items-start justify-center bg-primary/5 p-6"
801 aria-hidden
802 >
803 <div className="rounded-md border-2 border-dashed border-primary bg-background/90 px-4 py-2 text-sm font-medium text-primary shadow-sm">
804 Drop to upload
805 </div>
806 </div>
807 ) : null}
808 {}
811 {page ? <ReviewBanner pageId={page.id} workspaceId={page.workspaceId} /> : null}
812 {}
813 <header className="relative mb-4">
814 {}
816 {page?.cover ? (
817 <div
818 data-testid="page-cover"
819 aria-hidden
820 className="absolute inset-x-0 top-0 -z-10 h-20 rounded-xl"
821 style={{ background: coverBackground(page.cover) }}
822 />
823 ) : null}
824 <div className="flex items-start gap-3">
825 {}
828 <div className="flex shrink-0 items-start gap-1.5 pt-1.5">
829 {page?.icon ? (
830 <span data-testid="page-icon" className="block text-4xl leading-none" aria-hidden>
831 {page.icon}
832 </span>
833 ) : null}
834 <div className="relative">
835 <button
836 type="button"
837 data-testid="page-icon-button"
838 aria-label="Change icon"
839 title="Change icon"
840 onClick={() => setIconPickerOpen((v) => !v)}
841 className="rounded-md border border-border/60 p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground"
842 >
843 <Smile className="size-4" aria-hidden />
844 </button>
845 {iconPickerOpen && page ? (
846 <IconPicker
847 onPick={(icon) => {
848 setIconPickerOpen(false);
849 updatePage.mutate({ id: page.id, icon });
850 }}
851 />
852 ) : null}
853 </div>
854 <div className="relative">
855 <button
856 type="button"
857 data-testid="page-cover-picker"
858 aria-label="Change cover"
859 title="Change cover"
860 onClick={() => setCoverPickerOpen((v) => !v)}
861 className="rounded-md border border-border/60 p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground"
862 >
863 <ImageIcon className="size-4" aria-hidden />
864 </button>
865 {coverPickerOpen && page ? (
866 <CoverPicker
867 onPick={(cover) => {
868 setCoverPickerOpen(false);
869 updatePage.mutate({ id: page.id, cover });
870 }}
871 />
872 ) : null}
873 </div>
874 </div>
875 <div className="min-w-0 flex-1">
876 {}
880 <div
881 ref={titleRef}
882 contentEditable
883 suppressContentEditableWarning
884 role="textbox"
885 aria-label="Page title"
886 data-testid="page-title"
887
888
889
890
891 data-page-id={page?.id}
892 onInput={() => {
893
894
895
896 if (titleCommitTimer.current) clearTimeout(titleCommitTimer.current);
897 titleCommitTimer.current = setTimeout(() => commitTitleNow(), 600);
898 }}
899 onKeyDown={(e) => {
900 if (e.key === "Enter") {
901
902
903
904 commitTitleNow();
905 e.preventDefault();
906 (e.target as HTMLDivElement).blur();
907 } else if (e.key === "Escape") {
908
909
910 commitTitleNow();
911 (e.target as HTMLDivElement).blur();
912 }
913 }}
914 onBlur={() => {
915 commitTitleNow();
916 }}
917 className="w-full rounded-sm bg-transparent px-1 text-4xl font-bold tracking-tight outline-none empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground"
918 data-placeholder="Untitled"
919 />
920 <div className="mt-1.5 flex items-center gap-2 px-1 text-xs text-muted-foreground">
921 {page ? (
922 <>
923 <span className="inline-flex items-center gap-1">
924 <Clock className="size-3" aria-hidden />
925 Updated {formatRelativeTime(page.updatedAt)}
926 </span>
927 {page.createdByUser ? <span>· by {page.createdByUser.name}</span> : null}
928 </>
929 ) : null}
930 </div>
931 </div>
932 {}
935 <PresenceBar
936 peers={others}
937 selfUserId={selfUserId}
938 blockTypeFor={blockTypeFor}
939 className="mt-2 shrink-0"
940 />
941 {}
944 <button
945 type="button"
946 data-testid="comment-button"
947 className="relative mt-2 inline-flex shrink-0 items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs text-foreground/80 hover:bg-accent hover:text-foreground"
948 onClick={() => setCommentsOpen(true)}
949 aria-label={
950 commentPill > 0
951 ? `Open comments (${commentPill} new)`
952 : "Open comments"
953 }
954 >
955 <MessageSquare className="size-3.5" aria-hidden />
956 Comments
957 {commentPill > 0 ? (
958 <span
959 className="inline-flex min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold leading-4 text-primary-foreground"
960 aria-hidden
961 >
962 {commentPill > 99 ? "99+" : commentPill}
963 </span>
964 ) : null}
965 </button>
966 {}
968 <button
969 type="button"
970 data-testid="related-button"
971 className="relative mt-2 inline-flex shrink-0 items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs text-foreground/80 hover:bg-accent hover:text-foreground"
972 onClick={() => setRelatedOpen(true)}
973 aria-label="Open related pages"
974 >
975 <Link2 className="size-3.5" aria-hidden />
976 Related
977 {(backlinks.data?.length ?? 0) > 0 ? (
978 <span
979 className="inline-flex min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold leading-4 text-primary-foreground"
980 aria-hidden
981 >
982 {backlinks.data!.length > 99 ? "99+" : backlinks.data!.length}
983 </span>
984 ) : null}
985 </button>
986 {}
988 <button
989 type="button"
990 data-testid="files-picker-toggle"
991 onClick={() => setFilesPickerOpen((v) => !v)}
992 aria-expanded={filesPickerOpen}
993 className="relative mt-2 inline-flex shrink-0 items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs text-foreground/80 hover:bg-accent hover:text-foreground"
994 >
995 <FolderOpen className="size-3.5" aria-hidden />
996 Files
997 </button>
998 {}
1000 <button
1001 type="button"
1002 data-testid="ask-ai-button"
1003 onClick={() =>
1004 setAiPanel(true, {
1005 pageId,
1006 ...(selectedBlockId ? { blockId: selectedBlockId } : {}),
1007 ...(page?.title?.trim() ? { label: page.title } : {})
1008 })
1009 }
1010 className="relative mt-2 inline-flex shrink-0 items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs text-foreground/80 hover:bg-accent hover:text-foreground"
1011 aria-label="Ask AI about this page"
1012 >
1013 <Sparkles className="size-3.5" aria-hidden />
1014 Ask AI
1015 </button>
1016 {}
1018 {canGenerate ? (
1019 <button
1020 type="button"
1021 data-testid="generate-page-button"
1022 onClick={() => setGenerate(true, { parentId: pageId })}
1023 className="relative mt-2 inline-flex shrink-0 items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs text-foreground/80 hover:bg-accent hover:text-foreground"
1024 aria-label="Generate a page with AI"
1025 >
1026 <Sparkles className="size-3.5" aria-hidden />
1027 Generate
1028 </button>
1029 ) : null}
1030 </div>
1031 </header>
1032
1033 {filesPickerOpen && page?.workspaceId ? (
1034 <FilePicker
1035 workspaceId={page.workspaceId}
1036 pageId={pageId}
1037 onClose={() => setFilesPickerOpen(false)}
1038 />
1039 ) : null}
1040
1041 {}
1044 <p role="status" aria-live="polite" className="sr-only">
1045 {announcement}
1046 </p>
1047
1048 {}
1049 <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
1050 <SortableContext items={renderBlocks.map((b) => b.id)} strategy={verticalListSortingStrategy}>
1051 <div className="relative">
1052 {renderBlocks.map((block) => (
1053 <SortableBlockRow
1054 key={block.id}
1055 block={block}
1056 selected={selectedBlockId === block.id}
1057 multiSelected={selectedBlockIds.includes(block.id)}
1058 focusTarget={focusTarget}
1059 numberedIndex={numberedIndices.get(block.id)}
1060 onSelect={(id, mods) => selectRow(id, mods)}
1061 onContextMenu={(x, y, b) => setMenu({ x, y, block: b })}
1062 onWritingAction={(action, blockIds, language) => openWriting(action, blockIds, language)}
1063 onEnterKey={createSibling}
1064 onNavigate={(b, dir) => focusSibling(b, dir)}
1065 onSlashPick={(cmd, ctx) => void onSlashPick(cmd, ctx)}
1066 onEmptyBlur={(b, html) => handleEmptyBlur(b, html)}
1067 />
1068 ))}
1069 {}
1072 <CursorLayer peers={others} blockIds={blockIds} />
1073 </div>
1074 </SortableContext>
1075 </DndContext>
1076
1077 {}
1080 {contentBlockCount === 0 && canGenerate ? (
1081 <button
1082 type="button"
1083 data-testid="home-generate-card"
1084 onClick={() => setGenerate(true, { parentId: pageId })}
1085 className="group mb-4 flex w-full max-w-xl items-center gap-3 rounded-lg border border-dashed border-border bg-background/60 px-4 py-3 text-left hover:border-primary/50 hover:bg-accent/40"
1086 >
1087 <Sparkles className="size-4 shrink-0 text-muted-foreground group-hover:text-primary" aria-hidden />
1088 <span className="min-w-0 flex-1">
1089 <span className="block text-sm font-medium">Describe a page…</span>
1090 <span className="block text-xs text-muted-foreground">
1091 e.g. "A product launch plan with objectives, a timeline, and a tasks database"
1092 </span>
1093 </span>
1094 </button>
1095 ) : null}
1096
1097 {}
1103 {canGenerate ? (
1104 <button
1105 type="button"
1106 data-testid="subpage-create"
1107 onClick={() =>
1108 window.dispatchEvent(
1109 new CustomEvent("factory:new-page", { detail: { parentId: pageId } })
1110 )
1111 }
1112 className="mb-2 flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
1113 >
1114 <FolderOpen className="size-3.5" aria-hidden />
1115 Add a sub-page
1116 </button>
1117 ) : null}
1118
1119 {}
1122 <div
1123 data-testid="new-block"
1124 className="min-h-[16vh] cursor-text"
1125 onClick={onTrailingAreaClick}
1126 aria-hidden
1127 />
1128
1129 {}
1130 {menu ? (
1131 <ContextMenu
1132 x={menu.x}
1133 y={menu.y}
1134 block={menu.block}
1135 pageId={pageId}
1136 onClose={() => setMenu(null)}
1137 onWritingAction={page?.workspaceId ? (action, blockIds, language) => openWriting(action, blockIds, language) : undefined}
1138 canGenerate={canGenerate}
1139 />
1140 ) : null}
1141
1142 {}
1144 {selectedBlocks.length > 0 && page?.workspaceId ? (
1145 <SelectionToolbar
1146 pageId={pageId}
1147 blocks={selectedBlocks}
1148 onClear={clearSelection}
1149 onAction={(action, language) => openWriting(action, selectedBlocks.map((b) => b.id), language)}
1150 />
1151 ) : null}
1152 {writing.open && page?.workspaceId ? (
1153 <WritingDialog
1154 open
1155 workspaceId={page.workspaceId}
1156 pageId={pageId}
1157 blocks={dialogBlocks}
1158 action={writing.action}
1159 language={writing.language}
1160 onClose={() => setWriting((w) => ({ ...w, open: false }))}
1161 />
1162 ) : null}
1163
1164 {}
1165 <CommentPanel
1166 pageId={pageId}
1167 workspaceId={page?.workspaceId ?? ""}
1168 open={commentsOpen}
1169 onClose={() => setCommentsOpen(false)}
1170 />
1171
1172 {}
1174 <RelatedPanel
1175 pageId={pageId}
1176 workspaceSlug={wsParams?.workspace ?? ""}
1177 open={relatedOpen}
1178 onClose={() => setRelatedOpen(false)}
1179 refs={backlinks.data ?? []}
1180 isLoading={backlinks.isLoading}
1181 isError={backlinks.isError}
1182 />
1183
1184 {}
1185 {insert ? (
1186 <InsertDialog
1187 open
1188 initialTab={insert.tab}
1189 afterBlockId={insert.block.id}
1190 parentId={insert.block.parentId}
1191 pageId={pageId}
1192 workspaceId={page?.workspaceId ?? ""}
1193 onClose={() => setInsert(null)}
1194 />
1195 ) : null}
1196 {templateOpen ? (
1197 <TemplateDialog
1198 workspaceId={page?.workspaceId ?? ""}
1199 onClose={() => setTemplateOpen(false)}
1200 onApply={async (templateId, title) => {
1201
1202
1203 const created = await templateApi.apply(templateId, { workspaceId: page?.workspaceId ?? "", parentId: null });
1204 setTemplateOpen(false);
1205 useEditorStore.getState().setSelection(null);
1206 toast("success", "Template inserted", title);
1207 void created;
1208 }}
1209 />
1210 ) : null}
1211 </div>
1212 );
1213}
1214
1215
1216function SortableBlockRow({
1217 block,
1218 selected,
1219 multiSelected,
1220 focusTarget,
1221 numberedIndex,
1222 onSelect,
1223 onContextMenu,
1224 onEnterKey,
1225 onNavigate,
1226 onSlashPick,
1227 onWritingAction,
1228 onEmptyBlur,
1229}: {
1230 block: Block;
1231 selected: boolean;
1232
1234 multiSelected?: boolean;
1235 focusTarget: string | null;
1236 numberedIndex?: number;
1237
1238 onSelect: (id: string, mods: { metaKey: boolean; ctrlKey: boolean; shiftKey: boolean }) => void;
1239 onContextMenu: (x: number, y: number, block: Block) => void;
1240 onEnterKey: (block: Block) => void;
1241 onNavigate: (block: Block, dir: -1 | 1) => void;
1242 onSlashPick: (
1243 command: SlashCommand,
1244 context: { parentId: string | null; position: string },
1245 ) => void;
1246 onWritingAction?: (action: WritingAction, blockIds: string[], language?: string) => void;
1247
1249 onEmptyBlur?: (block: Block, html: string) => void;
1250}) {
1251 const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
1252 id: block.id,
1253 data: { type: "block", blockId: block.id },
1254 });
1255
1256
1257 const modsRef = useRef({ metaKey: false, ctrlKey: false, shiftKey: false });
1258
1259 return (
1260 <div
1261 ref={setNodeRef}
1262 data-block-id={block.id}
1263 data-testid={`block-${block.id}`}
1264 data-multi-selected={multiSelected || undefined}
1265 style={{ transform: CSS.Transform.toString(transform), transition }}
1266
1267
1268
1269
1270
1271
1272
1273
1274 className={cn("group block-row", (selected || multiSelected) && "is-selected", multiSelected && "is-multi-selected", isDragging && "z-10 opacity-90")}
1275 onMouseDownCapture={(e) => {
1276 modsRef.current = { metaKey: e.metaKey, ctrlKey: e.ctrlKey, shiftKey: e.shiftKey };
1277 }}
1278 onClick={(e) => {
1279 if (e.target === e.currentTarget) onSelect(block.id, modsRef.current);
1280 }}
1281 onContextMenu={(e) => {
1282 e.preventDefault();
1283 onContextMenu(e.clientX, e.clientY, block);
1284 }}
1285 >
1286 {}
1300 <div className="absolute right-0 top-1 z-30 flex items-center opacity-100 transition-opacity md:left-0 md:right-auto md:-translate-x-full md:pointer-events-none md:opacity-0 md:group-hover:pointer-events-auto md:group-hover:opacity-100">
1301 <BlockToolbar
1302 block={block}
1303 gripProps={{ attributes, listeners }}
1304 onWritingAction={(action, blockIds, language) => onWritingAction?.(action, blockIds, language)}
1305 />
1306 </div>
1307 <BlockRenderer
1308 block={block}
1309 selected={selected || multiSelected}
1310 requestFocus={focusTarget === block.id}
1311 numberedIndex={numberedIndex}
1312 onEnterKey={() => onEnterKey(block)}
1313 onNavigate={(dir) => onNavigate(block, dir)}
1314 onSelect={() => onSelect(block.id, modsRef.current)}
1315 onSlashPick={onSlashPick}
1316
1317
1318
1319 onEmptyBlur={onEmptyBlur}
1320 />
1321 </div>
1322 );
1323}
1324
1325
1331
1332
1339const COVER_STOPS: [string, string][] = [
1340 ["#6366f1", "#8b5cf6"],
1341 ["#f43f5e", "#f97316"],
1342 ["#10b981", "#14b8a6"],
1343 ["#0ea5e9", "#6366f1"],
1344 ["#f59e0b", "#f43f5e"],
1345 ["#1e293b", "#475569"]
1346];
1347
1348const coverToken = ([from, to]: [string, string]): string => {
1349 const svg =
1350 `<svg xmlns="http://www.w3.org/2000/svg" width="160" height="90">` +
1351 `<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">` +
1352 `<stop offset="0" stop-color="${from}"/><stop offset="1" stop-color="${to}"/>` +
1353 `</linearGradient></defs><rect width="160" height="90" fill="url(#g)"/></svg>`;
1354 return `data:image/svg+xml;base64,${btoa(svg)}`;
1355};
1356
1357const COVER_TOKENS: string[] = COVER_STOPS.map(coverToken);
1358
1359function coverBackground(cover: string): string {
1360
1361
1362
1363 return COVER_TOKENS.includes(cover)
1364 ? `url("${cover}") center / cover no-repeat`
1365 : "linear-gradient(135deg, #64748b, #334155)";
1366}
1367
1368const ICON_EMOJI: string[] = [
1369 "🚀", "📄", "📌", "⭐", "💡", "🎯", "🔥", "✅",
1370 "📊", "🗓️", "🔒", "🌱", "☁️", "🧠", "🛠️", "🎉"
1371];
1372
1373
1374function IconPicker({ onPick }: { onPick: (icon: string) => void }) {
1375 return (
1376 <div
1377 data-testid="icon-picker"
1378 role="menu"
1379 aria-label="Choose icon"
1380 className="absolute left-0 top-8 z-[75] grid w-56 grid-cols-8 gap-1 rounded-md border border-border bg-popover p-2 shadow-xl"
1381 >
1382 {ICON_EMOJI.map((e) => (
1383 <button
1384 key={e}
1385 type="button"
1386 role="menuitem"
1387 onClick={() => onPick(e)}
1388 className="rounded p-1 text-lg hover:bg-accent"
1389 >
1390 {e}
1391 </button>
1392 ))}
1393 </div>
1394 );
1395}
1396
1397
1398function CoverPicker({ onPick }: { onPick: (cover: string) => void }) {
1399 return (
1400 <div
1401 data-testid="cover-picker"
1402 role="menu"
1403 aria-label="Choose cover"
1404 className="absolute left-0 top-8 z-[75] grid w-56 grid-cols-3 gap-1.5 rounded-md border border-border bg-popover p-2 shadow-xl"
1405 >
1406 {COVER_TOKENS.map((g, i) => (
1407 <button
1408 key={g}
1409 type="button"
1410 role="menuitem"
1411 data-testid={`cover-color-${i}`}
1412 aria-label={`Cover color ${i + 1}`}
1413 onClick={() => onPick(g)}
1414 className="h-10 w-full rounded-md border border-border/40"
1415 style={{ background: coverBackground(g) }}
1416 />
1417 ))}
1418 </div>
1419 );
1420}
1421