diff --git a/packages/roosterjs-content-model-api/lib/modelApi/common/clearModelFormat.ts b/packages/roosterjs-content-model-api/lib/modelApi/common/clearModelFormat.ts index 04d8cff9d3e..5e1a551f6d0 100644 --- a/packages/roosterjs-content-model-api/lib/modelApi/common/clearModelFormat.ts +++ b/packages/roosterjs-content-model-api/lib/modelApi/common/clearModelFormat.ts @@ -33,7 +33,9 @@ export function clearModelFormat( blocksToClear: [ReadonlyContentModelBlockGroup[], ShallowMutableContentModelBlock][], segmentsToClear: ShallowMutableContentModelSegment[], tablesToClear: [ContentModelTable, boolean][] -) { +): boolean { + let pendingStructureChange = false; + iterateSelections( model, (path, tableContext, block, segments) => { @@ -75,14 +77,14 @@ export function clearModelFormat( blocksToClear.length == 1 ) { segmentsToClear.splice(0, segmentsToClear.length, ...adjustWordSelection(model, marker)); - clearListFormat(blocksToClear[0][0]); + pendingStructureChange = clearListFormat(blocksToClear[0][0]) || pendingStructureChange; } else if (blocksToClear.length > 1 || blocksToClear.some(x => isWholeBlockSelected(x[1]))) { // 2. If a full block or multiple blocks are selected, clear block format for (let i = blocksToClear.length - 1; i >= 0; i--) { const [path, block] = blocksToClear[i]; clearBlockFormat(path, block); - clearListFormat(path); + pendingStructureChange = clearListFormat(path) || pendingStructureChange; clearContainerFormat(path, block); } } @@ -92,6 +94,8 @@ export function clearModelFormat( // 4. Clear format for table if any createTablesFormat(tablesToClear); + + return pendingStructureChange; } function createTablesFormat(tablesToClear: [ContentModelTable, boolean][]) { @@ -191,6 +195,10 @@ function clearListFormat(path: ReadonlyContentModelBlockGroup[]) { if (listItem) { mutateBlock(listItem).levels = []; + + return true; + } else { + return false; } } diff --git a/packages/roosterjs-content-model-api/lib/publicApi/format/clearFormat.ts b/packages/roosterjs-content-model-api/lib/publicApi/format/clearFormat.ts index 5e8fa8a0a1a..05998818f3b 100644 --- a/packages/roosterjs-content-model-api/lib/publicApi/format/clearFormat.ts +++ b/packages/roosterjs-content-model-api/lib/publicApi/format/clearFormat.ts @@ -8,6 +8,8 @@ import type { ContentModelTable, } from 'roosterjs-content-model-types'; +const MAX_TRY = 3; + /** * Clear format of selection * @param editor The editor to clear format from @@ -17,17 +19,27 @@ export function clearFormat(editor: IEditor) { editor.formatContentModel( model => { - const blocksToClear: [ContentModelBlockGroup[], ContentModelBlock][] = []; - const segmentsToClear: ContentModelSegment[] = []; - const tablesToClear: [ContentModelTable, boolean][] = []; + let changed = false; + let needtoRun = true; + let triedTimes = 0; + + while (needtoRun && triedTimes++ < MAX_TRY) { + const blocksToClear: [ContentModelBlockGroup[], ContentModelBlock][] = []; + const segmentsToClear: ContentModelSegment[] = []; + const tablesToClear: [ContentModelTable, boolean][] = []; + + needtoRun = clearModelFormat(model, blocksToClear, segmentsToClear, tablesToClear); - clearModelFormat(model, blocksToClear, segmentsToClear, tablesToClear); + normalizeContentModel(model); - normalizeContentModel(model); + changed = + changed || + blocksToClear.length > 0 || + segmentsToClear.length > 0 || + tablesToClear.length > 0; + } - return ( - blocksToClear.length > 0 || segmentsToClear.length > 0 || tablesToClear.length > 0 - ); + return changed; }, { apiName: 'clearFormat', diff --git a/packages/roosterjs-content-model-api/test/modelApi/common/clearModelFormatTest.ts b/packages/roosterjs-content-model-api/test/modelApi/common/clearModelFormatTest.ts index 4eb36438c3e..932cb19fe05 100644 --- a/packages/roosterjs-content-model-api/test/modelApi/common/clearModelFormatTest.ts +++ b/packages/roosterjs-content-model-api/test/modelApi/common/clearModelFormatTest.ts @@ -102,12 +102,13 @@ describe('clearModelFormat', () => { const segments: any[] = []; const tables: any[] = []; - clearModelFormat(model, [], [], []); + const result = clearModelFormat(model, [], [], []); expect(model).toEqual({ blockGroupType: 'Document', blocks: [] }); expect(blocks).toEqual([]); expect(segments).toEqual([]); expect(tables).toEqual([]); + expect(result).toBeFalse(); }); it('Model without selection', () => { @@ -122,7 +123,7 @@ describe('clearModelFormat', () => { para.segments.push(text); model.blocks.push(para); - clearModelFormat(model, blocks, segments, tables); + const result = clearModelFormat(model, blocks, segments, tables); expect(model).toEqual({ blockGroupType: 'Document', @@ -144,6 +145,7 @@ describe('clearModelFormat', () => { expect(blocks).toEqual([]); expect(segments).toEqual([]); expect(tables).toEqual([]); + expect(result).toBeFalse(); }); it('Model with text selection', () => { @@ -161,7 +163,7 @@ describe('clearModelFormat', () => { const segments: any[] = []; const tables: any[] = []; - clearModelFormat(model, blocks, segments, tables); + const result = clearModelFormat(model, blocks, segments, tables); expect(model).toEqual({ blockGroupType: 'Document', @@ -188,6 +190,7 @@ describe('clearModelFormat', () => { expect(blocks).toEqual([[[model], para]]); expect(segments).toEqual([text2]); expect(tables).toEqual([]); + expect(result).toBeFalse(); }); it('Model with link', () => { @@ -211,7 +214,7 @@ describe('clearModelFormat', () => { const segments: any[] = []; const tables: any[] = []; - clearModelFormat(model, blocks, segments, tables); + const result = clearModelFormat(model, blocks, segments, tables); expect(model).toEqual({ blockGroupType: 'Document', @@ -239,6 +242,7 @@ describe('clearModelFormat', () => { expect(blocks).toEqual([[[model], para]]); expect(segments).toEqual([text1]); expect(tables).toEqual([]); + expect(result).toBeFalse(); }); it('Model with code', () => { @@ -260,7 +264,7 @@ describe('clearModelFormat', () => { const segments: any[] = []; const tables: any[] = []; - clearModelFormat(model, blocks, segments, tables); + const result = clearModelFormat(model, blocks, segments, tables); expect(model).toEqual({ blockGroupType: 'Document', @@ -282,6 +286,7 @@ describe('clearModelFormat', () => { expect(blocks).toEqual([[[model], para]]); expect(segments).toEqual([text1]); expect(tables).toEqual([]); + expect(result).toBeFalse(); }); it('Model with text selection in whole paragraph', () => { @@ -300,7 +305,7 @@ describe('clearModelFormat', () => { const segments: any[] = []; const tables: any[] = []; - clearModelFormat(model, blocks, segments, tables); + const result = clearModelFormat(model, blocks, segments, tables); expect(model).toEqual({ blockGroupType: 'Document', @@ -328,6 +333,7 @@ describe('clearModelFormat', () => { expect(blocks).toEqual([[[model], para]]); expect(segments).toEqual([text1, text2]); expect(tables).toEqual([]); + expect(result).toBeFalse(); }); it('Model with collapsed selection', () => { @@ -344,7 +350,7 @@ describe('clearModelFormat', () => { const segments: any[] = []; const tables: any[] = []; - clearModelFormat(model, blocks, segments, tables); + const result = clearModelFormat(model, blocks, segments, tables); expect(model).toEqual({ blockGroupType: 'Document', @@ -375,6 +381,7 @@ describe('clearModelFormat', () => { expect(blocks).toEqual([[[model], para]]); expect(segments).toEqual([marker]); expect(tables).toEqual([]); + expect(result).toBeFalse(); }); it('Model with collapsed selection inside word', () => { @@ -392,7 +399,7 @@ describe('clearModelFormat', () => { const segments: any[] = []; const tables: any[] = []; - clearModelFormat(model, blocks, segments, tables); + const result = clearModelFormat(model, blocks, segments, tables); expect(model).toEqual({ blockGroupType: 'Document', @@ -447,6 +454,7 @@ describe('clearModelFormat', () => { text3, ]); expect(tables).toEqual([]); + expect(result).toBeFalse(); }); it('Model with collapsed selection under list', () => { @@ -463,7 +471,7 @@ describe('clearModelFormat', () => { const segments: any[] = []; const tables: any[] = []; - clearModelFormat(model, blocks, segments, tables); + const result = clearModelFormat(model, blocks, segments, tables); expect(model).toEqual({ blockGroupType: 'Document', @@ -499,6 +507,7 @@ describe('clearModelFormat', () => { expect(blocks).toEqual([[[list, model], para]]); expect(segments).toEqual([marker]); expect(tables).toEqual([]); + expect(result).toBeTrue(); }); it('Model with divider selection', () => { @@ -516,7 +525,7 @@ describe('clearModelFormat', () => { const segments: any[] = []; const tables: any[] = []; - clearModelFormat(model, blocks, segments, tables); + const result = clearModelFormat(model, blocks, segments, tables); expect(model).toEqual({ blockGroupType: 'Document', @@ -540,6 +549,7 @@ describe('clearModelFormat', () => { expect(blocks).toEqual([[[model], divider]]); expect(segments).toEqual([]); expect(tables).toEqual([]); + expect(result).toBeFalse(); }); it('Model with selection under list', () => { @@ -558,7 +568,7 @@ describe('clearModelFormat', () => { const segments: any[] = []; const tables: any[] = []; - clearModelFormat(model, blocks, segments, tables); + const result = clearModelFormat(model, blocks, segments, tables); expect(model).toEqual({ blockGroupType: 'Document', @@ -600,6 +610,7 @@ describe('clearModelFormat', () => { }, ]); expect(tables).toEqual([]); + expect(result).toBeTrue(); }); it('Model with selection under list, has defaultSegmentFormat', () => { @@ -620,7 +631,7 @@ describe('clearModelFormat', () => { const segments: any[] = []; const tables: any[] = []; - clearModelFormat(model, blocks, segments, tables); + const result = clearModelFormat(model, blocks, segments, tables); expect(model).toEqual({ blockGroupType: 'Document', @@ -662,6 +673,7 @@ describe('clearModelFormat', () => { expect(blocks).toEqual([[[list, model], para]]); expect(segments).toEqual([text]); expect(tables).toEqual([]); + expect(result).toBeTrue(); }); it('Model with selection under quote', () => { @@ -688,7 +700,7 @@ describe('clearModelFormat', () => { const segments: any[] = []; const tables: any[] = []; - clearModelFormat(model, blocks, segments, tables); + const result = clearModelFormat(model, blocks, segments, tables); expect(model).toEqual({ blockGroupType: 'Document', @@ -774,6 +786,7 @@ describe('clearModelFormat', () => { ]); expect(segments).toEqual([text3, text4]); expect(tables).toEqual([]); + expect(result).toBeFalse(); }); it('Model with selection under table', () => { @@ -796,7 +809,7 @@ describe('clearModelFormat', () => { const segments: any[] = []; const tables: any[] = []; - clearModelFormat(model, blocks, segments, tables); + const result = clearModelFormat(model, blocks, segments, tables); expect(model).toEqual({ blockGroupType: 'Document', @@ -855,5 +868,6 @@ describe('clearModelFormat', () => { expect(blocks).toEqual([]); expect(segments).toEqual([]); expect(tables).toEqual([[table, true]]); + expect(result).toBeFalse(); }); }); diff --git a/packages/roosterjs-content-model-api/test/publicApi/format/clearFormatTest.ts b/packages/roosterjs-content-model-api/test/publicApi/format/clearFormatTest.ts index 80958b94380..489b81fa813 100644 --- a/packages/roosterjs-content-model-api/test/publicApi/format/clearFormatTest.ts +++ b/packages/roosterjs-content-model-api/test/publicApi/format/clearFormatTest.ts @@ -33,4 +33,76 @@ describe('clearFormat', () => { expect(normalizeContentModel.normalizeContentModel).toHaveBeenCalledTimes(1); expect(normalizeContentModel.normalizeContentModel).toHaveBeenCalledWith(model); }); + + it('Clear format with list under quote', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + tagName: 'blockquote', + blockType: 'BlockGroup', + format: {}, + blockGroupType: 'FormatContainer', + blocks: [ + { + formatHolder: { + isSelected: false, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [{ listType: 'OL', format: {}, dataset: {} }], + blockType: 'BlockGroup', + format: {}, + blockGroupType: 'ListItem', + blocks: [ + { + segments: [ + { + text: 'test', + segmentType: 'Text', + isSelected: true, + format: {}, + }, + ], + blockType: 'Paragraph', + format: {}, + }, + ], + }, + ], + }, + ], + }; + const formatContentModelSpy = jasmine + .createSpy('formatContentModel') + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { + expect(options.apiName).toEqual('clearFormat'); + callback(model, { newEntities: [], deletedEntities: [], newImages: [] }); + }); + const editor = ({ + focus: () => {}, + formatContentModel: formatContentModelSpy, + } as any) as IEditor; + + clearFormat(editor); + + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + segments: [ + { + text: 'test', + segmentType: 'Text', + isSelected: true, + format: {}, + }, + ], + blockType: 'Paragraph', + format: {}, + }, + ], + }); + }); });