@lazlon-platform/html-editor 0.2.2 → 0.3.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/model/geometry.ts +65 -0
- package/lib/model/index.ts +2 -0
- package/lib/model/node/shape/arrow.ts +50 -0
- package/lib/model/node/shape/ellipse.ts +26 -0
- package/lib/model/node/shape/polygon.ts +9 -74
- package/lib/ui/index.ts +2 -0
- package/lib/ui/node/ArrowContent.tsx +60 -0
- package/lib/ui/node/EllipseContent.tsx +49 -0
- package/package.json +1 -1
package/lib/model/geometry.ts
CHANGED
|
@@ -153,3 +153,68 @@ export function angle(center: Point, a: Point, b: Point): number {
|
|
|
153
153
|
const angB = radToDeg(Math.atan2(b.y - center.y, b.x - center.x))
|
|
154
154
|
return Math.round(((((angA + angB + 180) % 360) + 360) % 360) - 180)
|
|
155
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
|
+
}
|
package/lib/model/index.ts
CHANGED
|
@@ -5,6 +5,8 @@ export { EditableNode, type EditableNodeProps } from "./node/editable"
|
|
|
5
5
|
export { FormattableNode, type FormattableNodeProps } from "./node/formattable"
|
|
6
6
|
export { GroupNode, type GroupNodeProps } from "./node/group"
|
|
7
7
|
export { ImageNode, type ImageNodeProps } from "./node/image"
|
|
8
|
+
export { ArrowNode, type ArrowNodeProps } from "./node/shape/arrow"
|
|
9
|
+
export { EllipseNode, type EllipseNodeProps } from "./node/shape/ellipse"
|
|
8
10
|
export { PolygonNode, type PolygonNodeProps } from "./node/shape/polygon"
|
|
9
11
|
export { ShapeNode, type ShapeNodeProps } from "./node/shape/shape"
|
|
10
12
|
export { TextNode, type TextNodeProps } from "./node/text"
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { computed, state } from "react-bolt"
|
|
2
|
+
import type { Editor } from "../../editor"
|
|
3
|
+
import type { SerializedNode } from "../../node"
|
|
4
|
+
import type { Page } from "../../page"
|
|
5
|
+
import { ShapeNode, type ShapeNodeProps } from "./shape"
|
|
6
|
+
import { roundedPathData } from "../../geometry"
|
|
7
|
+
|
|
8
|
+
export type ArrowNodeProps = ShapeNodeProps & Partial<Pick<ArrowNode, "roundness">>
|
|
9
|
+
|
|
10
|
+
export class ArrowNode extends ShapeNode {
|
|
11
|
+
get name() {
|
|
12
|
+
return "arrow"
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
@state accessor roundness: number
|
|
16
|
+
|
|
17
|
+
constructor(editor: Editor, page: Page, { roundness = 0, ...props }: ArrowNodeProps) {
|
|
18
|
+
super(editor, page, props)
|
|
19
|
+
this.roundness = roundness
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
props(): ArrowNodeProps {
|
|
23
|
+
return {
|
|
24
|
+
...super.props(),
|
|
25
|
+
roundness: this.roundness,
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
serialize(): SerializedNode<this["name"], ArrowNodeProps> {
|
|
30
|
+
return super.serialize()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
@computed get svgPathData() {
|
|
34
|
+
const { width, height } = this
|
|
35
|
+
const bodyRight = width * 0.68
|
|
36
|
+
const inset = height * 0.28
|
|
37
|
+
|
|
38
|
+
const arrowPoints = [
|
|
39
|
+
{ x: 0, y: inset },
|
|
40
|
+
{ x: bodyRight, y: inset },
|
|
41
|
+
{ x: bodyRight, y: 0 },
|
|
42
|
+
{ x: width, y: height / 2 },
|
|
43
|
+
{ x: bodyRight, y: height },
|
|
44
|
+
{ x: bodyRight, y: height - inset },
|
|
45
|
+
{ x: 0, y: height - inset },
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
return roundedPathData(arrowPoints, this.roundness)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Editor } from "../../editor"
|
|
2
|
+
import type { SerializedNode } from "../../node"
|
|
3
|
+
import type { Page } from "../../page"
|
|
4
|
+
import { ShapeNode, type ShapeNodeProps } from "./shape"
|
|
5
|
+
|
|
6
|
+
export type EllipseNodeProps = ShapeNodeProps
|
|
7
|
+
|
|
8
|
+
export class EllipseNode extends ShapeNode {
|
|
9
|
+
get name() {
|
|
10
|
+
return "ellipse"
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
constructor(editor: Editor, page: Page, props: EllipseNodeProps) {
|
|
14
|
+
super(editor, page, props)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
props(): EllipseNodeProps {
|
|
18
|
+
return {
|
|
19
|
+
...super.props(),
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
serialize(): SerializedNode<this["name"], EllipseNodeProps> {
|
|
24
|
+
return super.serialize()
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { computed, state } from "react-bolt"
|
|
2
2
|
import type { Editor } from "../../editor"
|
|
3
|
-
import { add, clamp, dist, dot, mul, norm, sub, tidyFloat } from "../../geometry"
|
|
4
3
|
import type { SerializedNode } from "../../node"
|
|
5
4
|
import type { Page } from "../../page"
|
|
6
5
|
import { ShapeNode, type ShapeNodeProps } from "./shape"
|
|
6
|
+
import { regularPolygonPoints, roundedPathData } from "../../geometry"
|
|
7
7
|
|
|
8
8
|
export type PolygonNodeProps = ShapeNodeProps &
|
|
9
9
|
Partial<
|
|
@@ -96,78 +96,13 @@ export class PolygonNode extends ShapeNode {
|
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
@computed get svgPathData() {
|
|
99
|
-
return
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
99
|
+
return roundedPathData(
|
|
100
|
+
regularPolygonPoints({
|
|
101
|
+
width: this.width,
|
|
102
|
+
height: this.height,
|
|
103
|
+
sides: this.sides,
|
|
104
|
+
}),
|
|
105
|
+
this._roundness,
|
|
106
|
+
)
|
|
105
107
|
}
|
|
106
108
|
}
|
|
107
|
-
|
|
108
|
-
function pathData(props: {
|
|
109
|
-
width: number
|
|
110
|
-
height: number
|
|
111
|
-
sides: number
|
|
112
|
-
roundness: number
|
|
113
|
-
}): string {
|
|
114
|
-
const { width, height, sides, roundness } = props
|
|
115
|
-
const rotation = sides % 2 === 0 ? Math.PI / sides : 0
|
|
116
|
-
const cx = width / 2
|
|
117
|
-
const cy = height / 2
|
|
118
|
-
const rx = width / 2
|
|
119
|
-
const ry = height / 2
|
|
120
|
-
|
|
121
|
-
// vertices (raw)
|
|
122
|
-
const p = Array.from({ length: sides }, (_, i) => {
|
|
123
|
-
const angle = (i * Math.PI * 2) / sides - Math.PI / 2 + rotation
|
|
124
|
-
return {
|
|
125
|
-
x: cx + rx * Math.cos(angle),
|
|
126
|
-
y: cy + ry * Math.sin(angle),
|
|
127
|
-
}
|
|
128
|
-
})
|
|
129
|
-
|
|
130
|
-
const cmds = Array.from({ length: sides }, (_, i) => {
|
|
131
|
-
const prev = p[(i - 1 + sides) % sides]
|
|
132
|
-
const curr = p[i]
|
|
133
|
-
const next = p[(i + 1) % sides]
|
|
134
|
-
const cmd = i === 0 ? "M" : "L"
|
|
135
|
-
|
|
136
|
-
const v1 = norm(sub(prev, curr)) // direction from curr towards prev
|
|
137
|
-
const v2 = norm(sub(next, curr)) // direction from curr towards next
|
|
138
|
-
|
|
139
|
-
// interior angle between the two edges at curr
|
|
140
|
-
const cosTheta = clamp(dot(v1, v2), -1, 1)
|
|
141
|
-
const theta = Math.acos(cosTheta)
|
|
142
|
-
|
|
143
|
-
// degenerate / nearly straight: just treat as sharp
|
|
144
|
-
if (!isFinite(theta) || theta < 1e-6) {
|
|
145
|
-
const x = tidyFloat(curr.x)
|
|
146
|
-
const y = tidyFloat(curr.y)
|
|
147
|
-
return `${cmd} ${x} ${y}`
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
const dPrev = dist(curr, prev)
|
|
151
|
-
const dNext = dist(curr, next)
|
|
152
|
-
|
|
153
|
-
// how far to inset along each adjacent edge for radius r
|
|
154
|
-
const tIdeal = roundness / Math.tan(theta / 2)
|
|
155
|
-
|
|
156
|
-
// cannot exceed half of either edge
|
|
157
|
-
const t = Math.min(tIdeal, dPrev / 2, dNext / 2)
|
|
158
|
-
|
|
159
|
-
// if we had to clamp t, reduce radius so the arc still matches geometry
|
|
160
|
-
const rEff = t * Math.tan(theta / 2)
|
|
161
|
-
|
|
162
|
-
const p1 = add(curr, mul(v1, t)) // point on edge towards prev
|
|
163
|
-
const p2 = add(curr, mul(v2, t)) // point on edge towards next
|
|
164
|
-
|
|
165
|
-
// Arc to p2 with radius rEff.
|
|
166
|
-
// Use the small arc (0) and let SVG choose sweep; for convex polygons this is fine.
|
|
167
|
-
const arc = `A ${tidyFloat(rEff)} ${tidyFloat(rEff)} 0 0 1 ${tidyFloat(p2.x)} ${tidyFloat(p2.y)}`
|
|
168
|
-
|
|
169
|
-
return `${cmd} ${tidyFloat(p1.x)} ${tidyFloat(p1.y)} ${arc}`
|
|
170
|
-
})
|
|
171
|
-
|
|
172
|
-
return cmds.concat("Z").join(" ")
|
|
173
|
-
}
|
package/lib/ui/index.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export { getReadableForeground } from "./colors"
|
|
2
2
|
export { getColors, getFontFamilies } from "./extractor"
|
|
3
|
+
export { ArrowContent } from "./node/ArrowContent"
|
|
3
4
|
export { EditableContent } from "./node/EditableContent"
|
|
5
|
+
export { EllipseContent } from "./node/EllipseContent"
|
|
4
6
|
export { GroupContent } from "./node/GroupContent"
|
|
5
7
|
export { ImageContent } from "./node/ImageContent"
|
|
6
8
|
export { NodeView } from "./node/NodeView"
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import clsx from "clsx"
|
|
2
|
+
import { useId } from "react"
|
|
3
|
+
import { useStore } from "react-bolt"
|
|
4
|
+
import { type ArrowNode } from "../../model/node/shape/arrow"
|
|
5
|
+
import { EditableContent } from "./EditableContent"
|
|
6
|
+
|
|
7
|
+
export function ArrowContent(props: { node: ArrowNode; isStatic?: boolean }) {
|
|
8
|
+
const maskId = useId()
|
|
9
|
+
const { node, isStatic } = props
|
|
10
|
+
|
|
11
|
+
const [valign, halign, background, borderWidth, borderColor] = useStore(
|
|
12
|
+
node,
|
|
13
|
+
"valign",
|
|
14
|
+
"halign",
|
|
15
|
+
"background",
|
|
16
|
+
"borderWidth",
|
|
17
|
+
"borderColor",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
const [w, h, d] = useStore(node, "width", "height", "svgPathData")
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<div className="relative size-full">
|
|
24
|
+
<svg width={w} height={h} className="absolute inset-0">
|
|
25
|
+
<defs>
|
|
26
|
+
<mask id={maskId} maskUnits="userSpaceOnUse">
|
|
27
|
+
<path d={d} fill="white" />
|
|
28
|
+
</mask>
|
|
29
|
+
</defs>
|
|
30
|
+
|
|
31
|
+
<path d={d} fill={background} />
|
|
32
|
+
<path
|
|
33
|
+
d={d}
|
|
34
|
+
fill="none"
|
|
35
|
+
stroke={borderColor}
|
|
36
|
+
strokeWidth={borderWidth}
|
|
37
|
+
mask={`url(#${maskId})`}
|
|
38
|
+
/>
|
|
39
|
+
</svg>
|
|
40
|
+
<div
|
|
41
|
+
className={clsx(
|
|
42
|
+
"flex size-full",
|
|
43
|
+
valign === "top" && "items-start",
|
|
44
|
+
valign === "center" && "items-center",
|
|
45
|
+
valign === "bottom" && "items-end",
|
|
46
|
+
halign === "left" && "justify-start text-left",
|
|
47
|
+
halign === "center" && "justify-center text-center",
|
|
48
|
+
halign === "right" && "justify-end text-right",
|
|
49
|
+
halign === "justify" && "w-full text-justify",
|
|
50
|
+
)}
|
|
51
|
+
>
|
|
52
|
+
<EditableContent
|
|
53
|
+
isStatic={isStatic}
|
|
54
|
+
node={node}
|
|
55
|
+
className={clsx(halign === "justify" && "w-full")}
|
|
56
|
+
/>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import clsx from "clsx"
|
|
2
|
+
import { useStore } from "react-bolt"
|
|
3
|
+
import { type EllipseNode } from "../../model/node/shape/ellipse"
|
|
4
|
+
import { EditableContent } from "./EditableContent"
|
|
5
|
+
|
|
6
|
+
export function EllipseContent(props: { node: EllipseNode; isStatic?: boolean }) {
|
|
7
|
+
const { node, isStatic } = props
|
|
8
|
+
|
|
9
|
+
const [valign, halign, background, borderWidth, borderColor] = useStore(
|
|
10
|
+
node,
|
|
11
|
+
"valign",
|
|
12
|
+
"halign",
|
|
13
|
+
"background",
|
|
14
|
+
"borderWidth",
|
|
15
|
+
"borderColor",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<div className="relative size-full">
|
|
20
|
+
<div
|
|
21
|
+
className="absolute inset-0 size-full"
|
|
22
|
+
onDragStart={(e) => e.preventDefault()}
|
|
23
|
+
style={{
|
|
24
|
+
borderRadius: "50%",
|
|
25
|
+
background,
|
|
26
|
+
border: `${borderWidth}px solid ${borderColor}`,
|
|
27
|
+
}}
|
|
28
|
+
/>
|
|
29
|
+
<div
|
|
30
|
+
className={clsx(
|
|
31
|
+
"flex size-full",
|
|
32
|
+
valign === "top" && "items-start",
|
|
33
|
+
valign === "center" && "items-center",
|
|
34
|
+
valign === "bottom" && "items-end",
|
|
35
|
+
halign === "left" && "justify-start text-left",
|
|
36
|
+
halign === "center" && "justify-center text-center",
|
|
37
|
+
halign === "right" && "justify-end text-right",
|
|
38
|
+
halign === "justify" && "w-full text-justify",
|
|
39
|
+
)}
|
|
40
|
+
>
|
|
41
|
+
<EditableContent
|
|
42
|
+
isStatic={isStatic}
|
|
43
|
+
node={node}
|
|
44
|
+
className={clsx(halign === "justify" && "w-full")}
|
|
45
|
+
/>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
)
|
|
49
|
+
}
|