Skip to content

Commit 032de2c

Browse files
committed
refactor(zpl): apply ^FP UI review findings
Frontend-design pass on the ^FP settings panel addresses the six concrete findings: - Position: disclosure now sits between RotationSelect and the Invert toggle. ^FP and Rotation both govern glyph flow; Invert is a pixel effect, separate mental category. - Label de-jargonized across all 32 locales: "Field direction (CJK / RTL)" -> "Vertical / right-to-left text" (locale-native equivalents); the niche acronym CJK is dropped. - Char-gap input is disabled when V is selected because Labelary ignores the gap in vertical layout; a localised tooltip explains why so the disabled state is not silent. - The "H or gap=0 -> undefined" normalization moves into the text registry's new `normalizeChanges` hook so every prop-write path (panel, import, paste, future bulk edits) ends up with the same shape — round-trip stays clean without the panel having to know the schema invariant. - Direction icons follow Material Symbols' canonical `format_textdirection_*` idiom: short strokes representing text lines, plus a flow-direction arrow (→ for H, ↓ for V, ← for R). - Char-gap NumberInput is full-width again, dropping the unbalanced half-width wrapper that read as cut-off without a 2-col partner. Adds locale key `fpCharGapVHint` for the V-disabled tooltip.
1 parent 075a1e6 commit 032de2c

38 files changed

Lines changed: 215 additions & 124 deletions

‎src/components/Canvas/KonvaObject.tsx‎

Lines changed: 25 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { lookupBoundVariable, shouldShowFallbackTint } from "../../lib/variableB
44
import { BarcodeObject } from "./BarcodeObject";
55
import { LineObject } from "./LineObject";
66
import { ImageObject } from "./ImageObject";
7-
import Konva from "konva";
7+
import type Konva from "konva";
88
import { dotsToPx, pxToDots } from "../../lib/coordinates";
99
import { outlineInset } from "../../lib/shapeGeometry";
1010
import { reverseShapeStyle } from "./reverseShapeStyle";
@@ -85,20 +85,25 @@ function EllipseSelectionOverlay({
8585
);
8686
}
8787

88-
/** Shared `<Text>` props baked once per render of a text/serial field.
89-
* Every Text node in the field (single-line or per-^FB-line) uses
90-
* the same font / scale / rotation / fill / selection-stroke; only
91-
* `key`, `text`, and `(x, y)` vary across them. */
92-
/** Apply the canvas-side projection of ^FP to the raw text. V stacks
93-
* glyphs (one per line) by injecting line breaks; R reverses the
94-
* glyph order. H leaves the text untouched. Combined V+R is not a
95-
* spec form since ^FP's direction parameter is a single letter. */
88+
/** Canvas-side projection of ^FP. V+R never coincide: ^FP's
89+
* direction is a single letter. */
9690
function applyFpToContent(content: string, fpDirection: "H" | "V" | "R" | undefined): string {
9791
if (fpDirection === "V") return [...content].join("\n");
9892
if (fpDirection === "R") return [...content].reverse().join("");
9993
return content;
10094
}
10195

