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
25 changes: 24 additions & 1 deletion src/components/Canvas/KonvaObject.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useMemo } from "react";
import { useFontCacheVersion } from "../../hooks/useFontCacheVersion";
import { Ellipse, Group, Rect, Text } from "react-konva";
import { Ellipse, Group, Line, Rect, Text } from "react-konva";
import { lookupBoundVariable, shouldShowFallbackTint } from "../../lib/variableBinding";
import { BarcodeObject } from "./BarcodeObject";
import { LineObject } from "./LineObject";
Expand Down Expand Up @@ -348,6 +348,29 @@ function KonvaObjectInner({
stroke={isSelected ? colors.selection : undefined}
strokeWidth={isSelected ? 1 : 0}
/>
{/* ^FB wrap-guide: a dashed vertical line at blockWidth so
the user sees where the printer will break the text.
Only shown while selected — clutter-free for the common
view, immediate feedback when adjusting blockWidth.
Height = (lines * line-step) where line-step is the font
cap height plus the user-configured blockLineSpacing
(dots), matching how Zebra advances the y-cursor between
wrapped rows. */}
{obj.type === "text" && isSelected && obj.props.blockWidth ? (() => {
const lines = obj.props.blockLines ?? 1;
const spacingPx = dotsToPx(obj.props.blockLineSpacing ?? 0, scale, dpmm);
const lineStep = fontSizePx + spacingPx;
const guideX = dotsToPx(obj.props.blockWidth, scale, dpmm);
return (
<Line
points={[guideX, 0, guideX, lines * lineStep]}
stroke={colors.accent}
strokeWidth={1}
dash={[4, 3]}
listening={false}
/>
);
})() : null}
</Group>
);
}
Expand Down
69 changes: 69 additions & 0 deletions src/components/Properties/BlockTextSettings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { useT } from "../../lib/useT";
import { labelCls } from "./styles";
import { NumberInput } from "./NumberInput";
import { JustifyButtons } from "./JustifyButtons";
import type { TextProps } from "../../registry/text";

interface Props {
props: TextProps;
onChange: (patch: Partial<TextProps>) => void;
}

/** Block-text (`^FB`) sub-panel: width / max-lines / justify /
* line-spacing plus inline validation hints. Hints are purely
* informational — never auto-correct user-set blockLines, since
* content can legitimately have fewer lines than max (CSV-bound
* rows vary) or more (intentional truncation). The editor surfaces
* the mismatch but leaves the choice to the user. */
export function BlockTextSettings({ props: p, onChange }: Props) {
const t = useT();
const contentLines = p.content.split("\n").length;
Comment thread
u8array marked this conversation as resolved.
const maxLines = p.blockLines ?? 1;
const truncates = contentLines > maxLines;
return (
<>
<div className="grid grid-cols-2 gap-2">
<NumberInput
label={t.registry.text.blockWidth}
value={p.blockWidth ?? 0}
min={1}
onChange={(blockWidth) => onChange({ blockWidth })}
/>
<NumberInput
label={t.registry.text.blockLines}
value={p.blockLines ?? 1}
min={1}
onChange={(blockLines) => onChange({ blockLines })}
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col gap-1">
<label className={labelCls}>{t.registry.text.blockJustify}</label>
<JustifyButtons
value={p.blockJustify ?? "L"}
onChange={(blockJustify) => onChange({ blockJustify })}
/>
</div>
<NumberInput
label={t.registry.text.blockLineSpacing}
value={p.blockLineSpacing ?? 0}
onChange={(blockLineSpacing) => onChange({ blockLineSpacing })}
/>
</div>
{truncates && (
<p className="font-mono text-[10px] text-warning">
{t.registry.text.blockLinesExceededFmt
.replaceAll("{n}", String(contentLines))
.replaceAll("{max}", String(maxLines))}
</p>
)}
{!truncates && contentLines < maxLines && (
<p className="font-mono text-[10px] text-muted">
{t.registry.text.blockLinesUsageFmt
.replaceAll("{n}", String(contentLines))
.replaceAll("{max}", String(maxLines))}
</p>
)}
</>
);
}
84 changes: 84 additions & 0 deletions src/components/Properties/JustifyButtons.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import type { ReactNode } from "react";
import { useT } from "../../lib/useT";
import type { TextProps } from "../../registry/text";

