@lazlon-platform/html-editor 0.1.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 (86) hide show
  1. package/.claude/settings.local.json +9 -0
  2. package/.github/workflows/ci.yml +34 -0
  3. package/README.md +24 -0
  4. package/demo/App.tsx +62 -0
  5. package/demo/EditorView/PageView/NodeContent.tsx +35 -0
  6. package/demo/EditorView/PageView/SnapLines.tsx +28 -0
  7. package/demo/EditorView/PageView/index.tsx +45 -0
  8. package/demo/EditorView/SelectionFrame/Corner.tsx +24 -0
  9. package/demo/EditorView/SelectionFrame/Edge.tsx +21 -0
  10. package/demo/EditorView/SelectionFrame/index.tsx +27 -0
  11. package/demo/EditorView/SelectionOverlay/ActionHud.tsx +32 -0
  12. package/demo/EditorView/SelectionOverlay/Rotation.tsx +39 -0
  13. package/demo/EditorView/SelectionOverlay/Toolbar.tsx +128 -0
  14. package/demo/EditorView/SelectionOverlay/index.tsx +21 -0
  15. package/demo/EditorView/Toolbar/index.tsx +68 -0
  16. package/demo/EditorView/index.tsx +47 -0
  17. package/demo/Navbar/index.tsx +33 -0
  18. package/demo/Sidebar/index.tsx +71 -0
  19. package/demo/hotkeys.ts +93 -0
  20. package/demo/main.tsx +10 -0
  21. package/demo/style.css +1 -0
  22. package/eslint.config.js +43 -0
  23. package/index.html +14 -0
  24. package/lib/hooks/actions.ts +426 -0
  25. package/lib/hooks/batch.ts +102 -0
  26. package/lib/hooks/editor.ts +18 -0
  27. package/lib/hooks/index.ts +23 -0
  28. package/lib/hooks/node.ts +33 -0
  29. package/lib/hooks/page.ts +26 -0
  30. package/lib/hooks/pointer/moveable.ts +98 -0
  31. package/lib/hooks/pointer/pointer.ts +56 -0
  32. package/lib/hooks/pointer/resize.ts +281 -0
  33. package/lib/hooks/pointer/rotation.ts +111 -0
  34. package/lib/hooks/pointer/selectionFrame.ts +97 -0
  35. package/lib/hooks/pointer/selector.ts +64 -0
  36. package/lib/hooks/pointer/snap.ts +97 -0
  37. package/lib/hooks/textMarks.ts +276 -0
  38. package/lib/lib/googleFonts.ts +162 -0
  39. package/lib/model/editor.ts +169 -0
  40. package/lib/model/geometry.ts +155 -0
  41. package/lib/model/history.ts +135 -0
  42. package/lib/model/index.ts +12 -0
  43. package/lib/model/node/editable/index.ts +85 -0
  44. package/lib/model/node/editable/letterSpacing.ts +61 -0
  45. package/lib/model/node/editable/persistentMarks.ts +45 -0
  46. package/lib/model/node/editable/tiptapExtensions.ts +33 -0
  47. package/lib/model/node/formattable.ts +108 -0
  48. package/lib/model/node/group.ts +79 -0
  49. package/lib/model/node/image.ts +41 -0
  50. package/lib/model/node/shape/polygon.ts +173 -0
  51. package/lib/model/node/shape/shape.ts +48 -0
  52. package/lib/model/node/text.ts +55 -0
  53. package/lib/model/node.ts +101 -0
  54. package/lib/model/page.ts +51 -0
  55. package/lib/model/traversal.ts +21 -0
  56. package/lib/ui/colors.ts +23 -0
  57. package/lib/ui/extractor.ts +57 -0
  58. package/lib/ui/index.ts +8 -0
  59. package/lib/ui/node/EditableContent.tsx +101 -0
  60. package/lib/ui/node/GroupContent.tsx +46 -0
  61. package/lib/ui/node/ImageContent.tsx +36 -0
  62. package/lib/ui/node/NodeView.tsx +68 -0
  63. package/lib/ui/node/PolygonContent.tsx +81 -0
  64. package/lib/ui/node/TextContent.tsx +40 -0
  65. package/lib/ui/node/useDoubleClick.ts +37 -0
  66. package/lib/ui/selection.ts +38 -0
  67. package/package.json +70 -0
  68. package/tests/createTestEditor.ts +19 -0
  69. package/tests/hooks/actions.test.tsx +736 -0
  70. package/tests/hooks/batch.test.tsx +332 -0
  71. package/tests/hooks/editor.test.tsx +56 -0
  72. package/tests/hooks/page.test.tsx +135 -0
  73. package/tests/hooks/pointer/pointer.test.tsx +244 -0
  74. package/tests/hooks/textMarks.test.tsx +624 -0
  75. package/tests/model/editor.test.ts +384 -0
  76. package/tests/model/history.test.ts +293 -0
  77. package/tests/model/node/group.test.ts +294 -0
  78. package/tests/model/node/image.test.ts +150 -0
  79. package/tests/model/node/polygon.test.ts +408 -0
  80. package/tests/model/node/text.test.ts +158 -0
  81. package/tests/model/node.test.ts +276 -0
  82. package/tests/model/page.test.ts +150 -0
  83. package/tests/setup.ts +7 -0
  84. package/tsconfig.json +28 -0
  85. package/vite.config.ts +9 -0
  86. package/vitest.config.ts +13 -0
