@lazlon-platform/html-editor 0.1.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 (86) hide show
  1. package/.claude/settings.local.json +9 -0
  2. package/.github/workflows/ci.yml +34 -0
  3. package/README.md +24 -0
  4. package/demo/App.tsx +62 -0
  5. package/demo/EditorView/PageView/NodeContent.tsx +35 -0
  6. package/demo/EditorView/PageView/SnapLines.tsx +28 -0
  7. package/demo/EditorView/PageView/index.tsx +45 -0
  8. package/demo/EditorView/SelectionFrame/Corner.tsx +24 -0
  9. package/demo/EditorView/SelectionFrame/Edge.tsx +21 -0
  10. package/demo/EditorView/SelectionFrame/index.tsx +27 -0
  11. package/demo/EditorView/SelectionOverlay/ActionHud.tsx +32 -0
  12. package/demo/EditorView/SelectionOverlay/Rotation.tsx +39 -0
  13. package/demo/EditorView/SelectionOverlay/Toolbar.tsx +128 -0
  14. package/demo/EditorView/SelectionOverlay/index.tsx +21 -0
  15. package/demo/EditorView/Toolbar/index.tsx +68 -0
  16. package/demo/EditorView/index.tsx +47 -0
  17. package/demo/Navbar/index.tsx +33 -0
  18. package/demo/Sidebar/index.tsx +71 -0
  19. package/demo/hotkeys.ts +93 -0
  20. package/demo/main.tsx +10 -0
  21. package/demo/style.css +1 -0
  22. package/eslint.config.js +43 -0
  23. package/index.html +14 -0
  24. package/lib/hooks/actions.ts +426 -0
  25. package/lib/hooks/batch.ts +102 -0
  26. package/lib/hooks/editor.ts +18 -0
  27. package/lib/hooks/index.ts +23 -0
  28. package/lib/hooks/node.ts +33 -0
  29. package/lib/hooks/page.ts +26 -0
  30. package/lib/hooks/pointer/moveable.ts +98 -0
  31. package/lib/hooks/pointer/pointer.ts +56 -0
  32. package/lib/hooks/pointer/resize.ts +281 -0
  33. package/lib/hooks/pointer/rotation.ts +111 -0
  34. package/lib/hooks/pointer/selectionFrame.ts +97 -0
  35. package/lib/hooks/pointer/selector.ts +64 -0
  36. package/lib/hooks/pointer/snap.ts +97 -0
  37. package/lib/hooks/textMarks.ts +276 -0
  38. package/lib/lib/googleFonts.ts +162 -0
  39. package/lib/model/editor.ts +169 -0
  40. package/lib/model/geometry.ts +155 -0
  41. package/lib/model/history.ts +135 -0
  42. package/lib/model/index.ts +12 -0
  43. package/lib/model/node/editable/index.ts +85 -0
  44. package/lib/model/node/editable/letterSpacing.ts +61 -0
  45. package/lib/model/node/editable/persistentMarks.ts +45 -0
  46. package/lib/model/node/editable/tiptapExtensions.ts +33 -0
  47. package/lib/model/node/formattable.ts +108 -0
  48. package/lib/model/node/group.ts +79 -0
  49. package/lib/model/node/image.ts +41 -0
  50. package/lib/model/node/shape/polygon.ts +173 -0
  51. package/lib/model/node/shape/shape.ts +48 -0
  52. package/lib/model/node/text.ts +55 -0
  53. package/lib/model/node.ts +101 -0
  54. package/lib/model/page.ts +51 -0
  55. package/lib/model/traversal.ts +21 -0
  56. package/lib/ui/colors.ts +23 -0
  57. package/lib/ui/extractor.ts +57 -0
  58. package/lib/ui/index.ts +8 -0
  59. package/lib/ui/node/EditableContent.tsx +101 -0
  60. package/lib/ui/node/GroupContent.tsx +46 -0
  61. package/lib/ui/node/ImageContent.tsx +36 -0
  62. package/lib/ui/node/NodeView.tsx +68 -0
  63. package/lib/ui/node/PolygonContent.tsx +81 -0
  64. package/lib/ui/node/TextContent.tsx +40 -0
  65. package/lib/ui/node/useDoubleClick.ts +37 -0
  66. package/lib/ui/selection.ts +38 -0
  67. package/package.json +70 -0
  68. package/tests/createTestEditor.ts +19 -0
  69. package/tests/hooks/actions.test.tsx +736 -0
  70. package/tests/hooks/batch.test.tsx +332 -0
  71. package/tests/hooks/editor.test.tsx +56 -0
  72. package/tests/hooks/page.test.tsx +135 -0
  73. package/tests/hooks/pointer/pointer.test.tsx +244 -0
  74. package/tests/hooks/textMarks.test.tsx +624 -0
  75. package/tests/model/editor.test.ts +384 -0
  76. package/tests/model/history.test.ts +293 -0
  77. package/tests/model/node/group.test.ts +294 -0
  78. package/tests/model/node/image.test.ts +150 -0
  79. package/tests/model/node/polygon.test.ts +408 -0
  80. package/tests/model/node/text.test.ts +158 -0
  81. package/tests/model/node.test.ts +276 -0
  82. package/tests/model/page.test.ts +150 -0
  83. package/tests/setup.ts +7 -0
  84. package/tsconfig.json +28 -0
  85. package/vite.config.ts +9 -0
  86. package/vitest.config.ts +13 -0
