From 4810a7f37a1eee66f564c19fbfdcf0832482e4c7 Mon Sep 17 00:00:00 2001 From: jiuqingsong Date: Tue, 21 Apr 2026 14:05:48 -0700 Subject: [PATCH 1/2] Fix 422986 --- .../processors/listItemProcessor.ts | 98 +++++++++---------- .../processors/listItemProcessorTest.ts | 30 +++++- .../word/processPastedContentFromWacTest.ts | 62 +++++++++--- 3 files changed, 117 insertions(+), 73 deletions(-) diff --git a/packages/roosterjs-content-model-dom/lib/domToModel/processors/listItemProcessor.ts b/packages/roosterjs-content-model-dom/lib/domToModel/processors/listItemProcessor.ts index 07d3f1c178a0..52a547ab5ed6 100644 --- a/packages/roosterjs-content-model-dom/lib/domToModel/processors/listItemProcessor.ts +++ b/packages/roosterjs-content-model-dom/lib/domToModel/processors/listItemProcessor.ts @@ -1,4 +1,5 @@ import { createListItem } from '../../modelApi/creators/createListItem'; +import { createListLevel } from '../../modelApi/creators/createListLevel'; import { parseFormat } from '../utils/parseFormat'; import { stackFormat } from '../utils/stackFormat'; import type { ElementProcessor } from 'roosterjs-content-model-types'; @@ -8,60 +9,51 @@ import type { ElementProcessor } from 'roosterjs-content-model-types'; */ export const listItemProcessor: ElementProcessor = (group, element, context) => { const { listFormat } = context; + const listParent = listFormat.listParent ?? group; - if (listFormat.listParent && listFormat.levels.length > 0) { - stackFormat( - context, - { - segment: 'shallowCloneForBlock', - }, - () => { - parseFormat( - element, - context.formatParsers.segmentOnBlock, - context.segmentFormat, - context - ); - - const listItem = createListItem(listFormat.levels, context.segmentFormat); - parseFormat( - element, - context.formatParsers.listItemElement, - listItem.format, - context - ); - - listFormat.listParent!.blocks.push(listItem); - - parseFormat( - element, - context.formatParsers.listItemThread, - listItem.levels[listItem.levels.length - 1].format, - context - ); - - context.elementProcessors.child(listItem, element, context); - - const firstChild = listItem.blocks[0]; + if (listFormat.levels.length == 0) { + listFormat.levels.push(createListLevel('UL', context.blockFormat)); + } - if ( - listItem.blocks.length == 1 && - firstChild.blockType == 'Paragraph' && - firstChild.isImplicit - ) { - Object.assign(listItem.format, firstChild.format); - firstChild.format = {}; - } + listFormat.listParent = listParent; + + stackFormat( + context, + { + segment: 'shallowCloneForBlock', + }, + () => { + parseFormat( + element, + context.formatParsers.segmentOnBlock, + context.segmentFormat, + context + ); + + const listItem = createListItem(listFormat.levels, context.segmentFormat); + parseFormat(element, context.formatParsers.listItemElement, listItem.format, context); + + listParent.blocks.push(listItem); + + parseFormat( + element, + context.formatParsers.listItemThread, + listItem.levels[listItem.levels.length - 1].format, + context + ); + + context.elementProcessors.child(listItem, element, context); + + const firstChild = listItem.blocks[0]; + + if ( + listItem.blocks.length == 1 && + firstChild.blockType == 'Paragraph' && + firstChild.isImplicit + ) { + Object.assign(listItem.format, firstChild.format); + firstChild.format = {}; } - ); - } else { - const currentBlocks = listFormat.listParent?.blocks; - const lastItem = currentBlocks?.[currentBlocks?.length - 1]; - - context.elementProcessors['*']( - lastItem?.blockType == 'BlockGroup' ? lastItem : group, - element, - context - ); - } + } + ); }; diff --git a/packages/roosterjs-content-model-dom/test/domToModel/processors/listItemProcessorTest.ts b/packages/roosterjs-content-model-dom/test/domToModel/processors/listItemProcessorTest.ts index a39fc7173e7e..2811d3c7859a 100644 --- a/packages/roosterjs-content-model-dom/test/domToModel/processors/listItemProcessorTest.ts +++ b/packages/roosterjs-content-model-dom/test/domToModel/processors/listItemProcessorTest.ts @@ -26,10 +26,21 @@ describe('listItemProcessor', () => { blocks: [ { blockType: 'BlockGroup', - blockGroupType: 'General', - element: li, + blockGroupType: 'ListItem', blocks: [], format: {}, + levels: [ + { + listType: 'UL', + format: {}, + dataset: {}, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, }, ], }); @@ -381,10 +392,21 @@ describe('listItemProcessor without format handlers', () => { blocks: [ { blockType: 'BlockGroup', - blockGroupType: 'General', - element: li, + blockGroupType: 'ListItem', blocks: [], format: {}, + levels: [ + { + listType: 'UL', + format: {}, + dataset: {}, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, }, ], }); diff --git a/packages/roosterjs-content-model-plugins/test/paste/word/processPastedContentFromWacTest.ts b/packages/roosterjs-content-model-plugins/test/paste/word/processPastedContentFromWacTest.ts index 2a046e3a208a..50d8e1dabc15 100644 --- a/packages/roosterjs-content-model-plugins/test/paste/word/processPastedContentFromWacTest.ts +++ b/packages/roosterjs-content-model-plugins/test/paste/word/processPastedContentFromWacTest.ts @@ -2043,17 +2043,16 @@ describe('wordOnlineHandler', () => { // .test // .test // .test - it('shuold process html properly, when list items are not in side ul tag', () => { + it('should process html properly, when list items are not in side ul tag', () => { runTest( '
  • test

  • test

  • test

  • ', - '
  • test

  • test

  • test

  • ', + '', { blockGroupType: 'Document', blocks: [ { blockType: 'BlockGroup', - blockGroupType: 'General', - element: jasmine.anything() as any, + blockGroupType: 'ListItem', blocks: [ { blockType: 'Paragraph', @@ -2064,12 +2063,17 @@ describe('wordOnlineHandler', () => { decorator: { tagName: 'p', format: {} }, }, ], + levels: [{ listType: 'UL', format: {}, dataset: {} }], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, format: {}, }, { blockType: 'BlockGroup', - blockGroupType: 'General', - element: jasmine.anything() as any, + blockGroupType: 'ListItem', blocks: [ { blockType: 'Paragraph', @@ -2080,12 +2084,17 @@ describe('wordOnlineHandler', () => { decorator: { tagName: 'p', format: {} }, }, ], + levels: [{ listType: 'UL', format: {}, dataset: {} }], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, format: {}, }, { blockType: 'BlockGroup', - blockGroupType: 'General', - element: jasmine.anything() as any, + blockGroupType: 'ListItem', blocks: [ { blockType: 'Paragraph', @@ -2096,6 +2105,12 @@ describe('wordOnlineHandler', () => { decorator: { tagName: 'p', format: {} }, }, ], + levels: [{ listType: 'UL', format: {}, dataset: {} }], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, format: {}, }, ], @@ -2345,17 +2360,16 @@ describe('wordOnlineHandler', () => { // result: // 1. text // 2. text - it('shuold process html properly, if list item in a ListContainerWrapper are not inside ol ', () => { + it('should process html properly, if list item in a ListContainerWrapper are not inside ol ', () => { runTest( '
    1. test

    2. test

    3. test

    4. ', - '
    5. test

    6. test

    7. test

    8. ', + '', { blockGroupType: 'Document', blocks: [ { blockType: 'BlockGroup', - blockGroupType: 'General', - element: jasmine.anything() as any, + blockGroupType: 'ListItem', blocks: [ { blockType: 'Paragraph', @@ -2366,12 +2380,17 @@ describe('wordOnlineHandler', () => { decorator: { tagName: 'p', format: {} }, }, ], + levels: [{ listType: 'UL', format: {}, dataset: {} }], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, format: {}, }, { blockType: 'BlockGroup', - blockGroupType: 'General', - element: jasmine.anything() as any, + blockGroupType: 'ListItem', blocks: [ { blockType: 'Paragraph', @@ -2382,12 +2401,17 @@ describe('wordOnlineHandler', () => { decorator: { tagName: 'p', format: {} }, }, ], + levels: [{ listType: 'UL', format: {}, dataset: {} }], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, format: {}, }, { blockType: 'BlockGroup', - blockGroupType: 'General', - element: jasmine.anything() as any, + blockGroupType: 'ListItem', blocks: [ { blockType: 'Paragraph', @@ -2398,6 +2422,12 @@ describe('wordOnlineHandler', () => { decorator: { tagName: 'p', format: {} }, }, ], + levels: [{ listType: 'UL', format: {}, dataset: {} }], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, format: {}, }, ], From d332e31d2004c47b6f5168175ed238606ab91b68 Mon Sep 17 00:00:00 2001 From: jiuqingsong Date: Tue, 21 Apr 2026 14:33:58 -0700 Subject: [PATCH 2/2] improve --- .../processors/listItemProcessor.ts | 91 +++++++++++-------- .../domToModel/processors/listProcessor.ts | 7 +- .../processors/childProcessorTest.ts | 1 + .../test/endToEndTest.ts | 37 ++++++++ .../word/processPastedContentFromWacTest.ts | 10 +- .../lib/context/DomToModelFormatContext.ts | 6 ++ 6 files changed, 107 insertions(+), 45 deletions(-) diff --git a/packages/roosterjs-content-model-dom/lib/domToModel/processors/listItemProcessor.ts b/packages/roosterjs-content-model-dom/lib/domToModel/processors/listItemProcessor.ts index 52a547ab5ed6..61ccf92bd380 100644 --- a/packages/roosterjs-content-model-dom/lib/domToModel/processors/listItemProcessor.ts +++ b/packages/roosterjs-content-model-dom/lib/domToModel/processors/listItemProcessor.ts @@ -9,51 +9,70 @@ import type { ElementProcessor } from 'roosterjs-content-model-types'; */ export const listItemProcessor: ElementProcessor = (group, element, context) => { const { listFormat } = context; - const listParent = listFormat.listParent ?? group; + const originalListParent = listFormat.listParent; + let shouldPopListLevel = false; - if (listFormat.levels.length == 0) { - listFormat.levels.push(createListLevel('UL', context.blockFormat)); - } + try { + listFormat.listParent = listFormat.listParent ?? group; + + const listParent = listFormat.listParent; - listFormat.listParent = listParent; - - stackFormat( - context, - { - segment: 'shallowCloneForBlock', - }, - () => { - parseFormat( - element, - context.formatParsers.segmentOnBlock, - context.segmentFormat, - context + if (listFormat.levels.length == 0) { + listFormat.levels.push( + createListLevel(listFormat.potentialListType || 'UL', context.blockFormat) ); + shouldPopListLevel = true; + } - const listItem = createListItem(listFormat.levels, context.segmentFormat); - parseFormat(element, context.formatParsers.listItemElement, listItem.format, context); + stackFormat( + context, + { + segment: 'shallowCloneForBlock', + }, + () => { + parseFormat( + element, + context.formatParsers.segmentOnBlock, + context.segmentFormat, + context + ); - listParent.blocks.push(listItem); + const listItem = createListItem(listFormat.levels, context.segmentFormat); + parseFormat( + element, + context.formatParsers.listItemElement, + listItem.format, + context + ); - parseFormat( - element, - context.formatParsers.listItemThread, - listItem.levels[listItem.levels.length - 1].format, - context - ); + listParent.blocks.push(listItem); - context.elementProcessors.child(listItem, element, context); + parseFormat( + element, + context.formatParsers.listItemThread, + listItem.levels[listItem.levels.length - 1].format, + context + ); - const firstChild = listItem.blocks[0]; + context.elementProcessors.child(listItem, element, context); - if ( - listItem.blocks.length == 1 && - firstChild.blockType == 'Paragraph' && - firstChild.isImplicit - ) { - Object.assign(listItem.format, firstChild.format); - firstChild.format = {}; + const firstChild = listItem.blocks[0]; + + if ( + listItem.blocks.length == 1 && + firstChild.blockType == 'Paragraph' && + firstChild.isImplicit + ) { + Object.assign(listItem.format, firstChild.format); + firstChild.format = {}; + } } + ); + } finally { + if (shouldPopListLevel) { + listFormat.levels.pop(); } - ); + + listFormat.listParent = originalListParent; + } }; diff --git a/packages/roosterjs-content-model-dom/lib/domToModel/processors/listProcessor.ts b/packages/roosterjs-content-model-dom/lib/domToModel/processors/listProcessor.ts index 92517b7e51bc..e6334346ae51 100644 --- a/packages/roosterjs-content-model-dom/lib/domToModel/processors/listProcessor.ts +++ b/packages/roosterjs-content-model-dom/lib/domToModel/processors/listProcessor.ts @@ -18,10 +18,8 @@ export const listProcessor: ElementProcessor { - const level: ContentModelListLevel = createListLevel( - element.tagName as 'OL' | 'UL', - context.blockFormat - ); + const tagName = element.tagName as 'OL' | 'UL'; + const level: ContentModelListLevel = createListLevel(tagName, context.blockFormat); const { listFormat } = context; parseFormat(element, context.formatParsers.dataset, level.dataset, context); @@ -31,6 +29,7 @@ export const listProcessor: ElementProcessor { levels: [], listParent: undefined, threadItemCounts: [1], + potentialListType: 'OL', }); }); diff --git a/packages/roosterjs-content-model-dom/test/endToEndTest.ts b/packages/roosterjs-content-model-dom/test/endToEndTest.ts index 27fc73fa576f..8d55177d6929 100644 --- a/packages/roosterjs-content-model-dom/test/endToEndTest.ts +++ b/packages/roosterjs-content-model-dom/test/endToEndTest.ts @@ -3027,4 +3027,41 @@ describe('End to end test for DOM => Model => DOM/TEXT', () => { 'www.bing.com' ); }); + + it('LI without UL followed by other blocks', () => { + runTest( + '
    9. test
    10. other
      ', + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [{ segmentType: 'Text', text: 'test', format: {} }], + format: {}, + isImplicit: true, + }, + ], + levels: [{ listType: 'UL', format: {}, dataset: {} }], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, + format: {}, + }, + { + blockType: 'Paragraph', + segments: [{ segmentType: 'Text', text: 'other', format: {} }], + format: {}, + }, + ], + }, + 'test\r\nother', + '
      • test
      other
      ' + ); + }); }); diff --git a/packages/roosterjs-content-model-plugins/test/paste/word/processPastedContentFromWacTest.ts b/packages/roosterjs-content-model-plugins/test/paste/word/processPastedContentFromWacTest.ts index 50d8e1dabc15..d254b7198d6a 100644 --- a/packages/roosterjs-content-model-plugins/test/paste/word/processPastedContentFromWacTest.ts +++ b/packages/roosterjs-content-model-plugins/test/paste/word/processPastedContentFromWacTest.ts @@ -2043,7 +2043,7 @@ describe('wordOnlineHandler', () => { // .test // .test // .test - it('should process html properly, when list items are not in side ul tag', () => { + it('should process html properly, when list items are not inside ul tag', () => { runTest( '
      • test

      • test

      • test

      • ', '
        • test

        • test

        • test

        ', @@ -2363,7 +2363,7 @@ describe('wordOnlineHandler', () => { it('should process html properly, if list item in a ListContainerWrapper are not inside ol ', () => { runTest( '
        1. test

        2. test

        3. test

        4. ', - '
          • test

          • test

          • test

          ', + '
          1. test

          2. test

          3. test

          ', { blockGroupType: 'Document', blocks: [ @@ -2380,7 +2380,7 @@ describe('wordOnlineHandler', () => { decorator: { tagName: 'p', format: {} }, }, ], - levels: [{ listType: 'UL', format: {}, dataset: {} }], + levels: [{ listType: 'OL', format: {}, dataset: {} }], formatHolder: { segmentType: 'SelectionMarker', isSelected: false, @@ -2401,7 +2401,7 @@ describe('wordOnlineHandler', () => { decorator: { tagName: 'p', format: {} }, }, ], - levels: [{ listType: 'UL', format: {}, dataset: {} }], + levels: [{ listType: 'OL', format: {}, dataset: {} }], formatHolder: { segmentType: 'SelectionMarker', isSelected: false, @@ -2422,7 +2422,7 @@ describe('wordOnlineHandler', () => { decorator: { tagName: 'p', format: {} }, }, ], - levels: [{ listType: 'UL', format: {}, dataset: {} }], + levels: [{ listType: 'OL', format: {}, dataset: {} }], formatHolder: { segmentType: 'SelectionMarker', isSelected: false, diff --git a/packages/roosterjs-content-model-types/lib/context/DomToModelFormatContext.ts b/packages/roosterjs-content-model-types/lib/context/DomToModelFormatContext.ts index dbe1884ab1ef..df71a8e58dca 100644 --- a/packages/roosterjs-content-model-types/lib/context/DomToModelFormatContext.ts +++ b/packages/roosterjs-content-model-types/lib/context/DomToModelFormatContext.ts @@ -24,6 +24,12 @@ export interface DomToModelListFormat { * Current list type stack */ levels: ContentModelListLevel[]; + + /** + * This is used for handling an abnormal case where list items are not inside a ul or ol tag + * It is not common and against the HTML specification, but we need to handle it for robustness + */ + potentialListType?: 'OL' | 'UL'; } /**