@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,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
|
+
}
|