"use client"; import { useRef, useState, useEffect, useCallback } from "react"; import Image from "next/image"; import { X, Undo2, Trash2, Save, Loader2, Pen, Eraser, LayoutTemplate, XCircle } from "lucide-react"; import { cn } from "@/lib/utils"; import type { TakeAttachmentData } from "./TakeEditorPanel"; // ─── Types ──────────────────────────────────────────────────────────────────── interface Stroke { color: string; width: number; points: { x: number; y: number }[]; isEraser: boolean; } interface SketchTemplate { id: string; name: string; fileUrl: string; } interface Props { takeId: string; onSaved: (attachment: TakeAttachmentData) => void; onClose: () => void; } // ─── Config ─────────────────────────────────────────────────────────────────── const BG_COLOR = "#1c1c1e"; const COLORS = [ "#ffffff", "#d4d4d8", "#f87171", "#fb923c", "#facc15", "#4ade80", "#60a5fa", "#c084fc", ]; const BRUSH_SIZES = [2, 4, 8, 14, 24]; // ─── Template picker overlay ────────────────────────────────────────────────── function TemplatePicker({ current, onSelect, onClear, onClose, }: { current: SketchTemplate | null; onSelect: (t: SketchTemplate) => void; onClear: () => void; onClose: () => void; }) { const [templates, setTemplates] = useState([]); const [loading, setLoading] = useState(true); useEffect(() => { fetch("/api/sketch-templates") .then((r) => r.json()) .then((d) => setTemplates(d.templates ?? [])) .catch(() => {}) .finally(() => setLoading(false)); }, []); return (
e.stopPropagation()} >

Choose a sketch template

{loading ? (
Loading templates…
) : ( <>
{templates.map((t) => ( ))}
{templates.length === 0 && (

No templates yet — upload some in Settings → Sketch Templates.

)} )}
); } // ─── Main SketchPad ─────────────────────────────────────────────────────────── export function SketchPad({ takeId, onSaved, onClose }: Props) { // Two-canvas stack: // bgCanvas — BG_COLOR + optional template image (no pointer events) // drawCanvas — transparent, user strokes, pointer events on top const bgCanvasRef = useRef(null); const drawCanvasRef = useRef(null); const containerRef = useRef(null); const strokesRef = useRef([]); const currentStrokeRef = useRef(null); const isDrawingRef = useRef(false); const templateImageRef = useRef(null); const [strokeCount, setStrokeCount] = useState(0); const [color, setColor] = useState("#ffffff"); const [brushSize, setBrushSize] = useState(4); const [isEraser, setIsEraser] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [showTemplatePicker, setShowTemplatePicker] = useState(false); const [activeTemplate, setActiveTemplate] = useState(null); // ── Background canvas: BG_COLOR + scaled template ──────────────────────── const redrawBg = useCallback(() => { const canvas = bgCanvasRef.current; if (!canvas) return; const ctx = canvas.getContext("2d"); if (!ctx) return; ctx.fillStyle = BG_COLOR; ctx.fillRect(0, 0, canvas.width, canvas.height); const img = templateImageRef.current; if (img) { const scale = Math.min(canvas.width / img.naturalWidth, canvas.height / img.naturalHeight); const w = img.naturalWidth * scale; const h = img.naturalHeight * scale; ctx.drawImage(img, (canvas.width - w) / 2, (canvas.height - h) / 2, w, h); } }, []); // ── Drawing canvas: transparent bg, strokes with proper eraser ─────────── const redrawDraw = useCallback(() => { const canvas = drawCanvasRef.current; if (!canvas) return; const ctx = canvas.getContext("2d"); if (!ctx) return; ctx.clearRect(0, 0, canvas.width, canvas.height); for (const stroke of strokesRef.current) { ctx.save(); ctx.lineWidth = stroke.width; ctx.lineCap = "round"; ctx.lineJoin = "round"; if (stroke.isEraser) { ctx.globalCompositeOperation = "destination-out"; ctx.strokeStyle = "rgba(0,0,0,1)"; ctx.fillStyle = "rgba(0,0,0,1)"; } else { ctx.globalCompositeOperation = "source-over"; ctx.strokeStyle = stroke.color; ctx.fillStyle = stroke.color; } ctx.beginPath(); if (stroke.points.length === 1) { 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(); } ctx.restore(); } }, []); // ── Resize both canvases together ───────────────────────────────────────── const fit = useCallback(() => { const container = containerRef.current; if (!container) return; const { width, height } = container.getBoundingClientRect(); const w = Math.floor(width); const h = Math.floor(height); for (const ref of [bgCanvasRef, drawCanvasRef]) { const c = ref.current; if (c) { c.width = w; c.height = h; } } redrawBg(); redrawDraw(); }, [redrawBg, redrawDraw]); useEffect(() => { fit(); window.addEventListener("resize", fit); return () => window.removeEventListener("resize", fit); }, [fit]); // ── Load template image whenever activeTemplate changes ─────────────────── useEffect(() => { if (!activeTemplate) { templateImageRef.current = null; redrawBg(); return; } const img = new window.Image(); img.crossOrigin = "anonymous"; img.onload = () => { templateImageRef.current = img; redrawBg(); }; img.src = activeTemplate.fileUrl; }, [activeTemplate, redrawBg]); // ── Keyboard shortcuts ──────────────────────────────────────────────────── useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { if (showTemplatePicker) { setShowTemplatePicker(false); return; } 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); redrawDraw(); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [onClose, redrawDraw, showTemplatePicker]); // ── Pointer coordinate helper ───────────────────────────────────────────── const getPos = (e: React.PointerEvent) => { const canvas = drawCanvasRef.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) => { e.preventDefault(); (e.target as HTMLCanvasElement).setPointerCapture(e.pointerId); isDrawingRef.current = true; const pos = getPos(e); const stroke: Stroke = { color: isEraser ? "" : color, width: isEraser ? brushSize * 4 : brushSize, points: [pos], isEraser, }; currentStrokeRef.current = stroke; const ctx = drawCanvasRef.current!.getContext("2d")!; ctx.save(); if (isEraser) { ctx.globalCompositeOperation = "destination-out"; ctx.fillStyle = "rgba(0,0,0,1)"; } else { ctx.fillStyle = color; } ctx.beginPath(); ctx.arc(pos.x, pos.y, stroke.width / 2, 0, Math.PI * 2); ctx.fill(); ctx.restore(); }; const onPointerMove = (e: React.PointerEvent) => { 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); const ctx = drawCanvasRef.current!.getContext("2d")!; ctx.save(); ctx.lineWidth = stroke.width; ctx.lineCap = "round"; ctx.lineJoin = "round"; if (isEraser) { ctx.globalCompositeOperation = "destination-out"; ctx.strokeStyle = "rgba(0,0,0,1)"; } else { ctx.globalCompositeOperation = "source-over"; ctx.strokeStyle = stroke.color; } ctx.beginPath(); ctx.moveTo(prev.x, prev.y); ctx.lineTo(pos.x, pos.y); ctx.stroke(); ctx.restore(); }; 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); redrawDraw(); }; const clear = () => { strokesRef.current = []; setStrokeCount(0); redrawDraw(); }; // ── Save: composite bg + draw layers into one PNG ───────────────────────── const save = async () => { const bgCanvas = bgCanvasRef.current; const drawCanvas = drawCanvasRef.current; if (!bgCanvas || !drawCanvas) return; setSaving(true); setError(null); try { const composite = document.createElement("canvas"); composite.width = bgCanvas.width; composite.height = bgCanvas.height; const ctx = composite.getContext("2d")!; ctx.drawImage(bgCanvas, 0, 0); ctx.drawImage(drawCanvas, 0, 0); const blob = await new Promise((resolve) => composite.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: activeTemplate ? `Sketch — ${activeTemplate.name}` : "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 previewColor = isEraser ? "#ef4444" : color; return (
{/* ── Toolbar ── */}
{/* Pen / Eraser */}
{/* Colours */}
{COLORS.map((c) => (
{/* Brush sizes */}
{BRUSH_SIZES.map((s) => ( ))}
{/* Template selector */} {activeTemplate && ( )}
{error && {error}} {/* Actions */}
{/* ── Canvas area ── */}
{/* Background layer: BG_COLOR + optional template */} {/* Drawing layer: transparent, receives pointer events */} {strokeCount === 0 && !activeTemplate && (

Draw your sketch…

)} {/* Template picker modal */} {showTemplatePicker && ( { setActiveTemplate(t); setShowTemplatePicker(false); }} onClear={() => { setActiveTemplate(null); setShowTemplatePicker(false); }} onClose={() => setShowTemplatePicker(false)} /> )}
); } 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) => { 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) => { 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) => { 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((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 (
{/* ── Toolbar ── */}
{/* Pen / Eraser toggle */}
{/* Colour swatches */}
{COLORS.map((c) => (
{/* Brush sizes */}
{BRUSH_SIZES.map((s) => ( ))}
{error && {error}} {/* Action buttons */}
{/* ── Canvas ── */}
{strokeCount === 0 && (

Draw your sketch…

)}
); }