fix(editor): Strg/Cmd/Alt-Tasten frei, 40-Megapixel-Grenze, Umbenennen-Test

- Vorschau: Tastenkürzel mit Strg/Cmd/Alt gehören dem Browser (Seitenzoom).
- decodeImage lehnt Bilder über 40 Megapixel nach dem Laden, vor dem
  Dekodieren ab (Meldung "Das Bild ist zu groß (max. 40 Megapixel).").
- Test, dass das Umbenennen-Feld den Namen markiert (tat der Code schon).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-04 11:11:06 +00:00
1 parent 144181f2c9
commit 9c4e80ec9a
8 files changed
+144 -8

No files matched your search

+10
View File
@@ -620,6 +620,16 @@ describe('Karte öffnen (/?karte=<id>)', () => {
expect(store.get().saved).toBeNull(); expect(store.get().saved).toBeNull();
}); });
it('ein Bild über 40 Megapixel meldet die Größen-Meldung, ohne die Karte zu übernehmen', async () => {
open('k1');
mocks.get.mockResolvedValue(savedCard());
mocks.loadImage.mockResolvedValue(png());
mocks.decodeImage.mockRejectedValue(new Error('Das Bild ist zu groß (max. 40 Megapixel).'));
mount();
await vi.waitFor(() => expect(errorText()).toBe('Das Bild ist zu groß (max. 40 Megapixel).'));
expect(store.get().saved).toBeNull();
});
it('verlässt der Nutzer die Seite während des Ladens, wird nichts mehr übernommen', async () => { it('verlässt der Nutzer die Seite während des Ladens, wird nichts mehr übernommen', async () => {
open('k1'); open('k1');
let finish!: (c: SavedCard) => void; let finish!: (c: SavedCard) => void;
+3 -2
View File
@@ -18,7 +18,7 @@ import { attachFileDrop, createPreview } from './preview';
import { createSaveAction, type SaveStatus } from './save'; import { createSaveAction, type SaveStatus } from './save';
import { initialEditorState, setImage, type EditorState, type EditorStore, type SavedCardRef } from './state'; import { initialEditorState, setImage, type EditorState, type EditorStore, type SavedCardRef } from './state';
import { createTemplatesPanel } from './templates-panel'; import { createTemplatesPanel } from './templates-panel';
import { READ_MESSAGE, decodeImage, validateImageFile } from './upload'; import { PIXELS_MESSAGE, READ_MESSAGE, decodeImage, validateImageFile } from './upload';
const LOGO_ERROR = 'Die Logos konnten nicht geladen werden.'; const LOGO_ERROR = 'Die Logos konnten nicht geladen werden.';
const EXPORT_ERROR = 'Das PNG konnte nicht erzeugt werden.'; const EXPORT_ERROR = 'Das PNG konnte nicht erzeugt werden.';
@@ -272,7 +272,8 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
if (s.image === null && s.saved === null) store.set(initialEditorState()); if (s.image === null && s.saved === null) store.set(initialEditorState());
preview.setError(errorMessage(err, 'load')); preview.setError(errorMessage(err, 'load'));
} else { } else {
preview.setError(err instanceof Error && err.message === READ_MESSAGE ? READ_MESSAGE : errorMessage(err, 'load')); const imageProblem = err instanceof Error && (err.message === READ_MESSAGE || err.message === PIXELS_MESSAGE);
preview.setError(imageProblem ? err.message : errorMessage(err, 'load'));
} }
} finally { } finally {
if (!stale()) preview.setNotice(null); if (!stale()) preview.setNotice(null);
+56
View File
@@ -0,0 +1,56 @@
// Tastatursteuerung der Kartenvorschau (Verschieben, Zoom).
import { describe, expect, it, vi } from 'vitest';
import { createPreview, type PreviewHandlers } from './preview';
function setup() {
const handlers = {
onFile: vi.fn(),
onPan: vi.fn(),
onZoom: vi.fn(),
onDownload: vi.fn(),
onSave: vi.fn(),
onNewCard: vi.fn(),
} satisfies PreviewHandlers;
const preview = createPreview(handlers);
preview.sync({ hasImage: true, zoom: 1, saving: false, saved: false, boundName: null, exporting: false });
const press = (key: string, init: KeyboardEventInit = {}) => {
const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init });
preview.canvas.dispatchEvent(event);
return event;
};
return { handlers, press };
}
describe('Vorschau: Tastatur', () => {
it('Pfeiltasten verschieben, Umschalt vergrößert den Schritt', () => {
const { handlers, press } = setup();
expect(press('ArrowLeft').defaultPrevented).toBe(true);
expect(handlers.onPan).toHaveBeenLastCalledWith(-24, 0);
press('ArrowDown', { shiftKey: true });
expect(handlers.onPan).toHaveBeenLastCalledWith(0, 96);
});
it('+, = und - zoomen um 0,1', () => {
const { handlers, press } = setup();
press('+');
expect(handlers.onZoom).toHaveBeenLastCalledWith(expect.closeTo(1.1, 5));
press('=');
expect(handlers.onZoom).toHaveBeenLastCalledWith(expect.closeTo(1.1, 5));
press('-');
expect(handlers.onZoom).toHaveBeenLastCalledWith(expect.closeTo(0.9, 5));
});
it.each([
['Strg', { ctrlKey: true }],
['Cmd', { metaKey: true }],
['Alt', { altKey: true }],
])('%s + Taste gehört dem Browser (Browser-Zoom, Verlauf): nichts wird gesteuert oder verschluckt', (_name, mod) => {
const { handlers, press } = setup();
for (const key of ['+', '=', '-', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown']) {
const event = press(key, mod);
expect(event.defaultPrevented, `${key} mit ${_name}`).toBe(false);
}
expect(handlers.onPan).not.toHaveBeenCalled();
expect(handlers.onZoom).not.toHaveBeenCalled();
});
});
+2
View File
@@ -151,6 +151,8 @@ export function createPreview(handlers: PreviewHandlers): Preview {
canvas.addEventListener('pointercancel', endPan); canvas.addEventListener('pointercancel', endPan);
canvas.addEventListener('keydown', (e) => { canvas.addEventListener('keydown', (e) => {
// Mit Strg/Cmd/Alt gehören die Tasten dem Browser (Seitenzoom Strg + und Strg -, Verlauf Alt + Pfeil).
if (e.ctrlKey || e.metaKey || e.altKey) return;
const step = e.shiftKey ? KEY_STEP_BIG : KEY_STEP; const step = e.shiftKey ? KEY_STEP_BIG : KEY_STEP;
const moves: Record<string, [number, number]> = { const moves: Record<string, [number, number]> = {
ArrowLeft: [-step, 0], ArrowLeft: [-step, 0],
+37 -2
View File
@@ -4,6 +4,7 @@ import { decodeImage, validateImageFile } from './upload';
const TYPE_MESSAGE = 'Bitte ein PNG-, JPG- oder WebP-Bild wählen.'; const TYPE_MESSAGE = 'Bitte ein PNG-, JPG- oder WebP-Bild wählen.';
const SIZE_MESSAGE = 'Das Bild ist größer als 5 MiB.'; const SIZE_MESSAGE = 'Das Bild ist größer als 5 MiB.';
const READ_MESSAGE = 'Das Bild konnte nicht gelesen werden.'; const READ_MESSAGE = 'Das Bild konnte nicht gelesen werden.';
const PIXELS_MESSAGE = 'Das Bild ist zu groß (max. 40 Megapixel).';
describe('validateImageFile', () => { describe('validateImageFile', () => {
it('nimmt ein kleines PNG an', () => { it('nimmt ein kleines PNG an', () => {
@@ -55,6 +56,7 @@ describe('decodeImage', () => {
} }
let behaviour: Behaviour; let behaviour: Behaviour;
let created: FakeImage[]; let created: FakeImage[];
let decodeCalls: number;
let createObjectURL: ReturnType<typeof vi.fn>; let createObjectURL: ReturnType<typeof vi.fn>;
let revokeObjectURL: ReturnType<typeof vi.fn>; let revokeObjectURL: ReturnType<typeof vi.fn>;
@@ -69,8 +71,10 @@ describe('decodeImage', () => {
constructor() { constructor() {
created.push(this); created.push(this);
if (behaviour.decode !== 'missing') { if (behaviour.decode !== 'missing') {
this.decode = () => this.decode = () => {
behaviour.decode === 'ok' ? this.#settle() : Promise.reject(new DOMException('kaputt', 'EncodingError')); decodeCalls += 1;
return behaviour.decode === 'ok' ? this.#settle() : Promise.reject(new DOMException('kaputt', 'EncodingError'));
};
} }
} }
@@ -101,6 +105,7 @@ describe('decodeImage', () => {
beforeEach(() => { beforeEach(() => {
behaviour = { decode: 'ok', load: 'ok', width: 4000, height: 3000 }; behaviour = { decode: 'ok', load: 'ok', width: 4000, height: 3000 };
created = []; created = [];
decodeCalls = 0;
createObjectURL = vi.fn(() => 'blob:test-url'); createObjectURL = vi.fn(() => 'blob:test-url');
revokeObjectURL = vi.fn(); revokeObjectURL = vi.fn();
vi.stubGlobal('Image', FakeImage); vi.stubGlobal('Image', FakeImage);
@@ -152,4 +157,34 @@ describe('decodeImage', () => {
behaviour = { ...behaviour, decode: 'missing', load: 'fail' }; behaviour = { ...behaviour, decode: 'missing', load: 'fail' };
await expect(decodeImage(new Blob(['x']))).rejects.toThrow(READ_MESSAGE); await expect(decodeImage(new Blob(['x']))).rejects.toThrow(READ_MESSAGE);
}); });
describe('Pixel-Obergrenze (40 Megapixel)', () => {
it('nimmt genau 40 000 000 Pixel an', async () => {
behaviour = { ...behaviour, width: 8000, height: 5000 };
const image = await decodeImage(new Blob(['x']));
expect(image.w * image.h).toBe(40_000_000);
});
it('lehnt 40 000 005 Pixel mit der Größen-Meldung ab', async () => {
behaviour = { ...behaviour, width: 8001, height: 5000 };
await expect(decodeImage(new Blob(['x']))).rejects.toThrow(PIXELS_MESSAGE);
});
it('lehnt ein extrem großes Bild ab, ohne es zu dekodieren, und gibt die URL frei', async () => {
behaviour = { ...behaviour, width: 20000, height: 20000 };
await expect(decodeImage(new Blob(['x']))).rejects.toThrow(PIXELS_MESSAGE);
expect(decodeCalls).toBe(0);
expect(revokeObjectURL).toHaveBeenCalledTimes(1);
});
it('prüft auch ohne decode() (nur load)', async () => {
behaviour = { ...behaviour, decode: 'missing', width: 9000, height: 9000 };
await expect(decodeImage(new Blob(['x']))).rejects.toThrow(PIXELS_MESSAGE);
});
it('ein normales Bild wird weiterhin dekodiert', async () => {
await decodeImage(new Blob(['x']));
expect(decodeCalls).toBe(1);
});
});
}); });
+9 -1
View File
@@ -3,10 +3,13 @@ import type { CardImage } from '../card/render';
export const ACCEPTED_TYPES = ['image/png', 'image/jpeg', 'image/webp'] as const; export const ACCEPTED_TYPES = ['image/png', 'image/jpeg', 'image/webp'] as const;
export const MAX_IMAGE_BYTES = 5_242_880; export const MAX_IMAGE_BYTES = 5_242_880;
/** Obergrenze für Breite × Höhe: ein kleines, stark komprimiertes PNG kann sonst Gigabytes an Pixeln enthalten. */
export const MAX_IMAGE_PIXELS = 40_000_000;
export const TYPE_MESSAGE = 'Bitte ein PNG-, JPG- oder WebP-Bild wählen.'; 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 SIZE_MESSAGE = 'Das Bild ist größer als 5 MiB.';
export const READ_MESSAGE = 'Das Bild konnte nicht gelesen werden.'; export const READ_MESSAGE = 'Das Bild konnte nicht gelesen werden.';
export const PIXELS_MESSAGE = 'Das Bild ist zu groß (max. 40 Megapixel).';
export type FileCheck = { ok: true } | { ok: false; message: string }; export type FileCheck = { ok: true } | { ok: false; message: string };
@@ -43,10 +46,15 @@ export async function decodeImage(blob: Blob): Promise<CardImage> {
// Wird nur bei Bedarf abgewartet; ohne das hier meldet der Browser eine unbehandelte Ablehnung. // Wird nur bei Bedarf abgewartet; ohne das hier meldet der Browser eine unbehandelte Ablehnung.
loaded.catch(() => undefined); loaded.catch(() => undefined);
img.src = url; img.src = url;
// Erst auf load warten: dann sind die Maße bekannt, das Bild aber noch nicht dekodiert (decode() belegt den Speicher).
await loaded;
if (!img.naturalWidth || !img.naturalHeight) throw new Error(READ_MESSAGE);
if (img.naturalWidth * img.naturalHeight > MAX_IMAGE_PIXELS) throw new Error(PIXELS_MESSAGE);
await whenDecoded(img, loaded); await whenDecoded(img, loaded);
if (!img.naturalWidth || !img.naturalHeight) throw new Error(READ_MESSAGE); if (!img.naturalWidth || !img.naturalHeight) throw new Error(READ_MESSAGE);
return { source: img, w: img.naturalWidth, h: img.naturalHeight }; return { source: img, w: img.naturalWidth, h: img.naturalHeight };
} catch { } catch (err) {
if (err instanceof Error && err.message === PIXELS_MESSAGE) throw err;
throw new Error(READ_MESSAGE); throw new Error(READ_MESSAGE);
} finally { } finally {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
+25 -1
View File
@@ -31,7 +31,11 @@ vi.mock('../account/auth', () => ({
})); }));
vi.mock('../account/login-dialog', () => ({ requireLogin: mocks.requireLogin })); vi.mock('../account/login-dialog', () => ({ requireLogin: mocks.requireLogin }));
vi.mock('./router', () => ({ navigate: mocks.navigate })); vi.mock('./router', () => ({ navigate: mocks.navigate }));
vi.mock('../editor/upload', () => ({ READ_MESSAGE: 'Das Bild konnte nicht gelesen werden.', decodeImage: mocks.decodeImage })); vi.mock('../editor/upload', () => ({
READ_MESSAGE: 'Das Bild konnte nicht gelesen werden.',
PIXELS_MESSAGE: 'Das Bild ist zu groß (max. 40 Megapixel).',
decodeImage: mocks.decodeImage,
}));
vi.mock('../card/render', () => ({ loadCardAssets: mocks.loadCardAssets, exportCard: mocks.exportCard })); vi.mock('../card/render', () => ({ loadCardAssets: mocks.loadCardAssets, exportCard: mocks.exportCard }));
vi.mock('../editor/download', async (importOriginal) => ({ vi.mock('../editor/download', async (importOriginal) => ({
...(await importOriginal<typeof import('../editor/download')>()), ...(await importOriginal<typeof import('../editor/download')>()),
@@ -267,6 +271,19 @@ describe('Meine Karten: Öffnen und Herunterladen', () => {
expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe(''); expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe('');
}); });
it('Herunterladen eines über 40 Megapixel großen Bildes zeigt die Größen-Meldung an der Kachel', async () => {
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
mocks.loadImage.mockResolvedValue(new Blob(['i'], { type: 'image/png' }));
mocks.decodeImage.mockRejectedValue(new Error('Das Bild ist zu groß (max. 40 Megapixel).'));
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
button(tileFor('a'), 'Herunterladen').click();
await vi.waitFor(() =>
expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe('Das Bild ist zu groß (max. 40 Megapixel).'),
);
expect(mocks.saveBlobAs).not.toHaveBeenCalled();
});
it('Herunterladen ohne Bilddatei zeigt die Meldung an der Kachel', async () => { it('Herunterladen ohne Bilddatei zeigt die Meldung an der Kachel', async () => {
mocks.list.mockResolvedValue([card('a', 'Alpha')]); mocks.list.mockResolvedValue([card('a', 'Alpha')]);
mocks.loadImage.mockRejectedValue(Object.assign(new Error('weg'), { code: 404, type: 'storage_file_not_found' })); mocks.loadImage.mockRejectedValue(Object.assign(new Error('weg'), { code: 404, type: 'storage_file_not_found' }));
@@ -370,6 +387,13 @@ describe('Meine Karten: Umbenennen', () => {
expect(tileFor('b').querySelector('input')).toBeNull(); expect(tileFor('b').querySelector('input')).toBeNull();
}); });
it('der vorhandene Name ist beim Öffnen komplett markiert (Tippen ersetzt ihn, statt davor einzufügen)', async () => {
const input = await startRename();
expect(document.activeElement).toBe(input);
expect(input.selectionStart).toBe(0);
expect(input.selectionEnd).toBe('Beta'.length);
});
it('Esc bricht ab, ohne etwas zu speichern', async () => { it('Esc bricht ab, ohne etwas zu speichern', async () => {
const input = await startRename(); const input = await startRename();
input.value = 'Anders'; input.value = 'Anders';
+2 -2
View File
@@ -13,7 +13,7 @@ import { confirmDialog } from '../editor/confirm-dialog';
import { el } from '../editor/dom'; import { el } from '../editor/dom';
import { downloadFileName, pngSizeWarning, saveBlobAs } from '../editor/download'; import { downloadFileName, pngSizeWarning, saveBlobAs } from '../editor/download';
import type { EditorStore } from '../editor/state'; import type { EditorStore } from '../editor/state';
import { READ_MESSAGE, decodeImage } from '../editor/upload'; import { PIXELS_MESSAGE, READ_MESSAGE, decodeImage } from '../editor/upload';
import { navigate } from './router'; import { navigate } from './router';
const LOADING = 'Karten werden geladen …'; const LOADING = 'Karten werden geladen …';
@@ -328,7 +328,7 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi
function failureText(err: unknown): string { function failureText(err: unknown): string {
if (err instanceof LoginCancelled) return LOGIN_NEEDED; if (err instanceof LoginCancelled) return LOGIN_NEEDED;
if (err instanceof Error && err.message === READ_MESSAGE) return READ_MESSAGE; if (err instanceof Error && (err.message === READ_MESSAGE || err.message === PIXELS_MESSAGE)) return err.message;
return errorMessage(err, 'load'); return errorMessage(err, 'load');
} }