diff --git a/src/main.js b/src/main.js index 7b7fd9d..725087d 100644 --- a/src/main.js +++ b/src/main.js @@ -12,6 +12,7 @@ import { saveSession, loadSession, clearSession } from './storage.js'; import { renderScanView } from './ui/scan-view.js'; import { showResult } from './ui/result-overlay.js'; import { askForStack } from './ui/ambiguous-dialog.js'; +import { askResumeSession } from './ui/resume-dialog.js'; import { renderSessionList } from './ui/session-list.js'; const app = document.querySelector('#app'); @@ -19,8 +20,10 @@ const store = window.localStorage; // loadSession liefert entweder eine in sich stimmige Sitzung oder null - nie // einen halb brauchbaren Zustand (siehe storage.js). Eine gefundene Sitzung -// nach einem Neuladen hat Vorrang vor einer neuen. -let session = loadSession(store) ?? createSession(); +// wird nicht stillschweigend uebernommen, sondern dem Nutzer zur Fortsetzung +// angeboten (siehe initResumeOffer() weiter unten). +const restoredSession = loadSession(store); +let session = createSession(); // Sperrt die Erfassung, waehrend eine Erkennung laeuft. Wird in processCapture() // im finally-Block in jedem Ausgang wieder aufgehoben - auch wenn der Nutzer @@ -28,14 +31,28 @@ let session = loadSession(store) ?? createSession(); // verlassen wird (der fruehe return liegt innerhalb des try-Blocks). let busy = false; +// Kamera nicht verfuegbar: der Hinweis darauf muss dauerhaft sichtbar +// bleiben und darf nicht von der OCR-Verfuegbarkeitsanzeige am Ende jedes +// Scan-Durchlaufs ueberschrieben werden. +let cameraUnavailableMessage = null; + const view = renderScanView(app, { onCapture: () => processCapture(() => grabFrame(view.video)), onUndo: () => { + // Waehrend eine Erkennung laeuft, darf kein Eintrag zurueckgenommen + // werden - sonst koennte die Rueckgaengig-Flaeche einen Eintrag treffen, + // der gerade erst durch die laufende Erkennung entstehen wird. + if (busy) return; const entry = undoLast(session); view.setLast(entry ? `zurueckgenommen: Stapel ${entry.stackId}` : 'nichts zurueckzunehmen'); syncView(); }, - onOpenList: openSessionList, + onOpenList: () => { + // Waehrend eine Erkennung laeuft, bleibt die Sitzungsliste (und damit + // auch "Sitzung beenden") unerreichbar - siehe processCapture(). + if (busy) return; + openSessionList(); + }, onPickFile: (file) => processCapture(() => imageDataFromFile(file)), }); @@ -56,13 +73,13 @@ function describeEntry(spec, stackId) { } /** Naechster noch nicht vergebener Stapel-Buchstabe - fuer den Fall, dass der Nutzer im Rot-Dialog ausdruecklich einen neuen Stapel waehlt. */ -function nextFreeStackId() { - const used = new Set(session.stacks.map((stack) => stack.id)); +function nextFreeStackId(targetSession) { + const used = new Set(targetSession.stacks.map((stack) => stack.id)); for (const letter of 'ABCDEFGHIJKLMNOPQRSTUVWXYZ') { if (!used.has(letter)) return letter; } // Nach Z weiter mit A2, B2, ... - in der Praxis nie erreicht (siehe session.js). - return `A${session.stacks.length}`; + return `A${targetSession.stacks.length}`; } /** @@ -74,6 +91,12 @@ function nextFreeStackId() { async function processCapture(getFrame) { if (busy) return; busy = true; + // Die Sitzung, gegen die dieser Durchlauf arbeitet, wird hier fest + // gehalten. Wird "Sitzung beenden" ausgeloest, waehrend diese Erkennung + // noch laeuft (bis zu 20 Sekunden bei OCR), zeigt das Modul-level `session` + // danach auf eine neue, leere Sitzung - der Vergleich am Ende dieser + // Funktion verhindert, dass das verspaetete Ergebnis dort noch gebucht wird. + const targetSession = session; view.setStatus('erkenne …'); let failed = false; @@ -81,7 +104,7 @@ async function processCapture(getFrame) { const frame = await getFrame(); const { spec, source, confidence } = await recognize(frame, { decodeBarcodes, runOcr }); - const plan = proposeAssignment(session, spec); + const plan = proposeAssignment(targetSession, spec); let stackId = plan.stackId; if (confidence === 'red' || plan.kind === 'ambiguous') { @@ -89,7 +112,7 @@ async function processCapture(getFrame) { spec, candidates: plan.kind === 'ambiguous' ? plan.candidates - : session.stacks.map((stack) => stack.id), + : targetSession.stacks.map((stack) => stack.id), }); // "nochmal scannen": abbrechen, ohne zu buchen. Der fruehe return liegt @@ -101,11 +124,15 @@ async function processCapture(getFrame) { stackId = answer.stackId; } else { // "neuer Stapel": unabhaengig von jeder Vermutung der naechste freie Buchstabe. - stackId = nextFreeStackId(); + stackId = nextFreeStackId(targetSession); } } - const entry = commitAssignment(session, spec, source, stackId); + // Siehe Kommentar zu targetSession oben: eine zwischenzeitlich beendete + // Sitzung bucht das Ergebnis nicht mehr nach. + if (targetSession !== session) return; + + const entry = commitAssignment(targetSession, spec, source, stackId); view.setLast(describeEntry(spec, entry.stackId)); syncView(); @@ -120,13 +147,18 @@ async function processCapture(getFrame) { } finally { busy = false; // Die OCR-Verfuegbarkeitsanzeige darf eine Fehlermeldung, die der Nutzer - // noch lesen muss, nicht ueberschreiben. + // noch lesen muss, nicht ueberschreiben. Der Hinweis auf eine nicht + // verfuegbare Kamera hat Vorrang vor beidem und bleibt dauerhaft stehen. if (!failed) { - const available = isOcrAvailable(); - view.setStatus( - available ? '' : 'Texterkennung nicht verfuegbar — nur Barcodes werden gelesen', - !available, - ); + if (cameraUnavailableMessage) { + view.setStatus(cameraUnavailableMessage, true); + } else { + const available = isOcrAvailable(); + view.setStatus( + available ? '' : 'Texterkennung nicht verfuegbar — nur Barcodes werden gelesen', + !available, + ); + } } } } @@ -169,8 +201,33 @@ function openSessionList() { } startCamera(view.video).catch((error) => { - view.setStatus(`Kamera nicht verfuegbar (${error.message}) — Bild auswaehlen`, true); - view.openFilePicker(); + // Kein automatischer Aufruf der Dateiauswahl ohne Nutzergeste - Handy- + // Browser blockieren das regelmaessig. Stattdessen bleibt der Ersatzweg + // ueber eine sichtbare, dauerhaft eingeblendete Schaltflaeche erreichbar. + cameraUnavailableMessage = `Kamera nicht verfuegbar (${error.message}) — Bild auswaehlen`; + view.setStatus(cameraUnavailableMessage, true); + view.setFilePickerVisible(true); }); -syncView(); +/** + * Bietet eine beim Start gefundene, gesicherte Sitzung zur Fortsetzung an, + * statt sie stillschweigend zu uebernehmen. Wird fortgesetzt, zeigt die + * "Zuletzt"-Zeile den tatsaechlich letzten Eintrag, damit die Rueckgaengig- + * Flaeche das tut, was daneben steht. Wird verworfen, wird der gesicherte + * Stand geloescht. + */ +async function initResumeOffer() { + if (restoredSession && restoredSession.entries.length > 0) { + const answer = await askResumeSession(app, { entryCount: restoredSession.entries.length }); + if (answer.action === 'resume') { + session = restoredSession; + const last = session.entries[session.entries.length - 1]; + view.setLast(describeEntry(last.spec, last.stackId)); + } else { + clearSession(store); + } + } + syncView(); +} + +initResumeOffer(); diff --git a/src/ui/resume-dialog.js b/src/ui/resume-dialog.js new file mode 100644 index 0000000..f147218 --- /dev/null +++ b/src/ui/resume-dialog.js @@ -0,0 +1,81 @@ +/** + * Fragt beim Start nach, wenn eine gesicherte Sitzung vorliegt: fortsetzen + * oder verwerfen. Reine Darstellung, kennt weder Speicherung noch Sitzungs- + * Logik - bekommt nur die Anzahl bereits erfasster Eintraege herein. + * + * Gebaut nach demselben Muster wie ambiguous-dialog.js (modales Overlay, + * Geschwister per inert waehrend der Anzeige unerreichbar, Fokus kehrt + * danach zurueck), statt askForStack() zu erweitern: askForStack kennt + * Spec-Anzeige und Stapel-Kandidaten, die hier gar nicht vorkommen - eine + * Ja/Nein-Frage nach demselben Bauplan bleibt schlanker als eine + * Mehrzweck-Funktion mit fachfremden Feldern. + * + * @param {HTMLElement} root + * @param {{entryCount: number}} args + * @returns {Promise<{action: 'resume'|'discard'}>} + */ +export function askResumeSession(root, { entryCount }) { + const overlay = document.createElement('div'); + overlay.className = 'overlay'; + overlay.style.background = 'var(--bg)'; + overlay.setAttribute('role', 'dialog'); + overlay.setAttribute('aria-modal', 'true'); + overlay.setAttribute('aria-labelledby', 'resume-dialog-heading'); + overlay.innerHTML = ` +