"use client"; /** * TASK-0123 — the floating selection toolbar (the writing tools' entry point). * * Appears above a multi-block selection (⌘/Ctrl+click or Shift+click range in * the page view) with the **AI** menu (the four writing actions + translate * language picker — the shared `WritingActionsList`) and a Clear button. The * block context menu and the block hover toolbar reach the same actions via * their "Writing tools" submenus (single-block selections). * * a11y: role="toolbar"; the AI menu is role="menu" (the shared list), Escape * closes, focus returns to the trigger. * * Pinned testids (tests/e2e/specs/ai-writing.spec.ts — do not rename): * selection-toolbar the toolbar (visible when a selection exists) * selection-ai-menu the "AI" trigger button * selection-clear the Clear button */ import { useCallback, useEffect, useRef, useState } from "react"; import { Minus, Sparkles, X } from "lucide-react"; import { cn } from "@/lib/utils"; import { WritingActionsList } from "@/components/ai/writing-actions-menu"; import type { WritingAction } from "@/lib/ai"; import type { Block } from "@/lib/types"; export interface SelectionToolbarProps { pageId: string; /** The selected blocks (empty = the toolbar is not rendered by the caller). */ blocks: Block[]; onClear: () => void; /** The chosen writing action (+ target language for translate). */ onAction: (action: WritingAction, language?: string) => void; } export function SelectionToolbar({ pageId, blocks, onClear, onAction }: SelectionToolbarProps) { const [menuOpen, setMenuOpen] = useState(false); const [anchor, setAnchor] = useState<{ top: number; left: number } | null>(null); const rootRef = useRef(null); const triggerRef = useRef(null); // Position the toolbar above the topmost selected block (the page view // scopes the lookup to its own block list via [data-page-view]). useEffect(() => { const scope = rootRef.current?.closest("[data-page-view]"); if (!scope || blocks.length === 0) return; const els = blocks .map((b) => scope.querySelector(`[data-block-id="${b.id}"]`)) .filter((el): el is HTMLElement => !!el); if (els.length === 0) return; const top = Math.min(...els.map((el) => el.getBoundingClientRect().top)); const left = Math.max(8, Math.min(els[0]!.getBoundingClientRect().left, window.innerWidth - 280)); setAnchor({ top: top - 6, left }); }, [blocks]); // Close on outside click / Escape. useEffect(() => { if (!menuOpen) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { e.stopPropagation(); setMenuOpen(false); triggerRef.current?.focus(); } }; const onClick = (e: MouseEvent) => { if (rootRef.current && !rootRef.current.contains(e.target as Node)) setMenuOpen(false); }; window.addEventListener("keydown", onKey); window.addEventListener("mousedown", onClick); return () => { window.removeEventListener("keydown", onKey); window.removeEventListener("mousedown", onClick); }; }, [menuOpen]); const handlePick = useCallback( (action: WritingAction, language?: string) => { setMenuOpen(false); onAction(action, language); }, [onAction] ); if (blocks.length === 0) return null; return (
{blocks.length} selected
{menuOpen ? (
) : null}
); }