@lazlon-platform/html-editor 0.5.0 → 0.7.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.
Files changed (64) hide show
  1. package/lib/hooks/actions.ts +136 -87
  2. package/lib/hooks/batch.ts +24 -10
  3. package/lib/hooks/index.ts +7 -6
  4. package/lib/hooks/page.ts +2 -4
  5. package/lib/hooks/pointer/useMovePoint.ts +100 -0
  6. package/lib/hooks/pointer/{moveable.ts → useMoveable.ts} +47 -39
  7. package/lib/hooks/pointer/{pointer.ts → usePointer.ts} +4 -5
  8. package/lib/hooks/pointer/useResize/index.ts +31 -0
  9. package/lib/hooks/pointer/useResize/multi.ts +161 -0
  10. package/lib/hooks/pointer/useResize/multiLineNode.ts +99 -0
  11. package/lib/hooks/pointer/useResize/multiRegularNode.ts +109 -0
  12. package/lib/hooks/pointer/useResize/multiTextNode.ts +108 -0
  13. package/lib/hooks/pointer/useResize/singleRegularNode.ts +91 -0
  14. package/lib/hooks/pointer/useResize/singleTextNode.ts +115 -0
  15. package/lib/hooks/pointer/useRotation.ts +102 -0
  16. package/lib/hooks/pointer/{selector.ts → useSelector.ts} +18 -3
  17. package/lib/hooks/pointer/{snap.ts → useSnap.ts} +5 -4
  18. package/lib/hooks/{pointer/selectionFrame.ts → selectionFrame.ts} +9 -6
  19. package/lib/hooks/textMarks.ts +30 -21
  20. package/lib/lib/googleFonts.ts +1 -5
  21. package/lib/model/editor.ts +31 -13
  22. package/lib/model/geometry/math.ts +128 -1
  23. package/lib/model/history.ts +10 -13
  24. package/lib/model/index.ts +15 -10
  25. package/lib/model/node/{editable → editableNode}/index.ts +13 -29
  26. package/lib/model/node/{formattable.ts → formattableNode/index.ts} +5 -11
  27. package/lib/model/node/{group.ts → groupNode.ts} +9 -13
  28. package/lib/model/node/{image.ts → imageNode.ts} +6 -12
  29. package/lib/model/node/lineNode.ts +80 -0
  30. package/lib/model/node/{shape/shape.ts → shapeNode/index.ts} +30 -15
  31. package/lib/model/node/shapeNode/shape.ts +96 -0
  32. package/lib/model/node/{text.ts → textNode.ts} +9 -24
  33. package/lib/model/node.ts +27 -32
  34. package/lib/model/page.ts +4 -4
  35. package/lib/model/traversal.ts +1 -1
  36. package/lib/ui/extractor.ts +3 -3
  37. package/lib/ui/index.ts +2 -4
  38. package/lib/ui/node/{EditableContent.tsx → EditableContent/index.tsx} +10 -7
  39. package/lib/ui/node/GroupContent.tsx +1 -1
  40. package/lib/ui/node/ImageContent.tsx +1 -1
  41. package/lib/ui/node/LineContent.tsx +30 -0
  42. package/lib/ui/node/ShapeContent/ArrowContent.tsx +57 -0
  43. package/lib/ui/node/ShapeContent/EllipseContent.tsx +37 -0
  44. package/lib/ui/node/ShapeContent/PolygonContent.tsx +62 -0
  45. package/lib/ui/node/ShapeContent/RectangleContent.tsx +35 -0
  46. package/lib/ui/node/ShapeContent/StarContent.tsx +75 -0
  47. package/lib/ui/node/ShapeContent/index.tsx +43 -0
  48. package/lib/ui/node/TextContent.tsx +1 -1
  49. package/lib/ui/selection.ts +6 -5
  50. package/package.json +1 -1
  51. package/lib/hooks/pointer/resize.ts +0 -247
  52. package/lib/hooks/pointer/rotation.ts +0 -103
  53. package/lib/model/node/shape/arrow.ts +0 -50
  54. package/lib/model/node/shape/ellipse.ts +0 -26
  55. package/lib/model/node/shape/polygon.ts +0 -130
  56. package/lib/model/node/shape/star.ts +0 -91
  57. package/lib/ui/node/ArrowContent.tsx +0 -60
  58. package/lib/ui/node/EllipseContent.tsx +0 -49
  59. package/lib/ui/node/PolygonContent.tsx +0 -81
  60. package/lib/ui/node/StarContent.tsx +0 -60
  61. /package/lib/model/node/{editable → editableNode}/letterSpacing.ts +0 -0
  62. /package/lib/model/node/{editable → editableNode}/persistentMarks.ts +0 -0
  63. /package/lib/model/node/{editable → editableNode}/tiptapExtensions.ts +0 -0
  64. /package/lib/ui/node/{useDoubleClick.ts → EditableContent/useDoubleClick.ts} +0 -0
