@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
@@ -1,44 +1,61 @@
1
1
  import { useRef } from "react"
2
+ import { LineNode } from "../../model"
2
3
  import {
4
+ accessibleLine,
3
5
  box,
4
6
  boxBounds,
5
7
  boxContainsPoint,
8
+ lineContainsPoint,
6
9
  type Point,
7
10
  point,
8
11
  pointSubtract,
9
12
  rect,
10
13
  } from "../../model/geometry/math"
11
- import type { HistoryEntry } from "../../model/history"
12
14
  import type { Node } from "../../model/node"
13
15
  import { selectionBox } from "../../ui/selection"
14
16
  import { useEditor, usePage } from "../editor"
15
- import { cursorPosition, usePointer } from "./pointer"
16
- import { useSnap } from "./snap"
17
+ import { cursorPosition, usePointer } from "./usePointer"
18
+ import { useSnap } from "./useSnap"
17
19
 
18
20
  export function useMoveable() {
19
21
  const editor = useEditor()
20
22
  const page = usePage()
21
- const snap = useSnap()
23
+ const snap = useSnap(page)
22
24
 
23
25
  const state = useRef({
24
- initialSelectionRect: rect(),
26
+ baseRect: rect(),
25
27
  cursorOffset: point(),
26
28
  nodes: Array<{
27
29
  node: Node
28
- startingPoint: Point
30
+ basePosition: Point
29
31
  selectionOffset: Point
30
32
  }>(),
31
33
  })
32
34
 
35
+ function isCursorInSelection(cursor: Point) {
36
+ const [firstNode, ...rest] = editor.selection
37
+ if (rest.length === 0 && firstNode && firstNode instanceof LineNode) {
38
+ return lineContainsPoint(accessibleLine(firstNode), cursor)
39
+ }
40
+ return boxContainsPoint(selectionBox(editor.selection), cursor)
41
+ }
42
+
43
+ function isNodeHit(node: Node, cursor: Point) {
44
+ if (node instanceof LineNode) {
45
+ return lineContainsPoint(accessibleLine(node), cursor)
46
+ }
47
+ return boxContainsPoint(box(node), cursor)
48
+ }
49
+
33
50
  return usePointer({
34
51
  onDown(event) {
35
52
  const cursor = cursorPosition(event, page)
36
53
 
37
54
  // clicked outside of selection, try grabbing nodes under the cursor
38
- if (!boxContainsPoint(selectionBox(editor.selection), cursor)) {
55
+ if (!isCursorInSelection(cursor)) {
39
56
  const stackOrderedNodes = editor.nodes.values().toArray().toReversed()
40
57
  for (const node of stackOrderedNodes) {
41
- if (boxContainsPoint(box(node), cursor)) {
58
+ if (isNodeHit(node, cursor)) {
42
59
  editor.selection = new Set([node])
43
60
  break
44
61
  }
@@ -46,29 +63,25 @@ export function useMoveable() {
46
63
  }
47
64
 
48
65
  if (editor.selection.size === 0) return false
49
-
50
- const initialSelectionBox = selectionBox(editor.selection)
51
- const initialSelectionRect = boxBounds(initialSelectionBox)
66
+ if (!isCursorInSelection(cursor)) return false
52
67
 
53
68
  for (const node of editor.selection) {
54
- if (node.blockMove(event)) return false
69
+ if (node.locked) return false
55
70
  }
56
71
 
57
- if (boxContainsPoint(initialSelectionBox, cursor)) {
58
- state.current = {
59
- initialSelectionRect,
60
- cursorOffset: pointSubtract(cursor, initialSelectionRect),
61
- nodes: editor.selection
62
- .values()
63
- .map((node) => ({
64
- node,
65
- startingPoint: point(node),
66
- selectionOffset: pointSubtract(point(node), initialSelectionRect),
67
- }))
68
- .toArray(),
69
- }
70
- } else {
71
- return false
72
+ const baseRect = boxBounds(selectionBox(editor.selection))
73
+
74
+ state.current = {
75
+ baseRect,
76
+ cursorOffset: pointSubtract(cursor, baseRect),
77
+ nodes: editor.selection
78
+ .values()
79
+ .map((node) => ({
80
+ node,
81
+ basePosition: point(node),
82
+ selectionOffset: pointSubtract(point(node), baseRect),
83
+ }))
84
+ .toArray(),
72
85
  }
73
86
  },
74
87
 
@@ -81,8 +94,8 @@ export function useMoveable() {
81
94
  rect({
82
95
  x: target.x,
83
96
  y: target.y,
84
- width: state.current.initialSelectionRect.width,
85
- height: state.current.initialSelectionRect.height,
97
+ width: state.current.baseRect.width,
98
+ height: state.current.baseRect.height,
86
99
  }),
87
100
  )
88
101
 
@@ -103,17 +116,12 @@ export function useMoveable() {
103
116
  editor.action = {}
104
117
  page.snapLines = []
105
118
 
106
- const entries: HistoryEntry[] = state.current.nodes.map(
107
- ({ node, startingPoint: start }) => ({
108
- redo: ["set-node-props", [node.id, { x: node.x, y: node.y }]],
109
- undo: ["set-node-props", [node.id, { x: start.x, y: start.y }]],
110
- }),
119
+ editor.pushHistory(
120
+ state.current.nodes.map(({ node, basePosition }) => ({
121
+ redo: ["set-node-props", [node.id, point(node)]],
122
+ undo: ["set-node-props", [node.id, basePosition]],
123
+ })),
111
124
  )
112
-
113
- editor.history.push({
114
- redo: ["batch", entries.map((e) => e.redo)],
115
- undo: ["batch", entries.map((e) => e.undo)],
116
- })
117
125
  },
118
126
  })
119
127
  }
@@ -1,4 +1,4 @@
1
- import { Page, type Node } from "@lazlon/html-editor/model"
1
+ import { Page } from "@lazlon/html-editor/model"
2
2
  import { useCallback, useRef } from "react"
3
3
  import { floatNorm, type Point } from "../../model/geometry/math"
4
4
 
@@ -11,15 +11,14 @@ export type UsePointerProps = {
11
11
 
12
12
  export function cursorPosition(
13
13
  event: { clientX: number; clientY: number },
14
- relativeTo: Page | Node,
14
+ page: Page,
15
15
  ): Point {
16
- const page = relativeTo instanceof Page ? relativeTo : relativeTo.page
17
16
  const { zoom } = page.editor
18
17
  const { x, y } = page.ref!.getBoundingClientRect()
19
18
 
20
19
  return {
21
- x: floatNorm(event.clientX / zoom - x),
22
- y: floatNorm(event.clientY / zoom - y),
20
+ x: floatNorm((event.clientX - x) / zoom),
21
+ y: floatNorm((event.clientY - y) / zoom),
23
22
  }
24
23
  }
25
24
 
@@ -0,0 +1,31 @@
1
+ import { TextNode } from "@lazlon/html-editor/model"
2
+ import { useComputed, useStore } from "react-bolt"
3
+ import type { Corner, Edge } from "../../../model/geometry/math"
4
+ import { useEditor } from "../../editor"
5
+ import { useMultiResize } from "./multi"
6
+ import { useSingleRegularNodeResize } from "./singleRegularNode"
7
+ import { useSingleTextNodeResize } from "./singleTextNode"
8
+ import type { usePointer } from "../usePointer"
9
+
10
+ export function useResize(direction: Edge | Corner): ReturnType<typeof usePointer> {
11
+ const editor = useEditor()
12
+ const selection = useStore(editor, "selection")
13
+ const [node, ...nodes] = selection
14
+ const singleText = useSingleTextNodeResize(direction)
15
+ const single = useSingleRegularNodeResize(direction)
16
+ const multi = useMultiResize(direction)
17
+ const isLocked = useComputed(() => selection.values().some((node) => node.locked))
18
+
19
+ if (isLocked) {
20
+ return {
21
+ onPointerDown() {},
22
+ isMoving: () => false,
23
+ }
24
+ }
25
+
26
+ return nodes.length === 0
27
+ ? node instanceof TextNode && direction !== "w" && direction !== "e"
28
+ ? singleText
29
+ : single
30
+ : multi
31
+ }
@@ -0,0 +1,161 @@
1
+ import { useRef } from "react"
2
+ import { useStore } from "react-bolt"
3
+ import { LineNode, TextNode, type HistoryEntry, type Node } from "../../../model"
4
+ import {
5
+ boxRect,
6
+ point,
7
+ pointSubtract,
8
+ rect,
9
+ type Corner,
10
+ type Edge,
11
+ type Point,
12
+ type Rect,
13
+ } from "../../../model/geometry/math"
14
+ import { selectionBox } from "../../../ui/selection"
15
+ import { useEditor } from "../../editor"
16
+ import { cursorPosition, usePointer } from "../usePointer"
17
+ import { useMultiLineNodeResize } from "./multiLineNode"
18
+ import { useMultiRegularNodeResize } from "./multiRegularNode"
19
+ import { useMultiTextNodeResize } from "./multiTextNode"
20
+
21
+ interface MultiResizeHookDownContext {
22
+ event: React.PointerEvent
23
+ baseSelectionRect: Rect
24
+ baseCursorPosition: Point
25
+ relativeSize(node: Node): Rect
26
+ }
27
+
28
+ interface MultiResizeHookMoveContext {
29
+ event: globalThis.PointerEvent
30
+ baseSelectionRect: Rect
31
+ baseCursorPosition: Point
32
+ selectionRect: Rect
33
+ cursorPosition: Point
34
+ }
35
+
36
+ interface MultiResizeHookEndContext {
37
+ event: globalThis.PointerEvent
38
+ baseSelectionRect: Rect
39
+ baseCursorPosition: Point
40
+ }
41
+
42
+ export interface MultiResizeHook<N extends Node> {
43
+ onDown(ctx: MultiResizeHookDownContext): void
44
+ onMove(ctx: MultiResizeHookMoveContext): void
45
+ onEnd(ctx: MultiResizeHookEndContext): Array<HistoryEntry<N>>
46
+ }
47
+
48
+ export function useMultiResize(direction: Edge | Corner) {
49
+ const editor = useEditor()
50
+ const selection = useStore(editor, "selection").values().toArray()
51
+
52
+ const text = useMultiTextNodeResize(
53
+ direction,
54
+ selection.filter((node) => node instanceof TextNode),
55
+ )
56
+
57
+ const line = useMultiLineNodeResize(
58
+ direction,
59
+ selection.filter((node) => node instanceof LineNode),
60
+ )
61
+
62
+ const regular = useMultiRegularNodeResize(
63
+ direction,
64
+ selection.filter((node) => !(node instanceof TextNode || node instanceof LineNode)),
65
+ )
66
+
67
+ const state = useRef({
68
+ baseSelectionRect: rect(),
69
+ baseCursorPosition: point(),
70
+ })
71
+
72
+ return usePointer({
73
+ onDown(event) {
74
+ if (selection.length === 0 || !editor.selectionPage) {
75
+ return false
76
+ }
77
+
78
+ const baseSelectionRect = boxRect(selectionBox(editor.selection))
79
+ const baseCursorPosition = cursorPosition(event, editor.selectionPage!)
80
+ const { x, y, width, height } = baseSelectionRect
81
+
82
+ const ctx: MultiResizeHookDownContext = {
83
+ event,
84
+ baseSelectionRect,
85
+ baseCursorPosition,
86
+ relativeSize: (node) => {
87
+ return rect({
88
+ x: (node.x - x) / width,
89
+ y: (node.y - y) / height,
90
+ width: node.width / width,
91
+ height: node.height / height,
92
+ })
93
+ },
94
+ }
95
+
96
+ state.current = {
97
+ baseSelectionRect,
98
+ baseCursorPosition,
99
+ }
100
+
101
+ text.onDown(ctx)
102
+ line.onDown(ctx)
103
+ regular.onDown(ctx)
104
+ },
105
+
106
+ onMove(event) {
107
+ const r = { ...state.current.baseSelectionRect }
108
+ const p = pointSubtract(
109
+ cursorPosition(event, editor.selectionPage!),
110
+ state.current.baseCursorPosition,
111
+ )
112
+
113
+ /* mutate rect */ {
114
+ if (direction.includes("n") && r.height - p.y > 0) {
115
+ r.height -= p.y
116
+ r.y += p.y
117
+ }
118
+ if (direction.includes("s")) {
119
+ r.height += p.y
120
+ }
121
+ if (direction.includes("w") && r.width - p.x > 0) {
122
+ r.width -= p.x
123
+ r.x += p.x
124
+ }
125
+ if (direction.includes("e")) {
126
+ r.width += p.x
127
+ }
128
+ }
129
+
130
+ const ctx: MultiResizeHookMoveContext = {
131
+ event,
132
+ baseSelectionRect: state.current.baseSelectionRect,
133
+ baseCursorPosition: state.current.baseCursorPosition,
134
+ selectionRect: r,
135
+ cursorPosition: p,
136
+ }
137
+
138
+ text.onMove(ctx)
139
+ line.onMove(ctx)
140
+ regular.onMove(ctx)
141
+
142
+ editor.action = { action: "resize", payload: r }
143
+ },
144
+
145
+ onCancel() {
146
+ editor.action = {}
147
+ },
148
+
149
+ onEnd(event) {
150
+ editor.action = {}
151
+
152
+ const ctx: MultiResizeHookEndContext = {
153
+ event,
154
+ baseSelectionRect: state.current.baseSelectionRect,
155
+ baseCursorPosition: state.current.baseCursorPosition,
156
+ }
157
+
158
+ editor.pushHistory([...text.onEnd(ctx), ...line.onEnd(ctx), ...regular.onEnd(ctx)])
159
+ },
160
+ })
161
+ }
@@ -0,0 +1,99 @@
1
+ import { useRef } from "react"
2
+ import type { LineNode } from "../../../model"
3
+ import {
4
+ deg,
5
+ floatNorm,
6
+ point,
7
+ pointSubtract,
8
+ rect,
9
+ rectCenter,
10
+ rotatePoint,
11
+ type Corner,
12
+ type Edge,
13
+ type Point,
14
+ type Rect,
15
+ } from "../../../model/geometry/math"
16
+ import type { MultiResizeHook } from "./multi"
17
+
18
+ type NodeState = {
19
+ node: LineNode
20
+ basePoints: Point[]
21
+ baseX: number
22
+ baseY: number
23
+ relativeVertices: Point[]
24
+ }
25
+
26
+ function relativePoint(p: Point, selectionRect: Rect): Point {
27
+ return {
28
+ x: selectionRect.width === 0 ? 0 : (p.x - selectionRect.x) / selectionRect.width,
29
+ y: selectionRect.height === 0 ? 0 : (p.y - selectionRect.y) / selectionRect.height,
30
+ }
31
+ }
32
+
33
+ function pagePoint(relative: Point, selectionRect: Rect): Point {
34
+ return {
35
+ x: selectionRect.x + relative.x * selectionRect.width,
36
+ y: selectionRect.y + relative.y * selectionRect.height,
37
+ }
38
+ }
39
+
40
+ function unrotatedPagePoints(vertices: Point[], rotation: number): Point[] {
41
+ const origin = point()
42
+ const unrotatedBounds = rect(
43
+ ...vertices.map((p) => rotatePoint(p, origin, deg(-rotation))),
44
+ )
45
+ const center = rotatePoint(rectCenter(unrotatedBounds), origin, deg(rotation))
46
+ return vertices.map((p) => rotatePoint(p, center, deg(-rotation)))
47
+ }
48
+
49
+ function setLinePoints(node: LineNode, pagePoints: Point[]) {
50
+ const bounds = point(rect(...pagePoints))
51
+ node.x = floatNorm(bounds.x)
52
+ node.y = floatNorm(bounds.y)
53
+ node.points = pagePoints.map((p) => pointSubtract(p, bounds))
54
+ }
55
+
56
+ export function useMultiLineNodeResize(
57
+ _direction: Edge | Corner,
58
+ nodes: LineNode[],
59
+ ): MultiResizeHook<LineNode> {
60
+ const state = useRef(Array<NodeState>())
61
+
62
+ function redoProps(state: NodeState) {
63
+ const { x, y, points } = state.node
64
+ return { x, y, points }
65
+ }
66
+
67
+ function undoProps(state: NodeState) {
68
+ const { baseX: x, baseY: y, basePoints: points } = state
69
+ return { x, y, points }
70
+ }
71
+
72
+ return {
73
+ onDown({ baseSelectionRect }) {
74
+ state.current = nodes.map((node) => {
75
+ return {
76
+ node,
77
+ basePoints: node.points,
78
+ baseX: node.x,
79
+ baseY: node.y,
80
+ relativeVertices: node.vertices.map((p) => relativePoint(p, baseSelectionRect)),
81
+ }
82
+ })
83
+ },
84
+
85
+ onMove({ selectionRect }) {
86
+ for (const item of state.current) {
87
+ const vertices = item.relativeVertices.map((p) => pagePoint(p, selectionRect))
88
+ setLinePoints(item.node, unrotatedPagePoints(vertices, item.node.rotation))
89
+ }
90
+ },
91
+
92
+ onEnd() {
93
+ return state.current.map((state) => ({
94
+ redo: ["set-node-props", [state.node.id, redoProps(state)]],
95
+ undo: ["set-node-props", [state.node.id, undoProps(state)]],
96
+ }))
97
+ },
98
+ }
99
+ }
@@ -0,0 +1,109 @@
1
+ import { useRef } from "react"
2
+ import type { Node } from "../../../model"
3
+ import {
4
+ box,
5
+ boxRect,
6
+ rect,
7
+ rectCenter,
8
+ type Box,
9
+ type Corner,
10
+ type Edge,
11
+ type Point,
12
+ type Rect,
13
+ } from "../../../model/geometry/math"
14
+ import type { MultiResizeHook } from "./multi"
15
+ import { setRegularNodeSize } from "./singleRegularNode"
16
+
17
+ type NodeState = {
18
+ node: Node
19
+ baseSize: Box
20
+ relative: Rect
21
+ }
22
+
23
+ function resizeScale(enabled: boolean, base: number, target: number) {
24
+ return enabled && base !== 0 ? target / base : 1
25
+ }
26
+
27
+ function selectionScale(
28
+ direction: Edge | Corner,
29
+ baseSelectionRect: Rect,
30
+ selectionRect: Rect,
31
+ ) {
32
+ return {
33
+ x: resizeScale(
34
+ direction.includes("w") || direction.includes("e"),
35
+ baseSelectionRect.width,
36
+ selectionRect.width,
37
+ ),
38
+ y: resizeScale(
39
+ direction.includes("n") || direction.includes("s"),
40
+ baseSelectionRect.height,
41
+ selectionRect.height,
42
+ ),
43
+ }
44
+ }
45
+
46
+ function scaledRegularBox(baseSize: Box, target: Rect, scale: Point): Box {
47
+ const r = (baseSize.rotation * Math.PI) / 180
48
+ const cos = Math.cos(r)
49
+ const sin = Math.sin(r)
50
+ const center = rectCenter(target)
51
+
52
+ return {
53
+ center,
54
+ pivot: center,
55
+ rotation: baseSize.rotation,
56
+ width: baseSize.width * Math.hypot(scale.x * cos, scale.y * sin),
57
+ height: baseSize.height * Math.hypot(scale.x * sin, scale.y * cos),
58
+ }
59
+ }
60
+
61
+ export function useMultiRegularNodeResize(
62
+ direction: Edge | Corner,
63
+ nodes: Node[],
64
+ ): MultiResizeHook<Node> {
65
+ const state = useRef(Array<NodeState>())
66
+
67
+ function redoProps(state: NodeState) {
68
+ const { x, y, width, height } = state.node
69
+ return { x, y, width, height }
70
+ }
71
+
72
+ function undoProps(state: NodeState) {
73
+ const { x, y, width, height } = boxRect(state.baseSize)
74
+ return { x, y, width, height }
75
+ }
76
+
77
+ return {
78
+ onDown({ relativeSize }) {
79
+ state.current = nodes.map((node) => ({
80
+ node,
81
+ baseSize: box(node),
82
+ relative: relativeSize(node),
83
+ }))
84
+ },
85
+
86
+ onMove({ baseSelectionRect, selectionRect }) {
87
+ const { x, y, width, height } = selectionRect
88
+ const scale = selectionScale(direction, baseSelectionRect, selectionRect)
89
+
90
+ for (const { node, relative, baseSize } of state.current) {
91
+ const size = rect({
92
+ x: relative.x * width + x,
93
+ y: relative.y * height + y,
94
+ width: width * relative.width,
95
+ height: height * relative.height,
96
+ })
97
+
98
+ setRegularNodeSize(node, scaledRegularBox(baseSize, size, scale))
99
+ }
100
+ },
101
+
102
+ onEnd() {
103
+ return state.current.map((state) => ({
104
+ undo: ["set-node-props", [state.node.id, undoProps(state)]],
105
+ redo: ["set-node-props", [state.node.id, redoProps(state)]],
106
+ }))
107
+ },
108
+ }
109
+ }
@@ -0,0 +1,108 @@
1
+ import { useRef } from "react"
2
+ import { TextNode } from "../../../model"
3
+ import {
4
+ box,
5
+ boxRect,
6
+ rect,
7
+ scaleBox,
8
+ type Box,
9
+ type Corner,
10
+ type Edge,
11
+ type Rect,
12
+ } from "../../../model/geometry/math"
13
+ import type { MultiResizeHook } from "./multi"
14
+ import { setTextNodeSize } from "./singleTextNode"
15
+ import { setRegularNodeSize } from "./singleRegularNode"
16
+
17
+ type NodeState = {
18
+ node: TextNode
19
+ baseFontSize: number
20
+ baseBoxSize: Box
21
+ relative: Rect
22
+ }
23
+
24
+ function textScale(base: Box, target: Rect, direction: Edge | Corner) {
25
+ const horizontal = direction.includes("w") || direction.includes("e")
26
+ const vertical = direction.includes("n") || direction.includes("s")
27
+ const widthScale = base.width === 0 ? 1 : target.width / base.width
28
+ const heightScale = base.height === 0 ? 1 : target.height / base.height
29
+
30
+ if (horizontal && vertical) return Math.min(widthScale, heightScale)
31
+ if (horizontal) return widthScale
32
+ return heightScale
33
+ }
34
+
35
+ function scaledTextBox(base: Box, target: Rect, direction: Edge | Corner) {
36
+ const scale = textScale(base, target, direction)
37
+ const by =
38
+ direction.includes("n") || direction.includes("s")
39
+ ? base.height * scale - base.height
40
+ : base.width * scale - base.width
41
+
42
+ return scaleBox(base, direction, by)
43
+ }
44
+
45
+ export function useMultiTextNodeResize(
46
+ direction: Edge | Corner,
47
+ nodes: TextNode[],
48
+ ): MultiResizeHook<TextNode> {
49
+ const state = useRef(Array<NodeState>())
50
+
51
+ function redoProps(state: NodeState) {
52
+ const { x, y, width, height, size } = state.node
53
+ return { x, y, width, height, size }
54
+ }
55
+
56
+ function undoProps(state: NodeState) {
57
+ const { baseBoxSize, baseFontSize: size } = state
58
+ const { x, y, width, height } = boxRect(baseBoxSize)
59
+ return { x, y, width, height, size }
60
+ }
61
+
62
+ return {
63
+ onDown({ relativeSize }) {
64
+ state.current = nodes.map((node) => ({
65
+ node,
66
+ baseFontSize: node.size,
67
+ baseBoxSize: box(node),
68
+ relative: relativeSize(node),
69
+ }))
70
+ },
71
+
72
+ onMove({ selectionRect }) {
73
+ const { width, height, x, y } = selectionRect
74
+ for (const { node, relative, baseBoxSize, baseFontSize } of state.current) {
75
+ const size = rect({
76
+ x: relative.x * width + x,
77
+ y: relative.y * height + y,
78
+ width: width * relative.width,
79
+ height: height * relative.height,
80
+ })
81
+
82
+ if (direction === "w" || direction === "e") {
83
+ setRegularNodeSize(node, box(size))
84
+ } else {
85
+ const scaled = scaledTextBox(baseBoxSize, size, direction)
86
+ setTextNodeSize(
87
+ node,
88
+ baseFontSize,
89
+ baseBoxSize,
90
+ box({
91
+ x: size.x + size.width / 2 - scaled.width / 2,
92
+ y: size.y + size.height / 2 - scaled.height / 2,
93
+ width: scaled.width,
94
+ height: scaled.height,
95
+ }),
96
+ )
97
+ }
98
+ }
99
+ },
100
+
101
+ onEnd() {
102
+ return state.current.map((state) => ({
103
+ redo: ["set-node-props", [state.node.id, redoProps(state)]],
104
+ undo: ["set-node-props", [state.node.id, undoProps(state)]],
105
+ }))
106
+ },
107
+ }
108
+ }