@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.
- package/.claude/settings.local.json +9 -0
- package/.github/workflows/ci.yml +34 -0
- package/README.md +24 -0
- package/demo/App.tsx +62 -0
- package/demo/EditorView/PageView/NodeContent.tsx +35 -0
- package/demo/EditorView/PageView/SnapLines.tsx +28 -0
- package/demo/EditorView/PageView/index.tsx +45 -0
- package/demo/EditorView/SelectionFrame/Corner.tsx +24 -0
- package/demo/EditorView/SelectionFrame/Edge.tsx +21 -0
- package/demo/EditorView/SelectionFrame/index.tsx +27 -0
- package/demo/EditorView/SelectionOverlay/ActionHud.tsx +32 -0
- package/demo/EditorView/SelectionOverlay/Rotation.tsx +39 -0
- package/demo/EditorView/SelectionOverlay/Toolbar.tsx +128 -0
- package/demo/EditorView/SelectionOverlay/index.tsx +21 -0
- package/demo/EditorView/Toolbar/index.tsx +68 -0
- package/demo/EditorView/index.tsx +47 -0
- package/demo/Navbar/index.tsx +33 -0
- package/demo/Sidebar/index.tsx +71 -0
- package/demo/hotkeys.ts +93 -0
- package/demo/main.tsx +10 -0
- package/demo/style.css +1 -0
- package/eslint.config.js +43 -0
- package/index.html +14 -0
- package/lib/hooks/actions.ts +426 -0
- package/lib/hooks/batch.ts +102 -0
- package/lib/hooks/editor.ts +18 -0
- package/lib/hooks/index.ts +23 -0
- package/lib/hooks/node.ts +33 -0
- package/lib/hooks/page.ts +26 -0
- package/lib/hooks/pointer/moveable.ts +98 -0
- package/lib/hooks/pointer/pointer.ts +56 -0
- package/lib/hooks/pointer/resize.ts +281 -0
- package/lib/hooks/pointer/rotation.ts +111 -0
- package/lib/hooks/pointer/selectionFrame.ts +97 -0
- package/lib/hooks/pointer/selector.ts +64 -0
- package/lib/hooks/pointer/snap.ts +97 -0
- package/lib/hooks/textMarks.ts +276 -0
- package/lib/lib/googleFonts.ts +162 -0
- package/lib/model/editor.ts +169 -0
- package/lib/model/geometry.ts +155 -0
- package/lib/model/history.ts +135 -0
- package/lib/model/index.ts +12 -0
- package/lib/model/node/editable/index.ts +85 -0
- package/lib/model/node/editable/letterSpacing.ts +61 -0
- package/lib/model/node/editable/persistentMarks.ts +45 -0
- package/lib/model/node/editable/tiptapExtensions.ts +33 -0
- package/lib/model/node/formattable.ts +108 -0
- package/lib/model/node/group.ts +79 -0
- package/lib/model/node/image.ts +41 -0
- package/lib/model/node/shape/polygon.ts +173 -0
- package/lib/model/node/shape/shape.ts +48 -0
- package/lib/model/node/text.ts +55 -0
- package/lib/model/node.ts +101 -0
- package/lib/model/page.ts +51 -0
- package/lib/model/traversal.ts +21 -0
- package/lib/ui/colors.ts +23 -0
- package/lib/ui/extractor.ts +57 -0
- package/lib/ui/index.ts +8 -0
- package/lib/ui/node/EditableContent.tsx +101 -0
- package/lib/ui/node/GroupContent.tsx +46 -0
- package/lib/ui/node/ImageContent.tsx +36 -0
- package/lib/ui/node/NodeView.tsx +68 -0
- package/lib/ui/node/PolygonContent.tsx +81 -0
- package/lib/ui/node/TextContent.tsx +40 -0
- package/lib/ui/node/useDoubleClick.ts +37 -0
- package/lib/ui/selection.ts +38 -0
- package/package.json +70 -0
- package/tests/createTestEditor.ts +19 -0
- package/tests/hooks/actions.test.tsx +736 -0
- package/tests/hooks/batch.test.tsx +332 -0
- package/tests/hooks/editor.test.tsx +56 -0
- package/tests/hooks/page.test.tsx +135 -0
- package/tests/hooks/pointer/pointer.test.tsx +244 -0
- package/tests/hooks/textMarks.test.tsx +624 -0
- package/tests/model/editor.test.ts +384 -0
- package/tests/model/history.test.ts +293 -0
- package/tests/model/node/group.test.ts +294 -0
- package/tests/model/node/image.test.ts +150 -0
- package/tests/model/node/polygon.test.ts +408 -0
- package/tests/model/node/text.test.ts +158 -0
- package/tests/model/node.test.ts +276 -0
- package/tests/model/page.test.ts +150 -0
- package/tests/setup.ts +7 -0
- package/tsconfig.json +28 -0
- package/vite.config.ts +9 -0
- 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
|
+
}
|
package/demo/hotkeys.ts
ADDED
|
@@ -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
package/demo/style.css
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@import "tailwindcss";
|
package/eslint.config.js
ADDED
|
@@ -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
|
+
}
|