@@ -0,0 +1,102 @@
1
+ import { isEqual, pick } from "es-toolkit"
2
+ import { useRef } from "react"
3
+ import { useComputed } from "react-bolt"
4
+ import { type HistoryAction } from "../model/history"
5
+ import { Node } from "../model/node"
6
+ import { useEditor } from "./editor"
7
+
8
+ export function useNodeField<N extends Node, K extends keyof N>(
9
+ nodes: Iterable<N>,
10
+ key: K,
11
+ fallback: N[K],
12
+ ) {
13
+ const [first, ...rest] = nodes
14
+ return useComputed<N[K]>({
15
+ equals: isEqual,
16
+ fn: () =>
17
+ rest.reduce(
18
+ (acc, node) => (isEqual(node[key], acc) ? acc : fallback),
19
+ first ? first[key] : fallback,
20
+ ),
21
+ })
22
+ }
23
+
24
+ export function useNodeFieldBatch<N extends Node, K extends keyof N>(
25
+ nodes: N[],
26
+ key: K,
27
+ fallback: N[K],
28
+ ) {
29
+ const { history } = useEditor()
30
+ const initial = useRef<Map<N, N[K]> | null>(null)
31
+
32
+ return {
33
+ value: useNodeField(nodes, key, fallback),
34
+ onChange(set: N[K] | ((node: N) => N[K])) {
35
+ if (nodes.length === 0) return
36
+
37
+ if (!initial.current) {
38
+ initial.current = new Map(nodes.map((n) => [n, n[key]]))
39
+ }
40
+
41
+ for (const node of nodes) {
42
+ node[key] = typeof set === "function" ? (set as (node: N) => N[K])(node) : set
43
+ }
44
+ },
45
+ onChangeEnd(end: N[K] | ((node: N) => N[K])) {
46
+ if (nodes.length === 0) return
47
+
48
+ const init = initial.current || new Map(nodes.map((n) => [n, n[key]]))
49
+
50
+ const prev: HistoryAction[] = []
51
+ const next: HistoryAction[] = []
52
+
53
+ for (const node of nodes) {
54
+ const nextValue =
55
+ typeof end === "function" ? (end as (node: N) => N[K])(node) : end
56
+
57
+ prev.push(["set-node-props", [node.id, { [key]: init.get(node) }]])
58
+ next.push(["set-node-props", [node.id, { [key]: nextValue }]])
59
+ node[key] = nextValue
60
+ }
61
+
62
+ history.push({ undo: ["batch", prev], redo: ["batch", next] })
63
+ initial.current = null
64
+ },
65
+ }
66
+ }
67
+
68
+ type WritableKeys<T> = {
69
+ [K in keyof T]-?: IfEquals<{ [P in K]: T[P] }, { -readonly [P in K]: T[P] }, K>
70
+ }[keyof T]
71
+
72
+ type IfEquals<X, Y, Result> =
73
+ (<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1 : 2 ? Result : never
74
+
75
+ type Writable<T> = Pick<T, WritableKeys<T>>
76
+ type Props<N> = Partial<Writable<{ [K in keyof N]: N[K] }>>
77
+
78
+ export function useBatchSet() {
79
+ const { history } = useEditor()
80
+
81
+ return function batchSet<N extends Node>(
82
+ nodes: Iterable<N>,
83
+ set: Props<N> | ((n: N) => Props<N>),
84
+ ) {
85
+ let push = false
86
+ const prev: HistoryAction[] = []
87
+ const next: HistoryAction[] = []
88
+
89
+ for (const node of nodes) {
90
+ push = true
91
+ const nextValues = typeof set === "function" ? set(node) : set
92
+ const keys = Object.keys(nextValues) as WritableKeys<N>[]
93
+ prev.push(["set-node-props", [node.id, pick(node, keys)]])
94
+ next.push(["set-node-props", [node.id, nextValues]])
95
+ Object.assign(node, nextValues)
96
+ }
97
+
98
+ if (push) {
99
+ history.push({ undo: ["batch", prev], redo: ["batch", next] })
100
+ }
101
+ }
102
+ }
@@ -0,0 +1,18 @@
1
+ import { createContext, use } from "react"
2
+ import { Editor } from "../model/editor"
3
+ import type { Page } from "../model/page"
4
+
5
+ export const EditorContext = createContext<Editor | null>(null)
6
+ export const PageContext = createContext<Page | null>(null)
7
+
8
+ export function usePage() {
9
+ const page = use(PageContext)
10
+ if (!page) throw Error("missing PageContext")
11
+ return page
12
+ }
13
+
14
+ export function useEditor() {
15
+ const editor = use(EditorContext)
16
+ if (!editor) throw Error("missing EditorContext")
17
+ return editor
18
+ }
@@ -0,0 +1,23 @@
1
+ export {
2
+ clone,
3
+ useAddNodeAction,
4
+ useAlignAction,
5
+ useDistributeAction,
6
+ useDuplicateAction,
7
+ useGroupAction,
8
+ useMoveAction,
9
+ useStackOrderAction,
10
+ useToggleLockAction,
11
+ useTrashAction,
12
+ } from "./actions"
13
+ export { useBatchSet, useNodeField, useNodeFieldBatch } from "./batch"
14
+ export { EditorContext, PageContext, useEditor, usePage } from "./editor"
15
+ export { useMoveable } from "./pointer/moveable"
16
+ export { usePointer } from "./pointer/pointer"
17
+ export { useResize } from "./pointer/resize"
18
+ export { useRotation } from "./pointer/rotation"
19
+ export { useSelectionFrame } from "./pointer/selectionFrame"
20
+ export { useSelector } from "./pointer/selector"
21
+ export { useTextMarks } from "./textMarks"
22
+ export { useEditPage } from "./page"
23
+ export { useVisualPositionBatch } from "./node"
@@ -0,0 +1,33 @@
1
+ import type { Node } from "../model"
2
+ import { rotatedAABB } from "../model/geometry"
3
+ import { useNodeField, useNodeFieldBatch } from "./batch"
4
+
5
+ export function useVisualPositionBatch(nodes: Node[]) {
6
+ const rotation = useNodeField(nodes, "rotation", 0)
7
+ const width = useNodeField(nodes, "width", NaN)
8
+ const height = useNodeField(nodes, "height", NaN)
9
+ const x = useNodeFieldBatch(nodes, "x", NaN)
10
+ const y = useNodeFieldBatch(nodes, "y", NaN)
11
+
12
+ const box = rotatedAABB({ x: x.value, y: y.value, width, height }, rotation)
13
+
14
+ function withBoundingBox(axis: "x" | "y", fn: (value: number) => void) {
15
+ return function (value: number): void {
16
+ const delta = value - box[axis]
17
+ return fn(axis === "x" ? x.value + delta : y.value + delta)
18
+ }
19
+ }
20
+
21
+ return {
22
+ x: {
23
+ value: box.x,
24
+ onChange: withBoundingBox("x", x.onChange),
25
+ onChangeEnd: withBoundingBox("x", x.onChangeEnd),
26
+ },
27
+ y: {
28
+ value: box.y,
29
+ onChange: withBoundingBox("y", y.onChange),
30
+ onChangeEnd: withBoundingBox("y", y.onChangeEnd),
31
+ },
32
+ }
33
+ }
@@ -0,0 +1,26 @@
1
+ import { useRef } from "react"
2
+ import type { Page } from "../model"
3
+ import { useEditor } from "./editor"
4
+ import { useStore } from "react-bolt"
5
+
6
+ export function useEditPage<Key extends keyof Page>(page: Page, key: Key) {
7
+ const editor = useEditor()
8
+ const init = useRef(page[key])
9
+ const value = useStore(page, key)
10
+
11
+ return {
12
+ value,
13
+ onChange(value: Page[Key]) {
14
+ page[key] = value
15
+ },
16
+ onChangeEnd(value: Page[Key]) {
17
+ editor.history.push({
18
+ redo: ["set-page-props", [page.id, { [key]: value }]],
19
+ undo: ["set-page-props", [page.id, { [key]: init.current }]],
20
+ })
21
+
22
+ init.current = value
23
+ page[key] = value
24
+ },
25
+ }
26
+ }
@@ -0,0 +1,98 @@
1
+ import { useRef } from "react"
2
+ import type { Point } from "../../model/geometry"
3
+ import type { HistoryEntry } from "../../model/history"
4
+ import type { Node } from "../../model/node"
5
+ import { getTargetRect, isPointerInSelectionRect } from "../../ui/selection"
6
+ import { useEditor, usePage } from "../editor"
7
+ import { usePointer } from "./pointer"
8
+ import { useSnap } from "./snap"
9
+
10
+ type StartPoints = {
11
+ node: Node
12
+ start: Point
13
+ offset: Point
14
+ }
15
+
16
+ const moveInit = {
17
+ start: { x: 0, y: 0, width: 0, height: 0 },
18
+ grab: { x: 0, y: 0 },
19
+ }
20
+
21
+ export function useMoveable() {
22
+ const editor = useEditor()
23
+ const page = usePage()
24
+ const startpoints = useRef(Array<StartPoints>())
25
+ const move = useRef(moveInit)
26
+ const snap = useSnap()
27
+
28
+ return usePointer({
29
+ onDown(event) {
30
+ const { selection } = editor
31
+ const rect = getTargetRect(selection)
32
+
33
+ startpoints.current = selection
34
+ .values()
35
+ .map((n) => ({
36
+ node: n,
37
+ start: { x: n.x, y: n.y },
38
+ offset: { x: n.x - rect.x, y: n.y - rect.y },
39
+ }))
40
+ .toArray()
41
+
42
+ move.current = {
43
+ start: rect,
44
+ grab: {
45
+ x: Math.round(event.clientX / editor.zoom - rect.x),
46
+ y: Math.round(event.clientY / editor.zoom - rect.y),
47
+ },
48
+ }
49
+
50
+ for (const node of selection) {
51
+ if (node.blockMove(event)) return false
52
+ }
53
+
54
+ return isPointerInSelectionRect(selection, event)
55
+ },
56
+
57
+ onMove({ event }) {
58
+ const px = Math.round(event.clientX / editor.zoom - move.current.grab.x)
59
+ const py = Math.round(event.clientY / editor.zoom - move.current.grab.y)
60
+
61
+ const { x, y } = snap(!event.shiftKey, {
62
+ x: px,
63
+ y: py,
64
+ width: move.current.start.width,
65
+ height: move.current.start.height,
66
+ })
67
+
68
+ for (const { node, offset } of startpoints.current) {
69
+ node.x = x + offset.x
70
+ node.y = y + offset.y
71
+ }
72
+
73
+ editor.action = { action: "move", payload: { x, y } }
74
+ },
75
+
76
+ onCancel() {
77
+ editor.action = {}
78
+ page.snapLines = []
79
+ },
80
+
81
+ onEnd() {
82
+ editor.action = {}
83
+ page.snapLines = []
84
+
85
+ const entries = startpoints.current.map(({ node, start }): HistoryEntry => {
86
+ return {
87
+ redo: ["set-node-props", [node.id, { x: node.x, y: node.y }]],
88
+ undo: ["set-node-props", [node.id, { x: start.x, y: start.y }]],
89
+ }
90
+ })
91
+
92
+ editor.history.push({
93
+ redo: ["batch", entries.map((e) => e.redo)],
94
+ undo: ["batch", entries.map((e) => e.undo)],
95
+ })
96
+ },
97
+ })
98
+ }
@@ -0,0 +1,56 @@
1
+ import { useCallback, useRef } from "react"
2
+
3
+ type MoveEvent = {
4
+ start: React.PointerEvent
5
+ event: globalThis.PointerEvent
6
+ }
7
+
8
+ export type UsePointerProps = {
9
+ onDown?(event: React.PointerEvent): boolean | void
10
+ onMove?(event: MoveEvent): void
11
+ onEnd?(event: MoveEvent): void
12
+ onCancel?(event: MoveEvent): void
13
+ }
14
+
15
+ export function usePointer(props: UsePointerProps) {
16
+ const { onDown, onMove, onEnd, onCancel } = props
17
+ const isMovingRef = useRef(false)
18
+
19
+ const onPointerDown = useCallback(
20
+ function onPointerDown(start: React.PointerEvent) {
21
+ if (start.button !== 0) return
22
+ if (onDown?.(start) === false) return
23
+
24
+ function onPointerMove(event: globalThis.PointerEvent) {
25
+ isMovingRef.current = true
26
+ onMove?.({ start, event })
27
+ }
28
+
29
+ function onPointerUp(event: globalThis.PointerEvent) {
30
+ removeEventListener("pointermove", onPointerMove)
31
+ removeEventListener("pointerup", onPointerUp)
32
+ removeEventListener("pointercancel", onPointerUp)
33
+
34
+ if (isMovingRef.current) {
35
+ onEnd?.({ start, event })
36
+ } else {
37
+ onCancel?.({ start, event })
38
+ }
39
+
40
+ isMovingRef.current = false
41
+ }
42
+
43
+ addEventListener("pointermove", onPointerMove)
44
+ addEventListener("pointerup", onPointerUp)
45
+ addEventListener("pointercancel", onPointerUp)
46
+ },
47
+ [onDown, onMove, onEnd, onCancel, isMovingRef],
48
+ )
49
+
50
+ return {
51
+ onPointerDown,
52
+ isMoving() {
53
+ return isMovingRef.current
54
+ },
55
+ }
56
+ }
@@ -0,0 +1,281 @@
1
+ import { useRef } from "react"
2
+ 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"
7
+ import { useEditor } from "../editor"
8
+ import { usePointer } from "./pointer"
9
+
10
+ type Edge = "n" | "s" | "w" | "e"
11
+ type Corner = "ne" | "nw" | "se" | "sw"
12
+ type Direction = Edge | Corner
13
+
14
+ type ResizeInit = {
15
+ rect: Rect
16
+ nodes: Array<Rect & { node: Node; undo: Rect }>
17
+ }
18
+
19
+ function useSingleResize(direction: Direction) {
20
+ 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[]
24
+
25
+ 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 }
30
+ },
31
+ onMove({ start, event }) {
32
+ editor.action = { action: "resize", payload: { width: 0, height: 0 } }
33
+
34
+ let size = init.current
35
+
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,
43
+ direction,
44
+ distance(
45
+ { x: start.clientX, y: start.clientY },
46
+ { x: event.clientX, y: event.clientY },
47
+ node.rotation + r,
48
+ ),
49
+ )
50
+ }
51
+
52
+ node.width = size.width
53
+ node.height = size.height
54
+ node.x = size.x
55
+ node.y = size.y
56
+
57
+ editor.action = { action: "resize", payload: size }
58
+ },
59
+ onCancel() {
60
+ editor.action = {}
61
+ },
62
+ onEnd() {
63
+ editor.action = {}
64
+
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
+ }
72
+
73
+ editor.history.push({
74
+ redo: ["set-node-props", [node.id, next]],
75
+ undo: ["set-node-props", [node.id, prev]],
76
+ })
77
+ },
78
+ })
79
+ }
80
+
81
+ function useMultiResize(direction: Direction) {
82
+ 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: [],
89
+ })
90
+
91
+ 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
+ })),
114
+ }
115
+ },
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
+
120
+ const rect: Rect = { ...init.current.rect }
121
+
122
+ /* mutate rect */ {
123
+ const { x, y, height, width } = init.current.rect
124
+
125
+ if (directions.includes("n") && height - py > 0) {
126
+ rect.height = height - py
127
+ rect.y = y + py
128
+ }
129
+ if (directions.includes("s")) {
130
+ rect.height = height + py
131
+ }
132
+ if (directions.includes("w") && width - px > 0) {
133
+ rect.width = width - px
134
+ rect.x = x + px
135
+ }
136
+ if (directions.includes("e")) {
137
+ rect.width = width + px
138
+ }
139
+ }
140
+
141
+ /* 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)
147
+ }
148
+ }
149
+
150
+ editor.action = { action: "resize", payload: rect }
151
+ },
152
+ onCancel() {
153
+ editor.action = {}
154
+ },
155
+ onEnd() {
156
+ editor.action = {}
157
+
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
+ )
170
+
171
+ editor.history.push({
172
+ redo: ["batch", entries.map((e) => e.redo)],
173
+ undo: ["batch", entries.map((e) => e.undo)],
174
+ })
175
+ },
176
+ })
177
+ }
178
+
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) {
276
+ const editor = useEditor()
277
+ const selection = useStore(editor, "selection")
278
+ const single = useSingleResize(direction)
279
+ const multi = useMultiResize(direction)
280
+ return selection.size === 1 ? single : multi
281
+ }