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
6 changes: 6 additions & 0 deletions packages/core/src/lib/qrFd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,12 @@ function joinSegments(payload: string, manual: boolean, mixed: boolean, utf8: bo
return out.join('');
}

/** Payload start in a ^BQ field, after the switch or the three bytes qrFdToModel drops. Manual-mode segments count as payload. */
export function qrFdPayloadStart(fd: string): number {
const m = QR_FD_SWITCHES.exec(fd);
return m ? m[0].length - (m[4]?.length ?? 0) : Math.min(3, fd.length);
}

export interface QrFdParse {
errorCorrection: QrEcLevel;
content: string;
Expand Down
16 changes: 11 additions & 5 deletions src/components/Output/ZplCodeMirror.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { zpl, blobRanges, commandAtCursor, commandInsertion, placesCaret, points
import { toDiagnostic } from '../../lib/zplCmLint';
import { hideSidecarsExt, visibleLineNumber } from '../../lib/zplCmSidecars';
import { commandMarksExt } from '../../lib/zplCmCommandMarks';
import { blankFieldsExt } from '../../lib/zplCmBlankFields';
import { crlfIndex, isPureCrlf, minimalSplice, toDocPos } from '../../lib/sourceOffsets';

// Text.toString() always joins with LF; only sliceString honours the
Expand Down Expand Up @@ -78,6 +79,7 @@ const theme = EditorView.theme({
outlineOffset: '-1px',
borderRadius: '2px',
},
'.cm-zplBlankHint': { opacity: '0.45', fontStyle: 'italic', userSelect: 'none', WebkitUserSelect: 'none' },
// CM's base theme paints the tooltip for a light host (no `dark` declared
// here), and the pane itself is surface-2: without the app's token plus
// elevation the popup reads as text pasted onto the code.
Expand Down Expand Up @@ -125,10 +127,11 @@ const NO_LINES: ReadonlySet<number> = new Set();
const readOnlyExt = (ro: boolean) => [EditorState.readOnly.of(ro), EditorView.editable.of(!ro)];
const highlightExt = (lines: ReadonlySet<number>) =>
EditorView.decorations.of((v) => highlightDecorations(v.state, lines));
// One compartment: both are locale strings that change on the same event.
const localeExt = (ariaLabel: string, placeholderText: string) => [
// One compartment: the three strings change together, on a locale switch or the preview lock.
const localeExt = (ariaLabel: string, placeholderText: string, blankFieldHint: string | undefined) => [
EditorView.contentAttributes.of({ 'aria-label': ariaLabel }),
placeholder(placeholderText),
blankFieldsExt(blankFieldHint),
];

function useCompartmentSync<T>(viewRef: RefObject<EditorView | null>, compartment: Compartment, build: (value: T) => Extension, value: T): void {
Expand Down Expand Up @@ -187,6 +190,7 @@ export default function ZplCodeMirror({
onChange,
ariaLabel,
placeholderText,
blankFieldHint,
readOnly = false,
highlightLines = NO_LINES,
historyEpoch = 0,
Expand All @@ -203,6 +207,8 @@ export default function ZplCodeMirror({
ariaLabel: string;
/** Shown while the doc is empty (authoring a label from scratch). */
placeholderText: string;
/** Inlay label on every empty ^FD or ^FV slot, none when absent. */
blankFieldHint?: string;
readOnly?: boolean;
/** 0-based doc lines tinted as the canvas selection's emitted source. */
highlightLines?: ReadonlySet<number>;
Expand Down Expand Up @@ -281,7 +287,7 @@ export default function ZplCodeMirror({
highlightCompartment.of(highlightExt(highlightLines)),
sidecarCompartment.of(hideSidecarsExt(hideSidecars)),
commandMarksCompartment.of(commandMarksExt(catalogRow)),
localeCompartment.of(localeExt(ariaLabel, placeholderText)),
localeCompartment.of(localeExt(ariaLabel, placeholderText, blankFieldHint)),
// lintKeymap opens the diagnostics panel, the keyboard's only route to a repair action.
keymap.of([{ key: 'Escape', run: () => onEscapeRef.current?.() ?? false }, ...defaultKeymap, ...historyKeymap, ...foldKeymap, ...lintKeymap]),
EditorView.updateListener.of((u) => {
Expand Down Expand Up @@ -336,9 +342,9 @@ export default function ZplCodeMirror({
const view = viewRef.current;
if (!view) return;
view.dispatch({
effects: localeCompartment.reconfigure(localeExt(ariaLabel, placeholderText)),
effects: localeCompartment.reconfigure(localeExt(ariaLabel, placeholderText, blankFieldHint)),
});
}, [ariaLabel, placeholderText, localeCompartment]);
}, [ariaLabel, placeholderText, blankFieldHint, localeCompartment]);

const mountEpoch = useRef(historyEpoch);
useEffect(() => {
Expand Down
1 change: 1 addition & 0 deletions src/components/Output/ZplSourceEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ export function ZplSourceEditor({
highlightLines={highlightedLines}
historyEpoch={historyEpoch}
placeholderText={t.output.editSourcePlaceholder}
blankFieldHint={readOnly ? undefined : t.output.blankFieldHint}
diagnostics={diagnostics}
hideSidecars={hideSidecars}
insertPage={insertPage}
Expand Down
90 changes: 90 additions & 0 deletions src/lib/zplCmBlankFields.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// @vitest-environment jsdom
import { describe, it, expect, afterEach } from "vitest";
import { EditorView } from "@codemirror/view";
import { EditorState } from "@codemirror/state";
import { zpl } from "./zplLanguage";
import { blankFieldsExt } from "./zplCmBlankFields";

let view: EditorView | null = null;
afterEach(() => {
view?.destroy();
view = null;
});

function open(doc: string, hint?: string): EditorView {
view = new EditorView({ state: EditorState.create({ doc, extensions: [zpl(), blankFieldsExt(hint)] }), parent: document.body });
return view;
}

/** Document offsets carrying the hint. */
function hints(v: EditorView): number[] {
const out: number[] = [];
for (const deco of v.state.facet(EditorView.decorations)) {
(typeof deco === "function" ? deco(v) : deco).between(0, v.state.doc.length, (from, _to, value) => {
if ((value.spec.widget as { text?: string } | undefined)?.text === "placeholder") out.push(from);
});
}
return out;
}

/** The offset right after the n-th `needle`. */
const after = (doc: string, needle: string, n = 1): number => {
let at = -1;
for (let i = 0; i < n; i++) at = doc.indexOf(needle, at + 1);
return at + needle.length;
};

describe("blankFieldsExt", () => {
it("labels an empty field right after its ^FD and leaves a filled one alone", () => {
const doc = "^XA\n^FO10,10^BCN,100^FD^FS\n^FO10,80^A0N,30^FDHELLO^FS\n^XZ";
const v = open(doc, "placeholder");
expect(hints(v)).toEqual([after(doc, "^FD")]);
expect(v.dom.querySelector(".cm-zplBlankHint")?.textContent).toBe("placeholder");
expect(v.state.doc.toString()).not.toContain("placeholder");
});

it("skips a slot the printer fills through ^FN, armed, or bound after the data", () => {
expect(hints(open("^XA\n^FO10,10^FN1^FD^FS\n^FO10,80^FN2^FH_^FD^FS\n^FO10,90^FD^FN3^FS\n^XZ", "placeholder"))).toEqual([]);
});

it("treats a line break as the nothing the printer prints, and labels ^FV like ^FD", () => {
const v = open("^XA\n^FO1,1^FD\n^FS\n^FO1,9^FV^FS\n^FO1,9^FD ^FS\n^XZ", "placeholder");
const doc = v.state.doc.toString();
expect(hints(v)).toEqual([after(doc, "^FD"), after(doc, "^FV")]);
});

it("looks the field up in the tree, not in the visible slice", () => {
const doc = "^XA\n^FO10,10\n^FN1\n^FD^FS\n^XZ";
const v = open(doc, "placeholder");
Object.defineProperty(v, "visibleRanges", { get: () => [{ from: doc.indexOf("^FD"), to: doc.length }] });
expect(hints(v)).toEqual([]);
});

it("puts the QR label after the switch, whichever form it takes", () => {
const doc = "^XA\n^FO1,1^BQN,2,4^FDQA,^FS\n^FO1,9^BQN,2,4^FDD030122,QM,^FS\n^FO1,9^BQN,2,4^FDQA,hi^FS\n^XZ";
expect(hints(open(doc, "placeholder"))).toEqual([after(doc, "QA,"), after(doc, "QM,")]);
});

it("follows a ^CC remap", () => {
const doc = "^XA^CC#\n#FO10,10#FD#FS\n#XZ";
expect(hints(open(doc, "placeholder"))).toEqual([after(doc, "#FD")]);
});

it("labels a field that runs to the end of the document, and reads CRLF text", () => {
const v = open("^XA\r\n^FO1,1^FD^FS\r\n^FO1,9^FD", "placeholder");
const doc = v.state.doc.toString();
expect(hints(v)).toEqual([after(doc, "^FD"), after(doc, "^FD", 2)]);
});

it("clears only the label that is typed into", () => {
const doc = "^XA\n^FO10,10^FD^FS\n^FO10,80^FD^FS\n^XZ";
const v = open(doc, "placeholder");
expect(hints(v)).toHaveLength(2);
v.dispatch({ changes: { from: after(doc, "^FD"), insert: "A" }, userEvent: "input.type" });
expect(hints(v)).toEqual([after(doc, "^FD", 2) + 1]);
});

it("labels nothing without a hint text", () => {
expect(hints(open("^XA^FO1,1^FD^FS^XZ"))).toEqual([]);
});
});
77 changes: 77 additions & 0 deletions src/lib/zplCmBlankFields.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { RangeSetBuilder, type EditorState, type Extension } from '@codemirror/state';
import { Decoration, EditorView, WidgetType, type DecorationSet } from '@codemirror/view';
import { syntaxTree } from '@codemirror/language';
import type { SyntaxNode } from '@lezer/common';
import { qrFdPayloadStart } from '@zplab/core/lib/qrFd';
import { stripDataLineBreaks } from '@zplab/core/lib/zplParser/helpers';
import { commandName, commandOccurrences } from './zplLanguage';

class HintWidget extends WidgetType {
readonly text: string;
constructor(text: string) {
super();
this.text = text;
}
eq(other: HintWidget): boolean {
return other.text === this.text;
}
toDOM(): HTMLElement {
const span = document.createElement('span');
span.className = 'cm-zplBlankHint';
span.textContent = this.text;
return span;
}
/** A click on the label places the caret beside it, as on text. */
ignoreEvent(): boolean {
return false;
}
}

const DATA_COMMANDS = new Set(['^FD', '^FV']);

/** Commands that bound a field: the search for its ^FN and ^BQ stops here. */
const FIELD_BOUNDARY = new Set(['^FO', '^FT', '^FS', '^XA', '^XZ']);

/** The command ids sharing the field of `node`, in either direction. */
function* fieldCommands(state: EditorState, node: SyntaxNode): Generator<string> {
for (const step of ['prevSibling', 'nextSibling'] as const) {
for (let sibling = node[step]; sibling; sibling = sibling[step]) {
const id = commandName(state, sibling.firstChild);
if (id === null) continue;
if (FIELD_BOUNDARY.has(id)) break;
yield id;
}
}
}

/** Where the empty data slot of the data command `node` begins, or null when the field is filled or the printer fills it. */
function blankSlot(state: EditorState, node: SyntaxNode, dataFrom: number): number | null {
const ids = [...fieldCommands(state, node)];
if (ids.includes('^FN')) return null;
// A line break is the one byte the printer drops from field data.
const data = stripDataLineBreaks(state.sliceDoc(dataFrom, node.to));
const start = ids.includes('^BQ') ? qrFdPayloadStart(data) : 0;
return start === data.length ? dataFrom + start : null;
}

function blankHints(view: EditorView, widget: HintWidget): DecorationSet {
const builder = new RangeSetBuilder<Decoration>();
const decoration = Decoration.widget({ widget, side: 1 });
const tree = syntaxTree(view.state);
for (const { from, to } of view.visibleRanges) {
for (const occurrence of commandOccurrences(view.state, from, to)) {
if (!DATA_COMMANDS.has(occurrence.id)) continue;
const node = tree.resolveInner(occurrence.from, 1).parent;
const at = node ? blankSlot(view.state, node, occurrence.to) : null;
if (at !== null && at >= from && at <= to) builder.add(at, at, decoration);
}
}
return builder.finish();
}

/** Labels every empty ^FD or ^FV slot with `hint`, read from the text, so typing into a slot clears its label. */
export function blankFieldsExt(hint: string | undefined): Extension {
if (hint === undefined) return [];
const widget = new HintWidget(hint);
return EditorView.decorations.of((v) => blankHints(v, widget));
}
5 changes: 5 additions & 0 deletions src/lib/zplLanguage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,11 @@ function readCommandName(state: EditorState, token: SyntaxNodeRef): { id: string
return { id: `${token.name === "TildeCmdName" ? "~" : "^"}${name}`, name };
}

/** The canonical id of a command's name node, or null for any other node. */
export function commandName(state: EditorState, node: SyntaxNodeRef | null): string | null {
return node && NAME_NODES.has(node.name) ? readCommandName(state, node).id : null;
}

/** The command whose bytes surround `pos`, or null between commands; the command
* starting at the caret wins over the one ending there. */
export function commandAtCursor(state: EditorState, pos: number): CaretCommand | null {
Expand Down
1 change: 1 addition & 0 deletions src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -994,6 +994,7 @@ const ar = {
catalogNo: 'لا',
catalogNoCursor: 'ضع المؤشر على أمر لمعرفة وظيفته.',
catalogNoMatch: 'لا توجد أوامر تطابق البحث.',
blankFieldHint: 'عنصر نائب',
catalogDismissed: 'تم إخفاء المرجع. انقر على الأمر في الكود لإظهاره مرة أخرى.',
catalogNoEntryFmt: '{cmd} غير موجود في المرجع.',
copy: 'نسخ',
Expand Down
1 change: 1 addition & 0 deletions src/locales/bg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -994,6 +994,7 @@ const bg = {
catalogNo: 'не',
catalogNoCursor: 'Поставете курсора върху команда, за да видите какво прави.',
catalogNoMatch: 'Няма команди, отговарящи на търсенето.',
blankFieldHint: 'запазено място',
catalogDismissed: 'Справката е скрита. Щракнете върху командата в кода, за да я покажете отново.',
catalogNoEntryFmt: '{cmd} не е в справката.',
copy: 'Копирай',
Expand Down
1 change: 1 addition & 0 deletions src/locales/cs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -994,6 +994,7 @@ const cs = {
catalogNo: 'ne',
catalogNoCursor: 'Umístěte kurzor na příkaz a zobrazí se jeho popis.',
catalogNoMatch: 'Žádné příkazy neodpovídají hledání.',
blankFieldHint: 'zástupný text',
catalogDismissed: 'Reference je skrytá. Klikněte na příkaz v kódu a znovu se zobrazí.',
catalogNoEntryFmt: '{cmd} není v referenci.',
copy: 'Kopírovat',
Expand Down
1 change: 1 addition & 0 deletions src/locales/da.ts
Original file line number Diff line number Diff line change
Expand Up @@ -994,6 +994,7 @@ const da = {
catalogNo: 'nej',
catalogNoCursor: 'Placer markøren på en kommando for at se, hvad den gør.',
catalogNoMatch: 'Ingen kommandoer matcher søgningen.',
blankFieldHint: 'pladsholder',
catalogDismissed: 'Referencen er skjult. Klik på kommandoen i koden for at vise den igen.',
catalogNoEntryFmt: '{cmd} findes ikke i referencen.',
copy: 'Kopiér',
Expand Down
1 change: 1 addition & 0 deletions src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1024,6 +1024,7 @@ const de = {
catalogNo: 'nein',
catalogNoCursor: 'Cursor auf einen Befehl setzen, um seine Funktion zu sehen.',
catalogNoMatch: 'Keine Befehle passen zur Suche.',
blankFieldHint: 'Platzhalter',
catalogDismissed: 'Referenz ausgeblendet. Auf den Befehl im Code klicken, um sie wieder einzublenden.',
catalogNoEntryFmt: '{cmd} steht nicht in der Referenz.',
copy: 'Kopieren',
Expand Down
1 change: 1 addition & 0 deletions src/locales/el.ts
Original file line number Diff line number Diff line change
Expand Up @@ -994,6 +994,7 @@ const el = {
catalogNo: 'όχι',
catalogNoCursor: 'Τοποθετήστε τον δρομέα σε μια εντολή για να δείτε τι κάνει.',
catalogNoMatch: 'Καμία εντολή δεν ταιριάζει με την αναζήτηση.',
blankFieldHint: 'δεσμευτικό θέσης',
catalogDismissed: 'Η αναφορά είναι κρυμμένη. Κάντε κλικ στην εντολή στον κώδικα για να εμφανιστεί ξανά.',
catalogNoEntryFmt: '{cmd} δεν υπάρχει στην αναφορά.',
copy: 'Αντιγραφή',
Expand Down
1 change: 1 addition & 0 deletions src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1024,6 +1024,7 @@ const en = {
catalogNo: 'no',
catalogNoCursor: 'Place the cursor on a command to see what it does.',
catalogNoMatch: 'No commands match the search.',
blankFieldHint: 'placeholder',
catalogDismissed: 'Reference hidden. Click the command in the code to show it again.',
catalogNoEntryFmt: '{cmd} is not in the reference.',
copy: 'Copy',
Expand Down
1 change: 1 addition & 0 deletions src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -994,6 +994,7 @@ const es = {
catalogNo: 'no',
catalogNoCursor: 'Coloca el cursor sobre un comando para ver qué hace.',
catalogNoMatch: 'Ningún comando coincide con la búsqueda.',
blankFieldHint: 'marcador de posición',
catalogDismissed: 'Referencia oculta. Haz clic en el comando del código para volver a mostrarla.',
catalogNoEntryFmt: '{cmd} no está en la referencia.',
copy: 'Copiar',
Expand Down
1 change: 1 addition & 0 deletions src/locales/et.ts
Original file line number Diff line number Diff line change
Expand Up @@ -994,6 +994,7 @@ const et = {
catalogNo: 'ei',
catalogNoCursor: 'Aseta kursor käsule, et näha, mida see teeb.',
catalogNoMatch: 'Otsingule ei vasta ükski käsk.',
blankFieldHint: 'kohahoidja',
catalogDismissed: 'Teatmik on peidetud. Klõpsa koodis käsul, et seda uuesti näidata.',
catalogNoEntryFmt: '{cmd} ei ole teatmikus.',
copy: 'Kopeeri',
Expand Down
1 change: 1 addition & 0 deletions src/locales/fa.ts
Original file line number Diff line number Diff line change
Expand Up @@ -994,6 +994,7 @@ const fa = {
catalogNo: 'خیر',
catalogNoCursor: 'مکان‌نما را روی یک دستور قرار دهید تا عملکرد آن را ببینید.',
catalogNoMatch: 'هیچ دستوری با جستجو مطابقت ندارد.',
blankFieldHint: 'جای‌نگه‌دار',
catalogDismissed: 'مرجع پنهان شد. در کد روی دستور کلیک کنید تا دوباره نمایش داده شود.',
catalogNoEntryFmt: '{cmd} در مرجع نیست.',
copy: 'کپی',
Expand Down
1 change: 1 addition & 0 deletions src/locales/fi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -994,6 +994,7 @@ const fi = {
catalogNo: 'ei',
catalogNoCursor: 'Aseta kohdistin komentoon nähdäksesi, mitä se tekee.',
catalogNoMatch: 'Mikään komento ei vastaa hakua.',
blankFieldHint: 'paikkamerkki',
catalogDismissed: 'Viiteopas on piilotettu. Napsauta komentoa koodissa, niin se tulee taas näkyviin.',
catalogNoEntryFmt: '{cmd} ei ole viiteoppaassa.',
copy: 'Kopioi',
Expand Down
1 change: 1 addition & 0 deletions src/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -994,6 +994,7 @@ const fr = {
catalogNo: 'non',
catalogNoCursor: "Placez le curseur sur une commande pour voir ce qu'elle fait.",
catalogNoMatch: 'Aucune commande ne correspond à la recherche.',
blankFieldHint: 'espace réservé',
catalogDismissed: "Référence masquée. Cliquez sur la commande dans le code pour l'afficher à nouveau.",
catalogNoEntryFmt: '{cmd} ne figure pas dans la référence.',
copy: 'Copier',
Expand Down
1 change: 1 addition & 0 deletions src/locales/he.ts
Original file line number Diff line number Diff line change
Expand Up @@ -994,6 +994,7 @@ const he = {
catalogNo: 'לא',
catalogNoCursor: 'הנח את הסמן מעל פקודה כדי לראות מה היא עושה.',
catalogNoMatch: 'אין פקודות התואמות את החיפוש.',
blankFieldHint: 'ממלא מקום',
catalogDismissed: 'המדריך מוסתר. לחץ על הפקודה בקוד כדי להציג אותו שוב.',
catalogNoEntryFmt: '{cmd} לא נמצא במדריך.',
copy: 'העתק',
Expand Down
1 change: 1 addition & 0 deletions src/locales/hr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -994,6 +994,7 @@ const hr = {
catalogNo: 'ne',
catalogNoCursor: 'Postavite pokazivač na naredbu da vidite što radi.',
catalogNoMatch: 'Nijedna naredba ne odgovara pretrazi.',
blankFieldHint: 'rezervirano mjesto',
catalogDismissed: 'Referenca je skrivena. Kliknite naredbu u kodu da je ponovno prikažete.',
catalogNoEntryFmt: '{cmd} nije u referenci.',
copy: 'Kopiraj',
Expand Down
1 change: 1 addition & 0 deletions src/locales/hu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -994,6 +994,7 @@ const hu = {
catalogNo: 'nem',
catalogNoCursor: 'Helyezze a kurzort egy parancsra, hogy lássa, mit csinál.',
catalogNoMatch: 'Nincs a keresésnek megfelelő parancs.',
blankFieldHint: 'helyőrző',
catalogDismissed: 'A referencia rejtve van. Kattintson a parancsra a kódban, hogy újra megjelenjen.',
catalogNoEntryFmt: '{cmd} nincs a referenciában.',
copy: 'Másolás',
Expand Down
1 change: 1 addition & 0 deletions src/locales/it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -994,6 +994,7 @@ const it = {
catalogNo: 'no',
catalogNoCursor: 'Posiziona il cursore su un comando per vedere cosa fa.',
catalogNoMatch: 'Nessun comando corrisponde alla ricerca.',
blankFieldHint: 'segnaposto',
catalogDismissed: 'Riferimento nascosto. Fai clic sul comando nel codice per mostrarlo di nuovo.',
catalogNoEntryFmt: '{cmd} non è nel riferimento.',
copy: 'Copia',
Expand Down
Loading
Loading