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