From 30f4ddc0f472ce28792a0456571aae53e63eda2f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 08:05:50 +0000 Subject: [PATCH] =?UTF-8?q?feat(editor):=20Editor-Oberfl=C3=A4che=20mit=20?= =?UTF-8?q?Upload,=20Vorschau=20und=20Download?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/src/editor/dom.ts | 41 +++ web/src/editor/download.test.ts | 91 +++++ web/src/editor/download.ts | 51 +++ web/src/editor/editor-page.ts | 175 ++++++++++ web/src/editor/editor.css | 539 ++++++++++++++++++++++++++++++ web/src/editor/options-panel.ts | 96 ++++++ web/src/editor/preview.ts | 243 ++++++++++++++ web/src/editor/state.test.ts | 120 +++++++ web/src/editor/state.ts | 71 ++++ web/src/editor/templates-panel.ts | 56 ++++ web/src/editor/upload.test.ts | 155 +++++++++ web/src/editor/upload.ts | 54 +++ web/src/main.ts | 14 +- 13 files changed, 1703 insertions(+), 3 deletions(-) create mode 100644 web/src/editor/dom.ts create mode 100644 web/src/editor/download.test.ts create mode 100644 web/src/editor/download.ts create mode 100644 web/src/editor/editor-page.ts create mode 100644 web/src/editor/editor.css create mode 100644 web/src/editor/options-panel.ts create mode 100644 web/src/editor/preview.ts create mode 100644 web/src/editor/state.test.ts create mode 100644 web/src/editor/state.ts create mode 100644 web/src/editor/templates-panel.ts create mode 100644 web/src/editor/upload.test.ts create mode 100644 web/src/editor/upload.ts diff --git a/web/src/editor/dom.ts b/web/src/editor/dom.ts new file mode 100644 index 0000000..2e6b255 --- /dev/null +++ b/web/src/editor/dom.ts @@ -0,0 +1,41 @@ +// Kleine DOM-Helfer für die Editor-Oberfläche (kein Framework). + +type Attrs = Record; + +/** Erzeugt ein Element; `true` setzt ein leeres Attribut, `false`/null/undefined lässt es weg. */ +export function el( + tag: K, + attrs: Attrs = {}, + ...children: (Node | string)[] +): HTMLElementTagNameMap[K] { + const node = document.createElement(tag); + for (const [key, value] of Object.entries(attrs)) { + if (value === false || value == null) continue; + node.setAttribute(key, value === true ? '' : String(value)); + } + node.append(...children); + return node; +} + +// Feste Symbole (Strichstärke aus dem Stylesheet); die Zeichenketten sind Konstanten, keine Nutzereingaben. +const ICONS = { + imagePlus: + '' + + '', + lock: '', + check: '', + ban: '', +} as const; + +export type IconName = keyof typeof ICONS; + +/** Ein dekoratives Symbol (aria-hidden), Größe über CSS (`.icon`). */ +export function icon(name: IconName, className = ''): SVGSVGElement { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', '0 0 24 24'); + svg.setAttribute('class', `icon ${className}`.trim()); + svg.setAttribute('aria-hidden', 'true'); + svg.setAttribute('focusable', 'false'); + svg.innerHTML = ICONS[name]; + return svg; +} diff --git a/web/src/editor/download.test.ts b/web/src/editor/download.test.ts new file mode 100644 index 0000000..22d3d08 --- /dev/null +++ b/web/src/editor/download.test.ts @@ -0,0 +1,91 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { PNG_SIZE_WARNING, downloadFileName, pngSizeWarning, saveBlobAs } from './download'; + +describe('downloadFileName', () => { + const date = new Date('2026-10-04T10:00:00Z'); + + it('macht aus dem Kartennamen einen Dateinamen', () => { + expect(downloadFileName('Mein Urlaub!', date)).toBe('aircard-mein-urlaub.png'); + }); + + it('nimmt bei leerem Namen das Datum', () => { + expect(downloadFileName(' ', date)).toBe('aircard-2026-10-04.png'); + expect(downloadFileName('', date)).toBe('aircard-2026-10-04.png'); + }); + + it('nimmt das Datum auch, wenn nur Sonderzeichen übrig bleiben', () => { + expect(downloadFileName('!!! ???', date)).toBe('aircard-2026-10-04.png'); + }); + + it('schreibt Umlaute und ß aus', () => { + expect(downloadFileName('Grüße', date)).toBe('aircard-gruesse.png'); + expect(downloadFileName('Äpfel Öl Übung', date)).toBe('aircard-aepfel-oel-uebung.png'); + expect(downloadFileName('STRASSE Straße', date)).toBe('aircard-strasse-strasse.png'); + }); + + it('entfernt andere Akzente', () => { + expect(downloadFileName('Café Noël', date)).toBe('aircard-cafe-noel.png'); + }); + + it('fasst Trennzeichen zusammen und schneidet Bindestriche an den Rändern ab', () => { + expect(downloadFileName(' --Mein Foto__2024-- ', date)).toBe('aircard-mein-foto-2024.png'); + }); + + it('kürzt sehr lange Namen, ohne mit einem Bindestrich zu enden', () => { + const name = downloadFileName(`${'a'.repeat(39)} ${'b'.repeat(40)}`, date); + expect(name).toBe(`aircard-${'a'.repeat(39)}.png`); + expect(name.length).toBeLessThanOrEqual(`aircard-${'x'.repeat(40)}.png`.length); + }); + + it('füllt Monat und Tag mit Nullen', () => { + expect(downloadFileName('', new Date('2026-01-05T10:00:00Z'))).toBe('aircard-2026-01-05.png'); + }); +}); + +describe('pngSizeWarning', () => { + it('warnt erst über 4 000 000 Byte', () => { + expect(pngSizeWarning(4_000_000)).toBeNull(); + expect(pngSizeWarning(4_000_001)).toBe(PNG_SIZE_WARNING); + expect(pngSizeWarning(1000)).toBeNull(); + }); + + it('hat den vorgegebenen Wortlaut', () => { + expect(PNG_SIZE_WARNING).toBe('Das PNG ist größer als 4 MB – Wallet nimmt eventuell nur kleinere Bilder.'); + }); +}); + +describe('saveBlobAs', () => { + let clicked: HTMLAnchorElement[]; + let createObjectURL: ReturnType; + let revokeObjectURL: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + clicked = []; + createObjectURL = vi.fn(() => 'blob:png-url'); + revokeObjectURL = vi.fn(); + vi.stubGlobal('URL', Object.assign(URL, { createObjectURL, revokeObjectURL })); + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) { + clicked.push(this); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it('klickt einen temporären Link mit download-Namen und räumt danach auf', () => { + const blob = new Blob(['png'], { type: 'image/png' }); + saveBlobAs(blob, 'aircard-test.png'); + expect(createObjectURL).toHaveBeenCalledWith(blob); + expect(clicked).toHaveLength(1); + expect(clicked[0].download).toBe('aircard-test.png'); + expect(clicked[0].href).toContain('blob:png-url'); + // Der Link bleibt nicht im Dokument liegen. + expect(document.body.contains(clicked[0])).toBe(false); + vi.runAllTimers(); + expect(revokeObjectURL).toHaveBeenCalledWith('blob:png-url'); + }); +}); diff --git a/web/src/editor/download.ts b/web/src/editor/download.ts new file mode 100644 index 0000000..424c6cc --- /dev/null +++ b/web/src/editor/download.ts @@ -0,0 +1,51 @@ +// PNG-Download: Dateiname, Größenhinweis und das Anstoßen des Browser-Downloads. + +/** Wallet nimmt eventuell nur Bilder unter 4 MB. */ +export const PNG_WARN_BYTES = 4_000_000; +export const PNG_SIZE_WARNING = 'Das PNG ist größer als 4 MB – Wallet nimmt eventuell nur kleinere Bilder.'; + +const MAX_SLUG_LENGTH = 40; + +const TRANSLITERATION: Record = { ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' }; + +function slugify(name: string): string { + const slug = name + .toLowerCase() + .replace(/[äöüß]/g, (c) => TRANSLITERATION[c]) + .normalize('NFD') + .replace(/[̀-ͯ]/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + return slug.slice(0, MAX_SLUG_LENGTH).replace(/-+$/, ''); +} + +function isoDay(date: Date): string { + const pad = (n: number) => String(n).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; +} + +/** `aircard-.png`; bleibt vom Namen nichts übrig, steht das Datum (JJJJ-MM-TT) darin. */ +export function downloadFileName(cardName: string, date: Date): string { + return `aircard-${slugify(cardName) || isoDay(date)}.png`; +} + +/** Hinweistext, wenn das PNG über der Wallet-Grenze liegt, sonst null. */ +export function pngSizeWarning(size: number): string | null { + return size > PNG_WARN_BYTES ? PNG_SIZE_WARNING : null; +} + +// Manche Browser starten den Download erst nach dem Klick; sofort freigeben würde ihn abbrechen. +const REVOKE_DELAY_MS = 10_000; + +/** Lässt den Browser die Datei über einen temporären `` speichern. */ +export function saveBlobAs(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.style.display = 'none'; + document.body.append(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), REVOKE_DELAY_MS); +} diff --git a/web/src/editor/editor-page.ts b/web/src/editor/editor-page.ts new file mode 100644 index 0000000..c9b55ec --- /dev/null +++ b/web/src/editor/editor-page.ts @@ -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; + +/** 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) => 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 { + 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 { + 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; + }; +} diff --git a/web/src/editor/editor.css b/web/src/editor/editor.css new file mode 100644 index 0000000..84f7fe2 --- /dev/null +++ b/web/src/editor/editor.css @@ -0,0 +1,539 @@ +/* Editor: drei Spalten (Vorlagen | Vorschau | Optionen), unter 900 px einspaltig. */ + +.editor [hidden] { + display: none !important; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + +.icon { + width: 1em; + height: 1em; + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; +} + +.editor { + --card-radius: 3.711% / 5.882%; /* 57 px von 1536 x 969, nur in der Vorschau */ + + display: grid; + grid-template-columns: 300px 1fr 400px; + align-items: stretch; + border: 1px solid var(--line); + border-radius: 24px; +} + +.editor-side { + padding: 24px 20px; + background: var(--bg); +} + +.editor-templates { + border-right: 1px solid var(--line); + border-radius: 23px 0 0 23px; +} + +.editor-options { + border-left: 1px solid var(--line); + border-radius: 0 23px 23px 0; +} + +.editor-center { + min-width: 0; + padding: 24px; + background: var(--panel-2); +} + +.panel-title { + margin: 0 0 20px; + font-size: 1.25rem; + font-weight: 700; + line-height: 1.2; +} + +.panel-subtitle { + margin: 28px 0 12px; + padding-left: 4px; + font-size: 1.05rem; + font-weight: 600; + line-height: 1.2; +} + +.panel-title + .panel-subtitle { + margin-top: 0; +} + +/* Kacheln (Vorlagen, Netzwerke, Logos) */ + +.tile { + appearance: none; + margin: 0; + border: 1px solid var(--line); + border-radius: var(--radius); + background: transparent; + color: var(--text); + font: inherit; + text-align: left; + cursor: pointer; + transition: background-color 0.12s, border-color 0.12s; +} + +.tile:hover { + border-color: var(--line-strong); + background: var(--panel); +} + +.tile[aria-pressed='true'] { + border-color: var(--sel-line); + background: var(--sel-bg); +} + +/* Vorlagen */ + +.template-list { + display: flex; + flex-direction: column; + gap: 16px; +} + +.template-tile { + position: relative; + display: flex; + flex-direction: column; + gap: 4px; + padding: 12px; + border-color: transparent; +} + +.template-thumb { + display: block; + width: 100%; + height: auto; + margin-bottom: 8px; + border-radius: var(--card-radius); + background: var(--panel-2); +} + +.template-title { + padding: 0 4px; + font-size: 1.1rem; + font-weight: 700; +} + +.template-sub { + padding: 0 28px 0 4px; + color: var(--muted); + font-size: 0.95rem; + line-height: 1.35; +} + +.template-check { + position: absolute; + right: 16px; + bottom: 16px; + display: none; + font-size: 1.2rem; + color: var(--text); +} + +.template-tile[aria-pressed='true'] .template-check { + display: block; +} + +/* Kartennetzwerk und Logo */ + +.mark-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; +} + +.mark-tile { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + min-width: 0; + min-height: 104px; + padding: 10px 6px; + text-align: center; +} + +.mark-img { + display: block; + width: 100%; + height: 46px; + object-fit: contain; + user-select: none; + -webkit-user-drag: none; +} + +.mark-none { + width: 46px; + height: 46px; + padding: 8px; + color: var(--muted); +} + +.mark-label { + color: var(--muted); + font-size: 0.85rem; + line-height: 1.2; + overflow-wrap: anywhere; +} + +.mark-tile[aria-pressed='true'] .mark-label { + color: var(--text); +} + +/* Details: Hell/Dunkel */ + +.pill-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.pill { + display: block; + position: relative; + cursor: pointer; +} + +.pill-input { + position: absolute; + inset: 0; + margin: 0; + opacity: 0; + cursor: pointer; +} + +.pill-face { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + height: 56px; + border: 1px solid var(--line); + border-radius: var(--radius); + font-size: 1.05rem; + transition: background-color 0.12s, border-color 0.12s; +} + +.pill:hover .pill-face { + border-color: var(--line-strong); +} + +.pill-input:checked + .pill-face { + border-color: var(--sel-line); + background: var(--sel-bg); +} + +.pill-input:focus-visible + .pill-face { + outline: 2px solid var(--sel-line); + outline-offset: 2px; +} + +.swatch { + width: 24px; + height: 24px; + border: 1px solid var(--line-strong); + border-radius: 50%; +} + +.swatch-light { + background: #ffffff; +} + +.swatch-dark { + background: #111111; +} + +/* Mitte */ + +.preview-inner { + position: sticky; + top: 16px; + display: flex; + flex-direction: column; + gap: 14px; +} + +.stage { + width: 100%; + aspect-ratio: 1536 / 969; +} + +.stage.is-dragging .dropzone { + background: var(--sel-bg); + border-color: var(--text); +} + +.stage.is-dragging .card-wrap { + outline: 2px dashed var(--sel-line); + outline-offset: 6px; + border-radius: var(--card-radius); +} + +.dropzone { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 14px; + height: 100%; + padding: 16px; + border: 2px dashed var(--sel-line); + border-radius: 24px; + text-align: center; + cursor: pointer; + transition: background-color 0.12s, border-color 0.12s; +} + +.dropzone-icon { + width: 52px; + height: 52px; + color: var(--sel-line); + stroke-width: 1.5; +} + +.dropzone-title { + margin: 0; + font-size: 1.35rem; + font-weight: 700; + line-height: 1.2; +} + +.dropzone-hint { + margin: 0; + color: var(--muted); + font-size: 0.95rem; +} + +.card-wrap { + width: 100%; +} + +.card-canvas { + display: block; + width: 100%; + height: auto; + border-radius: var(--card-radius); + background: #1b2738; + box-shadow: 0 10px 34px rgba(0, 0, 0, 0.45); + cursor: grab; + touch-action: none; + user-select: none; + -webkit-user-select: none; +} + +.card-canvas.is-panning { + cursor: grabbing; +} + +.zoom-row { + display: flex; + align-items: center; + gap: 12px; +} + +.zoom-label { + color: var(--muted); + font-size: 0.95rem; +} + +.zoom-input { + flex: 1; + min-width: 0; + accent-color: var(--sel-line); +} + +.zoom-value { + min-width: 3.5em; + color: var(--muted); + font-size: 0.95rem; + font-variant-numeric: tabular-nums; + text-align: right; +} + +.msg { + margin: 0; + font-size: 0.95rem; +} + +.msg:empty { + display: none; +} + +.msg-error { + color: #ff9a9d; +} + +.msg-warning { + color: #f2c66d; +} + +.actions { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.size-label { + color: var(--muted); + font-size: 0.95rem; +} + +.action-buttons { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 10px; +} + +.btn { + appearance: none; + min-height: 44px; + margin: 0; + padding: 0 20px; + border: 1px solid var(--line-strong); + border-radius: 12px; + background: transparent; + color: var(--text); + font: inherit; + font-weight: 600; + cursor: pointer; + transition: background-color 0.12s, border-color 0.12s; +} + +.btn:hover:not(:disabled) { + border-color: var(--sel-line); + background: var(--panel); +} + +.btn-primary { + border-color: var(--primary); + background: var(--primary); + font-weight: 700; +} + +.btn-primary:hover:not(:disabled) { + border-color: var(--primary-hover); + background: var(--primary-hover); +} + +.btn-large { + min-height: 52px; + padding: 0 28px; + font-size: 1.1rem; +} + +.btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.note { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + margin: 4px 0 0; + color: var(--muted); + font-size: 0.9rem; + text-align: center; +} + +.note-icon { + flex: none; + font-size: 1.1rem; +} + +/* Unter 900 px: Vorschau, Vorlagen als waagerechte Reihe, Optionen */ + +@media (max-width: 899px) { + .editor { + grid-template-columns: minmax(0, 1fr); + gap: 16px; + border: 0; + border-radius: 0; + } + + .editor-center, + .editor-side { + border: 1px solid var(--line); + border-radius: 20px; + } + + .editor-center { + order: 1; + padding: 16px; + } + + .editor-templates { + order: 2; + padding: 20px 16px; + } + + .editor-options { + order: 3; + padding: 20px 16px; + } + + .preview-inner { + position: static; + } + + .template-list { + flex-direction: row; + gap: 12px; + margin: 0 -16px; + padding: 0 16px 8px; + overflow-x: auto; + scroll-snap-type: x proximity; + scroll-padding-inline: 16px; + } + + .template-tile { + flex: 0 0 200px; + scroll-snap-align: start; + } + + .action-buttons { + flex: 1 1 100%; + justify-content: stretch; + } + + .action-buttons .btn { + flex: 1 1 auto; + } + + .dropzone-title { + font-size: 1.15rem; + } +} + +@media (prefers-reduced-motion: reduce) { + .tile, + .pill-face, + .dropzone, + .btn { + transition: none; + } +} diff --git a/web/src/editor/options-panel.ts b/web/src/editor/options-panel.ts new file mode 100644 index 0000000..2f8d42a --- /dev/null +++ b/web/src/editor/options-panel.ts @@ -0,0 +1,96 @@ +// Rechte Spalte „Individuell gestalten“: Kartennetzwerk, Details (Hell/Dunkel) und Logo. +import type { BrandId, CardConfig, DetailsTone, NetworkId } from '../card/config'; +import { BRAND_MARKS, NETWORK_MARKS, tintSvg, type MarkDef } from '../card/marks'; +import { el, icon } from './dom'; + +export interface OptionsHandlers { + onNetwork(id: NetworkId): void; + onDetails(tone: DetailsTone): void; + onBrand(id: BrandId): void; +} + +export interface OptionsPanel { + el: HTMLElement; + select(config: CardConfig): void; +} + +const TONES: readonly { id: DetailsTone; label: string }[] = [ + { id: 'light', label: 'Hell' }, + { id: 'dark', label: 'Dunkel' }, +]; + +/** Logo der Kachel als Bild: einfarbige Logos in Weiß (currentColor hätte im die Farbe Schwarz). */ +function markSrc(def: MarkDef): string { + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(tintSvg(def.svg, 'light'))}`; +} + +function markTile(def: MarkDef, onClick: () => void) { + const name = `${def.group} – ${def.label}`; + const button = el( + 'button', + { type: 'button', class: 'tile mark-tile', 'aria-pressed': 'false', 'aria-label': name, title: name }, + el('img', { class: 'mark-img', src: markSrc(def), alt: '', draggable: 'false' }), + el('span', { class: 'mark-label' }, def.label), + ); + button.addEventListener('click', onClick); + return button; +} + +function heading(id: string, text: string, level: 'h2' | 'h3') { + return el(level, { id, class: level === 'h2' ? 'panel-title' : 'panel-subtitle' }, text); +} + +export function createOptionsPanel(handlers: OptionsHandlers): OptionsPanel { + const networkTiles = NETWORK_MARKS.map((def) => ({ + id: def.id as NetworkId, + button: markTile(def, () => handlers.onNetwork(def.id as NetworkId)), + })); + + const brandTiles: { id: BrandId; button: HTMLButtonElement }[] = BRAND_MARKS.map((def) => ({ + id: def.id as BrandId, + button: markTile(def, () => handlers.onBrand(def.id as BrandId)), + })); + const none = el( + 'button', + { type: 'button', class: 'tile mark-tile', 'aria-pressed': 'false', 'aria-label': 'Kein Logo', title: 'Kein Logo' }, + icon('ban', 'mark-none'), + el('span', { class: 'mark-label' }, 'Kein Logo'), + ); + none.addEventListener('click', () => handlers.onBrand('none')); + brandTiles.push({ id: 'none', button: none }); + + const toneInputs = TONES.map((t) => { + const input = el('input', { type: 'radio', name: 'card-details', value: t.id, class: 'pill-input' }); + input.addEventListener('change', () => { + if (input.checked) handlers.onDetails(t.id); + }); + const label = el( + 'label', + { class: 'pill' }, + input, + el('span', { class: 'pill-face' }, el('span', { class: `swatch swatch-${t.id}` }), t.label), + ); + return { id: t.id, input, label }; + }); + + const section = el( + 'section', + { class: 'editor-side editor-options' }, + heading('editor-options-heading', 'Individuell gestalten', 'h2'), + heading('editor-network-heading', 'Kartennetzwerk', 'h3'), + el('div', { class: 'mark-grid', role: 'group', 'aria-labelledby': 'editor-network-heading' }, ...networkTiles.map((t) => t.button)), + heading('editor-details-heading', 'Details', 'h3'), + el('div', { class: 'pill-row', role: 'radiogroup', 'aria-labelledby': 'editor-details-heading' }, ...toneInputs.map((t) => t.label)), + heading('editor-brand-heading', 'Logo', 'h3'), + el('div', { class: 'mark-grid', role: 'group', 'aria-labelledby': 'editor-brand-heading' }, ...brandTiles.map((t) => t.button)), + ); + + return { + el: section, + select(config) { + for (const t of networkTiles) t.button.setAttribute('aria-pressed', String(t.id === config.network)); + for (const t of brandTiles) t.button.setAttribute('aria-pressed', String(t.id === config.brand)); + for (const t of toneInputs) t.input.checked = t.id === config.details; + }, + }; +} diff --git a/web/src/editor/preview.ts b/web/src/editor/preview.ts new file mode 100644 index 0000000..577b93d --- /dev/null +++ b/web/src/editor/preview.ts @@ -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 = { + 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); + }, + }; +} diff --git a/web/src/editor/state.test.ts b/web/src/editor/state.test.ts new file mode 100644 index 0000000..3119940 --- /dev/null +++ b/web/src/editor/state.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it, vi } from 'vitest'; +import { DEFAULT_CONFIG, DEFAULT_CROP } from '../card/config'; +import type { CardImage } from '../card/render'; +import { createStore, initialEditorState, setImage } from './state'; + +const fakeImage = (w = 2000, h = 1000): CardImage => ({ source: {} as CanvasImageSource, w, h }); +const fakeBlob = () => new Blob(['x'], { type: 'image/png' }); + +describe('initialEditorState', () => { + it('startet ohne Bild mit der Standardkonfiguration', () => { + const s = initialEditorState(); + expect(s.config).toEqual(DEFAULT_CONFIG); + expect(s.image).toBeNull(); + expect(s.imageBlob).toBeNull(); + expect(s.imageChanged).toBe(false); + expect(s.saved).toBeNull(); + expect(s.cardName).toBe('Meine Karte'); + expect(s.saving).toBe(false); + }); + + it('liefert bei jedem Aufruf frische Objekte, die geteilten Standards bleiben unberührt', () => { + const a = initialEditorState(); + const b = initialEditorState(); + expect(a.config).not.toBe(DEFAULT_CONFIG); + expect(a.config.crop).not.toBe(DEFAULT_CONFIG.crop); + expect(a.config.crop).not.toBe(b.config.crop); + a.config.crop.x = 99; + a.config.template = 'image'; + expect(b.config.crop.x).toBe(0); + expect(DEFAULT_CONFIG.crop.x).toBe(0); + expect(DEFAULT_CONFIG.template).toBe('signature'); + }); +}); + +describe('createStore', () => { + it('get liefert den Anfangszustand', () => { + const initial = initialEditorState(); + expect(createStore(initial).get()).toEqual(initial); + }); + + it('set mischt den Patch ein und benachrichtigt mit dem neuen Zustand', () => { + const store = createStore(initialEditorState()); + const fn = vi.fn(); + store.subscribe(fn); + store.set({ cardName: 'Urlaub', saving: true }); + expect(store.get().cardName).toBe('Urlaub'); + expect(store.get().saving).toBe(true); + expect(store.get().config).toEqual(DEFAULT_CONFIG); + expect(fn).toHaveBeenCalledTimes(1); + expect(fn).toHaveBeenCalledWith(store.get()); + }); + + it('subscribe ruft nicht sofort auf', () => { + const store = createStore(initialEditorState()); + const fn = vi.fn(); + store.subscribe(fn); + expect(fn).not.toHaveBeenCalled(); + }); + + it('nach unsubscribe gibt es keine Aufrufe mehr', () => { + const store = createStore(initialEditorState()); + const fn = vi.fn(); + const unsubscribe = store.subscribe(fn); + store.set({ cardName: 'a' }); + unsubscribe(); + store.set({ cardName: 'b' }); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('mehrere Abonnenten werden alle benachrichtigt; Abmelden mitten im Verteilen stört die anderen nicht', () => { + const store = createStore(initialEditorState()); + const second = vi.fn(); + const first = vi.fn(() => unsubscribeFirst()); + const unsubscribeFirst = store.subscribe(first); + store.subscribe(second); + store.set({ cardName: 'a' }); + store.set({ cardName: 'b' }); + expect(first).toHaveBeenCalledTimes(1); + expect(second).toHaveBeenCalledTimes(2); + }); +}); + +describe('setImage', () => { + it('setzt Bild und Blob, markiert das Bild als geändert und setzt den Ausschnitt zurück', () => { + const store = createStore(initialEditorState()); + store.set({ config: { ...store.get().config, crop: { x: 300, y: 0, zoom: 2 } } }); + const image = fakeImage(); + const blob = fakeBlob(); + setImage(store, image, blob); + const s = store.get(); + expect(s.image).toBe(image); + expect(s.imageBlob).toBe(blob); + expect(s.imageChanged).toBe(true); + expect(s.config.crop).toEqual(DEFAULT_CROP); + }); + + it('behält die übrigen Einstellungen', () => { + const store = createStore(initialEditorState()); + store.set({ config: { ...store.get().config, template: 'classic', details: 'dark', crop: { x: 5, y: 6, zoom: 3 } } }); + setImage(store, fakeImage(), fakeBlob()); + expect(store.get().config.template).toBe('classic'); + expect(store.get().config.details).toBe('dark'); + }); + + it('kopiert den Standard-Ausschnitt statt ihn zu teilen', () => { + const store = createStore(initialEditorState()); + setImage(store, fakeImage(), fakeBlob()); + expect(store.get().config.crop).not.toBe(DEFAULT_CROP); + store.get().config.crop.x = 123; + expect(DEFAULT_CROP.x).toBe(0); + }); + + it('benachrichtigt die Abonnenten genau einmal', () => { + const store = createStore(initialEditorState()); + const fn = vi.fn(); + store.subscribe(fn); + setImage(store, fakeImage(), fakeBlob()); + expect(fn).toHaveBeenCalledTimes(1); + }); +}); diff --git a/web/src/editor/state.ts b/web/src/editor/state.ts new file mode 100644 index 0000000..2cbbe4e --- /dev/null +++ b/web/src/editor/state.ts @@ -0,0 +1,71 @@ +// Editor-Zustand: ein kleiner Store (get/set/subscribe) ohne Framework. +// Der Store wird einmal in main.ts erzeugt und überlebt Seitenwechsel. +import { DEFAULT_CONFIG, DEFAULT_CROP, type CardConfig } from '../card/config'; +import type { CardImage } from '../card/render'; + +export interface SavedCardRef { + id: string; + name: string; + imageFileId: string; + previewFileId: string; + updatedAt: string; +} + +export interface EditorState { + config: CardConfig; + image: CardImage | null; + imageBlob: Blob | null; + /** true, sobald ein neues Bild gewählt wurde, das noch nicht gespeichert ist. */ + imageChanged: boolean; + saved: SavedCardRef | null; + cardName: string; + saving: boolean; +} + +type Listener = (state: EditorState) => void; + +export function createStore(initial: EditorState) { + let state = initial; + const listeners = new Set(); + return { + get(): EditorState { + return state; + }, + set(patch: Partial): void { + state = { ...state, ...patch }; + // Kopie, damit sich ein Abonnent beim Verteilen abmelden darf. + for (const fn of [...listeners]) fn(state); + }, + subscribe(fn: Listener): () => void { + listeners.add(fn); + return () => { + listeners.delete(fn); + }; + }, + }; +} + +export type EditorStore = ReturnType; + +/** Frischer Anfangszustand; DEFAULT_CONFIG und DEFAULT_CROP werden kopiert, nie geteilt. */ +export function initialEditorState(): EditorState { + return { + config: structuredClone(DEFAULT_CONFIG), + image: null, + imageBlob: null, + imageChanged: false, + saved: null, + cardName: 'Meine Karte', + saving: false, + }; +} + +/** Übernimmt ein neues Bild und setzt den Ausschnitt zurück (der alte passt nicht zum neuen Bild). */ +export function setImage(store: EditorStore, image: CardImage, blob: Blob): void { + store.set({ + image, + imageBlob: blob, + imageChanged: true, + config: { ...store.get().config, crop: { ...DEFAULT_CROP } }, + }); +} diff --git a/web/src/editor/templates-panel.ts b/web/src/editor/templates-panel.ts new file mode 100644 index 0000000..045c3aa --- /dev/null +++ b/web/src/editor/templates-panel.ts @@ -0,0 +1,56 @@ +// Linke Spalte „Vorlage“: vier Kacheln mit Live-Miniatur der Karte. +import type { CardConfig, TemplateId } from '../card/config'; +import { drawCard, type CardAssets } from '../card/render'; +import { el, icon } from './dom'; + +export const THUMB_W = 384; +export const THUMB_H = 242; + +const TEMPLATES: readonly { id: TemplateId; title: string; subtitle: string }[] = [ + { id: 'image', title: 'Nur Bild', subtitle: 'Bild unverändert lassen' }, + { id: 'minimal', title: 'Minimalistisch', subtitle: 'Dein Bild steht im Mittelpunkt' }, + { id: 'classic', title: 'Klassisch', subtitle: 'Die vertrauten Kartendetails' }, + { id: 'signature', title: 'Signature', subtitle: 'Ein bisschen mehr Persönlichkeit' }, +]; + +export interface TemplatesPanel { + el: HTMLElement; + /** Markiert die gewählte Kachel (günstig, bei jeder Zustandsänderung). */ + select(template: TemplateId): void; + /** Zeichnet alle vier Miniaturen mit der aktuellen Konfiguration, jede mit ihrer eigenen Vorlage. */ + draw(config: CardConfig, assets: CardAssets): void; +} + +export function createTemplatesPanel(onSelect: (template: TemplateId) => void): TemplatesPanel { + const tiles = TEMPLATES.map((t) => { + const canvas = el('canvas', { class: 'template-thumb', width: THUMB_W, height: THUMB_H, 'aria-hidden': 'true' }); + const button = el( + 'button', + { type: 'button', class: 'tile template-tile', 'aria-pressed': 'false' }, + canvas, + el('span', { class: 'template-title' }, t.title), + el('span', { class: 'template-sub' }, t.subtitle), + icon('check', 'template-check'), + ); + button.addEventListener('click', () => onSelect(t.id)); + return { id: t.id, canvas, button }; + }); + + const heading = el('h2', { id: 'editor-templates-heading', class: 'panel-title' }, 'Vorlage'); + const list = el( + 'div', + { class: 'template-list', role: 'group', 'aria-labelledby': 'editor-templates-heading' }, + ...tiles.map((t) => t.button), + ); + const section = el('section', { class: 'editor-side editor-templates' }, heading, list); + + return { + el: section, + select(template) { + for (const t of tiles) t.button.setAttribute('aria-pressed', String(t.id === template)); + }, + draw(config, assets) { + for (const t of tiles) drawCard(t.canvas, { ...config, template: t.id }, assets); + }, + }; +} diff --git a/web/src/editor/upload.test.ts b/web/src/editor/upload.test.ts new file mode 100644 index 0000000..f3a01ab --- /dev/null +++ b/web/src/editor/upload.test.ts @@ -0,0 +1,155 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { decodeImage, validateImageFile } from './upload'; + +const TYPE_MESSAGE = 'Bitte ein PNG-, JPG- oder WebP-Bild wählen.'; +const SIZE_MESSAGE = 'Das Bild ist größer als 5 MiB.'; +const READ_MESSAGE = 'Das Bild konnte nicht gelesen werden.'; + +describe('validateImageFile', () => { + it('nimmt ein kleines PNG an', () => { + expect(validateImageFile({ type: 'image/png', size: 1000 })).toEqual({ ok: true }); + }); + + it('nimmt JPEG und WebP an', () => { + expect(validateImageFile({ type: 'image/jpeg', size: 1000 })).toEqual({ ok: true }); + expect(validateImageFile({ type: 'image/webp', size: 1000 })).toEqual({ ok: true }); + }); + + it('lehnt HEIC mit der Typ-Meldung ab', () => { + expect(validateImageFile({ type: 'image/heic', size: 1000 })).toEqual({ ok: false, message: TYPE_MESSAGE }); + }); + + it('lehnt einen leeren Typ mit der Typ-Meldung ab', () => { + expect(validateImageFile({ type: '', size: 1000 })).toEqual({ ok: false, message: TYPE_MESSAGE }); + }); + + it('lehnt GIF, SVG und andere Dateien ab', () => { + for (const type of ['image/gif', 'image/svg+xml', 'application/pdf', 'text/plain']) { + expect(validateImageFile({ type, size: 1000 })).toEqual({ ok: false, message: TYPE_MESSAGE }); + } + }); + + it('lehnt ein PNG mit 5 242 881 Byte als zu groß ab', () => { + expect(validateImageFile({ type: 'image/png', size: 5_242_881 })).toEqual({ ok: false, message: SIZE_MESSAGE }); + }); + + it('nimmt genau 5 242 880 Byte an', () => { + expect(validateImageFile({ type: 'image/png', size: 5_242_880 })).toEqual({ ok: true }); + }); + + it('meldet bei falschem Typ und zu großer Datei den Typ', () => { + expect(validateImageFile({ type: 'image/heic', size: 9_000_000 })).toEqual({ ok: false, message: TYPE_MESSAGE }); + }); + + it('lehnt eine leere Datei ab', () => { + expect(validateImageFile({ type: 'image/png', size: 0 })).toEqual({ ok: false, message: READ_MESSAGE }); + }); +}); + +describe('decodeImage', () => { + interface Behaviour { + decode: 'ok' | 'fail' | 'missing'; + load: 'ok' | 'fail'; + width: number; + height: number; + } + let behaviour: Behaviour; + let created: FakeImage[]; + let createObjectURL: ReturnType; + let revokeObjectURL: ReturnType; + + class FakeImage { + naturalWidth = 0; + naturalHeight = 0; + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + decode?: () => Promise; + #src = ''; + + constructor() { + created.push(this); + if (behaviour.decode !== 'missing') { + this.decode = () => + behaviour.decode === 'ok' ? this.#settle() : Promise.reject(new DOMException('kaputt', 'EncodingError')); + } + } + + #settle(): Promise { + this.naturalWidth = behaviour.width; + this.naturalHeight = behaviour.height; + return Promise.resolve(); + } + + set src(value: string) { + this.#src = value; + queueMicrotask(() => { + if (behaviour.load === 'ok') { + this.naturalWidth = behaviour.width; + this.naturalHeight = behaviour.height; + this.onload?.(); + } else { + this.onerror?.(); + } + }); + } + + get src(): string { + return this.#src; + } + } + + beforeEach(() => { + behaviour = { decode: 'ok', load: 'ok', width: 4000, height: 3000 }; + created = []; + createObjectURL = vi.fn(() => 'blob:test-url'); + revokeObjectURL = vi.fn(); + vi.stubGlobal('Image', FakeImage); + vi.stubGlobal('URL', Object.assign(URL, { createObjectURL, revokeObjectURL })); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('liefert Bildelement und Maße und gibt die Objekt-URL danach frei', async () => { + const blob = new Blob(['x'], { type: 'image/png' }); + const image = await decodeImage(blob); + expect(createObjectURL).toHaveBeenCalledWith(blob); + expect(image.source).toBe(created[0]); + expect(image.w).toBe(4000); + expect(image.h).toBe(3000); + expect(created[0].src).toBe('blob:test-url'); + expect(revokeObjectURL).toHaveBeenCalledTimes(1); + expect(revokeObjectURL).toHaveBeenCalledWith('blob:test-url'); + }); + + it('meldet einen Lesefehler, wenn decode und load scheitern, und gibt die URL trotzdem frei', async () => { + behaviour = { ...behaviour, decode: 'fail', load: 'fail' }; + await expect(decodeImage(new Blob(['x']))).rejects.toThrow(READ_MESSAGE); + expect(revokeObjectURL).toHaveBeenCalledWith('blob:test-url'); + }); + + it('meldet einen Lesefehler, wenn die Maße 0 sind', async () => { + behaviour = { ...behaviour, width: 0, height: 0 }; + await expect(decodeImage(new Blob(['x']))).rejects.toThrow(READ_MESSAGE); + expect(revokeObjectURL).toHaveBeenCalledTimes(1); + }); + + it('fällt ohne decode() auf load zurück', async () => { + behaviour = { ...behaviour, decode: 'missing' }; + const image = await decodeImage(new Blob(['x'])); + expect(image.w).toBe(4000); + expect(image.h).toBe(3000); + }); + + it('nimmt das Ergebnis von load, wenn decode() scheitert, das Bild aber lädt', async () => { + behaviour = { ...behaviour, decode: 'fail', load: 'ok' }; + const image = await decodeImage(new Blob(['x'])); + expect(image.w).toBe(4000); + }); + + it('meldet einen Lesefehler, wenn ohne decode() das Laden scheitert', async () => { + behaviour = { ...behaviour, decode: 'missing', load: 'fail' }; + await expect(decodeImage(new Blob(['x']))).rejects.toThrow(READ_MESSAGE); + }); +}); diff --git a/web/src/editor/upload.ts b/web/src/editor/upload.ts new file mode 100644 index 0000000..02555b5 --- /dev/null +++ b/web/src/editor/upload.ts @@ -0,0 +1,54 @@ +// Bild-Upload: Datei prüfen und in ein Bild für den Canvas dekodieren. Kein Netzwerk, kein Appwrite. +import type { CardImage } from '../card/render'; + +export const ACCEPTED_TYPES = ['image/png', 'image/jpeg', 'image/webp'] as const; +export const MAX_IMAGE_BYTES = 5_242_880; + +export const TYPE_MESSAGE = 'Bitte ein PNG-, JPG- oder WebP-Bild wählen.'; +export const SIZE_MESSAGE = 'Das Bild ist größer als 5 MiB.'; +export const READ_MESSAGE = 'Das Bild konnte nicht gelesen werden.'; + +export type FileCheck = { ok: true } | { ok: false; message: string }; + +/** Prüft Typ und Größe, bevor das Bild dekodiert wird. Der Typ hat Vorrang vor der Größe. */ +export function validateImageFile(f: { type: string; size: number }): FileCheck { + if (!(ACCEPTED_TYPES as readonly string[]).includes(f.type)) return { ok: false, message: TYPE_MESSAGE }; + if (f.size > MAX_IMAGE_BYTES) return { ok: false, message: SIZE_MESSAGE }; + if (f.size <= 0) return { ok: false, message: READ_MESSAGE }; + return { ok: true }; +} + +/** Wartet, bis das Bild fertig dekodiert ist; ohne decode() oder bei dessen Fehler gilt das load-Ereignis. */ +async function whenDecoded(img: HTMLImageElement, loaded: Promise): Promise { + if (typeof img.decode === 'function') { + try { + await img.decode(); + return; + } catch { + // Manche Browser lehnen decode() ab, obwohl das Bild lädt; dann entscheidet load/error. + } + } + await loaded; +} + +/** Dekodiert eine Bilddatei. `source` ist das HTMLImageElement (für drawImage), w/h die Originalmaße. */ +export async function decodeImage(blob: Blob): Promise { + const url = URL.createObjectURL(blob); + try { + const img = new Image(); + const loaded = new Promise((resolve, reject) => { + img.onload = () => resolve(); + img.onerror = () => reject(new Error(READ_MESSAGE)); + }); + // Wird nur bei Bedarf abgewartet; ohne das hier meldet der Browser eine unbehandelte Ablehnung. + loaded.catch(() => undefined); + img.src = url; + await whenDecoded(img, loaded); + if (!img.naturalWidth || !img.naturalHeight) throw new Error(READ_MESSAGE); + return { source: img, w: img.naturalWidth, h: img.naturalHeight }; + } catch { + throw new Error(READ_MESSAGE); + } finally { + URL.revokeObjectURL(url); + } +} diff --git a/web/src/main.ts b/web/src/main.ts index 169670f..8e894ed 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -1,15 +1,23 @@ import '@fontsource-variable/nunito-sans'; import './styles.css'; import { startRouter, type RouteId } from './pages/router'; +import { mountEditor } from './editor/editor-page'; +import { createStore, initialEditorState } from './editor/state'; -// Platzhalter, bis die Seiten aus den Folgeaufgaben (Editor, Karten, Passwort) angebunden sind. -const PLACEHOLDER_TITLES: Record = { - editor: 'Editor', +// Platzhalter, bis die Seiten aus den Folgeaufgaben (Karten, Passwort) angebunden sind. +const PLACEHOLDER_TITLES: Record, string> = { cards: 'Meine Karten', reset: 'Passwort neu setzen', }; +// Der Editor-Zustand lebt außerhalb der Seiten und überlebt Seitenwechsel. +const editorStore = createStore(initialEditorState()); + startRouter((route, root) => { + if (route === 'editor') { + // Speichern wird mit den Konten (spätere Aufgabe) angebunden; bis dahin passiert nichts. + return mountEditor(root, editorStore, { onSave: () => {} }); + } const heading = document.createElement('h1'); heading.textContent = PLACEHOLDER_TITLES[route]; root.replaceChildren(heading);