Funktionswahl vor der Kamera, Wechsel und Knopf-Sichtbarkeit je Funktion

Neuer Vollbild-Dialog (src/ui/mode-dialog.js) mit drei grossen Flaechen,
gebaut nach dem Muster der bestehenden Dialoge. Ablauf beim Start: erst
die Fortsetzen-Frage (falls eine gesicherte Sitzung vorliegt), dann die
Funktionswahl, erst danach startet die Kamera.

Die gewaehlte Funktion steht antippbar in der Scan-Ansicht und laesst
sich dort jederzeit wechseln - der Wechsel gilt sofort: laufende Suche
startet/stoppt, "Modul scannen" erscheint nur noch in Funktion "Text
erkennen", der Zielrahmen wird beim Wechsel weg von laufender Suche
zurueckgesetzt. pipeline.js bleibt unangetastet; main.js reicht je nach
Funktion nur unterschiedliche Adapter hinein (scan-recognition.js).
This commit is contained in:
2026-07-29 13:37:12 +02:00
parent 2eb9d1ecf8
commit 2e619ed64d
4 changed files with 247 additions and 20 deletions
+93 -17
View File
@@ -5,6 +5,8 @@ import { decodeBarcodes } from './barcode.js';
import { runOcr, isOcrAvailable } from './ocr.js'; import { runOcr, isOcrAvailable } from './ocr.js';
import { recognize } from './pipeline.js'; import { recognize } from './pipeline.js';
import { normalizeToken } from './spec.js'; import { normalizeToken } from './spec.js';
import { getScanMode } from './scan-modes.js';
import { buildRecognitionAdapters } from './scan-recognition.js';
import { import {
createSession, proposeAssignment, commitAssignment, createSession, proposeAssignment, commitAssignment,
undoLast, moveEntry, removeEntry, nextStackId, undoLast, moveEntry, removeEntry, nextStackId,
@@ -14,6 +16,7 @@ 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 { askResumeSession } from './ui/resume-dialog.js';
import { askForScanMode } from './ui/mode-dialog.js';
import { renderSessionList } from './ui/session-list.js'; import { renderSessionList } from './ui/session-list.js';
import { describeSpec } from './ui/describe-spec.js'; import { describeSpec } from './ui/describe-spec.js';
@@ -38,6 +41,14 @@ let busy = false;
// Scan-Durchlaufs ueberschrieben werden. // Scan-Durchlaufs ueberschrieben werden.
let cameraUnavailableMessage = null; let cameraUnavailableMessage = null;
// Vor dem ersten Start der Kamera waehlt der Nutzer eine der drei Scan-
// Funktionen (siehe scan-modes.js); erst danach steht diese Kennung fest.
// Waehrend applyMode() noch nicht gelaufen ist, darf weder die Dauersuche
// noch ein Scan-Durchlauf ausgeloest werden - beide sind bis dahin durch das
// Auswahl-Overlay (searchPaused()) bzw. durch busy/den fehlenden Kamerastart
// ohnehin unerreichbar.
let currentModeId = null;
const view = renderScanView(app, { const view = renderScanView(app, {
onCapture: () => processCapture(() => grabFrame(view.video)), onCapture: () => processCapture(() => grabFrame(view.video)),
onUndo: () => { onUndo: () => {
@@ -56,8 +67,44 @@ const view = renderScanView(app, {
openSessionList(); openSessionList();
}, },
onPickFile: (file) => processCapture(() => imageDataFromFile(file)), onPickFile: (file) => processCapture(() => imageDataFromFile(file)),
onChangeMode: () => {
// Waehrend eine Erkennung laeuft, bleibt auch der Funktionswechsel
// unerreichbar - aus demselben Grund wie bei Rueckgaengig/Sitzungsliste.
if (busy) return;
openModeDialog();
},
}); });
/**
* Uebernimmt eine (neu gewaehlte) Scan-Funktion: Anzeige, Sichtbarkeit von
* "Modul scannen" und - bei einem Wechsel weg von laufender Codesuche - der
* Zielrahmen wird sofort zurueckgesetzt, statt auf den naechsten Durchlauf
* der Dauersuche zu warten.
* @param {string} modeId
*/
function applyMode(modeId) {
currentModeId = modeId;
const mode = getScanMode(modeId);
view.setMode(mode.label);
// "Modul scannen" hat nur dort etwas zu tun, wo es keine laufende Suche
// gibt (Funktion 3) - siehe Kommentar in scan-view.js/setCaptureVisible.
view.setCaptureVisible(!mode.continuousSearch);
if (!mode.continuousSearch) {
view.setFrameDetected(false);
}
}
/**
* Oeffnet die Funktionswahl (auch fuer den Wechsel aus der laufenden
* Scan-Ansicht heraus, nicht nur beim Start). Solange das Overlay offen ist,
* pausiert die Dauersuche von selbst (siehe searchPaused()).
*/
async function openModeDialog() {
closeOverlays();
const modeId = await askForScanMode(app, { current: currentModeId });
applyMode(modeId);
}
/** Spiegelt den Sitzungsstand in die Oberflaeche und sichert ihn gegen Neuladen ab. */ /** Spiegelt den Sitzungsstand in die Oberflaeche und sichert ihn gegen Neuladen ab. */
function syncView() { function syncView() {
view.setStacks(session.stacks); view.setStacks(session.stacks);
@@ -98,9 +145,14 @@ async function processCapture(getFrame) {
(stack) => Array.isArray(stack.codes) && stack.codes.includes(normalized), (stack) => Array.isArray(stack.codes) && stack.codes.includes(normalized),
); );
}; };
// Welcher Adapter tatsaechlich etwas tut, haengt von der gewaehlten
// Scan-Funktion ab (siehe scan-modes.js/scan-recognition.js) - recognize()
// selbst (pipeline.js) bleibt unveraendert und kennt keine Funktionen.
const { decodeBarcodes: decode, runOcr: recognizeText } =
buildRecognitionAdapters(currentModeId, { decodeBarcodes, runOcr });
const { spec, source, confidence, codes } = await recognize( const { spec, source, confidence, codes } = await recognize(
frame, frame,
{ decodeBarcodes, runOcr, isKnownCode }, { decodeBarcodes: decode, runOcr: recognizeText, isKnownCode },
); );
const plan = proposeAssignment(targetSession, spec, codes); const plan = proposeAssignment(targetSession, spec, codes);
@@ -218,6 +270,11 @@ function searchPaused() {
* nichts tun. * nichts tun.
*/ */
async function runSearchAttempt() { async function runSearchAttempt() {
// Funktion 3 (Text erkennen) hat keine laufende Suche - siehe scan-modes.js.
// Ein Wechsel dorthin setzt den Rahmen bereits in applyMode() zurueck;
// hier genuegt es, schlicht nichts zu tun.
if (!getScanMode(currentModeId).continuousSearch) return;
if (searchPaused()) { if (searchPaused()) {
view.setFrameDetected(false); view.setFrameDetected(false);
return; return;
@@ -235,7 +292,7 @@ async function runSearchAttempt() {
let codes; let codes;
try { try {
codes = await decodeBarcodes(region); codes = await decodeBarcodes(region, getScanMode(currentModeId).barcodeFormats);
} catch { } catch {
view.setFrameDetected(false); view.setFrameDetected(false);
return; return;
@@ -331,20 +388,25 @@ function openSessionList() {
}); });
} }
startCamera(view.video) /** Startet die Kamera und, sobald sie brauchbare Bilder liefert, die Dauersuche. */
.then(() => { function startCameraAndSearch() {
// Erst ab hier liefert die Kamera brauchbare Bilder - die Dauersuche startCamera(view.video)
// laeuft fortan im Hintergrund, ohne dass der Nutzer etwas tun muss. .then(() => {
continuousSearchLoop(); // Erst ab hier liefert die Kamera brauchbare Bilder - die Dauersuche
}) // laeuft fortan im Hintergrund, ohne dass der Nutzer etwas tun muss.
.catch((error) => { // runSearchAttempt() selbst haelt sich zurueck, wenn die gewaehlte
// Kein automatischer Aufruf der Dateiauswahl ohne Nutzergeste - Handy- // Funktion keine laufende Suche vorsieht (Funktion 3).
// Browser blockieren das regelmaessig. Stattdessen bleibt der Ersatzweg continuousSearchLoop();
// ueber eine sichtbare, dauerhaft eingeblendete Schaltflaeche erreichbar. })
cameraUnavailableMessage = `Kamera nicht verfuegbar (${error.message}) — Bild auswaehlen`; .catch((error) => {
view.setStatus(cameraUnavailableMessage, true); // Kein automatischer Aufruf der Dateiauswahl ohne Nutzergeste - Handy-
view.setFilePickerVisible(true); // 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, * Bietet eine beim Start gefundene, gesicherte Sitzung zur Fortsetzung an,
@@ -367,4 +429,18 @@ async function initResumeOffer() {
syncView(); syncView();
} }
initResumeOffer(); /**
* Ablauf beim Start: erst - falls vorhanden - die Frage nach dem Fortsetzen
* einer gesicherten Sitzung, danach die Wahl der Scan-Funktion, und erst
* danach die Kamera. So sieht der Nutzer die Funktionswahl nie, bevor die
* Fortsetzen-Frage (falls noetig) beantwortet ist, und die Kamera startet
* nie, bevor feststeht, welche Codearten sie ueberhaupt suchen soll.
*/
async function boot() {
await initResumeOffer();
const modeId = await askForScanMode(app);
applyMode(modeId);
startCameraAndSearch();
}
boot();
+30
View File
@@ -21,6 +21,19 @@ body {
#app { display: flex; flex-direction: column; height: 100dvh; } #app { display: flex; flex-direction: column; height: 100dvh; }
.mode-button {
min-height: 56px;
width: 100%;
border: none;
border-bottom: 1px solid var(--line);
background: #1c2027;
color: var(--fg);
font-size: 15px;
font-weight: 600;
text-align: left;
padding: 0 14px;
}
.camera { position: relative; flex: 1; overflow: hidden; background: #000; } .camera { position: relative; flex: 1; overflow: hidden; background: #000; }
.camera video { width: 100%; height: 100%; object-fit: cover; } .camera video { width: 100%; height: 100%; object-fit: cover; }
@@ -115,5 +128,22 @@ body {
.choices { display: grid; gap: 12px; width: 100%; max-width: 420px; } .choices { display: grid; gap: 12px; width: 100%; max-width: 420px; }
.choices button { min-height: 64px; font-size: 20px; } .choices button { min-height: 64px; font-size: 20px; }
/* Auswahl der Scan-Funktion vor dem Scannen (mode-dialog.js): drei grosse,
gleich gestaltete Flaechen statt schmaler Schaltflaechen. */
.mode-tile {
min-height: 96px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
padding: 12px;
}
.mode-tile-label { font-size: 24px; font-weight: 700; }
.mode-tile-hint { font-size: 14px; opacity: 0.8; }
.mode-tile-current { outline: 3px solid var(--fg); outline-offset: -3px; }
.status { padding: 8px 12px; font-size: 14px; color: var(--muted); } .status { padding: 8px 12px; font-size: 14px; color: var(--muted); }
.status.warn { color: #f0b429; } .status.warn { color: #f0b429; }
+95
View File
@@ -0,0 +1,95 @@
import { SCAN_MODES } from '../scan-modes.js';
/**
* Kurzer Hinweis je Funktion, direkt aus ihren Merkmalen abgeleitet (siehe
* scan-modes.js) - keine zweite Stelle mit eigenem Text je Funktion.
*/
function describeMode(mode) {
if (mode.useOcr) return 'Auf Knopfdruck';
return 'Läuft von selbst, sobald ein Code im Bild ist';
}
/**
* Vollbild-Auswahl der Scan-Funktion: drei grosse Flaechen, eine je Funktion
* aus scan-modes.js (einzige Quelle fuer Kennung und Beschriftung). Gebaut
* nach demselben Muster wie die anderen Vollbild-Dialoge (ambiguous-dialog.js,
* resume-dialog.js): Geschwister waehrend der Anzeige per inert unerreichbar,
* Fokus kehrt danach zurueck. Reine Darstellung - kennt weder Kamera noch
* Erkennung.
*
* Wird sowohl vor dem ersten Start der Kamera verwendet als auch fuer den
* Wechsel aus der laufenden Scan-Ansicht heraus (siehe main.js).
*
* @param {HTMLElement} root
* @param {{current?: string}} [args] Kennung der aktuell gewaehlten Funktion,
* falls es sich um einen Wechsel handelt - hebt die passende Flaeche hervor.
* @returns {Promise<string>} Kennung der gewaehlten Funktion (siehe scan-modes.js).
*/
export function askForScanMode(root, { current } = {}) {
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', 'mode-dialog-heading');
overlay.innerHTML = `
<div class="stack-id" id="mode-dialog-heading" style="font-size:28px">Funktion wählen</div>
<div class="choices mode-choices" id="choices"></div>
`;
const choices = overlay.querySelector('#choices');
// Siehe ambiguous-dialog.js / resume-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 = (modeId) => {
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(modeId);
};
let firstButton = null;
for (const mode of SCAN_MODES) {
const button = document.createElement('button');
button.className = 'action mode-tile';
if (mode.id === current) button.classList.add('mode-tile-current');
button.setAttribute('aria-pressed', String(mode.id === current));
button.innerHTML = `
<span class="mode-tile-label">${mode.label}</span>
<span class="mode-tile-hint"></span>
`;
button.querySelector('.mode-tile-hint').textContent = describeMode(mode);
button.addEventListener('click', () => finish(mode.id));
choices.appendChild(button);
firstButton = firstButton ?? button;
}
firstButton?.focus();
});
}
+29 -3
View File
@@ -4,14 +4,15 @@ import { FRAME_INSET } from '../camera.js';
* Baut die Hauptansicht auf und liefert Aktualisierungsfunktionen zurueck. * Baut die Hauptansicht auf und liefert Aktualisierungsfunktionen zurueck.
* Kennt weder Kamera noch Erkennung - alles kommt ueber die Rueckrufe. * Kennt weder Kamera noch Erkennung - alles kommt ueber die Rueckrufe.
*/ */
export function renderScanView(root, { onCapture, onUndo, onOpenList, onPickFile }) { export function renderScanView(root, { onCapture, onUndo, onOpenList, onPickFile, onChangeMode }) {
root.innerHTML = ` root.innerHTML = `
<button class="mode-button" id="mode-button" type="button"></button>
<div class="camera"> <div class="camera">
<video id="preview" playsinline muted></video> <video id="preview" playsinline muted></video>
<div class="frame"></div> <div class="frame"></div>
</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" hidden>Modul scannen</button>
<button class="action secondary" id="pick-file-button" hidden>Bild auswählen</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">
@@ -27,6 +28,8 @@ export function renderScanView(root, { onCapture, onUndo, onOpenList, onPickFile
const fileEl = root.querySelector('#file'); const fileEl = root.querySelector('#file');
const pickFileButtonEl = root.querySelector('#pick-file-button'); const pickFileButtonEl = root.querySelector('#pick-file-button');
const frameEl = root.querySelector('.frame'); const frameEl = root.querySelector('.frame');
const captureEl = root.querySelector('#capture');
const modeButtonEl = root.querySelector('#mode-button');
// FRAME_INSET (camera.js) ist die einzige Quelle der Rahmenmasse - sowohl // FRAME_INSET (camera.js) ist die einzige Quelle der Rahmenmasse - sowohl
// fuer diesen sichtbaren Rahmen als auch fuer den nativen Ausschnitt // fuer diesen sichtbaren Rahmen als auch fuer den nativen Ausschnitt
@@ -36,8 +39,9 @@ export function renderScanView(root, { onCapture, onUndo, onOpenList, onPickFile
`${FRAME_INSET.top * 100}% ${FRAME_INSET.right * 100}% ` + `${FRAME_INSET.top * 100}% ${FRAME_INSET.right * 100}% ` +
`${FRAME_INSET.bottom * 100}% ${FRAME_INSET.left * 100}%`; `${FRAME_INSET.bottom * 100}% ${FRAME_INSET.left * 100}%`;
root.querySelector('#capture').addEventListener('click', onCapture); captureEl.addEventListener('click', onCapture);
root.querySelector('#undo').addEventListener('click', onUndo); root.querySelector('#undo').addEventListener('click', onUndo);
modeButtonEl.addEventListener('click', onChangeMode);
// Klick auf die sichtbare Schaltflaeche ist selbst die Nutzergeste, die // Klick auf die sichtbare Schaltflaeche ist selbst die Nutzergeste, die
// die versteckte Dateiauswahl oeffnen darf - anders als ein Aufruf aus dem // die versteckte Dateiauswahl oeffnen darf - anders als ein Aufruf aus dem
// Code heraus, den Handy-Browser blockieren. // Code heraus, den Handy-Browser blockieren.
@@ -74,6 +78,28 @@ export function renderScanView(root, { onCapture, onUndo, onOpenList, onPickFile
lastEl.textContent = text; lastEl.textContent = text;
}, },
/**
* Zeigt die gewaehlte Scan-Funktion in der Ansicht an - antippbar, um zu
* wechseln (siehe onChangeMode). Beschriftung kommt vom Aufrufer, der sie
* seinerseits aus scan-modes.js liest; diese Ansicht kennt die Funktionen
* selbst nicht.
* @param {string} label
*/
setMode(label) {
modeButtonEl.textContent = `${label} ändern ⌄`;
modeButtonEl.setAttribute('aria-label', `Scan-Funktion wechseln (aktuell: ${label})`);
},
/**
* "Modul scannen" hat nur in Funktionen mit Texterkennung auf Knopfdruck
* etwas zu tun (Funktion 3) - in Funktionen mit laufender Codesuche sucht
* die App ohnehin durchgehend, ein Knopf dafuer waere wirkungslos.
* @param {boolean} visible
*/
setCaptureVisible(visible) {
captureEl.hidden = !visible;
},
/** @param {string} text @param {boolean} warn */ /** @param {string} text @param {boolean} warn */
setStatus(text, warn = false) { setStatus(text, warn = false) {
statusEl.textContent = text; statusEl.textContent = text;