@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,294 @@
1
+ import { beforeEach, describe, expect, it } from "vitest"
2
+ import { GroupNode } from "../../../lib/model/node/group"
3
+ import { Page } from "../../../lib/model/page"
4
+ import { createTestEditor } from "../../createTestEditor"
5
+ import { ImageNodeProps } from "../../../lib/model"
6
+
7
+ describe("GroupNode", () => {
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("has name 'group'", () => {
18
+ const group = new GroupNode(editor, page, {
19
+ id: "group-1",
20
+ x: 0,
21
+ y: 0,
22
+ width: 200,
23
+ height: 200,
24
+ })
25
+ expect(group.name).toBe("group")
26
+ })
27
+
28
+ it("initializes with empty nodes set", () => {
29
+ const group = new GroupNode(editor, page, {
30
+ id: "group-1",
31
+ x: 0,
32
+ y: 0,
33
+ width: 200,
34
+ height: 200,
35
+ })
36
+ expect(group.nodes.size).toBe(0)
37
+ })
38
+
39
+ it("deserializes child nodes from props", () => {
40
+ const group = new GroupNode(editor, page, {
41
+ id: "group-1",
42
+ x: 0,
43
+ y: 0,
44
+ width: 200,
45
+ height: 200,
46
+ nodes: [
47
+ {
48
+ name: "image",
49
+ props: { id: "child-1", x: 10, y: 10, width: 50, height: 50 },
50
+ },
51
+ {
52
+ name: "image",
53
+ props: { id: "child-2", x: 70, y: 10, width: 50, height: 50 },
54
+ },
55
+ ],
56
+ })
57
+
58
+ expect(group.nodes.size).toBe(2)
59
+ })
60
+ })
61
+
62
+ describe("proportional scaling", () => {
63
+ it("scales child x positions when width changes", () => {
64
+ const group = new GroupNode(editor, page, {
65
+ id: "group-1",
66
+ x: 0,
67
+ y: 0,
68
+ width: 100,
69
+ height: 100,
70
+ nodes: [
71
+ { name: "image", props: { id: "child-1", x: 50, y: 0, width: 20, height: 20 } },
72
+ ],
73
+ })
74
+
75
+ const child = [...group.nodes][0]
76
+ expect(child.x).toBe(50) // 50% of 100
77
+
78
+ group.width = 200
79
+
80
+ expect(child.x).toBe(100) // 50% of 200
81
+ })
82
+
83
+ it("scales child widths when group width changes", () => {
84
+ const group = new GroupNode(editor, page, {
85
+ id: "group-1",
86
+ x: 0,
87
+ y: 0,
88
+ width: 100,
89
+ height: 100,
90
+ nodes: [
91
+ { name: "image", props: { id: "child-1", x: 0, y: 0, width: 50, height: 20 } },
92
+ ],
93
+ })
94
+
95
+ const child = [...group.nodes][0]
96
+ expect(child.width).toBe(50) // 50% of 100
97
+
98
+ group.width = 200
99
+
100
+ expect(child.width).toBe(100) // 50% of 200
101
+ })
102
+
103
+ it("scales child y positions when height changes", () => {
104
+ const group = new GroupNode(editor, page, {
105
+ id: "group-1",
106
+ x: 0,
107
+ y: 0,
108
+ width: 100,
109
+ height: 100,
110
+ nodes: [
111
+ { name: "image", props: { id: "child-1", x: 0, y: 25, width: 20, height: 20 } },
112
+ ],
113
+ })
114
+
115
+ const child = [...group.nodes][0]
116
+ expect(child.y).toBe(25) // 25% of 100
117
+
118
+ group.height = 200
119
+
120
+ expect(child.y).toBe(50) // 25% of 200
121
+ })
122
+
123
+ it("scales child heights when group height changes", () => {
124
+ const group = new GroupNode(editor, page, {
125
+ id: "group-1",
126
+ x: 0,
127
+ y: 0,
128
+ width: 100,
129
+ height: 100,
130
+ nodes: [
131
+ { name: "image", props: { id: "child-1", x: 0, y: 0, width: 20, height: 40 } },
132
+ ],
133
+ })
134
+
135
+ const child = [...group.nodes][0]
136
+ expect(child.height).toBe(40) // 40% of 100
137
+
138
+ group.height = 200
139
+
140
+ expect(child.height).toBe(80) // 40% of 200
141
+ })
142
+
143
+ it("scales all children proportionally", () => {
144
+ const group = new GroupNode(editor, page, {
145
+ id: "group-1",
146
+ x: 0,
147
+ y: 0,
148
+ width: 200,
149
+ height: 200,
150
+ nodes: [
151
+ {
152
+ name: "image",
153
+ props: { id: "child-1", x: 0, y: 0, width: 100, height: 100 },
154
+ },
155
+ {
156
+ name: "image",
157
+ props: { id: "child-2", x: 100, y: 100, width: 100, height: 100 },
158
+ },
159
+ ],
160
+ })
161
+
162
+ const [child1, child2] = [...group.nodes]
163
+
164
+ group.width = 400
165
+ group.height = 400
166
+
167
+ // Both children should scale proportionally
168
+ expect(child1.x).toBe(0)
169
+ expect(child1.y).toBe(0)
170
+ expect(child1.width).toBe(200)
171
+ expect(child1.height).toBe(200)
172
+
173
+ expect(child2.x).toBe(200)
174
+ expect(child2.y).toBe(200)
175
+ expect(child2.width).toBe(200)
176
+ expect(child2.height).toBe(200)
177
+ })
178
+ })
179
+
180
+ describe("serialization", () => {
181
+ it("includes serialized child nodes", () => {
182
+ const group = new GroupNode(editor, page, {
183
+ id: "group-1",
184
+ x: 0,
185
+ y: 0,
186
+ width: 200,
187
+ height: 200,
188
+ nodes: [
189
+ {
190
+ name: "image",
191
+ props: { id: "child-1", x: 10, y: 10, width: 50, height: 50 },
192
+ },
193
+ ],
194
+ })
195
+
196
+ const serialized = group.serialize()
197
+
198
+ expect(serialized.name).toBe("group")
199
+ expect(serialized.props.nodes).toHaveLength(1)
200
+ expect(serialized.props.nodes![0].name).toBe("image")
201
+ expect(serialized.props.nodes![0].props.id).toBe("child-1")
202
+ })
203
+
204
+ it("serializes empty group", () => {
205
+ const group = new GroupNode(editor, page, {
206
+ id: "group-1",
207
+ x: 0,
208
+ y: 0,
209
+ width: 200,
210
+ height: 200,
211
+ })
212
+
213
+ const serialized = group.serialize()
214
+
215
+ expect(serialized.props.nodes).toEqual([])
216
+ })
217
+ })
218
+
219
+ describe("deserialization round-trip", () => {
220
+ it("produces identical props after serialize -> deserialize -> serialize", () => {
221
+ const imageProps1: ImageNodeProps = {
222
+ id: "child-1",
223
+ x: 10,
224
+ y: 10,
225
+ width: 100,
226
+ height: 80,
227
+ url: "https://example.com/img1.jpg",
228
+ }
229
+ const imageProps2: ImageNodeProps = {
230
+ id: "child-2",
231
+ x: 120,
232
+ y: 10,
233
+ width: 100,
234
+ height: 80,
235
+ url: "https://example.com/img2.jpg",
236
+ }
237
+ const original = new GroupNode(editor, page, {
238
+ id: "group-1",
239
+ x: 50,
240
+ y: 100,
241
+ width: 300,
242
+ height: 250,
243
+ rotation: 15,
244
+ nodes: [
245
+ { name: "image", props: imageProps1 },
246
+ { name: "image", props: imageProps2 },
247
+ ],
248
+ })
249
+
250
+ const firstSerialized = original.serialize()
251
+ const restored = editor.deserializeNode(page, firstSerialized) as GroupNode
252
+ const secondSerialized = restored.serialize()
253
+
254
+ expect(secondSerialized).toEqual(firstSerialized)
255
+ })
256
+
257
+ it("preserves nested groups through round-trip", () => {
258
+ const groupProps = {
259
+ id: "inner-group",
260
+ x: 50,
261
+ y: 50,
262
+ width: 200,
263
+ height: 200,
264
+ nodes: [
265
+ {
266
+ name: "image",
267
+ props: { id: "deep-child", x: 10, y: 10, width: 50, height: 50 },
268
+ },
269
+ ],
270
+ }
271
+
272
+ const original = new GroupNode(editor, page, {
273
+ id: "outer-group",
274
+ x: 0,
275
+ y: 0,
276
+ width: 400,
277
+ height: 400,
278
+ nodes: [{ name: "group", props: groupProps }],
279
+ })
280
+
281
+ const firstSerialized = original.serialize()
282
+ const restored = editor.deserializeNode(page, firstSerialized) as GroupNode
283
+ const secondSerialized = restored.serialize()
284
+
285
+ expect(secondSerialized).toEqual(firstSerialized)
286
+
287
+ // Verify nested structure
288
+ expect(restored.nodes.size).toBe(1)
289
+ const innerGroup = [...restored.nodes][0] as GroupNode
290
+ expect(innerGroup.name).toBe("group")
291
+ expect(innerGroup.nodes.size).toBe(1)
292
+ })
293
+ })
294
+ })
@@ -0,0 +1,150 @@
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
+ describe("ImageNode", () => {
7
+ let editor: ReturnType<typeof createTestEditor>
8
+ let page: Page
9
+
10
+ beforeEach(() => {
11
+ editor = createTestEditor()
12
+ page = new Page(editor, { id: "page-1" })
13
+ })
14
+
15
+ describe("construction", () => {
16
+ it("has name 'image'", () => {
17
+ const node = new ImageNode(editor, page, {
18
+ id: "img-1",
19
+ x: 0,
20
+ y: 0,
21
+ width: 100,
22
+ height: 100,
23
+ })
24
+ expect(node.name).toBe("image")
25
+ })
26
+
27
+ it("applies default values", () => {
28
+ const node = new ImageNode(editor, page, {
29
+ id: "img-1",
30
+ x: 0,
31
+ y: 0,
32
+ width: 100,
33
+ height: 100,
34
+ })
35
+
36
+ expect(node.url).toBeNull()
37
+ expect(node.fit).toBe("cover")
38
+ expect(node.roundness).toBe(0)
39
+ expect(node.borderWidth).toBe(0)
40
+ expect(node.borderColor).toBe("#000000")
41
+ })
42
+
43
+ it("accepts custom properties", () => {
44
+ const node = new ImageNode(editor, page, {
45
+ id: "img-1",
46
+ x: 0,
47
+ y: 0,
48
+ width: 100,
49
+ height: 100,
50
+ url: "https://example.com/image.png",
51
+ fit: "contain",
52
+ roundness: 20,
53
+ borderWidth: 3,
54
+ borderColor: "#ff0000",
55
+ })
56
+
57
+ expect(node.url).toBe("https://example.com/image.png")
58
+ // Note: fit is hardcoded to "cover" in constructor (bug?)
59
+ expect(node.roundness).toBe(20)
60
+ expect(node.borderWidth).toBe(3)
61
+ expect(node.borderColor).toBe("#ff0000")
62
+ })
63
+ })
64
+
65
+ describe("serialization", () => {
66
+ it("includes all ImageNode-specific properties", () => {
67
+ const node = new ImageNode(editor, page, {
68
+ id: "img-1",
69
+ x: 10,
70
+ y: 20,
71
+ width: 200,
72
+ height: 150,
73
+ url: "https://example.com/photo.jpg",
74
+ fit: "fill",
75
+ roundness: 15,
76
+ borderWidth: 2,
77
+ borderColor: "#333333",
78
+ })
79
+
80
+ const serialized = node.serialize()
81
+
82
+ expect(serialized.name).toBe("image")
83
+ expect(serialized.props).toMatchObject({
84
+ id: "img-1",
85
+ x: 10,
86
+ y: 20,
87
+ width: 200,
88
+ height: 150,
89
+ url: "https://example.com/photo.jpg",
90
+ roundness: 15,
91
+ borderWidth: 2,
92
+ borderColor: "#333333",
93
+ })
94
+ })
95
+
96
+ it("serializes null url", () => {
97
+ const node = new ImageNode(editor, page, {
98
+ id: "img-1",
99
+ x: 0,
100
+ y: 0,
101
+ width: 100,
102
+ height: 100,
103
+ })
104
+
105
+ const props = node.props()
106
+ expect(props.url).toBeNull()
107
+ })
108
+ })
109
+
110
+ describe("deserialization round-trip", () => {
111
+ it("produces identical props after serialize -> deserialize -> serialize", () => {
112
+ const original = new ImageNode(editor, page, {
113
+ id: "img-1",
114
+ x: 50,
115
+ y: 100,
116
+ width: 300,
117
+ height: 200,
118
+ url: "https://cdn.example.com/images/photo.jpg",
119
+ fit: "cover",
120
+ roundness: 25,
121
+ borderWidth: 4,
122
+ borderColor: "#00ff00",
123
+ rotation: 45,
124
+ locked: true,
125
+ })
126
+
127
+ const firstSerialized = original.serialize()
128
+ const restored = editor.deserializeNode(page, firstSerialized) as ImageNode
129
+ const secondSerialized = restored.serialize()
130
+
131
+ expect(secondSerialized).toEqual(firstSerialized)
132
+ })
133
+
134
+ it("preserves default values through round-trip", () => {
135
+ const original = new ImageNode(editor, page, {
136
+ id: "img-1",
137
+ x: 0,
138
+ y: 0,
139
+ width: 100,
140
+ height: 100,
141
+ })
142
+
143
+ const firstSerialized = original.serialize()
144
+ const restored = editor.deserializeNode(page, firstSerialized) as ImageNode
145
+ const secondSerialized = restored.serialize()
146
+
147
+ expect(secondSerialized).toEqual(firstSerialized)
148
+ })
149
+ })
150
+ })