@lazlon-platform/html-editor 0.4.0 → 0.6.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 (59) hide show
  1. package/lib/hooks/actions.ts +54 -26
  2. package/lib/hooks/batch.ts +17 -7
  3. package/lib/hooks/index.ts +1 -0
  4. package/lib/hooks/node.ts +14 -6
  5. package/lib/hooks/pointer/movePoint.ts +75 -0
  6. package/lib/hooks/pointer/moveable.ts +92 -57
  7. package/lib/hooks/pointer/pointer.ts +21 -11
  8. package/lib/hooks/pointer/resize.ts +176 -210
  9. package/lib/hooks/pointer/rotation.ts +89 -68
  10. package/lib/hooks/pointer/selectionFrame.ts +8 -11
  11. package/lib/hooks/pointer/selector.ts +62 -40
  12. package/lib/hooks/pointer/snap.ts +23 -23
  13. package/lib/hooks/textMarks.ts +1 -3
  14. package/lib/lib/googleFonts.ts +1 -5
  15. package/lib/model/editor.ts +13 -9
  16. package/lib/model/geometry/math.ts +623 -0
  17. package/lib/model/geometry/svg.ts +55 -0
  18. package/lib/model/history.ts +10 -13
  19. package/lib/model/index.ts +7 -10
  20. package/lib/model/node/{editable → editableNode}/index.ts +13 -29
  21. package/lib/model/node/{formattable.ts → formattableNode/index.ts} +5 -11
  22. package/lib/model/node/{group.ts → groupNode.ts} +9 -13
  23. package/lib/model/node/{image.ts → imageNode.ts} +5 -11
  24. package/lib/model/node/lineNode.ts +59 -0
  25. package/lib/model/node/{shape/shape.ts → shapeNode/index.ts} +30 -15
  26. package/lib/model/node/shapeNode/shape.ts +96 -0
  27. package/lib/model/node/{text.ts → textNode.ts} +19 -21
  28. package/lib/model/node.ts +11 -29
  29. package/lib/model/page.ts +4 -3
  30. package/lib/model/traversal.ts +1 -1
  31. package/lib/ui/extractor.ts +3 -3
  32. package/lib/ui/index.ts +2 -4
  33. package/lib/ui/node/{EditableContent.tsx → EditableContent/index.tsx} +4 -3
  34. package/lib/ui/node/GroupContent.tsx +1 -1
  35. package/lib/ui/node/ImageContent.tsx +1 -1
  36. package/lib/ui/node/LineContent.tsx +32 -0
  37. package/lib/ui/node/NodeView.tsx +1 -13
  38. package/lib/ui/node/ShapeContent/ArrowContent.tsx +57 -0
  39. package/lib/ui/node/ShapeContent/EllipseContent.tsx +37 -0
  40. package/lib/ui/node/ShapeContent/PolygonContent.tsx +62 -0
  41. package/lib/ui/node/ShapeContent/RectangleContent.tsx +35 -0
  42. package/lib/ui/node/ShapeContent/StarContent.tsx +75 -0
  43. package/lib/ui/node/ShapeContent/index.tsx +43 -0
  44. package/lib/ui/node/TextContent.tsx +1 -1
  45. package/lib/ui/selection.ts +9 -26
  46. package/package.json +34 -34
  47. package/lib/model/geometry.ts +0 -247
  48. package/lib/model/node/shape/arrow.ts +0 -50
  49. package/lib/model/node/shape/ellipse.ts +0 -26
  50. package/lib/model/node/shape/polygon.ts +0 -108
  51. package/lib/model/node/shape/star.ts +0 -63
  52. package/lib/ui/node/ArrowContent.tsx +0 -60
  53. package/lib/ui/node/EllipseContent.tsx +0 -49
  54. package/lib/ui/node/PolygonContent.tsx +0 -81
  55. package/lib/ui/node/StarContent.tsx +0 -60
  56. /package/lib/model/node/{editable → editableNode}/letterSpacing.ts +0 -0
  57. /package/lib/model/node/{editable → editableNode}/persistentMarks.ts +0 -0
  58. /package/lib/model/node/{editable → editableNode}/tiptapExtensions.ts +0 -0
  59. /package/lib/ui/node/{useDoubleClick.ts → EditableContent/useDoubleClick.ts} +0 -0
@@ -1,60 +1,115 @@
1
+ import { TextNode, type HistoryEntry, type Node } from "@lazlon/html-editor/model"
2
+ import { pick } from "es-toolkit"
1
3
  import { useRef } from "react"
