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:
+93
-17
@@ -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();
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
@@ -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 = `
|
||||
<button class="mode-button" id="mode-button" type="button"></button>
|
||||
<div class="camera">
|
||||
<video id="preview" playsinline muted></video>
|
||||
<div class="frame"></div>
|
||||
</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>
|
||||
<div class="stacks" id="stacks"></div>
|
||||
<div class="last">
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user