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:
1 parent
8e27c703d2
commit
ea90f43e77
28 files changed
+1904
-1260
No files matched your search
+4
-1
@@ -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,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'
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,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)',
|
||||
}
|
||||
}
|
||||
@@ -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'])
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+333
-798
File diff suppressed because it is too large.
Load diff
@@ -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'
|
||||
|
||||
|
||||
@@ -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,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'
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user