"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 (
);
}
/* --------------------------------------------------------- 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.
) : (
// Long code is capped vertically + expandable (see CODE_CAP above).
// Edit mode below stays FULL height: the edit overlay's own
// scroll-sync (textarea ↔ highlighted
) already handles long
// code, and a capped editor would clip the caret.
)}
);
}
function ImageBlock({ block }: { block: Block }) {
const { src, caption } = block.properties;
if (!src) return ;
return (
{/* eslint-disable-next-line @next/next/no-img-element */}
{caption ? {caption} : null}
);
}
/**
* An image block with no `src` yet — the "Insert image" entry point
* (turn-into / legacy blocks; the slash command opens the insert dialog
* instead). Mirrors FilePickerBlock: file picker → presigned upload
* (lib/api uploadFile) → patch the block's properties.src with the signed
* URL. The upload stores the file on the page (pageId anchor) so it also
* appears in Files → page scope.
*/
function ImagePickerBlock({ block }: { block: Block }) {
const inputRef = useRef(null);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState(null);
const { data: page } = usePage(block.pageId);
const workspaceId = page?.workspaceId ?? null;
const { updateBlock } = useUpdateBlock(block.pageId);
const onFiles = async (list: FileList | null) => {
const file = list?.[0];
if (!file) return;
setUploading(true);
setError(null);
try {
const meta = await uploadFile(file, undefined, { workspaceId: workspaceId ?? undefined, pageId: block.pageId });
if (meta.status !== "ready" || !meta.url) {
setError("Upload was blocked (virus scan or processing) — the file is in Files.");
return;
}
await updateBlock(block.id, { properties: { ...block.properties, src: meta.url, fileId: meta.id, name: meta.name } });
} catch (e) {
setError(e instanceof Error ? e.message : "Upload failed");
} finally {
setUploading(false);
}
};
return (
{uploading ? "Uploading…" : error ? error : "No image yet — choose a file to attach"}
void onFiles(e.target.files)}
/>
);
}
function BookmarkBlock({ block }: { block: Block }) {
const { src, title, description } = block.properties;
const host = safeHost(src);
return (
{title ?? host ?? "Bookmark"}
{host}
{description ? (
{description}
) : null}
);
}
function ToggleBlock({ block, selected, requestFocus, onNavigate, onSelect, onSlashPick }: BlockRendererProps) {
// Notion behavior: a fresh toggle starts EXPANDED; the `collapsed`
// property (set by the chevron) is remembered across reloads.
const [open, setOpen] = useState(() => block.properties?.collapsed !== true);
const [focusChildId, setFocusChildId] = useState(null);
const children = usePageBlocks(block.pageId, block.id);
const { queueChange, saveNow } = useBlockSaveQueue(block.pageId);
const { updateBlock } = useUpdateBlock(block.pageId);
const insertBlock = useInsertBlock(block.pageId);
const childBlocks = children.blocks ?? [];
/** Chevron click: flip the local state AND persist `collapsed` so the
* state survives a reload (update() replaces the whole properties bag). */
const toggleCollapse = useCallback(() => {
setOpen((v) => {
const next = !v;
void updateBlock(block.id, {
properties: { ...(block.properties as Record), collapsed: !next }
});
return next;
});
}, [updateBlock, block.id, block.properties]);
/** Enter (title or a child) / empty-state click → a CHILD paragraph of
* this toggle (Notion semantics: content goes INSIDE the toggle). The
* old code called the page-level sibling handler, so Enter on the title
* escaped to a top-level block and the toggle stayed empty. */
const createChild = useCallback(
(after: Block | null) => {
void insertBlock
.mutateAsync({ parentId: block.id, afterBlockId: after?.id ?? null, type: "paragraph" })
.then((created) => {
setOpen(true); // the new child must be visible
setFocusChildId(created.id);
});
},
[insertBlock, block.id]
);
const focusChildSibling = useCallback(
(from: Block, dir: -1 | 1) => {
const idx = childBlocks.findIndex((b) => b.id === from.id);
const next = childBlocks[idx + dir];
if (next) {
const el = document.querySelector(
`[data-block-id="${next.id}"] [contenteditable], [data-block-id="${next.id}"] input[type="checkbox"]`
);
el?.focus();
} else if (dir === 1) {
createChild(from);
}
},
[childBlocks, createChild]
);
return (
{/* Controlled expand/collapse: the NATIVE toggle is
disabled (summary click preventDefault'd) so the ONLY way the
state changes is the chevron button — deterministic, and clicks
in the title editor never collapse the toggle. (Relying on the
native toggle event + a state flip made expand/collapse
unreliable; the QA report's "toggle doesn't work".) */}
e.preventDefault()}
>
onSelect?.()}
onEnterKey={() => createChild(null)}
onNavigate={onNavigate}
{...slashProps(block, onSlashPick)}
className="text-sm font-medium"
/>
);
}
function ChildPageBlock({ block }: { block: Block }) {
const pageId = block.properties.pageId;
const title = (block.properties.title as string | undefined) ?? "Page";
return (
{title}Link to page
);
}
/**
* Database block — delegates to the database views (components/database/
* db-embedded → DatabaseView, the named export the core editor relies on).
*/
function DatabaseBlock({ block }: { block: Block }) {
const databaseId = block.properties.databaseId;
if (!databaseId) return } label="Database not linked" />;
return ;
}
/** Video block — media files land in Phase 9 (files); placeholder until then. */
function VideoBlock({ block }: { block: Block }) {
const { src, caption } = block.properties;
if (!src) return } label="No video — media uploads land in Phase 9" />;
return (
{caption ? {caption} : null}
);
}
/** Audio block — media files land in Phase 9 (files); placeholder until then. */
function AudioBlock({ block }: { block: Block }) {
const { src, caption } = block.properties;
if (!src) return } label="No audio — media uploads land in Phase 9" />;
return (
{caption ? {caption} : null}
);
}
/** Equation block — raw LaTeX (KaTeX rendering is a later task). */
function EquationBlock({ block }: { block: Block }) {
const latex = typeof block.content === "string" ? block.content : String((block.properties as { latex?: string }).latex ?? "");
if (!latex) return } label="Empty equation" />;
return
{latex}
;
}
/**
* Chart block — the block carries a chartId; live chart rendering over the
* linked database is a later task, so this renders a labelled placeholder.
*/
function ChartBlock({ block }: { block: Block }) {
const chartId = block.properties.chartId;
if (!chartId) return } label="Chart not linked" />;
return (
Chart {chartId} — rendering lands in a later task
);
}
/** Form block — read-only render of the published form (submission via /f/:slug). */
function FormBlock({ block }: { block: Block }) {
const formId = block.properties.formId;
if (!formId) return } label="Form not linked" />;
return (
);
}
/**
* T-147 — Table of contents block. A pure marker: entries are derived from
* the page's top-level headings (levels 1–3). Links deep-link to the block
* route (`/w//page//`) and smooth-scroll; Copy link copies
* the full URL. The slug generator lives in @factory/editor (toc.ts) so the
* shared core, the renderer and the export stay consistent.
*/
function TableOfContentsBlock({ block }: { block: Block }) {
const params = useParams<{ workspace: string }>();
const ws = params?.workspace ?? "default";
const headings = useTocHeadings(block.pageId);
const [copied, setCopied] = useState(false);
const copyLink = (e: React.MouseEvent, blockId: string) => {
e.preventDefault();
e.stopPropagation();
const url = `${window.location.origin}/w/${ws}/page/${block.pageId}/${blockId}`;
void navigator.clipboard?.writeText(url);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
};
return (
);
}
function useTocHeadings(pageId: string) {
const { data } = usePageBlocks(pageId, null);
return (data ?? [])
.filter((b) => b.type === "heading_1" || b.type === "heading_2" || b.type === "heading_3")
.map((b) => ({
id: b.id,
level: Number(b.type.slice(-1)),
title: extractTitle(b) || "Untitled"
}));
}
/* ----------------------------------------------------------------- misc */
function EmptyMedia({ icon, label }: { icon: React.ReactNode; label: string }) {
return (
{icon}
{label}
);
}
function safeHost(url?: string): string | null {
if (!url) return null;
try {
return new URL(url).hostname;
} catch {
return null;
}
}
function extractTitle(block: Block): string {
// Reuse blockText (version-text): it handles every stored content shape —
// HTML string (legacy), the API canonical { text }/{ title }, and Tiptap
// docs. The old version handled ONLY the HTML string, so a heading saved in
// the canonical { text } shape (every API/AI-authored + most web blocks)
// rendered in the TOC as "Untitled".
return blockText(block.content).slice(0, 80);
}