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