feat(P4B.T4): WhiteboardCanvas — pen/eraser drawing engine

This commit is contained in:
byGalax
2026-05-16 20:27:09 +02:00
parent 2243c646ac
commit be6f5b9c6f
@@ -0,0 +1,142 @@
import { useEffect, useRef, useState } from 'react';
import type { WhiteboardStroke } from '@chat-app/shared/chat';
export type WhiteboardTool = 'pen' | 'eraser';
export type WhiteboardColor = '#000000' | '#ef4444' | '#3b82f6' | '#22c55e' | '#facc15' | '#a855f7';
export type WhiteboardWidth = 2 | 4 | 8;
export interface WhiteboardStrokePayload {
tool: WhiteboardTool;
color: WhiteboardColor;
width: WhiteboardWidth;
// [x, y, t-ms-since-stroke-start]
points: Array<[number, number, number]>;
}
interface Props {
strokes: WhiteboardStroke[];
tool: WhiteboardTool;
color: WhiteboardColor;
width: WhiteboardWidth;
onStroke: (payload: WhiteboardStrokePayload) => void;
logicalWidth?: number;
logicalHeight?: number;
}
const DEFAULT_LOGICAL_W = 1280;
const DEFAULT_LOGICAL_H = 720;
export function WhiteboardCanvas({
strokes,
tool,
color,
width,
onStroke,
logicalWidth = DEFAULT_LOGICAL_W,
logicalHeight = DEFAULT_LOGICAL_H,
}: Props) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const draftRef = useRef<WhiteboardStrokePayload | null>(null);
const strokeStartRef = useRef<number>(0);
const [, forceTick] = useState(0);
useEffect(() => {
const cv = canvasRef.current;
if (!cv) return;
cv.width = logicalWidth;
cv.height = logicalHeight;
const ctx = cv.getContext('2d');
if (!ctx) return;
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, cv.width, cv.height);
for (const s of strokes) {
const payload = s.strokeJson as Partial<WhiteboardStrokePayload> | null;
if (payload) renderStroke(ctx, payload);
}
if (draftRef.current) renderStroke(ctx, draftRef.current);
});
function canvasPoint(e: React.PointerEvent<HTMLCanvasElement>): [number, number] {
const cv = canvasRef.current!;
const rect = cv.getBoundingClientRect();
const scaleX = cv.width / rect.width;
const scaleY = cv.height / rect.height;
return [(e.clientX - rect.left) * scaleX, (e.clientY - rect.top) * scaleY];
}
const handlePointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
const cv = canvasRef.current;
if (!cv) return;
cv.setPointerCapture(e.pointerId);
const [x, y] = canvasPoint(e);
strokeStartRef.current = Date.now();
draftRef.current = {
tool,
color,
width,
points: [[x, y, 0]],
};
forceTick((n) => n + 1);
};
const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
if (!draftRef.current) return;
const [x, y] = canvasPoint(e);
draftRef.current.points.push([x, y, Date.now() - strokeStartRef.current]);
forceTick((n) => n + 1);
};
const handlePointerUp = (e: React.PointerEvent<HTMLCanvasElement>) => {
const cv = canvasRef.current;
if (cv && cv.hasPointerCapture(e.pointerId)) cv.releasePointerCapture(e.pointerId);
const draft = draftRef.current;
draftRef.current = null;
if (!draft) return;
if (draft.points.length < 2) {
forceTick((n) => n + 1);
return;
}
onStroke(draft);
forceTick((n) => n + 1);
};
return (
<canvas
ref={canvasRef}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
className="block max-h-full max-w-full cursor-crosshair touch-none rounded-lg border border-line/40 bg-white shadow-2xl"
style={{ aspectRatio: logicalWidth + ' / ' + logicalHeight, width: '100%' }}
/>
);
}
function renderStroke(
ctx: CanvasRenderingContext2D,
s: Partial<WhiteboardStrokePayload>,
): void {
const points = Array.isArray(s.points) ? s.points : null;
if (!points || points.length < 1) return;
ctx.save();
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.lineWidth = typeof s.width === 'number' ? s.width : 4;
if (s.tool === 'eraser') {
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = Math.max(8, (typeof s.width === 'number' ? s.width : 4) * 4);
} else {
ctx.strokeStyle = typeof s.color === 'string' ? s.color : '#000000';
}
ctx.beginPath();
ctx.moveTo(points[0]![0], points[0]![1]);
for (let i = 1; i < points.length; i++) {
ctx.lineTo(points[i]![0], points[i]![1]);
}
ctx.stroke();
ctx.restore();
}