@lazlon-platform/html-editor 0.4.0 → 0.6.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 (59) hide show
  1. package/lib/hooks/actions.ts +54 -26
  2. package/lib/hooks/batch.ts +17 -7
  3. package/lib/hooks/index.ts +1 -0
  4. package/lib/hooks/node.ts +14 -6
  5. package/lib/hooks/pointer/movePoint.ts +75 -0
  6. package/lib/hooks/pointer/moveable.ts +92 -57
  7. package/lib/hooks/pointer/pointer.ts +21 -11
  8. package/lib/hooks/pointer/resize.ts +176 -210
  9. package/lib/hooks/pointer/rotation.ts +89 -68
  10. package/lib/hooks/pointer/selectionFrame.ts +8 -11
  11. package/lib/hooks/pointer/selector.ts +62 -40
  12. package/lib/hooks/pointer/snap.ts +23 -23
  13. package/lib/hooks/textMarks.ts +1 -3
  14. package/lib/lib/googleFonts.ts +1 -5
  15. package/lib/model/editor.ts +13 -9
  16. package/lib/model/geometry/math.ts +623 -0
  17. package/lib/model/geometry/svg.ts +55 -0
  18. package/lib/model/history.ts +10 -13
  19. package/lib/model/index.ts +7 -10
  20. package/lib/model/node/{editable → editableNode}/index.ts +13 -29
  21. package/lib/model/node/{formattable.ts → formattableNode/index.ts} +5 -11
  22. package/lib/model/node/{group.ts → groupNode.ts} +9 -13
  23. package/lib/model/node/{image.ts → imageNode.ts} +5 -11
  24. package/lib/model/node/lineNode.ts +59 -0
  25. package/lib/model/node/{shape/shape.ts → shapeNode/index.ts} +30 -15
  26. package/lib/model/node/shapeNode/shape.ts +96 -0
  27. package/lib/model/node/{text.ts → textNode.ts} +19 -21
  28. package/lib/model/node.ts +11 -29
  29. package/lib/model/page.ts +4 -3
  30. package/lib/model/traversal.ts +1 -1
  31. package/lib/ui/extractor.ts +3 -3
  32. package/lib/ui/index.ts +2 -4
  33. package/lib/ui/node/{EditableContent.tsx → EditableContent/index.tsx} +4 -3
  34. package/lib/ui/node/GroupContent.tsx +1 -1
  35. package/lib/ui/node/ImageContent.tsx +1 -1
  36. package/lib/ui/node/LineContent.tsx +32 -0
  37. package/lib/ui/node/NodeView.tsx +1 -13
  38. package/lib/ui/node/ShapeContent/ArrowContent.tsx +57 -0
  39. package/lib/ui/node/ShapeContent/EllipseContent.tsx +37 -0
  40. package/lib/ui/node/ShapeContent/PolygonContent.tsx +62 -0
  41. package/lib/ui/node/ShapeContent/RectangleContent.tsx +35 -0
  42. package/lib/ui/node/ShapeContent/StarContent.tsx +75 -0
  43. package/lib/ui/node/ShapeContent/index.tsx +43 -0
  44. package/lib/ui/node/TextContent.tsx +1 -1
  45. package/lib/ui/selection.ts +9 -26
  46. package/package.json +34 -34
  47. package/lib/model/geometry.ts +0 -247
  48. package/lib/model/node/shape/arrow.ts +0 -50
  49. package/lib/model/node/shape/ellipse.ts +0 -26
  50. package/lib/model/node/shape/polygon.ts +0 -108
  51. package/lib/model/node/shape/star.ts +0 -63
  52. package/lib/ui/node/ArrowContent.tsx +0 -60
  53. package/lib/ui/node/EllipseContent.tsx +0 -49
  54. package/lib/ui/node/PolygonContent.tsx +0 -81
  55. package/lib/ui/node/StarContent.tsx +0 -60
  56. /package/lib/model/node/{editable → editableNode}/letterSpacing.ts +0 -0
  57. /package/lib/model/node/{editable → editableNode}/persistentMarks.ts +0 -0
  58. /package/lib/model/node/{editable → editableNode}/tiptapExtensions.ts +0 -0
  59. /package/lib/ui/node/{useDoubleClick.ts → EditableContent/useDoubleClick.ts} +0 -0
@@ -1,6 +1,7 @@
1
1
  import { useEffect, useRef } from "react"
2
2
  import { useComputed, useStore } from "react-bolt"
3
- import { getTargetDOMRect } from "../../ui/selection"
3
+ import { pointSubtract } from "../../model/geometry/math"
4
+ import { selectionDOMRect } from "../../ui/selection"
4
5
  import { useEditor } from "../editor"
5
6
 
