@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.
Files changed (64) hide show
  1. package/lib/hooks/actions.ts +136 -87
  2. package/lib/hooks/batch.ts +24 -10
  3. package/lib/hooks/index.ts +7 -6
  4. package/lib/hooks/page.ts +2 -4
  5. package/lib/hooks/pointer/useMovePoint.ts +100 -0
  6. package/lib/hooks/pointer/{moveable.ts → useMoveable.ts} +47 -39
  7. package/lib/hooks/pointer/{pointer.ts → usePointer.ts} +4 -5
  8. package/lib/hooks/pointer/useResize/index.ts +31 -0
  9. package/lib/hooks/pointer/useResize/multi.ts +161 -0
  10. package/lib/hooks/pointer/useResize/multiLineNode.ts +99 -0
  11. package/lib/hooks/pointer/useResize/multiRegularNode.ts +109 -0
  12. package/lib/hooks/pointer/useResize/multiTextNode.ts +108 -0
  13. package/lib/hooks/pointer/useResize/singleRegularNode.ts +91 -0
  14. package/lib/hooks/pointer/useResize/singleTextNode.ts +115 -0
  15. package/lib/hooks/pointer/useRotation.ts +102 -0
  16. package/lib/hooks/pointer/{selector.ts → useSelector.ts} +18 -3
  17. package/lib/hooks/pointer/{snap.ts → useSnap.ts} +5 -4
  18. package/lib/hooks/{pointer/selectionFrame.ts → selectionFrame.ts} +9 -6
  19. package/lib/hooks/textMarks.ts +30 -21
  20. package/lib/lib/googleFonts.ts +1 -5
  21. package/lib/model/editor.ts +31 -13
  22. package/lib/model/geometry/math.ts +128 -1
  23. package/lib/model/history.ts +10 -13
  24. package/lib/model/index.ts +15 -10
  25. package/lib/model/node/{editable → editableNode}/index.ts +13 -29
  26. package/lib/model/node/{formattable.ts → formattableNode/index.ts} +5 -11
  27. package/lib/model/node/{group.ts → groupNode.ts} +9 -13
  28. package/lib/model/node/{image.ts → imageNode.ts} +6 -12
  29. package/lib/model/node/lineNode.ts +80 -0
  30. package/lib/model/node/{shape/shape.ts → shapeNode/index.ts} +30 -15
  31. package/lib/model/node/shapeNode/shape.ts +96 -0
  32. package/lib/model/node/{text.ts → textNode.ts} +9 -24
  33. package/lib/model/node.ts +27 -32
  34. package/lib/model/page.ts +4 -4
  35. package/lib/model/traversal.ts +1 -1
  36. package/lib/ui/extractor.ts +3 -3
  37. package/lib/ui/index.ts +2 -4
  38. package/lib/ui/node/{EditableContent.tsx → EditableContent/index.tsx} +10 -7
  39. package/lib/ui/node/GroupContent.tsx +1 -1
  40. package/lib/ui/node/ImageContent.tsx +1 -1
  41. package/lib/ui/node/LineContent.tsx +30 -0
  42. package/lib/ui/node/ShapeContent/ArrowContent.tsx +57 -0
  43. package/lib/ui/node/ShapeContent/EllipseContent.tsx +37 -0
  44. package/lib/ui/node/ShapeContent/PolygonContent.tsx +62 -0
  45. package/lib/ui/node/ShapeContent/RectangleContent.tsx +35 -0
  46. package/lib/ui/node/ShapeContent/StarContent.tsx +75 -0
  47. package/lib/ui/node/ShapeContent/index.tsx +43 -0
  48. package/lib/ui/node/TextContent.tsx +1 -1
  49. package/lib/ui/selection.ts +6 -5
  50. package/package.json +1 -1
  51. package/lib/hooks/pointer/resize.ts +0 -247
  52. package/lib/hooks/pointer/rotation.ts +0 -103
  53. package/lib/model/node/shape/arrow.ts +0 -50
  54. package/lib/model/node/shape/ellipse.ts +0 -26
  55. package/lib/model/node/shape/polygon.ts +0 -130
  56. package/lib/model/node/shape/star.ts +0 -91
  57. package/lib/ui/node/ArrowContent.tsx +0 -60
  58. package/lib/ui/node/EllipseContent.tsx +0 -49
  59. package/lib/ui/node/PolygonContent.tsx +0 -81
  60. package/lib/ui/node/StarContent.tsx +0 -60
  61. /package/lib/model/node/{editable → editableNode}/letterSpacing.ts +0 -0
  62. /package/lib/model/node/{editable → editableNode}/persistentMarks.ts +0 -0
  63. /package/lib/model/node/{editable → editableNode}/tiptapExtensions.ts +0 -0
  64. /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 "./pointer"
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
- if (boxContainsPoint(box(node), cursorPosition(event, node))) {
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) => boxIntersects(selection, box(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, usePage } from "../editor"
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 threshold = editor.options.snapThreshold
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 "../../model/geometry/math"
4
- import { selectionDOMRect } from "../../ui/selection"
5
- import { useEditor } from "../editor"
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!.getBoundingClientRect()
75
+ const stage = editor.ref?.getBoundingClientRect()
76
+ if (!frame || !stage) return
74
77
 
75
- if (props?.accountForSingleSelection && selection.size === 1 && frame) {
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 if (frame) {
87
+ } else {
85
88
  const dom = selectionDOMRect(selection)
86
89
  const t = pointSubtract(dom, stage)
87
90
  frame.style.height = `${dom.height}px`
@@ -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
- editables.map((e) => e.tiptap.chain().selectAll()[`${action}${mark}`]().run())
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
- editables.map((e) => e.tiptap.chain().selectAll().setColor(color).run())
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
- .filter((e) => !(e instanceof TextNode))
234
- .map((e) => {
235
- e.tiptap.chain().selectAll().setFontSize(`${size}px`).run()
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
- editables.map((e) => e.tiptap.chain().selectAll().setFontFamily(family).run())
255
+ for (const editable of editables.filter(nonLocked)) {
256
+ editable.tiptap.chain().selectAll().setFontFamily(family).run()
257
+ }
251
258
  } else {
252
- editables.map((e) => e.tiptap.chain().selectAll().unsetFontFamily().run())
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.map((e) =>
262
- e.tiptap.chain().selectAll().setLetterSpacing(`${spacing}px`).run(),
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) =>
@@ -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
- // the editor only uses these
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) {
@@ -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, NodeProps, SerializedNode } from "./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<Props extends NodeProps = NodeProps> {
10
- prototype: Node
11
- new (ctx: Editor, page: Page, props: Props): Node
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 NodeProps>(
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(this, page, props)
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
  }