@lazlon-platform/html-editor 0.5.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 (53) hide show
  1. package/lib/hooks/actions.ts +51 -24
  2. package/lib/hooks/batch.ts +17 -7
  3. package/lib/hooks/index.ts +1 -0
  4. package/lib/hooks/pointer/movePoint.ts +75 -0
  5. package/lib/hooks/pointer/moveable.ts +22 -8
  6. package/lib/hooks/pointer/pointer.ts +2 -3
  7. package/lib/hooks/pointer/resize.ts +2 -2
  8. package/lib/hooks/pointer/rotation.ts +74 -39
  9. package/lib/hooks/pointer/selector.ts +16 -2
  10. package/lib/hooks/pointer/snap.ts +3 -3
  11. package/lib/hooks/textMarks.ts +1 -3
  12. package/lib/lib/googleFonts.ts +1 -5
  13. package/lib/model/editor.ts +13 -9
  14. package/lib/model/geometry/math.ts +139 -0
  15. package/lib/model/history.ts +10 -13
  16. package/lib/model/index.ts +7 -10
  17. package/lib/model/node/{editable → editableNode}/index.ts +13 -29
  18. package/lib/model/node/{formattable.ts → formattableNode/index.ts} +5 -11
  19. package/lib/model/node/{group.ts → groupNode.ts} +9 -13
  20. package/lib/model/node/{image.ts → imageNode.ts} +5 -11
  21. package/lib/model/node/lineNode.ts +59 -0
  22. package/lib/model/node/{shape/shape.ts → shapeNode/index.ts} +30 -15
  23. package/lib/model/node/shapeNode/shape.ts +96 -0
  24. package/lib/model/node/{text.ts → textNode.ts} +6 -12
  25. package/lib/model/node.ts +9 -23
  26. package/lib/model/page.ts +1 -2
  27. package/lib/model/traversal.ts +1 -1
  28. package/lib/ui/extractor.ts +3 -3
  29. package/lib/ui/index.ts +2 -4
  30. package/lib/ui/node/{EditableContent.tsx → EditableContent/index.tsx} +4 -3
  31. package/lib/ui/node/GroupContent.tsx +1 -1
  32. package/lib/ui/node/ImageContent.tsx +1 -1
  33. package/lib/ui/node/LineContent.tsx +32 -0
  34. package/lib/ui/node/ShapeContent/ArrowContent.tsx +57 -0
  35. package/lib/ui/node/ShapeContent/EllipseContent.tsx +37 -0
  36. package/lib/ui/node/ShapeContent/PolygonContent.tsx +62 -0
  37. package/lib/ui/node/ShapeContent/RectangleContent.tsx +35 -0
  38. package/lib/ui/node/ShapeContent/StarContent.tsx +75 -0
  39. package/lib/ui/node/ShapeContent/index.tsx +43 -0
  40. package/lib/ui/node/TextContent.tsx +1 -1
  41. package/package.json +1 -1
  42. package/lib/model/node/shape/arrow.ts +0 -50
  43. package/lib/model/node/shape/ellipse.ts +0 -26
  44. package/lib/model/node/shape/polygon.ts +0 -130
  45. package/lib/model/node/shape/star.ts +0 -91
  46. package/lib/ui/node/ArrowContent.tsx +0 -60
  47. package/lib/ui/node/EllipseContent.tsx +0 -49
  48. package/lib/ui/node/PolygonContent.tsx +0 -81
  49. package/lib/ui/node/StarContent.tsx +0 -60
  50. /package/lib/model/node/{editable → editableNode}/letterSpacing.ts +0 -0
  51. /package/lib/model/node/{editable → editableNode}/persistentMarks.ts +0 -0
  52. /package/lib/model/node/{editable → editableNode}/tiptapExtensions.ts +0 -0
  53. /package/lib/ui/node/{useDoubleClick.ts → EditableContent/useDoubleClick.ts} +0 -0
@@ -1,21 +1,30 @@
1
1
  import { useComputed } from "react-bolt"
2
+ import {
3
+ flattenNodes,
4
+ GroupNode,
5
+ type HistoryAction,
6
+ type Node,
7
+ type NodeProps,
8
+ type Page,
9
+ } from "../model"
2
10
  import type { Editor, NodeConstructor } from "../model/editor"
