Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Customize the behavior of merging format values #2865

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -17,7 +21,7 @@ export function getFormatState(editor: IEditor): ContentModelFormatState {

editor.formatContentModel(
model => {
retrieveModelFormatState(model, pendingFormat, result);
retrieveModelFormatState(model, pendingFormat, result, conflictSolution);

return false;
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@ describe('getFormatState', () => {
canUndo: false,
canRedo: false,
isDarkMode: false,
}
},
'remove'
);
expect(result).toEqual(expectedFormat);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand All @@ -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) {
Expand All @@ -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 => {
Expand All @@ -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
Expand Down Expand Up @@ -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'], []);
Expand All @@ -191,16 +199,17 @@ 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(
result,
'isBlockQuote',
containerIndex >= 0 &&
(path[containerIndex] as ReadonlyContentModelFormatContainer)?.tagName == 'blockquote',
isFirst
isFirst,
conflictSolution
);
}

Expand Down Expand Up @@ -241,14 +250,28 @@ function mergeValue<K extends keyof ContentModelFormatState>(
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;
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
});
});
1 change: 1 addition & 0 deletions packages/roosterjs-content-model-types/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Loading