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 { renderScanView } from './ui/scan-view.js';
|
||||||
import { showResult } from './ui/result-overlay.js';
|
import { showResult } from './ui/result-overlay.js';
|
||||||
import { askForStack } from './ui/ambiguous-dialog.js';
|
import { askForStack } from './ui/ambiguous-dialog.js';
|
||||||
|
import { askResumeSession } from './ui/resume-dialog.js';
|
||||||
import { renderSessionList } from './ui/session-list.js';
|
import { renderSessionList } from './ui/session-list.js';
|
||||||
|
|
||||||
const app = document.querySelector('#app');
|
const app = document.querySelector('#app');
|
||||||
@@ -19,8 +20,10 @@ const store = window.localStorage;
|
|||||||
|
|
||||||
// loadSession liefert entweder eine in sich stimmige Sitzung oder null - nie
|
// loadSession liefert entweder eine in sich stimmige Sitzung oder null - nie
|
||||||
// einen halb brauchbaren Zustand (siehe storage.js). Eine gefundene Sitzung
|
// einen halb brauchbaren Zustand (siehe storage.js). Eine gefundene Sitzung
|
||||||
// nach einem Neuladen hat Vorrang vor einer neuen.
|
// wird nicht stillschweigend uebernommen, sondern dem Nutzer zur Fortsetzung
|
||||||
let session = loadSession(store) ?? createSession();
|
// angeboten (siehe initResumeOffer() weiter unten).
|
||||||
|
const restoredSession = loadSession(store);
|
||||||
|
let session = createSession();
|
||||||
|
|
||||||
// Sperrt die Erfassung, waehrend eine Erkennung laeuft. Wird in processCapture()
|
// Sperrt die Erfassung, waehrend eine Erkennung laeuft. Wird in processCapture()
|
||||||
// im finally-Block in jedem Ausgang wieder aufgehoben - auch wenn der Nutzer
|
// 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).
|
// verlassen wird (der fruehe return liegt innerhalb des try-Blocks).
|
||||||
let busy = false;
|
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, {
|
const view = renderScanView(app, {
|
||||||
onCapture: () => processCapture(() => grabFrame(view.video)),
|
onCapture: () => processCapture(() => grabFrame(view.video)),
|
||||||
onUndo: () => {
|
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);
|
const entry = undoLast(session);
|
||||||
view.setLast(entry ? `zurueckgenommen: Stapel ${entry.stackId}` : 'nichts zurueckzunehmen');
|
view.setLast(entry ? `zurueckgenommen: Stapel ${entry.stackId}` : 'nichts zurueckzunehmen');
|
||||||
syncView();
|
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)),
|
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. */
|
/** Naechster noch nicht vergebener Stapel-Buchstabe - fuer den Fall, dass der Nutzer im Rot-Dialog ausdruecklich einen neuen Stapel waehlt. */
|
||||||
function nextFreeStackId() {
|
function nextFreeStackId(targetSession) {
|
||||||
const used = new Set(session.stacks.map((stack) => stack.id));
|
const used = new Set(targetSession.stacks.map((stack) => stack.id));
|
||||||
for (const letter of 'ABCDEFGHIJKLMNOPQRSTUVWXYZ') {
|
for (const letter of 'ABCDEFGHIJKLMNOPQRSTUVWXYZ') {
|
||||||
if (!used.has(letter)) return letter;
|
if (!used.has(letter)) return letter;
|
||||||
}
|
}
|
||||||
// Nach Z weiter mit A2, B2, ... - in der Praxis nie erreicht (siehe session.js).
|
// 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) {
|
async function processCapture(getFrame) {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
busy = true;
|
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 …');
|
view.setStatus('erkenne …');
|
||||||
let failed = false;
|
let failed = false;
|
||||||
|
|
||||||
@@ -81,7 +104,7 @@ async function processCapture(getFrame) {
|
|||||||
const frame = await getFrame();
|
const frame = await getFrame();
|
||||||
const { spec, source, confidence } = await recognize(frame, { decodeBarcodes, runOcr });
|
const { spec, source, confidence } = await recognize(frame, { decodeBarcodes, runOcr });
|
||||||
|
|
||||||
const plan = proposeAssignment(session, spec);
|
const plan = proposeAssignment(targetSession, spec);
|
||||||
let stackId = plan.stackId;
|
let stackId = plan.stackId;
|
||||||
|
|
||||||
if (confidence === 'red' || plan.kind === 'ambiguous') {
|
if (confidence === 'red' || plan.kind === 'ambiguous') {
|
||||||
@@ -89,7 +112,7 @@ async function processCapture(getFrame) {
|
|||||||
spec,
|
spec,
|
||||||
candidates: plan.kind === 'ambiguous'
|
candidates: plan.kind === 'ambiguous'
|
||||||
? plan.candidates
|
? plan.candidates
|
||||||
: session.stacks.map((stack) => stack.id),
|
: targetSession.stacks.map((stack) => stack.id),
|
||||||
});
|
});
|
||||||
|
|
||||||
// "nochmal scannen": abbrechen, ohne zu buchen. Der fruehe return liegt
|
// "nochmal scannen": abbrechen, ohne zu buchen. Der fruehe return liegt
|
||||||
@@ -101,11 +124,15 @@ async function processCapture(getFrame) {
|
|||||||
stackId = answer.stackId;
|
stackId = answer.stackId;
|
||||||
} else {
|
} else {
|
||||||
// "neuer Stapel": unabhaengig von jeder Vermutung der naechste freie Buchstabe.
|
// "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));
|
view.setLast(describeEntry(spec, entry.stackId));
|
||||||
syncView();
|
syncView();
|
||||||
|
|
||||||
@@ -120,8 +147,12 @@ async function processCapture(getFrame) {
|
|||||||
} finally {
|
} finally {
|
||||||
busy = false;
|
busy = false;
|
||||||
// Die OCR-Verfuegbarkeitsanzeige darf eine Fehlermeldung, die der Nutzer
|
// 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 (!failed) {
|
||||||
|
if (cameraUnavailableMessage) {
|
||||||
|
view.setStatus(cameraUnavailableMessage, true);
|
||||||
|
} else {
|
||||||
const available = isOcrAvailable();
|
const available = isOcrAvailable();
|
||||||
view.setStatus(
|
view.setStatus(
|
||||||
available ? '' : 'Texterkennung nicht verfuegbar — nur Barcodes werden gelesen',
|
available ? '' : 'Texterkennung nicht verfuegbar — nur Barcodes werden gelesen',
|
||||||
@@ -130,6 +161,7 @@ async function processCapture(getFrame) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Entfernt alle offenen Vollbild-Overlays innerhalb der App. */
|
/** Entfernt alle offenen Vollbild-Overlays innerhalb der App. */
|
||||||
function closeOverlays() {
|
function closeOverlays() {
|
||||||
@@ -169,8 +201,33 @@ function openSessionList() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
startCamera(view.video).catch((error) => {
|
startCamera(view.video).catch((error) => {
|
||||||
view.setStatus(`Kamera nicht verfuegbar (${error.message}) — Bild auswaehlen`, true);
|
// Kein automatischer Aufruf der Dateiauswahl ohne Nutzergeste - Handy-
|
||||||
view.openFilePicker();
|
// 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();
|
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>
|
||||||
<div class="status" id="status"></div>
|
<div class="status" id="status"></div>
|
||||||
<button class="action capture" id="capture">Modul scannen</button>
|
<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="stacks" id="stacks"></div>
|
||||||
<div class="last">
|
<div class="last">
|
||||||
<span id="last">Noch nichts erfasst</span>
|
<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 lastEl = root.querySelector('#last');
|
||||||
const statusEl = root.querySelector('#status');
|
const statusEl = root.querySelector('#status');
|
||||||
const fileEl = root.querySelector('#file');
|
const fileEl = root.querySelector('#file');
|
||||||
|
const pickFileButtonEl = root.querySelector('#pick-file-button');
|
||||||
|
|
||||||
root.querySelector('#capture').addEventListener('click', onCapture);
|
root.querySelector('#capture').addEventListener('click', onCapture);
|
||||||
root.querySelector('#undo').addEventListener('click', onUndo);
|
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', () => {
|
fileEl.addEventListener('change', () => {
|
||||||
if (fileEl.files[0]) onPickFile(fileEl.files[0]);
|
if (fileEl.files[0]) onPickFile(fileEl.files[0]);
|
||||||
fileEl.value = '';
|
fileEl.value = '';
|
||||||
@@ -37,7 +43,12 @@ export function renderScanView(root, { onCapture, onUndo, onOpenList, onPickFile
|
|||||||
setStacks(stacks) {
|
setStacks(stacks) {
|
||||||
stacksEl.innerHTML = '';
|
stacksEl.innerHTML = '';
|
||||||
if (stacks.length === 0) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
for (const stack of stacks) {
|
for (const stack of stacks) {
|
||||||
@@ -61,5 +72,15 @@ export function renderScanView(root, { onCapture, onUndo, onOpenList, onPickFile
|
|||||||
openFilePicker() {
|
openFilePicker() {
|
||||||
fileEl.click();
|
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