From d2283b4cc5cabe07da21885f63f693a1b4933abf Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 20:33:39 +0000 Subject: [PATCH] perf: Web-Canvas cached fertige Zeichnung, Live-Strich ohne Komplett-Redraw (#358) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Redraw-Effekt in DrawingCanvas.web.tsx hing an currentPath und lief bei jeder Mausbewegung komplett durch: clear, alle Striche, jeder Fill mit vollem getImageData/Flood-Fill/putImageData. Jetzt zwei Effekte: - Basis-Effekt (paths, Größe): rendert die fertige Zeichnung und cached sie in einem Offscreen-Canvas - Live-Strich-Effekt (currentPath, Farbe, Stärke, Tool): kopiert den Snapshot zurück und zeichnet nur den aktuellen Strich Zusätzlich EMPTY_PATHS als stabiler Default (kein neues []-Array pro Render). Test zählt getImageData-Aufrufe: vorher 44 für 20 Moves mit 2 Fills, jetzt 2. Nicht enthalten: Replay-Frames (Skalierung ändert sich pro Frame, bleibt in #358 offen). Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_017fxbsNALrmwMj7LGMDPLw2 --- CHANGELOG.md | 4 + components/DrawingCanvas.web.tsx | 31 ++++- .../__tests__/DrawingCanvas.web.test.tsx | 120 ++++++++++++++++++ 3 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 components/__tests__/DrawingCanvas.web.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index efee186..0daa457 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/components/DrawingCanvas.web.tsx b/components/DrawingCanvas.web.tsx index eaa58f4..b9e426b 100644 --- a/components/DrawingCanvas.web.tsx +++ b/components/DrawingCanvas.web.tsx @@ -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; @@ -24,7 +26,7 @@ export default function DrawingCanvas({ strokeColor = '#000000', strokeWidth = 3, tool = 'brush', - paths = [], + paths = EMPTY_PATHS, onDrawingChange, }: Props) { const { width: windowWidth } = useWindowDimensions(); @@ -34,6 +36,7 @@ export default function DrawingCanvas({ const isDrawingRef = useRef(false); const currentPathRef = useRef<{ x: number; y: number }[]>([]); const canvasRef = useRef(null); + const snapshotRef = useRef(null); const floodFill = (startX: number, startY: number, fillColor: string) => { if (!canvasRef.current) return; @@ -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; @@ -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; diff --git a/components/__tests__/DrawingCanvas.web.test.tsx b/components/__tests__/DrawingCanvas.web.test.tsx new file mode 100644 index 0000000..f737611 --- /dev/null +++ b/components/__tests__/DrawingCanvas.web.test.tsx @@ -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)[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( + , + ); + 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( + , + ); + expect(ctxCalls.getImageData).toBe(1); + + rerender( + , + ); + // 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( + , + ); + 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); + }); +});