feat(uploads): laufenden Upload abbrechen; fix(pdf): Zoomgeste entlasten

Zoom
----
Jede Zoomstufe warf die Leinwaende weg und liess PDF.js neu malen -- eine
Zusammenziehgeste hat leicht zwanzig davon, bei A4 und doppelter
Pixeldichte je rund acht Megabyte. Noetig war das nie: Die Leinwand liegt
mit width:100% im Seitenkasten, der Browser streckt sie also von selbst.
Gezeichnet wird jetzt 120 ms nach der letzten Stufe. Die Textebene wandert
korrekt mit (PDF.js setzt sie in Prozent), nur die Trefferkaesten sitzen in
Pixeln -- die werden waehrend der Geste geraeumt und danach neu gemalt.

Dabei ein aelterer Verlust: Jedes Oeffnen zeichnete einmal umsonst, weil
der Zeichner lief, bevor die Einpassung gemessen war. Jetzt wartet er.

Upload abbrechen
----------------
Das Kreuz an der Fortschrittsmeldung laesst einen laufenden Upload fallen;
eine noch wartende Datei faengt gar nicht erst an. Ein Abbruch ist kein
Fehler und verschwindet wie eine fertige Meldung.

Auf dem Server bleibt nichts liegen -- das ist die Bauweise (Temp-Datei,
Umbenennen erst am Ende), aber eine Bauweise ist keine Pruefung. Deshalb
scripts/upload-abbruch-selbsttest.py: kappt eine echte Verbindung auf einem
rohen Socket, 13 Pruefungen. Der teuerste Fall ist abgedeckt -- beim
Ueberschreiben mit Versionierung schiebt der Server die alte Fassung
kurzzeitig zur Seite; sie bleibt in Groesse, Pruefsumme und Inhalt heil.

Downloads haben bewusst kein Kreuz: Sie gehen als Anker bzw. Formular an
den Browser, dessen Download-Liste sie besitzt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-08 23:38:26 +02:00
1 parent ea90f43e77
commit 511213b40d
7 files changed
+437 -18

No files matched your search

+47 -3
View File
@@ -1,5 +1,5 @@
import { useCallback, useRef, useState, type DragEvent } from 'react'
import { foldersApi, uploadWithProgress, APIError } from '../api/client'
import { foldersApi, uploadWithProgress, APIError, UploadAborted } from '../api/client'
import type { UploadItem } from '../components/UploadQueue'
import { useInternalDrag } from './useInternalDrag'
@@ -48,6 +48,23 @@ export function useUploads({ folderId, onFinished }: Options) {
const [dragging, setDragging] = useState(false)
const isInternalDrag = useInternalDrag()
/**
* Der Abbruchgriff je laufender Datei.
*
* In einem Ref, nicht im Zustand: Er wird nur gerufen, nie angezeigt – und
* ein Zustand mehr hieße ein Neuaufbau der Liste bei jedem Anfang und Ende
* einer Datei.
*/
const running = useRef(new Map<string, AbortController>())
/**
* Wer abgebrochen wurde, **bevor** er an der Reihe war.
*
* Drei Dateien laufen gleichzeitig, der Rest wartet. Ein Klick auf das Kreuz
* einer wartenden Datei hat noch keine Verbindung zum Fallenlassen; ohne
* diesen Merkzettel finge sie später trotzdem an.
*/
const canceled = useRef(new Set<string>())
/**
* Der Zielordner in einem Ref – **nicht** in der Abhängigkeitsliste.
*
@@ -67,14 +84,41 @@ export function useUploads({ folderId, onFinished }: Options) {
const patch = useCallback((id: string, change: Partial<UploadItem>) => {
setItems(u => u.map(x => (x.id === id ? { ...x, ...change } : x)))
}, [])
const patchRef = useRef(patch)
patchRef.current = patch
/**
* Den Vorgang einer Zeile abbrechen.
*
* Läuft er schon, wird die Verbindung fallen gelassen; wartet er noch, wird
* er vorgemerkt und gar nicht erst begonnen. In beiden Fällen bleibt auf dem
* Server nichts zurück – er schreibt in eine Temp-Datei und benennt sie erst
* um, wenn die letzte Byte angekommen ist.
*/
const cancel = useCallback((id: string) => {
canceled.current.add(id)
running.current.get(id)?.abort()
patchRef.current(id, { status: 'canceled', progress: 0 })
}, [])
/** Eine Datei hochladen und ihre Zeile nachführen. Wirft nicht. */
const send = useCallback(async (id: string, file: File, target: string | undefined) => {
// Zwischen Einreihen und Anfangen kann geklickt worden sein.
if (canceled.current.has(id)) return
const controller = new AbortController()
running.current.set(id, controller)
try {
await uploadWithProgress(file, target, ({ percent }) => patch(id, { progress: percent }))
await uploadWithProgress(file, target,
({ percent }) => patch(id, { progress: percent }), controller.signal)
patch(id, { progress: 100, status: 'done' })
} catch (err) {
// Ein Abbruch ist kein Fehler: Die Zeile steht schon auf „abgebrochen",
// und eine rote Meldung darüber wäre ein Vorwurf für eine Entscheidung,
// die der Nutzer gerade selbst getroffen hat.
if (err instanceof UploadAborted) return
patch(id, { status: 'error', error: err instanceof APIError ? err.message : 'Upload failed' })
} finally {
running.current.delete(id)
}
}, [patch])
@@ -207,7 +251,7 @@ export function useUploads({ folderId, onFinished }: Options) {
}, [])
return {
items, dismiss, dragging, uploadFiles,
items, dismiss, cancel, dragging, uploadFiles,
/** Direkt an das äußerste Element der Seite hängen. */
dropProps: { onDragOver, onDragLeave, onDrop },
}