"use client"; /** * BlockRenderer — switch on block.type and render the block body. * * Text-ish types are edited through Tiptap (InlineEditor); keyboard * navigation (Enter → sibling, arrows → neighbor, Cmd+Z → Tiptap history) * is wired through the editor's handleKeyDown. Structural types read * `block.properties`. Database blocks delegate to the parallel-agent * ; nested types (toggle/columns/synced) load * their children lazily via usePageBlocks. * * Persistence: every change is queued for the debounced batch flush * (POST /blocks/batch, 500 ms) and re-saved individually on blur as a * fallback so a closed tab never loses the last edit. */ import { useCallback, useEffect, useRef, useState } from "react"; import Link from "next/link"; import { useParams } from "next/navigation"; import { useQuery } from "@tanstack/react-query"; import { Bookmark, Check, ChevronRight, Copy, ExternalLink, FileImage, Film, Calculator, FileText, Heading1, Heading2, Heading3, Music, Pencil, Table as TableIcon, Type, Undo2, Upload } from "lucide-react"; import { usePage, usePageBlocks } from "@/hooks/use-pages"; import { useInsertBlock, useUpdateBlock } from "@/hooks/use-blocks"; import { blockApi, uploadFile } from "@/lib/api"; import { cn, sortByPosition } from "@/lib/utils"; import { DatabaseView } from "@/components/database/db-embedded"; import { FileBlock } from "@/components/files/FileBlock"; import { FormRenderer } from "@/components/form/form-renderer"; import { InlineEditor } from "./inline-editor"; import { type SlashCommand } from "./slash-menu"; import { useBlockSaveQueue } from "./block-save-queue"; import { blockText } from "./version-text"; import { Expandable } from "./expandable"; import { CODE_LANGUAGES, codeToHtml } from "@/lib/code-highlight"; import type { Block } from "@/lib/types"; /* ---------------------------------------------------------------- types */ export interface BlockRendererProps { block: Block; selected?: boolean; /** Focus this block's editor (deep link / Enter-created block). */ requestFocus?: boolean; /** 1-based index within the current numbered run (block.type=numbered). */ numberedIndex?: number; onEnterKey?: () => void; onNavigate?: (dir: -1 | 1) => void; onSelect?: () => void; /** * Slash-menu handler (inherited from the page): insert the picked command * as a sibling after the block the menu was opened in. */ onSlashPick?: (command: SlashCommand, context: { parentId: string | null; position: string }) => void; /** * TASK-0136 — empty-block cleanup (inherited from the page): called with * the block's FINAL HTML on blur so the page can delete the block if it is * a text type that lost focus while still empty (nothing typed). Only the * inline text blocks forward this; the page's `handleEmptyBlur` is the * single owner of the guard + the delete mutation. */ onEmptyBlur?: (block: Block, html: string) => void; } /** Slash-menu bridge props shared by all text blocks. */ function slashProps(block: Block, onSlashPick?: BlockRendererProps["onSlashPick"]) { return { slashContext: { parentId: block.parentId, position: block.position }, onSlashPick }; } /** * TASK-0136 — the shared inline-editor `onBlur`: persist the final content * (the existing blur-save fallback) AND report the final HTML up to the * page, which deletes the block if it is a text type that lost focus while * still empty. Both fire in the same blur — order doesn't matter because the * page's `handleEmptyBlur` re-checks emptiness against the FINAL html and * drops this block's pending queue ops before it deletes. */ function onBlurHandler( block: Block, saveNow: (blockId: string, content: unknown) => void | Promise, onEmptyBlur?: (block: Block, html: string) => void, ) { return (blockId: string, content: unknown) => { void saveNow(blockId, content); onEmptyBlur?.(block, typeof content === "string" ? content : ""); }; } export function BlockRenderer({ block, selected, requestFocus, numberedIndex, onEnterKey, onNavigate, onSelect, onSlashPick, onEmptyBlur }: BlockRendererProps) { switch (block.type) { case "heading_1": case "heading_2": case "heading_3": return ( ); case "todo": return ( ); case "bulleted_list": return (
); case "numbered_list": return (
{numberedIndex ?? 1}.
); case "quote": return (
); case "callout": return ; case "code": return ; case "divider": return (

); case "image": return ; case "video": return ; case "audio": return ; case "file": return ; case "bookmark": return ; case "embed": return ; case "equation": return ; case "toggle": // Enter on a toggle creates a CHILD (Notion semantics) — the toggle // handles its own Enter; the page-level sibling handler is not wired. return ; case "spacer": // 8–200 px per docs/api/blocks.md §3 (clamped). return
; case "column_list": return ; case "table": return ; case "chart": return ; case "form": return ; case "database": case "linked_database": case "child_database": return ; case "synced_block": return ; case "toc": // T-147: pure marker block — the renderer derives entries from the // page's headings (component below uses its own page-blocks query). return ; case "child_page": return ; case "column": // Rendered inside its column_list parent; never top-level. return null; default: return ( // Long pasted paragraphs are capped vertically + expandable. The cap // is NOT lifted while the block is selected/focused — otherwise a // just-pasted long block (which is the selected one) would still // stretch the page: the exact complaint. ProseMirror keeps the live // caret scrolled into view INSIDE the cap (the cap is a scrollable // ancestor), so typing at the end of a long block is never clipped; // the user can always press Expand for the full-height view. ); } } /* ------------------------------------------------------ text-ish blocks */ function InlineTextBlock({ block, selected, requestFocus, onEnterKey, onNavigate, onSelect, onSlashPick, onEmptyBlur, className }: BlockRendererProps & { className?: string }) { const { queueChange, saveNow } = useBlockSaveQueue(block.pageId); return (
onSelect?.()} onEnterKey={onEnterKey} onNavigate={onNavigate} {...slashProps(block, onSlashPick)} />
); } function HeadingBlock({ block, level, selected, requestFocus, onEnterKey, onNavigate, onSelect, onSlashPick, onEmptyBlur }: BlockRendererProps & { level: 1 | 2 | 3 }) { const Icon = level === 1 ? Heading1 : level === 2 ? Heading2 : Heading3; const sizes = { 1: "text-3xl font-bold", 2: "text-2xl font-semibold", 3: "text-xl font-semibold" } as const; const { queueChange, saveNow } = useBlockSaveQueue(block.pageId); return (
H{level}
onSelect?.()} onEnterKey={onEnterKey} onNavigate={onNavigate} {...slashProps(block, onSlashPick)} className={cn(sizes[level])} />
); } function TodoBlock({ block, selected, requestFocus, onEnterKey, onNavigate, onSelect, onSlashPick, onEmptyBlur }: BlockRendererProps) { const checked = Boolean(block.properties.checked); const { queueChange, saveNow } = useBlockSaveQueue(block.pageId); const toggle = () => { void blockApi.update(block.id, { properties: { ...block.properties, checked: !checked } }); }; return (
onSelect?.()} onEnterKey={onEnterKey} onNavigate={onNavigate} {...slashProps(block, onSlashPick)} />
); } /** T-148 — callout visual variants. `variant` is persisted in properties * (additive; legacy callouts without it render as "info"). */ const CALLOUT_VARIANTS = { info: { label: "Info", border: "border-sky-500/40", bg: "bg-sky-500/10", icon: "ℹ️" }, warning: { label: "Warning", border: "border-amber-500/40", bg: "bg-amber-500/10", icon: "⚠️" }, tip: { label: "Tip", border: "border-emerald-500/40", bg: "bg-emerald-500/10", icon: "💡" }, error: { label: "Error", border: "border-red-500/40", bg: "bg-red-500/10", icon: "⛔" } } as const; type CalloutVariantKey = keyof typeof CALLOUT_VARIANTS; function CalloutBlock({ block, selected, onEnterKey, onNavigate, onSelect, onSlashPick, onEmptyBlur }: BlockRendererProps) { const { icon, color, variant } = block.properties; const isVariant = (x: unknown): x is CalloutVariantKey => typeof x === "string" && Object.hasOwn(CALLOUT_VARIANTS, x); const v = CALLOUT_VARIANTS[isVariant(variant) ? variant : "info"]; const { queueChange, saveNow } = useBlockSaveQueue(block.pageId); return (
{selected && ( )} {icon ?? v.icon}
onSelect?.()} onEnterKey={onEnterKey} onNavigate={onNavigate} {...slashProps(block, onSlashPick)} />
); } /* --------------------------------------------------------- media blocks */ /** * Extract the editable plain text from a code block's stored content. * Content is either a raw string (web edits), the API/AI canonical * `{ text }` envelope (slash-pick inserts and the AI apply path), or * anything else (fallback to the JSON rendering). */ export function codeBlockText(content: unknown): string { if (typeof content === "string") return content; if (content && typeof content === "object") { const t = (content as { text?: unknown }).text; if (typeof t === "string") return t; } return content == null ? "" : JSON.stringify(content, null, 2); } /** * Long code is capped VERTICALLY and made expandable (user request: a long * pasted code block shouldn't stretch the page to full height). The cap is a * multiple of the code line height so the cutoff lands cleanly on a line * boundary. `leading-6` = 24px; `p-3` = 12px top+bottom padding. */ const CODE_LINE_HEIGHT = 24; const CODE_CAP_LINES = 15; const CODE_CAP = CODE_CAP_LINES * CODE_LINE_HEIGHT + 24; // + one line's padding /** * Long PASTED TEXT is capped vertically + expandable too (same request). Body * copy is 14.5px at the inherited line-height 1.5 (Tailwind preflight) → * ≈21.75px/line; the cap is ~16 lines so a normal paragraph is never touched. */ const TEXT_CAP_LINES = 16; const TEXT_CAP = Math.round(TEXT_CAP_LINES * 14.5 * 1.5); // ≈348px export function CodeBlock({ block }: { block: Block }) { const { queueChange, saveNow } = useBlockSaveQueue(block.pageId); const [copied, setCopied] = useState(false); const [editing, setEditing] = useState(false); const [text, setText] = useState(() => codeBlockText(block.content)); const textareaRef = useRef(null); const overlayRef = useRef(null); const language = block.properties.language ?? "plaintext"; // The edit-mode textarea is an invisible-text overlay on top of a live // highlighted
, so typing/pasting is already colored. Identical font,
  // padding, wrapping and line height are what keep the two layers aligned.
  const codeMetrics =
    "min-h-[1.5em] bg-[var(--factory-code-bg)] p-3 font-mono leading-6 [font-size:var(--factory-code-font-size)]";

  // Auto-size the edit overlay to its content (grows with the code being
  // typed). Wrapping is on, so `scrollHeight` already counts wrapped lines.
  useEffect(() => {
    if (!editing) return;
    const el = textareaRef.current;
    if (!el) return;
    el.style.height = "0px";
    el.style.height = `${el.scrollHeight}px`;
  }, [editing, text]);

  // Keep the highlighted overlay scrolled in lockstep with the textarea
  // (vertical and horizontal) while editing.
  useEffect(() => {
    if (!editing) return;
    const ta = textareaRef.current;
    const pre = overlayRef.current;
    if (!ta || !pre) return;
    const sync = () => {
      pre.scrollTop = ta.scrollTop;
      pre.scrollLeft = ta.scrollLeft;
    };
    ta.addEventListener("scroll", sync);
    return () => ta.removeEventListener("scroll", sync);
  }, [editing]);

  const startEdit = () => {
    setText(codeBlockText(block.content));
    setEditing(true);
  };

  const commit = () => {
    if (!editing) return;
    setEditing(false);
    void saveNow(block.id, text);
  };

  const current = editing ? text : codeBlockText(block.content);

  const copy = () => {
    void navigator.clipboard.writeText(current).then(() => {
      setCopied(true);
      setTimeout(() => setCopied(false), 1500);
    });
  };

  return (
    
{editing ? ( // The textarea holds the text but paints it transparent; the // highlighted
 behind it (identical font metrics + padding)
        // provides the colors, so typing/paste is already syntax-highlighted.