6
7
  function arraysEqual<T>(a: T[], b: T[]): boolean {
@@ -73,23 +74,19 @@ export function useSelectionFrame<E extends HTMLElement>(props?: {
73
74
 
74
75
  if (props?.accountForSingleSelection && selection.size === 1 && frame) {
75
76
  const [firstNode] = selection
76
- const { page } = firstNode
77
-
78
77
  const { x, y, width, height, rotation } = firstNode
79
- const relative = page.ref!.getBoundingClientRect()
78
+ const relative = firstNode.page.ref!.getBoundingClientRect()
80
79
  const tx = relative.x - stage.x + x * zoom
81
80
  const ty = relative.y - stage.y + y * zoom
82
-
83
81
  frame.style.height = `${height * zoom}px`
84
82
  frame.style.width = `${width * zoom}px`
85
83
  frame.style.transform = `translate(${tx}px, ${ty}px) rotate(${rotation}deg)`
86
84
  } 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)`
85
+ const dom = selectionDOMRect(selection)
86
+ const t = pointSubtract(dom, stage)
87
+ frame.style.height = `${dom.height}px`
88
+ frame.style.width = `${dom.width}px`
89
+ frame.style.transform = `translate(${t.x}px, ${t.y}px)`
93
90
  }
94
91
  })
95
92
 
@@ -1,53 +1,78 @@
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
+ lineContainsPoint,
10
+ lineIntersectsBox,
11
+ accessibleLine,
12
+ } from "../../model/geometry/math"
2
13
  import { useEditor } from "../editor"
3
- import { usePointer } from "./pointer"
14
+ import { cursorPosition, usePointer } from "./pointer"
15
+ import { LineNode } from "../../model"
4
16
 
5
- export function useSelector(props: { ref: React.RefObject<HTMLDivElement | null> }) {
17
+ function clientPoint(event: { clientX: number; clientY: number }) {
18
+ return { x: event.clientX, y: event.clientY }
19
+ }
20
+
21
+ export function useSelector(view: (props: null | Rect) => void) {
6
22
  const editor = useEditor()
7
23
 
24
+ const dragAnchor = useRef({
25
+ clientX: 0,
26
+ clientY: 0,
27
+ })
28
+
8
29
  return usePointer({
9
30
  onDown(event) {
10
- const isPointerInsideRect = isPointerInSelectionRect(editor.selection, event)
31
+ for (const node of editor.nodes.values()) {
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
+ ) {
37
+ return false
38
+ }
39
+ }
11
40
 
12
- if (!isPointerInsideRect && !editor.action.action) {
41
+ if (!editor.action.action) {
13
42
  editor.action = { action: "select" }
43
+ dragAnchor.current = event
14
44
  }
15
-
16
- return !isPointerInsideRect
17
45
  },
18
- onMove({ event, start }) {
19
- const div = props.ref.current
20
- if (!div) throw Error("selector div ref is null")
46
+ onMove(event) {
21
47
  if (editor.action.action !== "select") return
48
+ const stage = editor.ref!.getBoundingClientRect()
22
49
 
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()
50
+ /* world view */ {
51
+ const p1 = clientPoint(dragAnchor.current)
52
+ const p2 = clientPoint(event)
53
+ view(rect(pointSubtract(p1, stage), pointSubtract(p2, stage)))
54
+ }
33
55
 
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
- )
56
+ const selection = editor.pages
57
+ .values()
58
+ .toArray()
59
+ .flatMap((page) => {
60
+ const p1 = cursorPosition(dragAnchor.current, page)
61
+ const p2 = cursorPosition(event, page)
62
+ const selection = box(rect(p1, p2))
63
+ return page.nodes
64
+ .values()
65
+ .toArray()
66
+ .filter((node) => {
67
+ if (node instanceof LineNode) {
68
+ return lineIntersectsBox(node, selection)
69
+ } else {
70
+ return boxIntersects(selection, box(node))
71
+ }
72
+ })
73
+ })
46
74
 
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"
75
+ editor.selection = new Set(selection)
51
76
  },
52
77
  onCancel() {
53
78
  editor.selection = new Set() // click away
@@ -55,10 +80,7 @@ export function useSelector(props: { ref: React.RefObject<HTMLDivElement | null>
55
80
  },
56
81
  onEnd() {
57
82
  editor.action = {}
58
-
59
- const div = props.ref.current
60
- if (!div) throw Error("selector div ref is null")
61
- div.style.display = "none"
83
+ view(null)
62
84
  },
63
85
  })
64
86
  }
@@ -1,14 +1,14 @@
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
- import { useEditor, usePage } from "../editor"
4
+ import { useEditor } from "../editor"
5
5
 
6
6
  type Lines = Page["snapLines"]
7
7
  const isN = (n?: number) => typeof n === "number"
8
8
 
9
- export function useSnap() {
10
- const page = usePage()
9
+ export function useSnap(page: Page) {
11
10
  const editor = useEditor()
11
+
12
12
  const threshold = editor.options.snapThreshold
13
13
 
14
14
  function shouldSnap(point: number) {
@@ -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,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 { TextNode, EditableNode, FormattableNode } from "../model"
7
5
  import { useBatchSet, useNodeFieldBatch } from "./batch"
8
6
 
9
7
  function mergeField<T>(values: T[]): T | null {
@@ -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 {
@@ -144,13 +144,17 @@ export class Editor {
144
144
  )
145
145
  }
146
146
 
147
- deserializeNode<T extends NodeProps>(
148
- page: Page,
149
- { name, props }: SerializedNode<string, T>,
150
- ): Node {
147
+ deserializeNode<T extends Node>(page: Page, { name, props }: SerializedNode<T>): T {
151
148
  const NodeClass = this.#schema.get(name)
152
149
  if (!NodeClass) throw Error(`cannot deserialize unknown Node: ${name}`)
153
- return new NodeClass(this, page, props)
150
+ return new NodeClass(page, props) as T
151
+ }
152
+
153
+ serializeNode<T extends Node>(node: T): SerializedNode<T> {
154
+ return {
155
+ name: node.name,
156
+ props: node.props,
157
+ }
154
158
  }
155
159
 
156
160
  serialize(): SerializedEditor {