diff --git a/packages/roosterjs-content-model-api/lib/publicApi/utils/formatInsertPointWithContentModel.ts b/packages/roosterjs-content-model-api/lib/publicApi/utils/formatInsertPointWithContentModel.ts index 2f9780cb1eb9..81f3f871f297 100644 --- a/packages/roosterjs-content-model-api/lib/publicApi/utils/formatInsertPointWithContentModel.ts +++ b/packages/roosterjs-content-model-api/lib/publicApi/utils/formatInsertPointWithContentModel.ts @@ -60,6 +60,15 @@ export function formatInsertPointWithContentModel( textWithSelection: getShadowTextProcessor(bundle), }, tryGetFromCache: false, + // When an element carries "container level" styles such as margin or padding, we first + // wrap it in a FormatContainer. After all its child nodes are processed, we decide whether + // to keep the FormatContainer or fall back to a plain paragraph when it only wraps a single + // paragraph. However, formatInsertPointWithContentModel persists the Content Model group path + // during processing so the later formatting callback can still use it (see the + // DomToModelContextWithPath interface below). If the FormatContainer falls back to a paragraph, + // it is removed from the model and the persisted path becomes invalid. To keep the path valid, + // we skip the fallback check here and always keep the FormatContainer when one is needed. + skipFormatContainerFallbackCheck: true, } ); } diff --git a/packages/roosterjs-content-model-api/test/publicApi/utils/formatInsertPointWithContentModelTest.ts b/packages/roosterjs-content-model-api/test/publicApi/utils/formatInsertPointWithContentModelTest.ts index f0857775e44a..1176f49badaa 100644 --- a/packages/roosterjs-content-model-api/test/publicApi/utils/formatInsertPointWithContentModelTest.ts +++ b/packages/roosterjs-content-model-api/test/publicApi/utils/formatInsertPointWithContentModelTest.ts @@ -47,6 +47,7 @@ describe('formatInsertPointWithContentModel', () => { textWithSelection: jasmine.anything() as any, }, tryGetFromCache: false, + skipFormatContainerFallbackCheck: true, } ); @@ -112,6 +113,7 @@ describe('formatInsertPointWithContentModel', () => { textWithSelection: jasmine.anything() as any, }, tryGetFromCache: false, + skipFormatContainerFallbackCheck: true, } ); diff --git a/packages/roosterjs-content-model-core/lib/coreApi/createContentModel/createContentModel.ts b/packages/roosterjs-content-model-core/lib/coreApi/createContentModel/createContentModel.ts index e0c9e8f27b23..e151a42e95ab 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/createContentModel/createContentModel.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/createContentModel/createContentModel.ts @@ -45,6 +45,10 @@ export const createContentModel: CreateContentModel = (core, option, selectionOv ? createDomToModelContext(editorContext, settings.builtIn, settings.customized, option) : createDomToModelContextWithConfig(settings.calculated, editorContext); + if (option?.skipFormatContainerFallbackCheck) { + domToModelContext.skipFormatContainerFallbackCheck = true; + } + if (selection) { domToModelContext.selection = selection; } diff --git a/packages/roosterjs-content-model-core/test/coreApi/createContentModel/createContentModelTest.ts b/packages/roosterjs-content-model-core/test/coreApi/createContentModel/createContentModelTest.ts index da9330723626..e5a516c4a230 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/createContentModel/createContentModelTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/createContentModel/createContentModelTest.ts @@ -107,6 +107,30 @@ describe('createContentModel', () => { expect(domToContentModelSpy).toHaveBeenCalledWith(mockedDiv, currentContext); expect(model).toBe(mockedModel); }); + + it('Pass skipFormatContainerFallbackCheck option to context', () => { + const currentContext = { ...originalContext } as DomToModelContext; + + spyOn(createDomToModelContext, 'createDomToModelContext').and.returnValue(currentContext); + + createContentModel(core, { + tryGetFromCache: false, + skipFormatContainerFallbackCheck: true, + }); + + expect(domToContentModelSpy).toHaveBeenCalledWith(mockedDiv, currentContext); + expect(currentContext.skipFormatContainerFallbackCheck).toBe(true); + }); + + it('Do not set skipFormatContainerFallbackCheck when option not passed', () => { + const currentContext = { ...originalContext } as DomToModelContext; + + spyOn(createDomToModelContext, 'createDomToModelContext').and.returnValue(currentContext); + + createContentModel(core, { tryGetFromCache: false }); + + expect(currentContext.skipFormatContainerFallbackCheck).toBeUndefined(); + }); }); describe('createContentModel with selection', () => { diff --git a/packages/roosterjs-content-model-dom/lib/domToModel/context/createDomToModelContext.ts b/packages/roosterjs-content-model-dom/lib/domToModel/context/createDomToModelContext.ts index 0fa8a29e4a9a..cf6c83545f42 100644 --- a/packages/roosterjs-content-model-dom/lib/domToModel/context/createDomToModelContext.ts +++ b/packages/roosterjs-content-model-dom/lib/domToModel/context/createDomToModelContext.ts @@ -39,7 +39,7 @@ export function createDomToModelContext( export function createDomToModelContextWithConfig( config: DomToModelSettings, editorContext?: EditorContext -) { +): DomToModelContext { return Object.assign( {}, editorContext, diff --git a/packages/roosterjs-content-model-dom/lib/domToModel/processors/formatContainerProcessor.ts b/packages/roosterjs-content-model-dom/lib/domToModel/processors/formatContainerProcessor.ts index c17e66f62fd6..4f9534e4c564 100644 --- a/packages/roosterjs-content-model-dom/lib/domToModel/processors/formatContainerProcessor.ts +++ b/packages/roosterjs-content-model-dom/lib/domToModel/processors/formatContainerProcessor.ts @@ -86,7 +86,11 @@ const formatContainerProcessorInternal = ( formatContainer.zeroFontSize = true; } - if (shouldFallbackToParagraph(formatContainer) && !forceFormatContainer) { + if ( + !context.skipFormatContainerFallbackCheck && + shouldFallbackToParagraph(formatContainer) && + !forceFormatContainer + ) { // For DIV container that only has one paragraph child, container style can be merged into paragraph // and no need to have this container const paragraph = formatContainer.blocks[0] as ContentModelParagraph; diff --git a/packages/roosterjs-content-model-dom/test/domToModel/processors/formatContainerProcessorTest.ts b/packages/roosterjs-content-model-dom/test/domToModel/processors/formatContainerProcessorTest.ts index 32e3be92aaf3..f209e1a0f53a 100644 --- a/packages/roosterjs-content-model-dom/test/domToModel/processors/formatContainerProcessorTest.ts +++ b/packages/roosterjs-content-model-dom/test/domToModel/processors/formatContainerProcessorTest.ts @@ -641,3 +641,122 @@ describe('forceFormatContainerProcessor', () => { }); }); }); + +describe('formatContainerProcessor with skipFormatContainerFallbackCheck', () => { + let context: DomToModelContext; + + beforeEach(() => { + context = createDomToModelContext(); + context.skipFormatContainerFallbackCheck = true; + }); + + it('div with single paragraph child should NOT fallback to paragraph', () => { + const group = createContentModelDocument(); + const div = document.createElement('div'); + + div.appendChild(document.createTextNode('test')); + + formatContainerProcessor(group, div, context); + + expect(group).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'FormatContainer', + tagName: 'div', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + format: {}, + }, + { blockType: 'Paragraph', segments: [], format: {}, isImplicit: true }, + ], + }); + }); + + it('div with id and single paragraph child should NOT fallback to paragraph', () => { + const group = createContentModelDocument(); + const div = document.createElement('div'); + + div.id = 'testId'; + div.appendChild(document.createTextNode('test')); + + formatContainerProcessor(group, div, context); + + expect(group).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'FormatContainer', + tagName: 'div', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + format: { + id: 'testId', + }, + }, + { blockType: 'Paragraph', segments: [], format: {}, isImplicit: true }, + ], + }); + }); + + it('blockquote (non-div) is unaffected and still kept as FormatContainer', () => { + const group = createContentModelDocument(); + const quote = document.createElement('blockquote'); + + quote.appendChild(document.createTextNode('test')); + + formatContainerProcessor(group, quote, context); + + expect(group).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'FormatContainer', + tagName: 'blockquote', + blocks: [ + { + blockType: 'Paragraph', + segments: [{ segmentType: 'Text', text: 'test', format: {} }], + format: {}, + isImplicit: true, + }, + ], + format: { + marginTop: '1em', + marginBottom: '1em', + marginRight: '40px', + marginLeft: '40px', + }, + }, + { blockType: 'Paragraph', segments: [], format: {}, isImplicit: true }, + ], + }); + }); +}); diff --git a/packages/roosterjs-content-model-types/lib/context/DomToModelOption.ts b/packages/roosterjs-content-model-types/lib/context/DomToModelOption.ts index 134e6d9e387f..a6f50ebb39c5 100644 --- a/packages/roosterjs-content-model-types/lib/context/DomToModelOption.ts +++ b/packages/roosterjs-content-model-types/lib/context/DomToModelOption.ts @@ -48,6 +48,14 @@ export interface DomToModelOptionForCreateModel extends DomToModelOption { * When this option is passed, "tryGetFromCache" will be ignored. */ recalculateTableSize?: boolean | 'all' | 'selected' | 'none'; + + /** + * When set to true, if a container element could be represented by a FormatContainer, always keep the + * FormatContainer and never fall back to a paragraph, even when it only has a single child. + * Set this when the intermediate FormatContainer is persisted during DOM to Content Model conversion + * and is later used during formatting. + */ + skipFormatContainerFallbackCheck?: boolean; } /** diff --git a/packages/roosterjs-content-model-types/lib/context/DomToModelSettings.ts b/packages/roosterjs-content-model-types/lib/context/DomToModelSettings.ts index ce4e64c26ac1..9c4f3438020a 100644 --- a/packages/roosterjs-content-model-types/lib/context/DomToModelSettings.ts +++ b/packages/roosterjs-content-model-types/lib/context/DomToModelSettings.ts @@ -152,4 +152,12 @@ export interface DomToModelSettings { * If true elements that has display:none style will be processed */ processNonVisibleElements?: boolean; + + /** + * When set to true, if a container element could be represented by a FormatContainer, always keep the + * FormatContainer and never fall back to a paragraph, even when it only has a single child. + * Set this when the intermediate FormatContainer is persisted during DOM to Content Model conversion + * and is later used during formatting. + */ + skipFormatContainerFallbackCheck?: boolean; }