1"use client";
2
3
17import { useCallback, useEffect, useRef, useState } from "react";
18import Link from "next/link";
19import { useParams } from "next/navigation";
20import { useQuery } from "@tanstack/react-query";
21import {
22 Bookmark,
23 Check,
24 ChevronRight,
25 Copy,
26 ExternalLink,
27 FileImage,
28 Film,
29 Calculator,
30 FileText,
31 Heading1,
32 Heading2,
33 Heading3,
34 Music,
35 Pencil,
36 Table as TableIcon,
37 Type,
38 Undo2,
39 Upload
40} from "lucide-react";
41import { usePage, usePageBlocks } from "@/hooks/use-pages";
42import { useInsertBlock, useUpdateBlock } from "@/hooks/use-blocks";
43import { blockApi, uploadFile } from "@/lib/api";
44import { cn, sortByPosition } from "@/lib/utils";
45import { DatabaseView } from "@/components/database/db-embedded";
46import { FileBlock } from "@/components/files/FileBlock";
47import { FormRenderer } from "@/components/form/form-renderer";
48import { InlineEditor } from "./inline-editor";
49import { type SlashCommand } from "./slash-menu";
50import { useBlockSaveQueue } from "./block-save-queue";
51import { blockText } from "./version-text";
52import { Expandable } from "./expandable";
53import { CODE_LANGUAGES, codeToHtml } from "@/lib/code-highlight";
54import type { Block } from "@/lib/types";
55
56
57
58export interface BlockRendererProps {
59 block: Block;
60 selected?: boolean;
61
62 requestFocus?: boolean;
63
64 numberedIndex?: number;
65 onEnterKey?: () => void;
66 onNavigate?: (dir: -1 | 1) => void;
67 onSelect?: () => void;
68
72 onSlashPick?: (command: SlashCommand, context: { parentId: string | null; position: string }) => void;
73
80 onEmptyBlur?: (block: Block, html: string) => void;
81}
82
83
84function slashProps(block: Block, onSlashPick?: BlockRendererProps["onSlashPick"]) {
85 return { slashContext: { parentId: block.parentId, position: block.position }, onSlashPick };
86}
87
88
96function onBlurHandler(
97 block: Block,
98 saveNow: (blockId: string, content: unknown) => void | Promise<void>,
99 onEmptyBlur?: (block: Block, html: string) => void,
100) {
101 return (blockId: string, content: unknown) => {
102 void saveNow(blockId, content);
103 onEmptyBlur?.(block, typeof content === "string" ? content : "");
104 };
105}
106
107export function BlockRenderer({
108 block,
109 selected,
110 requestFocus,
111 numberedIndex,
112 onEnterKey,
113 onNavigate,
114 onSelect,
115 onSlashPick,
116 onEmptyBlur
117}: BlockRendererProps) {
118 switch (block.type) {
119 case "heading_1":
120 case "heading_2":
121 case "heading_3":
122 return (
123 <HeadingBlock
124 block={block}
125 level={Number(block.type.split("_")[1]) as 1 | 2 | 3}
126 selected={selected}
127 requestFocus={requestFocus}
128 onEnterKey={onEnterKey}
129 onNavigate={onNavigate}
130 onSelect={onSelect}
131 onSlashPick={onSlashPick}
132 onEmptyBlur={onEmptyBlur}
133 />
134 );
135 case "todo":
136 return (
137 <TodoBlock
138 block={block}
139 selected={selected}
140 requestFocus={requestFocus}
141 onEnterKey={onEnterKey}
142 onNavigate={onNavigate}
143 onSelect={onSelect}
144 onSlashPick={onSlashPick}
145 onEmptyBlur={onEmptyBlur}
146 />
147 );
148 case "bulleted_list":
149 return (
150 <div className="flex gap-2">
151 <span className="mt-[0.6em] size-1.5 shrink-0 rounded-full bg-foreground" aria-hidden />
152 <InlineTextBlock
153 block={block}
154 selected={selected}
155 requestFocus={requestFocus}
156 onEnterKey={onEnterKey}
157 onNavigate={onNavigate}
158 onSelect={onSelect}
159 onSlashPick={onSlashPick}
160 onEmptyBlur={onEmptyBlur}
161 className="flex-1"
162 />
163 </div>
164 );
165 case "numbered_list":
166 return (
167 <div className="flex gap-2">
168 <span className="mt-[0.1em] w-5 shrink-0 text-right text-sm tabular-nums text-foreground" aria-hidden>
169 {numberedIndex ?? 1}.
170 </span>
171 <InlineTextBlock
172 block={block}
173 selected={selected}
174 requestFocus={requestFocus}
175 onEnterKey={onEnterKey}
176 onNavigate={onNavigate}
177 onSelect={onSelect}
178 onSlashPick={onSlashPick}
179 onEmptyBlur={onEmptyBlur}
180 className="flex-1"
181 />
182 </div>
183 );
184 case "quote":
185 return (
186 <div className="border-l-[3px] border-foreground pl-4">
187 <InlineTextBlock
188 block={block}
189 selected={selected}
190 requestFocus={requestFocus}
191 onEnterKey={onEnterKey}
192 onNavigate={onNavigate}
193 onSelect={onSelect}
194 onSlashPick={onSlashPick}
195 onEmptyBlur={onEmptyBlur}
196 className="text-[15px]"
197 />
198 </div>
199 );
200 case "callout":
201 return <CalloutBlock block={block} selected={selected} onEnterKey={onEnterKey} onNavigate={onNavigate} onSelect={onSelect} onSlashPick={onSlashPick} onEmptyBlur={onEmptyBlur} />;
202 case "code":
203 return <CodeBlock block={block} />;
204 case "divider":
205 return (
206 <div className="py-2">
207 <hr className="border-border" />
208 </div>
209 );
210 case "image":
211 return <ImageBlock block={block} />;
212 case "video":
213 return <VideoBlock block={block} />;
214 case "audio":
215 return <AudioBlock block={block} />;
216 case "file":
217 return <FileBlock block={block} />;
218 case "bookmark":
219 return <BookmarkBlock block={block} />;
220 case "embed":
221 return <EmbedBlock block={block} />;
222 case "equation":
223 return <EquationBlock block={block} />;
224 case "toggle":
225
226
227 return <ToggleBlock block={block} selected={selected} requestFocus={requestFocus} onNavigate={onNavigate} onSelect={onSelect} onSlashPick={onSlashPick} />;
228 case "spacer":
229
230 return <div style={{ height: Math.min(Math.max(block.properties.heightPx ?? 32, 8), 200) }} aria-hidden />;
231 case "column_list":
232 return <ColumnsBlock block={block} />;
233 case "table":
234 return <TableBlock block={block} />;
235 case "chart":
236 return <ChartBlock block={block} />;
237 case "form":
238 return <FormBlock block={block} />;
239 case "database":
240 case "linked_database":
241 case "child_database":
242 return <DatabaseBlock block={block} />;
243 case "synced_block":
244 return <SyncedBlock block={block} />;
245 case "toc":
246
247
248 return <TableOfContentsBlock block={block} />;
249 case "child_page":
250 return <ChildPageBlock block={block} />;
251 case "column":
252
253 return null;
254 default:
255 return (
256
257
258
259
260
261
262
263 <Expandable cap={TEXT_CAP} label="text block">
264 <InlineTextBlock
265 block={block}
266 selected={selected}
267 requestFocus={requestFocus}
268 onEnterKey={onEnterKey}
269 onNavigate={onNavigate}
270 onSelect={onSelect}
271 onSlashPick={onSlashPick}
272 onEmptyBlur={onEmptyBlur}
273 />
274 </Expandable>
275 );
276 }
277}
278
279
280
281function InlineTextBlock({
282 block,
283 selected,
284 requestFocus,
285 onEnterKey,
286 onNavigate,
287 onSelect,
288 onSlashPick,
289 onEmptyBlur,
290 className
291}: BlockRendererProps & { className?: string }) {
292 const { queueChange, saveNow } = useBlockSaveQueue(block.pageId);
293 return (
294 <div className={cn("min-w-0", className)}>
295 <InlineEditor
296 blockId={block.id}
297 content={block.content}
298 placeholder="Type '/' for commands"
299 selected={selected}
300 requestFocus={requestFocus}
301 onChange={queueChange}
302 onBlur={onBlurHandler(block, saveNow, onEmptyBlur)}
303 onFocusBlock={() => onSelect?.()}
304 onEnterKey={onEnterKey}
305 onNavigate={onNavigate}
306 {...slashProps(block, onSlashPick)}
307 />
308 </div>
309 );
310}
311
312function HeadingBlock({
313 block,
314 level,
315 selected,
316 requestFocus,
317 onEnterKey,
318 onNavigate,
319 onSelect,
320 onSlashPick,
321 onEmptyBlur
322}: BlockRendererProps & { level: 1 | 2 | 3 }) {
323 const Icon = level === 1 ? Heading1 : level === 2 ? Heading2 : Heading3;
324 const sizes = { 1: "text-3xl font-bold", 2: "text-2xl font-semibold", 3: "text-xl font-semibold" } as const;
325 const { queueChange, saveNow } = useBlockSaveQueue(block.pageId);
326 return (
327 <div className="flex items-center gap-2">
328 <span className="flex shrink-0 select-none items-center gap-1.5 opacity-0 transition-opacity group-hover:opacity-100" aria-hidden>
329 <Icon className="size-4 text-muted-foreground" />
330 <span className="text-xs text-muted-foreground">H{level}</span>
331 </span>
332 <div className="min-w-0 flex-1">
333 <InlineEditor
334 blockId={block.id}
335 content={block.content}
336 placeholder={`Heading ${level}`}
337 selected={selected}
338 requestFocus={requestFocus}
339 onChange={queueChange}
340 onBlur={onBlurHandler(block, saveNow, onEmptyBlur)}
341 onFocusBlock={() => onSelect?.()}
342 onEnterKey={onEnterKey}
343 onNavigate={onNavigate}
344 {...slashProps(block, onSlashPick)}
345 className={cn(sizes[level])}
346 />
347 </div>
348 </div>
349 );
350}
351
352function TodoBlock({
353 block,
354 selected,
355 requestFocus,
356 onEnterKey,
357 onNavigate,
358 onSelect,
359 onSlashPick,
360 onEmptyBlur
361}: BlockRendererProps) {
362 const checked = Boolean(block.properties.checked);
363 const { queueChange, saveNow } = useBlockSaveQueue(block.pageId);
364 const toggle = () => {
365 void blockApi.update(block.id, { properties: { ...block.properties, checked: !checked } });
366 };
367 return (
368 <div className="flex items-start gap-2">
369 <button
370 type="button"
371 role="checkbox"
372 aria-checked={checked}
373 aria-label={checked ? "Mark as not done" : "Mark as done"}
374 data-testid={`todo-checkbox-${block.id}`}
375 onClick={toggle}
376 className={cn(
377 "mt-[0.2em] flex size-4 shrink-0 items-center justify-center rounded-sm border",
378 checked
379 ? "border-primary bg-primary text-primary-foreground"
380 : "border-muted-foreground/50 hover:border-foreground"
381 )}
382 >
383 {checked ? <Check className="size-3" aria-hidden /> : null}
384 </button>
385 <div className={cn("min-w-0 flex-1", checked && "text-muted-foreground line-through")}>
386 <InlineEditor
387 blockId={block.id}
388 content={block.content}
389 placeholder="To-do"
390 selected={selected}
391 requestFocus={requestFocus}
392 onChange={queueChange}
393 onBlur={onBlurHandler(block, saveNow, onEmptyBlur)}
394 onFocusBlock={() => onSelect?.()}
395 onEnterKey={onEnterKey}
396 onNavigate={onNavigate}
397 {...slashProps(block, onSlashPick)}
398 />
399 </div>
400 </div>
401 );
402}
403
404
406const CALLOUT_VARIANTS = {
407 info: { label: "Info", border: "border-sky-500/40", bg: "bg-sky-500/10", icon: "ℹ️" },
408 warning: { label: "Warning", border: "border-amber-500/40", bg: "bg-amber-500/10", icon: "⚠️" },
409 tip: { label: "Tip", border: "border-emerald-500/40", bg: "bg-emerald-500/10", icon: "💡" },
410 error: { label: "Error", border: "border-red-500/40", bg: "bg-red-500/10", icon: "⛔" }
411} as const;
412type CalloutVariantKey = keyof typeof CALLOUT_VARIANTS;
413
414function CalloutBlock({
415 block,
416 selected,
417 onEnterKey,
418 onNavigate,
419 onSelect,
420 onSlashPick,
421 onEmptyBlur
422}: BlockRendererProps) {
423 const { icon, color, variant } = block.properties;
424 const isVariant = (x: unknown): x is CalloutVariantKey =>
425 typeof x === "string" && Object.hasOwn(CALLOUT_VARIANTS, x);
426 const v = CALLOUT_VARIANTS[isVariant(variant) ? variant : "info"];
427 const { queueChange, saveNow } = useBlockSaveQueue(block.pageId);
428 return (
429 <div
430 className={cn("relative flex gap-3 rounded-md border p-3", color ? "border-border" : cn(v.border, v.bg))}
431 style={
432 color
433 ? {
434 background: `var(--factory-color-${color}_bg, transparent)`,
435 borderColor: `var(--factory-color-${color}, transparent)`
436 }
437 : undefined
438 }
439 >
440 {selected && (
441 <select
442 value={(variant as string) ?? "info"}
443 onChange={(e) => void blockApi.update(block.id, { properties: { ...block.properties, variant: e.target.value } })}
444 aria-label="Callout variant"
445 className="absolute -top-2.5 right-2 z-10 rounded border border-border bg-background px-1 py-0 text-xs shadow-sm"
446 >
447 {(Object.keys(CALLOUT_VARIANTS) as CalloutVariantKey[]).map((k) => (
448 <option key={k} value={k}>
449 {CALLOUT_VARIANTS[k].label}
450 </option>
451 ))}
452 </select>
453 )}
454 <span className="text-lg leading-none" aria-hidden>
455 {icon ?? v.icon}
456 </span>
457 <div className="min-w-0 flex-1">
458 <InlineEditor
459 blockId={block.id}
460 content={block.content}
461 placeholder="Callout"
462 selected={selected}
463 onChange={queueChange}
464 onBlur={onBlurHandler(block, saveNow, onEmptyBlur)}
465 onFocusBlock={() => onSelect?.()}
466 onEnterKey={onEnterKey}
467 onNavigate={onNavigate}
468 {...slashProps(block, onSlashPick)}
469 />
470 </div>
471 </div>
472 );
473}
474
475
476
477
483export function codeBlockText(content: unknown): string {
484 if (typeof content === "string") return content;
485 if (content && typeof content === "object") {
486 const t = (content as { text?: unknown }).text;
487 if (typeof t === "string") return t;
488 }
489 return content == null ? "" : JSON.stringify(content, null, 2);
490}
491
492
498const CODE_LINE_HEIGHT = 24;
499const CODE_CAP_LINES = 15;
500const CODE_CAP = CODE_CAP_LINES * CODE_LINE_HEIGHT + 24;
501
502
507const TEXT_CAP_LINES = 16;
508const TEXT_CAP = Math.round(TEXT_CAP_LINES * 14.5 * 1.5);
509
510export function CodeBlock({ block }: { block: Block }) {
511 const { queueChange, saveNow } = useBlockSaveQueue(block.pageId);
512 const [copied, setCopied] = useState(false);
513 const [editing, setEditing] = useState(false);
514 const [text, setText] = useState<string>(() => codeBlockText(block.content));
515 const textareaRef = useRef<HTMLTextAreaElement>(null);
516 const overlayRef = useRef<HTMLPreElement>(null);
517 const language = block.properties.language ?? "plaintext";
518
519
520
521
522 const codeMetrics =
523 "min-h-[1.5em] bg-[var(--factory-code-bg)] p-3 font-mono leading-6 [font-size:var(--factory-code-font-size)]";
524
525
526
527 useEffect(() => {
528 if (!editing) return;
529 const el = textareaRef.current;
530 if (!el) return;
531 el.style.height = "0px";
532 el.style.height = `${el.scrollHeight}px`;
533 }, [editing, text]);
534
535
536
537 useEffect(() => {
538 if (!editing) return;
539 const ta = textareaRef.current;
540 const pre = overlayRef.current;
541 if (!ta || !pre) return;
542 const sync = () => {
543 pre.scrollTop = ta.scrollTop;
544 pre.scrollLeft = ta.scrollLeft;
545 };
546 ta.addEventListener("scroll", sync);
547 return () => ta.removeEventListener("scroll", sync);
548 }, [editing]);
549
550 const startEdit = () => {
551 setText(codeBlockText(block.content));
552 setEditing(true);
553 };
554
555 const commit = () => {
556 if (!editing) return;
557 setEditing(false);
558 void saveNow(block.id, text);
559 };
560
561 const current = editing ? text : codeBlockText(block.content);
562
563 const copy = () => {
564 void navigator.clipboard.writeText(current).then(() => {
565 setCopied(true);
566 setTimeout(() => setCopied(false), 1500);
567 });
568 };
569
570 return (
571 <div className="factory-codeblock group/code my-1 overflow-hidden rounded-md border border-border">
572 <div className="flex items-center justify-between bg-muted/60 px-3 py-1.5">
573 <select
574 value={language}
575 disabled={editing}
576 onChange={(e) => void blockApi.update(block.id, { properties: { ...block.properties, language: e.target.value } })}
577 aria-label="Code language"
578 className="bg-transparent text-xs text-muted-foreground outline-none"
579 >
580 {CODE_LANGUAGES.map((l) => (
581 <option key={l} value={l}>
582 {l}
583 </option>
584 ))}
585 </select>
586 <div className="flex items-center gap-1">
587 <button
588 type="button"
589 onClick={copy}
590 className="flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
591 aria-label="Copy code"
592 >
593 {copied ? <Check className="size-3" aria-hidden /> : <Copy className="size-3" aria-hidden />}
594 {copied ? "Copied" : "Copy"}
595 </button>
596 <button
597 type="button"
598 onClick={() => (editing ? commit() : startEdit())}
599 className="flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
600 aria-label={editing ? "Save code" : "Edit code"}
601 >
602 <Pencil className="size-3" aria-hidden />
603 {editing ? "Save" : "Edit"}
604 </button>
605 </div>
606 </div>
607 {editing ? (
608
609
610
611 <div className="relative">
612 <pre
613 ref={overlayRef}
614 aria-hidden
615 className={cn(codeMetrics, "pointer-events-none overflow-hidden whitespace-pre")}
616 >
617 <code dangerouslySetInnerHTML={{ __html: codeToHtml(text, language) }} />
618 </pre>
619 <textarea
620 ref={textareaRef}
621 value={text}
622 spellCheck={false}
623 autoFocus
624 onChange={(e) => {
625 setText(e.target.value);
626 queueChange(block.id, e.target.value);
627 }}
628 onBlur={commit}
629 onKeyDown={(e) => {
630 if (e.key === "Escape") {
631 e.stopPropagation();
632 commit();
633 }
634 }}
635 className={cn(
636 codeMetrics,
637 "absolute inset-0 block w-full resize-none overflow-x-auto overflow-y-auto whitespace-pre bg-transparent text-transparent caret-[var(--factory-code-fg)] outline-none"
638 )}
639 aria-label="Code"
640 />
641 </div>
642 ) : (
643
644
645
646
647 <Expandable cap={CODE_CAP} label="code block" lines={current.split("\n").length}>
648 <pre
649
650 data-testid="code-block-pre"
651 className={cn(codeMetrics, "cursor-text overflow-x-auto whitespace-pre")}
652 onClick={startEdit}
653 title="Click to edit"
654 >
655 <code dangerouslySetInnerHTML={{ __html: codeToHtml(current, language) }} />
656 </pre>
657 </Expandable>
658 )}
659 </div>
660 );
661}
662
663function ImageBlock({ block }: { block: Block }) {
664 const { src, caption } = block.properties;
665 if (!src) return <ImagePickerBlock block={block} />;
666 return (
667 <figure>
668 {}
669 <img src={src} alt={caption || "Image block"} className="max-w-full rounded-md" loading="lazy" />
670 {caption ? <figcaption className="mt-1 text-center text-xs text-muted-foreground">{caption}</figcaption> : null}
671 </figure>
672 );
673}
674
675
683function ImagePickerBlock({ block }: { block: Block }) {
684 const inputRef = useRef<HTMLInputElement>(null);
685 const [uploading, setUploading] = useState(false);
686 const [error, setError] = useState<string | null>(null);
687 const { data: page } = usePage(block.pageId);
688 const workspaceId = page?.workspaceId ?? null;
689 const { updateBlock } = useUpdateBlock(block.pageId);
690
691 const onFiles = async (list: FileList | null) => {
692 const file = list?.[0];
693 if (!file) return;
694 setUploading(true);
695 setError(null);
696 try {
697 const meta = await uploadFile(file, undefined, { workspaceId: workspaceId ?? undefined, pageId: block.pageId });
698 if (meta.status !== "ready" || !meta.url) {
699 setError("Upload was blocked (virus scan or processing) — the file is in Files.");
700 return;
701 }
702 await updateBlock(block.id, { properties: { ...block.properties, src: meta.url, fileId: meta.id, name: meta.name } });
703 } catch (e) {
704 setError(e instanceof Error ? e.message : "Upload failed");
705 } finally {
706 setUploading(false);
707 }
708 };
709
710 return (
711 <div className="flex items-center gap-3 rounded-md border border-dashed border-border p-3">
712 <span className="flex size-9 shrink-0 items-center justify-center rounded bg-muted">
713 <FileImage className="size-4 text-muted-foreground" />
714 </span>
715 <span className="min-w-0 flex-1 text-sm text-muted-foreground">
716 {uploading ? "Uploading…" : error ? error : "No image yet — choose a file to attach"}
717 </span>
718 <button
719 type="button"
720 disabled={uploading}
721 onClick={() => inputRef.current?.click()}
722 className="flex shrink-0 items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-xs font-medium hover:bg-accent disabled:opacity-50"
723 >
724 <Upload className="size-3.5" aria-hidden /> {uploading ? "Uploading…" : "Insert image"}
725 </button>
726 <input
727 ref={inputRef}
728 data-testid="image-input"
729 type="file"
730 accept="image/*"
731 className="sr-only"
732 aria-label="Choose an image to attach"
733 onChange={(e) => void onFiles(e.target.files)}
734 />
735 </div>
736 );
737}
738
739function EmbedBlock({ block }: { block: Block }) {
740 const { src, caption } = block.properties;
741 if (!src) return <EmptyMedia icon={<Film className="size-4" aria-hidden />} label="No embed URL" />;
742 return (
743 <div className="overflow-hidden rounded-md border border-border">
744 <iframe
745 src={src}
746 title={caption || "Embedded content"}
747 className="h-72 w-full bg-background"
748 sandbox="allow-scripts allow-same-origin"
749 loading="lazy"
750 />
751 </div>
752 );
753}
754
755function BookmarkBlock({ block }: { block: Block }) {
756 const { src, title, description } = block.properties;
757 const host = safeHost(src);
758 return (
759 <a
760 href={src}
761 target="_blank"
762 rel="noopener noreferrer"
763 className="group/bm flex gap-3 rounded-md border border-border p-3 transition-colors hover:bg-accent/50"
764 >
765 <span className="flex size-9 shrink-0 items-center justify-center rounded bg-muted">
766 <Bookmark className="size-4 text-muted-foreground" aria-hidden />
767 </span>
768 <span className="min-w-0 flex-1">
769 <span className="flex items-center gap-1.5 text-sm font-medium group-hover/bm:underline">
770 {title ?? host ?? "Bookmark"}
771 <ExternalLink className="size-3 text-muted-foreground" aria-hidden />
772 </span>
773 <span className="block truncate text-xs text-muted-foreground">{host}</span>
774 {description ? (
775 <span className="mt-0.5 line-clamp-2 block text-xs text-muted-foreground/80">{description}</span>
776 ) : null}
777 </span>
778 </a>
779 );
780}
781
782function ToggleBlock({ block, selected, requestFocus, onNavigate, onSelect, onSlashPick }: BlockRendererProps) {
783
784
785 const [open, setOpen] = useState(() => block.properties?.collapsed !== true);
786 const [focusChildId, setFocusChildId] = useState<string | null>(null);
787 const children = usePageBlocks(block.pageId, block.id);
788 const { queueChange, saveNow } = useBlockSaveQueue(block.pageId);
789 const { updateBlock } = useUpdateBlock(block.pageId);
790 const insertBlock = useInsertBlock(block.pageId);
791 const childBlocks = children.blocks ?? [];
792
793
795 const toggleCollapse = useCallback(() => {
796 setOpen((v) => {
797 const next = !v;
798 void updateBlock(block.id, {
799 properties: { ...(block.properties as Record<string, unknown>), collapsed: !next }
800 });
801 return next;
802 });
803 }, [updateBlock, block.id, block.properties]);
804
805
809 const createChild = useCallback(
810 (after: Block | null) => {
811 void insertBlock
812 .mutateAsync({ parentId: block.id, afterBlockId: after?.id ?? null, type: "paragraph" })
813 .then((created) => {
814 setOpen(true);
815 setFocusChildId(created.id);
816 });
817 },
818 [insertBlock, block.id]
819 );
820
821 const focusChildSibling = useCallback(
822 (from: Block, dir: -1 | 1) => {
823 const idx = childBlocks.findIndex((b) => b.id === from.id);
824 const next = childBlocks[idx + dir];
825 if (next) {
826 const el = document.querySelector<HTMLElement>(
827 `[data-block-id="${next.id}"] [contenteditable], [data-block-id="${next.id}"] input[type="checkbox"]`
828 );
829 el?.focus();
830 } else if (dir === 1) {
831 createChild(from);
832 }
833 },
834 [childBlocks, createChild]
835 );
836
837 return (
838 <div>
839 {}
845 <details open={open}>
846 <summary
847 className="flex cursor-default list-none items-center gap-1.5 [&::-webkit-details-marker]:hidden"
848 onClick={(e) => e.preventDefault()}
849 >
850 <button
851 type="button"
852 aria-expanded={open}
853 aria-label={open ? "Collapse toggle" : "Expand toggle"}
854 data-testid={`toggle-chevron-${block.id}`}
855 className="flex size-5 shrink-0 items-center justify-center rounded hover:bg-accent"
856 onMouseDown={(e) => e.preventDefault()}
857 onClick={toggleCollapse}
858 >
859 <ChevronRight
860 className={cn("size-4 shrink-0 text-muted-foreground transition-transform", open && "rotate-90")}
861 aria-hidden
862 />
863 </button>
864 <span className="min-w-0 flex-1">
865 <InlineEditor
866 blockId={block.id}
867 content={block.content}
868 placeholder="Toggle"
869 selected={selected}
870 requestFocus={requestFocus}
871 onChange={queueChange}
872 onBlur={saveNow}
873 onFocusBlock={() => onSelect?.()}
874 onEnterKey={() => createChild(null)}
875 onNavigate={onNavigate}
876 {...slashProps(block, onSlashPick)}
877 className="text-sm font-medium"
878 />
879 </span>
880 </summary>
881 <div className="mt-1 pl-5">
882 {childBlocks.length === 0 ? (
883 <button
884 type="button"
885 className="rounded px-1 text-sm text-muted-foreground hover:bg-accent"
886 onClick={() => createChild(null)}
887 >
888 Add content
889 </button>
890 ) : (
891 childBlocks.map((child) => (
892 <div key={child.id} data-block-id={child.id} className="group block-row">
893 <BlockRenderer
894 block={child}
895 requestFocus={focusChildId === child.id}
896 onEnterKey={() => createChild(child)}
897 onNavigate={(dir) => focusChildSibling(child, dir)}
898 onSlashPick={onSlashPick}
899 />
900 </div>
901 ))
902 )}
903 </div>
904 </details>
905 </div>
906 );
907}
908
909function TableBlock({ block }: { block: Block }) {
910 const cols = block.properties.columns ?? [];
911 const rows = block.properties.rows ?? [];
912 const { saveNow, invalidate } = useBlockSaveQueue(block.pageId);
913
914
915
916
917
918
919 const patch = (properties: Record<string, unknown>) => {
920 void blockApi.update(block.id, { properties: { ...block.properties, ...properties } }).then(
921 () => invalidate(block.pageId),
922 );
923 };
924 const setCell = (r: number, c: number, value: string) => {
925 const next = rows.map((row, ri) => (ri === r ? row.map((cell, ci) => (ci === c ? value : cell)) : row));
926 patch({ rows: next });
927 };
928 const addRow = () => patch({ rows: [...rows, Array(cols.length).fill("")] });
929 const addCol = () =>
930 patch({
931 columns: [
932 ...cols,
933 { id: `col${cols.length + 1}`, type: "text", width: 160, label: `Column ${cols.length + 1}` }
934 ],
935 rows: rows.map((r) => [...r, ""])
936 });
937
938
939 const removeRow = (ri: number) => {
940 patch({ rows: rows.filter((_r, i) => i !== ri) });
941 };
942 const removeCol = (ci: number) => {
943 if (cols.length <= 1) return;
944 patch({
945 columns: cols.filter((_c, i) => i !== ci),
946 rows: rows.map((r) => r.filter((_cell, i) => i !== ci))
947 });
948 };
949
950 return (
951 <div className="group/table overflow-x-auto rounded-md border border-border">
952 <table className="w-full border-collapse text-sm">
953 <thead>
954 <tr>
955 {cols.map((col, ci) => (
956 <th key={col.id} className="group/col border-b border-border bg-muted/40 px-3 py-2 text-left font-medium">
957 {col.label ?? col.id}
958 {cols.length > 1 && (
959 <button
960 type="button"
961 onClick={() => removeCol(ci)}
962 className="ml-2 hidden rounded p-0.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground group-hover/col:inline"
963 aria-label={`Remove column ${col.label ?? col.id}`}
964 >
965 ×
966 </button>
967 )}
968 </th>
969 ))}
970 <th className="w-8 border-b border-border" aria-label="Table actions">
971 <button
972 type="button"
973 onClick={addCol}
974 className="rounded p-1 text-muted-foreground hover:bg-accent"
975 aria-label="Add column"
976 >
977 +
978 </button>
979 </th>
980 </tr>
981 </thead>
982 <tbody>
983 {rows.map((row, ri) => (
984 <tr key={ri} className="group/row">
985 {row.map((cell, ci) => (
986 <td key={ci} className="border-b border-border px-1 py-0.5">
987 <input
988 value={cell}
989 onChange={(e) => setCell(ri, ci, e.target.value)}
990 onBlur={() => saveNow(block.id, block.content)}
991 className="w-full bg-transparent px-2 py-1.5 outline-none focus:bg-accent/40"
992 aria-label={`Row ${ri + 1}, column ${ci + 1}`}
993 />
994 </td>
995 ))}
996 <td className="w-6 border-b border-border">
997 <button
998 type="button"
999 onClick={() => removeRow(ri)}
1000 className="hidden rounded p-0.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground group-hover/row:inline"
1001 aria-label={`Remove row ${ri + 1}`}
1002 >
1003 ×
1004 </button>
1005 </td>
1006 </tr>
1007 ))}
1008 </tbody>
1009 </table>
1010 <div className="flex justify-end border-t border-border bg-muted/20 px-2 py-1">
1011 <button
1012 type="button"
1013 onClick={addRow}
1014 className="rounded px-2 py-0.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
1015 >
1016 + Add row
1017 </button>
1018 </div>
1019 </div>
1020 );
1021}
1022
1023function ChildPageBlock({ block }: { block: Block }) {
1024 const pageId = block.properties.pageId;
1025 const title = (block.properties.title as string | undefined) ?? "Page";
1026 return (
1027 <Link
1028 href={pageId ? `/w/default/page/${pageId}` : "#"}
1029 className="flex items-center gap-2 rounded-md border border-border bg-card p-2.5 transition-colors hover:bg-accent/50"
1030 >
1031 <Type className="size-4 shrink-0 text-blue-600" aria-hidden />
1032 <span className="min-w-0 flex-1">
1033 <span className="block truncate text-sm font-medium">{title}</span>
1034 <span className="block text-xs text-muted-foreground">Link to page</span>
1035 </span>
1036 <ExternalLink className="size-3.5 shrink-0 text-muted-foreground" aria-hidden />
1037 </Link>
1038 );
1039}
1040
1041
1045function DatabaseBlock({ block }: { block: Block }) {
1046 const databaseId = block.properties.databaseId;
1047 if (!databaseId) return <EmptyMedia icon={<TableIcon className="size-4" aria-hidden />} label="Database not linked" />;
1048 return <DatabaseView databaseId={databaseId} embedded />;
1049}
1050
1051
1052function VideoBlock({ block }: { block: Block }) {
1053 const { src, caption } = block.properties;
1054 if (!src) return <EmptyMedia icon={<Film className="size-4" aria-hidden />} label="No video — media uploads land in Phase 9" />;
1055 return (
1056 <figure>
1057 <video src={src} controls className="max-w-full rounded-md" />
1058 {caption ? <figcaption className="mt-1 text-center text-xs text-muted-foreground">{caption}</figcaption> : null}
1059 </figure>
1060 );
1061}
1062
1063
1064function AudioBlock({ block }: { block: Block }) {
1065 const { src, caption } = block.properties;
1066 if (!src) return <EmptyMedia icon={<Music className="size-4" aria-hidden />} label="No audio — media uploads land in Phase 9" />;
1067 return (
1068 <figure>
1069 <audio src={src} controls className="w-full" />
1070 {caption ? <figcaption className="mt-1 text-center text-xs text-muted-foreground">{caption}</figcaption> : null}
1071 </figure>
1072 );
1073}
1074
1075
1076function EquationBlock({ block }: { block: Block }) {
1077 const latex = typeof block.content === "string" ? block.content : String((block.properties as { latex?: string }).latex ?? "");
1078 if (!latex) return <EmptyMedia icon={<Calculator className="size-4" aria-hidden />} label="Empty equation" />;
1079 return <pre className="overflow-x-auto rounded-md border border-border bg-muted/30 p-3 font-mono text-sm">{latex}</pre>;
1080}
1081
1082
1086function ChartBlock({ block }: { block: Block }) {
1087 const chartId = block.properties.chartId;
1088 if (!chartId) return <EmptyMedia icon={<TableIcon className="size-4" aria-hidden />} label="Chart not linked" />;
1089 return (
1090 <div className="flex h-40 items-center justify-center rounded-md border border-dashed border-border bg-muted/20 text-xs text-muted-foreground">
1091 Chart {chartId} — rendering lands in a later task
1092 </div>
1093 );
1094}
1095
1096
1097function FormBlock({ block }: { block: Block }) {
1098 const formId = block.properties.formId;
1099 if (!formId) return <EmptyMedia icon={<FileText className="size-4" aria-hidden />} label="Form not linked" />;
1100 return (
1101 <div className="my-2">
1102 <FormRenderer formId={formId} />
1103 </div>
1104 );
1105}
1106
1107
1108function SyncedBlock({ block }: { block: Block }) {
1109 const targetPageId = block.properties.targetPageId;
1110 const targetBlockId = block.properties.targetBlockId;
1111 const query = useQuery({
1112 queryKey: ["pages", targetPageId ?? "", "synced", targetBlockId ?? ""],
1113 queryFn: async () => {
1114 if (!targetPageId) return [] as Block[];
1115 const all = await blockApi.list(targetPageId, {});
1116 return targetBlockId ? all.filter((b) => b.id === targetBlockId) : all;
1117 },
1118 enabled: Boolean(targetPageId)
1119 });
1120 return (
1121 <div className="rounded-md border border-dashed border-border bg-muted/20 p-2">
1122 <p className="mb-1 flex items-center gap-1 text-xs text-muted-foreground">
1123 <Undo2 className="size-3" aria-hidden />
1124 Synced block
1125 </p>
1126 {query.isLoading ? (
1127 <p className="text-sm text-muted-foreground">Loading…</p>
1128 ) : (
1129 sortByPosition(query.data ?? []).map((b) => <BlockRenderer key={b.id} block={b} />)
1130 )}
1131 </div>
1132 );
1133}
1134
1135function ColumnsBlock({ block }: { block: Block }) {
1136 const children = usePageBlocks(block.pageId, block.id);
1137 const columns = sortByPosition(children.data ?? []).filter((c) => c.type === "column");
1138 return (
1139 <div data-testid="column-list" className="flex flex-col gap-2 md:flex-row">
1140 {columns.length === 0 ? (
1141 <p className="w-full text-sm text-muted-foreground">Empty columns block — add content inside.</p>
1142 ) : (
1143 columns.map((col, i) => (
1144
1145 <div key={col.id} data-testid={`column-${i + 1}`} className="min-w-0 flex-1 rounded-md border border-border p-2">
1146 <ColumnChildren blockId={col.id} pageId={col.pageId} />
1147 </div>
1148 ))
1149 )}
1150 </div>
1151 );
1152}
1153
1154function ColumnChildren({ blockId, pageId }: { blockId: string; pageId: string }) {
1155 const children = usePageBlocks(pageId, blockId);
1156 return (
1157 <div>
1158 {sortByPosition(children.data ?? []).map((c) => (
1159 <div key={c.id} className="group block-row">
1160 <BlockRenderer block={c} />
1161 </div>
1162 ))}
1163 </div>
1164 );
1165}
1166
1167
1174function TableOfContentsBlock({ block }: { block: Block }) {
1175 const params = useParams<{ workspace: string }>();
1176 const ws = params?.workspace ?? "default";
1177 const headings = useTocHeadings(block.pageId);
1178 const [copied, setCopied] = useState(false);
1179 const copyLink = (e: React.MouseEvent, blockId: string) => {
1180 e.preventDefault();
1181 e.stopPropagation();
1182 const url = `${window.location.origin}/w/${ws}/page/${block.pageId}/${blockId}`;
1183 void navigator.clipboard?.writeText(url);
1184 setCopied(true);
1185 window.setTimeout(() => setCopied(false), 1500);
1186 };
1187 return (
1188 <nav aria-label="Table of contents" className="rounded-md border border-border bg-muted/20 p-3">
1189 <p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
1190 Table of contents
1191 </p>
1192 {headings.length === 0 ? (
1193 <p className="text-sm text-muted-foreground">No headings yet. Add H1–H3 blocks to populate this list.</p>
1194 ) : (
1195 <ul className="space-y-0.5">
1196 {headings.map((h) => {
1197 const href = `/w/${ws}/page/${block.pageId}/${h.id}`;
1198 return (
1199 <li key={h.id} className="group/toc flex items-center gap-1">
1200 <a
1201 href={href}
1202 onClick={(e) => {
1203 e.preventDefault();
1204 document.querySelector(`[data-block-id="${h.id}"]`)?.scrollIntoView({ block: "start", behavior: "smooth" });
1205 history.pushState(null, "", href);
1206 }}
1207 className={cn(
1208 "min-w-0 flex-1 truncate rounded px-1.5 py-0.5 text-sm hover:bg-accent",
1209 h.level === 2 && "pl-4",
1210 h.level === 3 && "pl-8 text-muted-foreground"
1211 )}
1212 >
1213 {h.title}
1214 </a>
1215 <button
1216 type="button"
1217 onClick={(e) => copyLink(e, h.id)}
1218 className="hidden shrink-0 rounded px-1 text-xs text-muted-foreground hover:bg-accent group-hover/toc:block"
1219 aria-label={`Copy link to ${h.title}`}
1220 >
1221 {copied ? "✓" : "⧉"}
1222 </button>
1223 </li>
1224 );
1225 })}
1226 </ul>
1227 )}
1228 </nav>
1229 );
1230}
1231
1232function useTocHeadings(pageId: string) {
1233 const { data } = usePageBlocks(pageId, null);
1234 return (data ?? [])
1235 .filter((b) => b.type === "heading_1" || b.type === "heading_2" || b.type === "heading_3")
1236 .map((b) => ({
1237 id: b.id,
1238 level: Number(b.type.slice(-1)),
1239 title: extractTitle(b) || "Untitled"
1240 }));
1241}
1242
1243
1244
1245function EmptyMedia({ icon, label }: { icon: React.ReactNode; label: string }) {
1246 return (
1247 <div className="flex h-24 items-center justify-center gap-2 rounded-md border border-dashed border-border text-sm text-muted-foreground">
1248 {icon}
1249 {label}
1250 </div>
1251 );
1252}
1253
1254function safeHost(url?: string): string | null {
1255 if (!url) return null;
1256 try {
1257 return new URL(url).hostname;
1258 } catch {
1259 return null;
1260 }
1261}
1262
1263function extractTitle(block: Block): string {
1264
1265
1266
1267
1268
1269 return blockText(block.content).slice(0, 80);
1270}
1271