@lazlon-platform/html-editor 0.4.0 → 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.
@@ -1,53 +1,64 @@
1
- import { isPointerInSelectionRect } from "../../ui/selection"
1
+ import { useRef } from "react"
2
+ import {
3
+ type Rect,
4
+ box,
5
+ boxContainsPoint,
6
+ pointSubtract,
7
+ rect,
8
+ boxIntersects,
9
+ } from "../../model/geometry/math"
2
10
  import { useEditor } from "../editor"
3
- import { usePointer } from "./pointer"
11
+ import { cursorPosition, usePointer } from "./pointer"
4
12
 
5
- export function useSelector(props: { ref: React.RefObject<HTMLDivElement | null> }) {
13
+ function clientPoint(event: { clientX: number; clientY: number }) {
14
+ return { x: event.clientX, y: event.clientY }
15
+ }
16
+
17
+ export function useSelector(view: (props: null | Rect) => void) {
6
18
  const editor = useEditor()
7
19
 
20
+ const dragAnchor = useRef({
21
+ clientX: 0,
22
+ clientY: 0,
23
+ })
24
+
8
25
  return usePointer({
9
26
  onDown(event) {
10
- const isPointerInsideRect = isPointerInSelectionRect(editor.selection, event)
27
+ for (const node of editor.nodes.values()) {
28
+ if (boxContainsPoint(box(node), cursorPosition(event, node))) {
29
+ return false
30
+ }
31
+ }
11
32
 
12
- if (!isPointerInsideRect && !editor.action.action) {
33
+ if (!editor.action.action) {
13
34
  editor.action = { action: "select" }
35
+ dragAnchor.current = event
14
36
  }
15
-
16
- return !isPointerInsideRect
17
37
  },
18
- onMove({ event, start }) {
19
- const div = props.ref.current
20
- if (!div) throw Error("selector div ref is null")
38
+ onMove(event) {
21
39
  if (editor.action.action !== "select") return
40
+ const stage = editor.ref!.getBoundingClientRect()
22
41
 
23
- const rect = editor.ref!.getBoundingClientRect()
24
-
25
- const x = event.clientX - start.clientX
26
- const y = event.clientY - start.clientY
27
- const width = Math.floor(Math.abs(x))
28
- const height = Math.floor(Math.abs(y))
29
- const tx = (x > 0 ? start.clientX : start.clientX - width) - rect.x
30
- const ty = (y > 0 ? start.clientY : start.clientY - height) - rect.y
31
-
32
- const { left, right, top, bottom } = div.getBoundingClientRect()
42
+ /* world view */ {
43
+ const p1 = clientPoint(dragAnchor.current)
44
+ const p2 = clientPoint(event)
45
+ view(rect(pointSubtract(p1, stage), pointSubtract(p2, stage)))
46
+ }
33
47
 
34
- editor.selection = new Set(
35
- editor.nodes.values().filter(({ ref }) => {
36
- const node = ref?.getBoundingClientRect()
37
- if (!node) throw Error("node.ref is null")
38
- return !(
39
- node.right <= left || // node is left of selection
40
- node.left >= right || // node is right of selection
41
- node.bottom <= top || // node is above selection
42
- node.top >= bottom // node is below selection
43
- )
44
- }),
45
- )
48
+ const selection = editor.pages
49
+ .values()
50
+ .toArray()
51
+ .flatMap((page) => {
52
+ const p1 = cursorPosition(dragAnchor.current, page)
53
+ const p2 = cursorPosition(event, page)
54
+ const selection = box(rect(p1, p2))
55
+ return page.nodes
56
+ .values()
57
+ .toArray()
58
+ .filter((node) => boxIntersects(selection, box(node)))
59
+ })
46
60
 
47
- div.style.width = `${width}px`
48
- div.style.height = `${height}px`
49
- div.style.transform = `translate(${tx}px, ${ty}px)`
50
- div.style.display = "block"
61
+ editor.selection = new Set(selection)
51
62
  },
52
63
  onCancel() {
53
64
  editor.selection = new Set() // click away
@@ -55,10 +66,7 @@ export function useSelector(props: { ref: React.RefObject<HTMLDivElement | null>
55
66
  },
56
67
  onEnd() {
57
68
  editor.action = {}
58
-
59
- const div = props.ref.current
60
- if (!div) throw Error("selector div ref is null")
61
- div.style.display = "none"
69
+ view(null)
62
70
  },
63
71
  })
64
72
  }
@@ -1,4 +1,4 @@
1
- import type { Rect } from "../../model/geometry"
1
+ import { type Rect, box, boxBounds, rect } from "../../model/geometry/math"
2
2
  import type { Node } from "../../model/node"
3
3
  import type { Page } from "../../model/page"
4
4
  import { useEditor, usePage } from "../editor"
@@ -17,13 +17,13 @@ export function useSnap() {
17
17
 
18
18
  function ySnap(nodes: Node[], rect: Rect): [number, ...Lines] {
19
19
  const nodelines = nodes.flatMap((n) => {
20
- const { y, height } = n.boundingBox
20
+ const { y, height } = boxBounds(box(n))
21
21
  return [y, y + height]
22
22
  })
23
23
 
24
24
  const hlines = [0, page.height, ...nodelines]
25
- const top = hlines.find(shouldSnap(rect.y))
26
- const bottom = hlines.find(shouldSnap(rect.y + rect.height))
25
+ const top = hlines.find(shouldSnap(rect.top))
26
+ const bottom = hlines.find(shouldSnap(rect.bottom))
27
27
 
28
28
  if (isN(top) && isN(bottom)) {
29
29
  return [
@@ -46,15 +46,15 @@ export function useSnap() {
46
46
  return [rect.y]
47
47
  }
48
48
 
49
- function xSnap(nodes: Node[], box: Rect): [number, ...Lines] {
49
+ function xSnap(nodes: Node[], rect: Rect): [number, ...Lines] {
50
50
  const nodelines = nodes.flatMap((n) => {
51
- const { x, width } = n.boundingBox
51
+ const { x, width } = boxBounds(box(n))
52
52
  return [x, x + width]
53
53
  })
54
54
 
55
55
  const hlines = [0, page.width, ...nodelines]
56
- const left = hlines.find(shouldSnap(box.x))
57
- const right = hlines.find(shouldSnap(box.x + box.width))
56
+ const left = hlines.find(shouldSnap(rect.left))
57
+ const right = hlines.find(shouldSnap(rect.right))
58
58
 
59
59
  if (isN(left) && isN(right)) {
60
60
  return [left, { x: left - 1 }, { x: right }]
@@ -65,16 +65,16 @@ export function useSnap() {
65
65
  }
66
66
 
67
67
  if (isN(right)) {
68
- return [right - box.width, { x: right }]
68
+ return [right - rect.width, { x: right }]
69
69
  }
70
70
 
71
- return [box.x]
71
+ return [rect.x]
72
72
  }
73
73
 
74
- return function snap(snap: boolean, rect: Rect): Rect {
74
+ return function snap(snap: boolean, r: Rect): Rect {
75
75
  if (!snap) {
76
76
  page.snapLines = []
77
- return rect
77
+ return r
78
78
  }
79
79
 
80
80
  const nodes = page.nodes
@@ -82,16 +82,16 @@ export function useSnap() {
82
82
  .filter((node) => !editor.selection.has(node))
83
83
  .toArray()
84
84
 
85
- const [left, ...hlines] = xSnap(nodes, rect)
86
- const [top, ...vlines] = ySnap(nodes, rect)
85
+ const [x, ...hlines] = xSnap(nodes, r)
86
+ const [y, ...vlines] = ySnap(nodes, r)
87
87
 
88
88
  page.snapLines = [...vlines, ...hlines]
89
89
 
90
- return {
91
- x: left,
92
- y: top,
93
- width: rect.width,
94
- height: rect.height,
95
- }
90
+ return rect({
91
+ x,
92
+ y,
93
+ width: r.width,
94
+ height: r.height,
95
+ })
96
96
  }
97
97
  }
@@ -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
+ }