@@ -0,0 +1,75 @@
1
+ import { useId } from "react"
2
+ import { useStore } from "react-bolt"
3
+ import type { Point } from "../../../model/geometry/math"
4
+ import { roundedPathData } from "../../../model/geometry/svg"
5
+ import type { ShapeNode, StarShape } from "../../../model/node/shapeNode"
6
+
7
+ function starPoints(props: {
8
+ width: number
9
+ height: number
10
+ corners: number
11
+ depth: number
12
+ }): Point[] {
13
+ const { width, height, corners, depth } = props
14
+ const cx = width / 2
15
+ const cy = height / 2
16
+ const outerRx = width / 2
17
+ const outerRy = height / 2
18
+ const innerRx = outerRx * depth
19
+ const innerRy = outerRy * depth
20
+
21
+ return Array.from({ length: corners * 2 }, (_, i) => {
22
+ const angle = (i * Math.PI) / corners - Math.PI / 2
23
+ const isOuter = i % 2 === 0
24
+ const rx = isOuter ? outerRx : innerRx
25
+ const ry = isOuter ? outerRy : innerRy
26
+
27
+ return {
28
+ x: cx + rx * Math.cos(angle),
29
+ y: cy + ry * Math.sin(angle),
30
+ }
31
+ })
32
+ }
33
+
34
+ export function StarContent(props: { node: ShapeNode; shape: StarShape["props"] }) {
35
+ const maskId = useId()
36
+ const { node, shape } = props
37
+
38
+ const [background, borderWidth, borderColor] = useStore(
39
+ node,
40
+ "background",
41
+ "borderWidth",
42
+ "borderColor",
43
+ )
44
+ const [width, height] = useStore(node, "width", "height")
45
+ const { corners, depth, roundness } = shape
46
+
47
+ const d = roundedPathData(
48
+ starPoints({
49
+ width,
50
+ height,
51
+ corners,
52
+ depth,
53
+ }),
54
+ roundness,
55
+ )
56
+
57
+ return (
58
+ <svg width={width} height={height} className="absolute inset-0">
59
+ <defs>
60
+ <mask id={maskId} maskUnits="userSpaceOnUse">
61
+ <path d={d} fill="white" />
62
+ </mask>
63
+ </defs>
64
+
65
+ <path d={d} fill={background} />
66
+ <path
67
+ d={d}
68
+ fill="none"
69
+ stroke={borderColor}
70
+ strokeWidth={borderWidth}
71
+ mask={`url(#${maskId})`}
72
+ />
73
+ </svg>
74
+ )
75
+ }
@@ -0,0 +1,43 @@
1
+ import clsx from "clsx"
2
+ import { useStore } from "react-bolt"
3
+ import type { ShapeNode } from "../../../model"
4
+ import { EditableContent } from "../EditableContent"
5
+ import { ArrowContent } from "./ArrowContent"
6
+ import { EllipseContent } from "./EllipseContent"
7
+ import { PolygonContent } from "./PolygonContent"
8
+ import { RectangleShape } from "./RectangleContent"
9
+ import { StarContent } from "./StarContent"
10
+
11
+ export function ShapeContent(props: { node: ShapeNode; isStatic?: boolean }) {
12
+ const { node, isStatic } = props
13
+ const [valign, halign, shape] = useStore(node, "valign", "halign", "shape")
14
+
15
+ return (
16
+ <div className="relative size-full">
17
+ {shape.name === "rectangle" && <RectangleShape node={node} shape={shape.props} />}
18
+ {shape.name === "polygon" && <PolygonContent node={node} shape={shape.props} />}
19
+ {shape.name === "star" && <StarContent node={node} shape={shape.props} />}
20
+ {shape.name === "ellipse" && <EllipseContent node={node} />}
21
+ {shape.name === "arrow" && <ArrowContent node={node} shape={shape.props} />}
22
+
23
+ <div
24
+ className={clsx(
25
+ "flex size-full",
26
+ valign === "top" && "items-start",
27
+ valign === "center" && "items-center",
28
+ valign === "bottom" && "items-end",
29
+ halign === "left" && "justify-start text-left",
30
+ halign === "center" && "justify-center text-center",
31
+ halign === "right" && "justify-end text-right",
32
+ halign === "justify" && "w-full text-justify",
33
+ )}
34
+ >
35
+ <EditableContent
36
+ isStatic={isStatic}
37
+ node={node}
38
+ className={clsx(halign === "justify" && "w-full")}
39
+ />
40
+ </div>
41
+ </div>
42
+ )
43
+ }
@@ -2,7 +2,7 @@ import clsx from "clsx"
2
2
  import { useEffect } from "react"
