diff --git a/src/components/canvas/CanvasWorkspace.tsx b/src/components/canvas/CanvasWorkspace.tsx index 1e84d15..172cf12 100644 --- a/src/components/canvas/CanvasWorkspace.tsx +++ b/src/components/canvas/CanvasWorkspace.tsx @@ -82,6 +82,7 @@ export function CanvasWorkspace({ screens, assetResolver }: CanvasWorkspaceProps clientToWorkspace, handleWheel, handlePanMouseDown, + handleStagePanMouseDown, handlePanMouseMove, endPan, } = useCanvasViewport({ containerRef, screens }) @@ -342,7 +343,10 @@ export function CanvasWorkspace({ screens, assetResolver }: CanvasWorkspaceProps height={containerSize.height} x={panX} y={panY} - onMouseDown={handleStageMouseDown} + onMouseDown={(event) => { + handleStagePanMouseDown(event) + handleStageMouseDown(event) + }} onMouseMove={handleStageMouseMove} onMouseUp={handleStageMouseUp} onContextMenu={(event) => { @@ -527,9 +531,9 @@ export function CanvasWorkspace({ screens, assetResolver }: CanvasWorkspaceProps />

- Scroll to zoom · Space to pan + Scroll to zoom · Space or bg-drag to pan - Space drag to pan · Scroll to zoom · ⌘0 fit active · Alt ← → switch screens + Space drag or background left-drag to pan · Scroll to zoom · ⌘0 fit active · Alt ← → switch screens

diff --git a/src/components/settings/KeyboardShortcutsDialog.tsx b/src/components/settings/KeyboardShortcutsDialog.tsx index 1bd895c..84ff69b 100644 --- a/src/components/settings/KeyboardShortcutsDialog.tsx +++ b/src/components/settings/KeyboardShortcutsDialog.tsx @@ -15,7 +15,7 @@ const SHORTCUTS = [ { keys: '⌘+ / ⌘-', action: 'Zoom in / out' }, { keys: '⌘0', action: 'Fit active screen' }, { keys: 'Alt ← / Alt →', action: 'Previous / next screen' }, - { keys: 'Space + drag', action: 'Pan canvas' }, + { keys: 'Space + drag / Left-drag background', action: 'Pan canvas' }, { keys: 'Scroll', action: 'Zoom at cursor' }, ] diff --git a/src/components/toolbar/ExportDialog.tsx b/src/components/toolbar/ExportDialog.tsx index c3d2ab7..509f924 100644 --- a/src/components/toolbar/ExportDialog.tsx +++ b/src/components/toolbar/ExportDialog.tsx @@ -1,5 +1,5 @@ import * as Dialog from '@radix-ui/react-dialog' -import { useEffect, useMemo } from 'react' +import { useCallback, useEffect, useMemo } from 'react' import { STORE_PRESETS } from '@/lib/presets/store-sizes' import { buildExportPlan, @@ -43,7 +43,10 @@ export function ExportDialog({ open, onOpenChange }: ExportDialogProps) { } }, [open, project, dispatch]) - const assetResolver = (assetId?: string) => (assetId ? assetUrls[assetId] : undefined) + const assetResolver = useCallback( + (assetId?: string) => (assetId ? assetUrls[assetId] : undefined), + [assetUrls], + ) const targetPreset = useMemo( () => STORE_PRESETS.find((preset) => preset.id === form.currentTarget), diff --git a/src/hooks/useCanvasViewport.test.tsx b/src/hooks/useCanvasViewport.test.tsx new file mode 100644 index 0000000..1c99333 --- /dev/null +++ b/src/hooks/useCanvasViewport.test.tsx @@ -0,0 +1,86 @@ +import { createRef } from 'react' +import { renderHook, act } from '@testing-library/react' +import { describe, expect, it, beforeEach } from 'vitest' +import { useCanvasViewport } from '@/hooks/useCanvasViewport' +import { useEditorStore } from '@/stores/editor-store' + +describe('useCanvasViewport left-drag pan', () => { + beforeEach(() => { + useEditorStore.setState({ + activeScreenId: null, + workspaceZoom: 1, + panX: 10, + panY: 20, + screenLayout: {}, + fitRequest: null, + focusScreenId: null, + isPanning: false, + isSpacePressed: false, + selectedElementIds: [], + leftPanelTab: 'layers', + viewMode: 'canvas', + clipboard: null, + styleClipboard: null, + marquee: null, + konvaStageBridge: null, + }) + }) + + it('starts panning only after threshold when dragging from empty stage background', () => { + const containerRef = createRef() + const { result } = renderHook(() => useCanvasViewport({ containerRef, screens: [] })) + + const stage = { getStage: () => stage } + const stageMouseDown = { + evt: { button: 0, clientX: 100, clientY: 120 }, + target: stage, + } as any + + act(() => { + result.current.handleStagePanMouseDown(stageMouseDown) + result.current.handlePanMouseMove({ clientX: 102, clientY: 122 } as React.MouseEvent) + }) + + expect(useEditorStore.getState().isPanning).toBe(false) + expect(useEditorStore.getState().panX).toBe(10) + expect(useEditorStore.getState().panY).toBe(20) + + act(() => { + result.current.handlePanMouseMove({ clientX: 106, clientY: 126 } as React.MouseEvent) + }) + + expect(useEditorStore.getState().isPanning).toBe(true) + expect(useEditorStore.getState().panX).toBe(16) + expect(useEditorStore.getState().panY).toBe(26) + + act(() => { + result.current.endPan() + }) + expect(useEditorStore.getState().isPanning).toBe(false) + }) + + it('does not start left-drag pan when mousedown starts on a non-stage target', () => { + const containerRef = createRef() + const { result } = renderHook(() => useCanvasViewport({ containerRef, screens: [] })) + + const stage = { getStage: () => stage } + const nonStageTarget = { getStage: () => stage } + const nonStageMouseDown = { + evt: { button: 0, clientX: 100, clientY: 120 }, + target: nonStageTarget, + } as any + + act(() => { + // target object intentionally differs from getStage() return value + result.current.handleStagePanMouseDown({ + ...nonStageMouseDown, + target: { ...nonStageTarget }, + } as any) + result.current.handlePanMouseMove({ clientX: 110, clientY: 130 } as React.MouseEvent) + }) + + expect(useEditorStore.getState().isPanning).toBe(false) + expect(useEditorStore.getState().panX).toBe(10) + expect(useEditorStore.getState().panY).toBe(20) + }) +}) diff --git a/src/hooks/useCanvasViewport.ts b/src/hooks/useCanvasViewport.ts index 16d3340..1e9b46a 100644 --- a/src/hooks/useCanvasViewport.ts +++ b/src/hooks/useCanvasViewport.ts @@ -5,6 +5,7 @@ import { getWorkspaceViewport } from '@/lib/canvas/perf/viewport' import { clamp } from '@/lib/utils' import { useEditorStore } from '@/stores/editor-store' import type { Screen } from '@/lib/types' +import type Konva from 'konva' interface UseCanvasViewportOptions { containerRef: RefObject @@ -13,7 +14,9 @@ interface UseCanvasViewportOptions { export function useCanvasViewport({ containerRef, screens }: UseCanvasViewportOptions) { const panStartRef = useRef<{ x: number; y: number; panX: number; panY: number } | null>(null) + const pendingLeftPanRef = useRef<{ x: number; y: number; panX: number; panY: number } | null>(null) const didInitialFitRef = useRef(false) + const LEFT_PAN_DRAG_THRESHOLD = 4 const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }) const [containerRect, setContainerRect] = useState(null) @@ -173,14 +176,39 @@ export function useCanvasViewport({ containerRef, screens }: UseCanvasViewportOp const handlePanMouseMove = useCallback( (event: React.MouseEvent) => { + const pendingLeftPan = pendingLeftPanRef.current + if (pendingLeftPan && !panStartRef.current) { + const distance = Math.hypot(event.clientX - pendingLeftPan.x, event.clientY - pendingLeftPan.y) + if (distance >= LEFT_PAN_DRAG_THRESHOLD) { + setIsPanning(true) + panStartRef.current = pendingLeftPan + pendingLeftPanRef.current = null + } + } const start = panStartRef.current if (!start) return setPan(start.panX + (event.clientX - start.x), start.panY + (event.clientY - start.y)) }, - [setPan], + [setIsPanning, setPan], + ) + + const handleStagePanMouseDown = useCallback( + (event: Konva.KonvaEventObject) => { + if (isSpacePressed) return + if (event.evt.button !== 0) return + if (event.target !== event.target.getStage()) return + pendingLeftPanRef.current = { + x: event.evt.clientX, + y: event.evt.clientY, + panX, + panY, + } + }, + [isSpacePressed, panX, panY], ) const endPan = useCallback(() => { + pendingLeftPanRef.current = null panStartRef.current = null setIsPanning(false) }, [setIsPanning]) @@ -200,6 +228,7 @@ export function useCanvasViewport({ containerRef, screens }: UseCanvasViewportOp clientToWorkspace, handleWheel, handlePanMouseDown, + handleStagePanMouseDown, handlePanMouseMove, endPan, } diff --git a/src/hooks/useExportPreview.ts b/src/hooks/useExportPreview.ts index 7b2fe62..61ee2e6 100644 --- a/src/hooks/useExportPreview.ts +++ b/src/hooks/useExportPreview.ts @@ -86,7 +86,7 @@ export function useExportPreview({ useEffect(() => { if (!open || tab !== 'quick' || exportScreens.length === 0) { - setGridPreviews([]) + setGridPreviews((current) => (current.length === 0 ? current : [])) return } diff --git a/src/lib/export/renderer.ts b/src/lib/export/renderer.ts index 5365994..e2a7d5d 100644 --- a/src/lib/export/renderer.ts +++ b/src/lib/export/renderer.ts @@ -202,9 +202,8 @@ async function drawElement( context.save() context.globalAlpha = element.opacity - context.translate(element.x + element.width / 2, element.y + element.height / 2) + context.translate(element.x, element.y) context.rotate((element.rotation * Math.PI) / 180) - context.translate(-element.width / 2, -element.height / 2) if (element.shadow?.enabled) { context.shadowColor = element.shadow.color