From 1f21d27965e7686191846fb4ddd7330263b5aad2 Mon Sep 17 00:00:00 2001 From: u8array Date: Fri, 16 Oct 2026 16:33:42 +0200 Subject: [PATCH 1/2] fix(core): a fraction on an integer slot drops like the printer drops it --- packages/core/src/lib/anchorRepin.ts | 14 +++++++++++--- packages/core/src/lib/designFile.ts | 9 +++++---- packages/core/src/types/propSpec.ts | 13 +++++++++++++ src/lib/designFile.test.ts | 4 ++-- src/store/anchorRepin.test.ts | 9 +++++++++ 5 files changed, 40 insertions(+), 9 deletions(-) diff --git a/packages/core/src/lib/anchorRepin.ts b/packages/core/src/lib/anchorRepin.ts index fe890a98..ce597723 100644 --- a/packages/core/src/lib/anchorRepin.ts +++ b/packages/core/src/lib/anchorRepin.ts @@ -2,6 +2,7 @@ import type { LabelObject } from "../types/Group"; import type { ObjectChanges } from "../types/LabelObject"; import { BARCODE_1D_TYPES, getEntry } from "../registry"; import type { NormalizeChangesCtx } from "../types/ObjectType"; +import { truncateIntegerProps, type PropSpec } from "../types/propSpec"; import { isAxisSwapped, objectRotation } from "../registry/rotation"; import { valueAnchorShift } from "./valueAnchor"; import type { Footprint as BarcodeFootprint } from "./footprintProber"; @@ -56,9 +57,10 @@ export function applyChanges( probe: (o: LabelObject) => BarcodeFootprint | null, ctx?: NormalizeChangesCtx, ): LabelObject { - const normalize = getEntry(obj.type)?.normalizeChanges; - const normalized = normalize ? normalize(obj as never, changes as never, ctx) : changes; - if (changesNothing(obj, normalized as ObjectChanges)) return obj; + const entry = getEntry(obj.type); + const shaped = entry?.normalizeChanges ? entry.normalizeChanges(obj as never, changes as never, ctx) : changes; + const normalized = wholeProps(entry?.propSpecs as Readonly> | undefined, shaped as ObjectChanges); + if (changesNothing(obj, normalized)) return obj; const current = (obj as { props?: object }).props ?? {}; const next = { ...obj, @@ -71,6 +73,12 @@ export function applyChanges( return anchorRepin(obj, normalized as ObjectChanges, next, probe); } +function wholeProps(specs: Readonly> | undefined, changes: ObjectChanges): ObjectChanges { + if (!specs || !changes.props) return changes; + const props = truncateIntegerProps(specs, changes.props as Record); + return props === changes.props ? changes : { ...changes, props }; +} + /** Shallow compare, props one level down. */ export function changesNothing(obj: LabelObject, changes: ObjectChanges): boolean { const o = obj as Record; diff --git a/packages/core/src/lib/designFile.ts b/packages/core/src/lib/designFile.ts index c07d34ed..2b5b2231 100644 --- a/packages/core/src/lib/designFile.ts +++ b/packages/core/src/lib/designFile.ts @@ -18,7 +18,7 @@ import { insertReverseBackingBoxes, pageNeedsReverseBacking } from "./reverseBac import { ok, err } from "./result"; import { schemaIssues } from "./schemaIssues"; import { getEntry } from "../registry"; -import { propDomainIssue, type PropSpec } from "../types/propSpec"; +import { propDomainIssue, truncateIntegerProps, type PropSpec } from "../types/propSpec"; /** Current design-file schema version. Bump when the persisted shape * changes in a way an older app cannot read. Add a vN schema + @@ -101,13 +101,13 @@ const designFileSchema = z.object({ }); // A string outside its set would reach a parameter slot as written (^A090). One without a default is dropped. -function conformEnumProps(leaf: { type?: unknown; props?: unknown }): void { +function conformProps(leaf: { type?: unknown; props?: unknown }): void { const entry = typeof leaf.type === "string" ? getEntry(leaf.type) : undefined; const props = leaf.props; if (!entry || !props || typeof props !== "object" || Array.isArray(props)) return; const specs = entry.propSpecs as Record; const defaults = entry.defaultProps as Record; - leaf.props = Object.fromEntries( + const conformed = Object.fromEntries( Object.entries(props as Record) .map(([key, value]) => { const spec = Object.hasOwn(specs, key) ? specs[key] : undefined; @@ -116,6 +116,7 @@ function conformEnumProps(leaf: { type?: unknown; props?: unknown }): void { }) .filter(([, value]) => value !== undefined), ); + leaf.props = truncateIntegerProps(specs, conformed); } export function parseDesignFile(text: string): DesignFileResult { @@ -134,7 +135,7 @@ export function parseDesignFile(text: string): DesignFileResult { migrateSerialToTextMode(json); migrateSingleBindToMarker(json); migrateBareFontDrive(json); - visitLeavesInPages((json as { pages?: unknown }).pages, conformEnumProps); + visitLeavesInPages((json as { pages?: unknown }).pages, conformProps); const parsed = designFileSchema.safeParse(json); if (parsed.success) { diff --git a/packages/core/src/types/propSpec.ts b/packages/core/src/types/propSpec.ts index 01647c74..725eb8a1 100644 --- a/packages/core/src/types/propSpec.ts +++ b/packages/core/src/types/propSpec.ts @@ -81,6 +81,19 @@ export function propTypeIssue(spec: PropSpec, value: unknown): string | null { return null; } +/** The printer reads an integer slot up to the first non-digit, so a fraction prints as its integer part. + * Same object back when nothing changes. */ +export function truncateIntegerProps(specs: Readonly>, props: Record): Record { + let next: Record | undefined; + for (const [key, value] of Object.entries(props)) { + const spec = Object.hasOwn(specs, key) ? specs[key] : undefined; + if (spec?.type !== 'number' || !spec.integer || typeof value !== 'number' || Number.isInteger(value) || !Number.isFinite(value)) continue; + next ??= { ...props }; + next[key] = Math.trunc(value); + } + return next ?? props; +} + /** Assumes `propTypeIssue` passed. */ export function propDomainIssue(spec: PropSpec, value: unknown): string | null { if (spec.type === 'number') { diff --git a/src/lib/designFile.test.ts b/src/lib/designFile.test.ts index cecc317e..cbaa554f 100644 --- a/src/lib/designFile.test.ts +++ b/src/lib/designFile.test.ts @@ -103,7 +103,7 @@ describe('parseDesignFile', () => { expect(result.value.pages[0]?.objects).toHaveLength(1); }); - it('repairs a string outside its set on load and leaves every number as written', () => { + it('conforms loaded props: a string to its set or dropped, a fraction off an integer slot, a number past its range kept', () => { const file = { schemaVersion: 5, label: { widthMm: 70, heightMm: 40, dpmm: 8 }, @@ -119,7 +119,7 @@ describe('parseDesignFile', () => { if (!result.ok) return; const props = result.value.pages[0]!.objects.map((o) => (o as unknown as { props: Record }).props); expect(props[0]).toMatchObject({ rounding: 12 }); - expect(props[1]).toMatchObject({ magnification: 99.4, errorCorrection: 'Q', rotation: 'N' }); + expect(props[1]).toMatchObject({ magnification: 99, errorCorrection: 'Q', rotation: 'N' }); expect(props[2]).not.toHaveProperty('blockJustify'); }); diff --git a/src/store/anchorRepin.test.ts b/src/store/anchorRepin.test.ts index 12155c07..bdf75b13 100644 --- a/src/store/anchorRepin.test.ts +++ b/src/store/anchorRepin.test.ts @@ -229,6 +229,15 @@ describe("dirty semantics of fieldJustify", () => { }); }); +describe("applyChanges on an integer slot", () => { + it("drops a fraction and leaves a whole value untouched", () => { + const box = { id: "b", type: "box", x: 0, y: 0, rotation: 0, props: { width: 200, height: 100, thickness: 3, filled: false, color: "B", rounding: 0 } } as LabelObject; + expect((applyChanges(box, { props: { rounding: 1.5 } }, () => null) as { props: { rounding: number } }).props.rounding).toBe(1); + expect((applyChanges(box, { props: { rounding: 2.5, width: 300 } }, () => null) as { props: { rounding: number; width: number } }).props).toMatchObject({ rounding: 2, width: 300 }); + expect(applyChanges(box, { props: { rounding: 0 } }, () => null)).toBe(box); + }); +}); + describe("applyChanges with an explicit props: undefined", () => { it("keeps the object's props instead of wiping them", () => { // ObjectChanges declares props?: object, and a conditional spread left the From aaf273bbc1f5a83fb9e4caa5357f951404c50049 Mon Sep 17 00:00:00 2001 From: u8array Date: Fri, 16 Oct 2026 16:33:42 +0200 Subject: [PATCH 2/2] feat(box): the rounding dropdown names and previews the radius of each step --- packages/core/src/lib/units.ts | 7 ++ src/components/Canvas/KonvaObject.tsx | 5 +- src/components/Properties/UnitNumberInput.tsx | 6 +- src/components/Properties/ZplCmd.tsx | 23 ++++-- src/components/ui/Select.tsx | 32 +++++++- src/lib/previewProps.ts | 7 ++ src/locales/ar.ts | 3 +- src/locales/bg.ts | 3 +- src/locales/cs.ts | 3 +- src/locales/da.ts | 3 +- src/locales/de.ts | 3 +- src/locales/el.ts | 3 +- src/locales/en.ts | 3 +- src/locales/es.ts | 3 +- src/locales/et.ts | 3 +- src/locales/fa.ts | 3 +- src/locales/fi.ts | 3 +- src/locales/fr.ts | 3 +- src/locales/he.ts | 3 +- src/locales/hr.ts | 3 +- src/locales/hu.ts | 3 +- src/locales/it.ts | 3 +- src/locales/ja.ts | 3 +- src/locales/ko.ts | 3 +- src/locales/lt.ts | 3 +- src/locales/lv.ts | 3 +- src/locales/nl.ts | 3 +- src/locales/no.ts | 3 +- src/locales/pl.ts | 3 +- src/locales/pt.ts | 3 +- src/locales/ro.ts | 3 +- src/locales/sk.ts | 3 +- src/locales/sl.ts | 3 +- src/locales/sr.ts | 3 +- src/locales/sv.ts | 3 +- src/locales/tr.ts | 3 +- src/locales/zh-hans.ts | 3 +- src/locales/zh-hant.ts | 3 +- src/registry/box.panel.test.tsx | 73 +++++++++++++++++++ src/registry/box.panel.tsx | 34 ++++++--- src/registry/image.panel.tsx | 14 +--- src/store/slices/uiSlice.ts | 10 ++- 42 files changed, 237 insertions(+), 70 deletions(-) create mode 100644 src/lib/previewProps.ts create mode 100644 src/registry/box.panel.test.tsx diff --git a/packages/core/src/lib/units.ts b/packages/core/src/lib/units.ts index 48a1a699..8c974df0 100644 --- a/packages/core/src/lib/units.ts +++ b/packages/core/src/lib/units.ts @@ -1,3 +1,5 @@ +import { dotsToMm } from './coordinates'; + export type Unit = 'mm' | 'cm' | 'in'; export function mmToUnit(mm: number, unit: Unit): number { @@ -20,6 +22,11 @@ export function mmToUnitExact(mm: number, unit: Unit): number { return mm; } +/** A dots length as the panels show it, rounded like the inputs. */ +export function dotsToUnit(dots: number, dpmm: number, unit: Unit): number { + return mmToUnit(dotsToMm(dots, dpmm), unit); +} + export function unitLabel(unit: Unit): string { return unit; } diff --git a/src/components/Canvas/KonvaObject.tsx b/src/components/Canvas/KonvaObject.tsx index be8a816b..81f26ff7 100644 --- a/src/components/Canvas/KonvaObject.tsx +++ b/src/components/Canvas/KonvaObject.tsx @@ -18,6 +18,7 @@ import { useColorScheme, CANVAS_WARNING } from "../../hooks/useColorScheme"; import { useLabelStore, selectRenderDesignLabel, selectRenderColumnMapping, selectRenderDataset } from "../../store/labelStore"; import { applyBindingToObject } from "@zplab/core/lib/variableBinding"; import { usePreviewBinding } from "../../store/usePreviewBinding"; +import { withPreviewProps } from "../../lib/previewProps"; import { ZPL_FONT_HEIGHT_TO_CSS_RATIO } from "@zplab/core/lib/labelGeometry/textPositionTransforms"; import { getTextRenderMetrics } from "@zplab/core/lib/labelGeometry/textRenderMetrics"; import { selectionHandlers, shapeHitProps, useBlankFieldWarns, PLACEHOLDER_DASH, PLACEHOLDER_STROKE_PX, type KonvaObjectProps } from "./konvaObjectProps"; @@ -463,13 +464,15 @@ const BARCODE_TYPES = new Set([ ]); export function KonvaObject(props_: Props) { + const preview = useLabelStore((s) => (s.propsPreview?.id === props_.obj.id ? s.propsPreview.props : undefined)); + const previewed = withPreviewProps(props_.obj, preview); const { variables, active, clock } = usePreviewBinding(); // Render dataset and mapping, like the binding above: the tint must classify the same // document the canvas is showing (a shadow remaps onto fresh variable ids). const dataset = useLabelStore(selectRenderDataset); const columnMapping = useLabelStore(selectRenderColumnMapping); const dataRenderMode = useLabelStore((s) => s.canvasSettings.dataRenderMode); - const obj = applyBindingToObject(props_.obj, variables, active, dataRenderMode, clock, ctrlParityFor(props_.obj)); + const obj = applyBindingToObject(previewed, variables, active, dataRenderMode, clock, ctrlParityFor(props_.obj)); const renderProps = obj === props_.obj ? props_ : { ...props_, obj }; // Classify the ORIGINAL content: `obj` has had its markers resolved to print diff --git a/src/components/Properties/UnitNumberInput.tsx b/src/components/Properties/UnitNumberInput.tsx index 85daca3a..b902ca6d 100644 --- a/src/components/Properties/UnitNumberInput.tsx +++ b/src/components/Properties/UnitNumberInput.tsx @@ -2,8 +2,8 @@ import { useId, useState } from 'react'; import { useLabelStore, currentPageLabel } from '../../store/labelStore'; import { inputCls } from './styles'; import { FieldLabel } from './ZplCmd'; -import { mmToUnit, unitToMm, unitLabel, unitStep } from '@zplab/core/lib/units'; -import { dotsToMm, mmToDots } from '@zplab/core/lib/coordinates'; +import { dotsToUnit, unitToMm, unitLabel, unitStep } from '@zplab/core/lib/units'; +import { mmToDots } from '@zplab/core/lib/coordinates'; import { effectiveDpmm } from '@zplab/core/types/LabelConfig'; interface UnitNumberInputProps { @@ -53,7 +53,7 @@ export function UnitNumberInput({ if (scope === 'physical') return s.label.dpmm; return effectiveDpmm(scope === 'design' ? s.label : currentPageLabel(s)); }); - const toUnit = (dots: number) => mmToUnit(dotsToMm(dots, dpmm), unit); + const toUnit = (dots: number) => dotsToUnit(dots, dpmm, unit); // null = not editing; show the canonical store value. Otherwise show the raw // keystrokes verbatim so the input never fights the user mid-entry. const [draft, setDraft] = useState(null); diff --git a/src/components/Properties/ZplCmd.tsx b/src/components/Properties/ZplCmd.tsx index c9e3f1de..94e77849 100644 --- a/src/components/Properties/ZplCmd.tsx +++ b/src/components/Properties/ZplCmd.tsx @@ -1,6 +1,8 @@ import type { ReactNode } from 'react'; import { useLabelStore } from '../../store/labelStore'; import { labelCls, zplCommandTagCls } from '../ui/formStyles'; +import { Tooltip } from '../ui/Tooltip'; +import { InformationCircleIcon } from '@heroicons/react/16/solid'; /** * Read-only ZPL command tag for a properties field (e.g. `^A`). Renders only @@ -15,25 +17,30 @@ export function ZplCmd({ cmd }: { cmd?: string }) { return {cmd}; } -/** - * Field label row with an optional command tag pushed to the right. Drop-in - * for the `` rows in registry panels; - * collapses to a plain label when the tag is hidden or absent. - */ +/** Field label row: the label, an optional hint icon beside it, the command tag right. */ export function FieldLabel({ children, cmd, htmlFor, + hint, }: { children: ReactNode; cmd?: string; htmlFor?: string; + hint?: string; }) { return (
- + + + {hint && ( + + + + )} +
); diff --git a/src/components/ui/Select.tsx b/src/components/ui/Select.tsx index 565656eb..bbf260dd 100644 --- a/src/components/ui/Select.tsx +++ b/src/components/ui/Select.tsx @@ -1,4 +1,4 @@ -import { Fragment, type ReactNode } from 'react'; +import { Fragment, useEffect, useRef, type ReactNode } from 'react'; import { Listbox, ListboxButton, @@ -36,6 +36,8 @@ interface SelectProps { id?: string; disabled?: boolean; 'aria-label'?: string; + /** The focused option, or null once the list closes. */ + onHoverOption?: (value: T | null) => void; } const triggerCls = @@ -55,6 +57,7 @@ export function Select({ id, disabled, 'aria-label': ariaLabel, + onHoverOption, }: SelectProps) { const selected = groups.flatMap((g) => g.options).find((o) => o.value === value); return ( @@ -67,7 +70,9 @@ export function Select({ onHoverOption?.(null)} > + {onHoverOption && } {groups.map((group, gi) => ( {group.label && ( @@ -83,8 +88,9 @@ export function Select({ title={opt.tooltip} className="flex items-center gap-2 px-2 py-1 cursor-pointer text-xs text-text data-[focus]:bg-surface-2 data-[selected]:bg-accent-dim data-[disabled]:opacity-40 data-[disabled]:cursor-not-allowed" > - {({ selected: isSel }) => ( + {({ selected: isSel, focus }) => ( <> + {onHoverOption && onHoverOption(opt.value)} />} @@ -105,3 +111,25 @@ export function Select({ ); } + +// Headless UI marks the option under the pointer or the arrow keys as focused, so one signal serves both. +function HoverStart({ active, onStart }: { active: boolean; onStart: () => void }) { + const latest = useRef(onStart); + useEffect(() => { + latest.current = onStart; + }); + useEffect(() => { + if (active) latest.current(); + }, [active]); + return null; +} + +// The options unmount when the list closes, with or without a choice. +function HoverEnd({ onEnd }: { onEnd: (value: T | null) => void }) { + const latest = useRef(onEnd); + useEffect(() => { + latest.current = onEnd; + }); + useEffect(() => () => latest.current(null), []); + return null; +} diff --git a/src/lib/previewProps.ts b/src/lib/previewProps.ts new file mode 100644 index 00000000..5df7ede7 --- /dev/null +++ b/src/lib/previewProps.ts @@ -0,0 +1,7 @@ +import type { LabelObject } from '@zplab/core/types/Group'; + +/** A panel previews props of its own object type, so the merged leaf keeps the object's type. */ +export function withPreviewProps(obj: T, preview: Record | undefined): T { + if (!preview || !('props' in obj)) return obj; + return { ...obj, props: { ...obj.props, ...preview } } as T; +} diff --git a/src/locales/ar.ts b/src/locales/ar.ts index 08077fcd..7fcfd0c4 100644 --- a/src/locales/ar.ts +++ b/src/locales/ar.ts @@ -1254,7 +1254,8 @@ const ar = { color: 'اللون', colorB: 'أسود', colorW: 'أبيض', - rounding: 'التدوير (0–8)', + rounding: 'التدوير', + roundingHint: 'تأخذ الطابعة خطوات كاملة فقط، كل خطوة تساوي واحدًا من ستة عشر من الجانب الأقصر. تعرض القائمة نصف قطر الزاوية الذي توفره كل خطوة لهذا المربع.', reverse: 'Invert', }, shapeMode: { diff --git a/src/locales/bg.ts b/src/locales/bg.ts index db3cf602..09ad6425 100644 --- a/src/locales/bg.ts +++ b/src/locales/bg.ts @@ -1254,7 +1254,8 @@ const bg = { color: 'Цвят', colorB: 'Черен', colorW: 'Бял', - rounding: 'Закръгляне (0–8)', + rounding: 'Закръгляне', + roundingHint: 'Принтерът прави само цели стъпки, всяка равна на една шестнайсета от по-късата страна. Списъкът показва радиуса на ъгъла, който всяка стъпка дава на този правоъгълник.', reverse: 'Invert', }, shapeMode: { diff --git a/src/locales/cs.ts b/src/locales/cs.ts index 32198460..6225c076 100644 --- a/src/locales/cs.ts +++ b/src/locales/cs.ts @@ -1254,7 +1254,8 @@ const cs = { color: 'Barva', colorB: 'Černá', colorW: 'Bílá', - rounding: 'Zaoblení (0–8)', + rounding: 'Zaoblení', + roundingHint: 'Tiskárna používá jen celé kroky, každý o velikosti jedné šestnáctiny kratší strany. Seznam zobrazuje poloměr rohu, který každý krok dává tomuto obdélníku.', reverse: 'Invert', }, shapeMode: { diff --git a/src/locales/da.ts b/src/locales/da.ts index 1a8d86bd..7379989c 100644 --- a/src/locales/da.ts +++ b/src/locales/da.ts @@ -1254,7 +1254,8 @@ const da = { color: 'Farve', colorB: 'Sort', colorW: 'Hvid', - rounding: 'Afrunding (0–8)', + rounding: 'Afrunding', + roundingHint: 'Printeren bruger kun hele trin, hvert på en sekstendedel af den kortere side. Listen viser hjørneradiussen, som hvert trin giver denne boks.', reverse: 'Invert', }, shapeMode: { diff --git a/src/locales/de.ts b/src/locales/de.ts index 99d0335a..5ca2c601 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -1319,7 +1319,8 @@ const de = { color: 'Farbe', colorB: 'Schwarz', colorW: 'Weiß', - rounding: 'Rundung (0–8)', + rounding: 'Rundung', + roundingHint: 'Der Drucker verwendet nur ganze Schritte, jeder ein Sechzehntel der kürzeren Seite. Die Liste zeigt den Eckradius, den jeder Schritt dieser Box gibt.', reverse: 'Invertieren', }, shapeMode: { diff --git a/src/locales/el.ts b/src/locales/el.ts index 4ee7fdf1..da3ff7ce 100644 --- a/src/locales/el.ts +++ b/src/locales/el.ts @@ -1254,7 +1254,8 @@ const el = { color: 'Χρώμα', colorB: 'Μαύρο', colorW: 'Λευκό', - rounding: 'Στρογγυλοποίηση (0–8)', + rounding: 'Στρογγυλοποίηση', + roundingHint: 'Ο εκτυπωτής χρησιμοποιεί μόνο ακέραια βήματα, καθένα ένα δέκατο έκτο της μικρότερης πλευράς. Η λίστα δείχνει την ακτίνα γωνίας που δίνει κάθε βήμα σε αυτό το πλαίσιο.', reverse: 'Invert', }, shapeMode: { diff --git a/src/locales/en.ts b/src/locales/en.ts index 5d9d6451..fb89dce9 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -1319,7 +1319,8 @@ const en = { color: 'Color', colorB: 'Black', colorW: 'White', - rounding: 'Rounding (0–8)', + rounding: 'Rounding', + roundingHint: 'The printer takes whole steps only, each one sixteenth of the shorter side. The list shows the corner radius each step gives this box.', reverse: 'Invert', }, shapeMode: { diff --git a/src/locales/es.ts b/src/locales/es.ts index 61d58273..deb594a2 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -1254,7 +1254,8 @@ const es = { color: 'Color', colorB: 'Negro', colorW: 'Blanco', - rounding: 'Redondeo (0–8)', + rounding: 'Redondeo', + roundingHint: 'La impresora solo usa pasos completos, cada uno de un dieciseisavo del lado más corto. La lista muestra el radio de esquina que cada paso da a esta caja.', reverse: 'Invert', }, shapeMode: { diff --git a/src/locales/et.ts b/src/locales/et.ts index a74bdcd3..1b790672 100644 --- a/src/locales/et.ts +++ b/src/locales/et.ts @@ -1254,7 +1254,8 @@ const et = { color: 'Värvus', colorB: 'Must', colorW: 'Valge', - rounding: 'Ümardus (0–8)', + rounding: 'Ümardus', + roundingHint: 'Printer kasutab ainult täissamme, millest igaüks on üks kuueteistkümnendik lühemast küljest. Loend näitab nurgaraadiust, mille iga samm annab sellele kastile.', reverse: 'Invert', }, shapeMode: { diff --git a/src/locales/fa.ts b/src/locales/fa.ts index e6f784c9..47aeb593 100644 --- a/src/locales/fa.ts +++ b/src/locales/fa.ts @@ -1254,7 +1254,8 @@ const fa = { color: 'رنگ', colorB: 'مشکی', colorW: 'سفید', - rounding: 'گردی (0–8)', + rounding: 'گردی', + roundingHint: 'چاپگر فقط مرحله‌های کامل را برمی‌دارد، هر مرحله برابر با یک‌شانزدهم ضلع کوتاه‌تر است. فهرست شعاع گوشه‌ای را نشان می‌دهد که هر مرحله به این کادر می‌دهد.', reverse: 'Invert', }, shapeMode: { diff --git a/src/locales/fi.ts b/src/locales/fi.ts index b090823f..edb75d30 100644 --- a/src/locales/fi.ts +++ b/src/locales/fi.ts @@ -1254,7 +1254,8 @@ const fi = { color: 'Väri', colorB: 'Musta', colorW: 'Valkoinen', - rounding: 'Pyöristys (0–8)', + rounding: 'Pyöristys', + roundingHint: 'Tulostin käyttää vain kokonaisia askelia, joista jokainen on yksi kuudestoistaosa lyhyemmästä sivusta. Luettelo näyttää kulmasäteen, jonka jokainen askel antaa tälle laatikolle.', reverse: 'Invert', }, shapeMode: { diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 2dc11cbc..367bbaf5 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -1254,7 +1254,8 @@ const fr = { color: 'Couleur', colorB: 'Noir', colorW: 'Blanc', - rounding: 'Arrondi (0–8)', + rounding: 'Arrondi', + roundingHint: "L'imprimante n'utilise que des étapes entières, chacune d'un seizième du côté le plus court. La liste indique le rayon d'angle que chaque étape donne à ce cadre.", reverse: 'Invert', }, shapeMode: { diff --git a/src/locales/he.ts b/src/locales/he.ts index c8d06712..990be96aa 100644 --- a/src/locales/he.ts +++ b/src/locales/he.ts @@ -1254,7 +1254,8 @@ const he = { color: 'צבע', colorB: 'שחור', colorW: 'לבן', - rounding: 'עיגול (0–8)', + rounding: 'עיגול', + roundingHint: 'המדפסת משתמשת רק בשלבים שלמים, וכל שלב הוא אחד חלקי שש עשרה מהצלע הקצרה יותר. הרשימה מציגה את רדיוס הפינה שכל שלב נותן לתיבה הזו.', reverse: 'Invert', }, shapeMode: { diff --git a/src/locales/hr.ts b/src/locales/hr.ts index 37777652..92564409 100644 --- a/src/locales/hr.ts +++ b/src/locales/hr.ts @@ -1254,7 +1254,8 @@ const hr = { color: 'Boja', colorB: 'Crna', colorW: 'Bijela', - rounding: 'Zaobljenje (0–8)', + rounding: 'Zaobljenje', + roundingHint: 'Pisač koristi samo cijele korake, svaki u iznosu od jedne šesnaestine kraće stranice. Popis prikazuje radijus kuta koji svaki korak daje ovom okviru.', reverse: 'Invert', }, shapeMode: { diff --git a/src/locales/hu.ts b/src/locales/hu.ts index 4b49828d..fda2e902 100644 --- a/src/locales/hu.ts +++ b/src/locales/hu.ts @@ -1254,7 +1254,8 @@ const hu = { color: 'Szín', colorB: 'Fekete', colorW: 'Fehér', - rounding: 'Lekerekítés (0–8)', + rounding: 'Lekerekítés', + roundingHint: 'A nyomtató csak egész lépéseket használ, amelyek mindegyike a rövidebb oldal egy tizenhatoda. A lista megmutatja a sarokrádiuszt, amelyet minden lépés ennek a doboznak ad.', reverse: 'Invert', }, shapeMode: { diff --git a/src/locales/it.ts b/src/locales/it.ts index ad220fdd..c0aba967 100644 --- a/src/locales/it.ts +++ b/src/locales/it.ts @@ -1254,7 +1254,8 @@ const it = { color: 'Colore', colorB: 'Nero', colorW: 'Bianco', - rounding: 'Arrotondamento (0–8)', + rounding: 'Arrotondamento', + roundingHint: "La stampante usa solo passi interi, ciascuno di un sedicesimo del lato più corto. L'elenco mostra il raggio d'angolo che ciascun passo dà a questo riquadro.", reverse: 'Invert', }, shapeMode: { diff --git a/src/locales/ja.ts b/src/locales/ja.ts index 7241bc48..1f2caa49 100644 --- a/src/locales/ja.ts +++ b/src/locales/ja.ts @@ -1254,7 +1254,8 @@ const ja = { color: '色', colorB: '黒', colorW: '白', - rounding: '角丸 (0–8)', + rounding: '角丸', + roundingHint: 'プリンターは整数の段階のみを使い、各段階はより短い辺の16分の1です。リストには、各段階がこのボックスに与える角の半径が表示されます。', reverse: 'Invert', }, shapeMode: { diff --git a/src/locales/ko.ts b/src/locales/ko.ts index cb5b4ba9..2776453f 100644 --- a/src/locales/ko.ts +++ b/src/locales/ko.ts @@ -1254,7 +1254,8 @@ const ko = { color: '색상', colorB: '검정', colorW: '흰색', - rounding: '모서리 둥글기 (0–8)', + rounding: '모서리 둥글기', + roundingHint: '프린터는 정수 단계만 사용하며, 각 단계는 더 짧은 변의 16분의 1입니다. 목록에는 각 단계가 이 상자에 부여하는 모서리 반경이 표시됩니다.', reverse: 'Invert', }, shapeMode: { diff --git a/src/locales/lt.ts b/src/locales/lt.ts index 43e193d4..54a1dd78 100644 --- a/src/locales/lt.ts +++ b/src/locales/lt.ts @@ -1254,7 +1254,8 @@ const lt = { color: 'Spalva', colorB: 'Juoda', colorW: 'Balta', - rounding: 'Apvalinimas (0–8)', + rounding: 'Apvalinimas', + roundingHint: 'Spausdintuvas naudoja tik sveikus žingsnius, kurių kiekvienas yra viena šešioliktoji trumpesnės kraštinės. Sąraše rodomas kampo spindulys, kurį kiekvienas žingsnis suteikia šiam stačiakampiui.', reverse: 'Invert', }, shapeMode: { diff --git a/src/locales/lv.ts b/src/locales/lv.ts index 29b6c76d..b41aa746 100644 --- a/src/locales/lv.ts +++ b/src/locales/lv.ts @@ -1254,7 +1254,8 @@ const lv = { color: 'Krāsa', colorB: 'Melna', colorW: 'Balta', - rounding: 'Noapaļojums (0–8)', + rounding: 'Noapaļojums', + roundingHint: 'Printeris izmanto tikai veselus soļus, no kuriem katrs ir viena sešpadsmitā daļa no īsākās malas. Sarakstā ir redzams stūra rādiuss, ko katrs solis piešķir šim taisnstūrim.', reverse: 'Invert', }, shapeMode: { diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 3153827f..c100ca81 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -1254,7 +1254,8 @@ const nl = { color: 'Kleur', colorB: 'Zwart', colorW: 'Wit', - rounding: 'Afronding (0–8)', + rounding: 'Afronding', + roundingHint: 'De printer gebruikt alleen hele stappen, elk een zestiende van de kortere zijde. De lijst toont de hoekradius die elke stap aan dit kader geeft.', reverse: 'Invert', }, shapeMode: { diff --git a/src/locales/no.ts b/src/locales/no.ts index 5ae05f1b..4450b24f 100644 --- a/src/locales/no.ts +++ b/src/locales/no.ts @@ -1254,7 +1254,8 @@ const no = { color: 'Farge', colorB: 'Svart', colorW: 'Hvit', - rounding: 'Avrunding (0–8)', + rounding: 'Avrunding', + roundingHint: 'Skriveren bruker bare hele trinn, hvert på en sekstendedel av den kortere siden. Listen viser hjørneradiusen som hvert trinn gir denne boksen.', reverse: 'Invert', }, shapeMode: { diff --git a/src/locales/pl.ts b/src/locales/pl.ts index 8a1eb2cc..e760c58d 100644 --- a/src/locales/pl.ts +++ b/src/locales/pl.ts @@ -1254,7 +1254,8 @@ const pl = { color: 'Kolor', colorB: 'Czarny', colorW: 'Biały', - rounding: 'Zaokrąglenie (0–8)', + rounding: 'Zaokrąglenie', + roundingHint: 'Drukarka używa tylko całych kroków, każdy o wielkości jednej szesnastej krótszej strony. Lista pokazuje promień rogu, który każdy krok daje temu prostokątowi.', reverse: 'Invert', }, shapeMode: { diff --git a/src/locales/pt.ts b/src/locales/pt.ts index 27eba70f..f9031707 100644 --- a/src/locales/pt.ts +++ b/src/locales/pt.ts @@ -1254,7 +1254,8 @@ const pt = { color: 'Cor', colorB: 'Preto', colorW: 'Branco', - rounding: 'Arredondamento (0–8)', + rounding: 'Arredondamento', + roundingHint: 'A impressora usa apenas etapas completas, cada uma de um dezasseisavo do lado mais curto. A lista mostra o raio do canto que cada etapa dá a esta caixa.', reverse: 'Invert', }, shapeMode: { diff --git a/src/locales/ro.ts b/src/locales/ro.ts index 490be55d..2e7b40b1 100644 --- a/src/locales/ro.ts +++ b/src/locales/ro.ts @@ -1254,7 +1254,8 @@ const ro = { color: 'Culoare', colorB: 'Negru', colorW: 'Alb', - rounding: 'Rotunjire (0–8)', + rounding: 'Rotunjire', + roundingHint: 'Imprimanta folosește doar pași întregi, fiecare de o șaisprezecime din latura mai scurtă. Lista arată raza colțului pe care fiecare pas o oferă acestei casete.', reverse: 'Invert', }, shapeMode: { diff --git a/src/locales/sk.ts b/src/locales/sk.ts index 15651927..1ffe038c 100644 --- a/src/locales/sk.ts +++ b/src/locales/sk.ts @@ -1254,7 +1254,8 @@ const sk = { color: 'Farba', colorB: 'Čierna', colorW: 'Biela', - rounding: 'Zaoblenie (0–8)', + rounding: 'Zaoblenie', + roundingHint: 'Tlačiareň používa iba celé kroky, každý v hodnote jednej šestnástiny kratšej strany. Zoznam zobrazuje polomer rohu, ktorý každý krok dáva tomuto obdĺžniku.', reverse: 'Invert', }, shapeMode: { diff --git a/src/locales/sl.ts b/src/locales/sl.ts index 1e7f2c9d..f398589a 100644 --- a/src/locales/sl.ts +++ b/src/locales/sl.ts @@ -1254,7 +1254,8 @@ const sl = { color: 'Barva', colorB: 'Črna', colorW: 'Bela', - rounding: 'Zaobljenje (0–8)', + rounding: 'Zaobljenje', + roundingHint: 'Tiskalnik uporablja samo cele korake, vsak v velikosti ene šestnajstine krajše stranice. Seznam prikazuje polmer kota, ki ga vsak korak daje temu okvirju.', reverse: 'Invert', }, shapeMode: { diff --git a/src/locales/sr.ts b/src/locales/sr.ts index 8670e1bc..a81787e8 100644 --- a/src/locales/sr.ts +++ b/src/locales/sr.ts @@ -1254,7 +1254,8 @@ const sr = { color: 'Boja', colorB: 'Crna', colorW: 'Bela', - rounding: 'Zaobljenje (0–8)', + rounding: 'Zaobljenje', + roundingHint: 'Штампач користи само целе кораке, сваки у износу од једне шеснаестине краће странице. Листа приказује радијус угла који сваки корак даје овом оквиру.', reverse: 'Invert', }, shapeMode: { diff --git a/src/locales/sv.ts b/src/locales/sv.ts index 12965c03..3a5da7fd 100644 --- a/src/locales/sv.ts +++ b/src/locales/sv.ts @@ -1254,7 +1254,8 @@ const sv = { color: 'Färg', colorB: 'Svart', colorW: 'Vit', - rounding: 'Avrundning (0–8)', + rounding: 'Avrundning', + roundingHint: 'Skrivaren använder bara hela steg, vardera en sextondel av den kortare sidan. Listan visar hörnradien som varje steg ger den här rutan.', reverse: 'Invert', }, shapeMode: { diff --git a/src/locales/tr.ts b/src/locales/tr.ts index c38c15bd..5f7772ef 100644 --- a/src/locales/tr.ts +++ b/src/locales/tr.ts @@ -1254,7 +1254,8 @@ const tr = { color: 'Renk', colorB: 'Siyah', colorW: 'Beyaz', - rounding: 'Yuvarlama (0–8)', + rounding: 'Yuvarlama', + roundingHint: 'Yazıcı yalnızca tam adımlar kullanır, her biri daha kısa kenarın on altıda biri kadardır. Liste, her adımın bu kutuya verdiği köşe yarıçapını gösterir.', reverse: 'Invert', }, shapeMode: { diff --git a/src/locales/zh-hans.ts b/src/locales/zh-hans.ts index f136e8be..dad32a37 100644 --- a/src/locales/zh-hans.ts +++ b/src/locales/zh-hans.ts @@ -1254,7 +1254,8 @@ const zhHans = { color: '颜色', colorB: '黑色', colorW: '白色', - rounding: '圆角 (0–8)', + rounding: '圆角', + roundingHint: '打印机只使用整数级,每一级是较短边的十六分之一。列表显示每一级赋予此方框的圆角半径。', reverse: 'Invert', }, shapeMode: { diff --git a/src/locales/zh-hant.ts b/src/locales/zh-hant.ts index 2ca8a0a0..4fa7cac4 100644 --- a/src/locales/zh-hant.ts +++ b/src/locales/zh-hant.ts @@ -1254,7 +1254,8 @@ const zhHant = { color: '顏色', colorB: '黑色', colorW: '白色', - rounding: '圓角 (0–8)', + rounding: '圓角', + roundingHint: '印表機只使用整數級,每一級是較短邊的十六分之一。清單顯示每一級賦予此方框的圓角半徑。', reverse: 'Invert', }, shapeMode: { diff --git a/src/registry/box.panel.test.tsx b/src/registry/box.panel.test.tsx new file mode 100644 index 00000000..c15928c4 --- /dev/null +++ b/src/registry/box.panel.test.tsx @@ -0,0 +1,73 @@ +// @vitest-environment jsdom +import { describe, it, expect, afterEach, beforeEach } from "vitest"; +import { render, cleanup, act, fireEvent } from "@testing-library/react"; +import { boxPanel } from "./box.panel"; +import { useLabelStore } from "../store/labelStore"; +import { fallbackTranslations as en } from "../locales"; +import type { BoxProps } from "@zplab/core/registry/box"; +import type { LabelObjectBase } from "@zplab/core/types/LabelObject"; + +afterEach(cleanup); +beforeEach(() => { + act(() => { + useLabelStore.setState({ + label: { widthMm: 100, heightMm: 50, dpmm: 8 }, + pages: [{ objects: [] }], + currentPageIndex: 0, + propsPreview: null, + canvasSettings: { ...useLabelStore.getState().canvasSettings, unit: "mm" }, + } as never); + }); +}); + +const box = (rounding: number): LabelObjectBase & { props: BoxProps } => ({ + id: "box-1", + type: "box", + x: 0, + y: 0, + rotation: 0, + props: { width: 400, height: 200, thickness: 4, rounding, color: "B", reverse: false } as BoxProps, +}); + +const Panel = boxPanel.PropertiesPanel; +const openRounding = (r: ReturnType) => fireEvent.click(r.getByRole("button", { name: en.registry.box.rounding })); + +describe("box panel rounding", () => { + it("lists the nine steps with the radius each one gives this box", () => { + const r = render( undefined} />); + expect(r.getByRole("button", { name: en.registry.box.rounding }).textContent).toContain("1.6 mm"); + openRounding(r); + const options = r.getAllByRole("option").map((o) => o.textContent); + expect(options).toHaveLength(9); + expect(options[0]).toContain("0 mm"); + expect(options[8]).toContain("12.5 mm"); + }); + + it("shows the radius in the user's unit and the page's own density", () => { + act(() => { + useLabelStore.setState({ canvasSettings: { ...useLabelStore.getState().canvasSettings, unit: "in" }, pages: [{ objects: [], jmDensity: "B" }] } as never); + }); + const r = render( undefined} />); + // 12.5 dots at the halved 4 dpmm, 3.1 mm after the dots-to-mm rounding. + expect(r.getByRole("button", { name: en.registry.box.rounding }).textContent).toContain("0.122 in"); + }); + + it("leaves no preview behind once a step is chosen", () => { + const r = render( undefined} />); + openRounding(r); + act(() => { + useLabelStore.setState({ propsPreview: { id: "box-1", props: { rounding: 4 } } } as never); + }); + fireEvent.click(r.getAllByRole("option")[2]!); + expect(useLabelStore.getState().propsPreview).toBeNull(); + }); + + it("still lists an imported index past the spec range, without a radius", () => { + const r = render( undefined} />); + expect(r.getByRole("button", { name: en.registry.box.rounding }).textContent).toBe("9"); + openRounding(r); + const options = r.getAllByRole("option"); + expect(options).toHaveLength(10); + expect(options[9]!.textContent).toBe("9"); + }); +}); diff --git a/src/registry/box.panel.tsx b/src/registry/box.panel.tsx index 584332e8..1605eb11 100644 --- a/src/registry/box.panel.tsx +++ b/src/registry/box.panel.tsx @@ -2,7 +2,6 @@ import type { ObjectTypeUi } from './panelTypes'; import { useT } from '../hooks/useT'; import { useLabelStore } from '../store/labelStore'; import { labelCls } from '../components/Properties/styles'; -import { NumberInput } from '../components/Properties/NumberInput'; import { UnitNumberInput } from '../components/Properties/UnitNumberInput'; import { SectionCard } from '../components/Properties/SectionCard'; import { FieldLabel, ZplCmd } from '../components/Properties/ZplCmd'; @@ -10,12 +9,26 @@ import { Select } from '../components/ui/Select'; import { fieldGridCols, fieldGridCell } from '../components/ui/formStyles'; import { ShapeModeToggle } from '../components/Properties/ShapeModeToggle'; import { BOX_PROP_SPECS, type BoxProps } from '@zplab/core/registry/box'; +import { boxCornerRadii } from '@zplab/core/lib/shapeGeometry'; +import { editBounds } from '@zplab/core/types/propSpec'; +import { effectiveDpmm } from '@zplab/core/types/LabelConfig'; +import { dotsToUnit, unitLabel } from '@zplab/core/lib/units'; +import { currentPageLabel } from '../store/labelStore.selectors'; + +const ROUNDING_BOUNDS = editBounds(BOX_PROP_SPECS.rounding); +const ROUNDING_STEPS = Array.from({ length: ROUNDING_BOUNDS.max - ROUNDING_BOUNDS.min + 1 }, (_, i) => ROUNDING_BOUNDS.min + i); +// An imported ^GB may carry an index past the spec range, so the list keeps it without a radius. +const roundingOptions = (current: number) => (ROUNDING_STEPS.includes(current) ? ROUNDING_STEPS : [...ROUNDING_STEPS, current]); export const boxPanel: ObjectTypeUi = { PropertiesPanel: ({ obj, onChange }) => { const t = useT(); const p = obj.props; const showZpl = useLabelStore((s) => s.showZplCommands); + const unit = useLabelStore((s) => s.canvasSettings.unit); + const dpmm = useLabelStore((s) => effectiveDpmm(currentPageLabel(s))); + const setPropsPreview = useLabelStore((s) => s.setPropsPreview); + const radiusLabel = (rounding: number) => `${dotsToUnit(boxCornerRadii(p.width, p.height, p.thickness, rounding).outer, dpmm, unit)} ${unitLabel(unit)}`; return ( = { ] }]} /> - onChange({ rounding })} - zplCmd="^GB" - className={fieldGridCell} - /> +
+ {t.registry.box.rounding} + + value={p.rounding} + onChange={(rounding) => onChange({ rounding })} + onHoverOption={(rounding) => setPropsPreview(rounding === null ? null : { id: obj.id, props: { rounding } })} + aria-label={t.registry.box.rounding} + groups={[{ options: roundingOptions(p.rounding).map((rounding) => ({ value: rounding, label: String(rounding), badge: ROUNDING_STEPS.includes(rounding) ? radiusLabel(rounding) : '' })) }]} + /> +
diff --git a/src/registry/image.panel.tsx b/src/registry/image.panel.tsx index c2d07935..38f62f19 100644 --- a/src/registry/image.panel.tsx +++ b/src/registry/image.panel.tsx @@ -1,5 +1,4 @@ import { useState, useRef, useCallback } from 'react'; -import { InformationCircleIcon } from '@heroicons/react/16/solid'; import type { ObjectTypeUi } from './panelTypes'; import { useT } from '../hooks/useT'; import { buttonCls, inputCls, labelCls } from '../components/Properties/styles'; @@ -7,11 +6,10 @@ import { disabledCls } from '../components/ui/formStyles'; import { loadImageFile, getImage } from '@zplab/core/lib/imageCache'; import { imageToGFA } from '@zplab/core/lib/imageToZpl'; import { formatStoragePath, MAX_STORAGE_NAME_LEN, sanitizeStorageName, STORAGE_DEVICES, uploadedGraphicPath } from '@zplab/core/lib/storagePath'; -import { Tooltip } from '../components/ui/Tooltip'; import { SectionCard, StaticSectionCard } from '../components/Properties/SectionCard'; import { UnitNumberInput } from '../components/Properties/UnitNumberInput'; import { RotationSelect } from '../components/Properties/RotationSelect'; -import { FieldLabel, ZplCmd } from '../components/Properties/ZplCmd'; +import { FieldLabel } from '../components/Properties/ZplCmd'; import { Select } from '../components/ui/Select'; import { IMAGE_PROP_SPECS, isImageRotatable, profileGraphicRecall, recallCommand, recalledSetupEntry, recallStoragePath, retiredSources, setupGraphicState, type ImageProps } from '@zplab/core/registry/image'; import { applyGraphicDelivery, draftGraphicIdentity, graphicDelivery, graphicWayBlocks, providedGraphic, type GraphicWayBlock, type ResourceDelivery } from '@zplab/core/lib/resourceDelivery'; @@ -224,15 +222,7 @@ export const imagePanel: ObjectTypeUi = { )}
-
- - - - -
- -
-
+ {t.registry.image.storage} {storedAs ? ( <>
diff --git a/src/store/slices/uiSlice.ts b/src/store/slices/uiSlice.ts index abf649af..6482bbdd 100644 --- a/src/store/slices/uiSlice.ts +++ b/src/store/slices/uiSlice.ts @@ -181,6 +181,8 @@ export interface UiSlice { pickingRfidPosition: boolean; /** Reference for the "Align" section toggle; see AlignSelectionRef. Transient. */ alignRef: AlignSelectionRef; + /** Props the canvas shows over one object while a panel option is hovered, never committed. Transient. */ + propsPreview: { id: string; props: Record } | null; printerSettingsTab: PrinterSettingsTab | null; /** Cross-component trigger for the output dialog. `null` keeps it closed. */ outputSource: OutputSource | null; @@ -251,6 +253,7 @@ export interface UiSlice { setBlockDragMode: (mode: BlockDragMode) => void; startRfidPositionPick: () => void; endRfidPositionPick: () => void; + setPropsPreview: (preview: { id: string; props: Record } | null) => void; setAlignRef: (ref: AlignSelectionRef) => void; setPrinterSettingsTab: (tab: PrinterSettingsTab | null) => void; openOutput: (source: OutputSource) => void; @@ -325,9 +328,9 @@ function defaultUiPrefs(): UiPrefs { } type DocumentUiReset = Pick; + | 'pickingRfidPosition' | 'gs1BuilderObjectId' | 'contentBuilderObjectId' | 'variableBuilderObjectId' | 'propsPreview'>; -/** Reset by every writer that replaces the document: the dialogs seed from it at mount, the pick would commit into the next one. */ +/** Reset by every writer that replaces the document: nothing here may outlive the document it was keyed to. */ export function resetDocumentUi(): DocumentUiReset { return { mappingModalOpen: false, @@ -337,6 +340,7 @@ export function resetDocumentUi(): DocumentUiReset { gs1BuilderObjectId: null, contentBuilderObjectId: null, variableBuilderObjectId: null, + propsPreview: null, }; } @@ -361,6 +365,7 @@ export const createUiSlice: StateCreator = (set, ge blockDragMode: 'frame', pickingRfidPosition: false, alignRef: 'selection', + propsPreview: null, printerSettingsTab: null, outputSource: null, outputChoice: defaultOutputChoice(isDesktopShell), @@ -490,6 +495,7 @@ export const createUiSlice: StateCreator = (set, ge togglePaletteEditing: () => set((state) => ({ paletteEditing: !state.paletteEditing })), startRfidPositionPick: () => set({ pickingRfidPosition: true, printerSettingsTab: null }), endRfidPositionPick: () => set({ pickingRfidPosition: false, printerSettingsTab: 'rfid' }), + setPropsPreview: (preview) => set({ propsPreview: preview }), setShowZplCommands: (show) => set({ showZplCommands: show }), setKeepExportMetadata: (on) => set({ keepExportMetadata: on }), setMcpServerEnabled: (enabled) => set({ mcpServerEnabled: enabled }),