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
4 changes: 2 additions & 2 deletions src/components/Canvas/KonvaObject.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ import { reverseShapeStyle } from "./reverseShapeStyle";
import { useColorScheme } from "../../lib/useColorScheme";
import { useLabelStore } from "../../store/labelStore";
import { applyBindingToObject, buildActiveCsvRow } from "../../lib/variableBinding";
import { ZPL_FONT_HEIGHT_TO_CSS_RATIO } from "./textPositionTransforms";
import { getTextRenderMetrics } from "./textRenderMetrics";
import { ZPL_FONT_HEIGHT_TO_CSS_RATIO } from "../../lib/labelGeometry/textPositionTransforms";
import { getTextRenderMetrics } from "../../lib/labelGeometry/textRenderMetrics";
import { selectionHandlers, type KonvaObjectProps } from "./konvaObjectProps";
import { DEFAULT_GS_SYMBOL_META, GS_SYMBOLS } from "../../registry/symbol";
import { GS_SYMBOL_PATHS, GS_VECTOR_CODES, type GsVectorCode } from "../../registry/gsSymbolPaths";
Expand Down
2 changes: 1 addition & 1 deletion src/components/Canvas/bwipHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ export { eanCheckDigit, upceCheckDigit } from "../../lib/barcodeCheckDigits";
* Code B here keeps the designer's module count in sync with Labelary.
* Returns null for characters outside Code B range (ASCII 32–126).
*/
export function toCode128BRaw(text: string): string | null {
function toCode128BRaw(text: string): string | null {
if (!text) return null;
const parts = ["^104"]; // Start B
for (const ch of text) {
Expand Down
57 changes: 11 additions & 46 deletions src/components/PrinterSettings/PrinterSettingsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,8 @@ import { PrintQualityTab } from "./PrintQualityTab";

type TopTabId = 'perLabel' | 'setupScript';

/** Sub-tab → top-tab assignment. `satisfies` makes TypeScript flag
* any `PrinterSettingsTab` literal that is added to the union but
* forgotten here — without this, a missing tab would silently
* default at runtime. This is the source of truth; rail groups
* below are derived from it. */
/** Sub-tab → top-tab. `satisfies` flags any PrinterSettingsTab union
* literal that gets added but forgotten here at compile time. */
const TOP_TAB_OF = {
mediaFeed: 'perLabel',
printQuality: 'perLabel',
Expand All @@ -35,12 +32,6 @@ const TOP_TAB_LABEL_KEY = {
setupScript: 'railGroupSetupScript',
} as const satisfies Record<TopTabId, 'railGroupPerLabel' | 'railGroupSetupScript'>;

/** Rail-tab order per top-tab, derived from TOP_TAB_OF so the source
* of truth stays the single literal map above. Iteration order over
* TOP_TAB_OF entries is insertion order, which is the desired rail
* order — keep entries in the map in the order the rail should show
* them. Seed is derived from TOP_TAB_ORDER so a new TopTabId added
* there can't silently produce `undefined.push(...)` at runtime. */
const TABS_BY_TOP_TAB: Record<TopTabId, readonly PrinterSettingsTab[]> = (() => {
const acc = Object.fromEntries(
TOP_TAB_ORDER.map((id) => [id, [] as PrinterSettingsTab[]]),
Expand All @@ -51,9 +42,7 @@ const TABS_BY_TOP_TAB: Record<TopTabId, readonly PrinterSettingsTab[]> = (() =>
return acc;
})();

/** Per-tab content registry. Tabs absent here render as disabled
* WIP rail entries; the rail prevents selection so the modal
* never has to handle a missing-component branch. */
/** Tabs absent here render as disabled WIP rail entries. */
const TAB_COMPONENTS: Partial<Record<PrinterSettingsTab, FC>> = {
mediaFeed: MediaFeedTab,
printQuality: PrintQualityTab,
Expand All @@ -62,21 +51,10 @@ const TAB_COMPONENTS: Partial<Record<PrinterSettingsTab, FC>> = {
identity: IdentityTab,
};

/** Modal-box class kept as a constant rather than inline so the
* width / shadow tuning is named and stays consistent if other
* modals adopt the same shell. Fixed height (h-[600px]) so the
* modal does not resize when switching tabs of different field
* counts; the docked preview anchors at the bottom. `max-h-[85vh]`
* is a fallback cap for very short viewports. */
const MODAL_BOX_CLS =
"bg-surface border border-border rounded-lg shadow-2xl " +
"w-[720px] max-w-[95vw] h-[600px] max-h-[85vh] flex flex-col overflow-hidden";

/** Docked preview height. 160px = ~9 lines of mono ZPL at 13px/1.45
* plus the header strip and padding. Fixed (not collapsible) so the
* modal's vertical rhythm stays identical across top-tabs; the empty
* per-label state keeps the same height as the populated setup-script
* state. */
const PREVIEW_HEIGHT = "h-40";

export function PrinterSettingsModal() {
Expand Down Expand Up @@ -126,11 +104,8 @@ export function PrinterSettingsModal() {
</header>

<div
// Plain nav, not tablist. The rail below uses `aria-current`
// (page-style) navigation buttons; using `role="tablist"`
// here as well would force the rail into tab semantics too,
// and a single page with two different "tab" paradigms is
// worse than two plain navs.
// Plain nav (not role=tablist) so the strip + rail share one
// a11y paradigm.
className="px-5 pt-3 border-b border-border flex gap-1"
aria-label={t.printerSettings.title}
>
Expand Down Expand Up @@ -210,10 +185,8 @@ export function PrinterSettingsModal() {
);
}

/** Docked preview pane at the bottom of the modal, rendered only on
* the Setup-Script top-tab. Shows the freshly-generated script plus
* Copy + Send actions. Per-Label has no preview here because per-
* label ZPL still lives in the editor's main output panel. */
/** Docked preview pane on the Setup-Script top-tab. Per-Label has no
* preview here — its ZPL still lives in the editor's main output. */
function PreviewDock({
setupScript,
printerProfile,
Expand All @@ -226,8 +199,7 @@ function PreviewDock({
onSend: () => void;
}) {
const t = useT();
// Live-clock mode (useCurrentTimeForClock) needs the payload
// freshly generated at click-time, not the closured snapshot.
// Live-clock mode needs the payload generated at click-time.
const { copy, copied } = useCopyToClipboard(() => generateSetupScript(printerProfile));

const hasScript = !!setupScript;
Expand All @@ -239,21 +211,14 @@ function PreviewDock({
{t.printerSettings.previewHeading}
</span>
<div className="flex items-center gap-3">
{/* Clear sits on the destructive side of a divider so the
red-hover affordance + spatial separation make a mis-
click into the adjacent primary (Send) much less
likely. Same muted resting state as Copy keeps the
header visually quiet until hover. */}
{/* Clear: separated from Send by a divider; red hover +
red focus-visible ring so destructive intent is visible
to both pointer and keyboard users. */}
<button
type="button"
onClick={onClear}
disabled={!hasScript}
title={t.printerSettings.previewClear}
// Destructive intent: red hover AND a red focus ring so
// keyboard users see the same warning the mouse hover
// gives. Without the ring keyboard-tab onto Clear shows
// the default browser focus style — identical to Copy /
// Send next to it.
className="flex items-center gap-1 font-mono text-[10px] text-muted hover:text-red-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/40 rounded disabled:opacity-25 disabled:cursor-not-allowed transition-colors"
>
<TrashIcon className="w-4 h-4" />
Expand Down
64 changes: 35 additions & 29 deletions src/components/Properties/TemplateContentInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,22 @@ import {
} from "../../lib/contentEditableCaret";
import type { Variable } from "../../types/Variable";

/** Read a caret offset relative to `editor` from the live Selection's
* anchor- or focus-node, returning `null` when the selection isn't
* inside the editor. Encapsulates the "Node | null narrowing after
* editor.contains()" dance that TypeScript can't follow. */
function caretOffsetIn(
editor: HTMLElement,
sel: Selection | null,
which: "anchor" | "focus" = "anchor",
): number | null {
if (!sel || sel.rangeCount === 0) return null;
const node = which === "anchor" ? sel.anchorNode : sel.focusNode;
const offset = which === "anchor" ? sel.anchorOffset : sel.focusOffset;
if (!node || !editor.contains(node)) return null;
return getCaretOffset(editor, node, offset);
}

interface Props {
value: string;
onChange: (next: string) => void;
Expand Down Expand Up @@ -150,19 +166,18 @@ export function TemplateContentInput({
if (editor.innerHTML === desired) return;
}
// Save caret offset within the editor before rebuild.
const sel = window.getSelection();
let caretOffset: number | null = null;
if (sel && sel.rangeCount > 0 && editor.contains(sel.anchorNode)) {
caretOffset = getCaretOffset(editor, sel.anchorNode!, sel.anchorOffset);
}
const caretOffset = caretOffsetIn(editor, window.getSelection());
editor.innerHTML = segmentsToHTML(segments);
if (caretOffset !== null && document.activeElement === editor) {
const pos = findCaretPosition(editor, caretOffset);
const range = document.createRange();
range.setStart(pos.node, pos.offset);
range.collapse(true);
sel!.removeAllRanges();
sel!.addRange(range);
const selAfter = window.getSelection();
if (selAfter) {
const pos = findCaretPosition(editor, caretOffset);
const range = document.createRange();
range.setStart(pos.node, pos.offset);
range.collapse(true);
selAfter.removeAllRanges();
selAfter.addRange(range);
Comment thread
u8array marked this conversation as resolved.
}
}
}, [value, segments]);

Expand Down Expand Up @@ -234,22 +249,17 @@ export function TemplateContentInput({

const getCaretOffsetInEditor = (): number => {
const editor = editorRef.current;
const sel = window.getSelection();
if (!editor || !sel || sel.rangeCount === 0) return value.length;
if (!editor.contains(sel.anchorNode)) return value.length;
return getCaretOffset(editor, sel.anchorNode!, sel.anchorOffset);
if (!editor) return value.length;
return caretOffsetIn(editor, window.getSelection()) ?? value.length;
};

const insertMarker = (markerBody: string) => {
const editor = editorRef.current;
const marker = `«${markerBody}»`;
const start = getCaretOffsetInEditor();
const end = (() => {
const sel = window.getSelection();
if (!editor || !sel || sel.rangeCount === 0) return start;
if (!editor.contains(sel.focusNode)) return start;
return getCaretOffset(editor, sel.focusNode!, sel.focusOffset);
})();
const end = editor
? (caretOffsetIn(editor, window.getSelection(), "focus") ?? start)
: start;
const lo = Math.min(start, end);
const hi = Math.max(start, end);
const next = value.slice(0, lo) + marker + value.slice(hi);
Expand Down Expand Up @@ -281,12 +291,8 @@ export function TemplateContentInput({
* to caret at end-of-value for off-editor selections. */
const selectionRange = (fallbackLen: number) => {
const sel = window.getSelection();
const start = sel && editor.contains(sel.anchorNode)
? getCaretOffset(editor, sel.anchorNode!, sel.anchorOffset)
: fallbackLen;
const end = sel && editor.contains(sel.focusNode)
? getCaretOffset(editor, sel.focusNode!, sel.focusOffset)
: start;
const start = caretOffsetIn(editor, sel, "anchor") ?? fallbackLen;
const end = caretOffsetIn(editor, sel, "focus") ?? start;
return { lo: Math.min(start, end), hi: Math.max(start, end) };
};
/** Browser-native undo/redo route through zundo (global Ctrl+Z),
Expand All @@ -303,7 +309,8 @@ export function TemplateContentInput({
const { value } = stateRef.current;
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) return false;
const caret = getCaretOffset(editor, sel.anchorNode!, sel.anchorOffset);
const caret = caretOffsetIn(editor, sel);
if (caret === null) return false;
const m = findAtomicMarker(value, caret, direction);
if (!m) return false;
commitInline(value.slice(0, m.start) + value.slice(m.end), m.start);
Expand Down Expand Up @@ -366,7 +373,6 @@ export function TemplateContentInput({
editor.removeEventListener("beforeinput", handler);
editor.removeEventListener("paste", handlePaste);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

const onInput = () => {
Expand Down
2 changes: 1 addition & 1 deletion src/components/Properties/useLayerDnd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { INDENT_STEP } from './layerLayout';

/** Sentinel container id for the top-level objects list. Group containers
* use the group's own id, so the root needs a value that can't collide. */
export const ROOT_CONTAINER = '__root__';
const ROOT_CONTAINER = '__root__';

/** Pixel bias subtracted from the cursor X before quantising to depth so a
* user has to drag a little before the target depth changes. Tuned to feel
Expand Down
9 changes: 6 additions & 3 deletions src/lib/contentEditableCaret.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@ export function getCaretOffset(root: Node, node: Node, offset: number): number {
}
// Element node: count text from the first `offset` children, then stop.
for (let i = 0; i < offset && i < n.childNodes.length; i += 1) {
visit(n.childNodes[i]!);
const child = n.childNodes[i];
if (child) visit(child);
}
found = true;
return;
Expand Down Expand Up @@ -114,7 +115,8 @@ export function findCaretPosition(
if (el.tagName === "BR") {
if (remaining === 0) {
// Caret right before the <br>: place it in the parent at the BR's index.
const parent = el.parentNode!;
const parent = el.parentNode;
if (!parent) return;
const idx = Array.prototype.indexOf.call(parent.childNodes, el);
result = { node: parent, offset: idx };
return;
Expand All @@ -137,7 +139,8 @@ export function findCaretPosition(
result = { node: next, offset: 0 };
return;
}
const parent = el.parentNode!;
const parent = el.parentNode;
if (!parent) return;
const idx = Array.prototype.indexOf.call(parent.childNodes, el);
result = { node: parent, offset: idx + 1 };
return;
Expand Down
50 changes: 29 additions & 21 deletions src/lib/designFile.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { parseDesignFile, serializeDesign } from './designFile';
import { parseDesignFile, serializeDesign, CURRENT_DESIGN_SCHEMA_VERSION } from './designFile';
import type { LabelObject } from '../types/Group';
import type { Variable } from '../types/Variable';

Expand All @@ -25,6 +25,15 @@ describe('serializeDesign', () => {
expect(parsed.pages[0]?.objects).toHaveLength(1);
});

it('writes the current schemaVersion field', () => {
const json = serializeDesign(
{ widthMm: 100, heightMm: 60, dpmm: 8 },
[{ objects: SAMPLE_OBJECTS }],
);
const parsed = JSON.parse(json) as { schemaVersion: number };
expect(parsed.schemaVersion).toBe(CURRENT_DESIGN_SCHEMA_VERSION);
});

it('serializes multiple pages', () => {
const json = serializeDesign(
{ widthMm: 100, heightMm: 60, dpmm: 8 },
Expand All @@ -48,16 +57,27 @@ describe('parseDesignFile', () => {
expect(result.value.pages[0]?.objects).toHaveLength(1);
});

it('migrates legacy { label, objects } shape into a single page', () => {
const legacyJson = JSON.stringify({
it('rejects files without a schemaVersion field', () => {
const json = JSON.stringify({
label: { widthMm: 100, heightMm: 60, dpmm: 8 },
objects: SAMPLE_OBJECTS,
pages: [{ objects: SAMPLE_OBJECTS }],
});
const result = parseDesignFile(legacyJson);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.pages).toHaveLength(1);
expect(result.value.pages[0]?.objects).toHaveLength(1);
const result = parseDesignFile(json);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error).toBe('invalid_schema');
});

it('rejects an unknown schemaVersion as invalid_schema', () => {
const futureJson = JSON.stringify({
schemaVersion: 999,
label: { widthMm: 100, heightMm: 60, dpmm: 8 },
pages: [{ objects: SAMPLE_OBJECTS }],
});
const result = parseDesignFile(futureJson);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error).toBe('invalid_schema');
});

it('returns parse_error for invalid JSON', () => {
Expand Down Expand Up @@ -195,18 +215,6 @@ describe('parseDesignFile', () => {
expect(result.value.variables).toEqual([]);
});

it('legacy { label, objects } shape loads with empty variables', () => {
const legacyJson = JSON.stringify({
label: { widthMm: 100, heightMm: 60, dpmm: 8 },
objects: SAMPLE_OBJECTS,
});
const result = parseDesignFile(legacyJson);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.variables).toEqual([]);
expect(result.value.csvMapping).toBeNull();
});

it('roundtrips csvMapping when present', () => {
const mapping = {
bindings: { v1: 'SKU', v2: 'Quantity' },
Expand Down
Loading