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:
1 parent
9a46342e0c
commit
30f4ddc0f4
13 files changed
+1703
-3
No files matched your search
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user