diff --git a/.changelog/20260402230801_ck_19127_pfo_continued_nested_lists_with_paragraphs.md b/.changelog/20260402230801_ck_19127_pfo_continued_nested_lists_with_paragraphs.md new file mode 100644 index 00000000000..7122e6f40b5 --- /dev/null +++ b/.changelog/20260402230801_ck_19127_pfo_continued_nested_lists_with_paragraphs.md @@ -0,0 +1,13 @@ +--- +type: Fix + +scope: + - ckeditor5-paste-from-office + +closes: + - ckeditor/ckeditor5#19127 +--- + +Fixed incorrect structure of nested lists pasted from Word when plain paragraphs appear between nested list items. + +Previously, all paragraphs were placed after the nested list items instead of between them. The fix also ensures that interrupted nested ordered lists continue numbering correctly across the paragraph breaks. diff --git a/packages/ckeditor5-paste-from-office/src/filters/list.ts b/packages/ckeditor5-paste-from-office/src/filters/list.ts index 27abd0344b7..a7cf49081ff 100644 --- a/packages/ckeditor5-paste-from-office/src/filters/list.ts +++ b/packages/ckeditor5-paste-from-office/src/filters/list.ts @@ -51,7 +51,13 @@ export function transformListItemLikeElementsIntoLists( return; } - const encounteredLists: Record = {}; + // Tracks how many items have been added to each encountered list, keyed by indent level and list ID. + // Used to set the `start` attribute on a new
    when a list at a given indent is interrupted by + // a non-list block (e.g. a paragraph) and then resumed. + // Structure: [ { [listId:level]: itemCount } ] (array index is the indent level) + // Example: [ { '1:1': 3 }, { '0:2': 2 } ] means the top-level list (id=1) has 3 items, + // and the nested list (id=0) has 2 items so the next continuation should start at 3. + const encounteredLists: Array> = []; const stack: ListStack = []; @@ -62,10 +68,18 @@ export function transformListItemLikeElementsIntoLists( if ( !isListContinuation( itemLikeElement ) ) { applyIndentationToTopLevelList( writer, stack, topLevelListInfo ); topLevelListInfo = createTopLevelListInfo(); + // Clear counters for nested levels only. The top-level counter (index 0) must survive + // so that a resumed top-level list (same id, interrupted by a paragraph) can still + // receive the correct `start` attribute. Nested counters must be cleared because + // a sibling top-level list item should not inherit the nested list counts from + // a previous top-level list item. + encounteredLists.length = 1; stack.length = 0; } - // Combined list ID for addressing encounter lists counters. + // Key used to look up this list inside `encounteredLists[indent]`. + // Combines the list id and level so that two different lists at the same indent + // level (e.g. first an
      , then a
        after a paragraph break) don't share a counter. const originalListId = `${ itemLikeElement.id }:${ itemLikeElement.indent }`; // Normalized list item indentation. @@ -73,11 +87,16 @@ export function transformListItemLikeElementsIntoLists( // Trimming of the list stack on list ID change. if ( indent < stack.length && stack[ indent ].id !== itemLikeElement.id ) { + // A different list started at this indent level — counters for this level and deeper + // belong to the previous list context and must not carry over. + encounteredLists.length = indent; stack.length = indent; } // Trimming of the list stack on lower indent list encountered. if ( indent < stack.length - 1 ) { + // We jumped back to a shallower indent — any counters deeper than the new top are stale. + encounteredLists.length = indent + 1; stack.length = indent + 1; } else { @@ -85,14 +104,15 @@ export function transformListItemLikeElementsIntoLists( // Create a new OL/UL if required (greater indent or different list type). if ( indent > stack.length - 1 || stack[ indent ].listElement.name != listStyle.type ) { - // Check if there is some start index to set from a previous list. + // If this list was seen before at this indent (i.e. it was interrupted by a non-list block + // and is now resuming), set `start` so the numbering continues from where it left off. if ( - indent == 0 && listStyle.type == 'ol' && itemLikeElement.id !== undefined && - encounteredLists[ originalListId ] + encounteredLists[ indent ] && + encounteredLists[ indent ][ originalListId ] ) { - listStyle.startIndex = encounteredLists[ originalListId ]; + listStyle.startIndex = encounteredLists[ indent ][ originalListId ]; } const listElement = createNewEmptyList( listStyle, writer, hasMultiLevelListPlugin ); @@ -116,9 +136,15 @@ export function transformListItemLikeElementsIntoLists( listItemElements: [] }; - // Prepare list counter for start index. - if ( indent == 0 && itemLikeElement.id !== undefined ) { - encounteredLists[ originalListId ] = listStyle.startIndex || 1; + // Record the starting value for this list so that if it is interrupted and resumed later, + // the continuation list can pick up numbering from the right value. + // For a fresh list `listStyle.startIndex` is undefined, so we fall back to 1. + if ( itemLikeElement.id !== undefined ) { + if ( !encounteredLists[ indent ] ) { + encounteredLists[ indent ] = {}; + } + + encounteredLists[ indent ][ originalListId ] = listStyle.startIndex || 1; } } } @@ -133,9 +159,9 @@ export function transformListItemLikeElementsIntoLists( writer.appendChild( listItem, stack[ indent ].listElement ); stack[ indent ].listItemElements.push( listItem ); - // Increment list counter. - if ( indent == 0 && itemLikeElement.id !== undefined ) { - encounteredLists[ originalListId ]++; + // Count the item so that `encounteredLists` always holds the value the *next* continuation list should start at. + if ( itemLikeElement.id !== undefined && encounteredLists[ indent ] ) { + encounteredLists[ indent ][ originalListId ]++; } // Append list block to LI. @@ -152,13 +178,24 @@ export function transformListItemLikeElementsIntoLists( // Other blocks in a list item. const stackItem = stack.find( stackItem => stackItem.marginLeft == itemLikeElement.marginLeft ); - // This might be a paragraph that has known margin, but it is not a real list block. + // A non-list block (e.g. a plain paragraph) whose margin-left matches one of the active list items. + // The match is done by margin-left value — nested list items sometimes have no explicit margin-left, + // so the match typically resolves to an ancestor
      • rather than the deepest one. if ( stackItem ) { const listItems = stackItem.listItemElements; // Append block to LI. writer.appendChild( itemLikeElement.element, listItems[ listItems.length - 1 ] ); writer.removeStyle( 'margin-left', itemLikeElement.element ); + + // Trim the stack to the matched level. Without this, the next nested list item would + // be appended to the existing nested
          /
            that appears *before* this paragraph + // in the DOM, instead of creating a new one *after* it. + stack.length = stack.indexOf( stackItem ) + 1; + // Clear counters only for levels deeper than the direct children of the matched
          • . + // The counter at `stack.length` must survive so the next nested list can continue + // numbering from where it left off (e.g.
              ). + encounteredLists.length = stack.length + 1; } else { stack.length = 0; } diff --git a/packages/ckeditor5-paste-from-office/tests/_data/list/index.js b/packages/ckeditor5-paste-from-office/tests/_data/list/index.js index 7eee5876a0a..25479ac83a3 100644 --- a/packages/ckeditor5-paste-from-office/tests/_data/list/index.js +++ b/packages/ckeditor5-paste-from-office/tests/_data/list/index.js @@ -23,6 +23,7 @@ import mixedElements from './mixed-elements/input.word.html'; import multiBlockBlockAfter from './multi-block-block-after/input.word.html'; import listContinuation from './list-continuation/input.word2016.html'; import indentBlockList from './indent-block-list/input.word.html'; +import nestedContinued from './nested-continued/input.word.html'; import simpleNormalized from './simple/normalized.word2016.html'; import styledNormalized from './styled/normalized.word2016.html'; @@ -43,6 +44,7 @@ import mixedElementsNormalized from './mixed-elements/normalized.word.html'; import multiBlockBlockAfterNormalized from './multi-block-block-after/normalized.word.html'; import listContinuationNormalized from './list-continuation/normalized.word2016.html'; import indentBlockListNormalized from './indent-block-list/normalized.word.html'; +import nestedContinuedNormalized from './nested-continued/normalized.word.html'; import simpleModel from './simple/model.word2016.html'; import styledModel from './styled/model.word2016.html'; @@ -63,6 +65,7 @@ import mixedElementsModel from './mixed-elements/model.word.html'; import multiBlockBlockAfterModel from './multi-block-block-after/model.word.html'; import listContinuationModel from './list-continuation/model.word2016.html'; import indentBlockListModel from './indent-block-list/model.word.html'; +import nestedContinuedModel from './nested-continued/model.word.html'; export const fixtures = { input: { @@ -84,7 +87,8 @@ export const fixtures = { mixedElements, multiBlockBlockAfter, listContinuation, - indentBlockList + indentBlockList, + nestedContinued }, normalized: { simple: simpleNormalized, @@ -105,7 +109,8 @@ export const fixtures = { mixedElements: mixedElementsNormalized, multiBlockBlockAfter: multiBlockBlockAfterNormalized, listContinuation: listContinuationNormalized, - indentBlockList: indentBlockListNormalized + indentBlockList: indentBlockListNormalized, + nestedContinued: nestedContinuedNormalized }, model: { simple: simpleModel, @@ -126,7 +131,8 @@ export const fixtures = { mixedElements: mixedElementsModel, multiBlockBlockAfter: multiBlockBlockAfterModel, listContinuation: listContinuationModel, - indentBlockList: indentBlockListModel + indentBlockList: indentBlockListModel, + nestedContinued: nestedContinuedModel } }; diff --git a/packages/ckeditor5-paste-from-office/tests/_data/list/nested-continued/discontinued-list.docx b/packages/ckeditor5-paste-from-office/tests/_data/list/nested-continued/discontinued-list.docx new file mode 100644 index 00000000000..57e421463ec Binary files /dev/null and b/packages/ckeditor5-paste-from-office/tests/_data/list/nested-continued/discontinued-list.docx differ diff --git a/packages/ckeditor5-paste-from-office/tests/_data/list/nested-continued/input.word.html b/packages/ckeditor5-paste-from-office/tests/_data/list/nested-continued/input.word.html new file mode 100644 index 00000000000..97dba127371 --- /dev/null +++ b/packages/ckeditor5-paste-from-office/tests/_data/list/nested-continued/input.word.html @@ -0,0 +1,1028 @@ + + + + + + + + + + + + + + + + + + + +

              Lorem +ipsum
              +
              +

              + +

              1.        +Item 1

              + +

               

              + +

              ·      +Item 2

              + +

              Paragraph 1

              + +

              ·      +Item 3

              + +

              Paragraph 2

              + +

              ·      +Item 4

              + +

              Paragraph 3

              + +

              ·      +Item 5

              + +

              Paragraph 4

              + + + + + diff --git a/packages/ckeditor5-paste-from-office/tests/_data/list/nested-continued/model.word.html b/packages/ckeditor5-paste-from-office/tests/_data/list/nested-continued/model.word.html new file mode 100644 index 00000000000..5622cf6f2a1 --- /dev/null +++ b/packages/ckeditor5-paste-from-office/tests/_data/list/nested-continued/model.word.html @@ -0,0 +1,11 @@ +<$text bold="true">Lorem ipsum +Item 1 + +Item 2 +Paragraph 1 +Item 3 +Paragraph 2 +Item 4 +Paragraph 3 +Item 5 +Paragraph 4 diff --git a/packages/ckeditor5-paste-from-office/tests/_data/list/nested-continued/normalized.word.html b/packages/ckeditor5-paste-from-office/tests/_data/list/nested-continued/normalized.word.html new file mode 100644 index 00000000000..fc4a7ae0f97 --- /dev/null +++ b/packages/ckeditor5-paste-from-office/tests/_data/list/nested-continued/normalized.word.html @@ -0,0 +1,31 @@ +

              Lorem ipsum

              +
                +
              1. +

                Item 1

                +

                +
                  +
                • +

                  Item 2

                  +
                • +
                +

                Paragraph 1

                +
                  +
                • +

                  Item 3

                  +
                • +
                +

                Paragraph 2

                +
                  +
                • +

                  Item 4

                  +
                • +
                +

                Paragraph 3

                +
                  +
                • +

                  Item 5

                  +
                • +
                +

                Paragraph 4

                +
              2. +
              diff --git a/packages/ckeditor5-paste-from-office/tests/filters/list.js b/packages/ckeditor5-paste-from-office/tests/filters/list.js index 8bad84938a6..de67972e161 100644 --- a/packages/ckeditor5-paste-from-office/tests/filters/list.js +++ b/packages/ckeditor5-paste-from-office/tests/filters/list.js @@ -634,6 +634,91 @@ describe( 'PasteFromOffice - filters', () => { } ); } } ); + + describe( 'interrupted nested lists', () => { + const level1 = 'style="mso-list:l1 level1 lfo2;margin-left:24px"'; + const level2 = 'style="mso-list:l0 level2 lfo1"'; + const para = 'style="margin-left:24px"'; + + it( 'places a non-list block after the nested list, not inside it', () => { + const html = + `

              Item 1

              ` + + `

              Item 2

              ` + + `

              Paragraph 1

              ` + + `

              Item 3

              `; + const view = htmlDataProcessor.toView( html ); + + transformListItemLikeElementsIntoLists( view, '' ); + + // margin-left is lifted from

              to

                — Item 1's

                no longer has it. + expect( _stringifyView( view ) ).to.equal( + '

                  ' + + '
                1. ' + + '

                  Item 1

                  ' + + '
                    ' + + `
                  1. Item 2

                  2. ` + + '
                  ' + + '

                  Paragraph 1

                  ' + + '
                    ' + + `
                  1. Item 3

                  2. ` + + '
                  ' + + '
                2. ' + + '
                ' + ); + } ); + + it( 'places multiple non-list blocks each after their respective nested list item', () => { + const html = + `

                Item 1

                ` + + `

                Item 2

                ` + + `

                Paragraph 1

                ` + + `

                Item 3

                ` + + `

                Paragraph 2

                ` + + `

                Item 4

                ` + + `

                Paragraph 3

                `; + const view = htmlDataProcessor.toView( html ); + + transformListItemLikeElementsIntoLists( view, '' ); + + // margin-left is lifted from

                to

                  — Item 1's

                  no longer has it. + expect( _stringifyView( view ) ).to.equal( + '

                    ' + + '
                  1. ' + + '

                    Item 1

                    ' + + '
                      ' + + `
                    1. Item 2

                    2. ` + + '
                    ' + + '

                    Paragraph 1

                    ' + + '
                      ' + + `
                    1. Item 3

                    2. ` + + '
                    ' + + '

                    Paragraph 2

                    ' + + '
                      ' + + `
                    1. Item 4

                    2. ` + + '
                    ' + + '

                    Paragraph 3

                    ' + + '
                  2. ' + + '
                  ' + ); + } ); + + it( 'does not carry over nested list numbering into a sibling top-level list item', () => { + const html = + `

                  Item A

                  ` + + `

                  Item A.1

                  ` + + `

                  Item A.2

                  ` + + `

                  Item B

                  ` + + `

                  Item B.1

                  `; + const view = htmlDataProcessor.toView( html ); + + transformListItemLikeElementsIntoLists( view, '' ); + + const result = _stringifyView( view ); + + // The nested list under Item B must start at 1, not continue from Item A's nested list. + expect( result ).to.contain( '
                  1. Item B.1

                  ' ); + } ); + } ); } ); } );