@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,624 +0,0 @@
1
- import { act, renderHook } from "@testing-library/react"
2
- import { beforeEach, describe, expect, it } from "vitest"
3
- import { EditorContext } from "../../lib/hooks/editor"
4
- import { blurNode, useTextMarks } from "../../lib/hooks/textMarks"
5
- import {
6
- FormattableNode,
7
- type FormattableNodeProps,
8
- } from "../../lib/model/node/formattable"
9
- import { TextNode, TextNodeProps } from "../../lib/model/node/text"
10
- import { Page } from "../../lib/model/page"
11
- import { createTestEditor } from "../createTestEditor"
12
- import { EditableNode } from "../../lib/model"
13
-
14
- // Concrete FormattableNode implementation for testing
15
- class TestFormattableNode extends FormattableNode {
16
- get name() {
17
- return "test-formattable"
18
- }
19
- }
20
-
21
- describe("useTextMarks", () => {
22
- let editor: ReturnType<typeof createTestEditor>
23
- let page: Page
24
-
25
- beforeEach(() => {
26
- editor = createTestEditor()
27
- page = new Page(editor, { id: "page-1" })
28
- editor.pages = new Map([["page-1", page]])
29
- })
30
-
31
- function wrapper({ children }: { children: React.ReactNode }) {
32
- return <EditorContext value={editor}>{children}</EditorContext>
33
- }
34
-
35
- function createTextNode(props: Partial<TextNodeProps> = {}): TextNode {
36
- return new TextNode(editor, page, {
37
- id: editor.id(),
38
- x: 0,
39
- y: 0,
40
- width: 100,
41
- height: 50,
42
- ...props,
43
- })
44
- }
45
-
46
- function createFormattableNode(
47
- props: Partial<FormattableNodeProps> = {},
48
- ): TestFormattableNode {
49
- return new TestFormattableNode(editor, page, {
50
- id: editor.id(),
51
- x: 0,
52
- y: 0,
53
- width: 100,
54
- height: 50,
55
- ...props,
56
- })
57
- }
58
-
59
- describe("with empty selection", () => {
60
- it("returns null state when no nodes provided", () => {
61
- const { result } = renderHook(
62
- () =>
63
- useTextMarks({
64
- editables: [],
65
- formattables: [],
66
- }),
67
- { wrapper },
68
- )
69
-
70
- expect(result.current.state).toBeNull()
71
- })
72
- })
73
-
74
- describe("with EditableNodes (TextNode)", () => {
75
- it("returns text marks state from single editable node", () => {
76
- const node = createTextNode()
77
- page.nodes = new Map([[node.id, node]])
78
-
79
- const { result } = renderHook(
80
- () =>
81
- useTextMarks({
82
- editables: [node],
83
- formattables: [],
84
- }),
85
- { wrapper },
86
- )
87
-
88
- // Default state values
89
- expect(result.current.state).not.toBeNull()
90
- expect(result.current.state?.isBold).toBe(false)
91
- expect(result.current.state?.isItalic).toBe(false)
92
- expect(result.current.state?.isUnderline).toBe(false)
93
- expect(result.current.state?.isStrike).toBe(false)
94
- })
95
-
96
- it("toggleBold toggles bold on all selected nodes", () => {
97
- const node = createTextNode()
98
- page.nodes = new Map([[node.id, node]])
99
-
100
- // Add some text content so it's not empty
101
- node.tiptap.commands.setContent({
102
- type: "doc",
103
- content: [{ type: "paragraph", content: [{ type: "text", text: "Hello" }] }],
104
- })
105
- node.tiptap.commands.selectAll()
106
-
107
- const { result } = renderHook(
108
- () =>
109
- useTextMarks({
110
- editables: [node],
111
- formattables: [],
112
- }),
113
- { wrapper },
114
- )
115
-
116
- act(() => {
117
- result.current.toggle("Bold")
118
- })
119
-
120
- // Verify bold was applied via Tiptap
121
- expect(node.tiptap.isActive("bold")).toBe(true)
122
- })
123
-
124
- it("setColor sets color on editable nodes", () => {
125
- const node = createTextNode()
126
- page.nodes = new Map([[node.id, node]])
127
-
128
- node.tiptap.commands.setContent({
129
- type: "doc",
130
- content: [{ type: "paragraph", content: [{ type: "text", text: "Hello" }] }],
131
- })
132
- node.tiptap.commands.selectAll()
133
-
134
- const { result } = renderHook(
135
- () =>
136
- useTextMarks({
137
- editables: [node],
138
- formattables: [],
139
- }),
140
- { wrapper },
141
- )
142
-
143
- act(() => {
144
- result.current.setColor("#ff0000", { end: true })
145
- })
146
-
147
- // Color is applied via Tiptap textStyle mark
148
- const attrs = node.tiptap.getAttributes("textStyle")
149
- expect(attrs.color).toBe("#ff0000")
150
- })
151
-
152
- it("setSize sets font size on editable nodes", () => {
153
- const node = createTextNode()
154
- page.nodes = new Map([[node.id, node]])
155
-
156
- node.tiptap.commands.setContent({
157
- type: "doc",
158
- content: [{ type: "paragraph", content: [{ type: "text", text: "Hello" }] }],
159
- })
160
- node.tiptap.commands.selectAll()
161
-
162
- const { result } = renderHook(
163
- () =>
164
- useTextMarks({
165
- editables: [node],
166
- formattables: [],
167
- }),
168
- { wrapper },
169
- )
170
-
171
- act(() => {
172
- result.current.setSize(24)
173
- })
174
-
175
- const attrs = node.tiptap.getAttributes("textStyle")
176
- expect(attrs.fontSize).toBe("24px")
177
- })
178
-
179
- it("setFamily sets font family on editable nodes", () => {
180
- const node = createTextNode()
181
- page.nodes = new Map([[node.id, node]])
182
-
183
- node.tiptap.commands.setContent({
184
- type: "doc",
185
- content: [{ type: "paragraph", content: [{ type: "text", text: "Hello" }] }],
186
- })
187
- node.tiptap.commands.selectAll()
188
-
189
- const { result } = renderHook(
190
- () =>
191
- useTextMarks({
192
- editables: [node],
193
- formattables: [],
194
- }),
195
- { wrapper },
196
- )
197
-
198
- act(() => {
199
- result.current.setFamily("Arial")
200
- })
201
-
202
- const attrs = node.tiptap.getAttributes("textStyle")
203
- expect(attrs.fontFamily).toBe("Arial")
204
- })
205
-
206
- it("setFamily with null unsets font family", () => {
207
- const node = createTextNode()
208
- page.nodes = new Map([[node.id, node]])
209
-
210
- node.tiptap.commands.setContent({
211
- type: "doc",
212
- content: [{ type: "paragraph", content: [{ type: "text", text: "Hello" }] }],
213
- })
214
- node.tiptap.commands.selectAll()
215
- node.tiptap.commands.setFontFamily("Arial")
216
-
217
- const { result } = renderHook(
218
- () =>
219
- useTextMarks({
220
- editables: [node],
221
- formattables: [],
222
- }),
223
- { wrapper },
224
- )
225
-
226
- act(() => {
227
- result.current.setFamily(null)
228
- })
229
-
230
- const attrs = node.tiptap.getAttributes("textStyle")
231
- expect(attrs.fontFamily).toBeUndefined()
232
- })
233
- })
234
-
235
- describe("with FormattableNodes", () => {
236
- it("returns text marks state from single formattable node", () => {
237
- const node = createFormattableNode({
238
- bold: true,
239
- italic: false,
240
- color: "#333333",
241
- size: 18,
242
- family: "Roboto",
243
- spacing: 1.5,
244
- })
245
- page.nodes = new Map([[node.id, node]])
246
-
247
- const { result } = renderHook(
248
- () =>
249
- useTextMarks({
250
- editables: [],
251
- formattables: [node],
252
- }),
253
- { wrapper },
254
- )
255
-
256
- expect(result.current.state).not.toBeNull()
257
- expect(result.current.state?.isBold).toBe(true)
258
- expect(result.current.state?.isItalic).toBe(false)
259
- expect(result.current.state?.color).toBe("#333333")
260
- expect(result.current.state?.size).toBe(18)
261
- expect(result.current.state?.family).toBe("Roboto")
262
- expect(result.current.state?.spacing).toBe(1.5)
263
- })
264
-
265
- it("toggle updates formattable node properties", () => {
266
- const node = createFormattableNode({ bold: false })
267
- page.nodes = new Map([[node.id, node]])
268
-
269
- const { result } = renderHook(
270
- () =>
271
- useTextMarks({
272
- editables: [],
273
- formattables: [node],
274
- }),
275
- { wrapper },
276
- )
277
-
278
- act(() => {
279
- result.current.toggle("Bold")
280
- })
281
-
282
- expect(node.bold).toBe(true)
283
- })
284
-
285
- it("toggle turns off mark when already active", () => {
286
- const node = createFormattableNode({ bold: true })
287
- page.nodes = new Map([[node.id, node]])
288
-
289
- const { result } = renderHook(
290
- () =>
291
- useTextMarks({
292
- editables: [],
293
- formattables: [node],
294
- }),
295
- { wrapper },
296
- )
297
-
298
- act(() => {
299
- result.current.toggle("Bold")
300
- })
301
-
302
- expect(node.bold).toBe(false)
303
- })
304
-
305
- it("setColor updates formattable node color with history", () => {
306
- const node = createFormattableNode({ color: "#000000" })
307
- page.nodes = new Map([[node.id, node]])
308
-
309
- const { result } = renderHook(
310
- () =>
311
- useTextMarks({
312
- editables: [],
313
- formattables: [node],
314
- }),
315
- { wrapper },
316
- )
317
-
318
- act(() => {
319
- result.current.setColor("#ff0000", { end: true })
320
- })
321
-
322
- expect(node.color).toBe("#ff0000")
323
- expect(editor.history.undoHistory.length).toBeGreaterThan(0)
324
- })
325
-
326
- it("setColor with end:false does not create history entry", () => {
327
- const node = createFormattableNode({ color: "#000000" })
328
- page.nodes = new Map([[node.id, node]])
329
-
330
- const { result } = renderHook(
331
- () =>
332
- useTextMarks({
333
- editables: [],
334
- formattables: [node],
335
- }),
336
- { wrapper },
337
- )
338
-
339
- const historyLengthBefore = editor.history.undoHistory.length
340
-
341
- act(() => {
342
- result.current.setColor("#ff0000", { end: false })
343
- })
344
-
345
- expect(node.color).toBe("#ff0000")
346
- expect(editor.history.undoHistory.length).toBe(historyLengthBefore)
347
- })
348
-
349
- it("setSize updates formattable node size with history", () => {
350
- const node = createFormattableNode({ size: 16 })
351
- page.nodes = new Map([[node.id, node]])
352
-
353
- const { result } = renderHook(
354
- () =>
355
- useTextMarks({
356
- editables: [],
357
- formattables: [node],
358
- }),
359
- { wrapper },
360
- )
361
-
362
- act(() => {
363
- result.current.setSize(24)
364
- })
365
-
366
- expect(node.size).toBe(24)
367
- expect(editor.history.undoHistory.length).toBeGreaterThan(0)
368
- })
369
-
370
- it("setFamily updates formattable node family", () => {
371
- const node = createFormattableNode({ family: null })
372
- page.nodes = new Map([[node.id, node]])
373
-
374
- const { result } = renderHook(
375
- () =>
376
- useTextMarks({
377
- editables: [],
378
- formattables: [node],
379
- }),
380
- { wrapper },
381
- )
382
-
383
- act(() => {
384
- result.current.setFamily("Georgia")
385
- })
386
-
387
- expect(node.family).toBe("Georgia")
388
- })
389
-
390
- it("setSpacing updates formattable node spacing", () => {
391
- const node = createFormattableNode({ spacing: 0 })
392
- page.nodes = new Map([[node.id, node]])
393
-
394
- const { result } = renderHook(
395
- () =>
396
- useTextMarks({
397
- editables: [],
398
- formattables: [node],
399
- }),
400
- { wrapper },
401
- )
402
-
403
- act(() => {
404
- result.current.setSpacing(2.5, { end: true })
405
- })
406
-
407
- expect(node.spacing).toBe(2.5)
408
- expect(editor.history.undoHistory.length).toBeGreaterThan(0)
409
- })
410
- })
411
-
412
- describe("with mixed EditableNodes and FormattableNodes", () => {
413
- it("merges state from both node types", () => {
414
- const textNode = createTextNode()
415
- const formattableNode = createFormattableNode({
416
- bold: true,
417
- color: "#ff0000",
418
- })
419
- page.nodes = new Map<string, EditableNode | FormattableNode>([
420
- [textNode.id, textNode],
421
- [formattableNode.id, formattableNode],
422
- ])
423
-
424
- const { result } = renderHook(
425
- () =>
426
- useTextMarks({
427
- editables: [textNode],
428
- formattables: [formattableNode],
429
- }),
430
- { wrapper },
431
- )
432
-
433
- // State should be merged - since textNode is empty, only formattable state is used
434
- expect(result.current.state).not.toBeNull()
435
- })
436
-
437
- it("toggle updates both editable and formattable nodes", () => {
438
- const textNode = createTextNode()
439
- const formattableNode = createFormattableNode({ italic: false })
440
- page.nodes = new Map<string, EditableNode | FormattableNode>([
441
- [textNode.id, textNode],
442
- [formattableNode.id, formattableNode],
443
- ])
444
-
445
- // Add content to text node
446
- textNode.tiptap.commands.setContent({
447
- type: "doc",
448
- content: [{ type: "paragraph", content: [{ type: "text", text: "Test" }] }],
449
- })
450
- textNode.tiptap.commands.selectAll()
451
-
452
- const { result } = renderHook(
453
- () =>
454
- useTextMarks({
455
- editables: [textNode],
456
- formattables: [formattableNode],
457
- }),
458
- { wrapper },
459
- )
460
-
461
- act(() => {
462
- result.current.toggle("Italic")
463
- })
464
-
465
- expect(textNode.tiptap.isActive("italic")).toBe(true)
466
- expect(formattableNode.italic).toBe(true)
467
- })
468
- })
469
-
470
- describe("state merging", () => {
471
- it("returns null for conflicting boolean values", () => {
472
- const node1 = createFormattableNode({ bold: true })
473
- const node2 = createFormattableNode({ bold: false })
474
- page.nodes = new Map([
475
- [node1.id, node1],
476
- [node2.id, node2],
477
- ])
478
-
479
- const { result } = renderHook(
480
- () =>
481
- useTextMarks({
482
- editables: [],
483
- formattables: [node1, node2],
484
- }),
485
- { wrapper },
486
- )
487
-
488
- // When values differ, merged value becomes null but state.isBold defaults to false
489
- expect(result.current.state?.isBold).toBe(false)
490
- })
491
-
492
- it("returns consistent value when all nodes match", () => {
493
- const node1 = createFormattableNode({ bold: true, color: "#ff0000" })
494
- const node2 = createFormattableNode({ bold: true, color: "#ff0000" })
495
- page.nodes = new Map([
496
- [node1.id, node1],
497
- [node2.id, node2],
498
- ])
499
-
500
- const { result } = renderHook(
501
- () =>
502
- useTextMarks({
503
- editables: [],
504
- formattables: [node1, node2],
505
- }),
506
- { wrapper },
507
- )
508
-
509
- expect(result.current.state?.isBold).toBe(true)
510
- expect(result.current.state?.color).toBe("#ff0000")
511
- })
512
-
513
- it("returns null color for conflicting colors", () => {
514
- const node1 = createFormattableNode({ color: "#ff0000" })
515
- const node2 = createFormattableNode({ color: "#00ff00" })
516
- page.nodes = new Map([
517
- [node1.id, node1],
518
- [node2.id, node2],
519
- ])
520
-
521
- const { result } = renderHook(
522
- () =>
523
- useTextMarks({
524
- editables: [],
525
- formattables: [node1, node2],
526
- }),
527
- { wrapper },
528
- )
529
-
530
- expect(result.current.state?.color).toBeNull()
531
- })
532
- })
533
-
534
- describe("blurNode", () => {
535
- it("clears the last focused editor reference", () => {
536
- const node = createTextNode()
537
- page.nodes = new Map([[node.id, node]])
538
-
539
- // Focus the editor
540
- node.tiptap.commands.focus()
541
-
542
- // Render the hook to establish the focused editor tracking
543
- renderHook(
544
- () =>
545
- useTextMarks({
546
- editables: [node],
547
- formattables: [],
548
- }),
549
- { wrapper },
550
- )
551
-
552
- // blurNode should clear the reference
553
- blurNode(node)
554
-
555
- // This test mainly verifies blurNode doesn't throw
556
- expect(true).toBe(true)
557
- })
558
- })
559
-
560
- describe("all toggle marks", () => {
561
- it.each([
562
- ["Bold", "bold"],
563
- ["Italic", "italic"],
564
- ["Underline", "underline"],
565
- ["Strike", "strike"],
566
- ["Superscript", "superscript"],
567
- ["Subscript", "subscript"],
568
- ] as const)("toggle %s updates formattable node %s property", (mark, prop) => {
569
- const node = createFormattableNode({ [prop]: false })
570
- page.nodes = new Map([[node.id, node]])
571
-
572
- const { result } = renderHook(
573
- () =>
574
- useTextMarks({
575
- editables: [],
576
- formattables: [node],
577
- }),
578
- { wrapper },
579
- )
580
-
581
- act(() => {
582
- result.current.toggle(mark)
583
- })
584
-
585
- expect(node[prop]).toBe(true)
586
- })
587
- })
588
-
589
- describe("default values", () => {
590
- it("uses default size from computed style when none specified", () => {
591
- const node = createFormattableNode()
592
- page.nodes = new Map([[node.id, node]])
593
-
594
- const { result } = renderHook(
595
- () =>
596
- useTextMarks({
597
- editables: [],
598
- formattables: [node],
599
- }),
600
- { wrapper },
601
- )
602
-
603
- // Size should be the node's size (default 16) or computed font size
604
- expect(result.current.state?.size).toBe(16)
605
- })
606
-
607
- it("uses Inter as default font family", () => {
608
- const node = createFormattableNode({ family: null })
609
- page.nodes = new Map([[node.id, node]])
610
-
611
- const { result } = renderHook(
612
- () =>
613
- useTextMarks({
614
- editables: [],
615
- formattables: [node],
616
- }),
617
- { wrapper },
618
- )
619
-
620
- // When all families are null, default is Inter
621
- expect(result.current.state?.family).toBe("Inter")
622
- })
623
- })
624
- })