@lazlon-platform/html-editor 0.3.6 → 0.5.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.
@@ -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),
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)
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,58 @@
1
1
  import { useRef } from "react"
2
- import { useStore } from "react-bolt"
3
- import { angle, type Point } from "../../model/geometry"
2
+ import { angle, deg, point, rectCenter, type Deg } from "../../model/geometry/math"
4
3
  import type { HistoryAction } from "../../model/history"
5
4
  import type { Node } from "../../model/node"
6
- import { getTargetDOMRect } from "../../ui/selection"
5
+ import { selectionDOMRect } from "../../ui/selection"
7
6
  import { useEditor } from "../editor"
8
7
  import { usePointer } from "./pointer"
9
8
 
10
- type RotationState = {
11
- anchor: Point
12
- center: Point
13
- deg: number
14
- nodes: Array<{ node: Node; deg: number }>
15
- }
16
-
17
9
  function snappedDeg(startDeg: number, delta: number, shiftKey: boolean) {
18
10
  const rotation = startDeg + delta
19
- return shiftKey ? Math.round(rotation / 45) * 45 : rotation
11
+ return deg(shiftKey ? Math.round(rotation / 45) * 45 : rotation)
20
12
  }
21
13
 
22
14
  export function useRotation() {
23
- const start = useRef<RotationState | null>(null)
24
15
  const editor = useEditor()
25
- const selection = useStore(editor, "selection")
26
- const selectionArray = selection.values().toArray()
16
+
17
+ const state = useRef({
18
+ centerPoint: point(),
19
+ inititalDeg: deg(0),
20
+ nodes: Array<{ node: Node; deg: Deg }>(),
21
+ })
27
22
 
28
23
  return usePointer({
29
24
  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
- })),
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 })),
51
36
  }
52
- editor.action = { action: "rotate", payload: { deg: 0 } }
37
+
38
+ const deg = nodes.length === 1 ? nodes[0].rotation : 0
39
+ editor.action = { action: "rotate", payload: { deg } }
53
40
  },
54
- onMove({ event }) {
55
- const s = start.current
56
- if (!s) return
57
41
 
58
- const point = { x: event.clientX, y: event.clientY }
59
- const deg = angle(s.center, s.anchor, point) - s.deg
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
60
47
 
61
- for (const n of s.nodes) {
48
+ for (const n of nodes) {
62
49
  n.node.rotation = snappedDeg(n.deg, deg, event.shiftKey)
63
50
  }
64
51
 
65
52
  const display =
66
- s.nodes.length === 1
67
- ? s.nodes[0].node.rotation
68
- : snappedDeg(s.deg, deg, event.shiftKey)
53
+ nodes.length === 1
54
+ ? nodes[0].node.rotation
55
+ : snappedDeg(inititalDeg, deg, event.shiftKey)
69
56
 
70
57
  editor.action = {
71
58
  action: "rotate",
@@ -74,19 +61,19 @@ export function useRotation() {
74
61
  },
75
62
  }
76
63
  },
64
+
77
65
  onCancel() {
78
66
  editor.action = {}
79
- start.current = null
80
67
  },
81
- onEnd({ event }) {
82
- const s = start.current
83
- if (!s) return
84
68
 
85
- const point = { x: event.clientX, y: event.clientY }
86
- const deg = angle(s.center, s.anchor, point) - s.deg
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
87
74
 
88
- if (s.nodes.length === 0) {
89
- const [n] = s.nodes
75
+ if (nodes.length === 0) {
76
+ const [n] = nodes
90
77
  const rotation = snappedDeg(n.deg, deg, event.shiftKey)
91
78
  editor.history.push({
92
79
  redo: ["set-node-props", [n.node.id, { rotation }]],
@@ -97,7 +84,7 @@ export function useRotation() {
97
84
  const redo: HistoryAction[] = []
98
85
  const undo: HistoryAction[] = []
99
86
 
100
- for (const n of s.nodes) {
87
+ for (const n of nodes) {
101
88
  const rotation = snappedDeg(n.deg, deg, event.shiftKey)
102
89
  redo.push(["set-node-props", [n.node.id, { rotation }]])
103
90
  undo.push(["set-node-props", [n.node.id, { rotation: n.deg }]])
@@ -111,7 +98,6 @@ export function useRotation() {
111
98
  }
112
99
 
113
100
  editor.action = {}
114
- start.current = null
115
101
  },
116
102
  })
117
103
  }
@@ -1,6 +1,7 @@
1
1
  import { useEffect, useRef } from "react"
2
2
  import { useComputed, useStore } from "react-bolt"
3
- import { getTargetDOMRect } from "../../ui/selection"
3
+ import { pointSubtract } from "../../model/geometry/math"
4
+ import { selectionDOMRect } from "../../ui/selection"
4
5
  import { useEditor } from "../editor"
5
6
 
6
7
  function arraysEqual<T>(a: T[], b: T[]): boolean {
@@ -73,23 +74,19 @@ export function useSelectionFrame<E extends HTMLElement>(props?: {
73
74
 
74
75
  if (props?.accountForSingleSelection && selection.size === 1 && frame) {
75
76
  const [firstNode] = selection
76
- const { page } = firstNode
77
-
78
77
  const { x, y, width, height, rotation } = firstNode
79
- const relative = page.ref!.getBoundingClientRect()
78
+ const relative = firstNode.page.ref!.getBoundingClientRect()
80
79
  const tx = relative.x - stage.x + x * zoom
81
80
  const ty = relative.y - stage.y + y * zoom
82
-
83
81
  frame.style.height = `${height * zoom}px`
84
82
  frame.style.width = `${width * zoom}px`
85
83
  frame.style.transform = `translate(${tx}px, ${ty}px) rotate(${rotation}deg)`
86
84
  } else if (frame) {
87
- const { x, y, width, height } = getTargetDOMRect(selection)
88
- const tx = x - stage.x
89
- const ty = y - stage.y
90
- frame.style.height = `${height}px`
91
- frame.style.width = `${width}px`
92
- frame.style.transform = `translate(${tx}px, ${ty}px)`
85
+ const dom = selectionDOMRect(selection)
86
+ const t = pointSubtract(dom, stage)
87
+ frame.style.height = `${dom.height}px`
88
+ frame.style.width = `${dom.width}px`
89
+ frame.style.transform = `translate(${t.x}px, ${t.y}px)`
93
90
  }
94
91
  })
95
92