Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions packages/core/src/lib/anchorRepin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<Record<string, PropSpec>> | undefined, shaped as ObjectChanges);
if (changesNothing(obj, normalized)) return obj;
const current = (obj as { props?: object }).props ?? {};
const next = {
...obj,
Expand All @@ -71,6 +73,12 @@ export function applyChanges(
return anchorRepin(obj, normalized as ObjectChanges, next, probe);
}

function wholeProps(specs: Readonly<Record<string, PropSpec>> | undefined, changes: ObjectChanges): ObjectChanges {
if (!specs || !changes.props) return changes;
const props = truncateIntegerProps(specs, changes.props as Record<string, unknown>);
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<string, unknown>;
Expand Down
9 changes: 5 additions & 4 deletions packages/core/src/lib/designFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 +
Expand Down Expand Up @@ -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<string, PropSpec>;
const defaults = entry.defaultProps as Record<string, unknown>;
leaf.props = Object.fromEntries(
const conformed = Object.fromEntries(
Object.entries(props as Record<string, unknown>)
.map(([key, value]) => {
const spec = Object.hasOwn(specs, key) ? specs[key] : undefined;
Expand All @@ -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 {
Expand All @@ -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) {
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/lib/units.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { dotsToMm } from './coordinates';

export type Unit = 'mm' | 'cm' | 'in';

export function mmToUnit(mm: number, unit: Unit): number {
Expand All @@ -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;
}
Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/types/propSpec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, PropSpec>>, props: Record<string, unknown>): Record<string, unknown> {
let next: Record<string, unknown> | 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') {
Expand Down
5 changes: 4 additions & 1 deletion src/components/Canvas/KonvaObject.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions src/components/Properties/UnitNumberInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string | null>(null);
Expand Down
23 changes: 15 additions & 8 deletions src/components/Properties/ZplCmd.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -15,25 +17,30 @@ export function ZplCmd({ cmd }: { cmd?: string }) {
return <span className={zplCommandTagCls}>{cmd}</span>;
}

/**
* Field label row with an optional command tag pushed to the right. Drop-in
* for the `<label className={labelCls}>…</label>` 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 (
<div className="flex items-start justify-between gap-2">
<label htmlFor={htmlFor} className={labelCls}>
{children}
</label>
<span className="flex items-center gap-2">
<label htmlFor={htmlFor} className={labelCls}>
{children}
</label>
{hint && (
<Tooltip content={hint}>
<InformationCircleIcon className="w-3 h-3 text-muted/60 cursor-help shrink-0" />
</Tooltip>
)}
</span>
<ZplCmd cmd={cmd} />
</div>
);
Expand Down
32 changes: 30 additions & 2 deletions src/components/ui/Select.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Fragment, type ReactNode } from 'react';
import { Fragment, useEffect, useRef, type ReactNode } from 'react';
import {
Listbox,
ListboxButton,
Expand Down Expand Up @@ -36,6 +36,8 @@ interface SelectProps<T extends string | number> {
id?: string;
disabled?: boolean;
'aria-label'?: string;
/** The focused option, or null once the list closes. */
onHoverOption?: (value: T | null) => void;
}

const triggerCls =
Expand All @@ -55,6 +57,7 @@ export function Select<T extends string | number>({
id,
disabled,
'aria-label': ariaLabel,
onHoverOption,
}: SelectProps<T>) {
const selected = groups.flatMap((g) => g.options).find((o) => o.value === value);
return (
Expand All @@ -67,7 +70,9 @@ export function Select<T extends string | number>({
<ListboxOptions
anchor="bottom start"
className="w-(--button-width) [--anchor-gap:4px] max-h-60 overflow-auto rounded border border-border-2 bg-surface shadow-lg z-50 py-1 focus:outline-none"
onMouseLeave={() => onHoverOption?.(null)}
>
{onHoverOption && <HoverEnd onEnd={onHoverOption} />}
{groups.map((group, gi) => (
<Fragment key={group.label ?? gi}>
{group.label && (
Expand All @@ -83,8 +88,9 @@ export function Select<T extends string | number>({
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 && <HoverStart active={focus} onStart={() => onHoverOption(opt.value)} />}
<CheckIcon
className={`w-3 h-3 shrink-0 ${isSel ? 'text-accent' : 'invisible'}`}
/>
Expand All @@ -105,3 +111,25 @@ export function Select<T extends string | number>({
</Listbox>
);
}

// 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<T extends string | number>({ onEnd }: { onEnd: (value: T | null) => void }) {
const latest = useRef(onEnd);
useEffect(() => {
latest.current = onEnd;
});
useEffect(() => () => latest.current(null), []);
return null;
}
4 changes: 2 additions & 2 deletions src/lib/designFile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand All @@ -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<string, unknown> }).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');
});

Expand Down
7 changes: 7 additions & 0 deletions src/lib/previewProps.ts
Original file line number Diff line number Diff line change
@@ -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<T extends LabelObject>(obj: T, preview: Record<string, unknown> | undefined): T {
if (!preview || !('props' in obj)) return obj;
return { ...obj, props: { ...obj.props, ...preview } } as T;
}
3 changes: 2 additions & 1 deletion src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1254,7 +1254,8 @@ const ar = {
color: 'اللون',
colorB: 'أسود',
colorW: 'أبيض',
rounding: 'التدوير (0–8)',
rounding: 'التدوير',
roundingHint: 'تأخذ الطابعة خطوات كاملة فقط، كل خطوة تساوي واحدًا من ستة عشر من الجانب الأقصر. تعرض القائمة نصف قطر الزاوية الذي توفره كل خطوة لهذا المربع.',
reverse: 'Invert',
},
shapeMode: {
Expand Down
3 changes: 2 additions & 1 deletion src/locales/bg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1254,7 +1254,8 @@ const bg = {
color: 'Цвят',
colorB: 'Черен',
colorW: 'Бял',
rounding: 'Закръгляне (0–8)',
rounding: 'Закръгляне',
roundingHint: 'Принтерът прави само цели стъпки, всяка равна на една шестнайсета от по-късата страна. Списъкът показва радиуса на ъгъла, който всяка стъпка дава на този правоъгълник.',
reverse: 'Invert',
},
shapeMode: {
Expand Down
3 changes: 2 additions & 1 deletion src/locales/cs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
3 changes: 2 additions & 1 deletion src/locales/da.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
3 changes: 2 additions & 1 deletion src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
3 changes: 2 additions & 1 deletion src/locales/el.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1254,7 +1254,8 @@ const el = {
color: 'Χρώμα',
colorB: 'Μαύρο',
colorW: 'Λευκό',
rounding: 'Στρογγυλοποίηση (0–8)',
rounding: 'Στρογγυλοποίηση',
roundingHint: 'Ο εκτυπωτής χρησιμοποιεί μόνο ακέραια βήματα, καθένα ένα δέκατο έκτο της μικρότερης πλευράς. Η λίστα δείχνει την ακτίνα γωνίας που δίνει κάθε βήμα σε αυτό το πλαίσιο.',
reverse: 'Invert',
},
shapeMode: {
Expand Down
3 changes: 2 additions & 1 deletion src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
3 changes: 2 additions & 1 deletion src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
3 changes: 2 additions & 1 deletion src/locales/et.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Loading
Loading