From a1e81231ca11868103dc120189329fe47ee2349b Mon Sep 17 00:00:00 2001 From: u8array Date: Fri, 15 May 2026 09:29:45 +0200 Subject: [PATCH] refactor(groups): post-merge cleanup bundle Three independent follow-up threads from the groups feature merge: * refactor(types): move `LabelObject` from `registry/index.ts` to `types/Group.ts`. Breaks the type-only cycle where registry imported `GroupObject` only to compose `LabelObject`, which `types/Group` then imported back. Now one-way: `types/Group` depends on registry for `LeafObject`. About 25 consumer imports updated. * fix(store): cascade lock through ancestor groups in `applyObjectChanges`. `updateObject` reads ancestor state once via `findAncestors`; `updateObjects` carries `inheritedLocked` through the recursive walk. Load-bearing, because `expandSelection`-driven flows (arrow-key nudges, multi-drag) previously sidestepped a locked group by targeting its leaf children directly. Bypass keys (locked, visible, includeInExport, comment, name) still pass so the layers panel can release the cascade. * refactor(design-file): split `labelObjectSchema` into discriminated leaf / group shapes (leaf requires `props`, group requires `children`). Adds a round-trip test for nested groups plus negative cases for malformed leaves and groups. Also drops the dead `inheritedHidden` walk parameter in `LabelCanvas.visibleLeaves` (early-continue meant it never propagated) and aligns the new cascade tests with the `isGroup`- narrowing pattern used elsewhere in `labelStore.test.ts`. --- src/components/Canvas/ImageObject.tsx | 2 +- src/components/Canvas/LabelCanvas.tsx | 15 ++-- src/components/Canvas/LineObject.tsx | 2 +- src/components/Canvas/bwipHelpers.ts | 3 +- src/components/Canvas/hooks/useCanvasLasso.ts | 3 +- .../Canvas/transformPosition.test.ts | 2 +- src/components/Canvas/transformPosition.ts | 3 +- src/components/Properties/LayerRow.tsx | 3 +- src/components/Properties/useLayerDnd.ts | 3 +- src/lib/designFile.test.ts | 73 ++++++++++++++++++- src/lib/designFile.ts | 27 +++++-- src/lib/printPreview.ts | 2 +- src/lib/shapeRender.ts | 2 +- src/lib/zplGenerator.test.ts | 3 +- src/lib/zplGenerator.ts | 3 +- src/lib/zplImportService.ts | 2 +- src/lib/zplParser.ts | 2 +- src/registry/index.ts | 10 +-- src/registry/line.test.ts | 2 +- src/store/labelStore.test.ts | 42 ++++++++++- src/store/labelStore.ts | 50 +++++++++---- src/test/testModels.ts | 2 +- src/types/Group.test.ts | 2 +- src/types/Group.ts | 10 ++- tests/fixtures/shapeTestCases.ts | 2 +- 25 files changed, 205 insertions(+), 65 deletions(-) diff --git a/src/components/Canvas/ImageObject.tsx b/src/components/Canvas/ImageObject.tsx index 3e1391a6..7a30ed49 100644 --- a/src/components/Canvas/ImageObject.tsx +++ b/src/components/Canvas/ImageObject.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useRef } from "react"; import { Group, Image as KImage, Rect, Text } from "react-konva"; import type Konva from "konva"; -import type { LabelObject } from "../../registry"; +import type { LabelObject } from "../../types/Group"; import { dotsToPx, pxToDots } from "../../lib/coordinates"; import { getImage } from "../../lib/imageCache"; import { useColorScheme } from "../../lib/useColorScheme"; diff --git a/src/components/Canvas/LabelCanvas.tsx b/src/components/Canvas/LabelCanvas.tsx index 9459a055..0aa8a6a6 100644 --- a/src/components/Canvas/LabelCanvas.tsx +++ b/src/components/Canvas/LabelCanvas.tsx @@ -13,7 +13,7 @@ import type { PaletteDragData } from "../../dnd/types"; import { Stage, Layer, Group, Rect, Transformer } from "react-konva"; import type Konva from "konva"; import { useLabelStore, useCurrentObjects, currentObjects, getCurrentObjects } from "../../store/labelStore"; -import { isGroup, getAllLeaves, expandSelection, selectionTargetId, findObjectById } from "../../types/Group"; +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"; import type { Unit } from "../../lib/units"; @@ -26,7 +26,7 @@ import { Grid } from "./Grid"; import { GuideLines } from "./GuideLines"; import { Ruler, RULER_SIZE } from "./Ruler"; import { ObjectRegistry } from "../../registry"; -import type { LabelObject, LeafObject } from "../../registry"; +import type { LeafObject } from "../../registry"; import { useColorScheme } from "../../lib/useColorScheme"; import { objectIdsAtPoint } from "./hitTesting"; import { useT } from "../../lib/useT"; @@ -131,21 +131,18 @@ export const LabelCanvas = forwardRef(function LabelCa // without each consumer having to walk ancestors. const visibleLeaves = useMemo(() => { const out: LeafObject[] = []; - const walk = (nodes: LabelObject[], inheritedLocked: boolean, inheritedHidden: boolean) => { + const walk = (nodes: LabelObject[], inheritedLocked: boolean) => { for (const n of nodes) { + if (n.visible === false) continue; const locked = inheritedLocked || !!n.locked; - const hidden = inheritedHidden || n.visible === false; - if (hidden) continue; if (isGroup(n)) { - walk(n.children, locked, hidden); + walk(n.children, locked); } else { - // Preserve object identity when nothing was inherited so React - // memoisation keeps unaffected leaves stable across renders. out.push(locked && !n.locked ? ({ ...n, locked: true } as LeafObject) : n); } } }; - walk(objects, false, false); + walk(objects, false); return out; }, [objects]); diff --git a/src/components/Canvas/LineObject.tsx b/src/components/Canvas/LineObject.tsx index 297f3547..6d6f6cfd 100644 --- a/src/components/Canvas/LineObject.tsx +++ b/src/components/Canvas/LineObject.tsx @@ -1,7 +1,7 @@ import { useRef, useState } from "react"; import { Group, Line as KLine, Rect } from "react-konva"; import type Konva from "konva"; -import type { LabelObject } from "../../registry"; +import type { LabelObject } from "../../types/Group"; import { dotsToPx, pxToDots } from "../../lib/coordinates"; import { constrainLine, type ConstrainMode } from "../../lib/lineConstrain"; import { useColorScheme } from "../../lib/useColorScheme"; diff --git a/src/components/Canvas/bwipHelpers.ts b/src/components/Canvas/bwipHelpers.ts index f38d705b..70b3a499 100644 --- a/src/components/Canvas/bwipHelpers.ts +++ b/src/components/Canvas/bwipHelpers.ts @@ -12,7 +12,8 @@ * bwipHelpers.test.ts ensures every BCID-registered type has a case. */ -import type { LabelObject, LeafObject } from "../../registry"; +import type { LeafObject } from "../../registry"; +import type { LabelObject } from "../../types/Group"; import type { Gs1DatabarProps } from "../../registry/gs1databar"; import { objectRotation } from "../../registry/rotation"; import { dotsToPx } from "../../lib/coordinates"; diff --git a/src/components/Canvas/hooks/useCanvasLasso.ts b/src/components/Canvas/hooks/useCanvasLasso.ts index 062849c1..3c0cac55 100644 --- a/src/components/Canvas/hooks/useCanvasLasso.ts +++ b/src/components/Canvas/hooks/useCanvasLasso.ts @@ -1,8 +1,7 @@ import { useState, useRef } from "react"; import type Konva from "konva"; import { getCurrentObjects } from "../../../store/labelStore"; -import type { LabelObject } from "../../../registry"; -import { isGroup } from "../../../types/Group"; +import { isGroup, type LabelObject } from "../../../types/Group"; import { getIdsIntersectingRect, type LassoRect } from "../lassoGeometry"; interface Options { diff --git a/src/components/Canvas/transformPosition.test.ts b/src/components/Canvas/transformPosition.test.ts index cfb78fd7..c9430fef 100644 --- a/src/components/Canvas/transformPosition.test.ts +++ b/src/components/Canvas/transformPosition.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from "vitest"; import { modelPositionFromRenderedTopLeft } from "./transformPosition"; import { QR_FO_Y_OFFSET_DOTS } from "./bwipConstants"; -import type { LabelObject } from "../../registry"; +import type { LabelObject } from "../../types/Group"; const qrFo: LabelObject = { id: "q1", diff --git a/src/components/Canvas/transformPosition.ts b/src/components/Canvas/transformPosition.ts index 08b30ffd..cbbca324 100644 --- a/src/components/Canvas/transformPosition.ts +++ b/src/components/Canvas/transformPosition.ts @@ -1,4 +1,5 @@ -import { BARCODE_1D_TYPES, type LabelObject } from "../../registry"; +import { BARCODE_1D_TYPES } from "../../registry"; +import type { LabelObject } from "../../types/Group"; import { QR_FO_Y_OFFSET_DOTS } from "./bwipConstants"; /** diff --git a/src/components/Properties/LayerRow.tsx b/src/components/Properties/LayerRow.tsx index b6b33494..b8d58659 100644 --- a/src/components/Properties/LayerRow.tsx +++ b/src/components/Properties/LayerRow.tsx @@ -10,8 +10,7 @@ import { LinkSlashIcon, } from '@heroicons/react/16/solid'; import { ObjectRegistry } from '../../registry'; -import type { LabelObject } from '../../registry'; -import { isGroup } from '../../types/Group'; +import { isGroup, type LabelObject } from '../../types/Group'; import { useT } from '../../lib/useT'; import { DragHandleIcon } from '../ui/DragHandleIcon'; import { INDENT_STEP } from './layerLayout'; diff --git a/src/components/Properties/useLayerDnd.ts b/src/components/Properties/useLayerDnd.ts index 3964ec82..246b6d98 100644 --- a/src/components/Properties/useLayerDnd.ts +++ b/src/components/Properties/useLayerDnd.ts @@ -2,8 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { PointerSensor, closestCenter, useSensor, useSensors } from '@dnd-kit/core'; import type { DragEndEvent, DragOverEvent } from '@dnd-kit/core'; import { isGroup, findObjectById, findAncestors } from '../../types/Group'; -import type { GroupObject } from '../../types/Group'; -import type { LabelObject } from '../../registry'; +import type { GroupObject, LabelObject } from '../../types/Group'; import { INDENT_STEP } from './layerLayout'; /** Sentinel container id for the top-level objects list. Group containers diff --git a/src/lib/designFile.test.ts b/src/lib/designFile.test.ts index 6eb44a55..8bce73c5 100644 --- a/src/lib/designFile.test.ts +++ b/src/lib/designFile.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { parseDesignFile, serializeDesign } from './designFile'; -import type { LabelObject } from '../registry'; +import type { LabelObject } from '../types/Group'; const SAMPLE_OBJECTS: LabelObject[] = [ { @@ -85,4 +85,75 @@ describe('parseDesignFile', () => { expect(result.value.pages[0]?.objects).toHaveLength(1); expect(result.value.pages[1]?.objects).toHaveLength(1); }); + + it('roundtrips a design containing nested groups without structural loss', () => { + const designWithGroups: LabelObject[] = [ + SAMPLE_OBJECTS[0]!, + { + id: 'grp-outer', + type: 'group', + x: 0, + y: 0, + rotation: 0, + name: 'Header', + children: [ + { + id: 'grp-inner', + type: 'group', + x: 0, + y: 0, + rotation: 0, + children: [ + { + id: 'obj-2', + type: 'box', + x: 5, + y: 5, + rotation: 0, + props: { width: 20, height: 10, thickness: 1, filled: true, color: 'B', rounding: 0 }, + }, + ], + } as LabelObject, + ], + } as LabelObject, + ]; + const json = serializeDesign( + { widthMm: 100, heightMm: 60, dpmm: 8 }, + [{ objects: designWithGroups }], + ); + const result = parseDesignFile(json); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.pages[0]?.objects).toEqual(designWithGroups); + }); + + it('rejects a leaf object that is missing its props', () => { + const malformed = JSON.stringify({ + label: { widthMm: 100, heightMm: 60, dpmm: 8 }, + pages: [ + { + objects: [{ id: 'a', type: 'box', x: 0, y: 0, rotation: 0 }], + }, + ], + }); + const result = parseDesignFile(malformed); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toBe('invalid_schema'); + }); + + it('rejects a group object that is missing its children', () => { + const malformed = JSON.stringify({ + label: { widthMm: 100, heightMm: 60, dpmm: 8 }, + pages: [ + { + objects: [{ id: 'g', type: 'group', x: 0, y: 0, rotation: 0 }], + }, + ], + }); + const result = parseDesignFile(malformed); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toBe('invalid_schema'); + }); }); diff --git a/src/lib/designFile.ts b/src/lib/designFile.ts index 33aa36e6..fda43583 100644 --- a/src/lib/designFile.ts +++ b/src/lib/designFile.ts @@ -1,23 +1,34 @@ import { z } from "zod"; import { labelConfigSchema, labelObjectBaseSchema, type LabelConfig } from "../types/ObjectType"; -import type { LabelObject } from "../registry"; +import type { LabelObject } from "../types/Group"; import { ok, err, type Result } from "./result"; export type DesignFileError = "parse_error" | "invalid_schema"; export interface DesignFilePage { objects: LabelObject[] } export interface DesignFile { label: LabelConfig; pages: DesignFilePage[] } -// Leaves carry `props`; groups carry `children` instead and skip `props`. -// Both shapes share the base fields. `children` is recursive so the -// validator descends into nested groups too — the lazy wrap is what -// lets the schema reference itself. -const labelObjectSchema: z.ZodType = z.lazy(() => +// Two distinct shapes share the base fields: +// * leaves carry `props` and have no `children`, +// * groups carry `children` and have no `props` (their `type` is 'group'). +// Split into separate schemas so a leaf missing its `props` or a group +// missing its `children` fails validation. `groupSchema` is wrapped in +// z.lazy so the recursion through `labelObjectSchema` resolves. +const leafSchema = labelObjectBaseSchema.extend({ + type: z.string().refine((t) => t !== 'group', { + message: "Leaf objects cannot have type 'group'", + }), + props: z.record(z.string(), z.unknown()), +}); + +const groupSchema: z.ZodType = z.lazy(() => labelObjectBaseSchema.extend({ - props: z.record(z.string(), z.unknown()).optional(), - children: z.array(labelObjectSchema).optional(), + type: z.literal('group'), + children: z.array(labelObjectSchema), }), ); +const labelObjectSchema: z.ZodType = z.union([groupSchema, leafSchema]); + const pageSchema = z.object({ objects: z.array(labelObjectSchema) }); const designFileSchema = z.object({ diff --git a/src/lib/printPreview.ts b/src/lib/printPreview.ts index 36904252..e205c928 100644 --- a/src/lib/printPreview.ts +++ b/src/lib/printPreview.ts @@ -1,7 +1,7 @@ import { generateZPL } from "./zplGenerator"; import { fetchPreview } from "./labelary"; import type { LabelConfig } from "../types/ObjectType"; -import type { LabelObject } from "../registry"; +import type { LabelObject } from "../types/Group"; export function buildLoadingHtml(): string { return `