3
- import type { HistoryAction } from "../model/history"
4
- import type { Node, NodeProps } from "../model/node"
5
- import { GroupNode } from "../model/node/group"
6
- import type { Page } from "../model/page"
7
- import { flattenNodes } from "../model/traversal"
11
+ import {
12
+ boxBounds,
13
+ deg,
14
+ floatNorm,
15
+ rectCenter,
16
+ rotatePoint,
17
+ } from "../model/geometry/math"
8
18
  import { selectionBox } from "../ui/selection"
9
19
  import { useBatchSet } from "./batch"
10
20
  import { useEditor } from "./editor"
11
- import { boxBounds } from "../model/geometry/math"
12
21
 
13
22
  export function clone(
14
23
  editor: Editor,
15
24
  node: Node,
16
25
  props?: ((props: NodeProps) => Partial<NodeProps>) | Partial<NodeProps>,
17
26
  ) {
18
- const copy = node.serialize().props
27
+ const copy = node.props
19
28
  const newProps = typeof props === "function" ? props(copy) : props
20
29
  const newNode = editor.deserializeNode(node.page, {
21
30
  props: { ...copy, ...newProps },
@@ -35,14 +44,14 @@ export function useAddNodeAction(page?: Page) {
35
44
 
36
45
  return function addNode<N extends NodeConstructor>(
37
46
  NodeClass: N,
38
- props?: Partial<ConstructorParameters<N>[2]>,
47
+ props?: Partial<N["prototype"]["props"]>,
39
48
  ) {
40
49
  const [firstPage] = editor.pages.values()
41
50
  const targetPage = page ?? firstPage
42
51
  if (!targetPage) return
43
52
  const { width, height, nodes } = targetPage
44
53
 
45
- const node = new NodeClass(editor, targetPage, {
54
+ const node = new NodeClass(targetPage, {
46
55
  id: editor.id(),
47
56
  x: Math.round(width / 2) - 50,
48
57
  y: Math.round(height / 2) - 50,
@@ -53,7 +62,7 @@ export function useAddNodeAction(page?: Page) {
53
62
 
54
63
  targetPage.nodes = new Map([...nodes, [node.id, node]])
55
64
  editor.history.push({
56
- redo: ["add-node", [targetPage.id, [node.serialize()]]],
65
+ redo: ["add-node", [targetPage.id, [editor.serializeNode(node)]]],
57
66
  undo: ["delete-node", [targetPage.id, [node.id]]],
58
67
  })
59
68
 
@@ -83,7 +92,7 @@ export function useTrashAction() {
83
92
 
84
93
  editor.history.push({
85
94
  redo: ["delete-node", [page.id, array.map((node) => node.id)]],
86
- undo: ["add-node", [page.id, array.map((n) => n.serialize())]],
95
+ undo: ["add-node", [page.id, array.map((n) => editor.serializeNode(n))]],
87
96
  })
88
97
 
89
98
  editor.selection = new Set()
@@ -99,22 +108,25 @@ export function useGroupAction() {
99
108
  const { width, height, x, y } = boxBounds(selectionBox(selection))
100
109
  if (selection.size === 0 || !page) return
101
110
 
102
- const group = new GroupNode(editor, page, {
111
+ const group = new GroupNode(page, {
103
112
  id: editor.id(),
104
113
  width,
105
114
  height,
106
115
  x,
107
116
  y,
108
- nodes: selection
117
+ })
118
+
119
+ group.nodes = new Set(
120
+ selection
109
121
  .values()
110
122
  .map((node) =>
111
123
  clone(editor, node, (n) => ({
112
124
  x: n.x - x,
113
125
  y: n.y - y,
114
- })).serialize(),
126
+ })),
115
127
  )
116
128
  .toArray(),
117
- })
129
+ )
118
130
 
119
131
  editor.selection = new Set()
120
132
  page.nodes = new Map([
@@ -139,7 +151,7 @@ export function useGroupAction() {
139
151
  .toArray(),
140
152
  ],
141
153
  ],
142
- ["add-node", [page.id, [group.serialize()]]],
154
+ ["add-node", [page.id, [editor.serializeNode(group)]]],
143
155
  ],
144
156
  ],
145
157
  undo: [
@@ -152,7 +164,7 @@ export function useGroupAction() {
152
164
  page.id,
153
165
  selection
154
166
  .values()
155
- .map((n) => n.serialize())
167
+ .map((n) => editor.serializeNode(n))
156
168
  .toArray(),
157
169
  ],
158
170
  ],
@@ -163,13 +175,28 @@ export function useGroupAction() {
163
175
 
164
176
  function ungroup(group: GroupNode) {
165
177
  const page = group.page
178
+ const groupCenter = rectCenter(group)
166
179
  const nodes = group.nodes
167
180
  .values()
168
181
  .map((node) =>
169
- clone(editor, node, (n) => ({
170
- x: n.x + group.x,
171
- y: n.y + group.y,
172
- })),
182
+ clone(editor, node, (n) => {
183
+ const center = rotatePoint(
184
+ rectCenter({
185
+ x: n.x + group.x,
186
+ y: n.y + group.y,
187
+ width: n.width,
188
+ height: n.height,
189
+ }),
190
+ groupCenter,
191
+ group.rotation,
192
+ )
193
+
194
+ return {
195
+ x: floatNorm(center.x - n.width / 2),
196
+ y: floatNorm(center.y - n.height / 2),
197
+ rotation: deg((n.rotation ?? 0) + group.rotation),
198
+ }
199
+ }),
173
200
  )
174
201
  .toArray()
175
202
 
@@ -184,14 +211,14 @@ export function useGroupAction() {
184
211
  "batch",
185
212
  [
186
213
  ["delete-node", [page.id, [group.id]]],
187
- ["add-node", [page.id, nodes.map((n) => n.serialize())]],
214
+ ["add-node", [page.id, nodes.map((n) => editor.serializeNode(n))]],
188
215
  ],
189
216
  ],
190
217
  undo: [
191
218
  "batch",
192
219
  [
193
220
  ["delete-node", [page.id, nodes.map((node) => node.id)]],
194
- ["add-node", [page.id, [group.serialize()]]],
221
+ ["add-node", [page.id, [editor.serializeNode(group)]]],
195
222
  ],
196
223
  ],
197
224
  })
@@ -226,7 +253,7 @@ export function useDuplicateAction() {
226
253
 
227
254
  editor.selection = new Set(nodes)
228
255
  editor.history.push({
229
- redo: ["add-node", [page.id, nodes.map((n) => n.serialize())]],
256
+ redo: ["add-node", [page.id, nodes.map((n) => editor.serializeNode(n))]],
230
257
  undo: ["delete-node", [page.id, nodes.map((node) => node.id)]],
231
258
  })
232
259
  }
@@ -5,27 +5,37 @@ import { type HistoryAction } from "../model/history"
5
5
  import { Node } from "../model/node"
6
6
  import { useEditor } from "./editor"
7
7
 
8
+ export function reduce<T>(values: T[], fallback: T) {
9
+ const [first, ...rest] = values
10
+ return rest.reduce(
11
+ (acc, value) => (isEqual(value, acc) ? acc : fallback),
12
+ values.length > 0 ? first : fallback,
13
+ )
14
+ }
15
+
8
16
  export function useNodeField<N extends Node, K extends keyof N>(
9
17
  nodes: Iterable<N>,
10
18
  key: K,
11
19
  fallback: N[K],
12
20
  ) {
13
- const [first, ...rest] = nodes
21
+ const values = Array.from(nodes).map((node) => node[key])
14
22
  return useComputed<N[K]>({
15
23
  equals: isEqual,
16
- fn: () =>
17
- rest.reduce(
18
- (acc, node) => (isEqual(node[key], acc) ? acc : fallback),
19
- first ? first[key] : fallback,
20
- ),
24
+ fn: () => reduce(values, fallback),
21
25
  })
22
26
  }
23
27
 
28
+ export interface NodeFieldBatch<N extends Node, T> {
29
+ value: T
30
+ onChange(set: T | ((node: N) => T)): void
31
+ onChangeEnd(set: T | ((node: N) => T)): void
32
+ }
33
+
24
34
  export function useNodeFieldBatch<N extends Node, K extends keyof N>(
25
35
  nodes: N[],
26
36
  key: K,
27
37
  fallback: N[K],
28
- ) {
38
+ ): NodeFieldBatch<N, N[K]> {
29
39
  const { history } = useEditor()
30
40
  const initial = useRef<Map<N, N[K]> | null>(null)
31
41
 
@@ -13,6 +13,7 @@ export {
13
13
  export { useBatchSet, useNodeField, useNodeFieldBatch } from "./batch"
14
14
  export { EditorContext, PageContext, useEditor, usePage } from "./editor"
15
15
  export { useMoveable } from "./pointer/moveable"
16
+ export { useMovePoint } from "./pointer/movePoint"
16
17
  export { usePointer } from "./pointer/pointer"
17
18
  export { useResize } from "./pointer/resize"
18
19
  export { useRotation } from "./pointer/rotation"
@@ -0,0 +1,75 @@
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,8 +1,10 @@
1
1
  import { useRef } from "react"
2
2
  import {
3
+ accessibleLine,
3
4
  box,
4
5
  boxBounds,
5
6
  boxContainsPoint,
7
+ lineContainsPoint,
6
8
  type Point,
7
9
  point,
8
10
  pointSubtract,
@@ -14,11 +16,12 @@ import { selectionBox } from "../../ui/selection"
14
16
  import { useEditor, usePage } from "../editor"
15
17
  import { cursorPosition, usePointer } from "./pointer"
16
18
  import { useSnap } from "./snap"
19
+ import { LineNode } from "@lazlon/html-editor/model"
17
20
 
18
21
  export function useMoveable() {
19
22
  const editor = useEditor()
20
23
  const page = usePage()
21
- const snap = useSnap()
24
+ const snap = useSnap(page)
22
25
 
23
26
  const state = useRef({
24
27
  initialSelectionRect: rect(),
@@ -30,15 +33,30 @@ export function useMoveable() {
30
33
  }>(),
31
34
  })
32
35
 
36
+ function isCursorInSelection(cursor: Point) {
37
+ const [firstNode, ...rest] = editor.selection
38
+ if (rest.length === 0 && firstNode && firstNode instanceof LineNode) {
39
+ return lineContainsPoint(accessibleLine(firstNode), cursor)
40
+ }
41
+ return boxContainsPoint(selectionBox(editor.selection), cursor)
42
+ }
43
+
44
+ function isNodeHit(node: Node, cursor: Point) {
45
+ if (node instanceof LineNode) {
46
+ return lineContainsPoint(accessibleLine(node), cursor)
47
+ }
48
+ return boxContainsPoint(box(node), cursor)
49
+ }
50
+
33
51
  return usePointer({
34
52
  onDown(event) {
35
53
  const cursor = cursorPosition(event, page)
36
54
 
37
55
  // clicked outside of selection, try grabbing nodes under the cursor
38
- if (!boxContainsPoint(selectionBox(editor.selection), cursor)) {
56
+ if (!isCursorInSelection(cursor)) {
39
57
  const stackOrderedNodes = editor.nodes.values().toArray().toReversed()
40
58
  for (const node of stackOrderedNodes) {
41
- if (boxContainsPoint(box(node), cursor)) {
59
+ if (isNodeHit(node, cursor)) {
42
60
  editor.selection = new Set([node])
43
61
  break
44
62
  }
@@ -50,11 +68,7 @@ export function useMoveable() {
50
68
  const initialSelectionBox = selectionBox(editor.selection)
51
69
  const initialSelectionRect = boxBounds(initialSelectionBox)
52
70
 
53
- for (const node of editor.selection) {
54
- if (node.blockMove(event)) return false
55
- }
56
-
57
- if (boxContainsPoint(initialSelectionBox, cursor)) {
71
+ if (isCursorInSelection(cursor)) {
58
72
  state.current = {
59
73
  initialSelectionRect,
60
74
  cursorOffset: pointSubtract(cursor, initialSelectionRect),
@@ -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,9 +11,8 @@ 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
 
@@ -77,11 +77,11 @@ export function useSingleResize(direction: Edge | Corner) {
77
77
  onDown(event) {
78
78
  state.current = {
79
79
  baseSize: box(node),
80
- anchorPoint: cursorPosition(event, node),
80
+ anchorPoint: cursorPosition(event, node.page),
81
81
  }
82
82
  },
83
83
  onMove(event) {
84
- const cursor = cursorPosition(event, node)
84
+ const cursor = cursorPosition(event, node.page)
85
85
 
86
86
  if (node instanceof TextNode && direction !== "w" && direction !== "e") {
87
87
  const size = scaleBox(
@@ -1,10 +1,21 @@
1
+ import { pick } from "es-toolkit"
1
2
  import { useRef } from "react"
2
- import { angle, deg, point, rectCenter, type Deg } from "../../model/geometry/math"
3
+ import {
4
+ angle,
5
+ box,
6
+ boxRect,
7
+ deg,
8
+ floatNorm,
9
+ point,
10
+ rotatePoint,
11
+ type Box,
12
+ } from "../../model/geometry/math"
3
13
  import type { HistoryAction } from "../../model/history"
4
14
  import type { Node } from "../../model/node"
5
- import { selectionDOMRect } from "../../ui/selection"
15
+ import type { Page } from "../../model/page"
16
+ import { selectionBox } from "../../ui/selection"
6
17
  import { useEditor } from "../editor"
7
- import { usePointer } from "./pointer"
18
+ import { cursorPosition, usePointer } from "./pointer"
8
19
 
9
20
  function snappedDeg(startDeg: number, delta: number, shiftKey: boolean) {
10
21
  const rotation = startDeg + delta
@@ -15,24 +26,52 @@ export function useRotation() {
15
26
  const editor = useEditor()
16
27
 
17
28
  const state = useRef({
18
- centerPoint: point(),
19
- inititalDeg: deg(0),
20
- nodes: Array<{ node: Node; deg: Deg }>(),
29
+ initialDeg: deg(0),
30
+ page: null as Page | null,
31
+ selectionCenter: point(),
32
+ nodes: Array<{
33
+ node: Node
34
+ base: Box
35
+ }>(),
21
36
  })
22
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
+
23
62
  return usePointer({
24
63
  onDown(event) {
25
- const target = selectionDOMRect(editor.selection)
26
64
  const nodes = editor.selection.values().toArray()
27
- const center = rectCenter(target)
65
+ const page = editor.selectionPage
66
+ if (nodes.length === 0 || !page) return false
67
+
68
+ const { center: selectionCenter } = selectionBox(editor.selection)
28
69
 
29
70
  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 })),
71
+ initialDeg: angle(selectionCenter, point(), cursorPosition(event, page)),
72
+ page,
73
+ selectionCenter,
74
+ nodes: nodes.map((node) => ({ node, base: box(node) })),
36
75
  }
37
76
 
38
77
  const deg = nodes.length === 1 ? nodes[0].rotation : 0
@@ -40,19 +79,12 @@ export function useRotation() {
40
79
  },
41
80
 
42
81
  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
82
+ const { initialDeg, page, selectionCenter } = state.current
83
+ if (!page) return
47
84
 
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)
85
+ const deg =
86
+ angle(selectionCenter, point(), cursorPosition(event, page)) - initialDeg
87
+ const display = applyRotation(deg, event.shiftKey)
56
88
 
57
89
  editor.action = {
58
90
  action: "rotate",
@@ -67,28 +99,31 @@ export function useRotation() {
67
99
  },
68
100
 
69
101
  onEnd(event) {
70
- const { centerPoint, inititalDeg, nodes } = state.current
102
+ const { initialDeg, nodes, page, selectionCenter } = state.current
103
+ if (!page) return
104
+
105
+ const deg =
106
+ angle(selectionCenter, point(), cursorPosition(event, page)) - initialDeg
71
107
 
72
- const clientPoint = { x: event.clientX, y: event.clientY }
73
- const deg = angle(centerPoint, point(), clientPoint) - inititalDeg
108
+ applyRotation(deg, event.shiftKey)
74
109
 
75
- if (nodes.length === 0) {
110
+ if (nodes.length === 1) {
76
111
  const [n] = nodes
77
- const rotation = snappedDeg(n.deg, deg, event.shiftKey)
78
112
  editor.history.push({
79
- redo: ["set-node-props", [n.node.id, { rotation }]],
80
- 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 }]],
81
115
  })
82
- n.node.rotation = rotation
83
116
  } else {
84
117
  const redo: HistoryAction[] = []
85
118
  const undo: HistoryAction[] = []
86
119
 
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
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
+ ])
92
127
  }
93
128
 
94
129
  editor.history.push({
@@ -6,9 +6,13 @@ import {
6
6
  pointSubtract,
7
7
  rect,
8
8
  boxIntersects,
9
+ lineContainsPoint,
10
+ lineIntersectsBox,
11
+ accessibleLine,
9
12
  } from "../../model/geometry/math"
10
13
  import { useEditor } from "../editor"
11
14
  import { cursorPosition, usePointer } from "./pointer"
15
+ import { LineNode } from "../../model"
12
16
 
13
17
  function clientPoint(event: { clientX: number; clientY: number }) {
14
18
  return { x: event.clientX, y: event.clientY }
@@ -25,7 +29,11 @@ export function useSelector(view: (props: null | Rect) => void) {
25
29
  return usePointer({
26
30
  onDown(event) {
27
31
  for (const node of editor.nodes.values()) {
28
- if (boxContainsPoint(box(node), cursorPosition(event, node))) {
32
+ const cursor = cursorPosition(event, node.page)
33
+ if (
34
+ (node instanceof LineNode && lineContainsPoint(accessibleLine(node), cursor)) ||
35
+ (!(node instanceof LineNode) && boxContainsPoint(box(node), cursor))
36
+ ) {
29
37
  return false
30
38
  }
31
39
  }
@@ -55,7 +63,13 @@ export function useSelector(view: (props: null | Rect) => void) {
55
63
  return page.nodes
56
64
  .values()
57
65
  .toArray()
58
- .filter((node) => boxIntersects(selection, box(node)))
66
+ .filter((node) => {
67
+ if (node instanceof LineNode) {
68
+ return lineIntersectsBox(node, selection)
69
+ } else {
70
+ return boxIntersects(selection, box(node))
71
+ }
72
+ })
59
73
  })
60
74
 
61
75
  editor.selection = new Set(selection)
@@ -1,14 +1,14 @@
1
1
  import { type Rect, box, boxBounds, rect } from "../../model/geometry/math"
2
2
  import type { Node } from "../../model/node"
3
3
  import type { Page } from "../../model/page"
4
- import { useEditor, usePage } from "../editor"
4
+ import { useEditor } from "../editor"
5
5
 
6
6
  type Lines = Page["snapLines"]
7
7
  const isN = (n?: number) => typeof n === "number"
8
8
 
9
- export function useSnap() {
10
- const page = usePage()
9
+ export function useSnap(page: Page) {
11
10
  const editor = useEditor()
11
+
12
12
  const threshold = editor.options.snapThreshold
13
13
 
14
14
  function shouldSnap(point: number) {
@@ -1,9 +1,7 @@
1
1
  import { isEqual } from "es-toolkit"
2
2
  import { useCallback, useRef, useSyncExternalStore } from "react"
3
3
  import { effect, useComputed } from "react-bolt"
4
- import { TextNode } from "../model"
5
- import { EditableNode } from "../model/node/editable"
6
- import { FormattableNode } from "../model/node/formattable"
4
+ import { TextNode, EditableNode, FormattableNode } from "../model"
7
5
  import { useBatchSet, useNodeFieldBatch } from "./batch"
8
6
 
9
7
  function mergeField<T>(values: T[]): T | null {
@@ -55,8 +55,6 @@ export type GoogleWebfont = {
55
55
  category: FontCategory
56
56
  }
57
57
 
58
- const variants: Variant[] = ["regular", "italic", "700", "700italic"]
59
-
60
58
  // server only
61
59
  export async function getGoogleFonts(
62
60
  key: string,
@@ -81,9 +79,7 @@ export async function getGoogleFonts(
81
79
  const res = await fetch(url)
82
80
  const { items = [] } = (await res.json()) as { items?: GoogleWebfont[] }
83
81
 
84
- // the editor only uses these
85
- const result = items.filter((font) => variants.some((v) => font.variants.includes(v)))
86
- return sort ? result : result.sort()
82
+ return items
87
83
  }
88
84
 
89
85
  export function getLink(font: GoogleWebfont) {