@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.
package/lib/model/node.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { computed, state } from "react-bolt"
2
2
  import type { Editor } from "./editor"
3
- import { rotatedAABB } from "./geometry"
4
3
  import type { Page } from "./page"
4
+ import { deg, type Deg } from "./geometry/math"
5
5
 
6
6
  export type Schema = Map<string, typeof Node>
7
7
 
@@ -31,7 +31,7 @@ export abstract class Node {
31
31
  @state private accessor _width: number
32
32
  @state private accessor _height: number
33
33
 
34
- @state accessor rotation: number
34
+ @state accessor rotation: Deg
35
35
  @state accessor role: string | null
36
36
  @state accessor locked: boolean
37
37
  @state accessor x: number
@@ -65,7 +65,7 @@ export abstract class Node {
65
65
  this._height = props.height
66
66
  this.role = props.role?.[0] || null
67
67
  this.locked = props.locked ?? false
68
- this.rotation = props.rotation ?? 0
68
+ this.rotation = props.rotation ?? deg(0)
69
69
  this.css = props.css ?? ""
70
70
  }
71
71
 
@@ -94,8 +94,4 @@ export abstract class Node {
94
94
  props: this.props(),
95
95
  }
96
96
  }
97
-
98
- @computed get boundingBox() {
99
- return rotatedAABB(this, this.rotation)
100
- }
101
97
  }
