143 lines
4.2 KiB
TypeScript
143 lines
4.2 KiB
TypeScript
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();
|
|
}
|