"use client"; /** * PageView — the editor surface for one page: * * - Header: icon + inline title (PATCH page) + meta (updated, author). * - Block list: top-level blocks sorted client-side by fractional position; * each renders via . Nested content (toggle, columns, * synced) is loaded lazily per block type by the renderer. * - Trailing empty area: click → creates + focuses a new paragraph. * - Drag & drop (dnd-kit): a drop rewrites only the moved block's position * (fractional midpoint, via useMoveBlock) — no reindexing (invariant 4). * - Keyboard: Enter in a text block → new sibling; ArrowUp/Down at the * block boundary → previous/next block; Cmd/Ctrl+Z → Tiptap history * (inside the editor); Delete on a selected, non-editing row removes it. * - Deep link: focusBlockId scrolls to + highlights the target block. */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useParams } from "next/navigation"; import { DndContext, PointerSensor, KeyboardSensor, closestCenter, useSensor, useSensors, type DragEndEvent, } from "@dnd-kit/core"; import { SortableContext, arrayMove, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { Clock, FolderOpen, Image as ImageIcon, Link2, MessageSquare, Smile, Sparkles } from "lucide-react"; import { useBacklinks, usePage, usePageBlocks, useUpdatePage } from "@/hooks/use-pages"; import { useWorkspace } from "@/hooks/use-workspace"; import { useDeleteBlock, useInsertBlock, useMoveBlock, useMergedBlocks } from "@/hooks/use-blocks"; import { isEmptyTextBlock, isContentEmpty, isAutoCleanableType } from "@/lib/block-content"; import { getBlockBatchQueue } from "@/lib/batch-queue"; import { usePagePresence } from "@/hooks/use-presence"; import { usePresenceAnnouncer } from "@/hooks/use-presence-announcer"; import { usePresenceToasts } from "@/hooks/use-presence-toasts"; import { useEditorStore } from "@/stores/editor-store"; import { useCommentPillStore } from "@/stores/comment-pill-store"; import { toast, useUiStore } from "@/stores/ui-store"; import { useAuthStore } from "@/lib/auth-store"; import { realtime } from "@/lib/realtime"; import { CommentPanel } from "@/components/comments/comment-panel"; import { RelatedPanel } from "./related-panel"; import { cn, formatRelativeTime, sortByPosition } from "@/lib/utils"; import { useFileUploads } from "@/lib/files/use-file-uploads"; import { BLOCK_TYPE_LABELS } from "@/lib/constants"; import { PresenceBar } from "@/components/collaboration/PresenceBar"; import { CursorLayer } from "@/components/collaboration/CursorLayer"; import { FilePicker } from "@/components/files/FilePicker"; import { BlockRenderer } from "./block-renderer"; import { BlockToolbar } from "./block-toolbar"; import { ContextMenu } from "./context-menu"; import { InsertDialog } from "./insert-dialog"; import { TemplateDialog } from "./template-dialog"; import { SelectionToolbar } from "./selection-toolbar"; import { WritingDialog } from "@/components/ai/writing-diff"; import { ReviewBanner } from "./review-banner"; import { blockApi, templateApi, type BatchBlockOp } from "@/lib/api"; import { useQueryClient } from "@tanstack/react-query"; import { htmlHasBlockStructure, pasteHtmlToBlocks, parseClipboardText, type PastedBlockSpec } from "@/lib/clipboard-import"; import type { WritingAction } from "@/lib/ai"; import type { Block } from "@/lib/types"; import type { SlashCommand } from "./slash-menu"; interface PageViewProps { pageId: string; focusBlockId?: string | null; } export function PageView({ pageId, focusBlockId }: PageViewProps) { const qc = useQueryClient(); const { data: page } = usePage(pageId); const { data: serverBlocks } = usePageBlocks(pageId, null); const blocks = useMergedBlocks(pageId, serverBlocks ?? []); const sorted = useMemo(() => sortByPosition(blocks), [blocks]); // Every page carries a title block (properties.isTitle). The Home empty-state // card means "no CONTENT yet" — so count content blocks (title excluded); a // freshly created page has exactly the title and shows the card. const contentBlockCount = useMemo( () => sorted.filter((b) => b.properties?.isTitle !== true).length, [sorted], ); // TASK-0126 — the "Generate page with AI" entry points (header button + Home // empty-state card) need EDIT; hidden for GUEST (the server still returns // 403 — this is the UI gate, AC item 6). The role comes from the workspace // loaded by the [workspace] layout (React Query cache hit). const wsParams = useParams<{ workspace: string }>(); const { data: wsRoleData } = useWorkspace(wsParams?.workspace ?? ""); const canGenerate = (wsRoleData?.role ?? "").toLowerCase() !== "guest"; // Presence (TASK-0093): the page's OTHER peers (self excluded by the // server + defensively below), the a11y live-region text (≤ 1/s, state // changes only), the join/leave toasts (the ui-store toggle, default on), // and a block-id → type-label map for the bar's "editing Heading 1". const selfUserId = useAuthStore((s) => s.user?.id ?? null); const presence = usePagePresence(pageId); const others = useMemo( () => (selfUserId ? presence.filter((p) => p.userId !== selfUserId) : presence), [presence, selfUserId], ); const announcement = usePresenceAnnouncer(pageId, selfUserId); usePresenceToasts(pageId, selfUserId); const blockTypeFor = useCallback( (blockId: string) => { const b = blocks.find((x) => x.id === blockId); return b ? BLOCK_TYPE_LABELS[b.type] : null; }, [blocks], ); const blockIds = useMemo(() => sorted.map((b) => b.id), [sorted]); // T-194 — the seeded title block (properties.isTitle) is NOT body content: // the header's `page-title` contenteditable is the canonical title (it PATCHes // the page and the server syncs the title block's text). The render loop maps // only REAL content blocks, so a fresh page (just the title) shows an empty // canvas + the `new-block` trailing surface — clicking it creates + focuses // the first paragraph (onTrailingAreaClick), which is where the slash menu // and typing begin. Consistent with the two existing isTitle exclusions // (contentBlockCount above; handleEmptyBlur's skip). `sorted` stays // title-inclusive for position math (drag/drop, focusSibling, slash anchor). const renderBlocks = useMemo(() => sorted.filter((b) => b.properties?.isTitle !== true), [sorted]); const updatePage = useUpdatePage(page?.workspaceId ?? ""); const insertBlock = useInsertBlock(pageId); const moveBlock = useMoveBlock(pageId); const deleteBlock = useDeleteBlock(pageId); const { setSelection } = useEditorStore(); const storeSelected = useEditorStore((s) => s.selectedBlockId); // TASK-0103 — upload paths (a) drag-drop from the OS and (b) paste an // image. Both funnel into the SAME upload service (useFileUploads → // lib/files/upload.ts): pre-check → presigned session → XHR PUT (progress // on the block) → complete → poll. The raw-`src` paths in the insert dialog // / legacy blocks are untouched. const { start: startUpload } = useFileUploads(page?.workspaceId ?? null, pageId); const [fileDragActive, setFileDragActive] = useState(false); const fileDragDepth = useRef(0); const lastDropPosition = useRef(null); const sortedRef = useRef(sorted); sortedRef.current = sorted; const dropTargetPosition = useCallback( (clientY: number): string | null => { // nearest block ABOVE the pointer → insert after it (null = end) const els = Array.from( pageRef.current?.querySelectorAll("[data-block-id]") ?? [], ); let best: string | null = null; let bestDist = Infinity; for (const el of els) { const rect = el.getBoundingClientRect(); if (clientY < rect.top) continue; // the pointer is above this block const dist = clientY - rect.top; if (dist < bestDist) { bestDist = dist; best = el.dataset.blockId ?? null; } } if (!best) return null; const above = sortedRef.current.find((b) => b.id === best); return above ? above.position : null; }, // pageRef is stable [], ); const onFilesDropped = useCallback( async (e: React.DragEvent) => { e.preventDefault(); fileDragDepth.current = 0; setFileDragActive(false); const files = Array.from(e.dataTransfer?.files ?? []); if (files.length === 0) return; const position = dropTargetPosition(e.clientY); lastDropPosition.current = position; for (const file of files) { await startUpload(file, { parentId: null, position }); } }, [startUpload, dropTargetPosition], ); const onDragEnter = useCallback((e: React.DragEvent) => { if (!e.dataTransfer?.types?.includes("Files")) return; fileDragDepth.current += 1; setFileDragActive(true); }, []); const onDragOver = useCallback((e: React.DragEvent) => { if (fileDragActive) e.preventDefault(); // allow the drop }, [fileDragActive]); const onDragLeave = useCallback(() => { fileDragDepth.current = Math.max(0, fileDragDepth.current - 1); if (fileDragDepth.current === 0) setFileDragActive(false); }, []); /** * Focus a freshly created block: it is not in the DOM yet (the list * refetches after the mutation), so the retry effect below scrolls to it * once it lands in the sorted list. (Declared before the paste handler — * the paste path focuses the first pasted block the same way.) */ const focusNewBlock = useCallback((id: string) => { setFocusTarget(id); pendingFocus.current = id; hasScrolledFocus.current = true; }, []); // (b) paste: // - image blob → upload at the selected block; // - structural HTML (Notion/Word/Docs: lists, toggles, headings, quotes) // → BLOCK-level import (lib/clipboard-import) preserving indentation and // toggle parent-child structure (the QA "copy from Notion" case); // - the app's internal clipboard payload (multi-block copy) → block import. // Plain inline text/HTML stays with the focused InlineEditor (ProseMirror's // native inline paste) — we never hijack a text-only paste. const runPasteImport = useCallback( async (specs: PastedBlockSpec[], anchorBlockId: string | null) => { // Anchor: the selected block's LAST DESCENDANT (paste under a TOGGLE // lands INSIDE it — matching where the caret is); otherwise top-level. // The page list is top-level only (depth 1), so the anchor's own // descendants are fetched on demand (bounded: one subtree). const sorted = sortedRef.current; const anchor = anchorBlockId ? sorted.find((b) => b.id === anchorBlockId) ?? null : null; let anchorAfter: string | null = null; if (anchor) { anchorAfter = anchor.id; try { // Walk the last-child chain (one direct-children fetch per level; // the list is position-sorted → last element = last sibling). let deepest = anchor.id; for (let d = 0; d < 8; d += 1) { const kids = await blockApi.list(pageId, { parentId: deepest }); const last = kids[kids.length - 1]; if (!last) break; deepest = last.id; if (deepest === anchor.id) break; } anchorAfter = deepest; } catch { /* fall back to anchoring at the block itself */ } } // ONE op per spec (parents precede their children in document order — // the parser guarantees it), chained: each block lands after the // PREVIOUSLY PASTED sibling (first in a parent group: after the parent // itself / the page anchor). The server assigns the fractional // position per op (same transaction, FIFO). Chaining per sibling group // (not all to the anchor) keeps the source order: two inserts "after // the same anchor" inside one tx would interleave reversed. const lastInGroup = new Map(); // parentId (or "__top__") → last pasted id const ops: BatchBlockOp[] = specs.map((s) => { const groupKey = s.parentId ?? "__top__"; const chainFrom = lastInGroup.get(groupKey) ?? (s.parentId ?? anchorAfter); lastInGroup.set(groupKey, s.id); return { op: "upsert" as const, block: { id: s.id, type: s.type, content: s.content, ...(s.properties ? { properties: s.properties } : { properties: {} }), parentId: s.parentId ?? null, ...(chainFrom ? { afterBlockId: chainFrom } : {}) } }; }); // The batch endpoint caps at 200 ops → chunk (preserving order). try { for (let i = 0; i < ops.length; i += 200) { await blockApi.batch(pageId, ops.slice(i, i + 200)); } qc.invalidateQueries({ queryKey: ["pages", pageId, "blocks"] }); toast("success", `Pasted ${specs.length} block${specs.length === 1 ? "" : "s"}`); // Focus the first top-level pasted block (it is in the DOM after the // invalidation refetch — the pendingFocus retry effect handles it). const firstRoot = specs.find((s) => s.parentId === null); if (firstRoot) focusNewBlock(firstRoot.id); } catch (err) { toast("error", "Paste failed", err instanceof Error ? err.message : undefined); } }, [pageId, focusNewBlock] ); useEffect(() => { const onPaste = (e: ClipboardEvent) => { if (useEditorStore.getState().activePageId !== pageId) return; const cd = e.clipboardData; if (!cd) return; // 1) image blob wins (unchanged legacy path). const fileItem = Array.from(cd.items ?? []).find((i) => i.kind === "file" && i.type.startsWith("image/")); if (fileItem) { const file = fileItem.getAsFile(); if (file) { e.preventDefault(); const selected = useEditorStore.getState().selectedBlockId; const anchor = sortedRef.current.find((b) => b.id === selected); void startUpload(file, { parentId: null, position: anchor ? anchor.position : null }); return; } } // 2) internal payload (our Copy, incl. subtree) — highest fidelity. const internal = parseClipboardText(cd.getData("text/plain")); if (internal) { e.preventDefault(); void runPasteImport(internal, useEditorStore.getState().selectedBlockId); return; } // 3) structural HTML from other apps (Notion/Word/Docs). const html = cd.getData("text/html"); if (html && htmlHasBlockStructure(html)) { e.preventDefault(); const specs = pasteHtmlToBlocks(html); if (specs.length > 0) void runPasteImport(specs, useEditorStore.getState().selectedBlockId); return; } // otherwise: inline text paste → the focused editor handles it. }; window.addEventListener("paste", onPaste); return () => window.removeEventListener("paste", onPaste); }, [pageId, startUpload, runPasteImport]); const [filesPickerOpen, setFilesPickerOpen] = useState(false); const [focusTarget, setFocusTarget] = useState(null); const [menu, setMenu] = useState<{ x: number; y: number; block: Block } | null>(null); // TASK-0038 icon/cover pickers (T-192). const [iconPickerOpen, setIconPickerOpen] = useState(false); const [coverPickerOpen, setCoverPickerOpen] = useState(false); // The title is a contenteditable (NOT an ): the E2E contract reads // it via toContainText (tests/README `page-title` = "editable text"), which // only matches an element's TEXT CONTENT — an input's title lives in the // `value` attribute and would never match (T-192). Text is set // imperatively so React re-renders don't fight the caret; we re-sync from // the server only while the field is NOT focused. const titleRef = useRef(null); useEffect(() => { const el = titleRef.current; if (!el) return; if (document.activeElement === el) return; // don't clobber the caret mid-edit el.textContent = page?.title ?? ""; }, [page?.id, page?.title]); // T-192: the title commit is DEBOUNCED — one commit per typing burst, // flushed on blur/Enter/Escape/unmount. Committing on every keystroke fired // N concurrent PATCHes the server applies in arbitrary order, so an // intermediate partial ("Chil") could overwrite the final "Child Page" // (the UI shows the DOM text while the server holds the stale partial — the // nested E2E flaked on exactly this). The commit targets the route `pageId` // (always current, unlike `page?.id` which lags the load), so it is correct // even before the page query resolves. const titleCommitTimer = useRef | null>(null); const commitTitleNow = () => { if (titleCommitTimer.current) { clearTimeout(titleCommitTimer.current); titleCommitTimer.current = null; } const el = titleRef.current; if (!el) return; updatePage.mutate({ id: pageId, title: el.textContent ?? "" }); }; const commitTitleRef = useRef(commitTitleNow); commitTitleRef.current = commitTitleNow; useEffect(() => { // Flush a pending title when leaving the page (soft navigation unmounts // without a blur) so the last-typed value still wins. return () => { if (titleCommitTimer.current) { clearTimeout(titleCommitTimer.current); titleCommitTimer.current = null; } const el = titleRef.current; if (el && el.textContent) commitTitleRef.current(); }; }, []); const [insert, setInsert] = useState<{ block: Block; tab: "image" | "link" | "table" | "database"; } | null>(null); const [templateOpen, setTemplateOpen] = useState(false); const [commentsOpen, setCommentsOpen] = useState(false); // T-140: the "Related" (backlinks) slide-over. const [relatedOpen, setRelatedOpen] = useState(false); const backlinks = useBacklinks(pageId); const setAiPanel = useUiStore((s) => s.setAiPanel); const setGenerate = useUiStore((s) => s.setGenerate); const commentPill = useCommentPillStore((s) => s.byPage[pageId] ?? 0); const pageRef = useRef(null); const useCommentsOpenRef = useRef(commentsOpen); useEffect(() => { useCommentsOpenRef.current = commentsOpen; }, [commentsOpen]); const pendingFocus = useRef(null); const hasScrolledFocus = useRef(false); // TASK-0094 R2 — the "New comments (n)" pill: a `comment.event` frame // (created / replied) for this page bumps the pill while the panel is // CLOSED. The socket + the `page:` channel are open (the page layout's // usePageRealtime) — the same connection. Opening the panel clears the // pill (the panel refetches, showing the new comments). useEffect(() => { const off = realtime.on("comment.event", (frame) => { if (frame.type !== "comment.event" || frame.pageId !== pageId) return; if (frame.kind !== "created" && frame.kind !== "replied") return; if (useCommentsOpenRef.current) return; // the panel shows it live useCommentPillStore.getState().add(pageId); }); return () => off(); }, [pageId]); // Single-block selection (the store is the source of truth — set by row // clicks, deep links and the AI panel). `storeSelected` is null until the // first selection; the row ring + the Delete-on-Backspace key use it. const selectedBlockId = storeSelected; // TASK-0123 — multi-block selection (the writing tools' entry point). // ⌘/Ctrl+click toggles a row into the set; Shift+click selects the range // from the last anchor; a plain click keeps the existing single-selection // behavior (backward compatible). The set is page-scoped (cleared on page // change) and drives the floating SelectionToolbar. const selectedBlockIds = useEditorStore((s) => s.selectedBlockIds); const toggleSelected = useEditorStore((s) => s.toggleSelected); const setManySelected = useEditorStore((s) => s.setManySelected); const shiftAnchorId = useRef(null); const [writing, setWriting] = useState<{ open: boolean; action: WritingAction; language?: string; blockIds: string[]; }>({ open: false, action: "summarize", blockIds: [] }); const selectedBlocks = useMemo(() => { const byId = new Map(sorted.map((b) => [b.id, b])); return selectedBlockIds.map((id) => byId.get(id)).filter((b): b is Block => !!b); }, [selectedBlockIds, sorted]); // The dialog's blocks: resolve the requested ids against the FULL page // list (not just the multi-selection — the context-menu path opens for a // single block that was never added to the selection). const dialogBlocks = useMemo( () => sorted.filter((b) => writing.blockIds.includes(b.id)), [sorted, writing.blockIds] ); const openWriting = useCallback( (action: WritingAction, blockIds: string[], language?: string) => { setWriting({ open: true, action, language, blockIds }); }, [] ); /** Handle a row selection, honoring the modifier keys for multi-select. */ const selectRow = useCallback( (id: string, e?: { metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean }) => { if (e?.metaKey || e?.ctrlKey) { // toggle into the multi-selection const next = selectedBlockIds.includes(id) ? selectedBlockIds.filter((x) => x !== id) : [...selectedBlockIds, id]; setManySelected(next); shiftAnchorId.current = id; setSelection(id); return; } if (e?.shiftKey && shiftAnchorId.current) { // range from the anchor to this row const a = sorted.findIndex((b) => b.id === shiftAnchorId.current); const b = sorted.findIndex((x) => x.id === id); if (a !== -1 && b !== -1) { const [lo, hi] = a < b ? [a, b] : [b, a]; setManySelected(sorted.slice(lo, hi + 1).map((x) => x.id)); } else { setManySelected([id]); } setSelection(id); return; } // plain click: the existing single-selection toggle (backward compat). setSelection(id === selectedBlockId ? null : id); shiftAnchorId.current = id; }, [selectedBlockIds, selectedBlockId, sorted, setManySelected, setSelection] ); const clearSelection = useCallback(() => { setManySelected([]); shiftAnchorId.current = null; }, [setManySelected]); const scrollAndFlash = useCallback((id: string) => { const el = pageRef.current?.querySelector(`[data-block-id="${id}"]`); if (!el) return false; el.scrollIntoView({ block: "center", behavior: "smooth" }); el.classList.add("block-focus-flash"); setTimeout(() => el.classList.remove("block-focus-flash"), 1600); return true; }, []); // Deep link (route [block] segment or ?focus=): scroll + flash the target. // The block may not be in the DOM yet (fresh query / just-created block), // so the scroll is retried until it appears in the sorted list. useEffect(() => { const target = focusBlockId ?? useEditorStore.getState().focusBlockId; if (!target) return; if (hasScrolledFocus.current && target === pendingFocus.current) return; hasScrolledFocus.current = true; pendingFocus.current = target; const t = setTimeout(() => { if (scrollAndFlash(target)) { pendingFocus.current = null; setFocusTarget(target); useEditorStore.getState().setFocus(null); } }, 80); return () => clearTimeout(t); // eslint-disable-next-line react-hooks/exhaustive-deps }, [focusBlockId, page]); // Retry focus once the (re)sorted list contains the pending block. useEffect(() => { if (!pendingFocus.current) return; if (!sorted.some((b) => b.id === pendingFocus.current)) return; const t = setTimeout(() => { if (scrollAndFlash(pendingFocus.current as string)) { setFocusTarget(pendingFocus.current); pendingFocus.current = null; } }, 40); return () => clearTimeout(t); }, [sorted, scrollAndFlash]); // Slash-menu command: insert a sibling block after the one the menu was // opened in. Plain types go straight to the API; media/database open the // insert dialog; "template" opens the template picker. const onSlashPick = useCallback( async (command: SlashCommand, context: { parentId: string | null; position: string }) => { // The slash menu is opened INSIDE a block: resolve that block (the // anchor) and hand the hook its ID — the API's resolvePosition owns the // fractional position (client-side position math threw on server // base-62 positions). If the anchor is gone (stale sort), fall back to // an append at the end of the same parent. const anchor = sorted.find( (b) => b.position === context.position && b.parentId === context.parentId, ); if (command.blockType) { try { const created = await insertBlock.mutateAsync({ parentId: context.parentId, afterBlockId: anchor?.id ?? null, type: command.blockType, }); focusNewBlock(created.id); } catch (e) { toast("error", "Insert failed", e instanceof Error ? e.message : undefined); } return; } if (command.special === "image") { // One-step image insert: open the insert dialog's Image tab (file // picker → presigned upload → image block with the signed URL). The // anchor is the block the menu was opened in. if (anchor) setInsert({ block: anchor, tab: "image" }); } else if (command.special === "table") { // Open the insert dialog's Table tab (rows × cols → grid pre-created). // A bare table block has no columns/rows — the renderer draws nothing. if (anchor) setInsert({ block: anchor, tab: "table" }); } else if (command.special === "database") { // The dialog needs the concrete anchor block (its id/parentId). if (anchor) setInsert({ block: anchor, tab: "database" }); } else if (command.special === "template") { setTemplateOpen(true); } else if (command.special === "synced") { // Synced blocks reference a target page/block; MVP inserts an empty // synced block the user links afterwards (targetPageId property). try { await insertBlock.mutateAsync({ parentId: context.parentId, afterBlockId: anchor?.id ?? null, type: "synced_block", properties: { targetPageId: null, targetBlockId: null }, }); } catch (e) { toast("error", "Insert failed", e instanceof Error ? e.message : undefined); } } }, [insertBlock, sorted, focusNewBlock], ); const createSibling = useCallback( (afterBlock: Block | null) => { // Anchor by block ID (the server computes the position); null anchor → // append after the last top-level sibling. void insertBlock .mutateAsync({ parentId: null, afterBlockId: afterBlock?.id ?? null, type: "paragraph", }) .then((created) => focusNewBlock(created.id)); }, [insertBlock, focusNewBlock], ); /** Focus a block row's editable region (Tiptap contenteditable, the todo * checkbox, a button or a link) — the shared caret mover for * ArrowUp/Down navigation and the blank-area click (see * `onTrailingAreaClick`). */ const focusBlockEditable = useCallback((blockId: string) => { const el = pageRef.current?.querySelector( `[data-block-id="${blockId}"] [contenteditable], [data-block-id="${blockId}"] input[type="checkbox"], [data-block-id="${blockId}"] button, [data-block-id="${blockId}"] a`, ); el?.focus(); }, []); /** TASK-0136 — the trailing blank area. Notion semantics: the area is a * "keep typing" surface, not a "add a row" button. * * - The page's LAST block is an EMPTY text block (paragraph / heading / * list / quote / callout / todo — NOT the title, NOT media/structural) * → focus THAT block. This is what stops the reported pile-up: * clicking the blank area with nothing typed reuses the empty row * instead of stacking another one behind it. * - Otherwise (last block has content, is media/structural, or the page * is empty) → create + focus a fresh paragraph, exactly as before. */ const onTrailingAreaClick = useCallback(() => { const last = sortedRef.current[sortedRef.current.length - 1] ?? null; if (last && isEmptyTextBlock(last)) { focusBlockEditable(last.id); return; } createSibling(last); }, [createSibling, focusBlockEditable]); const focusSibling = useCallback( (from: Block, dir: -1 | 1) => { const idx = sorted.findIndex((b) => b.id === from.id); const next = sorted[idx + dir]; if (next) { focusBlockEditable(next.id); } else if (dir === 1) { createSibling(from); } }, [sorted, createSibling, focusBlockEditable], ); /** TASK-0136 — empty-block cleanup (QA "blank-page input fields keep * piling up"): a text block (paragraph / heading / list / quote / * callout / todo) that LOSES FOCUS while still empty (nothing was * typed) is deleted, so the input field disappears instead of leaving a * blank row behind. * * Two guards, two sources of truth: * - TYPE + TITLE come from the stored block: `isAutoCleanableType` * (text types only — never media/structural) and `isTitle` (the * page-title block is protected). * - EMPTINESS comes from the editor's LIVE html (the blur argument): * the stored content lags behind the 500 ms debounce window and is * the empty `{}` default on a freshly created block, so it cannot * answer "did the user type anything and then clear it?" — the live * html can. If the live html has real content the block stays. * * Deleting the last content block is fine: a page with only its title * is the intended empty state, and the trailing area stays clickable. */ const handleEmptyBlur = useCallback( (block: Block, html: string) => { if (block.properties?.isTitle === true) return; if (!isAutoCleanableType(block.type)) return; // Never clean up the anchor of an OPEN insert dialog (table/image/ // database/link): it is the dialog's insertion point (`afterBlockId`). // The slash pick that opened the dialog cleared this block, so it is // empty and would blur-delete the moment focus moves into the dialog — // leaving the dialog's insertBlock({afterBlockId}) pointing at a // deleted id (404, dialog never closes). Leave the block while it is // the anchor (matches pre-TASK-0136 behavior for the dialog flow). if (insert && insert.block.id === block.id) return; const live = typeof html === "string" ? html : ""; if (live !== "" && !isContentEmpty(live)) return; // The blur save (saveNow) may have raced ahead of this cleanup, or a // queued 500 ms batch may still carry this block's content — drop the // pending ops so the flush never `update`s an id we are deleting // (the batch endpoint 400s on a missing id and aborts the whole batch). getBlockBatchQueue(pageId).dropBlock(block.id); deleteBlock.mutate(block.id, { onError: (e: Error) => toast("error", "Delete failed", e.message), }); }, [pageId, deleteBlock, insert], ); const onDragEnd = (event: DragEndEvent) => { const { active, over } = event; if (!over || active.id === over.id) return; const oldIndex = sorted.findIndex((b) => b.id === active.id); const newIndex = sorted.findIndex((b) => b.id === over.id); if (oldIndex < 0 || newIndex < 0) return; const moved = arrayMove(sorted, oldIndex, newIndex); const block = moved[newIndex]; const prev = moved[newIndex - 1]; const next = moved[newIndex + 1]; if (!block) return; // TASK-0135: delegate the position to the SERVER. The web's base-36 shared // engine rejected the API's base-62 positions (uppercase, e.g. "Zz") and // `positionForInsert` threw on every reorder of an API-created page — DnD // was 100% broken. The server resolves the position from the neighbor // block ids with its own canonical base-62 engine (blocks.service // `resolvePosition`). Server semantics: `afterBlockId` = insert AFTER that // block (it sits ABOVE the new slot), `beforeBlockId` = insert BEFORE it // (it sits BELOW the new slot) — verified against the live endpoint. moveBlock.mutate( { id: block.id, parentId: null, afterBlockId: prev?.id ?? null, beforeBlockId: next?.id ?? null, }, { onError: (e: Error) => toast("error", "Move failed", e.message) }, ); }; const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), // TASK-0100 S5: `sortableKeyboardCoordinates` is a keyboard // coordinate-GETTER, not a sensor — passing it bare to useSensor made // `Sensor.activators` undefined and crashed DndContext ("reading 'map'"). useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), ); // Delete key removes a selected block that is not currently being edited. const onKeyDown = (e: React.KeyboardEvent) => { if (e.key !== "Delete" || !selectedBlockId) return; const target = e.target as HTMLElement; if (target.isContentEditable || target.tagName === "INPUT" || target.tagName === "TEXTAREA") return; e.preventDefault(); deleteBlock.mutate(selectedBlockId, { onSuccess: () => setSelection(null), }); }; // Numbered runs: consecutive `numbered` blocks share a 1-based counter. const numberedIndices = useMemo(() => { const map = new Map(); let count = 0; for (const b of sorted) { if (b.type === "numbered_list") { count += 1; map.set(b.id, count); } else { count = 0; } } return map; }, [sorted]); return (
{ if (e.target === e.currentTarget) { setSelection(null); setManySelected([]); } }} onKeyDown={onKeyDown} onDragEnter={onDragEnter} onDragOver={onDragOver} onDragLeave={onDragLeave} onDrop={onFilesDropped} data-file-drop={fileDragActive ? "active" : undefined} > {/* TASK-0103 (a): the drag-drop indicator — a full-canvas overlay while an OS file drag is over the page (the block list is untouched, so dnd-kit's block-reorder drag is unaffected). */} {fileDragActive ? (
Drop to upload
) : null} {/* T-163: the review workflow banner (state chip + per-role actions; rendered for every state — including "No review yet" on fresh pages, which is where "Request review" starts). */} {page ? : null} {/* Header */}
{/* Cover band (TASK-0038, T-192): the visible `page-cover` — the selected gradient, above the icon/title row. */} {page?.cover ? (
) : null}
{/* Icon (when set) + the icon-picker button; the cover-picker button sits beside them. Both persist via PATCH /pages/:id (`icon`, `coverUrl` — see useUpdatePage's field mapping). */}
{page?.icon ? ( {page.icon} ) : null}
{iconPickerOpen && page ? ( { setIconPickerOpen(false); updatePage.mutate({ id: page.id, icon }); }} /> ) : null}
{coverPickerOpen && page ? ( { setCoverPickerOpen(false); updatePage.mutate({ id: page.id, cover }); }} /> ) : null}
{/* `page-title` (tests/README): an editable TEXT surface — the E2E asserts toContainText, so the title is text content, not an input value. Commits on input (same as the old input's onChange) + on blur; Enter keeps the caret (no form submit). */}
{ // Debounced commit (see titleCommitTimer): schedule a flush // 600ms after the last keystroke; blur/Enter/Escape flush // immediately. Never fires N concurrent PATCHes per title. if (titleCommitTimer.current) clearTimeout(titleCommitTimer.current); titleCommitTimer.current = setTimeout(() => commitTitleNow(), 600); }} onKeyDown={(e) => { if (e.key === "Enter") { // Single-line title (the old never took a newline). // Flush the pending commit BEFORE blur (blur fires second // and finds nothing pending). commitTitleNow(); e.preventDefault(); (e.target as HTMLDivElement).blur(); } else if (e.key === "Escape") { // Flush too — the E2E convention commits on Escape (and a // user pressing Escape has finished typing the title). commitTitleNow(); (e.target as HTMLDivElement).blur(); } }} onBlur={() => { commitTitleNow(); }} 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" data-placeholder="Untitled" />
{page ? ( <> Updated {formatRelativeTime(page.updatedAt)} {page.createdByUser ? · by {page.createdByUser.name} : null} ) : null}
{/* Presence (TASK-0093): the OTHERS' avatar bar — absent at 0 others (the empty state). The live region (below) is the SR channel; the bar is a plain list with per-avatar names. */} {/* TASK-0094 R2 — the comment button + the realtime "New comments (n)" pill (bumped by comment.event frames while the panel is closed; cleared when it opens). */} {/* T-140 — Related: the pages that reference this one (backlinks). Mirrors the Comments button; opens a read-only slide-over. */} {/* TASK-0103 (4): the page file picker — insert an existing file as a block at the cursor (sidebar "Files" for the active page). */} {/* TASK-0122 — Ask AI: opens the panel bound to this page (and the selected block, when one is highlighted). */} {/* TASK-0126 — Generate: opens the page-generation dialog (nested under this page when one is open). Hidden for GUEST (EDIT gate). */} {canGenerate ? ( ) : null}
{filesPickerOpen && page?.workspaceId ? ( setFilesPickerOpen(false)} /> ) : null} {/* Presence (TASK-0093 R5): the polite live region — SR announcements for join/edit-start/edit-stop/leave, ≤ 1/s, state changes only. Visually hidden (the avatars + toasts are the visual channel). */}

