Phase 4: Tool Migration (Weeks 9-16)¶
Table of Contents¶
- Overview
- Implementation Steps
- Background & Context
- Practical Examples
- Verification & Testing
- Troubleshooting
- Next Steps
Overview¶
Phase 4 focuses on converting the 28 drawing tools from the JavaScript namespace pattern to React components and TypeScript. This phase implements a systematic approach to tool migration while maintaining exact behavioral parity with the original tools.
Learning Focus: Advanced React patterns, testing strategies, component design, performance optimization, and maintaining complex state.
Duration: 8 weeks
Difficulty: Advanced
Prerequisites: Completed Phases 1-3, solid understanding of React hooks and TypeScript
Implementation Steps¶
Step 4.1: Design Tool Architecture Pattern¶
Goal: Create a consistent pattern for converting JavaScript tools to React components.
Create src/tools/ToolBase.ts
:
import { MouseEvent, TouchEvent } from "react";
import { KidPixState } from "../contexts/KidPixContext";
export interface ToolConfig {
name: string;
icon: string;
cursor?: string;
category: "basic" | "brush" | "effect" | "shape" | "special";
submenu?: SubmenuConfig[];
sounds?: {
start?: string;
during?: string;
end?: string;
};
}
export interface SubmenuConfig {
name: string;
icon: string;
value: string | number;
type: "size" | "pattern" | "mode" | "toggle";
}
export abstract class ToolBase {
protected config: ToolConfig;
protected state: KidPixState;
protected dispatch: React.Dispatch<KidPixAction>;
constructor(
config: ToolConfig,
state: KidPixState,
dispatch: React.Dispatch<KidPixAction>,
) {
this.config = config;
this.state = state;
this.dispatch = dispatch;
}
// Abstract methods that each tool must implement
abstract onMouseDown(
event: MouseEvent | TouchEvent,
canvas: HTMLCanvasElement,
): void;
abstract onMouseMove(
event: MouseEvent | TouchEvent,
canvas: HTMLCanvasElement,
): void;
abstract onMouseUp(
event: MouseEvent | TouchEvent,
canvas: HTMLCanvasElement,
): void;
// Optional methods with default implementations
onKeyDown?(event: KeyboardEvent): void;
onKeyUp?(event: KeyboardEvent): void;
onToolSelect?(): void;
onToolDeselect?(): void;
// Utility methods available to all tools
protected getMousePosition(
event: MouseEvent | TouchEvent,
canvas: HTMLCanvasElement,
) {
const rect = canvas.getBoundingClientRect();
const clientX =
"touches" in event ? (event.touches[0]?.clientX ?? 0) : event.clientX;
const clientY =
"touches" in event ? (event.touches[0]?.clientY ?? 0) : event.clientY;
return {
x: clientX - rect.left,
y: clientY - rect.top,
};
}
protected getModifierKeys(event: MouseEvent | TouchEvent | KeyboardEvent) {
return {
shift: event.shiftKey,
ctrl: event.ctrlKey,
alt: event.altKey,
meta: event.metaKey,
};
}
protected saveUndo(canvas: HTMLCanvasElement) {
const ctx = canvas.getContext("2d");
if (ctx) {
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
this.dispatch({ type: "PUSH_UNDO", payload: imageData });
}
}
protected playSound(soundType: "start" | "during" | "end") {
const soundPath = this.config.sounds?.[soundType];
if (soundPath) {
// Sound playing logic (will be implemented in Phase 5)
console.log(`Playing sound: ${soundPath}`);
}
}
}
Step 4.2: Create Tool Registry System¶
Goal: Centralized tool management and registration.
Create src/tools/ToolRegistry.ts
:
import { ToolBase, ToolConfig } from "./ToolBase";
import { KidPixState, KidPixAction } from "../contexts/KidPixContext";
export class ToolRegistry {
private static tools = new Map<
string,
new (state: KidPixState, dispatch: React.Dispatch<KidPixAction>) => ToolBase
>();
private static configs = new Map<string, ToolConfig>();
static register(
id: string,
toolClass: new (
state: KidPixState,
dispatch: React.Dispatch<KidPixAction>,
) => ToolBase,
config: ToolConfig,
) {
this.tools.set(id, toolClass);
this.configs.set(id, config);
}
static getTool(
id: string,
state: KidPixState,
dispatch: React.Dispatch<KidPixAction>,
): ToolBase | null {
const ToolClass = this.tools.get(id);
return ToolClass ? new ToolClass(state, dispatch) : null;
}
static getConfig(id: string): ToolConfig | null {
return this.configs.get(id) || null;
}
static getAllTools(): Array<{ id: string; config: ToolConfig }> {
return Array.from(this.configs.entries()).map(([id, config]) => ({
id,
config,
}));
}
static getToolsByCategory(
category: ToolConfig["category"],
): Array<{ id: string; config: ToolConfig }> {
return this.getAllTools().filter(
(tool) => tool.config.category === category,
);
}
}
Step 4.3: Convert Pencil Tool (Template)¶
Goal: Create the first converted tool as a template for others.
Create src/tools/PencilTool.ts
:
import { MouseEvent, TouchEvent } from "react";
import { ToolBase, ToolConfig } from "./ToolBase";
import { ToolRegistry } from "./ToolRegistry";
import { KidPixState, KidPixAction } from "../contexts/KidPixContext";
export class PencilTool extends ToolBase {
private isDrawing = false;
private lastPoint: { x: number; y: number } | null = null;
constructor(state: KidPixState, dispatch: React.Dispatch<KidPixAction>) {
const config: ToolConfig = {
name: "Pencil",
icon: "✏️",
cursor: "crosshair",
category: "basic",
submenu: [
{ name: "Small", icon: "●", value: 1, type: "size" },
{ name: "Medium", icon: "●", value: 3, type: "size" },
{ name: "Large", icon: "●", value: 5, type: "size" },
{ name: "Extra Large", icon: "●", value: 8, type: "size" },
],
sounds: {
start: "kidpix-tool-pencil.wav",
},
};
super(config, state, dispatch);
}
onMouseDown(event: MouseEvent | TouchEvent, canvas: HTMLCanvasElement): void {
this.saveUndo(canvas);
this.isDrawing = true;
const position = this.getMousePosition(event, canvas);
const modifiers = this.getModifierKeys(event);
this.lastPoint = position;
// Get tool size (shift makes it larger)
const baseSize = this.state.brushSize;
const size = modifiers.shift ? baseSize * 2 : baseSize;
// Draw initial point
this.drawPoint(canvas, position.x, position.y, size);
this.playSound("start");
this.dispatch({ type: "SET_DRAWING_STATE", payload: true });
}
onMouseMove(event: MouseEvent | TouchEvent, canvas: HTMLCanvasElement): void {
if (!this.isDrawing || !this.lastPoint) return;
const position = this.getMousePosition(event, canvas);
const modifiers = this.getModifierKeys(event);
const baseSize = this.state.brushSize;
const size = modifiers.shift ? baseSize * 2 : baseSize;
// Draw line from last point to current point
this.drawLine(
canvas,
this.lastPoint.x,
this.lastPoint.y,
position.x,
position.y,
size,
);
this.lastPoint = position;
}
onMouseUp(event: MouseEvent | TouchEvent, canvas: HTMLCanvasElement): void {
this.isDrawing = false;
this.lastPoint = null;
this.dispatch({ type: "SET_DRAWING_STATE", payload: false });
}
private drawPoint(
canvas: HTMLCanvasElement,
x: number,
y: number,
size: number,
): void {
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.fillStyle = this.state.currentColor;
ctx.beginPath();
ctx.arc(x, y, size / 2, 0, Math.PI * 2);
ctx.fill();
}
private drawLine(
canvas: HTMLCanvasElement,
x1: number,
y1: number,
x2: number,
y2: number,
size: number,
): void {
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.strokeStyle = this.state.currentColor;
ctx.lineWidth = size;
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
}
}
// Register the tool
ToolRegistry.register(
"pencil",
PencilTool,
new PencilTool({} as KidPixState, (() => {}) as any).config,
);
Step 4.4: Create Tool Hook for React Integration¶
Goal: Integrate tools with React component lifecycle.
Create src/hooks/useTool.ts
:
import { useCallback, useEffect, useRef } from "react";
import { useKidPix } from "../contexts/KidPixContext";
import { ToolRegistry } from "../tools/ToolRegistry";
import { ToolBase } from "../tools/ToolBase";
export const useTool = (canvas: HTMLCanvasElement | null) => {
const { state, dispatch } = useKidPix();
const currentToolRef = useRef<ToolBase | null>(null);
const previousToolRef = useRef<string | null>(null);
// Update current tool when tool changes
useEffect(() => {
if (state.currentTool !== previousToolRef.current) {
// Deselect previous tool
if (currentToolRef.current && previousToolRef.current) {
currentToolRef.current.onToolDeselect?.();
}
// Create new tool instance
currentToolRef.current = ToolRegistry.getTool(
state.currentTool,
state,
dispatch,
);
// Select new tool
if (currentToolRef.current) {
currentToolRef.current.onToolSelect?.();
}
previousToolRef.current = state.currentTool;
}
}, [state.currentTool, state, dispatch]);
// Mouse event handlers
const handleMouseDown = useCallback(
(event: React.MouseEvent | React.TouchEvent) => {
if (!canvas || !currentToolRef.current) return;
currentToolRef.current.onMouseDown(event, canvas);
},
[canvas],
);
const handleMouseMove = useCallback(
(event: React.MouseEvent | React.TouchEvent) => {
if (!canvas || !currentToolRef.current) return;
currentToolRef.current.onMouseMove(event, canvas);
},
[canvas],
);
const handleMouseUp = useCallback(
(event: React.MouseEvent | React.TouchEvent) => {
if (!canvas || !currentToolRef.current) return;
currentToolRef.current.onMouseUp(event, canvas);
},
[canvas],
);
// Keyboard event handlers
const handleKeyDown = useCallback((event: KeyboardEvent) => {
if (!currentToolRef.current) return;
currentToolRef.current.onKeyDown?.(event);
}, []);
const handleKeyUp = useCallback((event: KeyboardEvent) => {
if (!currentToolRef.current) return;
currentToolRef.current.onKeyUp?.(event);
}, []);
// Set up keyboard event listeners
useEffect(() => {
document.addEventListener("keydown", handleKeyDown);
document.addEventListener("keyup", handleKeyUp);
return () => {
document.removeEventListener("keydown", handleKeyDown);
document.removeEventListener("keyup", handleKeyUp);
};
}, [handleKeyDown, handleKeyUp]);
return {
handleMouseDown,
handleMouseMove,
handleMouseUp,
currentTool: currentToolRef.current,
toolConfig: currentToolRef.current
? ToolRegistry.getConfig(state.currentTool)
: null,
};
};
Step 4.5: Update Canvas Component to Use Tools¶
Goal: Integrate the tool system with the canvas component.
Update src/components/Canvas/CanvasLayer.tsx
:
import React, { useRef, useEffect, forwardRef } from 'react';
import { useKidPix } from '../../contexts/KidPixContext';
import { useTool } from '../../hooks/useTool';
interface CanvasLayerProps {
name: keyof KidPixState['canvasLayers'];
width: number;
height: number;
zIndex: number;
className?: string;
interactive?: boolean;
}
export const CanvasLayer = forwardRef<HTMLCanvasElement, CanvasLayerProps>(
({ name, width, height, zIndex, className, interactive = false }, ref) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const { dispatch } = useKidPix();
const { handleMouseDown, handleMouseMove, handleMouseUp, toolConfig } = useTool(
interactive ? canvasRef.current : null
);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
// Register canvas with context
dispatch({
type: 'SET_CANVAS_LAYER',
payload: { layer: name, canvas }
});
// Set up canvas properties
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.imageSmoothingEnabled = false;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
}
}, [name, dispatch]);
// Update cursor based on current tool
useEffect(() => {
const canvas = canvasRef.current;
if (canvas && interactive && toolConfig) {
canvas.style.cursor = toolConfig.cursor || 'crosshair';
}
}, [interactive, toolConfig]);
return (
<canvas
ref={canvasRef}
width={width}
height={height}
className={className}
style={{
position: 'absolute',
top: 0,
left: 0,
zIndex,
pointerEvents: interactive ? 'auto' : 'none',
imageRendering: 'pixelated'
}}
onMouseDown={interactive ? handleMouseDown : undefined}
onMouseMove={interactive ? handleMouseMove : undefined}
onMouseUp={interactive ? handleMouseUp : undefined}
onTouchStart={interactive ? handleMouseDown : undefined}
onTouchMove={interactive ? handleMouseMove : undefined}
onTouchEnd={interactive ? handleMouseUp : undefined}
/>
);
}
);
CanvasLayer.displayName = 'CanvasLayer';
Step 4.6: Convert Basic Tools (Week 9-10)¶
Goal: Convert the simplest tools first to establish patterns.
Tools to Convert:
- Pencil Tool ✅ (already done as template)
- Eraser Tool
- Line Tool
- Circle Tool
- Square Tool
Create src/tools/EraserTool.ts
:
import { MouseEvent, TouchEvent } from "react";
import { ToolBase, ToolConfig } from "./ToolBase";
import { ToolRegistry } from "./ToolRegistry";
import { KidPixState, KidPixAction } from "../contexts/KidPixContext";
export class EraserTool extends ToolBase {
private isErasing = false;
private lastPoint: { x: number; y: number } | null = null;
constructor(state: KidPixState, dispatch: React.Dispatch<KidPixAction>) {
const config: ToolConfig = {
name: "Eraser",
icon: "🧽",
cursor: "crosshair",
category: "basic",
submenu: [
{ name: "Small", icon: "⬜", value: 5, type: "size" },
{ name: "Medium", icon: "⬜", value: 10, type: "size" },
{ name: "Large", icon: "⬜", value: 20, type: "size" },
{ name: "Extra Large", icon: "⬜", value: 40, type: "size" },
],
};
super(config, state, dispatch);
}
onMouseDown(event: MouseEvent | TouchEvent, canvas: HTMLCanvasElement): void {
this.saveUndo(canvas);
this.isErasing = true;
const position = this.getMousePosition(event, canvas);
const modifiers = this.getModifierKeys(event);
this.lastPoint = position;
const baseSize = this.state.brushSize;
const size = modifiers.shift ? baseSize * 2 : baseSize;
this.erase(canvas, position.x, position.y, size);
this.dispatch({ type: "SET_DRAWING_STATE", payload: true });
}
onMouseMove(event: MouseEvent | TouchEvent, canvas: HTMLCanvasElement): void {
if (!this.isErasing || !this.lastPoint) return;
const position = this.getMousePosition(event, canvas);
const modifiers = this.getModifierKeys(event);
const baseSize = this.state.brushSize;
const size = modifiers.shift ? baseSize * 2 : baseSize;
// Erase along the path
this.eraseLine(
canvas,
this.lastPoint.x,
this.lastPoint.y,
position.x,
position.y,
size,
);
this.lastPoint = position;
}
onMouseUp(event: MouseEvent | TouchEvent, canvas: HTMLCanvasElement): void {
this.isErasing = false;
this.lastPoint = null;
this.dispatch({ type: "SET_DRAWING_STATE", payload: false });
}
private erase(
canvas: HTMLCanvasElement,
x: number,
y: number,
size: number,
): void {
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.globalCompositeOperation = "destination-out";
ctx.beginPath();
ctx.arc(x, y, size / 2, 0, Math.PI * 2);
ctx.fill();
ctx.globalCompositeOperation = "source-over"; // Reset blend mode
}
private eraseLine(
canvas: HTMLCanvasElement,
x1: number,
y1: number,
x2: number,
y2: number,
size: number,
): void {
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.globalCompositeOperation = "destination-out";
ctx.lineWidth = size;
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
ctx.globalCompositeOperation = "source-over";
}
}
ToolRegistry.register(
"eraser",
EraserTool,
new EraserTool({} as KidPixState, (() => {}) as any).config,
);
Step 4.7: Convert Shape Tools (Week 11-12)¶
Goal: Convert tools that draw geometric shapes.
Create src/tools/LineTool.ts
:
import { MouseEvent, TouchEvent } from "react";
import { ToolBase, ToolConfig } from "./ToolBase";
import { ToolRegistry } from "./ToolRegistry";
import { KidPixState, KidPixAction } from "../contexts/KidPixContext";
export class LineTool extends ToolBase {
private isDrawing = false;
private startPoint: { x: number; y: number } | null = null;
private previewCanvas: HTMLCanvasElement | null = null;
constructor(state: KidPixState, dispatch: React.Dispatch<KidPixAction>) {
const config: ToolConfig = {
name: "Line",
icon: "📏",
cursor: "crosshair",
category: "shape",
submenu: [
{ name: "Thin", icon: "─", value: 1, type: "size" },
{ name: "Medium", icon: "─", value: 3, type: "size" },
{ name: "Thick", icon: "─", value: 5, type: "size" },
{ name: "Extra Thick", icon: "─", value: 8, type: "size" },
],
sounds: {
start: "kidpix-tool-line-start.wav",
end: "kidpix-tool-line-end.wav",
during: "kidpix-tool-line-during.wav",
},
};
super(config, state, dispatch);
}
onMouseDown(event: MouseEvent | TouchEvent, canvas: HTMLCanvasElement): void {
this.saveUndo(canvas);
this.isDrawing = true;
const position = this.getMousePosition(event, canvas);
this.startPoint = position;
// Get preview canvas for live line preview
this.previewCanvas = this.state.canvasLayers.preview;
this.playSound("start");
this.dispatch({ type: "SET_DRAWING_STATE", payload: true });
}
onMouseMove(event: MouseEvent | TouchEvent, canvas: HTMLCanvasElement): void {
if (!this.isDrawing || !this.startPoint || !this.previewCanvas) return;
const position = this.getMousePosition(event, canvas);
const modifiers = this.getModifierKeys(event);
// Clear preview canvas
const previewCtx = this.previewCanvas.getContext("2d");
if (previewCtx) {
previewCtx.clearRect(
0,
0,
this.previewCanvas.width,
this.previewCanvas.height,
);
// Calculate line end point (snap to angles if shift is held)
let endX = position.x;
let endY = position.y;
if (modifiers.shift) {
// Snap to 45-degree angles
const deltaX = endX - this.startPoint.x;
const deltaY = endY - this.startPoint.y;
const angle = Math.atan2(deltaY, deltaX);
const snappedAngle = Math.round(angle / (Math.PI / 4)) * (Math.PI / 4);
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
endX = this.startPoint.x + Math.cos(snappedAngle) * distance;
endY = this.startPoint.y + Math.sin(snappedAngle) * distance;
}
// Draw preview line
this.drawLine(
this.previewCanvas,
this.startPoint.x,
this.startPoint.y,
endX,
endY,
);
}
}
onMouseUp(event: MouseEvent | TouchEvent, canvas: HTMLCanvasElement): void {
if (!this.isDrawing || !this.startPoint) return;
const position = this.getMousePosition(event, canvas);
const modifiers = this.getModifierKeys(event);
// Calculate final line end point
let endX = position.x;
let endY = position.y;
if (modifiers.shift) {
const deltaX = endX - this.startPoint.x;
const deltaY = endY - this.startPoint.y;
const angle = Math.atan2(deltaY, deltaX);
const snappedAngle = Math.round(angle / (Math.PI / 4)) * (Math.PI / 4);
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
endX = this.startPoint.x + Math.cos(snappedAngle) * distance;
endY = this.startPoint.y + Math.sin(snappedAngle) * distance;
}
// Draw final line on main canvas
this.drawLine(canvas, this.startPoint.x, this.startPoint.y, endX, endY);
// Clear preview canvas
if (this.previewCanvas) {
const previewCtx = this.previewCanvas.getContext("2d");
previewCtx?.clearRect(
0,
0,
this.previewCanvas.width,
this.previewCanvas.height,
);
}
this.isDrawing = false;
this.startPoint = null;
this.previewCanvas = null;
this.playSound("end");
this.dispatch({ type: "SET_DRAWING_STATE", payload: false });
}
private drawLine(
canvas: HTMLCanvasElement,
x1: number,
y1: number,
x2: number,
y2: number,
): void {
const ctx = canvas.getContext("2d");
if (!ctx) return;
const modifiers = this.getModifierKeys(event as any);
const baseSize = this.state.brushSize;
const size = modifiers.shift ? baseSize * 2 : baseSize;
ctx.strokeStyle = this.state.currentColor;
ctx.lineWidth = size;
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
}
}
ToolRegistry.register(
"line",
LineTool,
new LineTool({} as KidPixState, (() => {}) as any).config,
);
Step 4.8: Create Comprehensive Testing Suite¶
Goal: Establish testing patterns for all tools.
Create src/tools/__tests__/ToolBase.test.ts
:
import { ToolBase, ToolConfig } from "../ToolBase";
import { KidPixState, KidPixAction } from "../../contexts/KidPixContext";
// Mock tool for testing
class MockTool extends ToolBase {
onMouseDown = jest.fn();
onMouseMove = jest.fn();
onMouseUp = jest.fn();
}
const mockConfig: ToolConfig = {
name: "Mock Tool",
icon: "🔧",
category: "basic",
};
const mockState: KidPixState = {
currentTool: "mock",
currentColor: "#ff0000",
brushSize: 5,
isDrawing: false,
canvasLayers: {
main: null,
tmp: null,
preview: null,
anim: null,
bnim: null,
},
undoStack: [],
redoStack: [],
};
const mockDispatch = jest.fn();
describe("ToolBase", () => {
let tool: MockTool;
let canvas: HTMLCanvasElement;
let mockEvent: Partial<MouseEvent>;
beforeEach(() => {
tool = new MockTool(mockConfig, mockState, mockDispatch);
canvas = document.createElement("canvas");
canvas.width = 640;
canvas.height = 480;
mockEvent = {
clientX: 100,
clientY: 50,
shiftKey: false,
ctrlKey: false,
altKey: false,
metaKey: false,
};
// Mock getBoundingClientRect
canvas.getBoundingClientRect = jest.fn(() => ({
left: 0,
top: 0,
right: 640,
bottom: 480,
width: 640,
height: 480,
x: 0,
y: 0,
toJSON: () => {},
}));
});
test("getMousePosition calculates correct position", () => {
const position = tool["getMousePosition"](mockEvent as MouseEvent, canvas);
expect(position).toEqual({ x: 100, y: 50 });
});
test("getModifierKeys returns correct modifier state", () => {
const modifiers = tool["getModifierKeys"](mockEvent as MouseEvent);
expect(modifiers).toEqual({
shift: false,
ctrl: false,
alt: false,
meta: false,
});
});
test("getModifierKeys detects shift key", () => {
mockEvent.shiftKey = true;
const modifiers = tool["getModifierKeys"](mockEvent as MouseEvent);
expect(modifiers.shift).toBe(true);
});
});
Create src/tools/__tests__/PencilTool.test.ts
:
import { PencilTool } from "../PencilTool";
import { KidPixState } from "../../contexts/KidPixContext";
const mockState: KidPixState = {
currentTool: "pencil",
currentColor: "#000000",
brushSize: 3,
isDrawing: false,
canvasLayers: {
main: null,
tmp: null,
preview: null,
anim: null,
bnim: null,
},
undoStack: [],
redoStack: [],
};
const mockDispatch = jest.fn();
describe("PencilTool", () => {
let tool: PencilTool;
let canvas: HTMLCanvasElement;
let ctx: CanvasRenderingContext2D;
beforeEach(() => {
tool = new PencilTool(mockState, mockDispatch);
canvas = document.createElement("canvas");
canvas.width = 640;
canvas.height = 480;
ctx = canvas.getContext("2d")!;
// Mock canvas methods
ctx.fillRect = jest.fn();
ctx.arc = jest.fn();
ctx.fill = jest.fn();
ctx.stroke = jest.fn();
ctx.beginPath = jest.fn();
ctx.moveTo = jest.fn();
ctx.lineTo = jest.fn();
canvas.getBoundingClientRect = jest.fn(() => ({
left: 0,
top: 0,
right: 640,
bottom: 480,
width: 640,
height: 480,
x: 0,
y: 0,
toJSON: () => {},
}));
jest.clearAllMocks();
});
test("onMouseDown starts drawing and draws initial point", () => {
const event = {
clientX: 100,
clientY: 50,
shiftKey: false,
ctrlKey: false,
altKey: false,
metaKey: false,
} as MouseEvent;
tool.onMouseDown(event, canvas);
expect(mockDispatch).toHaveBeenCalledWith({
type: "SET_DRAWING_STATE",
payload: true,
});
expect(ctx.arc).toHaveBeenCalledWith(100, 50, 1.5, 0, Math.PI * 2);
expect(ctx.fill).toHaveBeenCalled();
});
test("onMouseDown with shift key doubles brush size", () => {
const event = {
clientX: 100,
clientY: 50,
shiftKey: true,
ctrlKey: false,
altKey: false,
metaKey: false,
} as MouseEvent;
tool.onMouseDown(event, canvas);
expect(ctx.arc).toHaveBeenCalledWith(100, 50, 3, 0, Math.PI * 2); // size 6 / 2 = 3
});
test("onMouseMove draws line when drawing", () => {
// Start drawing
const startEvent = {
clientX: 100,
clientY: 50,
shiftKey: false,
ctrlKey: false,
altKey: false,
metaKey: false,
} as MouseEvent;
tool.onMouseDown(startEvent, canvas);
jest.clearAllMocks();
// Move mouse
const moveEvent = {
clientX: 150,
clientY: 75,
shiftKey: false,
ctrlKey: false,
altKey: false,
metaKey: false,
} as MouseEvent;
tool.onMouseMove(moveEvent, canvas);
expect(ctx.moveTo).toHaveBeenCalledWith(100, 50);
expect(ctx.lineTo).toHaveBeenCalledWith(150, 75);
expect(ctx.stroke).toHaveBeenCalled();
});
test("onMouseUp stops drawing", () => {
// Start drawing
const startEvent = {
clientX: 100,
clientY: 50,
shiftKey: false,
ctrlKey: false,
altKey: false,
metaKey: false,
} as MouseEvent;
tool.onMouseDown(startEvent, canvas);
// Stop drawing
const endEvent = {
clientX: 150,
clientY: 75,
shiftKey: false,
ctrlKey: false,
altKey: false,
metaKey: false,
} as MouseEvent;
tool.onMouseUp(endEvent, canvas);
expect(mockDispatch).toHaveBeenCalledWith({
type: "SET_DRAWING_STATE",
payload: false,
});
});
});
Step 4.9: Performance Optimization¶
Goal: Ensure tools perform well with high-frequency events.
Create src/utils/performance.ts
:
// Throttle function for mouse move events
export function throttle<T extends (...args: any[]) => any>(
func: T,
limit: number,
): (...args: Parameters<T>) => void {
let inThrottle: boolean;
return function (this: any, ...args: Parameters<T>) {
if (!inThrottle) {
func.apply(this, args);
inThrottle = true;
setTimeout(() => (inThrottle = false), limit);
}
};
}
// Debounce function for expensive operations
export function debounce<T extends (...args: any[]) => any>(
func: T,
delay: number,
): (...args: Parameters<T>) => void {
let timeoutId: NodeJS.Timeout;
return function (this: any, ...args: Parameters<T>) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => func.apply(this, args), delay);
};
}
// Request animation frame helper
export function requestAnimationFrameThrottle<
T extends (...args: any[]) => any,
>(func: T): (...args: Parameters<T>) => void {
let rafId: number | null = null;
return function (this: any, ...args: Parameters<T>) {
if (rafId) return;
rafId = requestAnimationFrame(() => {
func.apply(this, args);
rafId = null;
});
};
}
// Performance monitoring
export class PerformanceMonitor {
private static instance: PerformanceMonitor;
private measurements: Map<string, number[]> = new Map();
static getInstance(): PerformanceMonitor {
if (!PerformanceMonitor.instance) {
PerformanceMonitor.instance = new PerformanceMonitor();
}
return PerformanceMonitor.instance;
}
startMeasurement(name: string): void {
performance.mark(`${name}-start`);
}
endMeasurement(name: string): number {
performance.mark(`${name}-end`);
performance.measure(name, `${name}-start`, `${name}-end`);
const measure = performance.getEntriesByName(name, "measure")[0];
const duration = measure.duration;
if (!this.measurements.has(name)) {
this.measurements.set(name, []);
}
this.measurements.get(name)!.push(duration);
// Keep only last 100 measurements
const measurements = this.measurements.get(name)!;
if (measurements.length > 100) {
measurements.shift();
}
return duration;
}
getAverageTime(name: string): number {
const measurements = this.measurements.get(name);
if (!measurements || measurements.length === 0) return 0;
const sum = measurements.reduce((a, b) => a + b, 0);
return sum / measurements.length;
}
getReport(): Record<
string,
{ average: number; count: number; latest: number }
> {
const report: Record<
string,
{ average: number; count: number; latest: number }
> = {};
for (const [name, measurements] of this.measurements) {
const sum = measurements.reduce((a, b) => a + b, 0);
report[name] = {
average: sum / measurements.length,
count: measurements.length,
latest: measurements[measurements.length - 1] || 0,
};
}
return report;
}
}
Background & Context¶
Component Design Patterns¶
Higher-Order Components (HOCs) vs Hooks:
Before hooks, React used HOCs for sharing logic:
// Old HOC pattern
function withMouseTracking(Component) {
return function MouseTrackingComponent(props) {
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
// ... mouse tracking logic
return <Component {...props} mousePos={mousePos} />;
};
}
// Usage
const MyComponentWithMouse = withMouseTracking(MyComponent);
With hooks, we can share logic more elegantly:
// Modern hook pattern
function useMouseTracking() {
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
// ... mouse tracking logic
return mousePos;
}
// Usage in component
function MyComponent() {
const mousePos = useMouseTracking();
return <div>Mouse at {mousePos.x}, {mousePos.y}</div>;
}
Compound Components Pattern:
// Components that work together
<Tabs defaultTab="first">
<Tabs.List>
<Tabs.Tab id="first">First Tab</Tabs.Tab>
<Tabs.Tab id="second">Second Tab</Tabs.Tab>
</Tabs.List>
<Tabs.Panels>
<Tabs.Panel id="first">First Content</Tabs.Panel>
<Tabs.Panel id="second">Second Content</Tabs.Panel>
</Tabs.Panels>
</Tabs>
Testing Strategies¶
Unit Testing Tools:
- Vitest: Fast unit test runner
- React Testing Library: Focus on testing user interactions
- Jest DOM: Additional matchers for DOM testing
Testing Philosophy:
// ❌ Testing implementation details
test("tool sets isDrawing to true", () => {
expect(tool.isDrawing).toBe(true);
});
// ✅ Testing behavior
test("tool draws when mouse is dragged", () => {
fireEvent.mouseDown(canvas, { clientX: 10, clientY: 10 });
fireEvent.mouseMove(canvas, { clientX: 20, clientY: 20 });
// Verify canvas was drawn on
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
expect(imageData.data.some((pixel) => pixel !== 0)).toBe(true);
});
Mocking Strategies:
// Mock Canvas API
beforeEach(() => {
const mockCtx = {
fillRect: jest.fn(),
clearRect: jest.fn(),
getImageData: jest.fn(),
putImageData: jest.fn(),
drawImage: jest.fn(),
beginPath: jest.fn(),
moveTo: jest.fn(),
lineTo: jest.fn(),
stroke: jest.fn(),
fill: jest.fn(),
arc: jest.fn(),
};
HTMLCanvasElement.prototype.getContext = jest.fn(() => mockCtx);
});
Performance Considerations¶
Event Handling Optimization:
// ❌ Creating new functions on every render
const Component = () => {
return (
<canvas
onMouseMove={(e) => handleMouseMove(e)}
onMouseDown={(e) => handleMouseDown(e)}
/>
);
};
// ✅ Using useCallback to prevent recreation
const Component = () => {
const handleMouseMove = useCallback((e) => {
// Handle mouse move
}, [dependencies]);
const handleMouseDown = useCallback((e) => {
// Handle mouse down
}, [dependencies]);
return (
<canvas
onMouseMove={handleMouseMove}
onMouseDown={handleMouseDown}
/>
);
};
Canvas Performance:
// ❌ Clearing entire canvas on every draw
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawEverything();
// ✅ Only redraw changed areas
const dirtyRect = calculateDirtyRect(lastDraw, currentDraw);
ctx.clearRect(dirtyRect.x, dirtyRect.y, dirtyRect.width, dirtyRect.height);
drawChangedAreas(dirtyRect);
Memory Management:
// ❌ Memory leak - event listeners not cleaned up
useEffect(() => {
document.addEventListener("mousemove", handleMouseMove);
}, []);
// ✅ Proper cleanup
useEffect(() => {
document.addEventListener("mousemove", handleMouseMove);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
};
}, [handleMouseMove]);
State Management Patterns¶
Local vs Global State:
// Local state - component-specific
function ToolSettings() {
const [isOpen, setIsOpen] = useState(false);
// This state only affects this component
}
// Global state - shared across app
function useKidPix() {
const { state, dispatch } = useContext(KidPixContext);
// This state affects entire application
}
State Normalization:
// ❌ Nested state structure
const state = {
tools: {
pencil: { size: 5, color: "red" },
brush: { size: 10, color: "blue" },
},
};
// ✅ Normalized state structure
const state = {
currentTool: "pencil",
toolSettings: {
pencil: { size: 5 },
brush: { size: 10 },
},
currentColor: "red",
};
Code Organization¶
Feature-Based vs Type-Based Organization:
// Type-based (traditional)
src / components / Toolbar.tsx;
Canvas.tsx;
hooks / useTool.ts;
useCanvas.ts;
utils / performance.ts;
// Feature-based (modern)
src / features / drawing / components / Canvas.tsx;
hooks / useDrawing.ts;
utils / drawingUtils.ts;
toolbar / components / Toolbar.tsx;
hooks / useToolbar.ts;
Practical Examples¶
Example 1: Advanced Tool with Preview¶
// Circle tool with live preview
export class CircleTool extends ToolBase {
private isDrawing = false;
private startPoint: { x: number; y: number } | null = null;
private previewImageData: ImageData | null = null;
onMouseDown(event: MouseEvent | TouchEvent, canvas: HTMLCanvasElement): void {
this.saveUndo(canvas);
this.isDrawing = true;
this.startPoint = this.getMousePosition(event, canvas);
// Save preview canvas state
const previewCanvas = this.state.canvasLayers.preview;
if (previewCanvas) {
const ctx = previewCanvas.getContext("2d");
this.previewImageData =
ctx?.getImageData(0, 0, previewCanvas.width, previewCanvas.height) ||
null;
}
}
onMouseMove(event: MouseEvent | TouchEvent, canvas: HTMLCanvasElement): void {
if (!this.isDrawing || !this.startPoint) return;
const currentPoint = this.getMousePosition(event, canvas);
const radius = Math.sqrt(
Math.pow(currentPoint.x - this.startPoint.x, 2) +
Math.pow(currentPoint.y - this.startPoint.y, 2),
);
// Update preview
this.updatePreview(this.startPoint, radius);
}
onMouseUp(event: MouseEvent | TouchEvent, canvas: HTMLCanvasElement): void {
if (!this.isDrawing || !this.startPoint) return;
const currentPoint = this.getMousePosition(event, canvas);
const radius = Math.sqrt(
Math.pow(currentPoint.x - this.startPoint.x, 2) +
Math.pow(currentPoint.y - this.startPoint.y, 2),
);
// Draw final circle
this.drawCircle(canvas, this.startPoint, radius);
// Clear preview
this.clearPreview();
this.isDrawing = false;
this.startPoint = null;
}
private updatePreview(
center: { x: number; y: number },
radius: number,
): void {
const previewCanvas = this.state.canvasLayers.preview;
if (!previewCanvas) return;
const ctx = previewCanvas.getContext("2d");
if (!ctx) return;
// Restore previous state
if (this.previewImageData) {
ctx.putImageData(this.previewImageData, 0, 0);
}
// Draw preview circle
ctx.strokeStyle = this.state.currentColor;
ctx.lineWidth = this.state.brushSize;
ctx.setLineDash([5, 5]); // Dashed line for preview
ctx.beginPath();
ctx.arc(center.x, center.y, radius, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]); // Reset line dash
}
private drawCircle(
canvas: HTMLCanvasElement,
center: { x: number; y: number },
radius: number,
): void {
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.strokeStyle = this.state.currentColor;
ctx.lineWidth = this.state.brushSize;
ctx.beginPath();
ctx.arc(center.x, center.y, radius, 0, Math.PI * 2);
ctx.stroke();
}
private clearPreview(): void {
const previewCanvas = this.state.canvasLayers.preview;
if (previewCanvas && this.previewImageData) {
const ctx = previewCanvas.getContext("2d");
ctx?.putImageData(this.previewImageData, 0, 0);
}
}
}
Example 2: Tool with Complex State¶
// Brush tool with pattern support
export class BrushTool extends ToolBase {
private brushPath: { x: number; y: number; pressure: number }[] = [];
private pattern: CanvasPattern | null = null;
constructor(state: KidPixState, dispatch: React.Dispatch<KidPixAction>) {
super(
{
name: "Brush",
icon: "🖌️",
category: "brush",
submenu: [
{ name: "Round", icon: "●", value: "round", type: "pattern" },
{ name: "Square", icon: "■", value: "square", type: "pattern" },
{ name: "Texture", icon: "▦", value: "texture", type: "pattern" },
],
},
state,
dispatch,
);
}
onToolSelect(): void {
this.updatePattern();
}
onMouseDown(event: MouseEvent | TouchEvent, canvas: HTMLCanvasElement): void {
this.saveUndo(canvas);
this.brushPath = [];
const position = this.getMousePosition(event, canvas);
const pressure = this.getPressure(event);
this.brushPath.push({ ...position, pressure });
this.applyBrushStroke(canvas, position, pressure);
}
onMouseMove(event: MouseEvent | TouchEvent, canvas: HTMLCanvasElement): void {
const position = this.getMousePosition(event, canvas);
const pressure = this.getPressure(event);
this.brushPath.push({ ...position, pressure });
// Draw stroke segment
if (this.brushPath.length > 1) {
const prevPoint = this.brushPath[this.brushPath.length - 2];
this.drawBrushSegment(canvas, prevPoint, { ...position, pressure });
}
}
onMouseUp(event: MouseEvent | TouchEvent, canvas: HTMLCanvasElement): void {
// Apply final smoothing to the brush path
this.smoothBrushPath(canvas);
this.brushPath = [];
}
private getPressure(event: MouseEvent | TouchEvent): number {
if ("touches" in event && event.touches[0]) {
// @ts-ignore - force property may not exist
return event.touches[0].force || 0.5;
}
return 0.5; // Default pressure for mouse
}
private updatePattern(): void {
// Create pattern based on current submenu selection
const patternCanvas = document.createElement("canvas");
patternCanvas.width = 20;
patternCanvas.height = 20;
const patternCtx = patternCanvas.getContext("2d");
if (patternCtx) {
// Draw pattern based on selected type
// This would be more complex in real implementation
patternCtx.fillStyle = this.state.currentColor;
patternCtx.fillRect(0, 0, 20, 20);
const canvas = this.state.canvasLayers.main;
const ctx = canvas?.getContext("2d");
this.pattern = ctx?.createPattern(patternCanvas, "repeat") || null;
}
}
private applyBrushStroke(
canvas: HTMLCanvasElement,
position: { x: number; y: number },
pressure: number,
): void {
const ctx = canvas.getContext("2d");
if (!ctx) return;
const size = this.state.brushSize * pressure;
if (this.pattern) {
ctx.fillStyle = this.pattern;
} else {
ctx.fillStyle = this.state.currentColor;
}
ctx.beginPath();
ctx.arc(position.x, position.y, size / 2, 0, Math.PI * 2);
ctx.fill();
}
private drawBrushSegment(
canvas: HTMLCanvasElement,
start: { x: number; y: number; pressure: number },
end: { x: number; y: number; pressure: number },
): void {
const ctx = canvas.getContext("2d");
if (!ctx) return;
// Calculate variable line width based on pressure
const startSize = this.state.brushSize * start.pressure;
const endSize = this.state.brushSize * end.pressure;
// Draw gradient stroke
const gradient = ctx.createLinearGradient(start.x, start.y, end.x, end.y);
gradient.addColorStop(0, this.state.currentColor);
gradient.addColorStop(1, this.state.currentColor);
ctx.strokeStyle = gradient;
ctx.lineWidth = (startSize + endSize) / 2;
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(start.x, start.y);
ctx.lineTo(end.x, end.y);
ctx.stroke();
}
private smoothBrushPath(canvas: HTMLCanvasElement): void {
// Implement path smoothing algorithm
// This would use techniques like Bezier curve fitting
}
}
Verification & Testing¶
Test Tool Registration¶
# Create comprehensive tool registry test
cat > src/tools/__tests__/ToolRegistry.test.ts << 'EOF'
import { ToolRegistry } from '../ToolRegistry';
import { PencilTool } from '../PencilTool';
import { mockState, mockDispatch } from './testUtils';
describe('ToolRegistry', () => {
beforeEach(() => {
// Clear registry before each test
ToolRegistry['tools'].clear();
ToolRegistry['configs'].clear();
});
test('registers and retrieves tools correctly', () => {
const tool = ToolRegistry.getTool('pencil', mockState, mockDispatch);
expect(tool).toBeInstanceOf(PencilTool);
});
test('returns null for unregistered tools', () => {
const tool = ToolRegistry.getTool('nonexistent', mockState, mockDispatch);
expect(tool).toBeNull();
});
test('getAllTools returns all registered tools', () => {
const allTools = ToolRegistry.getAllTools();
expect(allTools).toContainEqual({
id: 'pencil',
config: expect.objectContaining({
name: 'Pencil',
category: 'basic'
})
});
});
});
EOF
Performance Testing¶
# Create performance test
cat > src/tools/__tests__/performance.test.ts << 'EOF'
import { PencilTool } from '../PencilTool';
import { PerformanceMonitor } from '../../utils/performance';
import { mockState, mockDispatch, createMockCanvas } from './testUtils';
describe('Tool Performance', () => {
test('pencil tool performs within time limits', () => {
const tool = new PencilTool(mockState, mockDispatch);
const canvas = createMockCanvas();
const monitor = PerformanceMonitor.getInstance();
const startEvent = {
clientX: 100,
clientY: 100,
shiftKey: false,
ctrlKey: false,
altKey: false,
metaKey: false
} as MouseEvent;
// Test mousedown performance
monitor.startMeasurement('pencil-mousedown');
tool.onMouseDown(startEvent, canvas);
const mousedownTime = monitor.endMeasurement('pencil-mousedown');
expect(mousedownTime).toBeLessThan(16); // Should be faster than 1 frame (60fps)
// Test mousemove performance with many events
const moveEvents = Array.from({ length: 100 }, (_, i) => ({
clientX: 100 + i,
clientY: 100 + i,
shiftKey: false,
ctrlKey: false,
altKey: false,
metaKey: false
} as MouseEvent));
monitor.startMeasurement('pencil-mousemove-batch');
moveEvents.forEach(event => tool.onMouseMove(event, canvas));
const batchTime = monitor.endMeasurement('pencil-mousemove-batch');
const averageTime = batchTime / moveEvents.length;
expect(averageTime).toBeLessThan(1); // Average should be under 1ms per event
});
});
EOF
Integration Testing¶
# Create integration test
cat > src/components/__tests__/CanvasToolIntegration.test.tsx << 'EOF'
import { render, fireEvent, screen } from '@testing-library/react';
import { KidPixProvider } from '../../contexts/KidPixContext';
import { CanvasContainer } from '../Canvas/CanvasContainer';
test('canvas responds to tool interactions', async () => {
render(
<KidPixProvider>
<CanvasContainer />
</KidPixProvider>
);
const canvas = screen.getByRole('img'); // Canvas has img role
// Simulate drawing gesture
fireEvent.mouseDown(canvas, { clientX: 100, clientY: 100 });
fireEvent.mouseMove(canvas, { clientX: 150, clientY: 150 });
fireEvent.mouseUp(canvas, { clientX: 150, clientY: 150 });
// Verify canvas context methods were called
// (This would require proper mocking of canvas context)
});
EOF
E2E Testing with Playwright¶
# Create E2E test for tool interactions
cat > tests/e2e/tools.spec.ts << 'EOF'
import { test, expect } from '@playwright/test';
test('user can draw with different tools', async ({ page }) => {
await page.goto('/?react');
// Wait for canvas to be ready
await page.waitForSelector('canvas');
// Select pencil tool
await page.click('[data-testid="tool-pencil"]');
// Draw on canvas
const canvas = page.locator('canvas[style*="z-index: 5"]'); // Top layer
await canvas.click({ position: { x: 100, y: 100 } });
await canvas.dragTo(canvas, {
sourcePosition: { x: 100, y: 100 },
targetPosition: { x: 200, y: 200 }
});
// Take screenshot to verify drawing
await expect(page).toHaveScreenshot('pencil-drawing.png');
// Test undo functionality
await page.keyboard.press('Control+Z');
await expect(page).toHaveScreenshot('after-undo.png');
});
EOF
Troubleshooting¶
Canvas Context Issues¶
Problem: Canvas context not available
// Solution: Add null checks and fallbacks
const ctx = canvas.getContext("2d");
if (!ctx) {
console.warn("Canvas context not available");
return;
}
Problem: Canvas operations not visible
// Check canvas layer order and visibility
useEffect(() => {
const canvas = canvasRef.current;
if (canvas) {
console.log("Canvas style:", {
zIndex: canvas.style.zIndex,
pointerEvents: canvas.style.pointerEvents,
display: canvas.style.display,
});
}
}, []);
Event Handling Issues¶
Problem: Mouse events not firing
// Ensure canvas has proper event listeners
useEffect(() => {
if (!canvas || !interactive) return;
const handleMouseDown = (e: MouseEvent) => {
console.log("Native mouse down:", e.clientX, e.clientY);
// Your handler logic
};
canvas.addEventListener("mousedown", handleMouseDown);
return () => canvas.removeEventListener("mousedown", handleMouseDown);
}, [canvas, interactive]);
Problem: Touch events not working on mobile
// Add touch event support
<canvas
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
style={{ touchAction: 'none' }} // Prevent default touch behaviors
/>
Performance Issues¶
Problem: Drawing lags on mousemove
// Use requestAnimationFrame throttling
const throttledMouseMove = useCallback(
requestAnimationFrameThrottle((event: MouseEvent) => {
// Your mousemove logic
}),
[dependencies],
);
Problem: Memory leaks from event listeners
// Ensure all listeners are cleaned up
useEffect(() => {
const cleanup: (() => void)[] = [];
if (canvas) {
const handlers = {
mousedown: handleMouseDown,
mousemove: handleMouseMove,
mouseup: handleMouseUp,
};
Object.entries(handlers).forEach(([event, handler]) => {
canvas.addEventListener(event, handler);
cleanup.push(() => canvas.removeEventListener(event, handler));
});
}
return () => cleanup.forEach((fn) => fn());
}, [canvas, handleMouseDown, handleMouseMove, handleMouseUp]);
Next Steps¶
- Commit completed basic tools:
git add src/tools/ src/hooks/useTool.ts
git commit -m "feat(tools): implement basic drawing tools with React architecture"
- Set up tool testing pipeline:
# Run all tool tests
yarn test src/tools/
# Run performance tests
yarn test --testNamePattern="performance"
- Continue with advanced tools:
- Wacky brushes (Phase 5)
- Special effects tools
- Complex shape builders
Continue to: Phase 5: Advanced Features
Related Documentation: