2025-10-08 18:10:07 -06:00

191 lines
6.0 KiB
TypeScript

"use client";
import React, { useEffect, useRef, useState, useCallback } from "react";
import { cn } from "@/lib/utils";
type Point = { x: number; y: number };
const WhiteboardCanvas = () => {
const containerRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const [isDrawing, setIsDrawing] = useState(false);
const [lastPoint, setLastPoint] = useState<Point | null>(null);
const [strokeColor, setStrokeColor] = useState<string>("#111111");
const [strokeWidth, setStrokeWidth] = useState<number>(3);
// Setup canvas with proper DPR scaling
const resizeCanvas = useCallback(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) return;
const dpr = window.devicePixelRatio || 1;
const rect = container.getBoundingClientRect();
canvas.width = Math.max(320, Math.floor(rect.width * dpr));
canvas.height = Math.max(320, Math.floor((rect.height - 60) * dpr)); // minus controls height
canvas.style.width = `${Math.floor(canvas.width / dpr)}px`;
canvas.style.height = `${Math.floor(canvas.height / dpr)}px`;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctxRef.current = ctx;
// When resizing, keep existing content by redrawing the bitmap:
// Create a temp bitmap from existing content before resizing (if any).
// Note: For simplicity, we won't preserve content across resizes in this minimal version.
// Initialize canvas background as transparent. Users can export PNG.
ctx.lineCap = "round";
ctx.lineJoin = "round";
}, []);
useEffect(() => {
resizeCanvas();
const onResize = () => resizeCanvas();
window.addEventListener("resize", onResize);
// If container changes size (e.g., sheet opening), observe it
const ro = new ResizeObserver(() => resizeCanvas());
if (containerRef.current) {
ro.observe(containerRef.current);
}
return () => {
window.removeEventListener("resize", onResize);
ro.disconnect();
};
}, [resizeCanvas]);
const getRelativePoint = (e: PointerEvent | React.PointerEvent<HTMLCanvasElement>): Point => {
const canvas = canvasRef.current!;
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const x = (e.clientX - rect.left) * dpr;
const y = (e.clientY - rect.top) * dpr;
return { x, y };
};
const handlePointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
e.currentTarget.setPointerCapture(e.pointerId);
setIsDrawing(true);
const p = getRelativePoint(e);
setLastPoint(p);
};
const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
if (!isDrawing || !lastPoint || !ctxRef.current) return;
const p = getRelativePoint(e);
const ctx = ctxRef.current;
ctx.strokeStyle = strokeColor;
const dpr = window.devicePixelRatio || 1;
ctx.lineWidth = Math.max(1, strokeWidth * dpr);
ctx.beginPath();
ctx.moveTo(lastPoint.x, lastPoint.y);
ctx.lineTo(p.x, p.y);
ctx.stroke();
setLastPoint(p);
};
const handlePointerUp = (e: React.PointerEvent<HTMLCanvasElement>) => {
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
setIsDrawing(false);
setLastPoint(null);
};
const handlePointerLeave = () => {
setIsDrawing(false);
setLastPoint(null);
};
const handleClear = () => {
const canvas = canvasRef.current;
const ctx = ctxRef.current;
if (!canvas || !ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
};
const handleDownload = () => {
const canvas = canvasRef.current;
if (!canvas) return;
const link = document.createElement("a");
link.download = "whiteboard.png";
link.href = canvas.toDataURL("image/png");
link.click();
};
return (
<div ref={containerRef} className="w-full h-[70vh] min-h-[360px]">
<div className="flex items-center gap-2 pb-3">
<label className="text-sm" htmlFor="stroke-color">
Color
</label>
<input
id="stroke-color"
aria-label="Stroke color"
type="color"
value={strokeColor}
onChange={(e) => setStrokeColor(e.target.value)}
className="h-8 w-10 rounded border border-black/10 dark:border-white/10 bg-transparent"
/>
<label className="text-sm pl-2" htmlFor="stroke-width">
Size
</label>
<input
id="stroke-width"
aria-label="Stroke width"
type="range"
min={1}
max={20}
value={strokeWidth}
onChange={(e) => setStrokeWidth(Number(e.target.value))}
className="w-32 accent-[color:var(--accent)]"
/>
<button
type="button"
onClick={handleClear}
className={cn(
"px-3 py-1.5 text-sm rounded-md transition-colors",
"bg-black/80 text-white hover:bg-black",
"dark:bg-white/10 dark:text-white dark:hover:bg-white/20"
)}
aria-label="Clear canvas"
>
Clear
</button>
<button
type="button"
onClick={handleDownload}
className={cn(
"px-3 py-1.5 text-sm rounded-md transition-colors",
"bg-[color:var(--accent)] text-black hover:brightness-110"
)}
aria-label="Download image"
>
Download
</button>
</div>
<canvas
ref={canvasRef}
className={cn(
"w-full h-[calc(70vh-3rem)] min-h-[300px] rounded-md",
"bg-white dark:bg-neutral-900 border border-black/10 dark:border-white/10 touch-none"
)}
role="img"
aria-label="Whiteboard drawing area"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerLeave}
/>
</div>
);
};
export default WhiteboardCanvas;