@lazlon-platform/html-editor 0.6.0 → 0.7.1

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 (38) hide show
  1. package/lib/hooks/actions.ts +89 -67
  2. package/lib/hooks/batch.ts +9 -5
  3. package/lib/hooks/index.ts +7 -7
  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} +30 -36
  7. package/lib/hooks/pointer/{pointer.ts → usePointer.ts} +2 -2
  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} +2 -1
  17. package/lib/hooks/pointer/{snap.ts → useSnap.ts} +3 -2
  18. package/lib/hooks/{pointer/selectionFrame.ts → selectionFrame.ts} +9 -6
  19. package/lib/hooks/textMarks.ts +30 -19
  20. package/lib/model/editor.ts +18 -4
  21. package/lib/model/geometry/math.ts +8 -20
  22. package/lib/model/index.ts +9 -1
  23. package/lib/model/node/imageNode.ts +1 -1
  24. package/lib/model/node/lineNode.ts +41 -20
  25. package/lib/model/node/textNode.ts +5 -14
  26. package/lib/model/node.ts +18 -9
  27. package/lib/model/page.ts +3 -2
  28. package/lib/ui/node/EditableContent/index.tsx +6 -5
  29. package/lib/ui/node/ImageContent.tsx +0 -1
  30. package/lib/ui/node/LineContent.tsx +1 -3
  31. package/lib/ui/node/NodeView.tsx +2 -0
  32. package/lib/ui/node/ShapeContent/EllipseContent.tsx +0 -1
  33. package/lib/ui/node/ShapeContent/RectangleContent.tsx +0 -1
  34. package/lib/ui/selection.ts +6 -5
  35. package/package.json +1 -1
  36. package/lib/hooks/pointer/movePoint.ts +0 -75
  37. package/lib/hooks/pointer/resize.ts +0 -247
  38. package/lib/hooks/pointer/rotation.ts +0 -138
@@ -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
+ }
@@ -11,7 +11,7 @@ import {
11
11
  accessibleLine,
12
12
  } from "../../model/geometry/math"
13
13
  import { useEditor } from "../editor"
14
- import { cursorPosition, usePointer } from "./pointer"
14
+ import { cursorPosition, usePointer } from "./usePointer"
15
15
  import { LineNode } from "../../model"
16
16
 
