From 637878d9539a646d2238eae32a947b8f74f972f1 Mon Sep 17 00:00:00 2001 From: u8array Date: Fri, 8 May 2026 22:51:47 +0200 Subject: [PATCH 1/6] refactor(registry): extract commitUniformScaleTransform helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QR, Aztec, and DataMatrix all share the same commitTransform shape: clamp(min, max, round(prop * min(sx, sy))) on a single integer module- size prop. The bodies were near-duplicates; Aztec was missing the call entirely (canvas resize had no effect — users had to edit the props panel). Closes that gap as a side effect of consolidating. Add a factory helper, parametrised by prop name and range, and route all three registries through it. --- src/registry/aztec.tsx | 3 +++ src/registry/datamatrix.tsx | 6 ++---- src/registry/qrcode.tsx | 6 ++---- src/registry/transformHelpers.ts | 16 ++++++++++++++++ 4 files changed, 23 insertions(+), 8 deletions(-) diff --git a/src/registry/aztec.tsx b/src/registry/aztec.tsx index 70e59a80..6635aa04 100644 --- a/src/registry/aztec.tsx +++ b/src/registry/aztec.tsx @@ -2,6 +2,7 @@ import type { ObjectTypeDefinition } from "../types/ObjectType"; import { useT } from "../lib/useT"; import { inputCls, labelCls } from "../components/Properties/styles"; import { fieldPos, fdField } from "./zplHelpers"; +import { commitUniformScaleTransform } from "./transformHelpers"; import { type ZplRotation } from "./rotation"; import { RotationSelect } from "../components/Properties/RotationSelect"; import { NumberInput } from "../components/Properties/NumberInput"; @@ -25,6 +26,8 @@ export const aztec: ObjectTypeDefinition = { }, defaultSize: { width: 200, height: 200 }, + commitTransform: commitUniformScaleTransform<'magnification', AztecProps>('magnification', 1, 10), + toZPL: (obj) => { const p = obj.props; // ^B0 a,b,c,d,e,f,g = orientation, magnification, ecic, errorControl, diff --git a/src/registry/datamatrix.tsx b/src/registry/datamatrix.tsx index 183c539c..d6e565a9 100644 --- a/src/registry/datamatrix.tsx +++ b/src/registry/datamatrix.tsx @@ -2,7 +2,7 @@ import type { ObjectTypeDefinition } from '../types/ObjectType'; import { useT } from '../lib/useT'; import { inputCls, labelCls } from '../components/Properties/styles'; import { fieldPos, fdField } from './zplHelpers'; -import { clamp } from './transformHelpers'; +import { commitUniformScaleTransform } from './transformHelpers'; import { type ZplRotation } from './rotation'; import { RotationSelect } from '../components/Properties/RotationSelect'; import { NumberInput } from '../components/Properties/NumberInput'; @@ -26,9 +26,7 @@ export const datamatrix: ObjectTypeDefinition = { }, defaultSize: { width: 150, height: 150 }, - commitTransform: (obj, { sx, sy }) => ({ - dimension: clamp(1, 12, Math.round(obj.props.dimension * Math.min(sx, sy))), - }), + commitTransform: commitUniformScaleTransform<'dimension', DataMatrixProps>('dimension', 1, 12), toZPL: (obj) => { const p = obj.props; diff --git a/src/registry/qrcode.tsx b/src/registry/qrcode.tsx index 8979c807..7d6fe725 100644 --- a/src/registry/qrcode.tsx +++ b/src/registry/qrcode.tsx @@ -2,7 +2,7 @@ import type { ObjectTypeDefinition } from '../types/ObjectType'; import { useT } from '../lib/useT'; import { inputCls, labelCls } from '../components/Properties/styles'; import { fieldPos, fdField } from './zplHelpers'; -import { clamp } from './transformHelpers'; +import { commitUniformScaleTransform } from './transformHelpers'; import { type ZplRotation } from './rotation'; import { RotationSelect } from '../components/Properties/RotationSelect'; import { NumberInput } from '../components/Properties/NumberInput'; @@ -26,9 +26,7 @@ export const qrcode: ObjectTypeDefinition = { }, defaultSize: { width: 200, height: 200 }, - commitTransform: (obj, { sx, sy }) => ({ - magnification: clamp(1, 10, Math.round(obj.props.magnification * Math.min(sx, sy))), - }), + commitTransform: commitUniformScaleTransform<'magnification', QrCodeProps>('magnification', 1, 10), toZPL: (obj) => { const p = obj.props; diff --git a/src/registry/transformHelpers.ts b/src/registry/transformHelpers.ts index 2b786c13..b5ea4775 100644 --- a/src/registry/transformHelpers.ts +++ b/src/registry/transformHelpers.ts @@ -5,6 +5,22 @@ export function clamp(min: number, max: number, value: number): number { return Math.max(min, Math.min(max, value)); } +/** + * Factory for commitTransform on uniformly-scaling 2D codes (QR, Aztec, + * DataMatrix): a single integer module-size prop scales by min(sx, sy) + * and clamps to [min, max]. The prop name and range vary per code, so they + * are closed over at registry-definition time. + */ +export function commitUniformScaleTransform< + K extends string, + P extends Record, +>(propName: K, min: number, max: number) { + return (obj: LabelObjectBase & { props: P }, ctx: TransformContext): Partial

=> { + const next = clamp(min, max, Math.round(obj.props[propName] * Math.min(ctx.sx, ctx.sy))); + return { [propName]: next } as Partial

; + }; +} + interface WidthHeightProps { width: number; height: number; From 28d73c9f9908b4bf3efbf4106dba4041c0bf574e Mon Sep 17 00:00:00 2001 From: u8array Date: Fri, 8 May 2026 23:01:32 +0200 Subject: [PATCH 2/6] refactor(registry): tidy commitUniformScaleTransform call sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two small ergonomics improvements to the helper added in the previous commit: - Default P to Record in the helper signature so callers no longer have to repeat the prop literal twice as a type argument and again as the runtime arg. K is inferred from the runtime arg, P is contextually narrowed by the registry slot. Type safety is preserved (typos still fail via parameter contravariance against the registry's AztecProps/QrCodeProps/DataMatrixProps). - Extract per-registry MIN/MAX constants for the magnification / dimension range. The same range was repeated three times per code (helper call + NumberInput min + max) — now one source of truth per registry file. --- src/registry/aztec.tsx | 17 +++++++++++------ src/registry/datamatrix.tsx | 11 +++++++---- src/registry/qrcode.tsx | 11 +++++++---- src/registry/transformHelpers.ts | 2 +- 4 files changed, 26 insertions(+), 15 deletions(-) diff --git a/src/registry/aztec.tsx b/src/registry/aztec.tsx index 6635aa04..18ca71e2 100644 --- a/src/registry/aztec.tsx +++ b/src/registry/aztec.tsx @@ -7,9 +7,14 @@ import { type ZplRotation } from "./rotation"; import { RotationSelect } from "../components/Properties/RotationSelect"; import { NumberInput } from "../components/Properties/NumberInput"; +const MAGNIFICATION_MIN = 1; +const MAGNIFICATION_MAX = 10; +const EC_LEVEL_MIN = 0; +const EC_LEVEL_MAX = 232; + export interface AztecProps { content: string; - magnification: number; // 1–10, module size in dots + magnification: number; // module size in dots, range MAGNIFICATION_MIN..MAX ecLevel: number; // 0=default, 1-99=error correction %, 101-104=compact, 201-232=full, 300=rune rotation: ZplRotation; } @@ -26,7 +31,7 @@ export const aztec: ObjectTypeDefinition = { }, defaultSize: { width: 200, height: 200 }, - commitTransform: commitUniformScaleTransform<'magnification', AztecProps>('magnification', 1, 10), + commitTransform: commitUniformScaleTransform('magnification', MAGNIFICATION_MIN, MAGNIFICATION_MAX), toZPL: (obj) => { const p = obj.props; @@ -58,16 +63,16 @@ export const aztec: ObjectTypeDefinition = { onChange({ magnification })} /> onChange({ ecLevel })} /> diff --git a/src/registry/datamatrix.tsx b/src/registry/datamatrix.tsx index d6e565a9..fbd6ada0 100644 --- a/src/registry/datamatrix.tsx +++ b/src/registry/datamatrix.tsx @@ -7,9 +7,12 @@ import { type ZplRotation } from './rotation'; import { RotationSelect } from '../components/Properties/RotationSelect'; import { NumberInput } from '../components/Properties/NumberInput'; +const DIMENSION_MIN = 1; +const DIMENSION_MAX = 12; + export interface DataMatrixProps { content: string; - dimension: number; // module size in dots (1–12) + dimension: number; // module size in dots, range DIMENSION_MIN..MAX quality: 0 | 50 | 80 | 140 | 200; // 0 = auto rotation: ZplRotation; } @@ -26,7 +29,7 @@ export const datamatrix: ObjectTypeDefinition = { }, defaultSize: { width: 150, height: 150 }, - commitTransform: commitUniformScaleTransform<'dimension', DataMatrixProps>('dimension', 1, 12), + commitTransform: commitUniformScaleTransform('dimension', DIMENSION_MIN, DIMENSION_MAX), toZPL: (obj) => { const p = obj.props; @@ -54,8 +57,8 @@ export const datamatrix: ObjectTypeDefinition = { onChange({ dimension })} /> diff --git a/src/registry/qrcode.tsx b/src/registry/qrcode.tsx index 7d6fe725..c1a9f627 100644 --- a/src/registry/qrcode.tsx +++ b/src/registry/qrcode.tsx @@ -7,9 +7,12 @@ import { type ZplRotation } from './rotation'; import { RotationSelect } from '../components/Properties/RotationSelect'; import { NumberInput } from '../components/Properties/NumberInput'; +const MAGNIFICATION_MIN = 1; +const MAGNIFICATION_MAX = 10; + export interface QrCodeProps { content: string; - magnification: number; // 1–10, dot size per module + magnification: number; // dot size per module, range MAGNIFICATION_MIN..MAX errorCorrection: 'H' | 'Q' | 'M' | 'L'; rotation: ZplRotation; } @@ -26,7 +29,7 @@ export const qrcode: ObjectTypeDefinition = { }, defaultSize: { width: 200, height: 200 }, - commitTransform: commitUniformScaleTransform<'magnification', QrCodeProps>('magnification', 1, 10), + commitTransform: commitUniformScaleTransform('magnification', MAGNIFICATION_MIN, MAGNIFICATION_MAX), toZPL: (obj) => { const p = obj.props; @@ -65,8 +68,8 @@ export const qrcode: ObjectTypeDefinition = { onChange({ magnification })} /> diff --git a/src/registry/transformHelpers.ts b/src/registry/transformHelpers.ts index b5ea4775..f35a3527 100644 --- a/src/registry/transformHelpers.ts +++ b/src/registry/transformHelpers.ts @@ -13,7 +13,7 @@ export function clamp(min: number, max: number, value: number): number { */ export function commitUniformScaleTransform< K extends string, - P extends Record, + P extends Record = Record, >(propName: K, min: number, max: number) { return (obj: LabelObjectBase & { props: P }, ctx: TransformContext): Partial

=> { const next = clamp(min, max, Math.round(obj.props[propName] * Math.min(ctx.sx, ctx.sy))); From 0b8fa86546d3d2cb0cf8bf6e59869dab71a4ce94 Mon Sep 17 00:00:00 2001 From: u8array Date: Fri, 8 May 2026 23:01:41 +0200 Subject: [PATCH 3/6] test(registry): cover commitUniformScaleTransform + 2D commitTransform invariant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two tests motivated by the aztec resize regression: 1. Direct unit test for commitUniformScaleTransform — clamp on both ends, integer rounding, min(sx,sy) selection. The helper's other siblings in transformHelpers.ts have no direct tests either, but this is the only one with non-trivial logic worth pinning down. 2. Smoke test in registry.test.ts: every code-2d type must declare a commitTransform handler. This is the invariant aztec violated silently — without it, a canvas drag-resize is a no-op. Catches the same shape of regression for any future 2D code. --- src/registry/registry.test.ts | 10 ++++++++ src/registry/transformHelpers.test.ts | 33 +++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 src/registry/transformHelpers.test.ts diff --git a/src/registry/registry.test.ts b/src/registry/registry.test.ts index bef3af2b..74c878d8 100644 --- a/src/registry/registry.test.ts +++ b/src/registry/registry.test.ts @@ -414,4 +414,14 @@ describe('ObjectRegistry', () => { expect(validGroups.has(def.group)).toBe(true); } }); + + // 2D codes are always resizable on the canvas — without commitTransform a + // drag-resize silently has no effect (this was the aztec regression). + // Every code-2d entry must declare a commit handler. + it('every code-2d type has a commitTransform handler', () => { + for (const [key, def] of Object.entries(ObjectRegistry)) { + if (def.group !== 'code-2d') continue; + expect(def.commitTransform, `${key} is missing commitTransform`).toBeDefined(); + } + }); }); diff --git a/src/registry/transformHelpers.test.ts b/src/registry/transformHelpers.test.ts new file mode 100644 index 00000000..92dff925 --- /dev/null +++ b/src/registry/transformHelpers.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import { commitUniformScaleTransform } from './transformHelpers'; +import type { LabelObjectBase, TransformContext } from '../types/ObjectType'; + +const ctx = (sx: number, sy: number): TransformContext => ({ + sx, sy, snap: (n) => n, nodeHeight: 0, anchor: null, +}); + +interface Sample { magnification: number } +const obj = (mag: number): LabelObjectBase & { props: Sample } => ({ + id: 'id', type: 'sample', x: 0, y: 0, rotation: 0, props: { magnification: mag }, +}); + +describe('commitUniformScaleTransform', () => { + const handler = commitUniformScaleTransform<'magnification', Sample>('magnification', 1, 10); + + it('scales by min(sx, sy) so non-uniform drags stay inside the box', () => { + expect(handler(obj(4), ctx(2, 1.5))).toEqual({ magnification: 6 }); + expect(handler(obj(4), ctx(1.5, 2))).toEqual({ magnification: 6 }); + }); + + it('rounds to integer module sizes', () => { + expect(handler(obj(3), ctx(1.4, 1.4))).toEqual({ magnification: 4 }); + }); + + it('clamps to the configured maximum', () => { + expect(handler(obj(8), ctx(3, 3))).toEqual({ magnification: 10 }); + }); + + it('clamps to the configured minimum (collapsing drags)', () => { + expect(handler(obj(4), ctx(0, 0))).toEqual({ magnification: 1 }); + }); +}); From 30780fd0e6684360263957f0ee67158c7ed7a27b Mon Sep 17 00:00:00 2001 From: u8array Date: Fri, 8 May 2026 23:04:32 +0200 Subject: [PATCH 4/6] chore(registry): tidy test-call form and interface comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - transformHelpers.test.ts: drop the explicit type args at the helper call to actually exercise the inference path the previous commit introduced. The unused Sample type stays as the props shape. - qrcode/aztec/datamatrix: shorten the interface field comments to the semantic meaning. The range was duplicated as 'range FOO_MIN..MAX' pointing at file-private constants — readers consulting the props type from outside don't see those, and inside the file the constants sit right above. Plain 'module size in dots' is enough. --- src/registry/aztec.tsx | 2 +- src/registry/datamatrix.tsx | 2 +- src/registry/qrcode.tsx | 2 +- src/registry/transformHelpers.test.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/registry/aztec.tsx b/src/registry/aztec.tsx index 18ca71e2..d667ffcf 100644 --- a/src/registry/aztec.tsx +++ b/src/registry/aztec.tsx @@ -14,7 +14,7 @@ const EC_LEVEL_MAX = 232; export interface AztecProps { content: string; - magnification: number; // module size in dots, range MAGNIFICATION_MIN..MAX + magnification: number; // module size in dots ecLevel: number; // 0=default, 1-99=error correction %, 101-104=compact, 201-232=full, 300=rune rotation: ZplRotation; } diff --git a/src/registry/datamatrix.tsx b/src/registry/datamatrix.tsx index fbd6ada0..b72f4d04 100644 --- a/src/registry/datamatrix.tsx +++ b/src/registry/datamatrix.tsx @@ -12,7 +12,7 @@ const DIMENSION_MAX = 12; export interface DataMatrixProps { content: string; - dimension: number; // module size in dots, range DIMENSION_MIN..MAX + dimension: number; // module size in dots quality: 0 | 50 | 80 | 140 | 200; // 0 = auto rotation: ZplRotation; } diff --git a/src/registry/qrcode.tsx b/src/registry/qrcode.tsx index c1a9f627..e9519430 100644 --- a/src/registry/qrcode.tsx +++ b/src/registry/qrcode.tsx @@ -12,7 +12,7 @@ const MAGNIFICATION_MAX = 10; export interface QrCodeProps { content: string; - magnification: number; // dot size per module, range MAGNIFICATION_MIN..MAX + magnification: number; // dot size per module errorCorrection: 'H' | 'Q' | 'M' | 'L'; rotation: ZplRotation; } diff --git a/src/registry/transformHelpers.test.ts b/src/registry/transformHelpers.test.ts index 92dff925..db1d5098 100644 --- a/src/registry/transformHelpers.test.ts +++ b/src/registry/transformHelpers.test.ts @@ -12,7 +12,7 @@ const obj = (mag: number): LabelObjectBase & { props: Sample } => ({ }); describe('commitUniformScaleTransform', () => { - const handler = commitUniformScaleTransform<'magnification', Sample>('magnification', 1, 10); + const handler = commitUniformScaleTransform('magnification', 1, 10); it('scales by min(sx, sy) so non-uniform drags stay inside the box', () => { expect(handler(obj(4), ctx(2, 1.5))).toEqual({ magnification: 6 }); From 1cdf1970b3d48b458d257ca182b0f0ea5f534428 Mon Sep 17 00:00:00 2001 From: u8array Date: Fri, 8 May 2026 23:08:31 +0200 Subject: [PATCH 5/6] fix(aztec): unblock Rune ecLevel (300) in NumberInput EC_LEVEL_MAX was inherited as 232 from the previously hardcoded value; that excludes Aztec Rune (ecLevel=300), which the AztecProps comment documents as valid. The NumberInput's single-max constraint cannot express the discontinuous domain (0, 1-99, 101-104, 201-232, 300), so use the highest valid value as the upper bound. Closes the pre-existing gap surfaced by extracting the constant. --- src/registry/aztec.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/registry/aztec.tsx b/src/registry/aztec.tsx index d667ffcf..907e1349 100644 --- a/src/registry/aztec.tsx +++ b/src/registry/aztec.tsx @@ -10,7 +10,11 @@ import { NumberInput } from "../components/Properties/NumberInput"; const MAGNIFICATION_MIN = 1; const MAGNIFICATION_MAX = 10; const EC_LEVEL_MIN = 0; -const EC_LEVEL_MAX = 232; +// Aztec ecLevel domain is discontinuous: 0=default, 1-99=ECC%, 101-104=compact, +// 201-232=full, 300=Rune. NumberInput only enforces a single max, so use the +// highest valid value (300) and rely on the user / spec for the gaps. Catches +// at least the previous regression where Rune was unreachable. +const EC_LEVEL_MAX = 300; export interface AztecProps { content: string; From 31621bf45609b18f29887cdfebda2553288e002c Mon Sep 17 00:00:00 2001 From: u8array Date: Fri, 8 May 2026 23:10:33 +0200 Subject: [PATCH 6/6] docs(aztec): tighten EC_LEVEL_MAX comment to the why Strip the domain restatement (already in AztecProps), the regression reference (commit-message material), and keep only the non-obvious constraint: NumberInput's single-max can't model the discontinuous domain, so we pick the highest valid value. --- src/registry/aztec.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/registry/aztec.tsx b/src/registry/aztec.tsx index 907e1349..262fd882 100644 --- a/src/registry/aztec.tsx +++ b/src/registry/aztec.tsx @@ -10,10 +10,8 @@ import { NumberInput } from "../components/Properties/NumberInput"; const MAGNIFICATION_MIN = 1; const MAGNIFICATION_MAX = 10; const EC_LEVEL_MIN = 0; -// Aztec ecLevel domain is discontinuous: 0=default, 1-99=ECC%, 101-104=compact, -// 201-232=full, 300=Rune. NumberInput only enforces a single max, so use the -// highest valid value (300) and rely on the user / spec for the gaps. Catches -// at least the previous regression where Rune was unreachable. +// NumberInput can't express the discontinuous AztecProps domain — use the +// highest valid value (Rune = 300) as the upper bound. const EC_LEVEL_MAX = 300; export interface AztecProps {