feat: publish unlisted GTIN tool

This commit is contained in:
desfoto automation
2026-09-25 19:01:51 +02:00
parent 68613788b8
commit 160d72f144
35 changed files with 5632 additions and 31 deletions

998
site/gtin/js/app.js Normal file
View 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();