Skip to content

Commit 9175c4c

Browse files
authored
Merge pull request #114 from u8array/refactor/labelstore-decomposition
Refactor/labelstore decomposition
2 parents f9077d5 + d3df118 commit 9175c4c

14 files changed

Lines changed: 1499 additions & 1406 deletions

‎src/components/PrinterSettings/PrinterSettingsModal.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import { CheckIcon, ClipboardDocumentIcon, TrashIcon, XMarkIcon } from "@heroico
33
import { useCopyToClipboard } from "../../lib/useCopyToClipboard";
44
import { useT } from "../../lib/useT";
55
import { generateSetupScript } from "../../lib/zplSetupScript";
6-
import { useLabelStore, type PrinterSettingsTab } from "../../store/labelStore";
6+
import { useLabelStore } from "../../store/labelStore";
7+
import type { PrinterSettingsTab } from "../../store/slices/uiSlice";
78
import type { PrinterProfile } from "../../types/PrinterProfile";
89
import { DialogShell } from "../ui/DialogShell";
910
import { ZplLine } from "../Output/ZplLine";

‎src/store/labelStore.internals.ts‎

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import { isGroup, type LabelObject, type Page } from '../types/Group';
2+
import type { ObjectChanges } from '../types/LabelObject';
3+
import type { LocaleCode } from '../locales';
4+
import { locales } from '../locales';
5+
import { renameTemplateMarker } from '../lib/fnTemplate';
6+
import { getObjectStringContent } from '../lib/variableBinding';
7+
import { getEntry } from '../registry';
8+
9+
/** Meta fields that remain editable on a locked object so the user can
10+
* release the lock or annotate without unlocking first. Everything else
11+
* (position, props, rotation, positionType) is blocked. */
12+
const LOCK_BYPASS_KEYS = new Set(['locked', 'visible', 'includeInExport', 'comment', 'name']);
13+
14+
export function isLockBypass(changes: ObjectChanges): boolean {
15+
const keys = Object.keys(changes);
16+
return keys.length > 0 && keys.every((k) => LOCK_BYPASS_KEYS.has(k));
17+
}
18+
19+
/** Apply `renameTemplateMarker` to every leaf's `content` in a subtree.
20+
* Identity-preserving: returns the same array (and same node refs)
21+
* when no markers needed rewriting, so React memoisation downstream
22+
* stays effective for the common case where the rename touched no
23+
* templates. */
24+
export function rewriteTemplateMarkers(
25+
objects: LabelObject[],
26+
oldName: string,
27+
newName: string,
28+
): LabelObject[] {
29+
let changed = false;
30+
const next = objects.map((obj) => {
31+
if (isGroup(obj)) {
32+
const nextChildren = rewriteTemplateMarkers(obj.children, oldName, newName);
33+
if (nextChildren === obj.children) return obj;
34+
changed = true;
35+
return { ...obj, children: nextChildren };
36+
}
37+
const content = getObjectStringContent(obj);
38+
if (content === undefined) return obj;
39+
const renamed = renameTemplateMarker(content, oldName, newName);
40+
if (renamed === content) return obj;
41+
changed = true;
42+
const props = (obj as { props: object }).props;
43+
return { ...obj, props: { ...props, content: renamed } } as LabelObject;
44+
});
45+
return changed ? next : objects;
46+
}
47+
48+
export function applyObjectChanges(
49+
obj: LabelObject,
50+
changes: ObjectChanges,
51+
ancestorLocked = false,
52+
): LabelObject {
53+
// Lock cascades from any ancestor group: a leaf inside a locked group
54+
// accepts only bypass keys (locked / visible / includeInExport /
55+
// comment / name) so the user can still toggle visibility or release
56+
// the lock from the layers panel. Load-bearing — `expandSelection`-
57+
// driven callers (arrow-key nudges, shift-multi-drag) target the
58+
// group's leaf children directly and would otherwise sidestep the
59+
// group's own `locked` flag.
60+
if ((obj.locked || ancestorLocked) && !isLockBypass(changes)) return obj;
61+
if (isGroup(obj)) {
62+
// Groups have no registry entry (no normalize hook) and no props to
63+
// merge — apply top-level changes only. Children stay untouched;
64+
// tree updates reach them through their own mapObjectById call.
65+
return { ...obj, ...changes } as LabelObject;
66+
}
67+
const normalize = getEntry(obj.type)?.normalizeChanges;
68+
const normalized = normalize ? normalize(obj, changes) : changes;
69+
return {
70+
...obj,
71+
...normalized,
72+
props: normalized.props ? Object.assign({}, obj.props, normalized.props) : obj.props,
73+
} as LabelObject;
74+
}
75+
76+
/** Immutable insert-at-index that clamps `idx` into the array's bounds.
77+
* Used by reparent flows to splice a node into a children list or the
78+
* top-level list without crashing on out-of-range indices coming from
79+
* ephemeral drag state. */
80+
export function insertAt<T>(arr: readonly T[], idx: number, item: T): T[] {
81+
const clamped = Math.max(0, Math.min(idx, arr.length));
82+
return [...arr.slice(0, clamped), item, ...arr.slice(clamped)];
83+
}
84+
85+
export function detectLocale(): LocaleCode {
86+
const lang = navigator.language.slice(0, 2).toLowerCase();
87+
return (lang in locales ? lang : 'en') as LocaleCode;
88+
}
89+
90+
export function detectInitialTheme(): 'light' | 'dark' {
91+
return window.matchMedia('(prefers-color-scheme: dark)').matches
92+
? 'dark'
93+
: 'light';
94+
}
95+
96+
/** Build-time defaults for third-party services. Vite injects VITE_THIRD_PARTY_*
97+
* env values; missing values fall back to enabled. Tauri/Docker builds can flip
98+
* the default by setting VITE_THIRD_PARTY_LABELARY=false in their build env. */
99+
export function thirdPartyDefaults(): { labelary: boolean } {
100+
return {
101+
labelary: import.meta.env.VITE_THIRD_PARTY_LABELARY !== 'false',
102+
};
103+
}
104+
105+
/** Base offset (in dots) used to stagger duplicate / paste copies so they
106+
* don't sit exactly on top of the source. 20 dots ≈ 2.5 mm at 8dpmm. */
107+
export const DUPLICATE_OFFSET_DOTS = 20;
108+
109+
/** Deep-clone a children list with fresh ids and shallow-cloned props on
110+
* every leaf. Recurses through nested groups. */
111+
export function cloneChildrenFresh(children: LabelObject[]): LabelObject[] {
112+
return children.map((c) => {
113+
if (isGroup(c)) {
114+
return {
115+
...c,
116+
id: crypto.randomUUID(),
117+
children: cloneChildrenFresh(c.children),
118+
};
119+
}
120+
return {
121+
...c,
122+
id: crypto.randomUUID(),
123+
props: { ...c.props },
124+
} as LabelObject;
125+
});
126+
}
127+
128+
/** Build offset copies of objects identified by `ids`. Missing ids are
129+
* silently dropped. Props are shallow-cloned to avoid sharing the
130+
* reference with the original. */
131+
export function buildOffsetCopies(objs: LabelObject[], ids: readonly string[]): LabelObject[] {
132+
const byId = new Map(objs.map((o) => [o.id, o]));
133+
return ids.flatMap((id) => {
134+
const src = byId.get(id);
135+
if (!src) return [];
136+
if (isGroup(src)) {
137+
return [{
138+
...src,
139+
id: crypto.randomUUID(),
140+
x: src.x + DUPLICATE_OFFSET_DOTS,
141+
y: src.y + DUPLICATE_OFFSET_DOTS,
142+
children: cloneChildrenFresh(src.children),
143+
}];
144+
}
145+
return [{
146+
...src,
147+
id: crypto.randomUUID(),
148+
x: src.x + DUPLICATE_OFFSET_DOTS,
149+
y: src.y + DUPLICATE_OFFSET_DOTS,
150+
props: { ...src.props },
151+
} as LabelObject];
152+
});
153+
}
154+
155+
/** Subset of LabelState that paged mutators read. Slices that touch pages
156+
* via `set((state) => updateCurrentObjects(state, fn))` use this shape. */
157+
export interface PageState {
158+
pages: Page[];
159+
currentPageIndex: number;
160+
}
161+
162+
export function updateCurrentObjects(
163+
state: PageState,
164+
fn: (objects: LabelObject[]) => LabelObject[]
165+
): { pages: Page[] } {
166+
return {
167+
pages: state.pages.map((p, i) =>
168+
i === state.currentPageIndex ? { ...p, objects: fn(p.objects) } : p
169+
),
170+
};
171+
}

