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:
1 parent
144181f2c9
commit
9c4e80ec9a
8 files changed
+144
-8
No files matched your search
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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],
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user