@@ -0,0 +1,276 @@
1
+ import { describe, it, expect, beforeEach } from "vitest"
2
+ import { Page } from "../../lib/model/page"
3
+ import { ImageNode } from "../../lib/model/node/image"
4
+ import { createTestEditor } from "../createTestEditor"
5
+
6
+ // Using ImageNode as a concrete implementation of Node for testing
7
+ describe("Node", () => {
8
+ let editor: ReturnType<typeof createTestEditor>
9
+ let page: Page
10
+
11
+ beforeEach(() => {
12
+ editor = createTestEditor()
13
+ page = new Page(editor, { id: "page-1" })
14
+ })
15
+
16
+ describe("construction", () => {
17
+ it("initializes with required properties", () => {
18
+ const node = new ImageNode(editor, page, {
19
+ id: "node-1",
20
+ x: 10,
21
+ y: 20,
22
+ width: 100,
23
+ height: 50,
24
+ })
25
+
26
+ expect(node.id).toBe("node-1")
27
+ expect(node.x).toBe(10)
28
+ expect(node.y).toBe(20)
29
+ expect(node.width).toBe(100)
30
+ expect(node.height).toBe(50)
31
+ })
32
+
33
+ it("applies default values for optional properties", () => {
34
+ const node = new ImageNode(editor, page, {
35
+ id: "node-1",
36
+ x: 0,
37
+ y: 0,
38
+ width: 100,
39
+ height: 100,
40
+ })
41
+
42
+ expect(node.rotation).toBe(0)
43
+ expect(node.locked).toBe(false)
44
+ expect(node.role).toBeNull()
45
+ expect(node.css).toBe("")
46
+ })
47
+
48
+ it("accepts optional properties", () => {
49
+ const node = new ImageNode(editor, page, {
50
+ id: "node-1",
51
+ x: 0,
52
+ y: 0,
53
+ width: 100,
54
+ height: 100,
55
+ rotation: 45,
56
+ locked: true,
57
+ role: ["header"],
58
+ css: "filter: blur(2px);",
59
+ })
60
+
61
+ expect(node.rotation).toBe(45)
62
+ expect(node.locked).toBe(true)
63
+ expect(node.role).toBe("header")
64
+ expect(node.css).toBe("filter: blur(2px);")
65
+ })
66
+
67
+ it("stores reference to parent page", () => {
68
+ const node = new ImageNode(editor, page, {
69
+ id: "node-1",
70
+ x: 0,
71
+ y: 0,
72
+ width: 100,
73
+ height: 100,
74
+ })
75
+
76
+ expect(node.page).toBe(page)
77
+ })
78
+ })
79
+
80
+ describe("width and height constraints", () => {
81
+ it("enforces minimum width of 1", () => {
82
+ const node = new ImageNode(editor, page, {
83
+ id: "node-1",
84
+ x: 0,
85
+ y: 0,
86
+ width: 100,
87
+ height: 100,
88
+ })
89
+
90
+ node.width = 0
91
+ expect(node.width).toBe(1)
92
+
93
+ node.width = -10
94
+ expect(node.width).toBe(1)
95
+ })
96
+
97
+ it("enforces minimum height of 1", () => {
98
+ const node = new ImageNode(editor, page, {
99
+ id: "node-1",
100
+ x: 0,
101
+ y: 0,
102
+ width: 100,
103
+ height: 100,
104
+ })
105
+
106
+ node.height = 0
107
+ expect(node.height).toBe(1)
108
+
109
+ node.height = -10
110
+ expect(node.height).toBe(1)
111
+ })
112
+
113
+ it("allows setting width and height to exactly 1", () => {
114
+ const node = new ImageNode(editor, page, {
115
+ id: "node-1",
116
+ x: 0,
117
+ y: 0,
118
+ width: 100,
119
+ height: 100,
120
+ })
121
+
122
+ node.width = 1
123
+ node.height = 1
124
+
125
+ expect(node.width).toBe(1)
126
+ expect(node.height).toBe(1)
127
+ })
128
+ })
129
+
130
+ describe("boundingBox", () => {
131
+ it("returns node rect when rotation is 0", () => {
132
+ const node = new ImageNode(editor, page, {
133
+ id: "node-1",
134
+ x: 10,
135
+ y: 20,
136
+ width: 100,
137
+ height: 50,
138
+ rotation: 0,
139
+ })
140
+
141
+ const bbox = node.boundingBox
142
+ expect(bbox.x).toBe(10)
143
+ expect(bbox.y).toBe(20)
144
+ expect(bbox.width).toBe(100)
145
+ expect(bbox.height).toBe(50)
146
+ })
147
+
148
+ it("expands bounding box for rotated node", () => {
149
+ const node = new ImageNode(editor, page, {
150
+ id: "node-1",
151
+ x: 0,
152
+ y: 0,
153
+ width: 100,
154
+ height: 100,
155
+ rotation: 45,
156
+ })
157
+
158
+ const bbox = node.boundingBox
159
+ // A 100x100 square rotated 45 degrees has diagonal ~141.4
160
+ expect(bbox.width).toBeGreaterThan(100)
161
+ expect(bbox.height).toBeGreaterThan(100)
162
+ })
163
+ })
164
+
165
+ describe("blockMove", () => {
166
+ it("returns true when node is locked", () => {
167
+ const node = new ImageNode(editor, page, {
168
+ id: "node-1",
169
+ x: 0,
170
+ y: 0,
171
+ width: 100,
172
+ height: 100,
173
+ locked: true,
174
+ })
175
+
176
+ const mockEvent = {} as React.PointerEvent
177
+ expect(node.blockMove(mockEvent)).toBe(true)
178
+ })
179
+
180
+ it("returns false when node is not locked", () => {
181
+ const node = new ImageNode(editor, page, {
182
+ id: "node-1",
183
+ x: 0,
184
+ y: 0,
185
+ width: 100,
186
+ height: 100,
187
+ locked: false,
188
+ })
189
+
190
+ const mockEvent = {} as React.PointerEvent
191
+ expect(node.blockMove(mockEvent)).toBe(false)
192
+ })
193
+ })
194
+
195
+ describe("serialization", () => {
196
+ it("serializes required properties", () => {
197
+ const node = new ImageNode(editor, page, {
198
+ id: "node-1",
199
+ x: 10,
200
+ y: 20,
201
+ width: 100,
202
+ height: 50,
203
+ })
204
+
205
+ const serialized = node.serialize()
206
+
207
+ expect(serialized.name).toBe("image")
208
+ expect(serialized.props.id).toBe("node-1")
209
+ expect(serialized.props.x).toBe(10)
210
+ expect(serialized.props.y).toBe(20)
211
+ expect(serialized.props.width).toBe(100)
212
+ expect(serialized.props.height).toBe(50)
213
+ })
214
+
215
+ it("omits default optional properties", () => {
216
+ const node = new ImageNode(editor, page, {
217
+ id: "node-1",
218
+ x: 0,
219
+ y: 0,
220
+ width: 100,
221
+ height: 100,
222
+ })
223
+
224
+ const props = node.props()
225
+
226
+ expect(props).not.toHaveProperty("rotation")
227
+ expect(props).not.toHaveProperty("locked")
228
+ expect(props).not.toHaveProperty("css")
229
+ expect(props).not.toHaveProperty("role")
230
+ })
231
+
232
+ it("includes non-default optional properties", () => {
233
+ const node = new ImageNode(editor, page, {
234
+ id: "node-1",
235
+ x: 0,
236
+ y: 0,
237
+ width: 100,
238
+ height: 100,
239
+ rotation: 90,
240
+ locked: true,
241
+ css: "opacity: 0.5;",
242
+ role: ["button"],
243
+ })
244
+
245
+ const props = node.props()
246
+
247
+ expect(props.rotation).toBe(90)
248
+ expect(props.locked).toBe(true)
249
+ expect(props.css).toBe("opacity: 0.5;")
250
+ expect(props.role).toEqual(["button"])
251
+ })
252
+ })
253
+
254
+ describe("deserialization round-trip", () => {
255
+ it("produces identical props after serialize -> deserialize -> serialize", () => {
256
+ const original = new ImageNode(editor, page, {
257
+ id: "node-1",
258
+ x: 50,
259
+ y: 100,
260
+ width: 200,
261
+ height: 150,
262
+ rotation: 30,
263
+ locked: true,
264
+ css: "border: 1px solid red;",
265
+ role: ["logo"],
266
+ })
267
+
268
+ const firstSerialized = original.serialize()
269
+
270
+ const restored = editor.deserializeNode(page, firstSerialized)
271
+ const secondSerialized = restored.serialize()
272
+
273
+ expect(secondSerialized).toEqual(firstSerialized)
274
+ })
275
+ })
276
+ })
@@ -0,0 +1,150 @@
1
+ import { describe, it, expect, beforeEach } from "vitest"
2
+ import { Page } from "../../lib/model/page"
3
+ import { createTestEditor } from "../createTestEditor"
4
+
5
+ describe("Page", () => {
6
+ let editor: ReturnType<typeof createTestEditor>
7
+
8
+ beforeEach(() => {
9
+ editor = createTestEditor()
10
+ })
11
+
12
+ describe("construction", () => {
13
+ it("creates a page with required id", () => {
14
+ const page = new Page(editor, { id: "page-1" })
15
+ expect(page.id).toBe("page-1")
16
+ })
17
+
18
+ it("applies default dimensions (A4 landscape)", () => {
19
+ const page = new Page(editor, { id: "page-1" })
20
+ expect(page.width).toBe(841)
21
+ expect(page.height).toBe(595)
22
+ })
23
+
24
+ it("applies default white background", () => {
25
+ const page = new Page(editor, { id: "page-1" })
26
+ expect(page.background).toBe("#ffffff")
27
+ })
28
+
29
+ it("accepts custom dimensions", () => {
30
+ const page = new Page(editor, { id: "page-1", width: 1920, height: 1080 })
31
+ expect(page.width).toBe(1920)
32
+ expect(page.height).toBe(1080)
33
+ })
34
+
35
+ it("accepts custom background", () => {
36
+ const page = new Page(editor, { id: "page-1", background: "#ff0000" })
37
+ expect(page.background).toBe("#ff0000")
38
+ })
39
+
40
+ it("initializes with empty nodes map", () => {
41
+ const page = new Page(editor, { id: "page-1" })
42
+ expect(page.nodes.size).toBe(0)
43
+ })
44
+
45
+ it("initializes with empty snapLines", () => {
46
+ const page = new Page(editor, { id: "page-1" })
47
+ expect(page.snapLines).toEqual([])
48
+ })
49
+ })
50
+
51
+ describe("serialization", () => {
52
+ it("serializes all page properties", () => {
53
+ const page = new Page(editor, {
54
+ id: "page-1",
55
+ width: 800,
56
+ height: 600,
57
+ background: "#cccccc",
58
+ })
59
+
60
+ const serialized = page.serialize()
61
+
62
+ expect(serialized).toEqual({
63
+ id: "page-1",
64
+ width: 800,
65
+ height: 600,
66
+ background: "#cccccc",
67
+ nodes: [],
68
+ })
69
+ })
70
+
71
+ it("serializes with default values", () => {
72
+ const page = new Page(editor, { id: "page-1" })
73
+ const serialized = page.serialize()
74
+
75
+ expect(serialized).toEqual({
76
+ id: "page-1",
77
+ width: 841,
78
+ height: 595,
79
+ background: "#ffffff",
80
+ nodes: [],
81
+ })
82
+ })
83
+
84
+ it("serializes contained nodes", () => {
85
+ const page = new Page(editor, { id: "page-1" })
86
+
87
+ // Add a node via the editor's deserialize method
88
+ const node = editor.deserializeNode(page, {
89
+ name: "image",
90
+ props: { id: "img-1", x: 10, y: 20, width: 100, height: 100 },
91
+ })
92
+ page.nodes = new Map([["img-1", node]])
93
+
94
+ const serialized = page.serialize()
95
+
96
+ expect(serialized.nodes).toHaveLength(1)
97
+ expect(serialized.nodes[0].name).toBe("image")
98
+ expect(serialized.nodes[0].props.id).toBe("img-1")
99
+ })
100
+ })
101
+
102
+ describe("deserialization round-trip", () => {
103
+ it("produces identical output after serialize -> load -> serialize", () => {
104
+ const page = new Page(editor, {
105
+ id: "page-1",
106
+ width: 1024,
107
+ height: 768,
108
+ background: "#f0f0f0",
109
+ })
110
+
111
+ // Add nodes
112
+ const node = editor.deserializeNode(page, {
113
+ name: "image",
114
+ props: {
115
+ id: "img-1",
116
+ x: 50,
117
+ y: 100,
118
+ width: 200,
119
+ height: 150,
120
+ url: "https://example.com/image.png",
121
+ fit: "cover",
122
+ roundness: 10,
123
+ borderWidth: 2,
124
+ borderColor: "#333333",
125
+ },
126
+ })
127
+ page.nodes = new Map([["img-1", node]])
128
+
129
+ const firstSerialized = page.serialize()
130
+
131
+ // Create new page from serialized data
132
+ const restoredPage = new Page(editor, {
133
+ id: firstSerialized.id,
134
+ width: firstSerialized.width,
135
+ height: firstSerialized.height,
136
+ background: firstSerialized.background,
137
+ })
138
+ restoredPage.nodes = new Map(
139
+ firstSerialized.nodes.map((n) => [
140
+ n.props.id,
141
+ editor.deserializeNode(restoredPage, n),
142
+ ]),
143
+ )
144
+
145
+ const secondSerialized = restoredPage.serialize()
146
+
147
+ expect(secondSerialized).toEqual(firstSerialized)
148
+ })
149
+ })
150
+ })
package/tests/setup.ts ADDED
@@ -0,0 +1,7 @@
1
+ import "@testing-library/jest-dom/vitest"
2
+ import { cleanup } from "@testing-library/react"
3
+ import { afterEach } from "vitest"
4
+
5
+ afterEach(() => {
6
+ cleanup()
7
+ })
package/tsconfig.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "ESNext",
5
+ "lib": ["ESNext", "DOM"],
6
+ "types": ["vite/client"],
7
+ "skipLibCheck": true,
8
+
9
+ "moduleResolution": "Bundler",
10
+ "verbatimModuleSyntax": true,
11
+ "noEmit": true,
12
+ "jsx": "react-jsx",
13
+
14
+ "strict": true,
15
+ "noUnusedLocals": true,
16
+ "noUnusedParameters": true,
17
+ "erasableSyntaxOnly": true,
18
+ "noFallthroughCasesInSwitch": true,
19
+ "noUncheckedSideEffectImports": true,
20
+
21
+ "paths": {
22
+ "@lazlon/html-editor/ui": ["./lib/ui/index.ts"],
23
+ "@lazlon/html-editor/model": ["./lib/model/index.ts"],
24
+ "@lazlon/html-editor/hooks": ["./lib/hooks/index.ts"]
25
+ }
26
+ },
27
+ "include": ["lib", "demo"]
28
+ }
package/vite.config.ts ADDED
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from "vite"
2
+ import react from "@vitejs/plugin-react"
3
+ import tailwindcss from "@tailwindcss/vite"
4
+ import tsconfigPaths from "vite-tsconfig-paths"
5
+
6
+ // https://vite.dev/config/
7
+ export default defineConfig({
8
+ plugins: [react(), tailwindcss(), tsconfigPaths()],
9
+ })
@@ -0,0 +1,13 @@
1
+ import { defineConfig } from "vitest/config"
2
+ import react from "@vitejs/plugin-react"
3
+ import tsconfigPaths from "vite-tsconfig-paths"
4
+
5
+ export default defineConfig({
6
+ plugins: [react(), tsconfigPaths()],
7
+ test: {
8
+ globals: true,
9
+ environment: "happy-dom",
10
+ setupFiles: ["./tests/setup.ts"],
11
+ include: ["tests/**/*.test.{ts,tsx}"],
12
+ },
13
+ })