Kamera-Ersatzweg, Sitzungs-Fortsetzung und Sperre während Erkennung

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) <noreply@anthropic.com>
This commit is contained in:
vchuser
2026-07-28 18:09:42 +02:00
co-authored by Claude Opus 5
parent 00bc62f546
commit 2e8bd29a33
3 changed files with 179 additions and 20 deletions
+70 -13
View File
@@ -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,8 +147,12 @@ 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) {
if (cameraUnavailableMessage) {
view.setStatus(cameraUnavailableMessage, true);
} else {
const available = isOcrAvailable();
view.setStatus(
available ? '' : 'Texterkennung nicht verfuegbar — nur Barcodes werden gelesen',
@@ -130,6 +161,7 @@ async function processCapture(getFrame) {
}
}
}
}
/** Entfernt alle offenen Vollbild-Overlays innerhalb der App. */
function closeOverlays() {
@@ -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);
});
/**
* 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();
+81
View File
@@ -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 = `
<div class="stack-id" id="resume-dialog-heading" style="font-size:30px">Gesicherte Sitzung gefunden</div>
<div class="detail">${entryCount} Modul${entryCount === 1 ? '' : 'e'} bereits erfasst.</div>
<div class="choices" id="choices"></div>
`;
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();
});
}
+22 -1
View File
@@ -10,6 +10,7 @@ export function renderScanView(root, { onCapture, onUndo, onOpenList, onPickFile
</div>
<div class="status" id="status"></div>
<button class="action capture" id="capture">Modul scannen</button>
<button class="action secondary" id="pick-file-button" hidden>Bild auswählen</button>
<div class="stacks" id="stacks"></div>
<div class="last">
<span id="last">Noch nichts erfasst</span>
@@ -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;
},
};
}