package/lib/model/page.ts CHANGED
@@ -15,6 +15,7 @@ export interface SerializedPage extends Omit<PageProps, "nodes"> {
15
15
 
16
16
  export class Page {
17
17
  readonly id: string
18
+ readonly editor: Editor
18
19
 
19
20
  ref: HTMLElement | null = null
20
21
 
@@ -28,7 +29,8 @@ export class Page {
28
29
  | { y: number; x?: number; w?: number; h?: never } // vertical
29
30
  > = []
30
31
 
31
- constructor(_: Editor, props: PageProps) {
32
+ constructor(editor: Editor, props: PageProps) {
33
+ this.editor = editor
32
34
  this.id = props.id
33
35
  this.background = props.background ?? "#ffffff"
34
36
  this.width = props.width ?? 841
@@ -1,10 +1,9 @@
1
1
  import { useEffect } from "react"
2
2
  import { useStore } from "react-bolt"
3
3
  import { useEditor } from "../../hooks/editor"
4
+ import { isLoaded, loadFont } from "../../lib/googleFonts"
4
5
  import { Node } from "../../model/node"
5
6
  import { getFontFamilies } from "../../ui/extractor"
6
- import { isLoaded, loadFont } from "../../lib/googleFonts"
7
- import { isPointerInSelectionRect } from "../selection"
8
7
 
9
8
  export function NodeView(props: {
10
9
  node: Node
@@ -49,17 +48,6 @@ export function NodeView(props: {
49
48
  height: `${height}px`,
50
49
  transform: `translate(${x}px,${y}px) rotate(${rotation ?? 0}deg)`,
51
50
  }}
52
- onDragStart={(e) => e.preventDefault()}
53
- onPointerDown={(event) => {
54
- if (event.button !== 0) return
55
-
56
- const { selection } = editor
57
- if (selection.size === 0 || event.shiftKey) {
58
- editor.selection = new Set([...selection, node])
59
- } else if (!isPointerInSelectionRect(selection, event)) {
60
- editor.selection = new Set([node])
61
- }
62
- }}
63
51
  >
64
52
  {children}
65
53
  {css && <style>{`#${id} { ${css} }`}</style>}
@@ -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
  }
@@ -1,38 +1,21 @@
1
- import { boundingBox } from "../model/geometry"
1
+ import { box, boxBounds, rect, type Box, type Rect } from "../model/geometry/math"
2
2
  import type { Node } from "../model/node"
3
3
 
4
- export function getTargetDOMRect(selection: Set<Node>) {
4
+ export function selectionDOMRect(selection: Set<Node>): Rect {
5
5
  const rects = selection
6
6
  .values()
7
7
  .map((node) => node.ref)
8
8
  .filter((dom) => dom instanceof HTMLElement)
9
9
  .map((dom) => dom.getBoundingClientRect())
10
- .toArray()
11
10
 
12
- return boundingBox(rects)
11
+ return rect(...rects)
13
12
  }
14
13
 
15
- export function getTargetRect(selection: Set<Node>) {
16
- const rects = selection
17
- .values()
18
- .map((node) => node.boundingBox)
19
- .toArray()
20
-
21
- return boundingBox(rects)
22
- }
23
-
24
- export function isPointerInSelectionRect(
25
- selection: Set<Node>,
26
- event: React.PointerEvent,
27
- ) {
28
- const rect = getTargetDOMRect(selection)
29
- const right = rect.x + rect.width
30
- const bottom = rect.y + rect.height
14
+ export function selectionBox(selection: Set<Node>): Box {
15
+ if (selection.size === 1) {
16
+ const [node] = selection
17
+ return box(node)
18
+ }
31
19
 
32
- return (
33
- event.clientX >= rect.x &&
34
- event.clientX <= right &&
35
- event.clientY >= rect.y &&
36
- event.clientY <= bottom
37
- )
20
+ return box(boxBounds(...selection.values().map(box)))
38
21
  }
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.5.0",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "lib"
@@ -8,55 +8,55 @@
8
8
  "exports": {
9
9
  "./ui": "./lib/ui/index.ts",
10
10
  "./model": "./lib/model/index.ts",
11
- "./model/geometry": "./lib/model/geometry.ts",
11
+ "./model/geometry": "./lib/model/geometry/math.ts",
12
12
  "./hooks": "./lib/hooks/index.ts",
13
13
  "./googleFonts": "./lib/lib/googleFonts.ts"
14
14
  },
15
15
  "dependencies": {
16
- "@tiptap/core": "^3.20.0",
17
- "@tiptap/extension-bold": "^3.20.0",
18
- "@tiptap/extension-document": "^3.20.0",
19
- "@tiptap/extension-hard-break": "^3.20.0",
20
- "@tiptap/extension-italic": "^3.20.0",
21
- "@tiptap/extension-paragraph": "^3.20.0",
22
- "@tiptap/extension-strike": "^3.20.0",
23
- "@tiptap/extension-subscript": "^3.20.0",
24
- "@tiptap/extension-superscript": "^3.20.0",
25
- "@tiptap/extension-text": "^3.20.0",
26
- "@tiptap/extension-text-style": "^3.20.0",
27
- "@tiptap/extension-underline": "^3.20.0",
28
- "@tiptap/extensions": "^3.22.4",
29
- "@tiptap/pm": "^3.20.0",
30
- "@tiptap/react": "^3.20.0",
16
+ "@tiptap/core": "^3.23.1",
17
+ "@tiptap/extension-bold": "^3.23.1",
18
+ "@tiptap/extension-document": "^3.23.1",
19
+ "@tiptap/extension-hard-break": "^3.23.1",
20
+ "@tiptap/extension-italic": "^3.23.1",
21
+ "@tiptap/extension-paragraph": "^3.23.1",
22
+ "@tiptap/extension-strike": "^3.23.1",
23
+ "@tiptap/extension-subscript": "^3.23.1",
24
+ "@tiptap/extension-superscript": "^3.23.1",
25
+ "@tiptap/extension-text": "^3.23.1",
26
+ "@tiptap/extension-text-style": "^3.23.1",
27
+ "@tiptap/extension-underline": "^3.23.1",
28
+ "@tiptap/extensions": "^3.23.1",
29
+ "@tiptap/pm": "^3.23.1",
30
+ "@tiptap/react": "^3.23.1",
31
31
  "clsx": "^2.1.1",
32
- "es-toolkit": "^1.45.0",
33
- "react": "^19.2.0",
34
- "react-aria-components": "^1.15.1",
32
+ "es-toolkit": "^1.46.1",
33
+ "react": "^19.2.6",
34
+ "react-aria-components": "^1.17.0",
35
35
  "react-bolt": "^1.4.1",
36
- "react-dom": "^19.2.0",
37
- "react-hotkeys-hook": "^5.2.4",
38
- "tailwindcss": "^4.2.1"
36
+ "react-dom": "^19.2.6",
37
+ "react-hotkeys-hook": "^5.3.2",
38
+ "tailwindcss": "^4.3.0"
39
39
  },
40
40
  "devDependencies": {
41
- "@eslint/js": "^9.39.1",
42
- "@tailwindcss/vite": "^4.2.1",
41
+ "@eslint/js": "^9.39.4",
42
+ "@tailwindcss/vite": "^4.3.0",
43
43
  "@testing-library/jest-dom": "^6.9.1",
44
44
  "@testing-library/react": "^16.3.2",
45
- "@types/node": "^24.10.1",
45
+ "@types/node": "^24.12.3",
46
46
  "@types/react": "^19.2.7",
47
47
  "@types/react-dom": "^19.2.3",
48
- "@vitejs/plugin-react": "^5.1.1",
49
- "@vitest/coverage-v8": "^4.1.0",
50
- "eslint": "^9.39.1",
51
- "eslint-plugin-react-hooks": "^7.0.1",
48
+ "@vitejs/plugin-react": "^5.2.0",
49
+ "@vitest/coverage-v8": "^4.1.5",
50
+ "eslint": "^9.39.4",
51
+ "eslint-plugin-react-hooks": "^7.1.1",
52
52
  "eslint-plugin-react-refresh": "^0.4.24",
53
53
  "globals": "^16.5.0",
54
- "happy-dom": "^20.8.3",
54
+ "happy-dom": "^20.9.0",
55
55
  "typescript": "~5.9.3",
56
- "typescript-eslint": "^8.48.0",
57
- "vite": "^7.3.1",
56
+ "typescript-eslint": "^8.59.2",
57
+ "vite": "^7.3.3",
58
58
  "vite-tsconfig-paths": "^6.1.1",
59
- "vitest": "^4.1.0"
59
+ "vitest": "^4.1.5"
60
60
  },
61
61
  "prettier": {
62
62
  "semi": false,
@@ -1,247 +0,0 @@
1
- export interface Point {
2
- x: number
3
- y: number
4
- }
5
-
6
- export interface Rect extends Point {
7
- width: number
8
- height: number
9
- }
10
-
11
- function rectCorners({ x, y, width, height }: Rect): Point[] {
12
- return [
13
- { x, y },
14
- { x: x + width, y },
15
- { x, y: y + height },
16
- { x: x + width, y: y + height },
17
- ]
18
- }
19
-
20
- /**
21
- * @example
22
- * ```ts
23
- * const center = { x: 0, y: 0 }
24
- * const P = { x: 2, y: 0 }
25
- * rotate(P, center, 90) // P' = { x: 0, y: 2 }
26
- * ```
27
- *
28
- * P
29
- * ●
30
- * │
31
- * │ 90°
32
- * ●─────● P'
33
- */
34
- export function rotatePoint(p: Point, center: Point, deg: number): Point {
35
- const rad = (deg * Math.PI) / 180
36
- const cos = Math.cos(rad)
37
- const sin = Math.sin(rad)
38
- const dx = p.x - center.x
39
- const dy = p.y - center.y
40
-
41
- return {
42
- x: center.x + dx * cos - dy * sin,
43
- y: center.y + dx * sin + dy * cos,
44
- }
45
- }
46
-
47
- export function boundingBox(args: Point[] | Rect[]): Rect {
48
- const points: Point[] = args.flatMap((arg) =>
49
- "width" in arg && "height" in arg ? rectCorners(arg) : arg,
50
- )
51
-
52
- let minX = Infinity
53
- let minY = Infinity
54
- let maxX = -Infinity
55
- let maxY = -Infinity
56
-
57
- for (const p of points) {
58
- if (p.x < minX) minX = p.x
59
- if (p.y < minY) minY = p.y
60
- if (p.x > maxX) maxX = p.x
61
- if (p.y > maxY) maxY = p.y
62
- }
63
-
64
- return {
65
- x: minX,
66
- y: minY,
67
- width: maxX - minX,
68
- height: maxY - minY,
69
- }
70
- }
71
-
72
- export function rectCenter({ x, y, width, height }: Rect): Point {
73
- return {
74
- x: x + width / 2,
75
- y: y + height / 2,
76
- }
77
- }
78
-
79
- /**
80
- * axis-aligned bounding box (AABB) of the rotated rectangle
81
- */
82
- export function rotatedAABB(rect: Rect, deg: number): Rect {
83
- return boundingBox(rectCorners(rect).map((c) => rotatePoint(c, rectCenter(rect), deg)))
84
- }
85
-
86
- export function dist(a: Point, b: Point) {
87
- const dx = a.x - b.x
88
- const dy = a.y - b.y
89
- return Math.hypot(dx, dy)
90
- }
91
-
92
- export function sub(a: Point, b: Point) {
93
- return {
94
- x: a.x - b.x,
95
- y: a.y - b.y,
96
- }
97
- }
98
-
99
- export function add(a: Point, b: Point) {
100
- return {
101
- x: a.x + b.x,
102
- y: a.y + b.y,
103
- }
104
- }
105
-
106
- export function mul(p: Point, n: number) {
107
- return {
108
- x: p.x * n,
109
- y: p.y * n,
110
- }
111
- }
112
-
113
- export function norm(p: Point) {
114
- const d = Math.hypot(p.x, p.y)
115
- return {
116
- x: d === 0 ? 0 : p.x / d,
117
- y: d === 0 ? 0 : p.y / d,
118
- }
119
- }
120
-
121
- export function dot(a: Point, b: Point) {
122
- return a.x * b.x + a.y * b.y
123
- }
124
-
125
- export function clamp(n: number, lo: number, hi: number) {
126
- return Math.max(lo, Math.min(hi, n))
127
- }
128
-
129
- export function tidyFloat(v: number) {
130
- return Math.abs(v) < 1e-10 ? 0 : +v.toFixed(3)
131
- }
132
-
133
- export function radToDeg(rad: number): number {
134
- return (rad * 180) / Math.PI
135
- }
136
-
137
- /**
138
- * @example
139
- * ```ts
140
- * const center = { x: 0, y: 0 }
141
- * const a = { x: 0, y: 2 }
142
- * const b = { x: 2, y: 0 }
143
- * angle(center, a, b) // 90
144
- * ```
145
- * a
146
- * ●
147
- * │
148
- * │ 90°
149
- * ●─────● b
150
- */
151
- export function angle(center: Point, a: Point, b: Point): number {
152
- const angA = radToDeg(Math.atan2(a.y - center.y, a.x - center.x))
153
- const angB = radToDeg(Math.atan2(b.y - center.y, b.x - center.x))
154
- return Math.round(((((angA + angB + 180) % 360) + 360) % 360) - 180)
155
- }
156
-
157
- export function roundedPathData(points: Point[], roundness: number): string {
158
- const count = points.length
159
- const winding = Math.sign(
160
- points.reduce((area, curr, i) => {
161
- const next = points[(i + 1) % count]
162
- return area + curr.x * next.y - next.x * curr.y
163
- }, 0),
164
- )
165
-
166
- const cmds = Array.from({ length: count }, (_, i) => {
167
- const prev = points[(i - 1 + count) % count]
168
- const curr = points[i]
169
- const next = points[(i + 1) % count]
170
- const cmd = i === 0 ? "M" : "L"
171
-
172
- const v1 = norm(sub(prev, curr))
173
- const v2 = norm(sub(next, curr))
174
-
175
- const cosTheta = clamp(dot(v1, v2), -1, 1)
176
- const theta = Math.acos(cosTheta)
177
- const cornerCross =
178
- (curr.x - prev.x) * (next.y - curr.y) - (curr.y - prev.y) * (next.x - curr.x)
179
-
180
- if (!isFinite(theta) || theta < 1e-6 || roundness <= 0) {
181
- return `${cmd} ${tidyFloat(curr.x)} ${tidyFloat(curr.y)}`
182
- }
183
-
184
- const dPrev = dist(curr, prev)
185
- const dNext = dist(curr, next)
186
- const tIdeal = roundness / Math.tan(theta / 2)
187
- const t = Math.min(tIdeal, dPrev / 2, dNext / 2)
188
- const rEff = t * Math.tan(theta / 2)
189
- const p1 = add(curr, mul(v1, t))
190
- const p2 = add(curr, mul(v2, t))
191
- const isConcave = winding !== 0 && Math.sign(cornerCross) !== winding
192
- const sweep = isConcave ? 0 : 1
193
- const arc = `A ${tidyFloat(rEff)} ${tidyFloat(rEff)} 0 0 ${sweep} ${tidyFloat(p2.x)} ${tidyFloat(p2.y)}`
194
-
195
- return `${cmd} ${tidyFloat(p1.x)} ${tidyFloat(p1.y)} ${arc}`
196
- })
197
-
198
- return cmds.concat("Z").join(" ")
199
- }
200
-
201
- export function regularPolygonPoints(props: {
202
- width: number
203
- height: number
204
- sides: number
205
- }): Point[] {
206
- const { width, height, sides } = props
207
- const rotation = sides % 2 === 0 ? Math.PI / sides : 0
208
- const cx = width / 2
209
- const cy = height / 2
210
- const rx = width / 2
211
- const ry = height / 2
212
-
213
- return Array.from({ length: sides }, (_, i) => {
214
- const angle = (i * Math.PI * 2) / sides - Math.PI / 2 + rotation
215
- return {
216
- x: cx + rx * Math.cos(angle),
217
- y: cy + ry * Math.sin(angle),
218
- }
219
- })
220
- }
221
-
222
- export function starPoints(props: {
223
- width: number
224
- height: number
225
- corners: number
226
- depth: number
227
- }): Point[] {
228
- const { width, height, corners, depth } = props
229
- const cx = width / 2
230
- const cy = height / 2
231
- const outerRx = width / 2
232
- const outerRy = height / 2
233
- const innerRx = outerRx * depth
234
- const innerRy = outerRy * depth
235
-
236
- return Array.from({ length: corners * 2 }, (_, i) => {
237
- const angle = (i * Math.PI) / corners - Math.PI / 2
238
- const isOuter = i % 2 === 0
239
- const rx = isOuter ? outerRx : innerRx
240
- const ry = isOuter ? outerRy : innerRy
241
-
242
- return {
243
- x: cx + rx * Math.cos(angle),
244
- y: cy + ry * Math.sin(angle),
245
- }
246
- })
247
- }