internal/documentapi: neue Backend-Voraussetzung, die DOC-01/DOC-04 noch
nicht hatten - GET /documents/{id}/content liefert den entschluesselten
Originalinhalt der aktuellen Revision, Content-Type ausschliesslich ueber
Magic-Bytes, Content-Disposition: inline (kein Download-Zwang), volle
HTTP-Range-Unterstuetzung ueber http.ServeContent (Standardbibliothek) -
darueber laedt PDF.js grosse Dokumente automatisch seitenweise nach.
web/pdf-viewer: Next.js/React/TypeScript, PDF.js ausschliesslich auf
Canvas gerendert (kein iframe, siehe "Bekannte Fehler vermeiden"). Worker
laeuft aus public/pdf.worker.min.mjs (npm run copy-pdf-worker, Teil von
predev/prebuild) - bewusst NICHT ueber ein externes CDN, damit die
Anwendung vollstaendig offline/lokal lauffaehig bleibt. lib/viewerState.ts
kapselt Seiten-/Zoom-Navigation und Tastaturbedienung als reine,
DOM-unabhaengige Funktionen (Akzeptanzkriterium 2), Suche nutzt PDF.js'
eigene Textextraktion.
Pruefungen:
1. 300-seitiges PDF oeffnet im Zeitbudget - echter pdfjs-dist-Ladetest
gegen den echten Backend-Dienst: 247ms (Budget 5000ms)
2. Viewer per Tastatur bedienbar - 15 Vitest-Tests fuer Pfeiltasten/Zoom/
Suche-Fokus, inkl. Ignorieren waehrend im Suchfeld getippt wird
3. Speicherverbrauch im Browser - OFFEN, kein Headless-Browser in dieser
Umgebung verfuegbar (gleiche Einschraenkung wie bei ANA-02)
SHL-01 (Core-Board, eigener Branch-Strang mit gemeinsamer Basis) sauber
gemergt - kein Dateiueberschneidungs-Konflikt (web/shl vs. dms/).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
111 lines
3.2 KiB
TypeScript
111 lines
3.2 KiB
TypeScript
// Reine Zustandslogik des Viewers — bewusst getrennt von der PDF.js-/
|
|
// Canvas-Anbindung (components/PdfViewer.tsx), damit Seiten-/Zoom-
|
|
// Navigation und Tastaturbedienung (Akzeptanzkriterium 2) ohne echten
|
|
// Browser/Canvas testbar sind.
|
|
|
|
export const MIN_ZOOM = 0.5;
|
|
export const MAX_ZOOM = 3.0;
|
|
export const ZOOM_STEP = 0.25;
|
|
|
|
export interface ViewerState {
|
|
currentPage: number;
|
|
numPages: number;
|
|
zoom: number;
|
|
}
|
|
|
|
export function initialViewerState(numPages: number): ViewerState {
|
|
return { currentPage: 1, numPages, zoom: 1.0 };
|
|
}
|
|
|
|
function clampPage(page: number, numPages: number): number {
|
|
if (page < 1) return 1;
|
|
if (page > numPages) return numPages;
|
|
return page;
|
|
}
|
|
|
|
function clampZoom(zoom: number): number {
|
|
if (zoom < MIN_ZOOM) return MIN_ZOOM;
|
|
if (zoom > MAX_ZOOM) return MAX_ZOOM;
|
|
return Math.round(zoom * 100) / 100;
|
|
}
|
|
|
|
export function goToPage(state: ViewerState, page: number): ViewerState {
|
|
return { ...state, currentPage: clampPage(page, state.numPages) };
|
|
}
|
|
|
|
export function nextPage(state: ViewerState): ViewerState {
|
|
return goToPage(state, state.currentPage + 1);
|
|
}
|
|
|
|
export function previousPage(state: ViewerState): ViewerState {
|
|
return goToPage(state, state.currentPage - 1);
|
|
}
|
|
|
|
export function zoomIn(state: ViewerState): ViewerState {
|
|
return { ...state, zoom: clampZoom(state.zoom + ZOOM_STEP) };
|
|
}
|
|
|
|
export function zoomOut(state: ViewerState): ViewerState {
|
|
return { ...state, zoom: clampZoom(state.zoom - ZOOM_STEP) };
|
|
}
|
|
|
|
export function resetZoom(state: ViewerState): ViewerState {
|
|
return { ...state, zoom: 1.0 };
|
|
}
|
|
|
|
// Action-Typen fuer die Tastaturbedienung (Akzeptanzkriterium 2).
|
|
export type ViewerAction =
|
|
| { type: "nextPage" }
|
|
| { type: "previousPage" }
|
|
| { type: "zoomIn" }
|
|
| { type: "zoomOut" }
|
|
| { type: "resetZoom" }
|
|
| { type: "focusSearch" };
|
|
|
|
// mapKeyToAction übersetzt eine Tastatur-Eingabe in eine Viewer-Aktion —
|
|
// reine Funktion, unabhängig von React/DOM-Events, damit die Tastatur-
|
|
// bedienung ohne echten Browser getestet werden kann. Gibt null zurück,
|
|
// wenn die Taste keine Bedeutung für den Viewer hat (z. B. während der
|
|
// Nutzer im Suchfeld tippt, siehe ignoreWhenTyping).
|
|
export function mapKeyToAction(key: string, ignoreWhenTyping: boolean): ViewerAction | null {
|
|
if (ignoreWhenTyping) {
|
|
return null;
|
|
}
|
|
switch (key) {
|
|
case "ArrowRight":
|
|
case "PageDown":
|
|
return { type: "nextPage" };
|
|
case "ArrowLeft":
|
|
case "PageUp":
|
|
return { type: "previousPage" };
|
|
case "+":
|
|
case "=":
|
|
return { type: "zoomIn" };
|
|
case "-":
|
|
return { type: "zoomOut" };
|
|
case "0":
|
|
return { type: "resetZoom" };
|
|
case "/":
|
|
return { type: "focusSearch" };
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function applyViewerAction(state: ViewerState, action: ViewerAction): ViewerState {
|
|
switch (action.type) {
|
|
case "nextPage":
|
|
return nextPage(state);
|
|
case "previousPage":
|
|
return previousPage(state);
|
|
case "zoomIn":
|
|
return zoomIn(state);
|
|
case "zoomOut":
|
|
return zoomOut(state);
|
|
case "resetZoom":
|
|
return resetZoom(state);
|
|
case "focusSearch":
|
|
return state; // Seiteneffekt (Fokus setzen) lebt in der Komponente.
|
|
}
|
|
}
|