mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
refactor: eslint and prettier conflict will be resolved and formatted
This commit is contained in:
+214
-154
@@ -5,21 +5,21 @@ import { Button } from '@/components/ui/button';
|
||||
import { Undo2, Trash2, Minus, Plus, X } from 'lucide-react';
|
||||
|
||||
export interface AnnotationStroke {
|
||||
points: { x: number; y: number }[];
|
||||
color: string;
|
||||
width: number;
|
||||
points: { x: number; y: number }[];
|
||||
color: string;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export interface AnnotationCanvasHandle {
|
||||
getStrokes: () => AnnotationStroke[];
|
||||
getStrokes: () => AnnotationStroke[];
|
||||
}
|
||||
|
||||
interface AnnotationCanvasProps {
|
||||
mode: 'draw' | 'view';
|
||||
strokes?: AnnotationStroke[];
|
||||
onConfirm?: (strokes: AnnotationStroke[]) => void;
|
||||
onCancel?: () => void;
|
||||
onDismiss?: () => void; // For view mode, close overlay
|
||||
mode: 'draw' | 'view';
|
||||
strokes?: AnnotationStroke[];
|
||||
onConfirm?: (strokes: AnnotationStroke[]) => void;
|
||||
onCancel?: () => void;
|
||||
onDismiss?: () => void; // For view mode, close overlay
|
||||
}
|
||||
|
||||
const COLORS = ['#FF3B30', '#FF9500', '#FFCC00', '#34C759', '#007AFF', '#AF52DE', '#FFFFFF'];
|
||||
@@ -31,7 +31,11 @@ const MAX_WIDTH = 10;
|
||||
// Reference canvas width for stroke scaling
|
||||
const REF_WIDTH = 1000;
|
||||
|
||||
export const AnnotationCanvas = forwardRef<AnnotationCanvasHandle, AnnotationCanvasProps>(function AnnotationCanvas({ mode, strokes: initialStrokes, onConfirm, onCancel, onDismiss }, ref) {
|
||||
export const AnnotationCanvas = forwardRef<AnnotationCanvasHandle, AnnotationCanvasProps>(
|
||||
function AnnotationCanvas(
|
||||
{ mode, strokes: initialStrokes, onConfirm, onCancel, onDismiss },
|
||||
ref
|
||||
) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [strokes, setStrokes] = useState<AnnotationStroke[]>(initialStrokes || []);
|
||||
@@ -42,90 +46,107 @@ export const AnnotationCanvas = forwardRef<AnnotationCanvasHandle, AnnotationCan
|
||||
void onConfirm;
|
||||
|
||||
// Expose getStrokes so parent can grab current drawing without confirm
|
||||
useImperativeHandle(ref, () => ({
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
getStrokes: () => strokes,
|
||||
}), [strokes]);
|
||||
}),
|
||||
[strokes]
|
||||
);
|
||||
|
||||
// Render all strokes
|
||||
const renderStrokes = useCallback((ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement, strokeList: AnnotationStroke[], active?: AnnotationStroke | null) => {
|
||||
const renderStrokes = useCallback(
|
||||
(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
canvas: HTMLCanvasElement,
|
||||
strokeList: AnnotationStroke[],
|
||||
active?: AnnotationStroke | null
|
||||
) => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const scale = canvas.width / REF_WIDTH;
|
||||
|
||||
const draw = (s: AnnotationStroke) => {
|
||||
if (s.points.length < 2) return;
|
||||
ctx.strokeStyle = s.color;
|
||||
ctx.lineWidth = s.width * scale;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(s.points[0].x * canvas.width, s.points[0].y * canvas.height);
|
||||
for (let i = 1; i < s.points.length; i++) {
|
||||
ctx.lineTo(s.points[i].x * canvas.width, s.points[i].y * canvas.height);
|
||||
}
|
||||
ctx.stroke();
|
||||
if (s.points.length < 2) return;
|
||||
ctx.strokeStyle = s.color;
|
||||
ctx.lineWidth = s.width * scale;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(s.points[0].x * canvas.width, s.points[0].y * canvas.height);
|
||||
for (let i = 1; i < s.points.length; i++) {
|
||||
ctx.lineTo(s.points[i].x * canvas.width, s.points[i].y * canvas.height);
|
||||
}
|
||||
ctx.stroke();
|
||||
};
|
||||
|
||||
strokeList.forEach(draw);
|
||||
if (active) draw(active);
|
||||
}, []);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Keep a stable ref to the latest strokes/currentStroke so the resize
|
||||
// handler can redraw without being listed as a dependency (which would
|
||||
// re-register the listener on every stroke change).
|
||||
const strokesRef = useRef(strokes);
|
||||
const currentStrokeRef = useRef(currentStroke);
|
||||
useEffect(() => { strokesRef.current = strokes; }, [strokes]);
|
||||
useEffect(() => { currentStrokeRef.current = currentStroke; }, [currentStroke]);
|
||||
useEffect(() => {
|
||||
strokesRef.current = strokes;
|
||||
}, [strokes]);
|
||||
useEffect(() => {
|
||||
currentStrokeRef.current = currentStroke;
|
||||
}, [currentStroke]);
|
||||
|
||||
// Resize canvas to match container — listener registered once, never re-added.
|
||||
useEffect(() => {
|
||||
const resizeCanvas = () => {
|
||||
const canvas = canvasRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!canvas || !container) return;
|
||||
const resizeCanvas = () => {
|
||||
const canvas = canvasRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!canvas || !container) return;
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
canvas.width = rect.width;
|
||||
canvas.height = rect.height;
|
||||
const rect = container.getBoundingClientRect();
|
||||
canvas.width = rect.width;
|
||||
canvas.height = rect.height;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) renderStrokes(ctx, canvas, strokesRef.current, currentStrokeRef.current);
|
||||
};
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) renderStrokes(ctx, canvas, strokesRef.current, currentStrokeRef.current);
|
||||
};
|
||||
|
||||
resizeCanvas();
|
||||
window.addEventListener('resize', resizeCanvas);
|
||||
return () => window.removeEventListener('resize', resizeCanvas);
|
||||
resizeCanvas();
|
||||
window.addEventListener('resize', resizeCanvas);
|
||||
return () => window.removeEventListener('resize', resizeCanvas);
|
||||
}, [renderStrokes]); // renderStrokes is stable (useCallback with no deps that change)
|
||||
|
||||
// Re-render on stroke changes
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
renderStrokes(ctx, canvas, strokes, currentStroke);
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
renderStrokes(ctx, canvas, strokes, currentStroke);
|
||||
}, [strokes, currentStroke, renderStrokes]);
|
||||
|
||||
const getPoint = useCallback((e: React.MouseEvent | React.TouchEvent) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return null;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
let clientX: number, clientY: number;
|
||||
if ('touches' in e) {
|
||||
clientX = e.touches[0].clientX;
|
||||
clientY = e.touches[0].clientY;
|
||||
} else {
|
||||
clientX = e.clientX;
|
||||
clientY = e.clientY;
|
||||
}
|
||||
return {
|
||||
x: (clientX - rect.left) / rect.width,
|
||||
y: (clientY - rect.top) / rect.height,
|
||||
};
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return null;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
let clientX: number, clientY: number;
|
||||
if ('touches' in e) {
|
||||
clientX = e.touches[0].clientX;
|
||||
clientY = e.touches[0].clientY;
|
||||
} else {
|
||||
clientX = e.clientX;
|
||||
clientY = e.clientY;
|
||||
}
|
||||
return {
|
||||
x: (clientX - rect.left) / rect.width,
|
||||
y: (clientY - rect.top) / rect.height,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handlePointerDown = useCallback((e: React.MouseEvent | React.TouchEvent) => {
|
||||
const handlePointerDown = useCallback(
|
||||
(e: React.MouseEvent | React.TouchEvent) => {
|
||||
if (mode !== 'draw') return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -133,133 +154,172 @@ export const AnnotationCanvas = forwardRef<AnnotationCanvasHandle, AnnotationCan
|
||||
if (!pt) return;
|
||||
isDrawingRef.current = true;
|
||||
setCurrentStroke({ points: [pt], color, width });
|
||||
}, [mode, color, width, getPoint]);
|
||||
},
|
||||
[mode, color, width, getPoint]
|
||||
);
|
||||
|
||||
const handlePointerMove = useCallback((e: React.MouseEvent | React.TouchEvent) => {
|
||||
const handlePointerMove = useCallback(
|
||||
(e: React.MouseEvent | React.TouchEvent) => {
|
||||
if (mode !== 'draw' || !isDrawingRef.current) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const pt = getPoint(e);
|
||||
if (!pt) return;
|
||||
setCurrentStroke(prev => {
|
||||
if (!prev) return prev;
|
||||
return { ...prev, points: [...prev.points, pt] };
|
||||
setCurrentStroke((prev) => {
|
||||
if (!prev) return prev;
|
||||
return { ...prev, points: [...prev.points, pt] };
|
||||
});
|
||||
}, [mode, getPoint]);
|
||||
},
|
||||
[mode, getPoint]
|
||||
);
|
||||
|
||||
const handlePointerUp = useCallback((e: React.MouseEvent | React.TouchEvent) => {
|
||||
const handlePointerUp = useCallback(
|
||||
(e: React.MouseEvent | React.TouchEvent) => {
|
||||
if (mode !== 'draw' || !isDrawingRef.current) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
isDrawingRef.current = false;
|
||||
setCurrentStroke(prev => {
|
||||
if (prev && prev.points.length >= 2) {
|
||||
setStrokes(s => [...s, prev]);
|
||||
}
|
||||
return null;
|
||||
setCurrentStroke((prev) => {
|
||||
if (prev && prev.points.length >= 2) {
|
||||
setStrokes((s) => [...s, prev]);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}, [mode]);
|
||||
},
|
||||
[mode]
|
||||
);
|
||||
|
||||
const handleUndo = useCallback(() => {
|
||||
setStrokes(prev => prev.slice(0, -1));
|
||||
setStrokes((prev) => prev.slice(0, -1));
|
||||
}, []);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setStrokes([]);
|
||||
setStrokes([]);
|
||||
}, []);
|
||||
|
||||
// View mode: click to dismiss
|
||||
const handleViewClick = useCallback((e: React.MouseEvent) => {
|
||||
const handleViewClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (mode === 'view') {
|
||||
e.stopPropagation();
|
||||
onDismiss?.();
|
||||
e.stopPropagation();
|
||||
onDismiss?.();
|
||||
}
|
||||
}, [mode, onDismiss]);
|
||||
},
|
||||
[mode, onDismiss]
|
||||
);
|
||||
|
||||
if (mode === 'view') {
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="absolute inset-0 z-[60] cursor-pointer"
|
||||
onClick={handleViewClick}
|
||||
title="Click to dismiss annotation"
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="w-full h-full pointer-events-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="absolute inset-0 z-[60] cursor-pointer"
|
||||
onClick={handleViewClick}
|
||||
title="Click to dismiss annotation"
|
||||
>
|
||||
<canvas ref={canvasRef} className="w-full h-full pointer-events-none" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="absolute inset-0 z-[60]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="w-full h-full cursor-crosshair"
|
||||
onMouseDown={handlePointerDown}
|
||||
onMouseMove={handlePointerMove}
|
||||
onMouseUp={handlePointerUp}
|
||||
onMouseLeave={handlePointerUp}
|
||||
onTouchStart={handlePointerDown}
|
||||
onTouchMove={handlePointerMove}
|
||||
onTouchEnd={handlePointerUp}
|
||||
/>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="absolute inset-0 z-[60]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="w-full h-full cursor-crosshair"
|
||||
onMouseDown={handlePointerDown}
|
||||
onMouseMove={handlePointerMove}
|
||||
onMouseUp={handlePointerUp}
|
||||
onMouseLeave={handlePointerUp}
|
||||
onTouchStart={handlePointerDown}
|
||||
onTouchMove={handlePointerMove}
|
||||
onTouchEnd={handlePointerUp}
|
||||
/>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="absolute top-3 left-1/2 -translate-x-1/2 flex items-center justify-center flex-wrap gap-x-2 gap-y-2 w-[calc(100%-24px)] max-w-fit bg-background/90 backdrop-blur-sm rounded-lg px-3 py-2 shadow-lg border z-[70]">
|
||||
{/* Colors */}
|
||||
<div className="flex items-center justify-center flex-wrap gap-1.5">
|
||||
{COLORS.map(c => (
|
||||
<button
|
||||
key={c}
|
||||
className="w-6 h-6 rounded-full border-2 transition-transform hover:scale-110 shrink-0"
|
||||
style={{
|
||||
backgroundColor: c,
|
||||
borderColor: color === c ? 'white' : 'transparent',
|
||||
boxShadow: color === c ? `0 0 0 2px ${c}` : 'none',
|
||||
}}
|
||||
onClick={() => setColor(c)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* Toolbar */}
|
||||
<div className="absolute top-3 left-1/2 -translate-x-1/2 flex items-center justify-center flex-wrap gap-x-2 gap-y-2 w-[calc(100%-24px)] max-w-fit bg-background/90 backdrop-blur-sm rounded-lg px-3 py-2 shadow-lg border z-[70]">
|
||||
{/* Colors */}
|
||||
<div className="flex items-center justify-center flex-wrap gap-1.5">
|
||||
{COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
className="w-6 h-6 rounded-full border-2 transition-transform hover:scale-110 shrink-0"
|
||||
style={{
|
||||
backgroundColor: c,
|
||||
borderColor: color === c ? 'white' : 'transparent',
|
||||
boxShadow: color === c ? `0 0 0 2px ${c}` : 'none',
|
||||
}}
|
||||
onClick={() => setColor(c)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="hidden sm:block w-px h-6 bg-border mx-1" />
|
||||
<div className="hidden sm:block w-px h-6 bg-border mx-1" />
|
||||
|
||||
{/* Brush size */}
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setWidth(w => Math.max(MIN_WIDTH, w - 1))}>
|
||||
<Minus className="h-3 w-3" />
|
||||
</Button>
|
||||
<span className="text-xs tabular-nums w-4 text-center">{width}</span>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setWidth(w => Math.min(MAX_WIDTH, w + 1))}>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
{/* Brush size */}
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => setWidth((w) => Math.max(MIN_WIDTH, w - 1))}
|
||||
>
|
||||
<Minus className="h-3 w-3" />
|
||||
</Button>
|
||||
<span className="text-xs tabular-nums w-4 text-center">{width}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => setWidth((w) => Math.min(MAX_WIDTH, w + 1))}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="hidden sm:block w-px h-6 bg-border mx-1" />
|
||||
<div className="hidden sm:block w-px h-6 bg-border mx-1" />
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={handleUndo} disabled={strokes.length === 0} title="Undo">
|
||||
<Undo2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive hover:bg-destructive/10" onClick={handleClear} disabled={strokes.length === 0} title="Clear all">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={handleUndo}
|
||||
disabled={strokes.length === 0}
|
||||
title="Undo"
|
||||
>
|
||||
<Undo2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive hover:bg-destructive/10"
|
||||
onClick={handleClear}
|
||||
disabled={strokes.length === 0}
|
||||
title="Clear all"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="hidden sm:block w-px h-6 bg-border mx-1" />
|
||||
<div className="hidden sm:block w-px h-6 bg-border mx-1" />
|
||||
|
||||
{/* Close */}
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onCancel} title="Close annotation tool">
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{/* Close */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onCancel}
|
||||
title="Close annotation tool"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user