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,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;
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user