feat: publish unlisted GTIN tool
This commit is contained in:
998
site/gtin/js/app.js
Normal file
998
site/gtin/js/app.js
Normal file
@@ -0,0 +1,998 @@
|
||||
/**
|
||||
* Oberflaeche, Scanmodus, Zwischenablage, Verlauf, Wake Lock und PWA.
|
||||
*
|
||||
* Die fachliche Logik liegt in gtin.js, die Codierung in barcode.js. Diese
|
||||
* Datei verdrahtet beides mit dem DOM.
|
||||
*/
|
||||
|
||||
import { eingabeStatus, erzeugeGtin, FEHLERTEXT, nurZiffern } from './gtin.js';
|
||||
import { barcodeGroesse, erzeugeSvgMarkup, kodiereEan } from './barcode.js';
|
||||
import { schritt, istBestaetigungstaste, zaehlerText, zerlegeEingabe } from './liste.js';
|
||||
|
||||
/** @typedef {import('./gtin.js').GtinErgebnis} GtinErgebnis */
|
||||
|
||||
/** @typedef {{ artikel: string, gtin: string, typ: string }} VerlaufEintrag */
|
||||
/** @typedef {{ released: boolean, release: () => Promise<void>, addEventListener: (typ: 'release', hoerer: () => void) => void }} WakeLockLike */
|
||||
|
||||
const VERLAUF_SCHLUESSEL = 'desfoto.gtin.verlauf.v1';
|
||||
const VERLAUF_MAX = 20;
|
||||
const EINGABE_VERZOEGERUNG_MS = 450;
|
||||
const VERLAUF_VERZOEGERUNG_MS = 2500;
|
||||
const SCAN_HINWEIS_MS = 7000;
|
||||
const TOAST_MS = 1800;
|
||||
/** Anteil der Bildschirmhoehe, den der Barcode in der Normalansicht nutzen darf. */
|
||||
const BALKENHOEHE_ANTEIL = 0.45;
|
||||
/** Untergrenze, damit der Barcode auch auf kurzen Anzeigen scanbar bleibt. */
|
||||
const BALKENHOEHE_MIN_PX = 180;
|
||||
|
||||
/**
|
||||
* @param {string} auswahl
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
function element(auswahl) {
|
||||
const gefunden = document.querySelector(auswahl);
|
||||
if (!gefunden) throw new Error(`Pflichtelement fehlt: ${auswahl}`);
|
||||
return /** @type {HTMLElement} */ (gefunden);
|
||||
}
|
||||
|
||||
const input = /** @type {HTMLInputElement} */ (element('#artikelnummer'));
|
||||
const fehlerText = element('#fehler');
|
||||
const ergebnisBereich = element('#ergebnis');
|
||||
const gtinTyp = element('#gtin-typ');
|
||||
const gtinKnopf = element('#gtin');
|
||||
const barcodeBehaelter = element('#barcode');
|
||||
const grossKnopf = element('#gross');
|
||||
const kopierenKnopf = element('#kopieren');
|
||||
const neuKnopf = element('#neu');
|
||||
const verlaufBereich = element('#verlauf');
|
||||
const verlaufListe = element('#verlauf-liste');
|
||||
const verlaufLoeschenKnopf = element('#verlauf-loeschen');
|
||||
const installierenKnopf = element('#installieren');
|
||||
const iosHinweis = element('#ios-hinweis');
|
||||
const offlineHinweis = element('#offline-hinweis');
|
||||
const scanBereich = element('#scan');
|
||||
const scanInnen = element('#scan-innen');
|
||||
const scanBarcodeBehaelter = element('#scan-barcode');
|
||||
const scanGtin = element('#scan-gtin');
|
||||
const scanHinweis = element('#scan-hinweis');
|
||||
const scanZurueckKnopf = element('#scan-zurueck');
|
||||
const toast = element('#toast');
|
||||
const appBereich = element('.app');
|
||||
const listeOeffnenKnopf = element('#liste-oeffnen');
|
||||
const listeBereich = element('#liste');
|
||||
const listeForm = /** @type {HTMLFormElement} */ (element('#liste-form'));
|
||||
const listeErfassen = element('#liste-erfassen');
|
||||
const listeAnzeige = element('#liste-anzeige');
|
||||
const listeEingabe = /** @type {HTMLInputElement} */ (element('#liste-eingabe'));
|
||||
const listeHinzufuegenKnopf = /** @type {HTMLButtonElement} */ (element('#liste-hinzufuegen'));
|
||||
const listeFehler = element('#liste-fehler');
|
||||
const listeEintraege = element('#liste-eintraege');
|
||||
const listeLeer = element('#liste-leer');
|
||||
const listeAnzahl = element('#liste-anzahl');
|
||||
const listeWeiterKnopf = /** @type {HTMLButtonElement} */ (element('#liste-weiter'));
|
||||
const listeSchliessenKnopf = element('#liste-schliessen');
|
||||
const listeZaehler = element('#liste-zaehler');
|
||||
const listeBuehne = element('#liste-buehne');
|
||||
const listeMitte = element('#liste-mitte');
|
||||
const listeBarcodeBehaelter = element('#liste-barcode');
|
||||
const listeGtin = element('#liste-gtin');
|
||||
const listeArtikel = element('#liste-artikel');
|
||||
const listeZurueckKnopf = element('#liste-zurueck');
|
||||
const listeVorKnopf = element('#liste-vor');
|
||||
const listeAendernKnopf = element('#liste-aendern');
|
||||
const listeBeendenKnopf = element('#liste-beenden');
|
||||
|
||||
/** @type {GtinErgebnis | null} */
|
||||
let aktuell = null;
|
||||
/** @type {GtinErgebnis[]} */
|
||||
const liste = [];
|
||||
let listeAktiv = false;
|
||||
let listeAnzeigeAktiv = false;
|
||||
let listeIndex = 0;
|
||||
/** @type {number | undefined} */
|
||||
let eingabeTimer;
|
||||
/** @type {number | undefined} */
|
||||
let verlaufTimer;
|
||||
/** @type {number | undefined} */
|
||||
let toastTimer;
|
||||
/** @type {number | undefined} */
|
||||
let scanHinweisTimer;
|
||||
/** @type {number | undefined} */
|
||||
let groessenTimer;
|
||||
/** @type {WakeLockLike | null} */
|
||||
let wakeLock = null;
|
||||
let scanAktiv = false;
|
||||
/** @type {any} */
|
||||
let installationsAnfrage = null;
|
||||
|
||||
/* ---------------------------------------------------------------- Verlauf -- */
|
||||
|
||||
/**
|
||||
* @param {unknown} eintrag
|
||||
* @returns {eintrag is VerlaufEintrag}
|
||||
*/
|
||||
function istVerlaufEintrag(eintrag) {
|
||||
if (typeof eintrag !== 'object' || eintrag === null) return false;
|
||||
const kandidat = /** @type {Record<string, unknown>} */ (eintrag);
|
||||
return (
|
||||
typeof kandidat.artikel === 'string' &&
|
||||
typeof kandidat.gtin === 'string' &&
|
||||
typeof kandidat.typ === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
/** @returns {VerlaufEintrag[]} */
|
||||
function ladeVerlauf() {
|
||||
try {
|
||||
const roh = window.localStorage.getItem(VERLAUF_SCHLUESSEL);
|
||||
if (!roh) return [];
|
||||
const daten = /** @type {unknown} */ (JSON.parse(roh));
|
||||
if (!Array.isArray(daten)) return [];
|
||||
return daten.filter(istVerlaufEintrag).slice(0, VERLAUF_MAX);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {VerlaufEintrag[]} eintraege */
|
||||
function speichereVerlaufListe(eintraege) {
|
||||
try {
|
||||
window.localStorage.setItem(VERLAUF_SCHLUESSEL, JSON.stringify(eintraege));
|
||||
} catch {
|
||||
/* Speicher gesperrt oder voll: der Verlauf ist optional. */
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {GtinErgebnis} ergebnis */
|
||||
function merkeImVerlauf(ergebnis) {
|
||||
const eintrag = { artikel: ergebnis.eingabe, gtin: ergebnis.gtin, typ: ergebnis.typ };
|
||||
const bisher = ladeVerlauf().filter((vorhanden) => vorhanden.gtin !== eintrag.gtin);
|
||||
speichereVerlaufListe([eintrag, ...bisher].slice(0, VERLAUF_MAX));
|
||||
rendereVerlauf();
|
||||
}
|
||||
|
||||
function rendereVerlauf() {
|
||||
const eintraege = ladeVerlauf();
|
||||
verlaufListe.replaceChildren();
|
||||
for (const eintrag of eintraege) {
|
||||
const punkt = document.createElement('li');
|
||||
const knopf = document.createElement('button');
|
||||
knopf.type = 'button';
|
||||
knopf.className = 'verlauf-eintrag';
|
||||
knopf.dataset.artikel = eintrag.artikel;
|
||||
knopf.dataset.gtin = eintrag.gtin;
|
||||
|
||||
const artikel = document.createElement('span');
|
||||
artikel.className = 'verlauf-artikel';
|
||||
artikel.textContent = eintrag.artikel;
|
||||
|
||||
const pfeil = document.createElement('span');
|
||||
pfeil.className = 'verlauf-pfeil';
|
||||
pfeil.setAttribute('aria-hidden', 'true');
|
||||
pfeil.textContent = '→';
|
||||
|
||||
const nummer = document.createElement('span');
|
||||
nummer.className = 'verlauf-gtin';
|
||||
nummer.textContent = eintrag.gtin;
|
||||
|
||||
knopf.append(artikel, pfeil, nummer);
|
||||
knopf.setAttribute(
|
||||
'aria-label',
|
||||
`Artikelnummer ${eintrag.artikel}, GTIN ${eintrag.gtin} erneut anzeigen`,
|
||||
);
|
||||
punkt.append(knopf);
|
||||
verlaufListe.append(punkt);
|
||||
}
|
||||
verlaufBereich.hidden = eintraege.length === 0;
|
||||
}
|
||||
|
||||
function leereVerlauf() {
|
||||
speichereVerlaufListe([]);
|
||||
rendereVerlauf();
|
||||
zeigeToast('Verlauf gelöscht');
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- Anzeige -- */
|
||||
|
||||
function versteckeFehler() {
|
||||
fehlerText.hidden = true;
|
||||
fehlerText.textContent = '';
|
||||
}
|
||||
|
||||
function zeigeFehler() {
|
||||
fehlerText.textContent = FEHLERTEXT;
|
||||
fehlerText.hidden = false;
|
||||
}
|
||||
|
||||
function versteckeErgebnis() {
|
||||
aktuell = null;
|
||||
ergebnisBereich.hidden = true;
|
||||
barcodeBehaelter.replaceChildren();
|
||||
stoppeVerlaufTimer();
|
||||
}
|
||||
|
||||
/** Verfuegbare Hoehe fuer den Barcode in der Normalansicht. */
|
||||
function normalHoehe() {
|
||||
return Math.max(
|
||||
BALKENHOEHE_MIN_PX,
|
||||
Math.round(window.innerHeight * BALKENHOEHE_ANTEIL),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} behaelter
|
||||
* @param {GtinErgebnis} ergebnis
|
||||
* @param {'normal' | 'scan'} modus
|
||||
*/
|
||||
function rendereBarcode(behaelter, ergebnis, modus) {
|
||||
const symbol = kodiereEan(ergebnis.gtin);
|
||||
const gesamtModule =
|
||||
symbol.quietzone.links + symbol.module + symbol.quietzone.rechts;
|
||||
const gemessen = behaelter.getBoundingClientRect().width || behaelter.clientWidth;
|
||||
const verfuegbareBreite = Math.max(1, Math.round(gemessen || 320));
|
||||
// Im Scanmodus bestimmt die gemessene Flaeche die Groesse (Querformat: volle
|
||||
// Breite und Hoehe). In der Normalansicht deckelt ein Anteil der
|
||||
// Bildschirmhoehe den Barcode, damit die wichtigsten Bedienelemente ohne
|
||||
// Scrollen sichtbar bleiben.
|
||||
const verfuegbareHoehe =
|
||||
modus === 'scan'
|
||||
? Math.max(1, Math.round(behaelter.clientHeight || normalHoehe()))
|
||||
: normalHoehe();
|
||||
const { modulbreite: modulbreitePx, balkenhoeheModule } = barcodeGroesse(
|
||||
verfuegbareBreite,
|
||||
verfuegbareHoehe,
|
||||
gesamtModule,
|
||||
);
|
||||
const svg = erzeugeSvgMarkup(ergebnis.gtin, {
|
||||
modulbreite: modulbreitePx,
|
||||
balkenhoeheModule,
|
||||
});
|
||||
behaelter.innerHTML = svg.markup;
|
||||
}
|
||||
|
||||
function stoppeVerlaufTimer() {
|
||||
if (verlaufTimer !== undefined) {
|
||||
window.clearTimeout(verlaufTimer);
|
||||
verlaufTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Uebernimmt das aktuelle Ergebnis in den Verlauf, sobald es bestaetigt wirkt. */
|
||||
function merkeAktuellesNachKurzerPause() {
|
||||
stoppeVerlaufTimer();
|
||||
verlaufTimer = window.setTimeout(() => {
|
||||
verlaufTimer = undefined;
|
||||
if (aktuell) merkeImVerlauf(aktuell);
|
||||
}, VERLAUF_VERZOEGERUNG_MS);
|
||||
}
|
||||
|
||||
/** @param {GtinErgebnis} ergebnis */
|
||||
function zeigeErgebnis(ergebnis) {
|
||||
aktuell = ergebnis;
|
||||
versteckeFehler();
|
||||
gtinTyp.textContent = ergebnis.typ === 'EAN-8' ? 'GTIN-8' : 'GTIN-13';
|
||||
gtinKnopf.textContent = ergebnis.gtin;
|
||||
ergebnisBereich.hidden = false;
|
||||
rendereBarcode(barcodeBehaelter, ergebnis, 'normal');
|
||||
merkeAktuellesNachKurzerPause();
|
||||
}
|
||||
|
||||
function pruefeUndZeige() {
|
||||
const status = eingabeStatus(input.value);
|
||||
if (status === 'leer') {
|
||||
versteckeFehler();
|
||||
versteckeErgebnis();
|
||||
return;
|
||||
}
|
||||
// Auch eine zu kurze Nummer ist am Ende ungueltig. Der Fehler erscheint erst
|
||||
// nach der Entprellung, waehrend des Tippens also nicht.
|
||||
if (status === 'unvollstaendig' || status === 'ungueltig') {
|
||||
versteckeErgebnis();
|
||||
zeigeFehler();
|
||||
return;
|
||||
}
|
||||
const ergebnis = erzeugeGtin(input.value);
|
||||
if (!ergebnis) {
|
||||
versteckeErgebnis();
|
||||
zeigeFehler();
|
||||
return;
|
||||
}
|
||||
zeigeErgebnis(ergebnis);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- Eingabe -- */
|
||||
|
||||
function stoppeEingabeTimer() {
|
||||
if (eingabeTimer !== undefined) {
|
||||
window.clearTimeout(eingabeTimer);
|
||||
eingabeTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function beiEingabe() {
|
||||
const sauber = nurZiffern(input.value);
|
||||
if (sauber !== input.value) {
|
||||
input.value = sauber;
|
||||
input.setSelectionRange(sauber.length, sauber.length);
|
||||
}
|
||||
stoppeEingabeTimer();
|
||||
if (sauber.length === 0) {
|
||||
versteckeFehler();
|
||||
versteckeErgebnis();
|
||||
return;
|
||||
}
|
||||
if (sauber.length === 7) {
|
||||
pruefeUndZeige();
|
||||
return;
|
||||
}
|
||||
eingabeTimer = window.setTimeout(() => {
|
||||
eingabeTimer = undefined;
|
||||
pruefeUndZeige();
|
||||
}, EINGABE_VERZOEGERUNG_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Uebernimmt eine Artikelnummer in das Eingabefeld.
|
||||
*
|
||||
* @param {string} artikel
|
||||
* @param {{ fokussieren?: boolean }} [optionen] `fokussieren: false` laesst den
|
||||
* Fokus, wo er ist: Ein Tipp auf den Verlauf soll den Barcode zeigen und auf
|
||||
* Mobilgeraeten nicht die Tastatur ueber das Ergebnis legen.
|
||||
*/
|
||||
function uebernimmArtikel(artikel, optionen = {}) {
|
||||
input.value = artikel;
|
||||
stoppeEingabeTimer();
|
||||
pruefeUndZeige();
|
||||
if (optionen.fokussieren ?? true) input.focus();
|
||||
// Der Verlauf steht unter dem Ergebnis: Nach dem Uebernehmen dorthin
|
||||
// scrollen, damit der erzeugte Barcode sichtbar ist.
|
||||
ergebnisBereich.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------- Feld -- */
|
||||
|
||||
/**
|
||||
* Markiert die komplette Artikelnummer.
|
||||
*
|
||||
* Ein Tipp auf das Feld waehlt damit die alte Nummer aus: Die naechste Ziffer
|
||||
* ueberschreibt sie direkt, ohne dass vorher geloescht werden muss.
|
||||
*/
|
||||
function markiereEingabe() {
|
||||
if (input.value === '') {
|
||||
versteckeFehler();
|
||||
return;
|
||||
}
|
||||
input.setSelectionRange(0, input.value.length);
|
||||
}
|
||||
|
||||
/** true, wenn das Feld beim letzten Zeigerdruck bereits den Fokus hatte. */
|
||||
let feldWarFokussiert = false;
|
||||
|
||||
function neueNummer() {
|
||||
stoppeEingabeTimer();
|
||||
input.value = '';
|
||||
versteckeFehler();
|
||||
versteckeErgebnis();
|
||||
input.focus();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ Scanmodus -- */
|
||||
|
||||
/** @returns {{ request: (typ: 'screen') => Promise<WakeLockLike> } | null} */
|
||||
function wakeLockApi() {
|
||||
const api = /** @type {any} */ (navigator).wakeLock;
|
||||
return api && typeof api.request === 'function' ? api : null;
|
||||
}
|
||||
|
||||
function starteWakeLock() {
|
||||
const api = wakeLockApi();
|
||||
if (!api || wakeLock) return;
|
||||
api
|
||||
.request('screen')
|
||||
.then((sentinel) => {
|
||||
wakeLock = sentinel;
|
||||
sentinel.addEventListener('release', () => {
|
||||
if (wakeLock === sentinel) wakeLock = null;
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
wakeLock = null;
|
||||
});
|
||||
}
|
||||
|
||||
function stoppeWakeLock() {
|
||||
const sentinel = wakeLock;
|
||||
wakeLock = null;
|
||||
if (sentinel) sentinel.release().catch(() => undefined);
|
||||
}
|
||||
|
||||
function oeffneScan() {
|
||||
if (!aktuell || scanAktiv) return;
|
||||
scanAktiv = true;
|
||||
scanGtin.textContent = aktuell.gtin;
|
||||
scanBereich.hidden = false;
|
||||
document.body.classList.add('scan-offen');
|
||||
// Der Rest der Oberflaeche wird unerreichbar: Tastatur und Hilfstechnik
|
||||
// bleiben im Scanmodus, weil nur noch der Dialog bedienbar ist.
|
||||
appBereich.toggleAttribute('inert', true);
|
||||
rendereBarcode(scanBarcodeBehaelter, aktuell, 'scan');
|
||||
scanInnen.focus();
|
||||
starteWakeLock();
|
||||
scanHinweisTimer = window.setTimeout(() => {
|
||||
scanHinweisTimer = undefined;
|
||||
if (scanAktiv) scanHinweis.hidden = false;
|
||||
}, SCAN_HINWEIS_MS);
|
||||
merkeImVerlauf(aktuell);
|
||||
stoppeVerlaufTimer();
|
||||
}
|
||||
|
||||
function schliesseScan() {
|
||||
if (!scanAktiv) return;
|
||||
scanAktiv = false;
|
||||
scanBereich.hidden = true;
|
||||
scanHinweis.hidden = true;
|
||||
appBereich.toggleAttribute('inert', false);
|
||||
document.body.classList.remove('scan-offen');
|
||||
if (scanHinweisTimer !== undefined) {
|
||||
window.clearTimeout(scanHinweisTimer);
|
||||
scanHinweisTimer = undefined;
|
||||
}
|
||||
stoppeWakeLock();
|
||||
grossKnopf.focus();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ Listenmodus -- */
|
||||
|
||||
/*
|
||||
* Optionaler Modus: Die Liste liegt als kleiner Link unter dem Werkzeug. Sie
|
||||
* wird in zwei Schritten bedient - Nummern bestaetigen, dann mit Pfeilen,
|
||||
* Pfeiltasten oder Wischen GTIN und Barcode nacheinander anzeigen. Beide
|
||||
* Schritte enden ueber die kleinen Links zurueck in die Einzelansicht.
|
||||
*
|
||||
* Bestaetigt wird ueber das Formular: Auf einem Telefon hat das Numpad keine
|
||||
* Eingabetaste, seine Bestaetigungstaste loest aber das submit-Ereignis aus.
|
||||
* Zusaetzlich liegt unmittelbar neben dem Feld eine sichtbare Schaltflaeche,
|
||||
* damit die Bestaetigung auf keinem Geraet von der Tastatur abhaengt.
|
||||
*/
|
||||
|
||||
function versteckeListenFehler() {
|
||||
listeFehler.hidden = true;
|
||||
listeFehler.textContent = '';
|
||||
}
|
||||
|
||||
function zeigeListenFehler() {
|
||||
listeFehler.textContent = FEHLERTEXT;
|
||||
listeFehler.hidden = false;
|
||||
}
|
||||
|
||||
function rendereListe() {
|
||||
listeEintraege.replaceChildren();
|
||||
liste.forEach((eintrag, index) => {
|
||||
const punkt = document.createElement('li');
|
||||
punkt.className = 'liste-eintrag';
|
||||
|
||||
const nummer = document.createElement('span');
|
||||
nummer.className = 'liste-nr';
|
||||
nummer.textContent = eintrag.eingabe;
|
||||
|
||||
const gtin = document.createElement('span');
|
||||
gtin.className = 'liste-gtin';
|
||||
gtin.textContent = eintrag.gtin;
|
||||
|
||||
const weg = document.createElement('button');
|
||||
weg.type = 'button';
|
||||
weg.className = 'liste-weg';
|
||||
weg.dataset.index = String(index);
|
||||
weg.setAttribute('aria-label', `Artikelnummer ${eintrag.eingabe} aus der Liste entfernen`);
|
||||
weg.textContent = '×';
|
||||
|
||||
punkt.append(nummer, gtin, weg);
|
||||
listeEintraege.append(punkt);
|
||||
});
|
||||
|
||||
listeAnzahl.textContent = String(liste.length);
|
||||
listeLeer.hidden = liste.length > 0;
|
||||
listeWeiterKnopf.disabled = liste.length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uebernimmt die Eingabe in die Liste.
|
||||
*
|
||||
* Alles, was keine Ziffer ist, trennt Nummern: So laesst sich eine ganze Spalte
|
||||
* in einem Zug einfuegen. Die Bestaetigung im wirklich leeren Feld startet
|
||||
* direkt die Anzeige, damit die Erfassung ohne Klick auf "Weiter" endet. Steht
|
||||
* dagegen Text ohne eine einzige Ziffer im Feld, ist das ein Fehler und kein
|
||||
* Start.
|
||||
*/
|
||||
function uebernimmListeneingabe() {
|
||||
const roh = listeEingabe.value;
|
||||
const gruppen = zerlegeEingabe(roh);
|
||||
if (gruppen.length === 0) {
|
||||
if (roh.trim() !== '') {
|
||||
zeigeListenFehler();
|
||||
return;
|
||||
}
|
||||
versteckeListenFehler();
|
||||
if (liste.length > 0) starteListenAnzeige(0);
|
||||
return;
|
||||
}
|
||||
let uebernommen = 0;
|
||||
let verworfen = 0;
|
||||
for (const gruppe of gruppen) {
|
||||
const ergebnis = erzeugeGtin(gruppe);
|
||||
if (ergebnis) {
|
||||
liste.push(ergebnis);
|
||||
uebernommen += 1;
|
||||
} else {
|
||||
verworfen += 1;
|
||||
}
|
||||
}
|
||||
if (uebernommen > 0) {
|
||||
listeEingabe.value = '';
|
||||
rendereListe();
|
||||
listeEintraege.scrollTop = listeEintraege.scrollHeight;
|
||||
// Der Fokus bleibt im Feld: Die Bildschirmtastatur bleibt offen, und die
|
||||
// naechste Nummer laesst sich ohne einen weiteren Tipp eingeben.
|
||||
listeEingabe.focus();
|
||||
}
|
||||
if (verworfen > 0) zeigeListenFehler();
|
||||
else versteckeListenFehler();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bestaetigt die Erfassung ueber das Formular.
|
||||
*
|
||||
* Ein Numpad auf dem Telefon bestaetigt mit seinem eigenen Schluessel
|
||||
* (Haekchen, "Fertig", "Los"); er loest das submit-Ereignis aus. Hier laufen
|
||||
* deshalb Bestaetigungstaste, eingefuegte Spalte und Klick auf "Hinzufuegen"
|
||||
* zusammen.
|
||||
*
|
||||
* @param {SubmitEvent} ereignis
|
||||
*/
|
||||
function beiListenAbsenden(ereignis) {
|
||||
ereignis.preventDefault();
|
||||
// Ein leerer Aufruf ueber die Schaltflaeche springt nicht in die Anzeige:
|
||||
// Wer "Hinzufuegen" tippt, will etwas hinzufuegen. Die Bestaetigungstaste im
|
||||
// leeren Feld bleibt dagegen der schnelle Weg nach vorn.
|
||||
if (ereignis.submitter === listeHinzufuegenKnopf && listeEingabe.value.trim() === '') {
|
||||
listeEingabe.focus();
|
||||
return;
|
||||
}
|
||||
uebernimmListeneingabe();
|
||||
}
|
||||
|
||||
/** Entfernt einen Eintrag; mit der Tastatur bleibt der Fokus in der Liste.
|
||||
*
|
||||
* @param {number} index
|
||||
* @param {boolean} ueberTastatur
|
||||
*/
|
||||
function entferneListenEintrag(index, ueberTastatur) {
|
||||
if (!Number.isInteger(index) || index < 0 || index >= liste.length) return;
|
||||
const [entfernt] = liste.splice(index, 1);
|
||||
rendereListe();
|
||||
zeigeToast(`Artikelnummer ${entfernt.eingabe} entfernt`);
|
||||
if (ueberTastatur) {
|
||||
const naechster = listeEintraege.querySelector(
|
||||
`.liste-weg[data-index="${String(Math.min(index, liste.length - 1))}"]`,
|
||||
);
|
||||
if (naechster instanceof HTMLElement) {
|
||||
naechster.focus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
listeEingabe.focus();
|
||||
}
|
||||
|
||||
/** Zeigt die Richtung des Schritts: der neue Barcode kommt von der Seite.
|
||||
*
|
||||
* @param {number} richtung
|
||||
*/
|
||||
function animiereSchritt(richtung) {
|
||||
listeMitte.classList.remove('liste-schritt-vor', 'liste-schritt-zurueck');
|
||||
// Reflow erzwingt den Neustart der Animation beim schnellen Blaettern.
|
||||
void listeMitte.offsetWidth;
|
||||
listeMitte.classList.add(richtung < 0 ? 'liste-schritt-zurueck' : 'liste-schritt-vor');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} animiert
|
||||
* @param {number} [richtung]
|
||||
*/
|
||||
function zeigeListenEintrag(animiert, richtung = 0) {
|
||||
const eintrag = liste[listeIndex];
|
||||
if (!eintrag) return;
|
||||
listeZaehler.textContent = zaehlerText(listeIndex, liste.length);
|
||||
listeGtin.textContent = eintrag.gtin;
|
||||
listeArtikel.textContent = `Artikelnummer ${eintrag.eingabe}`;
|
||||
rendereBarcode(listeBarcodeBehaelter, eintrag, 'scan');
|
||||
if (animiert) animiereSchritt(richtung);
|
||||
}
|
||||
|
||||
/** @param {number} index */
|
||||
function starteListenAnzeige(index) {
|
||||
if (listeAnzeigeAktiv || liste.length === 0) return;
|
||||
listeAnzeigeAktiv = true;
|
||||
listeIndex = schritt(index, 0, liste.length);
|
||||
listeErfassen.hidden = true;
|
||||
listeAnzeige.hidden = false;
|
||||
zeigeListenEintrag(false);
|
||||
listeMitte.focus();
|
||||
starteWakeLock();
|
||||
}
|
||||
|
||||
/** @param {number} richtung */
|
||||
function blaettere(richtung) {
|
||||
if (!listeAnzeigeAktiv || liste.length === 0) return;
|
||||
listeIndex = schritt(listeIndex, richtung, liste.length);
|
||||
zeigeListenEintrag(true, richtung);
|
||||
}
|
||||
|
||||
/** @param {number} index */
|
||||
function springeZu(index) {
|
||||
if (!listeAnzeigeAktiv || liste.length === 0) return;
|
||||
const neu = schritt(index, 0, liste.length);
|
||||
if (neu === listeIndex) return;
|
||||
const richtung = neu > listeIndex ? 1 : -1;
|
||||
listeIndex = neu;
|
||||
zeigeListenEintrag(true, richtung);
|
||||
}
|
||||
|
||||
/** Zurueck zum Erfassen: Die Liste bleibt erhalten und wird nur bearbeitet. */
|
||||
function zeigeListeErfassen() {
|
||||
listeAnzeigeAktiv = false;
|
||||
listeAnzeige.hidden = true;
|
||||
listeErfassen.hidden = false;
|
||||
stoppeWakeLock();
|
||||
versteckeListenFehler();
|
||||
listeEingabe.value = '';
|
||||
rendereListe();
|
||||
listeEingabe.focus();
|
||||
}
|
||||
|
||||
function oeffneListe() {
|
||||
if (listeAktiv) return;
|
||||
listeAktiv = true;
|
||||
listeBereich.hidden = false;
|
||||
document.body.classList.add('liste-offen');
|
||||
// Der Rest der Oberflaeche wird unerreichbar, solange die Liste offen ist.
|
||||
appBereich.toggleAttribute('inert', true);
|
||||
zeigeListeErfassen();
|
||||
}
|
||||
|
||||
function schliesseListe() {
|
||||
if (!listeAktiv) return;
|
||||
listeAktiv = false;
|
||||
listeAnzeigeAktiv = false;
|
||||
listeBereich.hidden = true;
|
||||
listeErfassen.hidden = false;
|
||||
listeAnzeige.hidden = true;
|
||||
document.body.classList.remove('liste-offen');
|
||||
appBereich.toggleAttribute('inert', false);
|
||||
stoppeWakeLock();
|
||||
listeOeffnenKnopf.focus();
|
||||
}
|
||||
|
||||
/** @type {{ id: number, x: number, y: number } | null} */
|
||||
let geste = null;
|
||||
|
||||
/** @param {PointerEvent} ereignis */
|
||||
function beendeGeste(ereignis) {
|
||||
window.removeEventListener('pointerup', beendeGeste);
|
||||
window.removeEventListener('pointercancel', beendeGeste);
|
||||
const start = geste;
|
||||
geste = null;
|
||||
if (!start || start.id !== ereignis.pointerId || ereignis.type !== 'pointerup') return;
|
||||
const dx = ereignis.clientX - start.x;
|
||||
const dy = ereignis.clientY - start.y;
|
||||
// Erst ab 40 px und nur bei klarer Richtung: sonst waere jeder Tipp ein Blatt.
|
||||
if (Math.abs(dx) < 40 || Math.abs(dx) <= Math.abs(dy)) return;
|
||||
blaettere(dx < 0 ? 1 : -1);
|
||||
}
|
||||
|
||||
/** @param {PointerEvent} ereignis */
|
||||
function starteGeste(ereignis) {
|
||||
if (!listeAnzeigeAktiv || !ereignis.isPrimary) return;
|
||||
// Ein Zug, der auf einem Pfeil beginnt, ist ein Klick auf den Pfeil.
|
||||
if (ereignis.target instanceof Element && ereignis.target.closest('button')) return;
|
||||
geste = { id: ereignis.pointerId, x: ereignis.clientX, y: ereignis.clientY };
|
||||
window.addEventListener('pointerup', beendeGeste);
|
||||
window.addEventListener('pointercancel', beendeGeste);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ Zwischenablage -- */
|
||||
/** @param {string} text @returns {boolean} */
|
||||
function kopiereFallback(text) {
|
||||
const hilfe = document.createElement('textarea');
|
||||
hilfe.value = text;
|
||||
hilfe.setAttribute('readonly', '');
|
||||
hilfe.className = 'kopier-hilfe';
|
||||
document.body.append(hilfe);
|
||||
hilfe.select();
|
||||
let erfolgreich = false;
|
||||
try {
|
||||
erfolgreich = document.execCommand('copy');
|
||||
} catch {
|
||||
erfolgreich = false;
|
||||
}
|
||||
hilfe.remove();
|
||||
return erfolgreich;
|
||||
}
|
||||
|
||||
async function kopiereGtin() {
|
||||
if (!aktuell) return;
|
||||
const text = aktuell.gtin;
|
||||
let erfolgreich = false;
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
erfolgreich = true;
|
||||
}
|
||||
} catch {
|
||||
erfolgreich = false;
|
||||
}
|
||||
if (!erfolgreich) erfolgreich = kopiereFallback(text);
|
||||
zeigeToast(erfolgreich ? 'GTIN kopiert' : 'Kopieren nicht möglich');
|
||||
merkeImVerlauf(aktuell);
|
||||
stoppeVerlaufTimer();
|
||||
}
|
||||
|
||||
/** @param {string} text */
|
||||
function zeigeToast(text) {
|
||||
toast.textContent = text;
|
||||
toast.hidden = false;
|
||||
if (toastTimer !== undefined) window.clearTimeout(toastTimer);
|
||||
toastTimer = window.setTimeout(() => {
|
||||
toastTimer = undefined;
|
||||
toast.hidden = true;
|
||||
}, TOAST_MS);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ PWA -- */
|
||||
|
||||
function registriereServiceWorker() {
|
||||
if (!('serviceWorker' in navigator)) return;
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker
|
||||
.register('./sw.js', { scope: './' })
|
||||
.catch((fehler) => window.console.warn('Service Worker nicht registriert.', fehler));
|
||||
});
|
||||
}
|
||||
|
||||
function aktualisiereOfflineHinweis() {
|
||||
offlineHinweis.hidden = navigator.onLine !== false;
|
||||
}
|
||||
|
||||
function zeigeInstallationsmoeglichkeit() {
|
||||
const standalone =
|
||||
window.matchMedia('(display-mode: standalone)').matches ||
|
||||
/** @type {any} */ (navigator).standalone === true;
|
||||
const istIos =
|
||||
/iPad|iPhone|iPod/.test(navigator.userAgent) ||
|
||||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
iosHinweis.hidden = !istIos || standalone;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- Ereignisse -- */
|
||||
|
||||
function verbindeEreignisse() {
|
||||
input.addEventListener('input', beiEingabe);
|
||||
// Fokussieren markiert den gesamten Inhalt (direktes Ueberschreiben), auch
|
||||
// wenn der Browser das Caret nach dem Tippen selbst setzt.
|
||||
input.addEventListener('pointerdown', () => {
|
||||
feldWarFokussiert = document.activeElement === input;
|
||||
});
|
||||
input.addEventListener('focus', markiereEingabe);
|
||||
input.addEventListener('click', () => {
|
||||
if (!feldWarFokussiert) markiereEingabe();
|
||||
});
|
||||
input.addEventListener('blur', () => {
|
||||
feldWarFokussiert = false;
|
||||
});
|
||||
|
||||
gtinKnopf.addEventListener('click', () => void kopiereGtin());
|
||||
kopierenKnopf.addEventListener('click', () => void kopiereGtin());
|
||||
grossKnopf.addEventListener('click', oeffneScan);
|
||||
neuKnopf.addEventListener('click', neueNummer);
|
||||
scanZurueckKnopf.addEventListener('click', schliesseScan);
|
||||
verlaufLoeschenKnopf.addEventListener('click', leereVerlauf);
|
||||
|
||||
listeOeffnenKnopf.addEventListener('click', oeffneListe);
|
||||
listeSchliessenKnopf.addEventListener('click', schliesseListe);
|
||||
listeBeendenKnopf.addEventListener('click', schliesseListe);
|
||||
listeAendernKnopf.addEventListener('click', zeigeListeErfassen);
|
||||
listeWeiterKnopf.addEventListener('click', () => {
|
||||
// Eine noch nicht bestaetigte Nummer wird beim Wechsel uebernommen.
|
||||
if (listeEingabe.value.trim() !== '') {
|
||||
uebernimmListeneingabe();
|
||||
// Eine abgelehnte Nummer haelt die Erfassung offen: Der Fehler muss
|
||||
// gelesen werden koennen, bevor es weitergeht.
|
||||
if (!listeFehler.hidden) return;
|
||||
}
|
||||
// Ein leerer Aufruf mit alter Meldung im Feld raeumt sie weg und startet.
|
||||
versteckeListenFehler();
|
||||
starteListenAnzeige(0);
|
||||
});
|
||||
listeZurueckKnopf.addEventListener('click', () => blaettere(-1));
|
||||
listeVorKnopf.addEventListener('click', () => blaettere(1));
|
||||
listeBuehne.addEventListener('pointerdown', starteGeste);
|
||||
listeMitte.addEventListener('animationend', () => {
|
||||
listeMitte.classList.remove('liste-schritt-vor', 'liste-schritt-zurueck');
|
||||
});
|
||||
listeEingabe.addEventListener('keydown', (ereignis) => {
|
||||
if (!istBestaetigungstaste(ereignis)) return;
|
||||
// Selbst bestaetigen und die Standardaktion unterdruecken: Sonst kaeme
|
||||
// dieselbe Nummer ueber Tastendruck und submit zweimal an.
|
||||
ereignis.preventDefault();
|
||||
uebernimmListeneingabe();
|
||||
});
|
||||
listeForm.addEventListener('submit', beiListenAbsenden);
|
||||
// Die Schaltflaeche darf den Fokus nicht aus dem Feld ziehen: So bleibt die
|
||||
// Bildschirmtastatur zwischen zwei Nummern offen.
|
||||
listeHinzufuegenKnopf.addEventListener('pointerdown', (ereignis) => {
|
||||
ereignis.preventDefault();
|
||||
});
|
||||
listeEingabe.addEventListener('input', () => {
|
||||
if (!listeFehler.hidden) versteckeListenFehler();
|
||||
});
|
||||
listeEintraege.addEventListener('click', (ereignis) => {
|
||||
const ziel = ereignis.target instanceof Element ? ereignis.target.closest('.liste-weg') : null;
|
||||
if (!(ziel instanceof HTMLElement)) return;
|
||||
entferneListenEintrag(Number(ziel.dataset.index), ereignis.detail === 0);
|
||||
});
|
||||
|
||||
verlaufListe.addEventListener('click', (ereignis) => {
|
||||
const ziel = ereignis.target instanceof Element ? ereignis.target.closest('.verlauf-eintrag') : null;
|
||||
if (!(ziel instanceof HTMLElement)) return;
|
||||
const artikel = ziel.dataset.artikel ?? '';
|
||||
if (artikel) {
|
||||
// Mit der Tastatur aktiviert (Enter/Leertaste): Fokus ins Feld, damit
|
||||
// direkt weitergetippt werden kann. Mit dem Finger bleibt der Fokus
|
||||
// ausserhalb, sonst verdeckt die Bildschirmtastatur den Barcode.
|
||||
uebernimmArtikel(artikel, { fokussieren: ereignis.detail === 0 });
|
||||
}
|
||||
});
|
||||
|
||||
installierenKnopf.addEventListener('click', () => {
|
||||
if (!installationsAnfrage) return;
|
||||
installationsAnfrage.prompt();
|
||||
Promise.resolve(installationsAnfrage.userChoice).catch(() => undefined);
|
||||
installationsAnfrage = null;
|
||||
installierenKnopf.hidden = true;
|
||||
});
|
||||
|
||||
window.addEventListener('beforeinstallprompt', (ereignis) => {
|
||||
ereignis.preventDefault();
|
||||
installationsAnfrage = ereignis;
|
||||
installierenKnopf.hidden = false;
|
||||
});
|
||||
window.addEventListener('appinstalled', () => {
|
||||
installationsAnfrage = null;
|
||||
installierenKnopf.hidden = true;
|
||||
iosHinweis.hidden = true;
|
||||
zeigeToast('App installiert');
|
||||
});
|
||||
|
||||
window.addEventListener('online', aktualisiereOfflineHinweis);
|
||||
window.addEventListener('offline', aktualisiereOfflineHinweis);
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible' && (scanAktiv || listeAnzeigeAktiv)) {
|
||||
starteWakeLock();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
if (groessenTimer !== undefined) window.clearTimeout(groessenTimer);
|
||||
groessenTimer = window.setTimeout(() => {
|
||||
groessenTimer = undefined;
|
||||
if (aktuell) {
|
||||
rendereBarcode(barcodeBehaelter, aktuell, 'normal');
|
||||
if (scanAktiv) rendereBarcode(scanBarcodeBehaelter, aktuell, 'scan');
|
||||
}
|
||||
if (listeAnzeigeAktiv) {
|
||||
const eintrag = liste[listeIndex];
|
||||
if (eintrag) rendereBarcode(listeBarcodeBehaelter, eintrag, 'scan');
|
||||
}
|
||||
}, 150);
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (ereignis) => {
|
||||
if (listeAktiv) {
|
||||
if (ereignis.key === 'Escape') {
|
||||
ereignis.preventDefault();
|
||||
schliesseListe();
|
||||
return;
|
||||
}
|
||||
if (!listeAnzeigeAktiv) return;
|
||||
switch (ereignis.key) {
|
||||
case 'ArrowRight':
|
||||
case 'ArrowDown':
|
||||
case 'PageDown':
|
||||
ereignis.preventDefault();
|
||||
blaettere(1);
|
||||
return;
|
||||
case 'ArrowLeft':
|
||||
case 'ArrowUp':
|
||||
case 'PageUp':
|
||||
ereignis.preventDefault();
|
||||
blaettere(-1);
|
||||
return;
|
||||
case 'Home':
|
||||
ereignis.preventDefault();
|
||||
springeZu(0);
|
||||
return;
|
||||
case 'End':
|
||||
ereignis.preventDefault();
|
||||
springeZu(liste.length - 1);
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (scanAktiv) {
|
||||
if (ereignis.key === 'Escape') {
|
||||
ereignis.preventDefault();
|
||||
schliesseScan();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const ziel = ereignis.target;
|
||||
const imEingabefeld = ziel === input;
|
||||
// Auf Schaltflaechen und Links gilt die native Tastenaktivierung.
|
||||
const aufBedienelement =
|
||||
ziel instanceof HTMLElement &&
|
||||
!imEingabefeld &&
|
||||
(ziel.tagName === 'BUTTON' || ziel.tagName === 'A' || ziel.isContentEditable);
|
||||
|
||||
if (ereignis.key === 'Enter') {
|
||||
if (imEingabefeld) {
|
||||
ereignis.preventDefault();
|
||||
stoppeEingabeTimer();
|
||||
if (!aktuell || aktuell.eingabe !== nurZiffern(input.value)) pruefeUndZeige();
|
||||
if (aktuell) oeffneScan();
|
||||
} else if (!aufBedienelement && aktuell) {
|
||||
ereignis.preventDefault();
|
||||
oeffneScan();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (ereignis.key === 'Escape') {
|
||||
if (!aufBedienelement && aktuell) {
|
||||
ereignis.preventDefault();
|
||||
neueNummer();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (ereignis.key === 'Backspace' && !aufBedienelement && !imEingabefeld && aktuell) {
|
||||
ereignis.preventDefault();
|
||||
neueNummer();
|
||||
return;
|
||||
}
|
||||
|
||||
const kopiertastenkombination =
|
||||
(ereignis.ctrlKey || ereignis.metaKey) && ereignis.key.toLowerCase() === 'c';
|
||||
if (kopiertastenkombination && aktuell) {
|
||||
const auswahl = window.getSelection()?.toString() ?? '';
|
||||
const eingabeAuswahl =
|
||||
input.selectionStart !== null && input.selectionStart !== input.selectionEnd;
|
||||
if (auswahl === '' && !eingabeAuswahl) {
|
||||
ereignis.preventDefault();
|
||||
void kopiereGtin();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function starte() {
|
||||
verbindeEreignisse();
|
||||
rendereVerlauf();
|
||||
aktualisiereOfflineHinweis();
|
||||
zeigeInstallationsmoeglichkeit();
|
||||
registriereServiceWorker();
|
||||
}
|
||||
|
||||
starte();
|
||||
343
site/gtin/js/barcode.js
Normal file
343
site/gtin/js/barcode.js
Normal file
@@ -0,0 +1,343 @@
|
||||
/**
|
||||
* EAN-Codierung und SVG-Erzeugung.
|
||||
*
|
||||
* Die Datei ist bewusst frei von DOM-Zugriffen: sie liefert eine reine
|
||||
* Bitfolge (1 = Balken, 0 = Luecke) und daraus ein SVG-Markup. Die
|
||||
* Pruefzifferpruefung aus gtin.js wird vor jeder Codierung wiederholt, damit
|
||||
* kein Barcode fuer eine inkonsistente Nummer entstehen kann.
|
||||
*/
|
||||
|
||||
import { gtinGueltig } from './gtin.js';
|
||||
|
||||
/** L-Code (ungerade Paritaet), linke Haelfte. */
|
||||
const L_CODE = [
|
||||
'0001101',
|
||||
'0011001',
|
||||
'0010011',
|
||||
'0111101',
|
||||
'0100011',
|
||||
'0110001',
|
||||
'0101111',
|
||||
'0111011',
|
||||
'0110111',
|
||||
'0001011',
|
||||
];
|
||||
|
||||
/** G-Code (gerade Paritaet), linke Haelfte bei EAN-13. */
|
||||
const G_CODE = [
|
||||
'0100111',
|
||||
'0110011',
|
||||
'0011011',
|
||||
'0100001',
|
||||
'0011101',
|
||||
'0111001',
|
||||
'0000101',
|
||||
'0010001',
|
||||
'0001001',
|
||||
'0010111',
|
||||
];
|
||||
|
||||
/** R-Code, rechte Haelfte (Komplement des L-Codes). */
|
||||
const R_CODE = [
|
||||
'1110010',
|
||||
'1100110',
|
||||
'1101100',
|
||||
'1000010',
|
||||
'1011100',
|
||||
'1001110',
|
||||
'1010000',
|
||||
'1000100',
|
||||
'1001000',
|
||||
'1110100',
|
||||
];
|
||||
|
||||
/**
|
||||
* Paritaetsmuster der linken Gruppe in Abhaengigkeit der ersten Ziffer
|
||||
* (EAN-13). Die erste Ziffer wird selbst nicht als Balken codiert.
|
||||
*/
|
||||
const EAN13_PARITAET = [
|
||||
'LLLLLL',
|
||||
'LLGLGG',
|
||||
'LLGGLG',
|
||||
'LLGGGL',
|
||||
'LGLLGG',
|
||||
'LGGLLG',
|
||||
'LGGGLL',
|
||||
'LGLGLG',
|
||||
'LGLGGL',
|
||||
'LGGLGL',
|
||||
];
|
||||
|
||||
const START_UND_ENDE = '101';
|
||||
const MITTE = '01010';
|
||||
|
||||
/** Ruhezonen in Modulen (links/rechts). */
|
||||
export const QUIETZONE = {
|
||||
'EAN-8': { links: 7, rechts: 7 },
|
||||
'EAN-13': { links: 11, rechts: 7 },
|
||||
};
|
||||
|
||||
/** Standardhoehe der Balken in Modulen. */
|
||||
export const BALKENHOEHE_MODULE = 62;
|
||||
|
||||
/** Verlaengerung der Rand- und Mittelbalken nach unten, in Modulen. */
|
||||
export const GUARD_ZUSATZ_MODULE = 6;
|
||||
|
||||
/** Reservierte Hoehe fuer die Klartextzeile, in Modulen. */
|
||||
export const TEXT_HOEHE_MODULE = 13;
|
||||
|
||||
/** Schriftgroesse des Klartexts, in Modulen. */
|
||||
export const TEXT_GROESSE_MODULE = 9;
|
||||
|
||||
/**
|
||||
* @typedef {object} Textgruppe
|
||||
* @property {string} text anzuzeigende Ziffern
|
||||
* @property {number} start Startposition im Symbol (Modulindex)
|
||||
* @property {number} breite Breite der Gruppe in Modulen
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} EanSymbol
|
||||
* @property {string} gtin validierte GTIN
|
||||
* @property {'EAN-8' | 'EAN-13'} typ Barcode-Typ
|
||||
* @property {string} bits Bitfolge (1 = Balken)
|
||||
* @property {number} module Anzahl der Symbolmodule ohne Ruhezonen
|
||||
* @property {{ links: number, rechts: number }} quietzone Ruhezonen in Modulen
|
||||
* @property {Textgruppe[]} textgruppen Klartext unter dem Symbol
|
||||
* @property {string | null} fuehrendeZiffer erste Ziffer (nur EAN-13)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Codiert eine vollstaendige GTIN als EAN-8 oder EAN-13.
|
||||
*
|
||||
* @param {unknown} gtin vollstaendige GTIN (8 oder 13 Ziffern)
|
||||
* @returns {EanSymbol}
|
||||
* @throws {Error} wenn die GTIN ungueltig ist oder die Codierung nicht aufgeht
|
||||
*/
|
||||
export function kodiereEan(gtin) {
|
||||
const wert = String(gtin ?? '');
|
||||
if (!gtinGueltig(wert)) {
|
||||
throw new Error(`Ungültige GTIN: ${wert}`);
|
||||
}
|
||||
const typ = wert.length === 8 ? 'EAN-8' : 'EAN-13';
|
||||
|
||||
/** @type {string} */
|
||||
let links;
|
||||
/** @type {string} */
|
||||
let rechts;
|
||||
/** @type {Textgruppe[]} */
|
||||
let textgruppen;
|
||||
let fuehrendeZiffer = null;
|
||||
|
||||
if (typ === 'EAN-8') {
|
||||
links = '';
|
||||
for (let i = 0; i < 4; i += 1) links += L_CODE[Number(wert[i])];
|
||||
rechts = '';
|
||||
for (let i = 4; i < 8; i += 1) rechts += R_CODE[Number(wert[i])];
|
||||
textgruppen = [
|
||||
{ text: wert.slice(0, 4), start: 3, breite: 28 },
|
||||
{ text: wert.slice(4, 8), start: 36, breite: 28 },
|
||||
];
|
||||
} else {
|
||||
const paritaet = EAN13_PARITAET[Number(wert[0])];
|
||||
links = '';
|
||||
for (let i = 1; i <= 6; i += 1) {
|
||||
const ziffer = Number(wert[i]);
|
||||
links += paritaet[i - 1] === 'L' ? L_CODE[ziffer] : G_CODE[ziffer];
|
||||
}
|
||||
rechts = '';
|
||||
for (let i = 7; i < 13; i += 1) rechts += R_CODE[Number(wert[i])];
|
||||
textgruppen = [
|
||||
{ text: wert.slice(1, 7), start: 3, breite: 42 },
|
||||
{ text: wert.slice(7, 13), start: 50, breite: 42 },
|
||||
];
|
||||
fuehrendeZiffer = wert[0];
|
||||
}
|
||||
|
||||
const bits = START_UND_ENDE + links + MITTE + rechts + START_UND_ENDE;
|
||||
const erwartet = typ === 'EAN-8' ? 67 : 95;
|
||||
if (bits.length !== erwartet || !/^[01]+$/.test(bits)) {
|
||||
throw new Error(`EAN-Codierung fehlerhaft: ${bits.length} statt ${erwartet} Module.`);
|
||||
}
|
||||
|
||||
return {
|
||||
gtin: wert,
|
||||
typ,
|
||||
bits,
|
||||
module: bits.length,
|
||||
quietzone: QUIETZONE[typ],
|
||||
textgruppen,
|
||||
fuehrendeZiffer,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Groesste ganzzahlige Modulbreite, die in die verfuegbare Breite passt.
|
||||
*
|
||||
* @param {number} verfuegbareBreitePx
|
||||
* @param {number} moduleGesamt Anzahl Module inklusive Ruhezonen
|
||||
* @returns {number} ganzzahlige Modulbreite in Pixeln, mindestens 1
|
||||
*/
|
||||
export function modulbreite(verfuegbareBreitePx, moduleGesamt) {
|
||||
if (!Number.isFinite(verfuegbareBreitePx) || moduleGesamt <= 0) return 1;
|
||||
return Math.max(1, Math.floor(verfuegbareBreitePx / moduleGesamt));
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximale Balkenhoehe (in Modulen) fuer eine verfuegbare Anzeigehoehe.
|
||||
*
|
||||
* @param {number} verfuegbareHoehePx
|
||||
* @param {number} modulbreitePx
|
||||
* @param {number} [minimum] kleinste sinnvolle Balkenhoehe in Modulen
|
||||
* @returns {number}
|
||||
*/
|
||||
export function balkenhoeheFuerHoehe(verfuegbareHoehePx, modulbreitePx, minimum = 30) {
|
||||
if (!Number.isFinite(verfuegbareHoehePx) || verfuegbareHoehePx <= 0 || modulbreitePx <= 0) {
|
||||
return BALKENHOEHE_MODULE;
|
||||
}
|
||||
const reserviert = GUARD_ZUSATZ_MODULE + TEXT_HOEHE_MODULE;
|
||||
const moeglich = Math.floor(verfuegbareHoehePx / modulbreitePx) - reserviert;
|
||||
return Math.max(minimum, moeglich);
|
||||
}
|
||||
|
||||
/** Kleinste sinnvolle Balkenhoehe in Modulen. */
|
||||
export const MIN_BALKENHOEHE_MODULE = 30;
|
||||
|
||||
/**
|
||||
* Waehlt Modulbreite und Balkenhoehe fuer eine verfuegbare Flaeche.
|
||||
*
|
||||
* Gesucht ist die groesste ganzzahlige Modulbreite, mit der der komplette
|
||||
* Barcode (Balken, Guard-Zusatz und Klartextzeile) sowohl in die Breite als
|
||||
* auch in die Hoehe passt. So fuellt der Barcode die Anzeige bestmoeglich aus,
|
||||
* ohne abgeschnitten zu werden; die Modulbreite bleibt ganzzahlig und damit
|
||||
* gestochen scharf.
|
||||
*
|
||||
* @param {number} verfuegbareBreitePx
|
||||
* @param {number} verfuegbareHoehePx
|
||||
* @param {number} moduleGesamt Anzahl der Module inklusive Ruhezonen
|
||||
* @param {{ minimumBalkenhoehe?: number, maximumBalkenhoehe?: number }} [optionen]
|
||||
* @returns {{ modulbreite: number, balkenhoeheModule: number }}
|
||||
*/
|
||||
export function barcodeGroesse(verfuegbareBreitePx, verfuegbareHoehePx, moduleGesamt, optionen = {}) {
|
||||
const minimum = Math.max(
|
||||
1,
|
||||
Math.round(optionen.minimumBalkenhoehe ?? MIN_BALKENHOEHE_MODULE),
|
||||
);
|
||||
const maximum = Math.max(
|
||||
minimum,
|
||||
Math.round(optionen.maximumBalkenhoehe ?? BALKENHOEHE_MODULE),
|
||||
);
|
||||
const reserviert = GUARD_ZUSATZ_MODULE + TEXT_HOEHE_MODULE;
|
||||
const breite =
|
||||
Number.isFinite(verfuegbareBreitePx) && verfuegbareBreitePx > 0
|
||||
? Math.floor(verfuegbareBreitePx)
|
||||
: 320;
|
||||
const hoehe =
|
||||
Number.isFinite(verfuegbareHoehePx) && verfuegbareHoehePx > 0
|
||||
? Math.floor(verfuegbareHoehePx)
|
||||
: 320;
|
||||
const module = Number.isFinite(moduleGesamt) && moduleGesamt > 0 ? Math.floor(moduleGesamt) : 1;
|
||||
|
||||
const groessteBreite = Math.max(1, Math.floor(breite / module));
|
||||
for (let kandidat = groessteBreite; kandidat >= 1; kandidat -= 1) {
|
||||
const platz = Math.floor(hoehe / kandidat) - reserviert;
|
||||
if (kandidat === 1 || platz >= minimum) {
|
||||
return {
|
||||
modulbreite: kandidat,
|
||||
balkenhoeheModule: Math.min(maximum, Math.max(minimum, platz)),
|
||||
};
|
||||
}
|
||||
}
|
||||
return { modulbreite: 1, balkenhoeheModule: minimum };
|
||||
}
|
||||
|
||||
/**
|
||||
* Zerlegt die Bitfolge in zusammenhaengende Balken.
|
||||
*
|
||||
* @param {string} bits
|
||||
* @returns {{ start: number, breite: number }[]}
|
||||
*/
|
||||
function balkenLaeufe(bits) {
|
||||
/** @type {{ start: number, breite: number }[]} */
|
||||
const laeufe = [];
|
||||
let start = -1;
|
||||
for (let i = 0; i < bits.length; i += 1) {
|
||||
if (bits[i] === '1') {
|
||||
if (start === -1) start = i;
|
||||
} else if (start !== -1) {
|
||||
laeufe.push({ start, breite: i - start });
|
||||
start = -1;
|
||||
}
|
||||
}
|
||||
if (start !== -1) laeufe.push({ start, breite: bits.length - start });
|
||||
return laeufe;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt das SVG-Markup fuer einen EAN-Barcode.
|
||||
*
|
||||
* Alle Laengen werden in ganzen Modulen gerechnet; die Pixelbreite ergibt sich
|
||||
* aus einem ganzzahligen Vielfachen der Modulbreite. Dadurch entstehen harte
|
||||
* Kanten ohne Teilpixel.
|
||||
*
|
||||
* @param {unknown} gtin vollstaendige GTIN
|
||||
* @param {{ modulbreite?: number, balkenhoeheModule?: number }} [optionen]
|
||||
* @returns {{ markup: string, breite: number, hoehe: number, modulbreite: number, symbol: EanSymbol }}
|
||||
*/
|
||||
export function erzeugeSvgMarkup(gtin, optionen = {}) {
|
||||
const symbol = kodiereEan(gtin);
|
||||
const modulbreitePx = Math.max(1, Math.round(optionen.modulbreite ?? 3));
|
||||
const balkenhoehe = Math.max(
|
||||
20,
|
||||
Math.round(optionen.balkenhoeheModule ?? BALKENHOEHE_MODULE),
|
||||
);
|
||||
|
||||
const { links, rechts } = symbol.quietzone;
|
||||
const gesamtModule = links + symbol.module + rechts;
|
||||
const hoeheModule = balkenhoehe + GUARD_ZUSATZ_MODULE + TEXT_HOEHE_MODULE;
|
||||
const breitePx = gesamtModule * modulbreitePx;
|
||||
const hoehePx = hoeheModule * modulbreitePx;
|
||||
|
||||
const linkeGruppeEnde = 3 + (symbol.typ === 'EAN-8' ? 28 : 42);
|
||||
const guardBereiche = [
|
||||
{ start: 0, ende: 3 },
|
||||
{ start: linkeGruppeEnde, ende: linkeGruppeEnde + 5 },
|
||||
{ start: symbol.module - 3, ende: symbol.module },
|
||||
];
|
||||
const istGuard = (/** @type {number} */ start, /** @type {number} */ breite) =>
|
||||
guardBereiche.some((bereich) => start >= bereich.start && start + breite <= bereich.ende);
|
||||
|
||||
const rechtecke = balkenLaeufe(symbol.bits)
|
||||
.map(({ start, breite }) => {
|
||||
const hoehe = balkenhoehe + (istGuard(start, breite) ? GUARD_ZUSATZ_MODULE : 0);
|
||||
return `<rect x="${links + start}" y="0" width="${breite}" height="${hoehe}" fill="#000000"/>`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
const grundlinie = balkenhoehe + GUARD_ZUSATZ_MODULE + TEXT_GROESSE_MODULE;
|
||||
const text = symbol.textgruppen
|
||||
.map(
|
||||
(gruppe) =>
|
||||
`<text x="${links + gruppe.start + gruppe.breite / 2}" y="${grundlinie}" ` +
|
||||
`font-size="${TEXT_GROESSE_MODULE}" text-anchor="middle" ` +
|
||||
`textLength="${gruppe.breite}" lengthAdjust="spacing" fill="#000000">${gruppe.text}</text>`,
|
||||
)
|
||||
.join('');
|
||||
const fuehrend = symbol.fuehrendeZiffer
|
||||
? `<text x="${links - 4}" y="${grundlinie}" font-size="${TEXT_GROESSE_MODULE}" ` +
|
||||
`text-anchor="middle" fill="#000000">${symbol.fuehrendeZiffer}</text>`
|
||||
: '';
|
||||
|
||||
const beschriftung = `${symbol.typ}-Barcode ${symbol.gtin}`;
|
||||
const markup =
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${breitePx}" height="${hoehePx}" ` +
|
||||
`viewBox="0 0 ${gesamtModule} ${hoeheModule}" preserveAspectRatio="xMidYMid meet" ` +
|
||||
`role="img" aria-label="${beschriftung}" focusable="false">` +
|
||||
`<title>${beschriftung}</title>` +
|
||||
`<rect x="0" y="0" width="${gesamtModule}" height="${hoeheModule}" fill="#ffffff"/>` +
|
||||
`<g shape-rendering="crispEdges">${rechtecke}</g>` +
|
||||
`<g font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">${fuehrend}${text}</g>` +
|
||||
`</svg>`;
|
||||
|
||||
return { markup, breite: breitePx, hoehe: hoehePx, modulbreite: modulbreitePx, symbol };
|
||||
}
|
||||
162
site/gtin/js/gtin.js
Normal file
162
site/gtin/js/gtin.js
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Fachliche Logik des GTIN-Generators.
|
||||
*
|
||||
* Diese Datei enthaelt ausschliesslich die Nummernkreis-Regeln, die
|
||||
* Pruefziffernberechnung und die Validierung. Sie kennt weder DOM noch
|
||||
* Oberflaeche und ist deshalb direkt testbar.
|
||||
*
|
||||
* Regeln (belegte Formate):
|
||||
* 3 Ziffern -> links mit 0 auf 4 Stellen auffuellen -> Praefix 290 -> EAN-8
|
||||
* 4 Ziffern -> Praefix 290 -> EAN-8
|
||||
* 7 Ziffern -> Praefix 20090 -> EAN-13
|
||||
* alles andere ist ungueltig.
|
||||
*/
|
||||
|
||||
/** @typedef {'EAN-8' | 'EAN-13'} GtinTyp */
|
||||
|
||||
/**
|
||||
* @typedef {object} GtinErgebnis
|
||||
* @property {string} eingabe normalisierte Artikelnummer (3, 4 oder 7 Stellen)
|
||||
* @property {string} artikelblock Artikelblock mit fuehrender Null (4 Stellen)
|
||||
* @property {string} praefix Nummernkreis-Praefix
|
||||
* @property {string} basis GTIN ohne Pruefziffer
|
||||
* @property {string} pruefziffer berechnete Pruefziffer
|
||||
* @property {string} gtin vollstaendige GTIN inklusive Pruefziffer
|
||||
* @property {GtinTyp} typ Barcode-Typ
|
||||
* @property {number} laenge GTIN-Laenge (8 oder 13)
|
||||
*/
|
||||
|
||||
/** Fehlertext fuer die Oberflaeche (bewusst genau ein Text). */
|
||||
export const FEHLERTEXT =
|
||||
'Keine gültige Artikelnummer. Erwartet werden 3, 4 oder 7 Stellen.';
|
||||
|
||||
/** Praefix des kurzen Nummernkreises (EAN-8). */
|
||||
export const PRAEFIX_KURZ = '290';
|
||||
|
||||
/** Praefix des langen Nummernkreises (EAN-13). */
|
||||
export const PRAEFIX_LANG = '20090';
|
||||
|
||||
/**
|
||||
* Entfernt alles, was keine Ziffer ist. Leere Eingaben werden zu "".
|
||||
*
|
||||
* @param {unknown} eingabe
|
||||
* @returns {string}
|
||||
*/
|
||||
export function nurZiffern(eingabe) {
|
||||
return String(eingabe ?? '').replace(/[^0-9]/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet die GTIN-Pruefziffer fuer eine Ziffernfolge ohne Pruefziffer.
|
||||
*
|
||||
* Die Ziffern werden von rechts nach links abwechselnd mit 3 und 1 gewichtet
|
||||
* (rechteste Ziffer mal 3). Danach gilt (10 - (Summe mod 10)) mod 10.
|
||||
*
|
||||
* @param {string} ziffern Ziffernfolge ohne Pruefziffer, mindestens eine Ziffer
|
||||
* @returns {number} Pruefziffer 0-9
|
||||
*/
|
||||
export function berechnePruefziffer(ziffern) {
|
||||
if (typeof ziffern !== 'string' || !/^[0-9]+$/.test(ziffern)) {
|
||||
throw new Error('Prüfziffer kann nur für eine Ziffernfolge berechnet werden.');
|
||||
}
|
||||
let summe = 0;
|
||||
for (let i = 0; i < ziffern.length; i += 1) {
|
||||
const ziffer = Number(ziffern[ziffern.length - 1 - i]);
|
||||
summe += ziffer * (i % 2 === 0 ? 3 : 1);
|
||||
}
|
||||
return (10 - (summe % 10)) % 10;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prueft eine vollstaendige GTIN als komplette EAN-Nummer.
|
||||
*
|
||||
* @param {unknown} gtin
|
||||
* @returns {boolean} true, wenn Laenge, Ziffern und Pruefziffer stimmen
|
||||
*/
|
||||
export function gtinGueltig(gtin) {
|
||||
const wert = String(gtin ?? '');
|
||||
if (!/^(?:[0-9]{8}|[0-9]{13})$/.test(wert)) return false;
|
||||
const basis = wert.slice(0, -1);
|
||||
return berechnePruefziffer(basis) === Number(wert.slice(-1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Bestimmt den Nummernkreis einer normalisierten Artikelnummer.
|
||||
*
|
||||
* @param {string} ziffern ausschliesslich Ziffern
|
||||
* @returns {{ praefix: string, artikelblock: string, typ: GtinTyp, laenge: number } | null}
|
||||
*/
|
||||
export function bestimmeNummernkreis(ziffern) {
|
||||
if (!/^[0-9]+$/.test(ziffern)) return null;
|
||||
if (ziffern.length === 3) {
|
||||
return {
|
||||
praefix: PRAEFIX_KURZ,
|
||||
artikelblock: ziffern.padStart(4, '0'),
|
||||
typ: 'EAN-8',
|
||||
laenge: 8,
|
||||
};
|
||||
}
|
||||
if (ziffern.length === 4) {
|
||||
return {
|
||||
praefix: PRAEFIX_KURZ,
|
||||
artikelblock: ziffern,
|
||||
typ: 'EAN-8',
|
||||
laenge: 8,
|
||||
};
|
||||
}
|
||||
if (ziffern.length === 7) {
|
||||
return {
|
||||
praefix: PRAEFIX_LANG,
|
||||
artikelblock: ziffern,
|
||||
typ: 'EAN-13',
|
||||
laenge: 13,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zustand einer Eingabe fuer die Oberflaeche.
|
||||
*
|
||||
* @param {unknown} eingabe
|
||||
* @returns {'leer' | 'unvollstaendig' | 'ungueltig' | 'ok'}
|
||||
*/
|
||||
export function eingabeStatus(eingabe) {
|
||||
const ziffern = nurZiffern(eingabe);
|
||||
if (ziffern.length === 0) return 'leer';
|
||||
if (ziffern.length < 3) return 'unvollstaendig';
|
||||
return bestimmeNummernkreis(ziffern) ? 'ok' : 'ungueltig';
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt aus einer Artikelnummer die vollstaendige GTIN.
|
||||
*
|
||||
* Die erzeugte GTIN wird anschliessend erneut als komplette EAN validiert
|
||||
* (Sicherheitspruefung). Stimmt etwas nicht, wird null zurueckgegeben und es
|
||||
* darf kein Barcode gerendert werden.
|
||||
*
|
||||
* @param {unknown} eingabe Artikelnummer (3, 4 oder 7 Ziffern, Leerzeichen erlaubt)
|
||||
* @returns {GtinErgebnis | null}
|
||||
*/
|
||||
export function erzeugeGtin(eingabe) {
|
||||
const ziffern = nurZiffern(eingabe);
|
||||
const kreis = bestimmeNummernkreis(ziffern);
|
||||
if (!kreis) return null;
|
||||
|
||||
const basis = kreis.praefix + kreis.artikelblock;
|
||||
const pruefziffer = berechnePruefziffer(basis);
|
||||
const gtin = `${basis}${pruefziffer}`;
|
||||
|
||||
if (gtin.length !== kreis.laenge || !gtinGueltig(gtin)) return null;
|
||||
|
||||
return {
|
||||
eingabe: ziffern,
|
||||
artikelblock: kreis.artikelblock,
|
||||
praefix: kreis.praefix,
|
||||
basis,
|
||||
pruefziffer: String(pruefziffer),
|
||||
gtin,
|
||||
typ: kreis.typ,
|
||||
laenge: kreis.laenge,
|
||||
};
|
||||
}
|
||||
75
site/gtin/js/liste.js
Normal file
75
site/gtin/js/liste.js
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Reine Listenlogik des optionalen Listenmodus.
|
||||
*
|
||||
* Die Datei kennt weder DOM noch Oberflaeche: Sie zerlegt eine Eingabe in
|
||||
* Artikelnummern und rechnet die Schritte der Anzeige aus. Damit ist die Logik
|
||||
* unabhaengig von der Oberflaeche testbar, genau wie gtin.js und barcode.js.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Zerlegt eine Eingabe in einzelne Zifferngruppen.
|
||||
*
|
||||
* Alles, was keine Ziffer ist, trennt die Gruppen: Leerzeichen, Komma,
|
||||
* Semikolon, Zeilenumbruch und auch Buchstaben. So laesst sich eine ganze
|
||||
* Spalte aus einem Lieferschein in einem Zug einfuegen, ohne dass daraus eine
|
||||
* einzige, viel zu lange Nummer wird.
|
||||
*
|
||||
* @param {unknown} eingabe
|
||||
* @returns {string[]} Zifferngruppen in Eingabereihenfolge, ohne leere Gruppen
|
||||
*/
|
||||
export function zerlegeEingabe(eingabe) {
|
||||
return String(eingabe ?? '')
|
||||
.split(/[^0-9]+/)
|
||||
.filter((gruppe) => gruppe.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Naechster Index in der Liste.
|
||||
*
|
||||
* Der Schritt laeuft um: Nach dem letzten Eintrag folgt wieder der erste und
|
||||
* umgekehrt. Damit laesst sich eine Serie ohne Unterbrechung nacheinander
|
||||
* abscannen.
|
||||
*
|
||||
* @param {number} index aktueller Index
|
||||
* @param {number} richtung Schrittweite, z. B. 1 oder -1
|
||||
* @param {number} anzahl Anzahl der Eintraege
|
||||
* @returns {number} Index innerhalb von 0 bis anzahl-1
|
||||
*/
|
||||
export function schritt(index, richtung, anzahl) {
|
||||
if (!Number.isInteger(anzahl) || anzahl <= 0) return 0;
|
||||
const start = Number.isInteger(index) ? index : 0;
|
||||
const schrittweite = Number.isInteger(richtung) ? richtung : 0;
|
||||
return (((start + schrittweite) % anzahl) + anzahl) % anzahl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Beschriftung des Zaehlers, z. B. "3 / 7".
|
||||
*
|
||||
* @param {number} index aktueller Index
|
||||
* @param {number} anzahl Anzahl der Eintraege
|
||||
* @returns {string} leer, wenn die Liste leer ist
|
||||
*/
|
||||
export function zaehlerText(index, anzahl) {
|
||||
if (!Number.isInteger(anzahl) || anzahl <= 0) return '';
|
||||
const position = (Number.isInteger(index) ? index : 0) + 1;
|
||||
return `${Math.min(Math.max(position, 1), anzahl)} / ${anzahl}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erkennt die Bestaetigungstaste einer Tastatur.
|
||||
*
|
||||
* Ein Numpad auf dem Telefon hat keine Eingabetaste: Es bestaetigt mit einem
|
||||
* eigenen Schluessel (Haekchen, "Fertig", "Los"). Je nach Tastatur meldet der
|
||||
* Browser ihn als `key: "Enter"` oder nur ueber den alten `keyCode` 13, in
|
||||
* manchen Faellen mit `key: "Unidentified"`. Beide Kennungen muessen gelten,
|
||||
* sonst bleibt die eingegebene Nummer auf dem Telefon liegen.
|
||||
*
|
||||
* @param {{ key?: unknown, keyCode?: unknown } | null | undefined} ereignis Tastenereignis
|
||||
* @returns {boolean} true, wenn die Taste die Eingabe bestaetigt
|
||||
*/
|
||||
export function istBestaetigungstaste(ereignis) {
|
||||
if (!ereignis) return false;
|
||||
const taste = typeof ereignis.key === 'string' ? ereignis.key : '';
|
||||
const kennung = typeof ereignis.keyCode === 'number' ? ereignis.keyCode : 0;
|
||||
return taste === 'Enter' || kennung === 13;
|
||||
}
|
||||
Reference in New Issue
Block a user