1"use client";
2
3
32import { useCallback, useEffect, useMemo, useRef, useState } from "react";
33import { useRouter } from "next/navigation";
34import {
35 addElement,
36 anchorPoint,
37 arrowHead,
38 Canvas,
39 CanvasElement,
40 connectorPath,
41 decimatePoints,
42 displayBounds,
43 emptyCanvas,
44 groupElements,
45 hitTest,
46 moveMany,
47 nearestAnchor,
48 nextZ,
49 OpStack,
50 rectsIntersect,
51 removeElement,
52 screenToWorld,
53 ungroupElement,
54 updateElement,
55 viewportToWorldRect,
56 type Anchor,
57 type ElementLink,
58 type Viewport
59} from "@factory/whiteboard";
60import { whiteboardApi, type WhiteboardCanvasResponse } from "@/lib/api";
61import { usePageTree, usePageVersions } from "@/hooks/use-pages";
62import { useBlockPatchStore } from "@/stores/block-patch-store";
63import { useAuthStore } from "@/lib/auth-store";
64import { useWhiteboardCanvas, useWhiteboardSave, whiteboardKey } from "@/hooks/use-whiteboard";
65import { toast } from "@/stores/ui-store";
66import { CommentPanel } from "@/components/comments/comment-panel";
67import { ConfirmDialog } from "@/components/ui/confirm-dialog";
68import { X, Undo2, Redo2, History, MessageSquare, Link2, FilePlus2 } from "lucide-react";
69import type { Presence } from "@/lib/types";
70
71
72const STICKY_HEX: Record<string, string> = {
73 yellow: "#FEF3C7",
74 blue: "#DBEAFE",
75 green: "#DCFCE7",
76 pink: "#FCE7F3",
77 purple: "#EDE9FE",
78 orange: "#FFEDD5"
79};
80const SELECTION = "#2563EB";
81const MIN_SIZE = 10;
82
83const HISTORY_CAP = 100;
84
85const VERSION_LIMIT = 20;
86
87type Tool = "select" | "pan" | "sticky" | "text" | "rect" | "ellipse" | "connector" | "pen";
88
89interface Preview {
90 kind: "shape" | "connector" | "pen";
91 shape?: "rect" | "ellipse";
92 a: { x: number; y: number };
93 b: { x: number; y: number };
94 points?: number[];
95 fromEl?: { elementId: string; anchor: Anchor };
96}
97
98interface DragState {
99 mode: "pan" | "move" | "resize" | "marquee" | "create-shape" | "connector" | "pen";
100 startWorld: { x: number; y: number };
101
102 ids?: string[];
103
104 shape?: "rect" | "ellipse";
105
106 fromEl?: { elementId: string; anchor: Anchor };
107
108 points?: number[];
109 startScreen: { x: number; y: number };
110 startView: Viewport;
111 moved: boolean;
112}
113
114function newId(prefix: string): string {
115 return `${prefix}_${Math.random().toString(36).slice(2, 12)}`;
116}
117
118function normRect(a: { x: number; y: number }, b: { x: number; y: number }): { x: number; y: number; w: number; h: number } {
119 return { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y), w: Math.abs(b.x - a.x), h: Math.abs(b.y - a.y) };
120}
121
122
123function pointsAttr(points: number[]): string {
124 const out: string[] = [];
125 for (let i = 0; i + 1 < points.length; i += 2) {
126 const x = points[i];
127 const y = points[i + 1];
128 if (x === undefined || y === undefined) break;
129 out.push(`${x},${y}`);
130 }
131 return out.join(" ");
132}
133
134
135function wrapLines(text: string, width: number, fontSize: number, maxLines: number): string[] {
136 const cpl = Math.max(4, Math.floor(width / (fontSize * 0.58)));
137 const out: string[] = [];
138 for (const raw of text.split("\n")) {
139 let line = raw;
140 while (line.length > cpl) {
141 let cut = line.lastIndexOf(" ", cpl);
142 if (cut < cpl * 0.5) cut = cpl;
143 out.push(line.slice(0, cut).trimEnd());
144 line = line.slice(cut).trimStart();
145 }
146 out.push(line);
147 if (out.length >= maxLines) break;
148 }
149 if (out.length >= maxLines && text.length > out.join("").length) out[maxLines - 1] = (out[maxLines - 1] ?? "") + "…";
150 return out.slice(0, maxLines);
151}
152
153
155function Label({ x, y, w, text, fontSize, fill, anchor }: { x: number; y: number; w: number; text: string; fontSize: number; fill: string; anchor: "start" | "middle" }) {
156 const maxLines = Math.max(1, Math.floor(40 / fontSize));
157 const lines = wrapLines(text, w, fontSize, maxLines);
158 return (
159 <text
160 data-testid="wb-label"
161 x={anchor === "middle" ? x + w / 2 : x}
162 y={y}
163 fontSize={fontSize}
164 fill={fill}
165 textAnchor={anchor}
166 style={{ userSelect: "none", fontFamily: "ui-sans-serif, system-ui, sans-serif", pointerEvents: "none" }}
167 >
168 {lines.map((l, i) => (
169 <tspan key={i} x={anchor === "middle" ? x + w / 2 : x} dy={i === 0 ? fontSize * 0.9 : fontSize * 1.25}>
170 {l || " "}
171 </tspan>
172 ))}
173 </text>
174 );
175}
176
177
178function flattenPages(entries: { id: string; title: string; children?: unknown[] }[] | undefined): { id: string; title: string }[] {
179 const out: { id: string; title: string }[] = [];
180 const walk = (list: { id: string; title: string; children?: unknown[] }[] | undefined) => {
181 for (const e of list ?? []) {
182 out.push({ id: e.id, title: e.title });
183 if (Array.isArray(e.children)) walk(e.children as { id: string; title: string; children?: unknown[] }[]);
184 }
185 };
186 walk(entries);
187 return out;
188}
189
190
191function linkHref(link: ElementLink | undefined, workspace: string): string | null {
192 if (!link) return null;
193 if (link.kind === "url" && link.href) return link.href;
194 if ((link.kind === "page" || link.kind === "task") && link.refId) return `/w/${workspace}/page/${link.refId}`;
195 return null;
196}
197
198interface WhiteboardCanvasProps {
199 pageId: string;
200 workspaceId: string;
201
202 readOnly?: boolean;
203
204 canvasValid?: boolean;
205}
206
207export function WhiteboardCanvas({ pageId, workspaceId, readOnly = false, canvasValid = true }: WhiteboardCanvasProps) {
208 const router = useRouter();
209 const { data, isLoading } = useWhiteboardCanvas(pageId);
210 const save = useWhiteboardSave(pageId);
211 const selfUserId = useAuthStore((s) => s.user?.id ?? null);
212
213
214 const historyRef = useRef<OpStack<Canvas> | null>(null);
215 if (historyRef.current === null) historyRef.current = new OpStack(emptyCanvas(), HISTORY_CAP);
216 const history = historyRef.current;
217 const [canvas, setCanvas] = useState<Canvas>(() => history.present);
218 const initialized = useRef(false);
219 useEffect(() => {
220 if (data && !initialized.current) {
221 initialized.current = true;
222
223
224 historyRef.current!.reset(data.canvas);
225 setCanvas(historyRef.current!.present);
226 }
227 }, [data]);
228
229 const [viewport, setViewport] = useState<Viewport>({ scale: 1, tx: 32, ty: 32 });
230 const [selection, setSelection] = useState<string[]>([]);
231 const [tool, setTool] = useState<Tool>(readOnly ? "pan" : "select");
232 const [marquee, setMarquee] = useState<{ a: { x: number; y: number }; b: { x: number; y: number } } | null>(null);
233 const [preview, setPreview] = useState<Preview | null>(null);
234 const [editingId, setEditingId] = useState<string | null>(null);
235 const [dirty, setDirty] = useState(false);
236
237 const [connectorOrthogonal, setConnectorOrthogonal] = useState(true);
238 const [connectorArrow, setConnectorArrow] = useState(true);
239
240
241 const [commentsOpen, setCommentsOpen] = useState(false);
242 const [versionsOpen, setVersionsOpen] = useState(false);
243 const [restoreTarget, setRestoreTarget] = useState<{ id: string; createdAt: string } | null>(null);
244 const [linkTarget, setLinkTarget] = useState<string | null>(null);
245 const [convertTarget, setConvertTarget] = useState<"page" | "task" | null>(null);
246 const [converting, setConverting] = useState(false);
247 const versionsQuery = usePageVersions(pageId);
248
249 const svgRef = useRef<SVGSVGElement>(null);
250 const dragRef = useRef<DragState | null>(null);
251 const dragStartCanvas = useRef<Canvas>(emptyCanvas());
252 const containerRef = useRef<HTMLDivElement>(null);
253
256 const [size, setSize] = useState({ w: 0, h: 0 });
257 useEffect(() => {
258
259
260
261
262 const el = containerRef.current;
263
264
265 if (!el || typeof ResizeObserver === "undefined") return;
266 const ro = new ResizeObserver((entries) => {
267 const r = entries[0]?.contentRect;
268 if (r) setSize({ w: r.width, h: r.height });
269 });
270 ro.observe(el);
271 return () => ro.disconnect();
272 }, [isLoading]);
273
274 const effectiveReadOnly = readOnly || !canvasValid;
275
276
278 const apply = useCallback((next: Canvas) => {
279 history.push(next);
280 setCanvas(history.present);
281 setDirty(true);
282 }, [history]);
283
284
286 const undo = useCallback(() => {
287 if (!effectiveReadOnly && history.canUndo()) {
288 const prev = history.undo();
289 setCanvas(prev);
290 setDirty(true);
291 }
292 }, [history, effectiveReadOnly]);
293 const redo = useCallback(() => {
294 if (!effectiveReadOnly && history.canRedo()) {
295 const next = history.redo();
296 setCanvas(next);
297 setDirty(true);
298 }
299 }, [history, effectiveReadOnly]);
300
301
302 useEffect(() => {
303 if (!dirty || effectiveReadOnly) return;
304 const t = setTimeout(() => {
305 save.mutate(canvas, {
306 onError: (e) => toast("error", "Whiteboard save failed", (e as Error).message)
307 });
308 setDirty(false);
309 }, 1200);
310 return () => clearTimeout(t);
311 }, [canvas, dirty, effectiveReadOnly, save]);
312
313
315 const [savingVersion, setSavingVersion] = useState(false);
316 const doSaveVersion = async () => {
317 if (savingVersion) return;
318 setSavingVersion(true);
319 try {
320
321 if (dirty) await save.mutateAsync(canvas);
322 await whiteboardApi.saveVersion(pageId);
323 void versionsQuery.refetch();
324 toast("success", "Version saved");
325 } catch (e) {
326 toast("error", "Save version failed", e instanceof Error ? e.message : undefined);
327 } finally {
328 setSavingVersion(false);
329 }
330 };
331
332 const doRestore = async () => {
333 if (!restoreTarget) return;
334 try {
335 await whiteboardApi.restoreVersion(pageId, restoreTarget.id);
336
337 const wb = await whiteboardApi.get(pageId);
338
339
340 historyRef.current!.push(wb.canvas);
341 setCanvas(historyRef.current!.present);
342 setDirty(false);
343 void versionsQuery.refetch();
344 toast("success", "Version restored", `Restored to ${restoreTarget.createdAt}`);
345 setRestoreTarget(null);
346 setVersionsOpen(false);
347 } catch (e) {
348 toast("error", "Restore failed", e instanceof Error ? e.message : undefined);
349 }
350 };
351
352
353 const toWorld = useCallback(
354 (clientX: number, clientY: number): { x: number; y: number } => {
355 const box = svgRef.current?.getBoundingClientRect();
356 const sx = clientX - (box?.left ?? 0);
357 const sy = clientY - (box?.top ?? 0);
358 return screenToWorld(viewport, sx, sy);
359 },
360 [viewport]
361 );
362
363
364 useEffect(() => {
365 const el = containerRef.current;
366 if (!el) return;
367 const onWheel = (e: WheelEvent) => {
368 e.preventDefault();
369 const box = el.getBoundingClientRect();
370 const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
371 setViewport((vp) => {
372 const scale = Math.min(8, Math.max(0.05, vp.scale * factor));
373 const worldX = (e.clientX - box.left - vp.tx) / vp.scale;
374 const worldY = (e.clientY - box.top - vp.ty) / vp.scale;
375 return { scale, tx: e.clientX - box.left - worldX * scale, ty: e.clientY - box.top - worldY * scale };
376 });
377 };
378 el.addEventListener("wheel", onWheel, { passive: false });
379 return () => el.removeEventListener("wheel", onWheel);
380 }, []);
381
382
383 const doGroup = useCallback(() => {
384 if (effectiveReadOnly || selection.length < 2) return;
385 const gid = newId("g");
386 try {
387 const next = groupElements(canvas, { id: gid }, selection);
388 apply(next);
389 setSelection([gid]);
390 } catch (err) {
391 toast("error", "Cannot group", (err as Error).message);
392 }
393 }, [canvas, selection, effectiveReadOnly, apply]);
394
395
396 const doUngroup = useCallback(() => {
397 if (effectiveReadOnly || selection.length !== 1) return;
398 const selId = selection[0];
399 if (selId === undefined) return;
400 const el = canvas.elements[selId];
401 if (!el || el.type !== "group") return;
402 apply(ungroupElement(canvas, el.id));
403 setSelection([...el.children]);
404 }, [canvas, selection, effectiveReadOnly, apply]);
405
406
407 const doSetLink = (link: ElementLink | null) => {
408 if (effectiveReadOnly || selection.length !== 1) return;
409 const id = selection[0];
410 if (id === undefined) return;
411 apply(updateElement(canvas, id, { link: link ?? null }));
412 setLinkTarget(null);
413 };
414
415
417 const doConvert = async () => {
418 if (converting || effectiveReadOnly || selection.length === 0 || convertTarget === null) return;
419 setConverting(true);
420 try {
421 const res = await whiteboardApi.convertSelection(pageId, { elementIds: selection, target: convertTarget, parentId: null });
422 toast("success", convertTarget === "task" ? "Task created from selection" : "Page created from selection");
423 router.push(`/w/${workspaceId}/page/${res.page.id}`);
424 } catch (e) {
425 toast("error", "Convert failed", e instanceof Error ? e.message : undefined);
426 } finally {
427 setConverting(false);
428 }
429 };
430
431
432 const openLink = useCallback(
433 (el: CanvasElement) => {
434 const href = linkHref(el.link, workspaceId);
435 if (!href) return;
436 if (el.link!.kind === "url") window.open(href, "_blank", "noopener,noreferrer");
437 else router.push(href);
438 },
439 [workspaceId, router]
440 );
441
442
443 useEffect(() => {
444 const onKey = (e: KeyboardEvent) => {
445 const t = e.target as HTMLElement | null;
446 const tag = t?.tagName;
447 if (tag === "TEXTAREA" || tag === "INPUT" || t?.isContentEditable) return;
448 const mod = e.metaKey || e.ctrlKey;
449 if (mod && e.key.toLowerCase() === "z") {
450 e.preventDefault();
451 if (e.shiftKey) redo();
452 else undo();
453 return;
454 }
455 if (mod && e.key.toLowerCase() === "y") {
456 e.preventDefault();
457 redo();
458 return;
459 }
460 if (mod && e.key.toLowerCase() === "g") {
461 e.preventDefault();
462 if (e.shiftKey) doUngroup();
463 else doGroup();
464 return;
465 }
466 if (mod && e.key.toLowerCase() === "k") {
467 e.preventDefault();
468
469 const sid = selection[0];
470 if (!effectiveReadOnly && selection.length === 1 && sid !== undefined) setLinkTarget(sid);
471 return;
472 }
473 if ((e.key === "Delete" || e.key === "Backspace") && !effectiveReadOnly && selection.length > 0) {
474 e.preventDefault();
475 let c = canvas;
476 for (const id of selection) c = removeElement(c, id);
477 apply(c);
478 setSelection([]);
479 return;
480 }
481 if (e.key === "Enter" && !mod && selection.length === 1) {
482 const eid = selection[0];
483 const el = eid !== undefined ? canvas.elements[eid] : undefined;
484 if (el?.link) {
485 openLink(el);
486 return;
487 }
488 }
489 if (e.key === "Escape") {
490 setSelection([]);
491 setEditingId(null);
492 setPreview(null);
493 setLinkTarget(null);
494 setVersionsOpen(false);
495 return;
496 }
497 if (effectiveReadOnly) return;
498 if (e.metaKey || e.ctrlKey || e.altKey) return;
499 switch (e.key) {
500 case "v": setTool("select"); break;
501 case "h": setTool("pan"); break;
502 case "s": setTool("sticky"); break;
503 case "t": setTool("text"); break;
504 case "r": setTool("rect"); break;
505 case "o": setTool("ellipse"); break;
506 case "c": setTool("connector"); break;
507 case "p": setTool("pen"); break;
508 }
509 };
510 window.addEventListener("keydown", onKey);
511 return () => window.removeEventListener("keydown", onKey);
512 }, [canvas, selection, effectiveReadOnly, apply, doGroup, doUngroup, undo, redo, workspaceId]);
513
514
515 const hitAt = (world: { x: number; y: number }): CanvasElement | undefined =>
516 Object.values(canvas.elements)
517 .filter((el) => hitTest(el, world.x, world.y, 6, canvas.elements))
518 .sort((a, b) => b.z - a.z)[0];
519
520 const onPointerDown = (e: React.PointerEvent<SVGSVGElement>) => {
521
522 if (e.button === 1 || tool === "pan") {
523 e.currentTarget.setPointerCapture(e.pointerId);
524 dragRef.current = {
525 mode: "pan",
526 startWorld: toWorld(e.clientX, e.clientY),
527 startScreen: { x: e.clientX, y: e.clientY },
528 startView: viewport,
529 moved: false
530 };
531 return;
532 }
533 if (effectiveReadOnly) return;
534 const world = toWorld(e.clientX, e.clientY);
535
536
537 if (tool === "sticky" || tool === "text") {
538 const id = newId("el");
539 const base = { id, x: world.x - 80, y: world.y - 50, z: nextZ(canvas) };
540 const el: CanvasElement =
541 tool === "sticky"
542 ? { ...base, type: "sticky", w: 160, h: 110, text: "", color: "yellow" }
543 : { ...base, type: "text", w: 220, h: 32, text: "", fontSize: 16 };
544 apply(addElement(canvas, el));
545 setSelection([id]);
546 setEditingId(id);
547 setTool("select");
548 return;
549 }
550
551
552 if (tool === "rect" || tool === "ellipse") {
553 e.currentTarget.setPointerCapture(e.pointerId);
554 dragStartCanvas.current = canvas;
555 dragRef.current = {
556 mode: "create-shape",
557 shape: tool,
558 startWorld: world,
559 startScreen: { x: e.clientX, y: e.clientY },
560 startView: viewport,
561 moved: false
562 };
563 setPreview({ kind: "shape", shape: tool, a: world, b: world });
564 return;
565 }
566
567
568 if (tool === "connector") {
569 const hit = hitAt(world);
570 if (!hit) return;
571 e.currentTarget.setPointerCapture(e.pointerId);
572 const anchor = nearestAnchor(hit, world);
573 dragStartCanvas.current = canvas;
574 dragRef.current = {
575 mode: "connector",
576 fromEl: { elementId: hit.id, anchor },
577 startWorld: world,
578 startScreen: { x: e.clientX, y: e.clientY },
579 startView: viewport,
580 moved: false
581 };
582 setPreview({ kind: "connector", a: world, b: world, fromEl: { elementId: hit.id, anchor } });
583 return;
584 }
585
586
587 if (tool === "pen") {
588 e.currentTarget.setPointerCapture(e.pointerId);
589 dragRef.current = {
590 mode: "pen",
591 startWorld: world,
592 points: [world.x, world.y],
593 startScreen: { x: e.clientX, y: e.clientY },
594 startView: viewport,
595 moved: false
596 };
597 setPreview({ kind: "pen", a: world, b: world, points: [world.x, world.y] });
598 return;
599 }
600
601
602 const hit = hitAt(world);
603 e.currentTarget.setPointerCapture(e.pointerId);
604 if (hit) {
605 const multi = e.shiftKey && selection.includes(hit.id);
606 const ids = multi ? selection : [hit.id];
607 setSelection(multi ? selection : ids);
608
609
610 dragStartCanvas.current = canvas;
611 dragRef.current = {
612 mode: "move",
613 startWorld: world,
614 ids,
615 startScreen: { x: e.clientX, y: e.clientY },
616 startView: viewport,
617 moved: false
618 };
619 } else {
620 if (!e.shiftKey) setSelection([]);
621 dragRef.current = {
622 mode: "marquee",
623 startWorld: world,
624 startScreen: { x: e.clientX, y: e.clientY },
625 startView: viewport,
626 moved: false
627 };
628 setMarquee({ a: world, b: world });
629 }
630 };
631
632 const onPointerMove = (e: React.PointerEvent<SVGSVGElement>) => {
633 const d = dragRef.current;
634 if (!d) return;
635 d.moved = true;
636 const world = toWorld(e.clientX, e.clientY);
637 if (d.mode === "pan") {
638 setViewport({ scale: d.startView.scale, tx: d.startView.tx + (e.clientX - d.startScreen.x), ty: d.startView.ty + (e.clientY - d.startScreen.y) });
639 return;
640 }
641 if (d.mode === "marquee") {
642 setMarquee({ a: d.startWorld, b: world });
643 return;
644 }
645 if (d.mode === "create-shape") {
646 setPreview((p) => (p && p.kind === "shape" ? { ...p, b: world } : p));
647 return;
648 }
649 if (d.mode === "connector") {
650 setPreview((p) => (p && p.kind === "connector" ? { ...p, b: world } : p));
651 return;
652 }
653 if (d.mode === "pen") {
654 const pts = d.points!;
655 const n = pts.length;
656 const dx = world.x - (pts[n - 2] ?? world.x);
657 const dy = world.y - (pts[n - 1] ?? world.y);
658
659 if (dx * dx + dy * dy > (1 / viewport.scale) ** 2) {
660 pts.push(world.x, world.y);
661 setPreview((p) => (p && p.kind === "pen" ? { ...p, points: [...pts] } : p));
662 }
663 return;
664 }
665 if (d.mode === "move" && d.ids && !effectiveReadOnly) {
666 const dx = world.x - d.startWorld.x;
667 const dy = world.y - d.startWorld.y;
668 if (Math.abs(dx) < 0.01 && Math.abs(dy) < 0.01) return;
669
670
671 const movable = d.ids.filter((id) => {
672 const el = dragStartCanvas.current.elements[id];
673 return el && el.type !== "connector";
674 });
675 if (movable.length === 0) return;
676 setCanvas(moveMany(dragStartCanvas.current, movable, dx, dy));
677 setDirty(true);
678 return;
679 }
680 if (d.mode === "resize" && d.ids && !effectiveReadOnly) {
681 const id = d.ids[0];
682 if (id === undefined) return;
683 const el = dragStartCanvas.current.elements[id];
684 if (!el) return;
685 const w = Math.max(MIN_SIZE, world.x - el.x);
686 const h = Math.max(MIN_SIZE, world.y - el.y);
687 setCanvas((prev) => updateElement(prev, id, { w, h }));
688 setDirty(true);
689 }
690 };
691
692 const onPointerUp = (e: React.PointerEvent<SVGSVGElement>) => {
693 const d = dragRef.current;
694 dragRef.current = null;
695 setMarquee(null);
696 setPreview(null);
697 if (!d) return;
698 const world = toWorld(e.clientX, e.clientY);
699 if (d.mode === "marquee" && d.moved) {
700 const x = Math.min(d.startWorld.x, world.x);
701 const y = Math.min(d.startWorld.y, world.y);
702 const x2 = Math.max(d.startWorld.x, world.x);
703 const y2 = Math.max(d.startWorld.y, world.y);
704
705
706
707 const ids = visibleElements
708 .filter((el) => el.x < x2 && el.x + el.w > x && el.y < y2 && el.y + el.h > y)
709 .map((el) => el.id);
710 setSelection((prev) => (e.shiftKey ? [...new Set([...prev, ...ids])] : ids));
711 return;
712 }
713 if (d.mode === "create-shape" && d.moved) {
714 const r = normRect(d.startWorld, world);
715 if (r.w >= MIN_SIZE / viewport.scale || r.h >= MIN_SIZE / viewport.scale) {
716 const id = newId("el");
717 const el: CanvasElement = {
718 id,
719 type: d.shape ?? "rect",
720 x: r.x,
721 y: r.y,
722 w: Math.max(MIN_SIZE, r.w),
723 h: Math.max(MIN_SIZE, r.h),
724 z: nextZ(dragStartCanvas.current),
725 fill: null,
726 stroke: null
727 };
728 apply(addElement(dragStartCanvas.current, el));
729 setSelection([id]);
730 }
731 return;
732 }
733 if (d.mode === "connector" && d.moved && d.fromEl) {
734 const target = hitAt(world);
735 if (target && target.id !== d.fromEl.elementId) {
736 const anchor = nearestAnchor(target, world);
737 const id = newId("c");
738 const el: CanvasElement = {
739 id,
740 type: "connector",
741 x: 0,
742 y: 0,
743 w: 0,
744 h: 0,
745 z: nextZ(dragStartCanvas.current),
746 from: d.fromEl,
747 to: { elementId: target.id, anchor },
748 kind: connectorOrthogonal ? "orthogonal" : "straight",
749 arrow: connectorArrow
750 };
751 apply(addElement(dragStartCanvas.current, el));
752 setSelection([id]);
753 }
754 return;
755 }
756 if (d.mode === "pen" && d.moved && d.points) {
757
758 const simplified = decimatePoints(d.points, 1.5);
759 if (simplified.length >= 4) {
760 let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
761 for (let i = 0; i < simplified.length; i += 2) {
762 minX = Math.min(minX, simplified[i]!);
763 maxX = Math.max(maxX, simplified[i]!);
764 minY = Math.min(minY, simplified[i + 1]!);
765 maxY = Math.max(maxY, simplified[i + 1]!);
766 }
767 const id = newId("f");
768 const el: CanvasElement = {
769 id,
770 type: "freehand",
771 x: minX,
772 y: minY,
773 w: Math.max(1, maxX - minX),
774 h: Math.max(1, maxY - minY),
775 z: nextZ(dragStartCanvas.current),
776 points: simplified,
777 strokeWidth: 3
778 };
779 apply(addElement(dragStartCanvas.current, el));
780 setSelection([id]);
781 }
782 }
783 };
784
785 const onResizeHandleDown = (e: React.PointerEvent<SVGRectElement>) => {
786 e.stopPropagation();
787 if (effectiveReadOnly || selection.length !== 1) return;
788 const id = selection[0];
789 if (id === undefined) return;
790 const el = canvas.elements[id];
791
792
793 if (!el || el.type === "connector" || el.type === "freehand" || el.type === "group") return;
794 e.currentTarget.setPointerCapture(e.pointerId);
795 dragStartCanvas.current = canvas;
796 dragRef.current = {
797 mode: "resize",
798 ids: [id],
799 startWorld: toWorld(e.clientX, e.clientY),
800 startScreen: { x: e.clientX, y: e.clientY },
801 startView: viewport,
802 moved: false
803 };
804 };
805
806 const onDoubleClick = (e: React.MouseEvent<SVGSVGElement>) => {
807 if (effectiveReadOnly) return;
808 const world = toWorld(e.clientX, e.clientY);
809 const hit = Object.values(canvas.elements)
810 .filter((el) => (el.type === "sticky" || el.type === "text") && hitTest(el, world.x, world.y, 6, canvas.elements))
811 .sort((a, b) => b.z - a.z)[0];
812 if (hit) {
813 setSelection([hit.id]);
814 setEditingId(hit.id);
815 }
816 };
817
818
821 const onElementDoubleClick = (el: CanvasElement) => {
822 if (!el.link || effectiveReadOnly) return;
823 openLink(el);
824 };
825
826
827
828
829
830
831 const sortedElements = useMemo(
832 () => Object.values(canvas.elements).sort((a, b) => a.z - b.z),
833 [canvas]
834 );
835 const visibleRect = viewportToWorldRect(viewport, size.w, size.h);
836 const visibleElements = useMemo(() => {
837 if (!visibleRect) return sortedElements;
838 return sortedElements.filter((el) => rectsIntersect(displayBounds(el, canvas.elements), visibleRect));
839 }, [sortedElements, visibleRect, canvas.elements]);
840
841 const elements = useMemo(
842 () =>
843 visibleElements.map((el) => {
844 const selected = selection.includes(el.id);
845 const key = el.id;
846 const linked = Boolean(el.link);
847 const cursor = linked ? "pointer" : undefined;
848 switch (el.type) {
849 case "sticky":
850 return (
851 <g key={key} data-testid="wb-sticky" onDoubleClick={() => { onElementDoubleClick(el); }} style={linked ? { cursor } : undefined}>
852 <rect x={el.x} y={el.y} width={el.w} height={el.h} rx={2} fill={STICKY_HEX[el.color] ?? "#FEF3C7"} stroke={linked ? SELECTION : "#00000022"} strokeWidth={linked ? 1.5 : 1} />
853 <Label x={el.x + 8} y={el.y + 14} w={el.w - 16} text={el.text} fontSize={14} fill="#1F2937" anchor="start" />
854 {linked && <Link2 x={el.x + el.w - 18} y={el.y + el.h - 18} width={12} height={12} color="#2563EB" />}
855 </g>
856 );
857 case "text":
858 return (
859 <g key={key} data-testid="wb-text" onDoubleClick={() => { onElementDoubleClick(el); }} style={linked ? { cursor } : undefined}>
860 <rect x={el.x} y={el.y} width={el.w} height={el.h} fill="transparent" stroke="none" />
861 <Label x={el.x + 2} y={el.y + 4} w={el.w - 4} text={el.text} fontSize={el.fontSize} fill={linked ? SELECTION : "#111827"} anchor="start" />
862 {linked && <Link2 x={el.x + el.w - 16} y={el.y + el.h - 14} width={12} height={12} color="#2563EB" />}
863 </g>
864 );
865 case "rect":
866 return <rect key={key} data-testid="wb-rect" onDoubleClick={() => { onElementDoubleClick(el); }} x={el.x} y={el.y} width={el.w} height={el.h} fill={el.fill ?? "transparent"} stroke={el.stroke ?? "#374151"} strokeWidth={1.5} style={linked ? { cursor } : undefined} />;
867 case "ellipse":
868 return <ellipse key={key} data-testid="wb-ellipse" onDoubleClick={() => { onElementDoubleClick(el); }} cx={el.x + el.w / 2} cy={el.y + el.h / 2} rx={el.w / 2} ry={el.h / 2} fill={el.fill ?? "transparent"} stroke={el.stroke ?? "#374151"} strokeWidth={1.5} style={linked ? { cursor } : undefined} />;
869 case "freehand":
870 return <polyline key={key} data-testid="wb-freehand" onDoubleClick={() => { onElementDoubleClick(el); }} points={pointsAttr(el.points)} fill="none" stroke="#111827" strokeWidth={el.strokeWidth} strokeLinecap="round" strokeLinejoin="round" style={linked ? { cursor } : undefined} />;
871 case "connector": {
872 const from = canvas.elements[el.from.elementId];
873 const to = canvas.elements[el.to.elementId];
874 if (!from || !to) return null;
875 const p1 = anchorPoint(from, el.from.anchor);
876 const p2 = anchorPoint(to, el.to.anchor);
877 const dPath = connectorPath(p1, p2, el.kind);
878 const dx = p2.x - p1.x;
879 const dy = p2.y - p1.y;
880 const len = Math.hypot(dx, dy) || 1;
881 const head = arrowHead(p2, { x: dx / len, y: dy / len }, 10);
882 const headStr = head.map((p) => `${p[0]},${p[1]}`).join(" ");
883 return (
884 <g key={key} data-testid="wb-connector">
885 <path d={dPath} fill="none" stroke="#4B5563" strokeWidth={1.5} />
886 {el.arrow && <polygon points={headStr} fill="#4B5563" />}
887 {}
888 <path d={dPath} fill="none" stroke="transparent" strokeWidth={12} data-testid="wb-connector-hit" />
889 </g>
890 );
891 }
892 case "group":
893 return <rect key={key} data-testid="wb-group" onDoubleClick={() => { onElementDoubleClick(el); }} x={el.x} y={el.y} width={el.w} height={el.h} fill="none" stroke="#9CA3AF" strokeWidth={1.5} strokeDasharray="6 4" rx={4} style={linked ? { cursor } : undefined} />;
894 default:
895 return null;
896 }
897 }),
898 [visibleElements, canvas, openLink]
899 );
900
901
902 const selectedEls = selection
903 .map((id) => canvas.elements[id])
904 .filter((el): el is CanvasElement => Boolean(el));
905 const resizableEl =
906 selection.length === 1 && selectedEls[0] && !["connector", "freehand", "group"].includes(selectedEls[0].type)
907 ? selectedEls[0]
908 : null;
909
910 const connectorFromPoint =
911 preview?.kind === "connector" && preview.fromEl
912 ? (() => {
913 const from = canvas.elements[preview.fromEl!.elementId];
914 return from ? anchorPoint(from, preview.fromEl!.anchor) : preview.a;
915 })()
916 : null;
917
918 const editingEl = editingId ? canvas.elements[editingId] : undefined;
919
920 const commitEdit = useCallback(
921 (text: string) => {
922 if (editingId) {
923 const el = canvas.elements[editingId];
924 if (el && (el.type === "sticky" || el.type === "text")) apply(updateElement(canvas, editingId, { text }));
925 }
926 setEditingId(null);
927 },
928 [editingId, canvas, apply]
929 );
930
931
932 const presenceMap = useBlockPatchStore((s) => (pageId ? (s.presence[pageId] ?? ({} as Record<string, Presence>)) : ({} as Record<string, Presence>)));
933 const peers = useMemo(
934 () => Object.values(presenceMap).filter((p) => p.userId !== selfUserId),
935 [presenceMap, selfUserId]
936 );
937 const initials = (name: string) =>
938 name
939 .split(/\s+/)
940 .map((s) => s[0])
941 .filter(Boolean)
942 .slice(0, 2)
943 .join("")
944 .toUpperCase();
945
946
947 const { data: pageTree } = usePageTree(workspaceId);
948 const pages = useMemo(() => flattenPages(pageTree), [pageTree]);
949
950 if (isLoading) {
951 return (
952 <div className="flex h-full items-center justify-center text-sm text-gray-400" data-testid="wb-loading">
953 Loading whiteboard…
954 </div>
955 );
956 }
957
958 const saveState = save.isPending ? "Saving…" : dirty ? "Unsaved" : "Saved";
959 const canGroup = selection.length >= 2 && !effectiveReadOnly;
960 const firstSel = selection[0];
961 const canUngroup = selection.length === 1 && firstSel !== undefined && canvas.elements[firstSel]?.type === "group" && !effectiveReadOnly;
962 const canLink = selection.length === 1 && !effectiveReadOnly;
963 const linkEl = linkTarget ? canvas.elements[linkTarget] : undefined;
964 const versionList = (versionsQuery.data ?? []).slice(0, VERSION_LIMIT);
965
966 const tools: Array<{ key: Tool; testid: string; label: string; title: string }> = [
967 { key: "select", testid: "wb-tool-select", label: "Select", title: "Select (V)" },
968 { key: "sticky", testid: "wb-tool-sticky", label: "Sticky", title: "Sticky note (S)" },
969 { key: "text", testid: "wb-tool-text", label: "Text", title: "Text (T)" },
970 { key: "rect", testid: "wb-tool-rect", label: "Rect", title: "Rectangle (R)" },
971 { key: "ellipse", testid: "wb-tool-ellipse", label: "Ellipse", title: "Ellipse (O)" },
972 { key: "connector", testid: "wb-tool-connector", label: "Connector", title: "Connector (C)" },
973 { key: "pen", testid: "wb-tool-pen", label: "Pen", title: "Freehand (P)" }
974 ];
975
976 return (
977 <div className="flex h-full flex-col" data-testid="wb-root">
978 {!canvasValid && (
979 <div className="border-b border-amber-300 bg-amber-50 px-4 py-1.5 text-xs text-amber-800" data-testid="wb-readonly-banner">
980 This whiteboard uses a newer/unsupported canvas version — shown read-only.
981 </div>
982 )}
983 {effectiveReadOnly && canvasValid && (
984 <div className="border-b border-gray-200 bg-gray-50 px-4 py-1.5 text-xs text-gray-500">Read-only (view access).</div>
985 )}
986 {}
987 <div className="flex flex-wrap items-center gap-1 border-b border-gray-200 bg-white px-2 py-1" data-testid="wb-toolbar">
988 {!effectiveReadOnly && tools.map((t) => (
989 <button key={t.key} type="button" data-testid={t.testid} className={tool === t.key ? "wb-tool active" : "wb-tool"} onClick={() => setTool(t.key)} title={t.title}>
990 {t.label}
991 </button>
992 ))}
993 <button type="button" data-testid="wb-tool-pan" className={tool === "pan" ? "wb-tool active" : "wb-tool"} onClick={() => setTool("pan")} title="Pan (H)">
994 Pan
995 </button>
996 {!effectiveReadOnly && (
997 <>
998 <button type="button" data-testid="wb-btn-undo" className="wb-tool" disabled={!history.canUndo()} onClick={undo} title="Undo (⌘Z)">
999 <Undo2 className="h-3.5 w-3.5" />
1000 </button>
1001 <button type="button" data-testid="wb-btn-redo" className="wb-tool" disabled={!history.canRedo()} onClick={redo} title="Redo (⌘⇧Z)">
1002 <Redo2 className="h-3.5 w-3.5" />
1003 </button>
1004 <button type="button" data-testid="wb-btn-group" className="wb-tool" disabled={!canGroup} onClick={doGroup} title="Group selection (⌘G)">
1005 Group
1006 </button>
1007 <button type="button" data-testid="wb-btn-ungroup" className="wb-tool" disabled={!canUngroup} onClick={doUngroup} title="Ungroup (⌘⇧G)">
1008 Ungroup
1009 </button>
1010 <button type="button" data-testid="wb-link" className="wb-tool" disabled={!canLink} onClick={() => setLinkTarget(firstSel ?? null)} title="Link this element (⌘K)">
1011 <Link2 className="h-3.5 w-3.5" />
1012 </button>
1013 <button type="button" data-testid="wb-btn-convert" className="wb-tool" disabled={selection.length === 0} onClick={() => setConvertTarget("page")} title="Convert selection to a page or task">
1014 <FilePlus2 className="h-3.5 w-3.5" />
1015 </button>
1016 <label className="ml-1 flex items-center gap-1 text-xs text-gray-500" data-testid="wb-toggle-orthogonal">
1017 <input type="checkbox" checked={connectorOrthogonal} onChange={(e) => setConnectorOrthogonal(e.target.checked)} /> orthogonal
1018 </label>
1019 <label className="flex items-center gap-1 text-xs text-gray-500" data-testid="wb-toggle-arrow">
1020 <input type="checkbox" checked={connectorArrow} onChange={(e) => setConnectorArrow(e.target.checked)} /> arrow
1021 </label>
1022 </>
1023 )}
1024 <span className="ml-2 text-xs text-gray-400">{Math.round(viewport.scale * 100)}%</span>
1025 <span className="text-xs text-gray-400" data-testid="wb-save-state">
1026 {saveState}
1027 </span>
1028 <div className="ml-auto flex items-center gap-1">
1029 {}
1030 <div className="flex items-center -space-x-1.5" data-testid="wb-presence">
1031 {peers.slice(0, 5).map((p) => (
1032 <span
1033 key={p.userId}
1034 data-testid="wb-presence-peer"
1035 title={p.name}
1036 className="flex h-5 w-5 items-center justify-center rounded-full border border-white text-[10px] font-semibold text-white"
1037 style={{ backgroundColor: p.color }}
1038 >
1039 {initials(p.name)}
1040 </span>
1041 ))}
1042 {peers.length > 5 && <span className="pl-2 text-[10px] text-gray-400">+{peers.length - 5}</span>}
1043 </div>
1044 <button type="button" data-testid="wb-comments" className="wb-tool" onClick={() => setCommentsOpen(true)} title="Comments">
1045 <MessageSquare className="h-3.5 w-3.5" />
1046 </button>
1047 <button type="button" data-testid="wb-versions" className="wb-tool" onClick={() => setVersionsOpen(true)} title="Save & restore versions">
1048 <History className="h-3.5 w-3.5" />
1049 </button>
1050 </div>
1051 </div>
1052 {}
1053 <div ref={containerRef} className="relative min-h-0 flex-1 overflow-hidden bg-[#F8FAFC]" data-testid="wb-canvas-wrap">
1054 <svg
1055 ref={svgRef}
1056 className="h-full w-full touch-none"
1057 style={{ cursor: tool === "pan" ? "grab" : tool === "select" ? "default" : "crosshair", display: "block" }}
1058 data-testid="wb-canvas"
1059 onPointerDown={onPointerDown}
1060 onPointerMove={onPointerMove}
1061 onPointerUp={onPointerUp}
1062 onDoubleClick={onDoubleClick}
1063 >
1064 <g transform={`translate(${viewport.tx} ${viewport.ty}) scale(${viewport.scale})`}>
1065 {elements}
1066 {}
1067 {selectedEls.map((el) => (
1068 <rect
1069 key={`sel-${el.id}`}
1070 x={el.x - 2}
1071 y={el.y - 2}
1072 width={el.w + 4}
1073 height={el.h + 4}
1074 fill="none"
1075 stroke={SELECTION}
1076 strokeWidth={1.5 / viewport.scale}
1077 strokeDasharray="5 3"
1078 style={{ pointerEvents: "none" }}
1079 />
1080 ))}
1081 {resizableEl && (
1082 <rect
1083 data-testid="wb-resize-handle"
1084 x={resizableEl.x + resizableEl.w - 5}
1085 y={resizableEl.y + resizableEl.h - 5}
1086 width={10}
1087 height={10}
1088 fill={SELECTION}
1089 style={{ cursor: "nwse-resize" }}
1090 onPointerDown={onResizeHandleDown}
1091 />
1092 )}
1093 {}
1094 {marquee && (
1095 <rect
1096 x={Math.min(marquee.a.x, marquee.b.x)}
1097 y={Math.min(marquee.a.y, marquee.b.y)}
1098 width={Math.abs(marquee.b.x - marquee.a.x)}
1099 height={Math.abs(marquee.b.y - marquee.a.y)}
1100 fill="#2563EB11"
1101 stroke={SELECTION}
1102 strokeWidth={1}
1103 strokeDasharray="4 3"
1104 />
1105 )}
1106 {}
1107 {preview?.kind === "shape" && normRect(preview.a, preview.b).w > 0 && (
1108 preview.shape === "rect" ? (
1109 <rect x={normRect(preview.a, preview.b).x} y={normRect(preview.a, preview.b).y} width={normRect(preview.a, preview.b).w} height={normRect(preview.a, preview.b).h} fill="#37415111" stroke={SELECTION} strokeWidth={1} strokeDasharray="4 3" />
1110 ) : (
1111 <ellipse cx={normRect(preview.a, preview.b).x + normRect(preview.a, preview.b).w / 2} cy={normRect(preview.a, preview.b).y + normRect(preview.a, preview.b).h / 2} rx={normRect(preview.a, preview.b).w / 2} ry={normRect(preview.a, preview.b).h / 2} fill="#37415111" stroke={SELECTION} strokeWidth={1} strokeDasharray="4 3" />
1112 )
1113 )}
1114 {preview?.kind === "connector" && connectorFromPoint && (
1115 <path d={connectorPath(connectorFromPoint, preview.b, connectorOrthogonal ? "orthogonal" : "straight")} fill="none" stroke={SELECTION} strokeWidth={1.5} strokeDasharray="5 3" />
1116 )}
1117 {preview?.kind === "pen" && preview.points && preview.points.length >= 4 && (
1118 <polyline points={pointsAttr(preview.points)} fill="none" stroke="#111827" strokeWidth={3 / viewport.scale} strokeLinecap="round" strokeLinejoin="round" />
1119 )}
1120 {}
1121 {editingEl && (editingEl.type === "sticky" || editingEl.type === "text") && (
1122 <foreignObject x={editingEl.x} y={editingEl.y} width={editingEl.w} height={editingEl.h} data-testid="wb-editor">
1123 <textarea
1124 autoFocus
1125 defaultValue={editingEl.text}
1126 data-testid="wb-editor-input"
1127 onBlur={(e) => commitEdit(e.currentTarget.value)}
1128 onKeyDown={(e) => {
1129 if (e.key === "Escape") {
1130 e.stopPropagation();
1131 commitEdit((e.target as HTMLTextAreaElement).value);
1132 }
1133 e.stopPropagation();
1134 }}
1135 style={{
1136 width: "100%",
1137 height: "100%",
1138 resize: "none",
1139 border: "none",
1140 outline: "1.5px solid #2563EB",
1141 background: editingEl.type === "sticky" ? (STICKY_HEX[editingEl.color] ?? "#FEF3C7") : "white",
1142 padding: "6px 8px",
1143 fontSize: editingEl.type === "sticky" ? 14 : editingEl.fontSize,
1144 fontFamily: "ui-sans-serif, system-ui, sans-serif",
1145 color: "#111827",
1146 lineHeight: 1.25
1147 }}
1148 />
1149 </foreignObject>
1150 )}
1151 </g>
1152 </svg>
1153 </div>
1154
1155 {}
1156 <CommentPanel pageId={pageId} workspaceId={workspaceId} open={commentsOpen} onClose={() => setCommentsOpen(false)} />
1157
1158 {}
1159 {versionsOpen && (
1160 <div className="absolute right-2 top-10 z-50 w-80 rounded-md border border-border bg-popover p-3 shadow-xl" data-testid="wb-versions-popover">
1161 <div className="mb-2 flex items-center justify-between">
1162 <p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Whiteboard versions</p>
1163 <button type="button" data-testid="wb-versions-close" onClick={() => setVersionsOpen(false)}>
1164 <X className="h-4 w-4 text-muted-foreground" />
1165 </button>
1166 </div>
1167 {!effectiveReadOnly && (
1168 <button type="button" data-testid="wb-save-version" className="mb-2 w-full rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50" disabled={savingVersion} onClick={() => void doSaveVersion()}>
1169 {savingVersion ? "Saving…" : "Save version"}
1170 </button>
1171 )}
1172 {versionsQuery.isLoading ? (
1173 <p className="py-2 text-sm text-muted-foreground">Loading…</p>
1174 ) : versionList.length === 0 ? (
1175 <p className="py-2 text-sm text-muted-foreground">No versions yet.</p>
1176 ) : (
1177 <ul className="max-h-64 space-y-1 overflow-y-auto">
1178 {versionList.map((v) => (
1179 <li key={v.id} className="flex items-center justify-between gap-2 rounded-md px-2 py-1 text-sm hover:bg-muted">
1180 <span className="truncate text-muted-foreground" data-testid="wb-version-item">
1181 v{v.id.slice(-4)} · {new Date(v.createdAt).toLocaleString()}
1182 </span>
1183 {!effectiveReadOnly && (
1184 <button type="button" data-testid="wb-version-restore" className="shrink-0 text-xs text-primary hover:underline" onClick={() => setRestoreTarget({ id: v.id, createdAt: v.createdAt })}>
1185 Restore
1186 </button>
1187 )}
1188 </li>
1189 ))}
1190 </ul>
1191 )}
1192 </div>
1193 )}
1194
1195 {}
1196 <ConfirmDialog
1197 open={Boolean(restoreTarget)}
1198 title="Restore this version?"
1199 description="The current board will be replaced by this version. You can undo it with ⌘Z."
1200 confirmLabel="Restore"
1201 destructive
1202 onConfirm={() => void doRestore()}
1203 onClose={() => setRestoreTarget(null)}
1204 />
1205
1206 {}
1207 {linkEl && (
1208 <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/20" data-testid="wb-link-backdrop" onClick={() => setLinkTarget(null)}>
1209 <div className="w-full max-w-sm rounded-lg border border-border bg-card p-4 shadow-xl" data-testid="wb-link-dialog" onClick={(e) => e.stopPropagation()}>
1210 <div className="mb-3 flex items-center justify-between">
1211 <p className="text-sm font-semibold">Link this element</p>
1212 <button type="button" data-testid="wb-link-close" onClick={() => setLinkTarget(null)}>
1213 <X className="h-4 w-4 text-muted-foreground" />
1214 </button>
1215 </div>
1216 {linkEl.link && (
1217 <button type="button" data-testid="wb-link-clear" className="mb-3 text-xs text-muted-foreground hover:text-destructive" onClick={() => doSetLink(null)}>
1218 Remove link
1219 </button>
1220 )}
1221 <label className="mb-1 block text-xs font-medium text-muted-foreground">Page (internal)</label>
1222 <select
1223 data-testid="wb-link-page"
1224 className="mb-3 w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm"
1225 value=""
1226 onChange={(e) => {
1227 const refId = e.target.value;
1228 if (refId) doSetLink({ kind: "page", refId });
1229 }}
1230 >
1231 <option value="">Choose a page…</option>
1232 {pages.map((p) => (
1233 <option key={p.id} value={p.id}>
1234 {p.title}
1235 </option>
1236 ))}
1237 </select>
1238 <label className="mb-1 block text-xs font-medium text-muted-foreground">Or an external URL</label>
1239 <div className="flex gap-2">
1240 <input
1241 data-testid="wb-link-url"
1242 type="url"
1243 placeholder="https://…"
1244 className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm"
1245 onKeyDown={(e) => {
1246 if (e.key === "Enter") {
1247 const href = (e.target as HTMLInputElement).value.trim();
1248 if (/^https?:\/\/\S+$/.test(href)) doSetLink({ kind: "url", href });
1249 }
1250 }}
1251 />
1252 <button
1253 type="button"
1254 data-testid="wb-link-set-url"
1255 className="shrink-0 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90"
1256 onClick={() => {
1257 const href = (document.querySelector('[data-testid="wb-link-url"]') as HTMLInputElement)?.value.trim();
1258 if (href && /^https?:\/\/\S+$/.test(href)) doSetLink({ kind: "url", href });
1259 }}
1260 >
1261 Set
1262 </button>
1263 </div>
1264 </div>
1265 </div>
1266 )}
1267
1268 {}
1269 {convertTarget !== null && selection.length > 0 && (
1270 <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/20" data-testid="wb-convert-backdrop" onClick={() => setConvertTarget(null)}>
1271 <div className="w-full max-w-sm rounded-lg border border-border bg-card p-4 shadow-xl" data-testid="wb-convert-dialog" onClick={(e) => e.stopPropagation()}>
1272 <div className="mb-3 flex items-center justify-between">
1273 <p className="text-sm font-semibold">Convert {selection.length} selected {selection.length === 1 ? "object" : "objects"}</p>
1274 <button type="button" data-testid="wb-convert-close" onClick={() => setConvertTarget(null)}>
1275 <X className="h-4 w-4 text-muted-foreground" />
1276 </button>
1277 </div>
1278 <p className="mb-3 text-xs text-muted-foreground">
1279 Sticky notes and text objects become blocks in a new {convertTarget === "task" ? "task" : "page"} (shapes and connectors are skipped).
1280 </p>
1281 <label className="mb-1 block text-xs font-medium text-muted-foreground">Create as</label>
1282 <select
1283 data-testid="wb-convert-target"
1284 className="mb-4 w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm"
1285 value={convertTarget}
1286 onChange={(e) => setConvertTarget(e.target.value === "task" ? "task" : "page")}
1287 >
1288 <option value="page">Page</option>
1289 <option value="task">Task</option>
1290 </select>
1291 <button type="button" data-testid="wb-convert-go" className="w-full rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50" disabled={converting} onClick={() => void doConvert()}>
1292 {converting ? "Converting…" : "Convert"}
1293 </button>
1294 </div>
1295 </div>
1296 )}
1297 </div>
1298 );
1299}
1300