1"use client";
2
3
20import { useCallback, useEffect, useRef, useState } from "react";
21import { Minus, Sparkles, X } from "lucide-react";
22import { cn } from "@/lib/utils";
23import { WritingActionsList } from "@/components/ai/writing-actions-menu";
24import type { WritingAction } from "@/lib/ai";
25import type { Block } from "@/lib/types";
26
27export interface SelectionToolbarProps {
28 pageId: string;
29
30 blocks: Block[];
31 onClear: () => void;
32
33 onAction: (action: WritingAction, language?: string) => void;
34}
35
36export function SelectionToolbar({ pageId, blocks, onClear, onAction }: SelectionToolbarProps) {
37 const [menuOpen, setMenuOpen] = useState(false);
38 const [anchor, setAnchor] = useState<{ top: number; left: number } | null>(null);
39 const rootRef = useRef<HTMLDivElement>(null);
40 const triggerRef = useRef<HTMLButtonElement>(null);
41
42
43
44 useEffect(() => {
45 const scope = rootRef.current?.closest("[data-page-view]");
46 if (!scope || blocks.length === 0) return;
47 const els = blocks
48 .map((b) => scope.querySelector<HTMLElement>(`[data-block-id="${b.id}"]`))
49 .filter((el): el is HTMLElement => !!el);
50 if (els.length === 0) return;
51 const top = Math.min(...els.map((el) => el.getBoundingClientRect().top));
52 const left = Math.max(8, Math.min(els[0]!.getBoundingClientRect().left, window.innerWidth - 280));
53 setAnchor({ top: top - 6, left });
54 }, [blocks]);
55
56
57 useEffect(() => {
58 if (!menuOpen) return;
59 const onKey = (e: KeyboardEvent) => {
60 if (e.key === "Escape") {
61 e.stopPropagation();
62 setMenuOpen(false);
63 triggerRef.current?.focus();
64 }
65 };
66 const onClick = (e: MouseEvent) => {
67 if (rootRef.current && !rootRef.current.contains(e.target as Node)) setMenuOpen(false);
68 };
69 window.addEventListener("keydown", onKey);
70 window.addEventListener("mousedown", onClick);
71 return () => {
72 window.removeEventListener("keydown", onKey);
73 window.removeEventListener("mousedown", onClick);
74 };
75 }, [menuOpen]);
76
77 const handlePick = useCallback(
78 (action: WritingAction, language?: string) => {
79 setMenuOpen(false);
80 onAction(action, language);
81 },
82 [onAction]
83 );
84
85 if (blocks.length === 0) return null;
86
87 return (
88 <div
89 ref={rootRef}
90 data-testid="selection-toolbar"
91 className="fixed z-[70] flex -translate-y-full items-center gap-0.5 rounded-md border border-border bg-popover p-1 shadow-lg"
92 style={anchor ? { top: anchor.top, left: anchor.left } : { top: 0, left: 0, visibility: "hidden" }}
93 role="toolbar"
94 aria-label={`Actions for ${blocks.length} selected block${blocks.length === 1 ? "" : "s"}`}
95 >
96 <span className="px-2 py-1 text-[11px] font-medium text-muted-foreground">
97 {blocks.length} selected
98 </span>
99
100 <div className="relative">
101 <button
102 ref={triggerRef}
103 type="button"
104 data-testid="selection-ai-menu"
105 onClick={() => setMenuOpen((v) => !v)}
106 className={cn("flex items-center gap-1 rounded px-2 py-1 text-xs font-medium hover:bg-accent", menuOpen && "bg-accent")}
107 aria-haspopup="menu"
108 aria-expanded={menuOpen}
109 aria-label="AI writing tools"
110 >
111 <Sparkles className="size-3.5" aria-hidden />
112 AI
113 </button>
114 {menuOpen ? (
115 <div className="absolute left-0 top-full z-50 mt-0.5 w-44 rounded-md border border-border bg-popover p-1 shadow-lg" role="menu" aria-label="AI writing tools">
116 <WritingActionsList onPick={handlePick} />
117 </div>
118 ) : null}
119 </div>
120
121 <div className="mx-0.5 h-4 w-px bg-border" aria-hidden />
122
123 <button
124 type="button"
125 data-testid="selection-clear"
126 onClick={onClear}
127 className="flex items-center gap-1 rounded px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
128 aria-label="Clear selection"
129 >
130 <Minus className="size-3.5" aria-hidden />
131 Clear
132 </button>
133 <button
134 type="button"
135 onClick={onClear}
136 className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
137 aria-label="Close toolbar"
138 >
139 <X className="size-3.5" aria-hidden />
140 </button>
141 </div>
142 );
143}
144