The CSS zoom scale applied to the canvas. Lets the user fit a 1280px-wide breakpoint preview into a smaller editor column, or shrink the responsive canvas to see more vertical content at once. Defaults to fit-to-window on first load (Fra…
The CSS zoom scale applied to the canvas. Lets the user fit a 1280px-wide
breakpoint preview into a smaller editor column, or shrink the responsive
canvas to see more vertical content at once. Defaults to fit-to-window
on first load (Framer/Figma-style), persisted thereafter via phStorage
(localStorage).
| Atom | Used in | Storage key |
|---|---|---|
BreakpointZoomAtom | desktop view + breakpoint views (sm/md/lg/xl/2xl) | ph-editor-breakpoint-zoom (+ -fit) |
DeviceZoomAtom | mobile view with Device toggle on (phone bezel) | ph-editor-device-zoom (+ -fit) |
function readSavedZoom(key: string, fallback: number): number {
if (typeof window === "undefined") return fallback;
try {
const raw = localStorage.getItem(`ph-${key}`);
if (raw === null) return fallback;
const n = parseFloat(raw);
return Number.isFinite(n) && n >= 0.25 && n <= 2 ? n : fallback;
} catch {
return fallback;
}
}
export const DeviceZoomAtom = atom("deviceZoom", readSavedZoom("editor-device-zoom", 0.75));
export const BreakpointZoomAtom = atom(
"breakpointZoom",
readSavedZoom("editor-breakpoint-zoom", 0.75)
);
Both atoms hydrate from ph-editor-*-zoom on module load. 0.75 is the
fallback when no value is saved — but on first load, fitToWindow defaults
to true, so the actual zoom is recomputed from window size before any
render frame the user sees.
The same <CanvasZoom> component drives both. It picks up which atom and
which storage key to use via props — see CanvasZoom.tsx.
Viewport/Viewport.tsx (formerly ViewportShell.tsx):
const breakpointActive = !device && isEditorCanvasBreakpointView(view);
const canvasZoomActive = !device && (view === "desktop" || isEditorCanvasBreakpointView(view));
const deviceStyles: React.CSSProperties =
device && view === "mobile"
? ({
width: `${deviceDimensions.width + bezelX}px`,
height: `${deviceDimensions.height + bezelY}px`,
zoom: deviceZoom,
"--device-zoom-inverse": 1 / deviceZoom,
} as React.CSSProperties)
: canvasZoomActive && breakpointZoom !== 1
? ({ zoom: breakpointZoom } as React.CSSProperties)
: {};
zoom: deviceZoom + sets a --device-zoom-inverse CSS var
(used by overlays/toolbars that need to render at unscaled size).zoom: breakpointZoom. Zoom is only
applied when breakpointZoom !== 1 to avoid the perf cost of a needless
scale transform.zoom (not transform: scale) — chosen because layout/scrollbar math stays
intact, and Chromium-based rendering inside CraftJS treats it correctly. It
does NOT propagate getBoundingClientRect to children proportionally, which
is why we cache --device-zoom-inverse for overlay positioning.
CanvasZoom.tsx — UI primitive used by both the header dropdown and the in-canvas device toolbar. Props:
interface CanvasZoomProps {
zoomAtom: any; // BreakpointZoomAtom or DeviceZoomAtom
fitMode: FitMode; // how fit-to-window is calculated
activeKey: string; // scopes Ctrl/+/-/0 keyboard shortcuts
disabled?: boolean;
storageKey?: string; // phStorage key root
}
fitMode is the calc spec for the "Fit to window" option:
type FitMode =
| { kind: "height"; target: number; chromeOffset?: number; max?: number }
| { kind: "width"; target: number; chromeOffset?: number; max?: number };
// fit zoom = clamp(0.25, max, (window.innerSize - chromeOffset) / target)
The current call sites:
| Where | Atom | fitMode | storageKey |
|---|---|---|---|
| Header dropdown · device-mobile | DeviceZoomAtom | { height: deviceDimensions.height, chromeOffset: 350 } | editor-device-zoom |
| Header dropdown · desktop/breakpoint | BreakpointZoomAtom | { width: EDITOR_CANVAS_BREAKPOINT_PX[view] ?? 1024, chromeOffset: 420, max: 1 } | editor-breakpoint-zoom |
| Device-mobile in-canvas toolbar | DeviceZoomAtom | { height: deviceDimensions.height, chromeOffset: 350 } | editor-device-zoom |
The breakpoint fit is capped at max: 1 — fluid canvas should never zoom
past 1.0 from a fit calc (zooming in would make the canvas larger than its
container).
[ − ] [ 75% ▼ ] [ + ] [ ↺ ]
zoomPresets (25% → 200%, 13 stops).zoom=1, fit=false). Disabled when already at
100% and not in fit mode.Keyboard shortcuts (when the corresponding instance is mounted):
Ctrl/Cmd + = / + — zoom inCtrl/Cmd + - — zoom outCtrl/Cmd + 0 — reset to 100%Shortcuts are scoped via a data-canvas-zoom-<activeKey> attribute on the
component root and an activeSelector query inside the keydown handler —
ensures only the currently-mounted instance handles the keys.
Each <CanvasZoom> writes two keys to phStorage on every change:
<storageKey> — the zoom value as a string (e.g. "0.75")<storageKey>-fit — "true" or "false"Persistence happens inside persistZoom(value, fit) which is called from:
handleZoomChange — preset clicks, +/- buttons (via handler)useEffect — every recompute (resize / mount)const [fitToWindow, setFitToWindow] = useState(() => {
if (!storageKey || typeof window === "undefined") return true;
const savedFit = phStorage.get(`${storageKey}-fit`);
if (savedFit === "true") return true;
if (savedFit === "false") return false;
return true;
});
No saved -fit key → default true → fit calc fires on mount → zoom is
overwritten with the fit value before paint. So the user sees a properly
fit canvas on first ever load, never the literal 0.75 fallback (unless
they previously set a manual zoom and -fit is "false").
<span className="inline-flex h-4 min-w-6 items-center justify-center px-0.5">
{isScaled ? (
<span className="font-mono text-[11px] leading-none font-bold tracking-tight">
{Math.round(activeZoom * 100)}%
</span>
) : (
<>
{view === "mobile" && <TbDeviceMobile />}
{(view === "desktop" || view === "tablet") && <TbDeviceDesktop />}
{isEditorCanvasBreakpointView(view) && (
<span>{view === "2xl" ? "2XL" : view.toUpperCase()}</span>
)}
</>
)}
</span>
isScaled = Math.abs(activeZoom - 1) > 0.001 (epsilon to avoid float
flicker at exactly 1).activeZoom = device && view === "mobile" ? deviceZoom : breakpointZoom.zoom does not equal transform: scale. Don't try to swap them.
getBoundingClientRect behaves differently, scrollbar math differs, and
iframes inside the canvas care about zoom specifically.--device-zoom-inverse is set on the device-mobile wrapper for
overlays that need to ignore the parent zoom. Used by the device frame
notch, scrollbar, and floating preview-edit button.useEffect listens
to window.resize while fitToWindow is true. Every resize re-fires the
fit calc and re-persists.0.25 ≤ n ≤ 2 clamp in readSavedZoom is intentional. Anything
outside that range is treated as corrupt storage and falls back to 0.75.Math.abs(activeZoom - 1) > 0.001 — don't use activeZoom !== 1.
Fit-to-window can land on 0.9999… due to integer pixel math, and the
trigger would show "100%" but isScaled would be true.