diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 0cf3ceb05..882eeb902 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -1408,6 +1408,26 @@ animation-timing-function: cubic-bezier(0.7, 0, 0.84, 0); } +/* Why: this board is attached to the worktree sidebar, not floating above it; + the generic Sheet elevation casts a haze over the sidebar when left open. */ +.workspace-kanban-sheet-content { + box-shadow: inset 1px 0 0 color-mix(in srgb, var(--worktree-sidebar-border) 80%, transparent); + backdrop-filter: none; +} + +/* Why: a sidebar-card drag should advertise the board as a possible drop + surface without fully committing the companion panel until the pointer enters it. */ +.workspace-kanban-sheet-content[data-workspace-board-drag-preview='true'] { + background: color-mix(in srgb, var(--worktree-sidebar) 88%, var(--background)); + box-shadow: + inset 0 0 0 1px color-mix(in srgb, var(--sidebar-ring) 58%, transparent), + 0 10px 24px color-mix(in srgb, var(--foreground) 12%, transparent); +} + +.workspace-kanban-sheet-content[data-workspace-board-drag-preview='true'] > * { + opacity: 0.42; +} + @keyframes workspace-kanban-sheet-in { from { clip-path: inset(0 100% 0 0); diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx index ba906cb6e..4eeea8a6d 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx @@ -37,6 +37,7 @@ import { useContextualTour } from '@/components/contextual-tours/use-contextual- type WorkspaceKanbanDrawerProps = { leftSidebarStyle?: React.CSSProperties open: boolean + dragPreview: boolean preserveOpenForMenu: boolean onOpenChange: (open: boolean) => void onMenuOpenChange: (open: boolean) => void @@ -45,6 +46,7 @@ type WorkspaceKanbanDrawerProps = { export default function WorkspaceKanbanDrawer({ leftSidebarStyle, open, + dragPreview, preserveOpenForMenu, onOpenChange, onMenuOpenChange @@ -444,7 +446,7 @@ export default function WorkspaceKanbanDrawer({ useWorkspaceKanbanShiftWheelScroll(boardRef, laneScrollerRef, open, isPointerDragActiveRef) useWorkspaceKanbanOutsideDismiss({ open, boardRef, preserveOpenForMenu, onOpenChange }) - useContextualTour('workspace-board', open, 'workspace_board_visible') + useContextualTour('workspace-board', open && !dragPreview, 'workspace_board_visible') useEffect(() => { if (!open || selectedWorktreeIds.size === 0) { @@ -494,6 +496,7 @@ export default function WorkspaceKanbanDrawer({ } data-contextual-tour-target="workspace-board-surface" data-workspace-board-sheet="" + data-workspace-board-drag-preview={dragPreview ? 'true' : undefined} onOpenAutoFocus={(event) => { // Why: Radix focuses the first toolbar button on open, which opens // its tooltip without hover and makes the drawer feel noisy. diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index c3ede5b67..c41b30f1b 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -142,6 +142,7 @@ import { getWorkspaceKanbanSidebarDropGroups, getWorkspaceKanbanSidebarDropTarget, hasWorkspaceKanbanSidebarDropBoard, + isWorkspaceKanbanSidebarDropPointInBoard, updateWorkspaceKanbanSidebarDropTargetVisual } from './workspace-kanban-sidebar-drop' import { @@ -158,6 +159,7 @@ import { setSidebarPointerDragDocumentStyles, updateSidebarDragPreviewPosition } from './worktree-sidebar-pointer-drag-dom' +import { shouldStartWorkspaceBoardDragPreview } from './workspace-board-drag-preview-intent' import { getWorktreeSidebarDragAutoscroll, getWorktreeSidebarDragRectsForGroup, @@ -265,6 +267,7 @@ const EMPTY_AGENT_STATUS_BY_PANE_KEY: AppState['agentStatusByPaneKey'] = {} const EMPTY_TABS_BY_WORKTREE: AppState['tabsByWorktree'] = {} const EMPTY_TERMINAL_LAYOUTS_BY_TAB_ID: AppState['terminalLayoutsByTabId'] = {} const EXPANDING_CARD_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS = 300 +const NOOP_WORKSPACE_BOARD_DRAG_PREVIEW_CALLBACK = (): void => {} const WORKTREE_SIDEBAR_SCROLL_STYLE: React.CSSProperties = { // Why: TanStack Virtual owns scroll correction. Native browser anchoring can // fight virtual row measurement/remounts and produce visible jumps. @@ -487,6 +490,10 @@ type VirtualizedWorktreeViewportProps = { dropIndex: number groups: readonly WorktreeDragGroup[] }) => void + workspaceBoardOpen: boolean + onWorkspaceBoardDragPreviewStart: () => void + onWorkspaceBoardDragPreviewCommit: () => void + onWorkspaceBoardDragPreviewCancel: () => void shouldShowWorkspaceBoardDropIndicator: ( worktreeIds: readonly string[], status: WorkspaceStatus @@ -740,6 +747,7 @@ type WorktreePointerDrag = { preview: HTMLElement | null previewOffsetX: number previewOffsetY: number + workspaceBoardDragPreviewRequested: boolean frameId: number | null latestBoardDropTarget: WorkspaceKanbanCardTrackedDropTarget | null latestStatusDropTarget: WorktreeSidebarTrackedStatusDropTarget | null @@ -1085,6 +1093,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp onPinWorktree, onPinWorktrees, onDropWorktreesOnWorkspaceBoard, + workspaceBoardOpen, + onWorkspaceBoardDragPreviewStart, + onWorkspaceBoardDragPreviewCommit, + onWorkspaceBoardDragPreviewCancel, shouldShowWorkspaceBoardDropIndicator, onReorderWorktrees, scrollOffsetRef, @@ -2108,7 +2120,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp setDragOverStatus(null) setPinDragOver(false) clearWorkspaceKanbanSidebarDropTargetVisual() - }, [cancelWorktreePointerAutoscroll]) + onWorkspaceBoardDragPreviewCancel() + }, [cancelWorktreePointerAutoscroll, onWorkspaceBoardDragPreviewCancel]) const clearWorktreeDrag = useCallback(() => { cleanupWorktreePointerDrag() @@ -2157,6 +2170,25 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp clearWorktreeDrag() return } + const previewSidebarContainer = scrollRef.current + if ( + !drag.workspaceBoardDragPreviewRequested && + !workspaceBoardOpen && + previewSidebarContainer + ) { + const sidebarRect = previewSidebarContainer.getBoundingClientRect() + if ( + shouldStartWorkspaceBoardDragPreview({ + pointerX: drag.currentX, + startX: drag.startX, + sidebarRight: sidebarRect.right + }) && + !hasWorkspaceKanbanSidebarDropBoard() + ) { + drag.workspaceBoardDragPreviewRequested = true + onWorkspaceBoardDragPreviewStart() + } + } const boardTarget = updateWorkspaceKanbanSidebarDropTargetVisual({ x: drag.currentX, y: drag.currentY, @@ -2170,6 +2202,9 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp x: drag.currentX, y: drag.currentY } + if (isWorkspaceKanbanSidebarDropPointInBoard(drag.currentX, drag.currentY)) { + onWorkspaceBoardDragPreviewCommit() + } if (boardTarget.status || boardTarget.isPinDrop) { drag.latestStatusDropTarget = null setDragOverStatus(null) @@ -2317,8 +2352,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp clearWorktreeDrag, computeWorktreeDrop, computeWorktreeStatusDrop, + onWorkspaceBoardDragPreviewStart, refreshWorktreeDragSession, + onWorkspaceBoardDragPreviewCommit, shouldShowWorkspaceBoardDropIndicator, + workspaceBoardOpen, workspaceStatuses ]) @@ -2437,7 +2475,14 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp return } const rects = getWorktreeSidebarDragRectsForGroup(container, sourceGroupKey) - if (rects.length <= 1 && !hasWorkspaceKanbanSidebarDropBoard()) { + const canPreviewWorkspaceBoardOnDrag = + !workspaceBoardOpen && + onWorkspaceBoardDragPreviewStart !== NOOP_WORKSPACE_BOARD_DRAG_PREVIEW_CALLBACK + if ( + rects.length <= 1 && + !hasWorkspaceKanbanSidebarDropBoard() && + !canPreviewWorkspaceBoardOnDrag + ) { return } const draggedIds = @@ -2463,6 +2508,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp preview: null, previewOffsetX: 0, previewOffsetY: 0, + workspaceBoardDragPreviewRequested: false, frameId: null, latestBoardDropTarget: null, latestStatusDropTarget: null @@ -2472,8 +2518,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp getReorderDraggedIds, getReorderUnitDraggedIds, groupKeyByRowKey, + onWorkspaceBoardDragPreviewStart, selectedWorktreeIds, - selectedWorktrees + selectedWorktrees, + workspaceBoardOpen ] ) @@ -2528,6 +2576,9 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp x: event.clientX, y: event.clientY }) + if (isWorkspaceKanbanSidebarDropPointInBoard(event.clientX, event.clientY)) { + onWorkspaceBoardDragPreviewCommit() + } if (boardDropTarget.isPinDrop) { onPinWorktrees(drag.draggedIds) } else if (boardDropTarget.status) { @@ -2649,6 +2700,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp onDropWorktreesOnWorkspaceBoard, onPinWorktrees, onReorderWorktrees, + onWorkspaceBoardDragPreviewCommit, refreshWorktreeDragSession, scheduleWorktreePointerDragFrame, shouldShowWorkspaceBoardDropIndicator, @@ -4268,6 +4320,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp type WorktreeListProps = { scrollOffsetRef: React.MutableRefObject scrollAnchorRef: React.MutableRefObject + workspaceBoardOpen?: boolean + onWorkspaceBoardDragPreviewStart?: () => void + onWorkspaceBoardDragPreviewCommit?: () => void + onWorkspaceBoardDragPreviewCancel?: () => void } export function installWorktreeVisibleRefreshVisibilityListener(onChange: () => void): () => void { @@ -4277,7 +4333,11 @@ export function installWorktreeVisibleRefreshVisibilityListener(onChange: () => const WorktreeList = React.memo(function WorktreeList({ scrollOffsetRef, - scrollAnchorRef + scrollAnchorRef, + workspaceBoardOpen = false, + onWorkspaceBoardDragPreviewStart = NOOP_WORKSPACE_BOARD_DRAG_PREVIEW_CALLBACK, + onWorkspaceBoardDragPreviewCommit = NOOP_WORKSPACE_BOARD_DRAG_PREVIEW_CALLBACK, + onWorkspaceBoardDragPreviewCancel = NOOP_WORKSPACE_BOARD_DRAG_PREVIEW_CALLBACK }: WorktreeListProps) { // ── Granular selectors (each is a primitive or shallow-stable ref) ── const allWorktrees = useAllWorktrees() @@ -5711,6 +5771,10 @@ const WorktreeList = React.memo(function WorktreeList({ onPinWorktree={pinWorktree} onPinWorktrees={pinWorktrees} onDropWorktreesOnWorkspaceBoard={dropWorktreesOnWorkspaceBoard} + workspaceBoardOpen={workspaceBoardOpen} + onWorkspaceBoardDragPreviewStart={onWorkspaceBoardDragPreviewStart} + onWorkspaceBoardDragPreviewCommit={onWorkspaceBoardDragPreviewCommit} + onWorkspaceBoardDragPreviewCancel={onWorkspaceBoardDragPreviewCancel} shouldShowWorkspaceBoardDropIndicator={shouldShowWorkspaceBoardDropIndicator} onReorderWorktrees={reorderWorktrees} scrollOffsetRef={scrollOffsetRef} diff --git a/src/renderer/src/components/sidebar/index.tsx b/src/renderer/src/components/sidebar/index.tsx index 7fdd8d336..9cf478afc 100644 --- a/src/renderer/src/components/sidebar/index.tsx +++ b/src/renderer/src/components/sidebar/index.tsx @@ -43,11 +43,16 @@ function Sidebar({ const { nativeDropTarget, dropHandlers, affordance } = useSidebarProjectDrop() const { workspaceBoardOpen, + workspaceBoardRenderedOpen, + workspaceBoardDragPreviewOpen, workspaceBoardMenuOpen, toggleWorkspaceBoard, handleWorkspaceBoardOpenChange, setWorkspaceBoardMenuOpen, - closeWorkspaceBoard + closeWorkspaceBoard, + previewWorkspaceBoardFromDrag, + solidifyWorkspaceBoardFromDrag, + cancelWorkspaceBoardDragPreview } = useWorkspaceBoardPanel() const setLiveSidebarWidth = React.useCallback((width: number) => { @@ -63,10 +68,10 @@ function Sidebar({ }, [repoCount, fetchAllWorktrees]) useEffect(() => { - if (!sidebarOpen && workspaceBoardOpen) { + if (!sidebarOpen && workspaceBoardRenderedOpen) { closeWorkspaceBoard() } - }, [closeWorkspaceBoard, sidebarOpen, workspaceBoardOpen]) + }, [closeWorkspaceBoard, sidebarOpen, workspaceBoardRenderedOpen]) const { containerRef, onResizeStart } = useSidebarResize({ isOpen: sidebarOpen, @@ -95,6 +100,10 @@ function Sidebar({ @@ -146,7 +155,8 @@ function Sidebar({ {sidebarOpen ? ( { await updatePanel((state) => state.toggleWorkspaceBoard()) expect(panelState().workspaceBoardOpen).toBe(true) + expect(panelState().workspaceBoardRenderedOpen).toBe(true) + expect(panelState().workspaceBoardDragPreviewOpen).toBe(false) expect(mocks.recordFeatureInteraction).toHaveBeenCalledExactlyOnceWith('workspace-board') await updatePanel((state) => state.toggleWorkspaceBoard()) expect(panelState().workspaceBoardOpen).toBe(false) + expect(panelState().workspaceBoardRenderedOpen).toBe(false) expect(mocks.recordFeatureInteraction).toHaveBeenCalledOnce() }) + it('renders a drag preview without recording an open interaction', async () => { + await renderHookProbe() + + await updatePanel((state) => state.previewWorkspaceBoardFromDrag()) + + expect(panelState().workspaceBoardOpen).toBe(false) + expect(panelState().workspaceBoardRenderedOpen).toBe(true) + expect(panelState().workspaceBoardDragPreviewOpen).toBe(true) + expect(mocks.recordFeatureInteraction).not.toHaveBeenCalled() + }) + + it('cancels an uncommitted drag preview', async () => { + await renderHookProbe() + + await updatePanel((state) => state.previewWorkspaceBoardFromDrag()) + await updatePanel((state) => state.cancelWorkspaceBoardDragPreview()) + + expect(panelState().workspaceBoardOpen).toBe(false) + expect(panelState().workspaceBoardRenderedOpen).toBe(false) + expect(panelState().workspaceBoardDragPreviewOpen).toBe(false) + }) + + it('solidifies a drag preview and keeps the board open after drag cleanup', async () => { + await renderHookProbe() + + await updatePanel((state) => state.previewWorkspaceBoardFromDrag()) + await updatePanel((state) => state.solidifyWorkspaceBoardFromDrag()) + await updatePanel((state) => state.cancelWorkspaceBoardDragPreview()) + + expect(panelState().workspaceBoardOpen).toBe(true) + expect(panelState().workspaceBoardRenderedOpen).toBe(true) + expect(panelState().workspaceBoardDragPreviewOpen).toBe(false) + expect(mocks.recordFeatureInteraction).toHaveBeenCalledExactlyOnceWith('workspace-board') + }) + it('keeps the board open on Escape while a nested board menu is open', async () => { await renderHookProbe() diff --git a/src/renderer/src/components/sidebar/useWorkspaceBoardPanel.ts b/src/renderer/src/components/sidebar/useWorkspaceBoardPanel.ts index 02d9edfb7..26efafadb 100644 --- a/src/renderer/src/components/sidebar/useWorkspaceBoardPanel.ts +++ b/src/renderer/src/components/sidebar/useWorkspaceBoardPanel.ts @@ -13,34 +13,50 @@ const WORKSPACE_BOARD_ESCAPE_BLOCKING_OVERLAY_SELECTOR = [ export type WorkspaceBoardPanelState = { workspaceBoardOpen: boolean + workspaceBoardRenderedOpen: boolean + workspaceBoardDragPreviewOpen: boolean workspaceBoardMenuOpen: boolean openWorkspaceBoard: () => void closeWorkspaceBoard: () => void toggleWorkspaceBoard: () => void handleWorkspaceBoardOpenChange: (open: boolean) => void setWorkspaceBoardMenuOpen: (open: boolean) => void + previewWorkspaceBoardFromDrag: () => void + solidifyWorkspaceBoardFromDrag: () => void + cancelWorkspaceBoardDragPreview: () => void } export function useWorkspaceBoardPanel(): WorkspaceBoardPanelState { const [workspaceBoardOpen, setWorkspaceBoardOpen] = useState(false) + const [workspaceBoardDragPreviewOpen, setWorkspaceBoardDragPreviewOpen] = useState(false) const [workspaceBoardMenuOpen, setWorkspaceBoardMenuOpen] = useState(false) const workspaceBoardOpenRef = useRef(workspaceBoardOpen) + const workspaceBoardDragPreviewOpenRef = useRef(workspaceBoardDragPreviewOpen) workspaceBoardOpenRef.current = workspaceBoardOpen + workspaceBoardDragPreviewOpenRef.current = workspaceBoardDragPreviewOpen const openWorkspaceBoard = useCallback(() => { if (workspaceBoardOpenRef.current) { + if (workspaceBoardDragPreviewOpenRef.current) { + workspaceBoardDragPreviewOpenRef.current = false + setWorkspaceBoardDragPreviewOpen(false) + } return } workspaceBoardOpenRef.current = true + workspaceBoardDragPreviewOpenRef.current = false // Why: opening the board is the user action; recording here avoids a // post-render bookkeeping Effect in the drawer. useAppStore.getState().recordFeatureInteraction('workspace-board') setWorkspaceBoardOpen(true) + setWorkspaceBoardDragPreviewOpen(false) }, []) const closeWorkspaceBoard = useCallback(() => { workspaceBoardOpenRef.current = false + workspaceBoardDragPreviewOpenRef.current = false setWorkspaceBoardOpen(false) + setWorkspaceBoardDragPreviewOpen(false) setWorkspaceBoardMenuOpen(false) }, []) @@ -63,6 +79,37 @@ export function useWorkspaceBoardPanel(): WorkspaceBoardPanelState { openWorkspaceBoard() }, [closeWorkspaceBoard, openWorkspaceBoard]) + const previewWorkspaceBoardFromDrag = useCallback(() => { + if (workspaceBoardOpenRef.current || workspaceBoardDragPreviewOpenRef.current) { + return + } + workspaceBoardDragPreviewOpenRef.current = true + setWorkspaceBoardDragPreviewOpen(true) + }, []) + + const solidifyWorkspaceBoardFromDrag = useCallback(() => { + if (workspaceBoardOpenRef.current) { + if (workspaceBoardDragPreviewOpenRef.current) { + workspaceBoardDragPreviewOpenRef.current = false + setWorkspaceBoardDragPreviewOpen(false) + } + return + } + workspaceBoardOpenRef.current = true + workspaceBoardDragPreviewOpenRef.current = false + useAppStore.getState().recordFeatureInteraction('workspace-board') + setWorkspaceBoardOpen(true) + setWorkspaceBoardDragPreviewOpen(false) + }, []) + + const cancelWorkspaceBoardDragPreview = useCallback(() => { + if (!workspaceBoardDragPreviewOpenRef.current) { + return + } + workspaceBoardDragPreviewOpenRef.current = false + setWorkspaceBoardDragPreviewOpen(false) + }, []) + useEffect(() => { if (!workspaceBoardOpen) { return @@ -92,11 +139,16 @@ export function useWorkspaceBoardPanel(): WorkspaceBoardPanelState { return { workspaceBoardOpen, + workspaceBoardRenderedOpen: workspaceBoardOpen || workspaceBoardDragPreviewOpen, + workspaceBoardDragPreviewOpen: workspaceBoardDragPreviewOpen && !workspaceBoardOpen, workspaceBoardMenuOpen, openWorkspaceBoard, closeWorkspaceBoard, toggleWorkspaceBoard, handleWorkspaceBoardOpenChange, - setWorkspaceBoardMenuOpen + setWorkspaceBoardMenuOpen, + previewWorkspaceBoardFromDrag, + solidifyWorkspaceBoardFromDrag, + cancelWorkspaceBoardDragPreview } } diff --git a/src/renderer/src/components/sidebar/workspace-board-drag-preview-intent.test.ts b/src/renderer/src/components/sidebar/workspace-board-drag-preview-intent.test.ts new file mode 100644 index 000000000..9299240ce --- /dev/null +++ b/src/renderer/src/components/sidebar/workspace-board-drag-preview-intent.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { shouldStartWorkspaceBoardDragPreview } from './workspace-board-drag-preview-intent' + +describe('workspace board drag preview intent', () => { + it('does not start the expensive board preview for small sidebar reorder drags', () => { + expect( + shouldStartWorkspaceBoardDragPreview({ + pointerX: 132, + startX: 96, + sidebarRight: 285 + }) + ).toBe(false) + }) + + it('starts the board preview after a rightward drag reaches the sidebar edge zone', () => { + expect( + shouldStartWorkspaceBoardDragPreview({ + pointerX: 252, + startX: 96, + sidebarRight: 285 + }) + ).toBe(true) + }) + + it('ignores edge-zone drags that did not move right enough to signal board intent', () => { + expect( + shouldStartWorkspaceBoardDragPreview({ + pointerX: 252, + startX: 242, + sidebarRight: 285 + }) + ).toBe(false) + }) +}) diff --git a/src/renderer/src/components/sidebar/workspace-board-drag-preview-intent.ts b/src/renderer/src/components/sidebar/workspace-board-drag-preview-intent.ts new file mode 100644 index 000000000..1494b95bc --- /dev/null +++ b/src/renderer/src/components/sidebar/workspace-board-drag-preview-intent.ts @@ -0,0 +1,13 @@ +const WORKSPACE_BOARD_DRAG_PREVIEW_EDGE_ZONE_PX = 48 +const WORKSPACE_BOARD_DRAG_PREVIEW_MIN_RIGHTWARD_PX = 16 + +export function shouldStartWorkspaceBoardDragPreview(args: { + pointerX: number + startX: number + sidebarRight: number +}): boolean { + return ( + args.pointerX >= args.sidebarRight - WORKSPACE_BOARD_DRAG_PREVIEW_EDGE_ZONE_PX && + args.pointerX - args.startX >= WORKSPACE_BOARD_DRAG_PREVIEW_MIN_RIGHTWARD_PX + ) +} diff --git a/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.test.ts b/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.test.ts index 6631f19ee..e9ae00c76 100644 --- a/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.test.ts +++ b/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.test.ts @@ -5,6 +5,7 @@ import { clearWorkspaceKanbanSidebarDropTargetVisual, getWorkspaceKanbanSidebarDropGroups, getWorkspaceKanbanSidebarDropTarget, + isWorkspaceKanbanSidebarDropPointInBoard, updateWorkspaceKanbanSidebarDropTargetVisual } from './workspace-kanban-sidebar-drop' @@ -264,6 +265,21 @@ describe('workspace kanban sidebar drop DOM bridge', () => { expect(lane.hasAttribute('data-workspace-board-external-drag-target')).toBe(false) expect(document.querySelector('[data-workspace-board-card-drop-indicator]')).toBeNull() }) + + it('detects pointer entry across the whole board sheet', () => { + const sheet = document.createElement('div') + sheet.setAttribute('data-workspace-board-sheet', '') + setRect(sheet, { left: 300, top: 36, right: 900, bottom: 700, width: 600, height: 664 }) + + const { board } = appendBoard() + board.remove() + sheet.append(board) + document.body.append(sheet) + + expect(isWorkspaceKanbanSidebarDropPointInBoard(320, 60)).toBe(true) + expect(isWorkspaceKanbanSidebarDropPointInBoard(280, 60)).toBe(false) + expect(isWorkspaceKanbanSidebarDropPointInBoard(320, 720)).toBe(false) + }) }) describe('workspace kanban sidebar drop updates', () => { diff --git a/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.ts b/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.ts index 4588296e4..befa87d9e 100644 --- a/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.ts +++ b/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.ts @@ -21,6 +21,7 @@ import { } from './workspace-kanban-card-pointer-drag-dom' const BOARD_SELECTOR = '[data-workspace-board-selection-surface]' +const BOARD_SHEET_SELECTOR = '[data-workspace-board-sheet]' const EXTERNAL_DRAG_TARGET_ATTR = 'data-workspace-board-external-drag-target' let externalDragTargetElement: HTMLElement | null = null @@ -33,6 +34,16 @@ export function hasWorkspaceKanbanSidebarDropBoard(): boolean { return getWorkspaceKanbanBoardElement() !== null } +export function isWorkspaceKanbanSidebarDropPointInBoard(x: number, y: number): boolean { + const board = getWorkspaceKanbanBoardElement() + if (!board) { + return false + } + const hitSurface = board.closest(BOARD_SHEET_SELECTOR) ?? board + const rect = hitSurface.getBoundingClientRect() + return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom +} + function getStatusDropTargetElement( board: HTMLElement, status: WorkspaceStatus