@lazlon-platform/html-editor 0.3.6 → 0.4.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,4 +1,4 @@
1
- import { isEqual, pick } from "es-toolkit"
1
+ import { isEqual } from "es-toolkit"
2
2
  import { useRef } from "react"
3
3
  import { useComputed } from "react-bolt"
4
4
  import { type HistoryAction } from "../model/history"
@@ -90,7 +90,8 @@ export function useBatchSet() {
90
90
  push = true
91
91
  const nextValues = typeof set === "function" ? set(node) : set
92
92
  const keys = Object.keys(nextValues) as WritableKeys<N>[]
93
- prev.push(["set-node-props", [node.id, pick(node, keys)]])
93
+ const prevValues = Object.fromEntries(keys.map((key) => [key, node[key]]))
94
+ prev.push(["set-node-props", [node.id, prevValues]])
94
95
  next.push(["set-node-props", [node.id, nextValues]])
95
96
  Object.assign(node, nextValues)
96
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) {
@@ -4,8 +4,7 @@ import type { Page } from "../page"
4
4
  import { EditableNode, type EditableNodeProps } from "./editable"
5
5
  import type { SerializedNode } from "../node"
6
6
 
7
- export type TextNodeProps = EditableNodeProps &
8
- Partial<Pick<TextNode, "halign" | "scale">>
7
+ export type TextNodeProps = EditableNodeProps & Partial<Pick<TextNode, "halign" | "size">>
9
8
 
10
9
  export class TextNode extends EditableNode {
11
10
  get name() {
@@ -13,21 +12,27 @@ export class TextNode extends EditableNode {
13
12
  }
14
13
 
15
14
  @state accessor halign: "left" | "center" | "right" | "justify"
16
- @state accessor scale: number = 1
15
+ @state accessor size: number = 16
17
16
  @state accessor contentHeight: number = 24
18
17
 
19
18
  @computed get height(): number {
20
- return this.contentHeight * this.scale
19
+ return this.contentHeight
21
20
  }
22
21
 
23
22
  set height(n: number) {
24
- this.scale = n / this.contentHeight
23
+ const target = Math.max(1, n)
24
+
25
+ if (this.contentHeight > 0) {
26
+ this.size = (this.size * target) / this.contentHeight
27
+ }
28
+
29
+ this.contentHeight = target
25
30
  }
26
31
 
27
- constructor(editor: Editor, page: Page, { halign, scale, ...props }: TextNodeProps) {
32
+ constructor(editor: Editor, page: Page, { halign, size, ...props }: TextNodeProps) {
28
33
  super(editor, page, props)
29
34
  this.halign = halign ?? "center"
30
- this.scale = scale ?? 1
35
+ this.size = size ?? 16
31
36
 
32
37
  this.tiptap.on("transaction", () => {
33
38
  const height = this.tiptap.view.dom.clientHeight
@@ -45,8 +50,8 @@ export class TextNode extends EditableNode {
45
50
  }
46
51
 
47
52
  props(): TextNodeProps {
48
- const { halign, scale } = this
49
- return { ...super.props(), halign, scale }
53
+ const { halign, size } = this
54
+ return { ...super.props(), halign, size }
50
55
  }
51
56
 
52
57
  serialize(): SerializedNode<this["name"], TextNodeProps> {
@@ -13,7 +13,7 @@ export function TextContent(props: {
13
13
  const { node, placeholder, isStatic } = props
14
14
  const editor = useEditor()
15
15
  const selection = useStore(editor, "selection")
16
- const [halign, scale, height] = useStore(node, "halign", "scale", "height")
16
+ const [halign, size] = useStore(node, "halign", "size")
17
17
 
18
18
  useEffect(() => {
19
19
  if (node.tiptap.isEmpty && !selection.has(node)) {
@@ -22,20 +22,17 @@ export function TextContent(props: {
22
22
  }, [node, selection])
23
23
 
24
24
  return (
25
- <div style={{ height }} className="overflow-clip m-auto">
26
- <EditableContent
27
- node={node}
28
- isStatic={isStatic}
29
- placeholder={placeholder}
30
- style={{ transform: `scale(${scale})` }}
31
- className={clsx(
32
- "**:whitespace-nowrap",
33
- halign === "left" && "text-left origin-top-left",
34
- halign === "center" && "text-center origin-top",
35
- halign === "right" && "text-right origin-top-right",
36
- halign === "justify" && "text-justify *:w-full origin-top",
37
- )}
38
- />
39
- </div>
25
+ <EditableContent
26
+ node={node}
27
+ isStatic={isStatic}
28
+ placeholder={placeholder}
29
+ style={{ fontSize: `${size}px` }}
30
+ className={clsx(
31
+ halign === "left" && "text-left origin-top-left",
32
+ halign === "center" && "text-center origin-top",
33
+ halign === "right" && "text-right origin-top-right",
34
+ halign === "justify" && "text-justify *:w-full origin-top",
35
+ )}
36
+ />
40
37
  )
41
38
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lazlon-platform/html-editor",
3
- "version": "0.3.6",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "lib"