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
3 changes: 2 additions & 1 deletion src/components/PrinterSettings/PrinterSettingsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { CheckIcon, ClipboardDocumentIcon, TrashIcon, XMarkIcon } from "@heroico
import { useCopyToClipboard } from "../../lib/useCopyToClipboard";
import { useT } from "../../lib/useT";
import { generateSetupScript } from "../../lib/zplSetupScript";
import { useLabelStore, type PrinterSettingsTab } from "../../store/labelStore";
import { useLabelStore } from "../../store/labelStore";
import type { PrinterSettingsTab } from "../../store/slices/uiSlice";
import type { PrinterProfile } from "../../types/PrinterProfile";
import { DialogShell } from "../ui/DialogShell";
import { ZplLine } from "../Output/ZplLine";
Expand Down
171 changes: 171 additions & 0 deletions src/store/labelStore.internals.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { isGroup, type LabelObject, type Page } from '../types/Group';
import type { ObjectChanges } from '../types/LabelObject';
import type { LocaleCode } from '../locales';
import { locales } from '../locales';
import { renameTemplateMarker } from '../lib/fnTemplate';
import { getObjectStringContent } from '../lib/variableBinding';
import { getEntry } from '../registry';

/** Meta fields that remain editable on a locked object so the user can
* release the lock or annotate without unlocking first. Everything else
* (position, props, rotation, positionType) is blocked. */
const LOCK_BYPASS_KEYS = new Set(['locked', 'visible', 'includeInExport', 'comment', 'name']);

export function isLockBypass(changes: ObjectChanges): boolean {
const keys = Object.keys(changes);
return keys.length > 0 && keys.every((k) => LOCK_BYPASS_KEYS.has(k));
}

/** Apply `renameTemplateMarker` to every leaf's `content` in a subtree.
* Identity-preserving: returns the same array (and same node refs)
* when no markers needed rewriting, so React memoisation downstream
* stays effective for the common case where the rename touched no
* templates. */
export function rewriteTemplateMarkers(
objects: LabelObject[],
oldName: string,
newName: string,
): LabelObject[] {
let changed = false;
const next = objects.map((obj) => {
if (isGroup(obj)) {
const nextChildren = rewriteTemplateMarkers(obj.children, oldName, newName);
if (nextChildren === obj.children) return obj;
changed = true;
return { ...obj, children: nextChildren };
}
const content = getObjectStringContent(obj);
if (content === undefined) return obj;
const renamed = renameTemplateMarker(content, oldName, newName);
if (renamed === content) return obj;
changed = true;
const props = (obj as { props: object }).props;
return { ...obj, props: { ...props, content: renamed } } as LabelObject;
});
return changed ? next : objects;
}

export function applyObjectChanges(
obj: LabelObject,
changes: ObjectChanges,
ancestorLocked = false,
): LabelObject {
// Lock cascades from any ancestor group: a leaf inside a locked group
// accepts only bypass keys (locked / visible / includeInExport /
// comment / name) so the user can still toggle visibility or release
// the lock from the layers panel. Load-bearing — `expandSelection`-
// driven callers (arrow-key nudges, shift-multi-drag) target the
// group's leaf children directly and would otherwise sidestep the
// group's own `locked` flag.
if ((obj.locked || ancestorLocked) && !isLockBypass(changes)) return obj;
if (isGroup(obj)) {
// Groups have no registry entry (no normalize hook) and no props to
// merge — apply top-level changes only. Children stay untouched;
// tree updates reach them through their own mapObjectById call.
return { ...obj, ...changes } as LabelObject;
}
const normalize = getEntry(obj.type)?.normalizeChanges;
const normalized = normalize ? normalize(obj, changes) : changes;
return {
...obj,
...normalized,
props: normalized.props ? Object.assign({}, obj.props, normalized.props) : obj.props,
} as LabelObject;
}

