191 lines
6.0 KiB
TypeScript
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;
|