@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,111 @@
|
|
|
1
|
+
import { useRef } from "react"
|
|
2
|
+
import { useStore } from "react-bolt"
|
|
3
|
+
import { angle, type Point } from "../../model/geometry"
|
|
4
|
+
import type { HistoryAction } from "../../model/history"
|
|
5
|
+
import type { Node } from "../../model/node"
|
|
6
|
+
import { getTargetDOMRect } from "../../ui/selection"
|
|
7
|
+
import { useEditor } from "../editor"
|
|
8
|
+
import { usePointer } from "./pointer"
|
|
9
|
+
|
|
10
|
+
type RotationState = {
|
|
11
|
+
anchor: Point
|
|
12
|
+
center: Point
|
|
13
|
+
deg: number
|
|
14
|
+
nodes: Array<{ node: Node; deg: number }>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function useRotation() {
|
|
18
|
+
const start = useRef<RotationState | null>(null)
|
|
19
|
+
const editor = useEditor()
|
|
20
|
+
const selection = useStore(editor, "selection")
|
|
21
|
+
const selectionArray = selection.values().toArray()
|
|
22
|
+
|
|
23
|
+
return usePointer({
|
|
24
|
+
onDown(event) {
|
|
25
|
+
const { x, y, width, height } = getTargetDOMRect(selection)
|
|
26
|
+
const anchor = {
|
|
27
|
+
x: x + width / 2,
|
|
28
|
+
y: height,
|
|
29
|
+
}
|
|
30
|
+
const center = {
|
|
31
|
+
x: x + width / 2,
|
|
32
|
+
y: y + height / 2,
|
|
33
|
+
}
|
|
34
|
+
const deg = angle(center, anchor, {
|
|
35
|
+
x: event.clientX,
|
|
36
|
+
y: event.clientY,
|
|
37
|
+
})
|
|
38
|
+
start.current = {
|
|
39
|
+
anchor,
|
|
40
|
+
center,
|
|
41
|
+
deg,
|
|
42
|
+
nodes: selectionArray.map((node) => ({
|
|
43
|
+
node,
|
|
44
|
+
deg: node.rotation,
|
|
45
|
+
})),
|
|
46
|
+
}
|
|
47
|
+
editor.action = { action: "rotate", payload: { deg: 0 } }
|
|
48
|
+
},
|
|
49
|
+
onMove({ event }) {
|
|
50
|
+
if (!start.current) return
|
|
51
|
+
const { center, anchor, deg, nodes } = start.current
|
|
52
|
+
|
|
53
|
+
const next =
|
|
54
|
+
angle(center, anchor, {
|
|
55
|
+
x: event.clientX,
|
|
56
|
+
y: event.clientY,
|
|
57
|
+
}) - deg
|
|
58
|
+
|
|
59
|
+
for (const n of nodes) {
|
|
60
|
+
n.node.rotation = (n.deg + next) % 360
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
editor.action = {
|
|
64
|
+
action: "rotate",
|
|
65
|
+
payload: { deg: nodes.length === 1 ? nodes[0].node.rotation : next },
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
onCancel() {
|
|
69
|
+
editor.action = {}
|
|
70
|
+
start.current = null
|
|
71
|
+
},
|
|
72
|
+
onEnd({ event }) {
|
|
73
|
+
const s = start.current
|
|
74
|
+
if (!s) return
|
|
75
|
+
|
|
76
|
+
const deg =
|
|
77
|
+
angle(s.center, s.anchor, {
|
|
78
|
+
x: event.clientX,
|
|
79
|
+
y: event.clientY,
|
|
80
|
+
}) - s.deg
|
|
81
|
+
|
|
82
|
+
if (s.nodes.length === 0) {
|
|
83
|
+
const [n] = s.nodes
|
|
84
|
+
const rotation = (n.deg + deg) % 360
|
|
85
|
+
editor.history.push({
|
|
86
|
+
redo: ["set-node-props", [n.node.id, { rotation }]],
|
|
87
|
+
undo: ["set-node-props", [n.node.id, { rotation: n.deg }]],
|
|
88
|
+
})
|
|
89
|
+
n.node.rotation = rotation
|
|
90
|
+
} else {
|
|
91
|
+
const redo: HistoryAction[] = []
|
|
92
|
+
const undo: HistoryAction[] = []
|
|
93
|
+
|
|
94
|
+
for (const n of s.nodes) {
|
|
95
|
+
const rotation = (n.deg + deg) % 360
|
|
96
|
+
redo.push(["set-node-props", [n.node.id, { rotation }]])
|
|
97
|
+
undo.push(["set-node-props", [n.node.id, { rotation: n.deg }]])
|
|
98
|
+
n.node.rotation = rotation
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
editor.history.push({
|
|
102
|
+
redo: ["batch", redo],
|
|
103
|
+
undo: ["batch", undo],
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
editor.action = {}
|
|
108
|
+
start.current = null
|
|
109
|
+
},
|
|
110
|
+
})
|
|
111
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react"
|
|
2
|
+
import { useComputed, useStore } from "react-bolt"
|
|
3
|
+
import { getTargetDOMRect } from "../../ui/selection"
|
|
4
|
+
import { useEditor } from "../editor"
|
|
5
|
+
|
|
6
|
+
function arraysEqual<T>(a: T[], b: T[]): boolean {
|
|
7
|
+
if (a.length !== b.length) return false
|
|
8
|
+
|
|
9
|
+
for (let i = 0; i < a.length; i++) {
|
|
10
|
+
if (!Object.is(a[i], b[i])) return false
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return true
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function useObserver(onChange: () => void) {
|
|
17
|
+
const editor = useEditor()
|
|
18
|
+
const nodeRefs = useComputed({
|
|
19
|
+
equals: arraysEqual,
|
|
20
|
+
fn: () =>
|
|
21
|
+
editor.selection
|
|
22
|
+
.values()
|
|
23
|
+
.map((node) => node.ref)
|
|
24
|
+
.filter((ref) => ref instanceof HTMLElement)
|
|
25
|
+
.toArray(),
|
|
26
|
+
})
|
|
27
|
+
const editorRef = useStore(editor, "ref")
|
|
28
|
+
const zoom = useStore(editor, "zoom")
|
|
29
|
+
const pages = useStore(editor, "pages")
|
|
30
|
+
.values()
|
|
31
|
+
.map((page) => page.ref)
|
|
32
|
+
.toArray()
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
const mObserver = new MutationObserver(onChange)
|
|
36
|
+
const rObserver = new ResizeObserver(onChange)
|
|
37
|
+
|
|
38
|
+
for (const node of nodeRefs) {
|
|
39
|
+
mObserver.observe(node, { attributeFilter: ["style"] })
|
|
40
|
+
rObserver.observe(node)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
for (const page of pages) {
|
|
44
|
+
if (page) {
|
|
45
|
+
rObserver.observe(page)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (editorRef) {
|
|
50
|
+
editorRef.addEventListener("scroll", onChange)
|
|
51
|
+
rObserver.observe(editorRef)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return () => {
|
|
55
|
+
mObserver.disconnect()
|
|
56
|
+
rObserver.disconnect()
|
|
57
|
+
editorRef?.removeEventListener("scroll", onChange)
|
|
58
|
+
}
|
|
59
|
+
}, [onChange, nodeRefs, zoom, editor, pages, editorRef])
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function useSelectionFrame<E extends HTMLElement>(props?: {
|
|
63
|
+
accountForSingleSelection?: boolean
|
|
64
|
+
}) {
|
|
65
|
+
const ref = useRef<E>(null)
|
|
66
|
+
const editor = useEditor()
|
|
67
|
+
const selection = useStore(editor, "selection")
|
|
68
|
+
const zoom = useStore(editor, "zoom")
|
|
69
|
+
|
|
70
|
+
useObserver(() => {
|
|
71
|
+
const frame = ref.current
|
|
72
|
+
const stage = editor.ref!.getBoundingClientRect()
|
|
73
|
+
|
|
74
|
+
if (props?.accountForSingleSelection && selection.size === 1 && frame) {
|
|
75
|
+
const [firstNode] = selection
|
|
76
|
+
const { page } = firstNode
|
|
77
|
+
|
|
78
|
+
const { x, y, width, height, rotation } = firstNode
|
|
79
|
+
const relative = page.ref!.getBoundingClientRect()
|
|
80
|
+
const tx = relative.x - stage.x + x * zoom
|
|
81
|
+
const ty = relative.y - stage.y + y * zoom
|
|
82
|
+
|
|
83
|
+
frame.style.height = `${height * zoom}px`
|
|
84
|
+
frame.style.width = `${width * zoom}px`
|
|
85
|
+
frame.style.transform = `translate(${tx}px, ${ty}px) rotate(${rotation}deg)`
|
|
86
|
+
} else if (frame) {
|
|
87
|
+
const { x, y, width, height } = getTargetDOMRect(selection)
|
|
88
|
+
const tx = x - stage.x
|
|
89
|
+
const ty = y - stage.y
|
|
90
|
+
frame.style.height = `${height}px`
|
|
91
|
+
frame.style.width = `${width}px`
|
|
92
|
+
frame.style.transform = `translate(${tx}px, ${ty}px)`
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
return ref
|
|
97
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { isPointerInSelectionRect } from "../../ui/selection"
|
|
2
|
+
import { useEditor } from "../editor"
|
|
3
|
+
import { usePointer } from "./pointer"
|
|
4
|
+
|
|
5
|
+
export function useSelector(props: { ref: React.RefObject<HTMLDivElement | null> }) {
|
|
6
|
+
const editor = useEditor()
|
|
7
|
+
|
|
8
|
+
return usePointer({
|
|
9
|
+
onDown(event) {
|
|
10
|
+
const isPointerInsideRect = isPointerInSelectionRect(editor.selection, event)
|
|
11
|
+
|
|
12
|
+
if (!isPointerInsideRect && !editor.action.action) {
|
|
13
|
+
editor.action = { action: "select" }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return !isPointerInsideRect
|
|
17
|
+
},
|
|
18
|
+
onMove({ event, start }) {
|
|
19
|
+
const div = props.ref.current
|
|
20
|
+
if (!div) throw Error("selector div ref is null")
|
|
21
|
+
if (editor.action.action !== "select") return
|
|
22
|
+
|
|
23
|
+
const rect = editor.ref!.getBoundingClientRect()
|
|
24
|
+
|
|
25
|
+
const x = event.clientX - start.clientX
|
|
26
|
+
const y = event.clientY - start.clientY
|
|
27
|
+
const width = Math.floor(Math.abs(x))
|
|
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()
|
|
33
|
+
|
|
34
|
+
editor.selection = new Set(
|
|
35
|
+
editor.nodes.values().filter(({ ref }) => {
|
|
36
|
+
const node = ref?.getBoundingClientRect()
|
|
37
|
+
if (!node) throw Error("node.ref is null")
|
|
38
|
+
return !(
|
|
39
|
+
node.right <= left || // node is left of selection
|
|
40
|
+
node.left >= right || // node is right of selection
|
|
41
|
+
node.bottom <= top || // node is above selection
|
|
42
|
+
node.top >= bottom // node is below selection
|
|
43
|
+
)
|
|
44
|
+
}),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
div.style.width = `${width}px`
|
|
48
|
+
div.style.height = `${height}px`
|
|
49
|
+
div.style.transform = `translate(${tx}px, ${ty}px)`
|
|
50
|
+
div.style.display = "block"
|
|
51
|
+
},
|
|
52
|
+
onCancel() {
|
|
53
|
+
editor.selection = new Set() // click away
|
|
54
|
+
editor.action = {}
|
|
55
|
+
},
|
|
56
|
+
onEnd() {
|
|
57
|
+
editor.action = {}
|
|
58
|
+
|
|
59
|
+
const div = props.ref.current
|
|
60
|
+
if (!div) throw Error("selector div ref is null")
|
|
61
|
+
div.style.display = "none"
|
|
62
|
+
},
|
|
63
|
+
})
|
|
64
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { Rect } from "../../model/geometry"
|
|
2
|
+
import type { Node } from "../../model/node"
|
|
3
|
+
import type { Page } from "../../model/page"
|
|
4
|
+
import { useEditor, usePage } from "../editor"
|
|
5
|
+
|
|
6
|
+
type Lines = Page["snapLines"]
|
|
7
|
+
const isN = (n?: number) => typeof n === "number"
|
|
8
|
+
|
|
9
|
+
export function useSnap() {
|
|
10
|
+
const page = usePage()
|
|
11
|
+
const editor = useEditor()
|
|
12
|
+
const threshold = editor.options.snapThreshold
|
|
13
|
+
|
|
14
|
+
function shouldSnap(point: number) {
|
|
15
|
+
return (line: number) => line + threshold > point && point > line - threshold
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function ySnap(nodes: Node[], rect: Rect): [number, ...Lines] {
|
|
19
|
+
const nodelines = nodes.flatMap((n) => {
|
|
20
|
+
const { y, height } = n.boundingBox
|
|
21
|
+
return [y, y + height]
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
const hlines = [0, page.height, ...nodelines]
|
|
25
|
+
const top = hlines.find(shouldSnap(rect.y))
|
|
26
|
+
const bottom = hlines.find(shouldSnap(rect.y + rect.height))
|
|
27
|
+
|
|
28
|
+
if (isN(top) && isN(bottom)) {
|
|
29
|
+
return [
|
|
30
|
+
// FIXME: snap the the side thats closer to the box
|
|
31
|
+
top,
|
|
32
|
+
// FIXME: only show both lines if they are exactly on the box
|
|
33
|
+
{ y: top - 1 },
|
|
34
|
+
{ y: bottom },
|
|
35
|
+
]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (isN(top)) {
|
|
39
|
+
return [top, { y: top - 1 }]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (isN(bottom)) {
|
|
43
|
+
return [bottom - rect.height, { y: bottom }]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return [rect.y]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function xSnap(nodes: Node[], box: Rect): [number, ...Lines] {
|
|
50
|
+
const nodelines = nodes.flatMap((n) => {
|
|
51
|
+
const { x, width } = n.boundingBox
|
|
52
|
+
return [x, x + width]
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const hlines = [0, page.width, ...nodelines]
|
|
56
|
+
const left = hlines.find(shouldSnap(box.x))
|
|
57
|
+
const right = hlines.find(shouldSnap(box.x + box.width))
|
|
58
|
+
|
|
59
|
+
if (isN(left) && isN(right)) {
|
|
60
|
+
return [left, { x: left - 1 }, { x: right }]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (isN(left)) {
|
|
64
|
+
return [left, { x: left - 1 }]
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (isN(right)) {
|
|
68
|
+
return [right - box.width, { x: right }]
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return [box.x]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return function snap(snap: boolean, rect: Rect): Rect {
|
|
75
|
+
if (!snap) {
|
|
76
|
+
page.snapLines = []
|
|
77
|
+
return rect
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const nodes = page.nodes
|
|
81
|
+
.values()
|
|
82
|
+
.filter((node) => !editor.selection.has(node))
|
|
83
|
+
.toArray()
|
|
84
|
+
|
|
85
|
+
const [left, ...hlines] = xSnap(nodes, rect)
|
|
86
|
+
const [top, ...vlines] = ySnap(nodes, rect)
|
|
87
|
+
|
|
88
|
+
page.snapLines = [...vlines, ...hlines]
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
x: left,
|
|
92
|
+
y: top,
|
|
93
|
+
width: rect.width,
|
|
94
|
+
height: rect.height,
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { isEqual } from "es-toolkit"
|
|
2
|
+
import { useEffect, useState } from "react"
|
|
3
|
+
import { useComputed } from "react-bolt"
|
|
4
|
+
import { EditableNode } from "../model/node/editable"
|
|
5
|
+
import { FormattableNode } from "../model/node/formattable"
|
|
6
|
+
import { useBatchSet, useNodeField, useNodeFieldBatch } from "./batch"
|
|
7
|
+
import { TextNode } from "../model"
|
|
8
|
+
|
|
9
|
+
function mergeField<T>(values: T[]): T | null {
|
|
10
|
+
if (values.length === 0) return null
|
|
11
|
+
const [first, ...rest] = values
|
|
12
|
+
return rest.some((v) => v !== first) ? null : first
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function selector(node: EditableNode) {
|
|
16
|
+
const e = node.tiptap
|
|
17
|
+
const { color, fontSize, fontFamily, letterSpacing } = e.getAttributes("textStyle")
|
|
18
|
+
const size = fontSize
|
|
19
|
+
? parseInt(fontSize)
|
|
20
|
+
: Math.floor(parseFloat(getComputedStyle(document.documentElement).fontSize))
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
node,
|
|
24
|
+
isEmpty: e.isEmpty,
|
|
25
|
+
isBold: e.isActive("bold"),
|
|
26
|
+
isItalic: e.isActive("italic"),
|
|
27
|
+
isUnderline: e.isActive("underline"),
|
|
28
|
+
isStrike: e.isActive("strike"),
|
|
29
|
+
isSuperscript: e.isActive("superscript"),
|
|
30
|
+
isSubscript: e.isActive("subscript"),
|
|
31
|
+
color: color as string | null,
|
|
32
|
+
size: size,
|
|
33
|
+
spacing: letterSpacing ? parseFloat(letterSpacing) : 0,
|
|
34
|
+
family: fontFamily as string | null,
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function useTiptapState(editables: Array<EditableNode>) {
|
|
39
|
+
const [state, setState] = useState(() => editables.map(selector))
|
|
40
|
+
|
|
41
|
+
useNodeField(
|
|
42
|
+
editables.filter((n) => n instanceof TextNode),
|
|
43
|
+
"scale",
|
|
44
|
+
NaN,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
function update() {
|
|
49
|
+
const newState = editables.map(selector)
|
|
50
|
+
if (!isEqual(newState, state)) setState(newState)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
editables.map((e) => e.tiptap.on("transaction", update))
|
|
54
|
+
return () => void editables.map((e) => e.tiptap.off("transaction", update))
|
|
55
|
+
}, [editables, state, setState])
|
|
56
|
+
|
|
57
|
+
return state.map(({ node, ...s }) => {
|
|
58
|
+
const scale = node instanceof TextNode ? node.scale : 1
|
|
59
|
+
return { ...s, size: s.size * scale }
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function formattableProps(node: FormattableNode) {
|
|
64
|
+
return {
|
|
65
|
+
isBold: node.bold,
|
|
66
|
+
isItalic: node.italic,
|
|
67
|
+
isUnderline: node.underline,
|
|
68
|
+
isStrike: node.strike,
|
|
69
|
+
isSuperscript: node.superscript,
|
|
70
|
+
isSubscript: node.subscript,
|
|
71
|
+
color: node.color,
|
|
72
|
+
size: node.size,
|
|
73
|
+
spacing: node.spacing,
|
|
74
|
+
family: node.family,
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
type TextMarks = {
|
|
79
|
+
isBold: boolean
|
|
80
|
+
isItalic: boolean
|
|
81
|
+
isUnderline: boolean
|
|
82
|
+
isStrike: boolean
|
|
83
|
+
isSuperscript: boolean
|
|
84
|
+
isSubscript: boolean
|
|
85
|
+
color: string | null
|
|
86
|
+
size: number | null
|
|
87
|
+
spacing: number
|
|
88
|
+
family: string | null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function mergeState(states: TextMarks[]) {
|
|
92
|
+
return {
|
|
93
|
+
isBold: mergeField(states.map((s) => s.isBold)),
|
|
94
|
+
isItalic: mergeField(states.map((s) => s.isItalic)),
|
|
95
|
+
isUnderline: mergeField(states.map((s) => s.isUnderline)),
|
|
96
|
+
isStrike: mergeField(states.map((s) => s.isStrike)),
|
|
97
|
+
isSuperscript: mergeField(states.map((s) => s.isSuperscript)),
|
|
98
|
+
isSubscript: mergeField(states.map((s) => s.isSubscript)),
|
|
99
|
+
color: mergeField(states.map((s) => s.color)),
|
|
100
|
+
size: mergeField(states.map((s) => s.size)),
|
|
101
|
+
spacing: mergeField(states.map((s) => s.spacing)),
|
|
102
|
+
family: mergeField(states.map((s) => s.family)),
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function useFormattableState(formattables: Array<FormattableNode>) {
|
|
107
|
+
return useComputed({
|
|
108
|
+
equals: isEqual,
|
|
109
|
+
fn: () => formattables.map(formattableProps),
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function useTextMarksState(
|
|
114
|
+
editables: Array<EditableNode>,
|
|
115
|
+
formattables: Array<FormattableNode>,
|
|
116
|
+
) {
|
|
117
|
+
const editableState = useTiptapState(editables)
|
|
118
|
+
const formattableState = useFormattableState(formattables)
|
|
119
|
+
|
|
120
|
+
if (editables.length + formattables.length) {
|
|
121
|
+
const state = mergeState([
|
|
122
|
+
...editableState.filter((s) => !s.isEmpty),
|
|
123
|
+
...formattableState,
|
|
124
|
+
])
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
isBold: state.isBold ?? false,
|
|
128
|
+
isItalic: state.isItalic ?? false,
|
|
129
|
+
isUnderline: state.isUnderline ?? false,
|
|
130
|
+
isStrike: state.isStrike ?? false,
|
|
131
|
+
isSuperscript: state.isSuperscript ?? false,
|
|
132
|
+
isSubscript: state.isSubscript ?? false,
|
|
133
|
+
color: state.color,
|
|
134
|
+
size:
|
|
135
|
+
state.size ??
|
|
136
|
+
Math.floor(parseFloat(getComputedStyle(document.documentElement).fontSize)),
|
|
137
|
+
spacing: state.spacing ?? 0,
|
|
138
|
+
family: state.family ?? "Inter",
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return null
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// HACK:
|
|
146
|
+
// Clicking on the toolbar blurs the editor, but for editing actions we need to track it.
|
|
147
|
+
// We cannot do it purely in the `useFocusedTiptap` hook because the component using it
|
|
148
|
+
// might render *after* the blur, e.g the sidebar, but the `useTextMarks` hook operates
|
|
149
|
+
// on locally selected editor instances, e.g CalendarFields in the sidebar. We might be
|
|
150
|
+
// able to preserve the local state with an <Activity> but I feel like it makes more sense
|
|
151
|
+
// to handle it here, since there might only be one focused editor at a time either way.
|
|
152
|
+
let lastFocusedEditor: EditableNode | null = null
|
|
153
|
+
|
|
154
|
+
function getFocusedEditor(node: EditableNode[]) {
|
|
155
|
+
const focused = node.find((e) => e.tiptap.isFocused)
|
|
156
|
+
|
|
157
|
+
if (focused) {
|
|
158
|
+
return (lastFocusedEditor = focused)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (lastFocusedEditor && node.includes(lastFocusedEditor)) {
|
|
162
|
+
return lastFocusedEditor
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return null
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function blurNode(node: EditableNode) {
|
|
169
|
+
if (lastFocusedEditor === node) {
|
|
170
|
+
lastFocusedEditor = null
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function useFocusedTiptap(editables: EditableNode[]) {
|
|
175
|
+
const [focused, setFocused] = useState(getFocusedEditor(editables))
|
|
176
|
+
|
|
177
|
+
useEffect(() => {
|
|
178
|
+
const dispose = editables.map((e) => {
|
|
179
|
+
const onFocus = () => setFocused(e)
|
|
180
|
+
e.tiptap.on("focus", onFocus)
|
|
181
|
+
return () => e.tiptap.off("focus", onFocus)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
return () => void dispose.map((cb) => cb())
|
|
185
|
+
}, [editables, setFocused])
|
|
186
|
+
|
|
187
|
+
return focused
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// don't forget a unique key `selection.map((n) => n.id).join("")` on the parent component
|
|
191
|
+
export function useTextMarks(props: {
|
|
192
|
+
editables: Array<EditableNode>
|
|
193
|
+
formattables: Array<FormattableNode>
|
|
194
|
+
}) {
|
|
195
|
+
const { editables, formattables } = props
|
|
196
|
+
const state = useTextMarksState(editables, formattables)
|
|
197
|
+
const focused = useFocusedTiptap(editables)
|
|
198
|
+
const formattableColors = useNodeFieldBatch(formattables, "color", "")
|
|
199
|
+
const formattableSpacings = useNodeFieldBatch(formattables, "spacing", 0)
|
|
200
|
+
const batchSet = useBatchSet()
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
state,
|
|
204
|
+
toggle(
|
|
205
|
+
mark: "Bold" | "Italic" | "Underline" | "Strike" | "Superscript" | "Subscript",
|
|
206
|
+
) {
|
|
207
|
+
if (focused) {
|
|
208
|
+
focused.tiptap.commands[`toggle${mark}`]()
|
|
209
|
+
} else {
|
|
210
|
+
const key = mark.toLowerCase() as Lowercase<typeof mark>
|
|
211
|
+
const isMark = state && state[`is${mark}`]
|
|
212
|
+
const action = isMark ? "unset" : "set"
|
|
213
|
+
editables.map((e) => e.tiptap.chain().selectAll()[`${action}${mark}`]().run())
|
|
214
|
+
batchSet(formattables, { [key]: !isMark })
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
setColor(color: string, opts: { end: boolean }) {
|
|
218
|
+
if (focused) {
|
|
219
|
+
focused.tiptap.commands.setColor(color)
|
|
220
|
+
} else {
|
|
221
|
+
editables.map((e) => e.tiptap.chain().selectAll().setColor(color).run())
|
|
222
|
+
if (opts.end) {
|
|
223
|
+
formattableColors.onChangeEnd(color)
|
|
224
|
+
} else {
|
|
225
|
+
formattableColors.onChange(color)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
setSize(size: number) {
|
|
230
|
+
if (focused) {
|
|
231
|
+
const scale = focused instanceof TextNode ? focused.scale : 1
|
|
232
|
+
focused.tiptap.commands.setFontSize(`${size / scale}px`)
|
|
233
|
+
} else {
|
|
234
|
+
editables.map((e) => {
|
|
235
|
+
const scale = e instanceof TextNode ? e.scale : 1
|
|
236
|
+
e.tiptap
|
|
237
|
+
.chain()
|
|
238
|
+
.selectAll()
|
|
239
|
+
.setFontSize(`${size / scale}px`)
|
|
240
|
+
.run()
|
|
241
|
+
})
|
|
242
|
+
batchSet(formattables, { size })
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
setFamily(family: string | null) {
|
|
246
|
+
if (focused) {
|
|
247
|
+
if (family) {
|
|
248
|
+
focused.tiptap.commands.setFontFamily(family)
|
|
249
|
+
} else {
|
|
250
|
+
focused.tiptap.commands.unsetFontFamily()
|
|
251
|
+
}
|
|
252
|
+
} else {
|
|
253
|
+
if (family) {
|
|
254
|
+
editables.map((e) => e.tiptap.chain().selectAll().setFontFamily(family).run())
|
|
255
|
+
} else {
|
|
256
|
+
editables.map((e) => e.tiptap.chain().selectAll().unsetFontFamily().run())
|
|
257
|
+
}
|
|
258
|
+
batchSet(formattables, { family })
|
|
259
|
+
}
|
|
260
|
+
},
|
|
261
|
+
setSpacing(spacing: number, opts: { end: boolean }) {
|
|
262
|
+
if (focused) {
|
|
263
|
+
focused.tiptap.commands.setLetterSpacing(`${spacing}px`)
|
|
264
|
+
} else {
|
|
265
|
+
editables.map((e) =>
|
|
266
|
+
e.tiptap.chain().selectAll().setLetterSpacing(`${spacing}px`).run(),
|
|
267
|
+
)
|
|
268
|
+
if (opts.end) {
|
|
269
|
+
formattableSpacings.onChangeEnd(spacing)
|
|
270
|
+
} else {
|
|
271
|
+
formattableSpacings.onChange(spacing)
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
}
|
|
276
|
+
}
|