/** * 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, 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} */ (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 } | 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();