refactor(frontend): PdfViewer und FileBrowser aufteilen, Tests dazu

FileBrowser 1177 -> 712 Zeilen, PdfViewer 1193 -> 848. Herausgeloest
wurde, was fuer sich zusammenhaengt:

  hooks/useUploads            Warteschlange, Ordnerbaeume, Ablage
  components/files/           Werkzeugleiste, Dialoge, Menues, Reihenfolge
  components/pdf/             Werkzeugleiste, Zoomstufen, Seitenmasse, Suche
  src/format.ts               formatBytes -- lag in einer *Seite*

Dabei gefundene und behobene Fehler:

* Ordner-Upload haengte Fortschritt und Fehler an die falsche Zeile,
  sobald in einem Ordner Dateien *nach* einem Unterordner lagen: Die
  Zeilen standen in Fundreihenfolge, hochgeladen wurde nach Ordnern
  gruppiert, und ein mitlaufender Zaehler verband beides falsch.
* formatBytes gab es zweimal, zeichengleich (Dateibrowser + Admin).
* Beide Sortierungen liefen bei *jedem* Bild -- der Rahmen beim Ziehen
  ist eines je Mausbewegung. Jetzt gemerkt.
* Die Suche im PDF las bei jedem Tastendruck den Text aller Seiten neu.
  Jetzt 160 ms Wartezeit; "Searching..." bleibt auch bei einem Fehler
  nicht mehr stehen.
* Seitenmasse werden in Buendeln statt streng nacheinander geholt.

Pruefinfrastruktur: vitest stand auf 'jsdom', ohne dass jsdom je
installiert war -- "npm test" brach ab, bevor es etwas ausfuehrte. Jetzt
'node' und 32 Tests fuer die herausgeloesten reinen Funktionen. Das
Abbild baut nur noch mit gruenen Tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-08 22:23:21 +02:00
1 parent 8e27c703d2
commit ea90f43e77
28 files changed
+1904 -1260

No files matched your search

