@lazlon-platform/html-editor 0.3.6 → 0.5.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/lib/hooks/actions.ts +4 -3
- package/lib/hooks/batch.ts +3 -2
- package/lib/hooks/node.ts +14 -6
- package/lib/hooks/pointer/moveable.ts +75 -54
- package/lib/hooks/pointer/pointer.ts +22 -11
- package/lib/hooks/pointer/resize.ts +176 -210
- package/lib/hooks/pointer/rotation.ts +41 -55
- package/lib/hooks/pointer/selectionFrame.ts +8 -11
- package/lib/hooks/pointer/selector.ts +48 -40
- package/lib/hooks/pointer/snap.ts +20 -20
- package/lib/hooks/textMarks.ts +67 -50
- package/lib/model/geometry/math.ts +484 -0
- package/lib/model/geometry/svg.ts +55 -0
- package/lib/model/node/shape/arrow.ts +1 -1
- package/lib/model/node/shape/polygon.ts +23 -1
- package/lib/model/node/shape/star.ts +29 -1
- package/lib/model/node/text.ts +21 -12
- package/lib/model/node.ts +3 -7
- package/lib/model/page.ts +3 -1
- package/lib/ui/node/NodeView.tsx +1 -13
- package/lib/ui/node/TextContent.tsx +13 -16
- package/lib/ui/selection.ts +9 -26
- package/package.json +34 -34
- package/lib/model/geometry.ts +0 -247
|
@@ -1,53 +1,64 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useRef } from "react"
|
|
2
|
+
import {
|
|
3
|
+
type Rect,
|
|
4
|
+
box,
|
|
5
|
+
boxContainsPoint,
|
|
6
|
+
pointSubtract,
|
|
7
|
+
rect,
|
|
8
|
+
boxIntersects,
|
|
9
|
+
} from "../../model/geometry/math"
|
|
2
10
|
import { useEditor } from "../editor"
|
|
3
|
-
import { usePointer } from "./pointer"
|
|
11
|
+
import { cursorPosition, usePointer } from "./pointer"
|
|
4
12
|
|
|
5
|
-
|
|
13
|
+
function clientPoint(event: { clientX: number; clientY: number }) {
|
|
14
|
+
return { x: event.clientX, y: event.clientY }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function useSelector(view: (props: null | Rect) => void) {
|
|
6
18
|
const editor = useEditor()
|
|
7
19
|
|
|
20
|
+
const dragAnchor = useRef({
|
|
21
|
+
clientX: 0,
|
|
22
|
+
clientY: 0,
|
|
23
|
+
})
|
|
24
|
+
|
|
8
25
|
return usePointer({
|
|
9
26
|
onDown(event) {
|
|
10
|
-
const
|
|
27
|
+
for (const node of editor.nodes.values()) {
|
|
28
|
+
if (boxContainsPoint(box(node), cursorPosition(event, node))) {
|
|
29
|
+
return false
|
|
30
|
+
}
|
|
31
|
+
}
|
|
11
32
|
|
|
12
|
-
if (!
|
|
33
|
+
if (!editor.action.action) {
|
|
13
34
|
editor.action = { action: "select" }
|
|
35
|
+
dragAnchor.current = event
|
|
14
36
|
}
|
|
15
|
-
|
|
16
|
-
return !isPointerInsideRect
|
|
17
37
|
},
|
|
18
|
-
onMove(
|
|
19
|
-
const div = props.ref.current
|
|
20
|
-
if (!div) throw Error("selector div ref is null")
|
|
38
|
+
onMove(event) {
|
|
21
39
|
if (editor.action.action !== "select") return
|
|
40
|
+
const stage = editor.ref!.getBoundingClientRect()
|
|
22
41
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const height = Math.floor(Math.abs(y))
|
|
29
|
-
const tx = (x > 0 ? start.clientX : start.clientX - width) - rect.x
|
|
30
|
-
const ty = (y > 0 ? start.clientY : start.clientY - height) - rect.y
|
|
31
|
-
|
|
32
|
-
const { left, right, top, bottom } = div.getBoundingClientRect()
|
|
42
|
+
/* world view */ {
|
|
43
|
+
const p1 = clientPoint(dragAnchor.current)
|
|
44
|
+
const p2 = clientPoint(event)
|
|
45
|
+
view(rect(pointSubtract(p1, stage), pointSubtract(p2, stage)))
|
|
46
|
+
}
|
|
33
47
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
48
|
+
const selection = editor.pages
|
|
49
|
+
.values()
|
|
50
|
+
.toArray()
|
|
51
|
+
.flatMap((page) => {
|
|
52
|
+
const p1 = cursorPosition(dragAnchor.current, page)
|
|
53
|
+
const p2 = cursorPosition(event, page)
|
|
54
|
+
const selection = box(rect(p1, p2))
|
|
55
|
+
return page.nodes
|
|
56
|
+
.values()
|
|
57
|
+
.toArray()
|
|
58
|
+
.filter((node) => boxIntersects(selection, box(node)))
|
|
59
|
+
})
|
|
46
60
|
|
|
47
|
-
|
|
48
|
-
div.style.height = `${height}px`
|
|
49
|
-
div.style.transform = `translate(${tx}px, ${ty}px)`
|
|
50
|
-
div.style.display = "block"
|
|
61
|
+
editor.selection = new Set(selection)
|
|
51
62
|
},
|
|
52
63
|
onCancel() {
|
|
53
64
|
editor.selection = new Set() // click away
|
|
@@ -55,10 +66,7 @@ export function useSelector(props: { ref: React.RefObject<HTMLDivElement | null>
|
|
|
55
66
|
},
|
|
56
67
|
onEnd() {
|
|
57
68
|
editor.action = {}
|
|
58
|
-
|
|
59
|
-
const div = props.ref.current
|
|
60
|
-
if (!div) throw Error("selector div ref is null")
|
|
61
|
-
div.style.display = "none"
|
|
69
|
+
view(null)
|
|
62
70
|
},
|
|
63
71
|
})
|
|
64
72
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type Rect, box, boxBounds, rect } from "../../model/geometry/math"
|
|
2
2
|
import type { Node } from "../../model/node"
|
|
3
3
|
import type { Page } from "../../model/page"
|
|
4
4
|
import { useEditor, usePage } from "../editor"
|
|
@@ -17,13 +17,13 @@ export function useSnap() {
|
|
|
17
17
|
|
|
18
18
|
function ySnap(nodes: Node[], rect: Rect): [number, ...Lines] {
|
|
19
19
|
const nodelines = nodes.flatMap((n) => {
|
|
20
|
-
const { y, height } = n
|
|
20
|
+
const { y, height } = boxBounds(box(n))
|
|
21
21
|
return [y, y + height]
|
|
22
22
|
})
|
|
23
23
|
|
|
24
24
|
const hlines = [0, page.height, ...nodelines]
|
|
25
|
-
const top = hlines.find(shouldSnap(rect.
|
|
26
|
-
const bottom = hlines.find(shouldSnap(rect.
|
|
25
|
+
const top = hlines.find(shouldSnap(rect.top))
|
|
26
|
+
const bottom = hlines.find(shouldSnap(rect.bottom))
|
|
27
27
|
|
|
28
28
|
if (isN(top) && isN(bottom)) {
|
|
29
29
|
return [
|
|
@@ -46,15 +46,15 @@ export function useSnap() {
|
|
|
46
46
|
return [rect.y]
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
function xSnap(nodes: Node[],
|
|
49
|
+
function xSnap(nodes: Node[], rect: Rect): [number, ...Lines] {
|
|
50
50
|
const nodelines = nodes.flatMap((n) => {
|
|
51
|
-
const { x, width } = n
|
|
51
|
+
const { x, width } = boxBounds(box(n))
|
|
52
52
|
return [x, x + width]
|
|
53
53
|
})
|
|
54
54
|
|
|
55
55
|
const hlines = [0, page.width, ...nodelines]
|
|
56
|
-
const left = hlines.find(shouldSnap(
|
|
57
|
-
const right = hlines.find(shouldSnap(
|
|
56
|
+
const left = hlines.find(shouldSnap(rect.left))
|
|
57
|
+
const right = hlines.find(shouldSnap(rect.right))
|
|
58
58
|
|
|
59
59
|
if (isN(left) && isN(right)) {
|
|
60
60
|
return [left, { x: left - 1 }, { x: right }]
|
|
@@ -65,16 +65,16 @@ export function useSnap() {
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
if (isN(right)) {
|
|
68
|
-
return [right -
|
|
68
|
+
return [right - rect.width, { x: right }]
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
return [
|
|
71
|
+
return [rect.x]
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
return function snap(snap: boolean,
|
|
74
|
+
return function snap(snap: boolean, r: Rect): Rect {
|
|
75
75
|
if (!snap) {
|
|
76
76
|
page.snapLines = []
|
|
77
|
-
return
|
|
77
|
+
return r
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
const nodes = page.nodes
|
|
@@ -82,16 +82,16 @@ export function useSnap() {
|
|
|
82
82
|
.filter((node) => !editor.selection.has(node))
|
|
83
83
|
.toArray()
|
|
84
84
|
|
|
85
|
-
const [
|
|
86
|
-
const [
|
|
85
|
+
const [x, ...hlines] = xSnap(nodes, r)
|
|
86
|
+
const [y, ...vlines] = ySnap(nodes, r)
|
|
87
87
|
|
|
88
88
|
page.snapLines = [...vlines, ...hlines]
|
|
89
89
|
|
|
90
|
-
return {
|
|
91
|
-
x
|
|
92
|
-
y
|
|
93
|
-
width:
|
|
94
|
-
height:
|
|
95
|
-
}
|
|
90
|
+
return rect({
|
|
91
|
+
x,
|
|
92
|
+
y,
|
|
93
|
+
width: r.width,
|
|
94
|
+
height: r.height,
|
|
95
|
+
})
|
|
96
96
|
}
|
|
97
97
|
}
|
package/lib/hooks/textMarks.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { isEqual } from "es-toolkit"
|
|
2
|
-
import {
|
|
3
|
-
import { useComputed } from "react-bolt"
|
|
2
|
+
import { useCallback, useRef, useSyncExternalStore } from "react"
|
|
3
|
+
import { effect, useComputed } from "react-bolt"
|
|
4
4
|
import { TextNode } from "../model"
|
|
5
5
|
import { EditableNode } from "../model/node/editable"
|
|
6
6
|
import { FormattableNode } from "../model/node/formattable"
|
|
7
|
-
import { useBatchSet,
|
|
7
|
+
import { useBatchSet, useNodeFieldBatch } from "./batch"
|
|
8
8
|
|
|
9
9
|
function mergeField<T>(values: T[]): T | null {
|
|
10
10
|
if (values.length === 0) return null
|
|
@@ -12,14 +12,17 @@ function mergeField<T>(values: T[]): T | null {
|
|
|
12
12
|
return rest.some((v) => v !== first) ? null : first
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
function
|
|
15
|
+
function editableProps(node: EditableNode) {
|
|
16
16
|
const e = node.tiptap
|
|
17
17
|
const { color, fontSize, fontFamily, letterSpacing, lineHeight } =
|
|
18
18
|
e.getAttributes("textStyle")
|
|
19
19
|
|
|
20
|
-
const size =
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
const size =
|
|
21
|
+
node instanceof TextNode
|
|
22
|
+
? node.size
|
|
23
|
+
: fontSize
|
|
24
|
+
? parseInt(fontSize)
|
|
25
|
+
: Math.floor(parseFloat(getComputedStyle(document.documentElement).fontSize))
|
|
23
26
|
|
|
24
27
|
return {
|
|
25
28
|
node,
|
|
@@ -39,28 +42,36 @@ function selector(node: EditableNode) {
|
|
|
39
42
|
}
|
|
40
43
|
|
|
41
44
|
function useTiptapState(editables: Array<EditableNode>) {
|
|
42
|
-
const
|
|
45
|
+
const props = useRef(editables.map(editableProps))
|
|
43
46
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
47
|
+
return useSyncExternalStore(
|
|
48
|
+
useCallback(
|
|
49
|
+
(callback) => {
|
|
50
|
+
editables.map((e) => e.tiptap.on("transaction", callback))
|
|
51
|
+
const textNodes = editables.filter((e) => e instanceof TextNode)
|
|
52
|
+
// HACK: react-bolt does not expose .subscribe on store fields
|
|
53
|
+
const dispose = effect(() => {
|
|
54
|
+
for (const text of textNodes) {
|
|
55
|
+
void text.size
|
|
56
|
+
}
|
|
57
|
+
callback()
|
|
58
|
+
})
|
|
59
|
+
return () => {
|
|
60
|
+
dispose()
|
|
61
|
+
editables.map((e) => e.tiptap.off("transaction", callback))
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
[editables],
|
|
65
|
+
),
|
|
66
|
+
useCallback(() => {
|
|
67
|
+
const nextProps = editables.map(editableProps)
|
|
68
|
+
if (isEqual(props.current, nextProps)) {
|
|
69
|
+
return props.current
|
|
70
|
+
} else {
|
|
71
|
+
return (props.current = nextProps)
|
|
72
|
+
}
|
|
73
|
+
}, [editables]),
|
|
48
74
|
)
|
|
49
|
-
|
|
50
|
-
useEffect(() => {
|
|
51
|
-
function update() {
|
|
52
|
-
const newState = editables.map(selector)
|
|
53
|
-
if (!isEqual(newState, state)) setState(newState)
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
editables.map((e) => e.tiptap.on("transaction", update))
|
|
57
|
-
return () => void editables.map((e) => e.tiptap.off("transaction", update))
|
|
58
|
-
}, [editables, state, setState])
|
|
59
|
-
|
|
60
|
-
return state.map(({ node, ...s }) => {
|
|
61
|
-
const scale = node instanceof TextNode ? node.scale : 1
|
|
62
|
-
return { ...s, size: s.size * scale }
|
|
63
|
-
})
|
|
64
75
|
}
|
|
65
76
|
|
|
66
77
|
function formattableProps(node: FormattableNode) {
|
|
@@ -150,19 +161,24 @@ function useTextMarksState(
|
|
|
150
161
|
}
|
|
151
162
|
|
|
152
163
|
function useFocusedTiptap(editables: EditableNode[]) {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
})
|
|
161
|
-
|
|
162
|
-
return () => void dispose.map((cb) => cb())
|
|
163
|
-
}, [editables, setFocused])
|
|
164
|
+
return useSyncExternalStore(
|
|
165
|
+
useCallback(
|
|
166
|
+
(callback) => {
|
|
167
|
+
const dispose = editables.map((e) => {
|
|
168
|
+
e.tiptap.on("focus", callback)
|
|
169
|
+
return () => e.tiptap.off("focus", callback)
|
|
170
|
+
})
|
|
164
171
|
|
|
165
|
-
|
|
172
|
+
return () => {
|
|
173
|
+
dispose.forEach((cb) => cb())
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
[editables],
|
|
177
|
+
),
|
|
178
|
+
useCallback(() => {
|
|
179
|
+
return EditableNode.getFocused(editables)
|
|
180
|
+
}, [editables]),
|
|
181
|
+
)
|
|
166
182
|
}
|
|
167
183
|
|
|
168
184
|
// don't forget a unique key `selection.map((n) => n.id).join("")` on the parent component
|
|
@@ -207,18 +223,19 @@ export function useTextMarks(props: {
|
|
|
207
223
|
},
|
|
208
224
|
setSize(size: number) {
|
|
209
225
|
if (focused) {
|
|
210
|
-
|
|
211
|
-
|
|
226
|
+
if (focused instanceof TextNode) {
|
|
227
|
+
batchSet([focused], { size })
|
|
228
|
+
} else {
|
|
229
|
+
focused.tiptap.commands.setFontSize(`${size}px`)
|
|
230
|
+
}
|
|
212
231
|
} else {
|
|
213
|
-
editables
|
|
214
|
-
|
|
215
|
-
e
|
|
216
|
-
.chain()
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
})
|
|
221
|
-
batchSet(formattables, { size })
|
|
232
|
+
editables
|
|
233
|
+
.filter((e) => !(e instanceof TextNode))
|
|
234
|
+
.map((e) => {
|
|
235
|
+
e.tiptap.chain().selectAll().setFontSize(`${size}px`).run()
|
|
236
|
+
})
|
|
237
|
+
const textNodes = editables.filter((e) => e instanceof TextNode)
|
|
238
|
+
batchSet([...formattables, ...textNodes], { size })
|
|
222
239
|
}
|
|
223
240
|
},
|
|
224
241
|
setFamily(family: string | null) {
|