@lazlon-platform/html-editor 0.6.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 (34) hide show
  1. package/lib/hooks/actions.ts +89 -67
  2. package/lib/hooks/batch.ts +9 -5
  3. package/lib/hooks/index.ts +7 -7
  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} +30 -36
  7. package/lib/hooks/pointer/{pointer.ts → usePointer.ts} +2 -2
  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} +2 -1
  17. package/lib/hooks/pointer/{snap.ts → useSnap.ts} +3 -2
  18. package/lib/hooks/{pointer/selectionFrame.ts → selectionFrame.ts} +9 -6
  19. package/lib/hooks/textMarks.ts +30 -19
  20. package/lib/model/editor.ts +18 -4
  21. package/lib/model/geometry/math.ts +8 -20
  22. package/lib/model/index.ts +9 -1
  23. package/lib/model/node/imageNode.ts +1 -1
  24. package/lib/model/node/lineNode.ts +41 -20
  25. package/lib/model/node/textNode.ts +5 -14
  26. package/lib/model/node.ts +18 -9
  27. package/lib/model/page.ts +3 -2
  28. package/lib/ui/node/EditableContent/index.tsx +6 -4
  29. package/lib/ui/node/LineContent.tsx +1 -3
  30. package/lib/ui/selection.ts +6 -5
  31. package/package.json +1 -1
  32. package/lib/hooks/pointer/movePoint.ts +0 -75
  33. package/lib/hooks/pointer/resize.ts +0 -247
  34. package/lib/hooks/pointer/rotation.ts +0 -138
