web based infinite canvas
at main 278 lines 8.1 kB view raw
1import type { Action } from "../actions"; 2import type { BrushConfig, StrokePoint, StrokeStyle } from "../model"; 3import { createId, ShapeRecord } from "../model"; 4import type { EditorState, ToolId } from "../reactivity"; 5import { getCurrentPage } from "../reactivity"; 6import type { Tool } from "../tools/base"; 7 8/** 9 * Internal state for pen tool 10 */ 11type PenToolState = { 12 /** Whether we're currently drawing a stroke */ 13 isDrawing: boolean; 14 /** Points being collected for the current stroke */ 15 draftPoints: StrokePoint[]; 16 /** ID of the shape being created */ 17 draftShapeId: string | null; 18 /** Whether draft points are unsynced with document */ 19 draftNeedsSync: boolean; 20 /** Frame bucket when draft was last synced */ 21 lastUpdateFrame: number | null; 22}; 23 24/** 25 * Minimum points required for a valid stroke 26 */ 27const MIN_POINTS = 2; 28 29/** 30 * Minimum distance (in world units) between points to avoid redundant data 31 */ 32const MIN_POINT_DISTANCE = 1; 33 34/** 35 * Duration for a render frame (~60 FPS) 36 */ 37const FRAME_DURATION_MS = 1000 / 60; 38 39/** 40 * Default brush configuration 41 */ 42const DEFAULT_BRUSH = { size: 16, thinning: 0.5, smoothing: 0.5, streamline: 0.5, simulatePressure: true }; 43 44/** 45 * Default stroke style 46 */ 47const DEFAULT_STYLE: StrokeStyle = { color: "#000000", opacity: 1.0 }; 48 49/** 50 * Pen tool - creates freehand stroke shapes using perfect-freehand 51 * 52 * Features: 53 * - Draw smooth strokes by dragging 54 * - Points include optional pressure data 55 * - One undo step per stroke 56 * - Draft stroke is not persisted until pointer up 57 */ 58export class PenTool implements Tool { 59 readonly id: ToolId = "pen"; 60 private toolState: PenToolState; 61 private getBrush: () => BrushConfig; 62 private getStrokeStyle: () => StrokeStyle; 63 64 constructor(getBrush?: () => BrushConfig, getStrokeStyle?: () => StrokeStyle) { 65 this.toolState = { 66 isDrawing: false, 67 draftPoints: [], 68 draftShapeId: null, 69 draftNeedsSync: false, 70 lastUpdateFrame: null, 71 }; 72 this.getBrush = getBrush ?? (() => DEFAULT_BRUSH); 73 this.getStrokeStyle = getStrokeStyle ?? (() => DEFAULT_STYLE); 74 } 75 76 onEnter(state: EditorState): EditorState { 77 this.resetToolState(); 78 return state; 79 } 80 81 onExit(state: EditorState): EditorState { 82 let newState = state; 83 if (this.toolState.draftShapeId) { 84 newState = this.cancelStroke(state); 85 } 86 this.resetToolState(); 87 return newState; 88 } 89 90 onAction(state: EditorState, action: Action): EditorState { 91 switch (action.type) { 92 case "pointer-down": { 93 return this.handlePointerDown(state, action); 94 } 95 case "pointer-move": { 96 return this.handlePointerMove(state, action); 97 } 98 case "pointer-up": { 99 return this.handlePointerUp(state, action); 100 } 101 case "key-down": { 102 return this.handleKeyDown(state, action); 103 } 104 default: { 105 return state; 106 } 107 } 108 } 109 110 private handlePointerDown(state: EditorState, action: Action): EditorState { 111 if (action.type !== "pointer-down") return state; 112 113 const currentPage = getCurrentPage(state); 114 if (!currentPage) return state; 115 116 const shapeId = createId("shape"); 117 const firstPoint: StrokePoint = [action.world.x, action.world.y]; 118 119 const strokeStyle = { ...this.getStrokeStyle() }; 120 121 const shape = ShapeRecord.createStroke(currentPage.id, 0, 0, { 122 points: [firstPoint], 123 brush: this.getBrush(), 124 style: strokeStyle, 125 }, shapeId); 126 127 this.toolState.isDrawing = true; 128 this.toolState.draftPoints = [firstPoint]; 129 this.toolState.draftShapeId = shapeId; 130 this.toolState.draftNeedsSync = false; 131 this.toolState.lastUpdateFrame = frameFromTimestamp(action.timestamp); 132 133 const newPage = { ...currentPage, shapeIds: [...currentPage.shapeIds, shapeId] }; 134 135 return { 136 ...state, 137 doc: { 138 ...state.doc, 139 shapes: { ...state.doc.shapes, [shapeId]: shape }, 140 pages: { ...state.doc.pages, [currentPage.id]: newPage }, 141 }, 142 ui: { ...state.ui, selectionIds: [shapeId] }, 143 }; 144 } 145 146 private handlePointerMove(state: EditorState, action: Action): EditorState { 147 if (action.type !== "pointer-move" || !this.toolState.isDrawing) return state; 148 if (!this.toolState.draftShapeId) return state; 149 150 const shape = state.doc.shapes[this.toolState.draftShapeId]; 151 if (!shape || shape.type !== "stroke") return state; 152 153 const lastPoint = this.toolState.draftPoints[this.toolState.draftPoints.length - 1]; 154 const dx = action.world.x - lastPoint[0]; 155 const dy = action.world.y - lastPoint[1]; 156 const distance = Math.sqrt(dx * dx + dy * dy); 157 158 if (distance < MIN_POINT_DISTANCE) { 159 return state; 160 } 161 162 const newPoint: StrokePoint = [action.world.x, action.world.y]; 163 this.toolState.draftPoints.push(newPoint); 164 this.toolState.draftNeedsSync = true; 165 166 if (this.shouldSyncNow(action.timestamp)) { 167 return this.syncDraftShape(state); 168 } 169 170 return state; 171 } 172 173 private handlePointerUp(state: EditorState, action: Action): EditorState { 174 if (action.type !== "pointer-up" || !this.toolState.draftShapeId) return state; 175 176 let newState = this.syncDraftShape(state); 177 178 const shape = newState.doc.shapes[this.toolState.draftShapeId]; 179 if (!shape || shape.type !== "stroke") { 180 this.resetToolState(); 181 return newState; 182 } 183 184 if (this.toolState.draftPoints.length < MIN_POINTS) { 185 newState = this.cancelStroke(newState); 186 } 187 188 this.toolState.lastUpdateFrame = frameFromTimestamp(action.timestamp); 189 this.resetToolState(); 190 return newState; 191 } 192 193 private handleKeyDown(state: EditorState, action: Action): EditorState { 194 if (action.type !== "key-down") return state; 195 196 if (action.key === "Escape" && this.toolState.draftShapeId) { 197 const newState = this.cancelStroke(state); 198 this.resetToolState(); 199 return newState; 200 } 201 202 return state; 203 } 204 205 private cancelStroke(state: EditorState): EditorState { 206 if (!this.toolState.draftShapeId) return state; 207 208 const shape = state.doc.shapes[this.toolState.draftShapeId]; 209 if (!shape) return state; 210 211 const newShapes = { ...state.doc.shapes }; 212 delete newShapes[this.toolState.draftShapeId]; 213 214 const currentPage = getCurrentPage(state); 215 if (!currentPage) return state; 216 217 const newPage = { 218 ...currentPage, 219 shapeIds: currentPage.shapeIds.filter((id) => id !== this.toolState.draftShapeId), 220 }; 221 222 return { 223 ...state, 224 doc: { ...state.doc, shapes: newShapes, pages: { ...state.doc.pages, [currentPage.id]: newPage } }, 225 ui: { ...state.ui, selectionIds: [] }, 226 }; 227 } 228 229 private resetToolState(): void { 230 this.toolState = { 231 isDrawing: false, 232 draftPoints: [], 233 draftShapeId: null, 234 draftNeedsSync: false, 235 lastUpdateFrame: null, 236 }; 237 } 238 239 private shouldSyncNow(timestamp: number): boolean { 240 const frame = frameFromTimestamp(timestamp); 241 if (this.toolState.lastUpdateFrame === null) { 242 this.toolState.lastUpdateFrame = frame; 243 return true; 244 } 245 if (frame !== this.toolState.lastUpdateFrame) { 246 this.toolState.lastUpdateFrame = frame; 247 return true; 248 } 249 return false; 250 } 251 252 private syncDraftShape(state: EditorState): EditorState { 253 if (!this.toolState.draftShapeId || !this.toolState.draftNeedsSync) { 254 return state; 255 } 256 257 const shape = state.doc.shapes[this.toolState.draftShapeId]; 258 if (!shape || shape.type !== "stroke") { 259 this.toolState.draftNeedsSync = false; 260 return state; 261 } 262 263 const updatedShape = { ...shape, props: { ...shape.props, points: [...this.toolState.draftPoints] } }; 264 this.toolState.draftNeedsSync = false; 265 266 return { 267 ...state, 268 doc: { ...state.doc, shapes: { ...state.doc.shapes, [this.toolState.draftShapeId]: updatedShape } }, 269 }; 270 } 271} 272 273function frameFromTimestamp(timestamp: number): number { 274 if (!Number.isFinite(timestamp) || timestamp < 0) { 275 return 0; 276 } 277 return Math.floor(timestamp / FRAME_DURATION_MS); 278}