diff --git a/src/components/Canvas/LabelCanvas.tsx b/src/components/Canvas/LabelCanvas.tsx index 0aa8a6a6..2fb91ca8 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 @@ -173,12 +194,30 @@ 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) => { 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 +236,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 +617,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 +656,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 +690,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 +704,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; @@ -687,13 +735,44 @@ 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={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" && ( @@ -798,9 +877,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([])} /> @@ -816,39 +900,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.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 12474488..2c239b7e 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,9 +333,11 @@ 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 }) => { + if (selectPreviewLocksEditor(get())) return; const definition = ObjectRegistry[type]; if (!definition) return; @@ -321,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) => @@ -332,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- @@ -367,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 { @@ -377,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 { @@ -387,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 { @@ -397,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); @@ -414,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; @@ -458,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 @@ -473,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 @@ -518,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. @@ -548,6 +597,7 @@ export const useLabelStore = create()( addGroup: () => set((state) => { + if (selectPreviewLocksEditor(state)) return {}; const group: GroupObject = { id: crypto.randomUUID(), type: 'group', @@ -566,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) => @@ -591,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 {}; @@ -604,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 {}; @@ -617,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 {}; @@ -631,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 {}; @@ -645,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 {}; @@ -657,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 @@ -670,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 { @@ -680,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 }), @@ -696,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), @@ -711,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); @@ -729,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 {}; @@ -763,10 +829,43 @@ 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: [] }; }), + + 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', @@ -805,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 }; +};