@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,332 @@
1
+ import { describe, it, expect, beforeEach } from "vitest"
2
+ import { renderHook, act } from "@testing-library/react"
3
+ import { useNodeField, useNodeFieldBatch, useBatchSet } from "../../lib/hooks/batch"
4
+ import { EditorContext } from "../../lib/hooks/editor"
5
+ import { Page } from "../../lib/model/page"
6
+ import { ImageNode } from "../../lib/model/node/image"
7
+ import { Node } from "../../lib/model"
8
+ import { createTestEditor } from "../createTestEditor"
9
+
10
+ describe("batch hooks", () => {
11
+ let editor: ReturnType<typeof createTestEditor>
12
+ let page: Page
13
+
14
+ beforeEach(() => {
15
+ editor = createTestEditor()
16
+ page = new Page(editor, { id: "page-1" })
17
+ editor.pages = new Map([["page-1", page]])
18
+ })
19
+
20
+ function wrapper({ children }: { children: React.ReactNode }) {
21
+ return <EditorContext value={editor}>{children}</EditorContext>
22
+ }
23
+
24
+ describe("useNodeField", () => {
25
+ it("returns the field value for a single node", () => {
26
+ const node = new ImageNode(editor, page, {
27
+ id: "n1",
28
+ x: 50,
29
+ y: 0,
30
+ width: 100,
31
+ height: 100,
32
+ })
33
+ page.nodes = new Map([["n1", node]])
34
+
35
+ const { result } = renderHook(() => useNodeField([node], "x", 0), { wrapper })
36
+
37
+ expect(result.current).toBe(50)
38
+ })
39
+
40
+ it("returns the field value when all nodes have the same value", () => {
41
+ const node1 = new ImageNode(editor, page, {
42
+ id: "n1",
43
+ x: 50,
44
+ y: 0,
45
+ width: 100,
46
+ height: 100,
47
+ })
48
+ const node2 = new ImageNode(editor, page, {
49
+ id: "n2",
50
+ x: 50,
51
+ y: 0,
52
+ width: 100,
53
+ height: 100,
54
+ })
55
+ page.nodes = new Map([
56
+ ["n1", node1],
57
+ ["n2", node2],
58
+ ])
59
+
60
+ const { result } = renderHook(() => useNodeField([node1, node2], "x", 0), {
61
+ wrapper,
62
+ })
63
+
64
+ expect(result.current).toBe(50)
65
+ })
66
+
67
+ it("returns fallback when nodes have different values", () => {
68
+ const node1 = new ImageNode(editor, page, {
69
+ id: "n1",
70
+ x: 50,
71
+ y: 0,
72
+ width: 100,
73
+ height: 100,
74
+ })
75
+ const node2 = new ImageNode(editor, page, {
76
+ id: "n2",
77
+ x: 100,
78
+ y: 0,
79
+ width: 100,
80
+ height: 100,
81
+ })
82
+ page.nodes = new Map([
83
+ ["n1", node1],
84
+ ["n2", node2],
85
+ ])
86
+
87
+ const { result } = renderHook(() => useNodeField([node1, node2], "x", -999), {
88
+ wrapper,
89
+ })
90
+
91
+ expect(result.current).toBe(-999)
92
+ })
93
+
94
+ it("returns fallback for empty nodes array", () => {
95
+ const { result } = renderHook(() => useNodeField<Node, "x">([], "x", 42), {
96
+ wrapper,
97
+ })
98
+
99
+ expect(result.current).toBe(42)
100
+ })
101
+ })
102
+
103
+ describe("useNodeFieldBatch", () => {
104
+ it("returns value from useNodeField", () => {
105
+ const node = new ImageNode(editor, page, {
106
+ id: "n1",
107
+ x: 75,
108
+ y: 0,
109
+ width: 100,
110
+ height: 100,
111
+ })
112
+ page.nodes = new Map([["n1", node]])
113
+
114
+ const { result } = renderHook(() => useNodeFieldBatch([node], "x", 0), { wrapper })
115
+
116
+ expect(result.current.value).toBe(75)
117
+ })
118
+
119
+ it("onChange updates node property", () => {
120
+ const node = new ImageNode(editor, page, {
121
+ id: "n1",
122
+ x: 0,
123
+ y: 0,
124
+ width: 100,
125
+ height: 100,
126
+ })
127
+ page.nodes = new Map([["n1", node]])
128
+
129
+ const { result } = renderHook(() => useNodeFieldBatch([node], "x", 0), { wrapper })
130
+
131
+ act(() => {
132
+ result.current.onChange(100)
133
+ })
134
+
135
+ expect(node.x).toBe(100)
136
+ })
137
+
138
+ it("onChange with function updates each node individually", () => {
139
+ const node1 = new ImageNode(editor, page, {
140
+ id: "n1",
141
+ x: 10,
142
+ y: 0,
143
+ width: 100,
144
+ height: 100,
145
+ })
146
+ const node2 = new ImageNode(editor, page, {
147
+ id: "n2",
148
+ x: 20,
149
+ y: 0,
150
+ width: 100,
151
+ height: 100,
152
+ })
153
+ page.nodes = new Map([
154
+ ["n1", node1],
155
+ ["n2", node2],
156
+ ])
157
+
158
+ const { result } = renderHook(() => useNodeFieldBatch([node1, node2], "x", 0), {
159
+ wrapper,
160
+ })
161
+
162
+ act(() => {
163
+ result.current.onChange((node) => node.x + 50)
164
+ })
165
+
166
+ expect(node1.x).toBe(60)
167
+ expect(node2.x).toBe(70)
168
+ })
169
+
170
+ it("onChangeEnd creates history entry", () => {
171
+ const node = new ImageNode(editor, page, {
172
+ id: "n1",
173
+ x: 0,
174
+ y: 0,
175
+ width: 100,
176
+ height: 100,
177
+ })
178
+ page.nodes = new Map([["n1", node]])
179
+
180
+ const { result } = renderHook(() => useNodeFieldBatch([node], "x", 0), { wrapper })
181
+
182
+ act(() => {
183
+ result.current.onChange(50) // Start tracking
184
+ result.current.onChangeEnd(100) // End with final value
185
+ })
186
+
187
+ expect(node.x).toBe(100)
188
+ expect(editor.history.undoHistory).toHaveLength(1)
189
+
190
+ // Verify undo works
191
+ act(() => {
192
+ editor.history.undo()
193
+ })
194
+
195
+ expect(node.x).toBe(0)
196
+ })
197
+
198
+ it("does nothing for empty nodes array", () => {
199
+ const { result } = renderHook(() => useNodeFieldBatch<Node, "x">([], "x", 0), {
200
+ wrapper,
201
+ })
202
+
203
+ act(() => {
204
+ result.current.onChange(100)
205
+ result.current.onChangeEnd(100)
206
+ })
207
+
208
+ expect(editor.history.undoHistory).toHaveLength(0)
209
+ })
210
+ })
211
+
212
+ describe("useBatchSet", () => {
213
+ it("updates multiple properties on a single node", () => {
214
+ const node = new ImageNode(editor, page, {
215
+ id: "n1",
216
+ x: 0,
217
+ y: 0,
218
+ width: 100,
219
+ height: 100,
220
+ })
221
+ page.nodes = new Map([["n1", node]])
222
+
223
+ const { result } = renderHook(() => useBatchSet(), { wrapper })
224
+
225
+ act(() => {
226
+ result.current([node], { x: 50, y: 100 })
227
+ })
228
+
229
+ expect(node.x).toBe(50)
230
+ expect(node.y).toBe(100)
231
+ expect(editor.history.undoHistory).toHaveLength(1)
232
+ })
233
+
234
+ it("updates multiple nodes with same values", () => {
235
+ const node1 = new ImageNode(editor, page, {
236
+ id: "n1",
237
+ x: 0,
238
+ y: 0,
239
+ width: 100,
240
+ height: 100,
241
+ })
242
+ const node2 = new ImageNode(editor, page, {
243
+ id: "n2",
244
+ x: 10,
245
+ y: 10,
246
+ width: 100,
247
+ height: 100,
248
+ })
249
+ page.nodes = new Map([
250
+ ["n1", node1],
251
+ ["n2", node2],
252
+ ])
253
+
254
+ const { result } = renderHook(() => useBatchSet(), { wrapper })
255
+
256
+ act(() => {
257
+ result.current([node1, node2], { x: 200, y: 200 })
258
+ })
259
+
260
+ expect(node1.x).toBe(200)
261
+ expect(node1.y).toBe(200)
262
+ expect(node2.x).toBe(200)
263
+ expect(node2.y).toBe(200)
264
+ })
265
+
266
+ it("updates multiple nodes with function setter", () => {
267
+ const node1 = new ImageNode(editor, page, {
268
+ id: "n1",
269
+ x: 10,
270
+ y: 20,
271
+ width: 100,
272
+ height: 100,
273
+ })
274
+ const node2 = new ImageNode(editor, page, {
275
+ id: "n2",
276
+ x: 30,
277
+ y: 40,
278
+ width: 100,
279
+ height: 100,
280
+ })
281
+ page.nodes = new Map([
282
+ ["n1", node1],
283
+ ["n2", node2],
284
+ ])
285
+
286
+ const { result } = renderHook(() => useBatchSet(), { wrapper })
287
+
288
+ act(() => {
289
+ result.current([node1, node2], (n) => ({ x: n.x + 100 }))
290
+ })
291
+
292
+ expect(node1.x).toBe(110)
293
+ expect(node2.x).toBe(130)
294
+ })
295
+
296
+ it("does not push history for empty nodes", () => {
297
+ const { result } = renderHook(() => useBatchSet(), { wrapper })
298
+
299
+ act(() => {
300
+ result.current([], { x: 100 })
301
+ })
302
+
303
+ expect(editor.history.undoHistory).toHaveLength(0)
304
+ })
305
+
306
+ it("creates history entry for batch updates", () => {
307
+ const node = new ImageNode(editor, page, {
308
+ id: "n1",
309
+ x: 25,
310
+ y: 50,
311
+ width: 100,
312
+ height: 100,
313
+ })
314
+ page.nodes = new Map([["n1", node]])
315
+
316
+ const { result } = renderHook(() => useBatchSet(), { wrapper })
317
+
318
+ act(() => {
319
+ result.current([node], { x: 200, y: 300 })
320
+ })
321
+
322
+ expect(node.x).toBe(200)
323
+ expect(node.y).toBe(300)
324
+
325
+ // Verify history was created
326
+ expect(editor.history.undoHistory).toHaveLength(1)
327
+ const [entry] = editor.history.undoHistory
328
+ expect(entry.undo[0]).toBe("batch")
329
+ expect(entry.redo[0]).toBe("batch")
330
+ })
331
+ })
332
+ })
@@ -0,0 +1,56 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { renderHook } from "@testing-library/react"
3
+ import { EditorContext, PageContext, useEditor, usePage } from "../../lib/hooks/editor"
4
+ import { Page } from "../../lib/model/page"
5
+ import { createTestEditor } from "../createTestEditor"
6
+
7
+ describe("editor hooks", () => {
8
+ describe("useEditor", () => {
9
+ it("returns editor from context", () => {
10
+ const editor = createTestEditor()
11
+
12
+ const { result } = renderHook(() => useEditor(), {
13
+ wrapper: ({ children }) => (
14
+ <EditorContext value={editor}>{children}</EditorContext>
15
+ ),
16
+ })
17
+
18
+ expect(result.current).toBe(editor)
19
+ })
20
+
21
+ it("throws when used outside EditorContext", () => {
22
+ expect(() => {
23
+ renderHook(() => useEditor())
24
+ }).toThrow("missing EditorContext")
25
+ })
26
+ })
27
+
28
+ describe("usePage", () => {
29
+ it("returns page from context", () => {
30
+ const editor = createTestEditor()
31
+ const page = new Page(editor, { id: "test-page" })
32
+
33
+ const { result } = renderHook(() => usePage(), {
34
+ wrapper: ({ children }) => (
35
+ <EditorContext value={editor}>
36
+ <PageContext value={page}>{children}</PageContext>
37
+ </EditorContext>
38
+ ),
39
+ })
40
+
41
+ expect(result.current).toBe(page)
42
+ })
43
+
44
+ it("throws when used outside PageContext", () => {
45
+ const editor = createTestEditor()
46
+
47
+ expect(() => {
48
+ renderHook(() => usePage(), {
49
+ wrapper: ({ children }) => (
50
+ <EditorContext value={editor}>{children}</EditorContext>
51
+ ),
52
+ })
53
+ }).toThrow("missing PageContext")
54
+ })
55
+ })
56
+ })
@@ -0,0 +1,135 @@
1
+ import { describe, it, expect, beforeEach } from "vitest"
2
+ import { renderHook, act } from "@testing-library/react"
3
+ import { useEditPage } from "../../lib/hooks/page"
4
+ import { EditorContext } from "../../lib/hooks/editor"
5
+ import { Page } from "../../lib/model/page"
6
+ import { createTestEditor } from "../createTestEditor"
7
+
8
+ describe("page hooks", () => {
9
+ let editor: ReturnType<typeof createTestEditor>
10
+ let page: Page
11
+
12
+ beforeEach(() => {
13
+ editor = createTestEditor()
14
+ page = new Page(editor, { id: "page-1", width: 800, height: 600 })
15
+ editor.pages = new Map([["page-1", page]])
16
+ })
17
+
18
+ function wrapper({ children }: { children: React.ReactNode }) {
19
+ return <EditorContext value={editor}>{children}</EditorContext>
20
+ }
21
+
22
+ describe("useEditPage", () => {
23
+ it("returns current value", () => {
24
+ const { result } = renderHook(() => useEditPage(page, "width"), { wrapper })
25
+
26
+ expect(result.current.value).toBe(800)
27
+ })
28
+
29
+ it("onChange updates page property", () => {
30
+ const { result } = renderHook(() => useEditPage(page, "width"), { wrapper })
31
+
32
+ act(() => {
33
+ result.current.onChange(1024)
34
+ })
35
+
36
+ expect(page.width).toBe(1024)
37
+ })
38
+
39
+ it("onChange does not create history entry", () => {
40
+ const { result } = renderHook(() => useEditPage(page, "width"), { wrapper })
41
+
42
+ act(() => {
43
+ result.current.onChange(1024)
44
+ })
45
+
46
+ expect(editor.history.undoHistory).toHaveLength(0)
47
+ })
48
+
49
+ it("onChangeEnd creates history entry", () => {
50
+ const { result } = renderHook(() => useEditPage(page, "width"), { wrapper })
51
+
52
+ act(() => {
53
+ result.current.onChangeEnd(1024)
54
+ })
55
+
56
+ expect(page.width).toBe(1024)
57
+ expect(editor.history.undoHistory).toHaveLength(1)
58
+ })
59
+
60
+ it("undo restores original value", () => {
61
+ const { result } = renderHook(() => useEditPage(page, "width"), { wrapper })
62
+
63
+ act(() => {
64
+ result.current.onChangeEnd(1920)
65
+ })
66
+
67
+ expect(page.width).toBe(1920)
68
+
69
+ act(() => {
70
+ editor.history.undo()
71
+ })
72
+
73
+ expect(page.width).toBe(800)
74
+ })
75
+
76
+ it("works with height property", () => {
77
+ const { result } = renderHook(() => useEditPage(page, "height"), { wrapper })
78
+
79
+ expect(result.current.value).toBe(600)
80
+
81
+ act(() => {
82
+ result.current.onChangeEnd(1080)
83
+ })
84
+
85
+ expect(page.height).toBe(1080)
86
+ })
87
+
88
+ it("works with background property", () => {
89
+ const { result } = renderHook(() => useEditPage(page, "background"), { wrapper })
90
+
91
+ expect(result.current.value).toBe("#ffffff")
92
+
93
+ act(() => {
94
+ result.current.onChangeEnd("#ff0000")
95
+ })
96
+
97
+ expect(page.background).toBe("#ff0000")
98
+ })
99
+
100
+ it("tracks multiple changes correctly", () => {
101
+ const { result } = renderHook(() => useEditPage(page, "width"), { wrapper })
102
+
103
+ // First change
104
+ act(() => {
105
+ result.current.onChange(900)
106
+ result.current.onChange(1000)
107
+ result.current.onChangeEnd(1100)
108
+ })
109
+
110
+ expect(page.width).toBe(1100)
111
+
112
+ // Second change
113
+ act(() => {
114
+ result.current.onChangeEnd(1200)
115
+ })
116
+
117
+ expect(page.width).toBe(1200)
118
+ expect(editor.history.undoHistory).toHaveLength(2)
119
+
120
+ // Undo second change
121
+ act(() => {
122
+ editor.history.undo()
123
+ })
124
+
125
+ expect(page.width).toBe(1100)
126
+
127
+ // Undo first change
128
+ act(() => {
129
+ editor.history.undo()
130
+ })
131
+
132
+ expect(page.width).toBe(800)
133
+ })
134
+ })
135
+ })