diff --git a/packages/roosterjs-content-model-api/lib/publicApi/format/getFormatState.ts b/packages/roosterjs-content-model-api/lib/publicApi/format/getFormatState.ts index 6c476eff4e5..e7c35e5af0b 100644 --- a/packages/roosterjs-content-model-api/lib/publicApi/format/getFormatState.ts +++ b/packages/roosterjs-content-model-api/lib/publicApi/format/getFormatState.ts @@ -1,12 +1,16 @@ import { reducedModelChildProcessor } from '../../modelApi/common/reducedModelChildProcessor'; import { retrieveModelFormatState } from 'roosterjs-content-model-dom'; -import type { IEditor, ContentModelFormatState } from 'roosterjs-content-model-types'; +import type { IEditor, ContentModelFormatState, ConflictFormatSolution } from 'roosterjs-content-model-types'; /** * Get current format state * @param editor The editor to get format from + * @param conflictSolution The strategy for handling format conflicts */ -export function getFormatState(editor: IEditor): ContentModelFormatState { +export function getFormatState( + editor: IEditor, + conflictSolution: ConflictFormatSolution = 'remove' +): ContentModelFormatState { const pendingFormat = editor.getPendingFormat(); const manager = editor.getSnapshotsManager(); const result: ContentModelFormatState = { @@ -17,7 +21,7 @@ export function getFormatState(editor: IEditor): ContentModelFormatState { editor.formatContentModel( model => { - retrieveModelFormatState(model, pendingFormat, result); + retrieveModelFormatState(model, pendingFormat, result, conflictSolution); return false; }, diff --git a/packages/roosterjs-content-model-api/test/publicApi/format/getFormatStateTest.ts b/packages/roosterjs-content-model-api/test/publicApi/format/getFormatStateTest.ts index a45acf4ef0b..1ac08277482 100644 --- a/packages/roosterjs-content-model-api/test/publicApi/format/getFormatStateTest.ts +++ b/packages/roosterjs-content-model-api/test/publicApi/format/getFormatStateTest.ts @@ -72,7 +72,8 @@ describe('getFormatState', () => { canUndo: false, canRedo: false, isDarkMode: false, - } + }, + 'remove' ); expect(result).toEqual(expectedFormat); } diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/editing/retrieveModelFormatState.ts b/packages/roosterjs-content-model-dom/lib/modelApi/editing/retrieveModelFormatState.ts index 8b7cbb0a19b..6c6e6e0800a 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/editing/retrieveModelFormatState.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/editing/retrieveModelFormatState.ts @@ -5,6 +5,7 @@ import { isBold } from '../../domUtils/style/isBold'; import { iterateSelections } from '../selection/iterateSelections'; import { parseValueWithUnit } from '../../formatHandlers/utils/parseValueWithUnit'; import type { + ConflictFormatSolution, ContentModelFormatState, ContentModelSegmentFormat, ReadonlyContentModelBlockGroup, @@ -22,11 +23,13 @@ import type { * @param model The Content Model to retrieve format state from * @param pendingFormat Existing pending format, if any * @param formatState Existing format state object, used for receiving the result + * @param conflictSolution The strategy for handling format conflicts */ export function retrieveModelFormatState( model: ReadonlyContentModelDocument, pendingFormat: ContentModelSegmentFormat | null, - formatState: ContentModelFormatState + formatState: ContentModelFormatState, + conflictSolution: ConflictFormatSolution = 'remove' ) { let firstTableContext: ReadonlyTableSelectionContext | undefined; let firstBlock: ReadonlyContentModelBlock | undefined; @@ -38,7 +41,7 @@ export function retrieveModelFormatState( model, (path, tableContext, block, segments) => { // Structure formats - retrieveStructureFormat(formatState, path, isFirst); + retrieveStructureFormat(formatState, path, isFirst, conflictSolution); // Multiple line format if (block) { @@ -51,7 +54,7 @@ export function retrieveModelFormatState( if (block?.blockType == 'Paragraph') { // Paragraph formats - retrieveParagraphFormat(formatState, block, isFirst); + retrieveParagraphFormat(formatState, block, isFirst, conflictSolution); // Segment formats segments?.forEach(segment => { @@ -74,10 +77,11 @@ export function retrieveModelFormatState( segment.code?.format, segment.link?.format, pendingFormat - ) + ), + conflictSolution ); - mergeValue(formatState, 'isCodeInline', !!segment?.code, isFirst); + mergeValue(formatState, 'isCodeInline', !!segment?.code, isFirst, conflictSolution); } // We only care the format of selection marker when it is the first selected segment. This is because when selection marker @@ -138,51 +142,55 @@ export function retrieveModelFormatState( function retrieveSegmentFormat( result: ContentModelFormatState, isFirst: boolean, - mergedFormat: ContentModelSegmentFormat + mergedFormat: ContentModelSegmentFormat, + conflictSolution: ConflictFormatSolution = 'remove' ) { const superOrSubscript = mergedFormat.superOrSubScriptSequence?.split(' ')?.pop(); - mergeValue(result, 'isBold', isBold(mergedFormat.fontWeight), isFirst); - mergeValue(result, 'isItalic', mergedFormat.italic, isFirst); - mergeValue(result, 'isUnderline', mergedFormat.underline, isFirst); - mergeValue(result, 'isStrikeThrough', mergedFormat.strikethrough, isFirst); - mergeValue(result, 'isSuperscript', superOrSubscript == 'super', isFirst); - mergeValue(result, 'isSubscript', superOrSubscript == 'sub', isFirst); - mergeValue(result, 'letterSpacing', mergedFormat.letterSpacing, isFirst); + mergeValue(result, 'isBold', isBold(mergedFormat.fontWeight), isFirst, conflictSolution); + mergeValue(result, 'isItalic', mergedFormat.italic, isFirst, conflictSolution); + mergeValue(result, 'isUnderline', mergedFormat.underline, isFirst, conflictSolution); + mergeValue(result, 'isStrikeThrough', mergedFormat.strikethrough, isFirst, conflictSolution); + mergeValue(result, 'isSuperscript', superOrSubscript == 'super', isFirst, conflictSolution); + mergeValue(result, 'isSubscript', superOrSubscript == 'sub', isFirst, conflictSolution); + mergeValue(result, 'letterSpacing', mergedFormat.letterSpacing, isFirst, conflictSolution); - mergeValue(result, 'fontName', mergedFormat.fontFamily, isFirst); + mergeValue(result, 'fontName', mergedFormat.fontFamily, isFirst, conflictSolution); mergeValue( result, 'fontSize', mergedFormat.fontSize, isFirst, + conflictSolution, val => parseValueWithUnit(val, undefined, 'pt') + 'pt' ); - mergeValue(result, 'backgroundColor', mergedFormat.backgroundColor, isFirst); - mergeValue(result, 'textColor', mergedFormat.textColor, isFirst); - mergeValue(result, 'fontWeight', mergedFormat.fontWeight, isFirst); - mergeValue(result, 'lineHeight', mergedFormat.lineHeight, isFirst); + mergeValue(result, 'backgroundColor', mergedFormat.backgroundColor, isFirst, conflictSolution); + mergeValue(result, 'textColor', mergedFormat.textColor, isFirst, conflictSolution); + mergeValue(result, 'fontWeight', mergedFormat.fontWeight, isFirst, conflictSolution); + mergeValue(result, 'lineHeight', mergedFormat.lineHeight, isFirst, conflictSolution); } function retrieveParagraphFormat( result: ContentModelFormatState, paragraph: ReadonlyContentModelParagraph, - isFirst: boolean + isFirst: boolean, + conflictSolution: ConflictFormatSolution = 'remove' ) { const headingLevel = parseInt((paragraph.decorator?.tagName || '').substring(1)); const validHeadingLevel = headingLevel >= 1 && headingLevel <= 6 ? headingLevel : undefined; - mergeValue(result, 'marginBottom', paragraph.format.marginBottom, isFirst); - mergeValue(result, 'marginTop', paragraph.format.marginTop, isFirst); - mergeValue(result, 'headingLevel', validHeadingLevel, isFirst); - mergeValue(result, 'textAlign', paragraph.format.textAlign, isFirst); - mergeValue(result, 'direction', paragraph.format.direction, isFirst); + mergeValue(result, 'marginBottom', paragraph.format.marginBottom, isFirst, conflictSolution); + mergeValue(result, 'marginTop', paragraph.format.marginTop, isFirst, conflictSolution); + mergeValue(result, 'headingLevel', validHeadingLevel, isFirst, conflictSolution); + mergeValue(result, 'textAlign', paragraph.format.textAlign, isFirst, conflictSolution); + mergeValue(result, 'direction', paragraph.format.direction, isFirst, conflictSolution); } function retrieveStructureFormat( result: ContentModelFormatState, path: ReadonlyContentModelBlockGroup[], - isFirst: boolean + isFirst: boolean, + conflictSolution: ConflictFormatSolution = 'remove' ) { const listItemIndex = getClosestAncestorBlockGroupIndex(path, ['ListItem'], []); const containerIndex = getClosestAncestorBlockGroupIndex(path, ['FormatContainer'], []); @@ -191,8 +199,8 @@ function retrieveStructureFormat( const listItem = path[listItemIndex] as ReadonlyContentModelListItem; const listType = listItem?.levels[listItem.levels.length - 1]?.listType; - mergeValue(result, 'isBullet', listType == 'UL', isFirst); - mergeValue(result, 'isNumbering', listType == 'OL', isFirst); + mergeValue(result, 'isBullet', listType == 'UL', isFirst, conflictSolution); + mergeValue(result, 'isNumbering', listType == 'OL', isFirst, conflictSolution); } mergeValue( @@ -200,7 +208,8 @@ function retrieveStructureFormat( 'isBlockQuote', containerIndex >= 0 && (path[containerIndex] as ReadonlyContentModelFormatContainer)?.tagName == 'blockquote', - isFirst + isFirst, + conflictSolution ); } @@ -241,14 +250,28 @@ function mergeValue( key: K, newValue: ContentModelFormatState[K] | undefined, isFirst: boolean, - parseFn: (val: ContentModelFormatState[K]) => ContentModelFormatState[K] = val => val + conflictSolution: ConflictFormatSolution = 'remove', + parseFn: (val: ContentModelFormatState[K]) => ContentModelFormatState[K] = val => val, ) { if (isFirst) { if (newValue !== undefined) { format[key] = newValue; } } else if (parseFn(newValue) !== parseFn(format[key])) { - delete format[key]; + switch (conflictSolution) { + case 'remove': + delete format[key]; + break; + case 'keepFirst': + break; + case 'returnMultiple': + if (typeof format[key] === 'string') { + (format[key] as string) = 'Multiple'; + } else { + delete format[key]; + } + break; + } } } diff --git a/packages/roosterjs-content-model-dom/test/modelApi/editing/retrieveModelFormatStateTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/editing/retrieveModelFormatStateTest.ts index 6e4e485bdf5..c80bc063d8a 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/editing/retrieveModelFormatStateTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/editing/retrieveModelFormatStateTest.ts @@ -809,4 +809,35 @@ describe('retrieveModelFormatState', () => { canAddImageAltText: false, }); }); + + it('Returns multiple for conflict format', () => { + const model = createContentModelDocument({}); + const result: ContentModelFormatState = {}; + const para = createParagraph(); + const text1 = createText('test1', { italic: true, fontFamily: 'Aptos', fontSize: '16pt' }); + const text2 = createText('test2', { fontFamily: 'Arial', fontSize: '12pt' }); + para.segments.push(text1, text2); + + text1.isSelected = true; + text2.isSelected = true; + + spyOn(iterateSelections, 'iterateSelections').and.callFake((path: any, callback) => { + callback([path], undefined, para, [text1, text2]); + return false; + }); + + retrieveModelFormatState(model, null, result, 'returnMultiple'); + + expect(result).toEqual({ + isBlockQuote: false, + isBold: false, + isSuperscript: false, + isSubscript: false, + fontName: 'Multiple', + fontSize: 'Multiple', + isCodeInline: false, + canUnlink: false, + canAddImageAltText: false, + }); + }); }); diff --git a/packages/roosterjs-content-model-types/lib/index.ts b/packages/roosterjs-content-model-types/lib/index.ts index 888ccf1263b..7ecc0e7d9be 100644 --- a/packages/roosterjs-content-model-types/lib/index.ts +++ b/packages/roosterjs-content-model-types/lib/index.ts @@ -445,6 +445,7 @@ export { ModelToTextCallbacks, ModelToTextChecker, } from './parameter/ModelToTextCallbacks'; +export { ConflictFormatSolution } from './parameter/ConflictFormatSolution'; export { BasePluginEvent, BasePluginDomEvent } from './event/BasePluginEvent'; export { BeforeCutCopyEvent } from './event/BeforeCutCopyEvent'; diff --git a/packages/roosterjs-content-model-types/lib/parameter/ConflictFormatSolution.ts b/packages/roosterjs-content-model-types/lib/parameter/ConflictFormatSolution.ts new file mode 100644 index 00000000000..ee166f4c232 --- /dev/null +++ b/packages/roosterjs-content-model-types/lib/parameter/ConflictFormatSolution.ts @@ -0,0 +1,7 @@ +/** + * Specify how to handle conflicts when retrieving format state + * remove: removes the conflicting key from the result + * keepFirst: retains the first value of the conflicting key + * returnMultiple: sets 'Multiple' as the value if the conflicting value's type is string + */ +export type ConflictFormatSolution = 'remove' | 'keepFirst' | 'returnMultiple';