@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.
- package/lib/hooks/actions.ts +4 -3
- 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/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 +14 -10
- 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/selection.ts +9 -26
- package/package.json +34 -34
- package/lib/model/geometry.ts +0 -247
|
@@ -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,8 +1,8 @@
|
|
|
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
7
|
export type TextNodeProps = EditableNodeProps & Partial<Pick<TextNode, "halign" | "size">>
|
|
8
8
|
|
|
@@ -15,18 +15,16 @@ export class TextNode extends EditableNode {
|
|
|
15
15
|
@state accessor size: number = 16
|
|
16
16
|
@state accessor contentHeight: number = 24
|
|
17
17
|
|
|
18
|
+
private observer = new ResizeObserver(([entry]) => {
|
|
19
|
+
this.contentHeight = entry.target.clientHeight
|
|
20
|
+
})
|
|
21
|
+
|
|
18
22
|
@computed get height(): number {
|
|
19
23
|
return this.contentHeight
|
|
20
24
|
}
|
|
21
25
|
|
|
22
|
-
set height(
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
if (this.contentHeight > 0) {
|
|
26
|
-
this.size = (this.size * target) / this.contentHeight
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
this.contentHeight = target
|
|
26
|
+
set height(_) {
|
|
27
|
+
// no-op: TextNode's height can be set using its font-size
|
|
30
28
|
}
|
|
31
29
|
|
|
32
30
|
constructor(editor: Editor, page: Page, { halign, size, ...props }: TextNodeProps) {
|
|
@@ -42,7 +40,13 @@ export class TextNode extends EditableNode {
|
|
|
42
40
|
|
|
43
41
|
set contentRef(ref: HTMLElement | null) {
|
|
44
42
|
super.contentRef = ref
|
|
45
|
-
|
|
43
|
+
|
|
44
|
+
if (ref) {
|
|
45
|
+
this.observer.observe(ref)
|
|
46
|
+
this.contentHeight = ref.clientHeight
|
|
47
|
+
} else {
|
|
48
|
+
this.observer.disconnect()
|
|
49
|
+
}
|
|
46
50
|
}
|
|
47
51
|
|
|
48
52
|
@computed get contentRef() {
|
package/lib/model/node.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { computed, state } from "react-bolt"
|
|
2
2
|
import type { Editor } from "./editor"
|
|
3
|
-
import { rotatedAABB } from "./geometry"
|
|
4
3
|
import type { Page } from "./page"
|
|
4
|
+
import { deg, type Deg } from "./geometry/math"
|
|
5
5
|
|
|
6
6
|
export type Schema = Map<string, typeof Node>
|
|
7
7
|
|
|
@@ -31,7 +31,7 @@ export abstract class Node {
|
|
|
31
31
|
@state private accessor _width: number
|
|
32
32
|
@state private accessor _height: number
|
|
33
33
|
|
|
34
|
-
@state accessor rotation:
|
|
34
|
+
@state accessor rotation: Deg
|
|
35
35
|
@state accessor role: string | null
|
|
36
36
|
@state accessor locked: boolean
|
|
37
37
|
@state accessor x: number
|
|
@@ -65,7 +65,7 @@ export abstract class Node {
|
|
|
65
65
|
this._height = props.height
|
|
66
66
|
this.role = props.role?.[0] || null
|
|
67
67
|
this.locked = props.locked ?? false
|
|
68
|
-
this.rotation = props.rotation ?? 0
|
|
68
|
+
this.rotation = props.rotation ?? deg(0)
|
|
69
69
|
this.css = props.css ?? ""
|
|
70
70
|
}
|
|
71
71
|
|
|
@@ -94,8 +94,4 @@ export abstract class Node {
|
|
|
94
94
|
props: this.props(),
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
|
-
|
|
98
|
-
@computed get boundingBox() {
|
|
99
|
-
return rotatedAABB(this, this.rotation)
|
|
100
|
-
}
|
|
101
97
|
}
|
package/lib/model/page.ts
CHANGED
|
@@ -15,6 +15,7 @@ export interface SerializedPage extends Omit<PageProps, "nodes"> {
|
|
|
15
15
|
|
|
16
16
|
export class Page {
|
|
17
17
|
readonly id: string
|
|
18
|
+
readonly editor: Editor
|
|
18
19
|
|
|
19
20
|
ref: HTMLElement | null = null
|
|
20
21
|
|
|
@@ -28,7 +29,8 @@ export class Page {
|
|
|
28
29
|
| { y: number; x?: number; w?: number; h?: never } // vertical
|
|
29
30
|
> = []
|
|
30
31
|
|
|
31
|
-
constructor(
|
|
32
|
+
constructor(editor: Editor, props: PageProps) {
|
|
33
|
+
this.editor = editor
|
|
32
34
|
this.id = props.id
|
|
33
35
|
this.background = props.background ?? "#ffffff"
|
|
34
36
|
this.width = props.width ?? 841
|
package/lib/ui/node/NodeView.tsx
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { useEffect } from "react"
|
|
2
2
|
import { useStore } from "react-bolt"
|
|
3
3
|
import { useEditor } from "../../hooks/editor"
|
|
4
|
+
import { isLoaded, loadFont } from "../../lib/googleFonts"
|
|
4
5
|
import { Node } from "../../model/node"
|
|
5
6
|
import { getFontFamilies } from "../../ui/extractor"
|
|
6
|
-
import { isLoaded, loadFont } from "../../lib/googleFonts"
|
|
7
|
-
import { isPointerInSelectionRect } from "../selection"
|
|
8
7
|
|
|
9
8
|
export function NodeView(props: {
|
|
10
9
|
node: Node
|
|
@@ -49,17 +48,6 @@ export function NodeView(props: {
|
|
|
49
48
|
height: `${height}px`,
|
|
50
49
|
transform: `translate(${x}px,${y}px) rotate(${rotation ?? 0}deg)`,
|
|
51
50
|
}}
|
|
52
|
-
onDragStart={(e) => e.preventDefault()}
|
|
53
|
-
onPointerDown={(event) => {
|
|
54
|
-
if (event.button !== 0) return
|
|
55
|
-
|
|
56
|
-
const { selection } = editor
|
|
57
|
-
if (selection.size === 0 || event.shiftKey) {
|
|
58
|
-
editor.selection = new Set([...selection, node])
|
|
59
|
-
} else if (!isPointerInSelectionRect(selection, event)) {
|
|
60
|
-
editor.selection = new Set([node])
|
|
61
|
-
}
|
|
62
|
-
}}
|
|
63
51
|
>
|
|
64
52
|
{children}
|
|
65
53
|
{css && <style>{`#${id} { ${css} }`}</style>}
|
package/lib/ui/selection.ts
CHANGED
|
@@ -1,38 +1,21 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { box, boxBounds, rect, type Box, type Rect } from "../model/geometry/math"
|
|
2
2
|
import type { Node } from "../model/node"
|
|
3
3
|
|
|
4
|
-
export function
|
|
4
|
+
export function selectionDOMRect(selection: Set<Node>): Rect {
|
|
5
5
|
const rects = selection
|
|
6
6
|
.values()
|
|
7
7
|
.map((node) => node.ref)
|
|
8
8
|
.filter((dom) => dom instanceof HTMLElement)
|
|
9
9
|
.map((dom) => dom.getBoundingClientRect())
|
|
10
|
-
.toArray()
|
|
11
10
|
|
|
12
|
-
return
|
|
11
|
+
return rect(...rects)
|
|
13
12
|
}
|
|
14
13
|
|
|
15
|
-
export function
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
return boundingBox(rects)
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export function isPointerInSelectionRect(
|
|
25
|
-
selection: Set<Node>,
|
|
26
|
-
event: React.PointerEvent,
|
|
27
|
-
) {
|
|
28
|
-
const rect = getTargetDOMRect(selection)
|
|
29
|
-
const right = rect.x + rect.width
|
|
30
|
-
const bottom = rect.y + rect.height
|
|
14
|
+
export function selectionBox(selection: Set<Node>): Box {
|
|
15
|
+
if (selection.size === 1) {
|
|
16
|
+
const [node] = selection
|
|
17
|
+
return box(node)
|
|
18
|
+
}
|
|
31
19
|
|
|
32
|
-
return (
|
|
33
|
-
event.clientX >= rect.x &&
|
|
34
|
-
event.clientX <= right &&
|
|
35
|
-
event.clientY >= rect.y &&
|
|
36
|
-
event.clientY <= bottom
|
|
37
|
-
)
|
|
20
|
+
return box(boxBounds(...selection.values().map(box)))
|
|
38
21
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lazlon-platform/html-editor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"files": [
|
|
6
6
|
"lib"
|
|
@@ -8,55 +8,55 @@
|
|
|
8
8
|
"exports": {
|
|
9
9
|
"./ui": "./lib/ui/index.ts",
|
|
10
10
|
"./model": "./lib/model/index.ts",
|
|
11
|
-
"./model/geometry": "./lib/model/geometry.ts",
|
|
11
|
+
"./model/geometry": "./lib/model/geometry/math.ts",
|
|
12
12
|
"./hooks": "./lib/hooks/index.ts",
|
|
13
13
|
"./googleFonts": "./lib/lib/googleFonts.ts"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@tiptap/core": "^3.
|
|
17
|
-
"@tiptap/extension-bold": "^3.
|
|
18
|
-
"@tiptap/extension-document": "^3.
|
|
19
|
-
"@tiptap/extension-hard-break": "^3.
|
|
20
|
-
"@tiptap/extension-italic": "^3.
|
|
21
|
-
"@tiptap/extension-paragraph": "^3.
|
|
22
|
-
"@tiptap/extension-strike": "^3.
|
|
23
|
-
"@tiptap/extension-subscript": "^3.
|
|
24
|
-
"@tiptap/extension-superscript": "^3.
|
|
25
|
-
"@tiptap/extension-text": "^3.
|
|
26
|
-
"@tiptap/extension-text-style": "^3.
|
|
27
|
-
"@tiptap/extension-underline": "^3.
|
|
28
|
-
"@tiptap/extensions": "^3.
|
|
29
|
-
"@tiptap/pm": "^3.
|
|
30
|
-
"@tiptap/react": "^3.
|
|
16
|
+
"@tiptap/core": "^3.23.1",
|
|
17
|
+
"@tiptap/extension-bold": "^3.23.1",
|
|
18
|
+
"@tiptap/extension-document": "^3.23.1",
|
|
19
|
+
"@tiptap/extension-hard-break": "^3.23.1",
|
|
20
|
+
"@tiptap/extension-italic": "^3.23.1",
|
|
21
|
+
"@tiptap/extension-paragraph": "^3.23.1",
|
|
22
|
+
"@tiptap/extension-strike": "^3.23.1",
|
|
23
|
+
"@tiptap/extension-subscript": "^3.23.1",
|
|
24
|
+
"@tiptap/extension-superscript": "^3.23.1",
|
|
25
|
+
"@tiptap/extension-text": "^3.23.1",
|
|
26
|
+
"@tiptap/extension-text-style": "^3.23.1",
|
|
27
|
+
"@tiptap/extension-underline": "^3.23.1",
|
|
28
|
+
"@tiptap/extensions": "^3.23.1",
|
|
29
|
+
"@tiptap/pm": "^3.23.1",
|
|
30
|
+
"@tiptap/react": "^3.23.1",
|
|
31
31
|
"clsx": "^2.1.1",
|
|
32
|
-
"es-toolkit": "^1.
|
|
33
|
-
"react": "^19.2.
|
|
34
|
-
"react-aria-components": "^1.
|
|
32
|
+
"es-toolkit": "^1.46.1",
|
|
33
|
+
"react": "^19.2.6",
|
|
34
|
+
"react-aria-components": "^1.17.0",
|
|
35
35
|
"react-bolt": "^1.4.1",
|
|
36
|
-
"react-dom": "^19.2.
|
|
37
|
-
"react-hotkeys-hook": "^5.2
|
|
38
|
-
"tailwindcss": "^4.
|
|
36
|
+
"react-dom": "^19.2.6",
|
|
37
|
+
"react-hotkeys-hook": "^5.3.2",
|
|
38
|
+
"tailwindcss": "^4.3.0"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
|
-
"@eslint/js": "^9.39.
|
|
42
|
-
"@tailwindcss/vite": "^4.
|
|
41
|
+
"@eslint/js": "^9.39.4",
|
|
42
|
+
"@tailwindcss/vite": "^4.3.0",
|
|
43
43
|
"@testing-library/jest-dom": "^6.9.1",
|
|
44
44
|
"@testing-library/react": "^16.3.2",
|
|
45
|
-
"@types/node": "^24.
|
|
45
|
+
"@types/node": "^24.12.3",
|
|
46
46
|
"@types/react": "^19.2.7",
|
|
47
47
|
"@types/react-dom": "^19.2.3",
|
|
48
|
-
"@vitejs/plugin-react": "^5.
|
|
49
|
-
"@vitest/coverage-v8": "^4.1.
|
|
50
|
-
"eslint": "^9.39.
|
|
51
|
-
"eslint-plugin-react-hooks": "^7.
|
|
48
|
+
"@vitejs/plugin-react": "^5.2.0",
|
|
49
|
+
"@vitest/coverage-v8": "^4.1.5",
|
|
50
|
+
"eslint": "^9.39.4",
|
|
51
|
+
"eslint-plugin-react-hooks": "^7.1.1",
|
|
52
52
|
"eslint-plugin-react-refresh": "^0.4.24",
|
|
53
53
|
"globals": "^16.5.0",
|
|
54
|
-
"happy-dom": "^20.
|
|
54
|
+
"happy-dom": "^20.9.0",
|
|
55
55
|
"typescript": "~5.9.3",
|
|
56
|
-
"typescript-eslint": "^8.
|
|
57
|
-
"vite": "^7.3.
|
|
56
|
+
"typescript-eslint": "^8.59.2",
|
|
57
|
+
"vite": "^7.3.3",
|
|
58
58
|
"vite-tsconfig-paths": "^6.1.1",
|
|
59
|
-
"vitest": "^4.1.
|
|
59
|
+
"vitest": "^4.1.5"
|
|
60
60
|
},
|
|
61
61
|
"prettier": {
|
|
62
62
|
"semi": false,
|
package/lib/model/geometry.ts
DELETED
|
@@ -1,247 +0,0 @@
|
|
|
1
|
-
export interface Point {
|
|
2
|
-
x: number
|
|
3
|
-
y: number
|
|
4
|
-
}
|
|
5
|
-
|
|
6
|
-
export interface Rect extends Point {
|
|
7
|
-
width: number
|
|
8
|
-
height: number
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
function rectCorners({ x, y, width, height }: Rect): Point[] {
|
|
12
|
-
return [
|
|
13
|
-
{ x, y },
|
|
14
|
-
{ x: x + width, y },
|
|
15
|
-
{ x, y: y + height },
|
|
16
|
-
{ x: x + width, y: y + height },
|
|
17
|
-
]
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* @example
|
|
22
|
-
* ```ts
|
|
23
|
-
* const center = { x: 0, y: 0 }
|
|
24
|
-
* const P = { x: 2, y: 0 }
|
|
25
|
-
* rotate(P, center, 90) // P' = { x: 0, y: 2 }
|
|
26
|
-
* ```
|
|
27
|
-
*
|
|
28
|
-
* P
|
|
29
|
-
* ●
|
|
30
|
-
* │
|
|
31
|
-
* │ 90°
|
|
32
|
-
* ●─────● P'
|
|
33
|
-
*/
|
|
34
|
-
export function rotatePoint(p: Point, center: Point, deg: number): Point {
|
|
35
|
-
const rad = (deg * Math.PI) / 180
|
|
36
|
-
const cos = Math.cos(rad)
|
|
37
|
-
const sin = Math.sin(rad)
|
|
38
|
-
const dx = p.x - center.x
|
|
39
|
-
const dy = p.y - center.y
|
|
40
|
-
|
|
41
|
-
return {
|
|
42
|
-
x: center.x + dx * cos - dy * sin,
|
|
43
|
-
y: center.y + dx * sin + dy * cos,
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export function boundingBox(args: Point[] | Rect[]): Rect {
|
|
48
|
-
const points: Point[] = args.flatMap((arg) =>
|
|
49
|
-
"width" in arg && "height" in arg ? rectCorners(arg) : arg,
|
|
50
|
-
)
|
|
51
|
-
|
|
52
|
-
let minX = Infinity
|
|
53
|
-
let minY = Infinity
|
|
54
|
-
let maxX = -Infinity
|
|
55
|
-
let maxY = -Infinity
|
|
56
|
-
|
|
57
|
-
for (const p of points) {
|
|
58
|
-
if (p.x < minX) minX = p.x
|
|
59
|
-
if (p.y < minY) minY = p.y
|
|
60
|
-
if (p.x > maxX) maxX = p.x
|
|
61
|
-
if (p.y > maxY) maxY = p.y
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
return {
|
|
65
|
-
x: minX,
|
|
66
|
-
y: minY,
|
|
67
|
-
width: maxX - minX,
|
|
68
|
-
height: maxY - minY,
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export function rectCenter({ x, y, width, height }: Rect): Point {
|
|
73
|
-
return {
|
|
74
|
-
x: x + width / 2,
|
|
75
|
-
y: y + height / 2,
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* axis-aligned bounding box (AABB) of the rotated rectangle
|
|
81
|
-
*/
|
|
82
|
-
export function rotatedAABB(rect: Rect, deg: number): Rect {
|
|
83
|
-
return boundingBox(rectCorners(rect).map((c) => rotatePoint(c, rectCenter(rect), deg)))
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
export function dist(a: Point, b: Point) {
|
|
87
|
-
const dx = a.x - b.x
|
|
88
|
-
const dy = a.y - b.y
|
|
89
|
-
return Math.hypot(dx, dy)
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
export function sub(a: Point, b: Point) {
|
|
93
|
-
return {
|
|
94
|
-
x: a.x - b.x,
|
|
95
|
-
y: a.y - b.y,
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
export function add(a: Point, b: Point) {
|
|
100
|
-
return {
|
|
101
|
-
x: a.x + b.x,
|
|
102
|
-
y: a.y + b.y,
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
export function mul(p: Point, n: number) {
|
|
107
|
-
return {
|
|
108
|
-
x: p.x * n,
|
|
109
|
-
y: p.y * n,
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export function norm(p: Point) {
|
|
114
|
-
const d = Math.hypot(p.x, p.y)
|
|
115
|
-
return {
|
|
116
|
-
x: d === 0 ? 0 : p.x / d,
|
|
117
|
-
y: d === 0 ? 0 : p.y / d,
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
export function dot(a: Point, b: Point) {
|
|
122
|
-
return a.x * b.x + a.y * b.y
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
export function clamp(n: number, lo: number, hi: number) {
|
|
126
|
-
return Math.max(lo, Math.min(hi, n))
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
export function tidyFloat(v: number) {
|
|
130
|
-
return Math.abs(v) < 1e-10 ? 0 : +v.toFixed(3)
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
export function radToDeg(rad: number): number {
|
|
134
|
-
return (rad * 180) / Math.PI
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* @example
|
|
139
|
-
* ```ts
|
|
140
|
-
* const center = { x: 0, y: 0 }
|
|
141
|
-
* const a = { x: 0, y: 2 }
|
|
142
|
-
* const b = { x: 2, y: 0 }
|
|
143
|
-
* angle(center, a, b) // 90
|
|
144
|
-
* ```
|
|
145
|
-
* a
|
|
146
|
-
* ●
|
|
147
|
-
* │
|
|
148
|
-
* │ 90°
|
|
149
|
-
* ●─────● b
|
|
150
|
-
*/
|
|
151
|
-
export function angle(center: Point, a: Point, b: Point): number {
|
|
152
|
-
const angA = radToDeg(Math.atan2(a.y - center.y, a.x - center.x))
|
|
153
|
-
const angB = radToDeg(Math.atan2(b.y - center.y, b.x - center.x))
|
|
154
|
-
return Math.round(((((angA + angB + 180) % 360) + 360) % 360) - 180)
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
export function roundedPathData(points: Point[], roundness: number): string {
|
|
158
|
-
const count = points.length
|
|
159
|
-
const winding = Math.sign(
|
|
160
|
-
points.reduce((area, curr, i) => {
|
|
161
|
-
const next = points[(i + 1) % count]
|
|
162
|
-
return area + curr.x * next.y - next.x * curr.y
|
|
163
|
-
}, 0),
|
|
164
|
-
)
|
|
165
|
-
|
|
166
|
-
const cmds = Array.from({ length: count }, (_, i) => {
|
|
167
|
-
const prev = points[(i - 1 + count) % count]
|
|
168
|
-
const curr = points[i]
|
|
169
|
-
const next = points[(i + 1) % count]
|
|
170
|
-
const cmd = i === 0 ? "M" : "L"
|
|
171
|
-
|
|
172
|
-
const v1 = norm(sub(prev, curr))
|
|
173
|
-
const v2 = norm(sub(next, curr))
|
|
174
|
-
|
|
175
|
-
const cosTheta = clamp(dot(v1, v2), -1, 1)
|
|
176
|
-
const theta = Math.acos(cosTheta)
|
|
177
|
-
const cornerCross =
|
|
178
|
-
(curr.x - prev.x) * (next.y - curr.y) - (curr.y - prev.y) * (next.x - curr.x)
|
|
179
|
-
|
|
180
|
-
if (!isFinite(theta) || theta < 1e-6 || roundness <= 0) {
|
|
181
|
-
return `${cmd} ${tidyFloat(curr.x)} ${tidyFloat(curr.y)}`
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
const dPrev = dist(curr, prev)
|
|
185
|
-
const dNext = dist(curr, next)
|
|
186
|
-
const tIdeal = roundness / Math.tan(theta / 2)
|
|
187
|
-
const t = Math.min(tIdeal, dPrev / 2, dNext / 2)
|
|
188
|
-
const rEff = t * Math.tan(theta / 2)
|
|
189
|
-
const p1 = add(curr, mul(v1, t))
|
|
190
|
-
const p2 = add(curr, mul(v2, t))
|
|
191
|
-
const isConcave = winding !== 0 && Math.sign(cornerCross) !== winding
|
|
192
|
-
const sweep = isConcave ? 0 : 1
|
|
193
|
-
const arc = `A ${tidyFloat(rEff)} ${tidyFloat(rEff)} 0 0 ${sweep} ${tidyFloat(p2.x)} ${tidyFloat(p2.y)}`
|
|
194
|
-
|
|
195
|
-
return `${cmd} ${tidyFloat(p1.x)} ${tidyFloat(p1.y)} ${arc}`
|
|
196
|
-
})
|
|
197
|
-
|
|
198
|
-
return cmds.concat("Z").join(" ")
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
export function regularPolygonPoints(props: {
|
|
202
|
-
width: number
|
|
203
|
-
height: number
|
|
204
|
-
sides: number
|
|
205
|
-
}): Point[] {
|
|
206
|
-
const { width, height, sides } = props
|
|
207
|
-
const rotation = sides % 2 === 0 ? Math.PI / sides : 0
|
|
208
|
-
const cx = width / 2
|
|
209
|
-
const cy = height / 2
|
|
210
|
-
const rx = width / 2
|
|
211
|
-
const ry = height / 2
|
|
212
|
-
|
|
213
|
-
return Array.from({ length: sides }, (_, i) => {
|
|
214
|
-
const angle = (i * Math.PI * 2) / sides - Math.PI / 2 + rotation
|
|
215
|
-
return {
|
|
216
|
-
x: cx + rx * Math.cos(angle),
|
|
217
|
-
y: cy + ry * Math.sin(angle),
|
|
218
|
-
}
|
|
219
|
-
})
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
export function starPoints(props: {
|
|
223
|
-
width: number
|
|
224
|
-
height: number
|
|
225
|
-
corners: number
|
|
226
|
-
depth: number
|
|
227
|
-
}): Point[] {
|
|
228
|
-
const { width, height, corners, depth } = props
|
|
229
|
-
const cx = width / 2
|
|
230
|
-
const cy = height / 2
|
|
231
|
-
const outerRx = width / 2
|
|
232
|
-
const outerRy = height / 2
|
|
233
|
-
const innerRx = outerRx * depth
|
|
234
|
-
const innerRy = outerRy * depth
|
|
235
|
-
|
|
236
|
-
return Array.from({ length: corners * 2 }, (_, i) => {
|
|
237
|
-
const angle = (i * Math.PI) / corners - Math.PI / 2
|
|
238
|
-
const isOuter = i % 2 === 0
|
|
239
|
-
const rx = isOuter ? outerRx : innerRx
|
|
240
|
-
const ry = isOuter ? outerRy : innerRy
|
|
241
|
-
|
|
242
|
-
return {
|
|
243
|
-
x: cx + rx * Math.cos(angle),
|
|
244
|
-
y: cy + ry * Math.sin(angle),
|
|
245
|
-
}
|
|
246
|
-
})
|
|
247
|
-
}
|