From 2e8bd29a335d92eae091173c1b553833f44da4d1 Mon Sep 17 00:00:00 2001 From: vchuser Date: Tue, 28 Jul 2026 18:09:42 +0200 Subject: [PATCH] =?UTF-8?q?Kamera-Ersatzweg,=20Sitzungs-Fortsetzung=20und?= =?UTF-8?q?=20Sperre=20w=C3=A4hrend=20Erkennung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drei zusammenhängende Verhaltensfehler rund um main.js behoben: - Kamerafehler ruft die Dateiauswahl nicht mehr ohne Nutzergeste auf (Handy-Browser blockieren das). Stattdessen zeigt scan-view.js eine sichtbare, dauerhaft eingeblendete Schaltfläche "Bild auswählen"; der Kamera-Hinweis wird nicht mehr von der OCR-Verfügbarkeitsanzeige überschrieben. - Eine beim Start gefundene, gesicherte Sitzung wird nicht mehr stillschweigend übernommen, sondern über den neuen Dialog askResumeSession (resume-dialog.js, nach dem Muster von ambiguous-dialog.js) zur Fortsetzung angeboten. Fortsetzen zeigt den tatsächlich letzten Eintrag, Verwerfen löscht den gesicherten Stand. - Während einer laufenden Erkennung sind Sitzungsliste und Rückgängig- Fläche gesperrt; zusätzlich bucht ein durch einen zwischenzeitlichen Sitzungswechsel verspätetes Erkennungsergebnis nicht mehr in die neue, leere Sitzung (targetSession-Vergleich in processCapture). - Auch ohne erfasste Stapel bleibt die Sitzungsliste (und damit "Sitzung beenden") über eine Schaltfläche in der Stapel-Leiste erreichbar. Co-Authored-By: Claude Opus 5 (1M context) --- src/main.js | 95 ++++++++++++++++++++++++++++++++--------- src/ui/resume-dialog.js | 81 +++++++++++++++++++++++++++++++++++ src/ui/scan-view.js | 23 +++++++++- 3 files changed, 179 insertions(+), 20 deletions(-) create mode 100644 src/ui/resume-dialog.js 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 = ` +
Gesicherte Sitzung gefunden
+
${entryCount} Modul${entryCount === 1 ? '' : 'e'} bereits erfasst.
+
+ `; + + const choices = overlay.querySelector('#choices'); + + // Siehe ambiguous-dialog.js: waehrend der Dialog offen ist, werden die + // Geschwisterelemente per inert unerreichbar gemacht und danach wieder + // freigegeben. + const previouslyFocused = document.activeElement; + const madeInert = []; + for (const sibling of root.children) { + if (!sibling.hasAttribute('inert')) { + sibling.setAttribute('inert', ''); + madeInert.push(sibling); + } + } + + root.appendChild(overlay); + + return new Promise((resolve) => { + let settled = false; + const finish = (result) => { + if (settled) return; + settled = true; + overlay.remove(); + for (const sibling of madeInert) { + sibling.removeAttribute('inert'); + } + if ( + previouslyFocused && + typeof previouslyFocused.focus === 'function' && + document.contains(previouslyFocused) + ) { + previouslyFocused.focus(); + } else { + root.focus?.(); + } + resolve(result); + }; + + const resumeButton = document.createElement('button'); + resumeButton.className = 'action'; + resumeButton.textContent = 'fortsetzen'; + resumeButton.addEventListener('click', () => finish({ action: 'resume' })); + choices.appendChild(resumeButton); + + const discardButton = document.createElement('button'); + discardButton.className = 'action secondary'; + discardButton.textContent = 'verwerfen'; + discardButton.addEventListener('click', () => finish({ action: 'discard' })); + choices.appendChild(discardButton); + + resumeButton.focus(); + }); +} diff --git a/src/ui/scan-view.js b/src/ui/scan-view.js index b92599d..077dd73 100644 --- a/src/ui/scan-view.js +++ b/src/ui/scan-view.js @@ -10,6 +10,7 @@ export function renderScanView(root, { onCapture, onUndo, onOpenList, onPickFile
+
Noch nichts erfasst @@ -22,9 +23,14 @@ export function renderScanView(root, { onCapture, onUndo, onOpenList, onPickFile const lastEl = root.querySelector('#last'); const statusEl = root.querySelector('#status'); const fileEl = root.querySelector('#file'); + const pickFileButtonEl = root.querySelector('#pick-file-button'); root.querySelector('#capture').addEventListener('click', onCapture); root.querySelector('#undo').addEventListener('click', onUndo); + // Klick auf die sichtbare Schaltflaeche ist selbst die Nutzergeste, die + // die versteckte Dateiauswahl oeffnen darf - anders als ein Aufruf aus dem + // Code heraus, den Handy-Browser blockieren. + pickFileButtonEl.addEventListener('click', () => fileEl.click()); fileEl.addEventListener('change', () => { if (fileEl.files[0]) onPickFile(fileEl.files[0]); fileEl.value = ''; @@ -37,7 +43,12 @@ export function renderScanView(root, { onCapture, onUndo, onOpenList, onPickFile setStacks(stacks) { stacksEl.innerHTML = ''; if (stacks.length === 0) { - stacksEl.textContent = 'Noch keine Stapel'; + // Auch ohne erfasste Stapel muss die Sitzungsliste erreichbar + // bleiben - darueber liegt der einzige Weg zu "Sitzung beenden". + const button = document.createElement('button'); + button.textContent = 'Noch keine Stapel'; + button.addEventListener('click', onOpenList); + stacksEl.appendChild(button); return; } for (const stack of stacks) { @@ -61,5 +72,15 @@ export function renderScanView(root, { onCapture, onUndo, onOpenList, onPickFile openFilePicker() { fileEl.click(); }, + + /** + * Blendet die Ersatzweg-Schaltflaeche "Bild auswaehlen" ein oder aus. + * Wird eingeblendet, wenn die Kamera nicht zur Verfuegung steht, und + * bleibt dann dauerhaft sichtbar. + * @param {boolean} visible + */ + setFilePickerVisible(visible) { + pickFileButtonEl.hidden = !visible; + }, }; }