mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: store eraser strokes per-image for multi-file support
This commit is contained in:
@@ -2,11 +2,18 @@ import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useSta
|
|||||||
|
|
||||||
type Point = { x: number; y: number };
|
type Point = { x: number; y: number };
|
||||||
type Stroke = { points: Point[]; size: number };
|
type Stroke = { points: Point[]; size: number };
|
||||||
|
type ImageStrokeData = {
|
||||||
|
strokes: Stroke[];
|
||||||
|
canvasSize: { w: number; h: number };
|
||||||
|
naturalSize: { w: number; h: number };
|
||||||
|
};
|
||||||
|
|
||||||
export interface EraserCanvasRef {
|
export interface EraserCanvasRef {
|
||||||
exportMask: () => Promise<Blob | null>;
|
exportMask: () => Promise<Blob | null>;
|
||||||
|
exportAllMasks: () => Promise<Map<string, Blob>>;
|
||||||
getMaskCenter: () => number | null;
|
getMaskCenter: () => number | null;
|
||||||
clear: () => void;
|
clear: () => void;
|
||||||
|
clearAll: () => void;
|
||||||
undo: () => void;
|
undo: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14,10 +21,11 @@ interface EraserCanvasProps {
|
|||||||
imageSrc: string;
|
imageSrc: string;
|
||||||
brushSize: number;
|
brushSize: number;
|
||||||
onStrokeChange: (hasStrokes: boolean) => void;
|
onStrokeChange: (hasStrokes: boolean) => void;
|
||||||
|
onMaskedCountChange?: (count: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EraserCanvas = forwardRef<EraserCanvasRef, EraserCanvasProps>(function EraserCanvas(
|
export const EraserCanvas = forwardRef<EraserCanvasRef, EraserCanvasProps>(function EraserCanvas(
|
||||||
{ imageSrc, brushSize, onStrokeChange },
|
{ imageSrc, brushSize, onStrokeChange, onMaskedCountChange },
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -28,6 +36,7 @@ export const EraserCanvas = forwardRef<EraserCanvasRef, EraserCanvasProps>(funct
|
|||||||
const naturalRef = useRef({ w: 0, h: 0 });
|
const naturalRef = useRef({ w: 0, h: 0 });
|
||||||
|
|
||||||
const strokesRef = useRef<Stroke[]>([]);
|
const strokesRef = useRef<Stroke[]>([]);
|
||||||
|
const allStrokesRef = useRef<Map<string, ImageStrokeData>>(new Map());
|
||||||
const drawingRef = useRef(false);
|
const drawingRef = useRef(false);
|
||||||
const currentPointsRef = useRef<Point[]>([]);
|
const currentPointsRef = useRef<Point[]>([]);
|
||||||
|
|
||||||
@@ -51,13 +60,35 @@ export const EraserCanvas = forwardRef<EraserCanvasRef, EraserCanvasProps>(funct
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Reset strokes when image changes
|
// Persist current strokes to the per-image map
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: imageSrc triggers intentional reset
|
const persistStrokes = useCallback(() => {
|
||||||
|
if (!canvasSize || !naturalRef.current.w) return;
|
||||||
|
const map = allStrokesRef.current;
|
||||||
|
if (strokesRef.current.length > 0) {
|
||||||
|
map.set(imageSrc, {
|
||||||
|
strokes: [...strokesRef.current],
|
||||||
|
canvasSize: { ...canvasSize },
|
||||||
|
naturalSize: { ...naturalRef.current },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
map.delete(imageSrc);
|
||||||
|
}
|
||||||
|
}, [imageSrc, canvasSize]);
|
||||||
|
|
||||||
|
// Restore strokes when image changes
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: imageSrc triggers intentional restore
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
strokesRef.current = [];
|
const saved = allStrokesRef.current.get(imageSrc);
|
||||||
|
if (saved) {
|
||||||
|
strokesRef.current = [...saved.strokes];
|
||||||
|
naturalRef.current = saved.naturalSize;
|
||||||
|
} else {
|
||||||
|
strokesRef.current = [];
|
||||||
|
}
|
||||||
currentPointsRef.current = [];
|
currentPointsRef.current = [];
|
||||||
onStrokeChange(false);
|
onStrokeChange(strokesRef.current.length > 0);
|
||||||
setCanvasSize(null);
|
setCanvasSize(saved?.canvasSize ?? null);
|
||||||
|
onMaskedCountChange?.(allStrokesRef.current.size);
|
||||||
}, [imageSrc, onStrokeChange]);
|
}, [imageSrc, onStrokeChange]);
|
||||||
|
|
||||||
const drawStroke = useCallback((ctx: CanvasRenderingContext2D, stroke: Stroke) => {
|
const drawStroke = useCallback((ctx: CanvasRenderingContext2D, stroke: Stroke) => {
|
||||||
@@ -101,11 +132,13 @@ export const EraserCanvas = forwardRef<EraserCanvasRef, EraserCanvasProps>(funct
|
|||||||
strokesRef.current.pop();
|
strokesRef.current.pop();
|
||||||
onStrokeChange(strokesRef.current.length > 0);
|
onStrokeChange(strokesRef.current.length > 0);
|
||||||
redraw();
|
redraw();
|
||||||
|
persistStrokes();
|
||||||
|
onMaskedCountChange?.(allStrokesRef.current.size);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", handler);
|
window.addEventListener("keydown", handler);
|
||||||
return () => window.removeEventListener("keydown", handler);
|
return () => window.removeEventListener("keydown", handler);
|
||||||
}, [onStrokeChange, redraw]);
|
}, [onStrokeChange, onMaskedCountChange, redraw, persistStrokes]);
|
||||||
|
|
||||||
// Get canvas-relative point from event
|
// Get canvas-relative point from event
|
||||||
const getPoint = useCallback((e: React.MouseEvent | React.TouchEvent): Point | null => {
|
const getPoint = useCallback((e: React.MouseEvent | React.TouchEvent): Point | null => {
|
||||||
@@ -178,8 +211,10 @@ export const EraserCanvas = forwardRef<EraserCanvasRef, EraserCanvasProps>(funct
|
|||||||
currentPointsRef.current = [];
|
currentPointsRef.current = [];
|
||||||
onStrokeChange(true);
|
onStrokeChange(true);
|
||||||
redraw();
|
redraw();
|
||||||
|
persistStrokes();
|
||||||
|
onMaskedCountChange?.(allStrokesRef.current.size);
|
||||||
}
|
}
|
||||||
}, [brushSize, onStrokeChange, redraw]);
|
}, [brushSize, onStrokeChange, onMaskedCountChange, redraw, persistStrokes]);
|
||||||
|
|
||||||
const handleLeave = useCallback(() => {
|
const handleLeave = useCallback(() => {
|
||||||
setCursorPos(null);
|
setCursorPos(null);
|
||||||
@@ -239,6 +274,53 @@ export const EraserCanvas = forwardRef<EraserCanvasRef, EraserCanvasProps>(funct
|
|||||||
mask.toBlob((b) => resolve(b), "image/png");
|
mask.toBlob((b) => resolve(b), "image/png");
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
exportAllMasks: async () => {
|
||||||
|
persistStrokes();
|
||||||
|
const results = new Map<string, Blob>();
|
||||||
|
for (const [src, data] of allStrokesRef.current) {
|
||||||
|
if (data.strokes.length === 0) continue;
|
||||||
|
const mask = document.createElement("canvas");
|
||||||
|
mask.width = data.naturalSize.w;
|
||||||
|
mask.height = data.naturalSize.h;
|
||||||
|
const ctx = mask.getContext("2d");
|
||||||
|
if (!ctx) continue;
|
||||||
|
ctx.fillStyle = "black";
|
||||||
|
ctx.fillRect(0, 0, data.naturalSize.w, data.naturalSize.h);
|
||||||
|
const sx = data.naturalSize.w / data.canvasSize.w;
|
||||||
|
const sy = data.naturalSize.h / data.canvasSize.h;
|
||||||
|
ctx.fillStyle = "white";
|
||||||
|
ctx.strokeStyle = "white";
|
||||||
|
ctx.lineCap = "round";
|
||||||
|
ctx.lineJoin = "round";
|
||||||
|
for (const stroke of data.strokes) {
|
||||||
|
const scaledSize = stroke.size * Math.max(sx, sy);
|
||||||
|
if (stroke.points.length === 1) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(
|
||||||
|
stroke.points[0].x * sx,
|
||||||
|
stroke.points[0].y * sy,
|
||||||
|
scaledSize / 2,
|
||||||
|
0,
|
||||||
|
Math.PI * 2,
|
||||||
|
);
|
||||||
|
ctx.fill();
|
||||||
|
} else {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.lineWidth = scaledSize;
|
||||||
|
ctx.moveTo(stroke.points[0].x * sx, stroke.points[0].y * sy);
|
||||||
|
for (let i = 1; i < stroke.points.length; i++) {
|
||||||
|
ctx.lineTo(stroke.points[i].x * sx, stroke.points[i].y * sy);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const blob = await new Promise<Blob | null>((resolve) => {
|
||||||
|
mask.toBlob((b) => resolve(b), "image/png");
|
||||||
|
});
|
||||||
|
if (blob) results.set(src, blob);
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
},
|
||||||
getMaskCenter: () => {
|
getMaskCenter: () => {
|
||||||
if (strokesRef.current.length === 0 || !canvasSize) return null;
|
if (strokesRef.current.length === 0 || !canvasSize) return null;
|
||||||
let minX = Infinity;
|
let minX = Infinity;
|
||||||
@@ -258,14 +340,26 @@ export const EraserCanvas = forwardRef<EraserCanvasRef, EraserCanvasProps>(funct
|
|||||||
currentPointsRef.current = [];
|
currentPointsRef.current = [];
|
||||||
onStrokeChange(false);
|
onStrokeChange(false);
|
||||||
redraw();
|
redraw();
|
||||||
|
persistStrokes();
|
||||||
|
onMaskedCountChange?.(allStrokesRef.current.size);
|
||||||
|
},
|
||||||
|
clearAll: () => {
|
||||||
|
allStrokesRef.current.clear();
|
||||||
|
strokesRef.current = [];
|
||||||
|
currentPointsRef.current = [];
|
||||||
|
onStrokeChange(false);
|
||||||
|
onMaskedCountChange?.(0);
|
||||||
|
redraw();
|
||||||
},
|
},
|
||||||
undo: () => {
|
undo: () => {
|
||||||
strokesRef.current.pop();
|
strokesRef.current.pop();
|
||||||
onStrokeChange(strokesRef.current.length > 0);
|
onStrokeChange(strokesRef.current.length > 0);
|
||||||
redraw();
|
redraw();
|
||||||
|
persistStrokes();
|
||||||
|
onMaskedCountChange?.(allStrokesRef.current.size);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[canvasSize, onStrokeChange, redraw],
|
[canvasSize, onStrokeChange, onMaskedCountChange, redraw, persistStrokes],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user