@@ -570,324 +570,4 @@ export function SketchPad({ takeId, onSaved, onClose }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!canvas) return;
|
|
||||||
const ctx = canvas.getContext("2d");
|
|
||||||
if (!ctx) return;
|
|
||||||
|
|
||||||
ctx.fillStyle = BG_COLOR;
|
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
||||||
|
|
||||||
for (const stroke of strokesRef.current) {
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.strokeStyle = stroke.color;
|
|
||||||
ctx.fillStyle = stroke.color;
|
|
||||||
ctx.lineWidth = stroke.width;
|
|
||||||
ctx.lineCap = "round";
|
|
||||||
ctx.lineJoin = "round";
|
|
||||||
|
|
||||||
if (stroke.points.length === 1) {
|
|
||||||
// Single tap → dot
|
|
||||||
ctx.arc(stroke.points[0].x, stroke.points[0].y, stroke.width / 2, 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
} else {
|
|
||||||
ctx.moveTo(stroke.points[0].x, stroke.points[0].y);
|
|
||||||
for (let i = 1; i < stroke.points.length; i++) {
|
|
||||||
ctx.lineTo(stroke.points[i].x, stroke.points[i].y);
|
|
||||||
}
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// ── Size canvas to fill container on mount + window resize ────────────────
|
|
||||||
useEffect(() => {
|
|
||||||
const canvas = canvasRef.current;
|
|
||||||
if (!canvas) return;
|
|
||||||
|
|
||||||
const fit = () => {
|
|
||||||
const parent = canvas.parentElement;
|
|
||||||
if (!parent) return;
|
|
||||||
const { width, height } = parent.getBoundingClientRect();
|
|
||||||
canvas.width = Math.floor(width);
|
|
||||||
canvas.height = Math.floor(height);
|
|
||||||
redraw();
|
|
||||||
};
|
|
||||||
|
|
||||||
fit();
|
|
||||||
window.addEventListener("resize", fit);
|
|
||||||
return () => window.removeEventListener("resize", fit);
|
|
||||||
}, [redraw]);
|
|
||||||
|
|
||||||
// ── Keyboard shortcuts ────────────────────────────────────────────────────
|
|
||||||
useEffect(() => {
|
|
||||||
const onKey = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape") onClose();
|
|
||||||
if (e.key === "p" && !e.metaKey && !e.ctrlKey) setIsEraser(false);
|
|
||||||
if (e.key === "e" && !e.metaKey && !e.ctrlKey) setIsEraser(true);
|
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === "z") {
|
|
||||||
e.preventDefault();
|
|
||||||
strokesRef.current = strokesRef.current.slice(0, -1);
|
|
||||||
setStrokeCount(strokesRef.current.length);
|
|
||||||
redraw();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener("keydown", onKey);
|
|
||||||
return () => window.removeEventListener("keydown", onKey);
|
|
||||||
}, [onClose, redraw]);
|
|
||||||
|
|
||||||
// ── Coordinate helper — CSS px → canvas buffer px ────────────────────────
|
|
||||||
const getPos = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
|
||||||
const canvas = canvasRef.current!;
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
return {
|
|
||||||
x: (e.clientX - rect.left) * (canvas.width / rect.width),
|
|
||||||
y: (e.clientY - rect.top) * (canvas.height / rect.height),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Pointer handlers ──────────────────────────────────────────────────────
|
|
||||||
const onPointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
(e.target as HTMLCanvasElement).setPointerCapture(e.pointerId);
|
|
||||||
isDrawingRef.current = true;
|
|
||||||
|
|
||||||
const pos = getPos(e);
|
|
||||||
const stroke: Stroke = {
|
|
||||||
color: isEraser ? BG_COLOR : color,
|
|
||||||
width: isEraser ? brushSize * 4 : brushSize,
|
|
||||||
points: [pos],
|
|
||||||
};
|
|
||||||
currentStrokeRef.current = stroke;
|
|
||||||
|
|
||||||
// Immediate dot feedback
|
|
||||||
const ctx = canvasRef.current!.getContext("2d")!;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.fillStyle = stroke.color;
|
|
||||||
ctx.arc(pos.x, pos.y, stroke.width / 2, 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
};
|
|
||||||
|
|
||||||
const onPointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
|
||||||
if (!isDrawingRef.current || !currentStrokeRef.current) return;
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const pos = getPos(e);
|
|
||||||
const stroke = currentStrokeRef.current;
|
|
||||||
const prev = stroke.points[stroke.points.length - 1];
|
|
||||||
stroke.points.push(pos);
|
|
||||||
|
|
||||||
// Incremental draw — no full redraw needed during stroke
|
|
||||||
const ctx = canvasRef.current!.getContext("2d")!;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.strokeStyle = stroke.color;
|
|
||||||
ctx.lineWidth = stroke.width;
|
|
||||||
ctx.lineCap = "round";
|
|
||||||
ctx.lineJoin = "round";
|
|
||||||
ctx.moveTo(prev.x, prev.y);
|
|
||||||
ctx.lineTo(pos.x, pos.y);
|
|
||||||
ctx.stroke();
|
|
||||||
};
|
|
||||||
|
|
||||||
const onPointerUp = () => {
|
|
||||||
if (!isDrawingRef.current) return;
|
|
||||||
isDrawingRef.current = false;
|
|
||||||
if (currentStrokeRef.current) {
|
|
||||||
strokesRef.current = [...strokesRef.current, currentStrokeRef.current];
|
|
||||||
setStrokeCount(strokesRef.current.length);
|
|
||||||
currentStrokeRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const undo = () => {
|
|
||||||
strokesRef.current = strokesRef.current.slice(0, -1);
|
|
||||||
setStrokeCount(strokesRef.current.length);
|
|
||||||
redraw();
|
|
||||||
};
|
|
||||||
|
|
||||||
const clear = () => {
|
|
||||||
strokesRef.current = [];
|
|
||||||
setStrokeCount(0);
|
|
||||||
redraw();
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Save ──────────────────────────────────────────────────────────────────
|
|
||||||
const save = async () => {
|
|
||||||
const canvas = canvasRef.current;
|
|
||||||
if (!canvas) return;
|
|
||||||
setSaving(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const blob = await new Promise<Blob | null>((resolve) =>
|
|
||||||
canvas.toBlob(resolve, "image/png")
|
|
||||||
);
|
|
||||||
if (!blob) throw new Error("Canvas export failed");
|
|
||||||
|
|
||||||
const fileName = `sketch_${Date.now()}.png`;
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append("file", new File([blob], fileName, { type: "image/png" }));
|
|
||||||
fd.append("type", "image");
|
|
||||||
|
|
||||||
const uploadRes = await fetch("/api/upload", { method: "POST", body: fd });
|
|
||||||
if (!uploadRes.ok) throw new Error("Upload failed");
|
|
||||||
const { url, key } = await uploadRes.json();
|
|
||||||
|
|
||||||
const attRes = await fetch(`/api/shoot-log/takes/${takeId}/attachments`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
fileUrl: url,
|
|
||||||
fileKey: key ?? "",
|
|
||||||
fileName,
|
|
||||||
fileSize: blob.size,
|
|
||||||
fileType: "IMAGE",
|
|
||||||
category: "MISCELLANEOUS",
|
|
||||||
caption: "Sketch",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
if (!attRes.ok) throw new Error("Failed to save attachment");
|
|
||||||
const { attachment } = await attRes.json();
|
|
||||||
onSaved(attachment as TakeAttachmentData);
|
|
||||||
onClose();
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Save failed");
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Render ───────────────────────────────────────────────────────────────
|
|
||||||
const activeColor = isEraser ? BG_COLOR : color;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 z-50 flex flex-col" style={{ background: BG_COLOR }}>
|
|
||||||
|
|
||||||
{/* ── Toolbar ── */}
|
|
||||||
<div className="flex items-center gap-3 px-4 py-2 border-b border-zinc-800 bg-zinc-900/80 backdrop-blur shrink-0 flex-wrap">
|
|
||||||
|
|
||||||
{/* Pen / Eraser toggle */}
|
|
||||||
<div className="flex items-center gap-0.5 bg-zinc-800 rounded-lg p-0.5">
|
|
||||||
<button
|
|
||||||
onClick={() => setIsEraser(false)}
|
|
||||||
className={cn(
|
|
||||||
"p-1.5 rounded-md transition-colors",
|
|
||||||
!isEraser ? "bg-zinc-700 text-white shadow" : "text-zinc-500 hover:text-zinc-300"
|
|
||||||
)}
|
|
||||||
title="Pen (P)"
|
|
||||||
>
|
|
||||||
<Pen className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setIsEraser(true)}
|
|
||||||
className={cn(
|
|
||||||
"p-1.5 rounded-md transition-colors",
|
|
||||||
isEraser ? "bg-zinc-700 text-red-400 shadow" : "text-zinc-500 hover:text-zinc-300"
|
|
||||||
)}
|
|
||||||
title="Eraser (E)"
|
|
||||||
>
|
|
||||||
<Eraser className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-px h-5 bg-zinc-700" />
|
|
||||||
|
|
||||||
{/* Colour swatches */}
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
{COLORS.map((c) => (
|
|
||||||
<button
|
|
||||||
key={c}
|
|
||||||
onClick={() => { setColor(c); setIsEraser(false); }}
|
|
||||||
className={cn(
|
|
||||||
"rounded-full border-2 transition-all",
|
|
||||||
color === c && !isEraser
|
|
||||||
? "border-amber-400 scale-125"
|
|
||||||
: "border-zinc-700 hover:border-zinc-400"
|
|
||||||
)}
|
|
||||||
style={{ width: 20, height: 20, backgroundColor: c }}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-px h-5 bg-zinc-700" />
|
|
||||||
|
|
||||||
{/* Brush sizes */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{BRUSH_SIZES.map((s) => (
|
|
||||||
<button
|
|
||||||
key={s}
|
|
||||||
onClick={() => setBrushSize(s)}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center justify-center rounded-full transition-all bg-zinc-800",
|
|
||||||
brushSize === s ? "ring-2 ring-amber-400" : "opacity-50 hover:opacity-100"
|
|
||||||
)}
|
|
||||||
style={{ width: s + 10, height: s + 10 }}
|
|
||||||
title={`${s}px`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="rounded-full"
|
|
||||||
style={{ width: s, height: s, backgroundColor: activeColor === BG_COLOR ? "#ef4444" : activeColor }}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1" />
|
|
||||||
|
|
||||||
{error && <span className="text-xs text-red-400">{error}</span>}
|
|
||||||
|
|
||||||
{/* Action buttons */}
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<button
|
|
||||||
onClick={undo}
|
|
||||||
disabled={strokeCount === 0}
|
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium text-zinc-400 hover:text-white hover:bg-zinc-800 disabled:opacity-30 transition-colors"
|
|
||||||
title="Undo (Ctrl+Z)"
|
|
||||||
>
|
|
||||||
<Undo2 className="h-3.5 w-3.5" />
|
|
||||||
Undo
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={clear}
|
|
||||||
disabled={strokeCount === 0}
|
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium text-zinc-400 hover:text-white hover:bg-zinc-800 disabled:opacity-30 transition-colors"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
Clear
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={save}
|
|
||||||
disabled={saving || strokeCount === 0}
|
|
||||||
className="flex items-center gap-1.5 px-4 py-1.5 rounded-lg text-xs font-semibold bg-amber-600 hover:bg-amber-500 text-white disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />}
|
|
||||||
{saving ? "Saving…" : "Save Sketch"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="p-1.5 rounded-lg text-zinc-500 hover:text-white hover:bg-zinc-800 transition-colors ml-1"
|
|
||||||
title="Discard (Esc)"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Canvas ── */}
|
|
||||||
<div className="flex-1 relative overflow-hidden">
|
|
||||||
{strokeCount === 0 && (
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none select-none">
|
|
||||||
<p className="text-zinc-700 text-base">Draw your sketch…</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<canvas
|
|
||||||
ref={canvasRef}
|
|
||||||
className="absolute inset-0 cursor-crosshair"
|
|
||||||
style={{ touchAction: "none" }}
|
|
||||||
onPointerDown={onPointerDown}
|
|
||||||
onPointerMove={onPointerMove}
|
|
||||||
onPointerUp={onPointerUp}
|
|
||||||
onPointerLeave={onPointerUp}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user