@lazlon-platform/html-editor 0.1.0 → 0.2.1

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 (42) hide show
  1. package/package.json +14 -11
  2. package/.claude/settings.local.json +0 -9
  3. package/.github/workflows/ci.yml +0 -34
  4. package/demo/App.tsx +0 -62
  5. package/demo/EditorView/PageView/NodeContent.tsx +0 -35
  6. package/demo/EditorView/PageView/SnapLines.tsx +0 -28
  7. package/demo/EditorView/PageView/index.tsx +0 -45
  8. package/demo/EditorView/SelectionFrame/Corner.tsx +0 -24
  9. package/demo/EditorView/SelectionFrame/Edge.tsx +0 -21
  10. package/demo/EditorView/SelectionFrame/index.tsx +0 -27
  11. package/demo/EditorView/SelectionOverlay/ActionHud.tsx +0 -32
  12. package/demo/EditorView/SelectionOverlay/Rotation.tsx +0 -39
  13. package/demo/EditorView/SelectionOverlay/Toolbar.tsx +0 -128
  14. package/demo/EditorView/SelectionOverlay/index.tsx +0 -21
  15. package/demo/EditorView/Toolbar/index.tsx +0 -68
  16. package/demo/EditorView/index.tsx +0 -47
  17. package/demo/Navbar/index.tsx +0 -33
  18. package/demo/Sidebar/index.tsx +0 -71
  19. package/demo/hotkeys.ts +0 -93
  20. package/demo/main.tsx +0 -10
  21. package/demo/style.css +0 -1
  22. package/eslint.config.js +0 -43
  23. package/index.html +0 -14
  24. package/tests/createTestEditor.ts +0 -19
  25. package/tests/hooks/actions.test.tsx +0 -736
  26. package/tests/hooks/batch.test.tsx +0 -332
  27. package/tests/hooks/editor.test.tsx +0 -56
  28. package/tests/hooks/page.test.tsx +0 -135
  29. package/tests/hooks/pointer/pointer.test.tsx +0 -244
  30. package/tests/hooks/textMarks.test.tsx +0 -624
  31. package/tests/model/editor.test.ts +0 -384
  32. package/tests/model/history.test.ts +0 -293
  33. package/tests/model/node/group.test.ts +0 -294
  34. package/tests/model/node/image.test.ts +0 -150
  35. package/tests/model/node/polygon.test.ts +0 -408
  36. package/tests/model/node/text.test.ts +0 -158
  37. package/tests/model/node.test.ts +0 -276
  38. package/tests/model/page.test.ts +0 -150
  39. package/tests/setup.ts +0 -7
  40. package/tsconfig.json +0 -28
  41. package/vite.config.ts +0 -9
  42. package/vitest.config.ts +0 -13
