@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.
- package/lib/hooks/actions.ts +54 -26
- package/lib/hooks/batch.ts +17 -7
- package/lib/hooks/index.ts +1 -0
- package/lib/hooks/node.ts +14 -6
- package/lib/hooks/pointer/movePoint.ts +75 -0
- package/lib/hooks/pointer/moveable.ts +92 -57
- package/lib/hooks/pointer/pointer.ts +21 -11
- package/lib/hooks/pointer/resize.ts +176 -210
- package/lib/hooks/pointer/rotation.ts +89 -68
- package/lib/hooks/pointer/selectionFrame.ts +8 -11
- package/lib/hooks/pointer/selector.ts +62 -40
- package/lib/hooks/pointer/snap.ts +23 -23
- package/lib/hooks/textMarks.ts +1 -3
- package/lib/lib/googleFonts.ts +1 -5
- package/lib/model/editor.ts +13 -9
- package/lib/model/geometry/math.ts +623 -0
- package/lib/model/geometry/svg.ts +55 -0
- package/lib/model/history.ts +10 -13
- package/lib/model/index.ts +7 -10
- package/lib/model/node/{editable → editableNode}/index.ts +13 -29
- package/lib/model/node/{formattable.ts → formattableNode/index.ts} +5 -11
- package/lib/model/node/{group.ts → groupNode.ts} +9 -13
- package/lib/model/node/{image.ts → imageNode.ts} +5 -11
- package/lib/model/node/lineNode.ts +59 -0
- package/lib/model/node/{shape/shape.ts → shapeNode/index.ts} +30 -15
- package/lib/model/node/shapeNode/shape.ts +96 -0
- package/lib/model/node/{text.ts → textNode.ts} +19 -21
- package/lib/model/node.ts +11 -29
- package/lib/model/page.ts +4 -3
- package/lib/model/traversal.ts +1 -1
- package/lib/ui/extractor.ts +3 -3
- package/lib/ui/index.ts +2 -4
- package/lib/ui/node/{EditableContent.tsx → EditableContent/index.tsx} +4 -3
- package/lib/ui/node/GroupContent.tsx +1 -1
- package/lib/ui/node/ImageContent.tsx +1 -1
- package/lib/ui/node/LineContent.tsx +32 -0
- package/lib/ui/node/NodeView.tsx +1 -13
- package/lib/ui/node/ShapeContent/ArrowContent.tsx +57 -0
- package/lib/ui/node/ShapeContent/EllipseContent.tsx +37 -0
- package/lib/ui/node/ShapeContent/PolygonContent.tsx +62 -0
- package/lib/ui/node/ShapeContent/RectangleContent.tsx +35 -0
- package/lib/ui/node/ShapeContent/StarContent.tsx +75 -0
- package/lib/ui/node/ShapeContent/index.tsx +43 -0
- package/lib/ui/node/TextContent.tsx +1 -1
- package/lib/ui/selection.ts +9 -26
- package/package.json +34 -34
- package/lib/model/geometry.ts +0 -247
- package/lib/model/node/shape/arrow.ts +0 -50
- package/lib/model/node/shape/ellipse.ts +0 -26
- package/lib/model/node/shape/polygon.ts +0 -108
- package/lib/model/node/shape/star.ts +0 -63
- package/lib/ui/node/ArrowContent.tsx +0 -60
- package/lib/ui/node/EllipseContent.tsx +0 -49
- package/lib/ui/node/PolygonContent.tsx +0 -81
- package/lib/ui/node/StarContent.tsx +0 -60
- /package/lib/model/node/{editable → editableNode}/letterSpacing.ts +0 -0
- /package/lib/model/node/{editable → editableNode}/persistentMarks.ts +0 -0
- /package/lib/model/node/{editable → editableNode}/tiptapExtensions.ts +0 -0
- /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
|
+
}
|