From 2e619ed64d6a9088da9e8e945994912cf88580b7 Mon Sep 17 00:00:00 2001 From: TanerUslu Date: Wed, 29 Jul 2026 13:37:12 +0200 Subject: [PATCH] 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). --- src/main.js | 110 +++++++++++++++++++++++++++++++++++------- src/styles.css | 30 ++++++++++++ src/ui/mode-dialog.js | 95 ++++++++++++++++++++++++++++++++++++ src/ui/scan-view.js | 32 ++++++++++-- 4 files changed, 247 insertions(+), 20 deletions(-) create mode 100644 src/ui/mode-dialog.js diff --git a/src/main.js b/src/main.js index d79f5cd..1b5914e 100644 --- a/src/main.js +++ b/src/main.js @@ -5,6 +5,8 @@ import { decodeBarcodes } from './barcode.js'; import { runOcr, isOcrAvailable } from './ocr.js'; import { recognize } from './pipeline.js'; import { normalizeToken } from './spec.js'; +import { getScanMode } from './scan-modes.js'; +import { buildRecognitionAdapters } from './scan-recognition.js'; import { createSession, proposeAssignment, commitAssignment, undoLast, moveEntry, removeEntry, nextStackId, @@ -14,6 +16,7 @@ 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 { askForScanMode } from './ui/mode-dialog.js'; import { renderSessionList } from './ui/session-list.js'; import { describeSpec } from './ui/describe-spec.js'; @@ -38,6 +41,14 @@ let busy = false; // Scan-Durchlaufs ueberschrieben werden. 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, { onCapture: () => processCapture(() => grabFrame(view.video)), onUndo: () => { @@ -56,8 +67,44 @@ const view = renderScanView(app, { openSessionList(); }, 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. */ function syncView() { view.setStacks(session.stacks); @@ -98,9 +145,14 @@ async function processCapture(getFrame) { (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( frame, - { decodeBarcodes, runOcr, isKnownCode }, + { decodeBarcodes: decode, runOcr: recognizeText, isKnownCode }, ); const plan = proposeAssignment(targetSession, spec, codes); @@ -218,6 +270,11 @@ function searchPaused() { * nichts tun. */ 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()) { view.setFrameDetected(false); return; @@ -235,7 +292,7 @@ async function runSearchAttempt() { let codes; try { - codes = await decodeBarcodes(region); + codes = await decodeBarcodes(region, getScanMode(currentModeId).barcodeFormats); } catch { view.setFrameDetected(false); return; @@ -331,20 +388,25 @@ function openSessionList() { }); } -startCamera(view.video) - .then(() => { - // Erst ab hier liefert die Kamera brauchbare Bilder - die Dauersuche - // laeuft fortan im Hintergrund, ohne dass der Nutzer etwas tun muss. - continuousSearchLoop(); - }) - .catch((error) => { - // 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); - }); +/** Startet die Kamera und, sobald sie brauchbare Bilder liefert, die Dauersuche. */ +function startCameraAndSearch() { + startCamera(view.video) + .then(() => { + // Erst ab hier liefert die Kamera brauchbare Bilder - die Dauersuche + // laeuft fortan im Hintergrund, ohne dass der Nutzer etwas tun muss. + // runSearchAttempt() selbst haelt sich zurueck, wenn die gewaehlte + // Funktion keine laufende Suche vorsieht (Funktion 3). + continuousSearchLoop(); + }) + .catch((error) => { + // 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, @@ -367,4 +429,18 @@ async function initResumeOffer() { 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(); diff --git a/src/styles.css b/src/styles.css index 67082f6..01f5f15 100644 --- a/src/styles.css +++ b/src/styles.css @@ -21,6 +21,19 @@ body { #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 video { width: 100%; height: 100%; object-fit: cover; } @@ -115,5 +128,22 @@ body { .choices { display: grid; gap: 12px; width: 100%; max-width: 420px; } .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.warn { color: #f0b429; } diff --git a/src/ui/mode-dialog.js b/src/ui/mode-dialog.js new file mode 100644 index 0000000..68deb0d --- /dev/null +++ b/src/ui/mode-dialog.js @@ -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} 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 = ` +
Funktion wählen
+
+ `; + + 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 = ` + ${mode.label} + + `; + button.querySelector('.mode-tile-hint').textContent = describeMode(mode); + button.addEventListener('click', () => finish(mode.id)); + choices.appendChild(button); + firstButton = firstButton ?? button; + } + + firstButton?.focus(); + }); +} diff --git a/src/ui/scan-view.js b/src/ui/scan-view.js index 4686025..d33b1d1 100644 --- a/src/ui/scan-view.js +++ b/src/ui/scan-view.js @@ -4,14 +4,15 @@ import { FRAME_INSET } from '../camera.js'; * Baut die Hauptansicht auf und liefert Aktualisierungsfunktionen zurueck. * 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 = ` +
- +
@@ -27,6 +28,8 @@ export function renderScanView(root, { onCapture, onUndo, onOpenList, onPickFile const fileEl = root.querySelector('#file'); const pickFileButtonEl = root.querySelector('#pick-file-button'); 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 // 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.bottom * 100}% ${FRAME_INSET.left * 100}%`; - root.querySelector('#capture').addEventListener('click', onCapture); + captureEl.addEventListener('click', onCapture); root.querySelector('#undo').addEventListener('click', onUndo); + modeButtonEl.addEventListener('click', onChangeMode); // 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. @@ -74,6 +78,28 @@ export function renderScanView(root, { onCapture, onUndo, onOpenList, onPickFile 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 */ setStatus(text, warn = false) { statusEl.textContent = text;