1"use client";
2
3
11import { useEffect, useRef, useState, type CSSProperties, type ComponentType } from "react";
12import {
13 Clipboard,
14 ClipboardPaste,
15 Copy,
16 History,
17 Plus,
18 Scissors,
19 Sparkles,
20 Trash2,
21 Type as TypeIcon
22} from "lucide-react";
23import { blockApi, type BatchBlockOp } from "@/lib/api";
24import {
25 CLIPBOARD_APP_MARKER,
26 copySubtreeToClipboardPayload,
27 parseClipboardText
28} from "@/lib/clipboard-import";
29import { cn } from "@/lib/utils";
30import { useUiStore } from "@/stores/ui-store";
31import { AskAiSubmenu } from "@/components/ai/ask-ai-submenu";
32import { TypeMenu } from "./type-menu";
33import type { WritingAction } from "@/lib/ai";
34import type { Block, PageVersion } from "@/lib/types";
35
36export interface ContextMenuProps {
37 x: number;
38 y: number;
39 block: Block;
40 pageId: string;
41 onClose: () => void;
42
45 onWritingAction?: (action: WritingAction, blockIds: string[], language?: string) => void;
46
49 canGenerate?: boolean;
50}
51
52export function ContextMenu({ x, y, block, pageId, onClose, onWritingAction, canGenerate = true }: ContextMenuProps) {
53 const ref = useRef<HTMLDivElement>(null);
54 const [typeOpen, setTypeOpen] = useState(false);
55 const [historyOpen, setHistoryOpen] = useState(false);
56 const [askAiOpen, setAskAiOpen] = useState(false);
57 const setAiPanel = useUiStore((s) => s.setAiPanel);
58 const setGenerate = useUiStore((s) => s.setGenerate);
59 const setAssistant = useUiStore((s) => s.setAssistant);
60
61 useEffect(() => {
62 const onKey = (e: KeyboardEvent) => {
63 if (e.key === "Escape") onClose();
64 };
65 const onClick = (e: MouseEvent) => {
66 if (ref.current && !ref.current.contains(e.target as Node)) onClose();
67 };
68 document.addEventListener("keydown", onKey);
69 document.addEventListener("mousedown", onClick);
70 return () => {
71 document.removeEventListener("keydown", onKey);
72 document.removeEventListener("mousedown", onClick);
73 };
74 }, [onClose]);
75
76
77 const style: CSSProperties = {
78 top: Math.min(y, (typeof window !== "undefined" ? window.innerHeight : 800) - 320),
79 left: Math.min(x, (typeof window !== "undefined" ? window.innerWidth : 1200) - 240)
80 };
81
82
83
84
85 const copy = async () => {
86 let payload = "";
87 try {
88 const all = await blockApi.list(pageId, { parentId: null, depth: "all" });
89 const specs = copySubtreeToClipboardPayload(block.id, all);
90 payload = JSON.stringify({ app: CLIPBOARD_APP_MARKER, v: 1, blocks: specs });
91 } catch {
92 payload = JSON.stringify({ app: CLIPBOARD_APP_MARKER, v: 1, blocks: [{ id: "blk_anonymous", type: block.type, content: block.content, parentId: null }] });
93 }
94 await navigator.clipboard.writeText(payload).catch(() => {});
95 onClose();
96 };
97
98 const cut = async () => {
99 await copy();
100 await blockApi.remove(block.id);
101 onClose();
102 };
103
104 const paste = async () => {
105
106
107
108
109 const text = await navigator.clipboard.readText().catch(() => "");
110 const specs = parseClipboardText(text);
111 const ops: BatchBlockOp[] = [];
112 if (specs) {
113
114
115 let prevId = block.id;
116 for (const s of specs) {
117 ops.push({
118 op: "upsert",
119 block: {
120 id: s.id,
121 type: s.type,
122 content: s.content,
123 properties: s.properties ?? {},
124 parentId: s.parentId ?? null,
125 afterBlockId: prevId
126 }
127 });
128 prevId = s.id;
129 }
130 } else if (text.trim()) {
131
132 ops.push({
133 op: "upsert",
134 block: { type: "paragraph", content: text, properties: {}, parentId: block.parentId, afterBlockId: block.id }
135 });
136 }
137 if (ops.length > 0) {
138 for (let i = 0; i < ops.length; i += 200) {
139 await blockApi.batch(pageId, ops.slice(i, i + 200));
140 }
141 }
142 onClose();
143 };
144
145 const duplicate = async () => {
146 await blockApi.duplicate(block.id, { afterBlockId: block.id });
147 onClose();
148 };
149
150 const addSibling = (dir: -1 | 1) => {
151
152
153
154 void blockApi
155 .insert({
156 pageId,
157 parentId: block.parentId,
158 type: "paragraph",
159 ...(dir === 1 ? { afterBlockId: block.id } : { beforeBlockId: block.id })
160 })
161 .then(onClose);
162 };
163
164 const remove = async () => {
165 await blockApi.remove(block.id);
166 onClose();
167 };
168
169 const items: {
170 label: string;
171 icon: ComponentType<{ className?: string }>;
172 run?: () => void | Promise<void>;
173
174 submenu?: "ask-ai";
175 destructive?: boolean;
176
178 testId?: string;
179 }[] = [
180 { label: "Ask AI", icon: Sparkles, submenu: "ask-ai" },
181 {
182 label: "Generate page with AI",
183 icon: Sparkles,
184 run: () => {
185 setGenerate(true, { parentId: pageId });
186 onClose();
187 }
188 },
189 { label: "Copy", icon: Copy, run: copy },
190 { label: "Cut", icon: Scissors, run: cut },
191 { label: "Paste below", icon: ClipboardPaste, run: paste },
192 { label: "Duplicate", icon: Clipboard, run: duplicate },
193 { label: "Add block above", icon: Plus, run: () => addSibling(-1) },
194 { label: "Add block below", icon: Plus, run: () => addSibling(1) },
195 { label: "History…", icon: History, run: () => setHistoryOpen(true) },
196 { label: "Delete", icon: Trash2, run: remove, destructive: true, testId: "context-menu-delete" }
197 ];
198
199 const visibleItems = canGenerate ? items : items.filter((i) => i.label !== "Generate page with AI");
200
201 return (
202 <div ref={ref} role="menu" aria-label="Block menu" data-testid="block-context-menu" className="fixed z-[75] w-56 rounded-md border border-border bg-popover p-1 shadow-xl" style={style}>
203 {visibleItems.map((item) =>
204 item.submenu === "ask-ai" ? (
205 <div key={item.label} className="relative">
206 <button
207 type="button"
208 role="menuitem"
209 onClick={() => setAskAiOpen((v) => !v)}
210 className={cn(
211 "flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm hover:bg-accent",
212 askAiOpen && "bg-accent"
213 )}
214 aria-haspopup="menu"
215 aria-expanded={askAiOpen}
216 >
217 <item.icon className="size-4 shrink-0 text-muted-foreground" aria-hidden />
218 {item.label}
219 </button>
220 {askAiOpen ? (
221 <div
222 className="absolute left-full top-0 ml-1 w-64 rounded-md border border-border bg-popover p-1 shadow-xl"
223 role="menu"
224 aria-label="Ask AI"
225 >
226 <AskAiSubmenu
227 block={block}
228 onChat={() => {
229 setAskAiOpen(false);
230 setAiPanel(true, { pageId, blockId: block.id });
231 onClose();
232 }}
233 onWritingAction={onWritingAction}
234 />
235 </div>
236 ) : null}
237 </div>
238 ) : (
239 <button
240 key={item.label}
241 type="button"
242 role="menuitem"
243 data-testid={item.testId}
244 onClick={() => void item.run?.()}
245 className={cn(
246 "flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm hover:bg-accent",
247 item.destructive && "text-red-600"
248 )}
249 >
250 <item.icon className="size-4 shrink-0 text-muted-foreground" aria-hidden />
251 {item.label}
252 </button>
253 )
254 )}
255
256 <div className="relative">
257 <button
258 type="button"
259 role="menuitem"
260 onClick={() => setTypeOpen((v) => !v)}
261 className={cn("flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm hover:bg-accent", typeOpen && "bg-accent")}
262 aria-haspopup="menu"
263 aria-expanded={typeOpen}
264
265
266 data-testid="context-menu-turn-into"
267 >
268 <TypeIcon className="size-4 shrink-0 text-muted-foreground" aria-hidden />
269 Turn into…
270 </button>
271 {typeOpen ? (
272 <div className="absolute left-full top-0 ml-1 w-64">
273 <TypeMenu
274 blockType={block.type}
275 onPick={(type) => {
276 setTypeOpen(false);
277 void blockApi.update(block.id, { type }).then(onClose);
278 }}
279 onOpenChange={setTypeOpen}
280 />
281 </div>
282 ) : null}
283 </div>
284
285 {historyOpen ? (
286 <HistoryMenu pageId={pageId} onClose={() => { setHistoryOpen(false); onClose(); }} />
287 ) : null}
288 </div>
289 );
290}
291
292
293function HistoryMenu({ pageId, onClose }: { pageId: string; onClose: () => void }) {
294 const [versions, setVersions] = useState<PageVersion[]>([]);
295 const [loading, setLoading] = useState(true);
296
297 useEffect(() => {
298 import("@/lib/api")
299 .then(({ pageApi }) => pageApi.versions(pageId))
300 .then((v) => setVersions(v.slice(0, 5)))
301 .catch(() => setVersions([]))
302 .finally(() => setLoading(false));
303 }, [pageId]);
304
305 const restore = async (versionId: string) => {
306 try {
307 const { pageApi } = await import("@/lib/api");
308 await pageApi.restoreVersion(pageId, versionId);
309 onClose();
310 } catch {
311
312 }
313 };
314
315 return (
316 <div className="absolute left-full top-8 ml-1 w-64 rounded-md border border-border bg-popover p-1 shadow-xl" role="menu" aria-label="History">
317 <p className="px-2 py-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
318 Restore a version of this page
319 </p>
320 {loading ? (
321 <p className="px-2 py-1.5 text-sm text-muted-foreground">Loading…</p>
322 ) : versions.length === 0 ? (
323 <p className="px-2 py-1.5 text-sm text-muted-foreground">No saved versions yet.</p>
324 ) : (
325 versions.map((v) => (
326 <button
327 key={v.id}
328 type="button"
329 role="menuitem"
330 onClick={() => void restore(v.id)}
331 className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-accent"
332 >
333 <span className="block truncate font-medium">v{v.version}{v.note ? ` — ${v.note}` : ""}</span>
334 <span className="block text-xs text-muted-foreground">
335 {new Date(v.createdAt).toLocaleString()}
336 </span>
337 </button>
338 ))
339 )}
340 </div>
341 );
342}
343