96+
/** Per Labelary pixel scan: a reversed block's left edge sits
97+
* `FPR_ANCHOR_LEFT_PADDING_RATIO × fontHeight` dots right of
98+
* `FO − totalAdvance`. PrintLab Bold advance widths are calibrated
99+
* to CG Triumvirate (assets/fonts/NOTICE.md), so a fontHeight
100+
* factor stays stable across sizes. */
101+
const FPR_ANCHOR_LEFT_PADDING_RATIO = 0.8;
102+
103+
/** Shared `<Text>` props baked once per render of a text/serial field.
104+
* Every Text node in the field (single-line or per-^FB-line) uses
105+
* the same font / scale / rotation / fill / selection-stroke; only
106+
* `key`, `text`, and `(x, y)` vary across them. */
102107
interface BaseTextProps {
103108
fontSize: number;
104109
fontFamily: string;
@@ -109,9 +114,8 @@ interface BaseTextProps {
109114
stroke: string | undefined;
110115
strokeWidth: number;
111116
letterSpacing?: number;
112-
/** Horizontal shift in CSS px applied to every glyph; non-zero for
113-
* ^FPR so the reversed block anchors with the original first data
114-
* character (visually rightmost) at ^FO. */
117+
/** Horizontal CSS-px shift applied to every glyph (used by the
118+
* ^FPR anchor offset). */
115119
offsetXPx?: number;
116120
}
117121

@@ -411,48 +415,20 @@ function KonvaObjectInner({
411415
B: 270,
412416
};
413417

414-
// ^FP: V stacks glyphs as newlines, R reverses order, gap maps to
415-
// letterSpacing in px. Labelary ignores gap in V mode, so we mirror
416-
// that (still round-tripping the value through ZPL emit).
418+
// Labelary ignores ^FP gap in V mode; mirror that on canvas while
419+
// still round-tripping the value through ZPL emit.
417420
const fpDirection = obj.type === "text" ? obj.props.fpDirection : undefined;
418421
const fpCharGap = obj.type === "text" ? obj.props.fpCharGap ?? 0 : 0;
419422
const fpContent = applyFpToContent(content, fpDirection);
420423
const fpLetterSpacingPx =
421424
fpDirection !== "V" && fpCharGap > 0 ? dotsToPx(fpCharGap, scale, dpmm) : 0;
422-
// R-mode: Labelary anchors the original first data character (now
423-
// displayed rightmost) at ^FO, so the reversed block extends LEFT
424-
// of FO. Shift by (total render width − last char's own width) so
425-
// the rightmost glyph's left edge lands at FO. Using the proper
426-
// per-char metric (not inkWidth/n) matters because A0 is
427-
// proportional: a wide 'A' or 'M' as the data's first char would
428-
// otherwise leave the block too far left.
429-
// R-mode: Labelary places the reversed block such that the
430-
// block's LEFT edge lands `FPR_ANCHOR_LEFT_PADDING_RATIO × fontH`
431-
// dots right of (FO − totalAdvance). Verified against a Labelary
432-
// pixel scan: at fontH=50 with content "ABC", the block's left
433-
// ink edge sits at FO − (totalAdvance − ratio×fontH) = FO−42 dot
434-
// when fullProbe.width() ≈ 82, matching Labelary's dot 208.
435-
// PrintLab ZPL Bold advance widths are calibrated to Zebra CG
436-
// Triumvirate (see assets/fonts/NOTICE.md), so the ratio scales
437-
// with fontH without re-tuning per font size.
438-
const FPR_ANCHOR_LEFT_PADDING_RATIO = 0.8;
439-
const fpShiftXPx = (() => {
440-
if (fpDirection !== "R" || content.length <= 1) return 0;
441-
const probeOpts = {
442-
fontSize: fontSizePx,
443-
fontFamily,
444-
fontStyle: "bold",
445-
scaleX: fontScaleX,
446-
letterSpacing: fpLetterSpacingPx,
447-
};
448-
const fullProbe = new Konva.Text({ ...probeOpts, text: fpContent });
449-
const anchorWidthPx = dotsToPx(
450-
obj.props.fontHeight * FPR_ANCHOR_LEFT_PADDING_RATIO,
451-
scale,
452-
dpmm,
453-
);
454-
return -(fullProbe.width() - anchorWidthPx);
455-
})();
425+
let fpShiftXPx = 0;
426+
if (fpDirection === "R" && content.length > 1) {
427+
const anchorPadDots = obj.props.fontHeight * FPR_ANCHOR_LEFT_PADDING_RATIO;
428+
const reservedPx = dotsToPx(textMetrics.inkWidthDots - anchorPadDots, scale, dpmm);
429+
const gapPx = (content.length - 1) * fpLetterSpacingPx;
430+
fpShiftXPx = -(reservedPx + gapPx);
431+
}
456432

457433
if (obj.type === "text" && obj.props.reverse) {
458434
// ZPL `^A0,h,w` lets `w` differ from `h` to stretch each glyph

‎src/components/Properties/FpSettings.tsx‎

Lines changed: 26 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -11,47 +11,48 @@ interface Props {
1111
onChange: (patch: Partial<TextProps>) => void;
1212
}
1313

14-
/** SVG glyphs for the three ^FP direction modes. `currentColor`
15-
* inherits the active/inactive button colour so theme + selection
16-
* state drive the icon without per-icon styling. */
14+
/** Mirrors Material Symbols' `format_textdirection_*` idiom; strokes
15+
* use `currentColor` so the active-state token drives them. */
1716
const ICONS: Record<FpDir, ReactNode> = {
1817
H: (
19-
<svg viewBox="0 0 16 12" className="w-4 h-3 mx-auto" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
20-
<line x1="2" y1="6" x2="14" y2="6" />
21-
<polyline points="11,3 14,6 11,9" fill="none" />
18+
<svg viewBox="0 0 16 12" className="w-4 h-3 mx-auto" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" fill="none" aria-hidden="true">
19+
<line x1="2" y1="2.5" x2="11" y2="2.5" />
20+
<line x1="2" y1="5.5" x2="9" y2="5.5" />
21+
<line x1="11" y1="9.5" x2="14.5" y2="9.5" />
22+
<polyline points="13,8 14.5,9.5 13,11" />
2223
</svg>
2324
),
2425
V: (
25-
<svg viewBox="0 0 16 12" className="w-4 h-3 mx-auto" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
26-
<line x1="8" y1="1" x2="8" y2="11" />
27-
<polyline points="5,8 8,11 11,8" fill="none" />
26+
<svg viewBox="0 0 16 12" className="w-4 h-3 mx-auto" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" fill="none" aria-hidden="true">
27+
<line x1="4.5" y1="2" x2="7.5" y2="2" />
28+
<line x1="4.5" y1="5" x2="7.5" y2="5" />
29+
<line x1="4.5" y1="8" x2="7.5" y2="8" />
30+
<line x1="11" y1="2" x2="11" y2="9" />
31+
<polyline points="9.5,7.5 11,9 12.5,7.5" />
2832
</svg>
2933
),
3034
R: (
31-
<svg viewBox="0 0 16 12" className="w-4 h-3 mx-auto" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
32-
<line x1="2" y1="6" x2="14" y2="6" />
33-
<polyline points="5,3 2,6 5,9" fill="none" />
35+
<svg viewBox="0 0 16 12" className="w-4 h-3 mx-auto" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" fill="none" aria-hidden="true">
36+
<line x1="5" y1="2.5" x2="14" y2="2.5" />
37+
<line x1="7" y1="5.5" x2="14" y2="5.5" />
38+
<line x1="1.5" y1="9.5" x2="5" y2="9.5" />
39+
<polyline points="3,8 1.5,9.5 3,11" />
3440
</svg>
3541
),
3642
};
3743

38-
/** ^FP direction + gap sub-panel: horizontal / vertical-stack /
39-
* reverse-order with an inter-character gap. Niche feature (CJK /
40-
* RTL typography); collapsed behind the parent panel's disclosure
41-
* so it doesn't crowd the common case. */
44+
/** ^FP direction + inter-character gap sub-panel. Normalising 'H'
45+
* / gap=0 back to undefined is the registry's job, not the
46+
* panel's. */
4247
export function FpSettings({ props: p, onChange }: Props) {
4348
const t = useT();
4449
const dir = p.fpDirection ?? "H";
50+
const gapDisabled = dir === "V";
4551
const items: { v: FpDir; title: string }[] = [
4652
{ v: "H", title: t.registry.text.fpDirH },
4753
{ v: "V", title: t.registry.text.fpDirV },
4854
{ v: "R", title: t.registry.text.fpDirR },
4955
];
50-
const onDirChange = (next: FpDir) => {
51-
// 'H' with zero gap is the unset state; drop the prop so the
52-
// generator stops emitting ^FP and the round-trip stays clean.
53-
onChange({ fpDirection: next === "H" ? undefined : next });
54-
};
5556
return (
5657
<div className="pl-3 border-l border-border flex flex-col gap-3">
5758
<div className="flex items-center justify-between gap-2">
@@ -66,7 +67,7 @@ export function FpSettings({ props: p, onChange }: Props) {
6667
title={title}
6768
aria-label={title}
6869
aria-pressed={active}
69-
onClick={() => onDirChange(v)}
70+
onClick={() => onChange({ fpDirection: v })}
7071
className={`w-7 h-6 flex items-center justify-center rounded border transition-colors ${
7172
active
7273
? "border-accent bg-accent-dim text-accent"
@@ -79,14 +80,13 @@ export function FpSettings({ props: p, onChange }: Props) {
7980
})}
8081
</div>
8182
</div>
82-
<div className="w-1/2 pr-1">
83+
<div title={gapDisabled ? t.registry.text.fpCharGapVHint : undefined}>
8384
<NumberInput
8485
label={t.registry.text.fpCharGap}
8586
value={p.fpCharGap ?? 0}
8687
min={0}
87-
onChange={(fpCharGap) =>
88-
onChange({ fpCharGap: fpCharGap > 0 ? fpCharGap : undefined })
89-
}
88+
disabled={gapDisabled}
89+
onChange={(fpCharGap) => onChange({ fpCharGap })}
9090
/>
9191
</div>
9292
</div>

‎src/lib/zplParser/handlers/fields.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -177,11 +177,12 @@ export function createFieldHandlers(
177177

178178
// ── Field-direction modifier (CJK vertical / RTL reverse) ─────────────
179179
// ^FP{d},{g} d ∈ H|V|R, g = inter-char gap in dots (default 0).
180-
// Per-field; ^FS resets back to H/0.
180+
// Per-field; ^FS resets back to H/0. Gap is clamped to the spec's
181+
// 0..9999 range so a malformed import can't corrupt the prop.
181182
FP(p) {
182183
const d = (p[0] ?? "H").toUpperCase();
183184
s.field.fpDirection = d === "V" || d === "R" ? d : "H";
184-
s.field.fpCharGap = int(p[1], 0);
185+
s.field.fpCharGap = Math.max(0, Math.min(9999, int(p[1], 0)));
185186
},
186187

187188
// ── Label home (origin offset) ────────────────────────────────────────

‎src/locales/ar.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -304,12 +304,13 @@ const ar = {
304304
rotationI: '180° (I)',
305305
rotationB: '270° (B)',
306306
reverse: 'Invert',
307-
fpAdvanced: 'اتجاه الحقل (CJK / RTL)',
307+
fpAdvanced: 'النص العمودي / من اليمين إلى اليسار',
308308
fpDirection: 'الاتجاه',
309309
fpDirH: 'H — أفقي',
310310
fpDirV: 'V — رأسي (تكديس)',
311311
fpDirR: 'R — معكوس (RTL)',
312312
fpCharGap: 'مسافة الحرف (نقاط)',
313+
fpCharGapVHint: 'يتم تجاهله في التخطيط العمودي (Labelary يطبق الفجوة على التدفق الأفقي فقط).',
313314
fieldBlock: 'كتلة نص',
314315
blockWidth: 'عرض الكتلة (نقاط)',
315316
blockLines: 'أقصى سطور',

‎src/locales/bg.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -304,12 +304,13 @@ const bg = {
304304
rotationI: '180° (I)',
305305
rotationB: '270° (B)',
306306
reverse: 'Invert',
307-
fpAdvanced: 'Посока на полето (CJK / RTL)',
307+
fpAdvanced: 'Вертикален / RTL текст',
308308
fpDirection: 'Посока',
309309
fpDirH: 'H — Хоризонтално',
310310
fpDirV: 'V — Вертикално (натрупано)',
311311
fpDirR: 'R — Обратно (RTL)',
312312
fpCharGap: 'Разстояние знаци (точки)',
313+
fpCharGapVHint: 'Игнорира се при вертикално оформление (Labelary прилага разстоянието само хоризонтално).',
313314
fieldBlock: 'Текстов блок',
314315
blockWidth: 'Ширина на блок (точки)',
315316
blockLines: 'Макс. редове',

‎src/locales/cs.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -304,12 +304,13 @@ const cs = {
304304
rotationI: '180° (I)',
305305
rotationB: '270° (B)',
306306
reverse: 'Invert',
307-
fpAdvanced: 'Směr pole (CJK / RTL)',
307+
fpAdvanced: 'Svislý / RTL text',
308308
fpDirection: 'Směr',
309309
fpDirH: 'H — Vodorovně',
310310
fpDirV: 'V — Svisle (stoh)',
311311
fpDirR: 'R — Obráceně (RTL)',
312312
fpCharGap: 'Mezera znaků (body)',
313+
fpCharGapVHint: 'Ignorováno ve svislém rozložení (Labelary aplikuje mezeru jen vodorovně).',
313314
fieldBlock: 'Blok textu',
314315
blockWidth: 'Šířka bloku (body)',
315316
blockLines: 'Max. řádků',

‎src/locales/da.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -304,12 +304,13 @@ const da = {
304304
rotationI: '180° (I)',
305305
rotationB: '270° (B)',
306306
reverse: 'Invert',
307-
fpAdvanced: 'Feltretning (CJK / RTL)',
307+
fpAdvanced: 'Lodret / højre-mod-venstre tekst',
308308
fpDirection: 'Retning',
309309
fpDirH: 'H — Vandret',
310310
fpDirV: 'V — Lodret (stak)',
311311
fpDirR: 'R — Omvendt (RTL)',
312312
fpCharGap: 'Tegnafstand (punkter)',
313+
fpCharGapVHint: 'Ignoreres i lodret layout (Labelary anvender kun mellemrum vandret).',
313314
fieldBlock: 'Tekstblok',
314315
blockWidth: 'Blokbredde (punkter)',
315316
blockLines: 'Maks. linjer',

‎src/locales/de.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -325,12 +325,13 @@ const de = {
325325
rotationI: '180° (I)',
326326
rotationB: '270° (B)',
327327
reverse: 'Invertieren',
328-
fpAdvanced: 'Feld-Richtung (CJK / RTL)',
328+
fpAdvanced: 'Vertikaler / RTL-Text',
329329
fpDirection: 'Richtung',
330330
fpDirH: 'H — Horizontal',
331331
fpDirV: 'V — Vertikal (Stapel)',
332332
fpDirR: 'R — Umgekehrt (RTL)',
333333
fpCharGap: 'Zeichenabstand (Punkte)',
334+
fpCharGapVHint: 'In vertikalem Layout ignoriert (Labelary wendet den Abstand nur horizontal an).',
334335
fieldBlock: 'Feldblock',
335336
blockWidth: 'Blockbreite (Punkte)',
336337
blockLines: 'Max. Zeilen',

‎src/locales/el.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -304,12 +304,13 @@ const el = {
304304
rotationI: '180° (I)',
305305
rotationB: '270° (B)',
306306
reverse: 'Invert',
307-
fpAdvanced: 'Κατεύθυνση πεδίου (CJK / RTL)',
307+
fpAdvanced: 'Κατακόρυφο / RTL κείμενο',
308308
fpDirection: 'Κατεύθυνση',
309309
fpDirH: 'H — Οριζόντια',
310310
fpDirV: 'V — Κατακόρυφα (στοίβα)',
311311
fpDirR: 'R — Αντίστροφα (RTL)',
312312
fpCharGap: 'Διάκενο χαρ. (κουκκίδες)',
313+
fpCharGapVHint: 'Παραβλέπεται σε κατακόρυφη διάταξη (το Labelary εφαρμόζει το κενό μόνο οριζόντια).',
313314
fieldBlock: 'Μπλοκ κειμένου',
314315
blockWidth: 'Πλάτος μπλοκ (κουκκίδες)',
315316
blockLines: 'Μέγ. γραμμές',

‎src/locales/en.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -325,12 +325,13 @@ const en = {
325325
rotationI: '180° (I)',
326326
rotationB: '270° (B)',
327327
reverse: 'Invert',
328-
fpAdvanced: 'Field direction (CJK / RTL)',
328+
fpAdvanced: 'Vertical / right-to-left text',
329329
fpDirection: 'Direction',
330330
fpDirH: 'H — Horizontal',
331331
fpDirV: 'V — Vertical (stack)',
332332
fpDirR: 'R — Reverse (RTL)',
333333
fpCharGap: 'Char gap (dots)',
334+
fpCharGapVHint: 'Ignored in vertical layout (Labelary applies gap to horizontal flow only).',
334335
fieldBlock: 'Field block',
335336
blockWidth: 'Block width (dots)',
336337
blockLines: 'Max lines',

0 commit comments

Comments
 (0)