@lazlon-platform/html-editor 0.2.1 → 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.
@@ -3,7 +3,6 @@ import type { Editor, NodeConstructor } from "../model/editor"
3
3
  import type { HistoryAction } from "../model/history"
4
4
  import type { Node, NodeProps } from "../model/node"
5
5
  import { GroupNode } from "../model/node/group"
6
- import { TextNode } from "../model/node/text"
7
6
  import type { Page } from "../model/page"
8
7
  import { flattenNodes } from "../model/traversal"
9
8
  import { getTargetRect } from "../ui/selection"
@@ -57,11 +56,6 @@ export function useAddNodeAction(page?: Page) {
57
56
  undo: ["delete-node", [targetPage.id, [node.id]]],
58
57
  })
59
58
 
60
- if (node instanceof TextNode) {
61
- editor.selection = new Set([node])
62
- setTimeout(() => node.tiptap.commands.focus())
63
- }
64
-
65
59
  return node as InstanceType<N>
66
60
  }
67
61
  }
@@ -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
+ }
@@ -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 pathData({
100
- width: this.width,
101
- height: this.height,
102
- sides: this.sides,
103
- roundness: this._roundness,
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
+ }
@@ -20,7 +20,10 @@ export function EditableContent(props: {
20
20
  const selection = useStore(editor, "selection")
21
21
  const { action } = useStore(editor, "action")
22
22
  const prevContent = useRef(node.tiptap.getJSON())
23
- const [clicked, setClicked] = useState(false)
23
+
24
+ // start with selection.has(node) so that newly added editable nodes can be focused
25
+ const [clicked, setClicked] = useState(selection.has(node))
26
+
24
27
  const onDoubleClick = useDoubleClick(
25
28
  useCallback(() => {
26
29
  setClicked(true)
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lazlon-platform/html-editor",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "lib"