@lazlon-platform/html-editor 0.5.0 → 0.7.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 +136 -87
- package/lib/hooks/batch.ts +24 -10
- package/lib/hooks/index.ts +7 -6
- package/lib/hooks/page.ts +2 -4
- package/lib/hooks/pointer/useMovePoint.ts +100 -0
- package/lib/hooks/pointer/{moveable.ts → useMoveable.ts} +47 -39
- package/lib/hooks/pointer/{pointer.ts → usePointer.ts} +4 -5
- package/lib/hooks/pointer/useResize/index.ts +31 -0
- package/lib/hooks/pointer/useResize/multi.ts +161 -0
- package/lib/hooks/pointer/useResize/multiLineNode.ts +99 -0
- package/lib/hooks/pointer/useResize/multiRegularNode.ts +109 -0
- package/lib/hooks/pointer/useResize/multiTextNode.ts +108 -0
- package/lib/hooks/pointer/useResize/singleRegularNode.ts +91 -0
- package/lib/hooks/pointer/useResize/singleTextNode.ts +115 -0
- package/lib/hooks/pointer/useRotation.ts +102 -0
- package/lib/hooks/pointer/{selector.ts → useSelector.ts} +18 -3
- package/lib/hooks/pointer/{snap.ts → useSnap.ts} +5 -4
- package/lib/hooks/{pointer/selectionFrame.ts → selectionFrame.ts} +9 -6
- package/lib/hooks/textMarks.ts +30 -21
- package/lib/lib/googleFonts.ts +1 -5
- package/lib/model/editor.ts +31 -13
- package/lib/model/geometry/math.ts +128 -1
- package/lib/model/history.ts +10 -13
- package/lib/model/index.ts +15 -10
- package/lib/model/node/{editable → editableNode}/index.ts +13 -29
- package/lib/model/node/{formattable.ts → formattableNode/index.ts} +5 -11
- package/lib/model/node/{group.ts → groupNode.ts} +9 -13
- package/lib/model/node/{image.ts → imageNode.ts} +6 -12
- package/lib/model/node/lineNode.ts +80 -0
- package/lib/model/node/{shape/shape.ts → shapeNode/index.ts} +30 -15
- package/lib/model/node/shapeNode/shape.ts +96 -0
- package/lib/model/node/{text.ts → textNode.ts} +9 -24
- package/lib/model/node.ts +27 -32
- package/lib/model/page.ts +4 -4
- package/lib/model/traversal.ts +1 -1
- package/lib/ui/extractor.ts +3 -3
- package/lib/ui/index.ts +2 -4
- package/lib/ui/node/{EditableContent.tsx → EditableContent/index.tsx} +10 -7
- package/lib/ui/node/GroupContent.tsx +1 -1
- package/lib/ui/node/ImageContent.tsx +1 -1
- package/lib/ui/node/LineContent.tsx +30 -0
- package/lib/ui/node/ShapeContent/ArrowContent.tsx +57 -0
- package/lib/ui/node/ShapeContent/EllipseContent.tsx +37 -0
- package/lib/ui/node/ShapeContent/PolygonContent.tsx +62 -0
- package/lib/ui/node/ShapeContent/RectangleContent.tsx +35 -0
- package/lib/ui/node/ShapeContent/StarContent.tsx +75 -0
- package/lib/ui/node/ShapeContent/index.tsx +43 -0
- package/lib/ui/node/TextContent.tsx +1 -1
- package/lib/ui/selection.ts +6 -5
- package/package.json +1 -1
- package/lib/hooks/pointer/resize.ts +0 -247
- package/lib/hooks/pointer/rotation.ts +0 -103
- package/lib/model/node/shape/arrow.ts +0 -50
- package/lib/model/node/shape/ellipse.ts +0 -26
- package/lib/model/node/shape/polygon.ts +0 -130
- package/lib/model/node/shape/star.ts +0 -91
- package/lib/ui/node/ArrowContent.tsx +0 -60
- package/lib/ui/node/EllipseContent.tsx +0 -49
- package/lib/ui/node/PolygonContent.tsx +0 -81
- package/lib/ui/node/StarContent.tsx +0 -60
- /package/lib/model/node/{editable → editableNode}/letterSpacing.ts +0 -0
- /package/lib/model/node/{editable → editableNode}/persistentMarks.ts +0 -0
- /package/lib/model/node/{editable → editableNode}/tiptapExtensions.ts +0 -0
- /package/lib/ui/node/{useDoubleClick.ts → EditableContent/useDoubleClick.ts} +0 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { useRef } from "react"
|
|
2
|
+
import { useStore } from "react-bolt"
|
|
3
|
+
import { Node } from "../../../model"
|
|
4
|
+
import {
|
|
5
|
+
box,
|
|
6
|
+
boxRect,
|
|
7
|
+
deg,
|
|
8
|
+
floatNorm,
|
|
9
|
+
perpDistance,
|
|
10
|
+
point,
|
|
11
|
+
resizeBox,
|
|
12
|
+
type Box,
|
|
13
|
+
type Corner,
|
|
14
|
+
type Edge,
|
|
15
|
+
} from "../../../model/geometry/math"
|
|
16
|
+
import { useEditor } from "../../editor"
|
|
17
|
+
import { cursorPosition, usePointer } from "../usePointer"
|
|
18
|
+
|
|
19
|
+
export function setRegularNodeSize(node: Node, size: Box) {
|
|
20
|
+
const { x, y, width, height } = boxRect(size)
|
|
21
|
+
node.x = floatNorm(x)
|
|
22
|
+
node.y = floatNorm(y)
|
|
23
|
+
node.width = floatNorm(width)
|
|
24
|
+
node.height = floatNorm(height)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function useSingleRegularNodeResize(direction: Edge | Corner) {
|
|
28
|
+
const editor = useEditor()
|
|
29
|
+
const edges = direction.split("") as Edge[]
|
|
30
|
+
const [node] = useStore(editor, "selection")
|
|
31
|
+
|
|
32
|
+
const state = useRef({
|
|
33
|
+
base: box(),
|
|
34
|
+
anchorPoint: point(),
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
function redoProps() {
|
|
38
|
+
const { x, y, width, height } = node
|
|
39
|
+
return { x, y, width, height }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function undoProps() {
|
|
43
|
+
const { x, y, width, height } = boxRect(state.current.base)
|
|
44
|
+
return { x, y, width, height }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return usePointer({
|
|
48
|
+
onDown(event) {
|
|
49
|
+
if (!node || node.locked) return false
|
|
50
|
+
|
|
51
|
+
state.current = {
|
|
52
|
+
base: box(node),
|
|
53
|
+
anchorPoint: cursorPosition(event, node.page),
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
onMove(event) {
|
|
58
|
+
const { base, anchorPoint } = state.current
|
|
59
|
+
const cursor = cursorPosition(event, node.page)
|
|
60
|
+
|
|
61
|
+
setRegularNodeSize(
|
|
62
|
+
node,
|
|
63
|
+
edges.reduce((size, edge) => {
|
|
64
|
+
const r = edge === "w" ? 90 : edge === "e" ? -90 : edge === "n" ? 180 : 0
|
|
65
|
+
return resizeBox(
|
|
66
|
+
size,
|
|
67
|
+
edge,
|
|
68
|
+
perpDistance(anchorPoint, cursor, deg(node.rotation + r)),
|
|
69
|
+
)
|
|
70
|
+
}, base),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
editor.action = {
|
|
74
|
+
action: "resize",
|
|
75
|
+
payload: { width: node.width, height: node.height },
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
onCancel() {
|
|
80
|
+
editor.action = {}
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
onEnd() {
|
|
84
|
+
editor.action = {}
|
|
85
|
+
editor.pushHistory({
|
|
86
|
+
redo: ["set-node-props", [node.id, redoProps()]],
|
|
87
|
+
undo: ["set-node-props", [node.id, undoProps()]],
|
|
88
|
+
})
|
|
89
|
+
},
|
|
90
|
+
})
|
|
91
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { useRef } from "react"
|
|
2
|
+
import { useComputed, useStore } from "react-bolt"
|
|
3
|
+
import { Node, TextNode } from "../../../model"
|
|
4
|
+
import {
|
|
5
|
+
box,
|
|
6
|
+
boxRect,
|
|
7
|
+
floatNorm,
|
|
8
|
+
point,
|
|
9
|
+
pointNorm,
|
|
10
|
+
pointSubtract,
|
|
11
|
+
rotatePoint,
|
|
12
|
+
scaleBox,
|
|
13
|
+
vectorDotProd,
|
|
14
|
+
type Box,
|
|
15
|
+
type Corner,
|
|
16
|
+
type Edge,
|
|
17
|
+
type Point,
|
|
18
|
+
} from "../../../model/geometry/math"
|
|
19
|
+
import { useEditor } from "../../editor"
|
|
20
|
+
import { cursorPosition, usePointer } from "../usePointer"
|
|
21
|
+
|
|
22
|
+
export function setTextNodeSize(
|
|
23
|
+
node: TextNode,
|
|
24
|
+
baseFontSize: number,
|
|
25
|
+
baseBoxSize: Box,
|
|
26
|
+
size: Box,
|
|
27
|
+
) {
|
|
28
|
+
const { height, width, x, y } = boxRect(size)
|
|
29
|
+
const targetHeight = Math.max(8, height)
|
|
30
|
+
node.size = floatNorm((baseFontSize * targetHeight) / baseBoxSize.height)
|
|
31
|
+
node.x = floatNorm(x)
|
|
32
|
+
node.y = floatNorm(y)
|
|
33
|
+
node.height = floatNorm(targetHeight)
|
|
34
|
+
node.width = floatNorm(width)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function useSingleTextNodeResize(direction: Edge | Corner) {
|
|
38
|
+
const editor = useEditor()
|
|
39
|
+
const [node] = useStore(editor, "selection")
|
|
40
|
+
const isLocked = useComputed(() => node instanceof Node && node.locked)
|
|
41
|
+
const enable =
|
|
42
|
+
node instanceof TextNode && !isLocked && direction !== "w" && direction !== "e"
|
|
43
|
+
|
|
44
|
+
const state = useRef({
|
|
45
|
+
anchorPoint: point(),
|
|
46
|
+
baseFontSize: 0,
|
|
47
|
+
baseBoxSize: box(),
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
function scaleDistance(cursor: Point) {
|
|
51
|
+
const { width, height, rotation } = state.current.baseBoxSize
|
|
52
|
+
const localDirection = pointNorm({
|
|
53
|
+
x: direction.includes("w") ? -width : direction.includes("e") ? width : 0,
|
|
54
|
+
y: direction.includes("n") ? -height : direction.includes("s") ? height : 0,
|
|
55
|
+
})
|
|
56
|
+
const distance = vectorDotProd(
|
|
57
|
+
pointSubtract(cursor, state.current.anchorPoint),
|
|
58
|
+
rotatePoint(localDirection, point(), rotation),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
if (direction.length === 1) return distance
|
|
62
|
+
const diagonal = Math.hypot(width, height)
|
|
63
|
+
return diagonal === 0 ? 0 : (distance * height) / diagonal
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function redoProps() {
|
|
67
|
+
const { x, y, width, height, size } = node as TextNode
|
|
68
|
+
return { x, y, width, height, size }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function undoProps() {
|
|
72
|
+
const { x, y, width, height } = boxRect(state.current.baseBoxSize)
|
|
73
|
+
const size = state.current.baseFontSize
|
|
74
|
+
return { x, y, width, height, size }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return usePointer({
|
|
78
|
+
onDown(event) {
|
|
79
|
+
if (enable) {
|
|
80
|
+
state.current = {
|
|
81
|
+
anchorPoint: cursorPosition(event, node.page),
|
|
82
|
+
baseFontSize: node.size,
|
|
83
|
+
baseBoxSize: box(node),
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return enable
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
onMove(event) {
|
|
90
|
+
if (!enable) return
|
|
91
|
+
|
|
92
|
+
const { baseFontSize, baseBoxSize } = state.current
|
|
93
|
+
const cursor = cursorPosition(event, node.page)
|
|
94
|
+
const size = scaleBox(baseBoxSize, direction, scaleDistance(cursor))
|
|
95
|
+
setTextNodeSize(node, baseFontSize, baseBoxSize, size)
|
|
96
|
+
|
|
97
|
+
editor.action = {
|
|
98
|
+
action: "resize",
|
|
99
|
+
payload: { width: node.width, height: node.height },
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
onCancel() {
|
|
104
|
+
editor.action = {}
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
onEnd() {
|
|
108
|
+
editor.action = {}
|
|
109
|
+
editor.pushHistory<TextNode>({
|
|
110
|
+
redo: ["set-node-props", [node.id, redoProps()]],
|
|
111
|
+
undo: ["set-node-props", [node.id, undoProps()]],
|
|
112
|
+
})
|
|
113
|
+
},
|
|
114
|
+
})
|
|
115
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { useRef } from "react"
|
|
2
|
+
import {
|
|
3
|
+
angle,
|
|
4
|
+
box,
|
|
5
|
+
boxRect,
|
|
6
|
+
deg,
|
|
7
|
+
floatNorm,
|
|
8
|
+
point,
|
|
9
|
+
rotatePoint,
|
|
10
|
+
type Box,
|
|
11
|
+
type Deg,
|
|
12
|
+
} from "../../model/geometry/math"
|
|
13
|
+
import { Node } from "../../model/node"
|
|
14
|
+
import { selectionBox } from "../../ui/selection"
|
|
15
|
+
import { useEditor } from "../editor"
|
|
16
|
+
import { cursorPosition, usePointer } from "./usePointer"
|
|
17
|
+
|
|
18
|
+
function snap(v: number): Deg {
|
|
19
|
+
return deg(Math.round(v / 45) * 45)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function useRotation() {
|
|
23
|
+
const editor = useEditor()
|
|
24
|
+
const origin = point()
|
|
25
|
+
|
|
26
|
+
const state = useRef({
|
|
27
|
+
base: deg(0),
|
|
28
|
+
pivot: point(),
|
|
29
|
+
nodes: [] as Array<{ node: Node; baseBox: Box }>,
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
function historyProps(entry: Node | Box) {
|
|
33
|
+
const { x, y } = entry instanceof Node ? entry : boxRect(entry)
|
|
34
|
+
return { x, y, rotation: entry.rotation }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return usePointer({
|
|
38
|
+
onDown(event) {
|
|
39
|
+
const page = editor.selectionPage
|
|
40
|
+
const nodes = editor.selection
|
|
41
|
+
.values()
|
|
42
|
+
.toArray()
|
|
43
|
+
.filter((n) => !n.locked)
|
|
44
|
+
if (nodes.length === 0 || !page) return false
|
|
45
|
+
|
|
46
|
+
const { center: pivot } = selectionBox(editor.selection)
|
|
47
|
+
|
|
48
|
+
state.current = {
|
|
49
|
+
base: angle(pivot, origin, cursorPosition(event, page)),
|
|
50
|
+
pivot,
|
|
51
|
+
nodes: nodes.map((node) => ({ node, baseBox: box(node) })),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
editor.action = {
|
|
55
|
+
action: "rotate",
|
|
56
|
+
payload: { deg: nodes.length > 1 ? 0 : nodes[0].rotation },
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
onMove(event) {
|
|
61
|
+
const { base, pivot, nodes } = state.current
|
|
62
|
+
const cursor = cursorPosition(event, editor.selectionPage!)
|
|
63
|
+
|
|
64
|
+
const delta = deg(angle(pivot, origin, cursor) - base)
|
|
65
|
+
const singleBase = nodes.length === 1 ? nodes[0].baseBox.rotation : null
|
|
66
|
+
const resultDelta = deg(
|
|
67
|
+
event.shiftKey
|
|
68
|
+
? typeof singleBase === "number"
|
|
69
|
+
? snap(singleBase + delta) - singleBase
|
|
70
|
+
: snap(delta)
|
|
71
|
+
: delta,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
for (const { node, baseBox } of nodes) {
|
|
75
|
+
const { x, y } = rotatePoint(baseBox.center, pivot, resultDelta)
|
|
76
|
+
node.x = floatNorm(x - baseBox.width / 2)
|
|
77
|
+
node.y = floatNorm(y - baseBox.height / 2)
|
|
78
|
+
node.rotation = deg(baseBox.rotation + resultDelta)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
editor.action = {
|
|
82
|
+
action: "rotate",
|
|
83
|
+
payload: { deg: resultDelta },
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
onCancel() {
|
|
88
|
+
editor.action = {}
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
onEnd() {
|
|
92
|
+
editor.action = {}
|
|
93
|
+
|
|
94
|
+
editor.pushHistory(
|
|
95
|
+
state.current.nodes.map(({ node, baseBox }) => ({
|
|
96
|
+
redo: ["set-node-props", [node.id, historyProps(node)]],
|
|
97
|
+
undo: ["set-node-props", [node.id, historyProps(baseBox)]],
|
|
98
|
+
})),
|
|
99
|
+
)
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
}
|
|
@@ -6,9 +6,13 @@ import {
|
|
|
6
6
|
pointSubtract,
|
|
7
7
|
rect,
|
|
8
8
|
boxIntersects,
|
|
9
|
+
lineContainsPoint,
|
|
10
|
+
lineIntersectsBox,
|
|
11
|
+
accessibleLine,
|
|
9
12
|
} from "../../model/geometry/math"
|
|
10
13
|
import { useEditor } from "../editor"
|
|
11
|
-
import { cursorPosition, usePointer } from "./
|
|
14
|
+
import { cursorPosition, usePointer } from "./usePointer"
|
|
15
|
+
import { LineNode } from "../../model"
|
|
12
16
|
|
|
13
17
|
function clientPoint(event: { clientX: number; clientY: number }) {
|
|
14
18
|
return { x: event.clientX, y: event.clientY }
|
|
@@ -25,7 +29,11 @@ export function useSelector(view: (props: null | Rect) => void) {
|
|
|
25
29
|
return usePointer({
|
|
26
30
|
onDown(event) {
|
|
27
31
|
for (const node of editor.nodes.values()) {
|
|
28
|
-
|
|
32
|
+
const cursor = cursorPosition(event, node.page)
|
|
33
|
+
if (
|
|
34
|
+
(node instanceof LineNode && lineContainsPoint(accessibleLine(node), cursor)) ||
|
|
35
|
+
(!(node instanceof LineNode) && boxContainsPoint(box(node), cursor))
|
|
36
|
+
) {
|
|
29
37
|
return false
|
|
30
38
|
}
|
|
31
39
|
}
|
|
@@ -55,7 +63,14 @@ export function useSelector(view: (props: null | Rect) => void) {
|
|
|
55
63
|
return page.nodes
|
|
56
64
|
.values()
|
|
57
65
|
.toArray()
|
|
58
|
-
.filter((node) =>
|
|
66
|
+
.filter((node) => !node.locked)
|
|
67
|
+
.filter((node) => {
|
|
68
|
+
if (node instanceof LineNode) {
|
|
69
|
+
return lineIntersectsBox(node, selection)
|
|
70
|
+
} else {
|
|
71
|
+
return boxIntersects(selection, box(node))
|
|
72
|
+
}
|
|
73
|
+
})
|
|
59
74
|
})
|
|
60
75
|
|
|
61
76
|
editor.selection = new Set(selection)
|
|
@@ -1,15 +1,16 @@
|
|
|
1
|
+
import { useStore } from "react-bolt"
|
|
1
2
|
import { type Rect, box, boxBounds, rect } from "../../model/geometry/math"
|
|
2
3
|
import type { Node } from "../../model/node"
|
|
3
4
|
import type { Page } from "../../model/page"
|
|
4
|
-
import { useEditor
|
|
5
|
+
import { useEditor } from "../editor"
|
|
5
6
|
|
|
6
7
|
type Lines = Page["snapLines"]
|
|
7
8
|
const isN = (n?: number) => typeof n === "number"
|
|
8
9
|
|
|
9
|
-
export function useSnap() {
|
|
10
|
-
const page = usePage()
|
|
10
|
+
export function useSnap(page: Page) {
|
|
11
11
|
const editor = useEditor()
|
|
12
|
-
const
|
|
12
|
+
const zoom = useStore(editor, "zoom")
|
|
13
|
+
const threshold = editor.options.snapThreshold / zoom
|
|
13
14
|
|
|
14
15
|
function shouldSnap(point: number) {
|
|
15
16
|
return (line: number) => line + threshold > point && point > line - threshold
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { useEffect, useRef } from "react"
|
|
2
2
|
import { useComputed, useStore } from "react-bolt"
|
|
3
|
-
import { pointSubtract } from "
|
|
4
|
-
import { selectionDOMRect } from "
|
|
5
|
-
import { useEditor } from "
|
|
3
|
+
import { pointSubtract } from "../model/geometry/math"
|
|
4
|
+
import { selectionDOMRect } from "../ui/selection"
|
|
5
|
+
import { useEditor } from "./editor"
|
|
6
6
|
|
|
7
7
|
function arraysEqual<T>(a: T[], b: T[]): boolean {
|
|
8
8
|
if (a.length !== b.length) return false
|
|
@@ -33,6 +33,8 @@ function useObserver(onChange: () => void) {
|
|
|
33
33
|
.toArray()
|
|
34
34
|
|
|
35
35
|
useEffect(() => {
|
|
36
|
+
onChange()
|
|
37
|
+
|
|
36
38
|
const mObserver = new MutationObserver(onChange)
|
|
37
39
|
const rObserver = new ResizeObserver(onChange)
|
|
38
40
|
|
|
@@ -70,9 +72,10 @@ export function useSelectionFrame<E extends HTMLElement>(props?: {
|
|
|
70
72
|
|
|
71
73
|
useObserver(() => {
|
|
72
74
|
const frame = ref.current
|
|
73
|
-
const stage = editor.ref
|
|
75
|
+
const stage = editor.ref?.getBoundingClientRect()
|
|
76
|
+
if (!frame || !stage) return
|
|
74
77
|
|
|
75
|
-
if (props?.accountForSingleSelection && selection.size === 1
|
|
78
|
+
if (props?.accountForSingleSelection && selection.size === 1) {
|
|
76
79
|
const [firstNode] = selection
|
|
77
80
|
const { x, y, width, height, rotation } = firstNode
|
|
78
81
|
const relative = firstNode.page.ref!.getBoundingClientRect()
|
|
@@ -81,7 +84,7 @@ export function useSelectionFrame<E extends HTMLElement>(props?: {
|
|
|
81
84
|
frame.style.height = `${height * zoom}px`
|
|
82
85
|
frame.style.width = `${width * zoom}px`
|
|
83
86
|
frame.style.transform = `translate(${tx}px, ${ty}px) rotate(${rotation}deg)`
|
|
84
|
-
} else
|
|
87
|
+
} else {
|
|
85
88
|
const dom = selectionDOMRect(selection)
|
|
86
89
|
const t = pointSubtract(dom, stage)
|
|
87
90
|
frame.style.height = `${dom.height}px`
|
package/lib/hooks/textMarks.ts
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
import { isEqual } from "es-toolkit"
|
|
2
2
|
import { useCallback, useRef, useSyncExternalStore } from "react"
|
|
3
3
|
import { effect, useComputed } from "react-bolt"
|
|
4
|
-
import { TextNode } from "../model"
|
|
5
|
-
import { EditableNode } from "../model/node/editable"
|
|
6
|
-
import { FormattableNode } from "../model/node/formattable"
|
|
4
|
+
import { EditableNode, FormattableNode, type Node, TextNode } from "../model"
|
|
7
5
|
import { useBatchSet, useNodeFieldBatch } from "./batch"
|
|
8
6
|
|
|
9
7
|
function mergeField<T>(values: T[]): T | null {
|
|
@@ -181,6 +179,10 @@ function useFocusedTiptap(editables: EditableNode[]) {
|
|
|
181
179
|
)
|
|
182
180
|
}
|
|
183
181
|
|
|
182
|
+
function nonLocked(node: Node) {
|
|
183
|
+
return !node.locked
|
|
184
|
+
}
|
|
185
|
+
|
|
184
186
|
// don't forget a unique key `selection.map((n) => n.id).join("")` on the parent component
|
|
185
187
|
export function useTextMarks(props: {
|
|
186
188
|
editables: Array<EditableNode>
|
|
@@ -199,21 +201,25 @@ export function useTextMarks(props: {
|
|
|
199
201
|
toggle(
|
|
200
202
|
mark: "Bold" | "Italic" | "Underline" | "Strike" | "Superscript" | "Subscript",
|
|
201
203
|
) {
|
|
202
|
-
if (focused) {
|
|
204
|
+
if (focused && !focused.locked) {
|
|
203
205
|
focused.tiptap.commands[`toggle${mark}`]()
|
|
204
206
|
} else {
|
|
205
207
|
const key = mark.toLowerCase() as Lowercase<typeof mark>
|
|
206
208
|
const isMark = state && state[`is${mark}`]
|
|
207
209
|
const action = isMark ? "unset" : "set"
|
|
208
|
-
|
|
210
|
+
for (const editable of editables.filter(nonLocked)) {
|
|
211
|
+
editable.tiptap.chain().selectAll()[`${action}${mark}`]().run()
|
|
212
|
+
}
|
|
209
213
|
batchSet(formattables, { [key]: !isMark })
|
|
210
214
|
}
|
|
211
215
|
},
|
|
212
216
|
setColor(color: string, opts: { end: boolean }) {
|
|
213
|
-
if (focused) {
|
|
217
|
+
if (focused && !focused.locked) {
|
|
214
218
|
focused.tiptap.commands.setColor(color)
|
|
215
219
|
} else {
|
|
216
|
-
|
|
220
|
+
for (const editable of editables.filter(nonLocked)) {
|
|
221
|
+
editable.tiptap.chain().selectAll().setColor(color).run()
|
|
222
|
+
}
|
|
217
223
|
if (opts.end) {
|
|
218
224
|
formattableColors.onChangeEnd(color)
|
|
219
225
|
} else {
|
|
@@ -222,24 +228,23 @@ export function useTextMarks(props: {
|
|
|
222
228
|
}
|
|
223
229
|
},
|
|
224
230
|
setSize(size: number) {
|
|
225
|
-
if (focused) {
|
|
231
|
+
if (focused && !focused.locked) {
|
|
226
232
|
if (focused instanceof TextNode) {
|
|
227
233
|
batchSet([focused], { size })
|
|
228
234
|
} else {
|
|
229
235
|
focused.tiptap.commands.setFontSize(`${size}px`)
|
|
230
236
|
}
|
|
231
237
|
} else {
|
|
232
|
-
editables
|
|
233
|
-
|
|
234
|
-
.
|
|
235
|
-
|
|
236
|
-
})
|
|
238
|
+
const nonTextNodes = editables.filter((e) => !(e instanceof TextNode))
|
|
239
|
+
for (const textNode of nonTextNodes.filter(nonLocked)) {
|
|
240
|
+
textNode.tiptap.chain().selectAll().setFontSize(`${size}px`).run()
|
|
241
|
+
}
|
|
237
242
|
const textNodes = editables.filter((e) => e instanceof TextNode)
|
|
238
243
|
batchSet([...formattables, ...textNodes], { size })
|
|
239
244
|
}
|
|
240
245
|
},
|
|
241
246
|
setFamily(family: string | null) {
|
|
242
|
-
if (focused) {
|
|
247
|
+
if (focused && !focused.locked) {
|
|
243
248
|
if (family) {
|
|
244
249
|
focused.tiptap.commands.setFontFamily(family)
|
|
245
250
|
} else {
|
|
@@ -247,20 +252,24 @@ export function useTextMarks(props: {
|
|
|
247
252
|
}
|
|
248
253
|
} else {
|
|
249
254
|
if (family) {
|
|
250
|
-
|
|
255
|
+
for (const editable of editables.filter(nonLocked)) {
|
|
256
|
+
editable.tiptap.chain().selectAll().setFontFamily(family).run()
|
|
257
|
+
}
|
|
251
258
|
} else {
|
|
252
|
-
|
|
259
|
+
for (const editable of editables.filter(nonLocked)) {
|
|
260
|
+
editable.tiptap.chain().selectAll().unsetFontFamily().run()
|
|
261
|
+
}
|
|
253
262
|
}
|
|
254
263
|
batchSet(formattables, { family })
|
|
255
264
|
}
|
|
256
265
|
},
|
|
257
266
|
setSpacing(spacing: number, opts: { end: boolean }) {
|
|
258
|
-
if (focused) {
|
|
267
|
+
if (focused && !focused.locked) {
|
|
259
268
|
focused.tiptap.commands.setLetterSpacing(`${spacing}px`)
|
|
260
269
|
} else {
|
|
261
|
-
editables.
|
|
262
|
-
|
|
263
|
-
|
|
270
|
+
for (const editable of editables.filter(nonLocked)) {
|
|
271
|
+
editable.tiptap.chain().selectAll().setLetterSpacing(`${spacing}px`).run()
|
|
272
|
+
}
|
|
264
273
|
if (opts.end) {
|
|
265
274
|
formattableSpacings.onChangeEnd(spacing)
|
|
266
275
|
} else {
|
|
@@ -269,7 +278,7 @@ export function useTextMarks(props: {
|
|
|
269
278
|
}
|
|
270
279
|
},
|
|
271
280
|
setLineHeight(lineHeight: number, opts: { end: boolean }) {
|
|
272
|
-
if (focused) {
|
|
281
|
+
if (focused && !focused.locked) {
|
|
273
282
|
focused.tiptap.commands.setLineHeight(`${lineHeight}`)
|
|
274
283
|
} else {
|
|
275
284
|
editables.map((e) =>
|
package/lib/lib/googleFonts.ts
CHANGED
|
@@ -55,8 +55,6 @@ export type GoogleWebfont = {
|
|
|
55
55
|
category: FontCategory
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
const variants: Variant[] = ["regular", "italic", "700", "700italic"]
|
|
59
|
-
|
|
60
58
|
// server only
|
|
61
59
|
export async function getGoogleFonts(
|
|
62
60
|
key: string,
|
|
@@ -81,9 +79,7 @@ export async function getGoogleFonts(
|
|
|
81
79
|
const res = await fetch(url)
|
|
82
80
|
const { items = [] } = (await res.json()) as { items?: GoogleWebfont[] }
|
|
83
81
|
|
|
84
|
-
|
|
85
|
-
const result = items.filter((font) => variants.some((v) => font.variants.includes(v)))
|
|
86
|
-
return sort ? result : result.sort()
|
|
82
|
+
return items
|
|
87
83
|
}
|
|
88
84
|
|
|
89
85
|
export function getLink(font: GoogleWebfont) {
|
package/lib/model/editor.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { computed, createStore, state } from "react-bolt"
|
|
2
2
|
import type { GoogleWebfont } from "../lib/googleFonts"
|
|
3
3
|
import { HistoryStore, type HistoryEntry } from "./history"
|
|
4
|
-
import type { Node,
|
|
4
|
+
import type { Node, SerializedNode } from "./node"
|
|
5
5
|
import { Page, type SerializedPage } from "./page"
|
|
6
6
|
|
|
7
7
|
const CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
|
8
8
|
|
|
9
|
-
export interface NodeConstructor<
|
|
10
|
-
prototype:
|
|
11
|
-
new (
|
|
9
|
+
export interface NodeConstructor<N extends Node = Node> {
|
|
10
|
+
prototype: N
|
|
11
|
+
new (page: Page, props: N["props"]): Node
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
export interface SerializedEditor {
|
|
@@ -59,6 +59,7 @@ export class Editor {
|
|
|
59
59
|
@state accessor zoom: number = 1
|
|
60
60
|
|
|
61
61
|
readonly options: EditorOptions
|
|
62
|
+
|
|
62
63
|
get history() {
|
|
63
64
|
return this.#history
|
|
64
65
|
}
|
|
@@ -74,8 +75,7 @@ export class Editor {
|
|
|
74
75
|
if (!equals) this._selection = set
|
|
75
76
|
}
|
|
76
77
|
|
|
77
|
-
@computed
|
|
78
|
-
get nodes(): Map<string, Node> {
|
|
78
|
+
@computed get nodes(): Map<string, Node> {
|
|
79
79
|
return new Map(
|
|
80
80
|
this.pages
|
|
81
81
|
.values()
|
|
@@ -84,8 +84,7 @@ export class Editor {
|
|
|
84
84
|
)
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
@computed
|
|
88
|
-
get selectionPage() {
|
|
87
|
+
@computed get selectionPage() {
|
|
89
88
|
if (this.selection.size === 0) return null
|
|
90
89
|
const [first, ...rest] = this.selection
|
|
91
90
|
return rest.reduce(
|
|
@@ -144,13 +143,17 @@ export class Editor {
|
|
|
144
143
|
)
|
|
145
144
|
}
|
|
146
145
|
|
|
147
|
-
deserializeNode<T extends
|
|
148
|
-
page: Page,
|
|
149
|
-
{ name, props }: SerializedNode<string, T>,
|
|
150
|
-
): Node {
|
|
146
|
+
deserializeNode<T extends Node>(page: Page, { name, props }: SerializedNode<T>): T {
|
|
151
147
|
const NodeClass = this.#schema.get(name)
|
|
152
148
|
if (!NodeClass) throw Error(`cannot deserialize unknown Node: ${name}`)
|
|
153
|
-
return new NodeClass(
|
|
149
|
+
return new NodeClass(page, props) as T
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
serializeNode<T extends Node>(node: T): SerializedNode<T> {
|
|
153
|
+
return {
|
|
154
|
+
name: node.name,
|
|
155
|
+
props: node.props,
|
|
156
|
+
}
|
|
154
157
|
}
|
|
155
158
|
|
|
156
159
|
serialize(): SerializedEditor {
|
|
@@ -166,4 +169,19 @@ export class Editor {
|
|
|
166
169
|
},
|
|
167
170
|
}
|
|
168
171
|
}
|
|
172
|
+
|
|
173
|
+
pushHistory<N extends Node>(entries: HistoryEntry<N> | Array<HistoryEntry<N>>) {
|
|
174
|
+
if (Array.isArray(entries)) {
|
|
175
|
+
if (entries.length > 1) {
|
|
176
|
+
this.history.push({
|
|
177
|
+
undo: ["batch", entries.map((e) => e.undo)],
|
|
178
|
+
redo: ["batch", entries.map((e) => e.redo)],
|
|
179
|
+
})
|
|
180
|
+
} else if (entries.length === 1) {
|
|
181
|
+
this.history.push(entries[0])
|
|
182
|
+
}
|
|
183
|
+
} else {
|
|
184
|
+
this.history.push(entries)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
169
187
|
}
|