// Editor-Seite: setzt Vorlagen, Vorschau und Optionen zusammen und verbindet sie mit dem Store. import './editor.css'; import { cardsService } from '../account/cards'; import { getCurrentUser } from '../account/auth'; import { errorMessage, errorType } from '../account/errors'; import { requireLogin } from '../account/login-dialog'; import { LoginCancelled, withLogin } from '../account/with-login'; import type { BrandId, CardConfig, DetailsTone, NetworkId, TemplateId } from '../card/config'; import { clampCrop, panBy, zoomTo } from '../card/crop'; import { CARD_W } from '../card/geometry'; import { drawCard, exportCard, loadCardAssets, type CardAssets, type CardImage } from '../card/render'; import { downloadFileName, pngSizeWarning, saveBlobAs } from './download'; import { el } from './dom'; import { createOptionsPanel } from './options-panel'; import { attachFileDrop, createPreview } from './preview'; import { createSaveAction, type SaveStatus } from './save'; import { initialEditorState, setImage, type EditorState, type EditorStore, type SavedCardRef } from './state'; import { createTemplatesPanel } from './templates-panel'; import { READ_MESSAGE, decodeImage, validateImageFile } from './upload'; const LOGO_ERROR = 'Die Logos konnten nicht geladen werden.'; const EXPORT_ERROR = 'Das PNG konnte nicht erzeugt werden.'; const NO_IMAGE = 'Bitte zuerst ein Bild wählen.'; const LOADING_CARD = 'Karte wird geladen …'; const LOGIN_TO_OPEN = 'Melde dich an, um diese Karte zu öffnen.'; const PREVIEW_WIDTH = 480; const PREVIEW_QUALITY = 0.85; const SAVED_FLASH_MS = 2000; type Logos = Pick; /** 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}`; /** Der Teil einer gespeicherten Karte, den der Editor-Zustand festhält (ohne Konfiguration). */ function toRef(card: SavedCardRef): SavedCardRef { return { id: card.id, name: card.name, imageFileId: card.imageFileId, previewFileId: card.previewFileId, updatedAt: card.updatedAt, }; } export function mountEditor(root: HTMLElement, store: EditorStore): () => void { let disposed = false; const patchConfig = (patch: Partial) => 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() { if (store.get().saving) return; if (!store.get().imageBlob) { preview.setError(NO_IMAGE); return; } void saveCard(); }, }); 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 { // Während des Speicherns bleibt das Bild, das gerade gespeichert wird. if (store.get().saving) return; const token = ++fileToken; const check = validateImageFile(file); if (!check.ok) { preview.setError(check.message); return; } // Wer selbst ein Bild wählt, will die Karte, die noch lädt, nicht mehr. cancelCardLoad(); 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), (active) => preview.setDragging(active && !store.get().saving)); // Download: rendert die Karte frisch in voller Größe, unabhängig von der Vorschau. async function download(): Promise { 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()); } } // Speichern: Anmeldung prüfen, Karte speichern, bei abgelaufener Sitzung einmal neu anmelden (siehe save.ts). let savedFlash = false; let flashTimer: ReturnType | undefined; function onSaveStatus(status: SaveStatus, message?: string): void { clearTimeout(flashTimer); savedFlash = status === 'saved'; if (savedFlash) { flashTimer = setTimeout(() => { savedFlash = false; if (!disposed) sync(store.get()); }, SAVED_FLASH_MS); } if (!disposed) preview.setError(status === 'error' ? (message ?? null) : null); store.set({ saving: status === 'saving' }); } async function doSave(): Promise { // Ab hier zählt nur dieser Schnappschuss: Bild, Ausschnitt und Name, wie sie beim Klick waren. const { config, image, imageBlob, imageChanged, cardName, saved } = store.get(); if (!image || !imageBlob) throw new Error(NO_IMAGE); const assets = await loadCardAssets(config, image); const previewBlob = await exportCard(config, assets, { width: PREVIEW_WIDTH, type: 'image/webp', quality: PREVIEW_QUALITY, }); const card = await cardsService().save({ name: cardName, config, image: imageBlob, imageChanged, preview: previewBlob, previous: saved, }); // Hat inzwischen eine andere Karte den Editor übernommen (Öffnen aus „Meine Karten“), bleibt deren Zustand unberührt. const now = store.get(); if (now.imageBlob === imageBlob && now.saved?.id === saved?.id) { store.set({ saved: toRef(card), imageChanged: false, cardName: card.name }); } } const saveCard = createSaveAction({ getUser: getCurrentUser, requireLogin: () => requireLogin('login'), doSave, onStatus: onSaveStatus, }); // Karte öffnen (/?karte=): nur die zuletzt angeforderte zählt. let loadToken = 0; function cancelCardLoad(): void { loadToken += 1; preview.setNotice(null); } async function openSavedCard(id: string): Promise { const token = ++loadToken; const stale = () => disposed || token !== loadToken; const service = cardsService(); preview.setError(null); preview.setWarning(null); preview.setNotice(LOADING_CARD); try { const card = await withLogin(() => service.get(id)); if (stale()) return; // Fehlt die Bilddatei, öffnet die Karte trotzdem (ohne Bild, mit Hinweis). let blob: Blob | null = null; let missingImage: string | null = null; try { blob = await withLogin(() => service.loadImage(card)); } catch (err) { if (errorType(err) !== 'storage_file_not_found') throw err; missingImage = errorMessage(err, 'load'); } if (stale()) return; const image: CardImage | null = blob ? await decodeImage(blob) : null; if (stale()) return; store.set({ config: image ? { ...card.config, crop: clampCrop(image, card.config.crop) } : card.config, image, imageBlob: blob, imageChanged: false, saved: toRef(card), cardName: card.name, }); preview.setWarning(missingImage); } catch (err) { if (stale()) return; if (err instanceof LoginCancelled) { preview.setWarning(LOGIN_TO_OPEN); } else if (errorType(err) === 'row_not_found') { // Die Karte gibt es nicht (für dieses Konto): kein halb übernommener Zustand, sondern ein leerer Editor. store.set(initialEditorState()); preview.setError(errorMessage(err, 'load')); } else { preview.setError(err instanceof Error && err.message === READ_MESSAGE ? READ_MESSAGE : errorMessage(err, 'load')); } } finally { if (!stale()) preview.setNotice(null); } } // 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, saved: savedFlash, 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()); // Die Adresse wird nur beim Öffnen der Seite gelesen; hält der Editor die Karte schon, bleibt sein Stand. const cardId = new URLSearchParams(location.search).get('karte'); if (cardId && store.get().saved?.id !== cardId) void openSavedCard(cardId); return () => { disposed = true; clearTimeout(flashTimer); unsubscribe(); if (frame) cancelAnimationFrame(frame); frame = 0; }; }