/** Immutable insert-at-index that clamps `idx` into the array's bounds.
* Used by reparent flows to splice a node into a children list or the
* top-level list without crashing on out-of-range indices coming from
* ephemeral drag state. */
export function insertAt<T>(arr: readonly T[], idx: number, item: T): T[] {
const clamped = Math.max(0, Math.min(idx, arr.length));
return [...arr.slice(0, clamped), item, ...arr.slice(clamped)];
}

export function detectLocale(): LocaleCode {
const lang = navigator.language.slice(0, 2).toLowerCase();
return (lang in locales ? lang : 'en') as LocaleCode;
}
Comment thread
u8array marked this conversation as resolved.

export function detectInitialTheme(): 'light' | 'dark' {
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
}
Comment thread
u8array marked this conversation as resolved.

/** Build-time defaults for third-party services. Vite injects VITE_THIRD_PARTY_*
* env values; missing values fall back to enabled. Tauri/Docker builds can flip
* the default by setting VITE_THIRD_PARTY_LABELARY=false in their build env. */
export function thirdPartyDefaults(): { labelary: boolean } {
return {
labelary: import.meta.env.VITE_THIRD_PARTY_LABELARY !== 'false',
};
}
Comment thread
u8array marked this conversation as resolved.

/** Base offset (in dots) used to stagger duplicate / paste copies so they
* don't sit exactly on top of the source. 20 dots ≈ 2.5 mm at 8dpmm. */
export const DUPLICATE_OFFSET_DOTS = 20;

/** Deep-clone a children list with fresh ids and shallow-cloned props on
* every leaf. Recurses through nested groups. */
export function cloneChildrenFresh(children: LabelObject[]): LabelObject[] {
return children.map((c) => {
if (isGroup(c)) {
return {
...c,
id: crypto.randomUUID(),
children: cloneChildrenFresh(c.children),
};
}
return {
...c,
id: crypto.randomUUID(),
props: { ...c.props },
} as LabelObject;
});
}

/** Build offset copies of objects identified by `ids`. Missing ids are
* silently dropped. Props are shallow-cloned to avoid sharing the
* reference with the original. */
export function buildOffsetCopies(objs: LabelObject[], ids: readonly string[]): LabelObject[] {
const byId = new Map(objs.map((o) => [o.id, o]));
return ids.flatMap((id) => {
const src = byId.get(id);
if (!src) return [];
if (isGroup(src)) {
return [{
...src,
id: crypto.randomUUID(),
x: src.x + DUPLICATE_OFFSET_DOTS,
y: src.y + DUPLICATE_OFFSET_DOTS,
children: cloneChildrenFresh(src.children),
}];
}
return [{
...src,
id: crypto.randomUUID(),
x: src.x + DUPLICATE_OFFSET_DOTS,
y: src.y + DUPLICATE_OFFSET_DOTS,
props: { ...src.props },
} as LabelObject];
});
}

/** Subset of LabelState that paged mutators read. Slices that touch pages
* via `set((state) => updateCurrentObjects(state, fn))` use this shape. */
export interface PageState {
pages: Page[];
currentPageIndex: number;
}

export function updateCurrentObjects(
state: PageState,
fn: (objects: LabelObject[]) => LabelObject[]
): { pages: Page[] } {
return {
pages: state.pages.map((p, i) =>
i === state.currentPageIndex ? { ...p, objects: fn(p.objects) } : p
),
};
}
45 changes: 45 additions & 0 deletions src/store/labelStore.selectors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { LabelObject } from '../types/Group';
import { isDefaultLabelaryHost } from '../lib/labelary';
import type { CsvDataset } from './slices/csvSlice';
import type { CsvMapping } from '../types/Variable';
import type { LabelState } from './labelStore';
import type { PageState } from './labelStore.internals';

export const currentObjects = (state: PageState): LabelObject[] =>
state.pages[state.currentPageIndex]?.objects ?? [];