17
17
  function clientPoint(event: { clientX: number; clientY: number }) {
@@ -63,6 +63,7 @@ export function useSelector(view: (props: null | Rect) => void) {
63
63
  return page.nodes
64
64
  .values()
65
65
  .toArray()
66
+ .filter((node) => !node.locked)
66
67
  .filter((node) => {
67
68
  if (node instanceof LineNode) {
68
69
  return lineIntersectsBox(node, selection)
@@ -1,3 +1,4 @@
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"
@@ -8,8 +9,8 @@ const isN = (n?: number) => typeof n === "number"
8
9
 
9
10
  export function useSnap(page: Page) {
10
11
  const editor = useEditor()
11
-
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,7 +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, EditableNode, FormattableNode } from "../model"
4
+ import { EditableNode, FormattableNode, type Node, TextNode } from "../model"
5
5
  import { useBatchSet, useNodeFieldBatch } from "./batch"
6
6
 
7
7
  function mergeField<T>(values: T[]): T | null {
@@ -179,6 +179,10 @@ function useFocusedTiptap(editables: EditableNode[]) {
179
179
  )
180
180
  }
181
181
 
182
+ function nonLocked(node: Node) {
183
+ return !node.locked
184
+ }
185
+
182
186
  // don't forget a unique key `selection.map((n) => n.id).join("")` on the parent component
183
187
  export function useTextMarks(props: {
184
188
  editables: Array<EditableNode>
@@ -197,21 +201,25 @@ export function useTextMarks(props: {
197
201
  toggle(
198
202
  mark: "Bold" | "Italic" | "Underline" | "Strike" | "Superscript" | "Subscript",
199
203
  ) {
200
- if (focused) {
204
+ if (focused && !focused.locked) {
201
205
  focused.tiptap.commands[`toggle${mark}`]()
202
206
  } else {
203
207
  const key = mark.toLowerCase() as Lowercase<typeof mark>
204
208
  const isMark = state && state[`is${mark}`]
205
209
  const action = isMark ? "unset" : "set"
206
- 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
+ }
207
213
  batchSet(formattables, { [key]: !isMark })
208
214
  }
209
215
  },
210
216
  setColor(color: string, opts: { end: boolean }) {
211
- if (focused) {
217
+ if (focused && !focused.locked) {
212
218
  focused.tiptap.commands.setColor(color)
213
219
  } else {
214
- 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
+ }
215
223
  if (opts.end) {
216
224
  formattableColors.onChangeEnd(color)
217
225
  } else {
@@ -220,24 +228,23 @@ export function useTextMarks(props: {
220
228
  }
221
229
  },
222
230
  setSize(size: number) {
223
- if (focused) {
231
+ if (focused && !focused.locked) {
224
232
  if (focused instanceof TextNode) {
225
233
  batchSet([focused], { size })
226
234
  } else {
227
235
  focused.tiptap.commands.setFontSize(`${size}px`)
228
236
  }
229
237
  } else {
230
- editables
231
- .filter((e) => !(e instanceof TextNode))
232
- .map((e) => {
233
- e.tiptap.chain().selectAll().setFontSize(`${size}px`).run()
234
- })
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
+ }
235
242
  const textNodes = editables.filter((e) => e instanceof TextNode)
236
243
  batchSet([...formattables, ...textNodes], { size })
237
244
  }
238
245
  },
239
246
  setFamily(family: string | null) {
240
- if (focused) {
247
+ if (focused && !focused.locked) {
241
248
  if (family) {
242
249
  focused.tiptap.commands.setFontFamily(family)
243
250
  } else {
@@ -245,20 +252,24 @@ export function useTextMarks(props: {
245
252
  }
246
253
  } else {
247
254
  if (family) {
248
- 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
+ }
249
258
  } else {
250
- 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
+ }
251
262
  }
252
263
  batchSet(formattables, { family })
253
264
  }
254
265
  },
255
266
  setSpacing(spacing: number, opts: { end: boolean }) {
256
- if (focused) {
267
+ if (focused && !focused.locked) {
257
268
  focused.tiptap.commands.setLetterSpacing(`${spacing}px`)
258
269
  } else {
259
- editables.map((e) =>
260
- e.tiptap.chain().selectAll().setLetterSpacing(`${spacing}px`).run(),
261
- )
270
+ for (const editable of editables.filter(nonLocked)) {
271
+ editable.tiptap.chain().selectAll().setLetterSpacing(`${spacing}px`).run()
272
+ }
262
273
  if (opts.end) {
263
274
  formattableSpacings.onChangeEnd(spacing)
264
275
  } else {
@@ -267,7 +278,7 @@ export function useTextMarks(props: {
267
278
  }
268
279
  },
269
280
  setLineHeight(lineHeight: number, opts: { end: boolean }) {
270
- if (focused) {
281
+ if (focused && !focused.locked) {
271
282
  focused.tiptap.commands.setLineHeight(`${lineHeight}`)
272
283
  } else {
273
284
  editables.map((e) =>
@@ -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(
@@ -170,4 +169,19 @@ export class Editor {
170
169
  },
171
170
  }
172
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
+ }
173
187
  }
@@ -20,8 +20,8 @@ export interface Rect extends Point, Size {
20
20
  readonly right: number
21
21
  }
22
22
 
23
- export interface Line extends Point {
24
- readonly points: Point[]
23
+ export interface Line {
24
+ readonly vertices: Point[]
25
25
  readonly strokeWidth: number
26
26
  }
27
27
 
@@ -412,21 +412,13 @@ function segmentDistance(a: Point, b: Point, c: Point, d: Point): number {
412
412
  )
413
413
  }
414
414
 
415
- function linePoints(line: Line): Point[] {
416
- return line.points.map((p) => pointAdd(line, p))
417
- }
418
-
419
415
  /**
420
416
  * @returns Whether point is contained in the stroked polyline.
421
417
  */
422
418
  export function lineContainsPoint(line: Line, point: Point): boolean {
423
- const points = linePoints(line)
419
+ const points = line.vertices
424
420
  const radius = line.strokeWidth / 2
425
421
 
426
- if (points.length === 1) {
427
- return pointDist(point, points[0]) <= radius
428
- }
429
-
430
422
  for (let i = 0; i < points.length - 1; i++) {
431
423
  const a = points[i]
432
424
  const b = points[i + 1]
@@ -441,9 +433,7 @@ export function lineContainsPoint(line: Line, point: Point): boolean {
441
433
  * @returns Whether the stroked polyline intersects the box.
442
434
  */
443
435
  export function lineIntersectsBox(line: Line, box: Box): boolean {
444
- const points = linePoints(line)
445
-
446
- if (points.length === 0) return false
436
+ const points = line.vertices
447
437
 
448
438
  for (const point of points) {
449
439
  if (boxContainsPoint(box, point)) return true
@@ -479,12 +469,10 @@ export function lineIntersectsBox(line: Line, box: Box): boolean {
479
469
  * @returns Line with increased strokeWidth for accessibility reasons.
480
470
  */
481
471
  export function accessibleLine(line: Line): Line {
482
- const { x, y, strokeWidth, points } = line
472
+ const { strokeWidth, vertices } = line
483
473
  return {
484
- x,
485
- y,
486
- points,
487
- strokeWidth: Math.max(18, strokeWidth),
474
+ vertices,
475
+ strokeWidth: Math.max(24, strokeWidth),
488
476
  }
489
477
  }
490
478
 
@@ -586,7 +574,7 @@ export function resizeBox(box: Box, edge: Edge, by: number): Box {
586
574
  * Scale box by one of its edges keeping its aspect ratio.
587
575
  * The center and pivot is shifted accordingly so that visually only the edge moves.
588
576
  */
589
- export function scaleBox(box: Box, direction: Edge | Corner, by: number) {
577
+ export function scaleBox(box: Box, direction: Edge | Corner, by: number): Box {
590
578
  const vertical = direction.includes("n") || direction.includes("s")
591
579
  const scale =
592
580
  vertical && box.height !== 0
@@ -6,7 +6,15 @@ export { FormattableNode, type FormattableNodeProps } from "./node/formattableNo
6
6
  export { GroupNode, type GroupNodeProps } from "./node/groupNode"
7
7
  export { ImageNode, type ImageNodeProps } from "./node/imageNode"
8
8
  export { LineNode, type LineNodeProps } from "./node/lineNode"
9
- export { ShapeNode, type ShapeNodeProps } from "./node/shapeNode"
9
+ export {
10
+ ShapeNode,
11
+ type ShapeNodeProps,
12
+ newRectangleShape,
13
+ newPolygonShape,
14
+ newEllipseShape,
15
+ newArrowShape,
16
+ newStarShape,
17
+ } from "./node/shapeNode"
10
18
  export { TextNode, type TextNodeProps } from "./node/textNode"
11
19
  export { Page, type PageProps, type SerializedPage } from "./page"
12
20
  export { flattenNodes } from "./traversal"
@@ -22,7 +22,7 @@ export class ImageNode extends Node {
22
22
  ) {
23
23
  super(page, props)
24
24
  this.url = url ?? null
25
- this.fit = "cover"
25
+ this.fit = fit ?? "cover"
26
26
  this.roundness = roundness ?? 0
27
27
  this.borderColor = borderColor ?? "#000000"
28
28
  this.borderWidth = borderWidth ?? 0
@@ -1,5 +1,13 @@
1
1
  import { computed, state } from "react-bolt"
2
- import { point, pointAdd, rect, type Point } from "../geometry/math"
2
+ import {
3
+ floatNorm,
4
+ pointAdd,
5
+ rect,
6
+ rectCenter,
7
+ rotatePoint,
8
+ type Line,
9
+ type Point,
10
+ } from "../geometry/math"
3
11
  import { Node, type NodeProps } from "../node"
4
12
  import type { Page } from "../page"
5
13
 
@@ -12,7 +20,7 @@ export class LineNode extends Node {
12
20
  }
13
21
 
14
22
  /** Relative to {@link x},{@link y} */
15
- @state accessor points: Point[]
23
+ @state accessor points: Point[] = []
16
24
  @state accessor strokeWidth: number
17
25
  @state accessor strokeColor: string
18
26
 
@@ -20,40 +28,53 @@ export class LineNode extends Node {
20
28
  return this.rect.height
21
29
  }
22
30
 
23
- set height(_) {
24
- // no-op: size is calculated from x,y and points
31
+ set height(height: number) {
32
+ this.scalePoints("y", height)
25
33
  }
26
34
 
27
35
  @computed get width(): number {
28
36
  return this.rect.width
29
37
  }
30
38
 
31
- set width(_) {
32
- // no-op: size is calculated from x,y and points
33
- }
34
-
35
- @computed private get rect() {
36
- const local = point({
37
- x: super.x,
38
- y: super.y,
39
- })
40
-
41
- return rect(...this.points.map((p) => pointAdd(p, local)))
39
+ set width(width: number) {
40
+ this.scalePoints("x", width)
42
41
  }
43
42
 
44
43
  constructor(page: Page, { points, strokeWidth, strokeColor, ...props }: LineNodeProps) {
45
44
  super(page, props)
45
+ this.strokeWidth = strokeWidth ?? 1
46
+ this.strokeColor = strokeColor ?? "#000000"
46
47
  this.points = points ?? [
47
- { x: -50, y: -50 },
48
- { x: 100, y: 50 },
48
+ { x: 0, y: 0 },
49
49
  { x: 100, y: 0 },
50
50
  ]
51
- this.strokeWidth = strokeWidth ?? 1
52
- this.strokeColor = strokeColor ?? "#000000"
53
51
  }
54
52
 
55
53
  get props(): LineNodeProps {
56
- const { points, strokeWidth, strokeColor } = this
54
+ const { strokeWidth, strokeColor, points } = this
57
55
  return { ...super.props, points, strokeWidth, strokeColor }
58
56
  }
57
+
58
+ @computed private get rect() {
59
+ return rect(...this.points)
60
+ }
61
+
62
+ @computed get vertices(): Line["vertices"] {
63
+ const center = rectCenter(this)
64
+ return this.points.map((p) => rotatePoint(pointAdd(p, this), center, this.rotation))
65
+ }
66
+
67
+ private scalePoints(axis: keyof Point, size: number) {
68
+ const { x, y, width, height } = this.rect
69
+
70
+ const origin = axis === "x" ? x : y
71
+ const scale = size / (axis === "x" ? width : height)
72
+
73
+ this.points = this.points.map((p) => {
74
+ return {
75
+ ...p,
76
+ [axis]: floatNorm(origin + (p[axis] - origin) * scale),
77
+ }
78
+ })
79
+ }
59
80
  }
@@ -11,28 +11,19 @@ export class TextNode extends EditableNode {
11
11
 
12
12
  @state accessor halign: "left" | "center" | "right" | "justify"
13
13
  @state accessor size: number = 16
14
- @state accessor contentHeight: number = 24
15
14
 
16
15
  private observer = new ResizeObserver(([entry]) => {
17
- this.contentHeight = entry.target.clientHeight
16
+ this.height = entry.target.clientHeight
18
17
  })
19
18
 
20
- @computed get height(): number {
21
- return this.contentHeight
22
- }
23
-
24
- set height(_) {
25
- // no-op: TextNode's height can be set using its font-size
26
- }
27
-
28
- constructor(page: Page, { halign, size, ...props }: TextNodeProps) {
29
- super(page, props)
19
+ constructor(page: Page, { halign, size, height = 24, ...props }: TextNodeProps) {
20
+ super(page, { height, ...props })
30
21
  this.halign = halign ?? "center"
31
22
  this.size = size ?? 16
32
23
 
33
24
  this.tiptap.on("transaction", () => {
34
25
  const height = this.tiptap.view.dom.clientHeight
35
- if (height > 0) this.contentHeight = height
26
+ if (height > 0) this.height = height
36
27
  })
37
28
  }
38
29
 
@@ -41,7 +32,7 @@ export class TextNode extends EditableNode {
41
32
 
42
33
  if (ref) {
43
34
  this.observer.observe(ref)
44
- this.contentHeight = ref.clientHeight
35
+ this.height = ref.clientHeight
45
36
  } else {
46
37
  this.observer.disconnect()
47
38
  }
package/lib/model/node.ts CHANGED
@@ -10,7 +10,8 @@ export type SerializedNode<N extends Node = Node> = {
10
10
  }
11
11
 
12
12
  export interface NodeProps
13
- extends Pick<Node, "id" | "x" | "y" | "width" | "height">,
13
+ extends
14
+ Pick<Node, "id" | "x" | "y" | "width" | "height">,
14
15
  Partial<Pick<Node, "locked" | "rotation" | "css">> {
15
16
  // serialized as a list in case we need to allow
16
17
  // nodes to have multiple roles in the future
@@ -28,12 +29,12 @@ export abstract class Node {
28
29
  @state private accessor _width: number
29
30
  @state private accessor _height: number
30
31
 
31
- @state accessor rotation: Deg
32
- @state accessor role: string | null
33
- @state accessor locked: boolean
32
+ @state accessor rotation: Deg = deg(0)
33
+ @state accessor role: string | null = null
34
+ @state accessor locked: boolean = false
34
35
  @state accessor x: number = 0
35
36
  @state accessor y: number = 0
36
- @state accessor css: string
37
+ @state accessor css: string = ""
37
38
 
38
39
  @computed get width() {
39
40
  return this._width
@@ -60,10 +61,18 @@ export abstract class Node {
60
61
  this.y = props.y
61
62
  this._width = props.width
62
63
  this._height = props.height
63
- this.role = props.role?.[0] || null
64
- this.locked = props.locked ?? false
65
- this.rotation = props.rotation ?? deg(0)
66
- this.css = props.css ?? ""
64
+ if (typeof props.role === "object") {
65
+ this.role = props.role?.[0]
66
+ }
67
+ if (typeof props.locked === "boolean") {
68
+ this.locked = props.locked
69
+ }
70
+ if (typeof props.rotation === "number") {
71
+ this.rotation = props.rotation
72
+ }
73
+ if (typeof props.css === "string") {
74
+ this.css = props.css
75
+ }
67
76
  }
68
77
 
69
78
  get props(): NodeProps {
package/lib/model/page.ts CHANGED
@@ -2,8 +2,9 @@ import { state } from "react-bolt"
2
2
  import type { Editor } from "./editor"
3
3
  import { Node, type SerializedNode } from "./node"
4
4
 
5
- export interface PageProps
6
- extends Partial<Pick<Page, "background" | "width" | "height">> {
5
+ export interface PageProps extends Partial<
6
+ Pick<Page, "background" | "width" | "height">
7
+ > {
7
8
  id: string
8
9
  nodes?: Node[]
9
10
  }