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 '@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);
|
||||||
|
|||||||
Reference in new issue
Block a user