From 8de981176b8ff39e02550cfb21572fd80f856529 Mon Sep 17 00:00:00 2001 From: u8array Date: Fri, 15 May 2026 10:24:04 +0200 Subject: [PATCH 1/4] feat(preview): canvas overlay toggle replaces modal preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the separate-window LabelPreviewModal with an in-canvas overlay so the user can flip between the editor and the Labelary- rendered output at the same scale, position and rotation as a true 1:1 comparison. Store: `previewMode` is a state machine (idle, loading, active, error) with `enterPreviewMode` (async; runs ZPL emit + Labelary fetch) and `exitPreviewMode`. The fetched blob URL lives in the active state and is revoked on exit. Persistence whitelist already excludes `previewMode`, so reloading drops to idle. Canvas: the rendered KImage swaps in for the editor leaves at the same `labelOffset*` / `labelWidthPx`/`HeightPx` coordinates, which means viewRotation and zoom apply naturally to both views. The Transformer, lasso, guides, ghost-drop, rotation button and visibleLeaves all skip when the overlay is on. Mouse handlers (pan, lasso, stage click), keyboard handlers (delete, arrow keys, all useGlobalShortcuts entries) and palette drop guard on `selectPreviewLocksEditor` so the design cannot drift away from the frozen snapshot under the user's hands. PaginationControl disables its next/prev/delete for the same reason. ZPLOutput: the existing "Preview" button is now a toggle. First- press still flows through `LabelaryNoticeModal` for the privacy acknowledgement; subsequent activations fetch directly. Error state surfaces as an in-canvas amber banner with a close button (calls `exitPreviewMode`). Deletes `LabelPreview.tsx` — the modal had no remaining caller. --- src/components/Canvas/LabelCanvas.tsx | 202 ++++++++++++++------ src/components/Canvas/PaginationControl.tsx | 13 +- src/components/Output/LabelPreview.tsx | 118 ------------ src/components/Output/ZPLOutput.tsx | 34 +++- src/hooks/useGlobalShortcuts.ts | 7 +- src/store/labelStore.ts | 70 ++++++- 6 files changed, 252 insertions(+), 192 deletions(-) delete mode 100644 src/components/Output/LabelPreview.tsx diff --git a/src/components/Canvas/LabelCanvas.tsx b/src/components/Canvas/LabelCanvas.tsx index 0aa8a6a6..5f918a94 100644 --- a/src/components/Canvas/LabelCanvas.tsx +++ b/src/components/Canvas/LabelCanvas.tsx @@ -10,9 +10,9 @@ import { } from "react"; import { useDroppable, useDndMonitor } from "@dnd-kit/core"; import type { PaletteDragData } from "../../dnd/types"; -import { Stage, Layer, Group, Rect, Transformer } from "react-konva"; +import { Stage, Layer, Group, Image as KImage, Rect, Transformer } from "react-konva"; import type Konva from "konva"; -import { useLabelStore, useCurrentObjects, currentObjects, getCurrentObjects } from "../../store/labelStore"; +import { useLabelStore, useCurrentObjects, currentObjects, getCurrentObjects, selectPreviewLocksEditor } from "../../store/labelStore"; import { isGroup, getAllLeaves, expandSelection, selectionTargetId, findObjectById, type LabelObject } from "../../types/Group"; import { pxToDots, SCREEN_PX_PER_MM } from "../../lib/coordinates"; import { SNAP_OPTIONS } from "../../lib/units"; @@ -122,6 +122,27 @@ export const LabelCanvas = forwardRef(function LabelCa selectObjects, } = useLabelStore(); const objects = useCurrentObjects(); + const previewMode = useLabelStore((s) => s.previewMode); + const previewLocks = useLabelStore(selectPreviewLocksEditor); + const exitPreviewMode = useLabelStore((s) => s.exitPreviewMode); + + // Load the Labelary blob URL into an HTMLImageElement so react-konva's + // `Image` node can draw it. Decoding before mount avoids a one-frame + // flash of empty space when the user toggles preview on. + const [previewImg, setPreviewImg] = useState(null); + const previewUrl = previewMode.status === 'active' ? previewMode.url : null; + useEffect(() => { + if (!previewUrl) { + setPreviewImg(null); + return; + } + const img = new window.Image(); + img.onload = () => setPreviewImg(img); + img.src = previewUrl; + return () => { + img.onload = null; + }; + }, [previewUrl]); // Render path operates on visible leaves only: groups emit no node of // their own (v1 has no group transform), and a group with visible=false @@ -179,6 +200,9 @@ export const LabelCanvas = forwardRef(function LabelCa if (e.code !== "Delete" && e.code !== "Backspace") return; const tag = (e.target as HTMLElement).tagName; if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return; + // Preview overlay shows a frozen Labelary snapshot; editing while + // it's active would silently drift the comparison out of sync. + if (selectPreviewLocksEditor(useLabelStore.getState())) return; const { selectedIds: ids } = useLabelStore.getState(); if (ids.length === 0) return; e.preventDefault(); @@ -197,6 +221,7 @@ export const LabelCanvas = forwardRef(function LabelCa if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return; const state = useLabelStore.getState(); + if (selectPreviewLocksEditor(state)) return; const ids = state.selectedIds; const objs = currentObjects(state); if (ids.length === 0) return; @@ -577,6 +602,7 @@ export const LabelCanvas = forwardRef(function LabelCa const handleStageDragEnd = () => setGuides([]); const handleStageClick = (e: Konva.KonvaEventObject) => { + if (previewLocks) return; if (consumeDidPan()) return; if (consumeDidLasso()) return; if (e.target === e.target.getStage()) selectObjects([]); @@ -615,13 +641,19 @@ export const LabelCanvas = forwardRef(function LabelCa }; const handleMouseMove = (e: React.MouseEvent) => { + if (previewLocks) return; onPanMouseMove(e); onLassoMouseMove(e); }; const handleMouseUp = () => { + if (previewLocks) return; onPanMouseUp(); onLassoMouseUp(); }; + const handleMouseDown = (e: React.MouseEvent) => { + if (previewLocks) return; + onPanMouseDown(e); + }; const pointerToLabelDots = (clientX: number, clientY: number) => { const rect = containerRef.current?.getBoundingClientRect(); @@ -643,7 +675,7 @@ export const LabelCanvas = forwardRef(function LabelCa useDndMonitor({ onDragMove(event) { - if (event.over?.id !== "canvas") { + if (previewLocks || event.over?.id !== "canvas") { setGhost(null); return; } @@ -657,6 +689,7 @@ export const LabelCanvas = forwardRef(function LabelCa }, onDragEnd(event) { setGhost(null); + if (previewLocks) return; if (event.over?.id !== "canvas") return; const pos = pointerToLabelDots(lastPointerRef.current.x, lastPointerRef.current.y); if (!pos) return; @@ -689,11 +722,38 @@ export const LabelCanvas = forwardRef(function LabelCa backgroundSize: "24px 24px", cursor, }} - onMouseDown={onPanMouseDown} + onMouseDown={handleMouseDown} onMouseMove={handleMouseMove} onMouseUp={handleMouseUp} onMouseLeave={handleMouseUp} > + {/* Preview-mode overlays. Loading is shown DOM-side because + the Konva Image can't render before the bitmap decoded; the + error banner stays inside the canvas region so the user can + dismiss it without leaving the canvas viewport. */} + {previewMode.status === 'loading' && ( +
+ + {t.output.loading} + +
+ )} + {previewMode.status === 'error' && ( +
+
+ + {previewMode.error} + + +
+
+ )} + {label.printOrientation === "I" && ( @@ -816,39 +876,57 @@ export const LabelCanvas = forwardRef(function LabelCa /> )} - {visibleLeaves.map((obj) => ( - { - // Auto-select-parent: clicking a child of a group - // surfaces the outermost containing group as the - // selection target. Top-level leaves pass through. - const target = selectionTargetId(objects, obj.id); - // Lock cascades from the group: a click on a child - // of a locked group routes through handleLockedClick - // (so the next non-locked hit wins) instead of - // selecting through to a leaf the user can't move. - const targetObj = - target === obj.id ? obj : findObjectById(objects, target); - if (targetObj?.locked) handleLockedClick(add); - else if (add) toggleSelectObject(target); - else selectObject(target); - }} - onChange={(changes) => handleObjectChange(obj.id, changes)} - snap={snap} - getOthersSnapshot={snapEnabled ? undefined : getOthersSnapshot} - labelRect={transformerSnapLabelRect} - setGuides={setGuides} - /> - ))} + {/* Preview overlay replaces the editor leaves entirely so the + user sees exactly what Labelary would render at the same + scale and position. Falls back to nothing during loading + (loading spinner is rendered as a DOM overlay outside the + Konva stage), so neither view briefly blinks through. */} + {previewLocks ? ( + previewImg && ( + + ) + ) : ( + visibleLeaves.map((obj) => ( + { + // Auto-select-parent: clicking a child of a group + // surfaces the outermost containing group as the + // selection target. Top-level leaves pass through. + const target = selectionTargetId(objects, obj.id); + // Lock cascades from the group: a click on a child + // of a locked group routes through handleLockedClick + // (so the next non-locked hit wins) instead of + // selecting through to a leaf the user can't move. + const targetObj = + target === obj.id ? obj : findObjectById(objects, target); + if (targetObj?.locked) handleLockedClick(add); + else if (add) toggleSelectObject(target); + else selectObject(target); + }} + onChange={(changes) => handleObjectChange(obj.id, changes)} + snap={snap} + getOthersSnapshot={snapEnabled ? undefined : getOthersSnapshot} + labelRect={transformerSnapLabelRect} + setGuides={setGuides} + /> + )) + )} - {ghost && ( + {!previewLocks && ghost && ( (function LabelCa respects the rotation; snap guides come from computeSnap in stage pixels; the Transformer follows nodes through their accumulated parent transform. */} - {lassoRect && ( + {!previewLocks && lassoRect && ( (function LabelCa /> )} - - - + {!previewLocks && } + + {!previewLocks && ( + + )} - {rotationBtnPos && ( + {!previewLocks && rotationBtnPos && ( s.currentPageIndex); const setCurrentPage = useLabelStore((s) => s.setCurrentPage); const removePage = useLabelStore((s) => s.removePage); + const previewLocks = useLabelStore(selectPreviewLocksEditor); // Hide entirely on single-page documents; adding pages lives in the File menu. if (pageCount <= 1) return null; - const canPrev = currentPageIndex > 0; - const canNext = currentPageIndex < pageCount - 1; + // The preview overlay caches a snapshot of the current page; switching + // pages or deleting one would either invalidate the comparison or + // pull the rug from under it. + const canPrev = !previewLocks && currentPageIndex > 0; + const canNext = !previewLocks && currentPageIndex < pageCount - 1; return (
@@ -44,9 +48,10 @@ export function PaginationControl() {
diff --git a/src/components/Output/LabelPreview.tsx b/src/components/Output/LabelPreview.tsx deleted file mode 100644 index 378505f9..00000000 --- a/src/components/Output/LabelPreview.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; -import { XMarkIcon, ArrowDownTrayIcon } from '@heroicons/react/16/solid'; -import { useLabelStore, useCurrentObjects } from '../../store/labelStore'; -import { generateZPL } from '../../lib/zplGenerator'; -import { fetchPreview, labelaryErrorMessage, isDefaultLabelaryHost } from '../../lib/labelary'; -import { triggerDownload } from '../../lib/triggerDownload'; -import { useT } from '../../lib/useT'; -import { DialogShell } from '../ui/DialogShell'; - -interface Props { - onClose: () => void; -} - -/** Preview modal — assumes the privacy notice has already been - * acknowledged. Callers (ZPLOutput) gate the modal behind - * LabelaryNoticeModal so this component never has to handle the - * pre-ack state. */ -export function LabelPreviewModal({ onClose }: Props) { - const t = useT(); - const label = useLabelStore((s) => s.label); - const objects = useCurrentObjects(); - - const [previewUrl, setPreviewUrl] = useState(null); - const [error, setError] = useState(null); - const urlRef = useRef(null); - const zplRef = useRef(generateZPL(label, objects)); - const loading = !previewUrl && !error; - - useEffect(() => { - let cancelled = false; - fetchPreview(zplRef.current, label) - .then((url) => { - if (cancelled) { URL.revokeObjectURL(url); return; } - urlRef.current = url; - setPreviewUrl(url); - }) - .catch((e: unknown) => { - if (cancelled) return; - setError(labelaryErrorMessage(e)); - }); - return () => { - cancelled = true; - if (urlRef.current) URL.revokeObjectURL(urlRef.current); - }; - // `label` and the generated ZPL are intentionally captured once at mount - // (via zplRef): the preview should reflect the snapshot the user saw when - // they opened the modal, not refetch when the canvas changes underneath. - }, []); // eslint-disable-line react-hooks/exhaustive-deps - - const handleDownloadFallback = () => { - triggerDownload(new Blob([zplRef.current], { type: 'text/plain' }), 'label.zpl'); - }; - - return ( - -
- - {t.output.previewHeading} - - -
- - {/* Inset preview area: bg-bg gives a clear edge against the surrounding - surface (especially in light mode where the label image is white). - The outer div scrolls; the inner one stays at least as large as the - viewport so small previews are still centered. */} -
-
- {loading && ( - {t.output.loading} - )} - {!loading && error && ( -
- {error} - -
- )} - {!loading && !error && previewUrl && ( - Label preview - )} -
-
- - {isDefaultLabelaryHost() && ( - - )} -
- ); -} diff --git a/src/components/Output/ZPLOutput.tsx b/src/components/Output/ZPLOutput.tsx index 77b18d1a..1eb65052 100644 --- a/src/components/Output/ZPLOutput.tsx +++ b/src/components/Output/ZPLOutput.tsx @@ -3,7 +3,6 @@ import { CheckIcon, ClipboardDocumentIcon, ChevronDownIcon, ChevronUpIcon, EyeIc import { useLabelStore, selectLabelaryNoticeRequired } from '../../store/labelStore'; import { generateMultiPageZPL } from '../../lib/zplGenerator'; import { useT } from '../../lib/useT'; -import { LabelPreviewModal } from './LabelPreview'; import { LabelaryNoticeModal } from './LabelaryNoticeModal'; interface Props { @@ -18,13 +17,30 @@ export function ZPLOutput({ collapsed, onCollapse, onExpand }: Props) { const pages = useLabelStore((s) => s.pages); const labelaryEnabled = useLabelStore((s) => s.thirdParty.labelary); const noticeRequired = useLabelStore(selectLabelaryNoticeRequired); + const previewMode = useLabelStore((s) => s.previewMode); + const enterPreviewMode = useLabelStore((s) => s.enterPreviewMode); + const exitPreviewMode = useLabelStore((s) => s.exitPreviewMode); const [copied, setCopied] = useState(false); - const [showPreview, setShowPreview] = useState(false); const [showNotice, setShowNotice] = useState(false); const hasObjects = pages.some((p) => p.objects.length > 0); const zpl = hasObjects ? generateMultiPageZPL(label, pages) : ''; + const previewActive = + previewMode.status === 'loading' || previewMode.status === 'active'; + + const togglePreview = () => { + if (previewActive) { + exitPreviewMode(); + return; + } + if (noticeRequired) { + setShowNotice(true); + return; + } + void enterPreviewMode(); + }; + const handleCopy = () => { if (!zpl) return; navigator.clipboard.writeText(zpl).then(() => { @@ -50,10 +66,15 @@ export function ZPLOutput({ collapsed, onCollapse, onExpand }: Props) {
{labelaryEnabled && (
); } diff --git a/src/hooks/useGlobalShortcuts.ts b/src/hooks/useGlobalShortcuts.ts index 60eb6d0a..662ca5ca 100644 --- a/src/hooks/useGlobalShortcuts.ts +++ b/src/hooks/useGlobalShortcuts.ts @@ -1,5 +1,5 @@ import { useEffect } from "react"; -import { useLabelStore, useHistory, getCurrentObjects } from "../store/labelStore"; +import { useLabelStore, useHistory, getCurrentObjects, selectPreviewLocksEditor } from "../store/labelStore"; import { nextRotation } from "../components/Canvas/rotationGeometry"; export function useGlobalShortcuts() { @@ -16,6 +16,11 @@ export function useGlobalShortcuts() { useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { + // Preview overlay freezes the design at activation time; every + // shortcut here either edits the model or changes the selection + // /page, both of which would visibly drift away from the frozen + // snapshot. Block them wholesale. + if (selectPreviewLocksEditor(useLabelStore.getState())) return; const tag = (e.target as HTMLElement).tagName; const inInput = tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT"; const mod = e.metaKey || e.ctrlKey; diff --git a/src/store/labelStore.ts b/src/store/labelStore.ts index 12474488..d39db8b9 100644 --- a/src/store/labelStore.ts +++ b/src/store/labelStore.ts @@ -17,7 +17,8 @@ import { } from '../types/Group'; import { locales } from '../locales'; import type { LocaleCode } from '../locales'; -import { isDefaultLabelaryHost } from '../lib/labelary'; +import { isDefaultLabelaryHost, fetchPreview, labelaryErrorMessage } from '../lib/labelary'; +import { generateZPL } from '../lib/zplGenerator'; export type { ObjectChanges }; @@ -103,6 +104,18 @@ export interface CanvasSettings { export type ThemePreference = 'light' | 'dark'; +/** Labelary-backed canvas overlay. While `active`, the canvas renders + * the Labelary-rendered PNG in place of the editor objects so the user + * can A/B compare design vs. printed output at the same scale. The + * fetch happens on entry and the snapshot is frozen for the lifetime + * of the active session — no live refresh — because the comparison + * loses meaning if the underlying design shifts under it. */ +export type PreviewMode = + | { status: 'idle' } + | { status: 'loading' } + | { status: 'active'; url: string } + | { status: 'error'; error: string }; + interface LabelState { label: LabelConfig; pages: Page[]; @@ -121,6 +134,11 @@ interface LabelState { /** Whether the user has dismissed the one-time Labelary privacy notice. */ labelaryNoticeAcknowledged: boolean; + /** State of the Labelary canvas overlay. `idle` is the editor default; + * `loading`/`active`/`error` mean the comparison overlay is in play and + * editor surfaces should be visually locked. */ + previewMode: PreviewMode; + clipboard: LabelObject[]; pasteCount: number; @@ -175,6 +193,15 @@ interface LabelState { removePage: (index: number) => void; duplicatePage: (index: number) => void; setCurrentPage: (index: number) => void; + + /** Start a preview session: render the current page's objects to ZPL, + * fetch the Labelary PNG, swap status to `active` on success or + * `error` on failure. Should only be called when `previewMode.status` + * is `idle` or `error` (the toggle button enforces this). */ + enterPreviewMode: () => Promise; + /** End a preview session: revoke the cached blob URL and reset to + * `idle`. Safe to call from any non-`idle` status. */ + exitPreviewMode: () => void; } type PageState = Pick; @@ -196,6 +223,14 @@ export const canCallLabelary = (s: LabelState): boolean => export const selectLabelaryNoticeRequired = (s: LabelState): boolean => isDefaultLabelaryHost() && !s.labelaryNoticeAcknowledged; +/** True while the preview overlay is taking input away from the editor — + * i.e. the canvas Stage should not accept pointer events. Loading and + * active both qualify (loading blocks edits so the snapshot we're about + * to show isn't already stale); error and idle return false so the user + * can keep working after dismissing a failure. */ +export const selectPreviewLocksEditor = (s: LabelState): boolean => + s.previewMode.status === 'loading' || s.previewMode.status === 'active'; + function updateCurrentObjects( state: PageState, fn: (objects: LabelObject[]) => LabelObject[] @@ -298,6 +333,7 @@ export const useLabelStore = create()( theme: detectInitialTheme(), thirdParty: thirdPartyDefaults(), labelaryNoticeAcknowledged: false, + previewMode: { status: 'idle' }, canvasSettings: { showGrid: false, snapEnabled: false, snapSizeMm: 1, zoom: 1, unit: 'mm', viewRotation: 0 }, addObject: (type, position = { x: 50, y: 50 }) => { @@ -767,6 +803,38 @@ export const useLabelStore = create()( if (index === state.currentPageIndex) return {}; return { currentPageIndex: index, selectedIds: [] }; }), + + enterPreviewMode: async () => { + const state = get(); + if (state.previewMode.status === 'loading' || state.previewMode.status === 'active') { + return; + } + set({ previewMode: { status: 'loading' } }); + const objs = currentObjects(state); + const zpl = generateZPL(state.label, objs); + try { + const url = await fetchPreview(zpl, state.label); + // Avoid clobbering an exit that happened while the request was in + // flight — if the user toggled off, the loading state is gone. + if (get().previewMode.status !== 'loading') { + URL.revokeObjectURL(url); + return; + } + set({ previewMode: { status: 'active', url } }); + } catch (e) { + if (get().previewMode.status !== 'loading') return; + set({ previewMode: { status: 'error', error: labelaryErrorMessage(e) } }); + } + }, + + exitPreviewMode: () => + set((state) => { + if (state.previewMode.status === 'active') { + URL.revokeObjectURL(state.previewMode.url); + } + if (state.previewMode.status === 'idle') return {}; + return { previewMode: { status: 'idle' } }; + }), }), { name: 'zpl-designer-session', From 3898c5830df9d8e56b2b391e1946bef4f0ed58b8 Mon Sep 17 00:00:00 2001 From: u8array Date: Fri, 15 May 2026 10:35:02 +0200 Subject: [PATCH 2/4] feat(preview): amber glow on the label paper while overlay is active Swaps the label's default drop-shadow for a symmetric amber halo while `previewMode` is locking the editor, so the mode signal sits at the user's locus of attention (the paper) instead of only in the toolbar button at the bottom of the screen. Replaces (not stacks on) the existing shadow because layering an amber glow over the dark drop-shadow reads as a passive theme drift rather than an active mode change. Amber matches the colour the app already uses for Labelary-related warnings, so the colour language stays consistent. Glow is outward- only with `shadowOffsetY: 0` so it does not bleed into the preview image itself. --- src/components/Canvas/LabelCanvas.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/components/Canvas/LabelCanvas.tsx b/src/components/Canvas/LabelCanvas.tsx index 5f918a94..a2e0f860 100644 --- a/src/components/Canvas/LabelCanvas.tsx +++ b/src/components/Canvas/LabelCanvas.tsx @@ -858,9 +858,14 @@ export const LabelCanvas = forwardRef(function LabelCa width={labelWidthPx} height={labelHeightPx} fill="white" - shadowColor="rgba(0,0,0,0.4)" - shadowBlur={12} - shadowOffsetY={2} + // Preview overlay swaps the default drop-shadow for a + // symmetric amber glow so the paper itself carries the + // mode indicator at the user's locus of attention. Same + // amber the app uses for Labelary-related warnings, so + // the colour language stays consistent. + shadowColor={previewLocks ? 'rgba(251, 191, 36, 0.55)' : 'rgba(0,0,0,0.4)'} + shadowBlur={previewLocks ? 28 : 12} + shadowOffsetY={previewLocks ? 0 : 2} onClick={() => selectObjects([])} /> From 6e5ab030f0d7f2d25a56002b318511abfc5c4e5b Mon Sep 17 00:00:00 2001 From: u8array Date: Fri, 15 May 2026 10:44:11 +0200 Subject: [PATCH 3/4] feat(preview): not-allowed cursor + Escape to exit overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two affordances for the editor-locked state introduced by the preview overlay: * Cursor shows `not-allowed` while `selectPreviewLocksEditor` holds. Signals the locked state at the user's locus of attention (the canvas) instead of leaving the toolbar button as the only state feedback, so an attempted edit no longer silent-fails. * Escape key now exits the overlay. Bound globally so the user can return to the editor from anywhere; the existing useGlobalShortcuts guard already short-circuits other shortcut bindings while preview locks, so there is no collision. Click-to-exit was deliberately not added — hidden affordance, risks accidental exits, and removes the option to inspect the preview without leaving the mode. --- src/components/Canvas/LabelCanvas.tsx | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/components/Canvas/LabelCanvas.tsx b/src/components/Canvas/LabelCanvas.tsx index a2e0f860..2fb91ca8 100644 --- a/src/components/Canvas/LabelCanvas.tsx +++ b/src/components/Canvas/LabelCanvas.tsx @@ -194,6 +194,21 @@ export const LabelCanvas = forwardRef(function LabelCa // shapes hidden behind a filled (or inverted) form. useAltClickCycle({ containerRef, stageRef, selectObject }); + // Escape exits preview mode. Stays bound globally (not just to the + // canvas) so the user can return to the editor from anywhere — and + // the existing useGlobalShortcuts already short-circuits the rest of + // its bindings while preview locks, so there's no collision risk. + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.code !== 'Escape') return; + if (!selectPreviewLocksEditor(useLabelStore.getState())) return; + e.preventDefault(); + useLabelStore.getState().exitPreviewMode(); + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, []); + // Delete/Backspace removes all selected objects; ignored when focus is inside an input useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { @@ -720,7 +735,11 @@ export const LabelCanvas = forwardRef(function LabelCa background: colors.canvasBg, backgroundImage: `radial-gradient(circle, ${colors.canvasDot} 1px, transparent 1px)`, backgroundSize: "24px 24px", - cursor, + // `not-allowed` while the preview overlay is locking the editor: + // signals at the user's locus of attention (the canvas itself) + // that editing is paused, instead of relying on the toolbar button + // alone for state feedback. + cursor: previewLocks ? 'not-allowed' : cursor, }} onMouseDown={handleMouseDown} onMouseMove={handleMouseMove} From 58b81c02d924fb80d698a90ba99748c587661b24 Mon Sep 17 00:00:00 2001 From: u8array Date: Fri, 15 May 2026 10:54:17 +0200 Subject: [PATCH 4/4] refactor(preview): centralise editor-lock at the store mutators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Until now the preview overlay's "freeze the editor" invariant was enforced piecemeal at the UI surfaces: canvas mouse and keyboard handlers, palette drop, useGlobalShortcuts and PaginationControl each checked `selectPreviewLocksEditor` and short-circuited. The gap: PropertiesPanel inputs, LayersPanel toggles, header undo/redo and any future caller that goes straight to a store action could still drift the design under the frozen Labelary snapshot. Move the load-bearing guard to the store. Every design-mutating action now checks the lock at entry; selection, view settings, locale, theme and the preview controls themselves stay open. The existing UI guards become defense-in-depth and keep their value for the cursor / disabled-state UX they also provide. Also wraps zundo's `useHistory` so undo and redo become no-ops while locked — header buttons would otherwise rewind state without going through the store mutator path. --- src/store/labelStore.test.ts | 91 ++++++++++++++++++++++++++++++++++++ src/store/labelStore.ts | 62 ++++++++++++++++++++---- 2 files changed, 145 insertions(+), 8 deletions(-) diff --git a/src/store/labelStore.test.ts b/src/store/labelStore.test.ts index 93468aff..b86d11b2 100644 --- a/src/store/labelStore.test.ts +++ b/src/store/labelStore.test.ts @@ -12,6 +12,7 @@ function reset() { selectedIds: [], clipboard: [], pasteCount: 0, + previewMode: { status: 'idle' }, canvasSettings: { showGrid: false, snapEnabled: false, @@ -852,4 +853,94 @@ describe('ungroup', () => { expect(childLeaf().visible).toBe(false); }); }); + + describe('preview lock blocks design mutations', () => { + function enterPreview() { + // Push the store directly into the active state — the real + // `enterPreviewMode` would talk to Labelary and can't run in tests. + useLabelStore.setState({ + previewMode: { status: 'active', url: 'blob:test' }, + }); + } + + it('blocks addObject', () => { + enterPreview(); + state().addObject('text'); + expect(objs()).toHaveLength(0); + }); + + it('blocks updateObject', () => { + state().addObject('text'); + const id = defined(objs()[0]).id; + const before = defined(objs()[0]).x; + enterPreview(); + state().updateObject(id, { x: before + 100 }); + expect(defined(objs()[0]).x).toBe(before); + }); + + it('blocks updateObjects', () => { + state().addObject('text'); + const id = defined(objs()[0]).id; + const before = defined(objs()[0]).x; + enterPreview(); + state().updateObjects([{ id, changes: { x: before + 100 } }]); + expect(defined(objs()[0]).x).toBe(before); + }); + + it('blocks removeSelectedObjects', () => { + state().addObject('text'); + const id = defined(objs()[0]).id; + state().selectObject(id); + enterPreview(); + state().removeSelectedObjects(); + expect(objs()).toHaveLength(1); + }); + + it('blocks groupSelection', () => { + state().addObject('text'); + state().addObject('box'); + state().selectObjects(objs().map((o) => o.id)); + enterPreview(); + state().groupSelection(); + expect(objs().some(isGroup)).toBe(false); + }); + + it('blocks setLabelConfig', () => { + const before = state().label.widthMm; + enterPreview(); + state().setLabelConfig({ widthMm: before + 50 }); + expect(state().label.widthMm).toBe(before); + }); + + it('blocks setCurrentPage', () => { + state().addPage(); + const before = state().currentPageIndex; + enterPreview(); + state().setCurrentPage(0); + expect(state().currentPageIndex).toBe(before); + }); + + it('blocks pasteObjects', () => { + state().addObject('text'); + state().selectObjects([defined(objs()[0]).id]); + state().copySelectedObjects(); + enterPreview(); + state().pasteObjects(); + expect(objs()).toHaveLength(1); + }); + + it('does not block selection actions (selecting is harmless)', () => { + state().addObject('text'); + const id = defined(objs()[0]).id; + enterPreview(); + state().selectObject(id); + expect(state().selectedIds).toEqual([id]); + }); + + it('does not block exitPreviewMode', () => { + enterPreview(); + state().exitPreviewMode(); + expect(state().previewMode.status).toBe('idle'); + }); + }); }); diff --git a/src/store/labelStore.ts b/src/store/labelStore.ts index d39db8b9..2c239b7e 100644 --- a/src/store/labelStore.ts +++ b/src/store/labelStore.ts @@ -337,6 +337,7 @@ export const useLabelStore = create()( canvasSettings: { showGrid: false, snapEnabled: false, snapSizeMm: 1, zoom: 1, unit: 'mm', viewRotation: 0 }, addObject: (type, position = { x: 50, y: 50 }) => { + if (selectPreviewLocksEditor(get())) return; const definition = ObjectRegistry[type]; if (!definition) return; @@ -357,6 +358,7 @@ export const useLabelStore = create()( updateObject: (id, changes) => set((state) => { + if (selectPreviewLocksEditor(state)) return {}; const objs = currentObjects(state); const ancestorLocked = findAncestors(objs, id).some((g) => !!g.locked); return updateCurrentObjects(state, (curr) => @@ -368,6 +370,7 @@ export const useLabelStore = create()( updateObjects: (updates) => set((state) => { + if (selectPreviewLocksEditor(state)) return {}; if (updates.length === 0) return {}; // Single tree walk that applies every queued change in one // pass: O(tree) instead of O(updates × tree). Identity- @@ -403,6 +406,7 @@ export const useLabelStore = create()( removeObject: (id) => set((state) => { + if (selectPreviewLocksEditor(state)) return {}; const obj = currentObjects(state).find((o) => o.id === id); if (obj?.locked) return {}; return { @@ -413,6 +417,7 @@ export const useLabelStore = create()( duplicateObject: (id) => set((state) => { + if (selectPreviewLocksEditor(state)) return {}; const copies = buildOffsetCopies(currentObjects(state), [id]); if (copies.length === 0) return {}; return { @@ -423,6 +428,7 @@ export const useLabelStore = create()( duplicateSelectedObjects: () => set((state) => { + if (selectPreviewLocksEditor(state)) return {}; if (state.selectedIds.length === 0) return {}; const copies = buildOffsetCopies(currentObjects(state), state.selectedIds); return { @@ -433,6 +439,9 @@ export const useLabelStore = create()( copySelectedObjects: () => { const state = get(); + // Copy doesn't mutate the design, but the clipboard write would + // create a confusing "I copied something during preview" state. + if (selectPreviewLocksEditor(state)) return; const objs = currentObjects(state); const clipboard = state.selectedIds.flatMap((id) => { const obj = objs.find((o) => o.id === id); @@ -450,6 +459,7 @@ export const useLabelStore = create()( pasteObjects: () => set((state) => { + if (selectPreviewLocksEditor(state)) return {}; if (state.clipboard.length === 0) return {}; const pasteCount = state.pasteCount + 1; const offset = pasteCount * DUPLICATE_OFFSET_DOTS; @@ -494,6 +504,7 @@ export const useLabelStore = create()( removeSelectedObjects: () => set((state) => { + if (selectPreviewLocksEditor(state)) return {}; const sel = new Set(state.selectedIds); const objs = currentObjects(state); // Locked objects survive a Delete keystroke / bulk-remove; the @@ -509,6 +520,7 @@ export const useLabelStore = create()( groupSelection: () => set((state) => { + if (selectPreviewLocksEditor(state)) return {}; const objs = currentObjects(state); const sel = new Set(state.selectedIds); // Only consider top-level objects of the current page. Nested @@ -554,6 +566,7 @@ export const useLabelStore = create()( reparentObject: (id, target) => set((state) => { + if (selectPreviewLocksEditor(state)) return {}; const objs = currentObjects(state); // Forbid cycles: moving a group into itself or one of its // descendants would orphan the rest of the tree. @@ -584,6 +597,7 @@ export const useLabelStore = create()( addGroup: () => set((state) => { + if (selectPreviewLocksEditor(state)) return {}; const group: GroupObject = { id: crypto.randomUUID(), type: 'group', @@ -602,6 +616,7 @@ export const useLabelStore = create()( ungroupIds: (ids) => set((state) => { + if (selectPreviewLocksEditor(state)) return {}; const wanted = new Set(ids); const objs = currentObjects(state); const targets = objs.flatMap((o) => @@ -627,6 +642,7 @@ export const useLabelStore = create()( moveObjectToFront: (id) => set((state) => { + if (selectPreviewLocksEditor(state)) return {}; const objs = currentObjects(state); const idx = objs.findIndex((o) => o.id === id); if (idx === -1 || idx === objs.length - 1) return {}; @@ -640,6 +656,7 @@ export const useLabelStore = create()( moveObjectToBack: (id) => set((state) => { + if (selectPreviewLocksEditor(state)) return {}; const objs = currentObjects(state); const idx = objs.findIndex((o) => o.id === id); if (idx <= 0) return {}; @@ -653,6 +670,7 @@ export const useLabelStore = create()( moveObjectForward: (id) => set((state) => { + if (selectPreviewLocksEditor(state)) return {}; const objs = currentObjects(state); const idx = objs.findIndex((o) => o.id === id); if (idx === -1 || idx === objs.length - 1) return {}; @@ -667,6 +685,7 @@ export const useLabelStore = create()( moveObjectBackward: (id) => set((state) => { + if (selectPreviewLocksEditor(state)) return {}; const objs = currentObjects(state); const idx = objs.findIndex((o) => o.id === id); if (idx <= 0) return {}; @@ -681,6 +700,7 @@ export const useLabelStore = create()( reorderObject: (id, toIndex) => set((state) => { + if (selectPreviewLocksEditor(state)) return {}; const objs = currentObjects(state); const fromIndex = objs.findIndex((o) => o.id === id); if (fromIndex === -1 || fromIndex === toIndex) return {}; @@ -693,11 +713,14 @@ export const useLabelStore = create()( }), loadDesign: (label, pages) => - set({ - label, - pages: pages.length > 0 ? pages : [{ objects: [] }], - currentPageIndex: 0, - selectedIds: [], + set((state) => { + if (selectPreviewLocksEditor(state)) return {}; + return { + label, + pages: pages.length > 0 ? pages : [{ objects: [] }], + currentPageIndex: 0, + selectedIds: [], + }; }), // Append-mode counterpart to loadDesign: keeps the current label @@ -706,6 +729,7 @@ export const useLabelStore = create()( // page list, switching focus to the first appended page. appendPages: (pages) => set((state) => { + if (selectPreviewLocksEditor(state)) return {}; if (pages.length === 0) return {}; const newPages = [...state.pages, ...pages]; return { @@ -716,7 +740,10 @@ export const useLabelStore = create()( }), setLabelConfig: (config) => - set((state) => ({ label: { ...state.label, ...config } })), + set((state) => { + if (selectPreviewLocksEditor(state)) return {}; + return { label: { ...state.label, ...config } }; + }), setLocale: (locale) => set({ locale }), @@ -732,6 +759,7 @@ export const useLabelStore = create()( addPage: () => set((state) => { + if (selectPreviewLocksEditor(state)) return {}; const insertAt = state.currentPageIndex + 1; const newPages = [ ...state.pages.slice(0, insertAt), @@ -747,6 +775,7 @@ export const useLabelStore = create()( removePage: (index) => set((state) => { + if (selectPreviewLocksEditor(state)) return {}; if (state.pages.length <= 1) return {}; if (index < 0 || index >= state.pages.length) return {}; const newPages = state.pages.filter((_, i) => i !== index); @@ -765,6 +794,7 @@ export const useLabelStore = create()( duplicatePage: (index) => set((state) => { + if (selectPreviewLocksEditor(state)) return {}; if (index < 0 || index >= state.pages.length) return {}; const source = state.pages[index]; if (!source) return {}; @@ -799,6 +829,7 @@ export const useLabelStore = create()( setCurrentPage: (index) => set((state) => { + if (selectPreviewLocksEditor(state)) return {}; if (index < 0 || index >= state.pages.length) return {}; if (index === state.currentPageIndex) return {}; return { currentPageIndex: index, selectedIds: [] }; @@ -873,5 +904,20 @@ export const useCurrentObjects = () => useLabelStore(currentObjects); export const getCurrentObjects = (): LabelObject[] => currentObjects(useLabelStore.getState()); -// Undo / redo -export const useHistory = () => useStore(useLabelStore.temporal); +// Undo / redo. Wrapping zundo's hook so undo/redo become no-ops while +// the preview overlay is locking the editor. Header buttons read +// `canUndo`/`canRedo` from `pastStates`/`futureStates` — those keep +// reporting truthful values, so a separate UI check (or button +// disabled-state) still wins for visual feedback. The wrapper here is +// the load-bearing safety net for any caller that goes straight to +// `useHistory().undo()`. +const noopHistoryAction = () => { + /* preview lock: no-op so undo/redo never replay state under a frozen + * Labelary snapshot. */ +}; +export const useHistory = () => { + const history = useStore(useLabelStore.temporal); + const locked = useLabelStore(selectPreviewLocksEditor); + if (!locked) return history; + return { ...history, undo: noopHistoryAction, redo: noopHistoryAction }; +};