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
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user