web based infinite canvas
at main 673 lines 20 kB view raw
1import getStroke from "perfect-freehand"; 2import type { Box2, Vec2 } from "./math"; 3import { Box2 as Box2Ops, Vec2 as Vec2Ops } from "./math"; 4import type { 5 ArrowShape, 6 BrushConfig, 7 EllipseShape, 8 LineShape, 9 MarkdownShape, 10 RectShape, 11 ShapeRecord, 12 StrokePoint, 13 StrokeShape, 14 TextShape, 15} from "./model"; 16import type { EditorState } from "./reactivity"; 17import { getShapesOnCurrentPage } from "./reactivity"; 18 19/** 20 * Get the axis-aligned bounding box of a shape in world coordinates 21 * 22 * For shapes with rotation, this returns the bounding box of the rotated shape 23 * (not the minimal bounding box of the original shape) 24 * 25 * @param shape - The shape to get bounds for 26 * @returns Bounding box in world coordinates 27 */ 28export function shapeBounds(shape: ShapeRecord): Box2 { 29 switch (shape.type) { 30 case "rect": { 31 return rectBounds(shape); 32 } 33 case "ellipse": { 34 return ellipseBounds(shape); 35 } 36 case "line": { 37 return lineBounds(shape); 38 } 39 case "arrow": { 40 return arrowBounds(shape); 41 } 42 case "text": { 43 return textBounds(shape); 44 } 45 case "stroke": { 46 return strokeBounds(shape); 47 } 48 case "markdown": { 49 return markdownBounds(shape); 50 } 51 } 52} 53 54/** 55 * Get bounds for a rectangle shape 56 */ 57function rectBounds(shape: RectShape): Box2 { 58 const { w, h } = shape.props; 59 const { x, y, rot } = shape; 60 61 if (rot === 0) { 62 return Box2Ops.create(x, y, x + w, y + h); 63 } 64 65 const corners = [{ x: 0, y: 0 }, { x: w, y: 0 }, { x: w, y: h }, { x: 0, y: h }]; 66 const rotatedCorners = corners.map((corner) => rotatePoint(corner, rot)); 67 const translatedCorners = rotatedCorners.map((corner) => ({ x: corner.x + x, y: corner.y + y })); 68 return Box2Ops.fromPoints(translatedCorners); 69} 70 71/** 72 * Get bounds for an ellipse shape 73 */ 74function ellipseBounds(shape: EllipseShape): Box2 { 75 const { w, h } = shape.props; 76 const { x, y, rot } = shape; 77 78 if (rot === 0) { 79 return Box2Ops.create(x, y, x + w, y + h); 80 } 81 82 const corners = [{ x: 0, y: 0 }, { x: w, y: 0 }, { x: w, y: h }, { x: 0, y: h }]; 83 const rotatedCorners = corners.map((corner) => rotatePoint(corner, rot)); 84 const translatedCorners = rotatedCorners.map((corner) => ({ x: corner.x + x, y: corner.y + y })); 85 return Box2Ops.fromPoints(translatedCorners); 86} 87 88/** 89 * Get bounds for a line shape 90 */ 91function lineBounds(shape: LineShape): Box2 { 92 const { a, b } = shape.props; 93 const { x, y, rot } = shape; 94 95 const points = [a, b]; 96 97 if (rot === 0) { 98 const translatedPoints = points.map((p) => ({ x: p.x + x, y: p.y + y })); 99 return Box2Ops.fromPoints(translatedPoints); 100 } 101 102 const rotatedPoints = points.map((p) => rotatePoint(p, rot)); 103 const translatedPoints = rotatedPoints.map((p) => ({ x: p.x + x, y: p.y + y })); 104 return Box2Ops.fromPoints(translatedPoints); 105} 106 107function arrowBounds(shape: ArrowShape): Box2 { 108 const { x, y, rot } = shape; 109 const points = shape.props.points; 110 111 if (!points || points.length < 2) { 112 return { min: { x, y }, max: { x, y } }; 113 } 114 115 if (rot === 0) { 116 const translatedPoints = points.map((p) => ({ x: p.x + x, y: p.y + y })); 117 return Box2Ops.fromPoints(translatedPoints); 118 } 119 120 const rotatedPoints = points.map((p) => rotatePoint(p, rot)); 121 const translatedPoints = rotatedPoints.map((p) => ({ x: p.x + x, y: p.y + y })); 122 return Box2Ops.fromPoints(translatedPoints); 123} 124 125/** 126 * Get bounds for a text shape 127 */ 128function textBounds(shape: TextShape): Box2 { 129 const { fontSize, w } = shape.props; 130 const { x, y, rot } = shape; 131 132 const width = w ?? fontSize * 10; 133 const height = fontSize * 1.2; 134 135 if (rot === 0) { 136 return Box2Ops.create(x, y, x + width, y + height); 137 } 138 139 const corners = [{ x: 0, y: 0 }, { x: width, y: 0 }, { x: width, y: height }, { x: 0, y: height }]; 140 141 const rotatedCorners = corners.map((corner) => rotatePoint(corner, rot)); 142 const translatedCorners = rotatedCorners.map((corner) => ({ x: corner.x + x, y: corner.y + y })); 143 return Box2Ops.fromPoints(translatedCorners); 144} 145 146/** 147 * Get bounds for a markdown block shape 148 */ 149function markdownBounds(shape: MarkdownShape): Box2 { 150 const { w, h, fontSize } = shape.props; 151 const { x, y, rot } = shape; 152 153 const width = w; 154 const height = h ?? fontSize * 10; 155 156 if (rot === 0) { 157 return Box2Ops.create(x, y, x + width, y + height); 158 } 159 160 const corners = [{ x: 0, y: 0 }, { x: width, y: 0 }, { x: width, y: height }, { x: 0, y: height }]; 161 const rotatedCorners = corners.map((corner) => rotatePoint(corner, rot)); 162 const translatedCorners = rotatedCorners.map((corner) => ({ x: corner.x + x, y: corner.y + y })); 163 return Box2Ops.fromPoints(translatedCorners); 164} 165 166/** 167 * Compute outline polygon points for a stroke using perfect-freehand 168 * 169 * @param points - Array of stroke points [x, y, pressure?] 170 * @param brush - Brush configuration 171 * @returns Array of outline points [x, y] 172 */ 173export function computeOutline(points: StrokePoint[], brush: BrushConfig): Vec2[] { 174 if (points.length < 2) { 175 return []; 176 } 177 178 const formattedPoints = points.map((p) => { 179 if (p.length === 3 && p[2] !== undefined) { 180 return [p[0], p[1], p[2]]; 181 } 182 return [p[0], p[1]]; 183 }); 184 185 const outlinePoints = getStroke(formattedPoints, { 186 size: brush.size, 187 thinning: brush.thinning, 188 smoothing: brush.smoothing, 189 streamline: brush.streamline, 190 simulatePressure: brush.simulatePressure, 191 }); 192 193 return outlinePoints.map((p) => ({ x: p[0], y: p[1] })); 194} 195 196/** 197 * Compute bounding box from outline points 198 * 199 * @param outline - Array of outline points 200 * @returns Bounding box containing all outline points 201 */ 202export function boundsFromOutline(outline: Vec2[]): Box2 { 203 if (outline.length === 0) { 204 return Box2Ops.create(0, 0, 0, 0); 205 } 206 207 return Box2Ops.fromPoints(outline); 208} 209 210/** 211 * Get bounds for a stroke shape 212 * 213 * Computes the outline polygon and returns its bounding box 214 */ 215function strokeBounds(shape: StrokeShape): Box2 { 216 const { points, brush } = shape.props; 217 const { x, y } = shape; 218 219 if (points.length < 2) { 220 return Box2Ops.create(x, y, x, y); 221 } 222 223 const outline = computeOutline(points, brush); 224 const localBounds = boundsFromOutline(outline); 225 return Box2Ops.create(localBounds.min.x + x, localBounds.min.y + y, localBounds.max.x + x, localBounds.max.y + y); 226} 227 228/** 229 * Rotate a point around the origin 230 * 231 * @param p - Point to rotate 232 * @param theta - Rotation angle in radians 233 * @returns Rotated point 234 */ 235function rotatePoint(p: Vec2, theta: number): Vec2 { 236 const cos = Math.cos(theta); 237 const sin = Math.sin(theta); 238 return { x: p.x * cos - p.y * sin, y: p.x * sin + p.y * cos }; 239} 240 241/** 242 * Check if a point is inside a rectangle shape 243 * 244 * @param p - Point in world coordinates 245 * @param shape - Rectangle shape 246 * @returns True if point is inside the rectangle 247 */ 248export function pointInRect(p: Vec2, shape: RectShape): boolean { 249 const { x, y, rot } = shape; 250 const { w, h } = shape.props; 251 const localP = worldToLocal(p, x, y, rot); 252 return localP.x >= 0 && localP.x <= w && localP.y >= 0 && localP.y <= h; 253} 254 255/** 256 * Check if a point is inside an ellipse shape 257 * 258 * @param p - Point in world coordinates 259 * @param shape - Ellipse shape 260 * @returns True if point is inside the ellipse 261 */ 262export function pointInEllipse(p: Vec2, shape: EllipseShape): boolean { 263 const { x, y, rot } = shape; 264 const { w, h } = shape.props; 265 266 const localP = worldToLocal(p, x, y, rot); 267 268 const centerX = w / 2; 269 const centerY = h / 2; 270 const radiusX = w / 2; 271 const radiusY = h / 2; 272 273 const dx = localP.x - centerX; 274 const dy = localP.y - centerY; 275 return (dx * dx) / (radiusX * radiusX) + (dy * dy) / (radiusY * radiusY) <= 1; 276} 277 278/** 279 * Check if a point is near a line segment 280 * 281 * @param p - Point to test 282 * @param a - Start point of segment 283 * @param b - End point of segment 284 * @param tolerance - Maximum distance from segment to be considered "near" 285 * @returns True if point is within tolerance distance of the segment 286 */ 287export function pointNearSegment(p: Vec2, a: Vec2, b: Vec2, tolerance: number): boolean { 288 const ab = Vec2Ops.sub(b, a); 289 const ap = Vec2Ops.sub(p, a); 290 const abLengthSq = Vec2Ops.lenSq(ab); 291 292 if (abLengthSq === 0) { 293 return Vec2Ops.dist(p, a) <= tolerance; 294 } 295 296 const t = Math.max(0, Math.min(1, Vec2Ops.dot(ap, ab) / abLengthSq)); 297 const projection = Vec2Ops.add(a, Vec2Ops.mulScalar(ab, t)); 298 const distance = Vec2Ops.dist(p, projection); 299 return distance <= tolerance; 300} 301 302/** 303 * Check if a point is near a line or arrow shape 304 * 305 * @param p - Point in world coordinates 306 * @param shape - Line or arrow shape 307 * @param tolerance - Maximum distance from line to be considered a hit 308 * @returns True if point is near the line 309 */ 310export function pointNearLine(p: Vec2, shape: LineShape | ArrowShape, tolerance = 5): boolean { 311 const { x, y, rot } = shape; 312 313 let points: Vec2[]; 314 if (shape.type === "line") { 315 points = [shape.props.a, shape.props.b]; 316 } else { 317 if (!shape.props.points || shape.props.points.length < 2) { 318 return false; 319 } 320 points = shape.props.points; 321 } 322 323 const localP = worldToLocal(p, x, y, rot); 324 325 for (let i = 0; i < points.length - 1; i++) { 326 if (pointNearSegment(localP, points[i], points[i + 1], tolerance)) { 327 return true; 328 } 329 } 330 331 return false; 332} 333 334/** 335 * Check if a point is inside a text shape 336 * 337 * @param p - Point in world coordinates 338 * @param shape - Text shape 339 * @returns True if point is inside the text bounds 340 */ 341export function pointInText(p: Vec2, shape: TextShape): boolean { 342 const { x, y, rot } = shape; 343 const { fontSize, w } = shape.props; 344 const localP = worldToLocal(p, x, y, rot); 345 const width = w ?? fontSize * 10; 346 const height = fontSize * 1.2; 347 return localP.x >= 0 && localP.x <= width && localP.y >= 0 && localP.y <= height; 348} 349 350/** 351 * Check if a point is inside a markdown block shape 352 * 353 * @param p - Point in world coordinates 354 * @param shape - Markdown block shape 355 * @returns True if point is inside the markdown block bounds 356 */ 357export function pointInMarkdown(p: Vec2, shape: MarkdownShape): boolean { 358 const { x, y, rot } = shape; 359 const { w, h, fontSize } = shape.props; 360 const localP = worldToLocal(p, x, y, rot); 361 const width = w; 362 const height = h ?? fontSize * 10; 363 return localP.x >= 0 && localP.x <= width && localP.y >= 0 && localP.y <= height; 364} 365 366/** 367 * Check if a point is inside a polygon using ray casting algorithm 368 * 369 * @param p - Point to test 370 * @param polygon - Array of polygon vertices 371 * @returns True if point is inside the polygon 372 */ 373function pointInPolygon(p: Vec2, polygon: Vec2[]): boolean { 374 if (polygon.length < 3) return false; 375 376 let inside = false; 377 for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { 378 const xi = polygon[i].x; 379 const yi = polygon[i].y; 380 const xj = polygon[j].x; 381 const yj = polygon[j].y; 382 383 const intersect = yi > p.y !== yj > p.y && p.x < ((xj - xi) * (p.y - yi)) / (yj - yi) + xi; 384 if (intersect) inside = !inside; 385 } 386 387 return inside; 388} 389 390/** 391 * Check if a point is inside a stroke shape 392 * 393 * Uses bounds check first for performance, then polygon containment test 394 * 395 * @param p - Point in world coordinates 396 * @param shape - Stroke shape 397 * @returns True if point is inside the stroke 398 */ 399export function hitTestStroke(p: Vec2, shape: StrokeShape): boolean { 400 const { x, y } = shape; 401 const { points, brush } = shape.props; 402 403 if (points.length < 2) return false; 404 405 const bounds = strokeBounds(shape); 406 if (p.x < bounds.min.x || p.x > bounds.max.x || p.y < bounds.min.y || p.y > bounds.max.y) { 407 return false; 408 } 409 410 const localP = { x: p.x - x, y: p.y - y }; 411 412 const outline = computeOutline(points, brush); 413 return pointInPolygon(localP, outline); 414} 415 416/** 417 * Transform a point from world coordinates to shape-local coordinates 418 * 419 * @param p - Point in world coordinates 420 * @param shapeX - Shape x position 421 * @param shapeY - Shape y position 422 * @param shapeRot - Shape rotation in radians 423 * @returns Point in shape-local coordinates 424 */ 425function worldToLocal(p: Vec2, shapeX: number, shapeY: number, shapeRot: number): Vec2 { 426 const translated = { x: p.x - shapeX, y: p.y - shapeY }; 427 428 if (shapeRot === 0) { 429 return translated; 430 } 431 432 return rotatePoint(translated, -shapeRot); 433} 434 435/** 436 * Perform hit testing to find which shape is under a point 437 * 438 * Uses reverse order (topmost shape wins) based on page.shapeIds order. 439 * Line and arrow shapes use a tolerance for easier selection. 440 * 441 * @param state - Editor state 442 * @param worldPoint - Point to test in world coordinates 443 * @param tolerance - Tolerance for line/arrow hit testing (default: 5) 444 * @returns Shape ID of the topmost shape under the point, or null if no hit 445 */ 446export function hitTestPoint(state: EditorState, worldPoint: Vec2, tolerance = 5): string | null { 447 const shapes = getShapesOnCurrentPage(state); 448 449 for (let index = shapes.length - 1; index >= 0; index--) { 450 const shape = shapes[index]; 451 452 switch (shape.type) { 453 case "rect": { 454 if (pointInRect(worldPoint, shape)) { 455 return shape.id; 456 } 457 break; 458 } 459 case "ellipse": { 460 if (pointInEllipse(worldPoint, shape)) { 461 return shape.id; 462 } 463 break; 464 } 465 case "line": 466 case "arrow": { 467 if (pointNearLine(worldPoint, shape, tolerance)) { 468 return shape.id; 469 } 470 break; 471 } 472 case "text": { 473 if (pointInText(worldPoint, shape)) { 474 return shape.id; 475 } 476 break; 477 } 478 case "markdown": { 479 if (pointInMarkdown(worldPoint, shape)) { 480 return shape.id; 481 } 482 break; 483 } 484 case "stroke": { 485 if (hitTestStroke(worldPoint, shape)) { 486 return shape.id; 487 } 488 break; 489 } 490 } 491 } 492 493 return null; 494} 495 496/** 497 * Get the center point of a shape's bounding box in world coordinates 498 * 499 * @param shape - The shape to get center for 500 * @returns Center point in world coordinates 501 */ 502export function shapeCenter(shape: ShapeRecord): Vec2 { 503 const bounds = shapeBounds(shape); 504 return { x: (bounds.min.x + bounds.max.x) / 2, y: (bounds.min.y + bounds.max.y) / 2 }; 505} 506 507/** 508 * Compute anchor point on a shape's bounds given normalized coordinates 509 * 510 * @param shape - Target shape 511 * @param nx - Normalized x coordinate in [-1, 1] where -1 is left edge, 1 is right edge, 0 is center 512 * @param ny - Normalized y coordinate in [-1, 1] where -1 is top edge, 1 is bottom edge, 0 is center 513 * @param offset - Optional offset distance to push the anchor point away from the shape (default: 0) 514 * @returns World coordinates of the anchor point 515 */ 516export function computeEdgeAnchor(shape: ShapeRecord, nx: number, ny: number, offset = 0): Vec2 { 517 const bounds = shapeBounds(shape); 518 const centerX = (bounds.min.x + bounds.max.x) / 2; 519 const centerY = (bounds.min.y + bounds.max.y) / 2; 520 const halfWidth = (bounds.max.x - bounds.min.x) / 2; 521 const halfHeight = (bounds.max.y - bounds.min.y) / 2; 522 523 const baseX = centerX + nx * halfWidth; 524 const baseY = centerY + ny * halfHeight; 525 526 if (offset === 0) { 527 return { x: baseX, y: baseY }; 528 } 529 530 const dx = baseX - centerX; 531 const dy = baseY - centerY; 532 const distance = Math.sqrt(dx * dx + dy * dy); 533 534 if (distance < 0.01) { 535 return { x: baseX, y: baseY }; 536 } 537 538 const offsetX = (dx / distance) * offset; 539 const offsetY = (dy / distance) * offset; 540 return { x: baseX + offsetX, y: baseY + offsetY }; 541} 542 543/** 544 * Compute normalized anchor coordinates from a world point and target shape 545 * 546 * @param point - World coordinates of the point to anchor 547 * @param shape - Target shape to anchor to 548 * @returns Normalized coordinates {nx, ny} in [-1, 1] 549 */ 550export function computeNormalizedAnchor(point: Vec2, shape: ShapeRecord): { nx: number; ny: number } { 551 const bounds = shapeBounds(shape); 552 const centerX = (bounds.min.x + bounds.max.x) / 2; 553 const centerY = (bounds.min.y + bounds.max.y) / 2; 554 const halfWidth = Math.max((bounds.max.x - bounds.min.x) / 2, 1); 555 const halfHeight = Math.max((bounds.max.y - bounds.min.y) / 2, 1); 556 557 const nx = Math.max(-1, Math.min(1, (point.x - centerX) / halfWidth)); 558 const ny = Math.max(-1, Math.min(1, (point.y - centerY) / halfHeight)); 559 560 return { nx, ny }; 561} 562 563/** 564 * Resolve arrow endpoints considering bindings 565 * 566 * If an arrow endpoint is bound to a target shape, returns the bound position 567 * based on the binding anchor (center or edge with normalized coordinates). 568 * Otherwise returns the arrow's stored endpoint. 569 * 570 * @param state - Editor state 571 * @param arrowId - ID of the arrow shape 572 * @returns Resolved endpoints {a, b} in world coordinates, or null if arrow not found 573 */ 574export function resolveArrowEndpoints(state: EditorState, arrowId: string): { a: Vec2; b: Vec2 } | null { 575 const arrow = state.doc.shapes[arrowId]; 576 if (!arrow || arrow.type !== "arrow") return null; 577 578 const points = arrow.props.points; 579 if (!points || points.length < 2) return null; 580 581 const firstPoint = points[0]; 582 const lastPoint = points[points.length - 1]; 583 let a: Vec2 = { x: arrow.x + firstPoint.x, y: arrow.y + firstPoint.y }; 584 let b: Vec2 = { x: arrow.x + lastPoint.x, y: arrow.y + lastPoint.y }; 585 586 const arrowStrokeWidth = arrow.props.style?.width ?? 2; 587 const targetShapeStrokeWidth = 2; 588 const offset = targetShapeStrokeWidth / 2 + arrowStrokeWidth / 2; 589 590 for (const binding of Object.values(state.doc.bindings)) { 591 if (binding.fromShapeId !== arrowId) continue; 592 593 const targetShape = state.doc.shapes[binding.toShapeId]; 594 if (!targetShape) continue; 595 596 let anchorPoint: Vec2; 597 if (binding.anchor.kind === "center") { 598 anchorPoint = shapeCenter(targetShape); 599 } else { 600 anchorPoint = computeEdgeAnchor(targetShape, binding.anchor.nx, binding.anchor.ny, offset); 601 } 602 603 if (binding.handle === "start") { 604 a = anchorPoint; 605 } else if (binding.handle === "end") { 606 b = anchorPoint; 607 } 608 } 609 610 return { a, b }; 611} 612 613/** 614 * Compute orthogonal (Manhattan-style) routing between two points 615 * 616 * Creates a path with 2-4 segments that connects start to end using only horizontal and vertical lines. 617 * The path avoids overlapping segments and creates clean right angles. 618 * 619 * @param start - Starting point 620 * @param end - Ending point 621 * @returns Array of points forming the orthogonal path (includes start and end) 622 */ 623export function computeOrthogonalPath(start: Vec2, end: Vec2): Vec2[] { 624 const dx = end.x - start.x; 625 const dy = end.y - start.y; 626 627 if (Math.abs(dx) < 0.1 && Math.abs(dy) < 0.1) { 628 return [start, end]; 629 } 630 631 if (Math.abs(dx) < 0.1) { 632 return [start, end]; 633 } 634 635 if (Math.abs(dy) < 0.1) { 636 return [start, end]; 637 } 638 639 const midX = start.x + dx / 2; 640 641 return [start, { x: midX, y: start.y }, { x: midX, y: end.y }, end]; 642} 643 644/** 645 * Compute the total length of a polyline 646 */ 647export function computePolylineLength(points: Vec2[]): number { 648 let length = 0; 649 for (let i = 1; i < points.length; i++) { 650 const dx = points[i].x - points[i - 1].x; 651 const dy = points[i].y - points[i - 1].y; 652 length += Math.sqrt(dx * dx + dy * dy); 653 } 654 return length; 655} 656 657/** 658 * Get a point at a specific distance along a polyline 659 */ 660export function getPointAtDistance(points: Vec2[], targetDist: number): Vec2 { 661 let accum = 0; 662 for (let i = 1; i < points.length; i++) { 663 const dx = points[i].x - points[i - 1].x; 664 const dy = points[i].y - points[i - 1].y; 665 const segLen = Math.sqrt(dx * dx + dy * dy); 666 if (accum + segLen >= targetDist) { 667 const t = (targetDist - accum) / segLen; 668 return { x: points[i - 1].x + dx * t, y: points[i - 1].y + dy * t }; 669 } 670 accum += segLen; 671 } 672 return points[points.length - 1]; 673}