‎src/store/labelStore.selectors.ts‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import type { LabelObject } from '../types/Group';
2+
import { isDefaultLabelaryHost } from '../lib/labelary';
3+
import type { CsvDataset } from './slices/csvSlice';
4+
import type { CsvMapping } from '../types/Variable';
5+
import type { LabelState } from './labelStore';
6+
import type { PageState } from './labelStore.internals';
7+
8+
export const currentObjects = (state: PageState): LabelObject[] =>
9+
state.pages[state.currentPageIndex]?.objects ?? [];
10+
11+
/** True when a Labelary network call is permitted: the gate is on AND
12+
* the user has seen the privacy notice. UI buttons read
13+
* `thirdParty.labelary` and `labelaryNoticeAcknowledged` separately
14+
* to distinguish "hide" (gate off) from "show notice first". */
15+
export const canCallLabelary = (s: LabelState): boolean =>
16+
s.thirdParty.labelary && s.labelaryNoticeAcknowledged;
17+
18+
/** True when clicking a Labelary-backed action must first surface the
19+
* privacy notice modal. A custom-host build implies the operator
20+
* already controls the endpoint and no third-party disclosure is needed. */
21+
export const selectLabelaryNoticeRequired = (s: LabelState): boolean =>
22+
isDefaultLabelaryHost() && !s.labelaryNoticeAcknowledged;
23+
24+
/** True while the preview overlay is taking input away from the editor.
25+
* Loading and active both qualify (loading blocks edits so the snapshot
26+
* isn't already stale); error and idle return false so the user can
27+
* keep working after dismissing a failure. */
28+
export const selectPreviewLocksEditor = (s: LabelState): boolean =>
29+
s.previewMode.status === 'loading' || s.previewMode.status === 'active';
30+
31+
/** The dataset + mapping pair that batch emit needs, or null when batch
32+
* emit would produce nothing different from a single label. Requires a
33+
* loaded CSV with rows and at least one mapped Variable. */
34+
export const selectBatchInputs = (
35+
s: LabelState,
36+
): { dataset: CsvDataset; mapping: CsvMapping } | null => {
37+
const { csvDataset, csvMapping } = s;
38+
if (!csvDataset || csvDataset.rows.length === 0) return null;
39+
if (!csvMapping || Object.keys(csvMapping.bindings).length === 0) return null;
40+
return { dataset: csvDataset, mapping: csvMapping };
41+
};
42+
43+
/** Boolean form of {@link selectBatchInputs}. */
44+
export const selectCanBatchExport = (s: LabelState): boolean =>
45+
selectBatchInputs(s) !== null;
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { useLabelStore, persistPartialize, temporalPartialize } from './labelStore';
3+
4+
/** Snapshot the stable surface every slice extraction must preserve. */
5+
6+
const EXPECTED_PERSIST_KEYS = [
7+
'canvasSettings',
8+
'csvMapping',
9+
'currentPageIndex',
10+
'label',
11+
'labelaryNoticeAcknowledged',
12+
'locale',
13+
'pages',
14+
'printerProfile',
15+
'theme',
16+
'variables',
17+
].sort();
18+
19+
const EXPECTED_TEMPORAL_KEYS = [
20+
'csvMapping',
21+
'currentPageIndex',
22+
'label',
23+
'pages',
24+
'printerProfile',
25+
'variables',
26+
].sort();
27+
28+
const EXPECTED_PUBLIC_SELECTORS = [
29+
'canCallLabelary',
30+
'currentObjects',
31+
'getCurrentObjects',
32+
'selectBatchInputs',
33+
'selectCanBatchExport',
34+
'selectLabelaryNoticeRequired',
35+
'selectPreviewLocksEditor',
36+
'useCurrentObjects',
37+
'useHistory',
38+
];
39+
40+
describe('labelStore tripwires', () => {
41+
it('persist partialize covers exactly the agreed key set', () => {
42+
const subset = persistPartialize(useLabelStore.getState());
43+
expect(Object.keys(subset).sort()).toEqual(EXPECTED_PERSIST_KEYS);
44+
});
45+
46+
it('temporal partialize covers exactly the agreed key set', () => {
47+
const subset = temporalPartialize(useLabelStore.getState());
48+
expect(Object.keys(subset).sort()).toEqual(EXPECTED_TEMPORAL_KEYS);
49+
});
50+
51+
it('public state selectors stay exported and callable', async () => {
52+
const mod = await import('./labelStore');
53+
for (const name of EXPECTED_PUBLIC_SELECTORS) {
54+
expect(mod, `selector "${name}"`).toHaveProperty(name);
55+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
56+
expect(typeof (mod as any)[name], `selector "${name}" type`).toBe('function');
57+
}
58+
// Spot-call each against the current state to confirm signature compat.
59+
const state = useLabelStore.getState();
60+
expect(mod.currentObjects(state)).toBeDefined();
61+
expect(typeof mod.canCallLabelary(state)).toBe('boolean');
62+
expect(typeof mod.selectLabelaryNoticeRequired(state)).toBe('boolean');
63+
expect(typeof mod.selectPreviewLocksEditor(state)).toBe('boolean');
64+
expect(mod.selectBatchInputs(state)).toBeDefined();
65+
expect(typeof mod.selectCanBatchExport(state)).toBe('boolean');
66+
});
67+
});

0 commit comments

Comments
 (0)