diff --git a/docs/editor.md b/docs/editor.md index 8ed3fdb9b..e06249e9a 100644 --- a/docs/editor.md +++ b/docs/editor.md @@ -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. diff --git a/src/__tests__/canvas/canvasSlice.test.ts b/src/__tests__/canvas/canvasSlice.test.ts index 2d4f7ff2f..8194281cb 100644 --- a/src/__tests__/canvas/canvasSlice.test.ts +++ b/src/__tests__/canvas/canvasSlice.test.ts @@ -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 @@ -22,7 +22,6 @@ import { useEditorStore } from '@site/store/store' import { MIN_ZOOM, MAX_ZOOM, - INITIAL_ZOOM, RESET_ZOOM, MAX_PAN, clampZoom, @@ -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) - }) -}) diff --git a/src/__tests__/canvas/useCanvasWheelSync.test.tsx b/src/__tests__/canvas/useCanvasWheelSync.test.tsx index e07fed7bd..2b699fea8 100644 --- a/src/__tests__/canvas/useCanvasWheelSync.test.tsx +++ b/src/__tests__/canvas/useCanvasWheelSync.test.tsx @@ -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, @@ -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() + + 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() + + 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() diff --git a/src/__tests__/toolbar/toolbar.test.ts b/src/__tests__/toolbar/toolbar.test.ts index cececd737..197c59074 100644 --- a/src/__tests__/toolbar/toolbar.test.ts +++ b/src/__tests__/toolbar/toolbar.test.ts @@ -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 ───────────────────────────────────────────────── @@ -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) // --------------------------------------------------------------------------- diff --git a/src/admin/pages/site/canvas/canvasEvents.ts b/src/admin/pages/site/canvas/canvasEvents.ts new file mode 100644 index 000000000..4a27c54d9 --- /dev/null +++ b/src/admin/pages/site/canvas/canvasEvents.ts @@ -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' diff --git a/src/admin/pages/site/hooks/useCanvas.ts b/src/admin/pages/site/hooks/useCanvas.ts index 7c534214a..d26efc818 100644 --- a/src/admin/pages/site/hooks/useCanvas.ts +++ b/src/admin/pages/site/hooks/useCanvas.ts @@ -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, @@ -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 ───────────────────────────────────────────────────── @@ -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 @@ -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(() => { @@ -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 @@ -346,11 +381,11 @@ 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 } @@ -358,7 +393,7 @@ export function useCanvas({ canvasRootRef, transformLayerRef, enabled }: UseCanv // ─── 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 @@ -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 diff --git a/src/admin/pages/site/store/slices/canvasSlice.ts b/src/admin/pages/site/store/slices/canvasSlice.ts index 7956668ae..edcc25893 100644 --- a/src/admin/pages/site/store/slices/canvasSlice.ts +++ b/src/admin/pages/site/store/slices/canvasSlice.ts @@ -1,7 +1,6 @@ import type { EditorStoreSliceCreator } from '@site/store/types' import { INITIAL_ZOOM, - RESET_ZOOM, clampZoom, clampPan, nearestZoomStep, @@ -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 @@ -167,8 +165,6 @@ export const createCanvasSlice: EditorStoreSliceCreator = (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) diff --git a/src/admin/pages/site/toolbar/ZoomControls.tsx b/src/admin/pages/site/toolbar/ZoomControls.tsx index 90a62bd92..e69b1599d 100644 --- a/src/admin/pages/site/toolbar/ZoomControls.tsx +++ b/src/admin/pages/site/toolbar/ZoomControls.tsx @@ -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' @@ -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() @@ -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) @@ -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} >