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

+73 -4
View File
@@ -66,6 +66,27 @@ const sizeBatch = 24
*/
const searchDelay = 160
/**
* Wie lange nach dem letzten Zoomschritt gewartet wird, bevor neu gezeichnet
* wird.
*
* **Der Grund, warum eine Geste am Telefon ruckelte.** Jede Stufe warf die
* Leinwände weg und ließ PDF.js neu malen; eine Zusammenziehgeste hat leicht
* zwanzig davon. Zwanzig Neuzeichnungen eines A4-Blattes bei doppelter
* Pixeldichte sind zwanzig Bitmaps zu je rund acht Megabyte – der Abbruch der
* vorigen half nur gegen die Rechenzeit, nicht gegen das Belegen und Freigeben.
*
* Nötig war das nie: Die Leinwand liegt mit `width: 100%` im Seitenkasten, und
* der Kasten wächst sofort mit. Der Browser streckt das vorhandene Bild also
* von selbst – kostenlos und ohne Bild auszulassen. Es sieht währenddessen
* weich aus, mehr nicht. Scharf wird es, sobald die Finger stillstehen.
*
* Die Textebene wandert korrekt mit: PDF.js setzt ihre Stücke in **Prozent**
* des Kastens, und die Schriftgröße hängt an `--total-scale-factor`. Beides
* folgt dem Layout, nicht der Leinwand.
*/
const zoomSettle = 120
interface Props {
/** Adresse der Datei – dieselbe, die auch das <iframe> bekam. */
url: string
@@ -84,6 +105,16 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
const [sizes, setSizes] = useState<PageSize[]>([])
const [scale, setScale] = useState<number | 'fit'>('fit')
const [fitScale, setFitScale] = useState(1)
/**
* Ist die Einpassung schon gemessen?
*
* Ohne diese Wache zeichnete jedes Öffnen **einmal umsonst**: Die Maße
* kommen an, der Zeichner läuft los – und erst danach steht fest, dass das
* Blatt nicht auf 100 %, sondern auf 65 % gehört. Die halbfertige Leinwand
* wurde weggeworfen. Bei einem A4-Blatt in doppelter Pixeldichte sind das
* acht Megabyte, die niemand je zu sehen bekam.
*/
const [fitReady, setFitReady] = useState(false)
const [current, setCurrent] = useState(1)
const [query, setQuery] = useState(searchTerm ?? '')
@@ -99,13 +130,27 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
const scrollRef = useRef<HTMLDivElement>(null)
const pageRefs = useRef<(HTMLDivElement | null)[]>([])
/** Der Maßstab, in dem das Blatt **liegt** – Kastengröße, Textebene, Anzeige. */
const effScale = scale === 'fit' ? fitScale : scale
/**
* Der Maßstab, in dem zuletzt **gemalt** wurde. Er hinkt während einer Geste
* hinterher; solange streckt der Browser das vorhandene Bild. Siehe
* `zoomSettle`.
*/
const [renderScale, setRenderScale] = useState(effScale)
useEffect(() => {
if (renderScale === effScale) return
const t = setTimeout(() => setRenderScale(effScale), zoomSettle)
return () => clearTimeout(t)
}, [effScale, renderScale])
/** Läuft gerade eine Geste – ist das Blatt also nur gestreckt? */
const stretched = renderScale !== effScale
// ---------------------------------------------------------------- Laden
useEffect(() => {
let alive = true
setDoc(null); setSizes([]); setError(''); setCurrent(1)
setDoc(null); setSizes([]); setError(''); setCurrent(1); setFitReady(false)
// 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.
@@ -190,6 +235,7 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
// 16 für die Bildlaufleiste, die je nach Browser Breite wegnimmt.
const byWidth = (el.clientWidth - 2 * padOf(el.clientWidth) - 16) / commonWidth(sizes)
setFitScale(Math.max(0.1, Math.min(1, byWidth)))
setFitReady(true)
}, [sizes])
useEffect(() => {
@@ -211,8 +257,16 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
* Seiten eine Bildschirmhöhe im Voraus an; wieder verlassene Seiten geben
* ihre Leinwand frei.
*/
/**
* Steht der Maßstab fest? Bewusst als **ein Wahrheitswert** in der
* Abhängigkeitsliste und nicht als `scale`: Der Zeichner darf sich nicht bei
* jedem Zoomschritt neu aufbauen, sonst wäre die Geste wieder so teuer wie
* vorher. Dieser Wert kippt einmal von falsch auf wahr.
*/
const scaleKnown = scale !== 'fit' || fitReady
useEffect(() => {
if (!doc || sizes.length === 0) return
if (!doc || sizes.length === 0 || !scaleKnown) return
const root = scrollRef.current
if (!root) return
@@ -226,7 +280,7 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
if (!doc || !host || drawn.has(n)) return
drawn.add(n)
const page = await doc.getPage(n)
const viewport = page.getViewport({ scale: effScale })
const viewport = page.getViewport({ scale: renderScale })
const dpr = Math.min(window.devicePixelRatio || 1, 2)
const canvas = document.createElement('canvas')
@@ -293,7 +347,7 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
io.disconnect()
for (const t of running.values()) t.cancel()
}
}, [doc, sizes, effScale])
}, [doc, sizes, renderScale, scaleKnown])
// --------------------------------------------------- Sichtbare Seitenzahl
@@ -518,6 +572,21 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
}
}, [query, hits])
/**
* Während einer Zoomgeste die Färbung wegnehmen.
*
* Die Kästen sind das einzige im Seitenkasten, das in **Pixeln** sitzt –
* Leinwand und Textebene rechnen in Prozent und wandern von selbst mit. Ein
* stehengebliebener Kasten läge nach zwei Stufen sichtbar neben dem Wort.
* Nach dem Zeichnen kommt er von selbst zurück (`pdf-text-ready`).
*/
useEffect(() => {
if (!stretched) return
for (const host of pageRefs.current) {
host?.querySelector('.pdf-hitLayer')?.replaceChildren()
}
}, [stretched])
// Zur aktuellen Fundstelle springen und sie färben. Ist die Seite noch
// nicht gezeichnet, wartet das auf ihre Textebene – deshalb das Ereignis.
const target = hits[hitIndex]
+20 -8
View File
@@ -4,13 +4,15 @@ interface UploadItem {
id: string
name: string
progress: number // 0-100
status: 'uploading' | 'done' | 'error'
status: 'uploading' | 'done' | 'error' | 'canceled'
error?: string
}
interface Props {
items: UploadItem[]
onDismiss: (id: string) => void
/** Bricht einen laufenden oder noch wartenden Upload ab. */
onCancel: (id: string) => void
}
export type { UploadItem }
@@ -31,7 +33,7 @@ const dismissAfter = 4000
*/
const maxVisible = 4
export default function UploadQueue({ items, onDismiss }: Props) {
export default function UploadQueue({ items, onDismiss, onCancel }: Props) {
// Der Aufrufer gibt bei jedem Rendern eine neue Funktion herein. Über eine
// Referenz gelesen, muss der Effekt darauf nicht erneut laufen – sonst
// begännen die Fristen bei jedem Fortschrittsschritt von vorn.
@@ -45,7 +47,9 @@ export default function UploadQueue({ items, onDismiss }: Props) {
// Fehler bleiben stehen. Sie sind das Einzige, was hier eine Auskunft
// trägt, die man sonst nirgends wiederfindet – die verschwindet nicht
// von selbst.
if (item.status !== 'done') continue
// Abgebrochenes verschwindet wie Fertiges: Die Entscheidung ist
// getroffen, die Zeile hat nichts mehr zu sagen.
if (item.status !== 'done' && item.status !== 'canceled') continue
if (timers.current.has(item.id)) continue
timers.current.set(item.id, setTimeout(() => {
timers.current.delete(item.id)
@@ -85,11 +89,16 @@ export default function UploadQueue({ items, onDismiss }: Props) {
<span className="truncate font-medium text-xs" style={{ color: 'var(--color-text)', maxWidth: 180 }}>
{item.name}
</span>
{item.status !== 'uploading' && (
<button onClick={() => onDismiss(item.id)}
className="ml-2 opacity-50 hover:opacity-100 text-xs shrink-0"
style={{ color: 'var(--color-text-muted)' }}>✕</button>
)}
{/* Dasselbe Kreuz, zwei Bedeutungen – und beide sind dieselbe
Geste: „weg damit". Läuft der Upload noch, lässt es die
Verbindung fallen; ist er vorbei, räumt es nur die Meldung ab.
Auf dem Server bleibt in keinem Fall etwas liegen. */}
<button
onClick={() => item.status === 'uploading' ? onCancel(item.id) : onDismiss(item.id)}
title={item.status === 'uploading' ? 'Cancel upload' : 'Dismiss'}
aria-label={item.status === 'uploading' ? 'Cancel upload' : 'Dismiss'}
className="ml-2 opacity-50 hover:opacity-100 text-xs shrink-0"
style={{ color: 'var(--color-text-muted)' }}>✕</button>
</div>
{item.status === 'uploading' && (
@@ -108,6 +117,9 @@ export default function UploadQueue({ items, onDismiss }: Props) {
{item.status === 'error' && (
<p style={{ color: 'var(--color-danger)' }} className="text-xs truncate">✕ {item.error}</p>
)}
{item.status === 'canceled' && (
<p style={{ color: 'var(--color-text-muted)' }} className="text-xs">Cancelled — nothing was saved</p>
)}
</div>
))}