@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,33 @@
1
+ import { useAddNodeAction, useEditor } from "@lazlon/html-editor/hooks"
2
+ import { PolygonNode } from "@lazlon/html-editor/model"
3
+ import { useStore } from "react-bolt"
4
+
5
+ function randomHexColor(): string {
6
+ const value = Math.floor(Math.random() * 0xffffff)
7
+ return `#${value.toString(16).padStart(6, "0")}`
8
+ }
9
+
10
+ export default function Navbar() {
11
+ const { history } = useEditor()
12
+ const canUndo = useStore(history, "undoHistory").length
13
+ const canRedo = useStore(history, "redoHistory").length
14
+ const addNode = useAddNodeAction()
15
+
16
+ return (
17
+ <nav className="bg-white flex shadow-xl justify-between px-2">
18
+ <span className="text-3xl font-medium p-1">Brand</span>
19
+
20
+ <div className="flex gap-2 my-auto">
21
+ <button onClick={() => addNode(PolygonNode, { background: randomHexColor() })}>
22
+ add
23
+ </button>
24
+ <button disabled={!canRedo} onClick={history.redo}>
25
+ redo
26
+ </button>
27
+ <button disabled={!canUndo} onClick={history.undo}>
28
+ undo
29
+ </button>
30
+ </div>
31
+ </nav>
32
+ )
33
+ }
@@ -0,0 +1,71 @@
1
+ import {
2
+ useEditor,
3
+ useNodeFieldBatch,
4
+ useVisualPositionBatch,
5
+ } from "@lazlon/html-editor/hooks"
6
+ import { PolygonNode } from "@lazlon/html-editor/model"
7
+ import { ColorField, Input, Label, NumberField } from "react-aria-components"
8
+ import { useStore } from "react-bolt"
9
+
10
+ export default function SideBar() {
11
+ const editor = useEditor()
12
+ const selection = useStore(editor, "selection").values().toArray()
13
+
14
+ const width = useNodeFieldBatch(selection, "width", NaN)
15
+ const height = useNodeFieldBatch(selection, "height", NaN)
16
+ const { x, y } = useVisualPositionBatch(selection)
17
+ const rotation = useNodeFieldBatch(selection, "rotation", NaN)
18
+
19
+ const polygons = selection.filter((node) => node instanceof PolygonNode)
20
+ const color = useNodeFieldBatch(polygons, "background", "#000000")
21
+ const roundness = useNodeFieldBatch(polygons, "roundness", NaN)
22
+ const sides = useNodeFieldBatch(polygons, "sides", NaN)
23
+
24
+ return (
25
+ <div className="bg-white flex flex-col w-58 shadow-lg p-2 gap-8 h-full">
26
+ {selection.length > 0 && (
27
+ <fieldset className="grid grid-cols-2 gap-4">
28
+ <NumberField value={x.value} onChange={x.onChangeEnd}>
29
+ <Label>X Position</Label>
30
+ <Input className="w-full" />
31
+ </NumberField>
32
+ <NumberField value={y.value} onChange={y.onChangeEnd}>
33
+ <Label>Y Position</Label>
34
+ <Input className="w-full" />
35
+ </NumberField>
36
+ <NumberField value={width.value} onChange={width.onChangeEnd}>
37
+ <Label>Width</Label>
38
+ <Input className="w-full" />
39
+ </NumberField>
40
+ <NumberField value={height.value} onChange={height.onChangeEnd}>
41
+ <Label>Height</Label>
42
+ <Input className="w-full" />
43
+ </NumberField>
44
+ <NumberField value={rotation.value} onChange={rotation.onChangeEnd}>
45
+ <Label>Rotation</Label>
46
+ <Input className="w-full" />
47
+ </NumberField>
48
+ </fieldset>
49
+ )}
50
+ {polygons.length > 0 && (
51
+ <fieldset className="grid grid-cols-2 gap-4">
52
+ <ColorField
53
+ value={color.value}
54
+ onChange={(c) => color.onChangeEnd(c!.toString("hex"))}
55
+ >
56
+ <Label>Color</Label>
57
+ <Input className="w-full" />
58
+ </ColorField>
59
+ <NumberField value={roundness.value} onChange={roundness.onChangeEnd}>
60
+ <Label>Roundness</Label>
61
+ <Input className="w-full" />
62
+ </NumberField>
63
+ <NumberField value={sides.value} onChange={sides.onChangeEnd} minValue={3}>
64
+ <Label>Sides</Label>
65
+ <Input className="w-full" />
66
+ </NumberField>
67
+ </fieldset>
68
+ )}
69
+ </div>
70
+ )
71
+ }
@@ -0,0 +1,93 @@
1
+ import {
2
+ useAddNodeAction,
3
+ useDuplicateAction,
4
+ useEditor,
5
+ useGroupAction,
6
+ useMoveAction,
7
+ useTrashAction,
8
+ } from "@lazlon/html-editor/hooks"
9
+ import { GroupNode, PolygonNode, TextNode, type Node } from "@lazlon/html-editor/model"
10
+ import { useEffect } from "react"
11
+ import { useHotkeys } from "react-hotkeys-hook"
12
+
13
+ let pasteCount = 0
14
+ let clipboard = new Set<Node>()
15
+
16
+ export default function Hotkeys(): React.ReactNode {
17
+ const editor = useEditor()
18
+ const duplicate = useDuplicateAction()
19
+ const trash = useTrashAction()
20
+ const { group, ungroup } = useGroupAction()
21
+ const addNode = useAddNodeAction()
22
+ const move = useMoveAction()
23
+
24
+ useEffect(() => {
25
+ const stage = editor.ref
26
+
27
+ function onWheel(e: WheelEvent) {
28
+ if (!e.ctrlKey) return
29
+ e.preventDefault()
30
+
31
+ const sign = Math.sign(e.deltaY)
32
+
33
+ // zoom in, cap at 500%
34
+ if (sign < 0 && editor.zoom < 5) {
35
+ editor.zoom += 0.05
36
+ }
37
+
38
+ // zoom out, cap at 5%
39
+ if (sign > 0 && editor.zoom > 0.05) {
40
+ editor.zoom -= 0.05
41
+ }
42
+ }
43
+
44
+ if (stage) {
45
+ stage.addEventListener("wheel", onWheel, { passive: false })
46
+ return () => void stage.removeEventListener("wheel", onWheel)
47
+ }
48
+ })
49
+
50
+ useHotkeys("mod+c", () => {
51
+ pasteCount = 0
52
+ clipboard = new Set(editor.selection)
53
+ })
54
+
55
+ useHotkeys("mod+v", () => {
56
+ duplicate({ nodes: clipboard, offset: ++pasteCount * 10 })
57
+ })
58
+
59
+ useHotkeys("backspace", trash)
60
+ useHotkeys("delete", trash)
61
+ useHotkeys("mod+d", () => duplicate())
62
+
63
+ useHotkeys("mod+z", () => editor.history.undo())
64
+ useHotkeys("mod+y", () => editor.history.redo())
65
+
66
+ useHotkeys("mod+g", group)
67
+ useHotkeys("mod+shift+g", () => {
68
+ const [group, ...rest] = editor.selection
69
+ if (group && group instanceof GroupNode && rest.length === 0) {
70
+ ungroup(group)
71
+ }
72
+ })
73
+
74
+ useHotkeys("shift+r", () => addNode(PolygonNode, { sides: 4 }))
75
+ // useHotkeys("shift+l", () => addNode(LineNode))
76
+ useHotkeys("shift+t", () => addNode(TextNode))
77
+ // useHotkeys("shift+o", () => addNode(OvalNode))
78
+
79
+ useHotkeys("escape", () => {
80
+ editor.selection = new Set()
81
+ })
82
+
83
+ useHotkeys("up", () => move({ y: -1 }))
84
+ useHotkeys("down", () => move({ y: 1 }))
85
+ useHotkeys("left", () => move({ x: -1 }))
86
+ useHotkeys("right", () => move({ x: 1 }))
87
+ useHotkeys("shift+up", () => move({ y: -10 }))
88
+ useHotkeys("shift+down", () => move({ y: 10 }))
89
+ useHotkeys("shift+left", () => move({ x: -10 }))
90
+ useHotkeys("shift+right", () => move({ x: 10 }))
91
+
92
+ return null
93
+ }
package/demo/main.tsx ADDED
@@ -0,0 +1,10 @@
1
+ import { StrictMode } from "react"
2
+ import { createRoot } from "react-dom/client"
3
+ import App from "./App"
4
+ import "./style.css"
5
+
6
+ createRoot(document.body).render(
7
+ <StrictMode>
8
+ <App />
9
+ </StrictMode>,
10
+ )
package/demo/style.css ADDED
@@ -0,0 +1 @@
1
+ @import "tailwindcss";
@@ -0,0 +1,43 @@
1
+ // @ts-check
2
+
3
+ import js from "@eslint/js"
4
+ import globals from "globals"
5
+ import reactHooks from "eslint-plugin-react-hooks"
6
+ import reactRefresh from "eslint-plugin-react-refresh"
7
+ import tseslint from "typescript-eslint"
8
+ import { defineConfig, globalIgnores } from "eslint/config"
9
+
10
+ export default defineConfig([
11
+ globalIgnores(["dist"]),
12
+ {
13
+ files: ["**/*.{ts,tsx}"],
14
+ extends: [
15
+ js.configs.recommended,
16
+ tseslint.configs.recommended,
17
+ reactHooks.configs.flat.recommended,
18
+ reactRefresh.configs.vite,
19
+ ],
20
+ languageOptions: {
21
+ ecmaVersion: 2020,
22
+ globals: globals.browser,
23
+ },
24
+ rules: {
25
+ // react-bolt uses accessors for state updates
26
+ "react-hooks/immutability": ["off"],
27
+
28
+ // Allow unused variables with underscore
29
+ "@typescript-eslint/no-unused-vars": [
30
+ "error",
31
+ {
32
+ args: "all",
33
+ argsIgnorePattern: "^_",
34
+ caughtErrors: "all",
35
+ caughtErrorsIgnorePattern: "^_",
36
+ destructuredArrayIgnorePattern: "^_",
37
+ varsIgnorePattern: "^_",
38
+ ignoreRestSiblings: true,
39
+ },
40
+ ],
41
+ },
42
+ },
43
+ ])
package/index.html ADDED
@@ -0,0 +1,14 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>html-editor</title>
8
+ </head>
9
+
10
+ <body class="h-screen">
11
+ <script type="module" src="/demo/main.tsx"></script>
12
+ </body>
13
+
14
+ </html>
@@ -0,0 +1,426 @@
1
+ import { useComputed } from "react-bolt"
2
+ 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 { TextNode } from "../model/node/text"
7
+ import type { Page } from "../model/page"
8
+ import { flattenNodes } from "../model/traversal"
9
+ import { getTargetRect } from "../ui/selection"
10
+ import { useBatchSet } from "./batch"
11
+ import { useEditor } from "./editor"
12
+
13
+ export function clone(
14
+ editor: Editor,
15
+ node: Node,
16
+ props?: ((props: NodeProps) => Partial<NodeProps>) | Partial<NodeProps>,
17
+ ) {
18
+ const copy = node.serialize().props
19
+ const newProps = typeof props === "function" ? props(copy) : props
20
+ const newNode = editor.deserializeNode(node.page, {
21
+ props: { ...copy, ...newProps },
22
+ name: node.name,
23
+ })
24
+
25
+ for (const n of flattenNodes(newNode)) {
26
+ // @ts-expect-error id is readonly
27
+ n.id = editor.id()
28
+ }
29
+
30
+ return newNode
31
+ }
32
+
33
+ export function useAddNodeAction(page?: Page) {
34
+ const editor = useEditor()
35
+
36
+ return function addNode<N extends NodeConstructor>(
37
+ NodeClass: N,
38
+ props?: Partial<ConstructorParameters<N>[2]>,
39
+ ) {
40
+ const [firstPage] = editor.pages.values()
41
+ const targetPage = page ?? firstPage
42
+ if (!targetPage) return
43
+ const { width, height, nodes } = targetPage
44
+
45
+ const node = new NodeClass(editor, targetPage, {
46
+ id: editor.id(),
47
+ x: Math.round(width / 2) - 50,
48
+ y: Math.round(height / 2) - 50,
49
+ width: 100,
50
+ height: 100,
51
+ ...props,
52
+ })
53
+
54
+ targetPage.nodes = new Map([...nodes, [node.id, node]])
55
+ editor.history.push({
56
+ redo: ["add-node", [targetPage.id, [node.serialize()]]],
57
+ undo: ["delete-node", [targetPage.id, [node.id]]],
58
+ })
59
+
60
+ if (node instanceof TextNode) {
61
+ editor.selection = new Set([node])
62
+ setTimeout(() => node.tiptap.commands.focus())
63
+ }
64
+
65
+ return node as InstanceType<N>
66
+ }
67
+ }
68
+
69
+ export function useMoveAction() {
70
+ const editor = useEditor()
71
+ const batchSet = useBatchSet()
72
+
73
+ return function move(props: { x?: number; y?: number }) {
74
+ const { x, y } = props
75
+ const selection = editor.selection.values().toArray()
76
+ if (x) batchSet(selection, (n) => ({ x: n.x + x }))
77
+ if (y) batchSet(selection, (n) => ({ y: n.y + y }))
78
+ }
79
+ }
80
+
81
+ export function useTrashAction() {
82
+ const editor = useEditor()
83
+
84
+ return function trash() {
85
+ const { selection, selectionPage: page } = editor
86
+ const array = selection.values().toArray()
87
+ if (selection.size === 0 || !page) return
88
+
89
+ editor.history.push({
90
+ redo: ["delete-node", [page.id, array.map((node) => node.id)]],
91
+ undo: ["add-node", [page.id, array.map((n) => n.serialize())]],
92
+ })
93
+
94
+ editor.selection = new Set()
95
+ page.nodes = new Map(page.nodes.entries().filter(([, node]) => !selection.has(node)))
96
+ }
97
+ }
98
+
99
+ export function useGroupAction() {
100
+ const editor = useEditor()
101
+
102
+ function group() {
103
+ const { selection, selectionPage: page } = editor
104
+ const { width, height, x, y } = getTargetRect(selection)
105
+ if (selection.size === 0 || !page) return
106
+
107
+ const group = new GroupNode(editor, page, {
108
+ id: editor.id(),
109
+ width,
110
+ height,
111
+ x,
112
+ y,
113
+ nodes: selection
114
+ .values()
115
+ .map((node) =>
116
+ clone(editor, node, (n) => ({
117
+ x: n.x - x,
118
+ y: n.y - y,
119
+ })).serialize(),
120
+ )
121
+ .toArray(),
122
+ })
123
+
124
+ editor.selection = new Set()
125
+ page.nodes = new Map([
126
+ ...page.nodes
127
+ .values()
128
+ .filter((n) => !selection.has(n))
129
+ .map((n) => [n.id, n] as const),
130
+ [group.id, group],
131
+ ])
132
+
133
+ editor.history.push({
134
+ redo: [
135
+ "batch",
136
+ [
137
+ [
138
+ "delete-node",
139
+ [
140
+ page.id,
141
+ selection
142
+ .values()
143
+ .map((n) => n.id)
144
+ .toArray(),
145
+ ],
146
+ ],
147
+ ["add-node", [page.id, [group.serialize()]]],
148
+ ],
149
+ ],
150
+ undo: [
151
+ "batch",
152
+ [
153
+ ["delete-node", [page.id, [group.id]]],
154
+ [
155
+ "add-node",
156
+ [
157
+ page.id,
158
+ selection
159
+ .values()
160
+ .map((n) => n.serialize())
161
+ .toArray(),
162
+ ],
163
+ ],
164
+ ],
165
+ ],
166
+ })
167
+ }
168
+
169
+ function ungroup(group: GroupNode) {
170
+ const page = group.page
171
+ const nodes = group.nodes
172
+ .values()
173
+ .map((node) =>
174
+ clone(editor, node, (n) => ({
175
+ x: n.x + group.x,
176
+ y: n.y + group.y,
177
+ })),
178
+ )
179
+ .toArray()
180
+
181
+ editor.selection = new Set()
182
+ page.nodes = new Map([
183
+ ...page.nodes.entries().filter(([id]) => id !== group.id),
184
+ ...nodes.map((node) => [node.id, node] as const),
185
+ ])
186
+
187
+ editor.history.push({
188
+ redo: [
189
+ "batch",
190
+ [
191
+ ["delete-node", [page.id, [group.id]]],
192
+ ["add-node", [page.id, nodes.map((n) => n.serialize())]],
193
+ ],
194
+ ],
195
+ undo: [
196
+ "batch",
197
+ [
198
+ ["delete-node", [page.id, nodes.map((node) => node.id)]],
199
+ ["add-node", [page.id, [group.serialize()]]],
200
+ ],
201
+ ],
202
+ })
203
+ }
204
+
205
+ return { group, ungroup }
206
+ }
207
+
208
+ export function useDuplicateAction() {
209
+ const editor = useEditor()
210
+
211
+ return function duplicate(props?: { page?: Page; nodes?: Set<Node>; offset?: number }) {
212
+ const selection = props?.nodes ?? editor.selection
213
+ const offset = props?.offset ?? 10
214
+ const page = props?.page ?? editor.selectionPage
215
+ if (selection.size === 0 || !page) return
216
+
217
+ const nodes = selection
218
+ .values()
219
+ .map((node) =>
220
+ clone(editor, node, (n) => ({
221
+ x: n.x + offset,
222
+ y: n.y + offset,
223
+ })),
224
+ )
225
+ .toArray()
226
+
227
+ page.nodes = new Map([
228
+ ...page.nodes,
229
+ ...nodes.map((node) => [node.id, node] as const),
230
+ ])
231
+
232
+ editor.selection = new Set(nodes)
233
+ editor.history.push({
234
+ redo: ["add-node", [page.id, nodes.map((n) => n.serialize())]],
235
+ undo: ["delete-node", [page.id, nodes.map((node) => node.id)]],
236
+ })
237
+ }
238
+ }
239
+
240
+ export function useToggleLockAction() {
241
+ const editor = useEditor()
242
+
243
+ return function toggleLock() {
244
+ const selection = editor.selection.values().toArray()
245
+ const isLocked = selection.some((n) => n.locked)
246
+ for (const node of selection) {
247
+ node.locked = !isLocked
248
+ }
249
+ }
250
+ }
251
+
252
+ export function useAlignAction() {
253
+ type AlignProps = { x: number } | { y: number }
254
+
255
+ const editor = useEditor()
256
+
257
+ function align(fn: (node: Node) => { prev: AlignProps; next: AlignProps }) {
258
+ const undo: HistoryAction[] = []
259
+ const redo: HistoryAction[] = []
260
+
261
+ for (const node of editor.selection) {
262
+ const { prev, next } = fn(node)
263
+ undo.push(["set-node-props", [node.id, prev]])
264
+ redo.push(["set-node-props", [node.id, next]])
265
+ }
266
+
267
+ editor.history.push({
268
+ undo: ["batch", undo],
269
+ redo: ["batch", redo],
270
+ })
271
+ }
272
+
273
+ function halign(getX: (node: Node) => number) {
274
+ align((node) => {
275
+ const x = getX(node)
276
+ const res = { next: { x }, prev: { x: node.x } }
277
+ node.x = x
278
+ return res
279
+ })
280
+ }
281
+
282
+ function valign(getY: (node: Node) => number) {
283
+ align((node) => {
284
+ const y = getY(node)
285
+ const res = { next: { y }, prev: { y: node.y } }
286
+ node.y = y
287
+ return res
288
+ })
289
+ }
290
+
291
+ return {
292
+ left: () => halign(() => 0),
293
+ center: () => halign((n) => Math.round(n.page.width / 2 - n.width / 2)),
294
+ right: () => halign((n) => Math.round(n.page.width - n.width)),
295
+ top: () => valign(() => 0),
296
+ middle: () => valign((n) => Math.round(n.page.height / 2 - n.height / 2)),
297
+ bottom: () => valign((n) => Math.round(n.page.height - n.height)),
298
+ }
299
+ }
300
+
301
+ export function useDistributeAction() {
302
+ const editor = useEditor()
303
+
304
+ function distribute(pos: "x" | "y", size: "width" | "height") {
305
+ const rect = getTargetRect(editor.selection)
306
+ const array = editor.selection.values().toArray()
307
+
308
+ const undo: HistoryAction[] = array.map((node) => [
309
+ "set-node-props",
310
+ [node.id, { [pos]: node[pos] }],
311
+ ])
312
+
313
+ const total = array.reduce((sum, it) => sum + it[size], 0)
314
+ const gap = (rect[size] - total) / (array.length - 1)
315
+
316
+ let cursor = rect[pos]
317
+
318
+ const redo: HistoryAction[] = array
319
+ .toSorted((a, b) => a[pos] - b[pos])
320
+ .map((node) => {
321
+ node[pos] = cursor
322
+ cursor += node[size] + gap
323
+
324
+ return ["set-node-props", [node.id, { [pos]: node[pos] }]]
325
+ })
326
+
327
+ editor.history.push({
328
+ undo: ["batch", undo],
329
+ redo: ["batch", redo],
330
+ })
331
+ }
332
+
333
+ return {
334
+ horizontal: () => distribute("x", "width"),
335
+ vertical: () => distribute("y", "height"),
336
+ }
337
+ }
338
+
339
+ /** @example move([a,b,c,d], [b,d]) -> [b,a,d,c] */
340
+ function moveBackward<T>(arr: T[], elems: Set<T>): T[] {
341
+ if (arr.length < 2) return arr
342
+
343
+ for (let i = 1; i < arr.length; i++) {
344
+ const curr = arr[i]
345
+ if (elems.has(curr)) {
346
+ arr[i] = arr[i - 1]
347
+ arr[i - 1] = curr
348
+ }
349
+ }
350
+
351
+ return arr
352
+ }
353
+
354
+ /** @example move([a,b,c,d], [b,d]) -> [a,c,b,d] */
355
+ function moveForward<T>(arr: T[], elems: Set<T>): T[] {
356
+ if (arr.length < 2) return arr
357
+
358
+ for (let i = arr.length - 2; i >= 0; i--) {
359
+ const curr = arr[i]
360
+ if (elems.has(curr)) {
361
+ arr[i] = arr[i + 1]
362
+ arr[i + 1] = curr
363
+ }
364
+ }
365
+ return arr
366
+ }
367
+
368
+ export function useStackOrderAction() {
369
+ const editor = useEditor()
370
+
371
+ function order(page: Page, order: Node[]) {
372
+ const prevOrder = page.nodes.keys().toArray()
373
+ const nextOrder = order.map((n) => n.id)
374
+
375
+ page.nodes = new Map(order.map((n) => [n.id, n]))
376
+ editor.history.push({
377
+ undo: ["stack-order", [page.id, prevOrder]],
378
+ redo: ["stack-order", [page.id, nextOrder]],
379
+ })
380
+ }
381
+
382
+ const canBringBackward = useComputed(() => {
383
+ const { selection, selectionPage: page } = editor
384
+ if (selection.size !== 0 || !page) return false
385
+ const nodes = page.nodes.values().toArray()
386
+ return selection.values().some((n) => n !== nodes.at(0))
387
+ })
388
+
389
+ const canBringForward = useComputed(() => {
390
+ const { selection, selectionPage: page } = editor
391
+ if (selection.size !== 0 || !page) return false
392
+ const nodes = page.nodes.values().toArray()
393
+ return selection.values().some((n) => n !== nodes.at(-1))
394
+ })
395
+
396
+ return {
397
+ canBringBackward,
398
+ canBringForward,
399
+ bringBackward() {
400
+ const { selection, selectionPage: page } = editor
401
+ if (selection.size === 0 || !page) return
402
+ order(page, moveBackward(page.nodes.values().toArray(), selection))
403
+ },
404
+ bringForward() {
405
+ const { selection, selectionPage: page } = editor
406
+ if (selection.size === 0 || !page) return
407
+ order(page, moveForward(page.nodes.values().toArray(), selection))
408
+ },
409
+ bringToBack() {
410
+ const { selection, selectionPage: page } = editor
411
+ if (selection.size === 0 || !page) return
412
+ order(page, [
413
+ ...selection,
414
+ ...page.nodes.values().filter((node) => !selection.has(node)),
415
+ ])
416
+ },
417
+ bringToFront() {
418
+ const { selection, selectionPage: page } = editor
419
+ if (selection.size === 0 || !page) return
420
+ order(page, [
421
+ ...page.nodes.values().filter((node) => !selection.has(node)),
422
+ ...selection,
423
+ ])
424
+ },
425
+ }
426
+ }