"use client"; /** * Right-click context menu for a block: copy/cut/paste (clipboard via * @factory/editor serialization), duplicate, turn into (inline sub-grid), * add above/below, history (restore version — page level), delete. * * Clipboard format: the canonical block JSON produced by @factory/editor's * serializer, wrapped so the paste handler can detect our payload. */ import { useEffect, useRef, useState, type CSSProperties, type ComponentType } from "react"; import { Clipboard, ClipboardPaste, Copy, History, Plus, Scissors, Sparkles, Trash2, Type as TypeIcon } from "lucide-react"; import { blockApi, type BatchBlockOp } from "@/lib/api"; import { CLIPBOARD_APP_MARKER, copySubtreeToClipboardPayload, parseClipboardText } from "@/lib/clipboard-import"; import { cn } from "@/lib/utils"; import { useUiStore } from "@/stores/ui-store"; import { AskAiSubmenu } from "@/components/ai/ask-ai-submenu"; import { TypeMenu } from "./type-menu"; import type { WritingAction } from "@/lib/ai"; import type { Block, PageVersion } from "@/lib/types"; export interface ContextMenuProps { x: number; y: number; block: Block; pageId: string; onClose: () => void; /** TASK-0123 — a writing action picked from the "Ask AI" submenu (the * parent opens the WritingDialog for this single block). Omitted = the * submenu offers Chat only (back-compat for other callers). */ onWritingAction?: (action: WritingAction, blockIds: string[], language?: string) => void; /** TASK-0126 — whether the caller may generate pages (EDIT). When false the * "Generate page with AI" item is hidden (the server still enforces 403). * Defaults to true for back-compat with non-page callers. */ canGenerate?: boolean; } export function ContextMenu({ x, y, block, pageId, onClose, onWritingAction, canGenerate = true }: ContextMenuProps) { const ref = useRef(null); const [typeOpen, setTypeOpen] = useState(false); const [historyOpen, setHistoryOpen] = useState(false); const [askAiOpen, setAskAiOpen] = useState(false); const setAiPanel = useUiStore((s) => s.setAiPanel); const setGenerate = useUiStore((s) => s.setGenerate); const setAssistant = useUiStore((s) => s.setAssistant); useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; const onClick = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) onClose(); }; document.addEventListener("keydown", onKey); document.addEventListener("mousedown", onClick); return () => { document.removeEventListener("keydown", onKey); document.removeEventListener("mousedown", onClick); }; }, [onClose]); // Keep the menu on-screen. const style: CSSProperties = { top: Math.min(y, (typeof window !== "undefined" ? window.innerHeight : 800) - 320), left: Math.min(x, (typeof window !== "undefined" ? window.innerWidth : 1200) - 240) }; // Copy = the block's WHOLE subtree (a toggle copies its children; the // payload carries fresh ids + parent links so a paste re-creates the // nesting). text/plain keeps interop with plain-text readers. const copy = async () => { let payload = ""; try { const all = await blockApi.list(pageId, { parentId: null, depth: "all" }); const specs = copySubtreeToClipboardPayload(block.id, all); payload = JSON.stringify({ app: CLIPBOARD_APP_MARKER, v: 1, blocks: specs }); } catch { payload = JSON.stringify({ app: CLIPBOARD_APP_MARKER, v: 1, blocks: [{ id: "blk_anonymous", type: block.type, content: block.content, parentId: null }] }); } await navigator.clipboard.writeText(payload).catch(() => {}); onClose(); }; const cut = async () => { await copy(); await blockApi.remove(block.id); onClose(); }; const paste = async () => { // "Paste below" inserts the clipboard payload BELOW this block. The // window-level paste handler owns Ctrl+V (rich text / HTML / multi- // block); this menu item is the explicit placement path: read the // internal payload and upsert it as a batch anchored after `block`. const text = await navigator.clipboard.readText().catch(() => ""); const specs = parseClipboardText(text); const ops: BatchBlockOp[] = []; if (specs) { // Chain: the first pasted block lands BELOW this block, each next one // after its predecessor (the server orders them in the tx). let prevId = block.id; for (const s of specs) { ops.push({ op: "upsert", block: { id: s.id, type: s.type, content: s.content, properties: s.properties ?? {}, parentId: s.parentId ?? null, afterBlockId: prevId } }); prevId = s.id; } } else if (text.trim()) { // Plain text (not our payload): one paragraph below this block. ops.push({ op: "upsert", block: { type: "paragraph", content: text, properties: {}, parentId: block.parentId, afterBlockId: block.id } }); } if (ops.length > 0) { for (let i = 0; i < ops.length; i += 200) { await blockApi.batch(pageId, ops.slice(i, i + 200)); } } onClose(); }; const duplicate = async () => { await blockApi.duplicate(block.id, { afterBlockId: block.id }); onClose(); }; const addSibling = (dir: -1 | 1) => { // Id-anchors only: the server computes the fractional position (client // base-36 math THREW on the API's base-62 positions — the "unreeled // errors" behind every context-menu insert). void blockApi .insert({ pageId, parentId: block.parentId, type: "paragraph", ...(dir === 1 ? { afterBlockId: block.id } : { beforeBlockId: block.id }) }) .then(onClose); }; const remove = async () => { await blockApi.remove(block.id); onClose(); }; const items: { label: string; icon: ComponentType<{ className?: string }>; run?: () => void | Promise; /** The "Ask AI" submenu — opens a side panel instead of running. */ submenu?: "ask-ai"; destructive?: boolean; /** E2E contract id (T-194) — the menu renders text labels, which the tests/README selector convention bans specs from relying on. */ testId?: string; }[] = [ { label: "Ask AI", icon: Sparkles, submenu: "ask-ai" }, { label: "Generate page with AI", icon: Sparkles, run: () => { setGenerate(true, { parentId: pageId }); onClose(); } }, { label: "Copy", icon: Copy, run: copy }, { label: "Cut", icon: Scissors, run: cut }, { label: "Paste below", icon: ClipboardPaste, run: paste }, { label: "Duplicate", icon: Clipboard, run: duplicate }, { label: "Add block above", icon: Plus, run: () => addSibling(-1) }, { label: "Add block below", icon: Plus, run: () => addSibling(1) }, { label: "History…", icon: History, run: () => setHistoryOpen(true) }, { label: "Delete", icon: Trash2, run: remove, destructive: true, testId: "context-menu-delete" } ]; // TASK-0126 — hide the generate item for GUEST (server still enforces 403). const visibleItems = canGenerate ? items : items.filter((i) => i.label !== "Generate page with AI"); return (
{visibleItems.map((item) => item.submenu === "ask-ai" ? (
{askAiOpen ? (
{ setAskAiOpen(false); setAiPanel(true, { pageId, blockId: block.id }); onClose(); }} onWritingAction={onWritingAction} />
) : null}
) : ( ) )}
{typeOpen ? (
{ setTypeOpen(false); void blockApi.update(block.id, { type }).then(onClose); }} onOpenChange={setTypeOpen} />
) : null}
{historyOpen ? ( { setHistoryOpen(false); onClose(); }} /> ) : null}
); } /** Page-version restore sub-menu (versions of the block's page). */ function HistoryMenu({ pageId, onClose }: { pageId: string; onClose: () => void }) { const [versions, setVersions] = useState([]); const [loading, setLoading] = useState(true); useEffect(() => { import("@/lib/api") .then(({ pageApi }) => pageApi.versions(pageId)) .then((v) => setVersions(v.slice(0, 5))) .catch(() => setVersions([])) .finally(() => setLoading(false)); }, [pageId]); const restore = async (versionId: string) => { try { const { pageApi } = await import("@/lib/api"); await pageApi.restoreVersion(pageId, versionId); onClose(); } catch { /* keep the menu open on failure */ } }; return (

Restore a version of this page

{loading ? (

Loading…

) : versions.length === 0 ? (

No saved versions yet.

) : ( versions.map((v) => ( )) )}
); }