Skip to content
Open
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
2 changes: 1 addition & 1 deletion docs/editor.md
Original file line number Diff line number Diff line change
Expand Up @@ -392,7 +392,7 @@ Selectors are pure reads. Mutations go through actions (`useEditorStore.getState

`CanvasRoot` switches between two rendering surfaces based on `canvasView`:

- **Design mode** (`canvasView === 'design'`): `CanvasRoot` → `CanvasTransformLayer` → `BreakpointFrame` → `IframeFrameSurface` → `NodeRenderer`. Each breakpoint gets its own iframe rendered side-by-side inside the pan/zoom transform layer. The author sees all breakpoints at once and can zoom in/out. The canvas opens at 50% (`INITIAL_ZOOM`) so several frames fit in view; reset (Cmd/Ctrl+0, the toolbar % button) goes to 100% (`RESET_ZOOM`).
- **Design mode** (`canvasView === 'design'`): `CanvasRoot` → `CanvasTransformLayer` → `BreakpointFrame` → `IframeFrameSurface` → `NodeRenderer`. Each breakpoint gets its own iframe rendered side-by-side inside the pan/zoom transform layer. The author sees all breakpoints at once and can zoom in/out. The canvas opens at 50% (`INITIAL_ZOOM`) so several frames fit in view; reset (Cmd/Ctrl+0, the toolbar % button) zooms to 100% (`RESET_ZOOM`) around the visible canvas center, preserving the author's current work area.
- **Live mode** (`canvasView === 'live'`): `CanvasRoot` → `CanvasLiveSurface` → `IframeFrameSurface` → `NodeRenderer`. A single real-size frame at 100% width (optionally clamped to a selected breakpoint's width) scrolls normally. The toolbar zoom controls pin to 100% and disable with the reason in their tooltip ("Live mode always shows 100% zoom.") — the stored design-canvas zoom is preserved for the return to design mode. Resizable with side handles. Because the live frame is flush with the top of the canvas surface, both chrome controls — `CanvasNotch` (top-center) and `CanvasModeToggle` (top-left) — render in **peek** mode: they park above the top edge and roll down on hover/`:focus-within`, so they do not overlay the page's own header. In design mode they are always pinned.

Both modes use the same `IframeFrameSurface` and the same `NodeRenderer` — they are fully editable (click-to-select, properties panel, structural edits all work). The only difference is the layout wrapper. They also share the loading treatment: while the page is hydrating, design mode renders a `CanvasFrameSkeletonFrame` per breakpoint and live mode renders the same `CanvasFrameSkeleton` inside its single frame's width model.
Expand Down
33 changes: 1 addition & 32 deletions src/__tests__/canvas/canvasSlice.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* - setPan clamps to [±MAX_PAN]
* - zoomTo clamps both zoom and the resulting pan values
* - zoomIn / zoomOut step through ZOOM_STEPS
* - resetView restores defaults
* - zoomTo restores 100% around the requested viewport origin
*
* Security note (Contribution #435, Security Auditor review message #1270):
* Clamping MUST happen inside the setter, not at call sites. The agent tool
Expand All @@ -22,7 +22,6 @@ import { useEditorStore } from '@site/store/store'
import {
MIN_ZOOM,
MAX_ZOOM,
INITIAL_ZOOM,
RESET_ZOOM,
MAX_PAN,
clampZoom,
Expand Down Expand Up @@ -322,33 +321,3 @@ describe('zoomIn / zoomOut', () => {
expect(canvas().zoom).toBe(MAX_ZOOM)
})
})

// ---------------------------------------------------------------------------
// resetView
// ---------------------------------------------------------------------------

describe('resetView', () => {
it('resets zoom to RESET_ZOOM (100%), not the 50% initial design zoom', () => {
useEditorStore.setState({ zoom: 2.5 })
useEditorStore.getState().resetView()
expect(canvas().zoom).toBe(RESET_ZOOM)
expect(INITIAL_ZOOM).toBe(0.5)
})

it('resets pan to (0, 0)', () => {
useEditorStore.setState({ panX: 3000, panY: -1500 })
useEditorStore.getState().resetView()
const { panX, panY } = canvas()
expect(panX).toBe(0)
expect(panY).toBe(0)
})

it('resets from extreme (clamped) values', () => {
useEditorStore.setState({ zoom: MAX_ZOOM, panX: MAX_PAN, panY: -MAX_PAN })
useEditorStore.getState().resetView()
const { zoom, panX, panY } = canvas()
expect(zoom).toBe(RESET_ZOOM)
expect(panX).toBe(0)
expect(panY).toBe(0)
})
})
61 changes: 61 additions & 0 deletions src/__tests__/canvas/useCanvasWheelSync.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useRef } from 'react'
import { act, cleanup as cleanupRender, fireEvent, render, screen } from '@testing-library/react'
import { useEditorStore } from '@site/store/store'
import { RESET_ZOOM } from '@site/canvas/math'
import { CANVAS_RESET_ZOOM_EVENT } from '@site/canvas/canvasEvents'
import {
isCanvasPointerPanActive,
isCanvasSpacePanActive,
Expand Down Expand Up @@ -72,6 +73,66 @@ afterEach(() => {
})

describe('useCanvas wheel pan sync', () => {
it('resets zoom around the visible canvas center without jumping to the page top', () => {
useEditorStore.setState({ zoom: 2, panX: -800, panY: -1200 })
render(<TestCanvas />)

const root = screen.getByTestId('test-canvas-root')
const layer = screen.getByTestId('test-transform-layer')
Object.defineProperty(root, 'getBoundingClientRect', {
configurable: true,
value: () => ({ width: 800, height: 600, top: 0, left: 0 }),
})

fireEvent.keyDown(document, { metaKey: true, key: '0' })

expect(useEditorStore.getState()).toMatchObject({
zoom: RESET_ZOOM,
panX: -200,
panY: -450,
})
expect(layer.style.transform).toBe('translate(-200px, -450px) scale(1)')
})

it('resets from the live transform when the toolbar is clicked during a pending gesture commit', async () => {
render(<TestCanvas />)

const root = screen.getByTestId('test-canvas-root')
const layer = screen.getByTestId('test-transform-layer')
Object.defineProperty(root, 'getBoundingClientRect', {
configurable: true,
value: () => ({ width: 800, height: 600, top: 0, left: 0 }),
})

dispatchWheel(root, {
ctrlKey: true,
deltaY: -100,
clientX: 400,
clientY: 300,
})
await act(async () => {
await nextAnimationFrame()
})

expect(useEditorStore.getState().zoom).toBe(RESET_ZOOM)
expect(layer.style.transform).not.toBe('translate(0px, 0px) scale(1)')

document.dispatchEvent(new Event(CANVAS_RESET_ZOOM_EVENT))

expect(useEditorStore.getState()).toMatchObject({
zoom: RESET_ZOOM,
panX: 0,
panY: 0,
})
expect(layer.style.transform).toBe('translate(0px, 0px) scale(1)')

await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 120))
})

expect(useEditorStore.getState().zoom).toBe(RESET_ZOOM)
})

it('maps shift-wheel mouse scrolling to horizontal canvas pan', async () => {
render(<TestCanvas />)

Expand Down
24 changes: 23 additions & 1 deletion src/__tests__/toolbar/toolbar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,10 @@
import { describe, it, expect, beforeEach } from 'bun:test'
import React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { cleanup, render } from '@testing-library/react'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { ZoomControls } from '@site/toolbar/ZoomControls'
import { useEditorStore } from '@site/store/store'
import { CANVAS_RESET_ZOOM_EVENT } from '@site/canvas/canvasEvents'

// ─── Guideline #224 constants ─────────────────────────────────────────────────

Expand Down Expand Up @@ -104,6 +105,27 @@ describe('ZoomControls — live mode', () => {
})
})

describe('ZoomControls — reset dispatch', () => {
it('routes the percentage reset through the canvas event', () => {
useEditorStore.setState({ canvasView: 'design', zoom: 2 })
let resetCount = 0
const onReset = () => {
resetCount += 1
}
document.addEventListener(CANVAS_RESET_ZOOM_EVENT, onReset)

try {
const { container } = render(React.createElement(ZoomControls))
fireEvent.click(container.querySelectorAll('button')[1])

expect(resetCount).toBe(1)
} finally {
document.removeEventListener(CANVAS_RESET_ZOOM_EVENT, onReset)
cleanup()
}
})
})

// ---------------------------------------------------------------------------
// 2 — UndoRedoButtons — WCAG aria-disabled pattern (Guideline #224)
// ---------------------------------------------------------------------------
Expand Down
2 changes: 2 additions & 0 deletions src/admin/pages/site/canvas/canvasEvents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/** Events shared by the canvas surface and controls rendered outside it. */
export const CANVAS_RESET_ZOOM_EVENT = 'instatic:canvas-reset-zoom'
85 changes: 62 additions & 23 deletions src/admin/pages/site/hooks/useCanvas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,10 @@ import {
clampZoom,
clampPan,
incrementalScaleFromPinchMovement,
RESET_ZOOM,
} from '@site/canvas/math'
import { panToCenterBreakpointFrame } from '@site/canvas/canvasDomGeometry'
import { CANVAS_RESET_ZOOM_EVENT } from '@site/canvas/canvasEvents'
import {
CANVAS_DRAG_PAN_BUTTONS,
isCanvasPointerPanActive,
Expand Down Expand Up @@ -100,7 +102,20 @@ export function useCanvas({ canvasRootRef, transformLayerRef, enabled }: UseCanv
const setCanvasTransform = useEditorStore((s) => s.setCanvasTransform)
const zoomIn = useEditorStore((s) => s.zoomIn)
const zoomOut = useEditorStore((s) => s.zoomOut)
const resetView = useEditorStore((s) => s.resetView)

/**
* Resolve the current canvas viewport center, in canvas-local coords.
* Keyboard reset uses this as its zoom origin so the user's current work
* area stays in view while changing the scale.
*/
// Exception #1: referenced by resetCanvasZoom, which is itself an effect
// dependency; exhaustive-deps needs this geometry helper to stay stable.
const getViewportCenter = useCallback((): { x: number; y: number } | null => {
const el = canvasRootRef.current
if (!el) return null
const rect = el.getBoundingClientRect()
return { x: rect.width / 2, y: rect.height / 2 }
}, [canvasRootRef])

// ─── DOM write helper ─────────────────────────────────────────────────────

Expand Down Expand Up @@ -203,11 +218,29 @@ export function useCanvas({ canvasRootRef, transformLayerRef, enabled }: UseCanv
}

// Exception #1: referenced in the Cmd/Ctrl+0 reset shortcut's useEffect dep array.
const resetCanvasView = useCallback(() => {
resetView()
transformRef.current = { zoom: 1, panX: 0, panY: 0 }
applyTransformToDOM(transformRef.current, true)
}, [resetView, applyTransformToDOM])
const resetCanvasZoom = useCallback(() => {
if (commitTimerRef.current) {
clearTimeout(commitTimerRef.current)
commitTimerRef.current = null
}

const current = transformRef.current
const center = getViewportCenter()
const next = center
? applyZoom(current.zoom, RESET_ZOOM, center.x, center.y, current.panX, current.panY)
: { zoom: RESET_ZOOM, panX: current.panX, panY: current.panY }
const committed = {
zoom: next.zoom,
panX: clampPan(next.panX),
panY: clampPan(next.panY),
}

// Update the ref before the store so the external-store subscriber sees
// the values already applied and does not issue a second DOM write.
transformRef.current = committed
setCanvasTransform(committed.zoom, committed.panX, committed.panY)
applyTransformToDOM(committed, true)
}, [applyTransformToDOM, getViewportCenter, setCanvasTransform])

/**
* Pan the canvas so the given breakpoint's frame is horizontally centered and
Expand Down Expand Up @@ -286,6 +319,20 @@ export function useCanvas({ canvasRootRef, transformLayerRef, enabled }: UseCanv
}
}, [])

// The toolbar lives outside CanvasRoot, so it routes reset requests through
// this hook. Keeping the request here ensures the live transform ref and
// its pending debounced store commit are handled as one operation.
useEffect(() => {
function onResetZoom() {
if (enabled) resetCanvasZoom()
}

document.addEventListener(CANVAS_RESET_ZOOM_EVENT, onResetZoom)
return () => {
document.removeEventListener(CANVAS_RESET_ZOOM_EVENT, onResetZoom)
}
}, [enabled, resetCanvasZoom])

// ─── Browser-style reset shortcut ─────────────────────────────────────────

useEffect(() => {
Expand All @@ -300,29 +347,17 @@ export function useCanvas({ canvasRootRef, transformLayerRef, enabled }: UseCanv
) return

e.preventDefault()
resetCanvasView()
resetCanvasZoom()
}

document.addEventListener('keydown', onKeyDown)
return () => {
document.removeEventListener('keydown', onKeyDown)
}
}, [resetCanvasView])
}, [resetCanvasZoom])

// ─── Keyboard shortcuts ───────────────────────────────────────────────────

/**
* Resolve the current canvas viewport center, in canvas-local coords.
* Used as the zoom origin for keyboard +/− shortcuts so the zoom is
* anchored to the middle of the visible area, not the document top-left.
*/
const getViewportCenter = (): { x: number; y: number } | null => {
const el = canvasRootRef.current
if (!el) return null
const rect = el.getBoundingClientRect()
return { x: rect.width / 2, y: rect.height / 2 }
}

const handleKeyDown = (e: React.KeyboardEvent) => {
// Don't intercept typing — let inputs and contenteditables consume keys.
const target = e.target as HTMLElement | null
Expand All @@ -346,19 +381,19 @@ export function useCanvas({ canvasRootRef, transformLayerRef, enabled }: UseCanv
else zoomOut()
} else if ((e.metaKey || e.ctrlKey) && e.key === '0') {
e.preventDefault()
resetCanvasView()
resetCanvasZoom()
} else if (e.key === '1' && e.shiftKey) {
// `Shift+1` → Reset to 100% zoom (legacy muscle-memory shortcut).
e.preventDefault()
resetCanvasView()
resetCanvasZoom()
}
// Ctrl/Cmd+Z / Ctrl/Cmd+Shift+Z handled by App-level listener
}

// ─── External zoom/pan sync ───────────────────────────────────────────────
//
// ZoomControls (toolbar + buttons) and any other external caller update the
// Zustand store directly via zoomIn/zoomOut/resetView. The DOM transform
// Zustand store directly via zoomIn/zoomOut/zoomTo. The DOM transform
// layer is NOT subscribed to the store via React state (intentional perf
// design — avoids re-renders during 60fps gestures). Without this
// subscription those external actions only update the zoom indicator number
Expand All @@ -379,6 +414,10 @@ export function useCanvas({ canvasRootRef, transformLayerRef, enabled }: UseCanv
([zoom, panX, panY]) => {
const cur = transformRef.current
if (zoom !== cur.zoom || panX !== cur.panX || panY !== cur.panY) {
if (commitTimerRef.current) {
clearTimeout(commitTimerRef.current)
commitTimerRef.current = null
}
transformRef.current = { zoom, panX, panY }
// External transform updates (toolbar buttons, +/− keys, agent
// tools, undo/redo) animate to the new value. Continuous gestures
Expand Down
4 changes: 0 additions & 4 deletions src/admin/pages/site/store/slices/canvasSlice.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { EditorStoreSliceCreator } from '@site/store/types'
import {
INITIAL_ZOOM,
RESET_ZOOM,
clampZoom,
clampPan,
nearestZoomStep,
Expand Down Expand Up @@ -99,7 +98,6 @@ interface CanvasSlice {
toggleBreakpointCollapsed: (id: string) => void
/** Mount or release the agent's one-shot offscreen snapshot frame. */
setAgentSnapshotCaptureRequest: (request: AgentSnapshotCaptureRequest | null) => void
resetView: () => void
/**
* Step zoom up to the next preset level. When `originX`/`originY` are
* provided (in viewport-space, relative to the canvas root), the pan is
Expand Down Expand Up @@ -167,8 +165,6 @@ export const createCanvasSlice: EditorStoreSliceCreator<CanvasSlice> = (set, get
agentSnapshotCaptureRequest,
}),

resetView: () => set({ zoom: RESET_ZOOM, panX: 0, panY: 0 }),

zoomIn: (originX, originY) => {
const { zoom, panX, panY, zoomTo } = get()
const next = nearestZoomStep(zoom, 1)
Expand Down
8 changes: 6 additions & 2 deletions src/admin/pages/site/toolbar/ZoomControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
*/

import { useEditorStore } from '@site/store/store'
import { CANVAS_RESET_ZOOM_EVENT } from '@site/canvas/canvasEvents'
import { MinusIcon } from 'pixel-art-icons/icons/minus'
import { PlusIcon } from 'pixel-art-icons/icons/plus'
import { Button } from '@ui/components/Button'
Expand Down Expand Up @@ -51,7 +52,6 @@ export function ZoomControls() {
const isLive = useEditorStore((s) => s.canvasView === 'live')
const zoomIn = useEditorStore((s) => s.zoomIn)
const zoomOut = useEditorStore((s) => s.zoomOut)
const resetView = useEditorStore((s) => s.resetView)

const handleZoomIn = () => {
const center = getCanvasCenter()
Expand All @@ -65,6 +65,10 @@ export function ZoomControls() {
else zoomOut()
}

const handleResetZoom = () => {
document.dispatchEvent(new Event(CANVAS_RESET_ZOOM_EVENT))
}

// The live frame renders real-size regardless of the stored design-canvas
// zoom, which is preserved for the return to design view.
const pct = isLive ? 100 : Math.round(zoom * 100)
Expand Down Expand Up @@ -97,7 +101,7 @@ export function ZoomControls() {
aria-label={isLive ? LIVE_ZOOM_REASON : `Current zoom ${pct}%. Click to reset to 100%.`}
tooltip={isLive ? LIVE_ZOOM_REASON : 'Reset to 100% (Cmd/Ctrl+0)'}
disabled={isLive}
onClick={resetView}
onClick={handleResetZoom}
numeric
className={styles.zoomPct}
>
Expand Down