Skip to content

Commit 5246146

Browse files
authored
Fix 315302 (#2998)
1 parent 76750e2 commit 5246146

File tree

7 files changed

+175
-24
lines changed

7 files changed

+175
-24
lines changed

packages/roosterjs-content-model-api/lib/publicApi/format/getFormatState.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { reducedModelChildProcessor } from '../../modelApi/common/reducedModelChildProcessor';
22
import { retrieveModelFormatState } from 'roosterjs-content-model-dom';
3-
import type { IEditor, ContentModelFormatState, ConflictFormatSolution } from 'roosterjs-content-model-types';
3+
import type {
4+
IEditor,
5+
ContentModelFormatState,
6+
ConflictFormatSolution,
7+
} from 'roosterjs-content-model-types';
48

59
/**
610
* Get current format state
@@ -21,7 +25,13 @@ export function getFormatState(
2125

2226
editor.formatContentModel(
2327
model => {
24-
retrieveModelFormatState(model, pendingFormat, result, conflictSolution);
28+
retrieveModelFormatState(
29+
model,
30+
pendingFormat,
31+
result,
32+
conflictSolution,
33+
editor.getDOMHelper()
34+
);
2535

2636
return false;
2737
},

packages/roosterjs-content-model-api/test/publicApi/format/getFormatStateTest.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import * as retrieveModelFormatState from 'roosterjs-content-model-dom/lib/modelApi/editing/retrieveModelFormatState';
2-
import { ContentModelDocument, ContentModelSegmentFormat } from 'roosterjs-content-model-types';
32
import { ContentModelFormatState } from 'roosterjs-content-model-types';
43
import { getFormatState } from '../../../lib/publicApi/format/getFormatState';
54
import { IEditor } from 'roosterjs-content-model-types';
65
import { reducedModelChildProcessor } from '../../../lib/modelApi/common/reducedModelChildProcessor';
6+
import {
7+
ContentModelDocument,
8+
ContentModelSegmentFormat,
9+
DOMHelper,
10+
} from 'roosterjs-content-model-types';
711
import {
812
createContentModelDocument,
913
createDomToModelContext,
@@ -23,6 +27,7 @@ describe('getFormatState', () => {
2327
expectedModel: ContentModelDocument,
2428
expectedFormat: ContentModelFormatState
2529
) {
30+
const mockedDOMHelper: DOMHelper = {} as any;
2631
const editor = ({
2732
getSnapshotsManager: () => ({
2833
hasNewContent: false,
@@ -31,6 +36,7 @@ describe('getFormatState', () => {
3136
isDarkMode: () => false,
3237
getZoomScale: () => 1,
3338
getPendingFormat: () => pendingFormat,
39+
getDOMHelper: () => mockedDOMHelper,
3440
formatContentModel: (callback: Function) => {
3541
const model = createContentModelDocument();
3642
const editorDiv = document.createElement('div');
@@ -73,7 +79,8 @@ describe('getFormatState', () => {
7379
canRedo: false,
7480
isDarkMode: false,
7581
},
76-
'remove'
82+
'remove',
83+
mockedDOMHelper
7784
);
7885
expect(result).toEqual(expectedFormat);
7986
}

packages/roosterjs-content-model-core/lib/editor/core/DOMHelperImpl.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { isNodeOfType, parseValueWithUnit, toArray } from 'roosterjs-content-model-dom';
2-
import type { DOMHelper } from 'roosterjs-content-model-types';
2+
import type { ContentModelSegmentFormat, DOMHelper } from 'roosterjs-content-model-types';
33

44
class DOMHelperImpl implements DOMHelper {
55
constructor(private contentDiv: HTMLElement) {}
@@ -88,6 +88,31 @@ class DOMHelperImpl implements DOMHelper {
8888
getClonedRoot(): HTMLElement {
8989
return this.contentDiv.cloneNode(true /*deep*/) as HTMLElement;
9090
}
91+
92+
/**
93+
* Get format of the container element
94+
*/
95+
getContainerFormat(): ContentModelSegmentFormat {
96+
const window = this.contentDiv.ownerDocument.defaultView;
97+
98+
const style = window?.getComputedStyle(this.contentDiv);
99+
100+
return style
101+
? {
102+
fontSize: style.fontSize,
103+
fontFamily: style.fontFamily,
104+
fontWeight: style.fontWeight,
105+
textColor: style.color,
106+
backgroundColor: style.backgroundColor,
107+
italic: style.fontStyle == 'italic',
108+
letterSpacing: style.letterSpacing,
109+
lineHeight: style.lineHeight,
110+
strikethrough: style.textDecoration?.includes('line-through'),
111+
superOrSubScriptSequence: style.verticalAlign,
112+
underline: style.textDecoration?.includes('underline'),
113+
}
114+
: {};
115+
}
91116
}
92117

