fix(upload): gezogenes Vorschaubild wurde erneut hochgeladen
Zieht man das angezeigte Bild ein Stueck zur Seite, legt Chrome es als
echte Datei in den Ziehvorgang. dataTransfer.types enthaelt dann
"Files" - nicht zu unterscheiden von einer Datei aus dem Dateimanager.
Die Ablageflaeche lud das eigene Bild daraufhin ein zweites Mal hoch.
Zwei Riegel: draggable={false} an allen Bildern, die kein eigenes
Ziehen brauchen (die Kachel bleibt ziehbar und verschiebt die Datei),
und useInternalDrag - was in dieser Seite losgezogen ist, wird nie als
Upload behandelt. dragstart feuert nur bei eigener Quelle.
Gemessen: vorher 1 Upload, nachher 0. Ein Zug von aussen laedt
unveraendert hoch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e9e07f9bed
commit
b5a83e4057
11 files changed
+92
-3
No files matched your search
+26
@@ -1376,6 +1376,32 @@ hätte keine Media-Query sie erreicht.
|
|||||||
|
|
||||||
- [ ] Auf dem Flip 7 selbst gegenprüfen, auf beiden Schirmen
|
- [ ] Auf dem Flip 7 selbst gegenprüfen, auf beiden Schirmen
|
||||||
|
|
||||||
|
## Bild ziehen lud es erneut hoch (24.08.2026)
|
||||||
|
|
||||||
|
Zieht man das angezeigte Bild in der Vorschau ein Stück zur Seite, legt Chrome
|
||||||
|
es als **echte Datei** in den Ziehvorgang. `dataTransfer.types` enthält dann
|
||||||
|
`Files` – nicht zu unterscheiden von einer Datei aus dem Dateimanager. Die
|
||||||
|
Ablagefläche lud das Bild daraufhin ein zweites Mal hoch.
|
||||||
|
|
||||||
|
Nachgestellt und gemessen:
|
||||||
|
|
||||||
|
| Fall | vorher | nachher |
|
||||||
|
|---|---|---|
|
||||||
|
| Vorschaubild ziehen und ablegen | `draggable=true`, **1 Upload** | `draggable=false`, **0 Uploads** |
|
||||||
|
| Datei von außen ablegen | 1 Upload, Overlay sichtbar | unverändert 1 Upload, Overlay sichtbar |
|
||||||
|
|
||||||
|
Zwei Riegel, weil einer allein zu leicht wieder aufgeht:
|
||||||
|
|
||||||
|
1. `draggable={false}` an allen Bildern, die kein eigenes Ziehen brauchen –
|
||||||
|
Kachel-Vorschauen, Vorschaufenster, Suchtreffer, Papierkorb, Etiketten,
|
||||||
|
Logo, Profilbild. Die **Kachel** bleibt ziehbar und verschiebt die Datei.
|
||||||
|
2. `hooks/useInternalDrag.ts`: Was in dieser Seite losgezogen ist, wird nie
|
||||||
|
als Upload behandelt. `dragstart` feuert nur bei eigener Quelle; von außen
|
||||||
|
gibt es keins.
|
||||||
|
|
||||||
|
- [ ] Auf dem Handy gegenprüfen: Ein Bild lässt sich mit dem Finger nicht mehr
|
||||||
|
aus der Vorschau ziehen (dort war das Wischen ohnehin gemeint)
|
||||||
|
|
||||||
## Automatisiert geprüft (22.08.2026)
|
## Automatisiert geprüft (22.08.2026)
|
||||||
|
|
||||||
Diese Punkte hat Claude per API/`curl` gegen Wegwerf-Konten durchgespielt; sie
|
Diese Punkte hat Claude per API/`curl` gegen Wegwerf-Konten durchgespielt; sie
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ export default function FileCover({ file }: { file: FileItem }) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<img
|
<img
|
||||||
|
/* Ein Bild ist von Haus aus ziehbar – und Chrome legt beim Ziehen
|
||||||
|
eine echte Datei in die Zwischenablage. Fällt die auf die
|
||||||
|
Ablagefläche, lädt die Cloud ihr eigenes Bild erneut hoch.
|
||||||
|
Die Kachel selbst bleibt ziehbar; sie verschiebt die Datei. */
|
||||||
|
draggable={false}
|
||||||
src={filesApi.thumbnail(file.id)}
|
src={filesApi.thumbnail(file.id)}
|
||||||
alt=""
|
alt=""
|
||||||
onError={() => setFailed(true)}
|
onError={() => setFailed(true)}
|
||||||
|
|||||||
@@ -280,6 +280,9 @@ export default function FilePreview({ file, onPrev, onNext, onClose, onSaved, se
|
|||||||
|
|
||||||
{kind === 'image' && (
|
{kind === 'image' && (
|
||||||
<img src={src} alt={file.name}
|
<img src={src} alt={file.name}
|
||||||
|
/* Nicht ziehbar: Sonst nimmt Chrome das angezeigte Bild als Datei
|
||||||
|
und die darunterliegende Ablagefläche lädt es erneut hoch. */
|
||||||
|
draggable={false}
|
||||||
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }} />
|
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ export default function SearchBox({ onOpenFolder, onOpenFile }: Props) {
|
|||||||
{hit.kind === 'folder' ? (
|
{hit.kind === 'folder' ? (
|
||||||
<FolderGlyph size={24} />
|
<FolderGlyph size={24} />
|
||||||
) : hit.has_thumbnail ? (
|
) : hit.has_thumbnail ? (
|
||||||
<img src={filesApi.thumbnail(hit.id)} alt="" style={{
|
<img src={filesApi.thumbnail(hit.id)} alt="" draggable={false} style={{
|
||||||
width: 18, height: 18, borderRadius: 3, objectFit: 'cover',
|
width: 18, height: 18, borderRadius: 3, objectFit: 'cover',
|
||||||
}} />
|
}} />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -379,6 +379,7 @@ export default function Sidebar({ user, view, activeTagId, onOpenTag,
|
|||||||
<div style={{ padding: '20px 16px 16px', display: 'flex', alignItems: 'center', gap: 10 }}>
|
<div style={{ padding: '20px 16px 16px', display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
{logoUrl ? (
|
{logoUrl ? (
|
||||||
<img
|
<img
|
||||||
|
draggable={false}
|
||||||
src={logoUrl}
|
src={logoUrl}
|
||||||
alt=""
|
alt=""
|
||||||
style={{ height: 30, maxWidth: 120, objectFit: 'contain', flexShrink: 0 }}
|
style={{ height: 30, maxWidth: 120, objectFit: 'contain', flexShrink: 0 }}
|
||||||
|
|||||||
@@ -181,6 +181,7 @@ export function Avatar({ name, src, size = 26 }: { name: string; src?: string |
|
|||||||
{src && (
|
{src && (
|
||||||
<img
|
<img
|
||||||
src={src}
|
src={src}
|
||||||
|
draggable={false}
|
||||||
alt=""
|
alt=""
|
||||||
width={size}
|
width={size}
|
||||||
height={size}
|
height={size}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Läuft gerade ein Ziehvorgang, der **in dieser Seite** begonnen hat?
|
||||||
|
*
|
||||||
|
* Anlass war ein Fehler, den man nur beim Benutzen findet: Zieht man das
|
||||||
|
* angezeigte Bild ein Stück zur Seite, legt Chrome es als echte Datei in die
|
||||||
|
* Zwischenablage des Ziehvorgangs – `dataTransfer.types` enthält dann
|
||||||
|
* `Files`, genau wie bei einer Datei aus dem Dateimanager. Die Ablagefläche
|
||||||
|
* konnte beides nicht unterscheiden und lud das Bild ein zweites Mal hoch.
|
||||||
|
*
|
||||||
|
* Die Kennungen `aether-file-id` und `aether-folder-id` helfen hier nicht:
|
||||||
|
* Die setzt unser eigener Verschiebe-Code, ein vom Browser begonnener
|
||||||
|
* Bild-Zug hat sie nicht.
|
||||||
|
*
|
||||||
|
* Deshalb der Umweg über das Ereignis: `dragstart` feuert nur, wenn die
|
||||||
|
* Quelle in diesem Dokument liegt. Kommt etwas von außen, gibt es kein
|
||||||
|
* `dragstart` – und nur dann darf hochgeladen werden.
|
||||||
|
*/
|
||||||
|
export function useInternalDrag(): () => boolean {
|
||||||
|
const active = useRef(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const start = () => { active.current = true }
|
||||||
|
// Der Rückweg über zwei Ereignisse: `dragend` feuert an der Quelle,
|
||||||
|
// `drop` am Ziel. Fällt eines aus (abgebrochener Zug außerhalb des
|
||||||
|
// Fensters), setzt das andere zurück.
|
||||||
|
const stop = () => { active.current = false }
|
||||||
|
// Setzen in der Erfassungsphase, damit die Marke steht, bevor irgendein
|
||||||
|
// Handler sie liest. **Zurücksetzen dagegen in der Blasenphase**: Ein
|
||||||
|
// `drop`-Listener am Dokument mit `capture` liefe *vor* der Ablagefläche,
|
||||||
|
// hätte die Marke also schon gelöscht, wenn sie gebraucht wird.
|
||||||
|
document.addEventListener('dragstart', start, true)
|
||||||
|
document.addEventListener('dragend', stop)
|
||||||
|
document.addEventListener('drop', stop)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('dragstart', start, true)
|
||||||
|
document.removeEventListener('dragend', stop)
|
||||||
|
document.removeEventListener('drop', stop)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return () => active.current
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import MoveTo from '../components/MoveTo'
|
|||||||
import TagDialog from '../components/TagDialog'
|
import TagDialog from '../components/TagDialog'
|
||||||
import OfficeEditor from '../components/OfficeEditor'
|
import OfficeEditor from '../components/OfficeEditor'
|
||||||
import { useAppSettings } from '../hooks/useAppSettings'
|
import { useAppSettings } from '../hooks/useAppSettings'
|
||||||
|
import { useInternalDrag } from '../hooks/useInternalDrag'
|
||||||
import { DownloadIcon, ShareIcon, HistoryIcon, RenameIcon, MoveIcon, TrashIcon, FolderOpenIcon, StarIcon, CheckIcon, PeopleIcon, TagIcon, OfficeIcon, InfoIcon } from '../components/icons'
|
import { DownloadIcon, ShareIcon, HistoryIcon, RenameIcon, MoveIcon, TrashIcon, FolderOpenIcon, StarIcon, CheckIcon, PeopleIcon, TagIcon, OfficeIcon, InfoIcon } from '../components/icons'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -135,6 +136,7 @@ export default function FileBrowser({
|
|||||||
const [ctxMenu, setCtxMenu] = useState<CtxMenu | null>(null)
|
const [ctxMenu, setCtxMenu] = useState<CtxMenu | null>(null)
|
||||||
const [dialog, setDialog] = useState<Dialog | null>(null)
|
const [dialog, setDialog] = useState<Dialog | null>(null)
|
||||||
const [dragging, setDragging] = useState(false)
|
const [dragging, setDragging] = useState(false)
|
||||||
|
const isInternalDrag = useInternalDrag()
|
||||||
const [dragOverFolder, setDragOverFolder] = useState<string | null>(null)
|
const [dragOverFolder, setDragOverFolder] = useState<string | null>(null)
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>(() => loadPref('aether:view', 'grid'))
|
const [viewMode, setViewMode] = useState<ViewMode>(() => loadPref('aether:view', 'grid'))
|
||||||
const [sort, setSort] = useState<Sort>(() => loadPref('aether:sort', 'name-asc'))
|
const [sort, setSort] = useState<Sort>(() => loadPref('aether:sort', 'name-asc'))
|
||||||
@@ -414,6 +416,10 @@ export default function FileBrowser({
|
|||||||
setDragging(false)
|
setDragging(false)
|
||||||
const t = e.dataTransfer.types
|
const t = e.dataTransfer.types
|
||||||
if (t.includes('aether-file-id') || t.includes('aether-folder-id')) return // internal move, not upload
|
if (t.includes('aether-file-id') || t.includes('aether-folder-id')) return // internal move, not upload
|
||||||
|
// Alles, was in dieser Seite losgezogen ist, ist kein Upload – auch ein
|
||||||
|
// vom Browser begonnener Bild-Zug, der `Files` mitbringt, aber keine
|
||||||
|
// unserer Kennungen. Siehe hooks/useInternalDrag.
|
||||||
|
if (isInternalDrag()) return
|
||||||
|
|
||||||
// Capture FileSystemEntries synchronously – the DataTransferItemList is only
|
// Capture FileSystemEntries synchronously – the DataTransferItemList is only
|
||||||
// valid during the drop event itself.
|
// valid during the drop event itself.
|
||||||
@@ -718,7 +724,8 @@ export default function FileBrowser({
|
|||||||
onDragOver={e => {
|
onDragOver={e => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const t = e.dataTransfer.types
|
const t = e.dataTransfer.types
|
||||||
if (t.includes('Files') && !t.includes('aether-file-id') && !t.includes('aether-folder-id')) setDragging(true)
|
if (t.includes('Files') && !t.includes('aether-file-id') && !t.includes('aether-folder-id')
|
||||||
|
&& !isInternalDrag()) setDragging(true)
|
||||||
}}
|
}}
|
||||||
onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragging(false) }}
|
onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragging(false) }}
|
||||||
onDrop={onDrop}
|
onDrop={onDrop}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export default function LoginPage({ onLogin }: Props) {
|
|||||||
aus. */}
|
aus. */}
|
||||||
{config.logo_url ? (
|
{config.logo_url ? (
|
||||||
<img
|
<img
|
||||||
|
draggable={false}
|
||||||
src={config.logo_url}
|
src={config.logo_url}
|
||||||
alt={config.cloud_name}
|
alt={config.cloud_name}
|
||||||
className="mx-auto mb-4 object-contain"
|
className="mx-auto mb-4 object-contain"
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ export default function TagsPage({ tagId, correspondentId, onOpenFolder }: Props
|
|||||||
einer Liste reicht die kleine Fassung; die großen Cover
|
einer Liste reicht die kleine Fassung; die großen Cover
|
||||||
gehören zu den Kacheln. */}
|
gehören zu den Kacheln. */}
|
||||||
{f.has_thumbnail ? (
|
{f.has_thumbnail ? (
|
||||||
<img src={filesApi.thumbnail(f.id)} alt="" style={{
|
<img src={filesApi.thumbnail(f.id)} alt="" draggable={false} style={{
|
||||||
width: 22, height: 22, borderRadius: 4, objectFit: 'cover', flexShrink: 0,
|
width: 22, height: 22, borderRadius: 4, objectFit: 'cover', flexShrink: 0,
|
||||||
}} />
|
}} />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ export default function TrashPage({ onUserChanged }: Props) {
|
|||||||
}}>
|
}}>
|
||||||
{file.has_thumbnail ? (
|
{file.has_thumbnail ? (
|
||||||
<img
|
<img
|
||||||
|
draggable={false}
|
||||||
src={filesApi.thumbnail(file.id)}
|
src={filesApi.thumbnail(file.id)}
|
||||||
alt=""
|
alt=""
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
|
|||||||
Reference in new issue
Block a user