2
4
  import { useStore } from "react-bolt"
3
- import type { Point, Rect } from "../../model/geometry"
4
- import type { HistoryEntry } from "../../model/history"
5
- import type { Node } from "../../model/node"
6
- import { getTargetRect } from "../../ui/selection"
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"
7
27
  import { useEditor } from "../editor"
8
- import { usePointer } from "./pointer"
28
+ import { cursorPosition, usePointer } from "./pointer"
9
29
 
10
- type Edge = "n" | "s" | "w" | "e"
11
- type Corner = "ne" | "nw" | "se" | "sw"
12
- type Direction = Edge | Corner
30
+ const rectProps = ["x", "y", "width", "height"] as const
13
31
 
14
- type ResizeInit = {
15
- rect: Rect
16
- nodes: Array<Rect & { node: Node; undo: Rect }>
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)
17
37
  }
18
38
 
19
- function useSingleResize(direction: Direction) {
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) {
20
67
  const editor = useEditor()
21
- const [node, ...rest] = useStore(editor, "selection")
22
- const init = useRef<Rect>({ width: 0, height: 0, x: 0, y: 0 })
23
- const directions = direction.split("") as Edge[]
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
+ })
24
75
 
25
76
  return usePointer({
26
- onDown() {
27
- if (!node || rest.length > 0) return false
28
- const { x, y, width, height } = node
29
- init.current = { x, y, width, height }
77
+ onDown(event) {
78
+ state.current = {
79
+ baseSize: box(node),
80
+ anchorPoint: cursorPosition(event, node.page),
81
+ }
30
82
  },
31
- onMove({ start, event }) {
32
- editor.action = { action: "resize", payload: { width: 0, height: 0 } }
33
-
34
- let size = init.current
83
+ onMove(event) {
84
+ const cursor = cursorPosition(event, node.page)
35
85
 
36
- for (const direction of directions) {
37
- const r =
38
- direction === "w" ? 90 : direction === "e" ? -90 : direction === "n" ? 180 : 0
39
-
40
- size = resize(
41
- size,
42
- node.rotation,
86
+ if (node instanceof TextNode && direction !== "w" && direction !== "e") {
87
+ const size = scaleBox(
88
+ state.current.baseSize,
43
89
  direction,
44
- distance(
45
- { x: start.clientX, y: start.clientY },
46
- { x: event.clientX, y: event.clientY },
47
- node.rotation + r,
90
+ scaleDistance(
91
+ state.current.baseSize,
92
+ direction,
93
+ state.current.anchorPoint,
94
+ cursor,
48
95
  ),
49
96
  )
50
- }
51
97
 
52
- node.width = size.width
53
- node.height = size.height
54
- node.x = size.x
55
- node.y = size.y
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
+ }
56
110
 
57
- editor.action = { action: "resize", payload: size }
111
+ setNodeSize(node, boxRect(size))
112
+ }
58
113
  },
59
114
  onCancel() {
60
115
  editor.action = {}
@@ -62,111 +117,118 @@ function useSingleResize(direction: Direction) {
62
117
  onEnd() {
63
118
  editor.action = {}
64
119
 
65
- const prev = init.current
66
- const next = {
67
- x: node.x,
68
- y: node.y,
69
- width: node.width,
70
- height: node.height,
71
- }
120
+ const prev = boxRect(state.current.baseSize)
121
+ const next = rect(node)
72
122
 
73
123
  editor.history.push({
74
- redo: ["set-node-props", [node.id, next]],
75
- undo: ["set-node-props", [node.id, prev]],
124
+ redo: ["set-node-props", [node.id, pick(prev, rectProps)]],
125
+ undo: ["set-node-props", [node.id, pick(next, rectProps)]],
76
126
  })
77
127
  },
78
128
  })
79
129
  }
80
130
 
81
- function useMultiResize(direction: Direction) {
131
+ function useMultiResize(direction: Edge | Corner) {
82
132
  const editor = useEditor()
83
- const selection = useStore(editor, "selection")
84
- const selectionArray = selection.values().toArray()
85
- const directions = direction.split("") as Edge[]
86
- const init = useRef<ResizeInit>({
87
- rect: { x: 0, y: 0, width: 0, height: 0 },
88
- nodes: [],
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
+ }>(),
89
143
  })
90
144
 
91
145
  return usePointer({
92
- onDown() {
93
- if (selectionArray.some((n) => n.locked)) {
94
- return false
95
- }
96
-
97
- const { x, y, width, height } = getTargetRect(selection)
98
-
99
- init.current = {
100
- rect: { x, y, width, height },
101
- nodes: selectionArray.map((node) => ({
102
- node,
103
- undo: {
104
- x: node.x,
105
- y: node.y,
106
- width: node.width,
107
- height: node.height,
108
- },
109
- x: (node.x - x) / width,
110
- y: (node.y - y) / height,
111
- width: node.width / width,
112
- height: node.height / height,
113
- })),
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
+ })),
114
165
  }
115
166
  },
116
- onMove({ start, event }) {
117
- const px = Math.round((event.clientX - start.clientX) / editor.zoom)
118
- const py = Math.round((event.clientY - start.clientY) / editor.zoom)
119
167
 
120
- const rect: Rect = { ...init.current.rect }
168
+ onMove(event) {
169
+ const r = { ...state.current.initialTargetRect }
170
+ const p = pointSubtract(
171
+ cursorPosition(event, editor.selectionPage!),
172
+ state.current.initialCursorPos,
173
+ )
121
174
 
122
175
  /* mutate rect */ {
123
- const { x, y, height, width } = init.current.rect
176
+ const { x, y, height, width } = state.current.initialTargetRect
124
177
 
125
- if (directions.includes("n") && height - py > 0) {
126
- rect.height = height - py
127
- rect.y = y + py
178
+ if (edges.includes("n") && height - p.y > 0) {
179
+ r.height = height - p.y
180
+ r.y = y + p.y
128
181
  }
129
- if (directions.includes("s")) {
130
- rect.height = height + py
182
+ if (edges.includes("s")) {
183
+ r.height = height + p.y
131
184
  }
132
- if (directions.includes("w") && width - px > 0) {
133
- rect.width = width - px
134
- rect.x = x + px
185
+ if (edges.includes("w") && width - p.x > 0) {
186
+ r.width = width - p.x
187
+ r.x = x + p.x
135
188
  }
136
- if (directions.includes("e")) {
137
- rect.width = width + px
189
+ if (edges.includes("e")) {
190
+ r.width = width + p.x
138
191
  }
139
192
  }
140
193
 
141
194
  /* mutate nodes according to rect */ {
142
- for (const { node, x, y, height, width } of init.current.nodes) {
143
- node.x = Math.round(x * rect.width) + rect.x
144
- node.y = Math.round(y * rect.height) + rect.y
145
- node.width = Math.round(rect.width * width)
146
- node.height = Math.round(rect.height * height)
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
+ }
147
215
  }
148
216
  }
149
217
 
150
- editor.action = { action: "resize", payload: rect }
218
+ editor.action = { action: "resize", payload: r }
151
219
  },
220
+
152
221
  onCancel() {
153
222
  editor.action = {}
154
223
  },
224
+
155
225
  onEnd() {
156
226
  editor.action = {}
157
227
 
158
- const entries = init.current.nodes.map(
159
- ({ node, undo }): HistoryEntry => ({
160
- redo: [
161
- "set-node-props",
162
- [node.id, { x: node.x, y: node.y, width: node.width, height: node.height }],
163
- ],
164
- undo: [
165
- "set-node-props",
166
- [node.id, { width: undo.width, height: undo.height, x: undo.x, y: undo.y }],
167
- ],
168
- }),
169
- )
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
+ }))
170
232
 
171
233
  editor.history.push({
172
234
  redo: ["batch", entries.map((e) => e.redo)],
@@ -176,106 +238,10 @@ function useMultiResize(direction: Direction) {
176
238
  })
177
239
  }
178
240
 
179
- /**
180
- * Assumptions (matches typical HTML/CSS editors):
181
- * - node.x, node.y are the unrotated rectangle's TOP-LEFT (CSS left/top).
182
- * - rotation is around the rectangle CENTER (transform-origin: center).
183
- * - resizing keeps the OPPOSITE side fixed in world space (so it won't "drift" when rotated).
184
- * - n is the delta to width/height (positive grows, negative shrinks).
185
- */
186
- function resize(rect: Rect, deg: number, side: "n" | "s" | "e" | "w", n: number): Rect {
187
- const minSize = 1
188
-
189
- const w0 = rect.width
190
- const h0 = rect.height
191
-
192
- const w1 = side === "e" || side === "w" ? Math.max(minSize, w0 + n) : w0
193
- const h1 = side === "n" || side === "s" ? Math.max(minSize, h0 + n) : h0
194
-
195
- const hx0 = w0 / 2
196
- const hy0 = h0 / 2
197
- const hx1 = w1 / 2
198
- const hy1 = h1 / 2
199
-
200
- const theta = (deg * Math.PI) / 180
201
- const cos = Math.cos(theta)
202
- const sin = Math.sin(theta)
203
-
204
- const rot = (p: { x: number; y: number }) => ({
205
- x: p.x * cos - p.y * sin,
206
- y: p.x * sin + p.y * cos,
207
- })
208
-
209
- // Center of the rect in world coords (since x,y is top-left pre-rotation)
210
- const c0 = { x: rect.x + hx0, y: rect.y + hy0 }
211
-
212
- // Opposite side midpoint is the anchor (fixed in world space)
213
- const anchorLocal0 =
214
- side === "e"
215
- ? { x: -hx0, y: 0 }
216
- : side === "w"
217
- ? { x: hx0, y: 0 }
218
- : side === "s"
219
- ? { x: 0, y: -hy0 }
220
- : { x: 0, y: hy0 } // side === "n"
221
-
222
- const a = rot(anchorLocal0)
223
- const anchorWorld = { x: c0.x + a.x, y: c0.y + a.y }
224
-
225
- // After resize, the anchor local point changes because half-extents changed
226
- const anchorLocal1 =
227
- side === "e"
228
- ? { x: -hx1, y: 0 }
229
- : side === "w"
230
- ? { x: hx1, y: 0 }
231
- : side === "s"
232
- ? { x: 0, y: -hy1 }
233
- : { x: 0, y: hy1 } // side === "n"
234
-
235
- // Solve for new center so the anchor stays in the same world position:
236
- // anchorWorld = c1 + R * anchorLocal1 => c1 = anchorWorld - R * anchorLocal1
237
- const a1 = rot(anchorLocal1)
238
- const c1 = { x: anchorWorld.x - a1.x, y: anchorWorld.y - a1.y }
239
-
240
- // Convert center back to unrotated top-left
241
- const x1 = c1.x - hx1
242
- const y1 = c1.y - hy1
243
-
244
- return {
245
- x: x1,
246
- y: y1,
247
- width: w1,
248
- height: h1,
249
- }
250
- }
251
-
252
- /**
253
- * Perpendicular (signed) distance from point b to the infinite line
254
- * that passes through point a with direction `deg` (degrees).
255
- */
256
- function distance(a: Point, b: Point, deg: number): number {
257
- const t = (deg * Math.PI) / 180
258
-
259
- // unit direction along the line
260
- const dx = Math.cos(t)
261
- const dy = Math.sin(t)
262
-
263
- // unit normal (perpendicular) to the line
264
- const nx = -dy
265
- const ny = dx
266
-
267
- // vector from a to b
268
- const vx = b.x - a.x
269
- const vy = b.y - a.y
270
-
271
- // projection onto normal => signed perpendicular distance
272
- return vx * nx + vy * ny
273
- }
274
-
275
- export function useResize(direction: Direction) {
241
+ export function useResize(direction: Edge | Corner) {
276
242
  const editor = useEditor()
277
- const selection = useStore(editor, "selection")
243
+ const nodes = useStore(editor, "selection")
278
244
  const single = useSingleResize(direction)
279
245
  const multi = useMultiResize(direction)
280
- return selection.size === 1 ? single : multi
246
+ return nodes.size === 1 ? single : multi
281
247
  }
@@ -1,71 +1,90 @@
1
+ import { pick } from "es-toolkit"
1
2
  import { useRef } from "react"
2
- import { useStore } from "react-bolt"
3
- import { angle, type Point } from "../../model/geometry"
3
+ import {
4
+ angle,
5
+ box,
6
+ boxRect,
7
+ deg,
8
+ floatNorm,
9
+ point,
10
+ rotatePoint,
11
+ type Box,
12
+ } from "../../model/geometry/math"
4
13
  import type { HistoryAction } from "../../model/history"
5
14
  import type { Node } from "../../model/node"
6
- import { getTargetDOMRect } from "../../ui/selection"
15
+ import type { Page } from "../../model/page"
16
+ import { selectionBox } from "../../ui/selection"
7
17
  import { useEditor } from "../editor"
8
- import { usePointer } from "./pointer"
9
-
10
- type RotationState = {
11
- anchor: Point
12
- center: Point
13
- deg: number
14
- nodes: Array<{ node: Node; deg: number }>
15
- }
18
+ import { cursorPosition, usePointer } from "./pointer"
16
19
 
17
20
  function snappedDeg(startDeg: number, delta: number, shiftKey: boolean) {
18
21
  const rotation = startDeg + delta
19
- return shiftKey ? Math.round(rotation / 45) * 45 : rotation
22
+ return deg(shiftKey ? Math.round(rotation / 45) * 45 : rotation)
20
23
  }
21
24
 
22
25
  export function useRotation() {
23
- const start = useRef<RotationState | null>(null)
24
26
  const editor = useEditor()
25
- const selection = useStore(editor, "selection")
26
- const selectionArray = selection.values().toArray()
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
+ }
27
61
 
28
62
  return usePointer({
29
63
  onDown(event) {
30
- const { x, y, width, height } = getTargetDOMRect(selection)
31
- const anchor = {
32
- x: x + width / 2,
33
- y: height,
34
- }
35
- const center = {
36
- x: x + width / 2,
37
- y: y + height / 2,
38
- }
39
- const deg = angle(center, anchor, {
40
- x: event.clientX,
41
- y: event.clientY,
42
- })
43
- start.current = {
44
- anchor,
45
- center,
46
- deg,
47
- nodes: selectionArray.map((node) => ({
48
- node,
49
- deg: node.rotation,
50
- })),
51
- }
52
- editor.action = { action: "rotate", payload: { deg: 0 } }
53
- },
54
- onMove({ event }) {
55
- const s = start.current
56
- if (!s) return
64
+ const nodes = editor.selection.values().toArray()
65
+ const page = editor.selectionPage
66
+ if (nodes.length === 0 || !page) return false
57
67
 
58
- const point = { x: event.clientX, y: event.clientY }
59
- const deg = angle(s.center, s.anchor, point) - s.deg
68
+ const { center: selectionCenter } = selectionBox(editor.selection)
60
69
 
61
- for (const n of s.nodes) {
62
- n.node.rotation = snappedDeg(n.deg, deg, event.shiftKey)
70
+ state.current = {
71
+ initialDeg: angle(selectionCenter, point(), cursorPosition(event, page)),
72
+ page,
73
+ selectionCenter,
74
+ nodes: nodes.map((node) => ({ node, base: box(node) })),
63
75
  }
64
76
 
65
- const display =
66
- s.nodes.length === 1
67
- ? s.nodes[0].node.rotation
68
- : snappedDeg(s.deg, deg, event.shiftKey)
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)
69
88
 
70
89
  editor.action = {
71
90
  action: "rotate",
@@ -74,34 +93,37 @@ export function useRotation() {
74
93
  },
75
94
  }
76
95
  },
96
+
77
97
  onCancel() {
78
98
  editor.action = {}
79
- start.current = null
80
99
  },
81
- onEnd({ event }) {
82
- const s = start.current
83
- if (!s) return
84
100
 
85
- const point = { x: event.clientX, y: event.clientY }
86
- const deg = angle(s.center, s.anchor, point) - s.deg
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)
87
109
 
88
- if (s.nodes.length === 0) {
89
- const [n] = s.nodes
90
- const rotation = snappedDeg(n.deg, deg, event.shiftKey)
110
+ if (nodes.length === 1) {
111
+ const [n] = nodes
91
112
  editor.history.push({
92
- redo: ["set-node-props", [n.node.id, { rotation }]],
93
- undo: ["set-node-props", [n.node.id, { rotation: n.deg }]],
113
+ redo: ["set-node-props", [n.node.id, { rotation: n.node.rotation }]],
114
+ undo: ["set-node-props", [n.node.id, { rotation: n.base.rotation }]],
94
115
  })
95
- n.node.rotation = rotation
96
116
  } else {
97
117
  const redo: HistoryAction[] = []
98
118
  const undo: HistoryAction[] = []
99
119
 
100
- for (const n of s.nodes) {
101
- const rotation = snappedDeg(n.deg, deg, event.shiftKey)
102
- redo.push(["set-node-props", [n.node.id, { rotation }]])
103
- undo.push(["set-node-props", [n.node.id, { rotation: n.deg }]])
104
- n.node.rotation = rotation
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
+ ])
105
127
  }
106
128
 
107
129
  editor.history.push({
@@ -111,7 +133,6 @@ export function useRotation() {
111
133
  }
112
134
 
113
135
  editor.action = {}
114
- start.current = null
115
136
  },
116
137
  })
117
138
  }