93118
/**

packages/roosterjs-content-model-core/test/editor/core/DOMHelperImplTest.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,4 +366,44 @@ describe('DOMHelperImpl', () => {
366366
expect(cloneSpy).toHaveBeenCalledWith(true);
367367
});
368368
});
369+
370+
describe('getContainerFormat', () => {
371+
it('getContainerFormat', () => {
372+
const mockedDiv: HTMLDivElement = {
373+
ownerDocument: {
374+
defaultView: {
375+
getComputedStyle: () => ({
376+
fontSize: '12px',
377+
fontFamily: 'Arial',
378+
fontWeight: 'bold',
379+
color: 'red',
380+
backgroundColor: 'blue',
381+
fontStyle: 'italic',
382+
letterSpacing: '1px',
383+
lineHeight: '1.5',
384+
textDecoration: 'line-through underline',
385+
verticalAlign: 'super',
386+
}),
387+
},
388+
},
389+
} as any;
390+
const domHelper = createDOMHelper(mockedDiv);
391+
392+
const result = domHelper.getContainerFormat();
393+
394+
expect(result).toEqual({
395+
fontSize: '12px',
396+
fontFamily: 'Arial',
397+
fontWeight: 'bold',
398+
textColor: 'red',
399+
backgroundColor: 'blue',
400+
italic: true,
401+
letterSpacing: '1px',
402+
lineHeight: '1.5',
403+
strikethrough: true,
404+
superOrSubScriptSequence: 'super',
405+
underline: true,
406+
});
407+
});
408+
});
369409
});

packages/roosterjs-content-model-dom/lib/modelApi/editing/retrieveModelFormatState.ts

Lines changed: 43 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type {
1616
ReadonlyContentModelFormatContainer,
1717
ReadonlyContentModelListItem,
1818
ReadonlyContentModelDocument,
19+
DOMHelper,
1920
} from 'roosterjs-content-model-types';
2021

2122
/**
@@ -29,13 +30,21 @@ export function retrieveModelFormatState(
2930
model: ReadonlyContentModelDocument,
3031
pendingFormat: ContentModelSegmentFormat | null,
3132
formatState: ContentModelFormatState,
32-
conflictSolution: ConflictFormatSolution = 'remove'
33+
conflictSolution: ConflictFormatSolution = 'remove',
34+
domHelper?: DOMHelper
3335
) {
3436
let firstTableContext: ReadonlyTableSelectionContext | undefined;
3537
let firstBlock: ReadonlyContentModelBlock | undefined;
3638
let isFirst = true;
3739
let isFirstImage = true;
3840
let isFirstSegment = true;
41+
let containerFormat: ContentModelSegmentFormat | undefined = undefined;
42+
43+
const modelFormat = { ...model.format };
44+
45+
delete modelFormat.italic;
46+
delete modelFormat.underline;
47+
delete modelFormat.fontWeight;
3948

4049
iterateSelections(
4150
model,
@@ -59,29 +68,41 @@ export function retrieveModelFormatState(
5968
// Segment formats
6069
segments?.forEach(segment => {
6170
if (isFirstSegment || segment.segmentType != 'SelectionMarker') {
62-
const modelFormat = { ...model.format };
71+
let currentFormat = Object.assign(
72+
{},
73+
block.format,
74+
block.decorator?.format,
75+
segment.format,
76+
segment.code?.format,
77+
segment.link?.format,
78+
pendingFormat
79+
);
80+
81+
// Sometimes the content may not specify all required format but just leverage the container format to do so.
82+
// In this case, we need to merge the container format into the current format
83+
// to make sure the current format contains all required format.
84+
if (!hasAllRequiredFormat(currentFormat)) {
85+
if (!containerFormat) {
86+
containerFormat = domHelper?.getContainerFormat() ?? modelFormat;
87+
}
6388

64-
delete modelFormat.italic;
65-
delete modelFormat.underline;
66-
delete modelFormat.fontWeight;
89+
currentFormat = Object.assign({}, containerFormat, currentFormat);
90+
}
6791

6892
retrieveSegmentFormat(
6993
formatState,
7094
isFirst,
71-
Object.assign(
72-
{},
73-
modelFormat,
74-
block.format,
75-
block.decorator?.format,
76-
segment.format,
77-
segment.code?.format,
78-
segment.link?.format,
79-
pendingFormat
80-
),
95+
currentFormat,
8196
conflictSolution
8297
);
8398

84-
mergeValue(formatState, 'isCodeInline', !!segment?.code, isFirst, conflictSolution);
99+
mergeValue(
100+
formatState,
101+
'isCodeInline',
102+
!!segment?.code,
103+
isFirst,
104+
conflictSolution
105+
);
85106
}
86107

87108
// We only care the format of selection marker when it is the first selected segment. This is because when selection marker
@@ -251,7 +272,7 @@ function mergeValue<K extends keyof ContentModelFormatState>(
251272
newValue: ContentModelFormatState[K] | undefined,
252273
isFirst: boolean,
253274
conflictSolution: ConflictFormatSolution = 'remove',
254-
parseFn: (val: ContentModelFormatState[K]) => ContentModelFormatState[K] = val => val,
275+
parseFn: (val: ContentModelFormatState[K]) => ContentModelFormatState[K] = val => val
255276
) {
256277
if (isFirst) {
257278
if (newValue !== undefined) {
@@ -285,4 +306,8 @@ function px2Pt(px: string) {
285306
}
286307
}
287308
return px;
288-
}
309+
}
310+
311+
function hasAllRequiredFormat(format: ContentModelSegmentFormat) {
312+
return !!format.fontFamily && !!format.fontSize && !!format.textColor;
313+
}

packages/roosterjs-content-model-dom/test/modelApi/editing/retrieveModelFormatStateTest.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import * as iterateSelections from '../../../lib/modelApi/selection/iterateSelec
22
import { addCode } from '../../../lib/modelApi/common/addDecorators';
33
import { addSegment } from '../../../lib/modelApi/common/addSegment';
44
import { applyTableFormat } from '../../../lib/modelApi/editing/applyTableFormat';
5-
import { ContentModelFormatState, ContentModelSegmentFormat } from 'roosterjs-content-model-types';
65
import { createContentModelDocument } from '../../../lib/modelApi/creators/createContentModelDocument';
76
import { createDivider } from '../../../lib/modelApi/creators/createDivider';
87
import { createFormatContainer } from '../../../lib/modelApi/creators/createFormatContainer';
@@ -15,6 +14,11 @@ import { createTable } from '../../../lib/modelApi/creators/createTable';
1514
import { createTableCell } from '../../../lib/modelApi/creators/createTableCell';
1615
import { createText } from '../../../lib/modelApi/creators/createText';
1716
import { retrieveModelFormatState } from '../../../lib/modelApi/editing/retrieveModelFormatState';
17+
import {
18+
ContentModelFormatState,
19+
ContentModelSegmentFormat,
20+
DOMHelper,
21+
} from 'roosterjs-content-model-types';
1822

1923
describe('retrieveModelFormatState', () => {
2024
const segmentFormat: ContentModelSegmentFormat = {
@@ -840,4 +844,37 @@ describe('retrieveModelFormatState', () => {
840844
canAddImageAltText: false,
841845
});
842846
});
847+
848+
it('No format in model, with dom helper', () => {
849+
const model = createContentModelDocument();
850+
const result: ContentModelFormatState = {};
851+
const para = createParagraph();
852+
const text1 = createText('test1');
853+
854+
text1.isSelected = true;
855+
856+
spyOn(iterateSelections, 'iterateSelections').and.callFake((path: any, callback) => {
857+
callback([path], undefined, para, [text1]);
858+
return false;
859+
});
860+
861+
const domHelper: DOMHelper = {
862+
getContainerFormat: () => ({ fontFamily: 'a', fontSize: 'b', textColor: 'c' }),
863+
} as any;
864+
865+
retrieveModelFormatState(model, null, result, 'returnMultiple', domHelper);
866+
867+
expect(result).toEqual({
868+
isBlockQuote: false,
869+
isBold: false,
870+
isSuperscript: false,
871+
isSubscript: false,
872+
isCodeInline: false,
873+
canUnlink: false,
874+
canAddImageAltText: false,
875+
fontName: 'a',
876+
fontSize: 'b',
877+
textColor: 'c',
878+
});
879+
});
843880
});

packages/roosterjs-content-model-types/lib/parameter/DOMHelper.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { ContentModelSegmentFormat } from '../contentModel/format/ContentModelSegmentFormat';
2+
13
/**
24
* A helper class to provide DOM access APIs
35
*/
@@ -97,4 +99,9 @@ export interface DOMHelper {
9799
* Get a deep cloned root element
98100
*/
99101
getClonedRoot(): HTMLElement;
102+
103+
/**
104+
* Get format of the container element
105+
*/
106+
getContainerFormat(): ContentModelSegmentFormat;
100107
}

0 commit comments

Comments
 (0)