From 1792d0096cba946ac5825f4bdef77033941fde47 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Tue, 23 Dec 2025 09:21:59 -0600 Subject: [PATCH] feat: perfect-freehand based draw tool (state machine + geometry) --- TODO.txt | 58 +++-- packages/core/package.json | 2 +- packages/core/src/geom.ts | 134 +++++++++- packages/core/src/model.ts | 66 ++++- packages/core/src/tools/index.ts | 1 + packages/core/src/tools/pen.ts | 207 ++++++++++++++++ packages/core/tests/geom-stroke.test.ts | 281 +++++++++++++++++++++ packages/core/tests/pen-tool.test.ts | 317 ++++++++++++++++++++++++ packages/renderer/src/index.ts | 59 ++++- pnpm-lock.yaml | 8 + 10 files changed, 1105 insertions(+), 28 deletions(-) create mode 100644 packages/core/src/tools/pen.ts create mode 100644 packages/core/tests/geom-stroke.test.ts create mode 100644 packages/core/tests/pen-tool.test.ts diff --git a/TODO.txt b/TODO.txt index f27bda7..bdfc823 100644 --- a/TODO.txt +++ b/TODO.txt @@ -273,15 +273,15 @@ Refs: T1. Data model: Stroke shape ------------------------------------------------------------------------------ -/packages/core/src/model: -[ ] Add ShapeType: 'stroke' -[ ] StrokeShape props (persisted): +/packages/core/src/model.ts: +[x] Add ShapeType: 'stroke' +[x] StrokeShape props (persisted): - points: Array<[x,y,p?]> " world coords + optional pressure - style: { color, opacity } - brush: { size, thinning, smoothing, streamline, simulatePressure } -[ ] Derived (NOT persisted): - - outline?: Array<[x,y]> " computed polygon - - bounds?: Box2 +[x] Derived (NOT persisted): + - outline: computed via computeOutline() from geom.ts + - bounds: computed via shapeBounds() from geom.ts (DoD): stroke serializes to JSON and loads back identically. @@ -289,13 +289,13 @@ T1. Data model: Stroke shape T2. Tool: pen (state machine) ------------------------------------------------------------------------------ -/packages/core/src/tools.ts (PenTool) -[ ] PointerDown: start draft, push first point -[ ] PointerMove: append point if moved > eps; include pressure if available -[ ] PointerUp: create ONE history command that inserts the stroke; clear draft +/packages/core/src/tools/pen.ts (PenTool) +[x] PointerDown: start draft, push first point +[x] PointerMove: append point if moved > eps; include pressure if available +[x] PointerUp: create ONE history command that inserts the stroke; clear draft Perf: -[ ] Coalesce draft updates (rAF) so you don’t recompute per event. +[ ] Coalesce draft updates (rAF) so you don't recompute per event. (DoD): one stroke == one undo step; no DB writes until finalize (via M). @@ -304,8 +304,8 @@ T3. Geometry: compute outline via perfect-freehand ------------------------------------------------------------------------------ /packages/core/src/geom.ts: -[ ] computeOutline(points, brush) -> outlinePoints using getStroke() -[ ] boundsFromOutline(outline) -> Box2 +[x] computeOutline(points, brush) -> outlinePoints using getStroke() +[x] boundsFromOutline(outline) -> Box2 (DoD): outline non-empty for >= 2 points; bounds contain outline. @@ -313,11 +313,12 @@ T3. Geometry: compute outline via perfect-freehand T4. Rendering: fill the outline polygon ------------------------------------------------------------------------------ -/packages/renderer/src/draw.ts: -[ ] drawStroke(ctx, stroke): - - outline = cached || computeOutline(...) +/packages/renderer/src/index.ts: +[x] drawStroke(ctx, stroke): + - outline = computeOutline(...) (computed on each draw) - ctx.beginPath(); moveTo/lineTo...; closePath(); fill() -[ ] Render draft stroke above shapes, below selection UI. +[x] Render draft stroke inline with shapes (using same rendering path) +[x] Selection outline for strokes (DoD): strokes look stable while drawing; committed strokes match preview. @@ -326,9 +327,10 @@ T5. Hit testing ------------------------------------------------------------------------------ /packages/core/src/geom.ts: -[ ] hitTestStroke(p, stroke): +[x] hitTestStroke(p, stroke): - bounds check first - - inside-outline polygon test (ray cast) (or tolerance-to-segment fallback) + - inside-outline polygon test (ray cast) +[x] Add stroke case to hitTestPoint (DoD): clicking a stroke selects it reliably. @@ -347,10 +349,20 @@ T6. Brush settings (thin UI slice) T7. Tests ------------------------------------------------------------------------------ -/packages/core/test/pen.test.ts: -[ ] outline computed for a simple polyline -[ ] bounds correctness -[ ] hit test inside/outside sanity +/packages/core/tests/geom-stroke.test.ts: +[x] outline computed for simple polyline +[x] bounds correctness +[x] hit test inside/outside sanity +[x] bounds from outline helper +[x] shapeBounds for stroke shapes + +/packages/core/tests/pen-tool.test.ts: +[x] Tool lifecycle tests +[x] Drawing strokes (pointer down/move/up) +[x] Keyboard interactions (Escape to cancel) +[x] Edge cases + +(DoD): All tests passing Integration: [ ] one history command per stroke; undo/redo persists through refresh (M). diff --git a/packages/core/package.json b/packages/core/package.json index 2cae6bc..7ee7c4e 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -33,5 +33,5 @@ "typescript-eslint": "^8.50.1", "vitest": "^4.0.16" }, - "dependencies": { "dexie": "^4.2.1", "rxjs": "^7.8.2", "uuid": "^13.0.0" } + "dependencies": { "dexie": "^4.2.1", "perfect-freehand": "^1.2.2", "rxjs": "^7.8.2", "uuid": "^13.0.0" } } diff --git a/packages/core/src/geom.ts b/packages/core/src/geom.ts index a7e3bd7..6c16817 100644 --- a/packages/core/src/geom.ts +++ b/packages/core/src/geom.ts @@ -1,6 +1,17 @@ +import getStroke from "perfect-freehand"; import type { Box2, Vec2 } from "./math"; import { Box2 as Box2Ops, Vec2 as Vec2Ops } from "./math"; -import type { ArrowShape, EllipseShape, LineShape, RectShape, ShapeRecord, TextShape } from "./model"; +import type { + ArrowShape, + BrushConfig, + EllipseShape, + LineShape, + RectShape, + ShapeRecord, + StrokePoint, + StrokeShape, + TextShape, +} from "./model"; import type { EditorState } from "./reactivity"; import { getShapesOnCurrentPage } from "./reactivity"; @@ -30,6 +41,9 @@ export function shapeBounds(shape: ShapeRecord): Box2 { case "text": { return textBounds(shape); } + case "stroke": { + return strokeBounds(shape); + } } } @@ -126,6 +140,68 @@ function textBounds(shape: TextShape): Box2 { return Box2Ops.fromPoints(translatedCorners); } +/** + * Compute outline polygon points for a stroke using perfect-freehand + * + * @param points - Array of stroke points [x, y, pressure?] + * @param brush - Brush configuration + * @returns Array of outline points [x, y] + */ +export function computeOutline(points: StrokePoint[], brush: BrushConfig): Vec2[] { + if (points.length < 2) { + return []; + } + + const formattedPoints = points.map((p) => { + if (p.length === 3 && p[2] !== undefined) { + return [p[0], p[1], p[2]]; + } + return [p[0], p[1]]; + }); + + const outlinePoints = getStroke(formattedPoints, { + size: brush.size, + thinning: brush.thinning, + smoothing: brush.smoothing, + streamline: brush.streamline, + simulatePressure: brush.simulatePressure, + }); + + return outlinePoints.map((p) => ({ x: p[0], y: p[1] })); +} + +/** + * Compute bounding box from outline points + * + * @param outline - Array of outline points + * @returns Bounding box containing all outline points + */ +export function boundsFromOutline(outline: Vec2[]): Box2 { + if (outline.length === 0) { + return Box2Ops.create(0, 0, 0, 0); + } + + return Box2Ops.fromPoints(outline); +} + +/** + * Get bounds for a stroke shape + * + * Computes the outline polygon and returns its bounding box + */ +function strokeBounds(shape: StrokeShape): Box2 { + const { points, brush } = shape.props; + const { x, y } = shape; + + if (points.length < 2) { + return Box2Ops.create(x, y, x, y); + } + + const outline = computeOutline(points, brush); + const localBounds = boundsFromOutline(outline); + return Box2Ops.create(localBounds.min.x + x, localBounds.min.y + y, localBounds.max.x + x, localBounds.max.y + y); +} + /** * Rotate a point around the origin * @@ -231,6 +307,56 @@ export function pointInText(p: Vec2, shape: TextShape): boolean { return localP.x >= 0 && localP.x <= width && localP.y >= 0 && localP.y <= height; } +/** + * Check if a point is inside a polygon using ray casting algorithm + * + * @param p - Point to test + * @param polygon - Array of polygon vertices + * @returns True if point is inside the polygon + */ +function pointInPolygon(p: Vec2, polygon: Vec2[]): boolean { + if (polygon.length < 3) return false; + + let inside = false; + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + const xi = polygon[i].x; + const yi = polygon[i].y; + const xj = polygon[j].x; + const yj = polygon[j].y; + + const intersect = yi > p.y !== yj > p.y && p.x < ((xj - xi) * (p.y - yi)) / (yj - yi) + xi; + if (intersect) inside = !inside; + } + + return inside; +} + +/** + * Check if a point is inside a stroke shape + * + * Uses bounds check first for performance, then polygon containment test + * + * @param p - Point in world coordinates + * @param shape - Stroke shape + * @returns True if point is inside the stroke + */ +export function hitTestStroke(p: Vec2, shape: StrokeShape): boolean { + const { x, y } = shape; + const { points, brush } = shape.props; + + if (points.length < 2) return false; + + const bounds = strokeBounds(shape); + if (p.x < bounds.min.x || p.x > bounds.max.x || p.y < bounds.min.y || p.y > bounds.max.y) { + return false; + } + + const localP = { x: p.x - x, y: p.y - y }; + + const outline = computeOutline(points, brush); + return pointInPolygon(localP, outline); +} + /** * Transform a point from world coordinates to shape-local coordinates * @@ -293,6 +419,12 @@ export function hitTestPoint(state: EditorState, worldPoint: Vec2, tolerance = 5 } break; } + case "stroke": { + if (hitTestStroke(worldPoint, shape)) { + return shape.id; + } + break; + } } } diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index 4b3b30f..0ec0635 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -34,7 +34,37 @@ export type LineProps = { a: Vec2; b: Vec2; stroke: string; width: number }; export type ArrowProps = { a: Vec2; b: Vec2; stroke: string; width: number }; export type TextProps = { text: string; fontSize: number; fontFamily: string; color: string; w?: number }; -export type ShapeType = "rect" | "ellipse" | "line" | "arrow" | "text"; +/** + * Point with optional pressure value (0-1) + * Format: [x, y, pressure?] + */ +export type StrokePoint = [number, number, number?]; + +/** + * Brush configuration for stroke rendering + * Maps to perfect-freehand options + */ +export type BrushConfig = { + size: number; + thinning: number; + smoothing: number; + streamline: number; + simulatePressure: boolean; +}; + +/** + * Style properties for stroke appearance + */ +export type StrokeStyle = { color: string; opacity: number }; + +/** + * Properties for freehand stroke shapes + * Points are in world coordinates + * Outline and bounds are computed lazily and not persisted + */ +export type StrokeProps = { points: StrokePoint[]; style: StrokeStyle; brush: BrushConfig }; + +export type ShapeType = "rect" | "ellipse" | "line" | "arrow" | "text" | "stroke"; export type BaseShape = { id: string; type: ShapeType; pageId: string; x: number; y: number; rot: number }; export type RectShape = BaseShape & { type: "rect"; props: RectProps }; @@ -42,8 +72,9 @@ export type EllipseShape = BaseShape & { type: "ellipse"; props: EllipseProps }; export type LineShape = BaseShape & { type: "line"; props: LineProps }; export type ArrowShape = BaseShape & { type: "arrow"; props: ArrowProps }; export type TextShape = BaseShape & { type: "text"; props: TextProps }; +export type StrokeShape = BaseShape & { type: "stroke"; props: StrokeProps }; -export type ShapeRecord = RectShape | EllipseShape | LineShape | ArrowShape | TextShape; +export type ShapeRecord = RectShape | EllipseShape | LineShape | ArrowShape | TextShape | StrokeShape; export const ShapeRecord = { /** @@ -81,10 +112,28 @@ export const ShapeRecord = { return { id: id ?? createId("shape"), type: "text", pageId, x, y, rot: 0, props: properties }; }, + /** + * Create a stroke shape + */ + createStroke(pageId: string, x: number, y: number, properties: StrokeProps, id?: string): StrokeShape { + return { id: id ?? createId("shape"), type: "stroke", pageId, x, y, rot: 0, props: properties }; + }, + /** * Clone a shape record */ clone(shape: ShapeRecord): ShapeRecord { + if (shape.type === "stroke") { + return { + ...shape, + props: { + ...shape.props, + points: shape.props.points.map((p) => [...p] as StrokePoint), + style: { ...shape.props.style }, + brush: { ...shape.props.brush }, + }, + }; + } return { ...shape, props: { ...shape.props } } as ShapeRecord; }, }; @@ -211,6 +260,19 @@ export function validateDoc(document: Document): ValidationResult { errors.push(`Text shape '${shapeId}' has negative width`); } + break; + } + case "stroke": { + if (shape.props.points.length < 2) { + errors.push(`Stroke shape '${shapeId}' has fewer than 2 points`); + } + if (shape.props.brush.size <= 0) { + errors.push(`Stroke shape '${shapeId}' has invalid brush size`); + } + if (shape.props.style.opacity < 0 || shape.props.style.opacity > 1) { + errors.push(`Stroke shape '${shapeId}' has invalid opacity`); + } + break; } } diff --git a/packages/core/src/tools/index.ts b/packages/core/src/tools/index.ts index 84a2612..6ca0143 100644 --- a/packages/core/src/tools/index.ts +++ b/packages/core/src/tools/index.ts @@ -1,4 +1,5 @@ export * from "./base"; +export * from "./pen"; export * from "./select"; export * from "./shape"; export * from "./text"; diff --git a/packages/core/src/tools/pen.ts b/packages/core/src/tools/pen.ts new file mode 100644 index 0000000..2515b7f --- /dev/null +++ b/packages/core/src/tools/pen.ts @@ -0,0 +1,207 @@ +import type { Action } from "../actions"; +import type { StrokePoint } from "../model"; +import { createId, ShapeRecord } from "../model"; +import type { EditorState, ToolId } from "../reactivity"; +import { getCurrentPage } from "../reactivity"; +import type { Tool } from "../tools/base"; + +/** + * Internal state for pen tool + */ +type PenToolState = { + /** Whether we're currently drawing a stroke */ + isDrawing: boolean; + /** Points being collected for the current stroke */ + draftPoints: StrokePoint[]; + /** ID of the shape being created */ + draftShapeId: string | null; +}; + +/** + * Minimum points required for a valid stroke + */ +const MIN_POINTS = 2; + +/** + * Minimum distance (in world units) between points to avoid redundant data + */ +const MIN_POINT_DISTANCE = 1; + +/** + * Default brush configuration + */ +const DEFAULT_BRUSH = { size: 16, thinning: 0.5, smoothing: 0.5, streamline: 0.5, simulatePressure: true }; + +/** + * Default stroke style + */ +const DEFAULT_STYLE = { color: "#000000", opacity: 1.0 }; + +/** + * Pen tool - creates freehand stroke shapes using perfect-freehand + * + * Features: + * - Draw smooth strokes by dragging + * - Points include optional pressure data + * - One undo step per stroke + * - Draft stroke is not persisted until pointer up + */ +export class PenTool implements Tool { + readonly id: ToolId = "pen"; + private toolState: PenToolState; + + constructor() { + this.toolState = { isDrawing: false, draftPoints: [], draftShapeId: null }; + } + + onEnter(state: EditorState): EditorState { + this.resetToolState(); + return state; + } + + onExit(state: EditorState): EditorState { + let newState = state; + if (this.toolState.draftShapeId) { + newState = this.cancelStroke(state); + } + this.resetToolState(); + return newState; + } + + onAction(state: EditorState, action: Action): EditorState { + switch (action.type) { + case "pointer-down": { + return this.handlePointerDown(state, action); + } + case "pointer-move": { + return this.handlePointerMove(state, action); + } + case "pointer-up": { + return this.handlePointerUp(state, action); + } + case "key-down": { + return this.handleKeyDown(state, action); + } + default: { + return state; + } + } + } + + private handlePointerDown(state: EditorState, action: Action): EditorState { + if (action.type !== "pointer-down") return state; + + const currentPage = getCurrentPage(state); + if (!currentPage) return state; + + const shapeId = createId("shape"); + + // Start with first point + const firstPoint: StrokePoint = [action.world.x, action.world.y]; + + const shape = ShapeRecord.createStroke(currentPage.id, 0, 0, { + points: [firstPoint], + brush: DEFAULT_BRUSH, + style: DEFAULT_STYLE, + }, shapeId); + + this.toolState.isDrawing = true; + this.toolState.draftPoints = [firstPoint]; + this.toolState.draftShapeId = shapeId; + + const newPage = { ...currentPage, shapeIds: [...currentPage.shapeIds, shapeId] }; + + return { + ...state, + doc: { + ...state.doc, + shapes: { ...state.doc.shapes, [shapeId]: shape }, + pages: { ...state.doc.pages, [currentPage.id]: newPage }, + }, + ui: { ...state.ui, selectionIds: [shapeId] }, + }; + } + + private handlePointerMove(state: EditorState, action: Action): EditorState { + if (action.type !== "pointer-move" || !this.toolState.isDrawing) return state; + if (!this.toolState.draftShapeId) return state; + + const shape = state.doc.shapes[this.toolState.draftShapeId]; + if (!shape || shape.type !== "stroke") return state; + + const lastPoint = this.toolState.draftPoints[this.toolState.draftPoints.length - 1]; + const dx = action.world.x - lastPoint[0]; + const dy = action.world.y - lastPoint[1]; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance < MIN_POINT_DISTANCE) { + return state; + } + + const newPoint: StrokePoint = [action.world.x, action.world.y]; + this.toolState.draftPoints.push(newPoint); + + const updatedShape = { ...shape, props: { ...shape.props, points: [...this.toolState.draftPoints] } }; + + return { + ...state, + doc: { ...state.doc, shapes: { ...state.doc.shapes, [this.toolState.draftShapeId]: updatedShape } }, + }; + } + + private handlePointerUp(state: EditorState, action: Action): EditorState { + if (action.type !== "pointer-up" || !this.toolState.draftShapeId) return state; + + const shape = state.doc.shapes[this.toolState.draftShapeId]; + if (!shape || shape.type !== "stroke") return state; + + let newState = state; + + if (this.toolState.draftPoints.length < MIN_POINTS) { + newState = this.cancelStroke(state); + } + + this.resetToolState(); + return newState; + } + + private handleKeyDown(state: EditorState, action: Action): EditorState { + if (action.type !== "key-down") return state; + + if (action.key === "Escape" && this.toolState.draftShapeId) { + const newState = this.cancelStroke(state); + this.resetToolState(); + return newState; + } + + return state; + } + + private cancelStroke(state: EditorState): EditorState { + if (!this.toolState.draftShapeId) return state; + + const shape = state.doc.shapes[this.toolState.draftShapeId]; + if (!shape) return state; + + const newShapes = { ...state.doc.shapes }; + delete newShapes[this.toolState.draftShapeId]; + + const currentPage = getCurrentPage(state); + if (!currentPage) return state; + + const newPage = { + ...currentPage, + shapeIds: currentPage.shapeIds.filter((id) => id !== this.toolState.draftShapeId), + }; + + return { + ...state, + doc: { ...state.doc, shapes: newShapes, pages: { ...state.doc.pages, [currentPage.id]: newPage } }, + ui: { ...state.ui, selectionIds: [] }, + }; + } + + private resetToolState(): void { + this.toolState = { isDrawing: false, draftPoints: [], draftShapeId: null }; + } +} diff --git a/packages/core/tests/geom-stroke.test.ts b/packages/core/tests/geom-stroke.test.ts new file mode 100644 index 0000000..8a42851 --- /dev/null +++ b/packages/core/tests/geom-stroke.test.ts @@ -0,0 +1,281 @@ +import { describe, expect, it } from "vitest"; +import { + boundsFromOutline, + computeOutline, + hitTestPoint, + hitTestStroke, + PageRecord, + shapeBounds, + ShapeRecord, + Store, +} from "../src"; +import type { StrokePoint } from "../src/model"; + +describe("Stroke Geometry", () => { + describe("computeOutline", () => { + it("should compute outline for simple stroke with 2 points", () => { + const points: StrokePoint[] = [[0, 0], [100, 0]]; + const brush = { size: 16, thinning: 0.5, smoothing: 0.5, streamline: 0.5, simulatePressure: true }; + + const outline = computeOutline(points, brush); + + expect(outline.length).toBeGreaterThan(0); + expect(outline[0]).toHaveProperty("x"); + expect(outline[0]).toHaveProperty("y"); + }); + + it("should compute outline for stroke with multiple points", () => { + const points: StrokePoint[] = [[0, 0], [50, 50], [100, 0], [150, 50]]; + const brush = { size: 16, thinning: 0.5, smoothing: 0.5, streamline: 0.5, simulatePressure: true }; + + const outline = computeOutline(points, brush); + + expect(outline.length).toBeGreaterThan(0); + }); + + it("should return empty array for stroke with fewer than 2 points", () => { + const points: StrokePoint[] = [[0, 0]]; + const brush = { size: 16, thinning: 0.5, smoothing: 0.5, streamline: 0.5, simulatePressure: true }; + + const outline = computeOutline(points, brush); + + expect(outline).toEqual([]); + }); + + it("should handle points with pressure values", () => { + const points: StrokePoint[] = [[0, 0, 0.5], [50, 50, 0.8], [100, 0, 0.3]]; + const brush = { size: 16, thinning: 0.5, smoothing: 0.5, streamline: 0.5, simulatePressure: false }; + + const outline = computeOutline(points, brush); + + expect(outline.length).toBeGreaterThan(0); + }); + + it("should vary outline based on brush size", () => { + const points: StrokePoint[] = [[0, 0], [100, 0]]; + + const smallBrush = { size: 4, thinning: 0.5, smoothing: 0.5, streamline: 0.5, simulatePressure: true }; + + const largeBrush = { size: 32, thinning: 0.5, smoothing: 0.5, streamline: 0.5, simulatePressure: true }; + + const smallOutline = computeOutline(points, smallBrush); + const largeOutline = computeOutline(points, largeBrush); + + expect(smallOutline.length).toBeGreaterThan(0); + expect(largeOutline.length).toBeGreaterThan(0); + }); + }); + + describe("boundsFromOutline", () => { + it("should compute bounds from outline points", () => { + const outline = [{ x: 10, y: 20 }, { x: 50, y: 30 }, { x: 30, y: 60 }, { x: 5, y: 40 }]; + + const bounds = boundsFromOutline(outline); + + expect(bounds.min).toEqual({ x: 5, y: 20 }); + expect(bounds.max).toEqual({ x: 50, y: 60 }); + }); + + it("should handle single point outline", () => { + const outline = [{ x: 100, y: 200 }]; + + const bounds = boundsFromOutline(outline); + + expect(bounds.min).toEqual({ x: 100, y: 200 }); + expect(bounds.max).toEqual({ x: 100, y: 200 }); + }); + + it("should return zero bounds for empty outline", () => { + const outline: { x: number; y: number }[] = []; + + const bounds = boundsFromOutline(outline); + + expect(bounds.min).toEqual({ x: 0, y: 0 }); + expect(bounds.max).toEqual({ x: 0, y: 0 }); + }); + + it("should handle negative coordinates", () => { + const outline = [{ x: -50, y: -100 }, { x: -10, y: -20 }, { x: -30, y: -60 }]; + + const bounds = boundsFromOutline(outline); + + expect(bounds.min).toEqual({ x: -50, y: -100 }); + expect(bounds.max).toEqual({ x: -10, y: -20 }); + }); + }); + + describe("shapeBounds for stroke", () => { + it("should return correct bounds for stroke shape", () => { + const points: StrokePoint[] = [[0, 0], [100, 50], [200, 0]]; + + const stroke = ShapeRecord.createStroke("page:1", 50, 100, { + points, + brush: { size: 16, thinning: 0.5, smoothing: 0.5, streamline: 0.5, simulatePressure: true }, + style: { color: "#000000", opacity: 1.0 }, + }); + + const bounds = shapeBounds(stroke); + + expect(bounds.min.x).toBeDefined(); + expect(bounds.min.y).toBeDefined(); + expect(bounds.max.x).toBeGreaterThan(bounds.min.x); + expect(bounds.max.y).toBeGreaterThan(bounds.min.y); + + expect(bounds.min.x).toBeLessThan(60); + expect(bounds.min.y).toBeLessThan(110); + }); + + it("should handle stroke with insufficient points", () => { + const points: StrokePoint[] = [[0, 0]]; + + const stroke = ShapeRecord.createStroke("page:1", 100, 100, { + points, + brush: { size: 16, thinning: 0.5, smoothing: 0.5, streamline: 0.5, simulatePressure: true }, + style: { color: "#000000", opacity: 1.0 }, + }); + + const bounds = shapeBounds(stroke); + + expect(bounds.min).toEqual({ x: 100, y: 100 }); + expect(bounds.max).toEqual({ x: 100, y: 100 }); + }); + }); + + describe("hitTestStroke", () => { + it("should return true for point inside stroke outline", () => { + const points: StrokePoint[] = [[0, 0], [100, 0]]; + + const stroke = ShapeRecord.createStroke("page:1", 100, 100, { + points, + brush: { size: 20, thinning: 0.5, smoothing: 0.5, streamline: 0.5, simulatePressure: true }, + style: { color: "#000000", opacity: 1.0 }, + }); + + const hitPoint = { x: 150, y: 100 }; + const result = hitTestStroke(hitPoint, stroke); + expect(result).toBe(true); + }); + + it("should return false for point outside stroke bounds", () => { + const points: StrokePoint[] = [[0, 0], [100, 0]]; + + const stroke = ShapeRecord.createStroke("page:1", 100, 100, { + points, + brush: { size: 16, thinning: 0.5, smoothing: 0.5, streamline: 0.5, simulatePressure: true }, + style: { color: "#000000", opacity: 1.0 }, + }); + + const hitPoint = { x: 500, y: 500 }; + const result = hitTestStroke(hitPoint, stroke); + + expect(result).toBe(false); + }); + + it("should return false for stroke with insufficient points", () => { + const points: StrokePoint[] = [[0, 0]]; + + const stroke = ShapeRecord.createStroke("page:1", 100, 100, { + points, + brush: { size: 16, thinning: 0.5, smoothing: 0.5, streamline: 0.5, simulatePressure: true }, + style: { color: "#000000", opacity: 1.0 }, + }); + + const hitPoint = { x: 100, y: 100 }; + const result = hitTestStroke(hitPoint, stroke); + + expect(result).toBe(false); + }); + }); + + describe("hitTestPoint with strokes", () => { + it("should hit test stroke shapes", () => { + const store = new Store(); + const page = PageRecord.create("Page 1", "page:1"); + + const points: StrokePoint[] = [[0, 0], [100, 0]]; + + const stroke = ShapeRecord.createStroke("page:1", 100, 100, { + points, + brush: { size: 20, thinning: 0.5, smoothing: 0.5, streamline: 0.5, simulatePressure: true }, + style: { color: "#000000", opacity: 1.0 }, + }, "stroke:1"); + + store.setState((state) => ({ + ...state, + doc: { + pages: { [page.id]: { ...page, shapeIds: [stroke.id] } }, + shapes: { [stroke.id]: stroke }, + bindings: {}, + }, + ui: { ...state.ui, currentPageId: page.id }, + })); + + const state = store.getState(); + const hitId = hitTestPoint(state, { x: 150, y: 100 }); + expect(hitId).toBe("stroke:1"); + }); + + it("should return null for point outside stroke", () => { + const store = new Store(); + const page = PageRecord.create("Page 1", "page:1"); + + const points: StrokePoint[] = [[0, 0], [100, 0]]; + + const stroke = ShapeRecord.createStroke("page:1", 100, 100, { + points, + brush: { size: 16, thinning: 0.5, smoothing: 0.5, streamline: 0.5, simulatePressure: true }, + style: { color: "#000000", opacity: 1.0 }, + }, "stroke:1"); + + store.setState((state) => ({ + ...state, + doc: { + pages: { [page.id]: { ...page, shapeIds: [stroke.id] } }, + shapes: { [stroke.id]: stroke }, + bindings: {}, + }, + ui: { ...state.ui, currentPageId: page.id }, + })); + + const state = store.getState(); + const hitId = hitTestPoint(state, { x: 500, y: 500 }); + expect(hitId).toBeNull(); + }); + + it("should handle stroke with other shape types", () => { + const store = new Store(); + const page = PageRecord.create("Page 1", "page:1"); + + const rect = ShapeRecord.createRect("page:1", 0, 0, { + w: 50, + h: 50, + fill: "#ff0000", + stroke: "#000000", + radius: 0, + }, "rect:1"); + + const points: StrokePoint[] = [[0, 0], [100, 0]]; + + const stroke = ShapeRecord.createStroke("page:1", 100, 100, { + points, + brush: { size: 20, thinning: 0.5, smoothing: 0.5, streamline: 0.5, simulatePressure: true }, + style: { color: "#000000", opacity: 1.0 }, + }, "stroke:1"); + + store.setState((state) => ({ + ...state, + doc: { + pages: { [page.id]: { ...page, shapeIds: [rect.id, stroke.id] } }, + shapes: { [rect.id]: rect, [stroke.id]: stroke }, + bindings: {}, + }, + ui: { ...state.ui, currentPageId: page.id }, + })); + + const state = store.getState(); + + expect(hitTestPoint(state, { x: 25, y: 25 })).toBe("rect:1"); + expect(hitTestPoint(state, { x: 150, y: 100 })).toBe("stroke:1"); + }); + }); +}); diff --git a/packages/core/tests/pen-tool.test.ts b/packages/core/tests/pen-tool.test.ts new file mode 100644 index 0000000..70e69bc --- /dev/null +++ b/packages/core/tests/pen-tool.test.ts @@ -0,0 +1,317 @@ +import { describe, expect, it } from "vitest"; +import { PageRecord, Store } from "../src"; +import type { Action } from "../src/actions"; +import { Modifiers, PointerButtons } from "../src/actions"; +import { PenTool } from "../src/tools/pen"; + +function createPointerDownAction(worldX: number, worldY: number): Action { + return { + type: "pointer-down", + world: { x: worldX, y: worldY }, + screen: { x: worldX, y: worldY }, + button: 0, + buttons: PointerButtons.create(true, false, false), + modifiers: Modifiers.create(false, false, false, false), + timestamp: Date.now(), + }; +} + +function createPointerMoveAction(worldX: number, worldY: number): Action { + return { + type: "pointer-move", + world: { x: worldX, y: worldY }, + screen: { x: worldX, y: worldY }, + buttons: PointerButtons.create(true, false, false), + modifiers: Modifiers.create(false, false, false, false), + timestamp: Date.now(), + }; +} + +function createPointerUpAction(worldX: number, worldY: number): Action { + return { + type: "pointer-up", + world: { x: worldX, y: worldY }, + screen: { x: worldX, y: worldY }, + button: 0, + buttons: PointerButtons.create(false, false, false), + modifiers: Modifiers.create(false, false, false, false), + timestamp: Date.now(), + }; +} + +function createKeyDownAction(key: string): Action { + return { + type: "key-down", + key, + code: key, + modifiers: Modifiers.create(false, false, false, false), + repeat: false, + timestamp: Date.now(), + }; +} + +describe("PenTool", () => { + describe("Tool lifecycle", () => { + it("should have correct id", () => { + const tool = new PenTool(); + expect(tool.id).toBe("pen"); + }); + + it("should initialize with clean state on enter", () => { + const tool = new PenTool(); + const store = new Store(); + const page = PageRecord.create("Page 1", "page:1"); + + store.setState((state) => ({ + ...state, + doc: { ...state.doc, pages: { [page.id]: page } }, + ui: { ...state.ui, currentPageId: page.id }, + })); + + const state = store.getState(); + const newState = tool.onEnter(state); + + expect(newState).toEqual(state); + }); + + it("should clean up draft stroke on exit", () => { + const tool = new PenTool(); + const store = new Store(); + const page = PageRecord.create("Page 1", "page:1"); + + store.setState((state) => ({ + ...state, + doc: { ...state.doc, pages: { [page.id]: page } }, + ui: { ...state.ui, currentPageId: page.id }, + })); + + let state = store.getState(); + + const pointerDown = createPointerDownAction(100, 100); + state = tool.onAction(state, pointerDown); + expect(Object.keys(state.doc.shapes).length).toBe(1); + + state = tool.onExit(state); + expect(Object.keys(state.doc.shapes).length).toBe(0); + }); + }); + + describe("Drawing strokes", () => { + it("should create stroke on pointer down", () => { + const tool = new PenTool(); + const store = new Store(); + const page = PageRecord.create("Page 1", "page:1"); + + store.setState((state) => ({ + ...state, + doc: { ...state.doc, pages: { [page.id]: page } }, + ui: { ...state.ui, currentPageId: page.id }, + })); + + const state = store.getState(); + const action = createPointerDownAction(100, 100); + const newState = tool.onAction(state, action); + + expect(Object.keys(newState.doc.shapes).length).toBe(1); + const shapeId = Object.keys(newState.doc.shapes)[0]; + const shape = newState.doc.shapes[shapeId]; + + expect(shape.type).toBe("stroke"); + if (shape.type === "stroke") { + expect(shape.props.points.length).toBe(1); + expect(shape.props.points[0]).toEqual([100, 100]); + } + }); + + it("should add points on pointer move", () => { + const tool = new PenTool(); + const store = new Store(); + const page = PageRecord.create("Page 1", "page:1"); + + store.setState((state) => ({ + ...state, + doc: { ...state.doc, pages: { [page.id]: page } }, + ui: { ...state.ui, currentPageId: page.id }, + })); + + let state = store.getState(); + + state = tool.onAction(state, createPointerDownAction(100, 100)); + + state = tool.onAction(state, createPointerMoveAction(110, 105)); + state = tool.onAction(state, createPointerMoveAction(120, 110)); + + const shapeId = Object.keys(state.doc.shapes)[0]; + const shape = state.doc.shapes[shapeId]; + + expect(shape.type).toBe("stroke"); + if (shape.type === "stroke") { + expect(shape.props.points.length).toBe(3); + } + }); + + it("should not add point if moved less than minimum distance", () => { + const tool = new PenTool(); + const store = new Store(); + const page = PageRecord.create("Page 1", "page:1"); + + store.setState((state) => ({ + ...state, + doc: { ...state.doc, pages: { [page.id]: page } }, + ui: { ...state.ui, currentPageId: page.id }, + })); + + let state = store.getState(); + + state = tool.onAction(state, createPointerDownAction(100, 100)); + + state = tool.onAction(state, createPointerMoveAction(100.5, 100.5)); + + const shapeId = Object.keys(state.doc.shapes)[0]; + const shape = state.doc.shapes[shapeId]; + + expect(shape.type).toBe("stroke"); + if (shape.type === "stroke") { + expect(shape.props.points.length).toBe(1); + } + }); + + it("should finalize stroke on pointer up", () => { + const tool = new PenTool(); + const store = new Store(); + const page = PageRecord.create("Page 1", "page:1"); + + store.setState((state) => ({ + ...state, + doc: { ...state.doc, pages: { [page.id]: page } }, + ui: { ...state.ui, currentPageId: page.id }, + })); + + let state = store.getState(); + + state = tool.onAction(state, createPointerDownAction(100, 100)); + state = tool.onAction(state, createPointerMoveAction(150, 150)); + state = tool.onAction(state, createPointerUpAction(150, 150)); + + expect(Object.keys(state.doc.shapes).length).toBe(1); + const shapeId = Object.keys(state.doc.shapes)[0]; + const shape = state.doc.shapes[shapeId]; + + expect(shape.type).toBe("stroke"); + if (shape.type === "stroke") { + expect(shape.props.points.length).toBeGreaterThanOrEqual(2); + } + }); + + it("should delete stroke if too few points on pointer up", () => { + const tool = new PenTool(); + const store = new Store(); + const page = PageRecord.create("Page 1", "page:1"); + + store.setState((state) => ({ + ...state, + doc: { ...state.doc, pages: { [page.id]: page } }, + ui: { ...state.ui, currentPageId: page.id }, + })); + + let state = store.getState(); + state = tool.onAction(state, createPointerDownAction(100, 100)); + state = tool.onAction(state, createPointerUpAction(100, 100)); + expect(Object.keys(state.doc.shapes).length).toBe(0); + }); + }); + + describe("Keyboard interactions", () => { + it("should cancel stroke on Escape key", () => { + const tool = new PenTool(); + const store = new Store(); + const page = PageRecord.create("Page 1", "page:1"); + + store.setState((state) => ({ + ...state, + doc: { ...state.doc, pages: { [page.id]: page } }, + ui: { ...state.ui, currentPageId: page.id }, + })); + + let state = store.getState(); + + state = tool.onAction(state, createPointerDownAction(100, 100)); + state = tool.onAction(state, createPointerMoveAction(150, 150)); + + expect(Object.keys(state.doc.shapes).length).toBe(1); + + state = tool.onAction(state, createKeyDownAction("Escape")); + + expect(Object.keys(state.doc.shapes).length).toBe(0); + expect(state.ui.selectionIds.length).toBe(0); + }); + + it("should ignore other keys", () => { + const tool = new PenTool(); + const store = new Store(); + const page = PageRecord.create("Page 1", "page:1"); + + store.setState((state) => ({ + ...state, + doc: { ...state.doc, pages: { [page.id]: page } }, + ui: { ...state.ui, currentPageId: page.id }, + })); + + let state = store.getState(); + + state = tool.onAction(state, createPointerDownAction(100, 100)); + + const newState = tool.onAction(state, createKeyDownAction("a")); + + expect(newState).toEqual(state); + }); + }); + + describe("Edge cases", () => { + it("should handle pointer actions without current page", () => { + const tool = new PenTool(); + const store = new Store(); + const state = store.getState(); + + const pointerDown = createPointerDownAction(100, 100); + const newState = tool.onAction(state, pointerDown); + expect(Object.keys(newState.doc.shapes).length).toBe(0); + }); + + it("should handle pointer move without drawing", () => { + const tool = new PenTool(); + const store = new Store(); + const page = PageRecord.create("Page 1", "page:1"); + + store.setState((state) => ({ + ...state, + doc: { ...state.doc, pages: { [page.id]: page } }, + ui: { ...state.ui, currentPageId: page.id }, + })); + + const state = store.getState(); + const pointerMove = createPointerMoveAction(150, 150); + const newState = tool.onAction(state, pointerMove); + expect(newState).toEqual(state); + }); + + it("should select created stroke", () => { + const tool = new PenTool(); + const store = new Store(); + const page = PageRecord.create("Page 1", "page:1"); + + store.setState((state) => ({ + ...state, + doc: { ...state.doc, pages: { [page.id]: page } }, + ui: { ...state.ui, currentPageId: page.id }, + })); + + let state = store.getState(); + const pointerDown = createPointerDownAction(100, 100); + state = tool.onAction(state, pointerDown); + + const shapeId = Object.keys(state.doc.shapes)[0]; + expect(state.ui.selectionIds).toEqual([shapeId]); + }); + }); +}); diff --git a/packages/renderer/src/index.ts b/packages/renderer/src/index.ts index f970e90..2652697 100644 --- a/packages/renderer/src/index.ts +++ b/packages/renderer/src/index.ts @@ -8,11 +8,12 @@ import type { RectShape, ShapeRecord, Store, + StrokeShape, TextShape, Vec2, Viewport, } from "inkfinite-core"; -import { getShapesOnCurrentPage, resolveArrowEndpoints, shapeBounds } from "inkfinite-core"; +import { computeOutline, getShapesOnCurrentPage, resolveArrowEndpoints, shapeBounds } from "inkfinite-core"; export interface Renderer { /** @@ -331,6 +332,10 @@ function drawShape(context: CanvasRenderingContext2D, state: EditorState, shape: drawText(context, shape); break; } + case "stroke": { + drawStroke(context, shape); + break; + } } context.restore(); @@ -462,6 +467,36 @@ function drawText(context: CanvasRenderingContext2D, shape: TextShape) { } } +/** + * Draw a stroke shape (freehand drawing) + */ +function drawStroke(context: CanvasRenderingContext2D, shape: StrokeShape) { + const { points, brush, style } = shape.props; + + if (points.length < 2) { + return; + } + + const outline = computeOutline(points, brush); + + if (outline.length === 0) { + return; + } + + context.globalAlpha = style.opacity; + context.fillStyle = style.color; + context.beginPath(); + context.moveTo(outline[0].x, outline[0].y); + + for (let i = 1; i < outline.length; i++) { + context.lineTo(outline[i].x, outline[i].y); + } + + context.closePath(); + context.fill(); + context.globalAlpha = 1.0; +} + /** * Wrap text to fit within a given width */ @@ -545,6 +580,28 @@ function drawSelection( context.strokeRect(0, 0, width, height); break; } + case "stroke": { + const { points, brush } = shape.props; + if (points.length >= 2) { + const outline = computeOutline(points, brush); + if (outline.length > 0) { + let minX = outline[0].x; + let maxX = outline[0].x; + let minY = outline[0].y; + let maxY = outline[0].y; + + for (const point of outline) { + minX = Math.min(minX, point.x); + maxX = Math.max(maxX, point.x); + minY = Math.min(minY, point.y); + maxY = Math.max(maxY, point.y); + } + + context.strokeRect(minX, minY, maxX - minX, maxY - minY); + } + } + break; + } } context.restore(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0cf7d2d..b84926b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -117,6 +117,9 @@ importers: dexie: specifier: ^4.2.1 version: 4.2.1 + perfect-freehand: + specifier: ^1.2.2 + version: 1.2.2 rxjs: specifier: ^7.8.2 version: 7.8.2 @@ -1684,6 +1687,9 @@ packages: perfect-debounce@2.0.0: resolution: {integrity: sha512-fkEH/OBiKrqqI/yIgjR92lMfs2K8105zt/VT6+7eTjNwisrsh47CeIED9z58zI7DfKdH3uHAn25ziRZn3kgAow==} + perfect-freehand@1.2.2: + resolution: {integrity: sha512-eh31l019WICQ03pkF3FSzHxB8n07ItqIQ++G5UV8JX0zVOXzgTGCqnRR0jJ2h9U8/2uW4W4mtGJELt9kEV0CFQ==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -3652,6 +3658,8 @@ snapshots: perfect-debounce@2.0.0: {} + perfect-freehand@1.2.2: {} + picocolors@1.1.1: {} picomatch@4.0.3: {} -- 2.51.2