|
1 | 1 | import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; |
2 | | -import { EditorState } from 'prosemirror-state'; |
| 2 | +import { EditorState, TextSelection } from 'prosemirror-state'; |
3 | 3 | import { initTestEditor } from '@tests/helpers/helpers.js'; |
4 | 4 | import { createTable } from '../table/tableHelpers/createTable.js'; |
5 | 5 |
|
@@ -556,6 +556,263 @@ describe('updateStructuredContentByGroup', () => { |
556 | 556 | }); |
557 | 557 | }); |
558 | 558 |
|
| 559 | +describe('insertStructuredContentInline formatting', () => { |
| 560 | + let editor; |
| 561 | + let schema; |
| 562 | + |
| 563 | + beforeEach(() => { |
| 564 | + ({ editor } = initTestEditor()); |
| 565 | + ({ schema } = editor); |
| 566 | + }); |
| 567 | + |
| 568 | + afterEach(() => { |
| 569 | + editor?.destroy(); |
| 570 | + editor = null; |
| 571 | + schema = null; |
| 572 | + }); |
| 573 | + |
| 574 | + it('does not wrap structuredContent in a run when inserted inside a run', () => { |
| 575 | + const fontFamily = { |
| 576 | + ascii: 'Courier New', |
| 577 | + eastAsia: 'Courier New', |
| 578 | + hAnsi: 'Courier New', |
| 579 | + cs: 'Courier New', |
| 580 | + }; |
| 581 | + const textStyleMark = schema.marks.textStyle.create({ |
| 582 | + fontFamily: 'Courier New', |
| 583 | + fontSize: '12pt', |
| 584 | + }); |
| 585 | + const styledText = schema.text('This is some text', [textStyleMark]); |
| 586 | + const run = schema.nodes.run.create({ runProperties: { fontFamily } }, styledText); |
| 587 | + const paragraph = schema.nodes.paragraph.create(null, [run]); |
| 588 | + const doc = schema.nodes.doc.create(null, [paragraph]); |
| 589 | + |
| 590 | + // run content starts at position 2 (doc > paragraph > run), so cursor after "This is " is at 2 + 8 = 10 |
| 591 | + const cursorPos = 2 + 'This is '.length; // 10 |
| 592 | + const nextState = EditorState.create({ |
| 593 | + schema, |
| 594 | + doc, |
| 595 | + plugins: editor.state.plugins, |
| 596 | + selection: TextSelection.create(doc, cursorPos), |
| 597 | + }); |
| 598 | + editor.setState(nextState); |
| 599 | + |
| 600 | + editor.commands.insertStructuredContentInline({ |
| 601 | + text: 'Inline Header', |
| 602 | + attrs: { group: 'header' }, |
| 603 | + }); |
| 604 | + |
| 605 | + const updatedParagraph = editor.state.doc.firstChild; |
| 606 | + |
| 607 | + // The paragraph's direct children should be: run, structuredContent, run |
| 608 | + // The structuredContent must NOT be wrapped in a run |
| 609 | + const childTypes = []; |
| 610 | + updatedParagraph.forEach((child) => { |
| 611 | + childTypes.push(child.type.name); |
| 612 | + |
| 613 | + // If a run contains a structuredContent as a child, that's the bug |
| 614 | + if (child.type.name === 'run') { |
| 615 | + child.forEach((grandchild) => { |
| 616 | + if (grandchild.type.name === 'structuredContent') { |
| 617 | + throw new Error('structuredContent should not be wrapped inside a run'); |
| 618 | + } |
| 619 | + }); |
| 620 | + } |
| 621 | + }); |
| 622 | + |
| 623 | + expect(childTypes).toContain('structuredContent'); |
| 624 | + expect(updatedParagraph.textContent).toBe('This is Inline Headersome text'); |
| 625 | + |
| 626 | + // The SDT's inner content should be a run with the inherited formatting |
| 627 | + let sdt = null; |
| 628 | + updatedParagraph.forEach((child) => { |
| 629 | + if (child.type.name === 'structuredContent') sdt = child; |
| 630 | + }); |
| 631 | + const innerRun = sdt.firstChild; |
| 632 | + expect(innerRun.type.name).toBe('run'); |
| 633 | + expect(innerRun.attrs.runProperties).toMatchObject({ fontFamily }); |
| 634 | + }); |
| 635 | + |
| 636 | + it('does not produce an empty left run when cursor is at the start of a run', () => { |
| 637 | + const fontFamily = { |
| 638 | + ascii: 'Courier New', |
| 639 | + eastAsia: 'Courier New', |
| 640 | + hAnsi: 'Courier New', |
| 641 | + cs: 'Courier New', |
| 642 | + }; |
| 643 | + const textStyleMark = schema.marks.textStyle.create({ |
| 644 | + fontFamily: 'Courier New', |
| 645 | + fontSize: '12pt', |
| 646 | + }); |
| 647 | + const styledText = schema.text('Hello', [textStyleMark]); |
| 648 | + const run = schema.nodes.run.create({ runProperties: { fontFamily } }, styledText); |
| 649 | + const paragraph = schema.nodes.paragraph.create(null, [run]); |
| 650 | + const doc = schema.nodes.doc.create(null, [paragraph]); |
| 651 | + |
| 652 | + // Cursor at the very start of the run content: doc(1) + paragraph(1) = 2 |
| 653 | + const cursorPos = 2; |
| 654 | + const nextState = EditorState.create({ |
| 655 | + schema, |
| 656 | + doc, |
| 657 | + plugins: editor.state.plugins, |
| 658 | + selection: TextSelection.create(doc, cursorPos), |
| 659 | + }); |
| 660 | + editor.setState(nextState); |
| 661 | + |
| 662 | + editor.commands.insertStructuredContentInline({ |
| 663 | + text: 'Field', |
| 664 | + attrs: { group: 'header' }, |
| 665 | + }); |
| 666 | + |
| 667 | + const updatedParagraph = editor.state.doc.firstChild; |
| 668 | + |
| 669 | + // Should be: structuredContent, run — no empty run before the SDT |
| 670 | + const childTypes = []; |
| 671 | + updatedParagraph.forEach((child) => { |
| 672 | + childTypes.push(child.type.name); |
| 673 | + if (child.type.name === 'run') { |
| 674 | + expect(child.content.size).toBeGreaterThan(0); |
| 675 | + } |
| 676 | + }); |
| 677 | + |
| 678 | + expect(childTypes).toEqual(['structuredContent', 'run']); |
| 679 | + expect(updatedParagraph.textContent).toBe('FieldHello'); |
| 680 | + }); |
| 681 | + |
| 682 | + it('removes selected text when inserting with a ranged selection inside a run', () => { |
| 683 | + const fontFamily = { |
| 684 | + ascii: 'Courier New', |
| 685 | + eastAsia: 'Courier New', |
| 686 | + hAnsi: 'Courier New', |
| 687 | + cs: 'Courier New', |
| 688 | + }; |
| 689 | + const textStyleMark = schema.marks.textStyle.create({ |
| 690 | + fontFamily: 'Courier New', |
| 691 | + fontSize: '12pt', |
| 692 | + }); |
| 693 | + const styledText = schema.text('This is some text', [textStyleMark]); |
| 694 | + const run = schema.nodes.run.create({ runProperties: { fontFamily } }, styledText); |
| 695 | + const paragraph = schema.nodes.paragraph.create(null, [run]); |
| 696 | + const doc = schema.nodes.doc.create(null, [paragraph]); |
| 697 | + |
| 698 | + // Select "some" (positions 10..14 inside run content starting at 2: "some" = chars 8..12) |
| 699 | + const selFrom = 2 + 'This is '.length; // 10 |
| 700 | + const selTo = 2 + 'This is some'.length; // 14 |
| 701 | + const nextState = EditorState.create({ |
| 702 | + schema, |
| 703 | + doc, |
| 704 | + plugins: editor.state.plugins, |
| 705 | + selection: TextSelection.create(doc, selFrom, selTo), |
| 706 | + }); |
| 707 | + editor.setState(nextState); |
| 708 | + |
| 709 | + editor.commands.insertStructuredContentInline({ |
| 710 | + text: 'Inline Header', |
| 711 | + attrs: { group: 'header' }, |
| 712 | + }); |
| 713 | + |
| 714 | + const updatedParagraph = editor.state.doc.firstChild; |
| 715 | + |
| 716 | + // "some" should be removed; remaining text is "This is " + "Inline Header" + " text" |
| 717 | + expect(updatedParagraph.textContent).toBe('This is Inline Header text'); |
| 718 | + }); |
| 719 | + |
| 720 | + it('does not produce an empty right run when cursor is at the end of a run', () => { |
| 721 | + const fontFamily = { |
| 722 | + ascii: 'Courier New', |
| 723 | + eastAsia: 'Courier New', |
| 724 | + hAnsi: 'Courier New', |
| 725 | + cs: 'Courier New', |
| 726 | + }; |
| 727 | + const textStyleMark = schema.marks.textStyle.create({ |
| 728 | + fontFamily: 'Courier New', |
| 729 | + fontSize: '12pt', |
| 730 | + }); |
| 731 | + const styledText = schema.text('Hello', [textStyleMark]); |
| 732 | + const run = schema.nodes.run.create({ runProperties: { fontFamily } }, styledText); |
| 733 | + const paragraph = schema.nodes.paragraph.create(null, [run]); |
| 734 | + const doc = schema.nodes.doc.create(null, [paragraph]); |
| 735 | + |
| 736 | + // Cursor at the very end of the run content: doc(1) + paragraph(1) + "Hello"(5) = 7 |
| 737 | + const cursorPos = 2 + 'Hello'.length; // 7 |
| 738 | + const nextState = EditorState.create({ |
| 739 | + schema, |
| 740 | + doc, |
| 741 | + plugins: editor.state.plugins, |
| 742 | + selection: TextSelection.create(doc, cursorPos), |
| 743 | + }); |
| 744 | + editor.setState(nextState); |
| 745 | + |
| 746 | + editor.commands.insertStructuredContentInline({ |
| 747 | + text: 'Field', |
| 748 | + attrs: { group: 'header' }, |
| 749 | + }); |
| 750 | + |
| 751 | + const updatedParagraph = editor.state.doc.firstChild; |
| 752 | + |
| 753 | + // Should be: run, structuredContent — no empty run after the SDT |
| 754 | + const childTypes = []; |
| 755 | + updatedParagraph.forEach((child) => { |
| 756 | + childTypes.push(child.type.name); |
| 757 | + if (child.type.name === 'run') { |
| 758 | + expect(child.content.size).toBeGreaterThan(0); |
| 759 | + } |
| 760 | + }); |
| 761 | + |
| 762 | + expect(childTypes).toEqual(['run', 'structuredContent']); |
| 763 | + expect(updatedParagraph.textContent).toBe('HelloField'); |
| 764 | + }); |
| 765 | + |
| 766 | + it('places the cursor right after the inserted SDT', () => { |
| 767 | + const fontFamily = { |
| 768 | + ascii: 'Courier New', |
| 769 | + eastAsia: 'Courier New', |
| 770 | + hAnsi: 'Courier New', |
| 771 | + cs: 'Courier New', |
| 772 | + }; |
| 773 | + const textStyleMark = schema.marks.textStyle.create({ |
| 774 | + fontFamily: 'Courier New', |
| 775 | + fontSize: '12pt', |
| 776 | + }); |
| 777 | + const styledText = schema.text('Hello World', [textStyleMark]); |
| 778 | + const run = schema.nodes.run.create({ runProperties: { fontFamily } }, styledText); |
| 779 | + const paragraph = schema.nodes.paragraph.create(null, [run]); |
| 780 | + const doc = schema.nodes.doc.create(null, [paragraph]); |
| 781 | + |
| 782 | + // Cursor after "Hello " (6 chars): doc(1) + paragraph(1) + 6 = 8 |
| 783 | + const cursorPos = 2 + 'Hello '.length; // 8 |
| 784 | + const nextState = EditorState.create({ |
| 785 | + schema, |
| 786 | + doc, |
| 787 | + plugins: editor.state.plugins, |
| 788 | + selection: TextSelection.create(doc, cursorPos), |
| 789 | + }); |
| 790 | + editor.setState(nextState); |
| 791 | + |
| 792 | + editor.commands.insertStructuredContentInline({ |
| 793 | + text: 'Field', |
| 794 | + attrs: { group: 'header' }, |
| 795 | + }); |
| 796 | + |
| 797 | + const updatedState = editor.state; |
| 798 | + const updatedParagraph = updatedState.doc.firstChild; |
| 799 | + |
| 800 | + // Find the SDT node and compute where the cursor should be (right after it) |
| 801 | + let sdtEnd = null; |
| 802 | + let offset = 1; // paragraph opens at pos 1 |
| 803 | + updatedParagraph.forEach((child) => { |
| 804 | + if (child.type.name === 'structuredContent') { |
| 805 | + sdtEnd = offset + child.nodeSize; |
| 806 | + } |
| 807 | + offset += child.nodeSize; |
| 808 | + }); |
| 809 | + |
| 810 | + expect(sdtEnd).not.toBeNull(); |
| 811 | + expect(updatedState.selection.from).toBe(sdtEnd); |
| 812 | + expect(updatedState.selection.to).toBe(sdtEnd); |
| 813 | + }); |
| 814 | +}); |
| 815 | + |
559 | 816 | describe('StructuredContent ID Validation', () => { |
560 | 817 | let editor; |
561 | 818 |
|
|
0 commit comments