Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- Web-Zeichenfläche: Beim Malen wird nur noch der Live-Strich über einen gecachten Snapshot der fertigen Zeichnung gelegt. Vorher liefen bei jeder Mausbewegung alle Striche und alle Flood-Fills (volle `getImageData`/`putImageData`-Durchläufe) erneut (#358)

### Fixed

- „Von Level 1 neu starten" setzt den Galerie-Speicherstatus zurück (vorher blieb „In Galerie speichern" ggf. gesperrt)
Expand Down
31 changes: 30 additions & 1 deletion components/DrawingCanvas.web.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import type { DrawingPath } from './DrawingCanvas.shared';
export type { DrawingPath } from './DrawingCanvas.shared';
export { useDrawingCanvas } from './DrawingCanvas.hooks';

const EMPTY_PATHS: DrawingPath[] = [];

interface Props {
width?: number;
height?: number;
Expand All @@ -24,7 +26,7 @@ export default function DrawingCanvas({
strokeColor = '#000000',
strokeWidth = 3,
tool = 'brush',
paths = [],
paths = EMPTY_PATHS,
onDrawingChange,
}: Props) {
const { width: windowWidth } = useWindowDimensions();
Expand All @@ -34,6 +36,7 @@ export default function DrawingCanvas({
const isDrawingRef = useRef(false);
const currentPathRef = useRef<{ x: number; y: number }[]>([]);
const canvasRef = useRef<HTMLCanvasElement>(null);
const snapshotRef = useRef<HTMLCanvasElement | null>(null);

const floodFill = (startX: number, startY: number, fillColor: string) => {
if (!canvasRef.current) return;
Expand All @@ -52,6 +55,9 @@ export default function DrawingCanvas({
if (changed) ctx.putImageData(imageData, 0, 0);
};

// Fertige Zeichnung (Striche + Fills) nur bei Änderung von paths/Größe neu rendern und als
// Offscreen-Bild cachen. Der Flood-Fill (getImageData → Scan → putImageData über die ganze
// Fläche) lief vorher bei jedem Touch-Move mit (Issue #358).
useEffect(() => {
if (!canvasRef.current) return;
const canvas = canvasRef.current;
Expand Down Expand Up @@ -127,6 +133,29 @@ export default function DrawingCanvas({
}
});

// Snapshot der fertigen Zeichnung für den Live-Strich-Effekt
let snapshot = snapshotRef.current;
if (!snapshot) {
snapshot = document.createElement('canvas'); // platform-safe
snapshotRef.current = snapshot;
}
snapshot.width = width;
snapshot.height = height;
snapshot.getContext('2d')?.drawImage(canvas, 0, 0);
}, [paths, width, height, onDrawingChange]);

// Live-Strich: Snapshot zurückkopieren und nur den aktuellen Strich darüberzeichnen.
// Läuft nach dem Basis-Effekt (gleiche Render-Runde → Snapshot ist aktuell).
useEffect(() => {
const canvas = canvasRef.current;
const snapshot = snapshotRef.current;
if (!canvas || !snapshot) return;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) return;

ctx.clearRect(0, 0, width, height);
ctx.drawImage(snapshot, 0, 0);

if (onDrawingChange && currentPath.length > 1 && tool === 'brush') {
ctx.strokeStyle = strokeColor;
ctx.lineWidth = strokeWidth;
Expand Down
120 changes: 120 additions & 0 deletions components/__tests__/DrawingCanvas.web.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import type { DrawingPath } from '../DrawingCanvas.shared';

// Fake-2D-Kontext, der Aufrufe zählt (jsdom implementiert Canvas nicht).
const ctxCalls = { getImageData: 0, putImageData: 0, drawImage: 0, stroke: 0 };
const fakeCtx = {
clearRect: jest.fn(),
fillRect: jest.fn(),
beginPath: jest.fn(),
moveTo: jest.fn(),
lineTo: jest.fn(),
stroke: jest.fn(() => {
ctxCalls.stroke++;
}),
drawImage: jest.fn(() => {
ctxCalls.drawImage++;
}),
getImageData: jest.fn((_x: number, _y: number, w: number, h: number) => {
ctxCalls.getImageData++;
return { data: new Uint8ClampedArray(w * h * 4).fill(255), width: w, height: h };
}),
putImageData: jest.fn(() => {
ctxCalls.putImageData++;
}),
fillStyle: '',
strokeStyle: '',
lineWidth: 0,
lineCap: '',
lineJoin: '',
};

import DrawingCanvas from '../DrawingCanvas.web';

describe('DrawingCanvas.web — Live-Strich nutzt Snapshot statt Komplett-Redraw (#358)', () => {
let getContextSpy: jest.SpyInstance;

beforeEach(() => {
Object.keys(ctxCalls).forEach(k => ((ctxCalls as Record<string, number>)[k] = 0));
getContextSpy = jest
.spyOn(HTMLCanvasElement.prototype, 'getContext')
.mockImplementation(
(() => fakeCtx) as unknown as typeof HTMLCanvasElement.prototype.getContext,
);
});

afterEach(() => getContextSpy.mockRestore());

const fillPath: DrawingPath = {
points: [{ x: 5, y: 5 }],
color: '#FF0000',
strokeWidth: 0,
type: 'fill',
};

it('führt bei Touch-Moves keine Flood-Fills erneut aus', () => {
const paths = [fillPath, fillPath];
const { container } = render(
<DrawingCanvas width={100} height={100} paths={paths} onDrawingChange={jest.fn()} />,
);
const canvas = container.querySelector('canvas') as HTMLCanvasElement;
expect(canvas).not.toBeNull();

// Basis-Render: jeder gespeicherte Fill liest die Fläche einmal
expect(ctxCalls.getImageData).toBe(2);
const fillsAfterMount = ctxCalls.getImageData;

fireEvent.mouseDown(canvas, { clientX: 10, clientY: 10 });
for (let i = 1; i <= 20; i++) {
fireEvent.mouseMove(canvas, { clientX: 10 + i, clientY: 10 + i });
}

// Kein weiterer getImageData/putImageData-Durchlauf während des Zeichnens
expect(ctxCalls.getImageData).toBe(fillsAfterMount);
// Stattdessen wird der Snapshot zurückkopiert und der Live-Strich gezeichnet
expect(ctxCalls.drawImage).toBeGreaterThan(20);
expect(ctxCalls.stroke).toBeGreaterThan(0);
});

it('rendert die Basis neu, wenn sich paths ändern', () => {
const onDrawingChange = jest.fn();
const { rerender } = render(
<DrawingCanvas
width={100}
height={100}
paths={[fillPath]}
onDrawingChange={onDrawingChange}
/>,
);
expect(ctxCalls.getImageData).toBe(1);

rerender(
<DrawingCanvas
width={100}
height={100}
paths={[fillPath, fillPath]}
onDrawingChange={onDrawingChange}
/>,
);
// Neuer Basis-Render: beide Fills werden neu angewendet (1 + 2)
expect(ctxCalls.getImageData).toBe(3);
});

it('übergibt den fertigen Strich beim Loslassen an onDrawingChange', () => {
const onDrawingChange = jest.fn();
const { container } = render(
<DrawingCanvas width={100} height={100} paths={[]} onDrawingChange={onDrawingChange} />,
);
const canvas = container.querySelector('canvas') as HTMLCanvasElement;

fireEvent.mouseDown(canvas, { clientX: 10, clientY: 10 });
fireEvent.mouseMove(canvas, { clientX: 20, clientY: 20 });
fireEvent.mouseUp(canvas);

expect(onDrawingChange).toHaveBeenCalledTimes(1);
const [newPaths] = onDrawingChange.mock.calls[0];
expect(newPaths).toHaveLength(1);
expect(newPaths[0].points.length).toBeGreaterThan(1);
});
});
Loading