Files
desfoto/site/gtin/js/app.js
2026-09-25 19:01:51 +02:00

999 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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();