Show workspace board preview while dragging cards
This commit is contained in:
parent
3f7e869f0c
commit
896a2ae4de
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<number>
|
||||
scrollAnchorRef: React.MutableRefObject<VirtualizedScrollAnchor>
|
||||
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}
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement>({
|
||||
isOpen: sidebarOpen,
|
||||
|
|
@ -95,6 +100,10 @@ function Sidebar({
|
|||
<WorktreeList
|
||||
scrollOffsetRef={worktreeScrollOffsetRef}
|
||||
scrollAnchorRef={worktreeScrollAnchorRef}
|
||||
workspaceBoardOpen={workspaceBoardOpen}
|
||||
onWorkspaceBoardDragPreviewStart={previewWorkspaceBoardFromDrag}
|
||||
onWorkspaceBoardDragPreviewCommit={solidifyWorkspaceBoardFromDrag}
|
||||
onWorkspaceBoardDragPreviewCancel={cancelWorkspaceBoardDragPreview}
|
||||
/>
|
||||
|
||||
<SetupScriptPromptCard />
|
||||
|
|
@ -146,7 +155,8 @@ function Sidebar({
|
|||
</React.Suspense>
|
||||
{sidebarOpen ? (
|
||||
<WorkspaceKanbanDrawer
|
||||
open={workspaceBoardOpen}
|
||||
open={workspaceBoardRenderedOpen}
|
||||
dragPreview={workspaceBoardDragPreviewOpen}
|
||||
preserveOpenForMenu={workspaceBoardMenuOpen}
|
||||
onOpenChange={handleWorkspaceBoardOpenChange}
|
||||
onMenuOpenChange={setWorkspaceBoardMenuOpen}
|
||||
|
|
|
|||
|
|
@ -73,14 +73,52 @@ describe('useWorkspaceBoardPanel', () => {
|
|||
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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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<HTMLElement>(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
|
||||
|
|
|
|||
Loading…
Reference in New Issue