diff --git a/webui/src/features/board/harness/chrome/canvas-context-menu.tsx b/webui/src/features/board/harness/chrome/canvas-context-menu.tsx index b384a7bc..d0f79f63 100644 --- a/webui/src/features/board/harness/chrome/canvas-context-menu.tsx +++ b/webui/src/features/board/harness/chrome/canvas-context-menu.tsx @@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, - useRef, useState, type RefObject, } from "react" @@ -17,14 +16,24 @@ import { import { ArticleSummaryIcon, ChatTranslateIcon, - ChevronDownIcon, - ChevronRightIcon, DrawIcon, ImagePlaceholderIcon, SchemaMapIcon, SparklesIcon, TreeMapIcon, } from "@/components/icons" +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" import { buildContextTextFromNodes } from "@/features/board/utils/context-text" import { useAiSparkActions } from "@/features/board/hooks/use-ai-spark-actions" import { useIsLocalBoard } from "@/features/board/lib/use-is-local-board" @@ -48,7 +57,7 @@ export type CanvasContextMenuProps = { } -/** Common languages shown as one-click chips in the Translate submenu. */ +/** Common languages shown as one-click items in the Translate submenu. */ const COMMON_LANGUAGES: ReadonlyArray = [ "English", "French", @@ -78,6 +87,17 @@ const AI_SPARK_VISUALS: ReadonlyArray = [ ] +// Canvas AI-action key → the local (in-browser) transform kind. Actions with no +// local equivalent (drawify, translate) are omitted and stay backend-only. +const LOCAL_TRANSFORM_BY_ACTION: Record = { + summarize: "summify", + mapify: "mapify", + schemify: "schemify", + quizify: "quizify", + explain: "mapify", +} + + /** * Build the structured context text for the agent from the current * canvas selection. Skips edges (the agent only consumes nodes). @@ -100,37 +120,28 @@ const buildSelectedContextText = ( /** - * Right-click context menu for the canvas-harness board. Mirrors - * prod's `graph-context-menu.tsx` — Position / Export / AI Spark / - * Translate sections — wired entirely against the harness store + - * lib export helpers (no react-flow). + * Right-click context menu for the canvas-harness board — Position / Export / + * AI / Translate. Built on the real Radix menu (via `ui/dropdown-menu`), so + * positioning collides-and-flips against the viewport (no more part-hidden + * menus) and AI/Translate are proper hover submenus. + * + * The canvas owns pointer events, so a Radix ContextMenuTrigger can't overlay + * it; instead the canvas-aware right-click detection (selection-gated, skips + * editor inputs) sets the anchor point, and the menu opens (controlled) against + * a zero-size trigger placed there. */ -// Canvas AI-action key → the local (in-browser) transform kind. Actions with no -// local equivalent (drawify, translate) are omitted and stay backend-only. -const LOCAL_TRANSFORM_BY_ACTION: Record = { - summarize: "summify", - mapify: "mapify", - schemify: "schemify", - quizify: "quizify", - explain: "mapify", -} - - export function CanvasContextMenu({ wrapRef, store, rendererRef }: CanvasContextMenuProps) { const boardId = useBoardAppStore((s) => s.boardId) - // AI Spark / Translate are backend-only — hidden on local boards for now. + // AI actions need an in-browser LLM on local boards; hide the section when no + // model key is usable (parity with the floating island) instead of offering + // actions that can only fail. Online boards use the backend, so unaffected. const isLocal = useIsLocalBoard() - // Local transforms need an in-browser LLM; hide the AI section when no model - // key is usable (parity with the floating island) instead of offering actions - // that can only fail. Online boards use the backend, so they're unaffected. const hasUsableModel = useHasUsableModel() const showAiSection = !isLocal || hasUsableModel const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null) - const [aiOpen, setAiOpen] = useState(false) - const [translateOpen, setTranslateOpen] = useState(false) const [exportTransparent, setExportTransparent] = useState(false) const [customLanguage, setCustomLanguage] = useState("") - const menuRef = useRef(null) + const { actions: aiActions, processingKey, runAction } = useAiSparkActions() const runLocalTransform = useLocalTransform() @@ -140,19 +151,15 @@ export function CanvasContextMenu({ wrapRef, store, rendererRef }: CanvasContext [aiActions, isLocal], ) - const closeMenu = useCallback(() => { - setMenuPos(null) - setAiOpen(false) - setTranslateOpen(false) - }, []) + const closeMenu = useCallback(() => setMenuPos(null), []) - // Right-click trigger — only opens when something is selected. + // Right-click trigger — only opens when something is selected, and never over + // an editor input (so native text editing keeps its own menu). useEffect(() => { const wrap = wrapRef.current if (!wrap) return const onContext = (e: MouseEvent): void => { const target = e.target as HTMLElement | null - // Skip when right-clicking inside an editor input. if ( target && (target.tagName === "INPUT" || @@ -164,51 +171,18 @@ export function CanvasContextMenu({ wrapRef, store, rendererRef }: CanvasContext if (store.getSelection().length === 0) return e.preventDefault() setMenuPos({ x: e.clientX, y: e.clientY }) - setAiOpen(false) - setTranslateOpen(false) } wrap.addEventListener("contextmenu", onContext) return () => wrap.removeEventListener("contextmenu", onContext) }, [wrapRef, store]) - // Close on outside click / Esc. - useEffect(() => { - if (!menuPos) return - const onDown = (e: MouseEvent): void => { - const target = e.target as HTMLElement | null - if (target && menuRef.current?.contains(target)) return - closeMenu() - } - const onKey = (e: KeyboardEvent): void => { - if (e.key === "Escape") closeMenu() - } - window.addEventListener("mousedown", onDown, true) - window.addEventListener("keydown", onKey) - return () => { - window.removeEventListener("mousedown", onDown, true) - window.removeEventListener("keydown", onKey) - } - }, [menuPos, closeMenu]) - const selection = useCallback(() => store.getSelection(), [store]) // ---- Position --------------------------------------------------------- - const handleSendBackward = useCallback(() => { - store.sendBackward(selection()) - closeMenu() - }, [store, selection, closeMenu]) - const handleSendForward = useCallback(() => { - store.bringForward(selection()) - closeMenu() - }, [store, selection, closeMenu]) - const handleSendToBack = useCallback(() => { - store.sendToBack(selection()) - closeMenu() - }, [store, selection, closeMenu]) - const handleSendToFront = useCallback(() => { - store.bringToFront(selection()) - closeMenu() - }, [store, selection, closeMenu]) + const handleSendBackward = useCallback(() => store.sendBackward(selection()), [store, selection]) + const handleSendForward = useCallback(() => store.bringForward(selection()), [store, selection]) + const handleSendToBack = useCallback(() => store.sendToBack(selection()), [store, selection]) + const handleSendToFront = useCallback(() => store.bringToFront(selection()), [store, selection]) // ---- Export ----------------------------------------------------------- const handleExportPng = useCallback(async () => { @@ -239,10 +213,8 @@ export function CanvasContextMenu({ wrapRef, store, rendererRef }: CanvasContext } catch (err) { console.error("[context-menu] PNG export failed", err) toast.error("Couldn't export selection") - } finally { - closeMenu() } - }, [store, exportTransparent, closeMenu, rendererRef]) + }, [store, exportTransparent, rendererRef]) const handleExportSvg = useCallback(() => { try { @@ -257,12 +229,10 @@ export function CanvasContextMenu({ wrapRef, store, rendererRef }: CanvasContext } catch (err) { console.error("[context-menu] SVG export failed", err) toast.error("Couldn't export selection") - } finally { - closeMenu() } - }, [store, closeMenu]) + }, [store]) - // ---- AI Spark / Translate --------------------------------------------- + // ---- AI / Translate --------------------------------------------------- const handleAiAction = useCallback( async (actionKey: string) => { if (!boardId) { @@ -274,7 +244,6 @@ export function CanvasContextMenu({ wrapRef, store, rendererRef }: CanvasContext toast.error("Select at least one node with content.") return } - closeMenu() // Local board: run the in-browser transform instead of the backend /tools. if (isLocal) { const kind = LOCAL_TRANSFORM_BY_ACTION[actionKey] @@ -297,7 +266,7 @@ export function CanvasContextMenu({ wrapRef, store, rendererRef }: CanvasContext } await runAction({ boardId, contextText, actionKey }) }, - [boardId, store, runAction, closeMenu, isLocal, runLocalTransform], + [boardId, store, runAction, isLocal, runLocalTransform], ) const handleTranslate = useCallback( @@ -311,7 +280,6 @@ export function CanvasContextMenu({ wrapRef, store, rendererRef }: CanvasContext toast.error("Select at least one node with content.") return } - closeMenu() await runAction({ boardId, contextText, @@ -319,205 +287,148 @@ export function CanvasContextMenu({ wrapRef, store, rendererRef }: CanvasContext targetLanguage: language, }) }, - [boardId, store, runAction, closeMenu], + [boardId, store, runAction], ) - if (!menuPos) return null - return ( -
e.preventDefault()} - onMouseDown={(e) => e.stopPropagation()} - > - Position - - - - - - - Export - void handleExportPng()} - /> -
- Transparent background -
- - -
-
- - - {showAiSection && (<> - - - {aiOpen && ( -
- {aiMenuActions.map((action) => { - const visual = AI_SPARK_VISUALS.find((v) => v.key === action.key) - const Icon = visual?.Icon ?? SparklesIcon - return ( - void handleAiAction(action.key)} - /> - ) - })} - {!isLocal && (<> - - {translateOpen && ( -
-
- setCustomLanguage(e.target.value)} - onMouseDown={(e) => e.stopPropagation()} - /> - -
-
- {COMMON_LANGUAGES.map((language) => ( - - ))} -
-
- )} - )} -
- )} - )} -
- ) -} - - -type MenuButtonProps = { - icon: typeof ArticleSummaryIcon - label: string - onClick: () => void - disabled?: boolean - badge?: string - iconClassName?: string -} - - -function MenuButton({ - icon: Icon, - label, - onClick, - disabled, - badge, - iconClassName = "", -}: MenuButtonProps) { - return ( - - ) -} - - -function SectionLabel({ children }: { children: React.ReactNode }) { - return ( -
- {children} -
+ setCustomLanguage(e.target.value)} + onPointerDown={(e) => e.stopPropagation()} + /> + + + + + )} + + )} + + ) } - - -function Divider() { - return
-}