3
3
  import { useStore } from "react-bolt"
4
4
  import { useEditor } from "../../hooks/editor"
5
- import type { TextNode } from "../../model/node/text"
5
+ import type { TextNode } from "../../model"
6
6
  import { EditableContent } from "./EditableContent"
7
7
 
8
8
  export function TextContent(props: {
@@ -11,11 +11,12 @@ export function selectionDOMRect(selection: Set<Node>): Rect {
11
11
  return rect(...rects)
12
12
  }
13
13
 
14
- export function selectionBox(selection: Set<Node>): Box {
15
- if (selection.size === 1) {
16
- const [node] = selection
17
- return box(node)
14
+ export function selectionBox(selection: Iterable<Node>): Box {
15
+ const arr = Array.from(selection)
16
+
17
+ if (arr.length === 1) {
18
+ return box(arr[0])
18
19
  }
19
20
 
20
- return box(boxBounds(...selection.values().map(box)))
21
+ return box(boxBounds(...arr.map(box)))
21
22
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lazlon-platform/html-editor",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "lib"
@@ -1,247 +0,0 @@
1
- import { TextNode, type HistoryEntry, type Node } from "@lazlon/html-editor/model"
2
- import { pick } from "es-toolkit"
3
- import { useRef } from "react"
4
- import { useStore } from "react-bolt"
5
- import {
6
- box,
7
- boxRect,
8
- deg,
9
- floatNorm,
10
- perpDistance,
11
- point,
12
- pointNorm,
13
- pointSubtract,
14
- rect,
15
- resizeBox,
16
- rotatePoint,
17
- scaleBox,
18
- vectorDotProd,
19
- type Box,
20
- type Corner,
21
- type Edge,
22
- type Point,
23
- type Rect,
24
- type Size,
25
- } from "../../model/geometry/math"
26
- import { selectionBox } from "../../ui/selection"
27
- import { useEditor } from "../editor"
28
- import { cursorPosition, usePointer } from "./pointer"
29
-
30
- const rectProps = ["x", "y", "width", "height"] as const
31
-
32
- function setNodeSize(node: Node, size: Point & Size) {
33
- node.x = floatNorm(size.x)
34
- node.y = floatNorm(size.y)
35
- node.width = floatNorm(size.width)
36
- node.height = floatNorm(size.height)
37
- }
38
-
39
- function setTextNodeSize(node: TextNode, size: Point & Size) {
40
- const target = Math.max(1, size.height)
41
- node.size = (node.size * target) / node.contentHeight
42
- node.contentHeight = target
43
- setNodeSize(node, size)
44
- }
45
-
46
- function scaleDistance(
47
- base: Box,
48
- direction: Edge | Corner,
49
- anchor: Point,
50
- cursor: Point,
51
- ) {
52
- const vector = pointSubtract(cursor, anchor)
53
- const localDirection = pointNorm({
54
- x: direction.includes("w") ? -base.width : direction.includes("e") ? base.width : 0,
55
- y: direction.includes("n") ? -base.height : direction.includes("s") ? base.height : 0,
56
- })
57
- const worldDirection = rotatePoint(localDirection, point(), base.rotation)
58
- const distance = vectorDotProd(vector, worldDirection)
59
-
60
- if (direction.length === 1) return distance
61
-
62
- const diagonal = Math.hypot(base.width, base.height)
63
- return diagonal === 0 ? 0 : (distance * base.height) / diagonal
64
- }
65
-
66
- export function useSingleResize(direction: Edge | Corner) {
67
- const editor = useEditor()
68
- const edges = direction.split("") as Edge[]
69
- const [node] = useStore(editor, "selection")
70
-
71
- const state = useRef({
72
- baseSize: box(),
73
- anchorPoint: point(),
74
- })
75
-
76
- return usePointer({
77
- onDown(event) {
78
- state.current = {
79
- baseSize: box(node),
80
- anchorPoint: cursorPosition(event, node),
81
- }
82
- },
83
- onMove(event) {
84
- const cursor = cursorPosition(event, node)
85
-
86
- if (node instanceof TextNode && direction !== "w" && direction !== "e") {
87
- const size = scaleBox(
88
- state.current.baseSize,
89
- direction,
90
- scaleDistance(
91
- state.current.baseSize,
92
- direction,
93
- state.current.anchorPoint,
94
- cursor,
95
- ),
96
- )
97
-
98
- setTextNodeSize(node, boxRect(size))
99
- } else {
100
- let size = state.current.baseSize
101
-
102
- for (const edge of edges) {
103
- const r = edge === "w" ? 90 : edge === "e" ? -90 : edge === "n" ? 180 : 0
104
- size = resizeBox(
105
- size,
106
- edge,
107
- perpDistance(state.current.anchorPoint, cursor, deg(node.rotation + r)),
108
- )
109
- }
110
-
111
- setNodeSize(node, boxRect(size))
112
- }
113
- },
114
- onCancel() {
115
- editor.action = {}
116
- },
117
- onEnd() {
118
- editor.action = {}
119
-
120
- const prev = boxRect(state.current.baseSize)
121
- const next = rect(node)
122
-
123
- editor.history.push({
124
- redo: ["set-node-props", [node.id, pick(prev, rectProps)]],
125
- undo: ["set-node-props", [node.id, pick(next, rectProps)]],
126
- })
127
- },
128
- })
129
- }
130
-
131
- function useMultiResize(direction: Edge | Corner) {
132
- const editor = useEditor()
133
- const edges = direction.split("") as Edge[]
134
-
135
- const state = useRef({
136
- initialTargetRect: rect(),
137
- initialCursorPos: point(),
138
- nodes: Array<{
139
- node: Node
140
- baseSize: Box
141
- relative: Rect
142
- }>(),
143
- })
144
-
145
- return usePointer({
146
- onDown(event) {
147
- const { x, y, width, height } = boxRect(selectionBox(editor.selection))
148
-
149
- state.current = {
150
- initialTargetRect: rect({ x, y, width, height }),
151
- initialCursorPos: cursorPosition(event, editor.selectionPage!),
152
- nodes: editor.selection
153
- .values()
154
- .toArray()
155
- .map((node) => ({
156
- node,
157
- baseSize: box(node),
158
- relative: rect({
159
- x: (node.x - x) / width,
160
- y: (node.y - y) / height,
161
- width: node.width / width,
162
- height: node.height / height,
163
- }),
164
- })),
165
- }
166
- },
167
-
168
- onMove(event) {
169
- const r = { ...state.current.initialTargetRect }
170
- const p = pointSubtract(
171
- cursorPosition(event, editor.selectionPage!),
172
- state.current.initialCursorPos,
173
- )
174
-
175
- /* mutate rect */ {
176
- const { x, y, height, width } = state.current.initialTargetRect
177
-
178
- if (edges.includes("n") && height - p.y > 0) {
179
- r.height = height - p.y
180
- r.y = y + p.y
181
- }
182
- if (edges.includes("s")) {
183
- r.height = height + p.y
184
- }
185
- if (edges.includes("w") && width - p.x > 0) {
186
- r.width = width - p.x
187
- r.x = x + p.x
188
- }
189
- if (edges.includes("e")) {
190
- r.width = width + p.x
191
- }
192
- }
193
-
194
- /* mutate nodes according to rect */ {
195
- for (const { node, baseSize, relative } of state.current.nodes) {
196
- const size = {
197
- x: relative.x * r.width + r.x,
198
- y: relative.y * r.height + r.y,
199
- width: r.width * relative.width,
200
- height: r.height * relative.height,
201
- }
202
-
203
- if (node instanceof TextNode && direction !== "w" && direction !== "e") {
204
- const scaled = scaleBox(baseSize, direction, size.height - baseSize.height)
205
-
206
- setTextNodeSize(node, {
207
- x: size.x + size.width / 2 - scaled.width / 2,
208
- y: size.y + size.height / 2 - scaled.height / 2,
209
- width: scaled.width,
210
- height: scaled.height,
211
- })
212
- } else {
213
- setNodeSize(node, size)
214
- }
215
- }
216
- }
217
-
218
- editor.action = { action: "resize", payload: r }
219
- },
220
-
221
- onCancel() {
222
- editor.action = {}
223
- },
224
-
225
- onEnd() {
226
- editor.action = {}
227
-
228
- const entries: HistoryEntry[] = state.current.nodes.map(({ node, baseSize }) => ({
229
- redo: ["set-node-props", [node.id, pick(node, rectProps)]],
230
- undo: ["set-node-props", [node.id, pick(boxRect(baseSize), rectProps)]],
231
- }))
232
-
233
- editor.history.push({
234
- redo: ["batch", entries.map((e) => e.redo)],
235
- undo: ["batch", entries.map((e) => e.undo)],
236
- })
237
- },
238
- })
239
- }
240
-
241
- export function useResize(direction: Edge | Corner) {
242
- const editor = useEditor()
243
- const nodes = useStore(editor, "selection")
244
- const single = useSingleResize(direction)
245
- const multi = useMultiResize(direction)
246
- return nodes.size === 1 ? single : multi
247
- }
@@ -1,103 +0,0 @@
1
- import { useRef } from "react"
2
- import { angle, deg, point, rectCenter, type Deg } from "../../model/geometry/math"
3
- import type { HistoryAction } from "../../model/history"
4
- import type { Node } from "../../model/node"
5
- import { selectionDOMRect } from "../../ui/selection"
6
- import { useEditor } from "../editor"
7
- import { usePointer } from "./pointer"
8
-
9
- function snappedDeg(startDeg: number, delta: number, shiftKey: boolean) {
10
- const rotation = startDeg + delta
11
- return deg(shiftKey ? Math.round(rotation / 45) * 45 : rotation)
12
- }
13
-
14
- export function useRotation() {
15
- const editor = useEditor()
16
-
17
- const state = useRef({
18
- centerPoint: point(),
19
- inititalDeg: deg(0),
20
- nodes: Array<{ node: Node; deg: Deg }>(),
21
- })
22
-
23
- return usePointer({
24
- onDown(event) {
25
- const target = selectionDOMRect(editor.selection)
26
- const nodes = editor.selection.values().toArray()
27
- const center = rectCenter(target)
28
-
29
- state.current = {
30
- centerPoint: center,
31
- inititalDeg: angle(center, point(), {
32
- x: event.clientX,
33
- y: event.clientY,
34
- }),
35
- nodes: nodes.map((node) => ({ node, deg: node.rotation })),
36
- }
37
-
38
- const deg = nodes.length === 1 ? nodes[0].rotation : 0
39
- editor.action = { action: "rotate", payload: { deg } }
40
- },
41
-
42
- onMove(event) {
43
- const { centerPoint, inititalDeg, nodes } = state.current
44
-
45
- const clientPoint = { x: event.clientX, y: event.clientY }
46
- const deg = angle(centerPoint, point(), clientPoint) - inititalDeg
47
-
48
- for (const n of nodes) {
49
- n.node.rotation = snappedDeg(n.deg, deg, event.shiftKey)
50
- }
51
-
52
- const display =
53
- nodes.length === 1
54
- ? nodes[0].node.rotation
55
- : snappedDeg(inititalDeg, deg, event.shiftKey)
56
-
57
- editor.action = {
58
- action: "rotate",
59
- payload: {
60
- deg: display,
61
- },
62
- }
63
- },
64
-
65
- onCancel() {
66
- editor.action = {}
67
- },
68
-
69
- onEnd(event) {
70
- const { centerPoint, inititalDeg, nodes } = state.current
71
-
72
- const clientPoint = { x: event.clientX, y: event.clientY }
73
- const deg = angle(centerPoint, point(), clientPoint) - inititalDeg
74
-
75
- if (nodes.length === 0) {
76
- const [n] = nodes
77
- const rotation = snappedDeg(n.deg, deg, event.shiftKey)
78
- editor.history.push({
79
- redo: ["set-node-props", [n.node.id, { rotation }]],
80
- undo: ["set-node-props", [n.node.id, { rotation: n.deg }]],
81
- })
82
- n.node.rotation = rotation
83
- } else {
84
- const redo: HistoryAction[] = []
85
- const undo: HistoryAction[] = []
86
-
87
- for (const n of nodes) {
88
- const rotation = snappedDeg(n.deg, deg, event.shiftKey)
89
- redo.push(["set-node-props", [n.node.id, { rotation }]])
90
- undo.push(["set-node-props", [n.node.id, { rotation: n.deg }]])
91
- n.node.rotation = rotation
92
- }
93
-
94
- editor.history.push({
95
- redo: ["batch", redo],
96
- undo: ["batch", undo],
97
- })
98
- }
99
-
100
- editor.action = {}
101
- },
102
- })
103
- }
@@ -1,50 +0,0 @@
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/svg"
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
- }
@@ -1,26 +0,0 @@
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
- }