@lazlon-platform/html-editor 0.3.6 → 0.5.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 +4 -3
- package/lib/hooks/batch.ts +3 -2
- package/lib/hooks/node.ts +14 -6
- package/lib/hooks/pointer/moveable.ts +75 -54
- package/lib/hooks/pointer/pointer.ts +22 -11
- package/lib/hooks/pointer/resize.ts +176 -210
- package/lib/hooks/pointer/rotation.ts +41 -55
- package/lib/hooks/pointer/selectionFrame.ts +8 -11
- package/lib/hooks/pointer/selector.ts +48 -40
- package/lib/hooks/pointer/snap.ts +20 -20
- package/lib/hooks/textMarks.ts +67 -50
- package/lib/model/geometry/math.ts +484 -0
- package/lib/model/geometry/svg.ts +55 -0
- package/lib/model/node/shape/arrow.ts +1 -1
- package/lib/model/node/shape/polygon.ts +23 -1
- package/lib/model/node/shape/star.ts +29 -1
- package/lib/model/node/text.ts +21 -12
- package/lib/model/node.ts +3 -7
- package/lib/model/page.ts +3 -1
- package/lib/ui/node/NodeView.tsx +1 -13
- package/lib/ui/node/TextContent.tsx +13 -16
- package/lib/ui/selection.ts +9 -26
- package/package.json +34 -34
- package/lib/model/geometry.ts +0 -247
|
@@ -0,0 +1,484 @@
|
|
|
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
|
+
/**
|
|
24
|
+
* A rotated rectangle around a pivot point.
|
|
25
|
+
* Pivot point is relative to the center point.
|
|
26
|
+
*/
|
|
27
|
+
export interface Box extends Size {
|
|
28
|
+
readonly center: Point
|
|
29
|
+
readonly rotation: Deg
|
|
30
|
+
readonly pivot: Point
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Cast a number to a degree value.
|
|
35
|
+
*/
|
|
36
|
+
export function deg(deg: number): Deg {
|
|
37
|
+
return (deg % 360) as Deg
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Cast a number to a radian value.
|
|
42
|
+
*/
|
|
43
|
+
export function rad(rad: number): Rad {
|
|
44
|
+
return rad as Rad
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Create a point.
|
|
49
|
+
*/
|
|
50
|
+
export function point(p?: Partial<Point>): Point {
|
|
51
|
+
return { x: p?.x ?? 0, y: p?.y ?? 0 }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Create a rectangle around the given points and rectangles.
|
|
56
|
+
*/
|
|
57
|
+
export function rect(...args: Array<(Point & Size) | Point>): Rect {
|
|
58
|
+
const points: Point[] = args.flatMap((arg) =>
|
|
59
|
+
"width" in arg && "height" in arg ? rectCorners(arg) : arg,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
let minX = Infinity
|
|
63
|
+
let minY = Infinity
|
|
64
|
+
let maxX = -Infinity
|
|
65
|
+
let maxY = -Infinity
|
|
66
|
+
|
|
67
|
+
for (const p of points) {
|
|
68
|
+
if (p.x < minX) minX = p.x
|
|
69
|
+
if (p.y < minY) minY = p.y
|
|
70
|
+
if (p.x > maxX) maxX = p.x
|
|
71
|
+
if (p.y > maxY) maxY = p.y
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const rect = {
|
|
75
|
+
x: minX,
|
|
76
|
+
y: minY,
|
|
77
|
+
width: maxX - minX,
|
|
78
|
+
height: maxY - minY,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
...rect,
|
|
83
|
+
top: rect.y,
|
|
84
|
+
bottom: rect.y + rect.height,
|
|
85
|
+
left: rect.x,
|
|
86
|
+
right: rect.x + rect.width,
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Create a rotatable box for a rectangle
|
|
92
|
+
*/
|
|
93
|
+
export function box(input?: Size & Point & { rotation?: Deg }): Box {
|
|
94
|
+
return {
|
|
95
|
+
center: rectCenter(input ?? rect()),
|
|
96
|
+
rotation: input?.rotation ?? deg(0),
|
|
97
|
+
pivot: rectCenter(input ?? rect()),
|
|
98
|
+
width: input?.width ?? 0,
|
|
99
|
+
height: input?.height ?? 0,
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* @returns Four corners of a rectangle.
|
|
105
|
+
* ●───●
|
|
106
|
+
* │ │
|
|
107
|
+
* ●───●
|
|
108
|
+
*/
|
|
109
|
+
export function rectCorners(rect: Size & Point): [Point, Point, Point, Point] {
|
|
110
|
+
const top = rect.y
|
|
111
|
+
const bottom = rect.y + rect.height
|
|
112
|
+
const left = rect.x
|
|
113
|
+
const right = rect.x + rect.width
|
|
114
|
+
return [
|
|
115
|
+
{ y: top, x: left },
|
|
116
|
+
{ y: top, x: right },
|
|
117
|
+
{ y: bottom, x: right },
|
|
118
|
+
{ y: bottom, x: left },
|
|
119
|
+
]
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* @returns Center point of a rectangle.
|
|
124
|
+
* ┌─────┐
|
|
125
|
+
* │ ●C │
|
|
126
|
+
* └─────┘
|
|
127
|
+
*/
|
|
128
|
+
export function rectCenter({ x, y, width, height }: Size & Point): Point {
|
|
129
|
+
return {
|
|
130
|
+
x: x + width / 2,
|
|
131
|
+
y: y + height / 2,
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* @returns Rotate a point relative to a pivot point.
|
|
137
|
+
*
|
|
138
|
+
* @example
|
|
139
|
+
* ```
|
|
140
|
+
* P = (0,0)
|
|
141
|
+
* A = (2,0)
|
|
142
|
+
*
|
|
143
|
+
* rotate(A, P, 90)
|
|
144
|
+
* -> A' = (0,2)
|
|
145
|
+
* ```
|
|
146
|
+
*
|
|
147
|
+
* A
|
|
148
|
+
* ●
|
|
149
|
+
* │
|
|
150
|
+
* │ 90°
|
|
151
|
+
* ●─────● A'
|
|
152
|
+
* P
|
|
153
|
+
*/
|
|
154
|
+
export function rotatePoint(p: Point, pivot: Point, deg: Deg): Point {
|
|
155
|
+
const rad = (deg * Math.PI) / 180
|
|
156
|
+
const cos = Math.cos(rad)
|
|
157
|
+
const sin = Math.sin(rad)
|
|
158
|
+
const dx = p.x - pivot.x
|
|
159
|
+
const dy = p.y - pivot.y
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
x: pivot.x + dx * cos - dy * sin,
|
|
163
|
+
y: pivot.y + dx * sin + dy * cos,
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* @returns Axis-aligned bounding box (AABB) of the rotated rectangle.
|
|
169
|
+
* ⠀▁▁▁▁
|
|
170
|
+
* 🭵 ╱🭰
|
|
171
|
+
* 🭵╱ ╱🭰
|
|
172
|
+
* 🭵╱ 🭰
|
|
173
|
+
* ⠀▔▔▔▔
|
|
174
|
+
*/
|
|
175
|
+
export function boxBounds(...boxes: Box[]): Rect {
|
|
176
|
+
const points = boxes.flatMap((box) => {
|
|
177
|
+
const corners = rectCorners({
|
|
178
|
+
x: box.center.x - box.width / 2,
|
|
179
|
+
y: box.center.y - box.height / 2,
|
|
180
|
+
width: box.width,
|
|
181
|
+
height: box.height,
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
return corners.map((corner) => {
|
|
185
|
+
return rotatePoint(corner, box.pivot, box.rotation)
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
return rect(...points)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* @returns Distance between two points.
|
|
194
|
+
*
|
|
195
|
+
* ●
|
|
196
|
+
* ⠀╲
|
|
197
|
+
* ⠀⠀●
|
|
198
|
+
*/
|
|
199
|
+
export function pointDist(a: Point, b: Point): number {
|
|
200
|
+
const dx = a.x - b.x
|
|
201
|
+
const dy = a.y - b.y
|
|
202
|
+
return Math.hypot(dx, dy)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Subtract point `b` from point `a`.
|
|
207
|
+
* b● ────▶ ●a
|
|
208
|
+
*/
|
|
209
|
+
export function pointSubtract(a: Point, b: Point): Point {
|
|
210
|
+
return {
|
|
211
|
+
x: a.x - b.x,
|
|
212
|
+
y: a.y - b.y,
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Add point `b` to point `a`
|
|
218
|
+
*
|
|
219
|
+
* a● ────▶ ●b
|
|
220
|
+
*/
|
|
221
|
+
export function pointAdd(a: Point, b: Point): Point {
|
|
222
|
+
return {
|
|
223
|
+
x: a.x + b.x,
|
|
224
|
+
y: a.y + b.y,
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Multiply a point by a scalar.
|
|
230
|
+
*
|
|
231
|
+
* ●──▶ p
|
|
232
|
+
* ●──────▶ p * n
|
|
233
|
+
*/
|
|
234
|
+
export function pointMultiply(p: Point, n: number): Point {
|
|
235
|
+
return {
|
|
236
|
+
x: p.x * n,
|
|
237
|
+
y: p.y * n,
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Normalize a point as a vector from the origin.
|
|
243
|
+
*
|
|
244
|
+
* ●────────▶ p
|
|
245
|
+
* ●──▶ norm(p)
|
|
246
|
+
*/
|
|
247
|
+
export function pointNorm(p: Point): Point {
|
|
248
|
+
const d = Math.hypot(p.x, p.y)
|
|
249
|
+
return {
|
|
250
|
+
x: d === 0 ? 0 : p.x / d,
|
|
251
|
+
y: d === 0 ? 0 : p.y / d,
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Compute the dot product of two points as vectors.
|
|
257
|
+
*
|
|
258
|
+
* B●
|
|
259
|
+
* ╱
|
|
260
|
+
* ╱
|
|
261
|
+
* ▔▔▔▔▔●A
|
|
262
|
+
*/
|
|
263
|
+
export function vectorDotProd(a: Point, b: Point): number {
|
|
264
|
+
return a.x * b.x + a.y * b.y
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Clamp a number between inclusive lower and upper bounds.
|
|
269
|
+
*/
|
|
270
|
+
export function clamp(n: number, lo: number, hi: number) {
|
|
271
|
+
return Math.max(lo, Math.min(hi, n))
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Round tiny floating point noise to zero and limit precision.
|
|
276
|
+
* @example
|
|
277
|
+
* ```
|
|
278
|
+
* 0.00000000001 -> 0
|
|
279
|
+
* 1.23456 -> 1.235
|
|
280
|
+
* 10 -> 10
|
|
281
|
+
* ```
|
|
282
|
+
*/
|
|
283
|
+
export function floatNorm(v: number): number {
|
|
284
|
+
return Math.abs(v) < 1e-10 ? 0 : +v.toFixed(3)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Convert radians to degrees.
|
|
289
|
+
*
|
|
290
|
+
* ```text
|
|
291
|
+
* π rad = 180°
|
|
292
|
+
* ```
|
|
293
|
+
*/
|
|
294
|
+
export function radToDeg(rad: Rad): Deg {
|
|
295
|
+
return deg((rad * 180) / Math.PI)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* @example
|
|
300
|
+
* ```ts
|
|
301
|
+
* C = (0,0)
|
|
302
|
+
* A = (0,2)
|
|
303
|
+
* B = (2,0)
|
|
304
|
+
* angle(C, A, B) // 90
|
|
305
|
+
* ```
|
|
306
|
+
* A
|
|
307
|
+
* ●
|
|
308
|
+
* │
|
|
309
|
+
* │ 90°
|
|
310
|
+
* ●─────● B
|
|
311
|
+
* C
|
|
312
|
+
*/
|
|
313
|
+
export function angle(center: Point, a: Point, b: Point): Deg {
|
|
314
|
+
const angA = radToDeg(rad(Math.atan2(a.y - center.y, a.x - center.x)))
|
|
315
|
+
const angB = radToDeg(rad(Math.atan2(b.y - center.y, b.x - center.x)))
|
|
316
|
+
return deg(Math.round(((((angA + angB + 180) % 360) + 360) % 360) - 180))
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* @returns Unrotated rectangle for box
|
|
321
|
+
*/
|
|
322
|
+
export function boxRect(box: Box): Rect {
|
|
323
|
+
return rect({
|
|
324
|
+
x: box.center.x - box.width / 2,
|
|
325
|
+
y: box.center.y - box.height / 2,
|
|
326
|
+
width: box.width,
|
|
327
|
+
height: box.height,
|
|
328
|
+
})
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* @returns Whether point is contained in box.
|
|
333
|
+
* @example
|
|
334
|
+
* ┌──┐
|
|
335
|
+
* │ ●│
|
|
336
|
+
* └──┘
|
|
337
|
+
*/
|
|
338
|
+
export function boxContainsPoint(target: Box, point: Point): boolean {
|
|
339
|
+
const p = rotatePoint(point, target.pivot, deg(-target.rotation))
|
|
340
|
+
const r = boxRect(target)
|
|
341
|
+
const x = floatNorm(p.x)
|
|
342
|
+
const y = floatNorm(p.y)
|
|
343
|
+
|
|
344
|
+
return (
|
|
345
|
+
x >= floatNorm(r.left) &&
|
|
346
|
+
x <= floatNorm(r.right) &&
|
|
347
|
+
y >= floatNorm(r.top) &&
|
|
348
|
+
y <= floatNorm(r.bottom)
|
|
349
|
+
)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* @returns Four corners of a box.
|
|
354
|
+
* ⠀⠀ ●
|
|
355
|
+
* ⠀
|
|
356
|
+
* ● ●
|
|
357
|
+
* ⠀
|
|
358
|
+
* ⠀⠀⠀●
|
|
359
|
+
*/
|
|
360
|
+
function boxCorners(box: Box): [Point, Point, Point, Point] {
|
|
361
|
+
const [a, b, c, d] = rectCorners(boxRect(box)).map((corner) =>
|
|
362
|
+
rotatePoint(corner, box.pivot, box.rotation),
|
|
363
|
+
)
|
|
364
|
+
return [a, b, c, d]
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* @returns Perpendicular normalized axes for each box edge.
|
|
369
|
+
*/
|
|
370
|
+
function boxAxes(corners: Point[]): Point[] {
|
|
371
|
+
return corners
|
|
372
|
+
.map((corner, i) => pointSubtract(corners[(i + 1) % corners.length], corner))
|
|
373
|
+
.map((edge) => pointNorm({ x: -edge.y, y: edge.x }))
|
|
374
|
+
.filter((axis) => axis.x !== 0 || axis.y !== 0)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* @returns Smallest and largest scalar projections of points onto an axis.
|
|
379
|
+
*/
|
|
380
|
+
function projectPoints(points: Point[], axis: Point) {
|
|
381
|
+
let min = Infinity
|
|
382
|
+
let max = -Infinity
|
|
383
|
+
|
|
384
|
+
for (const point of points) {
|
|
385
|
+
const projection = floatNorm(vectorDotProd(point, axis))
|
|
386
|
+
if (projection < min) min = projection
|
|
387
|
+
if (projection > max) max = projection
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return { min, max }
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* @returns Whether `a` covers any point from `b`.
|
|
395
|
+
*/
|
|
396
|
+
export function boxIntersects(a: Box, b: Box): boolean {
|
|
397
|
+
const ac = boxCorners(a)
|
|
398
|
+
const bc = boxCorners(b)
|
|
399
|
+
|
|
400
|
+
return [...boxAxes(ac), ...boxAxes(bc)].every((axis) => {
|
|
401
|
+
const ap = projectPoints(ac, axis)
|
|
402
|
+
const bp = projectPoints(bc, axis)
|
|
403
|
+
|
|
404
|
+
return ap.max >= bp.min && bp.max >= ap.min
|
|
405
|
+
})
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Perpendicular (signed) distance from point b to the infinite line
|
|
410
|
+
* that passes through point a with direction `deg` (degrees).
|
|
411
|
+
*/
|
|
412
|
+
export function perpDistance(a: Point, b: Point, deg: Deg): number {
|
|
413
|
+
const normal = rotatePoint(point({ y: 1 }), point(), deg)
|
|
414
|
+
const vector = pointSubtract(b, a)
|
|
415
|
+
return vectorDotProd(vector, normal)
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Resize box by one of its edges.
|
|
420
|
+
* The center and pivot is shifted accordingly so that visually only the edge moves.
|
|
421
|
+
*/
|
|
422
|
+
export function resizeBox(box: Box, edge: Edge, by: number): Box {
|
|
423
|
+
const delta = by / 2
|
|
424
|
+
const direction =
|
|
425
|
+
edge === "n"
|
|
426
|
+
? { x: 0, y: -1 }
|
|
427
|
+
: edge === "s"
|
|
428
|
+
? { x: 0, y: 1 }
|
|
429
|
+
: edge === "w"
|
|
430
|
+
? { x: -1, y: 0 }
|
|
431
|
+
: { x: 1, y: 0 }
|
|
432
|
+
|
|
433
|
+
const shift = pointMultiply(rotatePoint(direction, point(), box.rotation), delta)
|
|
434
|
+
const center = pointAdd(box.center, shift)
|
|
435
|
+
const pivot = pointAdd(box.pivot, shift)
|
|
436
|
+
|
|
437
|
+
return {
|
|
438
|
+
...box,
|
|
439
|
+
center,
|
|
440
|
+
pivot,
|
|
441
|
+
width: edge === "w" || edge === "e" ? box.width + by : box.width,
|
|
442
|
+
height: edge === "n" || edge === "s" ? box.height + by : box.height,
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Scale box by one of its edges keeping its aspect ratio.
|
|
448
|
+
* The center and pivot is shifted accordingly so that visually only the edge moves.
|
|
449
|
+
*/
|
|
450
|
+
export function scaleBox(box: Box, direction: Edge | Corner, by: number) {
|
|
451
|
+
const vertical = direction.includes("n") || direction.includes("s")
|
|
452
|
+
const scale =
|
|
453
|
+
vertical && box.height !== 0
|
|
454
|
+
? (box.height + by) / box.height
|
|
455
|
+
: box.width !== 0
|
|
456
|
+
? (box.width + by) / box.width
|
|
457
|
+
: 1
|
|
458
|
+
|
|
459
|
+
const width = box.width * scale
|
|
460
|
+
const height = box.height * scale
|
|
461
|
+
const widthDelta = width - box.width
|
|
462
|
+
const heightDelta = height - box.height
|
|
463
|
+
|
|
464
|
+
const isCorner = direction.length === 2
|
|
465
|
+
const north = direction.includes("n") ? heightDelta : 0
|
|
466
|
+
const south = direction.includes("s") ? heightDelta : 0
|
|
467
|
+
const west = direction.includes("w") ? widthDelta : 0
|
|
468
|
+
const east = direction.includes("e") ? widthDelta : 0
|
|
469
|
+
const x = (east - west) / 2
|
|
470
|
+
const y = (south - north) / 2
|
|
471
|
+
const edgeShift = isCorner ? { x, y } : vertical ? { x: 0, y } : { x, y: 0 }
|
|
472
|
+
|
|
473
|
+
const shift = rotatePoint(edgeShift, point(), box.rotation)
|
|
474
|
+
const center = pointAdd(box.center, shift)
|
|
475
|
+
const pivot = pointAdd(box.pivot, shift)
|
|
476
|
+
|
|
477
|
+
return {
|
|
478
|
+
rotation: box.rotation,
|
|
479
|
+
center,
|
|
480
|
+
pivot,
|
|
481
|
+
width,
|
|
482
|
+
height,
|
|
483
|
+
}
|
|
484
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -3,7 +3,7 @@ import type { Editor } from "../../editor"
|
|
|
3
3
|
import type { SerializedNode } from "../../node"
|
|
4
4
|
import type { Page } from "../../page"
|
|
5
5
|
import { ShapeNode, type ShapeNodeProps } from "./shape"
|
|
6
|
-
import { roundedPathData } from "../../geometry"
|
|
6
|
+
import { roundedPathData } from "../../geometry/svg"
|
|
7
7
|
|
|
8
8
|
export type ArrowNodeProps = ShapeNodeProps & Partial<Pick<ArrowNode, "roundness">>
|
|
9
9
|
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { computed, state } from "react-bolt"
|
|
2
2
|
import type { Editor } from "../../editor"
|
|
3
|
+
import type { Point } from "../../geometry/math"
|
|
4
|
+
import { roundedPathData } from "../../geometry/svg"
|
|
3
5
|
import type { SerializedNode } from "../../node"
|
|
4
6
|
import type { Page } from "../../page"
|
|
5
7
|
import { ShapeNode, type ShapeNodeProps } from "./shape"
|
|
6
|
-
import { regularPolygonPoints, roundedPathData } from "../../geometry"
|
|
7
8
|
|
|
8
9
|
export type PolygonNodeProps = ShapeNodeProps &
|
|
9
10
|
Partial<
|
|
@@ -106,3 +107,24 @@ export class PolygonNode extends ShapeNode {
|
|
|
106
107
|
)
|
|
107
108
|
}
|
|
108
109
|
}
|
|
110
|
+
|
|
111
|
+
function regularPolygonPoints(props: {
|
|
112
|
+
width: number
|
|
113
|
+
height: number
|
|
114
|
+
sides: number
|
|
115
|
+
}): Point[] {
|
|
116
|
+
const { width, height, sides } = props
|
|
117
|
+
const rotation = sides % 2 === 0 ? Math.PI / sides : 0
|
|
118
|
+
const cx = width / 2
|
|
119
|
+
const cy = height / 2
|
|
120
|
+
const rx = width / 2
|
|
121
|
+
const ry = height / 2
|
|
122
|
+
|
|
123
|
+
return Array.from({ length: sides }, (_, i) => {
|
|
124
|
+
const angle = (i * Math.PI * 2) / sides - Math.PI / 2 + rotation
|
|
125
|
+
return {
|
|
126
|
+
x: cx + rx * Math.cos(angle),
|
|
127
|
+
y: cy + ry * Math.sin(angle),
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { computed, state } from "react-bolt"
|
|
2
|
-
import { clamp, roundedPathData, starPoints } from "../../geometry"
|
|
3
2
|
import type { Editor } from "../../editor"
|
|
4
3
|
import type { SerializedNode } from "../../node"
|
|
5
4
|
import type { Page } from "../../page"
|
|
6
5
|
import { ShapeNode, type ShapeNodeProps } from "./shape"
|
|
6
|
+
import { clamp, type Point } from "../../geometry/math"
|
|
7
|
+
import { roundedPathData } from "../../geometry/svg"
|
|
7
8
|
|
|
8
9
|
export type StarNodeProps = ShapeNodeProps &
|
|
9
10
|
Partial<Pick<StarNode, "corners" | "roundness" | "depth">>
|
|
@@ -61,3 +62,30 @@ export class StarNode extends ShapeNode {
|
|
|
61
62
|
)
|
|
62
63
|
}
|
|
63
64
|
}
|
|
65
|
+
|
|
66
|
+
function starPoints(props: {
|
|
67
|
+
width: number
|
|
68
|
+
height: number
|
|
69
|
+
corners: number
|
|
70
|
+
depth: number
|
|
71
|
+
}): Point[] {
|
|
72
|
+
const { width, height, corners, depth } = props
|
|
73
|
+
const cx = width / 2
|
|
74
|
+
const cy = height / 2
|
|
75
|
+
const outerRx = width / 2
|
|
76
|
+
const outerRy = height / 2
|
|
77
|
+
const innerRx = outerRx * depth
|
|
78
|
+
const innerRy = outerRy * depth
|
|
79
|
+
|
|
80
|
+
return Array.from({ length: corners * 2 }, (_, i) => {
|
|
81
|
+
const angle = (i * Math.PI) / corners - Math.PI / 2
|
|
82
|
+
const isOuter = i % 2 === 0
|
|
83
|
+
const rx = isOuter ? outerRx : innerRx
|
|
84
|
+
const ry = isOuter ? outerRy : innerRy
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
x: cx + rx * Math.cos(angle),
|
|
88
|
+
y: cy + ry * Math.sin(angle),
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
}
|
package/lib/model/node/text.ts
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { computed, state } from "react-bolt"
|
|
2
2
|
import type { Editor } from "../editor"
|
|
3
3
|
import type { Page } from "../page"
|
|
4
|
-
import { EditableNode, type EditableNodeProps } from "./editable"
|
|
5
4
|
import type { SerializedNode } from "../node"
|
|
5
|
+
import { EditableNode, type EditableNodeProps } from "./editable"
|
|
6
6
|
|
|
7
|
-
export type TextNodeProps = EditableNodeProps &
|
|
8
|
-
Partial<Pick<TextNode, "halign" | "scale">>
|
|
7
|
+
export type TextNodeProps = EditableNodeProps & Partial<Pick<TextNode, "halign" | "size">>
|
|
9
8
|
|
|
10
9
|
export class TextNode extends EditableNode {
|
|
11
10
|
get name() {
|
|
@@ -13,21 +12,25 @@ export class TextNode extends EditableNode {
|
|
|
13
12
|
}
|
|
14
13
|
|
|
15
14
|
@state accessor halign: "left" | "center" | "right" | "justify"
|
|
16
|
-
@state accessor
|
|
15
|
+
@state accessor size: number = 16
|
|
17
16
|
@state accessor contentHeight: number = 24
|
|
18
17
|
|
|
18
|
+
private observer = new ResizeObserver(([entry]) => {
|
|
19
|
+
this.contentHeight = entry.target.clientHeight
|
|
20
|
+
})
|
|
21
|
+
|
|
19
22
|
@computed get height(): number {
|
|
20
|
-
return this.contentHeight
|
|
23
|
+
return this.contentHeight
|
|
21
24
|
}
|
|
22
25
|
|
|
23
|
-
set height(
|
|
24
|
-
|
|
26
|
+
set height(_) {
|
|
27
|
+
// no-op: TextNode's height can be set using its font-size
|
|
25
28
|
}
|
|
26
29
|
|
|
27
|
-
constructor(editor: Editor, page: Page, { halign,
|
|
30
|
+
constructor(editor: Editor, page: Page, { halign, size, ...props }: TextNodeProps) {
|
|
28
31
|
super(editor, page, props)
|
|
29
32
|
this.halign = halign ?? "center"
|
|
30
|
-
this.
|
|
33
|
+
this.size = size ?? 16
|
|
31
34
|
|
|
32
35
|
this.tiptap.on("transaction", () => {
|
|
33
36
|
const height = this.tiptap.view.dom.clientHeight
|
|
@@ -37,7 +40,13 @@ export class TextNode extends EditableNode {
|
|
|
37
40
|
|
|
38
41
|
set contentRef(ref: HTMLElement | null) {
|
|
39
42
|
super.contentRef = ref
|
|
40
|
-
|
|
43
|
+
|
|
44
|
+
if (ref) {
|
|
45
|
+
this.observer.observe(ref)
|
|
46
|
+
this.contentHeight = ref.clientHeight
|
|
47
|
+
} else {
|
|
48
|
+
this.observer.disconnect()
|
|
49
|
+
}
|
|
41
50
|
}
|
|
42
51
|
|
|
43
52
|
@computed get contentRef() {
|
|
@@ -45,8 +54,8 @@ export class TextNode extends EditableNode {
|
|
|
45
54
|
}
|
|
46
55
|
|
|
47
56
|
props(): TextNodeProps {
|
|
48
|
-
const { halign,
|
|
49
|
-
return { ...super.props(), halign,
|
|
57
|
+
const { halign, size } = this
|
|
58
|
+
return { ...super.props(), halign, size }
|
|
50
59
|
}
|
|
51
60
|
|
|
52
61
|
serialize(): SerializedNode<this["name"], TextNodeProps> {
|