@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.
Files changed (86) hide show
  1. package/.claude/settings.local.json +9 -0
  2. package/.github/workflows/ci.yml +34 -0
  3. package/README.md +24 -0
  4. package/demo/App.tsx +62 -0
  5. package/demo/EditorView/PageView/NodeContent.tsx +35 -0
  6. package/demo/EditorView/PageView/SnapLines.tsx +28 -0
  7. package/demo/EditorView/PageView/index.tsx +45 -0
  8. package/demo/EditorView/SelectionFrame/Corner.tsx +24 -0
  9. package/demo/EditorView/SelectionFrame/Edge.tsx +21 -0
  10. package/demo/EditorView/SelectionFrame/index.tsx +27 -0
  11. package/demo/EditorView/SelectionOverlay/ActionHud.tsx +32 -0
  12. package/demo/EditorView/SelectionOverlay/Rotation.tsx +39 -0
  13. package/demo/EditorView/SelectionOverlay/Toolbar.tsx +128 -0
  14. package/demo/EditorView/SelectionOverlay/index.tsx +21 -0
  15. package/demo/EditorView/Toolbar/index.tsx +68 -0
  16. package/demo/EditorView/index.tsx +47 -0
  17. package/demo/Navbar/index.tsx +33 -0
  18. package/demo/Sidebar/index.tsx +71 -0
  19. package/demo/hotkeys.ts +93 -0
  20. package/demo/main.tsx +10 -0
  21. package/demo/style.css +1 -0
  22. package/eslint.config.js +43 -0
  23. package/index.html +14 -0
  24. package/lib/hooks/actions.ts +426 -0
  25. package/lib/hooks/batch.ts +102 -0
  26. package/lib/hooks/editor.ts +18 -0
  27. package/lib/hooks/index.ts +23 -0
  28. package/lib/hooks/node.ts +33 -0
  29. package/lib/hooks/page.ts +26 -0
  30. package/lib/hooks/pointer/moveable.ts +98 -0
  31. package/lib/hooks/pointer/pointer.ts +56 -0
  32. package/lib/hooks/pointer/resize.ts +281 -0
  33. package/lib/hooks/pointer/rotation.ts +111 -0
  34. package/lib/hooks/pointer/selectionFrame.ts +97 -0
  35. package/lib/hooks/pointer/selector.ts +64 -0
  36. package/lib/hooks/pointer/snap.ts +97 -0
  37. package/lib/hooks/textMarks.ts +276 -0
  38. package/lib/lib/googleFonts.ts +162 -0
  39. package/lib/model/editor.ts +169 -0
  40. package/lib/model/geometry.ts +155 -0
  41. package/lib/model/history.ts +135 -0
  42. package/lib/model/index.ts +12 -0
  43. package/lib/model/node/editable/index.ts +85 -0
  44. package/lib/model/node/editable/letterSpacing.ts +61 -0
  45. package/lib/model/node/editable/persistentMarks.ts +45 -0
  46. package/lib/model/node/editable/tiptapExtensions.ts +33 -0
  47. package/lib/model/node/formattable.ts +108 -0
  48. package/lib/model/node/group.ts +79 -0
  49. package/lib/model/node/image.ts +41 -0
  50. package/lib/model/node/shape/polygon.ts +173 -0
  51. package/lib/model/node/shape/shape.ts +48 -0
  52. package/lib/model/node/text.ts +55 -0
  53. package/lib/model/node.ts +101 -0
  54. package/lib/model/page.ts +51 -0
  55. package/lib/model/traversal.ts +21 -0
  56. package/lib/ui/colors.ts +23 -0
  57. package/lib/ui/extractor.ts +57 -0
  58. package/lib/ui/index.ts +8 -0
  59. package/lib/ui/node/EditableContent.tsx +101 -0
  60. package/lib/ui/node/GroupContent.tsx +46 -0
  61. package/lib/ui/node/ImageContent.tsx +36 -0
  62. package/lib/ui/node/NodeView.tsx +68 -0
  63. package/lib/ui/node/PolygonContent.tsx +81 -0
  64. package/lib/ui/node/TextContent.tsx +40 -0
  65. package/lib/ui/node/useDoubleClick.ts +37 -0
  66. package/lib/ui/selection.ts +38 -0
  67. package/package.json +70 -0
  68. package/tests/createTestEditor.ts +19 -0
  69. package/tests/hooks/actions.test.tsx +736 -0
  70. package/tests/hooks/batch.test.tsx +332 -0
  71. package/tests/hooks/editor.test.tsx +56 -0
  72. package/tests/hooks/page.test.tsx +135 -0
  73. package/tests/hooks/pointer/pointer.test.tsx +244 -0
  74. package/tests/hooks/textMarks.test.tsx +624 -0
  75. package/tests/model/editor.test.ts +384 -0
  76. package/tests/model/history.test.ts +293 -0
  77. package/tests/model/node/group.test.ts +294 -0
  78. package/tests/model/node/image.test.ts +150 -0
  79. package/tests/model/node/polygon.test.ts +408 -0
  80. package/tests/model/node/text.test.ts +158 -0
  81. package/tests/model/node.test.ts +276 -0
  82. package/tests/model/page.test.ts +150 -0
  83. package/tests/setup.ts +7 -0
  84. package/tsconfig.json +28 -0
  85. package/vite.config.ts +9 -0
  86. 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
+ }