@lazlon-platform/html-editor 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/lib/hooks/actions.ts +54 -26
  2. package/lib/hooks/batch.ts +17 -7
  3. package/lib/hooks/index.ts +1 -0
  4. package/lib/hooks/node.ts +14 -6
  5. package/lib/hooks/pointer/movePoint.ts +75 -0
  6. package/lib/hooks/pointer/moveable.ts +92 -57
  7. package/lib/hooks/pointer/pointer.ts +21 -11
  8. package/lib/hooks/pointer/resize.ts +176 -210
  9. package/lib/hooks/pointer/rotation.ts +89 -68
  10. package/lib/hooks/pointer/selectionFrame.ts +8 -11
  11. package/lib/hooks/pointer/selector.ts +62 -40
  12. package/lib/hooks/pointer/snap.ts +23 -23
  13. package/lib/hooks/textMarks.ts +1 -3
  14. package/lib/lib/googleFonts.ts +1 -5
  15. package/lib/model/editor.ts +13 -9
  16. package/lib/model/geometry/math.ts +623 -0
  17. package/lib/model/geometry/svg.ts +55 -0
  18. package/lib/model/history.ts +10 -13
  19. package/lib/model/index.ts +7 -10
  20. package/lib/model/node/{editable → editableNode}/index.ts +13 -29
  21. package/lib/model/node/{formattable.ts → formattableNode/index.ts} +5 -11
  22. package/lib/model/node/{group.ts → groupNode.ts} +9 -13
  23. package/lib/model/node/{image.ts → imageNode.ts} +5 -11
  24. package/lib/model/node/lineNode.ts +59 -0
  25. package/lib/model/node/{shape/shape.ts → shapeNode/index.ts} +30 -15
  26. package/lib/model/node/shapeNode/shape.ts +96 -0
  27. package/lib/model/node/{text.ts → textNode.ts} +19 -21
  28. package/lib/model/node.ts +11 -29
  29. package/lib/model/page.ts +4 -3
  30. package/lib/model/traversal.ts +1 -1
  31. package/lib/ui/extractor.ts +3 -3
  32. package/lib/ui/index.ts +2 -4
  33. package/lib/ui/node/{EditableContent.tsx → EditableContent/index.tsx} +4 -3
  34. package/lib/ui/node/GroupContent.tsx +1 -1
  35. package/lib/ui/node/ImageContent.tsx +1 -1
  36. package/lib/ui/node/LineContent.tsx +32 -0
  37. package/lib/ui/node/NodeView.tsx +1 -13
  38. package/lib/ui/node/ShapeContent/ArrowContent.tsx +57 -0
  39. package/lib/ui/node/ShapeContent/EllipseContent.tsx +37 -0
  40. package/lib/ui/node/ShapeContent/PolygonContent.tsx +62 -0
  41. package/lib/ui/node/ShapeContent/RectangleContent.tsx +35 -0
  42. package/lib/ui/node/ShapeContent/StarContent.tsx +75 -0
  43. package/lib/ui/node/ShapeContent/index.tsx +43 -0
  44. package/lib/ui/node/TextContent.tsx +1 -1
  45. package/lib/ui/selection.ts +9 -26
  46. package/package.json +34 -34
  47. package/lib/model/geometry.ts +0 -247
  48. package/lib/model/node/shape/arrow.ts +0 -50
  49. package/lib/model/node/shape/ellipse.ts +0 -26
  50. package/lib/model/node/shape/polygon.ts +0 -108
  51. package/lib/model/node/shape/star.ts +0 -63
  52. package/lib/ui/node/ArrowContent.tsx +0 -60
  53. package/lib/ui/node/EllipseContent.tsx +0 -49
  54. package/lib/ui/node/PolygonContent.tsx +0 -81
  55. package/lib/ui/node/StarContent.tsx +0 -60
  56. /package/lib/model/node/{editable → editableNode}/letterSpacing.ts +0 -0
  57. /package/lib/model/node/{editable → editableNode}/persistentMarks.ts +0 -0
  58. /package/lib/model/node/{editable → editableNode}/tiptapExtensions.ts +0 -0
  59. /package/lib/ui/node/{useDoubleClick.ts → EditableContent/useDoubleClick.ts} +0 -0
