Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions src/components/canvas/CanvasWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export function CanvasWorkspace({ screens, assetResolver }: CanvasWorkspaceProps
clientToWorkspace,
handleWheel,
handlePanMouseDown,
handleStagePanMouseDown,
handlePanMouseMove,
endPan,
} = useCanvasViewport({ containerRef, screens })
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -527,9 +531,9 @@ export function CanvasWorkspace({ screens, assetResolver }: CanvasWorkspaceProps
/>
</div>
<p className="hidden max-w-full truncate rounded-full bg-card/80 px-2.5 py-0.5 text-[10px] text-muted-foreground backdrop-blur-sm min-[360px]:block sm:text-[11px]">
<span className="sm:hidden">Scroll to zoom · Space to pan</span>
<span className="sm:hidden">Scroll to zoom · Space or bg-drag to pan</span>
<span className="hidden sm:inline">
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
</span>
</p>
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/components/settings/KeyboardShortcutsDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
]

Expand Down
7 changes: 5 additions & 2 deletions src/components/toolbar/ExportDialog.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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),
Expand Down
86 changes: 86 additions & 0 deletions src/hooks/useCanvasViewport.test.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>()
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<HTMLDivElement>()
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)
})
})
31 changes: 30 additions & 1 deletion src/hooks/useCanvasViewport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement | null>
Expand All @@ -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<DOMRect | null>(null)
Expand Down Expand Up @@ -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<MouseEvent>) => {
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])
Expand All @@ -200,6 +228,7 @@ export function useCanvasViewport({ containerRef, screens }: UseCanvasViewportOp
clientToWorkspace,
handleWheel,
handlePanMouseDown,
handleStagePanMouseDown,
handlePanMouseMove,
endPan,
}
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/useExportPreview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ export function useExportPreview({

useEffect(() => {
if (!open || tab !== 'quick' || exportScreens.length === 0) {
setGridPreviews([])
setGridPreviews((current) => (current.length === 0 ? current : []))
return
}

Expand Down
3 changes: 1 addition & 2 deletions src/lib/export/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading