@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,384 @@
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 { PolygonNode } from "../../lib/model/node/shape/polygon"
5
+ import { GroupNode } from "../../lib/model/node/group"
6
+ import { Node } from "../../lib/model"
7
+ import { createTestEditor } from "../createTestEditor"
8
+
9
+ describe("Editor", () => {
10
+ let editor: ReturnType<typeof createTestEditor>
11
+
12
+ beforeEach(() => {
13
+ editor = createTestEditor()
14
+ })
15
+
16
+ describe("construction", () => {
17
+ it("initializes with empty pages", () => {
18
+ expect(editor.pages.size).toBe(0)
19
+ })
20
+
21
+ it("initializes with empty selection", () => {
22
+ expect(editor.selection.size).toBe(0)
23
+ })
24
+
25
+ it("can deserialize registered node types", () => {
26
+ const page = new Page(editor, { id: "page-1" })
27
+
28
+ // These should not throw - proves schema is registered
29
+ expect(() =>
30
+ editor.deserializeNode(page, {
31
+ name: "image",
32
+ props: { id: "1", x: 0, y: 0, width: 100, height: 100 },
33
+ }),
34
+ ).not.toThrow()
35
+
36
+ expect(() =>
37
+ editor.deserializeNode(page, {
38
+ name: "text",
39
+ props: { id: "2", x: 0, y: 0, width: 100, height: 100 },
40
+ }),
41
+ ).not.toThrow()
42
+
43
+ expect(() =>
44
+ editor.deserializeNode(page, {
45
+ name: "polygon",
46
+ props: { id: "3", x: 0, y: 0, width: 100, height: 100 },
47
+ }),
48
+ ).not.toThrow()
49
+
50
+ expect(() =>
51
+ editor.deserializeNode(page, {
52
+ name: "group",
53
+ props: { id: "4", x: 0, y: 0, width: 100, height: 100 },
54
+ }),
55
+ ).not.toThrow()
56
+ })
57
+
58
+ it("applies default options", () => {
59
+ expect(editor.options.maxHistory).toBe(100)
60
+ expect(editor.options.snapThreshold).toBe(8)
61
+ })
62
+
63
+ it("accepts custom options", () => {
64
+ const customEditor = createTestEditor({ maxHistory: 50 })
65
+ expect(customEditor.options.maxHistory).toBe(50)
66
+ })
67
+ })
68
+
69
+ describe("id generation", () => {
70
+ it("generates unique ids", () => {
71
+ const ids = new Set<string>()
72
+ for (let i = 0; i < 100; i++) {
73
+ ids.add(editor.id())
74
+ }
75
+ expect(ids.size).toBe(100)
76
+ })
77
+
78
+ it("generates ids using base-52 encoding", () => {
79
+ const id = editor.id()
80
+ expect(id).toMatch(/^[a-zA-Z]+$/)
81
+ })
82
+ })
83
+
84
+ describe("deserializeNode", () => {
85
+ it("creates node from serialized data", () => {
86
+ const page = new Page(editor, { id: "page-1" })
87
+
88
+ const node = editor.deserializeNode(page, {
89
+ name: "image",
90
+ props: { id: "img-1", x: 10, y: 20, width: 100, height: 50 },
91
+ })
92
+
93
+ expect(node).toBeInstanceOf(ImageNode)
94
+ expect(node.id).toBe("img-1")
95
+ expect(node.x).toBe(10)
96
+ expect(node.y).toBe(20)
97
+ })
98
+
99
+ it("throws for unknown node type", () => {
100
+ const page = new Page(editor, { id: "page-1" })
101
+
102
+ expect(() => {
103
+ editor.deserializeNode(page, {
104
+ name: "unknown",
105
+ props: { id: "x", x: 0, y: 0, width: 100, height: 100 },
106
+ })
107
+ }).toThrow("cannot deserialize unknown Node: unknown")
108
+ })
109
+ })
110
+
111
+ describe("serialization", () => {
112
+ it("serializes empty editor", () => {
113
+ const serialized = editor.serialize()
114
+
115
+ expect(serialized).toEqual({
116
+ idCounter: 0,
117
+ pages: [],
118
+ history: { undoHistory: [], redoHistory: [] },
119
+ })
120
+ })
121
+
122
+ it("serializes idCounter", () => {
123
+ editor.id()
124
+ editor.id()
125
+ editor.id()
126
+
127
+ const serialized = editor.serialize()
128
+ expect(serialized.idCounter).toBe(3)
129
+ })
130
+
131
+ it("serializes pages with nodes", () => {
132
+ const page = new Page(editor, { id: "page-1", width: 800, height: 600 })
133
+ const node = new ImageNode(editor, page, {
134
+ id: "img-1",
135
+ x: 10,
136
+ y: 20,
137
+ width: 100,
138
+ height: 100,
139
+ })
140
+ page.nodes = new Map([[node.id, node]])
141
+ editor.pages = new Map([[page.id, page]])
142
+
143
+ const serialized = editor.serialize()
144
+
145
+ expect(serialized.pages).toHaveLength(1)
146
+ expect(serialized.pages[0].id).toBe("page-1")
147
+ expect(serialized.pages[0].nodes).toHaveLength(1)
148
+ expect(serialized.pages[0].nodes[0].name).toBe("image")
149
+ expect(serialized.pages[0].nodes[0].props.id).toBe("img-1")
150
+ })
151
+
152
+ it("serializes history", () => {
153
+ const page = new Page(editor, { id: "page-1" })
154
+ editor.pages = new Map([["page-1", page]])
155
+
156
+ editor.history.push({
157
+ redo: ["set-page-props", ["page-1", { width: 100 }]],
158
+ undo: ["set-page-props", ["page-1", { width: 841 }]],
159
+ })
160
+
161
+ const serialized = editor.serialize()
162
+
163
+ expect(serialized.history.undoHistory).toHaveLength(1)
164
+ expect(serialized.history.redoHistory).toHaveLength(0)
165
+ })
166
+ })
167
+
168
+ describe("load", () => {
169
+ it("loads idCounter", () => {
170
+ editor.load({ idCounter: 42 })
171
+ expect(editor.id()).toMatch(/^[a-zA-Z]+$/) // id 43
172
+ })
173
+
174
+ it("loads pages", () => {
175
+ editor.load({
176
+ pages: [
177
+ {
178
+ id: "page-1",
179
+ width: 1024,
180
+ height: 768,
181
+ background: "#cccccc",
182
+ nodes: [],
183
+ },
184
+ ],
185
+ })
186
+
187
+ expect(editor.pages.size).toBe(1)
188
+ const page = editor.pages.get("page-1")!
189
+ expect(page.width).toBe(1024)
190
+ expect(page.height).toBe(768)
191
+ expect(page.background).toBe("#cccccc")
192
+ })
193
+
194
+ it("loads pages with nodes", () => {
195
+ editor.load({
196
+ pages: [
197
+ {
198
+ id: "page-1",
199
+ width: 800,
200
+ height: 600,
201
+ background: "#ffffff",
202
+ nodes: [
203
+ {
204
+ name: "image",
205
+ props: { id: "img-1", x: 10, y: 20, width: 100, height: 50 },
206
+ },
207
+ ],
208
+ },
209
+ ],
210
+ })
211
+
212
+ const page = editor.pages.get("page-1")!
213
+ expect(page.nodes.size).toBe(1)
214
+ expect(page.nodes.has("img-1")).toBe(true)
215
+ })
216
+
217
+ it("loads history", () => {
218
+ editor.load({
219
+ history: {
220
+ undoHistory: [
221
+ {
222
+ redo: ["batch", []],
223
+ undo: ["batch", []],
224
+ },
225
+ ],
226
+ redoHistory: [],
227
+ },
228
+ })
229
+
230
+ expect(editor.history.undoHistory).toHaveLength(1)
231
+ })
232
+ })
233
+
234
+ describe("serialization round-trip", () => {
235
+ it("produces identical output after serialize -> load -> serialize", () => {
236
+ // Create complex state
237
+ const page1 = new Page(editor, { id: editor.id(), width: 1920, height: 1080 })
238
+ const page2 = new Page(editor, { id: editor.id(), background: "#f0f0f0" })
239
+
240
+ // Add various node types
241
+ const img: Node = new ImageNode(editor, page1, {
242
+ id: editor.id(),
243
+ x: 100,
244
+ y: 200,
245
+ width: 300,
246
+ height: 200,
247
+ url: "https://example.com/image.jpg",
248
+ fit: "cover",
249
+ roundness: 8,
250
+ borderWidth: 2,
251
+ borderColor: "#333333",
252
+ rotation: 15,
253
+ })
254
+
255
+ const polygon: Node = new PolygonNode(editor, page1, {
256
+ id: editor.id(),
257
+ x: 500,
258
+ y: 100,
259
+ width: 150,
260
+ height: 150,
261
+ sides: 6,
262
+ roundness: 5,
263
+ background: "#ff6600",
264
+ })
265
+
266
+ page1.nodes = new Map([
267
+ [img.id, img],
268
+ [polygon.id, polygon],
269
+ ])
270
+
271
+ editor.pages = new Map([
272
+ [page1.id, page1],
273
+ [page2.id, page2],
274
+ ])
275
+
276
+ editor.history.push({
277
+ redo: ["set-page-props", [page1.id, { width: 1920 }]],
278
+ undo: ["set-page-props", [page2.id, { width: 841 }]],
279
+ })
280
+
281
+ const firstSerialized = editor.serialize()
282
+
283
+ const newEditor = createTestEditor()
284
+ newEditor.load(firstSerialized)
285
+
286
+ const secondSerialized = newEditor.serialize()
287
+
288
+ expect(secondSerialized).toEqual(firstSerialized)
289
+ })
290
+
291
+ it("preserves GroupNode with nested children", () => {
292
+ const page = new Page(editor, { id: "page-1" })
293
+
294
+ const group = new GroupNode(editor, page, {
295
+ id: "group-1",
296
+ x: 0,
297
+ y: 0,
298
+ width: 200,
299
+ height: 200,
300
+ nodes: [
301
+ {
302
+ name: "image",
303
+ props: { id: "child-1", x: 10, y: 10, width: 50, height: 50 },
304
+ },
305
+ {
306
+ name: "image",
307
+ props: { id: "child-2", x: 70, y: 10, width: 50, height: 50 },
308
+ },
309
+ ],
310
+ })
311
+
312
+ page.nodes = new Map([["group-1", group]])
313
+ editor.pages = new Map([["page-1", page]])
314
+
315
+ const firstSerialized = editor.serialize()
316
+
317
+ const newEditor = createTestEditor()
318
+ newEditor.load(firstSerialized)
319
+
320
+ const secondSerialized = newEditor.serialize()
321
+
322
+ expect(secondSerialized).toEqual(firstSerialized)
323
+
324
+ const restoredPage = newEditor.pages.get("page-1")!
325
+ const restoredGroup = restoredPage.nodes.get("group-1") as GroupNode
326
+ expect(restoredGroup.nodes.size).toBe(2)
327
+ })
328
+ })
329
+
330
+ describe("selection", () => {
331
+ it("filters out deleted nodes from selection", () => {
332
+ const page = new Page(editor, { id: "page-1" })
333
+ const node = new ImageNode(editor, page, {
334
+ id: "n1",
335
+ x: 0,
336
+ y: 0,
337
+ width: 100,
338
+ height: 100,
339
+ })
340
+ page.nodes = new Map([["n1", node]])
341
+ editor.pages = new Map([["page-1", page]])
342
+
343
+ editor.selection = new Set([node])
344
+ expect(editor.selection.size).toBe(1)
345
+
346
+ page.nodes = new Map()
347
+
348
+ expect(editor.selection.size).toBe(0)
349
+ })
350
+ })
351
+
352
+ describe("selectionPage", () => {
353
+ it("returns null when selection is empty", () => {
354
+ expect(editor.selectionPage).toBeNull()
355
+ })
356
+
357
+ it("returns page when all selected nodes are from same page", () => {
358
+ const page = new Page(editor, { id: "page-1" })
359
+ const node1 = new ImageNode(editor, page, {
360
+ id: "n1",
361
+ x: 0,
362
+ y: 0,
363
+ width: 100,
364
+ height: 100,
365
+ })
366
+ const node2 = new ImageNode(editor, page, {
367
+ id: "n2",
368
+ x: 100,
369
+ y: 0,
370
+ width: 100,
371
+ height: 100,
372
+ })
373
+ page.nodes = new Map([
374
+ ["n1", node1],
375
+ ["n2", node2],
376
+ ])
377
+ editor.pages = new Map([["page-1", page]])
378
+
379
+ editor.selection = new Set([node1, node2])
380
+
381
+ expect(editor.selectionPage).toBe(page)
382
+ })
383
+ })
384
+ })
@@ -0,0 +1,293 @@
1
+ import { beforeEach, describe, expect, it } from "vitest"
2
+ import { ImageNode } from "../../lib/model/node/image"
3
+ import { Page } from "../../lib/model/page"
4
+ import { createTestEditor } from "../createTestEditor"
5
+
6
+ describe("HistoryStore", () => {
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
+ editor.pages = new Map([["page-1", page]])
14
+ })
15
+
16
+ describe("push", () => {
17
+ it("adds entry to undo history", () => {
18
+ editor.history.push({
19
+ redo: ["set-page-props", ["page-1", { width: 200 }]],
20
+ undo: ["set-page-props", ["page-1", { width: 841 }]],
21
+ })
22
+
23
+ expect(editor.history.undoHistory).toHaveLength(1)
24
+ })
25
+
26
+ it("clears redo history when pushing new entry", () => {
27
+ // First push an entry and undo it to populate redo history
28
+ editor.history.push({
29
+ redo: ["set-page-props", ["page-1", { width: 100 }]],
30
+ undo: ["set-page-props", ["page-1", { width: 841 }]],
31
+ })
32
+ editor.history.undo()
33
+ expect(editor.history.redoHistory).toHaveLength(1)
34
+
35
+ // Push new entry should clear redo history
36
+ editor.history.push({
37
+ redo: ["set-page-props", ["page-1", { width: 200 }]],
38
+ undo: ["set-page-props", ["page-1", { width: 841 }]],
39
+ })
40
+
41
+ expect(editor.history.redoHistory).toHaveLength(0)
42
+ })
43
+
44
+ it("respects maxHistory limit", () => {
45
+ const limitedEditor = createTestEditor({ maxHistory: 3 })
46
+ const limitedPage = new Page(limitedEditor, { id: "p1" })
47
+ limitedEditor.pages = new Map([["p1", limitedPage]])
48
+
49
+ for (let i = 0; i < 5; i++) {
50
+ limitedEditor.history.push({
51
+ redo: ["set-page-props", ["p1", { width: i }]],
52
+ undo: ["set-page-props", ["p1", { width: i - 1 }]],
53
+ })
54
+ }
55
+
56
+ expect(limitedEditor.history.undoHistory).toHaveLength(3)
57
+ })
58
+ })
59
+
60
+ describe("undo", () => {
61
+ it("executes undo action and moves entry to redo history", () => {
62
+ page.width = 200
63
+
64
+ editor.history.push({
65
+ redo: ["set-page-props", ["page-1", { width: 200 }]],
66
+ undo: ["set-page-props", ["page-1", { width: 841 }]],
67
+ })
68
+
69
+ editor.history.undo()
70
+
71
+ expect(page.width).toBe(841)
72
+ expect(editor.history.undoHistory).toHaveLength(0)
73
+ expect(editor.history.redoHistory).toHaveLength(1)
74
+ })
75
+
76
+ it("does nothing when undo history is empty", () => {
77
+ const initialWidth = page.width
78
+
79
+ editor.history.undo()
80
+
81
+ expect(page.width).toBe(initialWidth)
82
+ expect(editor.history.undoHistory).toHaveLength(0)
83
+ expect(editor.history.redoHistory).toHaveLength(0)
84
+ })
85
+ })
86
+
87
+ describe("redo", () => {
88
+ it("executes redo action and moves entry back to undo history", () => {
89
+ editor.history.push({
90
+ redo: ["set-page-props", ["page-1", { width: 200 }]],
91
+ undo: ["set-page-props", ["page-1", { width: 841 }]],
92
+ })
93
+
94
+ editor.history.undo()
95
+ expect(page.width).toBe(841)
96
+
97
+ editor.history.redo()
98
+ expect(page.width).toBe(200)
99
+ expect(editor.history.undoHistory).toHaveLength(1)
100
+ expect(editor.history.redoHistory).toHaveLength(0)
101
+ })
102
+
103
+ it("does nothing when redo history is empty", () => {
104
+ const initialWidth = page.width
105
+
106
+ editor.history.redo()
107
+
108
+ expect(page.width).toBe(initialWidth)
109
+ })
110
+ })
111
+
112
+ describe("batch action", () => {
113
+ it("executes multiple actions as a single history entry", () => {
114
+ const node1 = new ImageNode(editor, page, {
115
+ id: "n1",
116
+ x: 0,
117
+ y: 0,
118
+ width: 100,
119
+ height: 50,
120
+ })
121
+ const node2 = new ImageNode(editor, page, {
122
+ id: "n2",
123
+ x: 0,
124
+ y: 0,
125
+ width: 100,
126
+ height: 50,
127
+ })
128
+ page.nodes = new Map([
129
+ ["n1", node1],
130
+ ["n2", node2],
131
+ ])
132
+
133
+ node1.x = 50
134
+ node2.x = 50
135
+
136
+ editor.history.push({
137
+ redo: [
138
+ "batch",
139
+ [
140
+ ["set-node-props", ["n1", { x: 50 }]],
141
+ ["set-node-props", ["n2", { x: 50 }]],
142
+ ],
143
+ ],
144
+ undo: [
145
+ "batch",
146
+ [
147
+ ["set-node-props", ["n1", { x: 0 }]],
148
+ ["set-node-props", ["n2", { x: 0 }]],
149
+ ],
150
+ ],
151
+ })
152
+
153
+ editor.history.undo()
154
+
155
+ expect(node1.x).toBe(0)
156
+ expect(node2.x).toBe(0)
157
+ expect(editor.history.undoHistory).toHaveLength(0)
158
+ })
159
+ })
160
+
161
+ describe("add-node action", () => {
162
+ it("adds nodes to page on redo", () => {
163
+ editor.history.push({
164
+ redo: [
165
+ "add-node",
166
+ [
167
+ "page-1",
168
+ [
169
+ {
170
+ name: "image",
171
+ props: { id: "new-1", x: 0, y: 0, width: 100, height: 100 },
172
+ },
173
+ ],
174
+ ],
175
+ ],
176
+ undo: ["delete-node", ["page-1", ["new-1"]]],
177
+ })
178
+
179
+ // Push adds to undo history but doesn't execute redo
180
+ expect(page.nodes.has("new-1")).toBe(false)
181
+
182
+ // Undo then redo to execute the add
183
+ editor.history.undo()
184
+ editor.history.redo()
185
+
186
+ expect(page.nodes.has("new-1")).toBe(true)
187
+ })
188
+ })
189
+
190
+ describe("delete-node action", () => {
191
+ it("removes nodes from page", () => {
192
+ const node = new ImageNode(editor, page, {
193
+ id: "del-1",
194
+ x: 0,
195
+ y: 0,
196
+ width: 100,
197
+ height: 50,
198
+ })
199
+ page.nodes = new Map([["del-1", node]])
200
+
201
+ editor.history.push({
202
+ redo: ["delete-node", ["page-1", ["del-1"]]],
203
+ undo: ["add-node", ["page-1", [node.serialize()]]],
204
+ })
205
+
206
+ // Execute delete via undo then redo
207
+ editor.history.undo()
208
+ editor.history.redo()
209
+
210
+ expect(page.nodes.has("del-1")).toBe(false)
211
+ })
212
+ })
213
+
214
+ describe("set-node-props action", () => {
215
+ it("updates node properties", () => {
216
+ const node = new ImageNode(editor, page, {
217
+ id: "n1",
218
+ x: 0,
219
+ y: 0,
220
+ width: 100,
221
+ height: 50,
222
+ })
223
+ page.nodes = new Map([["n1", node]])
224
+
225
+ editor.history.push({
226
+ redo: ["set-node-props", ["n1", { x: 100, y: 200 }]],
227
+ undo: ["set-node-props", ["n1", { x: 0, y: 0 }]],
228
+ })
229
+
230
+ node.x = 100
231
+ node.y = 200
232
+
233
+ editor.history.undo()
234
+
235
+ expect(node.x).toBe(0)
236
+ expect(node.y).toBe(0)
237
+ })
238
+ })
239
+
240
+ describe("set-page-props action", () => {
241
+ it("updates page properties", () => {
242
+ editor.history.push({
243
+ redo: ["set-page-props", ["page-1", { background: "#ff0000" }]],
244
+ undo: ["set-page-props", ["page-1", { background: "#ffffff" }]],
245
+ })
246
+
247
+ page.background = "#ff0000"
248
+
249
+ editor.history.undo()
250
+
251
+ expect(page.background).toBe("#ffffff")
252
+ })
253
+ })
254
+
255
+ describe("stack-order action", () => {
256
+ it("reorders nodes in page", () => {
257
+ const n1 = new ImageNode(editor, page, {
258
+ id: "a",
259
+ x: 0,
260
+ y: 0,
261
+ width: 100,
262
+ height: 50,
263
+ })
264
+ const n2 = new ImageNode(editor, page, {
265
+ id: "b",
266
+ x: 0,
267
+ y: 0,
268
+ width: 100,
269
+ height: 50,
270
+ })
271
+ page.nodes = new Map([
272
+ ["a", n1],
273
+ ["b", n2],
274
+ ])
275
+
276
+ editor.history.push({
277
+ redo: ["stack-order", ["page-1", ["b", "a"]]],
278
+ undo: ["stack-order", ["page-1", ["a", "b"]]],
279
+ })
280
+
281
+ // Execute reorder via undo then redo
282
+ editor.history.undo()
283
+
284
+ const order = [...page.nodes.keys()]
285
+ expect(order).toEqual(["a", "b"])
286
+
287
+ editor.history.redo()
288
+
289
+ const newOrder = [...page.nodes.keys()]
290
+ expect(newOrder).toEqual(["b", "a"])
291
+ })
292
+ })
293
+ })