@@ -0,0 +1,623 @@
1
+ export type Deg = number & { readonly __unit: "degrees" }
2
+ export type Rad = number & { readonly __unit: "radians" }
3
+ export type Edge = "n" | "s" | "w" | "e"
4
+ export type Corner = "ne" | "nw" | "se" | "sw"
5
+
6
+ export interface Point {
7
+ readonly x: number
8
+ readonly y: number
9
+ }
10
+
11
+ export interface Size {
12
+ readonly width: number
13
+ readonly height: number
14
+ }
15
+
16
+ export interface Rect extends Point, Size {
17
+ readonly top: number
18
+ readonly bottom: number
19
+ readonly left: number
20
+ readonly right: number
21
+ }
22
+
23
+ export interface Line extends Point {
24
+ readonly points: Point[]
25
+ readonly strokeWidth: number
26
+ }
27
+
28
+ /**
29
+ * A rotated rectangle around a pivot point.
30
+ * Pivot point is relative to the center point.
31
+ */
32
+ export interface Box extends Size {
33
+ readonly center: Point
34
+ readonly rotation: Deg
35
+ readonly pivot: Point
36
+ }
37
+
38
+ /**
39
+ * Cast a number to a degree value.
40
+ */
41
+ export function deg(deg: number): Deg {
42
+ return (deg % 360) as Deg
43
+ }
44
+
45
+ /**
46
+ * Cast a number to a radian value.
47
+ */
48
+ export function rad(rad: number): Rad {
49
+ return rad as Rad
50
+ }
51
+
52
+ /**
53
+ * Create a point.
54
+ */
55
+ export function point(p?: Partial<Point>): Point {
56
+ return { x: p?.x ?? 0, y: p?.y ?? 0 }
57
+ }
58
+
59
+ /**
60
+ * Create a rectangle around the given points and rectangles.
61
+ */
62
+ export function rect(...args: Array<(Point & Size) | Point>): Rect {
63
+ const points: Point[] = args.flatMap((arg) =>
64
+ "width" in arg && "height" in arg ? rectCorners(arg) : arg,
65
+ )
66
+
67
+ let minX = Infinity
68
+ let minY = Infinity
69
+ let maxX = -Infinity
70
+ let maxY = -Infinity
71
+
72
+ for (const p of points) {
73
+ if (p.x < minX) minX = p.x
74
+ if (p.y < minY) minY = p.y
75
+ if (p.x > maxX) maxX = p.x
76
+ if (p.y > maxY) maxY = p.y
77
+ }
78
+
79
+ const rect = {
80
+ x: minX,
81
+ y: minY,
82
+ width: maxX - minX,
83
+ height: maxY - minY,
84
+ }
85
+
86
+ return {
87
+ ...rect,
88
+ top: rect.y,
89
+ bottom: rect.y + rect.height,
90
+ left: rect.x,
91
+ right: rect.x + rect.width,
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Create a rotatable box for a rectangle
97
+ */
98
+ export function box(input?: Size & Point & { rotation?: Deg }): Box {
99
+ return {
100
+ center: rectCenter(input ?? rect()),
101
+ rotation: input?.rotation ?? deg(0),
102
+ pivot: rectCenter(input ?? rect()),
103
+ width: input?.width ?? 0,
104
+ height: input?.height ?? 0,
105
+ }
106
+ }
107
+
108
+ /**
109
+ * @returns Four corners of a rectangle.
110
+ * ●───●
111
+ * │ │
112
+ * ●───●
113
+ */
114
+ export function rectCorners(rect: Size & Point): [Point, Point, Point, Point] {
115
+ const top = rect.y
116
+ const bottom = rect.y + rect.height
117
+ const left = rect.x
118
+ const right = rect.x + rect.width
119
+ return [
120
+ { y: top, x: left },
121
+ { y: top, x: right },
122
+ { y: bottom, x: right },
123
+ { y: bottom, x: left },
124
+ ]
125
+ }
126
+
127
+ /**
128
+ * @returns Center point of a rectangle.
129
+ * ┌─────┐
130
+ * │ ●C │
131
+ * └─────┘
132
+ */
133
+ export function rectCenter({ x, y, width, height }: Size & Point): Point {
134
+ return {
135
+ x: x + width / 2,
136
+ y: y + height / 2,
137
+ }
138
+ }
139
+
140
+ /**
141
+ * @returns Rotate a point relative to a pivot point.
142
+ *
143
+ * @example
144
+ * ```
145
+ * P = (0,0)
146
+ * A = (2,0)
147
+ *
148
+ * rotate(A, P, 90)
149
+ * -> A' = (0,2)
150
+ * ```
151
+ *
152
+ * A
153
+ * ●
154
+ * │
155
+ * │ 90°
156
+ * ●─────● A'
157
+ * P
158
+ */
159
+ export function rotatePoint(p: Point, pivot: Point, deg: Deg): Point {
160
+ const rad = (deg * Math.PI) / 180
161
+ const cos = Math.cos(rad)
162
+ const sin = Math.sin(rad)
163
+ const dx = p.x - pivot.x
164
+ const dy = p.y - pivot.y
165
+
166
+ return {
167
+ x: pivot.x + dx * cos - dy * sin,
168
+ y: pivot.y + dx * sin + dy * cos,
169
+ }
170
+ }
171
+
172
+ /**
173
+ * @returns Axis-aligned bounding box (AABB) of the rotated rectangle.
174
+ * ⠀▁▁▁▁
175
+ * 🭵 ╱🯒🯓🭰
176
+ * 🭵╱ ╱🭰
177
+ * 🭵🯒🯓╱ 🭰
178
+ * ⠀▔▔▔▔
179
+ */
180
+ export function boxBounds(...boxes: Box[]): Rect {
181
+ const points = boxes.flatMap((box) => {
182
+ const corners = rectCorners({
183
+ x: box.center.x - box.width / 2,
184
+ y: box.center.y - box.height / 2,
185
+ width: box.width,
186
+ height: box.height,
187
+ })
188
+
189
+ return corners.map((corner) => {
190
+ return rotatePoint(corner, box.pivot, box.rotation)
191
+ })
192
+ })
193
+
194
+ return rect(...points)
195
+ }
196
+
197
+ /**
198
+ * @returns Distance between two points.
199
+ *
200
+ * ●
201
+ * ⠀╲
202
+ * ⠀⠀●
203
+ */
204
+ export function pointDist(a: Point, b: Point): number {
205
+ const dx = a.x - b.x
206
+ const dy = a.y - b.y
207
+ return Math.hypot(dx, dy)
208
+ }
209
+
210
+ /**
211
+ * Subtract point `b` from point `a`.
212
+ * b● ────▶ ●a
213
+ */
214
+ export function pointSubtract(a: Point, b: Point): Point {
215
+ return {
216
+ x: a.x - b.x,
217
+ y: a.y - b.y,
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Add point `b` to point `a`
223
+ *
224
+ * a● ────▶ ●b
225
+ */
226
+ export function pointAdd(a: Point, b: Point): Point {
227
+ return {
228
+ x: a.x + b.x,
229
+ y: a.y + b.y,
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Multiply a point by a scalar.
235
+ *
236
+ * ●──▶ p
237
+ * ●──────▶ p * n
238
+ */
239
+ export function pointMultiply(p: Point, n: number): Point {
240
+ return {
241
+ x: p.x * n,
242
+ y: p.y * n,
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Normalize a point as a vector from the origin.
248
+ *
249
+ * ●────────▶ p
250
+ * ●──▶ norm(p)
251
+ */
252
+ export function pointNorm(p: Point): Point {
253
+ const d = Math.hypot(p.x, p.y)
254
+ return {
255
+ x: d === 0 ? 0 : p.x / d,
256
+ y: d === 0 ? 0 : p.y / d,
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Compute the dot product of two points as vectors.
262
+ *
263
+ * B●
264
+ * ╱
265
+ * ╱
266
+ * ▔▔▔▔▔●A
267
+ */
268
+ export function vectorDotProd(a: Point, b: Point): number {
269
+ return a.x * b.x + a.y * b.y
270
+ }
271
+
272
+ /**
273
+ * Clamp a number between inclusive lower and upper bounds.
274
+ */
275
+ export function clamp(n: number, lo: number, hi: number) {
276
+ return Math.max(lo, Math.min(hi, n))
277
+ }
278
+
279
+ /**
280
+ * Round tiny floating point noise to zero and limit precision.
281
+ * @example
282
+ * ```
283
+ * 0.00000000001 -> 0
284
+ * 1.23456 -> 1.235
285
+ * 10 -> 10
286
+ * ```
287
+ */
288
+ export function floatNorm(v: number): number {
289
+ return Math.abs(v) < 1e-10 ? 0 : +v.toFixed(3)
290
+ }
291
+
292
+ /**
293
+ * Convert radians to degrees.
294
+ *
295
+ * ```text
296
+ * π rad = 180°
297
+ * ```
298
+ */
299
+ export function radToDeg(rad: Rad): Deg {
300
+ return deg((rad * 180) / Math.PI)
301
+ }
302
+
303
+ /**
304
+ * @example
305
+ * ```ts
306
+ * C = (0,0)
307
+ * A = (0,2)
308
+ * B = (2,0)
309
+ * angle(C, A, B) // 90
310
+ * ```
311
+ * A
312
+ * ●
313
+ * │
314
+ * │ 90°
315
+ * ●─────● B
316
+ * C
317
+ */
318
+ export function angle(center: Point, a: Point, b: Point): Deg {
319
+ const angA = radToDeg(rad(Math.atan2(a.y - center.y, a.x - center.x)))
320
+ const angB = radToDeg(rad(Math.atan2(b.y - center.y, b.x - center.x)))
321
+ return deg(Math.round(((((angA + angB + 180) % 360) + 360) % 360) - 180))
322
+ }
323
+
324
+ /**
325
+ * @returns Unrotated rectangle for box
326
+ */
327
+ export function boxRect(box: Box): Rect {
328
+ return rect({
329
+ x: box.center.x - box.width / 2,
330
+ y: box.center.y - box.height / 2,
331
+ width: box.width,
332
+ height: box.height,
333
+ })
334
+ }
335
+
336
+ /**
337
+ * @returns Whether point is contained in box.
338
+ * @example
339
+ * ┌──┐
340
+ * │ ●│
341
+ * └──┘
342
+ */
343
+ export function boxContainsPoint(target: Box, point: Point): boolean {
344
+ const p = rotatePoint(point, target.pivot, deg(-target.rotation))
345
+ const r = boxRect(target)
346
+ const x = floatNorm(p.x)
347
+ const y = floatNorm(p.y)
348
+
349
+ return (
350
+ x >= floatNorm(r.left) &&
351
+ x <= floatNorm(r.right) &&
352
+ y >= floatNorm(r.top) &&
353
+ y <= floatNorm(r.bottom)
354
+ )
355
+ }
356
+
357
+ function pointSegmentDistance(target: Point, a: Point, b: Point): number {
358
+ const segment = pointSubtract(b, a)
359
+ const lengthSq = vectorDotProd(segment, segment)
360
+
361
+ if (lengthSq === 0) {
362
+ return pointDist(target, a)
363
+ }
364
+
365
+ const fromStart = pointSubtract(target, a)
366
+ const projection = clamp(vectorDotProd(fromStart, segment) / lengthSq, 0, 1)
367
+ const closest = pointAdd(a, pointMultiply(segment, projection))
368
+
369
+ return pointDist(target, closest)
370
+ }
371
+
372
+ function segmentCross(a: Point, b: Point, c: Point): number {
373
+ return floatNorm((b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x))
374
+ }
375
+
376
+ function pointOnSegment(target: Point, a: Point, b: Point): boolean {
377
+ const x = floatNorm(target.x)
378
+ const y = floatNorm(target.y)
379
+
380
+ return (
381
+ segmentCross(a, b, target) === 0 &&
382
+ x >= floatNorm(Math.min(a.x, b.x)) &&
383
+ x <= floatNorm(Math.max(a.x, b.x)) &&
384
+ y >= floatNorm(Math.min(a.y, b.y)) &&
385
+ y <= floatNorm(Math.max(a.y, b.y))
386
+ )
387
+ }
388
+
389
+ function segmentsIntersect(a: Point, b: Point, c: Point, d: Point): boolean {
390
+ const abc = segmentCross(a, b, c)
391
+ const abd = segmentCross(a, b, d)
392
+ const cda = segmentCross(c, d, a)
393
+ const cdb = segmentCross(c, d, b)
394
+
395
+ return (
396
+ (abc === 0 && pointOnSegment(c, a, b)) ||
397
+ (abd === 0 && pointOnSegment(d, a, b)) ||
398
+ (cda === 0 && pointOnSegment(a, c, d)) ||
399
+ (cdb === 0 && pointOnSegment(b, c, d)) ||
400
+ (abc * abd < 0 && cda * cdb < 0)
401
+ )
402
+ }
403
+
404
+ function segmentDistance(a: Point, b: Point, c: Point, d: Point): number {
405
+ if (segmentsIntersect(a, b, c, d)) return 0
406
+
407
+ return Math.min(
408
+ pointSegmentDistance(a, c, d),
409
+ pointSegmentDistance(b, c, d),
410
+ pointSegmentDistance(c, a, b),
411
+ pointSegmentDistance(d, a, b),
412
+ )
413
+ }
414
+
415
+ function linePoints(line: Line): Point[] {
416
+ return line.points.map((p) => pointAdd(line, p))
417
+ }
418
+
419
+ /**
420
+ * @returns Whether point is contained in the stroked polyline.
421
+ */
422
+ export function lineContainsPoint(line: Line, point: Point): boolean {
423
+ const points = linePoints(line)
424
+ const radius = line.strokeWidth / 2
425
+
426
+ if (points.length === 1) {
427
+ return pointDist(point, points[0]) <= radius
428
+ }
429
+
430
+ for (let i = 0; i < points.length - 1; i++) {
431
+ const a = points[i]
432
+ const b = points[i + 1]
433
+
434
+ if (pointSegmentDistance(point, a, b) <= radius) return true
435
+ }
436
+
437
+ return false
438
+ }
439
+
440
+ /**
441
+ * @returns Whether the stroked polyline intersects the box.
442
+ */
443
+ export function lineIntersectsBox(line: Line, box: Box): boolean {
444
+ const points = linePoints(line)
445
+
446
+ if (points.length === 0) return false
447
+
448
+ for (const point of points) {
449
+ if (boxContainsPoint(box, point)) return true
450
+ }
451
+
452
+ const radius = line.strokeWidth / 2
453
+ const corners = boxCorners(box)
454
+ const edges = corners.map(
455
+ (corner, i) => [corner, corners[(i + 1) % corners.length]] as const,
456
+ )
457
+
458
+ for (const corner of corners) {
459
+ if (lineContainsPoint(line, corner)) return true
460
+ }
461
+
462
+ if (points.length === 1) {
463
+ return edges.some(([a, b]) => pointSegmentDistance(points[0], a, b) <= radius)
464
+ }
465
+
466
+ for (let i = 0; i < points.length - 1; i++) {
467
+ const a = points[i]
468
+ const b = points[i + 1]
469
+
470
+ for (const [c, d] of edges) {
471
+ if (segmentDistance(a, b, c, d) <= radius) return true
472
+ }
473
+ }
474
+
475
+ return false
476
+ }
477
+
478
+ /**
479
+ * @returns Line with increased strokeWidth for accessibility reasons.
480
+ */
481
+ export function accessibleLine(line: Line): Line {
482
+ const { x, y, strokeWidth, points } = line
483
+ return {
484
+ x,
485
+ y,
486
+ points,
487
+ strokeWidth: Math.max(18, strokeWidth),
488
+ }
489
+ }
490
+
491
+ /**
492
+ * @returns Four corners of a box.
493
+ * ⠀⠀ ●
494
+ * ⠀🯐🯑 🯒🯓
495
+ * ● ●
496
+ * ⠀🯒🯓 🯐🯑
497
+ * ⠀⠀⠀●
498
+ */
499
+ function boxCorners(box: Box): [Point, Point, Point, Point] {
500
+ const [a, b, c, d] = rectCorners(boxRect(box)).map((corner) =>
501
+ rotatePoint(corner, box.pivot, box.rotation),
502
+ )
503
+ return [a, b, c, d]
504
+ }
505
+
506
+ /**
507
+ * @returns Perpendicular normalized axes for each box edge.
508
+ */
509
+ function boxAxes(corners: Point[]): Point[] {
510
+ return corners
511
+ .map((corner, i) => pointSubtract(corners[(i + 1) % corners.length], corner))
512
+ .map((edge) => pointNorm({ x: -edge.y, y: edge.x }))
513
+ .filter((axis) => axis.x !== 0 || axis.y !== 0)
514
+ }
515
+
516
+ /**
517
+ * @returns Smallest and largest scalar projections of points onto an axis.
518
+ */
519
+ function projectPoints(points: Point[], axis: Point) {
520
+ let min = Infinity
521
+ let max = -Infinity
522
+
523
+ for (const point of points) {
524
+ const projection = floatNorm(vectorDotProd(point, axis))
525
+ if (projection < min) min = projection
526
+ if (projection > max) max = projection
527
+ }
528
+
529
+ return { min, max }
530
+ }
531
+
532
+ /**
533
+ * @returns Whether `a` covers any point from `b`.
534
+ */
535
+ export function boxIntersects(a: Box, b: Box): boolean {
536
+ const ac = boxCorners(a)
537
+ const bc = boxCorners(b)
538
+
539
+ return [...boxAxes(ac), ...boxAxes(bc)].every((axis) => {
540
+ const ap = projectPoints(ac, axis)
541
+ const bp = projectPoints(bc, axis)
542
+
543
+ return ap.max >= bp.min && bp.max >= ap.min
544
+ })
545
+ }
546
+
547
+ /**
548
+ * Perpendicular (signed) distance from point b to the infinite line
549
+ * that passes through point a with direction `deg` (degrees).
550
+ */
551
+ export function perpDistance(a: Point, b: Point, deg: Deg): number {
552
+ const normal = rotatePoint(point({ y: 1 }), point(), deg)
553
+ const vector = pointSubtract(b, a)
554
+ return vectorDotProd(vector, normal)
555
+ }
556
+
557
+ /**
558
+ * Resize box by one of its edges.
559
+ * The center and pivot is shifted accordingly so that visually only the edge moves.
560
+ */
561
+ export function resizeBox(box: Box, edge: Edge, by: number): Box {
562
+ const delta = by / 2
563
+ const direction =
564
+ edge === "n"
565
+ ? { x: 0, y: -1 }
566
+ : edge === "s"
567
+ ? { x: 0, y: 1 }
568
+ : edge === "w"
569
+ ? { x: -1, y: 0 }
570
+ : { x: 1, y: 0 }
571
+
572
+ const shift = pointMultiply(rotatePoint(direction, point(), box.rotation), delta)
573
+ const center = pointAdd(box.center, shift)
574
+ const pivot = pointAdd(box.pivot, shift)
575
+
576
+ return {
577
+ ...box,
578
+ center,
579
+ pivot,
580
+ width: edge === "w" || edge === "e" ? box.width + by : box.width,
581
+ height: edge === "n" || edge === "s" ? box.height + by : box.height,
582
+ }
583
+ }
584
+
585
+ /**
586
+ * Scale box by one of its edges keeping its aspect ratio.
587
+ * The center and pivot is shifted accordingly so that visually only the edge moves.
588
+ */
589
+ export function scaleBox(box: Box, direction: Edge | Corner, by: number) {
590
+ const vertical = direction.includes("n") || direction.includes("s")
591
+ const scale =
592
+ vertical && box.height !== 0
593
+ ? (box.height + by) / box.height
594
+ : box.width !== 0
595
+ ? (box.width + by) / box.width
596
+ : 1
597
+
598
+ const width = box.width * scale
599
+ const height = box.height * scale
600
+ const widthDelta = width - box.width
601
+ const heightDelta = height - box.height
602
+
603
+ const isCorner = direction.length === 2
604
+ const north = direction.includes("n") ? heightDelta : 0
605
+ const south = direction.includes("s") ? heightDelta : 0
606
+ const west = direction.includes("w") ? widthDelta : 0
607
+ const east = direction.includes("e") ? widthDelta : 0
608
+ const x = (east - west) / 2
609
+ const y = (south - north) / 2
610
+ const edgeShift = isCorner ? { x, y } : vertical ? { x: 0, y } : { x, y: 0 }
611
+
612
+ const shift = rotatePoint(edgeShift, point(), box.rotation)
613
+ const center = pointAdd(box.center, shift)
614
+ const pivot = pointAdd(box.pivot, shift)
615
+
616
+ return {
617
+ rotation: box.rotation,
618
+ center,
619
+ pivot,
620
+ width,
621
+ height,
622
+ }
623
+ }
@@ -0,0 +1,55 @@
1
+ import {
2
+ clamp,
3
+ floatNorm,
4
+ type Point,
5
+ pointAdd,
6
+ pointDist,
7
+ pointMultiply,
8
+ pointNorm,
9
+ pointSubtract,
10
+ vectorDotProd,
11
+ } from "./math"
12
+
13
+ export function roundedPathData(points: Point[], roundness: number): string {
14
+ const count = points.length
15
+ const winding = Math.sign(
16
+ points.reduce((area, curr, i) => {
17
+ const next = points[(i + 1) % count]
18
+ return area + curr.x * next.y - next.x * curr.y
19
+ }, 0),
20
+ )
21
+
22
+ const cmds = Array.from({ length: count }, (_, i) => {
23
+ const prev = points[(i - 1 + count) % count]
24
+ const curr = points[i]
25
+ const next = points[(i + 1) % count]
26
+ const cmd = i === 0 ? "M" : "L"
27
+
28
+ const v1 = pointNorm(pointSubtract(prev, curr))
29
+ const v2 = pointNorm(pointSubtract(next, curr))
30
+
31
+ const cosTheta = clamp(vectorDotProd(v1, v2), -1, 1)
32
+ const theta = Math.acos(cosTheta)
33
+ const cornerCross =
34
+ (curr.x - prev.x) * (next.y - curr.y) - (curr.y - prev.y) * (next.x - curr.x)
35
+
36
+ if (!isFinite(theta) || theta < 1e-6 || roundness <= 0) {
37
+ return `${cmd} ${floatNorm(curr.x)} ${floatNorm(curr.y)}`
38
+ }
39
+
40
+ const dPrev = pointDist(curr, prev)
41
+ const dNext = pointDist(curr, next)
42
+ const tIdeal = roundness / Math.tan(theta / 2)
43
+ const t = Math.min(tIdeal, dPrev / 2, dNext / 2)
44
+ const rEff = t * Math.tan(theta / 2)
45
+ const p1 = pointAdd(curr, pointMultiply(v1, t))
46
+ const p2 = pointAdd(curr, pointMultiply(v2, t))
47
+ const isConcave = winding !== 0 && Math.sign(cornerCross) !== winding
48
+ const sweep = isConcave ? 0 : 1
49
+ const arc = `A ${floatNorm(rEff)} ${floatNorm(rEff)} 0 0 ${sweep} ${floatNorm(p2.x)} ${floatNorm(p2.y)}`
50
+
51
+ return `${cmd} ${floatNorm(p1.x)} ${floatNorm(p1.y)} ${arc}`
52
+ })
53
+
54
+ return cmds.concat("Z").join(" ")
55
+ }