{announcement}

{/* Blocks */} b.id)} strategy={verticalListSortingStrategy}>
{renderBlocks.map((block) => ( selectRow(id, mods)} onContextMenu={(x, y, b) => setMenu({ x, y, block: b })} onWritingAction={(action, blockIds, language) => openWriting(action, blockIds, language)} onEnterKey={createSibling} onNavigate={(b, dir) => focusSibling(b, dir)} onSlashPick={(cmd, ctx) => void onSlashPick(cmd, ctx)} onEmptyBlur={(b, html) => handleEmptyBlur(b, html)} /> ))} {/* Presence (TASK-0093 R4): the remote cursors + name labels + selection highlights, overlaid on the block list (the layer is an absolute, pointer-events-none, decorative overlay). */}
{/* TASK-0126 — empty-state "Describe a page…" card (the Home page with no blocks; the workspace root redirects here on a fresh workspace). Opens the page-generation dialog. Hidden for GUEST (EDIT gate). */} {contentBlockCount === 0 && canGenerate ? ( ) : null} {/* "Add a sub-page" (tests/README.md `subpage-create`, T-192): the in-page way to nest a child page (the sidebar tree row has the same action on hover). Reuses the sidebar's `factory:new-page` event — the handler creates the child + navigates to it, and its invalidation refreshes the sidebar tree (listener stays mounted even while the sidebar is collapsed). Hidden for GUEST (no EDIT). */} {canGenerate ? ( ) : null} {/* Trailing empty area — TASK-0136: focus the last block when it is already an empty text block (reuse, no pile-up), otherwise create + focus a new paragraph (see onTrailingAreaClick). */}
{/* Right-click context menu */} {menu ? ( setMenu(null)} onWritingAction={page?.workspaceId ? (action, blockIds, language) => openWriting(action, blockIds, language) : undefined} canGenerate={canGenerate} /> ) : null} {/* TASK-0123 — the floating multi-selection toolbar (the writing tools' entry point) + the suggestion dialog (accept-diff). */} {selectedBlocks.length > 0 && page?.workspaceId ? ( openWriting(action, selectedBlocks.map((b) => b.id), language)} /> ) : null} {writing.open && page?.workspaceId ? ( setWriting((w) => ({ ...w, open: false }))} /> ) : null} {/* TASK-0094 R2 — the comment drawer (the Phase-3 panel, now live). */} setCommentsOpen(false)} /> {/* T-140 — the "Related" (backlinks) slide-over. The query lives in useBacklinks (30 s staleTime); the panel is a pure presenter. */} setRelatedOpen(false)} refs={backlinks.data ?? []} isLoading={backlinks.isLoading} isError={backlinks.isError} /> {/* Slash-command insert flows */} {insert ? ( setInsert(null)} /> ) : null} {templateOpen ? ( setTemplateOpen(false)} onApply={async (templateId, title) => { // Apply duplicates the template into this workspace; then focus // the new page's first block for immediate editing. const created = await templateApi.apply(templateId, { workspaceId: page?.workspaceId ?? "", parentId: null }); setTemplateOpen(false); useEditorStore.getState().setSelection(null); toast("success", "Template inserted", title); void created; }} /> ) : null}
); } /** dnd-kit sortable wrapper: selection ring, hover toolbar, the block body. */ function SortableBlockRow({ block, selected, multiSelected, focusTarget, numberedIndex, onSelect, onContextMenu, onEnterKey, onNavigate, onSlashPick, onWritingAction, onEmptyBlur, }: { block: Block; selected: boolean; /** TASK-0123 — part of the multi-selection (a distinct ring, so a ⌘+click * selection is visible even when another row is the single highlight). */ multiSelected?: boolean; focusTarget: string | null; numberedIndex?: number; /** The row's selection click, carrying the modifier keys (multi-select). */ onSelect: (id: string, mods: { metaKey: boolean; ctrlKey: boolean; shiftKey: boolean }) => void; onContextMenu: (x: number, y: number, block: Block) => void; onEnterKey: (block: Block) => void; onNavigate: (block: Block, dir: -1 | 1) => void; onSlashPick: ( command: SlashCommand, context: { parentId: string | null; position: string }, ) => void; onWritingAction?: (action: WritingAction, blockIds: string[], language?: string) => void; /** TASK-0136 — empty-block cleanup: called with the block's FINAL html on * blur so the page can delete it if it is still empty (nothing typed). */ onEmptyBlur?: (block: Block, html: string) => void; }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: block.id, data: { type: "block", blockId: block.id }, }); // TASK-0123: capture the modifier keys at mousedown (the inner block's // `onSelect` fires without an event, so the row must remember the press). const modsRef = useRef({ metaKey: false, ctrlKey: false, shiftKey: false }); return (
{ modsRef.current = { metaKey: e.metaKey, ctrlKey: e.ctrlKey, shiftKey: e.shiftKey }; }} onClick={(e) => { if (e.target === e.currentTarget) onSelect(block.id, modsRef.current); }} onContextMenu={(e) => { e.preventDefault(); onContextMenu(e.clientX, e.clientY, block); }} > {/* Block toolbar gutter. Desktop (≥768px): the LEFT gutter, revealed on row hover (the `group-hover` utilities). Mobile (<768px): the RIGHT gutter, ALWAYS visible (docs/product/ux-requirements.md — "single-column everything; editor: full-width blocks, ⋮⋮ always visible in the right gutter"; the hover state does not exist for touch). The element is a single toolbar repositioned per breakpoint (one `data-testid="drag-handle"`, so the e2e helper's `.first()` is stable on both). The turn-into/more actions are additionally reachable on mobile via the block context menu. `z-30` = the "block toolbar" level of the ux z-index tokens (content 0 → block toolbar 30): on mobile the toolbar overlaps the row's own content, and the row content comes later in the DOM, so without the z-level the paragraph would paint over the grip and intercept pointer events at the handle. */}
onWritingAction?.(action, blockIds, language)} />
onEnterKey(block)} onNavigate={(dir) => onNavigate(block, dir)} onSelect={() => onSelect(block.id, modsRef.current)} onSlashPick={onSlashPick} // TASK-0136 — forward the empty-block cleanup down to the renderer; // without this the row's `onEmptyBlur` prop is dropped and an empty // text block that loses focus is never deleted (dead feature). onEmptyBlur={onEmptyBlur} />
); } /* ------------------------------------------------------------------------ * TASK-0038 icon + cover pickers (T-192) — the minimal emoji / solid- * gradient pickers the E2E contract (`icon-picker`, `cover-picker`, * `cover-color-{i}`, `page-icon`, `page-cover`) requires. Both write back * through `updatePage.mutate` (icon / cover → PATCH /pages/:id coverUrl). * ---------------------------------------------------------------------- */ /** Cover tokens (T-192). The cover is persisted as a STRING that the API's * `UpdatePageDto.coverUrl` validates with Zod `.url().max(2048)` — a raw CSS * gradient string is NOT a URL and 400s on PATCH (recorded conflict, T-192). * So each token is a data-URI SVG (Zod accepts `data:` URIs) built from the * same color stops, which renders identically in the picker swatch and the * `page-cover` band (exact round-trip, no mapping drift). `btoa` is used * (browser-safe; the SVG is pure ASCII) rather than Node's `Buffer`. */ const COVER_STOPS: [string, string][] = [ ["#6366f1", "#8b5cf6"], // 0 indigo ["#f43f5e", "#f97316"], // 1 rose→orange ["#10b981", "#14b8a6"], // 2 emerald ["#0ea5e9", "#6366f1"], // 3 sky→indigo ["#f59e0b", "#f43f5e"], // 4 amber→rose ["#1e293b", "#475569"] // 5 slate ]; const coverToken = ([from, to]: [string, string]): string => { const svg = `` + `` + `` + ``; return `data:image/svg+xml;base64,${btoa(svg)}`; }; const COVER_TOKENS: string[] = COVER_STOPS.map(coverToken); function coverBackground(cover: string): string { // A stored token (our own data-URI SVG) renders as a background image; // anything else (a future real image URL / free text) degrades to a neutral // block so the `page-cover` band is still visible + styled. return COVER_TOKENS.includes(cover) ? `url("${cover}") center / cover no-repeat` : "linear-gradient(135deg, #64748b, #334155)"; } const ICON_EMOJI: string[] = [ "🚀", "📄", "📌", "⭐", "💡", "🎯", "🔥", "✅", "📊", "🗓️", "🔒", "🌱", "☁️", "🧠", "🛠️", "🎉" ]; /** Emoji picker popover (anchored to the icon button). */ function IconPicker({ onPick }: { onPick: (icon: string) => void }) { return (
{ICON_EMOJI.map((e) => ( ))}
); } /** Solid-gradient cover picker popover (anchored to the cover button). */ function CoverPicker({ onPick }: { onPick: (cover: string) => void }) { return (
{COVER_TOKENS.map((g, i) => (
); }