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:
+70
-13
@@ -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();
|
||||
|
||||
@@ -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
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user