@@ -18,6 +18,7 @@ export function EditableContent(props: {
18
18
  const editor = useEditor()
19
19
  const selection = useStore(editor, "selection")
20
20
  const { action } = useStore(editor, "action")
21
+ const locked = useStore(node, "locked")
21
22
  const prevContent = useRef(node.tiptap.getJSON())
22
23
 
23
24
  // start with selection.has(node) so that newly added editable nodes can be focused
@@ -25,9 +26,10 @@ export function EditableContent(props: {
25
26
 
26
27
  const onDoubleClick = useDoubleClick(
27
28
  useCallback(() => {
29
+ if (locked) return
28
30
  setClicked(true)
29
31
  node.tiptap.commands.focus()
30
- }, [node, setClicked]),
32
+ }, [node, locked, setClicked]),
31
33
  )
32
34
 
33
35
  const html = useEditorState({
@@ -36,12 +38,12 @@ export function EditableContent(props: {
36
38
  })
37
39
 
38
40
  useEffect(() => {
39
- if (!selection.has(node)) {
41
+ if (!selection.has(node) || locked) {
40
42
  node.blur()
41
43
  // eslint-disable-next-line react-hooks/set-state-in-effect
42
44
  setClicked(false)
43
45
  }
44
- }, [selection, node])
46
+ }, [selection, node, locked])
45
47
 
46
48
  useEffect(() => {
47
49
  const onUpdate = debounce(() => {
@@ -71,7 +73,7 @@ export function EditableContent(props: {
71
73
  })
72
74
 
73
75
  const lineHeight = useStore(node, "lineHeight")
74
- const showEditor = !isStatic && selection.has(node) && !action && clicked
76
+ const showEditor = !isStatic && selection.has(node) && !action && clicked && !locked
75
77
 
76
78
  return (
77
79
  <div
@@ -11,8 +11,6 @@ export function LineContent(props: { node: LineNode }) {
11
11
  "strokeColor",
12
12
  )
13
13
 
14
- const polylinePoints = points.map(({ x, y }) => `${x},${y}`).join(" ")
15
-
16
14
  return (
17
15
  <svg
18
16
  className="absolute inset-0 overflow-visible"
@@ -21,7 +19,7 @@ export function LineContent(props: { node: LineNode }) {
21
19
  fill="none"
22
20
  >
23
21
  <polyline
24
- points={polylinePoints}
22
+ points={points.map(({ x, y }) => `${x},${y}`).join(" ")}
25
23
  stroke={strokeColor}
26
24
  strokeWidth={strokeWidth}
27
25
  strokeLinecap="round"
@@ -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.6.0",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "lib"
@@ -1,75 +0,0 @@
1
- import { useRef } from "react"
2
- import type { LineNode } from "../../model"
3
- import {
4
- point,
5
- pointAdd,
6
- pointSubtract,
7
- rect,
8
- type Point,
9
- } from "../../model/geometry/math"
10
- import { useEditor } from "../editor"
11
- import { cursorPosition, usePointer } from "./pointer"
12
- import { useSnap } from "./snap"
13
-
14
- export function useMovePoint(props: { lineNode: LineNode; pointIndex: number }) {
15
- const editor = useEditor()
16
- const { lineNode, pointIndex } = props
17
- const page = lineNode.page
18
- const snap = useSnap(page)
19
-
20
- const state = useRef({
21
- cursorOffset: point(),
22
- initialAnchor: point(),
23
- initialPoints: [] as Point[],
24
- })
25
-
26
- return usePointer({
27
- onDown(event) {
28
- const cursor = cursorPosition(event, page)
29
- const linePoint = lineNode.points[pointIndex]
30
- const pointPosition = pointAdd(lineNode, linePoint)
31
-
32
- event.preventDefault()
33
- event.stopPropagation()
34
-
35
- state.current = {
36
- cursorOffset: pointSubtract(cursor, pointPosition),
37
- initialAnchor: point(lineNode),
38
- initialPoints: Array.from(lineNode.points),
39
- }
40
- },
41
- onMove(event) {
42
- const { cursorOffset, initialPoints, initialAnchor } = state.current
43
- const cursor = pointSubtract(cursorPosition(event, page), cursorOffset)
44
- const snapped = snap(
45
- !event.shiftKey,
46
- rect({ x: cursor.x, y: cursor.y, width: 0, height: 0 }),
47
- )
48
-
49
- const points = initialPoints.map((p, i) =>
50
- i === pointIndex ? snapped : pointAdd(initialAnchor, p),
51
- )
52
-
53
- const bounds = point(rect(...points))
54
-
55
- lineNode.x = bounds.x
56
- lineNode.y = bounds.y
57
- lineNode.points = points.map((p) => pointSubtract(p, bounds))
58
-
59
- editor.action = { action: "move", payload: point(snapped) }
60
- },
61
- onCancel() {
62
- editor.action = {}
63
- page.snapLines = []
64
- },
65
- onEnd() {
66
- editor.action = {}
67
- page.snapLines = []
68
-
69
- lineNode.page.editor.history.push<LineNode>({
70
- redo: ["set-node-props", [lineNode.id, { points: lineNode.points }]],
71
- undo: ["set-node-props", [lineNode.id, { points: state.current.initialPoints }]],
72
- })
73
- },
74
- })
75
- }
@@ -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.page),
81
- }
82
- },
83
- onMove(event) {
84
- const cursor = cursorPosition(event, node.page)
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,138 +0,0 @@
1
- import { pick } from "es-toolkit"
2
- import { useRef } from "react"
3
- import {
4
- angle,
5
- box,
6
- boxRect,
7
- deg,
8
- floatNorm,
9
- point,
10
- rotatePoint,
11
- type Box,
12
- } from "../../model/geometry/math"
13
- import type { HistoryAction } from "../../model/history"
14
- import type { Node } from "../../model/node"
15
- import type { Page } from "../../model/page"
16
- import { selectionBox } from "../../ui/selection"
17
- import { useEditor } from "../editor"
18
- import { cursorPosition, usePointer } from "./pointer"
19
-
20
- function snappedDeg(startDeg: number, delta: number, shiftKey: boolean) {
21
- const rotation = startDeg + delta
22
- return deg(shiftKey ? Math.round(rotation / 45) * 45 : rotation)
23
- }
24
-
25
- export function useRotation() {
26
- const editor = useEditor()
27
-
28
- const state = useRef({
29
- initialDeg: deg(0),
30
- page: null as Page | null,
31
- selectionCenter: point(),
32
- nodes: Array<{
33
- node: Node
34
- base: Box
35
- }>(),
36
- })
37
-
38
- function applyRotation(delta: number, shiftKey: boolean) {
39
- const { initialDeg, nodes, selectionCenter } = state.current
40
-
41
- if (nodes.length > 1) {
42
- const groupDelta = snappedDeg(initialDeg, delta, shiftKey) - initialDeg
43
-
44
- for (const { node, base } of nodes) {
45
- const center = rotatePoint(base.center, selectionCenter, deg(groupDelta))
46
-
47
- node.x = floatNorm(center.x - base.width / 2)
48
- node.y = floatNorm(center.y - base.height / 2)
49
- node.rotation = deg(base.rotation + groupDelta)
50
- }
51
-
52
- return snappedDeg(initialDeg, delta, shiftKey)
53
- }
54
-
55
- for (const { node, base } of nodes) {
56
- node.rotation = snappedDeg(base.rotation, delta, shiftKey)
57
- }
58
-
59
- return nodes[0]?.node.rotation ?? deg(0)
60
- }
61
-
62
- return usePointer({
63
- onDown(event) {
64
- const nodes = editor.selection.values().toArray()
65
- const page = editor.selectionPage
66
- if (nodes.length === 0 || !page) return false
67
-
68
- const { center: selectionCenter } = selectionBox(editor.selection)
69
-
70
- state.current = {
71
- initialDeg: angle(selectionCenter, point(), cursorPosition(event, page)),
72
- page,
73
- selectionCenter,
74
- nodes: nodes.map((node) => ({ node, base: box(node) })),
75
- }
76
-
77
- const deg = nodes.length === 1 ? nodes[0].rotation : 0
78
- editor.action = { action: "rotate", payload: { deg } }
79
- },
80
-
81
- onMove(event) {
82
- const { initialDeg, page, selectionCenter } = state.current
83
- if (!page) return
84
-
85
- const deg =
86
- angle(selectionCenter, point(), cursorPosition(event, page)) - initialDeg
87
- const display = applyRotation(deg, event.shiftKey)
88
-
89
- editor.action = {
90
- action: "rotate",
91
- payload: {
92
- deg: display,
93
- },
94
- }
95
- },
96
-
97
- onCancel() {
98
- editor.action = {}
99
- },
100
-
101
- onEnd(event) {
102
- const { initialDeg, nodes, page, selectionCenter } = state.current
103
- if (!page) return
104
-
105
- const deg =
106
- angle(selectionCenter, point(), cursorPosition(event, page)) - initialDeg
107
-
108
- applyRotation(deg, event.shiftKey)
109
-
110
- if (nodes.length === 1) {
111
- const [n] = nodes
112
- editor.history.push({
113
- redo: ["set-node-props", [n.node.id, { rotation: n.node.rotation }]],
114
- undo: ["set-node-props", [n.node.id, { rotation: n.base.rotation }]],
115
- })
116
- } else {
117
- const redo: HistoryAction[] = []
118
- const undo: HistoryAction[] = []
119
-
120
- for (const { node, base } of nodes) {
121
- const baseRect = boxRect(base)
122
- redo.push(["set-node-props", [node.id, pick(node, ["rotation", "x", "y"])]])
123
- undo.push([
124
- "set-node-props",
125
- [node.id, { rotation: base.rotation, x: baseRect.x, y: baseRect.y }],
126
- ])
127
- }
128
-
129
- editor.history.push({
130
- redo: ["batch", redo],
131
- undo: ["batch", undo],
132
- })
133
- }
134
-
135
- editor.action = {}
136
- },
137
- })
138
- }