type Justify = NonNullable<TextProps["blockJustify"]>;

interface Props {
value: Justify;
onChange: (next: Justify) => void;
}

/** Inline SVG glyphs for the four justify modes. Stroke uses
* `currentColor` so the active/inactive button colour drives the
* icon — single source of truth for theming. Inline SVG (vs.
* Unicode glyphs) avoids font-fallback tofu on systems without the
* niche math/arrow ranges installed. */
const ICONS: Record<Justify, ReactNode> = {
L: (
<svg viewBox="0 0 16 12" className="w-4 h-3 mx-auto" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<line x1="1" y1="2" x2="15" y2="2" />
<line x1="1" y1="6" x2="11" y2="6" />
<line x1="1" y1="10" x2="13" y2="10" />
</svg>
),
C: (
<svg viewBox="0 0 16 12" className="w-4 h-3 mx-auto" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<line x1="1" y1="2" x2="15" y2="2" />
<line x1="3" y1="6" x2="13" y2="6" />
<line x1="2" y1="10" x2="14" y2="10" />
</svg>
),
R: (
<svg viewBox="0 0 16 12" className="w-4 h-3 mx-auto" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<line x1="1" y1="2" x2="15" y2="2" />
<line x1="5" y1="6" x2="15" y2="6" />
<line x1="3" y1="10" x2="15" y2="10" />
</svg>
),
J: (
<svg viewBox="0 0 16 12" className="w-4 h-3 mx-auto" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<line x1="1" y1="2" x2="15" y2="2" />
<line x1="1" y1="6" x2="15" y2="6" />
<line x1="1" y1="10" x2="15" y2="10" />
</svg>
),
};

/** ^FB text-justification toggle: 4 icon buttons (left / centre /
* right / justified) — same MS Word pattern users already know.
* Replaces the legacy `<select>` 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 (
<div className="flex gap-1" role="group" aria-label={t.registry.text.blockJustify}>
{items.map(({ v, title }) => {
const active = value === v;
return (
<button
key={v}
type="button"
title={title}
aria-label={title}
aria-pressed={active}
onClick={() => onChange(v)}
className={`flex-1 px-2 py-1 rounded border transition-colors ${
active
? "border-accent bg-accent-dim text-accent"
: "border-border text-muted hover:text-text hover:bg-surface-2"
}`}
>
{ICONS[v]}
</button>
);
})}
</div>
);
}
39 changes: 13 additions & 26 deletions src/components/Properties/TemplateContentInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -78,7 +56,14 @@ export function TemplateContentInput({
const mirrorRef = useRef<HTMLDivElement>(null);
const rootRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const segments = useMemo(() => tokenise(value), [value]);
const variableNames = useMemo(
() => new Set(variables.map((v: Variable) => v.name)),
[variables],
);
Comment thread
u8array marked this conversation as resolved.
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
Expand Down Expand Up @@ -152,8 +137,10 @@ export function TemplateContentInput({
<span key={i}>{s.text}</span>
) : s.kind === "var" ? (
<span key={i} className="text-accent">{s.text}</span>
) : (
) : s.kind === "clock" ? (
<span key={i} className="text-info">{s.text}</span>
) : (
<span key={i} className="text-error underline decoration-wavy decoration-error/60">{s.text}</span>
),
)}
{value.endsWith("\n") ? " " : ""}
Expand Down
7 changes: 7 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -48,6 +53,8 @@
--color-accent: #d97706;
--color-accent-dim:#fef3c7;
--color-info: #0e7490;
--color-warning: #b45309;
--color-error: #b91c1c;
}

*, *::before, *::after {
Expand Down
39 changes: 39 additions & 0 deletions src/lib/fbContent.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
49 changes: 49 additions & 0 deletions src/lib/fbContent.ts
Original file line number Diff line number Diff line change
@@ -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;
}
2 changes: 1 addition & 1 deletion src/lib/fcTemplate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ const TOKEN_FORMATTERS: Record<string, (d: Date) => 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");
Expand Down
Loading