feat(editor): Editor-Oberfläche mit Upload, Vorschau und Download

Store, Upload-Prüfung/Dekodierung und Download-Dateiname mit Tests; drei Spalten
(Vorlagen mit Live-Miniatur, Vorschau mit Ziehen/Zoom, Optionen), einspaltig unter 900 px.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-04 08:05:50 +00:00
1 parent 9a46342e0c
commit 30f4ddc0f4
13 files changed
+1703 -3

No files matched your search

+243
View File
@@ -0,0 +1,243 @@
// Mittlere Spalte: Ablagefläche bzw. Kartenvorschau (Verschieben, Zoom) und die Knopfleiste.
import { MAX_ZOOM, MIN_ZOOM } from '../card/config';
import { CARD_H, CARD_W } from '../card/geometry';
import { el, icon } from './dom';
import { ACCEPTED_TYPES } from './upload';
export interface PreviewHandlers {
onFile(file: File): void;
/** Verschiebung in Karten-px (1536 x 969), nicht in CSS-px. */
onPan(dx: number, dy: number): void;
onZoom(zoom: number): void;
onDownload(): void;
onSave(): void;
}
export interface PreviewView {
hasImage: boolean;
zoom: number;
saving: boolean;
exporting: boolean;
}
export interface Preview {
el: HTMLElement;
canvas: HTMLCanvasElement;
sync(view: PreviewView): void;
setDragging(active: boolean): void;
setError(message: string | null): void;
setWarning(message: string | null): void;
}
const NO_IMAGE_HINT = 'Erst ein Bild wählen';
const KEY_STEP = 24;
const KEY_STEP_BIG = 96;
const KEY_ZOOM_STEP = 0.1;
/** Nimmt Dateien per Ziehen und Ablegen an `target` an. Auch Ablagen neben der Fläche sollen das Bild laden, statt die Seite zu verlassen. */
export function attachFileDrop(
target: HTMLElement,
onFile: (file: File) => void,
onActive: (active: boolean) => void,
): void {
let depth = 0;
const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer?.types ?? []).includes('Files');
target.addEventListener('dragenter', (e) => {
if (!hasFiles(e)) return;
e.preventDefault();
depth += 1;
onActive(true);
});
target.addEventListener('dragover', (e) => {
if (!hasFiles(e)) return;
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
});
target.addEventListener('dragleave', (e) => {
if (!hasFiles(e)) return;
depth = Math.max(0, depth - 1);
if (depth === 0) onActive(false);
});
target.addEventListener('drop', (e) => {
if (!hasFiles(e)) return;
e.preventDefault();
depth = 0;
onActive(false);
const file = e.dataTransfer?.files[0];
if (file) onFile(file);
});
}
export function createPreview(handlers: PreviewHandlers): Preview {
let zoom = MIN_ZOOM;
const input = el('input', {
type: 'file',
accept: ACCEPTED_TYPES.join(','),
hidden: true,
'aria-label': 'Bilddatei wählen',
});
const openPicker = () => input.click();
input.addEventListener('change', () => {
const file = input.files?.[0];
// Zurücksetzen, damit dieselbe Datei später noch einmal gewählt werden kann.
input.value = '';
if (file) handlers.onFile(file);
});
// Ablagefläche (ohne Bild)
const chooseButton = el('button', { type: 'button', class: 'btn btn-primary btn-large' }, 'Bild auswählen');
const dropzone = el(
'div',
{ class: 'dropzone' },
icon('imagePlus', 'dropzone-icon'),
el('p', { class: 'dropzone-title' }, 'Ziehe deine Idee hierher'),
chooseButton,
el('p', { class: 'dropzone-hint' }, 'PNG / JPG / WebP · Max. 5 MiB'),
);
dropzone.addEventListener('click', openPicker);
// Kartenvorschau (mit Bild)
const canvas = el('canvas', {
class: 'card-canvas',
width: CARD_W,
height: CARD_H,
tabindex: 0,
role: 'application',
'aria-label': 'Kartenvorschau. Mit der Maus ziehen oder die Pfeiltasten benutzen, um das Bild zu verschieben.',
});
const cardWrap = el('div', { class: 'card-wrap', hidden: true }, canvas);
let dragId: number | null = null;
let lastX = 0;
let lastY = 0;
canvas.addEventListener('pointerdown', (e) => {
if (dragId !== null || (e.pointerType === 'mouse' && e.button !== 0)) return;
e.preventDefault();
dragId = e.pointerId;
lastX = e.clientX;
lastY = e.clientY;
canvas.setPointerCapture(e.pointerId);
canvas.classList.add('is-panning');
});
canvas.addEventListener('pointermove', (e) => {
if (e.pointerId !== dragId || canvas.clientWidth === 0) return;
const k = CARD_W / canvas.clientWidth;
const dx = (e.clientX - lastX) * k;
const dy = (e.clientY - lastY) * k;
lastX = e.clientX;
lastY = e.clientY;
if (dx !== 0 || dy !== 0) handlers.onPan(dx, dy);
});
const endPan = (e: PointerEvent) => {
if (e.pointerId !== dragId) return;
dragId = null;
canvas.classList.remove('is-panning');
if (canvas.hasPointerCapture(e.pointerId)) canvas.releasePointerCapture(e.pointerId);
};
canvas.addEventListener('pointerup', endPan);
canvas.addEventListener('pointercancel', endPan);
canvas.addEventListener('keydown', (e) => {
const step = e.shiftKey ? KEY_STEP_BIG : KEY_STEP;
const moves: Record<string, [number, number]> = {
ArrowLeft: [-step, 0],
ArrowRight: [step, 0],
ArrowUp: [0, -step],
ArrowDown: [0, step],
};
const move = moves[e.key];
if (move) {
e.preventDefault();
handlers.onPan(move[0], move[1]);
} else if (e.key === '+' || e.key === '=') {
e.preventDefault();
handlers.onZoom(zoom + KEY_ZOOM_STEP);
} else if (e.key === '-') {
e.preventDefault();
handlers.onZoom(zoom - KEY_ZOOM_STEP);
}
});
const stage = el('div', { class: 'stage' }, dropzone, cardWrap);
// Zoom
const zoomInput = el('input', {
type: 'range',
class: 'zoom-input',
id: 'editor-zoom',
min: MIN_ZOOM,
max: MAX_ZOOM,
step: 0.01,
value: MIN_ZOOM,
});
const zoomValue = el('output', { class: 'zoom-value', for: 'editor-zoom' }, '100 %');
zoomInput.addEventListener('input', () => handlers.onZoom(Number(zoomInput.value)));
const zoomRow = el(
'div',
{ class: 'zoom-row', hidden: true },
el('label', { class: 'zoom-label', for: 'editor-zoom' }, 'Zoom'),
zoomInput,
zoomValue,
);
// Meldungen
const errorBox = el('p', { class: 'msg msg-error', role: 'alert' });
const warningBox = el('p', { class: 'msg msg-warning', role: 'status' });
// Knopfleiste
const replaceButton = el('button', { type: 'button', class: 'btn btn-ghost' }, 'Bild ersetzen');
replaceButton.addEventListener('click', openPicker);
const downloadButton = el('button', { type: 'button', class: 'btn btn-primary' }, 'Herunterladen');
downloadButton.addEventListener('click', () => handlers.onDownload());
const saveButton = el('button', { type: 'button', class: 'btn btn-primary' }, 'Speichern');
saveButton.addEventListener('click', () => handlers.onSave());
const actions = el(
'div',
{ class: 'actions' },
el('span', { class: 'size-label' }, `${CARD_W} × ${CARD_H} PNG`),
el('div', { class: 'action-buttons' }, replaceButton, downloadButton, saveButton),
);
const note = el('p', { class: 'note' }, icon('lock', 'note-icon'), 'Dein Bild bleibt auf deinem Gerät, bis du speicherst.');
const inner = el('div', { class: 'preview-inner' }, stage, zoomRow, errorBox, warningBox, actions, note, input);
const section = el('section', { class: 'editor-center', 'aria-label': 'Kartenvorschau' }, inner);
function setDisabled(button: HTMLButtonElement, disabled: boolean, hint: string | null): void {
button.disabled = disabled;
if (disabled && hint) button.setAttribute('title', hint);
else button.removeAttribute('title');
}
function setMessage(box: HTMLElement, message: string | null): void {
box.textContent = message ?? '';
}
return {
el: section,
canvas,
sync(view) {
zoom = view.zoom;
dropzone.hidden = view.hasImage;
cardWrap.hidden = !view.hasImage;
zoomRow.hidden = !view.hasImage;
zoomInput.value = String(view.zoom);
zoomValue.textContent = `${Math.round(view.zoom * 100)} %`;
setDisabled(downloadButton, !view.hasImage || view.exporting, view.hasImage ? null : NO_IMAGE_HINT);
setDisabled(saveButton, !view.hasImage || view.saving, view.hasImage ? null : NO_IMAGE_HINT);
downloadButton.setAttribute('aria-busy', String(view.exporting));
saveButton.setAttribute('aria-busy', String(view.saving));
},
setDragging(active) {
stage.classList.toggle('is-dragging', active);
},
setError(message) {
setMessage(errorBox, message);
},
setWarning(message) {
setMessage(warningBox, message);
},
};
}