diff --git a/client/src/components/canvas/GameCanvas.tsx b/client/src/components/canvas/GameCanvas.tsx index 9988a0cdf..b3c346597 100644 --- a/client/src/components/canvas/GameCanvas.tsx +++ b/client/src/components/canvas/GameCanvas.tsx @@ -1,5 +1,6 @@ import { MouseEvent as ReactMouseEvent, TouchEvent as ReactTouchEvent, useCallback, useEffect, useRef } from 'react'; import { PlayerRole, RoomStatus } from '@troublepainter/core'; +import { Point } from '@troublepainter/core'; import { Canvas } from '@/components/canvas/CanvasUI'; import { COLORS_INFO, DEFAULT_MAX_PIXELS, MAINCANVAS_RESOLUTION_WIDTH } from '@/constants/canvasConstants'; import { handleInCanvas, handleOutCanvas } from '@/handlers/canvas/cursorInOutHandler'; @@ -9,8 +10,20 @@ import { useDrawing } from '@/hooks/canvas/useDrawing'; import { useDrawingSocket } from '@/hooks/socket/useDrawingSocket'; import { useCoordinateScale } from '@/hooks/useCoordinateScale'; import { CanvasEventHandlers } from '@/types/canvas.types'; -import { getCanvasContext } from '@/utils/getCanvasContext'; -import { getDrawPoint } from '@/utils/getDrawPoint'; + +const getCanvasPoint = ( + e: ReactMouseEvent | ReactTouchEvent, + canvas: HTMLCanvasElement, +): Point => { + const rect = canvas.getBoundingClientRect(); + const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX; + const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY; + + return { + x: ((clientX - rect.left) * canvas.width) / rect.width, + y: ((clientY - rect.top) * canvas.height) / rect.height, + }; +}; interface GameCanvasProps { role: PlayerRole; @@ -103,14 +116,12 @@ const GameCanvas = ({ role, maxPixels = DEFAULT_MAX_PIXELS, currentRound, roomSt })); const handleDrawStart = useCallback( - (e: ReactMouseEvent | ReactTouchEvent) => { - if (!isConnected) return; + async (e: ReactMouseEvent | ReactTouchEvent) => { + if (!isConnected || !canvasRef.current) return; - const { canvas } = getCanvasContext(canvasRef); - const point = getDrawPoint(e, canvas); - const convertPoint = convertCoordinate(point); + const point = getCanvasPoint(e, canvasRef.current); - const crdtDrawingData = startDrawing(convertPoint); + const crdtDrawingData = await startDrawing(point); if (crdtDrawingData) { void drawingSocketHandlers.sendDrawing(crdtDrawingData); } @@ -120,27 +131,27 @@ const GameCanvas = ({ role, maxPixels = DEFAULT_MAX_PIXELS, currentRound, roomSt const handleDrawMove = useCallback( (e: ReactMouseEvent | ReactTouchEvent) => { - const { canvas } = getCanvasContext(canvasRef); - const point = getDrawPoint(e, canvas); - const convertPoint = convertCoordinate(point); + if (!canvasRef.current) return; + + const point = getCanvasPoint(e, canvasRef.current); - handleInCanvas(cursorCanvasRef, convertPoint, brushSize); + handleInCanvas(cursorCanvasRef, point, brushSize); - const crdtDrawingData = continueDrawing(convertPoint); + const crdtDrawingData = continueDrawing(point); if (crdtDrawingData) { void drawingSocketHandlers.sendDrawing(crdtDrawingData); } }, - [continueDrawing, convertCoordinate, isConnected], + [continueDrawing, convertCoordinate, brushSize], ); const handleDrawLeave = useCallback( (e: ReactMouseEvent | ReactTouchEvent) => { - const { canvas } = getCanvasContext(canvasRef); - const point = getDrawPoint(e, canvas); - const convertPoint = convertCoordinate(point); + if (!canvasRef.current) return; + + const point = getCanvasPoint(e, canvasRef.current); - const crdtDrawingData = continueDrawing(convertPoint); + const crdtDrawingData = continueDrawing(point); if (crdtDrawingData) { void drawingSocketHandlers.sendDrawing(crdtDrawingData); } @@ -148,7 +159,7 @@ const GameCanvas = ({ role, maxPixels = DEFAULT_MAX_PIXELS, currentRound, roomSt handleOutCanvas(cursorCanvasRef); stopDrawing(); }, - [continueDrawing, handleOutCanvas, stopDrawing], + [continueDrawing, stopDrawing], ); const handleDrawEnd = useCallback(() => { diff --git a/client/src/hooks/canvas/drawingWorker.ts b/client/src/hooks/canvas/drawingWorker.ts new file mode 100644 index 000000000..516332079 --- /dev/null +++ b/client/src/hooks/canvas/drawingWorker.ts @@ -0,0 +1,200 @@ +import { DrawingData, Point, StrokeStyle } from '@troublepainter/core'; +import { RGBA } from '@/types/canvas.types'; +import { hexToRGBA } from '@/utils/hexToRGBA'; + +let canvas: OffscreenCanvas | null = null; +let ctx: OffscreenCanvasRenderingContext2D | null = null; + +const fillTargetColor = (pos: number, fillColor: RGBA, pixelArray: Uint8ClampedArray) => { + pixelArray[pos] = fillColor.r; + pixelArray[pos + 1] = fillColor.g; + pixelArray[pos + 2] = fillColor.b; + pixelArray[pos + 3] = fillColor.a; +}; + +const checkColorisNotEqual = (pos: number, startColor: RGBA, pixelArray: Uint8ClampedArray) => { + return ( + pixelArray[pos] !== startColor.r || + pixelArray[pos + 1] !== startColor.g || + pixelArray[pos + 2] !== startColor.b || + pixelArray[pos + 3] !== startColor.a + ); +}; + +const drawStroke = (points: Point[], style: StrokeStyle) => { + if (!ctx) throw new Error('Context not initialized'); + + ctx.beginPath(); + ctx.strokeStyle = style.color; + ctx.lineWidth = style.width; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + + if (points.length === 1) { + const point = points[0]; + ctx.arc(point.x, point.y, style.width / 2, 0, Math.PI * 2); + ctx.fill(); + } else { + ctx.moveTo(points[0].x, points[0].y); + points.slice(1).forEach((point) => ctx!.lineTo(point.x, point.y)); + ctx.stroke(); + } +}; + +const floodFill = (startX: number, startY: number, color: string, inkRemaining: number) => { + if (!ctx || !canvas) throw new Error('Canvas not initialized'); + + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const pixelArray = imageData.data; + const fillColor = hexToRGBA(color); + + const startPos = (startY * canvas.width + startX) * 4; + const startColor = { + r: pixelArray[startPos], + g: pixelArray[startPos + 1], + b: pixelArray[startPos + 2], + a: pixelArray[startPos + 3], + }; + + const pixelsToCheck: [number, number][] = [[startX, startY]]; + let pixelCount = 0; + const filledPoints: Point[] = []; + + while (pixelsToCheck.length > 0 && pixelCount <= inkRemaining) { + const [x, y] = pixelsToCheck.shift()!; + if (!canvas) throw new Error('Canvas not initialized'); + const pos = (y * canvas.width + x) * 4; + + if (x < 0 || x >= canvas.width || y < 0 || y >= canvas.height || checkColorisNotEqual(pos, startColor, pixelArray)) + continue; + + fillTargetColor(pos, fillColor, pixelArray); + filledPoints.push({ x, y }); + pixelsToCheck.push([x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]); + pixelCount++; + } + + ctx.putImageData(imageData, 0, 0); + + return { + points: filledPoints, + pixelCount, + }; +}; + +const applyFill = (fillData: DrawingData) => { + if (!ctx || !canvas) throw new Error('Canvas not initialized'); + + const { points, style } = fillData; + const fillColor = hexToRGBA(style.color); + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const pixelArray = imageData.data; + + points.forEach(({ x, y }) => { + if (!canvas) throw new Error('Canvas not initialized'); + const pos = (y * canvas.width + x) * 4; + fillTargetColor(pos, fillColor, pixelArray); + }); + + ctx.putImageData(imageData, 0, 0); +}; + +interface InitData { + canvas: OffscreenCanvas; + width?: number; + height?: number; + points?: Point[]; + style?: StrokeStyle; + startX?: number; + startY?: number; + color?: string; + inkRemaining?: number; + fillData?: DrawingData; +} + +self.onmessage = function (event: MessageEvent<{ type: string; data: InitData }>) { + try { + const { type, data } = event.data; + + switch (type) { + case 'INIT': { + if (!data?.canvas) { + throw new Error('Canvas is required for initialization'); + } + canvas = data.canvas; + ctx = canvas.getContext('2d'); + if (!ctx) { + throw new Error('Failed to get 2D context'); + } + canvas.width = data.width || 800; + canvas.height = data.height || 600; + + ctx.fillStyle = 'white'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + self.postMessage({ type: 'INIT_COMPLETE' }); + break; + } + + case 'DRAW_STROKE': { + if (!ctx || !canvas) { + throw new Error('Canvas not initialized'); + } + if (!data?.points || !data?.style) { + throw new Error('Points and style are required for drawing'); + } + drawStroke(data.points, data.style); + self.postMessage({ type: 'DRAW_COMPLETE' }); + break; + } + + case 'FLOOD_FILL': { + if (!ctx || !canvas) { + throw new Error('Canvas not initialized'); + } + const result = floodFill( + data.startX ?? 0, + data.startY ?? 0, + data.color ?? '#000000', + data.inkRemaining ?? Number.MAX_SAFE_INTEGER, + ); + self.postMessage({ + type: 'FILL_COMPLETE', + points: result.points, + pixelCount: result.pixelCount, + }); + break; + } + + case 'APPLY_FILL': { + if (!ctx || !canvas) { + throw new Error('Canvas not initialized'); + } + if (!data?.fillData) { + throw new Error('Fill data is required'); + } + applyFill(data.fillData); + self.postMessage({ type: 'APPLY_FILL_COMPLETE' }); + break; + } + + case 'CLEAR': { + if (!ctx || !canvas) { + throw new Error('Canvas not initialized'); + } + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = 'white'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + self.postMessage({ type: 'CLEAR_COMPLETE' }); + break; + } + + default: + throw new Error(`Unknown message type: ${type}`); + } + } catch (error) { + self.postMessage({ + type: 'ERROR', + error: error instanceof Error ? error.message : 'Unknown error', + }); + } +}; diff --git a/client/src/hooks/canvas/useDrawing.ts b/client/src/hooks/canvas/useDrawing.ts index 71833978e..7a4ca031d 100644 --- a/client/src/hooks/canvas/useDrawing.ts +++ b/client/src/hooks/canvas/useDrawing.ts @@ -114,7 +114,7 @@ export const useDrawing = ( ); const startDrawing = useCallback( - (point: Point): CRDTUpdateMessage | null => { + async (point: Point): Promise => { if (state.checkInkAvailability() === false || !state.crdtRef.current) return null; state.currentStrokeIdsRef.current = []; @@ -122,7 +122,7 @@ export const useDrawing = ( const drawingData = state.drawingMode === DRAWING_MODE.FILL - ? operation.floodFill(Math.floor(point.x), Math.floor(point.y)) + ? await operation.floodFill(Math.floor(point.x), Math.floor(point.y)) : createDrawingData([point]); if (!drawingData) return null; diff --git a/client/src/hooks/canvas/useDrawingOperation.ts b/client/src/hooks/canvas/useDrawingOperation.ts index 1c77390a2..8b4144399 100644 --- a/client/src/hooks/canvas/useDrawingOperation.ts +++ b/client/src/hooks/canvas/useDrawingOperation.ts @@ -1,26 +1,6 @@ -import { RefObject, useCallback } from 'react'; -import { DrawingData, Point, StrokeStyle } from '@troublepainter/core'; +import { RefObject, useCallback, useEffect, useRef } from 'react'; +import { DrawingData, StrokeStyle } from '@troublepainter/core'; import { useDrawingState } from './useDrawingState'; -import { MAINCANVAS_RESOLUTION_HEIGHT, MAINCANVAS_RESOLUTION_WIDTH } from '@/constants/canvasConstants'; -import { RGBA } from '@/types/canvas.types'; -import { getCanvasContext } from '@/utils/getCanvasContext'; -import { hexToRGBA } from '@/utils/hexToRGBA'; - -const fillTargetColor = (pos: number, fillColor: RGBA, pixelArray: Uint8ClampedArray) => { - pixelArray[pos] = fillColor.r; - pixelArray[pos + 1] = fillColor.g; - pixelArray[pos + 2] = fillColor.b; - pixelArray[pos + 3] = fillColor.a; -}; - -const checkColorisEqual = (pos: number, startColor: RGBA, pixelArray: Uint8ClampedArray) => { - return ( - pixelArray[pos] === startColor.r && - pixelArray[pos + 1] === startColor.g && - pixelArray[pos + 2] === startColor.b && - pixelArray[pos + 3] === startColor.a - ); -}; /* const checkOutsidePoint = (canvas: HTMLCanvasElement, point: Point) => { @@ -72,8 +52,50 @@ export const useDrawingOperation = ( canvasRef: RefObject, state: ReturnType, ) => { + const workerRef = useRef(); + const isOffscreenInitialized = useRef(false); const { currentColor, brushSize, inkRemaining, setInkRemaining } = state; + useEffect(() => { + if (!canvasRef.current || isOffscreenInitialized.current) return; + + if ('OffscreenCanvas' in window) { + try { + const offscreen = canvasRef.current.transferControlToOffscreen(); + workerRef.current = new Worker(new URL('./drawingWorker.ts', import.meta.url), { type: 'module' }); + workerRef.current.postMessage( + { + type: 'INIT', + data: { + canvas: offscreen, + width: canvasRef.current.width, + height: canvasRef.current.height, + }, + }, + [offscreen], + ); + + isOffscreenInitialized.current = true; + + workerRef.current.addEventListener('message', (event) => { + if (event.data.type === 'ERROR') { + console.error('Worker error:', event.data.error); + } + }); + } catch (error) { + console.error('Failed to initialize OffscreenCanvas:', error); + } + } + + return () => { + if (workerRef.current) { + workerRef.current.terminate(); + workerRef.current = undefined; + isOffscreenInitialized.current = false; + } + }; + }, []); + const getCurrentStyle = useCallback( (): StrokeStyle => ({ color: currentColor, @@ -83,128 +105,81 @@ export const useDrawingOperation = ( ); const drawStroke = useCallback((drawingData: DrawingData) => { - const { ctx } = getCanvasContext(canvasRef); - const { points, style } = drawingData; - - if (points.length === 0) return; - - ctx.strokeStyle = style.color; - ctx.fillStyle = style.color; - ctx.lineWidth = style.width; - ctx.beginPath(); - ctx.lineCap = 'round'; - ctx.lineJoin = 'round'; - - if (points.length === 1) { - const point = points[0]; - ctx.arc(point.x, point.y, style.width / 2, 0, Math.PI * 2); - ctx.fill(); - } else { - ctx.moveTo(points[0].x, points[0].y); - points.slice(1).forEach((point) => ctx.lineTo(point.x, point.y)); - ctx.stroke(); - } + if (!workerRef.current) return; + + workerRef.current.postMessage({ + type: 'DRAW_STROKE', + data: { + points: drawingData.points, + style: drawingData.style, + }, + }); }, []); - const redrawCanvas = useCallback(() => { - if (!state.crdtRef.current) return; - - const { canvas, ctx } = getCanvasContext(canvasRef); - ctx.clearRect(0, 0, canvas.width, canvas.height); - - const activeStrokes = state.crdtRef.current.getActiveStrokes(); - for (const { stroke } of activeStrokes) { - if (stroke.points.length > 2) applyFill(stroke); - else drawStroke(stroke); - } - }, [drawStroke]); - - const applyFill = (drawingData: DrawingData) => { - const { canvas, ctx } = getCanvasContext(canvasRef); - const { points, style } = drawingData; - - if (points.length === 0) return; - - const color = hexToRGBA(style.color); + const applyFill = useCallback((drawingData: DrawingData) => { + if (!workerRef.current) return; - const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); - const data = imageData.data; - - points.forEach(({ x, y }) => { - const pos = (y * canvas.width + x) * 4; - fillTargetColor(pos, color, data); + workerRef.current.postMessage({ + type: 'APPLY_FILL', + data: { fillData: drawingData }, }); - - ctx.putImageData(imageData, 0, 0); - }; + }, []); const floodFill = useCallback( - (startX: number, startY: number) => { - const { canvas, ctx } = getCanvasContext(canvasRef); - const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); - const pixelArray = imageData.data; - const fillColor = hexToRGBA(currentColor); - - const startPos = (startY * canvas.width + startX) * 4; - const startColor = { - r: pixelArray[startPos], - g: pixelArray[startPos + 1], - b: pixelArray[startPos + 2], - a: pixelArray[startPos + 3], - }; - - const pixelsToCheck = [[startX, startY]]; - const checkArray = new Array(MAINCANVAS_RESOLUTION_HEIGHT) - .fill(null) - .map(() => new Array(MAINCANVAS_RESOLUTION_WIDTH).fill(false)); - let pixelCount = 1; - const filledPoints: Point[] = [{ x: startX, y: startY }]; - - while (pixelsToCheck.length > 0 && pixelCount <= inkRemaining) { - const [currentX, currentY] = pixelsToCheck.shift()!; - for (const move of [ - [1, 0], - [0, -1], - [-1, 0], - [0, 1], - ]) { - const [nextX, nextY] = [currentX + move[0], currentY + move[1]]; - if ( - nextX < 0 || - nextX >= MAINCANVAS_RESOLUTION_WIDTH || - nextY < 0 || - nextY >= MAINCANVAS_RESOLUTION_HEIGHT || - checkArray[nextY][nextX] - ) - continue; - - const nextArrayIndex = (nextY * MAINCANVAS_RESOLUTION_WIDTH + nextX) * 4; - - if (!checkColorisEqual(nextArrayIndex, startColor, pixelArray)) continue; - - checkArray[nextY][nextX] = true; - fillTargetColor(nextArrayIndex, fillColor, pixelArray); - pixelsToCheck.push([nextX, nextY]); - filledPoints.push({ x: nextX, y: nextY }); - pixelCount++; + (startX: number, startY: number): Promise => { + if (!workerRef.current) return Promise.resolve(null); + + return new Promise((resolve) => { + const messageHandler = (event: MessageEvent) => { + if (event.data.type === 'FILL_COMPLETE') { + workerRef.current?.removeEventListener('message', messageHandler); + setInkRemaining((prev: number) => Math.max(0, prev - event.data.pixelCount)); + + resolve({ + points: event.data.points, + style: getCurrentStyle(), + timestamp: Date.now(), + }); + } else if (event.data.type === 'ERROR') { + console.error('Worker error:', event.data.error); + resolve(null); + } + }; + + if (workerRef.current) { + workerRef.current.addEventListener('message', messageHandler); + workerRef.current.postMessage({ + type: 'FLOOD_FILL', + data: { + startX, + startY, + color: currentColor, + inkRemaining, + }, + }); } - } - - ctx.putImageData(imageData, 0, 0); - setInkRemaining((prev: number) => Math.max(0, prev - pixelCount)); - - return { - points: filledPoints, - style: getCurrentStyle(), - timestamp: Date.now(), - }; + }); }, [currentColor, inkRemaining, getCurrentStyle, setInkRemaining], ); + const redrawCanvas = useCallback(() => { + if (!workerRef.current || !state.crdtRef.current) return; + + workerRef.current.postMessage({ type: 'CLEAR' }); + + const activeStrokes = state.crdtRef.current.getActiveStrokes(); + for (const { stroke } of activeStrokes) { + if (stroke.points.length > 2) { + applyFill(stroke); + } else { + drawStroke(stroke); + } + } + }, [drawStroke, applyFill, state.crdtRef]); + const clearCanvas = useCallback(() => { - const { canvas, ctx } = getCanvasContext(canvasRef); - ctx.clearRect(0, 0, canvas.width, canvas.height); + workerRef.current?.postMessage({ type: 'CLEAR' }); }, []); return { diff --git a/client/src/types/canvas.types.ts b/client/src/types/canvas.types.ts index 3f5282d25..04de46c5b 100644 --- a/client/src/types/canvas.types.ts +++ b/client/src/types/canvas.types.ts @@ -40,14 +40,27 @@ export interface DrawingOptions { maxPixels?: number; } +export type WorkerMessageType = + | 'INIT' + | 'INIT_COMPLETE' + | 'DRAW_STROKE' + | 'DRAW_COMPLETE' + | 'FLOOD_FILL' + | 'FILL_COMPLETE' + | 'APPLY_FILL' + | 'APPLY_FILL_COMPLETE' + | 'CLEAR' + | 'CLEAR_COMPLETE' + | 'ERROR'; + export type DrawingMode = (typeof DRAWING_MODE)[keyof typeof DRAWING_MODE]; export interface CanvasEventHandlers { - onMouseDown?: (e: MouseEvent) => void; + onMouseDown?: (e: MouseEvent) => Promise; onMouseMove?: (e: MouseEvent) => void; onMouseUp?: (e: MouseEvent) => void; onMouseLeave?: (e: MouseEvent) => void; - onTouchStart?: (e: TouchEvent) => void; + onTouchStart?: (e: TouchEvent) => Promise; onTouchMove?: (e: TouchEvent) => void; onTouchEnd?: (e: TouchEvent) => void; onTouchCancel?: (e: TouchEvent) => void;