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
2 changes: 1 addition & 1 deletion src/components/Canvas/ImageObject.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
15 changes: 6 additions & 9 deletions src/components/Canvas/LabelCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -131,21 +131,18 @@ export const LabelCanvas = forwardRef<LabelCanvasHandle, Props>(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]);

Expand Down
2 changes: 1 addition & 1 deletion src/components/Canvas/LineObject.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
3 changes: 2 additions & 1 deletion src/components/Canvas/bwipHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
3 changes: 1 addition & 2 deletions src/components/Canvas/hooks/useCanvasLasso.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion src/components/Canvas/transformPosition.test.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
3 changes: 2 additions & 1 deletion src/components/Canvas/transformPosition.ts
Original file line number Diff line number Diff line change
@@ -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";

/**
Expand Down
3 changes: 1 addition & 2 deletions src/components/Properties/LayerRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
3 changes: 1 addition & 2 deletions src/components/Properties/useLayerDnd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
73 changes: 72 additions & 1 deletion src/lib/designFile.test.ts
Original file line number Diff line number Diff line change
@@ -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[] = [
{
Expand Down Expand Up @@ -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');
});
});
27 changes: 19 additions & 8 deletions src/lib/designFile.ts
Original file line number Diff line number Diff line change
@@ -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<unknown> = 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<unknown> = 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<unknown> = z.union([groupSchema, leafSchema]);

const pageSchema = z.object({ objects: z.array(labelObjectSchema) });

const designFileSchema = z.object({
Expand Down
2 changes: 1 addition & 1 deletion src/lib/printPreview.ts
Original file line number Diff line number Diff line change
@@ -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 `<html><head><style>
Expand Down
2 changes: 1 addition & 1 deletion src/lib/shapeRender.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { LabelObject } from "../registry";
import type { LabelObject } from "../types/Group";
import { diagonalPolygonPoints } from "./shapeGeometry";

/** Inward-extruded ^GE / ^GC ring or solid disc, shared by ellipse and
Expand Down
3 changes: 1 addition & 2 deletions src/lib/zplGenerator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@ import { describe, it, expect } from 'vitest';
import { generateZPL, generateMultiPageZPL } from './zplGenerator';
import { parseZPL } from './zplParser';
import type { LabelConfig } from '../types/ObjectType';
import type { LabelObject } from '../registry';
import type { GroupObject } from '../types/Group';
import type { GroupObject, LabelObject } from '../types/Group';
import { defined, props } from '../test/helpers';

const BASE_LABEL: LabelConfig = {
Expand Down
3 changes: 1 addition & 2 deletions src/lib/zplGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@ import { mmToDots } from './coordinates';
import { ObjectRegistry } from '../registry';
import { stripZplCommandChars } from '../registry/zplHelpers';
import type { LabelConfig } from '../types/ObjectType';
import type { LabelObject } from '../registry';
import type { Page } from '../store/labelStore';
import { isGroup } from '../types/Group';
import { isGroup, type LabelObject } from '../types/Group';

/**
* Concatenates `generateZPL` output for every page. Each page becomes its own
Expand Down
2 changes: 1 addition & 1 deletion src/lib/zplImportService.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { parseZPL, type ImportFinding, type ImportFindingKind, type ImportReport } from "./zplParser";
import type { LabelConfig } from "../types/ObjectType";
import type { LabelObject } from "../registry";
import type { LabelObject } from "../types/Group";

export interface ZplImportResult {
labelConfig: Partial<LabelConfig>;
Expand Down
2 changes: 1 addition & 1 deletion src/lib/zplParser.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { LabelConfig } from "../types/ObjectType";
import type { LabelObject } from "../registry";
import type { LabelObject } from "../types/Group";
import type { TextProps } from "../registry/text";
import type { Code128Props } from "../registry/code128";
import type { Code39Props } from "../registry/code39";
Expand Down
10 changes: 2 additions & 8 deletions src/registry/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { ObjectTypeDefinition, LabelObjectBase } from '../types/ObjectType';
import type { GroupObject } from '../types/Group';
import { text } from './text.tsx';
import type { TextProps } from './text.tsx';
import { code128 } from './code128.tsx';
Expand Down Expand Up @@ -64,8 +63,8 @@ import { codablock } from './codablock.tsx';
import type { CodablockProps } from './codablock.tsx';

/** Leaf objects: every registry-backed type. These render to ZPL and
* have a PropertiesPanel. Groups (see `LabelObject`) are structural
* only and intentionally outside this union. */
* have a PropertiesPanel. The tree-level union `LabelObject` (which
* also covers `GroupObject`) lives in `types/Group.ts`. */
export type LeafObject =
| (LabelObjectBase & { type: 'text'; props: TextProps })
| (LabelObjectBase & { type: 'code128'; props: Code128Props })
Expand Down Expand Up @@ -99,11 +98,6 @@ export type LeafObject =
| (LabelObjectBase & { type: 'micropdf417'; props: MicroPdf417Props })
| (LabelObjectBase & { type: 'codablock'; props: CodablockProps });

/** Any node in the object tree: either a leaf (registry-backed) or a
* group container. Most code paths should operate on this; use
* `isGroup` from `types/Group` to narrow. */
export type LabelObject = LeafObject | GroupObject;

export const BARCODE_1D_TYPES = new Set([
'code128', 'code39', 'ean13', 'ean8', 'upca', 'upce', 'interleaved2of5', 'code93',
'code11', 'industrial2of5', 'standard2of5', 'codabar', 'logmars', 'msi', 'plessey',
Expand Down
2 changes: 1 addition & 1 deletion src/registry/line.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, it, expect } from "vitest";
import { pickAngle, line } from "./line";
import type { LabelObject } from "./index";
import type { LabelObject } from "../types/Group";

const makeLine = (overrides: Partial<{
x: number; y: number; angle: number; length: number; thickness: number;
Expand Down
42 changes: 40 additions & 2 deletions src/store/labelStore.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useLabelStore, currentObjects } from './labelStore';
import type { LabelObject } from '../registry';
import { isGroup } from '../types/Group';
import { isGroup, type LabelObject } from '../types/Group';
import { defined, props } from '../test/helpers';

/** Reset store to clean state before each test. */
Expand Down Expand Up @@ -814,4 +813,43 @@ describe('ungroup', () => {
expect(objs()).toHaveLength(2);
expect(objs().every((o) => !isGroup(o))).toBe(true);
});

describe('lock cascade', () => {
/** Set up "one text leaf inside a locked group" and return the leaf id. */
function setupLockedGroup(): string {
state().addObject('text');
const leafId = defined(objs()[0]).id;
state().selectObject(leafId);
state().groupSelection();
const gid = defined(state().selectedIds[0]);
state().updateObject(gid, { locked: true });
return leafId;
}

function childLeaf(): LabelObject {
const g = defined(objs()[0]);
if (!isGroup(g)) throw new Error('expected group');
return defined(g.children[0]);
}

it('blocks position changes on a child of a locked group via updateObject', () => {
const leafId = setupLockedGroup();
const before = childLeaf().x;
state().updateObject(leafId, { x: before + 50 });
expect(childLeaf().x).toBe(before);
});

it('blocks position changes on a child of a locked group via updateObjects', () => {
const leafId = setupLockedGroup();
const before = childLeaf().x;
state().updateObjects([{ id: leafId, changes: { x: before + 50 } }]);
expect(childLeaf().x).toBe(before);
});

it('still allows bypass keys (visible, locked) on a child of a locked group', () => {
const leafId = setupLockedGroup();
state().updateObject(leafId, { visible: false });
expect(childLeaf().visible).toBe(false);
});
});
});
Loading