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

+41
View File
@@ -0,0 +1,41 @@
// Kleine DOM-Helfer für die Editor-Oberfläche (kein Framework).
type Attrs = Record<string, string | number | boolean | null | undefined>;
/** Erzeugt ein Element; `true` setzt ein leeres Attribut, `false`/null/undefined lässt es weg. */
export function el<K extends keyof HTMLElementTagNameMap>(
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:
'<path d="M16 5h6"/><path d="M19 2v6"/><path d="M21 11.5V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7.5"/>' +
'<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/><circle cx="9" cy="9" r="2"/>',
lock: '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
check: '<path d="M20 6 9 17l-5-5"/>',
ban: '<circle cx="12" cy="12" r="9"/><path d="m5.6 5.6 12.8 12.8"/>',
} 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;
}
+91
View File
@@ -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<typeof vi.fn>;
let revokeObjectURL: ReturnType<typeof vi.fn>;
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');
});
});
+51
View File
@@ -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<string, string> = { ä: '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-<name>.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 `<a download>` 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);
}
+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;
};
}
+539
View File
@@ -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;
}
}
+96
View File
@@ -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 <img> 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;
},
};
}
+243
View File
@@ -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);
},
};
}
+120
View File
@@ -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);
});
});
+71
View File
@@ -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<Listener>();
return {
get(): EditorState {
return state;
},
set(patch: Partial<EditorState>): 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<typeof createStore>;
/** 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 } },
});
}
+56
View File
@@ -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);
},
};
}
+155
View File
@@ -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<typeof vi.fn>;
let revokeObjectURL: ReturnType<typeof vi.fn>;
class FakeImage {
naturalWidth = 0;
naturalHeight = 0;
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
decode?: () => Promise<void>;
#src = '';
constructor() {
created.push(this);
if (behaviour.decode !== 'missing') {
this.decode = () =>
behaviour.decode === 'ok' ? this.#settle() : Promise.reject(new DOMException('kaputt', 'EncodingError'));
}
}
#settle(): Promise<void> {
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);
});
});
+54
View File
@@ -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<void>): Promise<void> {
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<CardImage> {
const url = URL.createObjectURL(blob);
try {
const img = new Image();
const loaded = new Promise<void>((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);
}
}
+11 -3
View File
@@ -1,15 +1,23 @@
import '@fontsource-variable/nunito-sans'; import '@fontsource-variable/nunito-sans';
import './styles.css'; import './styles.css';
import { startRouter, type RouteId } from './pages/router'; 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. // Platzhalter, bis die Seiten aus den Folgeaufgaben (Karten, Passwort) angebunden sind.
const PLACEHOLDER_TITLES: Record<RouteId, string> = { const PLACEHOLDER_TITLES: Record<Exclude<RouteId, 'editor'>, string> = {
editor: 'Editor',
cards: 'Meine Karten', cards: 'Meine Karten',
reset: 'Passwort neu setzen', reset: 'Passwort neu setzen',
}; };
// Der Editor-Zustand lebt außerhalb der Seiten und überlebt Seitenwechsel.
const editorStore = createStore(initialEditorState());
startRouter((route, root) => { 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'); const heading = document.createElement('h1');
heading.textContent = PLACEHOLDER_TITLES[route]; heading.textContent = PLACEHOLDER_TITLES[route];
root.replaceChildren(heading); root.replaceChildren(heading);