@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.
@@ -1,53 +1,64 @@
1
- import { isPointerInSelectionRect } from "../../ui/selection"
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
- export function useSelector(props: { ref: React.RefObject<HTMLDivElement | null> }) {
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 isPointerInsideRect = isPointerInSelectionRect(editor.selection, event)
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 (!isPointerInsideRect && !editor.action.action) {
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({ event, start }) {
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
- 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()
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
- 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
- )
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
- 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"
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 { Rect } from "../../model/geometry"
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.boundingBox
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.y))
26
- const bottom = hlines.find(shouldSnap(rect.y + rect.height))
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[], box: Rect): [number, ...Lines] {
49
+ function xSnap(nodes: Node[], rect: Rect): [number, ...Lines] {
50
50
  const nodelines = nodes.flatMap((n) => {
51
- const { x, width } = n.boundingBox
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(box.x))
57
- const right = hlines.find(shouldSnap(box.x + box.width))
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 - box.width, { x: right }]
68
+ return [right - rect.width, { x: right }]
69
69
  }
70
70
 
71
- return [box.x]
71
+ return [rect.x]
72
72
  }
73
73
 
74
- return function snap(snap: boolean, rect: Rect): Rect {
74
+ return function snap(snap: boolean, r: Rect): Rect {
75
75
  if (!snap) {
76
76
  page.snapLines = []
77
- return rect
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 [left, ...hlines] = xSnap(nodes, rect)
86
- const [top, ...vlines] = ySnap(nodes, rect)
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: left,
92
- y: top,
93
- width: rect.width,
94
- height: rect.height,
95
- }
90
+ return rect({
91
+ x,
92
+ y,
93
+ width: r.width,
94
+ height: r.height,
95
+ })
96
96
  }
97
97
  }
@@ -1,10 +1,10 @@
1
1
  import { isEqual } from "es-toolkit"
2
- import { useEffect, useState } from "react"
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, useNodeField, useNodeFieldBatch } from "./batch"
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 selector(node: EditableNode) {
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 = fontSize
21
- ? parseInt(fontSize)
22
- : Math.floor(parseFloat(getComputedStyle(document.documentElement).fontSize))
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 [state, setState] = useState(() => editables.map(selector))
45
+ const props = useRef(editables.map(editableProps))
43
46
 
44
- useNodeField(
45
- editables.filter((n) => n instanceof TextNode),
46
- "scale",
47
- NaN,
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
- const [focused, setFocused] = useState(EditableNode.getFocused(editables))
154
-
155
- useEffect(() => {
156
- const dispose = editables.map((e) => {
157
- const onFocus = () => setFocused(e)
158
- e.tiptap.on("focus", onFocus)
159
- return () => e.tiptap.off("focus", onFocus)
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
- return focused
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
- const scale = focused instanceof TextNode ? focused.scale : 1
211
- focused.tiptap.commands.setFontSize(`${size / scale}px`)
226
+ if (focused instanceof TextNode) {
227
+ batchSet([focused], { size })
228
+ } else {
229
+ focused.tiptap.commands.setFontSize(`${size}px`)
230
+ }
212
231
  } else {
213
- editables.map((e) => {
214
- const scale = e instanceof TextNode ? e.scale : 1
215
- e.tiptap
216
- .chain()
217
- .selectAll()
218
- .setFontSize(`${size / scale}px`)
219
- .run()
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) {