web based infinite canvas
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}