+4 -1
View File
@@ -4,7 +4,10 @@ WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
# Erst prüfen, dann bauen: Die Tests decken die Stellen ab, die sich ohne
# Browser prüfen lassen – Reihenfolgen, Zoomstufen, Suche im Text. Sie laufen
# in einer halben Sekunde; ein Abbild mit einer kaputten Sortierung kostet mehr.
RUN npm test && npm run build
# ── Stage 2: Backend build ────────────────────────────────────────────────────
FROM golang:1.25-alpine AS backend-builder
+1 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { filesApi, type Correspondent, type FileItem, type Tag } from '../api/client'
import { formatBytes } from '../pages/FileBrowser'
import { formatBytes } from '../format'
import { useOverlayDismiss } from '../hooks/useOverlayDismiss'
import TagChip from './TagChip'
+1 -1
View File
@@ -5,7 +5,7 @@ import { TagRow } from './TagDialog'
import TagChip from './TagChip'
import { FileTypeIcon, FolderGlyph } from './icons'
import FileCover, { FolderCover } from './FileCover'
import { formatBytes } from '../pages/FileBrowser'
import { formatBytes } from '../format'
/**
* Die beiden Darstellungen einer Ordnerliste: Kacheln und Zeilen.
+101 -446
View File
@@ -3,6 +3,10 @@ import { filesApi } from '../api/client'
import * as pdfjs from 'pdfjs-dist'
import type { PDFDocumentProxy, PDFPageProxy } from 'pdfjs-dist'
import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'
import PdfToolbar from './pdf/PdfToolbar'
import { commonWidth, padOf, pageGap, type PageSize } from './pdf/geometry'
import { countIn, wordAround, type Hit } from './pdf/text'
import { nextFine, nextStep } from './pdf/zoom'
import './pdf.css'
/**
@@ -23,6 +27,10 @@ import './pdf.css'
* Was er absichtlich *nicht* kann: Anmerkungen, Formulare, Unterschriften.
* Das Original wird in dieser Anwendung nie verändert – ein Betrachter, der
* schreiben kann, wäre ein Widerspruch dazu.
*
* Hier steht, was am DOM hängt: laden, zeichnen, rollen, zoomen, färben. Was
* sich ohne Browser ausrechnen lässt, liegt daneben in `pdf/` – Zoomstufen,
* Seitenmaße, Suche im Text – und wird dort auch geprüft.
*/
// Der Arbeiter kommt gebündelt aus node_modules und wird vom eigenen Server
@@ -39,63 +47,24 @@ pdfjs.GlobalWorkerOptions.workerSrc = workerUrl
*/
const standardFontDataUrl = '/pdfjs/standard_fonts/'
/** Zoomstufen der **Knöpfe**. „Fit" liegt außerhalb und wird gerechnet. */
const zoomSteps = [0.5, 0.75, 1, 1.25, 1.5, 2, 3]
/**
* Wie viele Seitenmaße gleichzeitig geholt werden.
*
* Vorher lief die Schleife streng nacheinander: Bei einem 200-seitigen Scan
* waren das 200 Runden, bevor überhaupt etwas zu sehen war. Alle auf einmal
* wäre das andere Extrem – PDF.js hält dann 200 Seitenobjekte zugleich.
*/
const sizeBatch = 24
/**
* Zoomschritt für Finger und Mausrad: **fünf Prozentpunkte**, auf runde
* Zahlen gerastet – 65, 70, 75, 80 …
* Wartezeit, bevor eine Eingabe im Suchfeld das ganze Dokument durchsucht.
*
* Zuerst war es ein fester Faktor (fünf Prozent *vom aktuellen Wert*).
* Rechnerisch ist das die sauberere Größe: Sie fühlt sich unten wie oben
* gleich an. Abzulesen war sie aber nicht – die Anzeige sprang um drei
* Punkte, dann um vier, dann um sechs. Wer eine Zahl sieht, erwartet ein
* Raster, und das hier ist eines.
*
* Der Preis steht dazu: Bei 400 % ist ein Schritt gut ein Prozent, bei 25 %
* ein Fünftel. Das gleicht die Geste aus, nicht die Rasterung – siehe unten.
* Ohne sie las jeder einzelne Tastendruck den Text **aller** Seiten neu aus.
* Wer „Rechnung" tippt, löste damit acht vollständige Durchläufe aus, von
* denen sieben schon veraltet waren, bevor sie fertig wurden. Ein Sechstel
* einer Sekunde bemerkt beim Tippen niemand.
*/
const zoomStepPercent = 5
const minScale = 0.25
const maxScale = 4
/**
* Innenabstand der Seitenliste. Am Telefon fast keiner: Dort ist die Breite
* das knappe Gut, und ein 16-Pixel-Rand links und rechts kostet zusammen mehr
* als ein Zehntel der Darstellungsgröße.
*/
function padOf(width: number): number {
return width < 560 ? 4 : 16
}
/**
* Ab welcher Breite die Leiste als Dreispaltenraster taugt.
*
* Gerechnet, nicht geschätzt: Die Steuerung links braucht 275 Pixel
* (zwei Knöpfe, Seitenzahl, Trenner, Zoom), die Suche in der Mitte 492 – und
* die rechte Spalte ist genauso breit wie die linke, sonst säße die Suche
* nicht mittig. Macht 2 × 275 + 492.
*
* **Vorher stand hier 720, und dazwischen war die Leiste kaputt:** Das Raster
* gab der linken Spalte weniger, als ihr Inhalt braucht, und die Knöpfe
* schrumpften mit. Gemessen bei 980 Pixeln Fensterbreite – erster Knopf 16
* statt 28 Pixel, bei 760 die ganze Gruppe auf 108 statt 275. Am Ende war von
* der Steuerung nichts mehr zu sehen.
*/
const minGridWidth = 2 * 275 + 492
/**
* Breiten rechts vom Suchfeld – und damit das Maß für das Gegengewicht links.
* Beide Seiten gleich breit heißt: Das Feld sitzt in der Mitte der Leiste.
*/
const counterWidth = 54
const hitButtonsWidth = 60
/** Zähler + Knöpfe + die beiden Abstände dazwischen. */
const hitsWidth = counterWidth + hitButtonsWidth + 6
/** Abstand zwischen zwei Seiten, in Pixeln. Auch der Wert, mit dem gerechnet
* wird, wenn die sichtbare Seite bestimmt wird. */
const pageGap = 12
const searchDelay = 160
interface Props {
/** Adresse der Datei – dieselbe, die auch das <iframe> bekam. */
@@ -109,21 +78,10 @@ interface Props {
searchTerm?: string
}
/**
* Eine Fundstelle: Seite und die wievielte auf dieser Seite.
*
* `exact` unterscheidet die beiden Quellen. Bringt das PDF eigenen Text mit,
* steht die Stelle im Dokument und lässt sich einfärben. Bei einem **Scan**
* steht dort nur ein Bild; der Text kommt dann aus der Erkennung auf dem
* Server, und der weiß zwar die Seite, aber nicht den Ort auf dem Blatt –
* dafür müssten die Koordinaten der Erkennung aufbewahrt werden.
*/
interface Hit { page: number; nth: number; exact: boolean }
export default function PdfViewer({ url, fileId, searchTerm }: Props) {
const [doc, setDoc] = useState<PDFDocumentProxy | null>(null)
const [error, setError] = useState('')
const [sizes, setSizes] = useState<{ w: number; h: number }[]>([])
const [sizes, setSizes] = useState<PageSize[]>([])
const [scale, setScale] = useState<number | 'fit'>('fit')
const [fitScale, setFitScale] = useState(1)
const [current, setCurrent] = useState(1)
@@ -147,10 +105,13 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
useEffect(() => {
let alive = true
let task: ReturnType<typeof pdfjs.getDocument> | null = null
setDoc(null); setSizes([]); setError(''); setCurrent(1)
// Die Verweise auf die Seitenkästen gehören zum alten Dokument. Ohne das
// blieben bei einem kürzeren nächsten Dokument Einträge stehen, die auf
// längst entfernte Elemente zeigen.
pageRefs.current = []
task = pdfjs.getDocument({
const task = pdfjs.getDocument({
url,
// Same-Origin, die Sitzung steckt im Cookie – ohne das käme 401.
withCredentials: true,
@@ -165,10 +126,15 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
// Die Maße *aller* Seiten vorab: Sonst müsste die Liste mit einer
// geschätzten Höhe beginnen und beim Zeichnen springen. Ein Dokument
// mit gemischtem Hoch- und Querformat ist bei Scans der Normalfall.
const s: { w: number; h: number }[] = []
for (let i = 1; i <= d.numPages; i++) {
const v = (await d.getPage(i)).getViewport({ scale: 1 })
s.push({ w: v.width, h: v.height })
const s: PageSize[] = []
for (let from = 1; from <= d.numPages; from += sizeBatch) {
const to = Math.min(from + sizeBatch - 1, d.numPages)
const batch = await Promise.all(
Array.from({ length: to - from + 1 }, (_, i) =>
d.getPage(from + i).then(p => p.getViewport({ scale: 1 }))),
)
if (!alive) return
for (const v of batch) s.push({ w: v.width, h: v.height })
}
if (!alive) return
setSizes(s)
@@ -185,7 +151,7 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
)
return () => {
alive = false
task?.destroy()
task.destroy()
}
}, [url])
@@ -210,9 +176,6 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
*
* Am Telefon greift die Grenze von selbst nicht: Dort ist der Bildschirm
* schmaler als das Blatt, die Breite also unter 100 %.
*
* Gerechnet wird gegen die **häufigste** Seitenbreite, nicht gegen die
* breiteste – warum, steht bei `commonWidth`.
*/
const recomputeFit = useCallback(() => {
const el = scrollRef.current
@@ -403,10 +366,12 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
*/
useEffect(() => {
const q = query.trim().toLowerCase()
if (!doc || q.length < 2) { setHits([]); setHitIndex(0); return }
if (!doc || q.length < 2) { setHits([]); setHitIndex(0); setSearching(false); return }
let alive = true
setSearching(true)
;(async () => {
// Erst abwarten, ob noch ein Zeichen kommt – siehe `searchDelay`.
const timer = setTimeout(() => void (async () => {
const found: Hit[] = []
for (let n = 1; n <= doc.numPages; n++) {
if (!alive) return
@@ -446,9 +411,9 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
if (!alive) return
setHits(found)
setHitIndex(0)
setSearching(false)
})()
return () => { alive = false; setSearching(false) }
})().finally(() => { if (alive) setSearching(false) }), searchDelay)
return () => { alive = false; clearTimeout(timer) }
}, [doc, query, fileId])
/**
@@ -466,12 +431,10 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
* ab (`overflow: clip`). Daher „abgeschnitten und nicht gerade".
*
* Jetzt kommt die Geometrie **vollständig** aus der Auswahl – genau die
* Kästen, die der Browser auch beim Markieren mit der Maus malt. Das darf
* er jetzt auch: Unsere Ebene schneidet nichts ab, das war ja allein die
* Textebene.
* Kästen, die der Browser auch beim Markieren mit der Maus malt.
*
* Und markiert wird das **ganze Wort**, nicht der Buchstabenschnipsel –
* warum, steht bei `wordAround` unten.
* warum, steht bei `wordAround` in pdf/text.ts.
*
* Nebenbei bleibt die Schrift unberührt: Markieren und Kopieren gehen
* währenddessen weiter, was beim Umbauen des Textes nicht mehr sicher war.
@@ -604,6 +567,14 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
// Kommt der Betrachter aus einem Suchtreffer, steht das Wort schon da.
useEffect(() => { setQuery(searchTerm ?? '') }, [searchTerm, url])
const stepHit = useCallback((delta: number) => {
// Ohne die Wache teilte der Rest durch null und der Zeiger stünde auf
// NaN. Die Leiste ruft das zwar nur mit Treffern, aber ein Zähler, der
// sich nicht mehr erholt, ist ein teurer Preis für eine Annahme.
if (hits.length === 0) return
setHitIndex(i => (i + delta + hits.length) % hits.length)
}, [hits.length])
// ------------------------------------------------------------------ Zoom
/**
@@ -625,6 +596,7 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
/** Der aktuelle Maßstab, aus einem Zuhörer heraus lesbar. */
const scaleRef = useRef(effScale)
scaleRef.current = effScale
useEffect(() => {
const anchor = keepAnchor.current
if (anchor) {
@@ -643,41 +615,21 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
goToPage(n)
}, [effScale, goToPage])
/** Die nächste Stufe in eine Richtung, oder undefined am Ende der Leiter. */
function nextStep(now: number, dir: 1 | -1): number | undefined {
return dir > 0
? zoomSteps.find(s => s > now + 0.001)
: [...zoomSteps].reverse().find(s => s < now - 0.001)
}
/** Merkt den Punkt unter Zeiger oder Fingermitte, damit er dort bleibt. */
const anchorAt = useCallback((cx: number, cy: number, from: number) => {
const root = scrollRef.current
if (!root) return
const r = root.getBoundingClientRect()
keepAnchor.current = {
px: root.scrollLeft + (cx - r.left), py: root.scrollTop + (cy - r.top),
cx: cx - r.left, cy: cy - r.top, from,
}
}, [])
/**
* Die nächste runde Fünferzahl, für Finger und Mausrad. Undefined am
* Anschlag.
*
* Gerechnet wird in Prozentpunkten und nicht in Bruchteilen: Der Nutzer
* liest Prozente, und ein Raster, das nur *fast* auf 70 trifft, ist keins.
* Aus einer krummen Ausgangsgröße – „einpassen" ergibt zum Beispiel 65,3 %
* – führt der erste Schritt auf die nächste runde Zahl, danach geht es in
* Fünfern weiter.
*/
function nextFine(now: number, dir: 1 | -1): number | undefined {
// **Gerundet, bevor gerechnet wird.** 1,1 × 100 ergibt in Fließkomma
// 110.00000000000001, und daraus wird die „nächste kleinere Fünferzahl"
// wieder 110 – der Zoom blieb dann stehen. Gemessen: Beim Zuziehen kam
// genau eine Stufe, danach nichts mehr.
const p = Math.round(now * 100 * 1e6) / 1e6
const step = zoomStepPercent
const raw = dir > 0
? Math.floor(p / step + 1) * step
: Math.ceil(p / step - 1) * step
const next = Math.min(maxScale * 100, Math.max(minScale * 100, raw)) / 100
return Math.abs(next - now) < 0.001 ? undefined : next
}
function zoom(dir: 1 | -1) {
const next = nextStep(effScale, dir)
const zoom = useCallback((dir: 1 | -1) => {
const next = nextStep(scaleRef.current, dir)
if (next) { keepPage.current = current; setScale(next) }
}
}, [current])
/**
* Strg + Mausrad zoomt – wie in jedem Betrachter, und wie in InkNote.
@@ -694,31 +646,22 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
// Auch die Meta-Taste: Auf einem Mac ist sie die übliche.
if (!e.ctrlKey && !e.metaKey) return
e.preventDefault()
const root2 = scrollRef.current
if (!root2) return
const dir: 1 | -1 = e.deltaY < 0 ? 1 : -1
const next = nextFine(effScale, dir)
const from = scaleRef.current
const next = nextFine(from, e.deltaY < 0 ? 1 : -1)
if (!next) return
const r = root2.getBoundingClientRect()
const cx = e.clientX - r.left
const cy = e.clientY - r.top
keepAnchor.current = {
px: root2.scrollLeft + cx, py: root2.scrollTop + cy,
cx, cy, from: effScale,
}
anchorAt(e.clientX, e.clientY, from)
setScale(next)
}
root.addEventListener('wheel', onWheel, { passive: false })
return () => root.removeEventListener('wheel', onWheel)
}, [effScale])
}, [anchorAt])
/**
* Zwei Finger zoomen das Dokument – nicht die Seite.
*
* Bisher vergrößerte die Zusammenziehgeste die **Anwendung**: Leiste,
* Knöpfe und Suchfeld wuchsen mit, das PDF blieb so grob wie vorher, und
* hinterher stand alles verschoben. Das ist der Grund, aus dem am Telefon
* bisher nur die Knöpfe blieben.
* hinterher stand alles verschoben.
*
* Zwei Teile gehören dazu, und einer allein genügt nicht: `touch-action`
* unten am Behälter nimmt dem Browser die eigene Zoomgeste (Ziehen mit
@@ -744,9 +687,7 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
if (!root) return
function distance(t: TouchList) {
const dx = t[0].clientX - t[1].clientX
const dy = t[0].clientY - t[1].clientY
return Math.hypot(dx, dy)
return Math.hypot(t[0].clientX - t[1].clientX, t[0].clientY - t[1].clientY)
}
function onStart(e: TouchEvent) {
@@ -783,17 +724,13 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
if (dir < 0 && ratio > Math.min(0.98, needed)) return
pinchBase.current = now
const root2 = scrollRef.current
if (!root2) return
// Festgehalten wird die Mitte zwischen den Fingern – dieselbe Regel wie
// beim Mausrad, nur dass der Zeiger hier aus zwei Punkten entsteht.
const r = root2.getBoundingClientRect()
const cx = (e.touches[0].clientX + e.touches[1].clientX) / 2 - r.left
const cy = (e.touches[0].clientY + e.touches[1].clientY) / 2 - r.top
keepAnchor.current = {
px: root2.scrollLeft + cx, py: root2.scrollTop + cy,
cx, cy, from,
}
anchorAt(
(e.touches[0].clientX + e.touches[1].clientX) / 2,
(e.touches[0].clientY + e.touches[1].clientY) / 2,
from,
)
setScale(next)
}
@@ -812,60 +749,14 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
root.removeEventListener('touchend', onEnd)
root.removeEventListener('touchcancel', onEnd)
}
// Ohne Abhängigkeiten: Der Zuhörer meldet sich **einmal** an. Hinge er am
// Maßstab, meldete er sich nach jeder Stufe neu an – mitten in der Geste,
// und der gemerkte Fingerabstand wäre weg. Gemessen: Eine lange Geste kam
// dann über genau eine Stufe nicht hinaus.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// `anchorAt` ist über useCallback stabil, der Zuhörer meldet sich also
// **einmal** an. Hinge er am Maßstab, meldete er sich nach jeder Stufe neu
// an – mitten in der Geste, und der gemerkte Fingerabstand wäre weg.
// Gemessen: Eine lange Geste kam dann über genau eine Stufe nicht hinaus.
}, [anchorAt])
const pages = useMemo(() => sizes.map((_, i) => i + 1), [sizes])
/** Verkleinern, Prozentzahl (zugleich „einpassen"), Vergrößern. Einmal
* beschrieben, an zwei Stellen gezeigt – breit rechts außen, schmal in der
* ersten Zeile. */
const zoomGroup = (
<>
<ToolButton label="Zoom out" onClick={() => zoom(-1)} glyph="minus" />
{/* Zurück zur eingepassten Größe. Bewusst kein Umschalter zwischen
„Breite" und „100 %": Seit die Einpassung bei 100 % gedeckelt ist,
sind das am Schreibtisch oft dieselbe Zahl – ein Knopf, der nichts
tut, ist schlimmer als keiner. */}
<button
onClick={() => { keepPage.current = current; setScale('fit') }}
disabled={scale === 'fit'}
title="Fit width"
style={{
fontSize: 12, color: 'var(--color-text-muted)', minWidth: 52, flexShrink: 0,
border: 'none', background: 'none',
cursor: scale === 'fit' ? 'default' : 'pointer',
fontVariantNumeric: 'tabular-nums',
}}
>
{Math.round(effScale * 100)}%
</button>
<ToolButton label="Zoom in" onClick={() => zoom(1)} glyph="plus" />
</>
)
/**
* Reicht die Breite für drei Spalten? Unterhalb bricht die Leiste um, und
* ein Raster ergäbe eine gequetschte Suche neben gequetschten Knöpfen.
*/
const barRef = useRef<HTMLDivElement>(null)
const [barNarrow, setBarNarrow] = useState(false)
useEffect(() => {
const el = barRef.current
if (!el) return
// Dieselbe Vorsicht wie bei der Einpassung: Breite null heißt „noch nicht
// sichtbar", nicht „schmal". Ohne die Prüfung stand die Leiste beim
// Aufgehen der Vorschau im Umbruch-Zustand und blieb dort.
const set = () => { if (el.clientWidth >= 50) setBarNarrow(el.clientWidth < minGridWidth) }
set()
const ro = new ResizeObserver(set)
ro.observe(el)
return () => ro.disconnect()
}, [])
const [pad, setPad] = useState(16)
useEffect(() => {
const el = scrollRef.current
@@ -892,137 +783,22 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
width: '100%', height: '100%', display: 'flex', flexDirection: 'column',
borderRadius: 8, overflow: 'hidden', background: 'var(--color-surface)',
}}>
{/*
Werkzeugleiste.
Am Schreibtisch ein **Dreispaltenraster**: Steuerung links, Suche in
der Mitte, rechts eine leere Spalte gleicher Breite. Erst dadurch sitzt
die Suche wirklich mittig – mit zwei Abstandhaltern in einer Reihe
teilen sich diese nur den *Rest* neben der Steuerung, und das Feld
landete gemessen 79 bzw. 142 Pixel daneben.
Am Telefon reicht die Breite für drei Spalten nicht; dort bleibt es
beim Umbruch, und die Suche steht in der zweiten Zeile.
*/}
<div
ref={barRef}
style={{
display: barNarrow ? 'flex' : 'grid',
gridTemplateColumns: barNarrow ? undefined : 'minmax(0, 1fr) auto minmax(0, 1fr)',
alignItems: 'center', gap: 8, flexWrap: 'wrap',
padding: '6px 10px', flexShrink: 0,
borderBottom: '1px solid var(--color-border)',
background: 'var(--color-surface)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<ToolButton label="Previous page" disabled={current <= 1}
onClick={() => goToPage(current - 1)} glyph="up" />
<span style={{
fontSize: 12, color: 'var(--color-text-muted)',
minWidth: 62, flexShrink: 0,
textAlign: 'center', fontVariantNumeric: 'tabular-nums',
}}>
{doc ? `${current} / ${doc.numPages}` : '…'}
</span>
<ToolButton label="Next page" disabled={!doc || current >= doc.numPages}
onClick={() => goToPage(current + 1)} glyph="down" />
{barNarrow && (
<>
<span style={{ width: 1, height: 20, flexShrink: 0, background: 'var(--color-border)' }} />
{zoomGroup}
</>
)}
</div>
{/* Suche im Dokument – die mittlere Spalte. Sie ist der Grund, aus dem
es diesen Betrachter gibt; rechts an den Rand gedrängt sah sie aus
wie ein Zusatz, der nicht recht dazugehört. */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{/* Gegengewicht. Rechts vom Feld stehen Zähler und Sprungknöpfe;
ohne diesen Ausgleich säße die Gruppe zwar mittig, das **Feld**
aber 79 Pixel daneben – gemessen. Mittig soll das sein, was man
ansieht, nicht der Kasten drumherum.
Am Telefon fällt es weg: Dort gibt es nichts zu zentrieren, und
120 Pixel Leere hätten ein Drittel der Zeile gekostet – während
rechts der zweite Sprungknopf aus dem Bild fiel. */}
{!barNarrow && <span style={{ width: hitsWidth, flexShrink: 0 }} />}
<input
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault()
if (hits.length) setHitIndex(i => (i + (e.shiftKey ? -1 : 1) + hits.length) % hits.length)
}
// Escape gehört hier dem Feld, nicht dem Schließen der Vorschau.
if (e.key === 'Escape' && query) { e.stopPropagation(); setQuery('') }
}}
placeholder="Find in document"
style={{
// Breiter als die 150 vom Anfang: In der Mitte einer 1440 Pixel
// breiten Leiste sah ein Feld in Knopfbreite aus wie ein
// Restposten. Ein Suchbegriff wie „Zahlungsbedingungen" passt
// jetzt auch hinein, ohne zu wandern.
width: 240, minWidth: 0,
padding: '5px 8px', borderRadius: 8, fontSize: 12,
border: '1px solid var(--color-border)',
background: 'var(--color-background)', color: 'var(--color-text)',
outline: 'none',
}}
/>
<span style={{
fontSize: 11, color: 'var(--color-text-muted)',
// Feste Breite, nicht Mindestbreite: Sonst wüchse die rechte Seite
// mit „Searching…" und das Feld rutschte beim Tippen hin und her.
width: counterWidth, flexShrink: 0,
overflow: 'hidden', whiteSpace: 'nowrap',
fontVariantNumeric: 'tabular-nums',
}}>
{searching ? 'Searching…'
: query.trim().length < 2 ? ''
: hits.length === 0 ? 'No hits'
: `${hitIndex + 1} / ${hits.length}${hits[0] && !hits[0].exact ? ' · page' : ''}`}
</span>
{/* Erst zeigen, wenn es etwas zu springen gibt. Ohne Suche stand
hier ein zweites Paar Winkel neben dem zum Blättern – zwei
gleich aussehende Knopfpaare für zwei ganz verschiedene Dinge. */}
{/* Der Platz für die beiden Knöpfe steht immer bereit, auch wenn
sie fehlen. Sonst rückte das Suchfeld beim ersten Treffer um
knapp dreißig Pixel zur Seite – ausgerechnet während man darin
tippt. Leerer Raum fällt niemandem auf, ein wanderndes Feld
schon. */}
<span style={{ width: hitButtonsWidth, flexShrink: 0, display: 'flex', gap: 4 }}>
{hits.length > 0 && (
<>
<ToolButton label="Previous hit" glyph="hitUp"
onClick={() => setHitIndex(i => (i - 1 + hits.length) % hits.length)} />
<ToolButton label="Next hit" glyph="hitDown"
onClick={() => setHitIndex(i => (i + 1) % hits.length)} />
</>
)}
</span>
</div>
{/* Die dritte Spalte: der Zoom, rechtsbündig – dieselbe Anordnung wie
in InkNote. Die Spalte war ohnehin nötig, damit „Mitte" die Mitte
der Leiste ist und nicht die Mitte des Restes; jetzt trägt sie
auch etwas.
Schmal steht der Zoom **nicht** hier, sondern oben neben der
Seitenzahl: Als eigene Zeile wäre die Leiste am Telefon dreizeilig
geworden – gemessen 190 statt 140 Pixel, und die gehen dem
Dokument ab. */}
{!barNarrow && (
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
justifyContent: 'flex-end', minWidth: 0,
}}>
{zoomGroup}
</div>
)}
</div>
<PdfToolbar
page={current}
pageCount={doc?.numPages ?? null}
onGoPage={goToPage}
percent={Math.round(effScale * 100)}
isFit={scale === 'fit'}
onZoom={zoom}
onFit={() => { keepPage.current = current; setScale('fit') }}
query={query}
onQuery={setQuery}
searching={searching}
hitCount={hits.length}
hitIndex={hitIndex}
pageOnlyHits={!!hits[0] && !hits[0].exact}
onStepHit={stepHit}
/>
{/* Seiten */}
<div
@@ -1070,124 +846,3 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
</div>
)
}
/**
* Die häufigste Seitenbreite eines Dokuments.
*
* Vorher stand hier die **breiteste** Seite, mit dem Gedanken: Unterschiedlich
* breite Seiten unterschiedlich zu skalieren sieht nach Fehler aus. Das
* stimmt – nur ist der Preis höher als der Gewinn.
*
* Gemessen an einem zehnseitigen Dokument, in dem **eine** Seite quer liegt:
* Die neun hochkanten Seiten standen dadurch auf 46 % statt 65 %. Auf einem
* 412 Pixel breiten Telefon ist das der Unterschied zwischen lesbar und nicht.
* Eine einzelne Querformat-Seite – ein Anhang, eine breite Tabelle – bestimmt
* so die Darstellung des ganzen Dokuments.
*
* Jetzt entscheidet die Mehrheit. Die abweichende Seite ragt dann über den
* Rand und lässt sich seitlich schieben; das ist genau das, was man bei ihr
* ohnehin tun will.
*
* Bei Gleichstand gewinnt die breitere – dann ragt lieber nichts heraus.
*/
function commonWidth(sizes: { w: number; h: number }[]): number {
const count = new Map<number, number>()
for (const s of sizes) {
const w = Math.round(s.w)
count.set(w, (count.get(w) ?? 0) + 1)
}
let best = 0, bestN = 0
for (const [w, n] of count) {
if (n > bestN || (n === bestN && w > best)) { best = w; bestN = n }
}
return best || 1
}
/**
* Weitet eine Fundstelle auf das **ganze Wort** aus.
*
* Der Grund ist gemessen, nicht Geschmack. Über dem gemalten Bild liegt eine
* unsichtbare *Ersatz*schrift – hier `sans-serif 13,5px` für eine
* Serifenschrift im PDF. PDF.js dehnt sie über `--scale-x` (gemessen 0,937),
* damit die **Zeile insgesamt** stimmt. Die Buchstaben *darin* sitzen deshalb
* um ein bis drei Pixel anders als die gemalten.
*
* Bei einem ganzen Wort fällt das nicht auf. Bei „la" mitten in „Display"
* schon: Der Kasten ist zehn Pixel breit, und drei Pixel Versatz sehen aus,
* als läge er auf den falschen Buchstaben. Genau das war zu sehen.
*
* Gegenprobe im Browser: Unser Kasten und die Auswahl, die der Browser beim
* Markieren mit der Maus malt, liegen auf demselben Pixel (357,08 / 10,38).
* Der Versatz ist also nicht unser Fehler – er lässt sich nur nicht
* wegrechnen, ohne die Schrift des Dokuments zu besitzen. Ihn zu *umgehen*
* geht: ganze Wörter markieren, deren Ränder an Wortgrenzen liegen.
*
* Sehr lange „Wörter" (Adressen, Prüfsummen) bleiben ungedehnt – sonst wäre
* eine halbe Zeile eingefärbt, weil irgendwo darin zwei Buchstaben passten.
*/
const maxWordSnap = 40
function wordAround(text: string, at: number, to: number): [number, number] {
const isWord = (c: string) => /[\p{L}\p{N}]/u.test(c)
let s = at, e = to
while (s > 0 && isWord(text[s - 1])) s--
while (e < text.length && isWord(text[e])) e++
return e - s > maxWordSnap ? [at, to] : [s, e]
}
/** Zählt die Fundstellen eines Suchworts in einem Stück Text. */
function countIn(haystack: string, needle: string, page: number, exact: boolean): Hit[] {
const out: Hit[] = []
let from = 0, nth = 0
for (;;) {
const at = haystack.indexOf(needle, from)
if (at < 0) break
out.push({ page, nth: nth++, exact })
from = at + needle.length
}
return out
}
/**
* Die Knöpfe der Werkzeugleiste steuern **zwei verschiedene** Dinge: links
* die Seite, rechts die Fundstelle. Beide bewegen sich nach oben und unten –
* und mit demselben Winkel-Zeichen sahen beide Paare gleich aus. Wer die
* Leiste zum ersten Mal sieht, kann nicht wissen, welches welches ist.
*
* Deshalb zwei Formen: der schlanke Winkel blättert Seiten, der Pfeil mit
* Schaft springt zur nächsten Fundstelle. Der Pfeil erscheint ohnehin erst,
* wenn es Fundstellen gibt.
*/
const glyphs: Record<string, string> = {
up: '18 15 12 9 6 15',
down: '6 9 12 15 18 9',
}
function ToolButton({ label, onClick, disabled, glyph }: {
label: string; onClick: () => void; disabled?: boolean
glyph: 'up' | 'down' | 'plus' | 'minus' | 'hitUp' | 'hitDown'
}) {
return (
<button
onClick={onClick}
disabled={disabled}
title={label}
aria-label={label}
style={{
width: 28, height: 28, flexShrink: 0, borderRadius: 8, border: 'none',
background: 'none', color: 'var(--color-text-muted)',
cursor: disabled ? 'default' : 'pointer', opacity: disabled ? 0.35 : 1,
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0,
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2" strokeLinecap="round">
{glyph === 'plus' && <><line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /></>}
{glyph === 'minus' && <line x1="5" y1="12" x2="19" y2="12" />}
{(glyph === 'up' || glyph === 'down') && <polyline points={glyphs[glyph]} />}
{glyph === 'hitUp' && <><line x1="12" y1="19" x2="12" y2="6" /><polyline points="6 12 12 6 18 12" /></>}
{glyph === 'hitDown' && <><line x1="12" y1="5" x2="12" y2="18" /><polyline points="6 12 12 18 18 12" /></>}
</svg>
</button>
)
}
-1
View File
@@ -124,7 +124,6 @@ export default function Shell({ user, onLogout, onUserChanged }: Props) {
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0, overflow: 'hidden' }}>
{view === 'files' && (
<FileBrowser
user={user}
folderPath={folderPath}
onNavigate={nav.setFolderPath}
onUserChanged={onUserChanged}
+1 -1
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'
import { versionsApi, type FileItem, type FileVersion } from '../api/client'
import { formatBytes } from '../pages/FileBrowser'
import { formatBytes } from '../format'
import { useOverlayDismiss } from '../hooks/useOverlayDismiss'
import { useAppSettings } from '../hooks/useAppSettings'
@@ -0,0 +1,189 @@
import type { Correspondent, FileItem, FolderItem, Tag } from '../../api/client'
import InputDialog from '../Dialog'
import ConfirmDialog from '../ConfirmDialog'
import ShareDialog from '../ShareDialog'
import ShareWithDialog from '../ShareWithDialog'
import VersionsDialog from '../VersionsDialog'
import FileInfoDialog from '../FileInfoDialog'
import TagDialog from '../TagDialog'
import MoveTo from '../MoveTo'
/**
* Alle Dialoge des Dateibrowsers an einer Stelle.
*
* Es ist **immer höchstens einer** offen – das ist der Grund, warum sie einen
* gemeinsamen Zustand haben und hier eine gemeinsame Verzweigung. Am Ende der
* Seite standen dafür 150 Zeilen aus dreizehn fast gleich aussehenden Blöcken,
* hinter denen die eigentliche Anzeige verschwand.
*
* Die Verzweigung selbst bleibt, wie sie war: eine ausgezeichnete Vereinigung
* (`type`), keine dreizehn Wahrheitswerte. Ein Zustand, in dem zwei Dialoge
* zugleich „offen" sind, lässt sich so gar nicht erst hinschreiben.
*/
export type Dialog =
| { type: 'rename'; file: FileItem }
| { type: 'rename-folder'; folder: FolderItem }
| { type: 'confirm-delete-folder'; folder: FolderItem }
| { type: 'new-folder' }
| { type: 'share'; file: FileItem }
| { type: 'share-with'; resource: { type: 'folder' | 'file'; id: string; name: string } }
| { type: 'versions'; file: FileItem }
| { type: 'confirm-delete'; file: FileItem }
| { type: 'confirm-delete-many'; ids: string[] }
| { type: 'move-file'; file: FileItem }
| { type: 'move-folder'; folder: FolderItem }
| { type: 'tags'; file: FileItem }
| { type: 'info'; file: FileItem }
interface Props {
dialog: Dialog | null
/** Abbrechen – bei jedem Dialog dieselbe Geste. */
onClose: () => void
fileTags: Record<string, Tag[]>
fileCorrespondents: Record<string, Correspondent>
renameFile(file: FileItem, name: string): void
renameFolder(folder: FolderItem, name: string): void
createFolder(name: string): void
deleteFile(file: FileItem): void
deleteFolder(folder: FolderItem): void
deleteMany(ids: string[]): void
moveFile(file: FileItem, targetId: string | null): void
moveFolder(folder: FolderItem, targetId: string | null): void
versionRestored(file: FileItem): void
tagsSaved(fileId: string, tags: Tag[]): void
}
export default function FileDialogs({
dialog, onClose, fileTags, fileCorrespondents,
renameFile, renameFolder, createFolder,
deleteFile, deleteFolder, deleteMany,
moveFile, moveFolder, versionRestored, tagsSaved,
}: Props) {
if (!dialog) return null
switch (dialog.type) {
case 'rename':
return (
<InputDialog
title="Rename"
defaultValue={dialog.file.name}
confirmLabel="Rename"
onConfirm={name => renameFile(dialog.file, name)}
onCancel={onClose}
/>
)
case 'rename-folder':
return (
<InputDialog
title="Rename folder"
defaultValue={dialog.folder.name}
confirmLabel="Rename"
onConfirm={name => renameFolder(dialog.folder, name)}
onCancel={onClose}
/>
)
case 'new-folder':
return (
<InputDialog
title="New folder"
placeholder="Folder name"
confirmLabel="Create"
onConfirm={createFolder}
onCancel={onClose}
/>
)
case 'confirm-delete-folder':
return (
<ConfirmDialog
title="Delete folder"
message={`Move "${dialog.folder.name}" and all its contents to the trash?`}
confirmLabel="Delete"
danger
onConfirm={() => deleteFolder(dialog.folder)}
onCancel={onClose}
/>
)
case 'confirm-delete':
return (
<ConfirmDialog
title="Move to trash"
message={`Move "${dialog.file.name}" to the trash?`}
confirmLabel="Move to trash"
danger
onConfirm={() => deleteFile(dialog.file)}
onCancel={onClose}
/>
)
case 'confirm-delete-many':
return (
<ConfirmDialog
title="Move to trash"
message={`Move ${dialog.ids.length} ${dialog.ids.length === 1 ? 'file' : 'files'} to the trash? You can restore them from there.`}
confirmLabel={`Move ${dialog.ids.length} to trash`}
danger
onConfirm={() => deleteMany(dialog.ids)}
onCancel={onClose}
/>
)
case 'share':
return <ShareDialog file={dialog.file} onClose={onClose} />
case 'share-with':
return <ShareWithDialog resource={dialog.resource} onClose={onClose} />
case 'versions':
return (
<VersionsDialog
file={dialog.file}
onClose={onClose}
onRestored={versionRestored}
/>
)
case 'info':
return (
<FileInfoDialog
file={dialog.file}
tags={fileTags[dialog.file.id] ?? []}
correspondent={fileCorrespondents[dialog.file.id]}
onClose={onClose}
/>
)
case 'tags':
return (
<TagDialog
file={dialog.file}
current={fileTags[dialog.file.id] ?? []}
currentCorrespondent={fileCorrespondents[dialog.file.id]}
onClose={onClose}
onSaved={tags => tagsSaved(dialog.file.id, tags)}
/>
)
case 'move-file':
return (
<MoveTo
title={`Move "${dialog.file.name}"`}
onConfirm={targetId => moveFile(dialog.file, targetId)}
onCancel={onClose}
/>
)
case 'move-folder':
return (
<MoveTo
title={`Move "${dialog.folder.name}"`}
excludeId={dialog.folder.id}
onConfirm={targetId => moveFolder(dialog.folder, targetId)}
onCancel={onClose}
/>
)
}
}
@@ -0,0 +1,147 @@
import SearchBox from '../SearchBox'
import { DownloadIcon, TrashIcon } from '../icons'
import type { SearchHit } from '../../api/client'
/**
* Die Bedienelemente rechts in der Kopfleiste des Dateibrowsers.
*
* Zwei Zustände, nicht zwei Leisten: Solange etwas ausgewählt ist, **ersetzt**
* die Auswahlleiste Suche und Sortierung. Sie danebenzustellen wäre auf einem
* schmalen Schirm eine dritte Zeile – und solange man auswählt, will man
* ohnehin nichts anderes.
*/
export type ViewMode = 'grid' | 'list'
export type Sort = 'name-asc' | 'name-desc' | 'size-asc' | 'size-desc' | 'date-asc' | 'date-desc'
export const sortLabels: Record<Sort, string> = {
'name-asc': 'Name A–Z',
'name-desc': 'Name Z–A',
'size-asc': 'Smallest first',
'size-desc': 'Largest first',
'date-asc': 'Oldest first',
'date-desc': 'Newest first',
}
interface Props {
selectedCount: number
onDownloadSelection: () => void
onTrashSelection: () => void
onClearSelection: () => void
onOpenFolder: (folderId: string | null, folderName: string) => void
onOpenFile: (hit: SearchHit, term: string) => void
sort: Sort
onSort: (s: Sort) => void
viewMode: ViewMode
onViewMode: (v: ViewMode) => void
onNewFolder: () => void
onUpload: () => void
}
export default function FileToolbar({
selectedCount, onDownloadSelection, onTrashSelection, onClearSelection,
onOpenFolder, onOpenFile, sort, onSort, viewMode, onViewMode,
onNewFolder, onUpload,
}: Props) {
return (
<>
{selectedCount > 0 ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flex: 1, minWidth: 0 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text)', flexShrink: 0 }}>
{selectedCount} selected
</span>
<button onClick={onDownloadSelection} style={primaryButton}>
<DownloadIcon /> Download as ZIP
</button>
<button onClick={onTrashSelection} style={dangerButton}>
<TrashIcon /> Move to trash
</button>
<button onClick={onClearSelection} style={quietButton}>Clear</button>
</div>
) : (
<SearchBox onOpenFolder={onOpenFolder} onOpenFile={onOpenFile} />
)}
<select
value={sort}
onChange={e => onSort(e.target.value as Sort)}
title="Sort by"
style={{
padding: '6px 8px', borderRadius: 8, fontSize: 12,
border: '1px solid var(--color-border)',
background: 'var(--color-surface-2)',
color: 'var(--color-text-muted)', cursor: 'pointer',
}}
>
{(Object.keys(sortLabels) as Sort[]).map(key => (
<option key={key} value={key}>{sortLabels[key]}</option>
))}
</select>
<div style={{ display: 'flex', borderRadius: 8, overflow: 'hidden', border: '1px solid var(--color-border)' }}>
<button onClick={() => onViewMode('grid')} title="Grid view" style={toggleStyle(viewMode === 'grid')}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" />
</svg>
</button>
<button onClick={() => onViewMode('list')} title="List view" style={toggleStyle(viewMode === 'list')}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="8" y1="6" x2="21" y2="6" /><line x1="8" y1="12" x2="21" y2="12" /><line x1="8" y1="18" x2="21" y2="18" />
<circle cx="3" cy="6" r="1" fill="currentColor" /><circle cx="3" cy="12" r="1" fill="currentColor" /><circle cx="3" cy="18" r="1" fill="currentColor" />
</svg>
</button>
</div>
<button
onClick={onNewFolder}
title="New folder"
style={{
padding: '6px 14px', borderRadius: 8, border: '1px solid var(--color-border)',
background: 'transparent', color: 'var(--color-text-muted)', fontSize: 13, cursor: 'pointer',
}}
>
+ Folder
</button>
<button
onClick={onUpload}
title="Upload files (drag & drop for folders)"
style={{
padding: '6px 14px', borderRadius: 8, border: 'none',
background: 'var(--color-primary)', color: '#fff', fontSize: 13, fontWeight: 600, cursor: 'pointer',
}}
>
Upload
</button>
</>
)
}
const primaryButton: React.CSSProperties = {
display: 'flex', alignItems: 'center', gap: 7,
padding: '6px 14px', borderRadius: 8, border: 'none', cursor: 'pointer',
fontSize: 12, fontWeight: 600, color: '#fff', background: 'var(--color-primary)',
flexShrink: 0,
}
const dangerButton: React.CSSProperties = {
display: 'flex', alignItems: 'center', gap: 7,
padding: '6px 14px', borderRadius: 8, cursor: 'pointer',
fontSize: 12, fontWeight: 600, flexShrink: 0,
border: '1px solid var(--color-danger)', background: 'transparent',
color: 'var(--color-danger)',
}
const quietButton: React.CSSProperties = {
padding: '6px 10px', borderRadius: 8, cursor: 'pointer', fontSize: 12,
border: '1px solid var(--color-border)', background: 'transparent',
color: 'var(--color-text-muted)', flexShrink: 0,
}
function toggleStyle(active: boolean): React.CSSProperties {
return {
padding: '6px 10px', border: 'none', cursor: 'pointer',
background: active ? 'var(--color-surface-2)' : 'transparent',
color: active ? 'var(--color-text)' : 'var(--color-text-muted)',
}
}
+119
View File
@@ -0,0 +1,119 @@
import type { MenuItem } from '../ContextMenu'
import type { FileItem, FolderItem } from '../../api/client'
import { isOfficeFile } from '../../api/client'
import {
DownloadIcon, ShareIcon, HistoryIcon, RenameIcon, MoveIcon, TrashIcon,
FolderOpenIcon, StarIcon, CheckIcon, PeopleIcon, TagIcon, OfficeIcon, InfoIcon,
} from '../icons'
/**
* Die beiden Kontextmenüs des Dateibrowsers.
*
* Ausgelagert, weil sie **Beschreibung** sind und keine Ablaufsteuerung: eine
* Liste aus Beschriftung, Symbol und einem Ruf nach außen. Zwischen dem
* Hochladen und den Dialogen der Seite standen 120 Zeilen davon; hier lassen
* sie sich am Stück lesen – und die Reihenfolge der Einträge ist genau das,
* worüber man beim Lesen nachdenkt.
*
* Die Gruppierung ist Absicht und bleibt, wie sie war: erst, was man am
* häufigsten will, dann Teilen, dann Verwalten, ganz unten abgesetzt das
* Löschen.
*/
export interface FileActions {
/** Steht ein Office-Server bereit? Ohne ihn fehlt „Edit in Office". */
officeEnabled: boolean
download(file: FileItem): void
properties(file: FileItem): void
shareWithPeople(file: FileItem): void
shareLink(file: FileItem): void
organise(file: FileItem): void
editInOffice(file: FileItem): void
versionHistory(file: FileItem): void
setVersioning(file: FileItem, enabled: boolean): void
rename(file: FileItem): void
move(file: FileItem): void
remove(file: FileItem): void
}
export function fileMenuItems(file: FileItem, a: FileActions): MenuItem[] {
return [
{ label: 'Download', icon: <DownloadIcon />, onClick: () => a.download(file) },
// Nachschlagen statt anfassen – deshalb direkt beim Herunterladen und
// nicht unten bei Umbenennen/Verschieben. Beantwortet Fragen, die die
// Kachel nicht beantwortet: Bildmaße, genaue Bytezahl, Prüfsumme.
{ label: 'Properties…', icon: <InfoIcon />, onClick: () => a.properties(file) },
{ separator: true },
{ label: 'Share with people…', icon: <PeopleIcon />, onClick: () => a.shareWithPeople(file) },
{ label: 'Share link…', icon: <ShareIcon />, onClick: () => a.shareLink(file) },
{ separator: true },
// Zwischen Teilen und Verwalten: Etikettieren ist beides nicht ganz –
// es ordnet ein, ohne die Datei anzufassen.
// Hiess bis zum 23.08.2026 „Tags…". Der Dialog setzt inzwischen drei
// Dinge – Etiketten, Absender und Belegdatum –, und ein Menuepunkt,
// der nur eines davon nennt, laesst die anderen beiden unauffindbar.
{ label: 'Organise…', icon: <TagIcon />, onClick: () => a.organise(file) },
// Nur bei passenden Dateien und nur, wenn es einen Office-Server gibt.
// Ein Punkt, der immer scheitert, ist schlimmer als keiner.
...(a.officeEnabled && isOfficeFile(file.name)
? [{ label: 'Edit in Office…', icon: <OfficeIcon />, onClick: () => a.editInOffice(file) }]
: []),
{ separator: true },
{ label: 'Version history…', icon: <HistoryIcon />, onClick: () => a.versionHistory(file) },
{
label: 'Keep versions',
icon: <CheckIcon />,
checked: file.versioning_enabled,
onClick: () => a.setVersioning(file, !file.versioning_enabled),
},
{ separator: true },
{ label: 'Rename…', icon: <RenameIcon />, onClick: () => a.rename(file) },
{ label: 'Move to…', icon: <MoveIcon />, onClick: () => a.move(file) },
{ separator: true },
{ label: 'Delete', icon: <TrashIcon />, danger: true, onClick: () => a.remove(file) },
]
}
export interface FolderActions {
isFavourite(folderId: string): boolean
open(folder: FolderItem): void
toggleFavourite(folder: FolderItem, isFav: boolean): void
shareWithPeople(folder: FolderItem): void
downloadZip(folder: FolderItem): void
rename(folder: FolderItem): void
move(folder: FolderItem): void
remove(folder: FolderItem): void
}
export function folderMenuItems(folder: FolderItem, a: FolderActions): MenuItem[] {
const isFav = a.isFavourite(folder.id)
// Ein Gruppenordner gehört der Gruppe, nicht dem, der ihn gerade offen hat.
// Umbenennen, Verschieben und Löschen fehlen deshalb ganz statt als
// Einträge, die nur eine Fehlermeldung liefern – der Server lehnt sie
// ohnehin ab. Alles *innerhalb* des Ordners bleibt normal bedienbar.
const isGroupFolder = folder.group_id !== null
return [
{ label: 'Open', icon: <FolderOpenIcon />, onClick: () => a.open(folder) },
{
label: 'Favourite',
icon: <StarIcon filled={isFav} />,
checked: isFav,
onClick: () => a.toggleFavourite(folder, isFav),
},
...(isGroupFolder ? [] : [
{ separator: true as const },
{ label: 'Share with people…', icon: <PeopleIcon />, onClick: () => a.shareWithPeople(folder) },
{ separator: true as const },
// Ganzer Ordnerinhalt als Archiv – mit Unterordnern, ihre Struktur
// bleibt im Archiv erhalten.
{ label: 'Download as ZIP', icon: <DownloadIcon />, onClick: () => a.downloadZip(folder) },
{ separator: true as const },
{ label: 'Rename…', icon: <RenameIcon />, onClick: () => a.rename(folder) },
{ label: 'Move to…', icon: <MoveIcon />, onClick: () => a.move(folder) },
{ separator: true as const },
{ label: 'Delete', icon: <TrashIcon />, danger: true, onClick: () => a.remove(folder) },
]),
]
}
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest'
import { sortFiles, sortFolders } from './sorting'
import type { FileItem, FolderItem } from '../../api/client'
function file(name: string, size: number, updated: string): FileItem {
return { id: name, name, size, updated_at: updated } as FileItem
}
function folder(name: string): FolderItem {
return { id: name, name } as FolderItem
}
const files = [
file('beta.txt', 300, '2026-01-03T00:00:00Z'),
file('alpha.txt', 100, '2026-01-02T00:00:00Z'),
file('gamma.txt', 200, '2026-01-01T00:00:00Z'),
]
const names = (list: { name: string }[]) => list.map(x => x.name)
describe('sortFiles', () => {
it('sortiert nach Namen in beide Richtungen', () => {
expect(names(sortFiles(files, 'name-asc'))).toEqual(['alpha.txt', 'beta.txt', 'gamma.txt'])
expect(names(sortFiles(files, 'name-desc'))).toEqual(['gamma.txt', 'beta.txt', 'alpha.txt'])
})
it('sortiert nach Größe', () => {
expect(names(sortFiles(files, 'size-asc'))).toEqual(['alpha.txt', 'gamma.txt', 'beta.txt'])
expect(names(sortFiles(files, 'size-desc'))).toEqual(['beta.txt', 'gamma.txt', 'alpha.txt'])
})
it('sortiert nach Datum', () => {
expect(names(sortFiles(files, 'date-asc'))).toEqual(['gamma.txt', 'alpha.txt', 'beta.txt'])
expect(names(sortFiles(files, 'date-desc'))).toEqual(['beta.txt', 'alpha.txt', 'gamma.txt'])
})
it('lässt die übergebene Liste unangetastet', () => {
const before = names(files)
sortFiles(files, 'size-desc')
expect(names(files)).toEqual(before)
})
})
describe('sortFolders', () => {
const folders = [folder('Zeta'), folder('Alpha'), folder('Mitte')]
it('sortiert nach Namen', () => {
expect(names(sortFolders(folders, 'name-asc'))).toEqual(['Alpha', 'Mitte', 'Zeta'])
expect(names(sortFolders(folders, 'name-desc'))).toEqual(['Zeta', 'Mitte', 'Alpha'])
})
it('bleibt bei Größe und Datum aufsteigend beim Namen', () => {
// Ordner haben keine Größe und kein aussagekräftiges Datum – nach ihnen
// zu sortieren ergäbe eine zufällige Reihe, die nach Fehler aussieht.
for (const sort of ['size-asc', 'size-desc', 'date-asc', 'date-desc'] as const) {
expect(names(sortFolders(folders, sort))).toEqual(['Alpha', 'Mitte', 'Zeta'])
}
})
})
+34
View File
@@ -0,0 +1,34 @@
import type { FileItem, FolderItem } from '../../api/client'
import type { Sort } from './FileToolbar'
/**
* Die Reihenfolge in der Liste.
*
* Eigene Datei, weil das die einzigen Funktionen des Dateibrowsers sind, die
* man ohne Browser prüfen kann: hinein gehen Daten, heraus kommen Daten. Genau
* dafür gibt es Tests nebenan.
*/
/**
* Ordner haben keine Größe und kein Änderungsdatum, das in der Liste etwas
* aussagte – sie stehen deshalb **immer** nach Namen, nur die Richtung folgt
* der Einstellung. Nach „Größe" sortiert bliebe sonst eine Reihe Ordner in
* zufälliger Ordnung stehen, und das sieht nach Fehler aus.
*/
export function sortFolders(folders: FolderItem[], sort: Sort): FolderItem[] {
const desc = sort === 'name-desc'
return [...folders].sort((a, b) =>
desc ? b.name.localeCompare(a.name) : a.name.localeCompare(b.name))
}
export function sortFiles(files: FileItem[], sort: Sort): FileItem[] {
const [field, dir] = sort.split('-')
const factor = dir === 'desc' ? -1 : 1
return [...files].sort((a, b) => {
switch (field) {
case 'size': return (a.size - b.size) * factor
case 'date': return (new Date(a.updated_at).getTime() - new Date(b.updated_at).getTime()) * factor
default: return a.name.localeCompare(b.name) * factor
}
})
}
+279
View File
@@ -0,0 +1,279 @@
/**
* Die Werkzeugleiste des PDF-Betrachters.
*
* Am Schreibtisch ein **Dreispaltenraster**: Steuerung links, Suche in der
* Mitte, Zoom rechts. Erst dadurch sitzt die Suche wirklich mittig – mit zwei
* Abstandhaltern in einer Reihe teilen sich diese nur den *Rest* neben der
* Steuerung, und das Feld landete gemessen 79 bzw. 142 Pixel daneben.
*
* Am Telefon reicht die Breite für drei Spalten nicht; dort bleibt es beim
* Umbruch, die Suche steht in der zweiten Zeile, und der Zoom rückt nach oben
* neben die Seitenzahl. Als eigene Zeile wäre die Leiste dreizeilig geworden –
* gemessen 190 statt 140 Pixel, und die gehen dem Dokument ab.
*/
import { useEffect, useRef, useState } from 'react'
/**
* Ab welcher Breite die Leiste als Dreispaltenraster taugt.
*
* Gerechnet, nicht geschätzt: Die Steuerung links braucht 275 Pixel
* (zwei Knöpfe, Seitenzahl, Trenner, Zoom), die Suche in der Mitte 492 – und
* die rechte Spalte ist genauso breit wie die linke, sonst säße die Suche
* nicht mittig. Macht 2 × 275 + 492.
*
* **Vorher stand hier 720, und dazwischen war die Leiste kaputt:** Das Raster
* gab der linken Spalte weniger, als ihr Inhalt braucht, und die Knöpfe
* schrumpften mit. Gemessen bei 980 Pixeln Fensterbreite – erster Knopf 16
* statt 28 Pixel, bei 760 die ganze Gruppe auf 108 statt 275. Am Ende war von
* der Steuerung nichts mehr zu sehen.
*/
const minGridWidth = 2 * 275 + 492
/**
* Breiten rechts vom Suchfeld – und damit das Maß für das Gegengewicht links.
* Beide Seiten gleich breit heißt: Das Feld sitzt in der Mitte der Leiste.
*/
const counterWidth = 54
const hitButtonsWidth = 60
/** Zähler + Knöpfe + die beiden Abstände dazwischen. */
const hitsWidth = counterWidth + hitButtonsWidth + 6
interface Props {
page: number
/** null, solange das Dokument noch lädt. */
pageCount: number | null
onGoPage: (n: number) => void
/** Angezeigte Zoomstufe in Prozent. */
percent: number
/** Steht der Zoom auf „einpassen"? Dann ist der Prozentknopf tot. */
isFit: boolean
onZoom: (dir: 1 | -1) => void
onFit: () => void
query: string
onQuery: (q: string) => void
searching: boolean
hitCount: number
hitIndex: number
/** Treffer aus der Texterkennung kennen nur die Seite, nicht die Stelle. */
pageOnlyHits: boolean
onStepHit: (delta: number) => void
}
export default function PdfToolbar({
page, pageCount, onGoPage,
percent, isFit, onZoom, onFit,
query, onQuery, searching, hitCount, hitIndex, pageOnlyHits, onStepHit,
}: Props) {
const narrow = useNarrow()
/** Verkleinern, Prozentzahl (zugleich „einpassen"), Vergrößern. Einmal
* beschrieben, an zwei Stellen gezeigt – breit rechts außen, schmal in der
* ersten Zeile. */
const zoomGroup = (
<>
<ToolButton label="Zoom out" onClick={() => onZoom(-1)} glyph="minus" />
{/* Zurück zur eingepassten Größe. Bewusst kein Umschalter zwischen
„Breite" und „100 %": Seit die Einpassung bei 100 % gedeckelt ist,
sind das am Schreibtisch oft dieselbe Zahl – ein Knopf, der nichts
tut, ist schlimmer als keiner. */}
<button
onClick={onFit}
disabled={isFit}
title="Fit width"
style={{
fontSize: 12, color: 'var(--color-text-muted)', minWidth: 52, flexShrink: 0,
border: 'none', background: 'none',
cursor: isFit ? 'default' : 'pointer',
fontVariantNumeric: 'tabular-nums',
}}
>
{percent}%
</button>
<ToolButton label="Zoom in" onClick={() => onZoom(1)} glyph="plus" />
</>
)
return (
<div
ref={narrow.ref}
style={{
display: narrow.on ? 'flex' : 'grid',
gridTemplateColumns: narrow.on ? undefined : 'minmax(0, 1fr) auto minmax(0, 1fr)',
alignItems: 'center', gap: 8, flexWrap: 'wrap',
padding: '6px 10px', flexShrink: 0,
borderBottom: '1px solid var(--color-border)',
background: 'var(--color-surface)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<ToolButton label="Previous page" disabled={page <= 1}
onClick={() => onGoPage(page - 1)} glyph="up" />
<span style={{
fontSize: 12, color: 'var(--color-text-muted)',
minWidth: 62, flexShrink: 0,
textAlign: 'center', fontVariantNumeric: 'tabular-nums',
}}>
{pageCount ? `${page} / ${pageCount}` : '…'}
</span>
<ToolButton label="Next page" disabled={!pageCount || page >= pageCount}
onClick={() => onGoPage(page + 1)} glyph="down" />
{narrow.on && (
<>
<span style={{ width: 1, height: 20, flexShrink: 0, background: 'var(--color-border)' }} />
{zoomGroup}
</>
)}
</div>
{/* Suche im Dokument – die mittlere Spalte. Sie ist der Grund, aus dem
es diesen Betrachter gibt; rechts an den Rand gedrängt sah sie aus
wie ein Zusatz, der nicht recht dazugehört. */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{/* Gegengewicht. Rechts vom Feld stehen Zähler und Sprungknöpfe;
ohne diesen Ausgleich säße die Gruppe zwar mittig, das **Feld**
aber 79 Pixel daneben – gemessen. Mittig soll das sein, was man
ansieht, nicht der Kasten drumherum.
Am Telefon fällt es weg: Dort gibt es nichts zu zentrieren, und
120 Pixel Leere hätten ein Drittel der Zeile gekostet – während
rechts der zweite Sprungknopf aus dem Bild fiel. */}
{!narrow.on && <span style={{ width: hitsWidth, flexShrink: 0 }} />}
<input
value={query}
onChange={e => onQuery(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault()
if (hitCount) onStepHit(e.shiftKey ? -1 : 1)
}
// Escape gehört hier dem Feld, nicht dem Schließen der Vorschau.
if (e.key === 'Escape' && query) { e.stopPropagation(); onQuery('') }
}}
placeholder="Find in document"
style={{
// Breiter als die 150 vom Anfang: In der Mitte einer 1440 Pixel
// breiten Leiste sah ein Feld in Knopfbreite aus wie ein
// Restposten. Ein Suchbegriff wie „Zahlungsbedingungen" passt
// jetzt auch hinein, ohne zu wandern.
width: 240, minWidth: 0,
padding: '5px 8px', borderRadius: 8, fontSize: 12,
border: '1px solid var(--color-border)',
background: 'var(--color-background)', color: 'var(--color-text)',
outline: 'none',
}}
/>
<span style={{
fontSize: 11, color: 'var(--color-text-muted)',
// Feste Breite, nicht Mindestbreite: Sonst wüchse die rechte Seite
// mit „Searching…" und das Feld rutschte beim Tippen hin und her.
width: counterWidth, flexShrink: 0,
overflow: 'hidden', whiteSpace: 'nowrap',
fontVariantNumeric: 'tabular-nums',
}}>
{searching ? 'Searching…'
: query.trim().length < 2 ? ''
: hitCount === 0 ? 'No hits'
: `${hitIndex + 1} / ${hitCount}${pageOnlyHits ? ' · page' : ''}`}
</span>
{/* Der Platz für die beiden Knöpfe steht immer bereit, auch wenn sie
fehlen. Sonst rückte das Suchfeld beim ersten Treffer um knapp
dreißig Pixel zur Seite – ausgerechnet während man darin tippt.
Leerer Raum fällt niemandem auf, ein wanderndes Feld schon.
Erst zeigen, wenn es etwas zu springen gibt: Ohne Suche stünde
hier ein zweites Paar Winkel neben dem zum Blättern – zwei gleich
aussehende Knopfpaare für zwei ganz verschiedene Dinge. */}
<span style={{ width: hitButtonsWidth, flexShrink: 0, display: 'flex', gap: 4 }}>
{hitCount > 0 && (
<>
<ToolButton label="Previous hit" glyph="hitUp" onClick={() => onStepHit(-1)} />
<ToolButton label="Next hit" glyph="hitDown" onClick={() => onStepHit(1)} />
</>
)}
</span>
</div>
{/* Die dritte Spalte: der Zoom, rechtsbündig. Die Spalte war ohnehin
nötig, damit „Mitte" die Mitte der Leiste ist und nicht die Mitte
des Restes; jetzt trägt sie auch etwas. */}
{!narrow.on && (
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
justifyContent: 'flex-end', minWidth: 0,
}}>
{zoomGroup}
</div>
)}
</div>
)
}
/**
* Reicht die Breite für drei Spalten?
*
* Gemessen wird die Leiste selbst, nicht das Fenster: Der Betrachter steht in
* einer Vorschau, die schmaler sein kann als der Bildschirm.
*/
function useNarrow() {
const ref = useRef<HTMLDivElement>(null)
const [on, setOn] = useState(false)
useEffect(() => {
const el = ref.current
if (!el) return
// Breite null heißt „noch nicht sichtbar", nicht „schmal". Ohne die
// Prüfung stand die Leiste beim Aufgehen der Vorschau im Umbruch-Zustand
// und blieb dort.
const set = () => { if (el.clientWidth >= 50) setOn(el.clientWidth < minGridWidth) }
set()
const ro = new ResizeObserver(set)
ro.observe(el)
return () => ro.disconnect()
}, [])
return { ref, on }
}
/**
* Die Knöpfe der Werkzeugleiste steuern **zwei verschiedene** Dinge: links
* die Seite, rechts die Fundstelle. Beide bewegen sich nach oben und unten –
* und mit demselben Winkel-Zeichen sahen beide Paare gleich aus. Wer die
* Leiste zum ersten Mal sieht, kann nicht wissen, welches welches ist.
*
* Deshalb zwei Formen: der schlanke Winkel blättert Seiten, der Pfeil mit
* Schaft springt zur nächsten Fundstelle. Der Pfeil erscheint ohnehin erst,
* wenn es Fundstellen gibt.
*/
const chevrons: Record<string, string> = {
up: '18 15 12 9 6 15',
down: '6 9 12 15 18 9',
}
function ToolButton({ label, onClick, disabled, glyph }: {
label: string; onClick: () => void; disabled?: boolean
glyph: 'up' | 'down' | 'plus' | 'minus' | 'hitUp' | 'hitDown'
}) {
return (
<button
onClick={onClick}
disabled={disabled}
title={label}
aria-label={label}
style={{
width: 28, height: 28, flexShrink: 0, borderRadius: 8, border: 'none',
background: 'none', color: 'var(--color-text-muted)',
cursor: disabled ? 'default' : 'pointer', opacity: disabled ? 0.35 : 1,
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0,
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2" strokeLinecap="round">
{glyph === 'plus' && <><line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /></>}
{glyph === 'minus' && <line x1="5" y1="12" x2="19" y2="12" />}
{(glyph === 'up' || glyph === 'down') && <polyline points={chevrons[glyph]} />}
{glyph === 'hitUp' && <><line x1="12" y1="19" x2="12" y2="6" /><polyline points="6 12 12 6 18 12" /></>}
{glyph === 'hitDown' && <><line x1="12" y1="5" x2="12" y2="18" /><polyline points="6 12 12 18 18 12" /></>}
</svg>
</button>
)
}
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import { commonWidth, padOf } from './geometry'
describe('padOf', () => {
it('gibt am Telefon fast keinen Rand', () => {
expect(padOf(412)).toBe(4)
expect(padOf(559)).toBe(4)
})
it('gibt am Schreibtisch den vollen Rand', () => {
expect(padOf(560)).toBe(16)
expect(padOf(1440)).toBe(16)
})
})
describe('commonWidth', () => {
it('nimmt die Mehrheit, nicht die breiteste Seite', () => {
// Neun hochkante Seiten und ein Anhang im Querformat: Nach der breitesten
// gerechnet standen die neun auf 46 % statt 65 % – auf einem 412 Pixel
// breiten Telefon der Unterschied zwischen lesbar und nicht.
const sizes = [
...Array.from({ length: 9 }, () => ({ w: 595, h: 842 })),
{ w: 842, h: 595 },
]
expect(commonWidth(sizes)).toBe(595)
})
it('nimmt bei Gleichstand die breitere – dann ragt nichts heraus', () => {
expect(commonWidth([{ w: 595, h: 842 }, { w: 842, h: 595 }])).toBe(842)
})
it('rundet auf ganze Pixel, damit 594,96 und 595,04 dieselbe Breite sind', () => {
expect(commonWidth([{ w: 594.96, h: 842 }, { w: 595.04, h: 842 }])).toBe(595)
})
it('liefert nie null – damit niemand durch null teilt', () => {
expect(commonWidth([])).toBe(1)
})
})
+50
View File
@@ -0,0 +1,50 @@
/**
* Maße der Seitenliste: Ränder und die Breite, gegen die eingepasst wird.
*/
/** Abstand zwischen zwei Seiten, in Pixeln. Auch der Wert, mit dem gerechnet
* wird, wenn die sichtbare Seite bestimmt wird. */
export const pageGap = 12
/**
* Innenabstand der Seitenliste. Am Telefon fast keiner: Dort ist die Breite
* das knappe Gut, und ein 16-Pixel-Rand links und rechts kostet zusammen mehr
* als ein Zehntel der Darstellungsgröße.
*/
export function padOf(width: number): number {
return width < 560 ? 4 : 16
}
export interface PageSize { w: number; h: number }
/**
* Die häufigste Seitenbreite eines Dokuments.
*
* Vorher stand hier die **breiteste** Seite, mit dem Gedanken: Unterschiedlich
* breite Seiten unterschiedlich zu skalieren sieht nach Fehler aus. Das
* stimmt – nur ist der Preis höher als der Gewinn.
*
* Gemessen an einem zehnseitigen Dokument, in dem **eine** Seite quer liegt:
* Die neun hochkanten Seiten standen dadurch auf 46 % statt 65 %. Auf einem
* 412 Pixel breiten Telefon ist das der Unterschied zwischen lesbar und nicht.
* Eine einzelne Querformat-Seite – ein Anhang, eine breite Tabelle – bestimmt
* so die Darstellung des ganzen Dokuments.
*
* Jetzt entscheidet die Mehrheit. Die abweichende Seite ragt dann über den
* Rand und lässt sich seitlich schieben; das ist genau das, was man bei ihr
* ohnehin tun will.
*
* Bei Gleichstand gewinnt die breitere – dann ragt lieber nichts heraus.
*/
export function commonWidth(sizes: PageSize[]): number {
const count = new Map<number, number>()
for (const s of sizes) {
const w = Math.round(s.w)
count.set(w, (count.get(w) ?? 0) + 1)
}
let best = 0, bestN = 0
for (const [w, n] of count) {
if (n > bestN || (n === bestN && w > best)) { best = w; bestN = n }
}
return best || 1
}
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest'
import { countIn, wordAround } from './text'
describe('countIn', () => {
it('zählt jede Fundstelle mit laufender Nummer', () => {
expect(countIn('la la la', 'la', 3, true)).toEqual([
{ page: 3, nth: 0, exact: true },
{ page: 3, nth: 1, exact: true },
{ page: 3, nth: 2, exact: true },
])
})
it('überlappt nicht', () => {
// „aa" in „aaa" ist eine Fundstelle, nicht zwei – sonst zeigte der Zähler
// mehr Treffer, als sich anspringen lassen.
expect(countIn('aaa', 'aa', 1, true)).toHaveLength(1)
})
it('meldet leer, wenn nichts drinsteht', () => {
expect(countIn('Rechnung', 'quittung', 1, false)).toEqual([])
})
it('merkt sich die Quelle', () => {
expect(countIn('scan', 'scan', 7, false)[0]).toEqual({ page: 7, nth: 0, exact: false })
})
})
describe('wordAround', () => {
it('weitet auf das ganze Wort aus', () => {
const text = 'Display'
expect(wordAround(text, 3, 5)).toEqual([0, 7])
})
it('hört an Wortgrenzen auf', () => {
const text = 'Salat Löffel'
// „at" in „Salat" darf nicht über das Leerzeichen hinauslaufen.
expect(wordAround(text, 3, 5)).toEqual([0, 5])
})
it('zählt Ziffern und Umlaute zum Wort', () => {
expect(wordAround('Größe42', 2, 3)).toEqual([0, 7])
})
it('lässt sehr lange Zeichenketten ungedehnt', () => {
// Eine Prüfsumme ist ein „Wort" von sechzig Zeichen. Zwei Buchstaben darin
// dürfen nicht die halbe Zeile einfärben.
const long = 'a'.repeat(60)
expect(wordAround(long, 10, 12)).toEqual([10, 12])
})
it('lässt einen Treffer ohne Nachbarn, wie er ist', () => {
expect(wordAround('ab', 0, 2)).toEqual([0, 2])
})
})
+61
View File
@@ -0,0 +1,61 @@
/**
* Suchen im Text eines Dokuments – reine Zeichenkettenarbeit, ohne DOM.
*/
/**
* Eine Fundstelle: Seite und die wievielte auf dieser Seite.
*
* `exact` unterscheidet die beiden Quellen. Bringt das PDF eigenen Text mit,
* steht die Stelle im Dokument und lässt sich einfärben. Bei einem **Scan**
* steht dort nur ein Bild; der Text kommt dann aus der Erkennung auf dem
* Server, und der weiß zwar die Seite, aber nicht den Ort auf dem Blatt –
* dafür müssten die Koordinaten der Erkennung aufbewahrt werden.
*/
export interface Hit { page: number; nth: number; exact: boolean }
/**
* Sehr lange „Wörter" (Adressen, Prüfsummen) bleiben ungedehnt – sonst wäre
* eine halbe Zeile eingefärbt, weil irgendwo darin zwei Buchstaben passten.
*/
const maxWordSnap = 40
const isWordChar = (c: string) => /[\p{L}\p{N}]/u.test(c)
/**
* Weitet eine Fundstelle auf das **ganze Wort** aus.
*
* Der Grund ist gemessen, nicht Geschmack. Über dem gemalten Bild liegt eine
* unsichtbare *Ersatz*schrift – hier `sans-serif 13,5px` für eine
* Serifenschrift im PDF. PDF.js dehnt sie über `--scale-x` (gemessen 0,937),
* damit die **Zeile insgesamt** stimmt. Die Buchstaben *darin* sitzen deshalb
* um ein bis drei Pixel anders als die gemalten.
*
* Bei einem ganzen Wort fällt das nicht auf. Bei „la" mitten in „Display"
* schon: Der Kasten ist zehn Pixel breit, und drei Pixel Versatz sehen aus,
* als läge er auf den falschen Buchstaben. Genau das war zu sehen.
*
* Gegenprobe im Browser: Unser Kasten und die Auswahl, die der Browser beim
* Markieren mit der Maus malt, liegen auf demselben Pixel (357,08 / 10,38).
* Der Versatz ist also nicht unser Fehler – er lässt sich nur nicht
* wegrechnen, ohne die Schrift des Dokuments zu besitzen. Ihn zu *umgehen*
* geht: ganze Wörter markieren, deren Ränder an Wortgrenzen liegen.
*/
export function wordAround(text: string, at: number, to: number): [number, number] {
let s = at, e = to
while (s > 0 && isWordChar(text[s - 1])) s--
while (e < text.length && isWordChar(text[e])) e++
return e - s > maxWordSnap ? [at, to] : [s, e]
}
/** Zählt die Fundstellen eines Suchworts in einem Stück Text. */
export function countIn(haystack: string, needle: string, page: number, exact: boolean): Hit[] {
const out: Hit[] = []
let from = 0, nth = 0
for (;;) {
const at = haystack.indexOf(needle, from)
if (at < 0) break
out.push({ page, nth: nth++, exact })
from = at + needle.length
}
return out
}
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest'
import { maxScale, minScale, nextFine, nextStep, zoomSteps } from './zoom'
describe('nextStep – die Leiter der Knöpfe', () => {
it('geht zur nächsten Stufe', () => {
expect(nextStep(1, 1)).toBe(1.25)
expect(nextStep(1, -1)).toBe(0.75)
})
it('überspringt eine krumme Zwischengröße nicht', () => {
// „Einpassen" liefert oft etwas wie 0,653 – der erste Schritt muss auf
// die nächste Sprosse führen, nicht auf die übernächste.
expect(nextStep(0.653, 1)).toBe(0.75)
expect(nextStep(0.653, -1)).toBe(0.5)
})
it('meldet am Ende der Leiter nichts', () => {
expect(nextStep(zoomSteps.at(-1)!, 1)).toBeUndefined()
expect(nextStep(zoomSteps[0], -1)).toBeUndefined()
})
})
describe('nextFine – das Fünferraster für Finger und Mausrad', () => {
it('rastet auf runde Fünferzahlen', () => {
expect(nextFine(1, 1)).toBeCloseTo(1.05)
expect(nextFine(1, -1)).toBeCloseTo(0.95)
expect(nextFine(0.653, 1)).toBeCloseTo(0.7)
expect(nextFine(0.653, -1)).toBeCloseTo(0.65)
})
it('bleibt bei 1,1 nicht stehen', () => {
// 1,1 × 100 ergibt in Fließkomma 110.00000000000001. Ohne die Rundung
// vorweg war die „nächste kleinere Fünferzahl" wieder 110 – gemessen kam
// beim Zuziehen genau eine Stufe, danach nichts mehr.
expect(nextFine(1.1, -1)).toBeCloseTo(1.05)
expect(nextFine(1.1, 1)).toBeCloseTo(1.15)
})
it('hält sich an die Grenzen', () => {
expect(nextFine(maxScale, 1)).toBeUndefined()
expect(nextFine(minScale, -1)).toBeUndefined()
expect(nextFine(3.98, 1)).toBeCloseTo(maxScale)
})
it('macht in jeder Richtung tatsächlich einen Schritt', () => {
// Von ganz unten bis an den Anschlag: Jeder Schritt muss echt nach oben
// gehen. Bliebe einer stehen, liefe die Schleife bis zur Obergrenze
// durch, ohne den Anschlag zu erreichen – genau der Fehler von 1,1.
let s = minScale
let steps = 0
for (; steps < 1000; steps++) {
const next = nextFine(s, 1)
if (next === undefined) break
expect(next).toBeGreaterThan(s)
s = next
}
expect(s).toBeCloseTo(maxScale)
// (4 − 0,25) / 0,05 = 75 Schritte, dazu einer für den Abbruch.
expect(steps).toBe(75)
})
})
+55
View File
@@ -0,0 +1,55 @@
/**
* Die Zoomstufen des Betrachters – ohne DOM, ohne PDF.js, damit sie sich
* prüfen lassen. Die Regeln darin sind alle an echten Dokumenten gemessen
* worden, und genau solche Regeln verdienen einen Test.
*/
/** Zoomstufen der **Knöpfe**. „Fit" liegt außerhalb und wird gerechnet. */
export const zoomSteps = [0.5, 0.75, 1, 1.25, 1.5, 2, 3]
/**
* Zoomschritt für Finger und Mausrad: **fünf Prozentpunkte**, auf runde
* Zahlen gerastet – 65, 70, 75, 80 …
*
* Zuerst war es ein fester Faktor (fünf Prozent *vom aktuellen Wert*).
* Rechnerisch ist das die sauberere Größe: Sie fühlt sich unten wie oben
* gleich an. Abzulesen war sie aber nicht – die Anzeige sprang um drei
* Punkte, dann um vier, dann um sechs. Wer eine Zahl sieht, erwartet ein
* Raster, und das hier ist eines.
*
* Der Preis steht dazu: Bei 400 % ist ein Schritt gut ein Prozent, bei 25 %
* ein Fünftel. Das gleicht die Geste aus, nicht die Rasterung.
*/
export const zoomStepPercent = 5
export const minScale = 0.25
export const maxScale = 4
/** Die nächste Stufe der Knopfleiter, oder undefined an ihrem Ende. */
export function nextStep(now: number, dir: 1 | -1): number | undefined {
return dir > 0
? zoomSteps.find(s => s > now + 0.001)
: [...zoomSteps].reverse().find(s => s < now - 0.001)
}
/**
* Die nächste runde Fünferzahl, für Finger und Mausrad. Undefined am
* Anschlag.
*
* Gerechnet wird in Prozentpunkten und nicht in Bruchteilen: Der Nutzer
* liest Prozente, und ein Raster, das nur *fast* auf 70 trifft, ist keins.
* Aus einer krummen Ausgangsgröße – „einpassen" ergibt zum Beispiel 65,3 %
* – führt der erste Schritt auf die nächste runde Zahl, danach geht es in
* Fünfern weiter.
*/
export function nextFine(now: number, dir: 1 | -1): number | undefined {
// **Gerundet, bevor gerechnet wird.** 1,1 × 100 ergibt in Fließkomma
// 110.00000000000001, und daraus wird die „nächste kleinere Fünferzahl"
// wieder 110 – der Zoom blieb dann stehen. Gemessen: Beim Zuziehen kam
// genau eine Stufe, danach nichts mehr.
const p = Math.round(now * 100 * 1e6) / 1e6
const raw = dir > 0
? Math.floor(p / zoomStepPercent + 1) * zoomStepPercent
: Math.ceil(p / zoomStepPercent - 1) * zoomStepPercent
const next = Math.min(maxScale * 100, Math.max(minScale * 100, raw)) / 100
return Math.abs(next - now) < 0.001 ? undefined : next
}
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import { formatBytes } from './format'
describe('formatBytes', () => {
it('lässt kleine Zahlen als Bytes stehen', () => {
expect(formatBytes(0)).toBe('0 B')
expect(formatBytes(1023)).toBe('1023 B')
})
it('wechselt bei 1024 die Einheit, nicht bei 1000', () => {
expect(formatBytes(1000)).toBe('1000 B')
expect(formatBytes(1024)).toBe('1.0 KB')
expect(formatBytes(1024 ** 2)).toBe('1.0 MB')
expect(formatBytes(1024 ** 3)).toBe('1.0 GB')
})
it('rundet auf eine Nachkommastelle', () => {
expect(formatBytes(1536)).toBe('1.5 KB')
expect(formatBytes(1.44 * 1024 ** 2)).toBe('1.4 MB')
})
it('bleibt bei sehr großen Zahlen bei GB', () => {
expect(formatBytes(2048 * 1024 ** 3)).toBe('2048.0 GB')
})
})
+23
View File
@@ -0,0 +1,23 @@
/**
* Zahlen, wie ein Mensch sie liest.
*
* Stand bis hierher in `pages/FileBrowser.tsx` – und **fünf** Module holten
* sie von dort: Papierkorb, Etiketten, „Geteilt mit mir", Fassungen,
* Eigenschaften. Eine Seite als Fundort für eine Rechenfunktion heißt, dass
* jedes dieser Module am Dateibrowser hängt, samt allem, was der mitbringt.
* Zum Umbenennen einer Zahl musste man eine Seite anfassen.
*/
/**
* Byte-Zahl in Kilo, Mega, Giga.
*
* Tausend-vierundzwanzig, nicht tausend: Das ist die Einheit, in der
* Dateisysteme und Betriebssysteme rechnen – eine Datei, die dort „1,4 MB"
* heißt, soll hier nicht 1,5 heißen.
*/
export function formatBytes(n: number): string {
if (n < 1024) return `${n} B`
if (n < 1024 ** 2) return `${(n / 1024).toFixed(1)} KB`
if (n < 1024 ** 3) return `${(n / 1024 ** 2).toFixed(1)} MB`
return `${(n / 1024 ** 3).toFixed(1)} GB`
}
+248
View File
@@ -0,0 +1,248 @@
import { useCallback, useRef, useState, type DragEvent } from 'react'
import { foldersApi, uploadWithProgress, APIError } from '../api/client'
import type { UploadItem } from '../components/UploadQueue'
import { useInternalDrag } from './useInternalDrag'
/**
* Alles, was mit dem Hineinlegen von Dateien zu tun hat: die Warteschlange,
* das Hochladen einzelner Dateien, das Nachbauen eines ganzen Ordnerbaums und
* die Ablage-Gesten am Fenster.
*
* Stand bis hierher mitten im Dateibrowser – rund 180 Zeilen zwischen
* Kontextmenüs und Dialogen, die mit dem Anzeigen eines Ordners nichts zu tun
* haben. Sie hängen untereinander eng zusammen (die Warteschlange gehört zum
* Fortschritt, der Fortschritt zum Hochladen) und mit dem Rest der Seite fast
* gar nicht: heraus kommen eine Kennung, ein Zustand und ein „fertig".
*
* **Verhalten unverändert.** Verschoben, nicht umgeschrieben – bis auf die
* Stellen, die unten eigens vermerkt sind.
*/
/**
* Eine Kennung für die Warteschlange, ohne den Server zu fragen.
*
* `crypto.randomUUID` gibt es nur in einem sicheren Kontext (https oder
* localhost). Hinter einer weitergeleiteten Portnummer – VS Code, ein Tunnel –
* fehlt es, und ohne den Rückfall bräche das Hochladen dort mit einem
* Tippfehler-artigen Fehler ab.
*/
function uid(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
}
/** Wie viele Dateien gleichzeitig unterwegs sind. */
const concurrency = 3
interface Options {
/** Ordner, in den gelegt wird – undefined heißt Wurzelverzeichnis. */
folderId?: string
/** Läuft, wenn alles durch ist: Liste neu holen, Speicherbalken nachziehen. */
onFinished: () => Promise<void> | void
}
export function useUploads({ folderId, onFinished }: Options) {
const [items, setItems] = useState<UploadItem[]>([])
const [dragging, setDragging] = useState(false)
const isInternalDrag = useInternalDrag()
/**
* Der Zielordner in einem Ref – **nicht** in der Abhängigkeitsliste.
*
* Sonst zeigten die Ablage-Gesten bei jedem Ordnerwechsel auf neue
* Funktionen, und ein Zug, der über dem Fenster begonnen hat, liefe gegen
* einen abgemeldeten Zuhörer. Gelesen wird er erst, wenn wirklich abgelegt
* wird – dann ist er aktuell.
*/
const folderRef = useRef(folderId)
folderRef.current = folderId
const dismiss = useCallback((id: string) => {
setItems(u => u.filter(x => x.id !== id))
}, [])
/** Fortschritt, Erfolg und Fehler einer einzelnen Zeile. */
const patch = useCallback((id: string, change: Partial<UploadItem>) => {
setItems(u => u.map(x => (x.id === id ? { ...x, ...change } : x)))
}, [])
/** Eine Datei hochladen und ihre Zeile nachführen. Wirft nicht. */
const send = useCallback(async (id: string, file: File, target: string | undefined) => {
try {
await uploadWithProgress(file, target, ({ percent }) => patch(id, { progress: percent }))
patch(id, { progress: 100, status: 'done' })
} catch (err) {
patch(id, { status: 'error', error: err instanceof APIError ? err.message : 'Upload failed' })
}
}, [patch])
/** Einzelne Dateien – aus dem Auswahlfenster oder per Ablage. */
const uploadFiles = useCallback(async (fileList: File[]) => {
const target = folderRef.current
const queued = fileList.map(file => ({ id: uid(), file }))
setItems(u => [...u, ...queued.map(({ id, file }) => ({
id, name: file.name, progress: 0, status: 'uploading' as const,
}))])
// Drei gleichzeitig: Ein Dutzend Belege auf einmal geht so spürbar
// schneller, ohne die Verbindung zu verstopfen.
const queue = [...queued]
await Promise.all(
Array.from({ length: Math.min(concurrency, queue.length) }, async () => {
while (queue.length > 0) {
const item = queue.shift()!
await send(item.id, item.file, target)
}
}),
)
await onFinished()
}, [send, onFinished])
/**
* Ein abgelegter Ordner: Der Baum wird unter dem aktuellen Ordner
* nachgebaut, dann werden die Dateien hineingelegt.
*/
const uploadFolder = useCallback(async (entries: { file: File; relativePath: string }[]) => {
if (!entries.length) return
const target = folderRef.current
// "a/b" → Ordner-ID. Ohne den Merkzettel legte jede Datei ihren
// Elternordner erneut an.
const known = new Map<string, string>()
async function ensureFolder(relPath: string): Promise<string | undefined> {
if (!relPath) return target
const cached = known.get(relPath)
if (cached) return cached
let parent = target
let acc = ''
for (const part of relPath.split('/')) {
acc = acc ? `${acc}/${part}` : part
const hit = known.get(acc)
if (hit) { parent = hit; continue }
const f = await foldersApi.create(part, parent)
known.set(acc, f.id)
parent = f.id
}
return parent
}
// Nach Ordner gruppieren: So wird jeder Zweig einmal angelegt, nicht
// einmal je Datei darin.
const grouped = new Map<string, { file: File; relativePath: string }[]>()
for (const entry of entries) {
const parts = entry.relativePath.split('/')
parts.pop()
const key = parts.join('/')
const list = grouped.get(key)
if (list) list.push(entry)
else grouped.set(key, [entry])
}
const queued = entries.map(e => ({ id: uid(), entry: e }))
setItems(u => [...u, ...queued.map(({ id, entry }) => ({
id, name: entry.relativePath, progress: 0, status: 'uploading' as const,
}))])
// Die Zeilen stehen in der Reihenfolge von `entries`, hochgeladen wird
// nach Ordnern gruppiert. Der Zeiger sucht deshalb die Zeile zur Datei,
// statt mitzuzählen – vorher lief ein Zähler mit, und sobald die
// Gruppierung die Reihenfolge änderte, hing der Fortschritt an der
// falschen Zeile.
const idOf = new Map(queued.map(q => [q.entry, q.id]))
for (const [relPath, files] of grouped) {
const into = await ensureFolder(relPath).catch(() => target)
for (const entry of files) {
const id = idOf.get(entry)
if (id) await send(id, entry.file, into)
}
}
await onFinished()
}, [send, onFinished])
/**
* Ablage am Fenster.
*
* Was in dieser Seite losgezogen ist, ist **kein** Upload: Ein vom Browser
* begonnener Bild-Zug bringt `Files` mit, ohne dass jemand eine Datei
* hineinlegen wollte. Siehe hooks/useInternalDrag.
*/
const onDrop = useCallback(async (e: DragEvent) => {
e.preventDefault()
setDragging(false)
const t = e.dataTransfer.types
if (t.includes('aether-file-id') || t.includes('aether-folder-id')) return
if (isInternalDrag()) return
// Synchron einsammeln: Die Liste gilt nur während des Ereignisses selbst,
// danach ist sie leer.
const entries: FileSystemEntry[] = []
const list = e.dataTransfer.items
for (let i = 0; i < (list?.length ?? 0); i++) {
const entry = list[i].webkitGetAsEntry?.()
if (entry) entries.push(entry)
}
const files = Array.from(e.dataTransfer.files)
if (entries.some(en => en.isDirectory)) {
const gathered = await gatherFromEntries(entries)
if (gathered.length) await uploadFolder(gathered)
} else if (files.length) {
await uploadFiles(files)
}
}, [isInternalDrag, uploadFiles, uploadFolder])
const onDragOver = useCallback((e: DragEvent) => {
e.preventDefault()
const t = e.dataTransfer.types
if (t.includes('Files') && !t.includes('aether-file-id') && !t.includes('aether-folder-id')
&& !isInternalDrag()) setDragging(true)
}, [isInternalDrag])
const onDragLeave = useCallback((e: DragEvent) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragging(false)
}, [])
return {
items, dismiss, dragging, uploadFiles,
/** Direkt an das äußerste Element der Seite hängen. */
dropProps: { onDragOver, onDragLeave, onDrop },
}
}
/**
* Einen abgelegten Ordnerbaum in Paare aus Datei und Pfad auflösen.
*
* `readEntries` liefert in Häppchen – solange nachfragen, bis nichts mehr
* kommt. Wer den ersten Aufruf für die ganze Liste hält, verliert bei einem
* Ordner mit mehr als hundert Einträgen den Rest, und zwar stumm.
*/
async function gatherFromEntries(entries: FileSystemEntry[]) {
const out: { file: File; relativePath: string }[] = []
async function walk(entry: FileSystemEntry, prefix: string): Promise<void> {
if (entry.isFile) {
const fe = entry as FileSystemFileEntry
out.push({
file: await new Promise<File>((res, rej) => fe.file(res, rej)),
relativePath: prefix + entry.name,
})
return
}
if (!entry.isDirectory) return
const reader = (entry as FileSystemDirectoryEntry).createReader()
const children: FileSystemEntry[] = []
for (;;) {
const batch: FileSystemEntry[] = await new Promise((res, rej) => reader.readEntries(res, rej))
if (batch.length === 0) break
children.push(...batch)
}
for (const child of children) await walk(child, prefix + entry.name + '/')
}
for (const entry of entries) await walk(entry, '')
return out
}
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'
import { aclApi, filesApi, type SharedResource } from '../api/client'
import { formatBytes } from './FileBrowser'
import { formatBytes } from '../format'
import PageHeader from '../components/PageHeader'
import { FolderGlyph } from '../components/icons'
+1 -1
View File
@@ -3,7 +3,7 @@ import { tagsApi, correspondentsApi, filesApi, downloadZip, type Correspondent,
import PageHeader from '../components/PageHeader'
import { FileTypeIcon } from '../components/icons'
import TagChip, { tagColor } from '../components/TagChip'
import { formatBytes } from './FileBrowser'
import { formatBytes } from '../format'
/**
* Alles, was ein Etikett trägt – über Ordnergrenzen hinweg.
+1 -1
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'
import { trashApi, filesApi, type TrashItem } from '../api/client'
import { formatBytes } from './FileBrowser'
import { formatBytes } from '../format'
import PageHeader from '../components/PageHeader'
import ConfirmDialog from '../components/ConfirmDialog'
+4 -6
View File
@@ -10,12 +10,10 @@ import { useOverlayDismiss } from '../../hooks/useOverlayDismiss'
*/
export function formatBytes(n: number): string {
if (n < 1024) return `${n} B`
if (n < 1024 ** 2) return `${(n / 1024).toFixed(1)} KB`
if (n < 1024 ** 3) return `${(n / 1024 ** 2).toFixed(1)} MB`
return `${(n / 1024 ** 3).toFixed(1)} GB`
}
// Zeichengleiche zweite Fassung -- sie stand hier und noch einmal im
// Dateibrowser. Jetzt eine Stelle, hier nur weitergereicht, damit die
// Admin-Reiter ihre gewohnte Adresse behalten.
export { formatBytes } from '../../format'
export function bytesFromGB(gb: number): number {
return Math.round(gb * 1024 ** 3)
+7 -1
View File
@@ -1 +1,7 @@
import '@testing-library/react'
// Absichtlich leer.
//
// Hier stand `import '@testing-library/react'` – das setzt ein Fenster
// voraus. Solange die Tests reine Funktionen prüfen (siehe vite.config.ts,
// environment: 'node'), gäbe dieser Import nur einen Fehler beim Start.
// Wer Komponenten prüft, holt die Bibliothek in seiner eigenen Datei.
export {}
+8 -1
View File
@@ -137,7 +137,14 @@ export default defineConfig({
},
},
test: {
environment: 'jsdom',
// **node, nicht jsdom.** Die Einstellung stand auf 'jsdom', ohne dass
// jsdom je installiert war – `npm test` brach deshalb ab, bevor es
// irgendetwas ausführte. Geprüft wird hier, was sich ohne Browser prüfen
// lässt: Reihenfolgen, Zoomstufen, Suche im Text, Einheiten.
//
// Wer eine Komponente prüfen will, schreibt `// @vitest-environment jsdom`
// an den Anfang seiner Datei und nimmt jsdom in die devDependencies auf.
environment: 'node',
globals: true,
setupFiles: './src/test-setup.ts',
},