/** True when a Labelary network call is permitted: the gate is on AND
* the user has seen the privacy notice. UI buttons read
* `thirdParty.labelary` and `labelaryNoticeAcknowledged` separately
* to distinguish "hide" (gate off) from "show notice first". */
export const canCallLabelary = (s: LabelState): boolean =>
s.thirdParty.labelary && s.labelaryNoticeAcknowledged;

/** True when clicking a Labelary-backed action must first surface the
* privacy notice modal. A custom-host build implies the operator
* already controls the endpoint and no third-party disclosure is needed. */
export const selectLabelaryNoticeRequired = (s: LabelState): boolean =>
isDefaultLabelaryHost() && !s.labelaryNoticeAcknowledged;

/** True while the preview overlay is taking input away from the editor.
* Loading and active both qualify (loading blocks edits so the snapshot
* isn't already stale); error and idle return false so the user can
* keep working after dismissing a failure. */
export const selectPreviewLocksEditor = (s: LabelState): boolean =>
s.previewMode.status === 'loading' || s.previewMode.status === 'active';

/** The dataset + mapping pair that batch emit needs, or null when batch
* emit would produce nothing different from a single label. Requires a
* loaded CSV with rows and at least one mapped Variable. */
export const selectBatchInputs = (
s: LabelState,
): { dataset: CsvDataset; mapping: CsvMapping } | null => {
const { csvDataset, csvMapping } = s;
if (!csvDataset || csvDataset.rows.length === 0) return null;
if (!csvMapping || Object.keys(csvMapping.bindings).length === 0) return null;
return { dataset: csvDataset, mapping: csvMapping };
};

/** Boolean form of {@link selectBatchInputs}. */
export const selectCanBatchExport = (s: LabelState): boolean =>
selectBatchInputs(s) !== null;
67 changes: 67 additions & 0 deletions src/store/labelStore.tripwire.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe, it, expect } from 'vitest';
import { useLabelStore, persistPartialize, temporalPartialize } from './labelStore';

/** Snapshot the stable surface every slice extraction must preserve. */

const EXPECTED_PERSIST_KEYS = [
'canvasSettings',
'csvMapping',
'currentPageIndex',
'label',
'labelaryNoticeAcknowledged',
'locale',
'pages',
'printerProfile',
'theme',
'variables',
].sort();

const EXPECTED_TEMPORAL_KEYS = [
'csvMapping',
'currentPageIndex',
'label',
'pages',
'printerProfile',
'variables',
].sort();

const EXPECTED_PUBLIC_SELECTORS = [
'canCallLabelary',
'currentObjects',
'getCurrentObjects',
'selectBatchInputs',
'selectCanBatchExport',
'selectLabelaryNoticeRequired',
'selectPreviewLocksEditor',
'useCurrentObjects',
'useHistory',
];

describe('labelStore tripwires', () => {
it('persist partialize covers exactly the agreed key set', () => {
const subset = persistPartialize(useLabelStore.getState());
expect(Object.keys(subset).sort()).toEqual(EXPECTED_PERSIST_KEYS);
});

it('temporal partialize covers exactly the agreed key set', () => {
const subset = temporalPartialize(useLabelStore.getState());
expect(Object.keys(subset).sort()).toEqual(EXPECTED_TEMPORAL_KEYS);
});

it('public state selectors stay exported and callable', async () => {
const mod = await import('./labelStore');
for (const name of EXPECTED_PUBLIC_SELECTORS) {
expect(mod, `selector "${name}"`).toHaveProperty(name);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect(typeof (mod as any)[name], `selector "${name}" type`).toBe('function');
}
// Spot-call each against the current state to confirm signature compat.
const state = useLabelStore.getState();
expect(mod.currentObjects(state)).toBeDefined();
expect(typeof mod.canCallLabelary(state)).toBe('boolean');
expect(typeof mod.selectLabelaryNoticeRequired(state)).toBe('boolean');
expect(typeof mod.selectPreviewLocksEditor(state)).toBe('boolean');
expect(mod.selectBatchInputs(state)).toBeDefined();
expect(typeof mod.selectCanBatchExport(state)).toBe('boolean');
});
});
Loading