From b5276f1ca91146edd52cd5afa8fc8e62a03a5a90 Mon Sep 17 00:00:00 2001 From: u8array Date: Sun, 24 May 2026 19:42:36 +0200 Subject: [PATCH] feat(ui): ^FB Block-Text UX revamp + editor orphan-marker hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface ^FB state and validation in the Properties panel, light up broken markers in the content editor, and visualise the wrap-width on the canvas. - **Justify** picker: 4 toggle buttons (left / centre / right / justified) replacing the legacy `` so picking takes a single click + * and the active mode is visually obvious at a glance. */ +export function JustifyButtons({ value, onChange }: Props) { + const t = useT(); + const items: { v: Justify; title: string }[] = [ + { v: "L", title: t.registry.text.justifyL }, + { v: "C", title: t.registry.text.justifyC }, + { v: "R", title: t.registry.text.justifyR }, + { v: "J", title: t.registry.text.justifyJ }, + ]; + return ( +
+ {items.map(({ v, title }) => { + const active = value === v; + return ( + + ); + })} +
+ ); +} diff --git a/src/components/Properties/TemplateContentInput.tsx b/src/components/Properties/TemplateContentInput.tsx index 3e69997d..9f612e78 100644 --- a/src/components/Properties/TemplateContentInput.tsx +++ b/src/components/Properties/TemplateContentInput.tsx @@ -2,6 +2,8 @@ import { useEffect, useMemo, useRef, useState, useLayoutEffect } from "react"; import { useT } from "../../lib/useT"; import { useLabelStore } from "../../store/labelStore"; import { CLOCK_TOKEN_LABELS } from "../../lib/fcTemplate"; +import { tokeniseMarkers } from "../../lib/markerTokens"; +import type { Variable } from "../../types/Variable"; interface Props { value: string; @@ -14,30 +16,6 @@ interface Props { maxLength?: number; } -/** Tokenise content into literal / marker segments so the mirror layer - * can colour markers without breaking literal text. Marker grammar - * matches both variable markers (`«name»`) and clock markers - * (`«clock:T»`) — same `«…»` family. */ -type Segment = - | { kind: "text"; text: string } - | { kind: "var" | "clock"; text: string }; - -const MARKER_RE = /«([^»]+)»/g; - -function tokenise(content: string): Segment[] { - const out: Segment[] = []; - let last = 0; - for (const m of content.matchAll(MARKER_RE)) { - const idx = m.index ?? 0; - if (idx > last) out.push({ kind: "text", text: content.slice(last, idx) }); - const body = m[1] ?? ""; - out.push({ kind: body.startsWith("clock:") ? "clock" : "var", text: m[0] }); - last = idx + m[0].length; - } - if (last < content.length) out.push({ kind: "text", text: content.slice(last) }); - return out; -} - /** * Multi-line content editor for bindable fields. Renders a textarea * over a colour-mirror layer that highlights `«…»` markers in their @@ -78,7 +56,14 @@ export function TemplateContentInput({ const mirrorRef = useRef(null); const rootRef = useRef(null); const [open, setOpen] = useState(false); - const segments = useMemo(() => tokenise(value), [value]); + const variableNames = useMemo( + () => new Set(variables.map((v: Variable) => v.name)), + [variables], + ); + const segments = useMemo( + () => tokeniseMarkers(value, variableNames), + [value, variableNames], + ); // Auto-grow from MIN_ROWS up to MAX_ROWS based on actual rendered // height (so visual word-wrap counts, not just \n count). Mirror @@ -152,8 +137,10 @@ export function TemplateContentInput({ {s.text} ) : s.kind === "var" ? ( {s.text} - ) : ( + ) : s.kind === "clock" ? ( {s.text} + ) : ( + {s.text} ), )} {value.endsWith("\n") ? " " : ""} diff --git a/src/index.css b/src/index.css index fb4ac793..edc5bca0 100644 --- a/src/index.css +++ b/src/index.css @@ -28,6 +28,11 @@ editor — distinct from accent so the editor can colour-code marker families without collision. */ --color-info: #67c8e0; + /* Validation: amber = warning (truncation, soft mismatch), red = + error (missing required, unresolvable marker). Kept distinct + from accent so the user can scan severity at a glance. */ + --color-warning: #f59e0b; + --color-error: #ef4444; --font-sans: 'IBM Plex Sans', ui-sans-serif, system-ui, sans-serif; --font-mono: 'IBM Plex Mono', ui-monospace, monospace; @@ -48,6 +53,8 @@ --color-accent: #d97706; --color-accent-dim:#fef3c7; --color-info: #0e7490; + --color-warning: #b45309; + --color-error: #b91c1c; } *, *::before, *::after { diff --git a/src/lib/fbContent.test.ts b/src/lib/fbContent.test.ts new file mode 100644 index 00000000..c8743984 --- /dev/null +++ b/src/lib/fbContent.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from "vitest"; +import { encodeFbContent, decodeFbContent } from "./fbContent"; + +describe("fbContent encode/decode", () => { + it("encodes newlines as \\&", () => { + expect(encodeFbContent("line1\nline2")).toBe("line1\\&line2"); + }); + + it("escapes literal backslash so \\& payloads round-trip", () => { + expect(encodeFbContent("a\\&b")).toBe("a\\\\&b"); + expect(decodeFbContent("a\\\\&b")).toBe("a\\&b"); + }); + + it("decode is symmetric with encode", () => { + const samples = [ + "", + "plain", + "two\nlines", + "back\\slash", + "literal\\&marker", + "mixed\n\\&\nback\\slash", + "trailing\\", + ]; + for (const s of samples) { + expect(decodeFbContent(encodeFbContent(s))).toBe(s); + } + }); + + it("passes unknown escapes through unchanged (legacy payloads)", () => { + expect(decodeFbContent("a\\xb")).toBe("a\\xb"); + }); + + it("decodes legacy unescaped \\& to newline (pre-backslash-escape format)", () => { + // Payloads written before the encoder learned to escape `\` still + // emitted bare `\&` for newlines. Decoder must keep handling that + // so existing saved labels round-trip unchanged. + expect(decodeFbContent("line1\\&line2")).toBe("line1\nline2"); + }); +}); diff --git a/src/lib/fbContent.ts b/src/lib/fbContent.ts new file mode 100644 index 00000000..99a6e8fa --- /dev/null +++ b/src/lib/fbContent.ts @@ -0,0 +1,49 @@ +/** + * `^FB` block-text uses `\&` as the in-payload line-break marker (Zebra + * spec). The editor stores literal newlines in `content`; encode/decode + * translates between the two representations and is the single source of + * truth for both parser (decode) and generator (encode). + * + * To round-trip user payloads that happen to contain a literal `\&` + * sequence, `\` itself is escaped as `\\`. Decode reverses both + * substitutions in one scanning pass so order matters only inside the + * helper, not at the call sites. + */ + +/** Encode an editor string for emission inside a ^FB payload. */ +export function encodeFbContent(s: string): string { + let out = ""; + for (const ch of s) { + if (ch === "\\") out += "\\\\"; + else if (ch === "\n") out += "\\&"; + else out += ch; + } + return out; +} + +/** Decode a ^FB payload back to the editor representation. Symmetric + * with `encodeFbContent`; unknown `\x` sequences pass through literally + * so non-encoded legacy payloads survive without silent corruption. */ +export function decodeFbContent(s: string): string { + let out = ""; + let i = 0; + while (i < s.length) { + const ch = s[i]; + if (ch === "\\" && i + 1 < s.length) { + const next = s[i + 1]; + if (next === "\\") { + out += "\\"; + i += 2; + continue; + } + if (next === "&") { + out += "\n"; + i += 2; + continue; + } + } + out += ch; + i += 1; + } + return out; +} diff --git a/src/lib/fcTemplate.ts b/src/lib/fcTemplate.ts index 334a0317..79abb4e9 100644 --- a/src/lib/fcTemplate.ts +++ b/src/lib/fcTemplate.ts @@ -37,7 +37,7 @@ const TOKEN_FORMATTERS: Record string> = { const leap = (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0; let n = d.getDate(); for (let i = 0; i < d.getMonth(); i++) { - n += monthDays[i]!; + n += monthDays[i] ?? 0; if (i === 1 && leap) n += 1; } return String(n).padStart(3, "0"); diff --git a/src/lib/markerTokens.test.ts b/src/lib/markerTokens.test.ts new file mode 100644 index 00000000..39e8364c --- /dev/null +++ b/src/lib/markerTokens.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from "vitest"; +import { tokeniseMarkers } from "./markerTokens"; + +describe("tokeniseMarkers", () => { + const vars = new Set(["name", "qty"]); + + it("classifies known variables, clocks, and orphans", () => { + const segs = tokeniseMarkers("Hi «name», «clock:Y» «missing» «clock:Q»", vars); + expect(segs.map((s) => s.kind)).toEqual([ + "text", + "var", + "text", + "clock", + "text", + "orphan", + "text", + "orphan", + ]); + }); + + it("returns a single text segment when no markers present", () => { + expect(tokeniseMarkers("plain", vars)).toEqual([{ kind: "text", text: "plain" }]); + }); + + it("handles empty input", () => { + expect(tokeniseMarkers("", vars)).toEqual([]); + }); + + it("treats empty marker «» as literal text (not a marker)", () => { + // The marker regex requires at least one character inside the + // brackets. `«»` therefore never tokenises as a marker — users + // typing those glyphs literally don't get them coloured/treated + // as a broken variable. + expect(tokeniseMarkers("«»", vars)).toEqual([{ kind: "text", text: "«»" }]); + }); +}); diff --git a/src/lib/markerTokens.ts b/src/lib/markerTokens.ts new file mode 100644 index 00000000..4eb65eed --- /dev/null +++ b/src/lib/markerTokens.ts @@ -0,0 +1,43 @@ +import { CLOCK_TOKEN_LABELS } from "./fcTemplate"; + +/** Segment kinds the content editor's colour-mirror layer renders. */ +export type MarkerSegment = + | { kind: "text"; text: string } + | { kind: "var" | "clock"; text: string } + | { kind: "orphan"; text: string }; + +const MARKER_RE = /«([^»]+)»/g; +const KNOWN_CLOCK_TOKENS = new Set(CLOCK_TOKEN_LABELS.map((x) => x.token)); + +/** Tokenise template content into literal / marker segments so a + * highlight layer can colour markers without breaking literal text. + * Marker grammar matches both variable markers (`«name»`) and clock + * markers (`«clock:T»`) — same `«…»` family. + * + * Markers are classified into var / clock / orphan: + * - var: variable name resolves to a defined Variable → accent + * - clock: clock-token letter known to TOKEN_FORMATTERS → info + * - orphan: variable name unknown OR clock token unknown → red + * Orphan-detection is the visual half of validation — the user sees + * immediately when a marker won't resolve at render time. */ +export function tokeniseMarkers( + content: string, + variableNames: ReadonlySet, +): MarkerSegment[] { + const out: MarkerSegment[] = []; + let last = 0; + for (const m of content.matchAll(MARKER_RE)) { + const idx = m.index ?? 0; + if (idx > last) out.push({ kind: "text", text: content.slice(last, idx) }); + const body = m[1] ?? ""; + if (body.startsWith("clock:")) { + const tok = body.slice("clock:".length); + out.push({ kind: KNOWN_CLOCK_TOKENS.has(tok) ? "clock" : "orphan", text: m[0] }); + } else { + out.push({ kind: variableNames.has(body) ? "var" : "orphan", text: m[0] }); + } + last = idx + m[0].length; + } + if (last < content.length) out.push({ kind: "text", text: content.slice(last) }); + return out; +} diff --git a/src/lib/useColorScheme.ts b/src/lib/useColorScheme.ts index 18d0c840..2d47f18a 100644 --- a/src/lib/useColorScheme.ts +++ b/src/lib/useColorScheme.ts @@ -16,6 +16,10 @@ export interface CanvasColors { * a blue-ish convention here (Figma, Sketch, Illustrator) to keep * contrast usable across the B/W shape colour space. */ selection: string; + /** UI accent (amber). Used for non-selection design-affordance overlays + * on the canvas — e.g. the ^FB wrap-guide line so the user can tell + * it apart from selection/transformer chrome. */ + accent: string; } export const DARK_COLORS: CanvasColors = { @@ -30,6 +34,7 @@ export const DARK_COLORS: CanvasColors = { rulerLabel: '#cccccc', rulerSeparator: '#2a2a2a', selection: '#6366f1', + accent: '#f59e0b', }; export const LIGHT_COLORS: CanvasColors = { @@ -44,6 +49,7 @@ export const LIGHT_COLORS: CanvasColors = { rulerLabel: '#27272a', rulerSeparator: '#d4d4d8', selection: '#6366f1', + accent: '#d97706', }; export function useColorScheme(): CanvasColors { diff --git a/src/lib/zplParser.ts b/src/lib/zplParser.ts index 9916f90e..8ef0ffac 100644 --- a/src/lib/zplParser.ts +++ b/src/lib/zplParser.ts @@ -11,6 +11,7 @@ import { tokensToMarkers, type ClockChars, } from "./fcTemplate"; +import { decodeFbContent } from "./fbContent"; import { zplAnchorToModel } from "../components/Canvas/textPositionTransforms"; import { computeTextRenderMetrics } from "../components/Canvas/textRenderMetrics"; import type { LabelObject } from "../types/Group"; @@ -762,8 +763,9 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { const posType: "FT" | "FO" = positionIsFT ? "FT" : "FO"; const comment = takeComment(); - // Decode \& line breaks in ^FB text blocks - const decoded = fbWidth > 0 ? content.replace(/\\&/g, "\n") : content; + // Decode \& line breaks (and \\ escapes) in ^FB text blocks via the + // shared helper so parser and generator stay symmetric. + const decoded = fbWidth > 0 ? decodeFbContent(content) : content; switch (fieldType) { case "text": { diff --git a/src/locales/ar.ts b/src/locales/ar.ts index 5503a3d3..610347cd 100644 --- a/src/locales/ar.ts +++ b/src/locales/ar.ts @@ -208,6 +208,8 @@ const ar = { justifyC: 'C — وسط', justifyR: 'R — يمين', justifyJ: 'J — ضبط', + blockLinesExceededFmt: 'المحتوى يحتوي على {n} أسطر — يتجاوز الحد الأقصى {max}، ستقطعها الطابعة', + blockLinesUsageFmt: 'يستخدم {n} من الحد الأقصى {max} سطر', printerFont: 'Printer font', uploadFont: 'Upload font file', uploadingFont: 'Uploading…', diff --git a/src/locales/bg.ts b/src/locales/bg.ts index a837a5ef..099eb254 100644 --- a/src/locales/bg.ts +++ b/src/locales/bg.ts @@ -208,6 +208,8 @@ const bg = { justifyC: 'C — Център', justifyR: 'R — Дясно', justifyJ: 'J — Двустранно', + blockLinesExceededFmt: 'Съдържанието има {n} реда — надвишава макс {max}, ще бъде отрязано от принтера', + blockLinesUsageFmt: 'Използва {n} от макс {max} реда', printerFont: 'Printer font', uploadFont: 'Upload font file', uploadingFont: 'Uploading…', diff --git a/src/locales/cs.ts b/src/locales/cs.ts index 75998c85..3cf3e16c 100644 --- a/src/locales/cs.ts +++ b/src/locales/cs.ts @@ -208,6 +208,8 @@ const cs = { justifyC: 'C — Na střed', justifyR: 'R — Vpravo', justifyJ: 'J — Do bloku', + blockLinesExceededFmt: 'Obsah má {n} řádků — překračuje max {max}, tiskárna zkrátí', + blockLinesUsageFmt: 'Využívá {n} z max {max} řádků', printerFont: 'Printer font', uploadFont: 'Upload font file', uploadingFont: 'Uploading…', diff --git a/src/locales/da.ts b/src/locales/da.ts index e9931cd2..bc0eef64 100644 --- a/src/locales/da.ts +++ b/src/locales/da.ts @@ -208,6 +208,8 @@ const da = { justifyC: 'C — Centreret', justifyR: 'R — Højre', justifyJ: 'J — Lige margener', + blockLinesExceededFmt: 'Indholdet har {n} linjer — overskrider maks {max}, vil blive afkortet af printeren', + blockLinesUsageFmt: 'Bruger {n} af maks {max} linjer', printerFont: 'Printer font', uploadFont: 'Upload font file', uploadingFont: 'Uploading…', diff --git a/src/locales/de.ts b/src/locales/de.ts index e820ea7e..554d1467 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -229,6 +229,8 @@ const de = { justifyC: 'C — Zentriert', justifyR: 'R — Rechts', justifyJ: 'J — Blocksatz', + blockLinesExceededFmt: 'Inhalt hat {n} Zeilen — überschreitet Max {max}, wird vom Drucker abgeschnitten', + blockLinesUsageFmt: 'Nutzt {n} von max {max} Zeilen', printerFont: 'Druckerschrift', uploadFont: 'Schriftdatei hochladen', uploadingFont: 'Hochladen…', diff --git a/src/locales/el.ts b/src/locales/el.ts index 33f81f56..40e35a47 100644 --- a/src/locales/el.ts +++ b/src/locales/el.ts @@ -208,6 +208,8 @@ const el = { justifyC: 'C — Κέντρο', justifyR: 'R — Δεξιά', justifyJ: 'J — Πλήρης', + blockLinesExceededFmt: 'Το περιεχόμενο έχει {n} γραμμές — υπερβαίνει το μέγ {max}, θα περικοπεί από τον εκτυπωτή', + blockLinesUsageFmt: 'Χρησιμοποιεί {n} από μέγ {max} γραμμές', printerFont: 'Printer font', uploadFont: 'Upload font file', uploadingFont: 'Uploading…', diff --git a/src/locales/en.ts b/src/locales/en.ts index 13df47ad..9a13a5d8 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -229,6 +229,8 @@ const en = { justifyC: 'C — Center', justifyR: 'R — Right', justifyJ: 'J — Justified', + blockLinesExceededFmt: 'Content has {n} lines — exceeds max {max}, will be truncated by printer', + blockLinesUsageFmt: 'Uses {n} of max {max} lines', printerFont: 'Printer font', uploadFont: 'Upload font file', uploadingFont: 'Uploading…', diff --git a/src/locales/es.ts b/src/locales/es.ts index 783cf4d0..09d5ceff 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -208,6 +208,8 @@ const es = { justifyC: 'C — Centro', justifyR: 'R — Derecha', justifyJ: 'J — Justificado', + blockLinesExceededFmt: 'El contenido tiene {n} líneas — excede el máx {max}, será truncado por la impresora', + blockLinesUsageFmt: 'Usa {n} de máx {max} líneas', printerFont: 'Printer font', uploadFont: 'Upload font file', uploadingFont: 'Uploading…', diff --git a/src/locales/et.ts b/src/locales/et.ts index b34fd53c..10c1f8fb 100644 --- a/src/locales/et.ts +++ b/src/locales/et.ts @@ -208,6 +208,8 @@ const et = { justifyC: 'C — Keskele', justifyR: 'R — Paremale', justifyJ: 'J — Rööpjoondus', + blockLinesExceededFmt: 'Sisul on {n} rida — ületab max {max}, printer kärbib', + blockLinesUsageFmt: 'Kasutab {n} / max {max} rida', printerFont: 'Printer font', uploadFont: 'Upload font file', uploadingFont: 'Uploading…', diff --git a/src/locales/fa.ts b/src/locales/fa.ts index 0c09677f..f2945dff 100644 --- a/src/locales/fa.ts +++ b/src/locales/fa.ts @@ -208,6 +208,8 @@ const fa = { justifyC: 'C — وسط', justifyR: 'R — راست', justifyJ: 'J — تنظیم', + blockLinesExceededFmt: 'محتوا {n} خط دارد — از حداکثر {max} بیشتر است، چاپگر کوتاه خواهد کرد', + blockLinesUsageFmt: 'از {n} از حداکثر {max} خط استفاده می‌کند', printerFont: 'Printer font', uploadFont: 'Upload font file', uploadingFont: 'Uploading…', diff --git a/src/locales/fi.ts b/src/locales/fi.ts index 9795cab7..182ead42 100644 --- a/src/locales/fi.ts +++ b/src/locales/fi.ts @@ -208,6 +208,8 @@ const fi = { justifyC: 'C — Keskitetty', justifyR: 'R — Oikea', justifyJ: 'J — Tasattu', + blockLinesExceededFmt: 'Sisällössä on {n} riviä — ylittää max {max}, tulostin katkaisee', + blockLinesUsageFmt: 'Käyttää {n} / max {max} riviä', printerFont: 'Printer font', uploadFont: 'Upload font file', uploadingFont: 'Uploading…', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 1e7d0b08..f3c56b08 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -208,6 +208,8 @@ const fr = { justifyC: 'C — Centré', justifyR: 'R — Droite', justifyJ: 'J — Justifié', + blockLinesExceededFmt: 'Le contenu a {n} lignes — dépasse le max {max}, sera tronqué par l\'imprimante', + blockLinesUsageFmt: 'Utilise {n} sur max {max} lignes', printerFont: 'Printer font', uploadFont: 'Upload font file', uploadingFont: 'Uploading…', diff --git a/src/locales/he.ts b/src/locales/he.ts index 6d8fbf42..3efc800e 100644 --- a/src/locales/he.ts +++ b/src/locales/he.ts @@ -208,6 +208,8 @@ const he = { justifyC: 'C — מרכז', justifyR: 'R — ימין', justifyJ: 'J — מיושר', + blockLinesExceededFmt: 'התוכן מכיל {n} שורות — חורג מהמקס {max}, המדפסת תקצר', + blockLinesUsageFmt: 'משתמש ב-{n} מתוך {max} שורות', printerFont: 'Printer font', uploadFont: 'Upload font file', uploadingFont: 'Uploading…', diff --git a/src/locales/hr.ts b/src/locales/hr.ts index 96f4213b..f546c604 100644 --- a/src/locales/hr.ts +++ b/src/locales/hr.ts @@ -208,6 +208,8 @@ const hr = { justifyC: 'C — Sredina', justifyR: 'R — Desno', justifyJ: 'J — Obostrano', + blockLinesExceededFmt: 'Sadržaj ima {n} redaka — premašuje maks {max}, pisač će skratiti', + blockLinesUsageFmt: 'Koristi {n} od maks {max} redaka', printerFont: 'Printer font', uploadFont: 'Upload font file', uploadingFont: 'Uploading…', diff --git a/src/locales/hu.ts b/src/locales/hu.ts index d3e90286..2e39a653 100644 --- a/src/locales/hu.ts +++ b/src/locales/hu.ts @@ -208,6 +208,8 @@ const hu = { justifyC: 'C — Középre', justifyR: 'R — Jobbra', justifyJ: 'J — Sorkizárt', + blockLinesExceededFmt: 'A tartalom {n} sort tartalmaz — meghaladja a max {max}-ot, a nyomtató levágja', + blockLinesUsageFmt: '{n} sort használ a max {max}-ból', printerFont: 'Printer font', uploadFont: 'Upload font file', uploadingFont: 'Uploading…', diff --git a/src/locales/it.ts b/src/locales/it.ts index 80118313..b8985690 100644 --- a/src/locales/it.ts +++ b/src/locales/it.ts @@ -208,6 +208,8 @@ const it = { justifyC: 'C — Centro', justifyR: 'R — Destra', justifyJ: 'J — Giustificato', + blockLinesExceededFmt: 'Il contenuto ha {n} righe — supera il max {max}, sarà troncato dalla stampante', + blockLinesUsageFmt: 'Usa {n} di max {max} righe', printerFont: 'Printer font', uploadFont: 'Upload font file', uploadingFont: 'Uploading…', diff --git a/src/locales/ja.ts b/src/locales/ja.ts index 4131c633..e4009153 100644 --- a/src/locales/ja.ts +++ b/src/locales/ja.ts @@ -208,6 +208,8 @@ const ja = { justifyC: 'C — 中央揃え', justifyR: 'R — 右揃え', justifyJ: 'J — 両端揃え', + blockLinesExceededFmt: 'コンテンツは{n}行 — 最大{max}を超過、プリンターで切り詰められます', + blockLinesUsageFmt: '{n} / 最大 {max} 行使用', printerFont: 'Printer font', uploadFont: 'Upload font file', uploadingFont: 'Uploading…', diff --git a/src/locales/ko.ts b/src/locales/ko.ts index e0b9dda9..39d2a2d3 100644 --- a/src/locales/ko.ts +++ b/src/locales/ko.ts @@ -208,6 +208,8 @@ const ko = { justifyC: 'C — 가운데', justifyR: 'R — 오른쪽', justifyJ: 'J — 균등 배분', + blockLinesExceededFmt: '콘텐츠가 {n}줄 — 최대 {max}을 초과, 프린터가 자릅니다', + blockLinesUsageFmt: '최대 {max} 중 {n}줄 사용', printerFont: 'Printer font', uploadFont: 'Upload font file', uploadingFont: 'Uploading…', diff --git a/src/locales/lt.ts b/src/locales/lt.ts index dc05dcdb..e1194a37 100644 --- a/src/locales/lt.ts +++ b/src/locales/lt.ts @@ -208,6 +208,8 @@ const lt = { justifyC: 'C — Centras', justifyR: 'R — Dešinė', justifyJ: 'J — Abipusė', + blockLinesExceededFmt: 'Turinys turi {n} eilučių — viršija maks {max}, spausdintuvas sutrumpins', + blockLinesUsageFmt: 'Naudoja {n} iš max {max} eilučių', printerFont: 'Printer font', uploadFont: 'Upload font file', uploadingFont: 'Uploading…', diff --git a/src/locales/lv.ts b/src/locales/lv.ts index f26029f6..d36b8026 100644 --- a/src/locales/lv.ts +++ b/src/locales/lv.ts @@ -208,6 +208,8 @@ const lv = { justifyC: 'C — Centrēts', justifyR: 'R — Pa labi', justifyJ: 'J — Izlīdzināts', + blockLinesExceededFmt: 'Saturam ir {n} rindas — pārsniedz maks {max}, printeris saīsinās', + blockLinesUsageFmt: 'Izmanto {n} no maks {max} rindām', printerFont: 'Printer font', uploadFont: 'Upload font file', uploadingFont: 'Uploading…', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index ddfd7ffa..f9486e6c 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -208,6 +208,8 @@ const nl = { justifyC: 'C — Gecentreerd', justifyR: 'R — Rechts', justifyJ: 'J — Uitgevuld', + blockLinesExceededFmt: 'Inhoud heeft {n} regels — overschrijdt max {max}, wordt door printer afgekapt', + blockLinesUsageFmt: 'Gebruikt {n} van max {max} regels', printerFont: 'Printer font', uploadFont: 'Upload font file', uploadingFont: 'Uploading…', diff --git a/src/locales/no.ts b/src/locales/no.ts index 3ccbee79..67a318eb 100644 --- a/src/locales/no.ts +++ b/src/locales/no.ts @@ -208,6 +208,8 @@ const no = { justifyC: 'C — Sentrert', justifyR: 'R — Høyre', justifyJ: 'J — Blokkjustert', + blockLinesExceededFmt: 'Innholdet har {n} linjer — overstiger maks {max}, vil bli avkortet av skriveren', + blockLinesUsageFmt: 'Bruker {n} av maks {max} linjer', printerFont: 'Printer font', uploadFont: 'Upload font file', uploadingFont: 'Uploading…', diff --git a/src/locales/pl.ts b/src/locales/pl.ts index a2cfa119..8176bea6 100644 --- a/src/locales/pl.ts +++ b/src/locales/pl.ts @@ -208,6 +208,8 @@ const pl = { justifyC: 'C — Środek', justifyR: 'R — Prawo', justifyJ: 'J — Wyjustowane', + blockLinesExceededFmt: 'Zawartość ma {n} wierszy — przekracza max {max}, drukarka obetnie', + blockLinesUsageFmt: 'Używa {n} z max {max} wierszy', printerFont: 'Printer font', uploadFont: 'Upload font file', uploadingFont: 'Uploading…', diff --git a/src/locales/pt.ts b/src/locales/pt.ts index 50c4b542..dd72b511 100644 --- a/src/locales/pt.ts +++ b/src/locales/pt.ts @@ -208,6 +208,8 @@ const pt = { justifyC: 'C — Centro', justifyR: 'R — Direita', justifyJ: 'J — Justificado', + blockLinesExceededFmt: 'Conteúdo tem {n} linhas — excede o máx {max}, será truncado pela impressora', + blockLinesUsageFmt: 'Usa {n} de máx {max} linhas', printerFont: 'Printer font', uploadFont: 'Upload font file', uploadingFont: 'Uploading…', diff --git a/src/locales/ro.ts b/src/locales/ro.ts index 447ecf65..f16d8218 100644 --- a/src/locales/ro.ts +++ b/src/locales/ro.ts @@ -208,6 +208,8 @@ const ro = { justifyC: 'C — Centru', justifyR: 'R — Dreapta', justifyJ: 'J — Justify', + blockLinesExceededFmt: 'Conținutul are {n} linii — depășește max {max}, va fi trunchiat de imprimantă', + blockLinesUsageFmt: 'Folosește {n} din max {max} linii', printerFont: 'Printer font', uploadFont: 'Upload font file', uploadingFont: 'Uploading…', diff --git a/src/locales/sk.ts b/src/locales/sk.ts index 07b6bbc4..eb5cf072 100644 --- a/src/locales/sk.ts +++ b/src/locales/sk.ts @@ -208,6 +208,8 @@ const sk = { justifyC: 'C — Na stred', justifyR: 'R — Vpravo', justifyJ: 'J — Do bloku', + blockLinesExceededFmt: 'Obsah má {n} riadkov — prekračuje max {max}, tlačiareň skráti', + blockLinesUsageFmt: 'Využíva {n} z max {max} riadkov', printerFont: 'Printer font', uploadFont: 'Upload font file', uploadingFont: 'Uploading…', diff --git a/src/locales/sl.ts b/src/locales/sl.ts index 33047df7..5be2a4ae 100644 --- a/src/locales/sl.ts +++ b/src/locales/sl.ts @@ -208,6 +208,8 @@ const sl = { justifyC: 'C — Sredina', justifyR: 'R — Desno', justifyJ: 'J — Obojestransko', + blockLinesExceededFmt: 'Vsebina ima {n} vrstic — presega največ {max}, tiskalnik bo obrezal', + blockLinesUsageFmt: 'Uporablja {n} od največ {max} vrstic', printerFont: 'Printer font', uploadFont: 'Upload font file', uploadingFont: 'Uploading…', diff --git a/src/locales/sr.ts b/src/locales/sr.ts index 0c633d1e..185340f8 100644 --- a/src/locales/sr.ts +++ b/src/locales/sr.ts @@ -208,6 +208,8 @@ const sr = { justifyC: 'C — Центар', justifyR: 'R — Десно', justifyJ: 'J — Обострано', + blockLinesExceededFmt: 'Садржај има {n} редова — премашује макс {max}, штампач ће скратити', + blockLinesUsageFmt: 'Користи {n} од макс {max} редова', printerFont: 'Printer font', uploadFont: 'Upload font file', uploadingFont: 'Uploading…', diff --git a/src/locales/sv.ts b/src/locales/sv.ts index 83aa8271..21b38aef 100644 --- a/src/locales/sv.ts +++ b/src/locales/sv.ts @@ -208,6 +208,8 @@ const sv = { justifyC: 'C — Centrerad', justifyR: 'R — Höger', justifyJ: 'J — Marginaljust.', + blockLinesExceededFmt: 'Innehållet har {n} rader — överskrider max {max}, kommer att avkortas av skrivaren', + blockLinesUsageFmt: 'Använder {n} av max {max} rader', printerFont: 'Printer font', uploadFont: 'Upload font file', uploadingFont: 'Uploading…', diff --git a/src/locales/tr.ts b/src/locales/tr.ts index c5137797..3d70dfc2 100644 --- a/src/locales/tr.ts +++ b/src/locales/tr.ts @@ -208,6 +208,8 @@ const tr = { justifyC: 'C — Orta', justifyR: 'R — Sağ', justifyJ: 'J — İki yana', + blockLinesExceededFmt: 'İçerikte {n} satır var — max {max}\'ı aşıyor, yazıcı kırpacak', + blockLinesUsageFmt: '{n} / max {max} satır kullanılıyor', printerFont: 'Printer font', uploadFont: 'Upload font file', uploadingFont: 'Uploading…', diff --git a/src/locales/zh-hans.ts b/src/locales/zh-hans.ts index 5c629173..3a1f706d 100644 --- a/src/locales/zh-hans.ts +++ b/src/locales/zh-hans.ts @@ -208,6 +208,8 @@ const zhHans = { justifyC: 'C — 居中', justifyR: 'R — 右对齐', justifyJ: 'J — 两端对齐', + blockLinesExceededFmt: '内容有 {n} 行 — 超过最大 {max},将被打印机截断', + blockLinesUsageFmt: '使用 {n} / 最大 {max} 行', printerFont: 'Printer font', uploadFont: 'Upload font file', uploadingFont: 'Uploading…', diff --git a/src/locales/zh-hant.ts b/src/locales/zh-hant.ts index a1b23095..a4c49705 100644 --- a/src/locales/zh-hant.ts +++ b/src/locales/zh-hant.ts @@ -208,6 +208,8 @@ const zhHant = { justifyC: 'C — 置中', justifyR: 'R — 靠右', justifyJ: 'J — 左右對齊', + blockLinesExceededFmt: '內容有 {n} 行 — 超過最大 {max},將被印表機截斷', + blockLinesUsageFmt: '使用 {n} / 最大 {max} 行', printerFont: 'Printer font', uploadFont: 'Upload font file', uploadingFont: 'Uploading…', diff --git a/src/registry/text.tsx b/src/registry/text.tsx index 39308fd1..5ee09702 100644 --- a/src/registry/text.tsx +++ b/src/registry/text.tsx @@ -11,6 +11,8 @@ import { useLabelStore } from "../store/labelStore"; import { RotationSelect } from "../components/Properties/RotationSelect"; import { NumberInput } from "../components/Properties/NumberInput"; import { TemplateContentInput } from "../components/Properties/TemplateContentInput"; +import { BlockTextSettings } from "../components/Properties/BlockTextSettings"; +import { encodeFbContent } from "../lib/fbContent"; export interface TextProps { content: string; @@ -70,7 +72,14 @@ export const text: ObjectTypeDefinition = { const fbCmd = p.blockWidth ? `^FB${p.blockWidth},${p.blockLines ?? 1},${p.blockLineSpacing ?? 0},${p.blockJustify ?? "L"},0` : ""; - const body = [textFieldPos(obj), fontCmd, fbCmd, fdFieldFor(obj, p.content, ctx)] + // ^FB block-text uses `\&` as the in-payload line-break marker + // (Zebra spec). Encode via the shared helper so parser/generator + // stay symmetric (it also escapes literal backslashes so payloads + // containing `\&` round-trip without corruption). Outside ^FB the + // printer ignores embedded newlines anyway, so encoding only + // happens when blockWidth is set. + const content = p.blockWidth ? encodeFbContent(p.content) : p.content; + const body = [textFieldPos(obj), fontCmd, fbCmd, fdFieldFor(obj, content, ctx)] .filter(Boolean) .join(""); return wrapReverse(p.reverse, body); @@ -213,6 +222,39 @@ export const text: ObjectTypeDefinition = { /> + {/* Block-Text settings sit directly under the content editor + because they govern how that content renders (wrap width, + max lines, justify) — keeping them adjacent makes the + cause/effect relationship obvious. Other settings (font, + rotation, reverse) act on the whole field and come below. */} + + + {!!p.blockWidth && } +
= { /> {t.registry.text.reverse} - - - - {!!p.blockWidth && ( - <> -
- onChange({ blockWidth })} - /> - onChange({ blockLines })} - /> -
-
-
- - -
- onChange({ blockLineSpacing })} - /> -
- - )}
); },