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

+175
View File
@@ -0,0 +1,175 @@
// Editor-Seite: setzt Vorlagen, Vorschau und Optionen zusammen und verbindet sie mit dem Store.
import './editor.css';
import type { BrandId, CardConfig, DetailsTone, NetworkId, TemplateId } from '../card/config';
import { panBy, zoomTo } from '../card/crop';
import { CARD_W } from '../card/geometry';
import { drawCard, exportCard, loadCardAssets, type CardAssets } from '../card/render';
import { downloadFileName, pngSizeWarning, saveBlobAs } from './download';
import { el } from './dom';
import { createOptionsPanel } from './options-panel';
import { attachFileDrop, createPreview } from './preview';
import { setImage, type EditorState, type EditorStore } from './state';
import { createTemplatesPanel } from './templates-panel';
import { decodeImage, validateImageFile } from './upload';
const LOGO_ERROR = 'Die Logos konnten nicht geladen werden.';
const EXPORT_ERROR = 'Das PNG konnte nicht erzeugt werden.';
type Logos = Pick<CardAssets, 'network' | 'brand'>;
/** Alles, was Logo-Bilder bestimmt: wechselt der Schlüssel, müssen sie neu geladen werden. */
const logoKey = (c: CardConfig) => `${c.network}|${c.details}|${c.brand}`;
export function mountEditor(root: HTMLElement, store: EditorStore, hooks: { onSave(): void }): () => void {
let disposed = false;
const patchConfig = (patch: Partial<CardConfig>) => store.set({ config: { ...store.get().config, ...patch } });
const templates = createTemplatesPanel((template: TemplateId) => patchConfig({ template }));
const options = createOptionsPanel({
onNetwork: (network: NetworkId) => patchConfig({ network }),
onDetails: (details: DetailsTone) => patchConfig({ details }),
onBrand: (brand: BrandId) => patchConfig({ brand }),
});
let exporting = false;
const preview = createPreview({
onFile: (file) => void handleFile(file),
onPan(dx, dy) {
const s = store.get();
if (!s.image) return;
patchConfig({ crop: panBy(s.image, s.config.crop, dx, dy) });
},
onZoom(zoom) {
const s = store.get();
if (!s.image) return;
patchConfig({ crop: zoomTo(s.image, s.config.crop, zoom) });
},
onDownload: () => void download(),
onSave: () => hooks.onSave(),
});
const heading = el('h1', { class: 'sr-only' }, 'Kartenmotiv gestalten');
const page = el('div', { class: 'editor' }, templates.el, preview.el, options.el);
root.replaceChildren(heading, page);
// Dateien: nur das zuletzt gewählte Bild zählt, falls ein älteres noch dekodiert wird.
let fileToken = 0;
async function handleFile(file: File): Promise<void> {
const token = ++fileToken;
const check = validateImageFile(file);
if (!check.ok) {
preview.setError(check.message);
return;
}
try {
const image = await decodeImage(file);
if (disposed || token !== fileToken) return;
preview.setError(null);
setImage(store, image, file);
} catch (error) {
if (disposed || token !== fileToken) return;
preview.setError(error instanceof Error ? error.message : 'Das Bild konnte nicht gelesen werden.');
}
}
attachFileDrop(page, (file) => void handleFile(file), preview.setDragging);
// Download: rendert die Karte frisch in voller Größe, unabhängig von der Vorschau.
async function download(): Promise<void> {
const s = store.get();
if (!s.image || exporting) return;
exporting = true;
sync(store.get());
preview.setError(null);
preview.setWarning(null);
try {
const assets = await loadCardAssets(s.config, s.image);
const blob = await exportCard(s.config, assets, { width: CARD_W, type: 'image/png' });
saveBlobAs(blob, downloadFileName(s.cardName, new Date()));
preview.setWarning(pngSizeWarning(blob.size));
} catch (error) {
preview.setError(error instanceof Error && error.message ? error.message : EXPORT_ERROR);
} finally {
exporting = false;
if (!disposed) sync(store.get());
}
}
// Logos und Schrift: werden nur bei Wechsel von Netzwerk, Details oder Logo geladen (Logos sind zwischengespeichert).
let logos: (Logos & { key: string }) | null = null;
let pendingKey = '';
let failedKey = '';
let logoToken = 0;
function ensureLogos(config: CardConfig): void {
const key = logoKey(config);
if (logos?.key === key || pendingKey === key || failedKey === key) return;
pendingKey = key;
const token = ++logoToken;
loadCardAssets(config, null).then(
(assets) => {
if (disposed || token !== logoToken) return;
pendingKey = '';
logos = { network: assets.network, brand: assets.brand, key };
schedule();
},
() => {
if (disposed || token !== logoToken) return;
pendingKey = '';
failedKey = key;
preview.setError(LOGO_ERROR);
},
);
}
// Zeichnen: höchstens einmal je Animationsbild, egal wie oft sich der Zustand ändert.
let frame = 0;
function schedule(): void {
if (frame || disposed) return;
frame = requestAnimationFrame(() => {
frame = 0;
draw();
});
}
function draw(): void {
const s = store.get();
// Mit Logos eines anderen Netzwerks würde das Seitenverhältnis nicht passen; lieber auf die neuen warten.
if (!logos || logos.key !== logoKey(s.config)) return;
const assets: CardAssets = { image: s.image, network: logos.network, brand: logos.brand };
try {
if (s.image) drawCard(preview.canvas, s.config, assets);
templates.draw(s.config, assets);
} catch (error) {
preview.setError(error instanceof Error ? error.message : 'Die Vorschau konnte nicht gezeichnet werden.');
}
}
let lastImage: EditorState['image'] = null;
function sync(s: EditorState): void {
templates.select(s.config.template);
options.select(s.config);
preview.sync({ hasImage: s.image !== null, zoom: s.config.crop.zoom, saving: s.saving, exporting });
}
function onState(s: EditorState): void {
// Der Größenhinweis gehört zum letzten Download; ein anderes Bild macht ihn hinfällig.
if (s.image !== lastImage) {
lastImage = s.image;
preview.setWarning(null);
}
sync(s);
ensureLogos(s.config);
schedule();
}
const unsubscribe = store.subscribe(onState);
onState(store.get());
return () => {
disposed = true;
unsubscribe();
if (frame) cancelAnimationFrame(frame);
frame = 0;
};
}