feat(editor): Editor-Oberfläche mit Upload, Vorschau und Download
Store, Upload-Prüfung/Dekodierung und Download-Dateiname mit Tests; drei Spalten (Vorlagen mit Live-Miniatur, Vorschau mit Ziehen/Zoom, Optionen), einspaltig unter 900 px. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9a46342e0c
commit
30f4ddc0f4
13 files changed
+1703
-3
No files matched your search
@@ -0,0 +1,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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 } },
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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<RouteId, string> = {
|
||||
editor: 'Editor',
|
||||
// Platzhalter, bis die Seiten aus den Folgeaufgaben (Karten, Passwort) angebunden sind.
|
||||
const PLACEHOLDER_TITLES: Record<Exclude<RouteId, 'editor'>, 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);
|
||||
|
||||
Reference in new issue
Block a user