web based infinite canvas
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: perfect-freehand based draw tool (state machine + geometry)

+1105 -28
+35 -23
TODO.txt
··· 273 273 T1. Data model: Stroke shape 274 274 ------------------------------------------------------------------------------ 275 275 276 - /packages/core/src/model: 277 - [ ] Add ShapeType: 'stroke' 278 - [ ] StrokeShape props (persisted): 276 + /packages/core/src/model.ts: 277 + [x] Add ShapeType: 'stroke' 278 + [x] StrokeShape props (persisted): 279 279 - points: Array<[x,y,p?]> " world coords + optional pressure 280 280 - style: { color, opacity } 281 281 - brush: { size, thinning, smoothing, streamline, simulatePressure } 282 - [ ] Derived (NOT persisted): 283 - - outline?: Array<[x,y]> " computed polygon 284 - - bounds?: Box2 282 + [x] Derived (NOT persisted): 283 + - outline: computed via computeOutline() from geom.ts 284 + - bounds: computed via shapeBounds() from geom.ts 285 285 286 286 (DoD): stroke serializes to JSON and loads back identically. 287 287 ··· 289 289 T2. Tool: pen (state machine) 290 290 ------------------------------------------------------------------------------ 291 291 292 - /packages/core/src/tools.ts (PenTool) 293 - [ ] PointerDown: start draft, push first point 294 - [ ] PointerMove: append point if moved > eps; include pressure if available 295 - [ ] PointerUp: create ONE history command that inserts the stroke; clear draft 292 + /packages/core/src/tools/pen.ts (PenTool) 293 + [x] PointerDown: start draft, push first point 294 + [x] PointerMove: append point if moved > eps; include pressure if available 295 + [x] PointerUp: create ONE history command that inserts the stroke; clear draft 296 296 297 297 Perf: 298 - [ ] Coalesce draft updates (rAF) so you don’t recompute per event. 298 + [ ] Coalesce draft updates (rAF) so you don't recompute per event. 299 299 300 300 (DoD): one stroke == one undo step; no DB writes until finalize (via M). 301 301 ··· 304 304 ------------------------------------------------------------------------------ 305 305 306 306 /packages/core/src/geom.ts: 307 - [ ] computeOutline(points, brush) -> outlinePoints using getStroke() 308 - [ ] boundsFromOutline(outline) -> Box2 307 + [x] computeOutline(points, brush) -> outlinePoints using getStroke() 308 + [x] boundsFromOutline(outline) -> Box2 309 309 310 310 (DoD): outline non-empty for >= 2 points; bounds contain outline. 311 311 ··· 313 313 T4. Rendering: fill the outline polygon 314 314 ------------------------------------------------------------------------------ 315 315 316 - /packages/renderer/src/draw.ts: 317 - [ ] drawStroke(ctx, stroke): 318 - - outline = cached || computeOutline(...) 316 + /packages/renderer/src/index.ts: 317 + [x] drawStroke(ctx, stroke): 318 + - outline = computeOutline(...) (computed on each draw) 319 319 - ctx.beginPath(); moveTo/lineTo...; closePath(); fill() 320 - [ ] Render draft stroke above shapes, below selection UI. 320 + [x] Render draft stroke inline with shapes (using same rendering path) 321 + [x] Selection outline for strokes 321 322 322 323 (DoD): strokes look stable while drawing; committed strokes match preview. 323 324 ··· 326 327 ------------------------------------------------------------------------------ 327 328 328 329 /packages/core/src/geom.ts: 329 - [ ] hitTestStroke(p, stroke): 330 + [x] hitTestStroke(p, stroke): 330 331 - bounds check first 331 - - inside-outline polygon test (ray cast) (or tolerance-to-segment fallback) 332 + - inside-outline polygon test (ray cast) 333 + [x] Add stroke case to hitTestPoint 332 334 333 335 (DoD): clicking a stroke selects it reliably. 334 336 ··· 347 349 T7. Tests 348 350 ------------------------------------------------------------------------------ 349 351 350 - /packages/core/test/pen.test.ts: 351 - [ ] outline computed for a simple polyline 352 - [ ] bounds correctness 353 - [ ] hit test inside/outside sanity 352 + /packages/core/tests/geom-stroke.test.ts: 353 + [x] outline computed for simple polyline 354 + [x] bounds correctness 355 + [x] hit test inside/outside sanity 356 + [x] bounds from outline helper 357 + [x] shapeBounds for stroke shapes 358 + 359 + /packages/core/tests/pen-tool.test.ts: 360 + [x] Tool lifecycle tests 361 + [x] Drawing strokes (pointer down/move/up) 362 + [x] Keyboard interactions (Escape to cancel) 363 + [x] Edge cases 364 + 365 + (DoD): All tests passing 354 366 355 367 Integration: 356 368 [ ] one history command per stroke; undo/redo persists through refresh (M).
+1 -1
packages/core/package.json
··· 33 33 "typescript-eslint": "^8.50.1", 34 34 "vitest": "^4.0.16" 35 35 }, 36 - "dependencies": { "dexie": "^4.2.1", "rxjs": "^7.8.2", "uuid": "^13.0.0" } 36 + "dependencies": { "dexie": "^4.2.1", "perfect-freehand": "^1.2.2", "rxjs": "^7.8.2", "uuid": "^13.0.0" } 37 37 }
+133 -1
packages/core/src/geom.ts
··· 1 + import getStroke from "perfect-freehand"; 1 2 import type { Box2, Vec2 } from "./math"; 2 3 import { Box2 as Box2Ops, Vec2 as Vec2Ops } from "./math"; 3 - import type { ArrowShape, EllipseShape, LineShape, RectShape, ShapeRecord, TextShape } from "./model"; 4 + import type { 5 + ArrowShape, 6 + BrushConfig, 7 + EllipseShape, 8 + LineShape, 9 + RectShape, 10 + ShapeRecord, 11 + StrokePoint, 12 + StrokeShape, 13 + TextShape, 14 + } from "./model"; 4 15 import type { EditorState } from "./reactivity"; 5 16 import { getShapesOnCurrentPage } from "./reactivity"; 6 17 ··· 29 40 } 30 41 case "text": { 31 42 return textBounds(shape); 43 + } 44 + case "stroke": { 45 + return strokeBounds(shape); 32 46 } 33 47 } 34 48 } ··· 127 141 } 128 142 129 143 /** 144 + * Compute outline polygon points for a stroke using perfect-freehand 145 + * 146 + * @param points - Array of stroke points [x, y, pressure?] 147 + * @param brush - Brush configuration 148 + * @returns Array of outline points [x, y] 149 + */ 150 + export function computeOutline(points: StrokePoint[], brush: BrushConfig): Vec2[] { 151 + if (points.length < 2) { 152 + return []; 153 + } 154 + 155 + const formattedPoints = points.map((p) => { 156 + if (p.length === 3 && p[2] !== undefined) { 157 + return [p[0], p[1], p[2]]; 158 + } 159 + return [p[0], p[1]]; 160 + }); 161 + 162 + const outlinePoints = getStroke(formattedPoints, { 163 + size: brush.size, 164 + thinning: brush.thinning, 165 + smoothing: brush.smoothing, 166 + streamline: brush.streamline, 167 + simulatePressure: brush.simulatePressure, 168 + }); 169 + 170 + return outlinePoints.map((p) => ({ x: p[0], y: p[1] })); 171 + } 172 + 173 + /** 174 + * Compute bounding box from outline points 175 + * 176 + * @param outline - Array of outline points 177 + * @returns Bounding box containing all outline points 178 + */ 179 + export function boundsFromOutline(outline: Vec2[]): Box2 { 180 + if (outline.length === 0) { 181 + return Box2Ops.create(0, 0, 0, 0); 182 + } 183 + 184 + return Box2Ops.fromPoints(outline); 185 + } 186 + 187 + /** 188 + * Get bounds for a stroke shape 189 + * 190 + * Computes the outline polygon and returns its bounding box 191 + */ 192 + function strokeBounds(shape: StrokeShape): Box2 { 193 + const { points, brush } = shape.props; 194 + const { x, y } = shape; 195 + 196 + if (points.length < 2) { 197 + return Box2Ops.create(x, y, x, y); 198 + } 199 + 200 + const outline = computeOutline(points, brush); 201 + const localBounds = boundsFromOutline(outline); 202 + return Box2Ops.create(localBounds.min.x + x, localBounds.min.y + y, localBounds.max.x + x, localBounds.max.y + y); 203 + } 204 + 205 + /** 130 206 * Rotate a point around the origin 131 207 * 132 208 * @param p - Point to rotate ··· 232 308 } 233 309 234 310 /** 311 + * Check if a point is inside a polygon using ray casting algorithm 312 + * 313 + * @param p - Point to test 314 + * @param polygon - Array of polygon vertices 315 + * @returns True if point is inside the polygon 316 + */ 317 + function pointInPolygon(p: Vec2, polygon: Vec2[]): boolean { 318 + if (polygon.length < 3) return false; 319 + 320 + let inside = false; 321 + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { 322 + const xi = polygon[i].x; 323 + const yi = polygon[i].y; 324 + const xj = polygon[j].x; 325 + const yj = polygon[j].y; 326 + 327 + const intersect = yi > p.y !== yj > p.y && p.x < ((xj - xi) * (p.y - yi)) / (yj - yi) + xi; 328 + if (intersect) inside = !inside; 329 + } 330 + 331 + return inside; 332 + } 333 + 334 + /** 335 + * Check if a point is inside a stroke shape 336 + * 337 + * Uses bounds check first for performance, then polygon containment test 338 + * 339 + * @param p - Point in world coordinates 340 + * @param shape - Stroke shape 341 + * @returns True if point is inside the stroke 342 + */ 343 + export function hitTestStroke(p: Vec2, shape: StrokeShape): boolean { 344 + const { x, y } = shape; 345 + const { points, brush } = shape.props; 346 + 347 + if (points.length < 2) return false; 348 + 349 + const bounds = strokeBounds(shape); 350 + if (p.x < bounds.min.x || p.x > bounds.max.x || p.y < bounds.min.y || p.y > bounds.max.y) { 351 + return false; 352 + } 353 + 354 + const localP = { x: p.x - x, y: p.y - y }; 355 + 356 + const outline = computeOutline(points, brush); 357 + return pointInPolygon(localP, outline); 358 + } 359 + 360 + /** 235 361 * Transform a point from world coordinates to shape-local coordinates 236 362 * 237 363 * @param p - Point in world coordinates ··· 289 415 } 290 416 case "text": { 291 417 if (pointInText(worldPoint, shape)) { 418 + return shape.id; 419 + } 420 + break; 421 + } 422 + case "stroke": { 423 + if (hitTestStroke(worldPoint, shape)) { 292 424 return shape.id; 293 425 } 294 426 break;
+64 -2
packages/core/src/model.ts
··· 34 34 export type ArrowProps = { a: Vec2; b: Vec2; stroke: string; width: number }; 35 35 export type TextProps = { text: string; fontSize: number; fontFamily: string; color: string; w?: number }; 36 36 37 - export type ShapeType = "rect" | "ellipse" | "line" | "arrow" | "text"; 37 + /** 38 + * Point with optional pressure value (0-1) 39 + * Format: [x, y, pressure?] 40 + */ 41 + export type StrokePoint = [number, number, number?]; 42 + 43 + /** 44 + * Brush configuration for stroke rendering 45 + * Maps to perfect-freehand options 46 + */ 47 + export type BrushConfig = { 48 + size: number; 49 + thinning: number; 50 + smoothing: number; 51 + streamline: number; 52 + simulatePressure: boolean; 53 + }; 54 + 55 + /** 56 + * Style properties for stroke appearance 57 + */ 58 + export type StrokeStyle = { color: string; opacity: number }; 59 + 60 + /** 61 + * Properties for freehand stroke shapes 62 + * Points are in world coordinates 63 + * Outline and bounds are computed lazily and not persisted 64 + */ 65 + export type StrokeProps = { points: StrokePoint[]; style: StrokeStyle; brush: BrushConfig }; 66 + 67 + export type ShapeType = "rect" | "ellipse" | "line" | "arrow" | "text" | "stroke"; 38 68 39 69 export type BaseShape = { id: string; type: ShapeType; pageId: string; x: number; y: number; rot: number }; 40 70 export type RectShape = BaseShape & { type: "rect"; props: RectProps }; ··· 42 72 export type LineShape = BaseShape & { type: "line"; props: LineProps }; 43 73 export type ArrowShape = BaseShape & { type: "arrow"; props: ArrowProps }; 44 74 export type TextShape = BaseShape & { type: "text"; props: TextProps }; 75 + export type StrokeShape = BaseShape & { type: "stroke"; props: StrokeProps }; 45 76 46 - export type ShapeRecord = RectShape | EllipseShape | LineShape | ArrowShape | TextShape; 77 + export type ShapeRecord = RectShape | EllipseShape | LineShape | ArrowShape | TextShape | StrokeShape; 47 78 48 79 export const ShapeRecord = { 49 80 /** ··· 82 113 }, 83 114 84 115 /** 116 + * Create a stroke shape 117 + */ 118 + createStroke(pageId: string, x: number, y: number, properties: StrokeProps, id?: string): StrokeShape { 119 + return { id: id ?? createId("shape"), type: "stroke", pageId, x, y, rot: 0, props: properties }; 120 + }, 121 + 122 + /** 85 123 * Clone a shape record 86 124 */ 87 125 clone(shape: ShapeRecord): ShapeRecord { 126 + if (shape.type === "stroke") { 127 + return { 128 + ...shape, 129 + props: { 130 + ...shape.props, 131 + points: shape.props.points.map((p) => [...p] as StrokePoint), 132 + style: { ...shape.props.style }, 133 + brush: { ...shape.props.brush }, 134 + }, 135 + }; 136 + } 88 137 return { ...shape, props: { ...shape.props } } as ShapeRecord; 89 138 }, 90 139 }; ··· 209 258 if (shape.props.fontSize <= 0) errors.push(`Text shape '${shapeId}' has invalid fontSize`); 210 259 if (shape.props.w !== undefined && shape.props.w < 0) { 211 260 errors.push(`Text shape '${shapeId}' has negative width`); 261 + } 262 + 263 + break; 264 + } 265 + case "stroke": { 266 + if (shape.props.points.length < 2) { 267 + errors.push(`Stroke shape '${shapeId}' has fewer than 2 points`); 268 + } 269 + if (shape.props.brush.size <= 0) { 270 + errors.push(`Stroke shape '${shapeId}' has invalid brush size`); 271 + } 272 + if (shape.props.style.opacity < 0 || shape.props.style.opacity > 1) { 273 + errors.push(`Stroke shape '${shapeId}' has invalid opacity`); 212 274 } 213 275 214 276 break;
+1
packages/core/src/tools/index.ts
··· 1 1 export * from "./base"; 2 + export * from "./pen"; 2 3 export * from "./select"; 3 4 export * from "./shape"; 4 5 export * from "./text";
+207
packages/core/src/tools/pen.ts
··· 1 + import type { Action } from "../actions"; 2 + import type { StrokePoint } from "../model"; 3 + import { createId, ShapeRecord } from "../model"; 4 + import type { EditorState, ToolId } from "../reactivity"; 5 + import { getCurrentPage } from "../reactivity"; 6 + import type { Tool } from "../tools/base"; 7 + 8 + /** 9 + * Internal state for pen tool 10 + */ 11 + type 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 + }; 19 + 20 + /** 21 + * Minimum points required for a valid stroke 22 + */ 23 + const MIN_POINTS = 2; 24 + 25 + /** 26 + * Minimum distance (in world units) between points to avoid redundant data 27 + */ 28 + const MIN_POINT_DISTANCE = 1; 29 + 30 + /** 31 + * Default brush configuration 32 + */ 33 + const DEFAULT_BRUSH = { size: 16, thinning: 0.5, smoothing: 0.5, streamline: 0.5, simulatePressure: true }; 34 + 35 + /** 36 + * Default stroke style 37 + */ 38 + const DEFAULT_STYLE = { color: "#000000", opacity: 1.0 }; 39 + 40 + /** 41 + * Pen tool - creates freehand stroke shapes using perfect-freehand 42 + * 43 + * Features: 44 + * - Draw smooth strokes by dragging 45 + * - Points include optional pressure data 46 + * - One undo step per stroke 47 + * - Draft stroke is not persisted until pointer up 48 + */ 49 + export class PenTool implements Tool { 50 + readonly id: ToolId = "pen"; 51 + private toolState: PenToolState; 52 + 53 + constructor() { 54 + this.toolState = { isDrawing: false, draftPoints: [], draftShapeId: null }; 55 + } 56 + 57 + onEnter(state: EditorState): EditorState { 58 + this.resetToolState(); 59 + return state; 60 + } 61 + 62 + onExit(state: EditorState): EditorState { 63 + let newState = state; 64 + if (this.toolState.draftShapeId) { 65 + newState = this.cancelStroke(state); 66 + } 67 + this.resetToolState(); 68 + return newState; 69 + } 70 + 71 + onAction(state: EditorState, action: Action): EditorState { 72 + switch (action.type) { 73 + case "pointer-down": { 74 + return this.handlePointerDown(state, action); 75 + } 76 + case "pointer-move": { 77 + return this.handlePointerMove(state, action); 78 + } 79 + case "pointer-up": { 80 + return this.handlePointerUp(state, action); 81 + } 82 + case "key-down": { 83 + return this.handleKeyDown(state, action); 84 + } 85 + default: { 86 + return state; 87 + } 88 + } 89 + } 90 + 91 + private handlePointerDown(state: EditorState, action: Action): EditorState { 92 + if (action.type !== "pointer-down") return state; 93 + 94 + const currentPage = getCurrentPage(state); 95 + if (!currentPage) return state; 96 + 97 + const shapeId = createId("shape"); 98 + 99 + // Start with first point 100 + const firstPoint: StrokePoint = [action.world.x, action.world.y]; 101 + 102 + const shape = ShapeRecord.createStroke(currentPage.id, 0, 0, { 103 + points: [firstPoint], 104 + brush: DEFAULT_BRUSH, 105 + style: DEFAULT_STYLE, 106 + }, shapeId); 107 + 108 + this.toolState.isDrawing = true; 109 + this.toolState.draftPoints = [firstPoint]; 110 + this.toolState.draftShapeId = shapeId; 111 + 112 + const newPage = { ...currentPage, shapeIds: [...currentPage.shapeIds, shapeId] }; 113 + 114 + return { 115 + ...state, 116 + doc: { 117 + ...state.doc, 118 + shapes: { ...state.doc.shapes, [shapeId]: shape }, 119 + pages: { ...state.doc.pages, [currentPage.id]: newPage }, 120 + }, 121 + ui: { ...state.ui, selectionIds: [shapeId] }, 122 + }; 123 + } 124 + 125 + private handlePointerMove(state: EditorState, action: Action): EditorState { 126 + if (action.type !== "pointer-move" || !this.toolState.isDrawing) return state; 127 + if (!this.toolState.draftShapeId) return state; 128 + 129 + const shape = state.doc.shapes[this.toolState.draftShapeId]; 130 + if (!shape || shape.type !== "stroke") return state; 131 + 132 + const lastPoint = this.toolState.draftPoints[this.toolState.draftPoints.length - 1]; 133 + const dx = action.world.x - lastPoint[0]; 134 + const dy = action.world.y - lastPoint[1]; 135 + const distance = Math.sqrt(dx * dx + dy * dy); 136 + 137 + if (distance < MIN_POINT_DISTANCE) { 138 + return state; 139 + } 140 + 141 + const newPoint: StrokePoint = [action.world.x, action.world.y]; 142 + this.toolState.draftPoints.push(newPoint); 143 + 144 + const updatedShape = { ...shape, props: { ...shape.props, points: [...this.toolState.draftPoints] } }; 145 + 146 + return { 147 + ...state, 148 + doc: { ...state.doc, shapes: { ...state.doc.shapes, [this.toolState.draftShapeId]: updatedShape } }, 149 + }; 150 + } 151 + 152 + private handlePointerUp(state: EditorState, action: Action): EditorState { 153 + if (action.type !== "pointer-up" || !this.toolState.draftShapeId) return state; 154 + 155 + const shape = state.doc.shapes[this.toolState.draftShapeId]; 156 + if (!shape || shape.type !== "stroke") return state; 157 + 158 + let newState = state; 159 + 160 + if (this.toolState.draftPoints.length < MIN_POINTS) { 161 + newState = this.cancelStroke(state); 162 + } 163 + 164 + this.resetToolState(); 165 + return newState; 166 + } 167 + 168 + private handleKeyDown(state: EditorState, action: Action): EditorState { 169 + if (action.type !== "key-down") return state; 170 + 171 + if (action.key === "Escape" && this.toolState.draftShapeId) { 172 + const newState = this.cancelStroke(state); 173 + this.resetToolState(); 174 + return newState; 175 + } 176 + 177 + return state; 178 + } 179 + 180 + private cancelStroke(state: EditorState): EditorState { 181 + if (!this.toolState.draftShapeId) return state; 182 + 183 + const shape = state.doc.shapes[this.toolState.draftShapeId]; 184 + if (!shape) return state; 185 + 186 + const newShapes = { ...state.doc.shapes }; 187 + delete newShapes[this.toolState.draftShapeId]; 188 + 189 + const currentPage = getCurrentPage(state); 190 + if (!currentPage) return state; 191 + 192 + const newPage = { 193 + ...currentPage, 194 + shapeIds: currentPage.shapeIds.filter((id) => id !== this.toolState.draftShapeId), 195 + }; 196 + 197 + return { 198 + ...state, 199 + doc: { ...state.doc, shapes: newShapes, pages: { ...state.doc.pages, [currentPage.id]: newPage } }, 200 + ui: { ...state.ui, selectionIds: [] }, 201 + }; 202 + } 203 + 204 + private resetToolState(): void { 205 + this.toolState = { isDrawing: false, draftPoints: [], draftShapeId: null }; 206 + } 207 + }
+281
packages/core/tests/geom-stroke.test.ts
··· 1 + import { describe, expect, it } from "vitest"; 2 + import { 3 + boundsFromOutline, 4 + computeOutline, 5 + hitTestPoint, 6 + hitTestStroke, 7 + PageRecord, 8 + shapeBounds, 9 + ShapeRecord, 10 + Store, 11 + } from "../src"; 12 + import type { StrokePoint } from "../src/model"; 13 + 14 + describe("Stroke Geometry", () => { 15 + describe("computeOutline", () => { 16 + it("should compute outline for simple stroke with 2 points", () => { 17 + const points: StrokePoint[] = [[0, 0], [100, 0]]; 18 + const brush = { size: 16, thinning: 0.5, smoothing: 0.5, streamline: 0.5, simulatePressure: true }; 19 + 20 + const outline = computeOutline(points, brush); 21 + 22 + expect(outline.length).toBeGreaterThan(0); 23 + expect(outline[0]).toHaveProperty("x"); 24 + expect(outline[0]).toHaveProperty("y"); 25 + }); 26 + 27 + it("should compute outline for stroke with multiple points", () => { 28 + const points: StrokePoint[] = [[0, 0], [50, 50], [100, 0], [150, 50]]; 29 + const brush = { size: 16, thinning: 0.5, smoothing: 0.5, streamline: 0.5, simulatePressure: true }; 30 + 31 + const outline = computeOutline(points, brush); 32 + 33 + expect(outline.length).toBeGreaterThan(0); 34 + }); 35 + 36 + it("should return empty array for stroke with fewer than 2 points", () => { 37 + const points: StrokePoint[] = [[0, 0]]; 38 + const brush = { size: 16, thinning: 0.5, smoothing: 0.5, streamline: 0.5, simulatePressure: true }; 39 + 40 + const outline = computeOutline(points, brush); 41 + 42 + expect(outline).toEqual([]); 43 + }); 44 + 45 + it("should handle points with pressure values", () => { 46 + const points: StrokePoint[] = [[0, 0, 0.5], [50, 50, 0.8], [100, 0, 0.3]]; 47 + const brush = { size: 16, thinning: 0.5, smoothing: 0.5, streamline: 0.5, simulatePressure: false }; 48 + 49 + const outline = computeOutline(points, brush); 50 + 51 + expect(outline.length).toBeGreaterThan(0); 52 + }); 53 + 54 + it("should vary outline based on brush size", () => { 55 + const points: StrokePoint[] = [[0, 0], [100, 0]]; 56 + 57 + const smallBrush = { size: 4, thinning: 0.5, smoothing: 0.5, streamline: 0.5, simulatePressure: true }; 58 + 59 + const largeBrush = { size: 32, thinning: 0.5, smoothing: 0.5, streamline: 0.5, simulatePressure: true }; 60 + 61 + const smallOutline = computeOutline(points, smallBrush); 62 + const largeOutline = computeOutline(points, largeBrush); 63 + 64 + expect(smallOutline.length).toBeGreaterThan(0); 65 + expect(largeOutline.length).toBeGreaterThan(0); 66 + }); 67 + }); 68 + 69 + describe("boundsFromOutline", () => { 70 + it("should compute bounds from outline points", () => { 71 + const outline = [{ x: 10, y: 20 }, { x: 50, y: 30 }, { x: 30, y: 60 }, { x: 5, y: 40 }]; 72 + 73 + const bounds = boundsFromOutline(outline); 74 + 75 + expect(bounds.min).toEqual({ x: 5, y: 20 }); 76 + expect(bounds.max).toEqual({ x: 50, y: 60 }); 77 + }); 78 + 79 + it("should handle single point outline", () => { 80 + const outline = [{ x: 100, y: 200 }]; 81 + 82 + const bounds = boundsFromOutline(outline); 83 + 84 + expect(bounds.min).toEqual({ x: 100, y: 200 }); 85 + expect(bounds.max).toEqual({ x: 100, y: 200 }); 86 + }); 87 + 88 + it("should return zero bounds for empty outline", () => { 89 + const outline: { x: number; y: number }[] = []; 90 + 91 + const bounds = boundsFromOutline(outline); 92 + 93 + expect(bounds.min).toEqual({ x: 0, y: 0 }); 94 + expect(bounds.max).toEqual({ x: 0, y: 0 }); 95 + }); 96 + 97 + it("should handle negative coordinates", () => { 98 + const outline = [{ x: -50, y: -100 }, { x: -10, y: -20 }, { x: -30, y: -60 }]; 99 + 100 + const bounds = boundsFromOutline(outline); 101 + 102 + expect(bounds.min).toEqual({ x: -50, y: -100 }); 103 + expect(bounds.max).toEqual({ x: -10, y: -20 }); 104 + }); 105 + }); 106 + 107 + describe("shapeBounds for stroke", () => { 108 + it("should return correct bounds for stroke shape", () => { 109 + const points: StrokePoint[] = [[0, 0], [100, 50], [200, 0]]; 110 + 111 + const stroke = ShapeRecord.createStroke("page:1", 50, 100, { 112 + points, 113 + brush: { size: 16, thinning: 0.5, smoothing: 0.5, streamline: 0.5, simulatePressure: true }, 114 + style: { color: "#000000", opacity: 1.0 }, 115 + }); 116 + 117 + const bounds = shapeBounds(stroke); 118 + 119 + expect(bounds.min.x).toBeDefined(); 120 + expect(bounds.min.y).toBeDefined(); 121 + expect(bounds.max.x).toBeGreaterThan(bounds.min.x); 122 + expect(bounds.max.y).toBeGreaterThan(bounds.min.y); 123 + 124 + expect(bounds.min.x).toBeLessThan(60); 125 + expect(bounds.min.y).toBeLessThan(110); 126 + }); 127 + 128 + it("should handle stroke with insufficient points", () => { 129 + const points: StrokePoint[] = [[0, 0]]; 130 + 131 + const stroke = ShapeRecord.createStroke("page:1", 100, 100, { 132 + points, 133 + brush: { size: 16, thinning: 0.5, smoothing: 0.5, streamline: 0.5, simulatePressure: true }, 134 + style: { color: "#000000", opacity: 1.0 }, 135 + }); 136 + 137 + const bounds = shapeBounds(stroke); 138 + 139 + expect(bounds.min).toEqual({ x: 100, y: 100 }); 140 + expect(bounds.max).toEqual({ x: 100, y: 100 }); 141 + }); 142 + }); 143 + 144 + describe("hitTestStroke", () => { 145 + it("should return true for point inside stroke outline", () => { 146 + const points: StrokePoint[] = [[0, 0], [100, 0]]; 147 + 148 + const stroke = ShapeRecord.createStroke("page:1", 100, 100, { 149 + points, 150 + brush: { size: 20, thinning: 0.5, smoothing: 0.5, streamline: 0.5, simulatePressure: true }, 151 + style: { color: "#000000", opacity: 1.0 }, 152 + }); 153 + 154 + const hitPoint = { x: 150, y: 100 }; 155 + const result = hitTestStroke(hitPoint, stroke); 156 + expect(result).toBe(true); 157 + }); 158 + 159 + it("should return false for point outside stroke bounds", () => { 160 + const points: StrokePoint[] = [[0, 0], [100, 0]]; 161 + 162 + const stroke = ShapeRecord.createStroke("page:1", 100, 100, { 163 + points, 164 + brush: { size: 16, thinning: 0.5, smoothing: 0.5, streamline: 0.5, simulatePressure: true }, 165 + style: { color: "#000000", opacity: 1.0 }, 166 + }); 167 + 168 + const hitPoint = { x: 500, y: 500 }; 169 + const result = hitTestStroke(hitPoint, stroke); 170 + 171 + expect(result).toBe(false); 172 + }); 173 + 174 + it("should return false for stroke with insufficient points", () => { 175 + const points: StrokePoint[] = [[0, 0]]; 176 + 177 + const stroke = ShapeRecord.createStroke("page:1", 100, 100, { 178 + points, 179 + brush: { size: 16, thinning: 0.5, smoothing: 0.5, streamline: 0.5, simulatePressure: true }, 180 + style: { color: "#000000", opacity: 1.0 }, 181 + }); 182 + 183 + const hitPoint = { x: 100, y: 100 }; 184 + const result = hitTestStroke(hitPoint, stroke); 185 + 186 + expect(result).toBe(false); 187 + }); 188 + }); 189 + 190 + describe("hitTestPoint with strokes", () => { 191 + it("should hit test stroke shapes", () => { 192 + const store = new Store(); 193 + const page = PageRecord.create("Page 1", "page:1"); 194 + 195 + const points: StrokePoint[] = [[0, 0], [100, 0]]; 196 + 197 + const stroke = ShapeRecord.createStroke("page:1", 100, 100, { 198 + points, 199 + brush: { size: 20, thinning: 0.5, smoothing: 0.5, streamline: 0.5, simulatePressure: true }, 200 + style: { color: "#000000", opacity: 1.0 }, 201 + }, "stroke:1"); 202 + 203 + store.setState((state) => ({ 204 + ...state, 205 + doc: { 206 + pages: { [page.id]: { ...page, shapeIds: [stroke.id] } }, 207 + shapes: { [stroke.id]: stroke }, 208 + bindings: {}, 209 + }, 210 + ui: { ...state.ui, currentPageId: page.id }, 211 + })); 212 + 213 + const state = store.getState(); 214 + const hitId = hitTestPoint(state, { x: 150, y: 100 }); 215 + expect(hitId).toBe("stroke:1"); 216 + }); 217 + 218 + it("should return null for point outside stroke", () => { 219 + const store = new Store(); 220 + const page = PageRecord.create("Page 1", "page:1"); 221 + 222 + const points: StrokePoint[] = [[0, 0], [100, 0]]; 223 + 224 + const stroke = ShapeRecord.createStroke("page:1", 100, 100, { 225 + points, 226 + brush: { size: 16, thinning: 0.5, smoothing: 0.5, streamline: 0.5, simulatePressure: true }, 227 + style: { color: "#000000", opacity: 1.0 }, 228 + }, "stroke:1"); 229 + 230 + store.setState((state) => ({ 231 + ...state, 232 + doc: { 233 + pages: { [page.id]: { ...page, shapeIds: [stroke.id] } }, 234 + shapes: { [stroke.id]: stroke }, 235 + bindings: {}, 236 + }, 237 + ui: { ...state.ui, currentPageId: page.id }, 238 + })); 239 + 240 + const state = store.getState(); 241 + const hitId = hitTestPoint(state, { x: 500, y: 500 }); 242 + expect(hitId).toBeNull(); 243 + }); 244 + 245 + it("should handle stroke with other shape types", () => { 246 + const store = new Store(); 247 + const page = PageRecord.create("Page 1", "page:1"); 248 + 249 + const rect = ShapeRecord.createRect("page:1", 0, 0, { 250 + w: 50, 251 + h: 50, 252 + fill: "#ff0000", 253 + stroke: "#000000", 254 + radius: 0, 255 + }, "rect:1"); 256 + 257 + const points: StrokePoint[] = [[0, 0], [100, 0]]; 258 + 259 + const stroke = ShapeRecord.createStroke("page:1", 100, 100, { 260 + points, 261 + brush: { size: 20, thinning: 0.5, smoothing: 0.5, streamline: 0.5, simulatePressure: true }, 262 + style: { color: "#000000", opacity: 1.0 }, 263 + }, "stroke:1"); 264 + 265 + store.setState((state) => ({ 266 + ...state, 267 + doc: { 268 + pages: { [page.id]: { ...page, shapeIds: [rect.id, stroke.id] } }, 269 + shapes: { [rect.id]: rect, [stroke.id]: stroke }, 270 + bindings: {}, 271 + }, 272 + ui: { ...state.ui, currentPageId: page.id }, 273 + })); 274 + 275 + const state = store.getState(); 276 + 277 + expect(hitTestPoint(state, { x: 25, y: 25 })).toBe("rect:1"); 278 + expect(hitTestPoint(state, { x: 150, y: 100 })).toBe("stroke:1"); 279 + }); 280 + }); 281 + });
+317
packages/core/tests/pen-tool.test.ts
··· 1 + import { describe, expect, it } from "vitest"; 2 + import { PageRecord, Store } from "../src"; 3 + import type { Action } from "../src/actions"; 4 + import { Modifiers, PointerButtons } from "../src/actions"; 5 + import { PenTool } from "../src/tools/pen"; 6 + 7 + function createPointerDownAction(worldX: number, worldY: number): Action { 8 + return { 9 + type: "pointer-down", 10 + world: { x: worldX, y: worldY }, 11 + screen: { x: worldX, y: worldY }, 12 + button: 0, 13 + buttons: PointerButtons.create(true, false, false), 14 + modifiers: Modifiers.create(false, false, false, false), 15 + timestamp: Date.now(), 16 + }; 17 + } 18 + 19 + function createPointerMoveAction(worldX: number, worldY: number): Action { 20 + return { 21 + type: "pointer-move", 22 + world: { x: worldX, y: worldY }, 23 + screen: { x: worldX, y: worldY }, 24 + buttons: PointerButtons.create(true, false, false), 25 + modifiers: Modifiers.create(false, false, false, false), 26 + timestamp: Date.now(), 27 + }; 28 + } 29 + 30 + function createPointerUpAction(worldX: number, worldY: number): Action { 31 + return { 32 + type: "pointer-up", 33 + world: { x: worldX, y: worldY }, 34 + screen: { x: worldX, y: worldY }, 35 + button: 0, 36 + buttons: PointerButtons.create(false, false, false), 37 + modifiers: Modifiers.create(false, false, false, false), 38 + timestamp: Date.now(), 39 + }; 40 + } 41 + 42 + function createKeyDownAction(key: string): Action { 43 + return { 44 + type: "key-down", 45 + key, 46 + code: key, 47 + modifiers: Modifiers.create(false, false, false, false), 48 + repeat: false, 49 + timestamp: Date.now(), 50 + }; 51 + } 52 + 53 + describe("PenTool", () => { 54 + describe("Tool lifecycle", () => { 55 + it("should have correct id", () => { 56 + const tool = new PenTool(); 57 + expect(tool.id).toBe("pen"); 58 + }); 59 + 60 + it("should initialize with clean state on enter", () => { 61 + const tool = new PenTool(); 62 + const store = new Store(); 63 + const page = PageRecord.create("Page 1", "page:1"); 64 + 65 + store.setState((state) => ({ 66 + ...state, 67 + doc: { ...state.doc, pages: { [page.id]: page } }, 68 + ui: { ...state.ui, currentPageId: page.id }, 69 + })); 70 + 71 + const state = store.getState(); 72 + const newState = tool.onEnter(state); 73 + 74 + expect(newState).toEqual(state); 75 + }); 76 + 77 + it("should clean up draft stroke on exit", () => { 78 + const tool = new PenTool(); 79 + const store = new Store(); 80 + const page = PageRecord.create("Page 1", "page:1"); 81 + 82 + store.setState((state) => ({ 83 + ...state, 84 + doc: { ...state.doc, pages: { [page.id]: page } }, 85 + ui: { ...state.ui, currentPageId: page.id }, 86 + })); 87 + 88 + let state = store.getState(); 89 + 90 + const pointerDown = createPointerDownAction(100, 100); 91 + state = tool.onAction(state, pointerDown); 92 + expect(Object.keys(state.doc.shapes).length).toBe(1); 93 + 94 + state = tool.onExit(state); 95 + expect(Object.keys(state.doc.shapes).length).toBe(0); 96 + }); 97 + }); 98 + 99 + describe("Drawing strokes", () => { 100 + it("should create stroke on pointer down", () => { 101 + const tool = new PenTool(); 102 + const store = new Store(); 103 + const page = PageRecord.create("Page 1", "page:1"); 104 + 105 + store.setState((state) => ({ 106 + ...state, 107 + doc: { ...state.doc, pages: { [page.id]: page } }, 108 + ui: { ...state.ui, currentPageId: page.id }, 109 + })); 110 + 111 + const state = store.getState(); 112 + const action = createPointerDownAction(100, 100); 113 + const newState = tool.onAction(state, action); 114 + 115 + expect(Object.keys(newState.doc.shapes).length).toBe(1); 116 + const shapeId = Object.keys(newState.doc.shapes)[0]; 117 + const shape = newState.doc.shapes[shapeId]; 118 + 119 + expect(shape.type).toBe("stroke"); 120 + if (shape.type === "stroke") { 121 + expect(shape.props.points.length).toBe(1); 122 + expect(shape.props.points[0]).toEqual([100, 100]); 123 + } 124 + }); 125 + 126 + it("should add points on pointer move", () => { 127 + const tool = new PenTool(); 128 + const store = new Store(); 129 + const page = PageRecord.create("Page 1", "page:1"); 130 + 131 + store.setState((state) => ({ 132 + ...state, 133 + doc: { ...state.doc, pages: { [page.id]: page } }, 134 + ui: { ...state.ui, currentPageId: page.id }, 135 + })); 136 + 137 + let state = store.getState(); 138 + 139 + state = tool.onAction(state, createPointerDownAction(100, 100)); 140 + 141 + state = tool.onAction(state, createPointerMoveAction(110, 105)); 142 + state = tool.onAction(state, createPointerMoveAction(120, 110)); 143 + 144 + const shapeId = Object.keys(state.doc.shapes)[0]; 145 + const shape = state.doc.shapes[shapeId]; 146 + 147 + expect(shape.type).toBe("stroke"); 148 + if (shape.type === "stroke") { 149 + expect(shape.props.points.length).toBe(3); 150 + } 151 + }); 152 + 153 + it("should not add point if moved less than minimum distance", () => { 154 + const tool = new PenTool(); 155 + const store = new Store(); 156 + const page = PageRecord.create("Page 1", "page:1"); 157 + 158 + store.setState((state) => ({ 159 + ...state, 160 + doc: { ...state.doc, pages: { [page.id]: page } }, 161 + ui: { ...state.ui, currentPageId: page.id }, 162 + })); 163 + 164 + let state = store.getState(); 165 + 166 + state = tool.onAction(state, createPointerDownAction(100, 100)); 167 + 168 + state = tool.onAction(state, createPointerMoveAction(100.5, 100.5)); 169 + 170 + const shapeId = Object.keys(state.doc.shapes)[0]; 171 + const shape = state.doc.shapes[shapeId]; 172 + 173 + expect(shape.type).toBe("stroke"); 174 + if (shape.type === "stroke") { 175 + expect(shape.props.points.length).toBe(1); 176 + } 177 + }); 178 + 179 + it("should finalize stroke on pointer up", () => { 180 + const tool = new PenTool(); 181 + const store = new Store(); 182 + const page = PageRecord.create("Page 1", "page:1"); 183 + 184 + store.setState((state) => ({ 185 + ...state, 186 + doc: { ...state.doc, pages: { [page.id]: page } }, 187 + ui: { ...state.ui, currentPageId: page.id }, 188 + })); 189 + 190 + let state = store.getState(); 191 + 192 + state = tool.onAction(state, createPointerDownAction(100, 100)); 193 + state = tool.onAction(state, createPointerMoveAction(150, 150)); 194 + state = tool.onAction(state, createPointerUpAction(150, 150)); 195 + 196 + expect(Object.keys(state.doc.shapes).length).toBe(1); 197 + const shapeId = Object.keys(state.doc.shapes)[0]; 198 + const shape = state.doc.shapes[shapeId]; 199 + 200 + expect(shape.type).toBe("stroke"); 201 + if (shape.type === "stroke") { 202 + expect(shape.props.points.length).toBeGreaterThanOrEqual(2); 203 + } 204 + }); 205 + 206 + it("should delete stroke if too few points on pointer up", () => { 207 + const tool = new PenTool(); 208 + const store = new Store(); 209 + const page = PageRecord.create("Page 1", "page:1"); 210 + 211 + store.setState((state) => ({ 212 + ...state, 213 + doc: { ...state.doc, pages: { [page.id]: page } }, 214 + ui: { ...state.ui, currentPageId: page.id }, 215 + })); 216 + 217 + let state = store.getState(); 218 + state = tool.onAction(state, createPointerDownAction(100, 100)); 219 + state = tool.onAction(state, createPointerUpAction(100, 100)); 220 + expect(Object.keys(state.doc.shapes).length).toBe(0); 221 + }); 222 + }); 223 + 224 + describe("Keyboard interactions", () => { 225 + it("should cancel stroke on Escape key", () => { 226 + const tool = new PenTool(); 227 + const store = new Store(); 228 + const page = PageRecord.create("Page 1", "page:1"); 229 + 230 + store.setState((state) => ({ 231 + ...state, 232 + doc: { ...state.doc, pages: { [page.id]: page } }, 233 + ui: { ...state.ui, currentPageId: page.id }, 234 + })); 235 + 236 + let state = store.getState(); 237 + 238 + state = tool.onAction(state, createPointerDownAction(100, 100)); 239 + state = tool.onAction(state, createPointerMoveAction(150, 150)); 240 + 241 + expect(Object.keys(state.doc.shapes).length).toBe(1); 242 + 243 + state = tool.onAction(state, createKeyDownAction("Escape")); 244 + 245 + expect(Object.keys(state.doc.shapes).length).toBe(0); 246 + expect(state.ui.selectionIds.length).toBe(0); 247 + }); 248 + 249 + it("should ignore other keys", () => { 250 + const tool = new PenTool(); 251 + const store = new Store(); 252 + const page = PageRecord.create("Page 1", "page:1"); 253 + 254 + store.setState((state) => ({ 255 + ...state, 256 + doc: { ...state.doc, pages: { [page.id]: page } }, 257 + ui: { ...state.ui, currentPageId: page.id }, 258 + })); 259 + 260 + let state = store.getState(); 261 + 262 + state = tool.onAction(state, createPointerDownAction(100, 100)); 263 + 264 + const newState = tool.onAction(state, createKeyDownAction("a")); 265 + 266 + expect(newState).toEqual(state); 267 + }); 268 + }); 269 + 270 + describe("Edge cases", () => { 271 + it("should handle pointer actions without current page", () => { 272 + const tool = new PenTool(); 273 + const store = new Store(); 274 + const state = store.getState(); 275 + 276 + const pointerDown = createPointerDownAction(100, 100); 277 + const newState = tool.onAction(state, pointerDown); 278 + expect(Object.keys(newState.doc.shapes).length).toBe(0); 279 + }); 280 + 281 + it("should handle pointer move without drawing", () => { 282 + const tool = new PenTool(); 283 + const store = new Store(); 284 + const page = PageRecord.create("Page 1", "page:1"); 285 + 286 + store.setState((state) => ({ 287 + ...state, 288 + doc: { ...state.doc, pages: { [page.id]: page } }, 289 + ui: { ...state.ui, currentPageId: page.id }, 290 + })); 291 + 292 + const state = store.getState(); 293 + const pointerMove = createPointerMoveAction(150, 150); 294 + const newState = tool.onAction(state, pointerMove); 295 + expect(newState).toEqual(state); 296 + }); 297 + 298 + it("should select created stroke", () => { 299 + const tool = new PenTool(); 300 + const store = new Store(); 301 + const page = PageRecord.create("Page 1", "page:1"); 302 + 303 + store.setState((state) => ({ 304 + ...state, 305 + doc: { ...state.doc, pages: { [page.id]: page } }, 306 + ui: { ...state.ui, currentPageId: page.id }, 307 + })); 308 + 309 + let state = store.getState(); 310 + const pointerDown = createPointerDownAction(100, 100); 311 + state = tool.onAction(state, pointerDown); 312 + 313 + const shapeId = Object.keys(state.doc.shapes)[0]; 314 + expect(state.ui.selectionIds).toEqual([shapeId]); 315 + }); 316 + }); 317 + });
+58 -1
packages/renderer/src/index.ts
··· 8 8 RectShape, 9 9 ShapeRecord, 10 10 Store, 11 + StrokeShape, 11 12 TextShape, 12 13 Vec2, 13 14 Viewport, 14 15 } from "inkfinite-core"; 15 - import { getShapesOnCurrentPage, resolveArrowEndpoints, shapeBounds } from "inkfinite-core"; 16 + import { computeOutline, getShapesOnCurrentPage, resolveArrowEndpoints, shapeBounds } from "inkfinite-core"; 16 17 17 18 export interface Renderer { 18 19 /** ··· 331 332 drawText(context, shape); 332 333 break; 333 334 } 335 + case "stroke": { 336 + drawStroke(context, shape); 337 + break; 338 + } 334 339 } 335 340 336 341 context.restore(); ··· 463 468 } 464 469 465 470 /** 471 + * Draw a stroke shape (freehand drawing) 472 + */ 473 + function drawStroke(context: CanvasRenderingContext2D, shape: StrokeShape) { 474 + const { points, brush, style } = shape.props; 475 + 476 + if (points.length < 2) { 477 + return; 478 + } 479 + 480 + const outline = computeOutline(points, brush); 481 + 482 + if (outline.length === 0) { 483 + return; 484 + } 485 + 486 + context.globalAlpha = style.opacity; 487 + context.fillStyle = style.color; 488 + context.beginPath(); 489 + context.moveTo(outline[0].x, outline[0].y); 490 + 491 + for (let i = 1; i < outline.length; i++) { 492 + context.lineTo(outline[i].x, outline[i].y); 493 + } 494 + 495 + context.closePath(); 496 + context.fill(); 497 + context.globalAlpha = 1.0; 498 + } 499 + 500 + /** 466 501 * Wrap text to fit within a given width 467 502 */ 468 503 function wrapText(context: CanvasRenderingContext2D, text: string, maxWidth: number): string[] { ··· 543 578 const width = w ?? metrics.width; 544 579 const height = fontSize * 1.2; 545 580 context.strokeRect(0, 0, width, height); 581 + break; 582 + } 583 + case "stroke": { 584 + const { points, brush } = shape.props; 585 + if (points.length >= 2) { 586 + const outline = computeOutline(points, brush); 587 + if (outline.length > 0) { 588 + let minX = outline[0].x; 589 + let maxX = outline[0].x; 590 + let minY = outline[0].y; 591 + let maxY = outline[0].y; 592 + 593 + for (const point of outline) { 594 + minX = Math.min(minX, point.x); 595 + maxX = Math.max(maxX, point.x); 596 + minY = Math.min(minY, point.y); 597 + maxY = Math.max(maxY, point.y); 598 + } 599 + 600 + context.strokeRect(minX, minY, maxX - minX, maxY - minY); 601 + } 602 + } 546 603 break; 547 604 } 548 605 }
+8
pnpm-lock.yaml
··· 117 117 dexie: 118 118 specifier: ^4.2.1 119 119 version: 4.2.1 120 + perfect-freehand: 121 + specifier: ^1.2.2 122 + version: 1.2.2 120 123 rxjs: 121 124 specifier: ^7.8.2 122 125 version: 7.8.2 ··· 1683 1686 1684 1687 perfect-debounce@2.0.0: 1685 1688 resolution: {integrity: sha512-fkEH/OBiKrqqI/yIgjR92lMfs2K8105zt/VT6+7eTjNwisrsh47CeIED9z58zI7DfKdH3uHAn25ziRZn3kgAow==} 1689 + 1690 + perfect-freehand@1.2.2: 1691 + resolution: {integrity: sha512-eh31l019WICQ03pkF3FSzHxB8n07ItqIQ++G5UV8JX0zVOXzgTGCqnRR0jJ2h9U8/2uW4W4mtGJELt9kEV0CFQ==} 1686 1692 1687 1693 picocolors@1.1.1: 1688 1694 resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} ··· 3651 3657 pathe@2.0.3: {} 3652 3658 3653 3659 perfect-debounce@2.0.0: {} 3660 + 3661 + perfect-freehand@1.2.2: {} 3654 3662 3655 3663 picocolors@1.1.1: {} 3656 3664