From 8a482ce0295af3d5668415429e2c8b604b0921c0 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 21 Jul 2026 22:07:24 -0700 Subject: [PATCH] [eric] canvas: drag a lone card into the screen edge to snap-tile it (halves/quarters/fullscreen) --- .../Dashboard/canvas/DashboardCanvas.tsx | 5 ++ .../Dashboard/canvas/SnapZonePreview.tsx | 43 ++++++++++++++++ .../hooks/interaction/useCardDrag.ts | 49 +++++++++++++++++-- .../hooks/state/useDashboardController.ts | 3 +- 4 files changed, 96 insertions(+), 4 deletions(-) create mode 100644 frontend/src/app/pages/Dashboard/canvas/SnapZonePreview.tsx diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx index 7eb4b036..5d195732 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx @@ -7,6 +7,7 @@ import TetherLayer from './TetherLayer'; import DashboardCardLayer from './DashboardCardLayer'; import DashboardOverlays from './DashboardOverlays'; import DashboardEmptyState from './DashboardEmptyState'; +import SnapZonePreview from './SnapZonePreview'; import '../desktop/desktop.css'; import DesktopDock from '../desktop/DesktopDock'; import MinimizedStack from '../desktop/MinimizedStack'; @@ -61,6 +62,7 @@ interface DashboardCanvasProps { focusedCardId: string | null; pendingFocusNoteId: string | null; multiDragDelta: { dx: number; dy: number } | null; + snapZone: string | null; shakeDirection: Direction | null; neighborDirections: NeighborDirections; toolbarOpen: boolean; @@ -124,6 +126,7 @@ const DashboardCanvas: React.FC = ({ focusedCardId, pendingFocusNoteId, multiDragDelta, + snapZone, shakeDirection, neighborDirections, toolbarOpen, @@ -265,6 +268,8 @@ const DashboardCanvas: React.FC = ({ /> )} + + {!anyFullscreen && ( = ({ zone }) => { + const c = useClaudeTokens(); + if (!zone) return null; + + const vp = document.querySelector('[data-canvas-viewport]'); + const rect = vp ? vp.getBoundingClientRect() : { left: 0, top: 0, width: window.innerWidth, height: window.innerHeight }; + const frac = zone === 'fullscreen' ? { x: 0, y: 0, w: 1, h: 1 } : TILE_ZONES[zone]; + if (!frac) return null; + + return ( +
+ ); +}; + +export default SnapZonePreview; diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useCardDrag.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useCardDrag.ts index c01850e7..1c609173 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useCardDrag.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useCardDrag.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState, type RefObject } from 'react'; import { report } from '@/shared/serviceClient'; import { useAppDispatch } from '@/shared/hooks'; -import { moveCards } from '@/shared/state/dashboardLayoutSlice'; +import { moveCards, setTiledCard } from '@/shared/state/dashboardLayoutSlice'; import type { CardType, useDashboardSelection } from '../state/useDashboardSelection'; import type { CanvasActions } from './useCanvasControls'; @@ -15,6 +15,25 @@ interface UseCardDragArgs { const EDGE_ZONE = 60; const EDGE_MAX_SPEED = 8; +// Jam a dragged card into the very edge (inside the pan zone) to snap-tile it. Grazing the 60px +// edge still pans the infinite canvas; only the inner 24px band arms a snap and pauses the pan, so +// the two never fight. Top = fullscreen (macOS drag-to-top), sides = halves, corners = quarters. +const SNAP_BAND = 24; + +function snapZoneFor(mx: number, my: number, rect: DOMRect): string | null { + const nearL = mx <= rect.left + SNAP_BAND; + const nearR = mx >= rect.right - SNAP_BAND; + const nearT = my <= rect.top + SNAP_BAND; + const nearB = my >= rect.bottom - SNAP_BAND; + if (nearT && nearL) return 'tl'; + if (nearT && nearR) return 'tr'; + if (nearB && nearL) return 'bl'; + if (nearB && nearR) return 'br'; + if (nearT) return 'fullscreen'; + if (nearL) return 'left'; + if (nearR) return 'right'; + return null; // bottom-center is left alone: the composer + dock live there. +} // Clamped: an infinite canvas lets the cursor sit arbitrarily far outside the viewport, where an unclamped ramp would scale pan speed with distance instead of saturating. function axisIntensity(pos: number, lo: number, hi: number): number { @@ -32,6 +51,10 @@ export function useCardDrag({ const [multiDragDelta, setMultiDragDelta] = useState<{ dx: number; dy: number } | null>(null); const [liveDragInfo, setLiveDragInfo] = useState<{ cardId: string; dx: number; dy: number } | null>(null); + const [snapZone, setSnapZone] = useState(null); + const snapZoneRef = useRef(null); + // Snap only a lone card: dragging one of several selected cards to the edge shouldn't tile just it. + const snapEligibleRef = useRef(true); const activeDragCardRef = useRef(null); const isMultiDragRef = useRef(false); @@ -57,7 +80,8 @@ export function useCardDrag({ const dx = EDGE_MAX_SPEED * axisIntensity(mx, rect.left, rect.right); const dy = EDGE_MAX_SPEED * axisIntensity(my, rect.top, rect.bottom); - if (dx !== 0 || dy !== 0) { + // Snap armed = the card is about to tile at this edge, so freeze the canvas instead of panning. + if ((dx !== 0 || dy !== 0) && !snapZoneRef.current) { // Live-only write (no React commit per frame); clearDrag commits once when the drag ends. canvasActions.panBy(dx, dy); } @@ -67,6 +91,7 @@ export function useCardDrag({ const handleCardDragStart = useCallback((id: string, type: CardType) => { activeDragCardRef.current = id; + snapEligibleRef.current = selection.selectedArray().filter((s) => s.id !== id).length === 0; if (selection.isSelected(id)) { isMultiDragRef.current = true; } else { @@ -80,6 +105,16 @@ export function useCardDrag({ if (mouseX !== undefined && mouseY !== undefined) { lastMousePosRef.current = { x: mouseX, y: mouseY }; } + // Arm/clear the snap target from the live cursor. Single-card drags only: snapping one card of a + // multi-selection while the rest move is incoherent. + const vp = viewportRef.current; + const nextSnap = (vp && snapEligibleRef.current && mouseX !== undefined && mouseY !== undefined) + ? snapZoneFor(mouseX, mouseY, vp.getBoundingClientRect()) + : null; + if (nextSnap !== snapZoneRef.current) { + snapZoneRef.current = nextSnap; + setSnapZone(nextSnap); + } // Arm the webview shield on the first real MOVE, not on pointerdown: a plain click also arms the drag machinery, and shielding then made the click-to-focus camera fit skip (it saw a "drag in progress"), so focusing a card took two clicks. On a real drag the shield still goes up before the pointer travels, so the webview neutralization + no-nudge + release-over-webview fixes all hold. Idempotent add. document.body.classList.add('dashboard-marquee-active'); // Start edge panning only once actual dragging begins; a live frame handle means the loop is already running. @@ -99,6 +134,8 @@ export function useCardDrag({ // Reconcile React with whatever edge-pan wrote live during the drag. canvasActions.commit(); activeDragCardRef.current = null; + snapZoneRef.current = null; + setSnapZone(null); document.body.classList.remove('dashboard-marquee-active'); isMultiDragRef.current = false; setMultiDragDelta(null); @@ -107,7 +144,12 @@ export function useCardDrag({ const handleCardDragEnd = useCallback((dx: number, dy: number, didDrag: boolean) => { if (didDrag) report('dashboard', 'card_dragged'); - if (isMultiDragRef.current && didDrag) { + const snap = snapZoneRef.current; + const activeId = activeDragCardRef.current; + if (snap && didDrag && activeId) { + // Released against the edge: tile the card there instead of leaving it at the drop position. + dispatch(setTiledCard({ cardId: activeId, zone: snap })); + } else if (isMultiDragRef.current && didDrag) { const items = selection.selectedArray() .filter((s) => s.id !== activeDragCardRef.current); if (items.length > 0) { @@ -134,6 +176,7 @@ export function useCardDrag({ return { multiDragDelta, liveDragInfo, + snapZone, handleCardDragStart, handleCardDragMove, handleCardDragEnd, diff --git a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts index 7a06e8ba..7a1db232 100644 --- a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts +++ b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts @@ -103,6 +103,7 @@ export function useDashboardController(dashboardId: string, isActive: boolean) { const { multiDragDelta, liveDragInfo, + snapZone, handleCardDragStart, handleCardDragMove, handleCardDragEnd, @@ -332,7 +333,7 @@ export function useDashboardController(dashboardId: string, isActive: boolean) { cards, viewCards, browserCards, keepAliveBrowserCards, notes, outputs, glowingAgentCards, workflowCards, workflowsHub, expandedSessionIds, tethers, highlightedCardId, autoFocusSessionId, - focusedCardId, pendingFocusNoteId, multiDragDelta, shakeDirection, + focusedCardId, pendingFocusNoteId, multiDragDelta, snapZone, shakeDirection, neighborDirections, toolbarOpen, searchPaletteOpen, newAgentBounce, toolbarRef, spawnOriginsRef, revealSpawnedRef, measuredHeightsRef, getCanvasState, toolbarPrefill,