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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- Replay: Die Skalierung der Zeichnung wird aus der **gesamten** Zeichnung berechnet, nicht aus den gerade sichtbaren Pfaden — die Zeichnung erscheint von Anfang an in Endgröße und wandert nicht mehr mit. Dadurch können fertige Pfade und Fills in Web und Android zwischen den Frames wiederverwendet werden (pro Frame höchstens 2 statt N Flood-Fills) (#366)
- 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
Expand Down
33 changes: 29 additions & 4 deletions components/DrawingCanvas.native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@ import { t } from '@services/i18n';
import { styles, DEFAULT_CANVAS_WIDTH } from './DrawingCanvas.shared';
import type { DrawingPath } from './DrawingCanvas.shared';
import { captureException } from '@services/SentryService';
import { computeNativeFillLayers, type NativeFillLayer } from '@services/NativeFillLayerService';
import {
computeNativeFillLayersIncremental,
type NativeFillCache,
type NativeFillLayer,
} from '@services/NativeFillLayerService';

// Re-export shared API so '@components/DrawingCanvas' provides a complete module on native
export type { DrawingPath } from './DrawingCanvas.shared';
Expand Down Expand Up @@ -66,7 +70,15 @@ function tryLoadSkia(): boolean {
// Attempt initial load at module level
tryLoadSkia();

const EMPTY_PATHS: DrawingPath[] = [];

interface Props {
/**
* Pfade, aus denen die Anpassung der Zeichnung an die Fläche (Skalierung/Offset) berechnet wird.
* Standard: `paths`. Im Replay die vollständige Zeichnung übergeben, damit die Skalierung über
* alle Frames stabil bleibt und bereits berechnete Fills wiederverwendet werden können (#366).
*/
scaleReferencePaths?: DrawingPath[];
width?: number;
height?: number;
strokeColor?: string;
Expand Down Expand Up @@ -194,7 +206,8 @@ export default function DrawingCanvas({
strokeColor = '#000000',
strokeWidth = 3,
tool = 'brush',
paths = [],
paths = EMPTY_PATHS,
scaleReferencePaths,
onDrawingChange,
}: Props) {
const { width: windowWidth } = useWindowDimensions();
Expand All @@ -203,6 +216,7 @@ export default function DrawingCanvas({
const [nativePaths, setNativePaths] = useState(paths);
const [currentNativePath, setCurrentNativePath] = useState<{ x: number; y: number }[]>([]);
const [fillLayers, setFillLayers] = useState<NativeFillLayer[]>([]);
const fillCacheRef = useRef<NativeFillCache | null>(null);

useEffect(() => {
setNativePaths(paths);
Expand Down Expand Up @@ -253,7 +267,7 @@ export default function DrawingCanvas({

const getScaledPaths = () => {
// Exclude fill paths from bounding box – they only have a single click point
const strokePaths = nativePaths.filter(p => p.type !== 'fill');
const strokePaths = (scaleReferencePaths ?? nativePaths).filter(p => p.type !== 'fill');
if (strokePaths.length === 0) return { scale: 1, offsetX: 0, offsetY: 0 };

let minX = Infinity,
Expand Down Expand Up @@ -297,13 +311,24 @@ export default function DrawingCanvas({
// Recompute native fill layers whenever fill paths change.
useEffect(() => {
if (!hasFillPaths) {
fillCacheRef.current = null;
setFillLayers([]);
return;
}
try {
const layers = computeNativeFillLayers(nativePaths, width, height, scale, offsetX, offsetY);
const { layers, cache } = computeNativeFillLayersIncremental(
nativePaths,
width,
height,
scale,
offsetX,
offsetY,
fillCacheRef.current,
);
fillCacheRef.current = cache;
setFillLayers(layers);
} catch (e) {
fillCacheRef.current = null;
captureException(e instanceof Error ? e : new Error(String(e)), {
component: 'DrawingCanvas',
operation: 'computeNativeFillLayers',
Expand Down
137 changes: 101 additions & 36 deletions components/DrawingCanvas.web.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ export { useDrawingCanvas } from './DrawingCanvas.hooks';
const EMPTY_PATHS: DrawingPath[] = [];

interface Props {
/**
* Pfade, aus denen die Anpassung der Zeichnung an die Fläche (Skalierung/Offset) berechnet wird.
* Standard: `paths`. Im Replay die vollständige Zeichnung übergeben, damit die Skalierung über
* alle Frames stabil bleibt und bereits gerenderte Pfade wiederverwendet werden können (#366).
*/
scaleReferencePaths?: DrawingPath[];
width?: number;
height?: number;
strokeColor?: string;
Expand All @@ -20,13 +26,53 @@ interface Props {
onDrawingChange?: (paths: DrawingPath[]) => void;
}

function drawPath(
ctx: CanvasRenderingContext2D,
path: DrawingPath,
width: number,
height: number,
scale: number,
offsetX: number,
offsetY: number,
) {
if (path.type === 'fill' && path.points.length > 0) {
const point = {
x: path.points[0].x * scale + offsetX,
y: path.points[0].y * scale + offsetY,
};
const imageData = ctx.getImageData(0, 0, width, height);
const changed = floodFillPixels(
imageData.data,
width,
height,
point.x,
point.y,
hexToRgb(path.color),
);
if (changed) ctx.putImageData(imageData, 0, 0);
} else if (path.points.length >= 2) {
ctx.strokeStyle = path.color;
ctx.lineWidth = path.strokeWidth * scale;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';

ctx.beginPath();
ctx.moveTo(path.points[0].x * scale + offsetX, path.points[0].y * scale + offsetY);
for (let i = 1; i < path.points.length; i++) {
ctx.lineTo(path.points[i].x * scale + offsetX, path.points[i].y * scale + offsetY);
}
ctx.stroke();
}
}

export default function DrawingCanvas({
width: propWidth,
height = 400,
strokeColor = '#000000',
strokeWidth = 3,
tool = 'brush',
paths = EMPTY_PATHS,
scaleReferencePaths,
onDrawingChange,
}: Props) {
const { width: windowWidth } = useWindowDimensions();
Expand All @@ -37,6 +83,13 @@ export default function DrawingCanvas({
const currentPathRef = useRef<{ x: number; y: number }[]>([]);
const canvasRef = useRef<HTMLCanvasElement>(null);
const snapshotRef = useRef<HTMLCanvasElement | null>(null);
// Gerenderter Stand aller Pfade außer dem letzten ("committed"). Wächst `paths` nur am Ende
// (Malen, Replay), wird dieser Stand wiederverwendet statt alle Fills neu zu berechnen (#366).
const committedRef = useRef<{
canvas: HTMLCanvasElement;
paths: DrawingPath[];
key: string;
} | null>(null);

const floodFill = (startX: number, startY: number, fillColor: string) => {
if (!canvasRef.current) return;
Expand Down Expand Up @@ -64,19 +117,16 @@ export default function DrawingCanvas({
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) return;

ctx.clearRect(0, 0, width, height);
ctx.fillStyle = '#FFFFFF';
ctx.fillRect(0, 0, width, height);

const referencePaths = scaleReferencePaths ?? paths;
let scale = 1,
offsetX = 0,
offsetY = 0;
if (!onDrawingChange && paths.length > 0) {
if (!onDrawingChange && referencePaths.length > 0) {
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
paths.forEach(path => {
referencePaths.forEach(path => {
if (path.type === 'fill') return;
path.points.forEach(point => {
minX = Math.min(minX, point.x);
Expand All @@ -102,36 +152,51 @@ export default function DrawingCanvas({
}
}

paths.forEach(path => {
if (path.type === 'fill' && path.points.length > 0) {
const point = {
x: path.points[0].x * scale + offsetX,
y: path.points[0].y * scale + offsetY,
};
const imageData = ctx.getImageData(0, 0, width, height);
const changed = floodFillPixels(
imageData.data,
width,
height,
point.x,
point.y,
hexToRgb(path.color),
);
if (changed) ctx.putImageData(imageData, 0, 0);
} else if (path.points.length >= 2) {
ctx.strokeStyle = path.color;
ctx.lineWidth = path.strokeWidth * scale;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';

ctx.beginPath();
ctx.moveTo(path.points[0].x * scale + offsetX, path.points[0].y * scale + offsetY);
for (let i = 1; i < path.points.length; i++) {
ctx.lineTo(path.points[i].x * scale + offsetX, path.points[i].y * scale + offsetY);
}
ctx.stroke();
// Der letzte Pfad gilt als "flüchtig" (im Replay ist er der gerade wachsende Strich) und wird
// nicht gecacht; alle davor werden einmal gerendert und wiederverwendet, solange sie
// referenzgleich bleiben und sich Größe/Skalierung nicht ändern.
const key = `${width}x${height}|${scale}|${offsetX}|${offsetY}`;
const committed = paths.slice(0, -1);
const last = paths.length > 0 ? paths[paths.length - 1] : null;

const cache = committedRef.current;
let start = 0;
let reused = false;
ctx.clearRect(0, 0, width, height);
if (
cache &&
cache.key === key &&
cache.paths.length <= committed.length &&
cache.paths.every((p, i) => p === committed[i])
) {
ctx.drawImage(cache.canvas, 0, 0);
start = cache.paths.length;
reused = true;
} else {
ctx.fillStyle = '#FFFFFF';
ctx.fillRect(0, 0, width, height);
}

for (let i = start; i < committed.length; i++) {
drawPath(ctx, committed[i], width, height, scale, offsetX, offsetY);
}

if (committed.length === 0) {
committedRef.current = null;
} else if (!reused || start !== committed.length) {
// Neuer committed-Stand: Kopie der Zeichenfläche (ohne den flüchtigen letzten Pfad) merken
const store = cache?.canvas ?? document.createElement('canvas'); // platform-safe
if (store.width !== width || store.height !== height) {
store.width = width;
store.height = height;
}
});
const storeCtx = store.getContext('2d');
storeCtx?.clearRect(0, 0, width, height);
storeCtx?.drawImage(canvas, 0, 0);
committedRef.current = { canvas: store, paths: committed, key };
}

if (last) drawPath(ctx, last, width, height, scale, offsetX, offsetY);

// Snapshot der fertigen Zeichnung für den Live-Strich-Effekt
let snapshot = snapshotRef.current;
Expand All @@ -142,7 +207,7 @@ export default function DrawingCanvas({
snapshot.width = width;
snapshot.height = height;
snapshot.getContext('2d')?.drawImage(canvas, 0, 0);
}, [paths, width, height, onDrawingChange]);
}, [paths, scaleReferencePaths, 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).
Expand Down
Loading
Loading