@@ -1,736 +0,0 @@
1
- import { act, renderHook } from "@testing-library/react"
2
- import { beforeEach, describe, expect, it } from "vitest"
3
- import {
4
- clone,
5
- useAddNodeAction,
6
- useAlignAction,
7
- useDuplicateAction,
8
- useStackOrderAction,
9
- useToggleLockAction,
10
- useTrashAction,
11
- } from "../../lib/hooks/actions"
12
- import { EditorContext, PageContext } from "../../lib/hooks/editor"
13
- import { ImageNode } from "../../lib/model/node/image"
14
- import { Page } from "../../lib/model/page"
15
- import { createTestEditor } from "../createTestEditor"
16
-
17
- describe("action hooks", () => {
18
- let editor: ReturnType<typeof createTestEditor>
19
- let page: Page
20
-
21
- beforeEach(() => {
22
- editor = createTestEditor()
23
- page = new Page(editor, { id: "page-1", width: 800, height: 600 })
24
- editor.pages = new Map([["page-1", page]])
25
- })
26
-
27
- function wrapper({ children }: { children: React.ReactNode }) {
28
- return (
29
- <EditorContext value={editor}>
30
- <PageContext value={page}>{children}</PageContext>
31
- </EditorContext>
32
- )
33
- }
34
-
35
- describe("clone", () => {
36
- it("creates a deep copy of a node with new id", () => {
37
- const original = new ImageNode(editor, page, {
38
- id: "orig",
39
- x: 10,
40
- y: 20,
41
- width: 100,
42
- height: 50,
43
- })
44
-
45
- const cloned = clone(editor, original)
46
-
47
- expect(cloned.id).not.toBe("orig")
48
- expect(cloned.x).toBe(10)
49
- expect(cloned.y).toBe(20)
50
- expect(cloned.width).toBe(100)
51
- expect(cloned.height).toBe(50)
52
- })
53
-
54
- it("applies prop transformations with object", () => {
55
- const original = new ImageNode(editor, page, {
56
- id: "orig",
57
- x: 10,
58
- y: 20,
59
- width: 100,
60
- height: 50,
61
- })
62
-
63
- const cloned = clone(editor, original, { x: 50, y: 60 })
64
-
65
- expect(cloned.x).toBe(50)
66
- expect(cloned.y).toBe(60)
67
- })
68
-
69
- it("applies prop transformations with function", () => {
70
- const original = new ImageNode(editor, page, {
71
- id: "orig",
72
- x: 10,
73
- y: 20,
74
- width: 100,
75
- height: 50,
76
- })
77
-
78
- const cloned = clone(editor, original, (props) => ({
79
- x: props.x + 100,
80
- y: props.y + 100,
81
- }))
82
-
83
- expect(cloned.x).toBe(110)
84
- expect(cloned.y).toBe(120)
85
- })
86
- })
87
-
88
- describe("useAddNodeAction", () => {
89
- it("adds a new node to the page", () => {
90
- const { result } = renderHook(() => useAddNodeAction(page), { wrapper })
91
-
92
- act(() => {
93
- result.current(ImageNode, { x: 50, y: 50, width: 200, height: 100 })
94
- })
95
-
96
- expect(page.nodes.size).toBe(1)
97
- const [node] = page.nodes.values()
98
- expect(node.x).toBe(50)
99
- expect(node.y).toBe(50)
100
- expect(node.width).toBe(200)
101
- expect(node.height).toBe(100)
102
- })
103
-
104
- it("uses default positioning when no props provided", () => {
105
- const { result } = renderHook(() => useAddNodeAction(page), { wrapper })
106
-
107
- act(() => {
108
- result.current(ImageNode)
109
- })
110
-
111
- const [node] = page.nodes.values()
112
- // Default: center of page - 50
113
- expect(node.x).toBe(Math.round(800 / 2) - 50)
114
- expect(node.y).toBe(Math.round(600 / 2) - 50)
115
- })
116
-
117
- it("pushes history entry", () => {
118
- const { result } = renderHook(() => useAddNodeAction(page), { wrapper })
119
-
120
- act(() => {
121
- result.current(ImageNode)
122
- })
123
-
124
- expect(editor.history.undoHistory).toHaveLength(1)
125
- const [entry] = editor.history.undoHistory
126
- expect(entry.redo[0]).toBe("add-node")
127
- expect(entry.undo[0]).toBe("delete-node")
128
- })
129
-
130
- it("undo removes the added node", () => {
131
- const { result } = renderHook(() => useAddNodeAction(page), { wrapper })
132
-
133
- act(() => {
134
- result.current(ImageNode)
135
- })
136
-
137
- expect(page.nodes.size).toBe(1)
138
-
139
- act(() => {
140
- editor.history.undo()
141
- })
142
-
143
- expect(page.nodes.size).toBe(0)
144
- })
145
- })
146
-
147
- describe("useTrashAction", () => {
148
- it("removes selected nodes from page", () => {
149
- const node = new ImageNode(editor, page, {
150
- id: "n1",
151
- x: 0,
152
- y: 0,
153
- width: 100,
154
- height: 50,
155
- })
156
- page.nodes = new Map([["n1", node]])
157
- editor.selection = new Set([node])
158
-
159
- const { result } = renderHook(() => useTrashAction(), { wrapper })
160
-
161
- act(() => {
162
- result.current()
163
- })
164
-
165
- expect(page.nodes.size).toBe(0)
166
- expect(editor.selection.size).toBe(0)
167
- })
168
-
169
- it("removes only selected nodes", () => {
170
- const node1 = new ImageNode(editor, page, {
171
- id: "n1",
172
- x: 0,
173
- y: 0,
174
- width: 100,
175
- height: 50,
176
- })
177
- const node2 = new ImageNode(editor, page, {
178
- id: "n2",
179
- x: 100,
180
- y: 0,
181
- width: 100,
182
- height: 50,
183
- })
184
- page.nodes = new Map([
185
- ["n1", node1],
186
- ["n2", node2],
187
- ])
188
- editor.selection = new Set([node1])
189
-
190
- const { result } = renderHook(() => useTrashAction(), { wrapper })
191
-
192
- act(() => {
193
- result.current()
194
- })
195
-
196
- expect(page.nodes.size).toBe(1)
197
- expect(page.nodes.has("n2")).toBe(true)
198
- })
199
-
200
- it("does nothing when selection is empty", () => {
201
- const { result } = renderHook(() => useTrashAction(), { wrapper })
202
-
203
- act(() => {
204
- result.current()
205
- })
206
-
207
- expect(editor.history.undoHistory).toHaveLength(0)
208
- })
209
-
210
- it("undo restores deleted nodes", () => {
211
- const node = new ImageNode(editor, page, {
212
- id: "n1",
213
- x: 50,
214
- y: 100,
215
- width: 100,
216
- height: 50,
217
- })
218
- page.nodes = new Map([["n1", node]])
219
- editor.selection = new Set([node])
220
-
221
- const { result } = renderHook(() => useTrashAction(), { wrapper })
222
-
223
- act(() => {
224
- result.current()
225
- })
226
-
227
- expect(page.nodes.size).toBe(0)
228
-
229
- act(() => {
230
- editor.history.undo()
231
- })
232
-
233
- expect(page.nodes.size).toBe(1)
234
- expect(page.nodes.has("n1")).toBe(true)
235
- })
236
- })
237
-
238
- describe("useDuplicateAction", () => {
239
- it("duplicates selected nodes with default offset", () => {
240
- const node = new ImageNode(editor, page, {
241
- id: "n1",
242
- x: 100,
243
- y: 100,
244
- width: 100,
245
- height: 50,
246
- })
247
- page.nodes = new Map([["n1", node]])
248
- editor.selection = new Set([node])
249
-
250
- const { result } = renderHook(() => useDuplicateAction(), { wrapper })
251
-
252
- act(() => {
253
- result.current()
254
- })
255
-
256
- expect(page.nodes.size).toBe(2)
257
-
258
- const newNode = [...page.nodes.values()].find((n) => n.id !== "n1")
259
- expect(newNode?.x).toBe(110) // default offset is 10
260
- expect(newNode?.y).toBe(110)
261
- })
262
-
263
- it("supports custom offset", () => {
264
- const node = new ImageNode(editor, page, {
265
- id: "n1",
266
- x: 50,
267
- y: 50,
268
- width: 100,
269
- height: 50,
270
- })
271
- page.nodes = new Map([["n1", node]])
272
- editor.selection = new Set([node])
273
-
274
- const { result } = renderHook(() => useDuplicateAction(), { wrapper })
275
-
276
- act(() => {
277
- result.current({ offset: 25 })
278
- })
279
-
280
- const newNode = [...page.nodes.values()].find((n) => n.id !== "n1")
281
- expect(newNode?.x).toBe(75)
282
- expect(newNode?.y).toBe(75)
283
- })
284
-
285
- it("selects duplicated nodes", () => {
286
- const node = new ImageNode(editor, page, {
287
- id: "n1",
288
- x: 0,
289
- y: 0,
290
- width: 100,
291
- height: 50,
292
- })
293
- page.nodes = new Map([["n1", node]])
294
- editor.selection = new Set([node])
295
-
296
- const { result } = renderHook(() => useDuplicateAction(), { wrapper })
297
-
298
- act(() => {
299
- result.current()
300
- })
301
-
302
- // Selection should be the new node(s), not the original
303
- expect(editor.selection.size).toBe(1)
304
- expect(editor.selection.has(node)).toBe(false)
305
- })
306
-
307
- it("does nothing when selection is empty", () => {
308
- const { result } = renderHook(() => useDuplicateAction(), { wrapper })
309
-
310
- act(() => {
311
- result.current()
312
- })
313
-
314
- expect(editor.history.undoHistory).toHaveLength(0)
315
- })
316
- })
317
-
318
- describe("useToggleLockAction", () => {
319
- it("locks unlocked nodes", () => {
320
- const node = new ImageNode(editor, page, {
321
- id: "n1",
322
- x: 0,
323
- y: 0,
324
- width: 100,
325
- height: 50,
326
- locked: false,
327
- })
328
- page.nodes = new Map([["n1", node]])
329
- editor.selection = new Set([node])
330
-
331
- const { result } = renderHook(() => useToggleLockAction(), { wrapper })
332
-
333
- act(() => {
334
- result.current()
335
- })
336
-
337
- expect(node.locked).toBe(true)
338
- })
339
-
340
- it("unlocks locked nodes", () => {
341
- const node = new ImageNode(editor, page, {
342
- id: "n1",
343
- x: 0,
344
- y: 0,
345
- width: 100,
346
- height: 50,
347
- locked: true,
348
- })
349
- page.nodes = new Map([["n1", node]])
350
- editor.selection = new Set([node])
351
-
352
- const { result } = renderHook(() => useToggleLockAction(), { wrapper })
353
-
354
- act(() => {
355
- result.current()
356
- })
357
-
358
- expect(node.locked).toBe(false)
359
- })
360
-
361
- it("unlocks all if any are locked", () => {
362
- const node1 = new ImageNode(editor, page, {
363
- id: "n1",
364
- x: 0,
365
- y: 0,
366
- width: 100,
367
- height: 50,
368
- locked: true,
369
- })
370
- const node2 = new ImageNode(editor, page, {
371
- id: "n2",
372
- x: 100,
373
- y: 0,
374
- width: 100,
375
- height: 50,
376
- locked: false,
377
- })
378
- page.nodes = new Map([
379
- ["n1", node1],
380
- ["n2", node2],
381
- ])
382
- editor.selection = new Set([node1, node2])
383
-
384
- const { result } = renderHook(() => useToggleLockAction(), { wrapper })
385
-
386
- act(() => {
387
- result.current()
388
- })
389
-
390
- // When any are locked, toggle unlocks all
391
- expect(node1.locked).toBe(false)
392
- expect(node2.locked).toBe(false)
393
- })
394
- })
395
-
396
- describe("useAlignAction", () => {
397
- it("aligns nodes to left edge", () => {
398
- const n1 = new ImageNode(editor, page, {
399
- id: "n1",
400
- x: 50,
401
- y: 0,
402
- width: 100,
403
- height: 50,
404
- })
405
- const n2 = new ImageNode(editor, page, {
406
- id: "n2",
407
- x: 150,
408
- y: 0,
409
- width: 100,
410
- height: 50,
411
- })
412
- page.nodes = new Map([
413
- ["n1", n1],
414
- ["n2", n2],
415
- ])
416
- editor.selection = new Set([n1, n2])
417
-
418
- const { result } = renderHook(() => useAlignAction(), { wrapper })
419
-
420
- act(() => {
421
- result.current.left()
422
- })
423
-
424
- expect(n1.x).toBe(0)
425
- expect(n2.x).toBe(0)
426
- })
427
-
428
- it("centers nodes horizontally", () => {
429
- const n1 = new ImageNode(editor, page, {
430
- id: "n1",
431
- x: 0,
432
- y: 0,
433
- width: 100,
434
- height: 50,
435
- })
436
- page.nodes = new Map([["n1", n1]])
437
- editor.selection = new Set([n1])
438
-
439
- const { result } = renderHook(() => useAlignAction(), { wrapper })
440
-
441
- act(() => {
442
- result.current.center()
443
- })
444
-
445
- // page.width = 800, node.width = 100, so center = (800 - 100) / 2 = 350
446
- expect(n1.x).toBe(350)
447
- })
448
-
449
- it("aligns nodes to right edge", () => {
450
- const n1 = new ImageNode(editor, page, {
451
- id: "n1",
452
- x: 0,
453
- y: 0,
454
- width: 100,
455
- height: 50,
456
- })
457
- page.nodes = new Map([["n1", n1]])
458
- editor.selection = new Set([n1])
459
-
460
- const { result } = renderHook(() => useAlignAction(), { wrapper })
461
-
462
- act(() => {
463
- result.current.right()
464
- })
465
-
466
- // page.width = 800, node.width = 100, so right = 800 - 100 = 700
467
- expect(n1.x).toBe(700)
468
- })
469
-
470
- it("aligns nodes to top edge", () => {
471
- const n1 = new ImageNode(editor, page, {
472
- id: "n1",
473
- x: 0,
474
- y: 100,
475
- width: 100,
476
- height: 50,
477
- })
478
- page.nodes = new Map([["n1", n1]])
479
- editor.selection = new Set([n1])
480
-
481
- const { result } = renderHook(() => useAlignAction(), { wrapper })
482
-
483
- act(() => {
484
- result.current.top()
485
- })
486
-
487
- expect(n1.y).toBe(0)
488
- })
489
-
490
- it("centers nodes vertically", () => {
491
- const n1 = new ImageNode(editor, page, {
492
- id: "n1",
493
- x: 0,
494
- y: 0,
495
- width: 100,
496
- height: 50,
497
- })
498
- page.nodes = new Map([["n1", n1]])
499
- editor.selection = new Set([n1])
500
-
501
- const { result } = renderHook(() => useAlignAction(), { wrapper })
502
-
503
- act(() => {
504
- result.current.middle()
505
- })
506
-
507
- // page.height = 600, node.height = 50, so middle = (600 - 50) / 2 = 275
508
- expect(n1.y).toBe(275)
509
- })
510
-
511
- it("aligns nodes to bottom edge", () => {
512
- const n1 = new ImageNode(editor, page, {
513
- id: "n1",
514
- x: 0,
515
- y: 0,
516
- width: 100,
517
- height: 50,
518
- })
519
- page.nodes = new Map([["n1", n1]])
520
- editor.selection = new Set([n1])
521
-
522
- const { result } = renderHook(() => useAlignAction(), { wrapper })
523
-
524
- act(() => {
525
- result.current.bottom()
526
- })
527
-
528
- // page.height = 600, node.height = 50, so bottom = 600 - 50 = 550
529
- expect(n1.y).toBe(550)
530
- })
531
-
532
- it("creates history entry for alignment", () => {
533
- const n1 = new ImageNode(editor, page, {
534
- id: "n1",
535
- x: 50,
536
- y: 0,
537
- width: 100,
538
- height: 50,
539
- })
540
- page.nodes = new Map([["n1", n1]])
541
- editor.selection = new Set([n1])
542
-
543
- const { result } = renderHook(() => useAlignAction(), { wrapper })
544
-
545
- act(() => {
546
- result.current.left()
547
- })
548
-
549
- expect(editor.history.undoHistory).toHaveLength(1)
550
-
551
- act(() => {
552
- editor.history.undo()
553
- })
554
-
555
- expect(n1.x).toBe(50)
556
- })
557
- })
558
-
559
- describe("useStackOrderAction", () => {
560
- it("brings node forward in stack", () => {
561
- const n1 = new ImageNode(editor, page, {
562
- id: "a",
563
- x: 0,
564
- y: 0,
565
- width: 100,
566
- height: 50,
567
- })
568
- const n2 = new ImageNode(editor, page, {
569
- id: "b",
570
- x: 0,
571
- y: 0,
572
- width: 100,
573
- height: 50,
574
- })
575
- page.nodes = new Map([
576
- ["a", n1],
577
- ["b", n2],
578
- ])
579
- editor.selection = new Set([n1])
580
-
581
- const { result } = renderHook(() => useStackOrderAction(), { wrapper })
582
-
583
- act(() => {
584
- result.current.bringForward()
585
- })
586
-
587
- const order = [...page.nodes.keys()]
588
- expect(order).toEqual(["b", "a"])
589
- })
590
-
591
- it("brings node backward in stack", () => {
592
- const n1 = new ImageNode(editor, page, {
593
- id: "a",
594
- x: 0,
595
- y: 0,
596
- width: 100,
597
- height: 50,
598
- })
599
- const n2 = new ImageNode(editor, page, {
600
- id: "b",
601
- x: 0,
602
- y: 0,
603
- width: 100,
604
- height: 50,
605
- })
606
- page.nodes = new Map([
607
- ["a", n1],
608
- ["b", n2],
609
- ])
610
- editor.selection = new Set([n2])
611
-
612
- const { result } = renderHook(() => useStackOrderAction(), { wrapper })
613
-
614
- act(() => {
615
- result.current.bringBackward()
616
- })
617
-
618
- const order = [...page.nodes.keys()]
619
- expect(order).toEqual(["b", "a"])
620
- })
621
-
622
- it("brings node to front", () => {
623
- const n1 = new ImageNode(editor, page, {
624
- id: "a",
625
- x: 0,
626
- y: 0,
627
- width: 100,
628
- height: 50,
629
- })
630
- const n2 = new ImageNode(editor, page, {
631
- id: "b",
632
- x: 0,
633
- y: 0,
634
- width: 100,
635
- height: 50,
636
- })
637
- const n3 = new ImageNode(editor, page, {
638
- id: "c",
639
- x: 0,
640
- y: 0,
641
- width: 100,
642
- height: 50,
643
- })
644
- page.nodes = new Map([
645
- ["a", n1],
646
- ["b", n2],
647
- ["c", n3],
648
- ])
649
- editor.selection = new Set([n1])
650
-
651
- const { result } = renderHook(() => useStackOrderAction(), { wrapper })
652
-
653
- act(() => {
654
- result.current.bringToFront()
655
- })
656
-
657
- const order = [...page.nodes.keys()]
658
- expect(order).toEqual(["b", "c", "a"])
659
- })
660
-
661
- it("brings node to back", () => {
662
- const n1 = new ImageNode(editor, page, {
663
- id: "a",
664
- x: 0,
665
- y: 0,
666
- width: 100,
667
- height: 50,
668
- })
669
- const n2 = new ImageNode(editor, page, {
670
- id: "b",
671
- x: 0,
672
- y: 0,
673
- width: 100,
674
- height: 50,
675
- })
676
- const n3 = new ImageNode(editor, page, {
677
- id: "c",
678
- x: 0,
679
- y: 0,
680
- width: 100,
681
- height: 50,
682
- })
683
- page.nodes = new Map([
684
- ["a", n1],
685
- ["b", n2],
686
- ["c", n3],
687
- ])
688
- editor.selection = new Set([n3])
689
-
690
- const { result } = renderHook(() => useStackOrderAction(), { wrapper })
691
-
692
- act(() => {
693
- result.current.bringToBack()
694
- })
695
-
696
- const order = [...page.nodes.keys()]
697
- expect(order).toEqual(["c", "a", "b"])
698
- })
699
-
700
- it("undo restores original order", () => {
701
- const n1 = new ImageNode(editor, page, {
702
- id: "a",
703
- x: 0,
704
- y: 0,
705
- width: 100,
706
- height: 50,
707
- })
708
- const n2 = new ImageNode(editor, page, {
709
- id: "b",
710
- x: 0,
711
- y: 0,
712
- width: 100,
713
- height: 50,
714
- })
715
- page.nodes = new Map([
716
- ["a", n1],
717
- ["b", n2],
718
- ])
719
- editor.selection = new Set([n1])
720
-
721
- const { result } = renderHook(() => useStackOrderAction(), { wrapper })
722
-
723
- act(() => {
724
- result.current.bringToFront()
725
- })
726
-
727
- expect([...page.nodes.keys()]).toEqual(["b", "a"])
728
-
729
- act(() => {
730
- editor.history.undo()
731
- })
732
-
733
- expect([...page.nodes.keys()]).toEqual(["a", "b"])
734
- })
735
- })
736
- })