From 7815c4ceecbebc524e2bb29b730e6976fd840f3e Mon Sep 17 00:00:00 2001 From: DeadWaveWave Date: Mon, 30 Mar 2026 16:30:02 +0800 Subject: [PATCH 1/4] fix: unify shared menu positioning --- .../components/ViewportMenuSurface.tsx | 235 ++++++++++++++++++ .../components/viewportMenuPlacement.ts | 143 +++++++++++ .../shell/components/ProjectContextMenu.tsx | 22 +- .../components/SpaceArchiveRecordsWindow.tsx | 60 ++--- .../renderer/components/TaskNode.tsx | 3 + .../TaskPromptTemplatesMenu.tsx | 77 ++---- .../taskNode/TaskNodeAgentSessions.tsx | 55 ++-- .../view/WorkspaceCanvasTopOverlays.tsx | 82 ++---- .../view/WorkspaceContextArrangeBySubmenu.tsx | 17 +- .../view/WorkspaceContextMenu.helpers.ts | 115 +-------- .../view/WorkspaceContextMenuParts.tsx | 33 +-- .../view/WorkspaceSpaceActionMenu.tsx | 37 +-- .../windows/TaskCreatorWindow.tsx | 5 +- .../windows/TaskEditorWindow.tsx | 3 + tests/e2e/workspace-canvas.helpers.ts | 2 + ...ce-canvas.tasks.agent-session-menu.spec.ts | 91 +++++++ ...pace-canvas.tasks.prompt-templates.spec.ts | 52 +++- .../workspaceContextMenu.helpers.spec.ts | 15 ++ 18 files changed, 697 insertions(+), 350 deletions(-) create mode 100644 src/app/renderer/components/ViewportMenuSurface.tsx create mode 100644 src/app/renderer/components/viewportMenuPlacement.ts create mode 100644 tests/e2e/workspace-canvas.tasks.agent-session-menu.spec.ts diff --git a/src/app/renderer/components/ViewportMenuSurface.tsx b/src/app/renderer/components/ViewportMenuSurface.tsx new file mode 100644 index 00000000..eec23e67 --- /dev/null +++ b/src/app/renderer/components/ViewportMenuSurface.tsx @@ -0,0 +1,235 @@ +import React from 'react' +import { createPortal } from 'react-dom' +import { + placeViewportMenuAtPoint, + type MenuPoint, + type MenuPointAlignment, + type MenuSize, +} from './viewportMenuPlacement' + +interface AbsoluteViewportMenuPlacement { + type: 'absolute' + left: number + top: number +} + +interface PointViewportMenuPlacement { + type: 'point' + point: MenuPoint + alignX?: MenuPointAlignment + alignY?: MenuPointAlignment + padding?: number + estimatedSize?: MenuSize +} + +export type ViewportMenuPlacement = AbsoluteViewportMenuPlacement | PointViewportMenuPlacement + +export interface ViewportMenuSurfaceProps extends Omit< + React.HTMLAttributes, + 'children' +> { + open: boolean + placement: ViewportMenuPlacement + children: React.ReactNode + onDismiss?: () => void + dismissOnPointerDownOutside?: boolean + dismissOnEscape?: boolean + dismissIgnoreRefs?: Array> + stopEventPropagation?: boolean +} + +function assignRef(ref: React.ForwardedRef, value: T): void { + if (typeof ref === 'function') { + ref(value) + return + } + + if (ref) { + ref.current = value + } +} + +function callHandler( + handler: ((event: E) => void) | undefined, + event: E, +): void { + handler?.(event) +} + +export const ViewportMenuSurface = React.forwardRef( + function ViewportMenuSurface( + { + open, + placement, + children, + onDismiss, + dismissOnPointerDownOutside = false, + dismissOnEscape = false, + dismissIgnoreRefs = [], + stopEventPropagation = true, + style, + onMouseDown, + onClick, + ...rest + }, + forwardedRef, + ): React.JSX.Element | null { + const surfaceRef = React.useRef(null) + const [measuredSize, setMeasuredSize] = React.useState(null) + + const setRefs = React.useCallback( + (node: HTMLDivElement | null) => { + surfaceRef.current = node + assignRef(forwardedRef, node) + }, + [forwardedRef], + ) + + React.useLayoutEffect(() => { + if (!open) { + setMeasuredSize(null) + return + } + + if (placement.type !== 'point') { + return + } + + const element = surfaceRef.current + if (!element) { + setMeasuredSize(null) + return + } + + const updateMeasuredSize = (): void => { + const rect = element.getBoundingClientRect() + setMeasuredSize(previous => + previous !== null && + Math.abs(previous.width - rect.width) < 0.5 && + Math.abs(previous.height - rect.height) < 0.5 + ? previous + : { width: rect.width, height: rect.height }, + ) + } + + updateMeasuredSize() + + if (typeof ResizeObserver === 'undefined') { + return + } + + const observer = new ResizeObserver(() => { + updateMeasuredSize() + }) + observer.observe(element) + + return () => { + observer.disconnect() + } + }, [open, placement]) + + React.useEffect(() => { + if (!open) { + return + } + + if (!onDismiss || (!dismissOnPointerDownOutside && !dismissOnEscape)) { + return + } + + const shouldIgnoreTarget = (target: EventTarget | null): boolean => { + if (!(target instanceof Node)) { + return false + } + + if (surfaceRef.current?.contains(target)) { + return true + } + + return dismissIgnoreRefs.some(ref => ref.current?.contains(target) ?? false) + } + + const handlePointerDown = (event: PointerEvent): void => { + if (!dismissOnPointerDownOutside) { + return + } + + if (shouldIgnoreTarget(event.target)) { + return + } + + onDismiss() + } + + const handleKeyDown = (event: KeyboardEvent): void => { + if (!dismissOnEscape || event.key !== 'Escape') { + return + } + + onDismiss() + } + + document.addEventListener('pointerdown', handlePointerDown, true) + window.addEventListener('keydown', handleKeyDown, true) + + return () => { + document.removeEventListener('pointerdown', handlePointerDown, true) + window.removeEventListener('keydown', handleKeyDown, true) + } + }, [dismissIgnoreRefs, dismissOnEscape, dismissOnPointerDownOutside, onDismiss, open]) + + const resolvedPosition = React.useMemo(() => { + if (placement.type === 'absolute') { + return { + left: placement.left, + top: placement.top, + } + } + + const viewportWidth = typeof window === 'undefined' ? 1280 : window.innerWidth + const viewportHeight = typeof window === 'undefined' ? 720 : window.innerHeight + + return placeViewportMenuAtPoint({ + point: placement.point, + menuSize: measuredSize ?? placement.estimatedSize ?? { width: 0, height: 0 }, + viewport: { width: viewportWidth, height: viewportHeight }, + padding: placement.padding, + alignX: placement.alignX, + alignY: placement.alignY, + }) + }, [measuredSize, placement]) + + if (!open || typeof document === 'undefined' || !document.body) { + return null + } + + return createPortal( +
{ + if (stopEventPropagation) { + event.stopPropagation() + } + + callHandler(onMouseDown, event) + }} + onClick={event => { + if (stopEventPropagation) { + event.stopPropagation() + } + + callHandler(onClick, event) + }} + > + {children} +
, + document.body, + ) + }, +) diff --git a/src/app/renderer/components/viewportMenuPlacement.ts b/src/app/renderer/components/viewportMenuPlacement.ts new file mode 100644 index 00000000..6e903e92 --- /dev/null +++ b/src/app/renderer/components/viewportMenuPlacement.ts @@ -0,0 +1,143 @@ +export const VIEWPORT_MENU_PADDING = 12 + +export interface MenuViewportSize { + width: number + height: number +} + +export interface MenuSize { + width: number + height: number +} + +export interface MenuRect { + left: number + top: number + width: number + height: number +} + +export interface MenuPoint { + x: number + y: number +} + +export type MenuPointAlignment = 'start' | 'end' | 'auto' + +function clampMenuCoordinate( + origin: number, + size: number, + viewportExtent: number, + padding: number, +): number { + return Math.max(padding, Math.min(origin, Math.max(padding, viewportExtent - padding - size))) +} + +function resolveAlignedCoordinate(options: { + origin: number + size: number + viewportExtent: number + padding: number + alignment: MenuPointAlignment +}): number { + const { origin, size, viewportExtent, padding, alignment } = options + const startCoordinate = origin + const endCoordinate = origin - size + + if (alignment === 'start') { + return clampMenuCoordinate(startCoordinate, size, viewportExtent, padding) + } + + if (alignment === 'end') { + return clampMenuCoordinate(endCoordinate, size, viewportExtent, padding) + } + + const startFits = startCoordinate + size <= viewportExtent - padding + const endFits = endCoordinate >= padding + + if (startFits || !endFits) { + return clampMenuCoordinate(startCoordinate, size, viewportExtent, padding) + } + + return clampMenuCoordinate(endCoordinate, size, viewportExtent, padding) +} + +export function placeViewportMenuAtPoint(options: { + point: MenuPoint + menuSize: MenuSize + viewport: MenuViewportSize + padding?: number + alignX?: MenuPointAlignment + alignY?: MenuPointAlignment +}): { left: number; top: number } { + const padding = options.padding ?? VIEWPORT_MENU_PADDING + + return { + left: resolveAlignedCoordinate({ + origin: options.point.x, + size: options.menuSize.width, + viewportExtent: options.viewport.width, + padding, + alignment: options.alignX ?? 'start', + }), + top: resolveAlignedCoordinate({ + origin: options.point.y, + size: options.menuSize.height, + viewportExtent: options.viewport.height, + padding, + alignment: options.alignY ?? 'start', + }), + } +} + +export function placeViewportSubmenuAtItem(options: { + parentMenuRect: MenuRect + itemRect: MenuRect + submenuSize: MenuSize + viewport: MenuViewportSize + padding?: number + gap?: number +}): { left: number; top: number; side: 'left' | 'right' } { + const padding = options.padding ?? VIEWPORT_MENU_PADDING + const gap = options.gap ?? 6 + const preferredRight = options.parentMenuRect.left + options.parentMenuRect.width + gap + const preferredLeft = options.parentMenuRect.left - gap - options.submenuSize.width + const fitsRight = preferredRight + options.submenuSize.width <= options.viewport.width - padding + const fitsLeft = preferredLeft >= padding + + let side: 'left' | 'right' = 'right' + let rawLeft = preferredRight + + if (!fitsRight && fitsLeft) { + side = 'left' + rawLeft = preferredLeft + } else if (!fitsRight && !fitsLeft) { + const availableRight = options.viewport.width - padding - preferredRight + const availableLeft = options.parentMenuRect.left - gap - padding + side = availableLeft > availableRight ? 'left' : 'right' + rawLeft = side === 'left' ? preferredLeft : preferredRight + } + + return { + side, + left: clampMenuCoordinate(rawLeft, options.submenuSize.width, options.viewport.width, padding), + top: clampMenuCoordinate( + options.itemRect.top, + options.submenuSize.height, + options.viewport.height, + padding, + ), + } +} + +export function isPointWithinRect( + point: { x: number; y: number }, + rect: { x: number; y: number; width: number; height: number }, +): boolean { + return ( + point.x >= rect.x && + point.y >= rect.y && + point.x <= rect.x + rect.width && + point.y <= rect.y + rect.height + ) +} diff --git a/src/app/renderer/shell/components/ProjectContextMenu.tsx b/src/app/renderer/shell/components/ProjectContextMenu.tsx index 1acb0317..7e261c5a 100644 --- a/src/app/renderer/shell/components/ProjectContextMenu.tsx +++ b/src/app/renderer/shell/components/ProjectContextMenu.tsx @@ -1,6 +1,7 @@ import React from 'react' import { FolderX } from 'lucide-react' import { useTranslation } from '@app/renderer/i18n' +import { ViewportMenuSurface } from '@app/renderer/components/ViewportMenuSurface' export function ProjectContextMenu({ workspaceId, @@ -16,17 +17,16 @@ export function ProjectContextMenu({ const { t } = useTranslation() return ( -
{ - event.stopPropagation() - }} - onClick={event => { - event.stopPropagation() + placement={{ + type: 'point', + point: { x, y }, + estimatedSize: { + width: 188, + height: 56, + }, }} > -
+ ) } diff --git a/src/app/renderer/shell/components/SpaceArchiveRecordsWindow.tsx b/src/app/renderer/shell/components/SpaceArchiveRecordsWindow.tsx index 0fb7b7ab..1d86db2d 100644 --- a/src/app/renderer/shell/components/SpaceArchiveRecordsWindow.tsx +++ b/src/app/renderer/shell/components/SpaceArchiveRecordsWindow.tsx @@ -1,6 +1,7 @@ import React from 'react' import { Trash2 } from 'lucide-react' import { useTranslation } from '@app/renderer/i18n' +import { ViewportMenuSurface } from '@app/renderer/components/ViewportMenuSurface' import type { SpaceArchiveRecord, WorkspaceState, @@ -72,37 +73,6 @@ export function SpaceArchiveRecordsWindow({ } }, [isOpen]) - React.useEffect(() => { - if (!recordContextMenu) { - return - } - - const closeMenu = (event: MouseEvent): void => { - if ( - event.target instanceof Element && - event.target.closest('.space-archives-window__record-context-menu') - ) { - return - } - - setRecordContextMenu(null) - } - - const handleEscape = (event: KeyboardEvent): void => { - if (event.key === 'Escape') { - setRecordContextMenu(null) - } - } - - window.addEventListener('mousedown', closeMenu) - window.addEventListener('keydown', handleEscape) - - return () => { - window.removeEventListener('mousedown', closeMenu) - window.removeEventListener('keydown', handleEscape) - } - }, [recordContextMenu]) - const selectedRecord = selectedRecordId ? (records.find(record => record.id === selectedRecordId) ?? null) : null @@ -257,18 +227,28 @@ export function SpaceArchiveRecordsWindow({ )} {recordContextMenu ? ( -
{ - event.stopPropagation() + onDismiss={() => { + setRecordContextMenu(null) }} - onClick={event => { - event.stopPropagation() + dismissOnPointerDownOutside={true} + dismissOnEscape={true} + style={{ + zIndex: 25, }} > -
+ ) : null} diff --git a/src/contexts/task/presentation/renderer/components/TaskNode.tsx b/src/contexts/task/presentation/renderer/components/TaskNode.tsx index 95ed9b4c..61cc253e 100644 --- a/src/contexts/task/presentation/renderer/components/TaskNode.tsx +++ b/src/contexts/task/presentation/renderer/components/TaskNode.tsx @@ -96,6 +96,7 @@ export function TaskNode({ x: number y: number } | null>(null) + const promptTemplatesTriggerRef = React.useRef(null) const { draftFrame, handleResizePointerDown } = useNodeFrameResize({ position, @@ -305,6 +306,7 @@ export function TaskNode({
-
+ ) }, [ anchor, @@ -356,8 +325,10 @@ export function TaskPromptTemplatesMenu({ isOpen, openCreateDialog, projectTemplates, + closeMenu, t, testIdPrefix, + triggerRef, workspaceId, ]) diff --git a/src/contexts/task/presentation/renderer/components/taskNode/TaskNodeAgentSessions.tsx b/src/contexts/task/presentation/renderer/components/taskNode/TaskNodeAgentSessions.tsx index a19e5c61..591b3374 100644 --- a/src/contexts/task/presentation/renderer/components/taskNode/TaskNodeAgentSessions.tsx +++ b/src/contexts/task/presentation/renderer/components/taskNode/TaskNodeAgentSessions.tsx @@ -1,6 +1,7 @@ -import { useEffect, useMemo, useState, type JSX } from 'react' +import { useMemo, useState, type JSX } from 'react' import { RotateCcw, Trash2 } from 'lucide-react' import { useTranslation } from '@app/renderer/i18n' +import { ViewportMenuSurface } from '@app/renderer/components/ViewportMenuSurface' import type { AgentProvider } from '@contexts/settings/domain/agentSettings' import { isResumeSessionBindingVerified } from '@contexts/agent/domain/agentResumeBinding' import type { @@ -39,34 +40,6 @@ export function TaskNodeAgentSessions({ } | null>(null) const [resumeConfirmRecordId, setResumeConfirmRecordId] = useState(null) - useEffect(() => { - if (!agentSessionMenu) { - return - } - - const closeMenu = (event: MouseEvent): void => { - if (event.target instanceof Element && event.target.closest('.task-agent-session-menu')) { - return - } - - setAgentSessionMenu(null) - } - - const handleEscape = (event: KeyboardEvent): void => { - if (event.key === 'Escape') { - setAgentSessionMenu(null) - } - } - - window.addEventListener('mousedown', closeMenu) - window.addEventListener('keydown', handleEscape) - - return () => { - window.removeEventListener('mousedown', closeMenu) - window.removeEventListener('keydown', handleEscape) - } - }, [agentSessionMenu]) - const sortedAgentSessions = useMemo(() => { return [...agentSessions].sort((left, right) => { const leftTime = Date.parse(left.lastRunAt) @@ -192,16 +165,26 @@ export function TaskNodeAgentSessions({ {agentSessionMenu ? ( -
{ - event.stopPropagation() + placement={{ + type: 'point', + point: { + x: agentSessionMenu.x, + y: agentSessionMenu.y, + }, + estimatedSize: { + width: 188, + height: 96, + }, }} - onClick={event => { - event.stopPropagation() + onDismiss={() => { + setAgentSessionMenu(null) }} + dismissOnPointerDownOutside={true} + dismissOnEscape={true} > {agentSessionMenuRecord && isResumeSessionBindingVerified(agentSessionMenuRecord) ? ( -
+ ) : null} {resumeConfirmRecord ? ( diff --git a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/view/WorkspaceCanvasTopOverlays.tsx b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/view/WorkspaceCanvasTopOverlays.tsx index 3b3163c1..8e35ee3b 100644 --- a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/view/WorkspaceCanvasTopOverlays.tsx +++ b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/view/WorkspaceCanvasTopOverlays.tsx @@ -1,4 +1,5 @@ import React from 'react' +import { ViewportMenuSurface } from '@app/renderer/components/ViewportMenuSurface' import { ChevronDown, Tag, X } from 'lucide-react' import { useTranslation } from '@app/renderer/i18n' import { LABEL_COLORS, type LabelColor } from '@shared/types/labelColor' @@ -29,7 +30,6 @@ export function WorkspaceCanvasTopOverlays({ const { t } = useTranslation() const [isFilterMenuOpen, setIsFilterMenuOpen] = React.useState(false) const filterTriggerRef = React.useRef(null) - const filterMenuRef = React.useRef(null) const orderedUsedLabelColors = React.useMemo(() => { const usedSet = new Set(usedLabelColors) @@ -42,65 +42,28 @@ export function WorkspaceCanvasTopOverlays({ return ordered }, [usedLabelColors, activeLabelColorFilter]) - const filterMenuStyle = React.useMemo((): React.CSSProperties | undefined => { + const filterMenuPlacement = React.useMemo(() => { if (!isFilterMenuOpen) { - return undefined + return null } const rect = filterTriggerRef.current?.getBoundingClientRect() ?? null - const viewportWidth = typeof window === 'undefined' ? 1280 : window.innerWidth - const viewportHeight = typeof window === 'undefined' ? 720 : window.innerHeight - - const menuWidth = 196 - const menuHeight = 280 - const viewportPadding = 12 - const offset = 6 - - const anchorLeft = rect?.left ?? viewportPadding - const anchorTop = rect?.bottom ?? viewportPadding - - const left = Math.min(anchorLeft, viewportWidth - menuWidth - viewportPadding) - const top = Math.min(anchorTop + offset, viewportHeight - menuHeight - viewportPadding) - - return { top, left } + return { + type: 'point' as const, + point: { + x: rect?.left ?? 12, + y: (rect?.bottom ?? 12) + 6, + }, + estimatedSize: { + width: 196, + height: 280, + }, + } }, [isFilterMenuOpen]) const hasAnyOverlay = selectedNodeCount > 0 || spaces.length > 0 || orderedUsedLabelColors.length > 0 - React.useEffect(() => { - if (!isFilterMenuOpen) { - return - } - - const onPointerDown = (event: PointerEvent) => { - const target = event.target - if (!(target instanceof Node)) { - setIsFilterMenuOpen(false) - return - } - - if (filterTriggerRef.current?.contains(target) || filterMenuRef.current?.contains(target)) { - return - } - - setIsFilterMenuOpen(false) - } - - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - setIsFilterMenuOpen(false) - } - } - - window.addEventListener('pointerdown', onPointerDown, { capture: true }) - window.addEventListener('keydown', onKeyDown, { capture: true }) - return () => { - window.removeEventListener('pointerdown', onPointerDown, { capture: true }) - window.removeEventListener('keydown', onKeyDown, { capture: true }) - } - }, [isFilterMenuOpen]) - if (!hasAnyOverlay) { return null } @@ -176,16 +139,19 @@ export function WorkspaceCanvasTopOverlays({ ) : null} - {isFilterMenuOpen ? ( -
{ - event.stopPropagation() + onDismiss={() => { + setIsFilterMenuOpen(false) }} + dismissOnPointerDownOutside={true} + dismissOnEscape={true} + dismissIgnoreRefs={[filterTriggerRef]} > ))} -
+ ) : null} ) : null} diff --git a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/view/WorkspaceContextArrangeBySubmenu.tsx b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/view/WorkspaceContextArrangeBySubmenu.tsx index 0efd936b..7bf7d8c3 100644 --- a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/view/WorkspaceContextArrangeBySubmenu.tsx +++ b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/view/WorkspaceContextArrangeBySubmenu.tsx @@ -1,5 +1,6 @@ import React from 'react' import { Check } from 'lucide-react' +import { ViewportMenuSurface } from '@app/renderer/components/ViewportMenuSurface' import { useTranslation } from '@app/renderer/i18n' import type { WorkspaceSpaceState } from '../../../types' import type { @@ -47,16 +48,18 @@ export function WorkspaceContextArrangeBySubmenu({ const { t } = useTranslation() return ( -
{ - event.stopPropagation() + placement={{ + type: 'absolute', + top: style.top as number, + left: style.left as number, }} - onClick={event => { - event.stopPropagation() + style={{ + maxHeight: style.maxHeight, }} > -
+ ) } diff --git a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/view/WorkspaceContextMenu.helpers.ts b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/view/WorkspaceContextMenu.helpers.ts index 3c444f08..a5e184c9 100644 --- a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/view/WorkspaceContextMenu.helpers.ts +++ b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/view/WorkspaceContextMenu.helpers.ts @@ -1,113 +1,12 @@ +export { + isPointWithinRect, + placeViewportMenuAtPoint as placeContextMenuAtPoint, + placeViewportSubmenuAtItem as placeSubmenuAtItem, + VIEWPORT_MENU_PADDING as VIEWPORT_PADDING, +} from '@app/renderer/components/viewportMenuPlacement' + export const MENU_WIDTH = 188 export const SUBMENU_WIDTH = 240 -export const VIEWPORT_PADDING = 12 export const SUBMENU_GAP = 6 export const SUBMENU_CLOSE_DELAY_MS = 120 export const SUBMENU_MAX_HEIGHT = 640 - -export interface MenuViewportSize { - width: number - height: number -} - -export interface MenuSize { - width: number - height: number -} - -export interface MenuRect { - left: number - top: number - width: number - height: number -} - -export interface MenuPoint { - x: number - y: number -} - -function clampMenuCoordinate( - origin: number, - size: number, - viewportExtent: number, - padding: number, -): number { - return Math.max(padding, Math.min(origin, Math.max(padding, viewportExtent - padding - size))) -} - -export function placeContextMenuAtPoint(options: { - point: MenuPoint - menuSize: MenuSize - viewport: MenuViewportSize - padding?: number -}): { left: number; top: number } { - const padding = options.padding ?? VIEWPORT_PADDING - - return { - left: clampMenuCoordinate( - options.point.x, - options.menuSize.width, - options.viewport.width, - padding, - ), - top: clampMenuCoordinate( - options.point.y, - options.menuSize.height, - options.viewport.height, - padding, - ), - } -} - -export function placeSubmenuAtItem(options: { - parentMenuRect: MenuRect - itemRect: MenuRect - submenuSize: MenuSize - viewport: MenuViewportSize - padding?: number - gap?: number -}): { left: number; top: number; side: 'left' | 'right' } { - const padding = options.padding ?? VIEWPORT_PADDING - const gap = options.gap ?? SUBMENU_GAP - const preferredRight = options.parentMenuRect.left + options.parentMenuRect.width + gap - const preferredLeft = options.parentMenuRect.left - gap - options.submenuSize.width - const fitsRight = preferredRight + options.submenuSize.width <= options.viewport.width - padding - const fitsLeft = preferredLeft >= padding - - let side: 'left' | 'right' = 'right' - let rawLeft = preferredRight - - if (!fitsRight && fitsLeft) { - side = 'left' - rawLeft = preferredLeft - } else if (!fitsRight && !fitsLeft) { - const availableRight = options.viewport.width - padding - preferredRight - const availableLeft = options.parentMenuRect.left - gap - padding - side = availableLeft > availableRight ? 'left' : 'right' - rawLeft = side === 'left' ? preferredLeft : preferredRight - } - - return { - side, - left: clampMenuCoordinate(rawLeft, options.submenuSize.width, options.viewport.width, padding), - top: clampMenuCoordinate( - options.itemRect.top, - options.submenuSize.height, - options.viewport.height, - padding, - ), - } -} - -export function isPointWithinRect( - point: { x: number; y: number }, - rect: { x: number; y: number; width: number; height: number }, -): boolean { - return ( - point.x >= rect.x && - point.y >= rect.y && - point.x <= rect.x + rect.width && - point.y <= rect.y + rect.height - ) -} diff --git a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/view/WorkspaceContextMenuParts.tsx b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/view/WorkspaceContextMenuParts.tsx index 23ec90a2..5319269b 100644 --- a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/view/WorkspaceContextMenuParts.tsx +++ b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/view/WorkspaceContextMenuParts.tsx @@ -1,4 +1,5 @@ import React from 'react' +import { ViewportMenuSurface } from '@app/renderer/components/ViewportMenuSurface' import { ArrowRight, Check, @@ -276,16 +277,18 @@ export function WorkspaceContextAgentProviderSubmenu({ openAgentLauncherForProvider: (provider: AgentProvider) => void }): React.JSX.Element { return ( -
{ - event.stopPropagation() + placement={{ + type: 'absolute', + top: style.top as number, + left: style.left as number, }} - onClick={event => { - event.stopPropagation() + style={{ + maxHeight: style.maxHeight, }} onMouseEnter={keepSubmenuOpen} onMouseLeave={scheduleSubmenuClose} @@ -303,7 +306,7 @@ export function WorkspaceContextAgentProviderSubmenu({ {AGENT_PROVIDER_LABEL[provider]} ))} -
+ ) } @@ -325,16 +328,18 @@ export function WorkspaceContextLabelColorSubmenu({ const { t } = useTranslation() return ( -
{ - event.stopPropagation() + placement={{ + type: 'absolute', + top: style.top as number, + left: style.left as number, }} - onClick={event => { - event.stopPropagation() + style={{ + maxHeight: style.maxHeight, }} onMouseEnter={keepSubmenuOpen} onMouseLeave={scheduleSubmenuClose} @@ -387,6 +392,6 @@ export function WorkspaceContextLabelColorSubmenu({ {t(`labelColors.${color}`)} ))} -
+ ) } diff --git a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/view/WorkspaceSpaceActionMenu.tsx b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/view/WorkspaceSpaceActionMenu.tsx index 18d14cdc..24f802a9 100644 --- a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/view/WorkspaceSpaceActionMenu.tsx +++ b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/view/WorkspaceSpaceActionMenu.tsx @@ -1,4 +1,5 @@ import React from 'react' +import { ViewportMenuSurface } from '@app/renderer/components/ViewportMenuSurface' import { ChevronRight, Copy, @@ -128,12 +129,14 @@ export function WorkspaceSpaceActionMenu({ return ( <> -
{ - event.stopPropagation() + placement={{ + type: 'absolute', + top: menuTop, + left: menuLeft, }} onMouseEnter={cancelScheduledSubmenuClose} onMouseLeave={scheduleSubmenuClose} @@ -261,15 +264,17 @@ export function WorkspaceSpaceActionMenu({ ) : null} -
+ {shouldShowOpenSubmenu ? ( -
{ - event.stopPropagation() + placement={{ + type: 'absolute', + top: submenuTop, + left: submenuLeft, }} onMouseEnter={() => { cancelScheduledSubmenuClose() @@ -290,16 +295,18 @@ export function WorkspaceSpaceActionMenu({ {opener.label} ))} -
+ ) : null} {shouldShowLabelColorSubmenu ? ( -
{ - event.stopPropagation() + placement={{ + type: 'absolute', + top: submenuTop, + left: submenuLeft, }} onMouseEnter={() => { cancelScheduledSubmenuClose() @@ -340,7 +347,7 @@ export function WorkspaceSpaceActionMenu({ {t(`labelColors.${color}`)} ))} -
+ ) : null} ) diff --git a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/windows/TaskCreatorWindow.tsx b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/windows/TaskCreatorWindow.tsx index b43b25e1..ad337433 100644 --- a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/windows/TaskCreatorWindow.tsx +++ b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/windows/TaskCreatorWindow.tsx @@ -1,4 +1,4 @@ -import React, { useLayoutEffect, useState, type Dispatch, type SetStateAction } from 'react' +import React, { useLayoutEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react' import { useTranslation } from '@app/renderer/i18n' import { AI_NAMING_FEATURES } from '@shared/featureFlags/aiNaming' import type { TaskPriority } from '../../../types' @@ -39,6 +39,7 @@ export function TaskCreatorWindow({ x: number y: number } | null>(null) + const promptTemplatesTriggerRef = useRef(null) const isTaskAiNamingEnabled = AI_NAMING_FEATURES.taskTitleGeneration const isTaskCreatorOpen = taskCreator !== null const isPromptTemplatesMenuOpen = promptTemplatesMenuAnchor !== null @@ -74,6 +75,7 @@ export function TaskCreatorWindow({