From 9c4e80ec9a6c67fc90237074c28e75106edf5b8d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 11:11:06 +0000 Subject: [PATCH] fix(editor): Strg/Cmd/Alt-Tasten frei, 40-Megapixel-Grenze, Umbenennen-Test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- web/src/editor/editor-page.test.ts | 10 ++++++ web/src/editor/editor-page.ts | 5 +-- web/src/editor/preview.test.ts | 56 ++++++++++++++++++++++++++++++ web/src/editor/preview.ts | 2 ++ web/src/editor/upload.test.ts | 39 +++++++++++++++++++-- web/src/editor/upload.ts | 10 +++++- web/src/pages/cards-page.test.ts | 26 +++++++++++++- web/src/pages/cards-page.ts | 4 +-- 8 files changed, 144 insertions(+), 8 deletions(-) create mode 100644 web/src/editor/preview.test.ts diff --git a/web/src/editor/editor-page.test.ts b/web/src/editor/editor-page.test.ts index 2bfe55b..0abde94 100644 --- a/web/src/editor/editor-page.test.ts +++ b/web/src/editor/editor-page.test.ts @@ -620,6 +620,16 @@ describe('Karte öffnen (/?karte=)', () => { 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 () => { open('k1'); let finish!: (c: SavedCard) => void; diff --git a/web/src/editor/editor-page.ts b/web/src/editor/editor-page.ts index a623a1f..e4210cd 100644 --- a/web/src/editor/editor-page.ts +++ b/web/src/editor/editor-page.ts @@ -18,7 +18,7 @@ import { attachFileDrop, createPreview } from './preview'; import { createSaveAction, type SaveStatus } from './save'; import { initialEditorState, setImage, type EditorState, type EditorStore, type SavedCardRef } from './state'; 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 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()); preview.setError(errorMessage(err, 'load')); } 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 { if (!stale()) preview.setNotice(null); diff --git a/web/src/editor/preview.test.ts b/web/src/editor/preview.test.ts new file mode 100644 index 0000000..a28f165 --- /dev/null +++ b/web/src/editor/preview.test.ts @@ -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(); + }); +}); diff --git a/web/src/editor/preview.ts b/web/src/editor/preview.ts index fdc840b..06aef20 100644 --- a/web/src/editor/preview.ts +++ b/web/src/editor/preview.ts @@ -151,6 +151,8 @@ export function createPreview(handlers: PreviewHandlers): Preview { canvas.addEventListener('pointercancel', endPan); 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 moves: Record = { ArrowLeft: [-step, 0], diff --git a/web/src/editor/upload.test.ts b/web/src/editor/upload.test.ts index f3a01ab..885bb8c 100644 --- a/web/src/editor/upload.test.ts +++ b/web/src/editor/upload.test.ts @@ -4,6 +4,7 @@ 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.'; +const PIXELS_MESSAGE = 'Das Bild ist zu groß (max. 40 Megapixel).'; describe('validateImageFile', () => { it('nimmt ein kleines PNG an', () => { @@ -55,6 +56,7 @@ describe('decodeImage', () => { } let behaviour: Behaviour; let created: FakeImage[]; + let decodeCalls: number; let createObjectURL: ReturnType; let revokeObjectURL: ReturnType; @@ -69,8 +71,10 @@ describe('decodeImage', () => { constructor() { created.push(this); if (behaviour.decode !== 'missing') { - this.decode = () => - behaviour.decode === 'ok' ? this.#settle() : Promise.reject(new DOMException('kaputt', 'EncodingError')); + this.decode = () => { + decodeCalls += 1; + return behaviour.decode === 'ok' ? this.#settle() : Promise.reject(new DOMException('kaputt', 'EncodingError')); + }; } } @@ -101,6 +105,7 @@ describe('decodeImage', () => { beforeEach(() => { behaviour = { decode: 'ok', load: 'ok', width: 4000, height: 3000 }; created = []; + decodeCalls = 0; createObjectURL = vi.fn(() => 'blob:test-url'); revokeObjectURL = vi.fn(); vi.stubGlobal('Image', FakeImage); @@ -152,4 +157,34 @@ describe('decodeImage', () => { behaviour = { ...behaviour, decode: 'missing', load: 'fail' }; 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); + }); + }); }); diff --git a/web/src/editor/upload.ts b/web/src/editor/upload.ts index 02555b5..ab774d1 100644 --- a/web/src/editor/upload.ts +++ b/web/src/editor/upload.ts @@ -3,10 +3,13 @@ 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; +/** 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 SIZE_MESSAGE = 'Das Bild ist größer als 5 MiB.'; 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 }; @@ -43,10 +46,15 @@ export async function decodeImage(blob: Blob): Promise { // Wird nur bei Bedarf abgewartet; ohne das hier meldet der Browser eine unbehandelte Ablehnung. loaded.catch(() => undefined); 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); if (!img.naturalWidth || !img.naturalHeight) throw new Error(READ_MESSAGE); 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); } finally { URL.revokeObjectURL(url); diff --git a/web/src/pages/cards-page.test.ts b/web/src/pages/cards-page.test.ts index 9c1df73..a3ec15d 100644 --- a/web/src/pages/cards-page.test.ts +++ b/web/src/pages/cards-page.test.ts @@ -31,7 +31,11 @@ vi.mock('../account/auth', () => ({ })); vi.mock('../account/login-dialog', () => ({ requireLogin: mocks.requireLogin })); 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('../editor/download', async (importOriginal) => ({ ...(await importOriginal()), @@ -267,6 +271,19 @@ describe('Meine Karten: Öffnen und Herunterladen', () => { 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 () => { mocks.list.mockResolvedValue([card('a', 'Alpha')]); 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(); }); + 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 () => { const input = await startRename(); input.value = 'Anders'; diff --git a/web/src/pages/cards-page.ts b/web/src/pages/cards-page.ts index fe4ecfa..f56a29d 100644 --- a/web/src/pages/cards-page.ts +++ b/web/src/pages/cards-page.ts @@ -13,7 +13,7 @@ import { confirmDialog } from '../editor/confirm-dialog'; import { el } from '../editor/dom'; import { downloadFileName, pngSizeWarning, saveBlobAs } from '../editor/download'; 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'; const LOADING = 'Karten werden geladen …'; @@ -328,7 +328,7 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi function failureText(err: unknown): string { 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'); }