From cd26c3c150ef2f9cae44ed6bdf6ab28cf477d9a3 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Fri, 25 Oct 2024 18:06:21 -0300 Subject: [PATCH 01/30] fix test --- .../test/autoFormat/AutoFormatPluginTest.ts | 36 ++++++++++++++----- .../Cropper/createImageCropperTest.ts | 6 ++++ .../test/imageEdit/ImageEditPluginTest.ts | 5 +++ .../Resizer/createImageResizerTest.ts | 4 +++ .../Rotator/createImageRotatorTest.ts | 2 +- .../imageEdit/utils/createImageWrapperTest.ts | 6 ++-- .../test/imageEdit/utils/updateWrapperTest.ts | 1 + 7 files changed, 47 insertions(+), 13 deletions(-) diff --git a/packages/roosterjs-content-model-plugins/test/autoFormat/AutoFormatPluginTest.ts b/packages/roosterjs-content-model-plugins/test/autoFormat/AutoFormatPluginTest.ts index f9cb705bc4f..831f342f9aa 100644 --- a/packages/roosterjs-content-model-plugins/test/autoFormat/AutoFormatPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/autoFormat/AutoFormatPluginTest.ts @@ -1077,7 +1077,7 @@ describe('Content Model Auto Format Plugin Test', () => { expect(model).toEqual(expectedResult); } - xit('should call transformHyphen', () => { + it('should call transformHyphen', () => { const event: EditorInputEvent = { eventType: 'input', rawEvent: { data: ' ', preventDefault: () => {}, inputType: 'insertText' } as any, @@ -1092,7 +1092,13 @@ describe('Content Model Auto Format Plugin Test', () => { segments: [ { segmentType: 'Text', - text: 'test—test', + text: 'test—tes', + format: {}, + isSelected: undefined, + }, + { + segmentType: 'Text', + text: 't', format: {}, isSelected: undefined, }, @@ -1185,7 +1191,7 @@ describe('Content Model Auto Format Plugin Test', () => { const segment: ContentModelText = { segmentType: 'Text', - text: '1/2', + text: '1/2 ', format: {}, }; const paragraph: ContentModelParagraph = { @@ -1218,7 +1224,7 @@ describe('Content Model Auto Format Plugin Test', () => { expect(model).toEqual(expectResult); } - xit('should call transformFraction', () => { + it('should call transformFraction', () => { const event: EditorInputEvent = { eventType: 'input', rawEvent: { data: ' ', preventDefault: () => {}, inputType: 'insertText' } as any, @@ -1238,6 +1244,12 @@ describe('Content Model Auto Format Plugin Test', () => { format: {}, isSelected: undefined, }, + { + segmentType: 'Text', + text: ' ', + format: {}, + isSelected: undefined, + }, ], }, ], @@ -1249,7 +1261,7 @@ describe('Content Model Auto Format Plugin Test', () => { ); }); - it('should not call transformHyphen - disable options', () => { + it('should not call transformFraction - disable options', () => { const event: EditorInputEvent = { eventType: 'input', rawEvent: { data: ' ', preventDefault: () => {}, inputType: 'insertText' } as any, @@ -1262,7 +1274,7 @@ describe('Content Model Auto Format Plugin Test', () => { { blockType: 'Paragraph', format: {}, - segments: [{ segmentType: 'Text', text: '1/2', format: {} }], + segments: [{ segmentType: 'Text', text: '1/2 ', format: {} }], }, ], }, @@ -1286,7 +1298,7 @@ describe('Content Model Auto Format Plugin Test', () => { const segment: ContentModelText = { segmentType: 'Text', - text: '1st', + text: '1st ', format: {}, }; const paragraph: ContentModelParagraph = { @@ -1319,7 +1331,7 @@ describe('Content Model Auto Format Plugin Test', () => { expect(model).toEqual(expectResult); } - xit('should call transformOrdinals', () => { + it('should call transformOrdinals', () => { const event: EditorInputEvent = { eventType: 'input', rawEvent: { data: ' ', preventDefault: () => {}, inputType: 'insertText' } as any, @@ -1345,6 +1357,12 @@ describe('Content Model Auto Format Plugin Test', () => { format: { superOrSubScriptSequence: 'super' }, isSelected: undefined, }, + { + segmentType: 'Text', + text: ' ', + format: {}, + isSelected: undefined, + }, ], }, ], @@ -1369,7 +1387,7 @@ describe('Content Model Auto Format Plugin Test', () => { { blockType: 'Paragraph', format: {}, - segments: [{ segmentType: 'Text', text: '1st', format: {} }], + segments: [{ segmentType: 'Text', text: '1st ', format: {} }], }, ], }, diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/Cropper/createImageCropperTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/Cropper/createImageCropperTest.ts index 820270013c9..e30d87e825a 100644 --- a/packages/roosterjs-content-model-plugins/test/imageEdit/Cropper/createImageCropperTest.ts +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/Cropper/createImageCropperTest.ts @@ -44,5 +44,11 @@ describe('createImageCropper', () => { ] as HTMLDivElement[]; expect(JSON.stringify(croppers)).toEqual(JSON.stringify(expectedCropper)); + + cropCenterDiv.remove(); + cropOverlayTopLeftDiv.remove(); + cropOverlayTopRightDiv.remove(); + cropOverlayBottomLeftDiv.remove(); + cropOverlayBottomRightDiv.remove(); }); }); diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts index d00cc2cde4a..9bcaabf2f3a 100644 --- a/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts @@ -148,6 +148,10 @@ describe('ImageEditPlugin', () => { } as any; }); + afterEach(() => { + editor = null!; + }); + it('keyDown', () => { const mockedImage = { getAttribute: getAttributeSpy, @@ -719,6 +723,7 @@ describe('ImageEditPlugin - applyFormatWithContentModel', () => { plugin.dispose(); editor.dispose(); editor = null; + mockedImage.remove(); } it('image to text', () => { diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/Resizer/createImageResizerTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/Resizer/createImageResizerTest.ts index 590b69ef19f..7c7ab3101eb 100644 --- a/packages/roosterjs-content-model-plugins/test/imageEdit/Resizer/createImageResizerTest.ts +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/Resizer/createImageResizerTest.ts @@ -12,6 +12,10 @@ describe('createImageResizer', () => { const result = createImageResizer(document); const resizers = [...createCorners(), ...createSides()].filter(element => !!element); expect(result).toEqual(resizers); + + result.forEach((resizer, index) => { + resizer.remove(); + }); }); }); diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/Rotator/createImageRotatorTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/Rotator/createImageRotatorTest.ts index c707a5f6d0e..8beba2076b2 100644 --- a/packages/roosterjs-content-model-plugins/test/imageEdit/Rotator/createImageRotatorTest.ts +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/Rotator/createImageRotatorTest.ts @@ -15,6 +15,6 @@ describe('createImageRotator', () => { const expectedRotator = div.firstElementChild! as HTMLDivElement; expect(result).toEqual([expectedRotator]); - document.body.removeChild(div); + div.remove(); }); }); diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/createImageWrapperTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/createImageWrapperTest.ts index 33ccfa11cee..24e9fb43605 100644 --- a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/createImageWrapperTest.ts +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/createImageWrapperTest.ts @@ -77,7 +77,7 @@ describe('createImageWrapper', () => { rotators: [], croppers: [], }); - document.body.removeChild(imageSpan); + imageSpan.remove(); }); it('rotate', () => { @@ -124,7 +124,7 @@ describe('createImageWrapper', () => { rotators: rotator, croppers: [], }); - document.body.removeChild(imageSpan); + imageSpan.remove(); }); it('crop', () => { @@ -179,7 +179,7 @@ describe('createImageWrapper', () => { rotators: [], croppers: cropper, }); - document.body.removeChild(imageSpan); + imageSpan.remove(); }); }); diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/updateWrapperTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/updateWrapperTest.ts index 9575c9c8b5b..eebc19dcce8 100644 --- a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/updateWrapperTest.ts +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/updateWrapperTest.ts @@ -56,5 +56,6 @@ describe('updateWrapper', () => { expect(imageClone.style.height).toBe('13.3333px'); expect(imageClone.style.verticalAlign).toBe('bottom'); expect(imageClone.style.position).toBe('absolute'); + image.remove(); }); }); From 336ef57c357719b90d4bf656f265314671059f33 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Mon, 28 Oct 2024 20:04:49 -0300 Subject: [PATCH 02/30] test --- .../test/autoFormat/AutoFormatPluginTest.ts | 32 +++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/packages/roosterjs-content-model-plugins/test/autoFormat/AutoFormatPluginTest.ts b/packages/roosterjs-content-model-plugins/test/autoFormat/AutoFormatPluginTest.ts index 831f342f9aa..481571448fc 100644 --- a/packages/roosterjs-content-model-plugins/test/autoFormat/AutoFormatPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/autoFormat/AutoFormatPluginTest.ts @@ -1044,12 +1044,17 @@ describe('Content Model Auto Format Plugin Test', () => { const segment: ContentModelText = { segmentType: 'Text', - text: 'test--test', + text: 'test--test ', format: {}, }; + const selectionMarker: ContentModelSelectionMarker = { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }; const paragraph: ContentModelParagraph = { blockType: 'Paragraph', - segments: [segment], + segments: [segment, selectionMarker], format: {}, }; const model: ContentModelDocument = { @@ -1092,16 +1097,21 @@ describe('Content Model Auto Format Plugin Test', () => { segments: [ { segmentType: 'Text', - text: 'test—tes', + text: 'test—test', format: {}, isSelected: undefined, }, { segmentType: 'Text', - text: 't', + text: ' ', format: {}, isSelected: undefined, }, + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, ], format: {}, }, @@ -1129,9 +1139,14 @@ describe('Content Model Auto Format Plugin Test', () => { segments: [ { segmentType: 'Text', - text: 'test--test', + text: 'test--test ', format: {}, }, + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, ], format: {}, }, @@ -1163,8 +1178,13 @@ describe('Content Model Auto Format Plugin Test', () => { segments: [ { segmentType: 'Text', - text: 'test--test', + text: 'test--test ', + format: {}, + }, + { + segmentType: 'SelectionMarker', format: {}, + isSelected: true, }, ], format: {}, From 978a819602f1934f0d8a8c65db53ce19d44da8f2 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Tue, 29 Oct 2024 16:03:47 -0300 Subject: [PATCH 03/30] images in word table --- .../roosterjs-content-model-api/lib/index.ts | 1 + .../lib/modelApi/common/queryContentModel.ts | 117 ++ .../modelApi/common/queryContentModelTest.ts | 1796 +++++++++++++++++ .../lib/imageEdit/utils/findEditingImage.ts | 79 +- 4 files changed, 1932 insertions(+), 61 deletions(-) create mode 100644 packages/roosterjs-content-model-api/lib/modelApi/common/queryContentModel.ts create mode 100644 packages/roosterjs-content-model-api/test/modelApi/common/queryContentModelTest.ts diff --git a/packages/roosterjs-content-model-api/lib/index.ts b/packages/roosterjs-content-model-api/lib/index.ts index 9010a248c90..2f1558ac367 100644 --- a/packages/roosterjs-content-model-api/lib/index.ts +++ b/packages/roosterjs-content-model-api/lib/index.ts @@ -60,3 +60,4 @@ export { setModelIndentation } from './modelApi/block/setModelIndentation'; export { matchLink } from './modelApi/link/matchLink'; export { promoteLink } from './modelApi/link/promoteLink'; export { getListAnnounceData } from './modelApi/list/getListAnnounceData'; +export { queryContentModel, QueryContentModelOptions } from './modelApi/common/queryContentModel'; diff --git a/packages/roosterjs-content-model-api/lib/modelApi/common/queryContentModel.ts b/packages/roosterjs-content-model-api/lib/modelApi/common/queryContentModel.ts new file mode 100644 index 00000000000..0ff93545342 --- /dev/null +++ b/packages/roosterjs-content-model-api/lib/modelApi/common/queryContentModel.ts @@ -0,0 +1,117 @@ +import type { + ContentModelBlockType, + ContentModelSegmentType, + ReadonlyContentModelBlock, + ReadonlyContentModelBlockGroup, + ReadonlyContentModelParagraph, + ReadonlyContentModelSegment, + ReadonlyContentModelTable, +} from 'roosterjs-content-model-types'; + +/** + * Options for queryContentModel + */ +export interface QueryContentModelOptions { + /** + * The type of block to query @default 'Paragraph' + */ + type?: ContentModelBlockType; + + /** + * The type of segment to query + */ + segmentType?: ContentModelSegmentType; + + /** + * Optional selector to filter the blocks/segments + */ + selector?: (element: T) => boolean; + + /** + * True to return the first block only, false to return all blocks + */ + findFirstOnly?: boolean; +} + +/** + * Query content model blocks or segments + * @param group The block group to query + * @param options The query option + */ +export function queryContentModel< + T extends ReadonlyContentModelBlock | ReadonlyContentModelSegment +>(group: ReadonlyContentModelBlockGroup, options: QueryContentModelOptions): T[] { + const elements: T[] = []; + const searchOptions = options.type ? options : { ...options, type: 'Paragraph' }; + const { type, segmentType, selector, findFirstOnly } = searchOptions; + + for (let i = 0; i < group.blocks.length; i++) { + if (findFirstOnly && elements.length > 0) { + break; + } + const block = group.blocks[i]; + switch (block.blockType) { + case 'BlockGroup': + if (type == block.blockType && (!selector || selector(block as T))) { + elements.push(block as T); + } + const blockGroupsResults = queryContentModel(block, options); + elements.push(...(blockGroupsResults as T[])); + break; + case 'Table': + if (type == block.blockType && (!selector || selector(block as T))) { + elements.push(block as T); + } + const tableResults = searchInTables(block, options); + elements.push(...(tableResults as T[])); + break; + case 'Divider': + case 'Entity': + if (type == block.blockType && (!selector || selector(block as T))) { + elements.push(block as T); + } + break; + case 'Paragraph': + if (type == block.blockType) { + if (!segmentType && (!selector || selector(block as T))) { + elements.push(block as T); + } else if (segmentType) { + const segments = searchInParagraphs(block, segmentType, selector); + elements.push(...(segments as T[])); + } + } + break; + } + } + + return elements; +} + +function searchInTables( + table: ReadonlyContentModelTable, + options: QueryContentModelOptions +): T[] { + const blocks: T[] = []; + for (const row of table.rows) { + for (const cell of row.cells) { + const items = queryContentModel(cell, options); + + blocks.push(...items); + } + } + return blocks; +} + +function searchInParagraphs

( + block: ReadonlyContentModelParagraph, + segmentType: ContentModelSegmentType, + selector?: (element: P) => boolean +): P[] { + const segments: P[] = []; + for (const segment of block.segments) { + if (segment.segmentType == segmentType && (!selector || selector(segment as P))) { + segments.push(segment as P); + } + } + return segments; +} diff --git a/packages/roosterjs-content-model-api/test/modelApi/common/queryContentModelTest.ts b/packages/roosterjs-content-model-api/test/modelApi/common/queryContentModelTest.ts new file mode 100644 index 00000000000..9306f1ead08 --- /dev/null +++ b/packages/roosterjs-content-model-api/test/modelApi/common/queryContentModelTest.ts @@ -0,0 +1,1796 @@ +import { queryContentModel } from '../../../lib/modelApi/common/queryContentModel'; +import { + ReadonlyContentModelBlockGroup, + ReadonlyContentModelImage, + ReadonlyContentModelListItem, + ReadonlyContentModelParagraph, + ReadonlyContentModelTable, +} from 'roosterjs-content-model-types'; + +describe('queryContentModel', () => { + it('should return empty array if no blocks', () => { + // Arrange + const group: ReadonlyContentModelBlockGroup = { + blockGroupType: 'Document', + blocks: [], + }; + + // Act + const result = queryContentModel(group, {}); + + // Assert + expect(result).toEqual([]); + }); + + it('should return empty array if no blocks match the type', () => { + // Arrange + const group: ReadonlyContentModelBlockGroup = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + segmentFormat: {}, + }, + ], + }; + + // Act + const result = queryContentModel(group, { type: 'Table' }); + + // Assert + expect(result).toEqual([]); + }); + + it('should return blocks that match the type', () => { + // Arrange + const group: ReadonlyContentModelBlockGroup = { + blockGroupType: 'Document', + blocks: [ + { + widths: [120, 120], + rows: [ + { + height: 22, + cells: [ + { + spanAbove: false, + spanLeft: false, + isHeader: false, + blockGroupType: 'TableCell', + blocks: [ + { + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + dataset: {}, + }, + { + spanAbove: false, + spanLeft: false, + isHeader: false, + blockGroupType: 'TableCell', + blocks: [ + { + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + dataset: {}, + }, + ], + format: {}, + }, + { + height: 22, + cells: [ + { + spanAbove: false, + spanLeft: false, + isHeader: false, + blockGroupType: 'TableCell', + blocks: [ + { + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + dataset: {}, + }, + { + spanAbove: false, + spanLeft: false, + isHeader: false, + blockGroupType: 'TableCell', + blocks: [ + { + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + dataset: {}, + }, + ], + format: {}, + }, + ], + blockType: 'Table', + format: { + useBorderBox: true, + borderCollapse: true, + }, + dataset: { + editingInfo: + '{"topBorderColor":"#ABABAB","bottomBorderColor":"#ABABAB","verticalBorderColor":"#ABABAB","hasHeaderRow":false,"hasFirstColumn":false,"hasBandedRows":false,"hasBandedColumns":false,"bgColorEven":null,"bgColorOdd":"#ABABAB20","headerRowColor":"#ABABAB","tableBorderFormat":0,"verticalAlign":"top"}', + }, + }, + { + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + segmentFormat: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + }, + blockType: 'Paragraph', + format: {}, + }, + { + segments: [ + { + text: 'Test', + segmentType: 'Text', + format: {}, + }, + { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + { + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + }; + + const expected: ReadonlyContentModelTable[] = [ + { + widths: [120, 120], + rows: [ + { + height: 22, + cells: [ + { + spanAbove: false, + spanLeft: false, + isHeader: false, + blockGroupType: 'TableCell', + blocks: [ + { + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + dataset: {}, + }, + { + spanAbove: false, + spanLeft: false, + isHeader: false, + blockGroupType: 'TableCell', + blocks: [ + { + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + dataset: {}, + }, + ], + format: {}, + }, + { + height: 22, + cells: [ + { + spanAbove: false, + spanLeft: false, + isHeader: false, + blockGroupType: 'TableCell', + blocks: [ + { + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + dataset: {}, + }, + { + spanAbove: false, + spanLeft: false, + isHeader: false, + blockGroupType: 'TableCell', + blocks: [ + { + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + dataset: {}, + }, + ], + format: {}, + }, + ], + blockType: 'Table', + format: { + useBorderBox: true, + borderCollapse: true, + }, + dataset: { + editingInfo: + '{"topBorderColor":"#ABABAB","bottomBorderColor":"#ABABAB","verticalBorderColor":"#ABABAB","hasHeaderRow":false,"hasFirstColumn":false,"hasBandedRows":false,"hasBandedColumns":false,"bgColorEven":null,"bgColorOdd":"#ABABAB20","headerRowColor":"#ABABAB","tableBorderFormat":0,"verticalAlign":"top"}', + }, + }, + ]; + + // Act + const result = queryContentModel(group, { type: 'Table' }); + + // Assert + expect(result).toEqual(expected); + }); + + it('should return blocks that match the type and selector', () => { + const paragraph: ReadonlyContentModelParagraph = { + segments: [ + { + text: 'Test', + segmentType: 'Text', + format: {}, + }, + { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }; + + // Arrange + const group: ReadonlyContentModelBlockGroup = { + blockGroupType: 'Document', + blocks: [ + { + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + paragraph, + { + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + }; + + // Act + const result = queryContentModel(group, { + type: 'Paragraph', + + selector: block => block.segments.length == 2, + }); + + // Assert + expect(result).toEqual([paragraph]); + }); + + it('should return first segment that match the type and selector', () => { + const image: ReadonlyContentModelImage = { + src: + 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAsJCQcJCQcJCQkJCwkJCQkJCQsJCwsMCwsLDA0QDB...', + isSelectedAsImageSelection: true, + segmentType: 'Image', + isSelected: true, + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + id: 'image_0', + maxWidth: '1492px', + }, + dataset: { + isEditing: 'true', + }, + }; + const model: ReadonlyContentModelBlockGroup = { + blockGroupType: 'Document', + blocks: [ + { + widths: [120, 153], + rows: [ + { + height: 157, + cells: [ + { + spanAbove: false, + spanLeft: false, + isHeader: false, + blockGroupType: 'TableCell', + blocks: [ + { + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + dataset: {}, + }, + { + spanAbove: false, + spanLeft: false, + isHeader: false, + blockGroupType: 'TableCell', + blocks: [ + { + segments: [image], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + dataset: {}, + }, + ], + format: {}, + }, + ], + blockType: 'Table', + format: { + useBorderBox: true, + borderCollapse: true, + }, + dataset: { + editingInfo: + '{"topBorderColor":"#ABABAB","bottomBorderColor":"#ABABAB","verticalBorderColor":"#ABABAB","hasHeaderRow":false,"hasFirstColumn":false,"hasBandedRows":false,"hasBandedColumns":false,"bgColorEven":null,"bgColorOdd":"#ABABAB20","headerRowColor":"#ABABAB","tableBorderFormat":0,"verticalAlign":"top"}', + }, + }, + { + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + { + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + }; + const result = queryContentModel(model, { + segmentType: 'Image', + selector: (segment: ReadonlyContentModelImage) => !!segment.dataset.isEditing, + findFirstOnly: true, + }); + expect(result).toEqual([image]); + }); + + it('should return all tables that match the type and selector', () => { + const model: ReadonlyContentModelBlockGroup = { + blockGroupType: 'Document', + blocks: [ + { + widths: [120, 120], + rows: [ + { + height: 22, + cells: [ + { + spanAbove: false, + spanLeft: false, + isHeader: false, + blockGroupType: 'TableCell', + blocks: [ + { + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + dataset: {}, + }, + { + spanAbove: false, + spanLeft: false, + isHeader: false, + blockGroupType: 'TableCell', + blocks: [ + { + segments: [ + { + text: 'Test', + segmentType: 'Text', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + dataset: {}, + }, + ], + format: {}, + }, + ], + blockType: 'Table', + format: { + useBorderBox: true, + borderCollapse: true, + }, + dataset: { + editingInfo: + '{"topBorderColor":"#ABABAB","bottomBorderColor":"#ABABAB","verticalBorderColor":"#ABABAB","hasHeaderRow":false,"hasFirstColumn":false,"hasBandedRows":false,"hasBandedColumns":false,"bgColorEven":null,"bgColorOdd":"#ABABAB20","headerRowColor":"#ABABAB","tableBorderFormat":0,"verticalAlign":"top"}', + }, + }, + { + isImplicit: false, + segments: [ + { + segmentType: 'Br', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + }, + ], + segmentFormat: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + blockType: 'Paragraph', + format: {}, + }, + { + segments: [ + { + text: 'not table', + segmentType: 'Text', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + }, + { + isSelected: true, + segmentType: 'SelectionMarker', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + }, + ], + segmentFormat: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + blockType: 'Paragraph', + format: {}, + }, + { + segments: [ + { + segmentType: 'Br', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + }, + ], + segmentFormat: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + blockType: 'Paragraph', + format: {}, + }, + { + widths: [120], + rows: [ + { + height: 22, + cells: [ + { + spanAbove: false, + spanLeft: false, + isHeader: false, + blockGroupType: 'TableCell', + blocks: [ + { + segments: [ + { + text: 'table 2', + segmentType: 'Text', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + }, + ], + segmentFormat: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + blockType: 'Paragraph', + format: {}, + }, + ], + format: { + useBorderBox: true, + borderTop: '1px solid #ABABAB', + borderRight: '1px solid #ABABAB', + borderBottom: '1px solid #ABABAB', + borderLeft: '1px solid #ABABAB', + verticalAlign: 'top', + }, + dataset: {}, + }, + ], + format: {}, + }, + ], + blockType: 'Table', + format: { + borderCollapse: true, + useBorderBox: true, + }, + dataset: { + editingInfo: + '{"topBorderColor":"#ABABAB","bottomBorderColor":"#ABABAB","verticalBorderColor":"#ABABAB","hasHeaderRow":false,"hasFirstColumn":false,"hasBandedRows":false,"hasBandedColumns":false,"bgColorEven":null,"bgColorOdd":"#ABABAB20","headerRowColor":"#ABABAB","tableBorderFormat":0,"verticalAlign":"top"}', + }, + }, + { + segments: [ + { + segmentType: 'Br', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + }, + ], + segmentFormat: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + blockType: 'Paragraph', + format: {}, + }, + { + segments: [ + { + segmentType: 'Br', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + }, + }, + ], + segmentFormat: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + }, + blockType: 'Paragraph', + format: {}, + }, + { + segments: [ + { + segmentType: 'Br', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + }, + }, + ], + segmentFormat: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + }, + blockType: 'Paragraph', + format: {}, + }, + ], + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + }; + + const expected: ReadonlyContentModelTable[] = [ + { + widths: [120, 120], + rows: [ + { + height: 22, + cells: [ + { + spanAbove: false, + spanLeft: false, + isHeader: false, + blockGroupType: 'TableCell', + blocks: [ + { + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + dataset: {}, + }, + { + spanAbove: false, + spanLeft: false, + isHeader: false, + blockGroupType: 'TableCell', + blocks: [ + { + segments: [ + { + text: 'Test', + segmentType: 'Text', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + dataset: {}, + }, + ], + format: {}, + }, + ], + blockType: 'Table', + format: { + useBorderBox: true, + borderCollapse: true, + }, + dataset: { + editingInfo: + '{"topBorderColor":"#ABABAB","bottomBorderColor":"#ABABAB","verticalBorderColor":"#ABABAB","hasHeaderRow":false,"hasFirstColumn":false,"hasBandedRows":false,"hasBandedColumns":false,"bgColorEven":null,"bgColorOdd":"#ABABAB20","headerRowColor":"#ABABAB","tableBorderFormat":0,"verticalAlign":"top"}', + }, + }, + { + widths: [120], + rows: [ + { + height: 22, + cells: [ + { + spanAbove: false, + spanLeft: false, + isHeader: false, + blockGroupType: 'TableCell', + blocks: [ + { + segments: [ + { + text: 'table 2', + segmentType: 'Text', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + }, + ], + segmentFormat: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + blockType: 'Paragraph', + format: {}, + }, + ], + format: { + useBorderBox: true, + borderTop: '1px solid #ABABAB', + borderRight: '1px solid #ABABAB', + borderBottom: '1px solid #ABABAB', + borderLeft: '1px solid #ABABAB', + verticalAlign: 'top', + }, + dataset: {}, + }, + ], + format: {}, + }, + ], + blockType: 'Table', + format: { + borderCollapse: true, + useBorderBox: true, + }, + dataset: { + editingInfo: + '{"topBorderColor":"#ABABAB","bottomBorderColor":"#ABABAB","verticalBorderColor":"#ABABAB","hasHeaderRow":false,"hasFirstColumn":false,"hasBandedRows":false,"hasBandedColumns":false,"bgColorEven":null,"bgColorOdd":"#ABABAB20","headerRowColor":"#ABABAB","tableBorderFormat":0,"verticalAlign":"top"}', + }, + }, + ]; + const result = queryContentModel(model, { type: 'Table' }); + expect(result).toEqual(expected); + }); + + it('should return all tables in list', () => { + const table: ReadonlyContentModelTable = { + widths: [120], + rows: [ + { + height: 22, + cells: [ + { + spanAbove: false, + spanLeft: false, + isHeader: false, + blockGroupType: 'TableCell', + blocks: [ + { + segments: [ + { + text: 'table 2', + segmentType: 'Text', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + dataset: {}, + }, + ], + format: {}, + }, + ], + blockType: 'Table', + format: { + useBorderBox: true, + borderCollapse: true, + }, + dataset: { + editingInfo: + '{"topBorderColor":"#ABABAB","bottomBorderColor":"#ABABAB","verticalBorderColor":"#ABABAB","hasHeaderRow":false,"hasFirstColumn":false,"hasBandedRows":false,"hasBandedColumns":false,"bgColorEven":null,"bgColorOdd":"#ABABAB20","headerRowColor":"#ABABAB","tableBorderFormat":0,"verticalAlign":"top"}', + }, + }; + + const model: ReadonlyContentModelBlockGroup = { + blockGroupType: 'Document', + blocks: [ + { + isImplicit: true, + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + { + formatHolder: { + isSelected: false, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'UL', + format: { + listStyleType: 'disc', + }, + dataset: { + editingInfo: '{"applyListStyleFromLevel":true}', + }, + }, + ], + blockType: 'BlockGroup', + format: {}, + blockGroupType: 'ListItem', + blocks: [ + { + isImplicit: true, + segments: [ + { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + table, + ], + }, + { + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + { + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + { + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + }; + + const expected: ReadonlyContentModelTable[] = [table]; + const result = queryContentModel(model, { type: 'Table' }); + expect(result).toEqual(expected); + }); + + it('should return all lists', () => { + const model: ReadonlyContentModelBlockGroup = { + blockGroupType: 'Document', + blocks: [ + { + formatHolder: { + isSelected: false, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'UL', + format: { + startNumberOverride: 1, + listStyleType: 'disc', + }, + dataset: { + editingInfo: + '{"applyListStyleFromLevel":false,"unorderedStyleType":1}', + }, + }, + ], + blockType: 'BlockGroup', + format: {}, + blockGroupType: 'ListItem', + blocks: [ + { + segments: [ + { + text: 'table', + segmentType: 'Text', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + }, + { + segments: [ + { + text: 'test', + segmentType: 'Text', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + { + formatHolder: { + isSelected: false, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'OL', + format: { + startNumberOverride: 1, + listStyleType: 'decimal', + }, + dataset: { + editingInfo: + '{"applyListStyleFromLevel":false,"orderedStyleType":1}', + }, + }, + ], + blockType: 'BlockGroup', + format: {}, + blockGroupType: 'ListItem', + blocks: [ + { + segments: [ + { + text: 'test', + segmentType: 'Text', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + }, + { + formatHolder: { + isSelected: false, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'OL', + format: { + listStyleType: 'decimal', + }, + dataset: { + editingInfo: + '{"applyListStyleFromLevel":false,"orderedStyleType":1}', + }, + }, + ], + blockType: 'BlockGroup', + format: {}, + blockGroupType: 'ListItem', + blocks: [ + { + segments: [ + { + text: 'test', + segmentType: 'Text', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + }, + { + segments: [ + { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + }; + + const listExpected: ReadonlyContentModelListItem[] = [ + { + formatHolder: { + isSelected: false, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'UL', + format: { + startNumberOverride: 1, + listStyleType: 'disc', + }, + dataset: { + editingInfo: '{"applyListStyleFromLevel":false,"unorderedStyleType":1}', + }, + }, + ], + blockType: 'BlockGroup', + format: {}, + blockGroupType: 'ListItem', + blocks: [ + { + segments: [ + { + text: 'table', + segmentType: 'Text', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + }, + { + formatHolder: { + isSelected: false, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'OL', + format: { + startNumberOverride: 1, + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"applyListStyleFromLevel":false,"orderedStyleType":1}', + }, + }, + ], + blockType: 'BlockGroup', + format: {}, + blockGroupType: 'ListItem', + blocks: [ + { + segments: [ + { + text: 'test', + segmentType: 'Text', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + }, + { + formatHolder: { + isSelected: false, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'OL', + format: { + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"applyListStyleFromLevel":false,"orderedStyleType":1}', + }, + }, + ], + blockType: 'BlockGroup', + format: {}, + blockGroupType: 'ListItem', + blocks: [ + { + segments: [ + { + text: 'test', + segmentType: 'Text', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + }, + ]; + + const result = queryContentModel(model, { + type: 'BlockGroup', + selector: block => block.blockGroupType == 'ListItem', + }); + expect(result).toEqual(listExpected); + }); + + it('should return all images', () => { + const model: ReadonlyContentModelBlockGroup = { + blockGroupType: 'Document', + blocks: [ + { + segments: [ + { + src: + 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAsJCQcJCQcJCQkJCwkJCQkJCQsJCwsMCwsLDA0QDB...', + segmentType: 'Image', + format: {}, + dataset: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + { + formatHolder: { + isSelected: false, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'OL', + format: { + startNumberOverride: 1, + listStyleType: 'decimal', + }, + dataset: { + editingInfo: + '{"applyListStyleFromLevel":false,"orderedStyleType":1}', + }, + }, + ], + blockType: 'BlockGroup', + format: {}, + blockGroupType: 'ListItem', + blocks: [ + { + segments: [ + { + text: 'test', + segmentType: 'Text', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + }, + { + formatHolder: { + isSelected: false, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'OL', + format: { + listStyleType: 'decimal', + }, + dataset: { + editingInfo: + '{"applyListStyleFromLevel":false,"orderedStyleType":1}', + }, + }, + ], + blockType: 'BlockGroup', + format: {}, + blockGroupType: 'ListItem', + blocks: [ + { + segments: [ + { + src: + 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAsJCQcJCQcJCQkJCwkJCQkJCQsJCwsMCwsLDA0QDB...', + segmentType: 'Image', + format: {}, + dataset: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + }, + { + widths: [153], + rows: [ + { + height: 157, + cells: [ + { + spanAbove: false, + spanLeft: false, + isHeader: false, + blockGroupType: 'TableCell', + blocks: [ + { + segments: [ + { + src: + 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAsJCQcJCQcJCQkJCwkJCQkJCQsJCwsMCwsLDA0QDB...', + segmentType: 'Image', + format: {}, + dataset: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + dataset: {}, + }, + ], + format: {}, + }, + ], + blockType: 'Table', + format: {}, + dataset: { + editingInfo: + '{"topBorderColor":"#ABABAB","bottomBorderColor":"#ABABAB","verticalBorderColor":"#ABABAB","hasHeaderRow":false,"hasFirstColumn":false,"hasBandedRows":false,"hasBandedColumns":false,"bgColorEven":null,"bgColorOdd":"#ABABAB20","headerRowColor":"#ABABAB","tableBorderFormat":0,"verticalAlign":"top"}', + }, + }, + { + isImplicit: true, + segments: [ + { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + { + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + { + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + { + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + { + formatHolder: { + isSelected: false, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'OL', + format: { + listStyleType: 'decimal', + displayForDummyItem: 'block', + }, + dataset: { + editingInfo: + '{"applyListStyleFromLevel":false,"orderedStyleType":1}', + }, + }, + ], + blockType: 'BlockGroup', + format: {}, + blockGroupType: 'ListItem', + blocks: [ + { + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + }, + { + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + }; + + const expected: ReadonlyContentModelImage[] = [ + { + src: + 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAsJCQcJCQcJCQkJCwkJCQkJCQsJCwsMCwsLDA0QDB...', + segmentType: 'Image', + format: {}, + dataset: {}, + }, + { + src: + 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAsJCQcJCQcJCQkJCwkJCQkJCQsJCwsMCwsLDA0QDB...', + segmentType: 'Image', + format: {}, + dataset: {}, + }, + { + src: + 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAsJCQcJCQcJCQkJCwkJCQkJCQsJCwsMCwsLDA0QDB...', + segmentType: 'Image', + format: {}, + dataset: {}, + }, + ]; + + const result = queryContentModel(model, { + segmentType: 'Image', + }); + expect(result).toEqual(expected); + }); + + it('should return image from a word online table', () => { + const model: ReadonlyContentModelBlockGroup = { + blockGroupType: 'Document', + blocks: [ + { + widths: [], + rows: [ + { + height: 0, + cells: [ + { + spanAbove: false, + spanLeft: false, + isHeader: false, + blockGroupType: 'TableCell', + blocks: [ + { + tagName: 'div', + blockType: 'BlockGroup', + format: { + textAlign: 'start', + marginLeft: '0px', + marginRight: '0px', + marginTop: '0px', + marginBottom: '0px', + paddingRight: '7px', + paddingLeft: '7px', + }, + blockGroupType: 'FormatContainer', + blocks: [ + { + segments: [ + { + text: ' ', + segmentType: 'Text', + format: { + fontFamily: + 'Aptos, Aptos_EmbeddedFont, Aptos_MSFontService, sans-serif', + fontSize: '12pt', + textColor: 'rgb(0, 0, 0)', + italic: false, + fontWeight: 'normal', + lineHeight: '18px', + }, + }, + ], + segmentFormat: { + italic: false, + fontWeight: 'normal', + textColor: 'rgb(0, 0, 0)', + }, + blockType: 'Paragraph', + format: { + textAlign: 'start', + direction: 'ltr', + marginLeft: '0px', + marginRight: '0px', + textIndent: '0px', + whiteSpace: 'pre-wrap', + marginTop: '0px', + marginBottom: '0px', + }, + decorator: { + tagName: 'p', + format: {}, + }, + }, + ], + }, + ], + format: { + textAlign: 'start', + borderTop: '1px solid', + borderRight: '1px solid', + borderBottom: '1px solid', + borderLeft: '1px solid', + verticalAlign: 'top', + width: '312px', + }, + dataset: { + celllook: '0', + }, + }, + { + spanAbove: false, + spanLeft: false, + isHeader: false, + blockGroupType: 'TableCell', + blocks: [ + { + tagName: 'div', + blockType: 'BlockGroup', + format: { + textAlign: 'start', + marginLeft: '0px', + marginRight: '0px', + marginTop: '0px', + marginBottom: '0px', + paddingRight: '7px', + paddingLeft: '7px', + }, + blockGroupType: 'FormatContainer', + blocks: [ + { + segments: [ + { + text: ' ', + segmentType: 'Text', + format: { + fontFamily: + 'Aptos, Aptos_EmbeddedFont, Aptos_MSFontService, sans-serif', + fontSize: '12pt', + textColor: 'rgb(0, 0, 0)', + italic: false, + fontWeight: 'normal', + lineHeight: '18px', + }, + }, + { + src: + 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAsJCQcJCQcJCQkJCwkJCQkJCQsJCwsMCwsLDA0QDB...', + isSelectedAsImageSelection: true, + segmentType: 'Image', + isSelected: true, + format: { + fontFamily: + 'Aptos, Aptos_EmbeddedFont, Aptos_MSFontService, sans-serif', + fontSize: '12pt', + textColor: 'rgb(0, 0, 0)', + italic: false, + fontWeight: 'normal', + lineHeight: '18px', + backgroundColor: '', + maxWidth: '1492px', + id: 'image_0', + }, + dataset: { + isEditing: 'true', + }, + }, + ], + segmentFormat: { + italic: false, + fontWeight: 'normal', + textColor: 'rgb(0, 0, 0)', + }, + blockType: 'Paragraph', + format: { + textAlign: 'start', + direction: 'ltr', + marginLeft: '0px', + marginRight: '0px', + textIndent: '0px', + whiteSpace: 'pre-wrap', + marginTop: '0px', + marginBottom: '0px', + }, + decorator: { + tagName: 'p', + format: {}, + }, + }, + ], + }, + ], + format: { + textAlign: 'start', + borderTop: '1px solid', + borderRight: '1px solid', + borderBottom: '1px solid', + borderLeft: '1px solid', + verticalAlign: 'top', + width: '312px', + }, + dataset: { + celllook: '0', + }, + }, + ], + format: {}, + }, + ], + blockType: 'Table', + format: { + textAlign: 'start', + marginTop: '0px', + marginRight: '0px', + marginBottom: '0px', + marginLeft: '0px', + width: '0px', + tableLayout: 'fixed', + borderCollapse: true, + }, + dataset: { + tablelook: '1696', + tablestyle: 'MsoTableGrid', + }, + }, + { + segments: [ + { + segmentType: 'Br', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + }, + }, + ], + segmentFormat: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + }, + blockType: 'Paragraph', + format: {}, + }, + ], + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + }; + const image: ReadonlyContentModelImage = { + src: + 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAsJCQcJCQcJCQkJCwkJCQkJCQsJCwsMCwsLDA0QDB...', + isSelectedAsImageSelection: true, + segmentType: 'Image', + isSelected: true, + format: { + fontFamily: 'Aptos, Aptos_EmbeddedFont, Aptos_MSFontService, sans-serif', + fontSize: '12pt', + textColor: 'rgb(0, 0, 0)', + italic: false, + fontWeight: 'normal', + lineHeight: '18px', + backgroundColor: '', + maxWidth: '1492px', + id: 'image_0', + }, + dataset: { + isEditing: 'true', + }, + }; + const result = queryContentModel(model, { + segmentType: 'Image', + findFirstOnly: true, + selector: (segment: ReadonlyContentModelImage) => !!segment.dataset.isEditing, + }); + expect(result).toEqual([image]); + }); +}); diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/findEditingImage.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/findEditingImage.ts index 4fff6e96286..b49de71efcb 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/findEditingImage.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/findEditingImage.ts @@ -1,6 +1,7 @@ +import { queryContentModel } from 'roosterjs-content-model-api'; import type { ReadonlyContentModelBlockGroup, - ReadonlyContentModelTable, + ReadonlyContentModelParagraph, } from 'roosterjs-content-model-types'; import type { ImageAndParagraph } from '../types/ImageAndParagraph'; @@ -11,65 +12,21 @@ export function findEditingImage( group: ReadonlyContentModelBlockGroup, imageId?: string ): ImageAndParagraph | null { - for (let i = 0; i < group.blocks.length; i++) { - const block = group.blocks[i]; - - switch (block.blockType) { - case 'BlockGroup': - const result = findEditingImage(block, imageId); - - if (result) { - return result; - } - break; - - case 'Paragraph': - for (let j = 0; j < block.segments.length; j++) { - const segment = block.segments[j]; - switch (segment.segmentType) { - case 'Image': - if ( - (imageId && segment.format.id == imageId) || - segment.dataset.isEditing - ) { - return { - paragraph: block, - image: segment, - }; - } - break; - - case 'General': - const result = findEditingImage(segment, imageId); - - if (result) { - return result; - } - break; - } + let imageAndParagraph: ImageAndParagraph | null = null; + queryContentModel(group, { + selector: (paragraph: ReadonlyContentModelParagraph) => { + for (const segment of paragraph.segments) { + if ( + segment.segmentType == 'Image' && + ((imageId && segment.format.id == imageId) || segment.dataset.isEditing) + ) { + imageAndParagraph = { image: segment, paragraph }; + break; } - break; - case 'Table': - const imageInTable = findEditingImageOnTable(block, imageId); - - if (imageInTable) { - return imageInTable; - } - break; - } - } - - return null; -} - -const findEditingImageOnTable = (table: ReadonlyContentModelTable, imageId?: string) => { - for (const row of table.rows) { - for (const cell of row.cells) { - const result = findEditingImage(cell, imageId); - if (result) { - return result; } - } - } - return null; -}; + return !!imageAndParagraph; + }, + findFirstOnly: true, + }); + return imageAndParagraph; +} From 2a56e9a27341b73c973db03aa4002899991997f7 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Tue, 29 Oct 2024 16:39:53 -0300 Subject: [PATCH 04/30] nit --- .../lib/imageEdit/utils/findEditingImage.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/findEditingImage.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/findEditingImage.ts index b49de71efcb..35bc9f8140c 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/findEditingImage.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/findEditingImage.ts @@ -21,12 +21,13 @@ export function findEditingImage( ((imageId && segment.format.id == imageId) || segment.dataset.isEditing) ) { imageAndParagraph = { image: segment, paragraph }; - break; + return true; } } - return !!imageAndParagraph; + return false; }, findFirstOnly: true, }); + return imageAndParagraph; } From eeec49fa31a721a121151509d5daf86a574addeb Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Tue, 29 Oct 2024 13:53:51 -0700 Subject: [PATCH 05/30] Merge text node and segments (#2846) * Merge text segments * Fix test * merge node * fix build and test * add test * Add test * fix test --- .../lib/corePlugin/cache/domIndexerImpl.ts | 20 + .../command/paste/mergePasteContentTest.ts | 54 +- .../corePlugin/cache/domIndexerImplTest.ts | 97 +++ .../lib/modelApi/common/normalizeParagraph.ts | 61 +- .../lib/modelToDom/contentModelToDom.ts | 5 +- .../modelToDom/handlers/handleParagraph.ts | 2 +- .../lib/modelToDom/optimizers/optimize.ts | 59 +- .../domToModel/processors/brProcessorTest.ts | 1 + .../processors/entityProcessorTest.ts | 1 + .../processors/generalProcessorTest.ts | 1 + .../processors/imageProcessorTest.ts | 1 + .../processors/tableProcessorTest.ts | 1 + .../processors/textProcessorTest.ts | 3 + .../common/normalizeContentModelTest.ts | 7 +- .../modelApi/common/normalizeParagraphTest.ts | 682 +++++++++++++++++- .../test/modelApi/editing/mergeModelTest.ts | 39 +- .../test/modelToDom/contentModelToDomTest.ts | 70 ++ .../handlers/handleParagraphTest.ts | 345 ++++++++- .../modelToDom/handlers/handleTableTest.ts | 1 + .../modelToDom/optimizers/optimizeTest.ts | 6 +- .../paste/processPastedContentFromWacTest.ts | 290 +------- ...processPastedContentFromWordDesktopTest.ts | 28 +- .../lib/context/DomIndexer.ts | 9 + .../lib/context/ModelToDomSelectionContext.ts | 5 + 24 files changed, 1381 insertions(+), 407 deletions(-) diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/cache/domIndexerImpl.ts b/packages/roosterjs-content-model-core/lib/corePlugin/cache/domIndexerImpl.ts index 001b80a0fde..dd96fd509ae 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/cache/domIndexerImpl.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/cache/domIndexerImpl.ts @@ -145,6 +145,11 @@ function getIndexedTableItem(element: HTMLTableElement): TableItem | null { } } +// Make a node not indexed. Do not export this function since we should not let code outside here know this detail +function unindex(node: Partial) { + delete node.__roosterjsContentModel; +} + /** * @internal * Implementation of DomIndexer @@ -197,6 +202,21 @@ export class DomIndexerImpl implements DomIndexer { this.onBlockEntityDelimiter(entity.wrapper.nextSibling, entity, group); } + onMergeText(targetText: Text, sourceText: Text) { + if (isIndexedSegment(targetText) && isIndexedSegment(sourceText)) { + if (targetText.nextSibling == sourceText) { + targetText.__roosterjsContentModel.segments.push( + ...sourceText.__roosterjsContentModel.segments + ); + + unindex(sourceText); + } + } else { + unindex(sourceText); + unindex(targetText); + } + } + reconcileSelection( model: ContentModelDocument, newSelection: DOMSelection, diff --git a/packages/roosterjs-content-model-core/test/command/paste/mergePasteContentTest.ts b/packages/roosterjs-content-model-core/test/command/paste/mergePasteContentTest.ts index 71efb5798c0..2810be3d786 100644 --- a/packages/roosterjs-content-model-core/test/command/paste/mergePasteContentTest.ts +++ b/packages/roosterjs-content-model-core/test/command/paste/mergePasteContentTest.ts @@ -971,7 +971,7 @@ describe('mergePasteContent', () => { segments: [ { segmentType: 'Text', - text: 'Unformatted line', + text: 'Unformatted line\n', format: { fontSize: '14px', textColor: 'white', @@ -1149,15 +1149,7 @@ describe('mergePasteContent', () => { segments: [ { segmentType: 'Text', - text: 'Unformatted line', - format: { - fontSize: '14px', - textColor: 'white', - }, - }, - { - segmentType: 'Text', - text: '\n', + text: 'Unformatted line\n', format: { fontSize: '14px', textColor: 'white', @@ -1490,15 +1482,7 @@ describe('mergePasteContent', () => { segments: [ { segmentType: 'Text', - text: 'Inline text', - format: { - fontSize: '14px', - textColor: 'rgb(0,0,0)', - }, - }, - { - segmentType: 'Text', - text: '\n', + text: 'Inline text\n', format: { fontSize: '14px', textColor: 'rgb(0,0,0)', @@ -1553,15 +1537,7 @@ describe('mergePasteContent', () => { }, { segmentType: 'Text', - text: 'Inline text', - format: { - fontSize: '14px', - textColor: 'rgb(0,0,0)', - }, - }, - { - segmentType: 'Text', - text: '\n', + text: 'Inline text\n', format: { fontSize: '14px', textColor: 'rgb(0,0,0)', @@ -1629,16 +1605,7 @@ describe('mergePasteContent', () => { segments: [ { segmentType: 'Text', - text: 'Inline text', - format: { - fontFamily: 'Aptos', - fontSize: '14px', - textColor: 'white', - }, - }, - { - segmentType: 'Text', - text: '\n', + text: 'Inline text\n', format: { fontFamily: 'Aptos', fontSize: '14px', @@ -1686,16 +1653,7 @@ describe('mergePasteContent', () => { { segmentType: 'Text', text: 'Text in source', format: {} }, { segmentType: 'Text', - text: 'Inline text', - format: { - fontFamily: 'Aptos', - fontSize: '14px', - textColor: 'white', - }, - }, - { - segmentType: 'Text', - text: '\n', + text: 'Inline text\n', format: { fontFamily: 'Aptos', fontSize: '14px', diff --git a/packages/roosterjs-content-model-core/test/corePlugin/cache/domIndexerImplTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/cache/domIndexerImplTest.ts index 85c4310d0b6..a1a3b565a75 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/cache/domIndexerImplTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/cache/domIndexerImplTest.ts @@ -250,6 +250,103 @@ describe('domIndexerImpl.onBlockEntity', () => { }); }); +describe('domIndexImpl.onMergeText', () => { + it('Two unindexed node', () => { + const text1 = document.createTextNode('test1'); + const text2 = document.createTextNode('test1'); + const div = document.createElement('div'); + + div.appendChild(text1); + div.appendChild(text2); + + new DomIndexerImpl().onMergeText(text1, text2); + + expect(((text1 as Node) as IndexedSegmentNode).__roosterjsContentModel).toBeUndefined(); + expect(((text2 as Node) as IndexedSegmentNode).__roosterjsContentModel).toBeUndefined(); + }); + + it('One indexed node, one unindexed node', () => { + const text1 = document.createTextNode('test1'); + const text2 = document.createTextNode('test1'); + const div = document.createElement('div'); + + div.appendChild(text1); + div.appendChild(text2); + + ((text1 as Node) as IndexedSegmentNode).__roosterjsContentModel = { + paragraph: createParagraph(), + segments: [], + }; + + new DomIndexerImpl().onMergeText(text1, text2); + + expect(((text1 as Node) as IndexedSegmentNode).__roosterjsContentModel).toBeUndefined(); + expect(((text2 as Node) as IndexedSegmentNode).__roosterjsContentModel).toBeUndefined(); + }); + + it('Two separated indexed node', () => { + const text1 = document.createTextNode('test1'); + const text2 = document.createTextNode('test1'); + const div = document.createElement('div'); + + div.appendChild(text1); + div.appendChild(document.createElement('img')); + div.appendChild(text2); + + const text1Model = createText('test1'); + const text2Model = createText('test2'); + + ((text1 as Node) as IndexedSegmentNode).__roosterjsContentModel = { + paragraph: createParagraph(), + segments: [text1Model], + }; + ((text2 as Node) as IndexedSegmentNode).__roosterjsContentModel = { + paragraph: createParagraph(), + segments: [text2Model], + }; + + new DomIndexerImpl().onMergeText(text1, text2); + + expect(((text1 as Node) as IndexedSegmentNode).__roosterjsContentModel).toEqual({ + paragraph: createParagraph(), + segments: [text1Model], + }); + expect(((text2 as Node) as IndexedSegmentNode).__roosterjsContentModel).toEqual({ + paragraph: createParagraph(), + segments: [text2Model], + }); + }); + + it('Two continuous indexed node', () => { + const text1 = document.createTextNode('test1'); + const text2 = document.createTextNode('test1'); + const div = document.createElement('div'); + + div.appendChild(text1); + div.appendChild(text2); + + const text1Model = createText('test1'); + const text2Model = createText('test2'); + + ((text1 as Node) as IndexedSegmentNode).__roosterjsContentModel = { + paragraph: createParagraph(), + segments: [text1Model], + }; + ((text2 as Node) as IndexedSegmentNode).__roosterjsContentModel = { + paragraph: createParagraph(), + segments: [text2Model], + }; + + new DomIndexerImpl().onMergeText(text1, text2); + + expect(((text1 as Node) as IndexedSegmentNode).__roosterjsContentModel).toEqual({ + paragraph: createParagraph(), + segments: [text1Model, text2Model], + }); + expect(((text2 as Node) as IndexedSegmentNode).__roosterjsContentModel).toBeUndefined(); + }); +}); + describe('domIndexerImpl.reconcileSelection', () => { let setSelectionSpy: jasmine.Spy; let model: ContentModelDocument; diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/common/normalizeParagraph.ts b/packages/roosterjs-content-model-dom/lib/modelApi/common/normalizeParagraph.ts index c86efc34dae..6a760889ac2 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/common/normalizeParagraph.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/common/normalizeParagraph.ts @@ -2,12 +2,16 @@ import { areSameFormats } from '../../domToModel/utils/areSameFormats'; import { createBr } from '../creators/createBr'; import { isSegmentEmpty } from './isEmpty'; import { isWhiteSpacePreserved } from '../../domUtils/isWhiteSpacePreserved'; -import { mutateBlock, mutateSegment } from './mutate'; +import { mutateBlock, mutateSegment, mutateSegments } from './mutate'; import { normalizeAllSegments } from './normalizeSegment'; import type { ContentModelSegmentFormat, + ContentModelText, + ReadonlyContentModelCode, + ReadonlyContentModelLink, ReadonlyContentModelParagraph, ReadonlyContentModelSegment, + ReadonlyContentModelText, } from 'roosterjs-content-model-types'; /** @@ -47,9 +51,8 @@ export function normalizeParagraph(paragraph: ReadonlyContentModelParagraph) { } removeEmptyLinks(paragraph); - removeEmptySegments(paragraph); - + mergeTextSegments(paragraph); moveUpSegmentFormat(paragraph); } @@ -73,6 +76,58 @@ function removeEmptySegments(block: ReadonlyContentModelParagraph) { } } +function mergeTextSegments(block: ReadonlyContentModelParagraph) { + let lastText: ReadonlyContentModelText | null = null; + + for (let i = 0; i < block.segments.length; i++) { + const segment = block.segments[i]; + + if (segment.segmentType != 'Text') { + lastText = null; + } else if (!lastText || !segmentsWithSameFormat(lastText, segment)) { + lastText = segment; + } else { + const [mutableBlock, [mutableLastText]] = mutateSegments(block, [lastText, segment]); + + (mutableLastText as ContentModelText).text += segment.text; + mutableBlock.segments.splice(i, 1); + i--; + } + } +} + +function segmentsWithSameFormat( + seg1: ReadonlyContentModelSegment, + seg2: ReadonlyContentModelSegment +) { + return ( + !!seg1.isSelected == !!seg2.isSelected && + areSameFormats(seg1.format, seg2.format) && + areSameLinks(seg1.link, seg2.link) && + areSameCodes(seg1.code, seg2.code) + ); +} + +function areSameLinks( + link1: ReadonlyContentModelLink | undefined, + link2: ReadonlyContentModelLink | undefined +) { + return ( + (!link1 && !link2) || + (link1 && + link2 && + areSameFormats(link1.format, link2.format) && + areSameFormats(link1.dataset, link2.dataset)) + ); +} + +function areSameCodes( + code1: ReadonlyContentModelCode | undefined, + code2: ReadonlyContentModelCode | undefined +) { + return (!code1 && !code2) || (code1 && code2 && areSameFormats(code1.format, code2.format)); +} + function removeEmptyLinks(paragraph: ReadonlyContentModelParagraph) { const marker = paragraph.segments.find(x => x.segmentType == 'SelectionMarker'); if (marker) { diff --git a/packages/roosterjs-content-model-dom/lib/modelToDom/contentModelToDom.ts b/packages/roosterjs-content-model-dom/lib/modelToDom/contentModelToDom.ts index 131e7d68730..cfdde4f1520 100644 --- a/packages/roosterjs-content-model-dom/lib/modelToDom/contentModelToDom.ts +++ b/packages/roosterjs-content-model-dom/lib/modelToDom/contentModelToDom.ts @@ -83,7 +83,10 @@ function calcPosition( if (!pos.segment) { result = { container: pos.block, offset: 0 }; } else if (isNodeOfType(pos.segment, 'TEXT_NODE')) { - result = { container: pos.segment, offset: pos.segment.nodeValue?.length || 0 }; + result = { + container: pos.segment, + offset: pos.offset ?? pos.segment.nodeValue?.length ?? 0, + }; } else if (pos.segment.parentNode) { result = { container: pos.segment.parentNode, diff --git a/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleParagraph.ts b/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleParagraph.ts index 2ae14c63391..73054976b03 100644 --- a/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleParagraph.ts +++ b/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleParagraph.ts @@ -98,7 +98,7 @@ export const handleParagraph: ContentModelBlockHandler = handleSegments(); } - optimize(container); + optimize(container, context); // It is possible the next sibling node is changed during processing child segments // e.g. When this paragraph is an implicit paragraph and it contains an inline entity segment diff --git a/packages/roosterjs-content-model-dom/lib/modelToDom/optimizers/optimize.ts b/packages/roosterjs-content-model-dom/lib/modelToDom/optimizers/optimize.ts index eed720aa157..465017f41f4 100644 --- a/packages/roosterjs-content-model-dom/lib/modelToDom/optimizers/optimize.ts +++ b/packages/roosterjs-content-model-dom/lib/modelToDom/optimizers/optimize.ts @@ -1,11 +1,16 @@ import { isEntityElement } from '../../domUtils/entityUtils'; +import { isNodeOfType } from '../../domUtils/isNodeOfType'; import { mergeNode } from './mergeNode'; import { removeUnnecessarySpan } from './removeUnnecessarySpan'; +import type { + ModelToDomBlockAndSegmentNode, + ModelToDomContext, +} from 'roosterjs-content-model-types'; /** * @internal */ -export function optimize(root: Node) { +export function optimize(root: Node, context: ModelToDomContext) { /** * Do no do any optimization to entity */ @@ -17,6 +22,56 @@ export function optimize(root: Node) { mergeNode(root); for (let child = root.firstChild; child; child = child.nextSibling) { - optimize(child); + optimize(child, context); + } + + normalizeTextNode(root, context); +} + +// Merge continuous text nodes into one single node (same with normalize()), +// and update selection and dom indexes +function normalizeTextNode(root: Node, context: ModelToDomContext) { + let lastText: Text | null = null; + let child: Node | null; + let next: Node | null; + const selection = context.regularSelection; + + for ( + child = root.firstChild, next = child ? child.nextSibling : null; + child; + child = next, next = child ? child.nextSibling : null + ) { + if (!isNodeOfType(child, 'TEXT_NODE')) { + lastText = null; + } else if (!lastText) { + lastText = child; + } else { + const originalLength = lastText.nodeValue?.length ?? 0; + + context.domIndexer?.onMergeText(lastText, child); + lastText.nodeValue += child.nodeValue ?? ''; + + if (selection) { + updateSelection(selection.start, lastText, child, originalLength); + updateSelection(selection.end, lastText, child, originalLength); + } + + root.removeChild(child); + } + } +} + +function updateSelection( + mark: ModelToDomBlockAndSegmentNode | undefined, + lastText: Text, + nextText: Text, + lastTextOriginalLength: number +) { + if (mark && mark.offset == undefined) { + if (mark.segment == lastText) { + mark.offset = lastTextOriginalLength; + } else if (mark.segment == nextText) { + mark.segment = lastText; + } } } diff --git a/packages/roosterjs-content-model-dom/test/domToModel/processors/brProcessorTest.ts b/packages/roosterjs-content-model-dom/test/domToModel/processors/brProcessorTest.ts index 9ba29b9601b..5d36f9b76e6 100644 --- a/packages/roosterjs-content-model-dom/test/domToModel/processors/brProcessorTest.ts +++ b/packages/roosterjs-content-model-dom/test/domToModel/processors/brProcessorTest.ts @@ -77,6 +77,7 @@ describe('brProcessor', () => { reconcileChildList: null!, onBlockEntity: null!, reconcileElementId: null!, + onMergeText: null!, }; context.domIndexer = domIndexer; diff --git a/packages/roosterjs-content-model-dom/test/domToModel/processors/entityProcessorTest.ts b/packages/roosterjs-content-model-dom/test/domToModel/processors/entityProcessorTest.ts index 8521782a205..52b005ea005 100644 --- a/packages/roosterjs-content-model-dom/test/domToModel/processors/entityProcessorTest.ts +++ b/packages/roosterjs-content-model-dom/test/domToModel/processors/entityProcessorTest.ts @@ -262,6 +262,7 @@ describe('entityProcessor', () => { reconcileChildList: null!, onBlockEntity: null!, reconcileElementId: null!, + onMergeText: null!, }; context.domIndexer = domIndexer; diff --git a/packages/roosterjs-content-model-dom/test/domToModel/processors/generalProcessorTest.ts b/packages/roosterjs-content-model-dom/test/domToModel/processors/generalProcessorTest.ts index a6d516f3e66..528415eb76f 100644 --- a/packages/roosterjs-content-model-dom/test/domToModel/processors/generalProcessorTest.ts +++ b/packages/roosterjs-content-model-dom/test/domToModel/processors/generalProcessorTest.ts @@ -397,6 +397,7 @@ describe('generalProcessor', () => { reconcileChildList: null!, onBlockEntity: null!, reconcileElementId: null!, + onMergeText: null!, }; context.domIndexer = domIndexer; diff --git a/packages/roosterjs-content-model-dom/test/domToModel/processors/imageProcessorTest.ts b/packages/roosterjs-content-model-dom/test/domToModel/processors/imageProcessorTest.ts index 15bd59f68a7..ec391daf6a6 100644 --- a/packages/roosterjs-content-model-dom/test/domToModel/processors/imageProcessorTest.ts +++ b/packages/roosterjs-content-model-dom/test/domToModel/processors/imageProcessorTest.ts @@ -326,6 +326,7 @@ describe('imageProcessor', () => { reconcileChildList: null!, onBlockEntity: null!, reconcileElementId: null!, + onMergeText: null!, }; context.domIndexer = domIndexer; diff --git a/packages/roosterjs-content-model-dom/test/domToModel/processors/tableProcessorTest.ts b/packages/roosterjs-content-model-dom/test/domToModel/processors/tableProcessorTest.ts index 27bcf4221be..c18816850d4 100644 --- a/packages/roosterjs-content-model-dom/test/domToModel/processors/tableProcessorTest.ts +++ b/packages/roosterjs-content-model-dom/test/domToModel/processors/tableProcessorTest.ts @@ -293,6 +293,7 @@ describe('tableProcessor', () => { reconcileChildList: null!, onBlockEntity: null!, reconcileElementId: null!, + onMergeText: null!, }; context.domIndexer = domIndexer; diff --git a/packages/roosterjs-content-model-dom/test/domToModel/processors/textProcessorTest.ts b/packages/roosterjs-content-model-dom/test/domToModel/processors/textProcessorTest.ts index 621e29e17a8..40fb02ac06b 100644 --- a/packages/roosterjs-content-model-dom/test/domToModel/processors/textProcessorTest.ts +++ b/packages/roosterjs-content-model-dom/test/domToModel/processors/textProcessorTest.ts @@ -580,6 +580,7 @@ describe('textProcessor', () => { reconcileChildList: null!, onBlockEntity: null!, reconcileElementId: null!, + onMergeText: null!, }; context.domIndexer = domIndexer; @@ -617,6 +618,7 @@ describe('textProcessor', () => { reconcileChildList: null!, onBlockEntity: null!, reconcileElementId: null!, + onMergeText: null!, }; context.domIndexer = domIndexer; @@ -665,6 +667,7 @@ describe('textProcessor', () => { reconcileChildList: null!, onBlockEntity: null!, reconcileElementId: null!, + onMergeText: null!, }; context.domIndexer = domIndexer; diff --git a/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeContentModelTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeContentModelTest.ts index bc3aa260c52..3466a7656b3 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeContentModelTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeContentModelTest.ts @@ -59,12 +59,7 @@ describe('normalizeContentModel', () => { { segmentType: 'Text', format: {}, - text: 'test1', - }, - { - segmentType: 'Text', - format: {}, - text: 'test2', + text: 'test1test2', }, ], }, diff --git a/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeParagraphTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeParagraphTest.ts index 1de5d7c3e3b..de798f6a570 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeParagraphTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeParagraphTest.ts @@ -6,7 +6,12 @@ import { createSelectionMarker } from '../../../lib/modelApi/creators/createSele import { createText } from '../../../lib/modelApi/creators/createText'; import { normalizeContentModel } from '../../../lib/modelApi/common/normalizeContentModel'; import { normalizeParagraph } from '../../../lib/modelApi/common/normalizeParagraph'; -import { ReadonlyContentModelParagraph } from 'roosterjs-content-model-types'; +import { + ContentModelParagraph, + ContentModelSegment, + ContentModelSegmentFormat, + ReadonlyContentModelParagraph, +} from 'roosterjs-content-model-types'; describe('Normalize text that contains space', () => { function runTest(texts: string[], expected: string[], whiteSpace?: string) { @@ -69,9 +74,9 @@ describe('Normalize text that contains space', () => { }); it('Text ends with  ', () => { - runTest(['a\u00A0', 'b'], ['a ', 'b']); - runTest(['a\u00A0\u00A0', 'b'], ['a\u00A0 ', 'b']); - runTest(['a \u00A0', 'b'], ['a \u00A0', 'b']); + runTest(['a\u00A0', 'b'], ['a b']); + runTest(['a\u00A0\u00A0', 'b'], ['a\u00A0 b']); + runTest(['a \u00A0', 'b'], ['a \u00A0b']); }); it('with other type of segment', () => { @@ -166,12 +171,7 @@ describe('Normalize text that contains space', () => { segments: [ { segmentType: 'Text', - text: 'a ', - format: {}, - }, - { - segmentType: 'Text', - text: '\u00A0b', + text: 'a \u00A0b', format: {}, }, ], @@ -528,17 +528,11 @@ describe('Move up format', () => { segments: [ { segmentType: 'Text', - text: 'test1', - format: {}, - }, - { - segmentType: 'Text', - text: 'test2', + text: 'test1test2', format: {}, }, ], format: {}, - cachedElement: mockedCache, }); }); @@ -840,3 +834,657 @@ describe('Move up format', () => { }); }); }); + +describe('Merge text segments', () => { + function runTest( + input: ContentModelSegment[], + expectedResult: ContentModelSegment[], + stillHasCache: boolean, + expectedParagraphFormat?: ContentModelSegmentFormat + ) { + const paragraph = createParagraph(); + const cache = 'CACHE' as any; + + paragraph.cachedElement = cache; + + paragraph.segments = input; + + normalizeParagraph(paragraph); + + const expectedParagraph: ContentModelParagraph = { + blockType: 'Paragraph', + format: {}, + segments: expectedResult, + }; + + if (expectedParagraphFormat) { + expectedParagraph.segmentFormat = expectedParagraphFormat; + } + + if (stillHasCache) { + expectedParagraph.cachedElement = cache; + } + + expect(paragraph).toEqual(expectedParagraph); + } + + it('Empty paragraph', () => { + runTest([], [], true); + }); + + it('Single text segment', () => { + runTest( + [ + { + segmentType: 'Text', + text: 'abc', + format: {}, + }, + ], + [ + { + segmentType: 'Text', + text: 'abc', + format: {}, + }, + ], + true + ); + }); + + it('Two text segments, same format', () => { + runTest( + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + ], + [ + { + segmentType: 'Text', + text: 'abcdef', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + ], + false, + { fontFamily: 'Aptos', fontSize: '12pt' } + ); + }); + + it('Two text segments, same format, with space - 1', () => { + runTest( + [ + { + segmentType: 'Text', + text: ' abc ', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: ' def ', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + ], + [ + { + segmentType: 'Text', + text: 'abc def', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + ], + false, + { fontFamily: 'Aptos', fontSize: '12pt' } + ); + }); + + it('Two text segments, same format, with space - 2', () => { + runTest( + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: ' def', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + ], + [ + { + segmentType: 'Text', + text: 'abc\u00A0def', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + ], + false, + { fontFamily: 'Aptos', fontSize: '12pt' } + ); + }); + + it('Two text segments, different format - 1', () => { + runTest( + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos', fontSize: '12pt', italic: true }, + }, + ], + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos', fontSize: '12pt', italic: true }, + }, + ], + false, + { fontFamily: 'Aptos', fontSize: '12pt' } + ); + }); + + it('Two text segments, different format - 2', () => { + runTest( + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos' }, + }, + ], + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos' }, + }, + ], + false, + { fontFamily: 'Aptos' } + ); + }); + + it('Two text segments, different format - 3', () => { + runTest( + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos', fontSize: '14pt' }, + }, + ], + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos', fontSize: '14pt' }, + }, + ], + false, + { fontFamily: 'Aptos' } + ); + }); + + it('Two text segments, one has link', () => { + runTest( + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + link: { + dataset: {}, + format: {}, + }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + ], + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + link: { + dataset: {}, + format: {}, + }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + ], + false, + { fontFamily: 'Aptos', fontSize: '12pt' } + ); + }); + + it('Two text segments, both have same link', () => { + runTest( + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + link: { + dataset: {}, + format: {}, + }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + link: { + dataset: {}, + format: {}, + }, + }, + ], + [ + { + segmentType: 'Text', + text: 'abcdef', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + link: { + dataset: {}, + format: {}, + }, + }, + ], + false, + { fontFamily: 'Aptos', fontSize: '12pt' } + ); + }); + + it('Two text segments, both have different link', () => { + runTest( + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + link: { + dataset: {}, + format: { href: 'url1' }, + }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + link: { + dataset: {}, + format: { href: 'url2' }, + }, + }, + ], + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + link: { + dataset: {}, + format: { href: 'url1' }, + }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + link: { + dataset: {}, + format: { href: 'url2' }, + }, + }, + ], + false, + { fontFamily: 'Aptos', fontSize: '12pt' } + ); + }); + + it('Two text segments, one has code', () => { + runTest( + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + code: { + format: {}, + }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + ], + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + code: { + format: {}, + }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + ], + false, + { fontFamily: 'Aptos', fontSize: '12pt' } + ); + }); + + it('Two text segments, both have same code', () => { + runTest( + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + code: { + format: {}, + }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + code: { + format: {}, + }, + }, + ], + [ + { + segmentType: 'Text', + text: 'abcdef', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + code: { + format: {}, + }, + }, + ], + false, + { fontFamily: 'Aptos', fontSize: '12pt' } + ); + }); + + it('Two text segments around selection marker', () => { + runTest( + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'SelectionMarker', + format: {}, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + ], + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'SelectionMarker', + format: {}, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + ], + false, + { fontFamily: 'Aptos', fontSize: '12pt' } + ); + }); + + it('Two text segments after selection marker', () => { + runTest( + [ + { + segmentType: 'SelectionMarker', + format: {}, + }, + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + ], + [ + { + segmentType: 'SelectionMarker', + format: {}, + }, + { + segmentType: 'Text', + text: 'abcdef', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + ], + false, + { fontFamily: 'Aptos', fontSize: '12pt' } + ); + }); + + it('Two text segments before selection marker', () => { + runTest( + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'SelectionMarker', + format: {}, + }, + ], + [ + { + segmentType: 'Text', + text: 'abcdef', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'SelectionMarker', + format: {}, + }, + ], + false, + { fontFamily: 'Aptos', fontSize: '12pt' } + ); + }); + + it('Three text segments with same format', () => { + runTest( + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: 'ghi', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + ], + [ + { + segmentType: 'Text', + text: 'abcdefghi', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + ], + false, + { fontFamily: 'Aptos', fontSize: '12pt' } + ); + }); + + it('Two pairs - 1', () => { + runTest( + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: 'ghi', + format: { fontFamily: 'Aptos', fontSize: '14pt' }, + }, + { + segmentType: 'Text', + text: 'jkl', + format: { fontFamily: 'Aptos', fontSize: '14pt' }, + }, + ], + [ + { + segmentType: 'Text', + text: 'abcdef', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: 'ghijkl', + format: { fontFamily: 'Aptos', fontSize: '14pt' }, + }, + ], + false, + { fontFamily: 'Aptos' } + ); + }); + + it('Two pairs - 2', () => { + runTest( + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontSize: '14pt' }, + }, + { + segmentType: 'Text', + text: 'ghi', + format: { fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: 'jkl', + format: { fontSize: '14pt' }, + }, + ], + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontSize: '14pt' }, + }, + { + segmentType: 'Text', + text: 'ghi', + format: { fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: 'jkl', + format: { fontSize: '14pt' }, + }, + ], + true + ); + }); +}); diff --git a/packages/roosterjs-content-model-dom/test/modelApi/editing/mergeModelTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/editing/mergeModelTest.ts index f806f20e84a..78ec1d24aa9 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/editing/mergeModelTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/editing/mergeModelTest.ts @@ -96,12 +96,7 @@ describe('mergeModel', () => { segments: [ { segmentType: 'Text', - text: 'test1', - format: {}, - }, - { - segmentType: 'Text', - text: 'test2', + text: 'test1test2', format: {}, }, { @@ -400,12 +395,7 @@ describe('mergeModel', () => { segments: [ { segmentType: 'Text', - text: 'test11', - format: {}, - }, - { - segmentType: 'Text', - text: 'newText1', + text: 'test11newText1', format: {}, }, ], @@ -1700,12 +1690,7 @@ describe('mergeModel', () => { }, { segmentType: 'Text', - text: 'test1', - format: {}, - }, - { - segmentType: 'Text', - text: 'new text', + text: 'test1new text', format: {}, }, marker2, @@ -2952,9 +2937,7 @@ describe('mergeModel', () => { const paragraph: ContentModelParagraph = { blockType: 'Paragraph', segments: [ - { segmentType: 'Text', text: 'test1', format: {} }, - { segmentType: 'Text', text: 'sourceTest1', format: {} }, - { segmentType: 'Text', text: 'sourceTest2', format: {} }, + { segmentType: 'Text', text: 'test1sourceTest1sourceTest2', format: {} }, { segmentType: 'SelectionMarker', isSelected: true, @@ -4097,12 +4080,7 @@ describe('mergeModel', () => { }, { segmentType: 'Text', - text: 'test1', - format: {}, - }, - { - segmentType: 'Text', - text: 'new text', + text: 'test1new text', format: {}, }, marker2, @@ -4974,12 +4952,7 @@ describe('mergeModel', () => { }, { segmentType: 'Text', - text: 'test1', - format: {}, - }, - { - segmentType: 'Text', - text: 'new text', + text: 'test1new text', format: {}, }, marker2, diff --git a/packages/roosterjs-content-model-dom/test/modelToDom/contentModelToDomTest.ts b/packages/roosterjs-content-model-dom/test/modelToDom/contentModelToDomTest.ts index 871c1a22baf..cd87a418155 100644 --- a/packages/roosterjs-content-model-dom/test/modelToDom/contentModelToDomTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelToDom/contentModelToDomTest.ts @@ -104,6 +104,76 @@ describe('contentModelToDom', () => { expect((range as RangeSelection).isReverted).toBe(false); }); + it('Extract expanded range - range in middle of text', () => { + const mockedHandler = jasmine.createSpy('blockGroupChildren'); + const context = createModelToDomContext(undefined, { + modelHandlerOverride: { + blockGroupChildren: mockedHandler, + }, + }); + + const root = document.createElement('div'); + const div = document.createElement('div'); + const text = document.createTextNode('abcd'); + + div.appendChild(text); + root.appendChild(div); + + context.regularSelection.start = { + block: div, + segment: text, + offset: 1, + }; + context.regularSelection.end = { + block: div, + segment: text, + offset: 3, + }; + + const range = contentModelToDom(document, root, {} as any, context); + + expect(range!.type).toBe('range'); + expect((range as RangeSelection).range.startContainer).toBe(text); + expect((range as RangeSelection).range.startOffset).toBe(1); + expect((range as RangeSelection).range.endContainer).toBe(text); + expect((range as RangeSelection).range.endOffset).toBe(3); + expect((range as RangeSelection).isReverted).toBe(false); + }); + + it('Extract range after empty text', () => { + const mockedHandler = jasmine.createSpy('blockGroupChildren'); + const context = createModelToDomContext(undefined, { + modelHandlerOverride: { + blockGroupChildren: mockedHandler, + }, + }); + + const root = document.createElement('div'); + const div = document.createElement('div'); + const text = document.createTextNode(''); + + div.appendChild(text); + root.appendChild(div); + + context.regularSelection.start = { + block: div, + segment: text, + }; + context.regularSelection.end = { + block: div, + segment: text, + }; + + const range = contentModelToDom(document, root, {} as any, context); + + expect(range!.type).toBe('range'); + expect((range as RangeSelection).range.startContainer).toBe(div); + expect((range as RangeSelection).range.startOffset).toBe(0); + expect((range as RangeSelection).range.endContainer).toBe(div); + expect((range as RangeSelection).range.endOffset).toBe(0); + expect((range as RangeSelection).isReverted).toBe(false); + }); + it('Extract selection range - normal collapsed range with empty text', () => { const mockedHandler = jasmine.createSpy('blockGroupChildren'); const context = createModelToDomContext(undefined, { diff --git a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleParagraphTest.ts b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleParagraphTest.ts index 2c906304572..32daefdcfd1 100644 --- a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleParagraphTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleParagraphTest.ts @@ -431,7 +431,7 @@ describe('handleParagraph', () => { expect(para2.cachedElement).toBe(parent.firstChild?.nextSibling as HTMLElement); expect(para2.cachedElement?.outerHTML).toBe('

test2
'); - optimize(parent); + optimize(parent, context); expect(parent.innerHTML).toBe( '
test1
test2

' @@ -584,6 +584,7 @@ describe('handleParagraph', () => { reconcileChildList: null!, onBlockEntity: null!, reconcileElementId: null!, + onMergeText: null!, }; context.domIndexer = domIndexer; @@ -630,6 +631,7 @@ describe('handleParagraph', () => { reconcileChildList: null!, onBlockEntity: null!, reconcileElementId: null!, + onMergeText: null!, }; context.domIndexer = domIndexer; @@ -650,3 +652,344 @@ describe('handleParagraph', () => { expect(onSegmentSpy).toHaveBeenCalledWith(parent.lastChild, paragraph, [segment2]); }); }); + +describe('Handle paragraph and adjust selections', () => { + it('Selection is at beginning, followed by BR', () => { + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + }; + + const parent = document.createElement('div'); + const context = createModelToDomContext(); + + handleParagraph(document, parent, paragraph, context, null); + + expect(parent.innerHTML).toBe('

'); + expect(context.regularSelection.start).toEqual({ + block: parent.firstChild, + segment: parent.firstChild!.firstChild, + }); + expect(context.regularSelection.end).toEqual({ + block: parent.firstChild, + segment: parent.firstChild!.firstChild, + }); + expect(parent.firstChild!.firstChild!.nodeType).toBe(Node.TEXT_NODE); + }); + + it('Selection is at beginning, followed by Text', () => { + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + }; + + const parent = document.createElement('div'); + const context = createModelToDomContext(); + + handleParagraph(document, parent, paragraph, context, null); + + expect(parent.innerHTML).toBe('
test
'); + expect(context.regularSelection.start).toEqual({ + block: parent.firstChild, + segment: parent.firstChild!.firstChild, + offset: 0, + }); + expect(context.regularSelection.end).toEqual({ + block: parent.firstChild, + segment: parent.firstChild!.firstChild, + offset: 0, + }); + expect(parent.firstChild!.firstChild!.nodeType).toBe(Node.TEXT_NODE); + expect(parent.firstChild!.firstChild).toBe(parent.firstChild!.lastChild); + }); + + it('Selection is in middle of text', () => { + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'test1', + format: {}, + }, + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + { + segmentType: 'Text', + text: 'test2', + format: {}, + }, + ], + }; + + const parent = document.createElement('div'); + const context = createModelToDomContext(); + + handleParagraph(document, parent, paragraph, context, null); + + expect(parent.innerHTML).toBe('
test1test2
'); + expect(context.regularSelection.start).toEqual({ + block: parent.firstChild, + segment: parent.firstChild!.firstChild, + offset: 5, + }); + expect(context.regularSelection.end).toEqual({ + block: parent.firstChild, + segment: parent.firstChild!.firstChild, + offset: 5, + }); + expect(parent.firstChild!.firstChild!.nodeType).toBe(Node.TEXT_NODE); + expect(parent.firstChild!.firstChild).toBe(parent.firstChild!.lastChild); + }); + + it('Selection is at end of text', () => { + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'test1', + format: {}, + }, + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + { + segmentType: 'Br', + format: {}, + }, + { + segmentType: 'Text', + text: 'test2', + format: {}, + }, + ], + }; + + const parent = document.createElement('div'); + const context = createModelToDomContext(); + + handleParagraph(document, parent, paragraph, context, null); + + expect(parent.innerHTML).toBe('
test1
test2
'); + expect(context.regularSelection.start).toEqual({ + block: parent.firstChild, + segment: parent.firstChild!.firstChild, + }); + expect(context.regularSelection.end).toEqual({ + block: parent.firstChild, + segment: parent.firstChild!.firstChild, + }); + expect(parent.firstChild!.firstChild!.nodeType).toBe(Node.TEXT_NODE); + expect(parent.firstChild!.lastChild!.nodeType).toBe(Node.TEXT_NODE); + expect(parent.firstChild!.firstChild).not.toBe(parent.firstChild!.lastChild); + }); + + it('Selection is in middle of text, expanded', () => { + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'test1', + format: {}, + }, + { + segmentType: 'Text', + text: 'test2', + format: {}, + isSelected: true, + }, + { + segmentType: 'Text', + text: 'test3', + format: {}, + }, + ], + }; + + const parent = document.createElement('div'); + const context = createModelToDomContext(); + + handleParagraph(document, parent, paragraph, context, null); + + expect(parent.innerHTML).toBe('
test1test2test3
'); + expect(context.regularSelection.start).toEqual({ + block: parent.firstChild, + segment: parent.firstChild!.firstChild, + offset: 5, + }); + expect(context.regularSelection.end).toEqual({ + block: parent.firstChild, + segment: parent.firstChild!.firstChild, + offset: 10, + }); + expect(parent.firstChild!.firstChild!.nodeType).toBe(Node.TEXT_NODE); + expect(parent.firstChild!.lastChild!.nodeType).toBe(Node.TEXT_NODE); + expect(parent.firstChild!.firstChild).toBe(parent.firstChild!.lastChild); + }); + + it('Selection is in front of text, expanded', () => { + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'test1', + format: {}, + isSelected: true, + }, + { + segmentType: 'Text', + text: 'test2', + format: {}, + }, + ], + }; + + const parent = document.createElement('div'); + const context = createModelToDomContext(); + + handleParagraph(document, parent, paragraph, context, null); + + expect(parent.innerHTML).toBe('
test1test2
'); + expect(context.regularSelection.start).toEqual({ + block: parent.firstChild, + segment: null, + }); + expect(context.regularSelection.end).toEqual({ + block: parent.firstChild, + segment: parent.firstChild!.firstChild, + offset: 5, + }); + expect(parent.firstChild!.firstChild!.nodeType).toBe(Node.TEXT_NODE); + expect(parent.firstChild!.lastChild!.nodeType).toBe(Node.TEXT_NODE); + expect(parent.firstChild!.firstChild).toBe(parent.firstChild!.lastChild); + }); + + it('Selection is at the end of text, expanded', () => { + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'test1', + format: {}, + }, + { + segmentType: 'Text', + text: 'test2', + format: {}, + isSelected: true, + }, + ], + }; + + const parent = document.createElement('div'); + const context = createModelToDomContext(); + + handleParagraph(document, parent, paragraph, context, null); + + expect(parent.innerHTML).toBe('
test1test2
'); + expect(context.regularSelection.start).toEqual({ + block: parent.firstChild, + segment: parent.firstChild!.firstChild, + offset: 5, + }); + expect(context.regularSelection.end).toEqual({ + block: parent.firstChild, + segment: parent.firstChild!.firstChild, + }); + expect(parent.firstChild!.firstChild!.nodeType).toBe(Node.TEXT_NODE); + expect(parent.firstChild!.lastChild!.nodeType).toBe(Node.TEXT_NODE); + expect(parent.firstChild!.firstChild).toBe(parent.firstChild!.lastChild); + }); + + it('Selection is in middle of text and BR, expanded', () => { + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'test1', + format: {}, + }, + { + segmentType: 'Text', + text: 'test2', + format: {}, + isSelected: true, + }, + { + segmentType: 'Br', + format: {}, + }, + { + segmentType: 'Text', + text: 'test3', + format: {}, + isSelected: true, + }, + { + segmentType: 'Text', + text: 'test4', + format: {}, + }, + ], + }; + + const parent = document.createElement('div'); + const context = createModelToDomContext(); + + handleParagraph(document, parent, paragraph, context, null); + + expect(parent.innerHTML).toBe('
test1test2
test3test4
'); + expect(context.regularSelection.start).toEqual({ + block: parent.firstChild, + segment: parent.firstChild!.firstChild, + offset: 5, + }); + expect(context.regularSelection.end).toEqual({ + block: parent.firstChild, + segment: parent.firstChild!.lastChild, + offset: 5, + }); + expect(parent.firstChild!.firstChild!.nodeType).toBe(Node.TEXT_NODE); + expect(parent.firstChild!.lastChild!.nodeType).toBe(Node.TEXT_NODE); + expect(parent.firstChild!.firstChild).not.toBe(parent.firstChild!.lastChild); + }); +}); diff --git a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleTableTest.ts b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleTableTest.ts index 5eac39a4727..75cc441f0c3 100644 --- a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleTableTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleTableTest.ts @@ -604,6 +604,7 @@ describe('handleTable', () => { reconcileChildList: null!, onBlockEntity: null!, reconcileElementId: null!, + onMergeText: null!, }; context.domIndexer = domIndexer; diff --git a/packages/roosterjs-content-model-dom/test/modelToDom/optimizers/optimizeTest.ts b/packages/roosterjs-content-model-dom/test/modelToDom/optimizers/optimizeTest.ts index 3d3a934706f..c3acd36e9b4 100644 --- a/packages/roosterjs-content-model-dom/test/modelToDom/optimizers/optimizeTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelToDom/optimizers/optimizeTest.ts @@ -11,7 +11,7 @@ describe('optimize', () => { it('Optimize', () => { const div = document.createElement('div'); - optimize(div); + optimize(div, {} as any); expect(mergeNode.mergeNode).toHaveBeenCalled(); expect(removeUnnecessarySpan.removeUnnecessarySpan).toHaveBeenCalled(); @@ -22,7 +22,7 @@ describe('optimize', () => { const span = document.createElement('span'); div.appendChild(span); - optimize(div); + optimize(div, {} as any); expect(mergeNode.mergeNode).toHaveBeenCalledTimes(2); expect(mergeNode.mergeNode).toHaveBeenCalledWith(div); @@ -49,7 +49,7 @@ describe('real optimization', () => { div.appendChild(span1); div.appendChild(span2); - optimize(div); + optimize(div, {} as any); expect(div.outerHTML).toBe( '
test1entity
' diff --git a/packages/roosterjs-content-model-plugins/test/paste/processPastedContentFromWacTest.ts b/packages/roosterjs-content-model-plugins/test/paste/processPastedContentFromWacTest.ts index 50ff7a50e80..fab4753280f 100644 --- a/packages/roosterjs-content-model-plugins/test/paste/processPastedContentFromWacTest.ts +++ b/packages/roosterjs-content-model-plugins/test/paste/processPastedContentFromWacTest.ts @@ -3103,10 +3103,7 @@ describe('wordOnlineHandler', () => { blocks: [ { blockType: 'Paragraph', - segments: [ - { segmentType: 'Text', text: 'it went: ', format: {} }, - { segmentType: 'Text', text: ' ', format: {} }, - ], + segments: [{ segmentType: 'Text', text: 'it went:  ', format: {} }], format: { marginTop: '1em', marginBottom: '1em' }, decorator: { tagName: 'p', format: {} }, }, @@ -3135,10 +3132,7 @@ describe('wordOnlineHandler', () => { blocks: [ { blockType: 'Paragraph', - segments: [ - { segmentType: 'Text', text: 'Test.', format: {} }, - { segmentType: 'Text', text: ' ', format: {} }, - ], + segments: [{ segmentType: 'Text', text: 'Test. ', format: {} }], format: { marginTop: '1em', marginBottom: '1em' }, decorator: { tagName: 'p', format: {} }, }, @@ -4417,19 +4411,7 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_', - format: { - fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', - fontSize: '12pt', - italic: false, - fontWeight: 'normal', - textColor: 'rgb(0, 0, 0)', - lineHeight: '22.0875px', - }, - }, - { - segmentType: 'Text', - text: ' ', + text: '_ ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', @@ -4483,19 +4465,7 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_', - format: { - fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', - fontSize: '12pt', - italic: false, - fontWeight: 'normal', - textColor: 'rgb(0, 0, 0)', - lineHeight: '22.0875px', - }, - }, - { - segmentType: 'Text', - text: ' ', + text: '_ ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', @@ -4549,19 +4519,7 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_', - format: { - fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', - fontSize: '12pt', - italic: false, - fontWeight: 'normal', - textColor: 'rgb(0, 0, 0)', - lineHeight: '22.0875px', - }, - }, - { - segmentType: 'Text', - text: ' ', + text: '_ ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', @@ -4625,19 +4583,7 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_', - format: { - fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', - fontSize: '12pt', - italic: false, - fontWeight: 'normal', - textColor: 'rgb(0, 0, 0)', - lineHeight: '22.0875px', - }, - }, - { - segmentType: 'Text', - text: ' ', + text: '_ ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', @@ -4701,19 +4647,7 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_', - format: { - fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', - fontSize: '12pt', - italic: false, - fontWeight: 'normal', - textColor: 'rgb(0, 0, 0)', - lineHeight: '22.0875px', - }, - }, - { - segmentType: 'Text', - text: ' ', + text: '_ ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', @@ -4787,19 +4721,7 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_', - format: { - fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', - fontSize: '12pt', - italic: false, - fontWeight: 'normal', - textColor: 'rgb(0, 0, 0)', - lineHeight: '22.0875px', - }, - }, - { - segmentType: 'Text', - text: ' ', + text: '_ ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', @@ -4873,19 +4795,7 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_', - format: { - fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', - fontSize: '12pt', - italic: false, - fontWeight: 'normal', - textColor: 'rgb(0, 0, 0)', - lineHeight: '22.0875px', - }, - }, - { - segmentType: 'Text', - text: ' ', + text: '_ ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', @@ -4949,19 +4859,7 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_', - format: { - fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', - fontSize: '12pt', - italic: false, - fontWeight: 'normal', - textColor: 'rgb(0, 0, 0)', - lineHeight: '22.0875px', - }, - }, - { - segmentType: 'Text', - text: ' ', + text: '_ ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', @@ -5025,19 +4923,7 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_', - format: { - fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', - fontSize: '12pt', - italic: false, - fontWeight: 'normal', - textColor: 'rgb(0, 0, 0)', - lineHeight: '22.0875px', - }, - }, - { - segmentType: 'Text', - text: ' ', + text: '_ ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', @@ -5091,19 +4977,7 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_', - format: { - fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', - fontSize: '12pt', - italic: false, - fontWeight: 'normal', - textColor: 'rgb(0, 0, 0)', - lineHeight: '22.0875px', - }, - }, - { - segmentType: 'Text', - text: ' ', + text: '_ ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', @@ -5258,19 +5132,7 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_', - format: { - fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', - fontSize: '12pt', - italic: false, - textColor: 'rgb(0, 0, 0)', - fontWeight: 'normal', - lineHeight: '22.0875px', - }, - }, - { - segmentType: 'Text', - text: ' ', + text: '_ ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', @@ -5369,19 +5231,7 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_', - format: { - fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', - fontSize: '12pt', - italic: false, - fontWeight: 'normal', - textColor: 'rgb(0, 0, 0)', - lineHeight: '22.0875px', - }, - }, - { - segmentType: 'Text', - text: ' ', + text: '_ ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', @@ -5441,19 +5291,7 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_', - format: { - fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', - fontSize: '12pt', - italic: false, - textColor: 'rgb(0, 0, 0)', - fontWeight: 'normal', - lineHeight: '22.0875px', - }, - }, - { - segmentType: 'Text', - text: ' ', + text: '_ ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', @@ -5502,19 +5340,7 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_', - format: { - fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', - fontSize: '12pt', - italic: false, - fontWeight: 'normal', - textColor: 'rgb(0, 0, 0)', - lineHeight: '22.0875px', - }, - }, - { - segmentType: 'Text', - text: ' ', + text: '_ ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', @@ -5619,19 +5445,7 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_', - format: { - fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', - fontSize: '12pt', - italic: false, - textColor: 'rgb(0, 0, 0)', - fontWeight: 'normal', - lineHeight: '22.0875px', - }, - }, - { - segmentType: 'Text', - text: ' ', + text: '_ ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', @@ -5730,19 +5544,7 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_', - format: { - fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', - fontSize: '12pt', - italic: false, - fontWeight: 'normal', - textColor: 'rgb(0, 0, 0)', - lineHeight: '22.0875px', - }, - }, - { - segmentType: 'Text', - text: ' ', + text: '_ ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', @@ -5801,19 +5603,7 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_', - format: { - fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', - fontSize: '12pt', - italic: false, - fontWeight: 'normal', - textColor: 'rgb(0, 0, 0)', - lineHeight: '22.0875px', - }, - }, - { - segmentType: 'Text', - text: ' ', + text: '_ ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', @@ -5918,19 +5708,7 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_', - format: { - fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', - fontSize: '12pt', - italic: false, - textColor: 'rgb(0, 0, 0)', - fontWeight: 'normal', - lineHeight: '22.0875px', - }, - }, - { - segmentType: 'Text', - text: ' ', + text: '_ ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', @@ -5979,19 +5757,7 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_', - format: { - fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', - fontSize: '12pt', - italic: false, - fontWeight: 'normal', - textColor: 'rgb(0, 0, 0)', - lineHeight: '22.0875px', - }, - }, - { - segmentType: 'Text', - text: ' ', + text: '_ ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', @@ -6050,19 +5816,7 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_', - format: { - fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', - fontSize: '12pt', - italic: false, - fontWeight: 'normal', - textColor: 'rgb(0, 0, 0)', - lineHeight: '22.0875px', - }, - }, - { - segmentType: 'Text', - text: ' ', + text: '_ ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', diff --git a/packages/roosterjs-content-model-plugins/test/paste/processPastedContentFromWordDesktopTest.ts b/packages/roosterjs-content-model-plugins/test/paste/processPastedContentFromWordDesktopTest.ts index 07f17b4039d..bfa5272bc6e 100644 --- a/packages/roosterjs-content-model-plugins/test/paste/processPastedContentFromWordDesktopTest.ts +++ b/packages/roosterjs-content-model-plugins/test/paste/processPastedContentFromWordDesktopTest.ts @@ -80,12 +80,7 @@ describe('processPastedContentFromWordDesktopTest', () => { segments: [ { segmentType: 'Text', - text: 'Test', - format: {}, - }, - { - segmentType: 'Text', - text: 'Test', + text: 'TestTest', format: {}, }, ], @@ -106,12 +101,7 @@ describe('processPastedContentFromWordDesktopTest', () => { segments: [ { segmentType: 'Text', - text: 'Test', - format: {}, - }, - { - segmentType: 'Text', - text: 'Test', + text: 'TestTest', format: {}, }, ], @@ -4264,12 +4254,7 @@ describe('processPastedContentFromWordDesktopTest', () => { isImplicit: true, segments: [ { - text: 'text', - segmentType: 'Text', - format: {}, - }, - { - text: '.', + text: 'text.', segmentType: 'Text', format: {}, }, @@ -4848,12 +4833,7 @@ describe('processPastedContentFromWordDesktopTest', () => { isImplicit: true, segments: [ { - text: 'text', - segmentType: 'Text', - format: {}, - }, - { - text: ' ', + text: 'text ', segmentType: 'Text', format: {}, }, diff --git a/packages/roosterjs-content-model-types/lib/context/DomIndexer.ts b/packages/roosterjs-content-model-types/lib/context/DomIndexer.ts index 7c600dc0c8f..60a3429a919 100644 --- a/packages/roosterjs-content-model-types/lib/context/DomIndexer.ts +++ b/packages/roosterjs-content-model-types/lib/context/DomIndexer.ts @@ -43,6 +43,15 @@ export interface DomIndexer { */ onBlockEntity: (entity: ContentModelEntity, group: ContentModelBlockGroup) => void; + /** + * Invoke when merge two continuous text nodes, we need to merge their indexes as well + * @param targetText Target text node to merge into + * @param sourceText Source text node to merge from + * @example Assume we have two text nodes: Node1="Foo", Node2="Bar", after merge, + * Node1 will become "FooBar", Node2 will be removed from DOM tree + */ + onMergeText: (targetText: Text, sourceText: Text) => void; + /** * When document content or selection is changed by user, we need to use this function to update the content model * to reflect the latest document. This process can fail since the selected node may not have a related model data structure. diff --git a/packages/roosterjs-content-model-types/lib/context/ModelToDomSelectionContext.ts b/packages/roosterjs-content-model-types/lib/context/ModelToDomSelectionContext.ts index d1af5f375c3..525b2aec21e 100644 --- a/packages/roosterjs-content-model-types/lib/context/ModelToDomSelectionContext.ts +++ b/packages/roosterjs-content-model-types/lib/context/ModelToDomSelectionContext.ts @@ -14,6 +14,11 @@ export interface ModelToDomBlockAndSegmentNode { * Segment node of this position. When provided, it represents the position right after this node */ segment: Node | null; + + /** + * Offset number of this position. It is only used for Text node, default value is 0 + */ + offset?: number; } /** From 5991526f35449dc7ff994a73b2f6f2ca13acdfa8 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Wed, 30 Oct 2024 08:20:45 -0700 Subject: [PATCH 06/30] Remove tablePreProcessor (#2849) --- .../lib/editor/Editor.ts | 4 - .../core/createEditorDefaultSettings.ts | 7 +- .../lib/override/tablePreProcessor.ts | 30 --- .../test/editor/EditorTest.ts | 8 +- .../core/createEditorDefaultSettingsTest.ts | 28 +-- .../test/overrides/tablePreProcessorTest.ts | 214 ------------------ 6 files changed, 6 insertions(+), 285 deletions(-) delete mode 100644 packages/roosterjs-content-model-core/lib/override/tablePreProcessor.ts delete mode 100644 packages/roosterjs-content-model-core/test/overrides/tablePreProcessorTest.ts diff --git a/packages/roosterjs-content-model-core/lib/editor/Editor.ts b/packages/roosterjs-content-model-core/lib/editor/Editor.ts index 6bad5976a24..c01aee381c7 100644 --- a/packages/roosterjs-content-model-core/lib/editor/Editor.ts +++ b/packages/roosterjs-content-model-core/lib/editor/Editor.ts @@ -1,7 +1,6 @@ import { createEditorCore } from './core/createEditorCore'; import { createEmptyModel, - tableProcessor, ChangeSource, cloneModel, transformColor, @@ -102,9 +101,6 @@ export class Editor implements IEditor { case 'disconnected': return cloneModel( core.api.createContentModel(core, { - processorOverride: { - table: tableProcessor, - }, tryGetFromCache: false, }), { diff --git a/packages/roosterjs-content-model-core/lib/editor/core/createEditorDefaultSettings.ts b/packages/roosterjs-content-model-core/lib/editor/core/createEditorDefaultSettings.ts index 52bd64b886f..ffab5e66ffa 100644 --- a/packages/roosterjs-content-model-core/lib/editor/core/createEditorDefaultSettings.ts +++ b/packages/roosterjs-content-model-core/lib/editor/core/createEditorDefaultSettings.ts @@ -1,5 +1,4 @@ import { createDomToModelConfig, createModelToDomConfig } from 'roosterjs-content-model-dom'; -import { tablePreProcessor } from '../../override/tablePreProcessor'; import { listItemMetadataApplier, listLevelMetadataApplier, @@ -21,11 +20,7 @@ import type { export function createDomToModelSettings( options: EditorOptions ): ContentModelSettings { - const builtIn: DomToModelOption = { - processorOverride: { - table: tablePreProcessor, - }, - }; + const builtIn: DomToModelOption = {}; const customized: DomToModelOption = options.defaultDomToModelOptions ?? {}; return { diff --git a/packages/roosterjs-content-model-core/lib/override/tablePreProcessor.ts b/packages/roosterjs-content-model-core/lib/override/tablePreProcessor.ts deleted file mode 100644 index 0a753606abd..00000000000 --- a/packages/roosterjs-content-model-core/lib/override/tablePreProcessor.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { - entityProcessor, - getSelectionRootNode, - hasMetadata, - tableProcessor, -} from 'roosterjs-content-model-dom'; -import type { DomToModelContext, ElementProcessor } from 'roosterjs-content-model-types'; - -/** - * @internal - */ -export const tablePreProcessor: ElementProcessor = (group, element, context) => { - const processor = shouldUseTableProcessor(element, context) ? tableProcessor : entityProcessor; - - processor(group, element, context); -}; - -function shouldUseTableProcessor(element: HTMLTableElement, context: DomToModelContext) { - const selectionRoot = getSelectionRootNode(context.selection); - // Treat table as a real table when: - // 1. It is a roosterjs table (has metadata) - // 2. Table is in selection - // 3. There is selection inside table (or whole table is selected) - // Otherwise, we treat the table as entity so we will not change it when write back - return ( - hasMetadata(element) || - context.isInSelection || - (selectionRoot && element.contains(selectionRoot)) - ); -} diff --git a/packages/roosterjs-content-model-core/test/editor/EditorTest.ts b/packages/roosterjs-content-model-core/test/editor/EditorTest.ts index 3db0c62136f..ace32194a31 100644 --- a/packages/roosterjs-content-model-core/test/editor/EditorTest.ts +++ b/packages/roosterjs-content-model-core/test/editor/EditorTest.ts @@ -4,9 +4,9 @@ import * as createEditorCore from '../../lib/editor/core/createEditorCore'; import * as createEmptyModel from 'roosterjs-content-model-dom/lib/modelApi/creators/createEmptyModel'; import * as domToContentModel from 'roosterjs-content-model-dom/lib/domToModel/domToContentModel'; import * as transformColor from 'roosterjs-content-model-dom/lib/domUtils/style/transformColor'; -import { ChangeSource, tableProcessor } from 'roosterjs-content-model-dom'; import { Editor } from '../../lib/editor/Editor'; import { expectHtml } from 'roosterjs-content-model-dom/test/testUtils'; +import { ChangeSource } from 'roosterjs-content-model-dom'; import { CachedElementHandler, ContentModelDocument, @@ -198,9 +198,6 @@ describe('Editor', () => { expect(model).toBe(mockedClonedModel); expect(createContentModelSpy).toHaveBeenCalledWith(mockedCore, { - processorOverride: { - table: tableProcessor, - }, tryGetFromCache: false, }); expect(transformColorSpy).not.toHaveBeenCalled(); @@ -212,9 +209,6 @@ describe('Editor', () => { expect(model).toBe(mockedClonedModel); expect(createContentModelSpy).toHaveBeenCalledWith(mockedCore, { - processorOverride: { - table: tableProcessor, - }, tryGetFromCache: false, }); expect(transformColorSpy).toHaveBeenCalledWith( diff --git a/packages/roosterjs-content-model-core/test/editor/core/createEditorDefaultSettingsTest.ts b/packages/roosterjs-content-model-core/test/editor/core/createEditorDefaultSettingsTest.ts index 2c9a69aab21..942c94313df 100644 --- a/packages/roosterjs-content-model-core/test/editor/core/createEditorDefaultSettingsTest.ts +++ b/packages/roosterjs-content-model-core/test/editor/core/createEditorDefaultSettingsTest.ts @@ -1,6 +1,5 @@ import * as createDomToModelContext from 'roosterjs-content-model-dom/lib/domToModel/context/createDomToModelContext'; import * as createModelToDomContext from 'roosterjs-content-model-dom/lib/modelToDom/context/createModelToDomContext'; -import { tablePreProcessor } from '../../../lib/override/tablePreProcessor'; import { listItemMetadataApplier, listLevelMetadataApplier, @@ -23,22 +22,11 @@ describe('createDomToModelSettings', () => { const settings = createDomToModelSettings({}); expect(settings).toEqual({ - builtIn: { - processorOverride: { - table: tablePreProcessor, - }, - }, + builtIn: {}, customized: {}, calculated: mockedCalculatedConfig, }); - expect(createDomToModelContext.createDomToModelConfig).toHaveBeenCalledWith([ - { - processorOverride: { - table: tablePreProcessor, - }, - }, - {}, - ]); + expect(createDomToModelContext.createDomToModelConfig).toHaveBeenCalledWith([{}, {}]); }); it('Has options', () => { @@ -48,20 +36,12 @@ describe('createDomToModelSettings', () => { }); expect(settings).toEqual({ - builtIn: { - processorOverride: { - table: tablePreProcessor, - }, - }, + builtIn: {}, customized: defaultDomToModelOptions, calculated: mockedCalculatedConfig, }); expect(createDomToModelContext.createDomToModelConfig).toHaveBeenCalledWith([ - { - processorOverride: { - table: tablePreProcessor, - }, - }, + {}, defaultDomToModelOptions, ]); }); diff --git a/packages/roosterjs-content-model-core/test/overrides/tablePreProcessorTest.ts b/packages/roosterjs-content-model-core/test/overrides/tablePreProcessorTest.ts deleted file mode 100644 index 55089ec522a..00000000000 --- a/packages/roosterjs-content-model-core/test/overrides/tablePreProcessorTest.ts +++ /dev/null @@ -1,214 +0,0 @@ -import * as tableProcessor from 'roosterjs-content-model-dom/lib/domToModel/processors/tableProcessor'; -import { createContentModelDocument, createDomToModelContext } from 'roosterjs-content-model-dom'; -import { tablePreProcessor } from '../../lib/override/tablePreProcessor'; - -describe('tablePreProcessor', () => { - it('Table without metadata, use Entity', () => { - const table = document.createElement('table'); - const group = createContentModelDocument(); - const context = createDomToModelContext(); - - tablePreProcessor(group, table, context); - - expect(group).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Entity', - segmentType: 'Entity', - format: {}, - entityFormat: { - isFakeEntity: true, - id: undefined, - entityType: undefined, - isReadonly: true, - }, - wrapper: table, - }, - ], - }); - }); - - it('Table with metadata, do not use Entity', () => { - const table = document.createElement('table'); - const group = createContentModelDocument(); - const context = createDomToModelContext(); - const tableProcessorSpy = spyOn(tableProcessor, 'tableProcessor'); - - table.dataset.editingInfo = '{}'; - - tablePreProcessor(group, table, context); - - expect(group).toEqual({ - blockGroupType: 'Document', - blocks: [], - }); - expect(tableProcessorSpy).toHaveBeenCalledWith(group, table, context); - }); - - it('Table with regular selection 1, do not use Entity', () => { - const table = document.createElement('table'); - const tr = document.createElement('tr'); - const td = document.createElement('td'); - const txt = document.createTextNode('test'); - const group = createContentModelDocument(); - const context = createDomToModelContext(); - const tableProcessorSpy = spyOn(tableProcessor, 'tableProcessor'); - - table.appendChild(tr); - tr.appendChild(td); - td.appendChild(txt); - - context.selection = { - type: 'range', - range: { - commonAncestorContainer: txt, - } as any, - isReverted: false, - }; - - tablePreProcessor(group, table, context); - - expect(group).toEqual({ - blockGroupType: 'Document', - blocks: [], - }); - expect(tableProcessorSpy).toHaveBeenCalledWith(group, table, context); - }); - - it('Table with regular selection 2, do not use Entity', () => { - const table = document.createElement('table'); - const tr = document.createElement('tr'); - const td = document.createElement('td'); - const txt = document.createTextNode('test'); - const group = createContentModelDocument(); - const context = createDomToModelContext(); - const tableProcessorSpy = spyOn(tableProcessor, 'tableProcessor'); - - table.appendChild(tr); - tr.appendChild(td); - td.appendChild(txt); - - context.selection = { - type: 'range', - range: { - commonAncestorContainer: txt, - } as any, - } as any; - - tablePreProcessor(group, table, context); - - expect(group).toEqual({ - blockGroupType: 'Document', - blocks: [], - }); - expect(tableProcessorSpy).toHaveBeenCalledWith(group, table, context); - }); - - it('Table with regular selection 3, do not use Entity', () => { - const table = document.createElement('table'); - const tr = document.createElement('tr'); - const td = document.createElement('td'); - const txt = document.createTextNode('test'); - const group = createContentModelDocument(); - const context = createDomToModelContext(); - const tableProcessorSpy = spyOn(tableProcessor, 'tableProcessor'); - - table.appendChild(tr); - tr.appendChild(td); - td.appendChild(txt); - - context.selection = { - type: 'range', - range: { - commonAncestorContainer: table, - } as any, - } as any; - - tablePreProcessor(group, table, context); - - expect(group).toEqual({ - blockGroupType: 'Document', - blocks: [], - }); - expect(tableProcessorSpy).toHaveBeenCalledWith(group, table, context); - }); - - it('Table with table selection, do not use Entity', () => { - const table = document.createElement('table'); - const tr = document.createElement('tr'); - const td = document.createElement('td'); - const txt = document.createTextNode('test'); - const group = createContentModelDocument(); - const context = createDomToModelContext(); - const tableProcessorSpy = spyOn(tableProcessor, 'tableProcessor'); - - table.appendChild(tr); - tr.appendChild(td); - td.appendChild(txt); - - context.selection = { - type: 'table', - table, - } as any; - - tablePreProcessor(group, table, context); - - expect(group).toEqual({ - blockGroupType: 'Document', - blocks: [], - }); - expect(tableProcessorSpy).toHaveBeenCalledWith(group, table, context); - }); - - it('Table with image selection, do not use Entity', () => { - const table = document.createElement('table'); - const tr = document.createElement('tr'); - const td = document.createElement('td'); - const txt = document.createTextNode('test'); - const group = createContentModelDocument(); - const context = createDomToModelContext(); - const tableProcessorSpy = spyOn(tableProcessor, 'tableProcessor'); - - table.appendChild(tr); - tr.appendChild(td); - td.appendChild(txt); - - context.selection = { - type: 'image', - image: txt as any, - } as any; - - tablePreProcessor(group, table, context); - - expect(group).toEqual({ - blockGroupType: 'Document', - blocks: [], - }); - expect(tableProcessorSpy).toHaveBeenCalledWith(group, table, context); - }); - - it('Table in selection, do not use Entity', () => { - const table = document.createElement('table'); - const tr = document.createElement('tr'); - const td = document.createElement('td'); - const txt = document.createTextNode('test'); - const group = createContentModelDocument(); - const context = createDomToModelContext(); - const tableProcessorSpy = spyOn(tableProcessor, 'tableProcessor'); - - table.appendChild(tr); - tr.appendChild(td); - td.appendChild(txt); - - context.isInSelection = true; - - tablePreProcessor(group, table, context); - - expect(group).toEqual({ - blockGroupType: 'Document', - blocks: [], - }); - expect(tableProcessorSpy).toHaveBeenCalledWith(group, table, context); - }); -}); From fad8ec664399ab129348530b7e70ebaf7c7303b9 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Fri, 1 Nov 2024 11:10:34 -0700 Subject: [PATCH 07/30] Add change data and apiName to ContentChangedEvent when handle keyboard input (#2854) --- .../lib/edit/keyboardEnter.ts | 2 ++ .../lib/edit/keyboardInput.ts | 2 ++ .../roosterjs-content-model-plugins/lib/edit/keyboardTab.ts | 5 +++++ .../test/edit/inputSteps/handleEnterOnListTest.ts | 2 +- 4 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/roosterjs-content-model-plugins/lib/edit/keyboardEnter.ts b/packages/roosterjs-content-model-plugins/lib/edit/keyboardEnter.ts index 2bb32aa5fe5..4b31935d854 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/keyboardEnter.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/keyboardEnter.ts @@ -58,6 +58,8 @@ export function keyboardEnter( rawEvent, scrollCaretIntoView: true, changeSource: ChangeSource.Keyboard, + getChangeData: () => rawEvent.which, + apiName: 'handleEnterKey', } ); } diff --git a/packages/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts b/packages/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts index 95ffb0d8667..201f387f455 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts @@ -38,6 +38,8 @@ export function keyboardInput(editor: IEditor, rawEvent: KeyboardEvent) { scrollCaretIntoView: true, rawEvent, changeSource: ChangeSource.Keyboard, + getChangeData: () => rawEvent.which, + apiName: 'handleInputKey', } ); diff --git a/packages/roosterjs-content-model-plugins/lib/edit/keyboardTab.ts b/packages/roosterjs-content-model-plugins/lib/edit/keyboardTab.ts index 495b79fdc80..0c275284a93 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/keyboardTab.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/keyboardTab.ts @@ -30,6 +30,9 @@ export function keyboardTab(editor: IEditor, rawEvent: KeyboardEvent) { }, { apiName: 'handleTabKey', + rawEvent, + changeSource: ChangeSource.Keyboard, + getChangeData: () => rawEvent.which, } ); @@ -41,7 +44,9 @@ export function keyboardTab(editor: IEditor, rawEvent: KeyboardEvent) { }, { apiName: 'handleTabKey', + rawEvent, changeSource: ChangeSource.Keyboard, + getChangeData: () => rawEvent.which, } ); return true; diff --git a/packages/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts b/packages/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts index c96f2291b05..da42138e570 100644 --- a/packages/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts +++ b/packages/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts @@ -1817,7 +1817,7 @@ describe('handleEnterOnList - keyboardEnter', () => { let editor: any; editingTestCommon( - undefined, + 'handleEnterKey', newEditor => { editor = newEditor; From b5d60cd17cdadb909a09ac2249e184aa140864ce Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Mon, 4 Nov 2024 14:52:15 -0300 Subject: [PATCH 08/30] WIP --- .../lib/modelApi/common/queryContentModel.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/roosterjs-content-model-api/lib/modelApi/common/queryContentModel.ts b/packages/roosterjs-content-model-api/lib/modelApi/common/queryContentModel.ts index 0ff93545342..048b7447609 100644 --- a/packages/roosterjs-content-model-api/lib/modelApi/common/queryContentModel.ts +++ b/packages/roosterjs-content-model-api/lib/modelApi/common/queryContentModel.ts @@ -47,9 +47,10 @@ export function queryContentModel< for (let i = 0; i < group.blocks.length; i++) { if (findFirstOnly && elements.length > 0) { - break; + return elements; } const block = group.blocks[i]; + console.log(block.blockType); switch (block.blockType) { case 'BlockGroup': if (type == block.blockType && (!selector || selector(block as T))) { @@ -76,7 +77,7 @@ export function queryContentModel< if (!segmentType && (!selector || selector(block as T))) { elements.push(block as T); } else if (segmentType) { - const segments = searchInParagraphs(block, segmentType, selector); + const segments = searchInParagraphs(block, segmentType, options, selector); elements.push(...(segments as T[])); } } @@ -95,7 +96,6 @@ function searchInTables(cell, options); - blocks.push(...items); } } @@ -105,12 +105,18 @@ function searchInTables( block: ReadonlyContentModelParagraph, segmentType: ContentModelSegmentType, + options: QueryContentModelOptions

, selector?: (element: P) => boolean ): P[] { const segments: P[] = []; for (const segment of block.segments) { if (segment.segmentType == segmentType && (!selector || selector(segment as P))) { - segments.push(segment as P); + if (segment.segmentType !== 'General') { + segments.push(segment as P); + } else { + const blocks = queryContentModel

(segment, options); + segments.push(...blocks); + } } } return segments; From 6370fded7131838ea8626438e3dd0433194080fe Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Mon, 4 Nov 2024 15:49:03 -0300 Subject: [PATCH 09/30] nits --- .../lib/modelApi/common/queryContentModel.ts | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/packages/roosterjs-content-model-api/lib/modelApi/common/queryContentModel.ts b/packages/roosterjs-content-model-api/lib/modelApi/common/queryContentModel.ts index 048b7447609..e89a351f656 100644 --- a/packages/roosterjs-content-model-api/lib/modelApi/common/queryContentModel.ts +++ b/packages/roosterjs-content-model-api/lib/modelApi/common/queryContentModel.ts @@ -50,7 +50,6 @@ export function queryContentModel< return elements; } const block = group.blocks[i]; - console.log(block.blockType); switch (block.blockType) { case 'BlockGroup': if (type == block.blockType && (!selector || selector(block as T))) { @@ -77,7 +76,7 @@ export function queryContentModel< if (!segmentType && (!selector || selector(block as T))) { elements.push(block as T); } else if (segmentType) { - const segments = searchInParagraphs(block, segmentType, options, selector); + const segments = searchInParagraphs(block, segmentType, selector); elements.push(...(segments as T[])); } } @@ -105,18 +104,12 @@ function searchInTables( block: ReadonlyContentModelParagraph, segmentType: ContentModelSegmentType, - options: QueryContentModelOptions

, selector?: (element: P) => boolean ): P[] { const segments: P[] = []; for (const segment of block.segments) { if (segment.segmentType == segmentType && (!selector || selector(segment as P))) { - if (segment.segmentType !== 'General') { - segments.push(segment as P); - } else { - const blocks = queryContentModel

(segment, options); - segments.push(...blocks); - } + segments.push(segment as P); } } return segments; From c2051c78d474ddce2501258be15aec7cf6173b34 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Mon, 4 Nov 2024 12:58:11 -0800 Subject: [PATCH 10/30] Fix #2857 (#2858) --- .../lib/edit/inputSteps/handleEnterOnList.ts | 2 +- .../edit/inputSteps/handleEnterOnListTest.ts | 102 ++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/packages/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts b/packages/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts index 224510a39f6..de044090317 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts @@ -98,7 +98,7 @@ const createNewListItem = ( const levels = createNewListLevel(listItem); const newListItem: ShallowMutableContentModelListItem = createListItem( levels, - insertPoint.marker.format + listItem.formatHolder.format ); newListItem.blocks.push(newParagraph); diff --git a/packages/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts b/packages/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts index da42138e570..8a25562b7cf 100644 --- a/packages/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts +++ b/packages/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts @@ -1797,6 +1797,108 @@ describe('handleEnterOnList', () => { }; runTest(model, expectedModel, 'range', listItem); }); + + it('Selection Marker has styles from previous list item', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: { fontSize: '20pt', fontWeight: 'bold' }, + }, + { + segmentType: 'Text', + text: 'test', + format: { fontWeight: 'bold' }, + }, + ], + format: {}, + }, + ], + format: {}, + formatHolder: { + segmentType: 'SelectionMarker', + format: { fontSize: '10pt' }, + }, + levels: [{ listType: 'UL', format: {}, dataset: {} }], + }, + ], + }; + const listItem: ContentModelListItem = { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: { fontSize: '20pt', fontWeight: 'bold' }, + }, + { + segmentType: 'Text', + text: 'test', + format: { fontWeight: 'bold' }, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'UL', + format: { + startNumberOverride: undefined, + displayForDummyItem: undefined, + }, + dataset: {}, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: { fontSize: '10pt' }, + }, + format: {}, + }; + const expectedModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: { fontSize: '20pt', fontWeight: 'bold' }, + }, + ], + format: {}, + segmentFormat: { fontSize: '20pt' }, + }, + ], + format: {}, + formatHolder: { segmentType: 'SelectionMarker', format: { fontSize: '10pt' } }, + levels: [{ listType: 'UL', format: {}, dataset: {} }], + }, + listItem, + ], + }; + + runTest(model, expectedModel, 'range', listItem); + }); }); describe('handleEnterOnList - keyboardEnter', () => { From c172ca4c63ca9cd5b707838bcbef863ae1b7973c Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Tue, 5 Nov 2024 14:10:07 -0300 Subject: [PATCH 11/30] refactor --- .../controlsV2/demoButtons/cutButton.ts | 22 + .../roosterjs-content-model-api/lib/index.ts | 5 +- .../lib/modelApi/common/queryContentModel.ts | 116 ----- .../common/queryContentModelBlocks.ts | 108 ++++ ...Test.ts => queryContentModelBlocksTest.ts} | 490 +++--------------- .../lib/imageEdit/utils/findEditingImage.ts | 8 +- 6 files changed, 223 insertions(+), 526 deletions(-) create mode 100644 demo/scripts/controlsV2/demoButtons/cutButton.ts delete mode 100644 packages/roosterjs-content-model-api/lib/modelApi/common/queryContentModel.ts create mode 100644 packages/roosterjs-content-model-api/lib/modelApi/common/queryContentModelBlocks.ts rename packages/roosterjs-content-model-api/test/modelApi/common/{queryContentModelTest.ts => queryContentModelBlocksTest.ts} (78%) diff --git a/demo/scripts/controlsV2/demoButtons/cutButton.ts b/demo/scripts/controlsV2/demoButtons/cutButton.ts new file mode 100644 index 00000000000..9c118a4a9b1 --- /dev/null +++ b/demo/scripts/controlsV2/demoButtons/cutButton.ts @@ -0,0 +1,22 @@ +import type { RibbonButton } from 'roosterjs-react'; + +/** + * Key of localized strings of Cut button + */ +export type CutButtonStringKey = 'buttonNameCut'; + +/** + * "Cut" button on the format ribbon + */ +export const cutButton: RibbonButton = { + key: 'buttonNameCut', + unlocalizedText: ' Cut', + iconName: 'ClearNight', + onClick: editor => { + const selection = editor.getDOMSelection(); + if (selection) { + document.execCommand('cut'); + } + return true; + }, +}; diff --git a/packages/roosterjs-content-model-api/lib/index.ts b/packages/roosterjs-content-model-api/lib/index.ts index 2f1558ac367..88d60893bc5 100644 --- a/packages/roosterjs-content-model-api/lib/index.ts +++ b/packages/roosterjs-content-model-api/lib/index.ts @@ -60,4 +60,7 @@ export { setModelIndentation } from './modelApi/block/setModelIndentation'; export { matchLink } from './modelApi/link/matchLink'; export { promoteLink } from './modelApi/link/promoteLink'; export { getListAnnounceData } from './modelApi/list/getListAnnounceData'; -export { queryContentModel, QueryContentModelOptions } from './modelApi/common/queryContentModel'; +export { + queryContentModelBlocks, + QueryContentModelOptions, +} from './modelApi/common/queryContentModelBlocks'; diff --git a/packages/roosterjs-content-model-api/lib/modelApi/common/queryContentModel.ts b/packages/roosterjs-content-model-api/lib/modelApi/common/queryContentModel.ts deleted file mode 100644 index e89a351f656..00000000000 --- a/packages/roosterjs-content-model-api/lib/modelApi/common/queryContentModel.ts +++ /dev/null @@ -1,116 +0,0 @@ -import type { - ContentModelBlockType, - ContentModelSegmentType, - ReadonlyContentModelBlock, - ReadonlyContentModelBlockGroup, - ReadonlyContentModelParagraph, - ReadonlyContentModelSegment, - ReadonlyContentModelTable, -} from 'roosterjs-content-model-types'; - -/** - * Options for queryContentModel - */ -export interface QueryContentModelOptions { - /** - * The type of block to query @default 'Paragraph' - */ - type?: ContentModelBlockType; - - /** - * The type of segment to query - */ - segmentType?: ContentModelSegmentType; - - /** - * Optional selector to filter the blocks/segments - */ - selector?: (element: T) => boolean; - - /** - * True to return the first block only, false to return all blocks - */ - findFirstOnly?: boolean; -} - -/** - * Query content model blocks or segments - * @param group The block group to query - * @param options The query option - */ -export function queryContentModel< - T extends ReadonlyContentModelBlock | ReadonlyContentModelSegment ->(group: ReadonlyContentModelBlockGroup, options: QueryContentModelOptions): T[] { - const elements: T[] = []; - const searchOptions = options.type ? options : { ...options, type: 'Paragraph' }; - const { type, segmentType, selector, findFirstOnly } = searchOptions; - - for (let i = 0; i < group.blocks.length; i++) { - if (findFirstOnly && elements.length > 0) { - return elements; - } - const block = group.blocks[i]; - switch (block.blockType) { - case 'BlockGroup': - if (type == block.blockType && (!selector || selector(block as T))) { - elements.push(block as T); - } - const blockGroupsResults = queryContentModel(block, options); - elements.push(...(blockGroupsResults as T[])); - break; - case 'Table': - if (type == block.blockType && (!selector || selector(block as T))) { - elements.push(block as T); - } - const tableResults = searchInTables(block, options); - elements.push(...(tableResults as T[])); - break; - case 'Divider': - case 'Entity': - if (type == block.blockType && (!selector || selector(block as T))) { - elements.push(block as T); - } - break; - case 'Paragraph': - if (type == block.blockType) { - if (!segmentType && (!selector || selector(block as T))) { - elements.push(block as T); - } else if (segmentType) { - const segments = searchInParagraphs(block, segmentType, selector); - elements.push(...(segments as T[])); - } - } - break; - } - } - - return elements; -} - -function searchInTables( - table: ReadonlyContentModelTable, - options: QueryContentModelOptions -): T[] { - const blocks: T[] = []; - for (const row of table.rows) { - for (const cell of row.cells) { - const items = queryContentModel(cell, options); - blocks.push(...items); - } - } - return blocks; -} - -function searchInParagraphs

( - block: ReadonlyContentModelParagraph, - segmentType: ContentModelSegmentType, - selector?: (element: P) => boolean -): P[] { - const segments: P[] = []; - for (const segment of block.segments) { - if (segment.segmentType == segmentType && (!selector || selector(segment as P))) { - segments.push(segment as P); - } - } - return segments; -} diff --git a/packages/roosterjs-content-model-api/lib/modelApi/common/queryContentModelBlocks.ts b/packages/roosterjs-content-model-api/lib/modelApi/common/queryContentModelBlocks.ts new file mode 100644 index 00000000000..afceced01b5 --- /dev/null +++ b/packages/roosterjs-content-model-api/lib/modelApi/common/queryContentModelBlocks.ts @@ -0,0 +1,108 @@ +import type { + ContentModelBlockType, + ReadonlyContentModelBlock, + ReadonlyContentModelBlockGroup, + ReadonlyContentModelTable, +} from 'roosterjs-content-model-types'; + +/** + * Options for queryContentModel + */ +export interface QueryContentModelOptions { + /** + * The type of block to query @default 'Paragraph' + */ + blockType?: ContentModelBlockType; + + /** + * Optional selector to filter the blocks + */ + filter?: (element: T) => element is T; + + /** + * True to return the first block only, false to return all blocks + */ + findFirstOnly?: boolean; +} + +/** + * Query content model blocks + * @param group The block group to query + * @param options The query option + */ +export function queryContentModelBlocks( + group: ReadonlyContentModelBlockGroup, + options: QueryContentModelOptions +): T[] { + const { blockType, filter, findFirstOnly } = options; + const type = blockType || 'Paragraph'; + + return queryContentModelBlocksInternal(group, type, filter, findFirstOnly); +} + +function queryContentModelBlocksInternal( + group: ReadonlyContentModelBlockGroup, + type: ContentModelBlockType, + filter?: (element: T) => element is T, + findFirstOnly?: boolean +): T[] { + const elements: T[] = []; + for (let i = 0; i < group.blocks.length; i++) { + if (findFirstOnly && elements.length > 0) { + return elements; + } + const block = group.blocks[i]; + switch (block.blockType) { + case 'BlockGroup': + if (isBlockType(block, type) && (!filter || filter(block))) { + elements.push(block); + } + const blockGroupsResults = queryContentModelBlocksInternal( + block, + type, + filter, + findFirstOnly + ); + elements.push(...blockGroupsResults); + break; + case 'Table': + if (isBlockType(block, type) && (!filter || filter(block))) { + elements.push(block); + } + const tableResults = searchInTables(block, type, filter, findFirstOnly); + elements.push(...tableResults); + break; + case 'Divider': + case 'Entity': + case 'Paragraph': + if (isBlockType(block, type) && (!filter || filter(block))) { + elements.push(block); + } + break; + } + } + return elements; +} + +function isBlockType( + block: ReadonlyContentModelBlock, + type: string +): block is T { + return block.blockType == type; +} + +function searchInTables( + table: ReadonlyContentModelTable, + type: ContentModelBlockType, + filter?: (element: T) => element is T, + findFirstOnly?: boolean +): T[] { + const blocks: T[] = []; + for (const row of table.rows) { + for (const cell of row.cells) { + const items = queryContentModelBlocksInternal(cell, type, filter, findFirstOnly); + blocks.push(...items); + } + } + return blocks; +} diff --git a/packages/roosterjs-content-model-api/test/modelApi/common/queryContentModelTest.ts b/packages/roosterjs-content-model-api/test/modelApi/common/queryContentModelBlocksTest.ts similarity index 78% rename from packages/roosterjs-content-model-api/test/modelApi/common/queryContentModelTest.ts rename to packages/roosterjs-content-model-api/test/modelApi/common/queryContentModelBlocksTest.ts index 9306f1ead08..34c64d45c49 100644 --- a/packages/roosterjs-content-model-api/test/modelApi/common/queryContentModelTest.ts +++ b/packages/roosterjs-content-model-api/test/modelApi/common/queryContentModelBlocksTest.ts @@ -1,13 +1,12 @@ -import { queryContentModel } from '../../../lib/modelApi/common/queryContentModel'; +import { queryContentModelBlocks } from '../../../lib/modelApi/common/queryContentModelBlocks'; import { ReadonlyContentModelBlockGroup, - ReadonlyContentModelImage, ReadonlyContentModelListItem, ReadonlyContentModelParagraph, ReadonlyContentModelTable, } from 'roosterjs-content-model-types'; -describe('queryContentModel', () => { +describe('queryContentModelBlocksBlocks', () => { it('should return empty array if no blocks', () => { // Arrange const group: ReadonlyContentModelBlockGroup = { @@ -16,7 +15,7 @@ describe('queryContentModel', () => { }; // Act - const result = queryContentModel(group, {}); + const result = queryContentModelBlocks(group, {}); // Assert expect(result).toEqual([]); @@ -37,7 +36,7 @@ describe('queryContentModel', () => { }; // Act - const result = queryContentModel(group, { type: 'Table' }); + const result = queryContentModelBlocks(group, { blockType: 'Table' }); // Assert expect(result).toEqual([]); @@ -319,7 +318,9 @@ describe('queryContentModel', () => { ]; // Act - const result = queryContentModel(group, { type: 'Table' }); + const result = queryContentModelBlocks(group, { + blockType: 'Table', + }); // Assert expect(result).toEqual(expected); @@ -376,126 +377,15 @@ describe('queryContentModel', () => { }; // Act - const result = queryContentModel(group, { - type: 'Paragraph', - - selector: block => block.segments.length == 2, + const result = queryContentModelBlocks(group, { + blockType: 'Paragraph', + filter: (block): block is ReadonlyContentModelParagraph => block.segments.length == 2, }); // Assert expect(result).toEqual([paragraph]); }); - it('should return first segment that match the type and selector', () => { - const image: ReadonlyContentModelImage = { - src: - 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAsJCQcJCQcJCQkJCwkJCQkJCQsJCwsMCwsLDA0QDB...', - isSelectedAsImageSelection: true, - segmentType: 'Image', - isSelected: true, - format: { - fontFamily: 'Calibri', - fontSize: '11pt', - textColor: 'rgb(0, 0, 0)', - id: 'image_0', - maxWidth: '1492px', - }, - dataset: { - isEditing: 'true', - }, - }; - const model: ReadonlyContentModelBlockGroup = { - blockGroupType: 'Document', - blocks: [ - { - widths: [120, 153], - rows: [ - { - height: 157, - cells: [ - { - spanAbove: false, - spanLeft: false, - isHeader: false, - blockGroupType: 'TableCell', - blocks: [ - { - segments: [ - { - segmentType: 'Br', - format: {}, - }, - ], - segmentFormat: {}, - blockType: 'Paragraph', - format: {}, - }, - ], - format: {}, - dataset: {}, - }, - { - spanAbove: false, - spanLeft: false, - isHeader: false, - blockGroupType: 'TableCell', - blocks: [ - { - segments: [image], - segmentFormat: {}, - blockType: 'Paragraph', - format: {}, - }, - ], - format: {}, - dataset: {}, - }, - ], - format: {}, - }, - ], - blockType: 'Table', - format: { - useBorderBox: true, - borderCollapse: true, - }, - dataset: { - editingInfo: - '{"topBorderColor":"#ABABAB","bottomBorderColor":"#ABABAB","verticalBorderColor":"#ABABAB","hasHeaderRow":false,"hasFirstColumn":false,"hasBandedRows":false,"hasBandedColumns":false,"bgColorEven":null,"bgColorOdd":"#ABABAB20","headerRowColor":"#ABABAB","tableBorderFormat":0,"verticalAlign":"top"}', - }, - }, - { - segments: [ - { - segmentType: 'Br', - format: {}, - }, - ], - segmentFormat: {}, - blockType: 'Paragraph', - format: {}, - }, - { - segments: [ - { - segmentType: 'Br', - format: {}, - }, - ], - segmentFormat: {}, - blockType: 'Paragraph', - format: {}, - }, - ], - }; - const result = queryContentModel(model, { - segmentType: 'Image', - selector: (segment: ReadonlyContentModelImage) => !!segment.dataset.isEditing, - findFirstOnly: true, - }); - expect(result).toEqual([image]); - }); - it('should return all tables that match the type and selector', () => { const model: ReadonlyContentModelBlockGroup = { blockGroupType: 'Document', @@ -875,7 +765,9 @@ describe('queryContentModel', () => { }, }, ]; - const result = queryContentModel(model, { type: 'Table' }); + const result = queryContentModelBlocks(model, { + blockType: 'Table', + }); expect(result).toEqual(expected); }); @@ -1013,7 +905,9 @@ describe('queryContentModel', () => { }; const expected: ReadonlyContentModelTable[] = [table]; - const result = queryContentModel(model, { type: 'Table' }); + const result = queryContentModelBlocks(model, { + blockType: 'Table', + }); expect(result).toEqual(expected); }); @@ -1273,274 +1167,14 @@ describe('queryContentModel', () => { }, ]; - const result = queryContentModel(model, { - type: 'BlockGroup', - selector: block => block.blockGroupType == 'ListItem', + const result = queryContentModelBlocks(model, { + blockType: 'BlockGroup', + filter: (block): block is ReadonlyContentModelListItem => + block.blockGroupType == 'ListItem', }); expect(result).toEqual(listExpected); }); - it('should return all images', () => { - const model: ReadonlyContentModelBlockGroup = { - blockGroupType: 'Document', - blocks: [ - { - segments: [ - { - src: - 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAsJCQcJCQcJCQkJCwkJCQkJCQsJCwsMCwsLDA0QDB...', - segmentType: 'Image', - format: {}, - dataset: {}, - }, - ], - segmentFormat: {}, - blockType: 'Paragraph', - format: {}, - }, - { - formatHolder: { - isSelected: false, - segmentType: 'SelectionMarker', - format: {}, - }, - levels: [ - { - listType: 'OL', - format: { - startNumberOverride: 1, - listStyleType: 'decimal', - }, - dataset: { - editingInfo: - '{"applyListStyleFromLevel":false,"orderedStyleType":1}', - }, - }, - ], - blockType: 'BlockGroup', - format: {}, - blockGroupType: 'ListItem', - blocks: [ - { - segments: [ - { - text: 'test', - segmentType: 'Text', - format: {}, - }, - ], - segmentFormat: {}, - blockType: 'Paragraph', - format: {}, - }, - ], - }, - { - formatHolder: { - isSelected: false, - segmentType: 'SelectionMarker', - format: {}, - }, - levels: [ - { - listType: 'OL', - format: { - listStyleType: 'decimal', - }, - dataset: { - editingInfo: - '{"applyListStyleFromLevel":false,"orderedStyleType":1}', - }, - }, - ], - blockType: 'BlockGroup', - format: {}, - blockGroupType: 'ListItem', - blocks: [ - { - segments: [ - { - src: - 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAsJCQcJCQcJCQkJCwkJCQkJCQsJCwsMCwsLDA0QDB...', - segmentType: 'Image', - format: {}, - dataset: {}, - }, - ], - segmentFormat: {}, - blockType: 'Paragraph', - format: {}, - }, - ], - }, - { - widths: [153], - rows: [ - { - height: 157, - cells: [ - { - spanAbove: false, - spanLeft: false, - isHeader: false, - blockGroupType: 'TableCell', - blocks: [ - { - segments: [ - { - src: - 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAsJCQcJCQcJCQkJCwkJCQkJCQsJCwsMCwsLDA0QDB...', - segmentType: 'Image', - format: {}, - dataset: {}, - }, - ], - segmentFormat: {}, - blockType: 'Paragraph', - format: {}, - }, - ], - format: {}, - dataset: {}, - }, - ], - format: {}, - }, - ], - blockType: 'Table', - format: {}, - dataset: { - editingInfo: - '{"topBorderColor":"#ABABAB","bottomBorderColor":"#ABABAB","verticalBorderColor":"#ABABAB","hasHeaderRow":false,"hasFirstColumn":false,"hasBandedRows":false,"hasBandedColumns":false,"bgColorEven":null,"bgColorOdd":"#ABABAB20","headerRowColor":"#ABABAB","tableBorderFormat":0,"verticalAlign":"top"}', - }, - }, - { - isImplicit: true, - segments: [ - { - isSelected: true, - segmentType: 'SelectionMarker', - format: {}, - }, - ], - segmentFormat: {}, - blockType: 'Paragraph', - format: {}, - }, - { - segments: [ - { - segmentType: 'Br', - format: {}, - }, - ], - segmentFormat: {}, - blockType: 'Paragraph', - format: {}, - }, - { - segments: [ - { - segmentType: 'Br', - format: {}, - }, - ], - segmentFormat: {}, - blockType: 'Paragraph', - format: {}, - }, - { - segments: [ - { - segmentType: 'Br', - format: {}, - }, - ], - segmentFormat: {}, - blockType: 'Paragraph', - format: {}, - }, - { - formatHolder: { - isSelected: false, - segmentType: 'SelectionMarker', - format: {}, - }, - levels: [ - { - listType: 'OL', - format: { - listStyleType: 'decimal', - displayForDummyItem: 'block', - }, - dataset: { - editingInfo: - '{"applyListStyleFromLevel":false,"orderedStyleType":1}', - }, - }, - ], - blockType: 'BlockGroup', - format: {}, - blockGroupType: 'ListItem', - blocks: [ - { - segments: [ - { - segmentType: 'Br', - format: {}, - }, - ], - segmentFormat: {}, - blockType: 'Paragraph', - format: {}, - }, - ], - }, - { - segments: [ - { - segmentType: 'Br', - format: {}, - }, - ], - segmentFormat: {}, - blockType: 'Paragraph', - format: {}, - }, - ], - format: {}, - }; - - const expected: ReadonlyContentModelImage[] = [ - { - src: - 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAsJCQcJCQcJCQkJCwkJCQkJCQsJCwsMCwsLDA0QDB...', - segmentType: 'Image', - format: {}, - dataset: {}, - }, - { - src: - 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAsJCQcJCQcJCQkJCwkJCQkJCQsJCwsMCwsLDA0QDB...', - segmentType: 'Image', - format: {}, - dataset: {}, - }, - { - src: - 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAsJCQcJCQcJCQkJCwkJCQkJCQsJCwsMCwsLDA0QDB...', - segmentType: 'Image', - format: {}, - dataset: {}, - }, - ]; - - const result = queryContentModel(model, { - segmentType: 'Image', - }); - expect(result).toEqual(expected); - }); - it('should return image from a word online table', () => { const model: ReadonlyContentModelBlockGroup = { blockGroupType: 'Document', @@ -1765,32 +1399,76 @@ describe('queryContentModel', () => { textColor: '#000000', }, }; - const image: ReadonlyContentModelImage = { - src: - 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAsJCQcJCQcJCQkJCwkJCQkJCQsJCwsMCwsLDA0QDB...', - isSelectedAsImageSelection: true, - segmentType: 'Image', - isSelected: true, - format: { - fontFamily: 'Aptos, Aptos_EmbeddedFont, Aptos_MSFontService, sans-serif', - fontSize: '12pt', - textColor: 'rgb(0, 0, 0)', + const imageAndParagraph: ReadonlyContentModelParagraph = { + segments: [ + { + text: ' ', + segmentType: 'Text', + format: { + fontFamily: 'Aptos, Aptos_EmbeddedFont, Aptos_MSFontService, sans-serif', + fontSize: '12pt', + textColor: 'rgb(0, 0, 0)', + italic: false, + fontWeight: 'normal', + lineHeight: '18px', + }, + }, + { + src: + 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAsJCQcJCQcJCQkJCwkJCQkJCQsJCwsMCwsLDA0QDB...', + isSelectedAsImageSelection: true, + segmentType: 'Image', + isSelected: true, + format: { + fontFamily: 'Aptos, Aptos_EmbeddedFont, Aptos_MSFontService, sans-serif', + fontSize: '12pt', + textColor: 'rgb(0, 0, 0)', + italic: false, + fontWeight: 'normal', + lineHeight: '18px', + backgroundColor: '', + maxWidth: '1492px', + id: 'image_0', + }, + dataset: { + isEditing: 'true', + }, + }, + ], + segmentFormat: { italic: false, fontWeight: 'normal', - lineHeight: '18px', - backgroundColor: '', - maxWidth: '1492px', - id: 'image_0', + textColor: 'rgb(0, 0, 0)', }, - dataset: { - isEditing: 'true', + blockType: 'Paragraph', + format: { + textAlign: 'start', + direction: 'ltr', + marginLeft: '0px', + marginRight: '0px', + textIndent: '0px', + whiteSpace: 'pre-wrap', + marginTop: '0px', + marginBottom: '0px', + }, + decorator: { + tagName: 'p', + format: {}, }, }; - const result = queryContentModel(model, { - segmentType: 'Image', + const result = queryContentModelBlocks(model, { findFirstOnly: true, - selector: (segment: ReadonlyContentModelImage) => !!segment.dataset.isEditing, + filter: ( + block: ReadonlyContentModelParagraph + ): block is ReadonlyContentModelParagraph => { + for (const segment of block.segments) { + if (segment.segmentType == 'Image' && segment.dataset.isEditing) { + return true; + } + } + return false; + }, }); - expect(result).toEqual([image]); + expect(result).toEqual([imageAndParagraph]); }); }); diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/findEditingImage.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/findEditingImage.ts index 35bc9f8140c..8c20467c371 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/findEditingImage.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/findEditingImage.ts @@ -1,4 +1,4 @@ -import { queryContentModel } from 'roosterjs-content-model-api'; +import { queryContentModelBlocks } from 'roosterjs-content-model-api'; import type { ReadonlyContentModelBlockGroup, ReadonlyContentModelParagraph, @@ -13,8 +13,10 @@ export function findEditingImage( imageId?: string ): ImageAndParagraph | null { let imageAndParagraph: ImageAndParagraph | null = null; - queryContentModel(group, { - selector: (paragraph: ReadonlyContentModelParagraph) => { + queryContentModelBlocks(group, { + filter: ( + paragraph: ReadonlyContentModelParagraph + ): paragraph is ReadonlyContentModelParagraph => { for (const segment of paragraph.segments) { if ( segment.segmentType == 'Image' && From 1f3422cef84c58d92ba4c311f251f9d5d4aa34ef Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Wed, 6 Nov 2024 11:23:34 -0300 Subject: [PATCH 12/30] refactor --- .../common/queryContentModelBlocks.ts | 100 +++++++++--------- 1 file changed, 50 insertions(+), 50 deletions(-) diff --git a/packages/roosterjs-content-model-api/lib/modelApi/common/queryContentModelBlocks.ts b/packages/roosterjs-content-model-api/lib/modelApi/common/queryContentModelBlocks.ts index afceced01b5..aed7d04f0f7 100644 --- a/packages/roosterjs-content-model-api/lib/modelApi/common/queryContentModelBlocks.ts +++ b/packages/roosterjs-content-model-api/lib/modelApi/common/queryContentModelBlocks.ts @@ -2,7 +2,6 @@ import type { ContentModelBlockType, ReadonlyContentModelBlock, ReadonlyContentModelBlockGroup, - ReadonlyContentModelTable, } from 'roosterjs-content-model-types'; /** @@ -36,73 +35,74 @@ export function queryContentModelBlocks( ): T[] { const { blockType, filter, findFirstOnly } = options; const type = blockType || 'Paragraph'; - - return queryContentModelBlocksInternal(group, type, filter, findFirstOnly); + const elements: T[] = []; + for (let i = 0; i < group.blocks.length; i++) { + if (findFirstOnly && elements.length > 0) { + return elements; + } + const block = group.blocks[i]; + const results = queryContentModelBlocksInternal(block, type, filter, findFirstOnly); + elements.push(...results); + } + return elements; } function queryContentModelBlocksInternal( - group: ReadonlyContentModelBlockGroup, + block: ReadonlyContentModelBlock, type: ContentModelBlockType, filter?: (element: T) => element is T, findFirstOnly?: boolean ): T[] { const elements: T[] = []; - for (let i = 0; i < group.blocks.length; i++) { - if (findFirstOnly && elements.length > 0) { - return elements; + if (isExpectedBlockType(block, type, filter)) { + elements.push(block); + } + + if (block.blockType == 'BlockGroup') { + for (const childBlock of block.blocks) { + if (findFirstOnly && elements.length > 0) { + return elements; + } + const results = queryContentModelBlocksInternal( + childBlock, + type, + filter, + findFirstOnly + ); + elements.push(...results); } - const block = group.blocks[i]; - switch (block.blockType) { - case 'BlockGroup': - if (isBlockType(block, type) && (!filter || filter(block))) { - elements.push(block); - } - const blockGroupsResults = queryContentModelBlocksInternal( - block, - type, - filter, - findFirstOnly - ); - elements.push(...blockGroupsResults); - break; - case 'Table': - if (isBlockType(block, type) && (!filter || filter(block))) { - elements.push(block); - } - const tableResults = searchInTables(block, type, filter, findFirstOnly); - elements.push(...tableResults); - break; - case 'Divider': - case 'Entity': - case 'Paragraph': - if (isBlockType(block, type) && (!filter || filter(block))) { - elements.push(block); + } + + if (block.blockType == 'Table') { + const table = block; + for (const row of table.rows) { + for (const cell of row.cells) { + for (const cellBlock of cell.blocks) { + const results = queryContentModelBlocksInternal( + cellBlock, + type, + filter, + findFirstOnly + ); + elements.push(...results); } - break; + } } } return elements; } +function isExpectedBlockType( + block: ReadonlyContentModelBlock, + type: ContentModelBlockType, + filter?: (element: T) => element is T +): block is T { + return isBlockType(block, type) && (!filter || filter(block)); +} + function isBlockType( block: ReadonlyContentModelBlock, type: string ): block is T { return block.blockType == type; } - -function searchInTables( - table: ReadonlyContentModelTable, - type: ContentModelBlockType, - filter?: (element: T) => element is T, - findFirstOnly?: boolean -): T[] { - const blocks: T[] = []; - for (const row of table.rows) { - for (const cell of row.cells) { - const items = queryContentModelBlocksInternal(cell, type, filter, findFirstOnly); - blocks.push(...items); - } - } - return blocks; -} From 3a00c66a0417fee350589830803fc50349e14a22 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Wed, 6 Nov 2024 11:41:54 -0300 Subject: [PATCH 13/30] refactor --- .../roosterjs-content-model-api/lib/index.ts | 5 +- .../common/queryContentModelBlocks.ts | 29 +++--------- .../common/queryContentModelBlocksTest.ts | 47 +++++++++---------- .../lib/imageEdit/utils/findEditingImage.ts | 12 ++--- 4 files changed, 34 insertions(+), 59 deletions(-) diff --git a/packages/roosterjs-content-model-api/lib/index.ts b/packages/roosterjs-content-model-api/lib/index.ts index 88d60893bc5..292f44d08e5 100644 --- a/packages/roosterjs-content-model-api/lib/index.ts +++ b/packages/roosterjs-content-model-api/lib/index.ts @@ -60,7 +60,4 @@ export { setModelIndentation } from './modelApi/block/setModelIndentation'; export { matchLink } from './modelApi/link/matchLink'; export { promoteLink } from './modelApi/link/promoteLink'; export { getListAnnounceData } from './modelApi/list/getListAnnounceData'; -export { - queryContentModelBlocks, - QueryContentModelOptions, -} from './modelApi/common/queryContentModelBlocks'; +export { queryContentModelBlocks } from './modelApi/common/queryContentModelBlocks'; diff --git a/packages/roosterjs-content-model-api/lib/modelApi/common/queryContentModelBlocks.ts b/packages/roosterjs-content-model-api/lib/modelApi/common/queryContentModelBlocks.ts index aed7d04f0f7..ebd290de2a7 100644 --- a/packages/roosterjs-content-model-api/lib/modelApi/common/queryContentModelBlocks.ts +++ b/packages/roosterjs-content-model-api/lib/modelApi/common/queryContentModelBlocks.ts @@ -4,36 +4,19 @@ import type { ReadonlyContentModelBlockGroup, } from 'roosterjs-content-model-types'; -/** - * Options for queryContentModel - */ -export interface QueryContentModelOptions { - /** - * The type of block to query @default 'Paragraph' - */ - blockType?: ContentModelBlockType; - - /** - * Optional selector to filter the blocks - */ - filter?: (element: T) => element is T; - - /** - * True to return the first block only, false to return all blocks - */ - findFirstOnly?: boolean; -} - /** * Query content model blocks * @param group The block group to query - * @param options The query option + * @param blockType The type of block to query @default 'Paragraph' + * @param filter Optional selector to filter the blocks + * @param findFirstOnly True to return the first block only, false to return all blocks */ export function queryContentModelBlocks( group: ReadonlyContentModelBlockGroup, - options: QueryContentModelOptions + blockType?: ContentModelBlockType, + filter?: (element: T) => element is T, + findFirstOnly?: boolean ): T[] { - const { blockType, filter, findFirstOnly } = options; const type = blockType || 'Paragraph'; const elements: T[] = []; for (let i = 0; i < group.blocks.length; i++) { diff --git a/packages/roosterjs-content-model-api/test/modelApi/common/queryContentModelBlocksTest.ts b/packages/roosterjs-content-model-api/test/modelApi/common/queryContentModelBlocksTest.ts index 34c64d45c49..8a07dc41da6 100644 --- a/packages/roosterjs-content-model-api/test/modelApi/common/queryContentModelBlocksTest.ts +++ b/packages/roosterjs-content-model-api/test/modelApi/common/queryContentModelBlocksTest.ts @@ -15,7 +15,7 @@ describe('queryContentModelBlocksBlocks', () => { }; // Act - const result = queryContentModelBlocks(group, {}); + const result = queryContentModelBlocks(group); // Assert expect(result).toEqual([]); @@ -36,7 +36,7 @@ describe('queryContentModelBlocksBlocks', () => { }; // Act - const result = queryContentModelBlocks(group, { blockType: 'Table' }); + const result = queryContentModelBlocks(group, 'Table'); // Assert expect(result).toEqual([]); @@ -318,9 +318,7 @@ describe('queryContentModelBlocksBlocks', () => { ]; // Act - const result = queryContentModelBlocks(group, { - blockType: 'Table', - }); + const result = queryContentModelBlocks(group, 'Table'); // Assert expect(result).toEqual(expected); @@ -377,10 +375,11 @@ describe('queryContentModelBlocksBlocks', () => { }; // Act - const result = queryContentModelBlocks(group, { - blockType: 'Paragraph', - filter: (block): block is ReadonlyContentModelParagraph => block.segments.length == 2, - }); + const result = queryContentModelBlocks( + group, + 'Paragraph', + (block): block is ReadonlyContentModelParagraph => block.segments.length == 2 + ); // Assert expect(result).toEqual([paragraph]); @@ -765,9 +764,7 @@ describe('queryContentModelBlocksBlocks', () => { }, }, ]; - const result = queryContentModelBlocks(model, { - blockType: 'Table', - }); + const result = queryContentModelBlocks(model, 'Table'); expect(result).toEqual(expected); }); @@ -905,9 +902,7 @@ describe('queryContentModelBlocksBlocks', () => { }; const expected: ReadonlyContentModelTable[] = [table]; - const result = queryContentModelBlocks(model, { - blockType: 'Table', - }); + const result = queryContentModelBlocks(model, 'Table'); expect(result).toEqual(expected); }); @@ -1167,11 +1162,11 @@ describe('queryContentModelBlocksBlocks', () => { }, ]; - const result = queryContentModelBlocks(model, { - blockType: 'BlockGroup', - filter: (block): block is ReadonlyContentModelListItem => - block.blockGroupType == 'ListItem', - }); + const result = queryContentModelBlocks( + model, + 'BlockGroup', + (block): block is ReadonlyContentModelListItem => block.blockGroupType == 'ListItem' + ); expect(result).toEqual(listExpected); }); @@ -1456,11 +1451,10 @@ describe('queryContentModelBlocksBlocks', () => { format: {}, }, }; - const result = queryContentModelBlocks(model, { - findFirstOnly: true, - filter: ( - block: ReadonlyContentModelParagraph - ): block is ReadonlyContentModelParagraph => { + const result = queryContentModelBlocks( + model, + 'Paragraph', + (block: ReadonlyContentModelParagraph): block is ReadonlyContentModelParagraph => { for (const segment of block.segments) { if (segment.segmentType == 'Image' && segment.dataset.isEditing) { return true; @@ -1468,7 +1462,8 @@ describe('queryContentModelBlocksBlocks', () => { } return false; }, - }); + true /* findFirstOnly */ + ); expect(result).toEqual([imageAndParagraph]); }); }); diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/findEditingImage.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/findEditingImage.ts index 8c20467c371..a3b257c9d94 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/findEditingImage.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/findEditingImage.ts @@ -13,10 +13,10 @@ export function findEditingImage( imageId?: string ): ImageAndParagraph | null { let imageAndParagraph: ImageAndParagraph | null = null; - queryContentModelBlocks(group, { - filter: ( - paragraph: ReadonlyContentModelParagraph - ): paragraph is ReadonlyContentModelParagraph => { + queryContentModelBlocks( + group, + 'Paragraph', + (paragraph: ReadonlyContentModelParagraph): paragraph is ReadonlyContentModelParagraph => { for (const segment of paragraph.segments) { if ( segment.segmentType == 'Image' && @@ -28,8 +28,8 @@ export function findEditingImage( } return false; }, - findFirstOnly: true, - }); + true /*findFirstOnly*/ + ); return imageAndParagraph; } From d27448442941ffa5270c6318389c26ec2e393297 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Wed, 6 Nov 2024 11:55:21 -0300 Subject: [PATCH 14/30] nit --- .../controlsV2/demoButtons/cutButton.ts | 22 ------------------- 1 file changed, 22 deletions(-) delete mode 100644 demo/scripts/controlsV2/demoButtons/cutButton.ts diff --git a/demo/scripts/controlsV2/demoButtons/cutButton.ts b/demo/scripts/controlsV2/demoButtons/cutButton.ts deleted file mode 100644 index 9c118a4a9b1..00000000000 --- a/demo/scripts/controlsV2/demoButtons/cutButton.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { RibbonButton } from 'roosterjs-react'; - -/** - * Key of localized strings of Cut button - */ -export type CutButtonStringKey = 'buttonNameCut'; - -/** - * "Cut" button on the format ribbon - */ -export const cutButton: RibbonButton = { - key: 'buttonNameCut', - unlocalizedText: ' Cut', - iconName: 'ClearNight', - onClick: editor => { - const selection = editor.getDOMSelection(); - if (selection) { - document.execCommand('cut'); - } - return true; - }, -}; From 53a7c768e4f5006f85e7448ebee776a5d0d67fe1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BA=84=E9=BB=9B=E6=B7=B3=E5=8D=8E?= Date: Thu, 7 Nov 2024 01:42:18 +0800 Subject: [PATCH 15/30] Revert Shortcut command for Clear Format on mac (#2834) * revert Shortcut command for Clear Format on mac * fix ctrl and meta can press in the same time * update comment --- .../lib/shortcut/ShortcutPlugin.ts | 6 +++++- .../lib/shortcut/shortcuts.ts | 3 +-- .../test/shortcut/ShortcutPluginTest.ts | 20 +++++++++++++++++++ 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/packages/roosterjs-content-model-plugins/lib/shortcut/ShortcutPlugin.ts b/packages/roosterjs-content-model-plugins/lib/shortcut/ShortcutPlugin.ts index 96987379992..b8d23493fab 100644 --- a/packages/roosterjs-content-model-plugins/lib/shortcut/ShortcutPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/shortcut/ShortcutPlugin.ts @@ -119,7 +119,11 @@ export class ShortcutPlugin implements EditorPlugin { private cacheGetCommand(event: KeyDownEvent) { return cacheGetEventData(event, CommandCacheKey, event => { const editor = this.editor; - + const { ctrlKey, metaKey } = event.rawEvent; + if (ctrlKey && metaKey) { + // We don't support both Ctrl and Meta key pressed at the same time. + return null; + } return ( editor && this.shortcuts.filter( diff --git a/packages/roosterjs-content-model-plugins/lib/shortcut/shortcuts.ts b/packages/roosterjs-content-model-plugins/lib/shortcut/shortcuts.ts index ffb004af755..ed1831e02d5 100644 --- a/packages/roosterjs-content-model-plugins/lib/shortcut/shortcuts.ts +++ b/packages/roosterjs-content-model-plugins/lib/shortcut/shortcuts.ts @@ -71,7 +71,7 @@ export const ShortcutUnderline: ShortcutCommand = { /** * Shortcut command for Clear Format * Windows: Ctrl + Space - * MacOS: N/A + * MacOS: Meta + Space, this shortcut is the same as the default global spotlight shortcut, so it is invalid if the user keeps spotlight‘s. */ export const ShortcutClearFormat: ShortcutCommand = { shortcutKey: { @@ -80,7 +80,6 @@ export const ShortcutClearFormat: ShortcutCommand = { which: Keys.SPACE, }, onClick: editor => clearFormat(editor), - environment: 'nonMac', }; /** diff --git a/packages/roosterjs-content-model-plugins/test/shortcut/ShortcutPluginTest.ts b/packages/roosterjs-content-model-plugins/test/shortcut/ShortcutPluginTest.ts index bcb173ed278..f863627e71c 100644 --- a/packages/roosterjs-content-model-plugins/test/shortcut/ShortcutPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/shortcut/ShortcutPluginTest.ts @@ -448,6 +448,26 @@ describe('ShortcutPlugin', () => { expect(apiSpy).toHaveBeenCalledWith(mockedEditor); }); + it('clear format', () => { + const apiSpy = spyOn(clearFormat, 'clearFormat'); + const plugin = new ShortcutPlugin(); + const event: PluginEvent = { + eventType: 'keyDown', + rawEvent: createMockedEvent(Keys.SPACE, false, false, false, true), + }; + + plugin.initialize(mockedEditor); + + const exclusively = plugin.willHandleEventExclusively(event); + + expect(exclusively).toBeTrue(); + expect(event.eventDataCache!.__ShortcutCommandCache).toBeDefined(); + + plugin.onPluginEvent(event); + + expect(apiSpy).toHaveBeenCalledWith(mockedEditor); + }); + it('undo 1', () => { const apiSpy = spyOn(undo, 'undo'); const plugin = new ShortcutPlugin(); From f753bec82d520ba176b9eded0a17072d98681042 Mon Sep 17 00:00:00 2001 From: Rain-Zheng <67583056+Rain-Zheng@users.noreply.github.com> Date: Fri, 8 Nov 2024 06:36:43 +0800 Subject: [PATCH 16/30] Allow browser's default paste behavior when pasting from Office Android (#2863) * Implement shouldPreventDefaultPaste function * fix build * fix build --- .../corePlugin/copyPaste/CopyPastePlugin.ts | 30 +++++++++++- .../copyPaste/CopyPastePluginTest.ts | 46 +++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/copyPaste/CopyPastePlugin.ts b/packages/roosterjs-content-model-core/lib/corePlugin/copyPaste/CopyPastePlugin.ts index 62ba6d1248e..bfc3eb63042 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/copyPaste/CopyPastePlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/copyPaste/CopyPastePlugin.ts @@ -198,10 +198,10 @@ class CopyPastePlugin implements PluginWithState { const dataTransfer = event.clipboardData; - if (dataTransfer?.items) { + if (shouldPreventDefaultPaste(dataTransfer, editor)) { event.preventDefault(); extractClipboardItems( - toArray(dataTransfer.items), + toArray(dataTransfer!.items), this.state.allowedCustomPasteType ).then((clipboardData: ClipboardData) => { if (!editor.isDisposed()) { @@ -339,6 +339,32 @@ export function preprocessTable(table: ContentModelTable) { : []; } +/** + * @internal + * Exported only for unit testing + */ +export function shouldPreventDefaultPaste( + dataTransfer: DataTransfer | null, + editor: IEditor +): boolean { + if (!dataTransfer?.items) { + return false; + } + + if (!editor.getEnvironment().isAndroid) { + return true; + } + + // On Android, the clipboard data from Office apps is a file, which can't be loaded + // so we have to allow the default browser behavior + return toArray(dataTransfer.items).some(item => { + const { type } = item; + const isNormalFile = item.kind === 'file' && type !== ''; + const isText = type.indexOf('text/') === 0; + return isNormalFile || isText; + }); +} + /** * @internal * Create a new instance of CopyPastePlugin diff --git a/packages/roosterjs-content-model-core/test/corePlugin/copyPaste/CopyPastePluginTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/copyPaste/CopyPastePluginTest.ts index 0ecfcf4a175..92a80cd199f 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/copyPaste/CopyPastePluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/copyPaste/CopyPastePluginTest.ts @@ -27,6 +27,7 @@ import { createCopyPastePlugin, onNodeCreated, preprocessTable, + shouldPreventDefaultPaste, } from '../../../lib/corePlugin/copyPaste/CopyPastePlugin'; const modelValue = { @@ -1390,4 +1391,49 @@ describe('CopyPastePlugin |', () => { }); }); }); + + describe('shouldPreventDefaultPaste', () => { + it('should not prevent default for empty clipboard data', () => { + const clipboardData = ({ + items: null + }); + const editor = ({}); + expect(shouldPreventDefaultPaste(clipboardData, editor)).toBeFalse(); + expect(shouldPreventDefaultPaste(null, editor)).toBeFalse(); + }); + + it('should prevent default on non-Android platforms', () => { + const clipboardData = ({ + items: [{ type: '', kind: 'file' }] + }); + const editor = ({ + getEnvironment: () => ({ isAndroid: false }) + }); + expect(shouldPreventDefaultPaste(clipboardData, editor)).toBeTrue(); + }); + + it('should prevent default for text or image clipboard data on Android platform', () => { + const textClipboardData = ({ + items: [{ type: 'text/plain', kind: 'string' }] + }); + const imageClipboardData = ({ + items: [{ type: 'image/png', kind: 'file' }] + }); + const editor = ({ + getEnvironment: () => ({ isAndroid: true }) + }); + expect(shouldPreventDefaultPaste(textClipboardData, editor)).toBeTrue(); + expect(shouldPreventDefaultPaste(imageClipboardData, editor)).toBeTrue(); + }); + + it('should not prevent default for file-only clipboard data on Android platform', () => { + const clipboardData = ({ + items: [{ type: '', kind: 'file' }] + }); + const editor = ({ + getEnvironment: () => ({ isAndroid: true }) + }); + expect(shouldPreventDefaultPaste(clipboardData, editor)).toBeFalse(); + }); + }); }); From 4fe6d3eac1b68081315b6d58293dafa43acd92aa Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Fri, 8 Nov 2024 17:45:01 -0300 Subject: [PATCH 17/30] fix list bugs --- .../lib/autoFormat/list/getListTypeStyle.ts | 39 ++- .../autoFormat/list/keyboardListTrigger.ts | 2 +- .../autoFormat/list/getListTypeStyleTest.ts | 324 ++++++++++++++++++ 3 files changed, 354 insertions(+), 11 deletions(-) diff --git a/packages/roosterjs-content-model-plugins/lib/autoFormat/list/getListTypeStyle.ts b/packages/roosterjs-content-model-plugins/lib/autoFormat/list/getListTypeStyle.ts index 81ffbc8f7a4..1439054d8d0 100644 --- a/packages/roosterjs-content-model-plugins/lib/autoFormat/list/getListTypeStyle.ts +++ b/packages/roosterjs-content-model-plugins/lib/autoFormat/list/getListTypeStyle.ts @@ -51,7 +51,7 @@ export function getListTypeStyle( if (bulletType && shouldSearchForBullet) { return { listType: 'UL', styleType: bulletType }; } else if (shouldSearchForNumbering) { - const previousList = getPreviousListLevel(model, paragraph); + const { previousList, hasSpaceBetween } = getPreviousListLevel(model, paragraph); const previousIndex = getPreviousListIndex(model, previousList); const previousListStyle = getPreviousListStyle(previousList); const numberingType = getNumberingListStyle( @@ -64,12 +64,13 @@ export function getListTypeStyle( return { listType: 'OL', styleType: numberingType, - index: - !isNewList(listMarker) && - previousListStyle === numberingType && - previousIndex - ? previousIndex + 1 - : undefined, + index: getIndex( + listMarker, + previousListStyle, + numberingType, + previousIndex, + hasSpaceBetween + ), }; } } @@ -77,6 +78,21 @@ export function getListTypeStyle( return undefined; } +const getIndex = ( + listMarker: string, + previousListStyle?: number, + numberingType?: number, + previousIndex?: number, + hasSpaceBetween?: boolean +) => { + const newList = isNewList(listMarker); + return previousListStyle && previousListStyle !== numberingType && newList + ? 1 + : !newList && previousListStyle === numberingType && hasSpaceBetween && previousIndex + ? previousIndex + 1 + : undefined; +}; + const getPreviousListIndex = ( model: ReadonlyContentModelDocument, previousListItem?: ReadonlyContentModelListItem @@ -93,7 +109,8 @@ const getPreviousListLevel = ( ['ListItem'], ['TableCell'] )[0]; - let listItem: ContentModelListItem | undefined = undefined; + let previousList: ContentModelListItem | undefined = undefined; + let hasSpaceBetween = false; if (blocks) { const listBlockIndex = blocks.parent.blocks.indexOf(paragraph); @@ -101,14 +118,16 @@ const getPreviousListLevel = ( for (let i = listBlockIndex - 1; i > -1; i--) { const item = blocks.parent.blocks[i]; if (isBlockGroupOfType(item, 'ListItem')) { - listItem = item; + previousList = item; break; + } else { + hasSpaceBetween = listBlockIndex > 0 ? true : false; } } } } - return listItem; + return { previousList, hasSpaceBetween }; }; const getPreviousListStyle = (list?: ContentModelListItem) => { diff --git a/packages/roosterjs-content-model-plugins/lib/autoFormat/list/keyboardListTrigger.ts b/packages/roosterjs-content-model-plugins/lib/autoFormat/list/keyboardListTrigger.ts index a3d707c1f7a..08686fbe122 100644 --- a/packages/roosterjs-content-model-plugins/lib/autoFormat/list/keyboardListTrigger.ts +++ b/packages/roosterjs-content-model-plugins/lib/autoFormat/list/keyboardListTrigger.ts @@ -44,7 +44,7 @@ const triggerList = ( ) => { setListType(model, listType); const isOrderedList = listType == 'OL'; - if (index && index > 1 && isOrderedList) { + if (index && index > 0 && isOrderedList) { setModelListStartNumber(model, index); } setModelListStyle( diff --git a/packages/roosterjs-content-model-plugins/test/autoFormat/list/getListTypeStyleTest.ts b/packages/roosterjs-content-model-plugins/test/autoFormat/list/getListTypeStyleTest.ts index 7b75a84dadf..99531aa40e3 100644 --- a/packages/roosterjs-content-model-plugins/test/autoFormat/list/getListTypeStyleTest.ts +++ b/packages/roosterjs-content-model-plugins/test/autoFormat/list/getListTypeStyleTest.ts @@ -1032,4 +1032,328 @@ describe('getListTypeStyle', () => { runTest(model, undefined); }); + + it('should continue a list ', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + formatHolder: { + isSelected: false, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'OL', + format: { + startNumberOverride: 1, + listStyleType: 'decimal', + }, + dataset: { + editingInfo: + '{"applyListStyleFromLevel":false,"orderedStyleType":1}', + }, + }, + ], + blockType: 'BlockGroup', + format: {}, + blockGroupType: 'ListItem', + blocks: [ + { + segments: [ + { + text: 'test', + segmentType: 'Text', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + }, + { + formatHolder: { + isSelected: false, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'OL', + format: { + listStyleType: 'decimal', + }, + dataset: { + editingInfo: + '{"applyListStyleFromLevel":false,"orderedStyleType":1}', + }, + }, + ], + blockType: 'BlockGroup', + format: {}, + blockGroupType: 'ListItem', + blocks: [ + { + segments: [ + { + text: 'test', + segmentType: 'Text', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + }, + { + segments: [ + { + text: '3.', + segmentType: 'Text', + format: {}, + }, + { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + }; + runTest(model, { + listType: 'OL', + styleType: NumberingListType.Decimal, + index: undefined, + }); + }); + + it('should not continue a list - different styles ', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + formatHolder: { + isSelected: false, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'OL', + format: { + startNumberOverride: 1, + listStyleType: 'decimal', + }, + dataset: { + editingInfo: + '{"applyListStyleFromLevel":false,"orderedStyleType":1}', + }, + }, + ], + blockType: 'BlockGroup', + format: {}, + blockGroupType: 'ListItem', + blocks: [ + { + segments: [ + { + text: 'test', + segmentType: 'Text', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + }, + { + formatHolder: { + isSelected: false, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'OL', + format: { + listStyleType: 'decimal', + }, + dataset: { + editingInfo: + '{"applyListStyleFromLevel":false,"orderedStyleType":1}', + }, + }, + ], + blockType: 'BlockGroup', + format: {}, + blockGroupType: 'ListItem', + blocks: [ + { + segments: [ + { + text: 'test', + segmentType: 'Text', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + }, + { + segments: [ + { + text: 'a.', + segmentType: 'Text', + format: {}, + }, + { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + }; + runTest(model, { + listType: 'OL', + styleType: NumberingListType.LowerAlpha, + index: 1, + }); + }); + + it('should not continue a list - has space between list', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + formatHolder: { + isSelected: false, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'OL', + format: { + startNumberOverride: 1, + listStyleType: 'decimal', + }, + dataset: { + editingInfo: + '{"applyListStyleFromLevel":false,"orderedStyleType":1}', + }, + }, + ], + blockType: 'BlockGroup', + format: {}, + blockGroupType: 'ListItem', + blocks: [ + { + segments: [ + { + text: 'test', + segmentType: 'Text', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + }, + { + formatHolder: { + isSelected: false, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'OL', + format: { + listStyleType: 'decimal', + }, + dataset: { + editingInfo: + '{"applyListStyleFromLevel":false,"orderedStyleType":1}', + }, + }, + ], + blockType: 'BlockGroup', + format: {}, + blockGroupType: 'ListItem', + blocks: [ + { + segments: [ + { + text: 'test', + segmentType: 'Text', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + }, + { + segments: [ + { + text: 'test', + segmentType: 'Text', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + { + segments: [ + { + text: '3.', + segmentType: 'Text', + format: {}, + }, + { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + }; + runTest(model, { + listType: 'OL', + styleType: NumberingListType.Decimal, + index: 3, + }); + }); }); From 9f5db3a2cedc3dedd448b89a05bf4ce7ad34935b Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Tue, 12 Nov 2024 16:59:27 -0300 Subject: [PATCH 18/30] list-bugs --- .../lib/edit/inputSteps/handleEnterOnList.ts | 59 +++- .../edit/inputSteps/handleEnterOnListTest.ts | 328 ++++++++++++++++++ 2 files changed, 377 insertions(+), 10 deletions(-) diff --git a/packages/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts b/packages/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts index de044090317..2cae41ef692 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts @@ -10,6 +10,7 @@ import { import type { ContentModelListItem, DeleteSelectionStep, + ReadonlyContentModelBlock, ReadonlyContentModelBlockGroup, ReadonlyContentModelListItem, ShallowMutableContentModelListItem, @@ -53,19 +54,28 @@ export const handleEnterOnList: DeleteSelectionStep = context => { const nextBlock = listParent.blocks[listIndex + 1]; if (nextBlock) { - const nextListItem = listParent.blocks[listIndex + 1]; - if ( - isBlockGroupOfType(nextListItem, 'ListItem') && - nextListItem.levels[0] + isBlockGroupOfType(nextBlock, 'ListItem') && + nextBlock.levels[0] ) { - nextListItem.levels.forEach(level => { + nextBlock.levels.forEach(level => { // Remove startNumberOverride so that next list item can join current list, unless it is 1. // List start with 1 means it should be an explicit new list and should never join another list before it if (level.format.startNumberOverride !== 1) { level.format.startNumberOverride = undefined; } }); + + if (listItem.levels.length == 0) { + const index = findIndex( + listParent.blocks, + nextBlock.levels.length, + listIndex + ); + nextBlock.levels[ + nextBlock.levels.length - 1 + ].format.startNumberOverride = index; + } } } @@ -75,12 +85,15 @@ export const handleEnterOnList: DeleteSelectionStep = context => { }; const isEmptyListItem = (listItem: ReadonlyContentModelListItem) => { + return listItem.blocks.length === 1 && isEmptyParagraph(listItem.blocks[0]); +}; + +const isEmptyParagraph = (block: ReadonlyContentModelBlock) => { return ( - listItem.blocks.length === 1 && - listItem.blocks[0].blockType === 'Paragraph' && - listItem.blocks[0].segments.length === 2 && - listItem.blocks[0].segments[0].segmentType === 'SelectionMarker' && - listItem.blocks[0].segments[1].segmentType === 'Br' + block.blockType === 'Paragraph' && + block.segments.length === 2 && + block.segments[0].segmentType === 'SelectionMarker' && + block.segments[1].segmentType === 'Br' ); }; @@ -134,3 +147,29 @@ const createNewListLevel = (listItem: ReadonlyContentModelListItem) => { ); }); }; + +const findIndex = ( + blocks: readonly ReadonlyContentModelBlock[], + levelLength: number, + index: number +) => { + let counter = 1; + for (let i = index; i > -1; i--) { + const listItem = blocks[i]; + if ( + isBlockGroupOfType(listItem, 'ListItem') && + listItem.levels.length === levelLength + ) { + counter++; + } else if ( + !( + isBlockGroupOfType(listItem, 'ListItem') && + listItem.levels.length == 0 + ) + ) { + return counter; + } + } + + return counter; +}; diff --git a/packages/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts b/packages/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts index 8a25562b7cf..75d7b96a537 100644 --- a/packages/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts +++ b/packages/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts @@ -2870,4 +2870,332 @@ describe('handleEnterOnList - keyboardEnter', () => { runTest(model, false, expectedModel, false, 1); }); + + it('List item must continue second level', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + formatHolder: { + isSelected: false, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'OL', + format: { + startNumberOverride: 1, + listStyleType: 'decimal', + }, + dataset: { + editingInfo: + '{"applyListStyleFromLevel":false,"orderedStyleType":1}', + }, + }, + ], + blockType: 'BlockGroup', + format: {}, + blockGroupType: 'ListItem', + blocks: [ + { + segments: [ + { + text: 'test', + segmentType: 'Text', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + }, + { + formatHolder: { + isSelected: false, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'OL', + format: { + listStyleType: 'decimal', + }, + dataset: { + editingInfo: + '{"applyListStyleFromLevel":false,"orderedStyleType":1}', + }, + }, + { + listType: 'OL', + format: { + listStyleType: 'lower-alpha', + }, + dataset: { + editingInfo: '{"applyListStyleFromLevel":true}', + }, + }, + ], + blockType: 'BlockGroup', + format: {}, + blockGroupType: 'ListItem', + blocks: [ + { + segments: [ + { + text: 'test', + segmentType: 'Text', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + }, + { + formatHolder: { + isSelected: false, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'OL', + format: { + listStyleType: 'decimal', + }, + dataset: { + editingInfo: + '{"applyListStyleFromLevel":false,"orderedStyleType":1}', + }, + }, + ], + blockType: 'BlockGroup', + format: {}, + blockGroupType: 'ListItem', + blocks: [ + { + segments: [ + { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + }, + { + formatHolder: { + isSelected: false, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'OL', + format: { + listStyleType: 'decimal', + }, + dataset: { + editingInfo: + '{"applyListStyleFromLevel":false,"orderedStyleType":1}', + }, + }, + { + listType: 'OL', + format: { + listStyleType: 'lower-alpha', + }, + dataset: { + editingInfo: '{"applyListStyleFromLevel":true}', + }, + }, + ], + blockType: 'BlockGroup', + format: {}, + blockGroupType: 'ListItem', + blocks: [ + { + segments: [ + { + text: 'test', + segmentType: 'Text', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + }, + ], + format: {}, + }; + const expectedModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + formatHolder: { + isSelected: false, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'OL', + format: { + startNumberOverride: 1, + listStyleType: 'decimal', + }, + dataset: { + editingInfo: + '{"applyListStyleFromLevel":false,"orderedStyleType":1}', + }, + }, + ], + blockType: 'BlockGroup', + format: {}, + blockGroupType: 'ListItem', + blocks: [ + { + segments: [ + { + text: 'test', + segmentType: 'Text', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + }, + { + formatHolder: { + isSelected: false, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'OL', + format: { + listStyleType: 'decimal', + }, + dataset: { + editingInfo: + '{"applyListStyleFromLevel":false,"orderedStyleType":1}', + }, + }, + { + listType: 'OL', + format: { + listStyleType: 'lower-alpha', + }, + dataset: { + editingInfo: '{"applyListStyleFromLevel":true}', + }, + }, + ], + blockType: 'BlockGroup', + format: {}, + blockGroupType: 'ListItem', + blocks: [ + { + segments: [ + { + text: 'test', + segmentType: 'Text', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + }, + { + segments: [ + { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + { + formatHolder: { + isSelected: false, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'OL', + format: { + listStyleType: 'decimal', + startNumberOverride: undefined, + }, + dataset: { + editingInfo: + '{"applyListStyleFromLevel":false,"orderedStyleType":1}', + }, + }, + { + listType: 'OL', + format: { + listStyleType: 'lower-alpha', + startNumberOverride: 2, + }, + dataset: { + editingInfo: '{"applyListStyleFromLevel":true}', + }, + }, + ], + blockType: 'BlockGroup', + format: {}, + blockGroupType: 'ListItem', + blocks: [ + { + segments: [ + { + text: 'test', + segmentType: 'Text', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + ], + }, + ], + format: {}, + }; + runTest(model, false, expectedModel, false, 1); + }); }); From daddc865551176182548c9b5df8a8d678f79e8bb Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Tue, 12 Nov 2024 17:54:12 -0300 Subject: [PATCH 19/30] fix image floating --- .../lib/imageEdit/utils/createImageWrapper.ts | 2 +- .../test/imageEdit/utils/createImageWrapperTest.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/createImageWrapper.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/createImageWrapper.ts index b1fceadb007..7c2aaaedf44 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/createImageWrapper.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/createImageWrapper.ts @@ -69,7 +69,7 @@ const createShadowSpan = (wrapper: HTMLElement, imageSpan: HTMLSpanElement) => { const shadowRoot = imageSpan.attachShadow({ mode: 'open', }); - imageSpan.style.verticalAlign = 'bottom'; + wrapper.style.verticalAlign = 'bottom'; shadowRoot.appendChild(wrapper); return imageSpan; }; diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/createImageWrapperTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/createImageWrapperTest.ts index 24e9fb43605..cc92c26b0f1 100644 --- a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/createImageWrapperTest.ts +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/createImageWrapperTest.ts @@ -202,7 +202,7 @@ const createShadowSpan = (wrapper: HTMLSpanElement) => { const shadowRoot = span.attachShadow({ mode: 'open', }); - span.style.verticalAlign = 'bottom'; + wrapper.style.verticalAlign = 'bottom'; shadowRoot.append(wrapper); return span; }; From b1bf4e32416e43cd8b1ece8b8f1f7b5119f2a7ab Mon Sep 17 00:00:00 2001 From: Rain-Zheng <67583056+Rain-Zheng@users.noreply.github.com> Date: Fri, 15 Nov 2024 05:45:23 +0800 Subject: [PATCH 20/30] Customize the behavior of merging format values (#2865) * define types * add callbacks * use param instead * fix test --------- Co-authored-by: Jiuqing Song --- .../lib/publicApi/format/getFormatState.ts | 10 ++- .../publicApi/format/getFormatStateTest.ts | 3 +- .../editing/retrieveModelFormatState.ts | 83 ++++++++++++------- .../editing/retrieveModelFormatStateTest.ts | 31 +++++++ .../lib/index.ts | 1 + .../lib/parameter/ConflictFormatSolution.ts | 7 ++ 6 files changed, 101 insertions(+), 34 deletions(-) create mode 100644 packages/roosterjs-content-model-types/lib/parameter/ConflictFormatSolution.ts 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'; From 91c25fb60a543f081793a2bc978badbafb48ac10 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Fri, 15 Nov 2024 09:48:51 -0800 Subject: [PATCH 21/30] Fix merge segment issue (#2871) --- .../utils/formatSegmentWithContentModel.ts | 14 +- .../segment/applySegmentFormatTest.ts | 18 +- .../segment/setBackgroundColorTest.ts | 9 +- .../test/publicApi/segment/setFontNameTest.ts | 10 +- .../test/publicApi/segment/setFontSizeTest.ts | 10 +- .../publicApi/segment/setTextColorTest.ts | 10 +- .../test/publicApi/segment/toggleBoldTest.ts | 10 +- .../test/publicApi/segment/toggleCodeTest.ts | 13 +- .../publicApi/segment/toggleItalicTest.ts | 10 +- .../segment/toggleStrikethroughTest.ts | 10 +- .../publicApi/segment/toggleSubscriptTest.ts | 10 +- .../segment/toggleSuperscriptTest.ts | 10 +- .../publicApi/segment/toggleUnderlineTest.ts | 10 +- .../lib/corePlugin/cache/domIndexerImpl.ts | 3 +- .../command/paste/mergePasteContentTest.ts | 54 +- .../corePlugin/cache/domIndexerImplTest.ts | 48 +- .../roosterjs-content-model-dom/lib/index.ts | 1 + .../lib/modelApi/common/mergeTextSegments.ts | 62 ++ .../lib/modelApi/common/normalizeParagraph.ts | 59 +- .../modelApi/common/mergeTextSegmentsTest.ts | 607 ++++++++++++++++ .../common/normalizeContentModelTest.ts | 7 +- .../modelApi/common/normalizeParagraphTest.ts | 682 +----------------- .../test/modelApi/editing/mergeModelTest.ts | 39 +- .../paste/processPastedContentFromWacTest.ts | 290 +++++++- ...processPastedContentFromWordDesktopTest.ts | 28 +- 25 files changed, 1132 insertions(+), 892 deletions(-) create mode 100644 packages/roosterjs-content-model-dom/lib/modelApi/common/mergeTextSegments.ts create mode 100644 packages/roosterjs-content-model-dom/test/modelApi/common/mergeTextSegmentsTest.ts diff --git a/packages/roosterjs-content-model-api/lib/publicApi/utils/formatSegmentWithContentModel.ts b/packages/roosterjs-content-model-api/lib/publicApi/utils/formatSegmentWithContentModel.ts index 3a8a2811f3b..1966f591fcf 100644 --- a/packages/roosterjs-content-model-api/lib/publicApi/utils/formatSegmentWithContentModel.ts +++ b/packages/roosterjs-content-model-api/lib/publicApi/utils/formatSegmentWithContentModel.ts @@ -1,5 +1,5 @@ import { adjustWordSelection } from '../../modelApi/selection/adjustWordSelection'; -import { getSelectedSegmentsAndParagraphs } from 'roosterjs-content-model-dom'; +import { getSelectedSegmentsAndParagraphs, mergeTextSegments } from 'roosterjs-content-model-dom'; import type { ContentModelSegmentFormat, IEditor, @@ -72,12 +72,18 @@ export function formatSegmentWithContentModel( ) : false; - formatsAndSegments.forEach(([format, segment, paragraph]) => - toggleStyleCallback(format, !isTurningOff, segment, paragraph) - ); + formatsAndSegments.forEach(([format, segment, paragraph]) => { + toggleStyleCallback(format, !isTurningOff, segment, paragraph); + }); afterFormatCallback?.(model); + formatsAndSegments.forEach(([_, __, paragraph]) => { + if (paragraph) { + mergeTextSegments(paragraph); + } + }); + if (isCollapsedSelection) { context.newPendingFormat = segmentAndParagraphs[0][0].format; editor.focus(); diff --git a/packages/roosterjs-content-model-api/test/publicApi/segment/applySegmentFormatTest.ts b/packages/roosterjs-content-model-api/test/publicApi/segment/applySegmentFormatTest.ts index 673e07f7340..bd958c71d76 100644 --- a/packages/roosterjs-content-model-api/test/publicApi/segment/applySegmentFormatTest.ts +++ b/packages/roosterjs-content-model-api/test/publicApi/segment/applySegmentFormatTest.ts @@ -275,23 +275,7 @@ describe('applySegmentFormat', () => { segments: [ { segmentType: 'Text', - text: 'test', - format: { - backgroundColor: undefined, - fontFamily: undefined, - fontSize: '10px', - fontWeight: undefined, - italic: true, - strikethrough: undefined, - superOrSubScriptSequence: undefined, - textColor: 'red', - underline: false, - }, - isSelected: true, - }, - { - segmentType: 'Text', - text: 'test', + text: 'testtest', format: { backgroundColor: undefined, fontFamily: undefined, diff --git a/packages/roosterjs-content-model-api/test/publicApi/segment/setBackgroundColorTest.ts b/packages/roosterjs-content-model-api/test/publicApi/segment/setBackgroundColorTest.ts index bfc9e208a91..093943724c5 100644 --- a/packages/roosterjs-content-model-api/test/publicApi/segment/setBackgroundColorTest.ts +++ b/packages/roosterjs-content-model-api/test/publicApi/segment/setBackgroundColorTest.ts @@ -248,14 +248,7 @@ describe('setBackgroundColor', () => { segments: [ { segmentType: 'Text', - text: 'test', - format: { - backgroundColor: 'red', - }, - }, - { - segmentType: 'Text', - text: 'test', + text: 'testtest', format: { backgroundColor: 'red', }, diff --git a/packages/roosterjs-content-model-api/test/publicApi/segment/setFontNameTest.ts b/packages/roosterjs-content-model-api/test/publicApi/segment/setFontNameTest.ts index be71d490150..b30609d21a9 100644 --- a/packages/roosterjs-content-model-api/test/publicApi/segment/setFontNameTest.ts +++ b/packages/roosterjs-content-model-api/test/publicApi/segment/setFontNameTest.ts @@ -237,15 +237,7 @@ describe('setFontName', () => { segments: [ { segmentType: 'Text', - text: 'test', - format: { - fontFamily: 'Arial', - }, - isSelected: true, - }, - { - segmentType: 'Text', - text: 'test', + text: 'testtest', format: { fontFamily: 'Arial', }, diff --git a/packages/roosterjs-content-model-api/test/publicApi/segment/setFontSizeTest.ts b/packages/roosterjs-content-model-api/test/publicApi/segment/setFontSizeTest.ts index 81725bd874a..d34d2b8f21c 100644 --- a/packages/roosterjs-content-model-api/test/publicApi/segment/setFontSizeTest.ts +++ b/packages/roosterjs-content-model-api/test/publicApi/segment/setFontSizeTest.ts @@ -235,15 +235,7 @@ describe('setFontSize', () => { segments: [ { segmentType: 'Text', - text: 'test', - format: { - fontSize: '10pt', - }, - isSelected: true, - }, - { - segmentType: 'Text', - text: 'test', + text: 'testtest', format: { fontSize: '10pt', }, diff --git a/packages/roosterjs-content-model-api/test/publicApi/segment/setTextColorTest.ts b/packages/roosterjs-content-model-api/test/publicApi/segment/setTextColorTest.ts index 0dae57bd630..73d84831021 100644 --- a/packages/roosterjs-content-model-api/test/publicApi/segment/setTextColorTest.ts +++ b/packages/roosterjs-content-model-api/test/publicApi/segment/setTextColorTest.ts @@ -236,15 +236,7 @@ describe('setTextColor', () => { segments: [ { segmentType: 'Text', - text: 'test', - format: { - textColor: 'red', - }, - isSelected: true, - }, - { - segmentType: 'Text', - text: 'test', + text: 'testtest', format: { textColor: 'red', }, diff --git a/packages/roosterjs-content-model-api/test/publicApi/segment/toggleBoldTest.ts b/packages/roosterjs-content-model-api/test/publicApi/segment/toggleBoldTest.ts index 7ff3e947bab..09637527422 100644 --- a/packages/roosterjs-content-model-api/test/publicApi/segment/toggleBoldTest.ts +++ b/packages/roosterjs-content-model-api/test/publicApi/segment/toggleBoldTest.ts @@ -229,15 +229,7 @@ describe('toggleBold', () => { segments: [ { segmentType: 'Text', - text: 'test', - format: { - fontWeight: 'bold', - }, - isSelected: true, - }, - { - segmentType: 'Text', - text: 'test', + text: 'testtest', format: { fontWeight: 'bold', }, diff --git a/packages/roosterjs-content-model-api/test/publicApi/segment/toggleCodeTest.ts b/packages/roosterjs-content-model-api/test/publicApi/segment/toggleCodeTest.ts index bd032376893..f35827e5277 100644 --- a/packages/roosterjs-content-model-api/test/publicApi/segment/toggleCodeTest.ts +++ b/packages/roosterjs-content-model-api/test/publicApi/segment/toggleCodeTest.ts @@ -245,18 +245,7 @@ describe('toggleCode', () => { segments: [ { segmentType: 'Text', - text: 'test', - format: {}, - isSelected: true, - code: { - format: { - fontFamily: 'monospace', - }, - }, - }, - { - segmentType: 'Text', - text: 'test', + text: 'testtest', format: {}, isSelected: true, code: { diff --git a/packages/roosterjs-content-model-api/test/publicApi/segment/toggleItalicTest.ts b/packages/roosterjs-content-model-api/test/publicApi/segment/toggleItalicTest.ts index 402f873de61..cac644f507e 100644 --- a/packages/roosterjs-content-model-api/test/publicApi/segment/toggleItalicTest.ts +++ b/packages/roosterjs-content-model-api/test/publicApi/segment/toggleItalicTest.ts @@ -229,15 +229,7 @@ describe('toggleItalic', () => { segments: [ { segmentType: 'Text', - text: 'test', - format: { - italic: true, - }, - isSelected: true, - }, - { - segmentType: 'Text', - text: 'test', + text: 'testtest', format: { italic: true, }, diff --git a/packages/roosterjs-content-model-api/test/publicApi/segment/toggleStrikethroughTest.ts b/packages/roosterjs-content-model-api/test/publicApi/segment/toggleStrikethroughTest.ts index b758010f93d..2da229db01d 100644 --- a/packages/roosterjs-content-model-api/test/publicApi/segment/toggleStrikethroughTest.ts +++ b/packages/roosterjs-content-model-api/test/publicApi/segment/toggleStrikethroughTest.ts @@ -229,15 +229,7 @@ describe('toggleStrikethrough', () => { segments: [ { segmentType: 'Text', - text: 'test', - format: { - strikethrough: true, - }, - isSelected: true, - }, - { - segmentType: 'Text', - text: 'test', + text: 'testtest', format: { strikethrough: true, }, diff --git a/packages/roosterjs-content-model-api/test/publicApi/segment/toggleSubscriptTest.ts b/packages/roosterjs-content-model-api/test/publicApi/segment/toggleSubscriptTest.ts index 04a710b652d..7337932648c 100644 --- a/packages/roosterjs-content-model-api/test/publicApi/segment/toggleSubscriptTest.ts +++ b/packages/roosterjs-content-model-api/test/publicApi/segment/toggleSubscriptTest.ts @@ -229,15 +229,7 @@ describe('toggleSubscript', () => { segments: [ { segmentType: 'Text', - text: 'test', - format: { - superOrSubScriptSequence: 'sub', - }, - isSelected: true, - }, - { - segmentType: 'Text', - text: 'test', + text: 'testtest', format: { superOrSubScriptSequence: 'sub', }, diff --git a/packages/roosterjs-content-model-api/test/publicApi/segment/toggleSuperscriptTest.ts b/packages/roosterjs-content-model-api/test/publicApi/segment/toggleSuperscriptTest.ts index d554ad27168..9f94609b08b 100644 --- a/packages/roosterjs-content-model-api/test/publicApi/segment/toggleSuperscriptTest.ts +++ b/packages/roosterjs-content-model-api/test/publicApi/segment/toggleSuperscriptTest.ts @@ -229,15 +229,7 @@ describe('toggleSuperscript', () => { segments: [ { segmentType: 'Text', - text: 'test', - format: { - superOrSubScriptSequence: 'super', - }, - isSelected: true, - }, - { - segmentType: 'Text', - text: 'test', + text: 'testtest', format: { superOrSubScriptSequence: 'super', }, diff --git a/packages/roosterjs-content-model-api/test/publicApi/segment/toggleUnderlineTest.ts b/packages/roosterjs-content-model-api/test/publicApi/segment/toggleUnderlineTest.ts index 3356aee5a0b..41e740e437c 100644 --- a/packages/roosterjs-content-model-api/test/publicApi/segment/toggleUnderlineTest.ts +++ b/packages/roosterjs-content-model-api/test/publicApi/segment/toggleUnderlineTest.ts @@ -229,15 +229,7 @@ describe('toggleUnderline', () => { segments: [ { segmentType: 'Text', - text: 'test', - format: { - underline: true, - }, - isSelected: true, - }, - { - segmentType: 'Text', - text: 'test', + text: 'testtest', format: { underline: true, }, diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/cache/domIndexerImpl.ts b/packages/roosterjs-content-model-core/lib/corePlugin/cache/domIndexerImpl.ts index dd96fd509ae..e51c7f888c9 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/cache/domIndexerImpl.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/cache/domIndexerImpl.ts @@ -109,7 +109,8 @@ function isIndexedSegment(node: Node): node is IndexedSegmentNode { paragraph && paragraph.blockType == 'Paragraph' && Array.isArray(paragraph.segments) && - Array.isArray(segments) + Array.isArray(segments) && + segments.every(segment => paragraph.segments.includes(segment)) ); } diff --git a/packages/roosterjs-content-model-core/test/command/paste/mergePasteContentTest.ts b/packages/roosterjs-content-model-core/test/command/paste/mergePasteContentTest.ts index 2810be3d786..71efb5798c0 100644 --- a/packages/roosterjs-content-model-core/test/command/paste/mergePasteContentTest.ts +++ b/packages/roosterjs-content-model-core/test/command/paste/mergePasteContentTest.ts @@ -971,7 +971,7 @@ describe('mergePasteContent', () => { segments: [ { segmentType: 'Text', - text: 'Unformatted line\n', + text: 'Unformatted line', format: { fontSize: '14px', textColor: 'white', @@ -1149,7 +1149,15 @@ describe('mergePasteContent', () => { segments: [ { segmentType: 'Text', - text: 'Unformatted line\n', + text: 'Unformatted line', + format: { + fontSize: '14px', + textColor: 'white', + }, + }, + { + segmentType: 'Text', + text: '\n', format: { fontSize: '14px', textColor: 'white', @@ -1482,7 +1490,15 @@ describe('mergePasteContent', () => { segments: [ { segmentType: 'Text', - text: 'Inline text\n', + text: 'Inline text', + format: { + fontSize: '14px', + textColor: 'rgb(0,0,0)', + }, + }, + { + segmentType: 'Text', + text: '\n', format: { fontSize: '14px', textColor: 'rgb(0,0,0)', @@ -1537,7 +1553,15 @@ describe('mergePasteContent', () => { }, { segmentType: 'Text', - text: 'Inline text\n', + text: 'Inline text', + format: { + fontSize: '14px', + textColor: 'rgb(0,0,0)', + }, + }, + { + segmentType: 'Text', + text: '\n', format: { fontSize: '14px', textColor: 'rgb(0,0,0)', @@ -1605,7 +1629,16 @@ describe('mergePasteContent', () => { segments: [ { segmentType: 'Text', - text: 'Inline text\n', + text: 'Inline text', + format: { + fontFamily: 'Aptos', + fontSize: '14px', + textColor: 'white', + }, + }, + { + segmentType: 'Text', + text: '\n', format: { fontFamily: 'Aptos', fontSize: '14px', @@ -1653,7 +1686,16 @@ describe('mergePasteContent', () => { { segmentType: 'Text', text: 'Text in source', format: {} }, { segmentType: 'Text', - text: 'Inline text\n', + text: 'Inline text', + format: { + fontFamily: 'Aptos', + fontSize: '14px', + textColor: 'white', + }, + }, + { + segmentType: 'Text', + text: '\n', format: { fontFamily: 'Aptos', fontSize: '14px', diff --git a/packages/roosterjs-content-model-core/test/corePlugin/cache/domIndexerImplTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/cache/domIndexerImplTest.ts index a1a3b565a75..478b8db5fde 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/cache/domIndexerImplTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/cache/domIndexerImplTest.ts @@ -89,6 +89,7 @@ describe('domIndexerImpl.onParagraph', () => { const segment1 = 'Segment1' as any; const segment2 = 'Segment2' as any; const segment3 = 'Segment3' as any; + paragraph.segments.push(segment1, segment2, segment3); text1.__roosterjsContentModel = { paragraph, @@ -136,6 +137,8 @@ describe('domIndexerImpl.onParagraph', () => { const segment3 = 'Segment3' as any; const segment4 = 'Segment4' as any; + paragraph.segments.push(segment1, segment2, segment3, segment4); + text1.__roosterjsContentModel = { paragraph, segments: [segment1], @@ -295,24 +298,27 @@ describe('domIndexImpl.onMergeText', () => { const text1Model = createText('test1'); const text2Model = createText('test2'); + const paragraph = createParagraph(); + + paragraph.segments.push(text1Model, text2Model); ((text1 as Node) as IndexedSegmentNode).__roosterjsContentModel = { - paragraph: createParagraph(), + paragraph: paragraph, segments: [text1Model], }; ((text2 as Node) as IndexedSegmentNode).__roosterjsContentModel = { - paragraph: createParagraph(), + paragraph: paragraph, segments: [text2Model], }; new DomIndexerImpl().onMergeText(text1, text2); expect(((text1 as Node) as IndexedSegmentNode).__roosterjsContentModel).toEqual({ - paragraph: createParagraph(), + paragraph: paragraph, segments: [text1Model], }); expect(((text2 as Node) as IndexedSegmentNode).__roosterjsContentModel).toEqual({ - paragraph: createParagraph(), + paragraph: paragraph, segments: [text2Model], }); }); @@ -327,20 +333,23 @@ describe('domIndexImpl.onMergeText', () => { const text1Model = createText('test1'); const text2Model = createText('test2'); + const paragraph = createParagraph(); + + paragraph.segments.push(text1Model, text2Model); ((text1 as Node) as IndexedSegmentNode).__roosterjsContentModel = { - paragraph: createParagraph(), + paragraph: paragraph, segments: [text1Model], }; ((text2 as Node) as IndexedSegmentNode).__roosterjsContentModel = { - paragraph: createParagraph(), + paragraph: paragraph, segments: [text2Model], }; new DomIndexerImpl().onMergeText(text1, text2); expect(((text1 as Node) as IndexedSegmentNode).__roosterjsContentModel).toEqual({ - paragraph: createParagraph(), + paragraph: paragraph, segments: [text1Model, text2Model], }); expect(((text2 as Node) as IndexedSegmentNode).__roosterjsContentModel).toBeUndefined(); @@ -1089,6 +1098,31 @@ describe('domIndexerImpl.reconcileSelection', () => { expect(setSelectionSpy).not.toHaveBeenCalled(); expect(model.hasRevertedRangeSelection).toBeFalsy(); }); + + it('Index segment is not in paragraph, bad index', () => { + const node = document.createTextNode('test'); + const paragraph = createParagraph(); + const segment = createText('test'); + + domIndexerImpl.onSegment(node, paragraph, [segment]); + + const newRangeEx: DOMSelection = { + type: 'range', + range: createRange(node, 2), + isReverted: false, + }; + + expect(((node as Node) as IndexedSegmentNode).__roosterjsContentModel).toEqual({ + paragraph, + segments: [segment], + }); + + const result = domIndexerImpl.reconcileSelection(model, newRangeEx); + + expect(result).toBeFalse(); + expect(setSelectionSpy).not.toHaveBeenCalled(); + expect(model.hasRevertedRangeSelection).toBeFalsy(); + }); }); describe('domIndexerImpl.reconcileChildList', () => { diff --git a/packages/roosterjs-content-model-dom/lib/index.ts b/packages/roosterjs-content-model-dom/lib/index.ts index 12077de7df0..2723beb3035 100644 --- a/packages/roosterjs-content-model-dom/lib/index.ts +++ b/packages/roosterjs-content-model-dom/lib/index.ts @@ -71,6 +71,7 @@ export { unwrapBlock } from './modelApi/common/unwrapBlock'; export { addSegment } from './modelApi/common/addSegment'; export { isEmpty } from './modelApi/common/isEmpty'; export { normalizeSingleSegment } from './modelApi/common/normalizeSegment'; +export { mergeTextSegments } from './modelApi/common/mergeTextSegments'; export { setParagraphNotImplicit } from './modelApi/block/setParagraphNotImplicit'; export { getOrderedListNumberStr } from './modelApi/list/getOrderedListNumberStr'; diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/common/mergeTextSegments.ts b/packages/roosterjs-content-model-dom/lib/modelApi/common/mergeTextSegments.ts new file mode 100644 index 00000000000..211928f62af --- /dev/null +++ b/packages/roosterjs-content-model-dom/lib/modelApi/common/mergeTextSegments.ts @@ -0,0 +1,62 @@ +import { areSameFormats } from '../../domToModel/utils/areSameFormats'; +import type { + ContentModelText, + ReadonlyContentModelCode, + ReadonlyContentModelLink, + ReadonlyContentModelSegment, + ShallowMutableContentModelParagraph, +} from 'roosterjs-content-model-types'; + +/** + * Find continuous text segments that have the same format and decorators, merge them, So we can reduce total count of segments + * @param block The parent paragraph to check. + */ +export function mergeTextSegments(block: ShallowMutableContentModelParagraph) { + let lastText: ContentModelText | null = null; + + for (let i = 0; i < block.segments.length; i++) { + const segment = block.segments[i]; + + if (segment.segmentType != 'Text') { + lastText = null; + } else if (!lastText || !segmentsWithSameFormat(lastText, segment)) { + lastText = segment; + } else { + lastText.text += segment.text; + block.segments.splice(i, 1); + i--; + } + } +} + +function segmentsWithSameFormat( + seg1: ReadonlyContentModelSegment, + seg2: ReadonlyContentModelSegment +) { + return ( + !!seg1.isSelected == !!seg2.isSelected && + areSameFormats(seg1.format, seg2.format) && + areSameLinks(seg1.link, seg2.link) && + areSameCodes(seg1.code, seg2.code) + ); +} + +function areSameLinks( + link1: ReadonlyContentModelLink | undefined, + link2: ReadonlyContentModelLink | undefined +) { + return ( + (!link1 && !link2) || + (link1 && + link2 && + areSameFormats(link1.format, link2.format) && + areSameFormats(link1.dataset, link2.dataset)) + ); +} + +function areSameCodes( + code1: ReadonlyContentModelCode | undefined, + code2: ReadonlyContentModelCode | undefined +) { + return (!code1 && !code2) || (code1 && code2 && areSameFormats(code1.format, code2.format)); +} diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/common/normalizeParagraph.ts b/packages/roosterjs-content-model-dom/lib/modelApi/common/normalizeParagraph.ts index 6a760889ac2..aaee4b92d99 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/common/normalizeParagraph.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/common/normalizeParagraph.ts @@ -2,16 +2,12 @@ import { areSameFormats } from '../../domToModel/utils/areSameFormats'; import { createBr } from '../creators/createBr'; import { isSegmentEmpty } from './isEmpty'; import { isWhiteSpacePreserved } from '../../domUtils/isWhiteSpacePreserved'; -import { mutateBlock, mutateSegment, mutateSegments } from './mutate'; +import { mutateBlock, mutateSegment } from './mutate'; import { normalizeAllSegments } from './normalizeSegment'; import type { ContentModelSegmentFormat, - ContentModelText, - ReadonlyContentModelCode, - ReadonlyContentModelLink, ReadonlyContentModelParagraph, ReadonlyContentModelSegment, - ReadonlyContentModelText, } from 'roosterjs-content-model-types'; /** @@ -52,7 +48,6 @@ export function normalizeParagraph(paragraph: ReadonlyContentModelParagraph) { removeEmptyLinks(paragraph); removeEmptySegments(paragraph); - mergeTextSegments(paragraph); moveUpSegmentFormat(paragraph); } @@ -76,58 +71,6 @@ function removeEmptySegments(block: ReadonlyContentModelParagraph) { } } -function mergeTextSegments(block: ReadonlyContentModelParagraph) { - let lastText: ReadonlyContentModelText | null = null; - - for (let i = 0; i < block.segments.length; i++) { - const segment = block.segments[i]; - - if (segment.segmentType != 'Text') { - lastText = null; - } else if (!lastText || !segmentsWithSameFormat(lastText, segment)) { - lastText = segment; - } else { - const [mutableBlock, [mutableLastText]] = mutateSegments(block, [lastText, segment]); - - (mutableLastText as ContentModelText).text += segment.text; - mutableBlock.segments.splice(i, 1); - i--; - } - } -} - -function segmentsWithSameFormat( - seg1: ReadonlyContentModelSegment, - seg2: ReadonlyContentModelSegment -) { - return ( - !!seg1.isSelected == !!seg2.isSelected && - areSameFormats(seg1.format, seg2.format) && - areSameLinks(seg1.link, seg2.link) && - areSameCodes(seg1.code, seg2.code) - ); -} - -function areSameLinks( - link1: ReadonlyContentModelLink | undefined, - link2: ReadonlyContentModelLink | undefined -) { - return ( - (!link1 && !link2) || - (link1 && - link2 && - areSameFormats(link1.format, link2.format) && - areSameFormats(link1.dataset, link2.dataset)) - ); -} - -function areSameCodes( - code1: ReadonlyContentModelCode | undefined, - code2: ReadonlyContentModelCode | undefined -) { - return (!code1 && !code2) || (code1 && code2 && areSameFormats(code1.format, code2.format)); -} - function removeEmptyLinks(paragraph: ReadonlyContentModelParagraph) { const marker = paragraph.segments.find(x => x.segmentType == 'SelectionMarker'); if (marker) { diff --git a/packages/roosterjs-content-model-dom/test/modelApi/common/mergeTextSegmentsTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/common/mergeTextSegmentsTest.ts new file mode 100644 index 00000000000..952222cc398 --- /dev/null +++ b/packages/roosterjs-content-model-dom/test/modelApi/common/mergeTextSegmentsTest.ts @@ -0,0 +1,607 @@ +import { createParagraph } from '../../../lib/modelApi/creators/createParagraph'; +import { mergeTextSegments } from '../../../lib/modelApi/common/mergeTextSegments'; +import type { ContentModelSegment, ContentModelParagraph } from 'roosterjs-content-model-types'; + +describe('mergeTextSegments', () => { + function runTest(input: ContentModelSegment[], expectedResult: ContentModelSegment[]) { + const paragraph = createParagraph(); + + paragraph.segments = input; + + mergeTextSegments(paragraph); + + const expectedParagraph: ContentModelParagraph = { + blockType: 'Paragraph', + format: {}, + segments: expectedResult, + }; + + expect(paragraph).toEqual(expectedParagraph); + } + + it('Empty paragraph', () => { + runTest([], []); + }); + + it('Single text segment', () => { + runTest( + [ + { + segmentType: 'Text', + text: 'abc', + format: {}, + }, + ], + [ + { + segmentType: 'Text', + text: 'abc', + format: {}, + }, + ] + ); + }); + + it('Two text segments, same format', () => { + runTest( + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + ], + [ + { + segmentType: 'Text', + text: 'abcdef', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + ] + ); + }); + + it('Two text segments, same format, with space - 1', () => { + runTest( + [ + { + segmentType: 'Text', + text: ' abc ', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: ' def ', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + ], + [ + { + segmentType: 'Text', + text: ' abc def ', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + ] + ); + }); + + it('Two text segments, same format, with space - 2', () => { + runTest( + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: ' def', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + ], + [ + { + segmentType: 'Text', + text: 'abc def', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + ] + ); + }); + + it('Two text segments, different format - 1', () => { + runTest( + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos', fontSize: '12pt', italic: true }, + }, + ], + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos', fontSize: '12pt', italic: true }, + }, + ] + ); + }); + + it('Two text segments, different format - 2', () => { + runTest( + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos' }, + }, + ], + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos' }, + }, + ] + ); + }); + + it('Two text segments, different format - 3', () => { + runTest( + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos', fontSize: '14pt' }, + }, + ], + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos', fontSize: '14pt' }, + }, + ] + ); + }); + + it('Two text segments, one has link', () => { + runTest( + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + link: { + dataset: {}, + format: {}, + }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + ], + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + link: { + dataset: {}, + format: {}, + }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + ] + ); + }); + + it('Two text segments, both have same link', () => { + runTest( + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + link: { + dataset: {}, + format: {}, + }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + link: { + dataset: {}, + format: {}, + }, + }, + ], + [ + { + segmentType: 'Text', + text: 'abcdef', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + link: { + dataset: {}, + format: {}, + }, + }, + ] + ); + }); + + it('Two text segments, both have different link', () => { + runTest( + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + link: { + dataset: {}, + format: { href: 'url1' }, + }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + link: { + dataset: {}, + format: { href: 'url2' }, + }, + }, + ], + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + link: { + dataset: {}, + format: { href: 'url1' }, + }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + link: { + dataset: {}, + format: { href: 'url2' }, + }, + }, + ] + ); + }); + + it('Two text segments, one has code', () => { + runTest( + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + code: { + format: {}, + }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + ], + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + code: { + format: {}, + }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + ] + ); + }); + + it('Two text segments, both have same code', () => { + runTest( + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + code: { + format: {}, + }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + code: { + format: {}, + }, + }, + ], + [ + { + segmentType: 'Text', + text: 'abcdef', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + code: { + format: {}, + }, + }, + ] + ); + }); + + it('Two text segments around selection marker', () => { + runTest( + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'SelectionMarker', + format: {}, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + ], + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'SelectionMarker', + format: {}, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + ] + ); + }); + + it('Two text segments after selection marker', () => { + runTest( + [ + { + segmentType: 'SelectionMarker', + format: {}, + }, + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + ], + [ + { + segmentType: 'SelectionMarker', + format: {}, + }, + { + segmentType: 'Text', + text: 'abcdef', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + ] + ); + }); + + it('Two text segments before selection marker', () => { + runTest( + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'SelectionMarker', + format: {}, + }, + ], + [ + { + segmentType: 'Text', + text: 'abcdef', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'SelectionMarker', + format: {}, + }, + ] + ); + }); + + it('Three text segments with same format', () => { + runTest( + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: 'ghi', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + ], + [ + { + segmentType: 'Text', + text: 'abcdefghi', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + ] + ); + }); + + it('Two pairs - 1', () => { + runTest( + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: 'ghi', + format: { fontFamily: 'Aptos', fontSize: '14pt' }, + }, + { + segmentType: 'Text', + text: 'jkl', + format: { fontFamily: 'Aptos', fontSize: '14pt' }, + }, + ], + [ + { + segmentType: 'Text', + text: 'abcdef', + format: { fontFamily: 'Aptos', fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: 'ghijkl', + format: { fontFamily: 'Aptos', fontSize: '14pt' }, + }, + ] + ); + }); + + it('Two pairs - 2', () => { + runTest( + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontSize: '14pt' }, + }, + { + segmentType: 'Text', + text: 'ghi', + format: { fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: 'jkl', + format: { fontSize: '14pt' }, + }, + ], + [ + { + segmentType: 'Text', + text: 'abc', + format: { fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: 'def', + format: { fontSize: '14pt' }, + }, + { + segmentType: 'Text', + text: 'ghi', + format: { fontSize: '12pt' }, + }, + { + segmentType: 'Text', + text: 'jkl', + format: { fontSize: '14pt' }, + }, + ] + ); + }); +}); diff --git a/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeContentModelTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeContentModelTest.ts index 3466a7656b3..bc3aa260c52 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeContentModelTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeContentModelTest.ts @@ -59,7 +59,12 @@ describe('normalizeContentModel', () => { { segmentType: 'Text', format: {}, - text: 'test1test2', + text: 'test1', + }, + { + segmentType: 'Text', + format: {}, + text: 'test2', }, ], }, diff --git a/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeParagraphTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeParagraphTest.ts index de798f6a570..1de5d7c3e3b 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeParagraphTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeParagraphTest.ts @@ -6,12 +6,7 @@ import { createSelectionMarker } from '../../../lib/modelApi/creators/createSele import { createText } from '../../../lib/modelApi/creators/createText'; import { normalizeContentModel } from '../../../lib/modelApi/common/normalizeContentModel'; import { normalizeParagraph } from '../../../lib/modelApi/common/normalizeParagraph'; -import { - ContentModelParagraph, - ContentModelSegment, - ContentModelSegmentFormat, - ReadonlyContentModelParagraph, -} from 'roosterjs-content-model-types'; +import { ReadonlyContentModelParagraph } from 'roosterjs-content-model-types'; describe('Normalize text that contains space', () => { function runTest(texts: string[], expected: string[], whiteSpace?: string) { @@ -74,9 +69,9 @@ describe('Normalize text that contains space', () => { }); it('Text ends with  ', () => { - runTest(['a\u00A0', 'b'], ['a b']); - runTest(['a\u00A0\u00A0', 'b'], ['a\u00A0 b']); - runTest(['a \u00A0', 'b'], ['a \u00A0b']); + runTest(['a\u00A0', 'b'], ['a ', 'b']); + runTest(['a\u00A0\u00A0', 'b'], ['a\u00A0 ', 'b']); + runTest(['a \u00A0', 'b'], ['a \u00A0', 'b']); }); it('with other type of segment', () => { @@ -171,7 +166,12 @@ describe('Normalize text that contains space', () => { segments: [ { segmentType: 'Text', - text: 'a \u00A0b', + text: 'a ', + format: {}, + }, + { + segmentType: 'Text', + text: '\u00A0b', format: {}, }, ], @@ -528,11 +528,17 @@ describe('Move up format', () => { segments: [ { segmentType: 'Text', - text: 'test1test2', + text: 'test1', + format: {}, + }, + { + segmentType: 'Text', + text: 'test2', format: {}, }, ], format: {}, + cachedElement: mockedCache, }); }); @@ -834,657 +840,3 @@ describe('Move up format', () => { }); }); }); - -describe('Merge text segments', () => { - function runTest( - input: ContentModelSegment[], - expectedResult: ContentModelSegment[], - stillHasCache: boolean, - expectedParagraphFormat?: ContentModelSegmentFormat - ) { - const paragraph = createParagraph(); - const cache = 'CACHE' as any; - - paragraph.cachedElement = cache; - - paragraph.segments = input; - - normalizeParagraph(paragraph); - - const expectedParagraph: ContentModelParagraph = { - blockType: 'Paragraph', - format: {}, - segments: expectedResult, - }; - - if (expectedParagraphFormat) { - expectedParagraph.segmentFormat = expectedParagraphFormat; - } - - if (stillHasCache) { - expectedParagraph.cachedElement = cache; - } - - expect(paragraph).toEqual(expectedParagraph); - } - - it('Empty paragraph', () => { - runTest([], [], true); - }); - - it('Single text segment', () => { - runTest( - [ - { - segmentType: 'Text', - text: 'abc', - format: {}, - }, - ], - [ - { - segmentType: 'Text', - text: 'abc', - format: {}, - }, - ], - true - ); - }); - - it('Two text segments, same format', () => { - runTest( - [ - { - segmentType: 'Text', - text: 'abc', - format: { fontFamily: 'Aptos', fontSize: '12pt' }, - }, - { - segmentType: 'Text', - text: 'def', - format: { fontFamily: 'Aptos', fontSize: '12pt' }, - }, - ], - [ - { - segmentType: 'Text', - text: 'abcdef', - format: { fontFamily: 'Aptos', fontSize: '12pt' }, - }, - ], - false, - { fontFamily: 'Aptos', fontSize: '12pt' } - ); - }); - - it('Two text segments, same format, with space - 1', () => { - runTest( - [ - { - segmentType: 'Text', - text: ' abc ', - format: { fontFamily: 'Aptos', fontSize: '12pt' }, - }, - { - segmentType: 'Text', - text: ' def ', - format: { fontFamily: 'Aptos', fontSize: '12pt' }, - }, - ], - [ - { - segmentType: 'Text', - text: 'abc def', - format: { fontFamily: 'Aptos', fontSize: '12pt' }, - }, - ], - false, - { fontFamily: 'Aptos', fontSize: '12pt' } - ); - }); - - it('Two text segments, same format, with space - 2', () => { - runTest( - [ - { - segmentType: 'Text', - text: 'abc', - format: { fontFamily: 'Aptos', fontSize: '12pt' }, - }, - { - segmentType: 'Text', - text: ' def', - format: { fontFamily: 'Aptos', fontSize: '12pt' }, - }, - ], - [ - { - segmentType: 'Text', - text: 'abc\u00A0def', - format: { fontFamily: 'Aptos', fontSize: '12pt' }, - }, - ], - false, - { fontFamily: 'Aptos', fontSize: '12pt' } - ); - }); - - it('Two text segments, different format - 1', () => { - runTest( - [ - { - segmentType: 'Text', - text: 'abc', - format: { fontFamily: 'Aptos', fontSize: '12pt' }, - }, - { - segmentType: 'Text', - text: 'def', - format: { fontFamily: 'Aptos', fontSize: '12pt', italic: true }, - }, - ], - [ - { - segmentType: 'Text', - text: 'abc', - format: { fontFamily: 'Aptos', fontSize: '12pt' }, - }, - { - segmentType: 'Text', - text: 'def', - format: { fontFamily: 'Aptos', fontSize: '12pt', italic: true }, - }, - ], - false, - { fontFamily: 'Aptos', fontSize: '12pt' } - ); - }); - - it('Two text segments, different format - 2', () => { - runTest( - [ - { - segmentType: 'Text', - text: 'abc', - format: { fontFamily: 'Aptos', fontSize: '12pt' }, - }, - { - segmentType: 'Text', - text: 'def', - format: { fontFamily: 'Aptos' }, - }, - ], - [ - { - segmentType: 'Text', - text: 'abc', - format: { fontFamily: 'Aptos', fontSize: '12pt' }, - }, - { - segmentType: 'Text', - text: 'def', - format: { fontFamily: 'Aptos' }, - }, - ], - false, - { fontFamily: 'Aptos' } - ); - }); - - it('Two text segments, different format - 3', () => { - runTest( - [ - { - segmentType: 'Text', - text: 'abc', - format: { fontFamily: 'Aptos', fontSize: '12pt' }, - }, - { - segmentType: 'Text', - text: 'def', - format: { fontFamily: 'Aptos', fontSize: '14pt' }, - }, - ], - [ - { - segmentType: 'Text', - text: 'abc', - format: { fontFamily: 'Aptos', fontSize: '12pt' }, - }, - { - segmentType: 'Text', - text: 'def', - format: { fontFamily: 'Aptos', fontSize: '14pt' }, - }, - ], - false, - { fontFamily: 'Aptos' } - ); - }); - - it('Two text segments, one has link', () => { - runTest( - [ - { - segmentType: 'Text', - text: 'abc', - format: { fontFamily: 'Aptos', fontSize: '12pt' }, - link: { - dataset: {}, - format: {}, - }, - }, - { - segmentType: 'Text', - text: 'def', - format: { fontFamily: 'Aptos', fontSize: '12pt' }, - }, - ], - [ - { - segmentType: 'Text', - text: 'abc', - format: { fontFamily: 'Aptos', fontSize: '12pt' }, - link: { - dataset: {}, - format: {}, - }, - }, - { - segmentType: 'Text', - text: 'def', - format: { fontFamily: 'Aptos', fontSize: '12pt' }, - }, - ], - false, - { fontFamily: 'Aptos', fontSize: '12pt' } - ); - }); - - it('Two text segments, both have same link', () => { - runTest( - [ - { - segmentType: 'Text', - text: 'abc', - format: { fontFamily: 'Aptos', fontSize: '12pt' }, - link: { - dataset: {}, - format: {}, - }, - }, - { - segmentType: 'Text', - text: 'def', - format: { fontFamily: 'Aptos', fontSize: '12pt' }, - link: { - dataset: {}, - format: {}, - }, - }, - ], - [ - { - segmentType: 'Text', - text: 'abcdef', - format: { fontFamily: 'Aptos', fontSize: '12pt' }, - link: { - dataset: {}, - format: {}, - }, - }, - ], - false, - { fontFamily: 'Aptos', fontSize: '12pt' } - ); - }); - - it('Two text segments, both have different link', () => { - runTest( - [ - { - segmentType: 'Text', - text: 'abc', - format: { fontFamily: 'Aptos', fontSize: '12pt' }, - link: { - dataset: {}, - format: { href: 'url1' }, - }, - }, - { - segmentType: 'Text', - text: 'def', - format: { fontFamily: 'Aptos', fontSize: '12pt' }, - link: { - dataset: {}, - format: { href: 'url2' }, - }, - }, - ], - [ - { - segmentType: 'Text', - text: 'abc', - format: { fontFamily: 'Aptos', fontSize: '12pt' }, - link: { - dataset: {}, - format: { href: 'url1' }, - }, - }, - { - segmentType: 'Text', - text: 'def', - format: { fontFamily: 'Aptos', fontSize: '12pt' }, - link: { - dataset: {}, - format: { href: 'url2' }, - }, - }, - ], - false, - { fontFamily: 'Aptos', fontSize: '12pt' } - ); - }); - - it('Two text segments, one has code', () => { - runTest( - [ - { - segmentType: 'Text', - text: 'abc', - format: { fontFamily: 'Aptos', fontSize: '12pt' }, - code: { - format: {}, - }, - }, - { - segmentType: 'Text', - text: 'def', - format: { fontFamily: 'Aptos', fontSize: '12pt' }, - }, - ], - [ - { - segmentType: 'Text', - text: 'abc', - format: { fontFamily: 'Aptos', fontSize: '12pt' }, - code: { - format: {}, - }, - }, - { - segmentType: 'Text', - text: 'def', - format: { fontFamily: 'Aptos', fontSize: '12pt' }, - }, - ], - false, - { fontFamily: 'Aptos', fontSize: '12pt' } - ); - }); - - it('Two text segments, both have same code', () => { - runTest( - [ - { - segmentType: 'Text', - text: 'abc', - format: { fontFamily: 'Aptos', fontSize: '12pt' }, - code: { - format: {}, - }, - }, - { - segmentType: 'Text', - text: 'def', - format: { fontFamily: 'Aptos', fontSize: '12pt' }, - code: { - format: {}, - }, - }, - ], - [ - { - segmentType: 'Text', - text: 'abcdef', - format: { fontFamily: 'Aptos', fontSize: '12pt' }, - code: { - format: {}, - }, - }, - ], - false, - { fontFamily: 'Aptos', fontSize: '12pt' } - ); - }); - - it('Two text segments around selection marker', () => { - runTest( - [ - { - segmentType: 'Text', - text: 'abc', - format: { fontFamily: 'Aptos', fontSize: '12pt' }, - }, - { - segmentType: 'SelectionMarker', - format: {}, - }, - { - segmentType: 'Text', - text: 'def', - format: { fontFamily: 'Aptos', fontSize: '12pt' }, - }, - ], - [ - { - segmentType: 'Text', - text: 'abc', - format: { fontFamily: 'Aptos', fontSize: '12pt' }, - }, - { - segmentType: 'SelectionMarker', - format: {}, - }, - { - segmentType: 'Text', - text: 'def', - format: { fontFamily: 'Aptos', fontSize: '12pt' }, - }, - ], - false, - { fontFamily: 'Aptos', fontSize: '12pt' } - ); - }); - - it('Two text segments after selection marker', () => { - runTest( - [ - { - segmentType: 'SelectionMarker', - format: {}, - }, - { - segmentType: 'Text', - text: 'abc', - format: { fontFamily: 'Aptos', fontSize: '12pt' }, - }, - { - segmentType: 'Text', - text: 'def', - format: { fontFamily: 'Aptos', fontSize: '12pt' }, - }, - ], - [ - { - segmentType: 'SelectionMarker', - format: {}, - }, - { - segmentType: 'Text', - text: 'abcdef', - format: { fontFamily: 'Aptos', fontSize: '12pt' }, - }, - ], - false, - { fontFamily: 'Aptos', fontSize: '12pt' } - ); - }); - - it('Two text segments before selection marker', () => { - runTest( - [ - { - segmentType: 'Text', - text: 'abc', - format: { fontFamily: 'Aptos', fontSize: '12pt' }, - }, - { - segmentType: 'Text', - text: 'def', - format: { fontFamily: 'Aptos', fontSize: '12pt' }, - }, - { - segmentType: 'SelectionMarker', - format: {}, - }, - ], - [ - { - segmentType: 'Text', - text: 'abcdef', - format: { fontFamily: 'Aptos', fontSize: '12pt' }, - }, - { - segmentType: 'SelectionMarker', - format: {}, - }, - ], - false, - { fontFamily: 'Aptos', fontSize: '12pt' } - ); - }); - - it('Three text segments with same format', () => { - runTest( - [ - { - segmentType: 'Text', - text: 'abc', - format: { fontFamily: 'Aptos', fontSize: '12pt' }, - }, - { - segmentType: 'Text', - text: 'def', - format: { fontFamily: 'Aptos', fontSize: '12pt' }, - }, - { - segmentType: 'Text', - text: 'ghi', - format: { fontFamily: 'Aptos', fontSize: '12pt' }, - }, - ], - [ - { - segmentType: 'Text', - text: 'abcdefghi', - format: { fontFamily: 'Aptos', fontSize: '12pt' }, - }, - ], - false, - { fontFamily: 'Aptos', fontSize: '12pt' } - ); - }); - - it('Two pairs - 1', () => { - runTest( - [ - { - segmentType: 'Text', - text: 'abc', - format: { fontFamily: 'Aptos', fontSize: '12pt' }, - }, - { - segmentType: 'Text', - text: 'def', - format: { fontFamily: 'Aptos', fontSize: '12pt' }, - }, - { - segmentType: 'Text', - text: 'ghi', - format: { fontFamily: 'Aptos', fontSize: '14pt' }, - }, - { - segmentType: 'Text', - text: 'jkl', - format: { fontFamily: 'Aptos', fontSize: '14pt' }, - }, - ], - [ - { - segmentType: 'Text', - text: 'abcdef', - format: { fontFamily: 'Aptos', fontSize: '12pt' }, - }, - { - segmentType: 'Text', - text: 'ghijkl', - format: { fontFamily: 'Aptos', fontSize: '14pt' }, - }, - ], - false, - { fontFamily: 'Aptos' } - ); - }); - - it('Two pairs - 2', () => { - runTest( - [ - { - segmentType: 'Text', - text: 'abc', - format: { fontSize: '12pt' }, - }, - { - segmentType: 'Text', - text: 'def', - format: { fontSize: '14pt' }, - }, - { - segmentType: 'Text', - text: 'ghi', - format: { fontSize: '12pt' }, - }, - { - segmentType: 'Text', - text: 'jkl', - format: { fontSize: '14pt' }, - }, - ], - [ - { - segmentType: 'Text', - text: 'abc', - format: { fontSize: '12pt' }, - }, - { - segmentType: 'Text', - text: 'def', - format: { fontSize: '14pt' }, - }, - { - segmentType: 'Text', - text: 'ghi', - format: { fontSize: '12pt' }, - }, - { - segmentType: 'Text', - text: 'jkl', - format: { fontSize: '14pt' }, - }, - ], - true - ); - }); -}); diff --git a/packages/roosterjs-content-model-dom/test/modelApi/editing/mergeModelTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/editing/mergeModelTest.ts index 78ec1d24aa9..f806f20e84a 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/editing/mergeModelTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/editing/mergeModelTest.ts @@ -96,7 +96,12 @@ describe('mergeModel', () => { segments: [ { segmentType: 'Text', - text: 'test1test2', + text: 'test1', + format: {}, + }, + { + segmentType: 'Text', + text: 'test2', format: {}, }, { @@ -395,7 +400,12 @@ describe('mergeModel', () => { segments: [ { segmentType: 'Text', - text: 'test11newText1', + text: 'test11', + format: {}, + }, + { + segmentType: 'Text', + text: 'newText1', format: {}, }, ], @@ -1690,7 +1700,12 @@ describe('mergeModel', () => { }, { segmentType: 'Text', - text: 'test1new text', + text: 'test1', + format: {}, + }, + { + segmentType: 'Text', + text: 'new text', format: {}, }, marker2, @@ -2937,7 +2952,9 @@ describe('mergeModel', () => { const paragraph: ContentModelParagraph = { blockType: 'Paragraph', segments: [ - { segmentType: 'Text', text: 'test1sourceTest1sourceTest2', format: {} }, + { segmentType: 'Text', text: 'test1', format: {} }, + { segmentType: 'Text', text: 'sourceTest1', format: {} }, + { segmentType: 'Text', text: 'sourceTest2', format: {} }, { segmentType: 'SelectionMarker', isSelected: true, @@ -4080,7 +4097,12 @@ describe('mergeModel', () => { }, { segmentType: 'Text', - text: 'test1new text', + text: 'test1', + format: {}, + }, + { + segmentType: 'Text', + text: 'new text', format: {}, }, marker2, @@ -4952,7 +4974,12 @@ describe('mergeModel', () => { }, { segmentType: 'Text', - text: 'test1new text', + text: 'test1', + format: {}, + }, + { + segmentType: 'Text', + text: 'new text', format: {}, }, marker2, diff --git a/packages/roosterjs-content-model-plugins/test/paste/processPastedContentFromWacTest.ts b/packages/roosterjs-content-model-plugins/test/paste/processPastedContentFromWacTest.ts index fab4753280f..50ff7a50e80 100644 --- a/packages/roosterjs-content-model-plugins/test/paste/processPastedContentFromWacTest.ts +++ b/packages/roosterjs-content-model-plugins/test/paste/processPastedContentFromWacTest.ts @@ -3103,7 +3103,10 @@ describe('wordOnlineHandler', () => { blocks: [ { blockType: 'Paragraph', - segments: [{ segmentType: 'Text', text: 'it went:  ', format: {} }], + segments: [ + { segmentType: 'Text', text: 'it went: ', format: {} }, + { segmentType: 'Text', text: ' ', format: {} }, + ], format: { marginTop: '1em', marginBottom: '1em' }, decorator: { tagName: 'p', format: {} }, }, @@ -3132,7 +3135,10 @@ describe('wordOnlineHandler', () => { blocks: [ { blockType: 'Paragraph', - segments: [{ segmentType: 'Text', text: 'Test. ', format: {} }], + segments: [ + { segmentType: 'Text', text: 'Test.', format: {} }, + { segmentType: 'Text', text: ' ', format: {} }, + ], format: { marginTop: '1em', marginBottom: '1em' }, decorator: { tagName: 'p', format: {} }, }, @@ -4411,7 +4417,19 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_ ', + text: '_', + format: { + fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', + fontSize: '12pt', + italic: false, + fontWeight: 'normal', + textColor: 'rgb(0, 0, 0)', + lineHeight: '22.0875px', + }, + }, + { + segmentType: 'Text', + text: ' ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', @@ -4465,7 +4483,19 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_ ', + text: '_', + format: { + fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', + fontSize: '12pt', + italic: false, + fontWeight: 'normal', + textColor: 'rgb(0, 0, 0)', + lineHeight: '22.0875px', + }, + }, + { + segmentType: 'Text', + text: ' ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', @@ -4519,7 +4549,19 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_ ', + text: '_', + format: { + fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', + fontSize: '12pt', + italic: false, + fontWeight: 'normal', + textColor: 'rgb(0, 0, 0)', + lineHeight: '22.0875px', + }, + }, + { + segmentType: 'Text', + text: ' ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', @@ -4583,7 +4625,19 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_ ', + text: '_', + format: { + fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', + fontSize: '12pt', + italic: false, + fontWeight: 'normal', + textColor: 'rgb(0, 0, 0)', + lineHeight: '22.0875px', + }, + }, + { + segmentType: 'Text', + text: ' ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', @@ -4647,7 +4701,19 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_ ', + text: '_', + format: { + fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', + fontSize: '12pt', + italic: false, + fontWeight: 'normal', + textColor: 'rgb(0, 0, 0)', + lineHeight: '22.0875px', + }, + }, + { + segmentType: 'Text', + text: ' ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', @@ -4721,7 +4787,19 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_ ', + text: '_', + format: { + fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', + fontSize: '12pt', + italic: false, + fontWeight: 'normal', + textColor: 'rgb(0, 0, 0)', + lineHeight: '22.0875px', + }, + }, + { + segmentType: 'Text', + text: ' ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', @@ -4795,7 +4873,19 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_ ', + text: '_', + format: { + fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', + fontSize: '12pt', + italic: false, + fontWeight: 'normal', + textColor: 'rgb(0, 0, 0)', + lineHeight: '22.0875px', + }, + }, + { + segmentType: 'Text', + text: ' ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', @@ -4859,7 +4949,19 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_ ', + text: '_', + format: { + fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', + fontSize: '12pt', + italic: false, + fontWeight: 'normal', + textColor: 'rgb(0, 0, 0)', + lineHeight: '22.0875px', + }, + }, + { + segmentType: 'Text', + text: ' ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', @@ -4923,7 +5025,19 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_ ', + text: '_', + format: { + fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', + fontSize: '12pt', + italic: false, + fontWeight: 'normal', + textColor: 'rgb(0, 0, 0)', + lineHeight: '22.0875px', + }, + }, + { + segmentType: 'Text', + text: ' ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', @@ -4977,7 +5091,19 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_ ', + text: '_', + format: { + fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', + fontSize: '12pt', + italic: false, + fontWeight: 'normal', + textColor: 'rgb(0, 0, 0)', + lineHeight: '22.0875px', + }, + }, + { + segmentType: 'Text', + text: ' ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', @@ -5132,7 +5258,19 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_ ', + text: '_', + format: { + fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', + fontSize: '12pt', + italic: false, + textColor: 'rgb(0, 0, 0)', + fontWeight: 'normal', + lineHeight: '22.0875px', + }, + }, + { + segmentType: 'Text', + text: ' ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', @@ -5231,7 +5369,19 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_ ', + text: '_', + format: { + fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', + fontSize: '12pt', + italic: false, + fontWeight: 'normal', + textColor: 'rgb(0, 0, 0)', + lineHeight: '22.0875px', + }, + }, + { + segmentType: 'Text', + text: ' ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', @@ -5291,7 +5441,19 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_ ', + text: '_', + format: { + fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', + fontSize: '12pt', + italic: false, + textColor: 'rgb(0, 0, 0)', + fontWeight: 'normal', + lineHeight: '22.0875px', + }, + }, + { + segmentType: 'Text', + text: ' ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', @@ -5340,7 +5502,19 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_ ', + text: '_', + format: { + fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', + fontSize: '12pt', + italic: false, + fontWeight: 'normal', + textColor: 'rgb(0, 0, 0)', + lineHeight: '22.0875px', + }, + }, + { + segmentType: 'Text', + text: ' ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', @@ -5445,7 +5619,19 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_ ', + text: '_', + format: { + fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', + fontSize: '12pt', + italic: false, + textColor: 'rgb(0, 0, 0)', + fontWeight: 'normal', + lineHeight: '22.0875px', + }, + }, + { + segmentType: 'Text', + text: ' ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', @@ -5544,7 +5730,19 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_ ', + text: '_', + format: { + fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', + fontSize: '12pt', + italic: false, + fontWeight: 'normal', + textColor: 'rgb(0, 0, 0)', + lineHeight: '22.0875px', + }, + }, + { + segmentType: 'Text', + text: ' ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', @@ -5603,7 +5801,19 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_ ', + text: '_', + format: { + fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', + fontSize: '12pt', + italic: false, + fontWeight: 'normal', + textColor: 'rgb(0, 0, 0)', + lineHeight: '22.0875px', + }, + }, + { + segmentType: 'Text', + text: ' ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', @@ -5708,7 +5918,19 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_ ', + text: '_', + format: { + fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', + fontSize: '12pt', + italic: false, + textColor: 'rgb(0, 0, 0)', + fontWeight: 'normal', + lineHeight: '22.0875px', + }, + }, + { + segmentType: 'Text', + text: ' ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', @@ -5757,7 +5979,19 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_ ', + text: '_', + format: { + fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', + fontSize: '12pt', + italic: false, + fontWeight: 'normal', + textColor: 'rgb(0, 0, 0)', + lineHeight: '22.0875px', + }, + }, + { + segmentType: 'Text', + text: ' ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', @@ -5816,7 +6050,19 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Text', - text: '_ ', + text: '_', + format: { + fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', + fontSize: '12pt', + italic: false, + fontWeight: 'normal', + textColor: 'rgb(0, 0, 0)', + lineHeight: '22.0875px', + }, + }, + { + segmentType: 'Text', + text: ' ', format: { fontFamily: 'Aptos, Aptos_MSFontService, sans-serif', fontSize: '12pt', diff --git a/packages/roosterjs-content-model-plugins/test/paste/processPastedContentFromWordDesktopTest.ts b/packages/roosterjs-content-model-plugins/test/paste/processPastedContentFromWordDesktopTest.ts index bfa5272bc6e..07f17b4039d 100644 --- a/packages/roosterjs-content-model-plugins/test/paste/processPastedContentFromWordDesktopTest.ts +++ b/packages/roosterjs-content-model-plugins/test/paste/processPastedContentFromWordDesktopTest.ts @@ -80,7 +80,12 @@ describe('processPastedContentFromWordDesktopTest', () => { segments: [ { segmentType: 'Text', - text: 'TestTest', + text: 'Test', + format: {}, + }, + { + segmentType: 'Text', + text: 'Test', format: {}, }, ], @@ -101,7 +106,12 @@ describe('processPastedContentFromWordDesktopTest', () => { segments: [ { segmentType: 'Text', - text: 'TestTest', + text: 'Test', + format: {}, + }, + { + segmentType: 'Text', + text: 'Test', format: {}, }, ], @@ -4254,7 +4264,12 @@ describe('processPastedContentFromWordDesktopTest', () => { isImplicit: true, segments: [ { - text: 'text.', + text: 'text', + segmentType: 'Text', + format: {}, + }, + { + text: '.', segmentType: 'Text', format: {}, }, @@ -4833,7 +4848,12 @@ describe('processPastedContentFromWordDesktopTest', () => { isImplicit: true, segments: [ { - text: 'text ', + text: 'text', + segmentType: 'Text', + format: {}, + }, + { + text: ' ', segmentType: 'Text', format: {}, }, From a91d593a72c8884b4137d277f3945cf455edb223 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Thu, 21 Nov 2024 14:40:49 -0300 Subject: [PATCH 22/30] refactor --- .../common/queryContentModelBlocks.ts | 82 ++++++++----------- .../common/queryContentModelBlocksTest.ts | 4 +- 2 files changed, 36 insertions(+), 50 deletions(-) diff --git a/packages/roosterjs-content-model-api/lib/modelApi/common/queryContentModelBlocks.ts b/packages/roosterjs-content-model-api/lib/modelApi/common/queryContentModelBlocks.ts index ebd290de2a7..3b098bfc4ee 100644 --- a/packages/roosterjs-content-model-api/lib/modelApi/common/queryContentModelBlocks.ts +++ b/packages/roosterjs-content-model-api/lib/modelApi/common/queryContentModelBlocks.ts @@ -1,75 +1,61 @@ import type { ContentModelBlockType, ReadonlyContentModelBlock, + ReadonlyContentModelBlockBase, ReadonlyContentModelBlockGroup, } from 'roosterjs-content-model-types'; /** * Query content model blocks * @param group The block group to query - * @param blockType The type of block to query @default 'Paragraph' + * @param type The type of block to query * @param filter Optional selector to filter the blocks * @param findFirstOnly True to return the first block only, false to return all blocks */ export function queryContentModelBlocks( group: ReadonlyContentModelBlockGroup, - blockType?: ContentModelBlockType, + type: T extends ReadonlyContentModelBlockBase ? U : never, filter?: (element: T) => element is T, findFirstOnly?: boolean ): T[] { - const type = blockType || 'Paragraph'; const elements: T[] = []; for (let i = 0; i < group.blocks.length; i++) { if (findFirstOnly && elements.length > 0) { return elements; } const block = group.blocks[i]; - const results = queryContentModelBlocksInternal(block, type, filter, findFirstOnly); - elements.push(...results); - } - return elements; -} -function queryContentModelBlocksInternal( - block: ReadonlyContentModelBlock, - type: ContentModelBlockType, - filter?: (element: T) => element is T, - findFirstOnly?: boolean -): T[] { - const elements: T[] = []; - if (isExpectedBlockType(block, type, filter)) { - elements.push(block); - } - - if (block.blockType == 'BlockGroup') { - for (const childBlock of block.blocks) { - if (findFirstOnly && elements.length > 0) { - return elements; - } - const results = queryContentModelBlocksInternal( - childBlock, - type, - filter, - findFirstOnly - ); - elements.push(...results); - } - } - - if (block.blockType == 'Table') { - const table = block; - for (const row of table.rows) { - for (const cell of row.cells) { - for (const cellBlock of cell.blocks) { - const results = queryContentModelBlocksInternal( - cellBlock, - type, - filter, - findFirstOnly - ); - elements.push(...results); + switch (block.blockType) { + case 'Paragraph': + case 'Divider': + case 'Entity': + if (isExpectedBlockType(block, type, filter)) { + elements.push(block); + } + break; + case 'BlockGroup': + if (isExpectedBlockType(block, type, filter)) { + elements.push(block); + } + const results = queryContentModelBlocks(block, type, filter, findFirstOnly); + elements.push(...results); + break; + case 'Table': + if (isExpectedBlockType(block, type, filter)) { + elements.push(block); + } + for (const row of block.rows) { + for (const cell of row.cells) { + const results = queryContentModelBlocks( + cell, + type, + filter, + findFirstOnly + ); + elements.push(...results); + } } - } + break; } } return elements; @@ -85,7 +71,7 @@ function isExpectedBlockType( function isBlockType( block: ReadonlyContentModelBlock, - type: string + type: ContentModelBlockType ): block is T { return block.blockType == type; } diff --git a/packages/roosterjs-content-model-api/test/modelApi/common/queryContentModelBlocksTest.ts b/packages/roosterjs-content-model-api/test/modelApi/common/queryContentModelBlocksTest.ts index 8a07dc41da6..64a76d37dde 100644 --- a/packages/roosterjs-content-model-api/test/modelApi/common/queryContentModelBlocksTest.ts +++ b/packages/roosterjs-content-model-api/test/modelApi/common/queryContentModelBlocksTest.ts @@ -15,7 +15,7 @@ describe('queryContentModelBlocksBlocks', () => { }; // Act - const result = queryContentModelBlocks(group); + const result = queryContentModelBlocks(group, 'Paragraph'); // Assert expect(result).toEqual([]); @@ -36,7 +36,7 @@ describe('queryContentModelBlocksBlocks', () => { }; // Act - const result = queryContentModelBlocks(group, 'Table'); + const result = queryContentModelBlocks(group, 'Table'); // Assert expect(result).toEqual([]); From 3bbddf55303a28db59cedc1e2a2c27dc189f5d0b Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Fri, 22 Nov 2024 09:14:05 -0800 Subject: [PATCH 23/30] Export isModelEmptyFast and let it accept block group (#2888) --- packages/roosterjs-content-model-plugins/lib/index.ts | 1 + .../lib/watermark/isModelEmptyFast.ts | 5 ++--- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/roosterjs-content-model-plugins/lib/index.ts b/packages/roosterjs-content-model-plugins/lib/index.ts index 884e0472dbc..0ce5c79b0fe 100644 --- a/packages/roosterjs-content-model-plugins/lib/index.ts +++ b/packages/roosterjs-content-model-plugins/lib/index.ts @@ -29,6 +29,7 @@ export { ShortcutKeyDefinition, ShortcutCommand } from './shortcut/ShortcutComma export { ContextMenuPluginBase, ContextMenuOptions } from './contextMenuBase/ContextMenuPluginBase'; export { WatermarkPlugin } from './watermark/WatermarkPlugin'; export { WatermarkFormat } from './watermark/WatermarkFormat'; +export { isModelEmptyFast } from './watermark/isModelEmptyFast'; export { MarkdownPlugin, MarkdownOptions } from './markdown/MarkdownPlugin'; export { HyperlinkPlugin } from './hyperlink/HyperlinkPlugin'; export { HyperlinkToolTip } from './hyperlink/HyperlinkToolTip'; diff --git a/packages/roosterjs-content-model-plugins/lib/watermark/isModelEmptyFast.ts b/packages/roosterjs-content-model-plugins/lib/watermark/isModelEmptyFast.ts index b17d285d964..5089e328461 100644 --- a/packages/roosterjs-content-model-plugins/lib/watermark/isModelEmptyFast.ts +++ b/packages/roosterjs-content-model-plugins/lib/watermark/isModelEmptyFast.ts @@ -1,10 +1,9 @@ -import type { ReadonlyContentModelDocument } from 'roosterjs-content-model-types'; +import type { ReadonlyContentModelBlockGroup } from 'roosterjs-content-model-types'; /** - * @internal * A fast way to check if content model is empty */ -export function isModelEmptyFast(model: ReadonlyContentModelDocument): boolean { +export function isModelEmptyFast(model: ReadonlyContentModelBlockGroup): boolean { const firstBlock = model.blocks[0]; if (model.blocks.length > 1) { From 9a58a18d386273714aa5b6f45d988a0f313745f5 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Fri, 22 Nov 2024 10:40:06 -0800 Subject: [PATCH 24/30] #2860 Expose added and removed block elements during Content Model rewrite (#2873) * #2860 * Fix test * Improve * Improve --- .../setContentModel/setContentModel.ts | 26 ++- .../corePlugin/lifecycle/LifecyclePlugin.ts | 9 +- .../lib/editor/Editor.ts | 8 +- .../setContentModel/setContentModelTest.ts | 65 +++++++- .../corePlugin/entity/EntityPluginTest.ts | 6 + .../lifecycle/LifecyclePluginTest.ts | 61 +++++++ .../test/corePlugin/undo/UndoPluginTest.ts | 8 + .../test/editor/EditorTest.ts | 6 +- .../lib/domUtils/reuseCachedElement.ts | 26 +-- .../context/createModelToDomContext.ts | 13 +- .../handlers/handleBlockGroupChildren.ts | 5 + .../lib/modelToDom/handlers/handleDivider.ts | 3 +- .../lib/modelToDom/handlers/handleEntity.ts | 2 +- .../handlers/handleFormatContainer.ts | 3 +- .../modelToDom/handlers/handleGeneralModel.ts | 3 +- .../lib/modelToDom/handlers/handleListItem.ts | 1 + .../modelToDom/handlers/handleParagraph.ts | 4 +- .../lib/modelToDom/handlers/handleTable.ts | 3 +- .../domUtils/event/cacheGetEventDataTest.ts | 4 + .../test/domUtils/reuseCachedElementTest.ts | 72 +++++++- .../context/createModelToDomContextTest.ts | 12 ++ .../handlers/handleBlockGroupChildrenTest.ts | 48 ++++++ .../modelToDom/handlers/handleDividerTest.ts | 32 ++++ .../handlers/handleFormatContainerTest.ts | 25 +++ .../handlers/handleGeneralModelTest.ts | 16 ++ .../modelToDom/handlers/handleListItemTest.ts | 38 +++++ .../handlers/handleParagraphTest.ts | 154 ++++++++++++++++++ .../modelToDom/handlers/handleTableTest.ts | 64 ++++++++ .../test/watermark/WatermarkPluginTest.ts | 30 +++- .../lib/context/ModelToDomContext.ts | 4 +- .../lib/context/RewriteFromModel.ts | 24 +++ .../lib/editor/EditorCore.ts | 8 +- .../lib/event/EditorReadyEvent.ts | 3 +- .../lib/event/PluginEvent.ts | 2 + .../lib/event/PluginEventType.ts | 5 + .../lib/event/RewriteFromModelEvent.ts | 9 + .../lib/index.ts | 2 + .../lib/pluginState/LifecyclePluginState.ts | 6 + .../lib/editor/utils/eventConverter.ts | 3 + .../test/corePlugins/BridgePluginTest.ts | 18 +- .../test/editor/utils/eventConverterTest.ts | 4 + packages/roosterjs/test/createEditorTest.ts | 2 + 42 files changed, 792 insertions(+), 45 deletions(-) create mode 100644 packages/roosterjs-content-model-types/lib/context/RewriteFromModel.ts create mode 100644 packages/roosterjs-content-model-types/lib/event/RewriteFromModelEvent.ts diff --git a/packages/roosterjs-content-model-core/lib/coreApi/setContentModel/setContentModel.ts b/packages/roosterjs-content-model-core/lib/coreApi/setContentModel/setContentModel.ts index 19d3da8e411..beb993f2e97 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/setContentModel/setContentModel.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/setContentModel/setContentModel.ts @@ -12,8 +12,17 @@ import type { SetContentModel } from 'roosterjs-content-model-types'; * @param core The editor core object * @param model The content model to set * @param option Additional options to customize the behavior of Content Model to DOM conversion + * @param onNodeCreated An optional callback that will be called when a DOM node is created + * @param isInitializing True means editor is being initialized then it will save modification nodes onto + * lifecycleState instead of triggering events, false means other cases */ -export const setContentModel: SetContentModel = (core, model, option, onNodeCreated) => { +export const setContentModel: SetContentModel = ( + core, + model, + option, + onNodeCreated, + isInitializing +) => { const editorContext = core.api.createEditorContext(core, true /*saveIndex*/); const modelToDomContext = option ? createModelToDomContext( @@ -49,5 +58,20 @@ export const setContentModel: SetContentModel = (core, model, option, onNodeCrea } } + if (isInitializing) { + // When initialize, we should not trigger event until all plugins are initialized, so put these node in lifecycle state temporarily + core.lifecycle.rewriteFromModel = modelToDomContext.rewriteFromModel; + } else { + // Otherwise, trigger RewriteFromModel event immediately + core.api.triggerEvent( + core, + { + eventType: 'rewriteFromModel', + ...modelToDomContext.rewriteFromModel, + }, + true /*broadcast*/ + ); + } + return selection; }; diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/lifecycle/LifecyclePlugin.ts b/packages/roosterjs-content-model-core/lib/corePlugin/lifecycle/LifecyclePlugin.ts index 1e3e72b2aff..c038e5776e7 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/lifecycle/LifecyclePlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/lifecycle/LifecyclePlugin.ts @@ -6,6 +6,7 @@ import type { PluginEvent, PluginWithState, EditorOptions, + RewriteFromModel, } from 'roosterjs-content-model-types'; const ContentEditableAttributeName = 'contenteditable'; @@ -74,7 +75,13 @@ class LifecyclePlugin implements PluginWithState { this.adjustColor(); // Let other plugins know that we are ready - this.editor.triggerEvent('editorReady', {}, true /*broadcast*/); + const rewriteFromModel: RewriteFromModel = this.state.rewriteFromModel ?? { + addedBlockElements: [], + removedBlockElements: [], + }; + + this.editor.triggerEvent('editorReady', rewriteFromModel, true /*broadcast*/); + delete this.state.rewriteFromModel; // Initialize the Announce container. this.state.announceContainer = createAriaLiveElement(editor.getDocument()); diff --git a/packages/roosterjs-content-model-core/lib/editor/Editor.ts b/packages/roosterjs-content-model-core/lib/editor/Editor.ts index c01aee381c7..6977040e9fa 100644 --- a/packages/roosterjs-content-model-core/lib/editor/Editor.ts +++ b/packages/roosterjs-content-model-core/lib/editor/Editor.ts @@ -50,7 +50,13 @@ export class Editor implements IEditor { const initialModel = options.initialModel ?? createEmptyModel(options.defaultSegmentFormat); - this.core.api.setContentModel(this.core, initialModel, { ignoreSelection: true }); + this.core.api.setContentModel( + this.core, + initialModel, + { ignoreSelection: true }, + undefined /*onNodeCreated*/, + true /*isInitializing*/ + ); this.core.plugins.forEach(plugin => plugin.initialize(this)); } diff --git a/packages/roosterjs-content-model-core/test/coreApi/setContentModel/setContentModelTest.ts b/packages/roosterjs-content-model-core/test/coreApi/setContentModel/setContentModelTest.ts index 201e90ae96c..15c33e36ed6 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/setContentModel/setContentModelTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/setContentModel/setContentModelTest.ts @@ -1,13 +1,13 @@ import * as contentModelToDom from 'roosterjs-content-model-dom/lib/modelToDom/contentModelToDom'; import * as createModelToDomContext from 'roosterjs-content-model-dom/lib/modelToDom/context/createModelToDomContext'; import * as updateCache from '../../../lib/corePlugin/cache/updateCache'; -import { EditorCore } from 'roosterjs-content-model-types'; +import { ContentModelDocument, EditorCore, ModelToDomContext } from 'roosterjs-content-model-types'; import { setContentModel } from '../../../lib/coreApi/setContentModel/setContentModel'; const mockedDoc = 'DOCUMENT' as any; const mockedModel = 'MODEL' as any; const mockedEditorContext = 'EDITORCONTEXT' as any; -const mockedContext = { name: 'CONTEXT' } as any; +const mockedContext = { name: 'CONTEXT', rewriteFromModel: {} } as any; const mockedDiv = { ownerDocument: mockedDoc } as any; const mockedConfig = 'CONFIG' as any; @@ -21,6 +21,7 @@ describe('setContentModel', () => { let getDOMSelectionSpy: jasmine.Spy; let flushMutationsSpy: jasmine.Spy; let updateCacheSpy: jasmine.Spy; + let triggerEventSpy: jasmine.Spy; beforeEach(() => { contentModelToDomSpy = spyOn(contentModelToDom, 'contentModelToDom'); @@ -38,6 +39,7 @@ describe('setContentModel', () => { setDOMSelectionSpy = jasmine.createSpy('setDOMSelection'); getDOMSelectionSpy = jasmine.createSpy('getDOMSelection'); flushMutationsSpy = jasmine.createSpy('flushMutations'); + triggerEventSpy = jasmine.createSpy('triggerEvent'); core = { physicalRoot: mockedDiv, @@ -46,6 +48,7 @@ describe('setContentModel', () => { createEditorContext, setDOMSelection: setDOMSelectionSpy, getDOMSelection: getDOMSelectionSpy, + triggerEvent: triggerEventSpy, }, lifecycle: {}, cache: { @@ -271,4 +274,62 @@ describe('setContentModel', () => { expect(flushMutationsSpy).toHaveBeenCalledBefore(updateCacheSpy); expect(updateCacheSpy).toHaveBeenCalledBefore(setDOMSelectionSpy); }); + + it('Receive modified DOM elements, not in init', () => { + const mockedAddedNodes = 'ADD' as any; + const mockedRemovedNodes = 'REMOVE' as any; + + contentModelToDomSpy.and.callFake( + ( + doc: Document, + root: Node, + model: ContentModelDocument, + context: ModelToDomContext + ) => { + context.rewriteFromModel.addedBlockElements = mockedAddedNodes; + context.rewriteFromModel.removedBlockElements = mockedRemovedNodes; + return {} as any; + } + ); + + setContentModel(core, mockedModel); + + expect(triggerEventSpy).toHaveBeenCalledTimes(1); + expect(triggerEventSpy).toHaveBeenCalledWith( + core, + { + eventType: 'rewriteFromModel', + addedBlockElements: mockedAddedNodes, + removedBlockElements: mockedRemovedNodes, + }, + true + ); + expect(core.lifecycle.rewriteFromModel).toBeUndefined(); + }); + + it('Receive modified DOM elements, in init', () => { + const mockedAddedNodes = 'ADD' as any; + const mockedRemovedNodes = 'REMOVE' as any; + + contentModelToDomSpy.and.callFake( + ( + doc: Document, + root: Node, + model: ContentModelDocument, + context: ModelToDomContext + ) => { + context.rewriteFromModel.addedBlockElements = mockedAddedNodes; + context.rewriteFromModel.removedBlockElements = mockedRemovedNodes; + return {} as any; + } + ); + + setContentModel(core, mockedModel, undefined, undefined, true); + + expect(triggerEventSpy).not.toHaveBeenCalled(); + expect(core.lifecycle.rewriteFromModel).toEqual({ + addedBlockElements: mockedAddedNodes, + removedBlockElements: mockedRemovedNodes, + }); + }); }); diff --git a/packages/roosterjs-content-model-core/test/corePlugin/entity/EntityPluginTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/entity/EntityPluginTest.ts index 2f79d523656..b3514156042 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/entity/EntityPluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/entity/EntityPluginTest.ts @@ -64,6 +64,8 @@ describe('EntityPlugin', () => { plugin.onPluginEvent({ eventType: 'editorReady', + addedBlockElements: [], + removedBlockElements: [], }); const state = plugin.getState(); @@ -85,6 +87,8 @@ describe('EntityPlugin', () => { plugin.onPluginEvent({ eventType: 'editorReady', + addedBlockElements: [], + removedBlockElements: [], }); const state = plugin.getState(); @@ -130,6 +134,8 @@ describe('EntityPlugin', () => { plugin.onPluginEvent({ eventType: 'editorReady', + addedBlockElements: [], + removedBlockElements: [], }); const state = plugin.getState(); diff --git a/packages/roosterjs-content-model-core/test/corePlugin/lifecycle/LifecyclePluginTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/lifecycle/LifecyclePluginTest.ts index e4b48f607ce..a1981b03077 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/lifecycle/LifecyclePluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/lifecycle/LifecyclePluginTest.ts @@ -42,6 +42,10 @@ describe('LifecyclePlugin', () => { expect(div.innerHTML).toBe(''); expect(triggerEvent).toHaveBeenCalledTimes(1); expect(triggerEvent.calls.argsFor(0)[0]).toBe('editorReady'); + expect(triggerEvent.calls.argsFor(0)[1]).toEqual({ + addedBlockElements: [], + removedBlockElements: [], + }); plugin.dispose(); expect(div.isContentEditable).toBeFalse(); @@ -85,6 +89,55 @@ describe('LifecyclePlugin', () => { expect(div.style.userSelect).toBe('text'); expect(triggerEvent).toHaveBeenCalledTimes(1); expect(triggerEvent.calls.argsFor(0)[0]).toBe('editorReady'); + expect(triggerEvent.calls.argsFor(0)[1]).toEqual({ + addedBlockElements: [], + removedBlockElements: [], + }); + + plugin.dispose(); + expect(div.isContentEditable).toBeFalse(); + }); + + it('init with rewriteFromModel', () => { + const div = document.createElement('div'); + const plugin = createLifecyclePlugin({}, div); + const triggerEvent = jasmine.createSpy('triggerEvent'); + const getDocument = jasmine.createSpy('getDocument').and.returnValue(document); + + const state = plugin.getState(); + const mockedAddedElements = 'ADD' as any; + const mockedRemovedElements = 'REMOVE' as any; + + state.rewriteFromModel = { + addedBlockElements: mockedAddedElements, + removedBlockElements: mockedRemovedElements, + }; + + plugin.initialize(({ + triggerEvent, + getFocusedPosition: () => null, + getColorManager: () => null, + isDarkMode: () => false, + getDocument, + })); + + expect(state).toEqual({ + isDarkMode: false, + shadowEditFragment: null, + styleElements: {}, + announcerStringGetter: undefined, + announceContainer, + }); + + expect(div.isContentEditable).toBeTrue(); + expect(div.style.userSelect).toBe('text'); + expect(div.innerHTML).toBe(''); + expect(triggerEvent).toHaveBeenCalledTimes(1); + expect(triggerEvent.calls.argsFor(0)[0]).toBe('editorReady'); + expect(triggerEvent.calls.argsFor(0)[1]).toEqual({ + addedBlockElements: mockedAddedElements, + removedBlockElements: mockedRemovedElements, + }); plugin.dispose(); expect(div.isContentEditable).toBeFalse(); @@ -109,6 +162,10 @@ describe('LifecyclePlugin', () => { expect(div.style.userSelect).toBe(''); expect(triggerEvent).toHaveBeenCalledTimes(1); expect(triggerEvent.calls.argsFor(0)[0]).toBe('editorReady'); + expect(triggerEvent.calls.argsFor(0)[1]).toEqual({ + addedBlockElements: [], + removedBlockElements: [], + }); plugin.dispose(); expect(div.isContentEditable).toBeTrue(); @@ -133,6 +190,10 @@ describe('LifecyclePlugin', () => { expect(div.style.userSelect).toBe(''); expect(triggerEvent).toHaveBeenCalledTimes(1); expect(triggerEvent.calls.argsFor(0)[0]).toBe('editorReady'); + expect(triggerEvent.calls.argsFor(0)[1]).toEqual({ + addedBlockElements: [], + removedBlockElements: [], + }); plugin.dispose(); expect(div.isContentEditable).toBeFalse(); diff --git a/packages/roosterjs-content-model-core/test/corePlugin/undo/UndoPluginTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/undo/UndoPluginTest.ts index 665df07084e..770b017e9aa 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/undo/UndoPluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/undo/UndoPluginTest.ts @@ -104,6 +104,8 @@ describe('UndoPlugin', () => { it('Not handled exclusively for EditorReady event', () => { const result = plugin.willHandleEventExclusively({ eventType: 'editorReady', + addedBlockElements: [], + removedBlockElements: [], }); expect(result).toBeFalse(); @@ -285,6 +287,8 @@ describe('UndoPlugin', () => { plugin.onPluginEvent({ eventType: 'editorReady', + addedBlockElements: [], + removedBlockElements: [], }); expect(takeSnapshotSpy).toHaveBeenCalledTimes(1); @@ -306,6 +310,8 @@ describe('UndoPlugin', () => { plugin.onPluginEvent({ eventType: 'editorReady', + addedBlockElements: [], + removedBlockElements: [], }); expect(takeSnapshotSpy).toHaveBeenCalledTimes(0); @@ -327,6 +333,8 @@ describe('UndoPlugin', () => { plugin.onPluginEvent({ eventType: 'editorReady', + addedBlockElements: [], + removedBlockElements: [], }); expect(takeSnapshotSpy).toHaveBeenCalledTimes(0); diff --git a/packages/roosterjs-content-model-core/test/editor/EditorTest.ts b/packages/roosterjs-content-model-core/test/editor/EditorTest.ts index ace32194a31..87707aa74b0 100644 --- a/packages/roosterjs-content-model-core/test/editor/EditorTest.ts +++ b/packages/roosterjs-content-model-core/test/editor/EditorTest.ts @@ -4,9 +4,9 @@ import * as createEditorCore from '../../lib/editor/core/createEditorCore'; import * as createEmptyModel from 'roosterjs-content-model-dom/lib/modelApi/creators/createEmptyModel'; import * as domToContentModel from 'roosterjs-content-model-dom/lib/domToModel/domToContentModel'; import * as transformColor from 'roosterjs-content-model-dom/lib/domUtils/style/transformColor'; +import { ChangeSource } from 'roosterjs-content-model-dom'; import { Editor } from '../../lib/editor/Editor'; import { expectHtml } from 'roosterjs-content-model-dom/test/testUtils'; -import { ChangeSource } from 'roosterjs-content-model-dom'; import { CachedElementHandler, ContentModelDocument, @@ -90,7 +90,9 @@ describe('Editor', () => { expect(setContentModelSpy).toHaveBeenCalledWith( jasmine.anything() /*core*/, mockedInitialModel, - { ignoreSelection: true } + { ignoreSelection: true }, + undefined, + true ); expect(initSpy1).toHaveBeenCalledWith(editor); diff --git a/packages/roosterjs-content-model-dom/lib/domUtils/reuseCachedElement.ts b/packages/roosterjs-content-model-dom/lib/domUtils/reuseCachedElement.ts index 471c94ece4a..675661cf8aa 100644 --- a/packages/roosterjs-content-model-dom/lib/domUtils/reuseCachedElement.ts +++ b/packages/roosterjs-content-model-dom/lib/domUtils/reuseCachedElement.ts @@ -1,15 +1,22 @@ import { isEntityElement } from './entityUtils'; +import { isNodeOfType } from './isNodeOfType'; +import type { RewriteFromModel } from 'roosterjs-content-model-types'; /** * When set a DOM tree into editor, reuse the existing element in editor and no need to change it - * @param param Parent node of the reused element + * @param parent Parent node of the reused element * @param element The element to keep in parent node * @param refNode Reference node, it is point to current node that is being processed. It must be a child of parent node, or null. * We will start processing from this node, if it is not the same with element, remove it and keep processing its next sibling, * until we see an element that is the same with the passed in element or null. * @returns The new reference element */ -export function reuseCachedElement(parent: Node, element: Node, refNode: Node | null): Node | null { +export function reuseCachedElement( + parent: Node, + element: Node, + refNode: Node | null, + context?: RewriteFromModel +): Node | null { if (element.parentNode == parent) { const isEntity = isEntityElement(element); @@ -20,6 +27,11 @@ export function reuseCachedElement(parent: Node, element: Node, refNode: Node | const next = refNode.nextSibling; refNode.parentNode?.removeChild(refNode); + + if (isNodeOfType(refNode, 'ELEMENT_NODE')) { + context?.removedBlockElements.push(refNode); + } + refNode = next; } @@ -34,13 +46,3 @@ export function reuseCachedElement(parent: Node, element: Node, refNode: Node | return refNode; } - -/** - * @internal - */ -export function removeNode(node: Node): Node | null { - const next = node.nextSibling; - node.parentNode?.removeChild(node); - - return next; -} diff --git a/packages/roosterjs-content-model-dom/lib/modelToDom/context/createModelToDomContext.ts b/packages/roosterjs-content-model-dom/lib/modelToDom/context/createModelToDomContext.ts index 1a059cc885e..e71f2a31455 100644 --- a/packages/roosterjs-content-model-dom/lib/modelToDom/context/createModelToDomContext.ts +++ b/packages/roosterjs-content-model-dom/lib/modelToDom/context/createModelToDomContext.ts @@ -5,6 +5,7 @@ import { defaultFormatKeysPerCategory, } from '../../formatHandlers/defaultFormatHandlers'; import type { + RewriteFromModelContext, EditorContext, FormatApplier, FormatAppliers, @@ -37,12 +38,13 @@ export function createModelToDomContext( export function createModelToDomContextWithConfig( config: ModelToDomSettings, editorContext?: EditorContext -) { +): ModelToDomContext { return Object.assign( {}, editorContext, createModelToDomSelectionContext(), createModelToDomFormatContext(), + createRewriteFromModelContext(), config ); } @@ -68,6 +70,15 @@ function createModelToDomFormatContext(): ModelToDomFormatContext { }; } +function createRewriteFromModelContext(): RewriteFromModelContext { + return { + rewriteFromModel: { + addedBlockElements: [], + removedBlockElements: [], + }, + }; +} + /** * Create Content Model to DOM Config object * @param options All customizations of DOM creation diff --git a/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleBlockGroupChildren.ts b/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleBlockGroupChildren.ts index 8c749f5a039..9b1350668ac 100644 --- a/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleBlockGroupChildren.ts +++ b/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleBlockGroupChildren.ts @@ -1,3 +1,4 @@ +import { isNodeOfType } from '../../domUtils/isNodeOfType'; import type { ContentModelBlockGroup, ContentModelHandler, @@ -42,6 +43,10 @@ export const handleBlockGroupChildren: ContentModelHandler = ( let element = context.allowCacheElement ? divider.cachedElement : undefined; if (element && !divider.isSelected) { - refNode = reuseCachedElement(parent, element, refNode); + refNode = reuseCachedElement(parent, element, refNode, context.rewriteFromModel); } else { element = doc.createElement(divider.tagName); @@ -28,6 +28,7 @@ export const handleDivider: ContentModelBlockHandler = ( } parent.insertBefore(element, refNode); + context.rewriteFromModel.addedBlockElements.push(element); applyFormat(element, context.formatAppliers.divider, divider.format, context); diff --git a/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleEntity.ts b/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleEntity.ts index 3032b63e791..0ecbfcabc68 100644 --- a/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleEntity.ts +++ b/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleEntity.ts @@ -34,7 +34,7 @@ export const handleEntityBlock: ContentModelBlockHandler = ( const isContained = wrapper.parentElement?.classList.contains(BlockEntityContainer); const elementToReuse = isContained && isCursorAroundEntity ? wrapper.parentElement! : wrapper; - refNode = reuseCachedElement(parent, elementToReuse, refNode); + refNode = reuseCachedElement(parent, elementToReuse, refNode, context.rewriteFromModel); if (isCursorAroundEntity) { if (!isContained) { diff --git a/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleFormatContainer.ts b/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleFormatContainer.ts index 700b6ef3ce9..da761c33f25 100644 --- a/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleFormatContainer.ts +++ b/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleFormatContainer.ts @@ -28,7 +28,7 @@ export const handleFormatContainer: ContentModelBlockHandler { applyFormat(containerNode, context.formatAppliers.container, container.format, context); diff --git a/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleGeneralModel.ts b/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleGeneralModel.ts index 42dae971d96..ed10002d52e 100644 --- a/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleGeneralModel.ts +++ b/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleGeneralModel.ts @@ -23,7 +23,7 @@ export const handleGeneralBlock: ContentModelBlockHandler = ( // It is possible listParent is the same with parent param. // This happens when outdent a list item to cause it has no list level listParent.insertBefore(li, refNode?.parentNode == listParent ? refNode : null); + context.rewriteFromModel.addedBlockElements.push(li); if (level) { applyFormat(li, context.formatAppliers.segment, listItem.formatHolder.format, context); diff --git a/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleParagraph.ts b/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleParagraph.ts index 73054976b03..ef6732c9dde 100644 --- a/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleParagraph.ts +++ b/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleParagraph.ts @@ -25,7 +25,7 @@ export const handleParagraph: ContentModelBlockHandler = let container = context.allowCacheElement ? paragraph.cachedElement : undefined; if (container && paragraph.segments.every(x => x.segmentType != 'General' && !x.isSelected)) { - refNode = reuseCachedElement(parent, container, refNode); + refNode = reuseCachedElement(parent, container, refNode, context.rewriteFromModel); } else { stackFormat(context, paragraph.decorator?.tagName || null, () => { const needParagraphWrapper = @@ -117,6 +117,8 @@ export const handleParagraph: ContentModelBlockHandler = if (context.allowCacheElement) { paragraph.cachedElement = container; } + + context.rewriteFromModel.addedBlockElements.push(container); } else { unwrap(container); container = undefined; diff --git a/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleTable.ts b/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleTable.ts index d9096d9fd75..5f564971b79 100644 --- a/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleTable.ts +++ b/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleTable.ts @@ -28,7 +28,7 @@ export const handleTable: ContentModelBlockHandler = ( let tableNode = context.allowCacheElement ? table.cachedElement : undefined; if (tableNode) { - refNode = reuseCachedElement(parent, tableNode, refNode); + refNode = reuseCachedElement(parent, tableNode, refNode, context.rewriteFromModel); moveChildNodes(tableNode); } else { @@ -39,6 +39,7 @@ export const handleTable: ContentModelBlockHandler = ( } parent.insertBefore(tableNode, refNode); + context.rewriteFromModel.addedBlockElements.push(tableNode); applyFormat(tableNode, context.formatAppliers.block, table.format, context); applyFormat(tableNode, context.formatAppliers.table, table.format, context); diff --git a/packages/roosterjs-content-model-dom/test/domUtils/event/cacheGetEventDataTest.ts b/packages/roosterjs-content-model-dom/test/domUtils/event/cacheGetEventDataTest.ts index 49d145a3f9c..7a567dc7b6a 100644 --- a/packages/roosterjs-content-model-dom/test/domUtils/event/cacheGetEventDataTest.ts +++ b/packages/roosterjs-content-model-dom/test/domUtils/event/cacheGetEventDataTest.ts @@ -6,6 +6,8 @@ describe('cacheGetEventData', () => { it('get cached data', () => { const event: EditorReadyEvent = { eventType: 'editorReady', + addedBlockElements: [], + removedBlockElements: [], }; const mockedData = 'DATA'; @@ -21,6 +23,8 @@ describe('cacheGetEventData', () => { eventDataCache: { [cacheKey]: mockedData, }, + addedBlockElements: [], + removedBlockElements: [], }); const data2 = cacheGetEventData(event, cacheKey, mockedGetter); diff --git a/packages/roosterjs-content-model-dom/test/domUtils/reuseCachedElementTest.ts b/packages/roosterjs-content-model-dom/test/domUtils/reuseCachedElementTest.ts index 1f612a8c6cb..3e4ac5916b0 100644 --- a/packages/roosterjs-content-model-dom/test/domUtils/reuseCachedElementTest.ts +++ b/packages/roosterjs-content-model-dom/test/domUtils/reuseCachedElementTest.ts @@ -1,16 +1,25 @@ import { reuseCachedElement } from '../../lib/domUtils/reuseCachedElement'; import { setEntityElementClasses } from './entityUtilTest'; +import type { RewriteFromModel } from 'roosterjs-content-model-types'; describe('reuseCachedElement', () => { it('No refNode', () => { const parent = document.createElement('div'); const element = document.createElement('span'); + const context: RewriteFromModel = { + addedBlockElements: [], + removedBlockElements: [], + }; - const result = reuseCachedElement(parent, element, null); + const result = reuseCachedElement(parent, element, null, context); expect(parent.outerHTML).toBe('

'); expect(parent.firstChild).toBe(element); expect(result).toBe(null); + expect(context).toEqual({ + addedBlockElements: [], + removedBlockElements: [], + }); }); it('RefNode is not current element', () => { @@ -20,11 +29,20 @@ describe('reuseCachedElement', () => { parent.appendChild(refNode); - const result = reuseCachedElement(parent, element, refNode); + const context: RewriteFromModel = { + addedBlockElements: [], + removedBlockElements: [], + }; + + const result = reuseCachedElement(parent, element, refNode, context); expect(parent.outerHTML).toBe('

'); expect(parent.firstChild).toBe(element); expect(result).toBe(refNode); + expect(context).toEqual({ + addedBlockElements: [], + removedBlockElements: [], + }); }); it('RefNode is current element', () => { @@ -35,30 +53,49 @@ describe('reuseCachedElement', () => { parent.appendChild(element); parent.appendChild(nextNode); - const result = reuseCachedElement(parent, element, element); + const context: RewriteFromModel = { + addedBlockElements: [], + removedBlockElements: [], + }; + + const result = reuseCachedElement(parent, element, element, context); expect(parent.outerHTML).toBe('

'); expect(parent.firstChild).toBe(element); expect(parent.firstChild?.nextSibling).toBe(nextNode); expect(result).toBe(nextNode); + expect(context).toEqual({ + addedBlockElements: [], + removedBlockElements: [], + }); }); it('RefNode is before current element', () => { const parent = document.createElement('div'); - const refNode = document.createElement('hr'); + const hr = document.createElement('hr'); const element = document.createElement('span'); const nextNode = document.createElement('br'); + const refNode = hr; parent.appendChild(refNode); parent.appendChild(element); parent.appendChild(nextNode); - const result = reuseCachedElement(parent, element, refNode); + const context: RewriteFromModel = { + addedBlockElements: [], + removedBlockElements: [], + }; + + const result = reuseCachedElement(parent, element, refNode, context); expect(parent.outerHTML).toBe('

'); expect(parent.firstChild).toBe(element); expect(parent.firstChild?.nextSibling).toBe(nextNode); expect(result).toBe(nextNode); + expect(context).toEqual({ + addedBlockElements: [], + removedBlockElements: [hr], + }); }); it('RefNode is entity', () => { @@ -74,7 +111,12 @@ describe('reuseCachedElement', () => { setEntityElementClasses(refNode, 'TestEntity', true); - const result = reuseCachedElement(parent, element, refNode); + const context: RewriteFromModel = { + addedBlockElements: [], + removedBlockElements: [], + }; + + const result = reuseCachedElement(parent, element, refNode, context); expect(removeChildSpy).not.toHaveBeenCalled(); expect(parent.outerHTML).toBe( @@ -83,14 +125,19 @@ describe('reuseCachedElement', () => { expect(parent.firstChild).toBe(element); expect(parent.firstChild?.nextSibling).toBe(refNode); expect(result).toBe(refNode); + expect(context).toEqual({ + addedBlockElements: [], + removedBlockElements: [], + }); }); it('RefNode is entity, current element is entity', () => { const parent = document.createElement('div'); - const refNode = document.createElement('div'); + const entity = document.createElement('div'); const element = document.createElement('span'); const nextNode = document.createElement('br'); const removeChildSpy = spyOn(Node.prototype, 'removeChild').and.callThrough(); + const refNode = entity; parent.appendChild(refNode); parent.appendChild(element); @@ -99,7 +146,12 @@ describe('reuseCachedElement', () => { setEntityElementClasses(refNode, 'TestEntity', true); setEntityElementClasses(element, 'TestEntity2', true); - const result = reuseCachedElement(parent, element, refNode); + const context: RewriteFromModel = { + addedBlockElements: [], + removedBlockElements: [], + }; + + const result = reuseCachedElement(parent, element, refNode, context); expect(removeChildSpy).toHaveBeenCalledTimes(1); expect(removeChildSpy).toHaveBeenCalledWith(refNode); @@ -110,5 +162,9 @@ describe('reuseCachedElement', () => { expect(parent.firstChild).toBe(element); expect(parent.firstChild?.nextSibling).toBe(nextNode); expect(result).toBe(nextNode); + expect(context).toEqual({ + addedBlockElements: [], + removedBlockElements: [entity], + }); }); }); diff --git a/packages/roosterjs-content-model-dom/test/modelToDom/context/createModelToDomContextTest.ts b/packages/roosterjs-content-model-dom/test/modelToDom/context/createModelToDomContextTest.ts index 1e1c8c0f9ee..6d5dff298c7 100644 --- a/packages/roosterjs-content-model-dom/test/modelToDom/context/createModelToDomContextTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelToDom/context/createModelToDomContextTest.ts @@ -27,6 +27,10 @@ describe('createModelToDomContext', () => { defaultModelHandlers: defaultContentModelHandlers, defaultFormatAppliers, metadataAppliers: {}, + rewriteFromModel: { + addedBlockElements: [], + removedBlockElements: [], + }, }); }); @@ -55,6 +59,10 @@ describe('createModelToDomContext', () => { defaultModelHandlers: defaultContentModelHandlers, defaultFormatAppliers, metadataAppliers: {}, + rewriteFromModel: { + addedBlockElements: [], + removedBlockElements: [], + }, }); }); @@ -114,6 +122,10 @@ describe('createModelToDomContext', () => { defaultModelHandlers: defaultContentModelHandlers, defaultFormatAppliers, metadataAppliers: {}, + rewriteFromModel: { + addedBlockElements: [], + removedBlockElements: [], + }, }); }); }); diff --git a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleBlockGroupChildrenTest.ts b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleBlockGroupChildrenTest.ts index 2a5673a1af6..6da28672a57 100644 --- a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleBlockGroupChildrenTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleBlockGroupChildrenTest.ts @@ -42,6 +42,10 @@ describe('handleBlockGroupChildren', () => { expect(parent.outerHTML).toBe('
'); expect(context.listFormat.nodeStack).toEqual([]); expect(handleBlock).not.toHaveBeenCalled(); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [], + removedBlockElements: [], + }); }); it('Single child block group', () => { @@ -56,6 +60,10 @@ describe('handleBlockGroupChildren', () => { expect(context.listFormat.nodeStack).toEqual([]); expect(handleBlock).toHaveBeenCalledTimes(1); expect(handleBlock).toHaveBeenCalledWith(document, parent, paragraph, context, null); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('Multiple child block group', () => { @@ -73,6 +81,10 @@ describe('handleBlockGroupChildren', () => { expect(handleBlock).toHaveBeenCalledTimes(2); expect(handleBlock).toHaveBeenCalledWith(document, parent, paragraph1, context, null); expect(handleBlock).toHaveBeenCalledWith(document, parent, paragraph2, context, null); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('Multiple child block group with nodeStack and no list', () => { @@ -95,6 +107,10 @@ describe('handleBlockGroupChildren', () => { expect(context.listFormat.nodeStack).toBe(nodeStack); expect(handleBlock).toHaveBeenCalledTimes(1); expect(handleBlock).toHaveBeenCalledWith(document, parent, paragraph, context, null); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [], + removedBlockElements: [], + }); }); it('Multiple child block group with nodeStack and no list', () => { @@ -133,6 +149,10 @@ describe('handleBlockGroupChildren', () => { expect(handleBlock).toHaveBeenCalledWith(document, parent, paragraph1, context, null); expect(handleBlock).toHaveBeenCalledWith(document, parent, paragraph2, context, null); expect(handleBlock).toHaveBeenCalledWith(document, parent, list, context, null); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [], + removedBlockElements: [], + }); }); it('handle document with cache 1', () => { @@ -168,6 +188,10 @@ describe('handleBlockGroupChildren', () => { expect(parent.outerHTML).toBe('
test1
test2
'); expect(parent.firstChild).toBe(div1); expect(parent.lastChild).toBe(div2); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [], + removedBlockElements: [], + }); }); it('handle document with cache 2', () => { @@ -203,6 +227,10 @@ describe('handleBlockGroupChildren', () => { expect(parent.outerHTML).toBe('
test2
test1
'); expect(parent.firstChild).toBe(div2); expect(parent.firstChild?.nextSibling).toBe(div1); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [], + removedBlockElements: [div1], + }); }); it('handle document with cache 3', () => { @@ -239,6 +267,10 @@ describe('handleBlockGroupChildren', () => { expect(parent.outerHTML).toBe('
test2
test1
'); expect(parent.firstChild).toBe(div2); expect(parent.firstChild?.nextSibling).toBe(div1); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [], + removedBlockElements: [div1], + }); }); it('handle document with cache 4', () => { @@ -284,6 +316,10 @@ describe('handleBlockGroupChildren', () => { ); expect(parent.firstChild).toBe(div2); expect(parent.firstChild?.nextSibling).toBe(quote); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [], + removedBlockElements: [quote], + }); }); it('handle document with cache 5', () => { @@ -327,6 +363,10 @@ describe('handleBlockGroupChildren', () => { '

' ); expect(parent.firstChild).toBe(quote); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild!.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('Inline entity is next to a cached paragraph', () => { @@ -379,6 +419,10 @@ describe('handleBlockGroupChildren', () => { expect(parent.innerHTML).toBe( '

' ); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [], + removedBlockElements: [], + }); }); it('child contains entity', () => { @@ -407,5 +451,9 @@ describe('handleBlockGroupChildren', () => { expect(handleBlock).toHaveBeenCalledWith(document, parent, entity, context, null); expect(onBlockEntity).toHaveBeenCalledTimes(1); expect(onBlockEntity).toHaveBeenCalledWith(entity, group); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); }); diff --git a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleDividerTest.ts b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleDividerTest.ts index a9520509234..4d401d7e079 100644 --- a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleDividerTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleDividerTest.ts @@ -23,6 +23,10 @@ describe('handleDivider', () => { expect(parent.innerHTML).toBe('
'); expect(hr.cachedElement).toBe(parent.firstChild as HTMLElement); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('HR with format', () => { @@ -38,6 +42,10 @@ describe('handleDivider', () => { expect(parent.innerHTML).toBe('
'); expect(hr.cachedElement).toBe(parent.firstChild as HTMLElement); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('DIV with format', () => { @@ -53,6 +61,10 @@ describe('handleDivider', () => { expect(parent.innerHTML).toBe('
'); expect(hr.cachedElement).toBe(parent.firstChild as HTMLElement); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('HR with width, size and display', () => { @@ -77,6 +89,10 @@ describe('handleDivider', () => { ].indexOf(parent.innerHTML) >= 0 ).toBeTrue(); expect(hr.cachedElement).toBe(parent.firstChild as HTMLElement); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('HR with border and padding', () => { @@ -97,6 +113,10 @@ describe('handleDivider', () => { '
' ); expect(hr.cachedElement).toBe(parent.firstChild as HTMLElement); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('HR with refNode', () => { @@ -116,6 +136,10 @@ describe('handleDivider', () => { expect(parent.innerHTML).toBe('

'); expect(hr.cachedElement).toBe(parent.firstChild as HTMLElement); expect(result).toBe(br); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('HR with refNode, already in target node', () => { @@ -139,6 +163,10 @@ describe('handleDivider', () => { expect(hr.cachedElement).toBe(hrNode); expect(parent.firstChild).toBe(hrNode); expect(result).toBe(br); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [], + removedBlockElements: [], + }); }); it('With onNodeCreated', () => { @@ -157,5 +185,9 @@ describe('handleDivider', () => { expect(parent.innerHTML).toBe('
'); expect(onNodeCreated.calls.argsFor(0)[0]).toBe(hr); expect(onNodeCreated.calls.argsFor(0)[1]).toBe(parent.querySelector('hr')); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); }); diff --git a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleFormatContainerTest.ts b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleFormatContainerTest.ts index f4c8c748d43..d03a267af13 100644 --- a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleFormatContainerTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleFormatContainerTest.ts @@ -36,6 +36,10 @@ describe('handleFormatContainer', () => { expect(parent.outerHTML).toBe('
'); expect(quote.cachedElement).toBeUndefined(); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [], + removedBlockElements: [], + }); }); it('Quote with child', () => { @@ -61,6 +65,13 @@ describe('handleFormatContainer', () => { context ); expect(quote.cachedElement).toBe(parent.firstChild as HTMLQuoteElement); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [ + parent.firstChild as HTMLElement, + parent.firstChild!.firstChild as HTMLElement, + ], + removedBlockElements: [], + }); }); it('Quote with child and refNode', () => { @@ -90,6 +101,13 @@ describe('handleFormatContainer', () => { ); expect(quote.cachedElement).toBe(parent.firstChild as HTMLQuoteElement); expect(result).toBe(br); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [ + parent.firstChild as HTMLElement, + parent.firstChild!.firstChild as HTMLElement, + ], + removedBlockElements: [], + }); }); it('With onNodeCreated', () => { @@ -114,5 +132,12 @@ describe('handleFormatContainer', () => { expect(onNodeCreated).toHaveBeenCalledTimes(3); expect(onNodeCreated.calls.argsFor(2)[0]).toBe(quote); expect(onNodeCreated.calls.argsFor(2)[1]).toBe(parent.querySelector('blockquote')); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [ + parent.firstChild as HTMLElement, + parent.firstChild!.firstChild as HTMLElement, + ], + removedBlockElements: [], + }); }); }); diff --git a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleGeneralModelTest.ts b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleGeneralModelTest.ts index dd4f1b3b26f..b8b712bebef 100644 --- a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleGeneralModelTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleGeneralModelTest.ts @@ -61,6 +61,10 @@ describe('handleBlockGroup', () => { context ); expect(applyFormat.applyFormat).toHaveBeenCalledTimes(1); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [clonedChild], + removedBlockElements: [], + }); }); it('General block with color', () => { @@ -75,6 +79,10 @@ describe('handleBlockGroup', () => { expect(parent.outerHTML).toBe( '
' ); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('General segment: empty element', () => { @@ -230,6 +238,10 @@ describe('handleBlockGroup', () => { expect(applyFormat.applyFormat).toHaveBeenCalledTimes(1); expect(result).toBe(br); expect(group.element).toBe(clonedChild); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [clonedChild], + removedBlockElements: [], + }); }); it('General block with refNode, already in target node', () => { @@ -249,6 +261,10 @@ describe('handleBlockGroup', () => { expect(handleBlockGroupChildren).toHaveBeenCalledWith(document, node, group, context); expect(result).toBe(br); expect(group.element).toBe(node); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [], + removedBlockElements: [], + }); }); it('With onNodeCreated', () => { diff --git a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleListItemTest.ts b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleListItemTest.ts index 0c2b0ac6e3c..114c55af239 100644 --- a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleListItemTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleListItemTest.ts @@ -77,6 +77,10 @@ describe('handleListItem without format handler', () => { context ); expect(paragraph.isImplicit).toBeFalse(); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [document.createElement('li')], + removedBlockElements: [], + }); }); it('OL parent', () => { @@ -140,6 +144,10 @@ describe('handleListItem without format handler', () => { listItem, context ); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [document.createElement('li')], + removedBlockElements: [], + }); }); it('UL parent', () => { @@ -203,6 +211,10 @@ describe('handleListItem without format handler', () => { listItem, context ); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [document.createElement('li')], + removedBlockElements: [], + }); }); it('UL with refNode', () => { @@ -226,6 +238,10 @@ describe('handleListItem without format handler', () => { context ); expect(result).toBe(br); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [document.createElement('li')], + removedBlockElements: [], + }); }); it('UL with same format on list and segment', () => { @@ -263,6 +279,15 @@ describe('handleListItem without format handler', () => { context ); expect(result).toBe(null); + + const li = parent.firstChild!.firstChild as HTMLElement; + + expect(li.tagName).toBe('LI'); + + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [li, li.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('UL with different format on list and segment', () => { @@ -300,6 +325,15 @@ describe('handleListItem without format handler', () => { context ); expect(result).toBe(null); + + const li = parent.firstChild!.firstChild as HTMLElement; + + expect(li.tagName).toBe('LI'); + + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [li, li.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('With onNodeCreated', () => { @@ -334,5 +368,9 @@ describe('handleListItem without format handler', () => { expect(onNodeCreated.calls.argsFor(0)[1]).toBe(parent.querySelector('ol')); expect(onNodeCreated.calls.argsFor(1)[0]).toBe(listItem); expect(onNodeCreated.calls.argsFor(1)[1]).toBe(parent.querySelector('li')); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [document.createElement('li')], + removedBlockElements: [], + }); }); }); diff --git a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleParagraphTest.ts b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleParagraphTest.ts index 32daefdcfd1..8a0c2ca2d5e 100644 --- a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleParagraphTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleParagraphTest.ts @@ -1,3 +1,4 @@ +import * as reuseCachedElement from '../../../lib/domUtils/reuseCachedElement'; import * as stackFormat from '../../../lib/modelToDom/utils/stackFormat'; import * as unwrap from '../../../lib/domUtils/unwrap'; import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext'; @@ -56,6 +57,10 @@ describe('handleParagraph', () => { '
', 0 ); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('Handle empty implicit paragraph', () => { @@ -69,6 +74,10 @@ describe('handleParagraph', () => { '', 0 ); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [], + removedBlockElements: [], + }); }); it('Handle paragraph with single text segment', () => { @@ -94,6 +103,10 @@ describe('handleParagraph', () => { context, [] ); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('Handle implicit paragraph single text segment', () => { @@ -114,6 +127,10 @@ describe('handleParagraph', () => { ); expect(handleSegment).toHaveBeenCalledWith(document, parent, segment, context, []); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [], + removedBlockElements: [], + }); }); it('Handle multiple segments', () => { @@ -154,6 +171,10 @@ describe('handleParagraph', () => { context, [] ); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('handle p without margin', () => { @@ -178,6 +199,10 @@ describe('handleParagraph', () => { '

test

', 1 ); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('handle p with margin', () => { @@ -202,6 +227,10 @@ describe('handleParagraph', () => { '

test

', 1 ); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('handle headers', () => { @@ -226,6 +255,10 @@ describe('handleParagraph', () => { '

test

', 1 ); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('handle headers with default format override', () => { @@ -250,6 +283,10 @@ describe('handleParagraph', () => { '

test

', 1 ); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('handle headers without default format', () => { @@ -274,6 +311,10 @@ describe('handleParagraph', () => { '

test

', 1 ); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('handle headers that has non-bold text', () => { @@ -305,6 +346,10 @@ describe('handleParagraph', () => { '

test 1test 2

', 2 ); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('handle headers with implicit block and other inline format', () => { @@ -330,6 +375,10 @@ describe('handleParagraph', () => { '

test

', 1 ); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('handle implicit paragraph with segments and format', () => { @@ -353,6 +402,10 @@ describe('handleParagraph', () => { '
test
', 1 ); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('call stackFormat', () => { @@ -382,6 +435,10 @@ describe('handleParagraph', () => { expect(stackFormat.stackFormat).toHaveBeenCalledTimes(2); expect((stackFormat.stackFormat).calls.argsFor(0)[1]).toBe('h1'); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('Handle paragraph with refNode', () => { @@ -404,6 +461,10 @@ describe('handleParagraph', () => { expect(parent.innerHTML).toBe('

'); expect(paragraph.cachedElement).toBe(parent.firstChild as HTMLElement); expect(result).toBe(br); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('Handle paragraph with PRE', () => { @@ -440,6 +501,13 @@ describe('handleParagraph', () => { expect(para1.cachedElement?.outerHTML).toBe('
test1
'); expect(para2.cachedElement).toBe(parent.firstChild?.nextSibling as HTMLElement); expect(para2.cachedElement?.outerHTML).toBe('
test2
'); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [ + parent.firstChild as HTMLElement, + parent.firstChild!.nextSibling as HTMLElement, + ], + removedBlockElements: [], + }); }); it('With onNodeCreated', () => { @@ -465,6 +533,10 @@ describe('handleParagraph', () => { expect(onNodeCreated).toHaveBeenCalledTimes(1); expect(onNodeCreated.calls.argsFor(0)[0]).toBe(paragraph); expect(onNodeCreated.calls.argsFor(0)[1]).toBe(parent.querySelector('div')); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('With onNodeCreated on implicit paragraph', () => { @@ -489,6 +561,10 @@ describe('handleParagraph', () => { expect(parent.innerHTML).toBe(''); expect(onNodeCreated).toHaveBeenCalled(); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [], + removedBlockElements: [], + }); }); it('Paragraph with only selection marker and BR', () => { @@ -531,6 +607,10 @@ describe('handleParagraph', () => { start: { block: div, segment: txt }, end: { block: div, segment: txt }, }); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('Paragraph with inline format', () => { @@ -557,6 +637,10 @@ describe('handleParagraph', () => { expect(parent.innerHTML).toBe( '
test
' ); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('Paragraph with domIndexer', () => { @@ -603,6 +687,10 @@ describe('handleParagraph', () => { expect(onSegmentSpy).toHaveBeenCalledWith(parent.firstChild!.lastChild, paragraph, [ segment2, ]); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('Implicit paragraph with domIndexer', () => { @@ -650,6 +738,10 @@ describe('handleParagraph', () => { expect(onSegmentSpy).toHaveBeenCalledTimes(2); expect(onSegmentSpy).toHaveBeenCalledWith(parent.firstChild, paragraph, [segment1]); expect(onSegmentSpy).toHaveBeenCalledWith(parent.lastChild, paragraph, [segment2]); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [], + removedBlockElements: [], + }); }); }); @@ -686,6 +778,10 @@ describe('Handle paragraph and adjust selections', () => { segment: parent.firstChild!.firstChild, }); expect(parent.firstChild!.firstChild!.nodeType).toBe(Node.TEXT_NODE); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('Selection is at beginning, followed by Text', () => { @@ -724,6 +820,10 @@ describe('Handle paragraph and adjust selections', () => { }); expect(parent.firstChild!.firstChild!.nodeType).toBe(Node.TEXT_NODE); expect(parent.firstChild!.firstChild).toBe(parent.firstChild!.lastChild); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('Selection is in middle of text', () => { @@ -767,6 +867,10 @@ describe('Handle paragraph and adjust selections', () => { }); expect(parent.firstChild!.firstChild!.nodeType).toBe(Node.TEXT_NODE); expect(parent.firstChild!.firstChild).toBe(parent.firstChild!.lastChild); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('Selection is at end of text', () => { @@ -813,6 +917,10 @@ describe('Handle paragraph and adjust selections', () => { expect(parent.firstChild!.firstChild!.nodeType).toBe(Node.TEXT_NODE); expect(parent.firstChild!.lastChild!.nodeType).toBe(Node.TEXT_NODE); expect(parent.firstChild!.firstChild).not.toBe(parent.firstChild!.lastChild); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('Selection is in middle of text, expanded', () => { @@ -858,6 +966,10 @@ describe('Handle paragraph and adjust selections', () => { expect(parent.firstChild!.firstChild!.nodeType).toBe(Node.TEXT_NODE); expect(parent.firstChild!.lastChild!.nodeType).toBe(Node.TEXT_NODE); expect(parent.firstChild!.firstChild).toBe(parent.firstChild!.lastChild); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('Selection is in front of text, expanded', () => { @@ -897,6 +1009,10 @@ describe('Handle paragraph and adjust selections', () => { expect(parent.firstChild!.firstChild!.nodeType).toBe(Node.TEXT_NODE); expect(parent.firstChild!.lastChild!.nodeType).toBe(Node.TEXT_NODE); expect(parent.firstChild!.firstChild).toBe(parent.firstChild!.lastChild); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('Selection is at the end of text, expanded', () => { @@ -936,6 +1052,10 @@ describe('Handle paragraph and adjust selections', () => { expect(parent.firstChild!.firstChild!.nodeType).toBe(Node.TEXT_NODE); expect(parent.firstChild!.lastChild!.nodeType).toBe(Node.TEXT_NODE); expect(parent.firstChild!.firstChild).toBe(parent.firstChild!.lastChild); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); }); it('Selection is in middle of text and BR, expanded', () => { @@ -991,5 +1111,39 @@ describe('Handle paragraph and adjust selections', () => { expect(parent.firstChild!.firstChild!.nodeType).toBe(Node.TEXT_NODE); expect(parent.firstChild!.lastChild!.nodeType).toBe(Node.TEXT_NODE); expect(parent.firstChild!.firstChild).not.toBe(parent.firstChild!.lastChild); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); + }); + + it('Has cache', () => { + const div = document.createElement('div'); + const parent = document.createElement('div'); + const paraModel = createParagraph(); + const reuseCachedElementSpy = spyOn( + reuseCachedElement, + 'reuseCachedElement' + ).and.callThrough(); + const context = createModelToDomContext(); + + div.id = 'div1'; + paraModel.cachedElement = div; + context.allowCacheElement = true; + + handleParagraph(document, parent, paraModel, context, null); + + expect(parent.innerHTML).toBe('
'); + expect(parent.firstChild).toBe(div); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [], + removedBlockElements: [], + }); + expect(reuseCachedElementSpy).toHaveBeenCalledWith( + parent, + div, + null, + context.rewriteFromModel + ); }); }); diff --git a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleTableTest.ts b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleTableTest.ts index 75cc441f0c3..8e60ecf31a1 100644 --- a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleTableTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleTableTest.ts @@ -1,4 +1,5 @@ import * as handleBlock from '../../../lib/modelToDom/handlers/handleBlock'; +import * as reuseCachedElement from '../../../lib/domUtils/reuseCachedElement'; import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext'; import { createTable } from '../../../lib/modelApi/creators/createTable'; import { createTableCell } from '../../../lib/modelApi/creators/createTableCell'; @@ -22,6 +23,14 @@ describe('handleTable', () => { const div = document.createElement('div'); handleTable(document, div, model, context, null); expect(div.innerHTML).toBe(expectedInnerHTML); + + if (expectedInnerHTML) { + expect((div.firstChild as HTMLElement).tagName).toBe('TABLE'); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [div.firstChild as HTMLElement], + removedBlockElements: [], + }); + } } it('Empty table', () => { @@ -616,4 +625,59 @@ describe('handleTable', () => { expect(div.innerHTML).toBe('
'); expect(onTableSpy).toHaveBeenCalledWith(div.firstChild, tableModel); }); + + it('handleTable with cache', () => { + const cachedTable = document.createElement('table'); + const parent = document.createElement('div'); + const tableModel = createTable(1); + const cell = createTableCell(); + const reuseCachedElementSpy = spyOn( + reuseCachedElement, + 'reuseCachedElement' + ).and.callThrough(); + + cachedTable.id = 'table1'; + tableModel.rows[0].cells.push(cell); + tableModel.cachedElement = cachedTable; + context.allowCacheElement = true; + + handleTable(document, parent, tableModel, context, null); + + expect(parent.innerHTML).toBe( + '
' + ); + expect(parent.firstChild).toBe(cachedTable); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [], + removedBlockElements: [], + }); + expect(reuseCachedElementSpy).toHaveBeenCalledWith( + parent, + cachedTable, + null, + context.rewriteFromModel + ); + }); + + it('handleTable without cache', () => { + const parent = document.createElement('div'); + const tableModel = createTable(1); + const cell = createTableCell(); + const reuseCachedElementSpy = spyOn( + reuseCachedElement, + 'reuseCachedElement' + ).and.callThrough(); + + tableModel.rows[0].cells.push(cell); + context.allowCacheElement = true; + + handleTable(document, parent, tableModel, context, null); + + expect(parent.innerHTML).toBe('
'); + expect(context.rewriteFromModel).toEqual({ + addedBlockElements: [parent.firstChild as HTMLElement], + removedBlockElements: [], + }); + expect(reuseCachedElementSpy).not.toHaveBeenCalled(); + }); }); diff --git a/packages/roosterjs-content-model-plugins/test/watermark/WatermarkPluginTest.ts b/packages/roosterjs-content-model-plugins/test/watermark/WatermarkPluginTest.ts index 220c26ca261..94b3775f614 100644 --- a/packages/roosterjs-content-model-plugins/test/watermark/WatermarkPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/watermark/WatermarkPluginTest.ts @@ -36,7 +36,11 @@ describe('WatermarkPlugin', () => { plugin.initialize(editor); - plugin.onPluginEvent({ eventType: 'editorReady' }); + plugin.onPluginEvent({ + eventType: 'editorReady', + addedBlockElements: [], + removedBlockElements: [], + }); expect(formatContentModelSpy).toHaveBeenCalledTimes(1); expect(setEditorStyleSpy).toHaveBeenCalledTimes(1); @@ -65,7 +69,11 @@ describe('WatermarkPlugin', () => { plugin.initialize(editor); - plugin.onPluginEvent({ eventType: 'editorReady' }); + plugin.onPluginEvent({ + eventType: 'editorReady', + addedBlockElements: [], + removedBlockElements: [], + }); expect(formatContentModelSpy).toHaveBeenCalledTimes(1); expect(setEditorStyleSpy).not.toHaveBeenCalled(); @@ -97,7 +105,11 @@ describe('WatermarkPlugin', () => { plugin.initialize(editor); - plugin.onPluginEvent({ eventType: 'editorReady' }); + plugin.onPluginEvent({ + eventType: 'editorReady', + addedBlockElements: [], + removedBlockElements: [], + }); expect(formatContentModelSpy).toHaveBeenCalledTimes(1); expect(setEditorStyleSpy).toHaveBeenCalledTimes(1); @@ -130,7 +142,11 @@ describe('WatermarkPlugin', () => { plugin.initialize(editor); - plugin.onPluginEvent({ eventType: 'editorReady' }); + plugin.onPluginEvent({ + eventType: 'editorReady', + addedBlockElements: [], + removedBlockElements: [], + }); expect(formatContentModelSpy).toHaveBeenCalledTimes(1); expect(setEditorStyleSpy).toHaveBeenCalledTimes(1); @@ -196,7 +212,11 @@ describe('WatermarkPlugin dark mode', () => { plugin.initialize(editor); - plugin.onPluginEvent({ eventType: 'editorReady' }); + plugin.onPluginEvent({ + eventType: 'editorReady', + addedBlockElements: [], + removedBlockElements: [], + }); expect(formatContentModelSpy).toHaveBeenCalledTimes(1); expect(setEditorStyleSpy).toHaveBeenCalledTimes(1); diff --git a/packages/roosterjs-content-model-types/lib/context/ModelToDomContext.ts b/packages/roosterjs-content-model-types/lib/context/ModelToDomContext.ts index 93293d56b97..f9cdf73b010 100644 --- a/packages/roosterjs-content-model-types/lib/context/ModelToDomContext.ts +++ b/packages/roosterjs-content-model-types/lib/context/ModelToDomContext.ts @@ -1,3 +1,4 @@ +import type { RewriteFromModelContext } from './RewriteFromModel'; import type { EditorContext } from './EditorContext'; import type { ModelToDomFormatContext } from './ModelToDomFormatContext'; import type { ModelToDomSelectionContext } from './ModelToDomSelectionContext'; @@ -10,4 +11,5 @@ export interface ModelToDomContext extends EditorContext, ModelToDomSelectionContext, ModelToDomFormatContext, - ModelToDomSettings {} + ModelToDomSettings, + RewriteFromModelContext {} diff --git a/packages/roosterjs-content-model-types/lib/context/RewriteFromModel.ts b/packages/roosterjs-content-model-types/lib/context/RewriteFromModel.ts new file mode 100644 index 00000000000..b14e45d2438 --- /dev/null +++ b/packages/roosterjs-content-model-types/lib/context/RewriteFromModel.ts @@ -0,0 +1,24 @@ +/** + * Represents added and removed block elements during content model to dom conversion + */ +export interface RewriteFromModel { + /** + * Added block elements + */ + addedBlockElements: HTMLElement[]; + + /** + * Removed block elements + */ + removedBlockElements: HTMLElement[]; +} + +/** + * Context object used by contentModelToDom to record added and removed block elements + */ +export interface RewriteFromModelContext { + /** + * DOM modification object + */ + rewriteFromModel: RewriteFromModel; +} diff --git a/packages/roosterjs-content-model-types/lib/editor/EditorCore.ts b/packages/roosterjs-content-model-types/lib/editor/EditorCore.ts index 46d00bd3733..e97c74fc77e 100644 --- a/packages/roosterjs-content-model-types/lib/editor/EditorCore.ts +++ b/packages/roosterjs-content-model-types/lib/editor/EditorCore.ts @@ -53,12 +53,15 @@ export type GetDOMSelection = (core: EditorCore) => DOMSelection | null; * @param model The content model to set * @param option Additional options to customize the behavior of Content Model to DOM conversion * @param onNodeCreated An optional callback that will be called when a DOM node is created + * @param isInitializing True means editor is being initialized then it will save modification nodes onto + * lifecycleState instead of triggering events, false means other cases */ export type SetContentModel = ( core: EditorCore, model: ContentModelDocument, option?: ModelToDomOption, - onNodeCreated?: OnNodeCreated + onNodeCreated?: OnNodeCreated, + isInitializing?: boolean ) => DOMSelection | null; /** @@ -211,6 +214,9 @@ export interface CoreApiMap { * @param core The EditorCore object * @param model The content model to set * @param option Additional options to customize the behavior of Content Model to DOM conversion + * @param onNodeCreated An optional callback that will be called when a DOM node is created + * @param isInitializing True means editor is being initialized then it will save modification nodes onto + * lifecycleState instead of triggering events, false means other cases */ setContentModel: SetContentModel; diff --git a/packages/roosterjs-content-model-types/lib/event/EditorReadyEvent.ts b/packages/roosterjs-content-model-types/lib/event/EditorReadyEvent.ts index 611343fe51d..ea970d9d098 100644 --- a/packages/roosterjs-content-model-types/lib/event/EditorReadyEvent.ts +++ b/packages/roosterjs-content-model-types/lib/event/EditorReadyEvent.ts @@ -1,6 +1,7 @@ +import type { RewriteFromModel } from '../context/RewriteFromModel'; import type { BasePluginEvent } from './BasePluginEvent'; /** * Provides a chance for plugin to change the content before it is pasted into editor. */ -export interface EditorReadyEvent extends BasePluginEvent<'editorReady'> {} +export interface EditorReadyEvent extends RewriteFromModel, BasePluginEvent<'editorReady'> {} diff --git a/packages/roosterjs-content-model-types/lib/event/PluginEvent.ts b/packages/roosterjs-content-model-types/lib/event/PluginEvent.ts index f76b5091740..b6c9834c422 100644 --- a/packages/roosterjs-content-model-types/lib/event/PluginEvent.ts +++ b/packages/roosterjs-content-model-types/lib/event/PluginEvent.ts @@ -5,6 +5,7 @@ import type { BeforePasteEvent } from './BeforePasteEvent'; import type { BeforeSetContentEvent } from './BeforeSetContentEvent'; import type { ContentChangedEvent } from './ContentChangedEvent'; import type { ContextMenuEvent } from './ContextMenuEvent'; +import type { RewriteFromModelEvent } from './RewriteFromModelEvent'; import type { EditImageEvent } from './EditImageEvent'; import type { EditorInputEvent } from './EditorInputEvent'; import type { EditorReadyEvent } from './EditorReadyEvent'; @@ -30,6 +31,7 @@ export type PluginEvent = | CompositionEndEvent | ContentChangedEvent | ContextMenuEvent + | RewriteFromModelEvent | EditImageEvent | EditorReadyEvent | EnterShadowEditEvent diff --git a/packages/roosterjs-content-model-types/lib/event/PluginEventType.ts b/packages/roosterjs-content-model-types/lib/event/PluginEventType.ts index bd3681f5adb..da6e6155c23 100644 --- a/packages/roosterjs-content-model-types/lib/event/PluginEventType.ts +++ b/packages/roosterjs-content-model-types/lib/event/PluginEventType.ts @@ -111,6 +111,11 @@ export type PluginEventType = */ | 'zoomChanged' + /** + * Rewrite result information from Content Model + */ + | 'rewriteFromModel' + /** * EXPERIMENTAL FEATURE * Editor changed the selection. diff --git a/packages/roosterjs-content-model-types/lib/event/RewriteFromModelEvent.ts b/packages/roosterjs-content-model-types/lib/event/RewriteFromModelEvent.ts new file mode 100644 index 00000000000..77ff6c9bba4 --- /dev/null +++ b/packages/roosterjs-content-model-types/lib/event/RewriteFromModelEvent.ts @@ -0,0 +1,9 @@ +import type { RewriteFromModel } from '../context/RewriteFromModel'; +import type { BasePluginEvent } from './BasePluginEvent'; + +/** + * The event triggered when Content Model modifies editor DOM tree, provides added and removed block level elements + */ +export interface RewriteFromModelEvent + extends RewriteFromModel, + BasePluginEvent<'rewriteFromModel'> {} diff --git a/packages/roosterjs-content-model-types/lib/index.ts b/packages/roosterjs-content-model-types/lib/index.ts index 7ecc0e7d9be..215f43d77df 100644 --- a/packages/roosterjs-content-model-types/lib/index.ts +++ b/packages/roosterjs-content-model-types/lib/index.ts @@ -298,6 +298,7 @@ export { ModelToDomListContext, ModelToDomFormatContext, } from './context/ModelToDomFormatContext'; +export { RewriteFromModel, RewriteFromModelContext } from './context/RewriteFromModel'; export { ContentModelHandler, ContentModelSegmentHandler, @@ -455,6 +456,7 @@ export { BeforePasteEvent, MergePastedContentFunc } from './event/BeforePasteEve export { BeforeSetContentEvent } from './event/BeforeSetContentEvent'; export { ContentChangedEvent, ChangedEntity } from './event/ContentChangedEvent'; export { ContextMenuEvent } from './event/ContextMenuEvent'; +export { RewriteFromModelEvent } from './event/RewriteFromModelEvent'; export { EditImageEvent } from './event/EditImageEvent'; export { EditorReadyEvent } from './event/EditorReadyEvent'; export { EntityOperationEvent, Entity } from './event/EntityOperationEvent'; diff --git a/packages/roosterjs-content-model-types/lib/pluginState/LifecyclePluginState.ts b/packages/roosterjs-content-model-types/lib/pluginState/LifecyclePluginState.ts index ce69c682d00..d261effa622 100644 --- a/packages/roosterjs-content-model-types/lib/pluginState/LifecyclePluginState.ts +++ b/packages/roosterjs-content-model-types/lib/pluginState/LifecyclePluginState.ts @@ -1,3 +1,4 @@ +import type { RewriteFromModel } from '../context/RewriteFromModel'; import type { KnownAnnounceStrings } from '../parameter/AnnounceData'; /** @@ -19,6 +20,11 @@ export interface LifecyclePluginState { */ announceContainer?: HTMLElement; + /** + * added and removed block elements when initialize + */ + rewriteFromModel?: RewriteFromModel; + /** * A callback to help get string template to announce, used for accessibility * @param key The key of known announce data diff --git a/packages/roosterjs-editor-adapter/lib/editor/utils/eventConverter.ts b/packages/roosterjs-editor-adapter/lib/editor/utils/eventConverter.ts index 248adf32c9b..447c1b68929 100644 --- a/packages/roosterjs-editor-adapter/lib/editor/utils/eventConverter.ts +++ b/packages/roosterjs-editor-adapter/lib/editor/utils/eventConverter.ts @@ -214,9 +214,12 @@ export function oldEventToNewEvent( }; case PluginEventType.EditorReady: + const refEditorReadyEvent = refEvent?.eventType == 'editorReady' ? refEvent : undefined; return { eventType: 'editorReady', eventDataCache: input.eventDataCache, + addedBlockElements: refEditorReadyEvent?.addedBlockElements ?? [], + removedBlockElements: refEditorReadyEvent?.removedBlockElements ?? [], }; case PluginEventType.EnteredShadowEdit: diff --git a/packages/roosterjs-editor-adapter/test/corePlugins/BridgePluginTest.ts b/packages/roosterjs-editor-adapter/test/corePlugins/BridgePluginTest.ts index 482701720eb..7011ce96419 100644 --- a/packages/roosterjs-editor-adapter/test/corePlugins/BridgePluginTest.ts +++ b/packages/roosterjs-editor-adapter/test/corePlugins/BridgePluginTest.ts @@ -99,7 +99,11 @@ describe('BridgePlugin', () => { expect(onPluginEventSpy1).not.toHaveBeenCalled(); expect(onPluginEventSpy2).not.toHaveBeenCalled(); - plugin.onPluginEvent({ eventType: 'editorReady' }); + plugin.onPluginEvent({ + eventType: 'editorReady', + addedBlockElements: [], + removedBlockElements: [], + }); expect(onPluginEventSpy1).toHaveBeenCalledTimes(1); expect(onPluginEventSpy2).toHaveBeenCalledTimes(1); @@ -186,7 +190,11 @@ describe('BridgePlugin', () => { expect(onPluginEventSpy1).not.toHaveBeenCalled(); expect(onPluginEventSpy2).not.toHaveBeenCalled(); - plugin.onPluginEvent({ eventType: 'editorReady' }); + plugin.onPluginEvent({ + eventType: 'editorReady', + addedBlockElements: [], + removedBlockElements: [], + }); expect(onPluginEventSpy1).toHaveBeenCalledTimes(1); expect(onPluginEventSpy2).toHaveBeenCalledTimes(1); @@ -473,7 +481,11 @@ describe('BridgePlugin', () => { expect(disposeSpy).not.toHaveBeenCalled(); expect(initializeSpy).toHaveBeenCalledWith(mockedEditor); - plugin.onPluginEvent({ eventType: 'editorReady' }); + plugin.onPluginEvent({ + eventType: 'editorReady', + addedBlockElements: [], + removedBlockElements: [], + }); expect(onPluginEventSpy1).toHaveBeenCalledTimes(1); expect(onPluginEventSpy2).toHaveBeenCalledTimes(1); diff --git a/packages/roosterjs-editor-adapter/test/editor/utils/eventConverterTest.ts b/packages/roosterjs-editor-adapter/test/editor/utils/eventConverterTest.ts index 0860987e5bd..18bc59c88a5 100644 --- a/packages/roosterjs-editor-adapter/test/editor/utils/eventConverterTest.ts +++ b/packages/roosterjs-editor-adapter/test/editor/utils/eventConverterTest.ts @@ -361,6 +361,8 @@ describe('oldEventToNewEvent', () => { { eventType: 'editorReady', eventDataCache: mockedDataCache, + addedBlockElements: [], + removedBlockElements: [], } ); }); @@ -930,6 +932,8 @@ describe('newEventToOldEvent', () => { { eventType: 'editorReady', eventDataCache: mockedDataCache, + addedBlockElements: [], + removedBlockElements: [], }, undefined, { diff --git a/packages/roosterjs/test/createEditorTest.ts b/packages/roosterjs/test/createEditorTest.ts index 4d41c1a362d..d5c5eb2860d 100644 --- a/packages/roosterjs/test/createEditorTest.ts +++ b/packages/roosterjs/test/createEditorTest.ts @@ -32,6 +32,8 @@ describe('createEditor', () => { expect(dispose).not.toHaveBeenCalled(); expect(onPluginEvent).toHaveBeenCalledWith({ eventType: 'editorReady', + addedBlockElements: [div.firstChild], + removedBlockElements: [], }); editor.dispose(); From 261055db9dbea74c644303141ffa505dd663ac05 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Fri, 22 Nov 2024 10:46:00 -0800 Subject: [PATCH 25/30] #2861 Allow specify pending format for paragraph (#2885) * #2861 * Fix build and test * add test --- .../formatContentModel/formatContentModel.ts | 15 +- .../lib/corePlugin/format/FormatPlugin.ts | 14 +- .../corePlugin/format/applyPendingFormat.ts | 51 ++-- .../formatContentModelTest.ts | 102 +++++++- .../corePlugin/format/FormatPluginTest.ts | 17 +- .../format/applyPendingFormatTest.ts | 219 ++++++++++++++++++ .../parameter/FormatContentModelContext.ts | 10 + .../lib/pluginState/FormatPluginState.ts | 8 +- 8 files changed, 407 insertions(+), 29 deletions(-) diff --git a/packages/roosterjs-content-model-core/lib/coreApi/formatContentModel/formatContentModel.ts b/packages/roosterjs-content-model-core/lib/coreApi/formatContentModel/formatContentModel.ts index 35be053782e..45a53be6dd2 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/formatContentModel/formatContentModel.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/formatContentModel/formatContentModel.ts @@ -136,10 +136,19 @@ function handlePendingFormat( context.newPendingFormat == 'preserve' ? core.format.pendingFormat?.format : context.newPendingFormat; - - if (pendingFormat && selection?.type == 'range' && selection.range.collapsed) { + const pendingParagraphFormat = + context.newPendingParagraphFormat == 'preserve' + ? core.format.pendingFormat?.paragraphFormat + : context.newPendingParagraphFormat; + + if ( + (pendingFormat || pendingParagraphFormat) && + selection?.type == 'range' && + selection.range.collapsed + ) { core.format.pendingFormat = { - format: { ...pendingFormat }, + format: pendingFormat ? { ...pendingFormat } : undefined, + paragraphFormat: pendingParagraphFormat ? { ...pendingParagraphFormat } : undefined, insertPoint: { node: selection.range.startContainer, offset: selection.range.startOffset, diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/format/FormatPlugin.ts b/packages/roosterjs-content-model-core/lib/corePlugin/format/FormatPlugin.ts index 872a4dd1d12..eb068b1a113 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/format/FormatPlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/format/FormatPlugin.ts @@ -118,13 +118,16 @@ class FormatPlugin implements PluginWithState { break; case 'keyDown': - const isAndroidIME = this.editor.getEnvironment().isAndroid && event.rawEvent.key == UnidentifiedKey; + const isAndroidIME = + this.editor.getEnvironment().isAndroid && event.rawEvent.key == UnidentifiedKey; if (isCursorMovingKey(event.rawEvent)) { this.clearPendingFormat(); this.lastCheckedNode = null; } else if ( this.defaultFormatKeys.size > 0 && - (isAndroidIME || isCharacterValue(event.rawEvent) || event.rawEvent.key == ProcessKey) && + (isAndroidIME || + isCharacterValue(event.rawEvent) || + event.rawEvent.key == ProcessKey) && this.shouldApplyDefaultFormat(this.editor) ) { applyDefaultFormat(this.editor, this.state.defaultFormat); @@ -145,7 +148,12 @@ class FormatPlugin implements PluginWithState { private checkAndApplyPendingFormat(data: string | null) { if (this.editor && data && this.state.pendingFormat) { - applyPendingFormat(this.editor, data, this.state.pendingFormat.format); + applyPendingFormat( + this.editor, + data, + this.state.pendingFormat.format, + this.state.pendingFormat.paragraphFormat + ); this.clearPendingFormat(); } } diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/format/applyPendingFormat.ts b/packages/roosterjs-content-model-core/lib/corePlugin/format/applyPendingFormat.ts index d5086b2b99c..ca8ec9309fc 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/format/applyPendingFormat.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/format/applyPendingFormat.ts @@ -1,11 +1,16 @@ import { createText, iterateSelections, + mutateBlock, mutateSegment, normalizeContentModel, setParagraphNotImplicit, } from 'roosterjs-content-model-dom'; -import type { ContentModelSegmentFormat, IEditor } from 'roosterjs-content-model-types'; +import type { + ContentModelBlockFormat, + ContentModelSegmentFormat, + IEditor, +} from 'roosterjs-content-model-types'; const ANSI_SPACE = '\u0020'; const NON_BREAK_SPACE = '\u00A0'; @@ -19,7 +24,8 @@ const NON_BREAK_SPACE = '\u00A0'; export function applyPendingFormat( editor: IEditor, data: string, - format: ContentModelSegmentFormat + segmentFormat?: ContentModelSegmentFormat, + paragraphFormat?: ContentModelBlockFormat ) { let isChanged = false; @@ -41,24 +47,35 @@ export function applyPendingFormat( // For space, there can be (space) or   ( ), we treat them as the same if (subStr == data || (data == ANSI_SPACE && subStr == NON_BREAK_SPACE)) { - mutateSegment(block, previousSegment, previousSegment => { - previousSegment.text = text.substring(0, text.length - data.length); - }); + if (segmentFormat) { + mutateSegment(block, previousSegment, previousSegment => { + previousSegment.text = text.substring( + 0, + text.length - data.length + ); + }); - mutateSegment(block, marker, (marker, block) => { - marker.format = { ...format }; + mutateSegment(block, marker, (marker, block) => { + marker.format = { ...segmentFormat }; - const newText = createText( - data == ANSI_SPACE ? NON_BREAK_SPACE : data, - { - ...previousSegment.format, - ...format, - } - ); + const newText = createText( + data == ANSI_SPACE ? NON_BREAK_SPACE : data, + { + ...previousSegment.format, + ...segmentFormat, + } + ); - block.segments.splice(index, 0, newText); - setParagraphNotImplicit(block); - }); + block.segments.splice(index, 0, newText); + setParagraphNotImplicit(block); + }); + } + + if (paragraphFormat) { + const mutableParagraph = mutateBlock(block); + + Object.assign(mutableParagraph.format, paragraphFormat); + } isChanged = true; } diff --git a/packages/roosterjs-content-model-core/test/coreApi/formatContentModel/formatContentModelTest.ts b/packages/roosterjs-content-model-core/test/coreApi/formatContentModel/formatContentModelTest.ts index 5fdae3d372e..8109dea5a2b 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/formatContentModel/formatContentModelTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/formatContentModel/formatContentModelTest.ts @@ -658,6 +658,7 @@ describe('formatContentModel', () => { it('Has pending format, callback returns true, preserve pending format', () => { core.format.pendingFormat = { format: mockedFormat1, + paragraphFormat: mockedFormat2, insertPoint: { node: mockedStartContainer1, offset: mockedStartOffset1, @@ -671,6 +672,7 @@ describe('formatContentModel', () => { expect(core.format.pendingFormat).toEqual({ format: mockedFormat1, + paragraphFormat: undefined, insertPoint: { node: mockedStartContainer2, offset: mockedStartOffset2, @@ -678,9 +680,35 @@ describe('formatContentModel', () => { } as any); }); - it('Has pending format, callback returns false, preserve pending format', () => { + it('Has pending format, callback returns true, preserve paragraph pending format', () => { core.format.pendingFormat = { format: mockedFormat1, + paragraphFormat: mockedFormat2, + insertPoint: { + node: mockedStartContainer1, + offset: mockedStartOffset1, + }, + }; + + formatContentModel(core, (model, context) => { + context.newPendingParagraphFormat = 'preserve'; + return true; + }); + + expect(core.format.pendingFormat).toEqual({ + format: undefined, + paragraphFormat: mockedFormat2, + insertPoint: { + node: mockedStartContainer2, + offset: mockedStartOffset2, + }, + } as any); + }); + + it('Has pending format, callback returns true, preserve both pending format', () => { + core.format.pendingFormat = { + format: mockedFormat1, + paragraphFormat: mockedFormat2, insertPoint: { node: mockedStartContainer1, offset: mockedStartOffset1, @@ -689,11 +717,39 @@ describe('formatContentModel', () => { formatContentModel(core, (model, context) => { context.newPendingFormat = 'preserve'; + context.newPendingParagraphFormat = 'preserve'; + return true; + }); + + expect(core.format.pendingFormat).toEqual({ + format: mockedFormat1, + paragraphFormat: mockedFormat2, + insertPoint: { + node: mockedStartContainer2, + offset: mockedStartOffset2, + }, + } as any); + }); + + it('Has pending format, callback returns false, preserve both pending format', () => { + core.format.pendingFormat = { + format: mockedFormat1, + paragraphFormat: mockedFormat2, + insertPoint: { + node: mockedStartContainer1, + offset: mockedStartOffset1, + }, + }; + + formatContentModel(core, (model, context) => { + context.newPendingFormat = 'preserve'; + context.newPendingParagraphFormat = 'preserve'; return false; }); expect(core.format.pendingFormat).toEqual({ format: mockedFormat1, + paragraphFormat: mockedFormat2, insertPoint: { node: mockedStartContainer2, offset: mockedStartOffset2, @@ -709,6 +765,23 @@ describe('formatContentModel', () => { expect(core.format.pendingFormat).toEqual({ format: mockedFormat2, + paragraphFormat: undefined, + insertPoint: { + node: mockedStartContainer2, + offset: mockedStartOffset2, + }, + }); + }); + + it('No pending format, callback returns true, new paragraph format', () => { + formatContentModel(core, (model, context) => { + context.newPendingParagraphFormat = mockedFormat2; + return true; + }); + + expect(core.format.pendingFormat).toEqual({ + format: undefined, + paragraphFormat: mockedFormat2, insertPoint: { node: mockedStartContainer2, offset: mockedStartOffset2, @@ -724,6 +797,7 @@ describe('formatContentModel', () => { expect(core.format.pendingFormat).toEqual({ format: mockedFormat2, + paragraphFormat: undefined, insertPoint: { node: mockedStartContainer2, offset: mockedStartOffset2, @@ -747,6 +821,7 @@ describe('formatContentModel', () => { expect(core.format.pendingFormat).toEqual({ format: mockedFormat2, + paragraphFormat: undefined, insertPoint: { node: mockedStartContainer2, offset: mockedStartOffset2, @@ -770,6 +845,31 @@ describe('formatContentModel', () => { expect(core.format.pendingFormat).toEqual({ format: mockedFormat2, + paragraphFormat: undefined, + insertPoint: { + node: mockedStartContainer2, + offset: mockedStartOffset2, + }, + }); + }); + + it('Has pending format, callback returns false, new paragraph format', () => { + core.format.pendingFormat = { + paragraphFormat: mockedFormat1, + insertPoint: { + node: mockedStartContainer1, + offset: mockedStartOffset1, + }, + }; + + formatContentModel(core, (model, context) => { + context.newPendingParagraphFormat = mockedFormat2; + return false; + }); + + expect(core.format.pendingFormat).toEqual({ + format: undefined, + paragraphFormat: mockedFormat2, insertPoint: { node: mockedStartContainer2, offset: mockedStartOffset2, diff --git a/packages/roosterjs-content-model-core/test/corePlugin/format/FormatPluginTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/format/FormatPluginTest.ts index b947a73cffa..e278325c4cf 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/format/FormatPluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/format/FormatPluginTest.ts @@ -8,6 +8,9 @@ describe('FormatPlugin', () => { const mockedFormat = { fontSize: '10px', }; + const mockedFormat2 = { + lineSpace: 2, + }; let applyPendingFormatSpy: jasmine.Spy; beforeEach(() => { @@ -49,6 +52,7 @@ describe('FormatPlugin', () => { (state.pendingFormat = { format: mockedFormat, + paragraphFormat: mockedFormat2, } as any), plugin.initialize(editor); @@ -60,7 +64,12 @@ describe('FormatPlugin', () => { plugin.dispose(); expect(applyPendingFormatSpy).toHaveBeenCalledTimes(1); - expect(applyPendingFormatSpy).toHaveBeenCalledWith(editor, 'a', mockedFormat); + expect(applyPendingFormatSpy).toHaveBeenCalledWith( + editor, + 'a', + mockedFormat, + mockedFormat2 + ); expect(state.pendingFormat).toBeNull(); }); @@ -92,7 +101,7 @@ describe('FormatPlugin', () => { }); plugin.dispose(); - expect(applyPendingFormatSpy).toHaveBeenCalledWith(editor, 'test', mockedFormat); + expect(applyPendingFormatSpy).toHaveBeenCalledWith(editor, 'test', mockedFormat, undefined); expect(state.pendingFormat).toBeNull(); }); @@ -111,7 +120,7 @@ describe('FormatPlugin', () => { const state = plugin.getState(); state.pendingFormat = { - format: mockedFormat, + paragraphFormat: mockedFormat2, } as any; plugin.onPluginEvent({ @@ -122,7 +131,7 @@ describe('FormatPlugin', () => { expect(applyPendingFormatSpy).not.toHaveBeenCalled(); expect(state.pendingFormat).toEqual({ - format: mockedFormat, + paragraphFormat: mockedFormat2, } as any); }); diff --git a/packages/roosterjs-content-model-core/test/corePlugin/format/applyPendingFormatTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/format/applyPendingFormatTest.ts index 9b2ed118a8c..be044ad6e32 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/format/applyPendingFormatTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/format/applyPendingFormatTest.ts @@ -94,6 +94,225 @@ describe('applyPendingFormat', () => { }); }); + it('Has pending paragraph format', () => { + const text: ContentModelText = { + segmentType: 'Text', + text: 'abc', + format: {}, + }; + const marker: ContentModelSelectionMarker = { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [text, marker], + format: { textAlign: 'start', textIndent: '10pt' }, + }; + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [paragraph], + }; + + const formatContentModelSpy = jasmine + .createSpy('formatContentModel') + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { + expect(options.apiName).toEqual('applyPendingFormat'); + callback(model, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); + }); + + const editor = ({ + formatContentModel: formatContentModelSpy, + } as any) as IEditor; + + spyOn(iterateSelections, 'iterateSelections').and.callFake((_, callback) => { + callback([model], undefined, paragraph, [marker]); + return false; + }); + + applyPendingFormat(editor, 'c', undefined, { + textIndent: '20pt', + lineHeight: '2', + }); + + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: { textAlign: 'start', textIndent: '20pt', lineHeight: '2' }, + segments: [ + { + segmentType: 'Text', + text: 'abc', + format: {}, + }, + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + }); + }); + + it('Has pending both format', () => { + const text: ContentModelText = { + segmentType: 'Text', + text: 'abc', + format: {}, + }; + const marker: ContentModelSelectionMarker = { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [text, marker], + format: { textAlign: 'start', textIndent: '10pt' }, + }; + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [paragraph], + }; + + const formatContentModelSpy = jasmine + .createSpy('formatContentModel') + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { + expect(options.apiName).toEqual('applyPendingFormat'); + callback(model, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); + }); + + const editor = ({ + formatContentModel: formatContentModelSpy, + } as any) as IEditor; + + spyOn(iterateSelections, 'iterateSelections').and.callFake((_, callback) => { + callback([model], undefined, paragraph, [marker]); + return false; + }); + + applyPendingFormat( + editor, + 'c', + { fontSize: '10px' }, + { + textIndent: '20pt', + lineHeight: '2', + } + ); + + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: { textAlign: 'start', textIndent: '20pt', lineHeight: '2' }, + segments: [ + { + segmentType: 'Text', + text: 'ab', + format: {}, + }, + { + segmentType: 'Text', + text: 'c', + format: { + fontSize: '10px', + }, + }, + { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + ], + }, + ], + }); + }); + + it('Has no pending format', () => { + const text: ContentModelText = { + segmentType: 'Text', + text: 'abc', + format: {}, + }; + const marker: ContentModelSelectionMarker = { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [text, marker], + format: {}, + }; + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [paragraph], + }; + + const formatContentModelSpy = jasmine + .createSpy('formatContentModel') + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { + expect(options.apiName).toEqual('applyPendingFormat'); + callback(model, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); + }); + + const editor = ({ + formatContentModel: formatContentModelSpy, + } as any) as IEditor; + + spyOn(iterateSelections, 'iterateSelections').and.callFake((_, callback) => { + callback([model], undefined, paragraph, [marker]); + return false; + }); + + applyPendingFormat(editor, 'c'); + + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'abc', + format: {}, + }, + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + }); + }); + it('Has pending format but wrong text', () => { const text: ContentModelText = { segmentType: 'Text', diff --git a/packages/roosterjs-content-model-types/lib/parameter/FormatContentModelContext.ts b/packages/roosterjs-content-model-types/lib/parameter/FormatContentModelContext.ts index 0cc6e7f63c2..b7115dc66da 100644 --- a/packages/roosterjs-content-model-types/lib/parameter/FormatContentModelContext.ts +++ b/packages/roosterjs-content-model-types/lib/parameter/FormatContentModelContext.ts @@ -3,6 +3,7 @@ import type { ContentModelEntity } from '../contentModel/entity/ContentModelEnti import type { ContentModelImage } from '../contentModel/segment/ContentModelImage'; import type { ContentModelSegmentFormat } from '../contentModel/format/ContentModelSegmentFormat'; import type { EntityRemovalOperation } from '../enum/EntityOperation'; +import type { ContentModelBlockFormat } from '../contentModel/format/ContentModelBlockFormat'; /** * State for an entity. This is used for storing entity undo snapshot @@ -87,6 +88,15 @@ export interface FormatContentModelContext { */ newPendingFormat?: ContentModelSegmentFormat | 'preserve'; + /** + * @optional + * Specify new pending format for paragraph + * To keep current format event selection position is changed, set this value to "preserved", editor will update pending format position to the new position + * To set a new pending format, set this property to the format object + * Otherwise, leave it there and editor will automatically decide if the original pending format is still available + */ + newPendingParagraphFormat?: ContentModelBlockFormat | 'preserve'; + /** * @optional Entity states related to the format API that will be added together with undo snapshot. * When entity states are set, each entity state will cause an EntityOperation event with operation = EntityOperation.UpdateEntityState diff --git a/packages/roosterjs-content-model-types/lib/pluginState/FormatPluginState.ts b/packages/roosterjs-content-model-types/lib/pluginState/FormatPluginState.ts index 5286fc09d71..3dda933222d 100644 --- a/packages/roosterjs-content-model-types/lib/pluginState/FormatPluginState.ts +++ b/packages/roosterjs-content-model-types/lib/pluginState/FormatPluginState.ts @@ -1,5 +1,6 @@ import type { DOMInsertPoint } from '../selection/DOMSelection'; import type { ContentModelSegmentFormat } from '../contentModel/format/ContentModelSegmentFormat'; +import type { ContentModelBlockFormat } from '../contentModel/format/ContentModelBlockFormat'; /** * Pending format holder interface @@ -8,7 +9,12 @@ export interface PendingFormat { /** * The pending format */ - format: ContentModelSegmentFormat; + format?: ContentModelSegmentFormat; + + /** + * Customized format for paragraph + */ + paragraphFormat?: ContentModelBlockFormat; /** * Insert point of pending format From 2a249dd44c72925fbac072fe03d4d13f63c0cc7a Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Fri, 22 Nov 2024 10:50:59 -0800 Subject: [PATCH 26/30] #2855 Add a parameter for formatContentModel to allow auto detection of new/removed entities (#2887) --- .../formatContentModel/formatContentModel.ts | 37 ++--- .../formatContentModelTest.ts | 134 ++++++++++++++++++ .../parameter/FormatContentModelContext.ts | 8 +- 3 files changed, 162 insertions(+), 17 deletions(-) diff --git a/packages/roosterjs-content-model-core/lib/coreApi/formatContentModel/formatContentModel.ts b/packages/roosterjs-content-model-core/lib/coreApi/formatContentModel/formatContentModel.ts index 45a53be6dd2..480a7ad3a28 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/formatContentModel/formatContentModel.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/formatContentModel/formatContentModel.ts @@ -157,20 +157,25 @@ function handlePendingFormat( } } -function getChangedEntities(context: FormatContentModelContext, rawEvent?: Event): ChangedEntity[] { - return context.newEntities - .map( - (entity): ChangedEntity => ({ - entity, - operation: 'newEntity', - rawEvent, - }) - ) - .concat( - context.deletedEntities.map(entry => ({ - entity: entry.entity, - operation: entry.operation, - rawEvent, - })) - ); +function getChangedEntities( + context: FormatContentModelContext, + rawEvent?: Event +): ChangedEntity[] | undefined { + return context.autoDetectChangedEntities + ? undefined + : context.newEntities + .map( + (entity): ChangedEntity => ({ + entity, + operation: 'newEntity', + rawEvent, + }) + ) + .concat( + context.deletedEntities.map(entry => ({ + entity: entry.entity, + operation: entry.operation, + rawEvent, + })) + ); } diff --git a/packages/roosterjs-content-model-core/test/coreApi/formatContentModel/formatContentModelTest.ts b/packages/roosterjs-content-model-core/test/coreApi/formatContentModel/formatContentModelTest.ts index 8109dea5a2b..6583eff98a6 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/formatContentModel/formatContentModelTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/formatContentModel/formatContentModelTest.ts @@ -1070,4 +1070,138 @@ describe('formatContentModel', () => { expect(announce).toHaveBeenCalledWith(core, mockedData); }); }); + + describe('Changed entities', () => { + it('Callback return true, changed entities are not specified', () => { + const callback = jasmine.createSpy('callback').and.returnValue(true); + + formatContentModel(core, callback, { apiName }); + + expect(callback).toHaveBeenCalledWith(mockedModel, { + newEntities: [], + deletedEntities: [], + rawEvent: undefined, + newImages: [], + }); + expect(createContentModel).toHaveBeenCalledTimes(1); + expect(addUndoSnapshot).toHaveBeenCalled(); + expect(setContentModel).toHaveBeenCalled(); + expect(triggerEvent).toHaveBeenCalledWith( + core, + { + eventType: 'contentChanged', + contentModel: mockedModel, + selection: mockedSelection, + source: 'Format', + data: undefined, + formatApiName: 'mockedApi', + changedEntities: [], + }, + true + ); + }); + + it('Callback return true, changed entities are specified', () => { + const wrapper1 = document.createElement('span'); + const wrapper2 = document.createElement('div'); + const callback = jasmine + .createSpy('callback') + .and.callFake((model: any, context: FormatContentModelContext) => { + context.newEntities.push({ + segmentType: 'Entity', + blockType: 'Entity', + entityFormat: { + entityType: 'test', + }, + format: {}, + wrapper: wrapper1, + }); + context.deletedEntities.push({ + entity: { + segmentType: 'Entity', + blockType: 'Entity', + entityFormat: { + entityType: 'test', + }, + format: {}, + wrapper: wrapper2, + }, + operation: 'overwrite', + }); + return true; + }); + + formatContentModel(core, callback, { apiName }); + + expect(callback).toHaveBeenCalled(); + expect(createContentModel).toHaveBeenCalledTimes(1); + expect(addUndoSnapshot).toHaveBeenCalled(); + expect(setContentModel).toHaveBeenCalled(); + expect(triggerEvent).toHaveBeenCalledWith( + core, + { + eventType: 'contentChanged', + contentModel: mockedModel, + selection: mockedSelection, + source: 'Format', + data: undefined, + formatApiName: 'mockedApi', + changedEntities: [ + { + entity: { + segmentType: 'Entity', + blockType: 'Entity', + entityFormat: { entityType: 'test' }, + format: {}, + wrapper: wrapper1, + }, + operation: 'newEntity', + rawEvent: undefined, + }, + { + entity: { + segmentType: 'Entity', + blockType: 'Entity', + entityFormat: { entityType: 'test' }, + format: {}, + wrapper: wrapper2, + }, + operation: 'overwrite', + rawEvent: undefined, + }, + ], + }, + true + ); + }); + + it('Callback return true, auto detect entity change', () => { + const callback = jasmine + .createSpy('callback') + .and.callFake((model: any, context: FormatContentModelContext) => { + context.autoDetectChangedEntities = true; + return true; + }); + + formatContentModel(core, callback, { apiName }); + + expect(callback).toHaveBeenCalled(); + expect(createContentModel).toHaveBeenCalledTimes(1); + expect(addUndoSnapshot).toHaveBeenCalled(); + expect(setContentModel).toHaveBeenCalled(); + expect(triggerEvent).toHaveBeenCalledWith( + core, + { + eventType: 'contentChanged', + contentModel: mockedModel, + selection: mockedSelection, + source: 'Format', + data: undefined, + formatApiName: 'mockedApi', + changedEntities: undefined, + }, + true + ); + }); + }); }); diff --git a/packages/roosterjs-content-model-types/lib/parameter/FormatContentModelContext.ts b/packages/roosterjs-content-model-types/lib/parameter/FormatContentModelContext.ts index b7115dc66da..7ee2b7f1894 100644 --- a/packages/roosterjs-content-model-types/lib/parameter/FormatContentModelContext.ts +++ b/packages/roosterjs-content-model-types/lib/parameter/FormatContentModelContext.ts @@ -47,12 +47,13 @@ export interface DeletedEntity { */ export interface FormatContentModelContext { /** - * New entities added during the format process + * New entities added during the format process. This value is only respected when autoDetectChangedEntities is not set to true */ readonly newEntities: ContentModelEntity[]; /** * Entities got deleted during formatting. Need to be set by the formatter function + * This value is only respected when autoDetectChangedEntities is not set to true */ readonly deletedEntities: DeletedEntity[]; @@ -113,4 +114,9 @@ export interface FormatContentModelContext { * @optional Set this value to tell AnnouncePlugin to announce the given information */ announceData?: AnnounceData | null; + + /** + * @optional When set to true, EntityPlugin will detect any entity changes during this process, newEntities and deletedEntities will be ignored + */ + autoDetectChangedEntities?: boolean; } From 24f5af0b6f4f71a747c448dc8d6ee0235bc4d8a2 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Fri, 22 Nov 2024 11:05:44 -0800 Subject: [PATCH 27/30] Fix #2880 optimize() causes segment cache to be wrong when merging (#2889) --- .../formatTextSegmentBeforeSelectionMarker.ts | 5 ++ ...matTextSegmentBeforeSelectionMarkerTest.ts | 7 +- .../lib/corePlugin/cache/domIndexerImpl.ts | 20 +++++- .../corePlugin/cache/domIndexerImplTest.ts | 72 +++++++++++++++++++ .../handlers/handleSegmentDecorator.ts | 5 +- .../test/modelToDom/handlers/handleBrTest.ts | 3 +- .../handlers/handleSegmentDecoratorTest.ts | 49 ++++--------- .../utils/handleSegmentCommonTest.ts | 3 +- 8 files changed, 115 insertions(+), 49 deletions(-) diff --git a/packages/roosterjs-content-model-api/lib/publicApi/utils/formatTextSegmentBeforeSelectionMarker.ts b/packages/roosterjs-content-model-api/lib/publicApi/utils/formatTextSegmentBeforeSelectionMarker.ts index cca5bceb881..b0ca265b4ed 100644 --- a/packages/roosterjs-content-model-api/lib/publicApi/utils/formatTextSegmentBeforeSelectionMarker.ts +++ b/packages/roosterjs-content-model-api/lib/publicApi/utils/formatTextSegmentBeforeSelectionMarker.ts @@ -48,6 +48,11 @@ export function formatTextSegmentBeforeSelectionMarker( if (previousSegment && previousSegment.segmentType === 'Text') { result = true; + + // Preserve pending format if any when format text segment, so if there is pending format (e.g. from paste) + // and some auto action happens after paste, the pending format will still take effect + context.newPendingFormat = 'preserve'; + rewrite = callback( model, previousSegment, diff --git a/packages/roosterjs-content-model-api/test/publicApi/utils/formatTextSegmentBeforeSelectionMarkerTest.ts b/packages/roosterjs-content-model-api/test/publicApi/utils/formatTextSegmentBeforeSelectionMarkerTest.ts index 5c58f674525..7dd4b2f8c2e 100644 --- a/packages/roosterjs-content-model-api/test/publicApi/utils/formatTextSegmentBeforeSelectionMarkerTest.ts +++ b/packages/roosterjs-content-model-api/test/publicApi/utils/formatTextSegmentBeforeSelectionMarkerTest.ts @@ -23,13 +23,16 @@ describe('formatTextSegmentBeforeSelectionMarker', () => { const formatWithContentModelSpy = jasmine .createSpy('formatWithContentModel') .and.callFake((callback, options) => { - const result = callback(input, { + const context: FormatContentModelContext = { newEntities: [], deletedEntities: [], newImages: [], canUndoByBackspace: true, - }); + }; + const result = callback(input, context); + expect(result).toBe(expectedResult); + expect(context.newPendingFormat).toBe(expectedResult ? 'preserve' : undefined); }); formatTextSegmentBeforeSelectionMarker( diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/cache/domIndexerImpl.ts b/packages/roosterjs-content-model-core/lib/corePlugin/cache/domIndexerImpl.ts index e51c7f888c9..2d2b80723ab 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/cache/domIndexerImpl.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/cache/domIndexerImpl.ts @@ -592,14 +592,14 @@ export class DomIndexerImpl implements DomIndexer { const { previousSibling, nextSibling } = node; if ( - (segmentItem = getIndexedSegmentItem(previousSibling)) && + (segmentItem = getIndexedSegmentItem(getLastLeaf(previousSibling))) && (existingSegment = segmentItem.segments[segmentItem.segments.length - 1]) && (index = segmentItem.paragraph.segments.indexOf(existingSegment)) >= 0 ) { // When we can find indexed segment before current one, use it as the insert index this.indexNode(segmentItem.paragraph, index + 1, node, existingSegment.format); } else if ( - (segmentItem = getIndexedSegmentItem(nextSibling)) && + (segmentItem = getIndexedSegmentItem(getFirstLeaf(nextSibling))) && (existingSegment = segmentItem.segments[0]) && (index = segmentItem.paragraph.segments.indexOf(existingSegment)) >= 0 ) { @@ -691,3 +691,19 @@ export class DomIndexerImpl implements DomIndexer { this.onSegment(textNode, paragraph, [text]); } } + +function getLastLeaf(node: Node | null): Node | null { + while (node?.lastChild) { + node = node.lastChild; + } + + return node; +} + +function getFirstLeaf(node: Node | null): Node | null { + while (node?.firstChild) { + node = node.firstChild; + } + + return node; +} diff --git a/packages/roosterjs-content-model-core/test/corePlugin/cache/domIndexerImplTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/cache/domIndexerImplTest.ts index 478b8db5fde..4937f5205c8 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/cache/domIndexerImplTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/cache/domIndexerImplTest.ts @@ -17,6 +17,7 @@ import { DOMSelection, } from 'roosterjs-content-model-types'; import { + addLink, createBr, createContentModelDocument, createEntity, @@ -1301,6 +1302,77 @@ describe('domIndexerImpl.reconcileChildList', () => { segments: [segment], }); }); + + it('Added Text after link that contains image and text', () => { + const domIndexer = new DomIndexerImpl(true); + const a = document.createElement('a'); + const img = document.createElement('img'); + const text = document.createTextNode('test'); + const newText = document.createTextNode('a'); + const div = document.createElement('div'); + + a.appendChild(img); + a.appendChild(text); + div.appendChild(a); + div.appendChild(newText); + + const paragraph = createParagraph(); + const segmentImg = createImage('src'); + const segmentText = createText('test'); + + addLink(segmentImg, { + format: { href: 'test' }, + dataset: {}, + }); + addLink(segmentText, { + format: { href: 'test' }, + dataset: {}, + }); + + paragraph.segments.push(segmentImg, segmentText); + + ((img as Node) as IndexedSegmentNode).__roosterjsContentModel = { + paragraph: paragraph, + segments: [segmentImg], + }; + ((text as Node) as IndexedSegmentNode).__roosterjsContentModel = { + paragraph: paragraph, + segments: [segmentText], + }; + + const result = domIndexer.reconcileChildList([newText], []); + + expect(result).toBeTrue(); + expect(paragraph).toEqual({ + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Image', + src: 'src', + format: {}, + dataset: {}, + link: { format: { href: 'test' }, dataset: {} }, + }, + { + segmentType: 'Text', + text: 'test', + format: {}, + link: { format: { href: 'test' }, dataset: {} }, + }, + { segmentType: 'Text', text: 'a', format: {} }, + ], + format: {}, + }); + expect(((newText as Node) as IndexedSegmentNode).__roosterjsContentModel.paragraph).toBe( + paragraph + ); + expect( + ((newText as Node) as IndexedSegmentNode).__roosterjsContentModel.segments.length + ).toBe(1); + expect(((newText as Node) as IndexedSegmentNode).__roosterjsContentModel.segments[0]).toBe( + paragraph.segments[2] + ); + }); }); describe('domIndexerImpl.reconcileElementId', () => { diff --git a/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleSegmentDecorator.ts b/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleSegmentDecorator.ts index d2dcc7706aa..1a2f2da82da 100644 --- a/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleSegmentDecorator.ts +++ b/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleSegmentDecorator.ts @@ -14,8 +14,7 @@ export const handleSegmentDecorator: ContentModelSegmentHandler { const { code, link } = segment; @@ -27,7 +26,6 @@ export const handleSegmentDecorator: ContentModelSegmentHandler { handleBr(document, parent, br, context, newSegments); expect(parent.innerHTML).toBe('
'); - expect(newSegments.length).toBe(2); + expect(newSegments.length).toBe(1); expect((newSegments[0] as HTMLElement).outerHTML).toBe('
'); - expect((newSegments[1] as HTMLElement).outerHTML).toBe('
'); }); }); diff --git a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleSegmentDecoratorTest.ts b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleSegmentDecoratorTest.ts index 022733f2116..748e1d4b39b 100644 --- a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleSegmentDecoratorTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleSegmentDecoratorTest.ts @@ -1,5 +1,4 @@ import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext'; -import { expectHtml } from '../../testUtils'; import { handleSegmentDecorator } from '../../../lib/modelToDom/handlers/handleSegmentDecorator'; import { ContentModelCode, @@ -20,8 +19,7 @@ describe('handleSegmentDecorator', () => { function runTest( link: ContentModelLink | undefined, code: ContentModelCode | undefined, - expectedInnerHTML: string, - expectedSegmentNodesHTML: (string | string[])[] + expectedInnerHTML: string ) { parent = document.createElement('span'); parent.textContent = 'test'; @@ -37,10 +35,7 @@ describe('handleSegmentDecorator', () => { handleSegmentDecorator(document, parent, segment, context, segmentNodes); expect(parent.innerHTML).toBe(expectedInnerHTML); - expect(segmentNodes.length).toBe(expectedSegmentNodesHTML.length); - expectedSegmentNodesHTML.forEach((expectedHTML, i) => { - expectHtml((segmentNodes[i] as HTMLElement).outerHTML, expectedHTML); - }); + expect(segmentNodes.length).toBe(0); } it('simple link', () => { @@ -52,9 +47,7 @@ describe('handleSegmentDecorator', () => { dataset: {}, }; - runTest(link, undefined, 'test', [ - 'test', - ]); + runTest(link, undefined, 'test'); }); it('link with color', () => { @@ -67,9 +60,7 @@ describe('handleSegmentDecorator', () => { dataset: {}, }; - runTest(link, undefined, 'test', [ - 'test', - ]); + runTest(link, undefined, 'test'); }); it('link without underline', () => { @@ -84,8 +75,7 @@ describe('handleSegmentDecorator', () => { runTest( link, undefined, - 'test', - ['test'] + 'test' ); }); @@ -101,9 +91,7 @@ describe('handleSegmentDecorator', () => { }, }; - runTest(link, undefined, 'test', [ - 'test', - ]); + runTest(link, undefined, 'test'); }); it('simple code', () => { @@ -113,7 +101,7 @@ describe('handleSegmentDecorator', () => { }, }; - runTest(undefined, code, 'test', ['test']); + runTest(undefined, code, 'test'); }); it('code with font', () => { @@ -123,9 +111,7 @@ describe('handleSegmentDecorator', () => { }, }; - runTest(undefined, code, 'test', [ - 'test', - ]); + runTest(undefined, code, 'test'); }); it('link and code', () => { @@ -142,10 +128,7 @@ describe('handleSegmentDecorator', () => { }, }; - runTest(link, code, 'test', [ - 'test', - 'test', - ]); + runTest(link, code, 'test'); }); it('Link with onNodeCreated', () => { @@ -194,12 +177,7 @@ describe('handleSegmentDecorator', () => { dataset: {}, }; - runTest( - link, - undefined, - 'test', - ['test'] - ); + runTest(link, undefined, 'test'); }); it('code with display: block', () => { @@ -210,9 +188,7 @@ describe('handleSegmentDecorator', () => { }, }; - runTest(undefined, code, 'test', [ - 'test', - ]); + runTest(undefined, code, 'test'); }); it('link with background color', () => { @@ -228,8 +204,7 @@ describe('handleSegmentDecorator', () => { runTest( link, undefined, - 'test', - ['test'] + 'test' ); }); }); diff --git a/packages/roosterjs-content-model-dom/test/modelToDom/utils/handleSegmentCommonTest.ts b/packages/roosterjs-content-model-dom/test/modelToDom/utils/handleSegmentCommonTest.ts index 8a3b3c923a4..8292a138235 100644 --- a/packages/roosterjs-content-model-dom/test/modelToDom/utils/handleSegmentCommonTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelToDom/utils/handleSegmentCommonTest.ts @@ -33,9 +33,8 @@ describe('handleSegmentCommon', () => { 'test' ); expect(onNodeCreated).toHaveBeenCalledWith(segment, txt); - expect(segmentNodes.length).toBe(2); + expect(segmentNodes.length).toBe(1); expect(segmentNodes[0]).toBe(txt); - expect(segmentNodes[1]).toBe(txt.parentNode!); }); it('element with child', () => { From 64eb364ecc90cbb7321a66e9035155ebe78d4322 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Fri, 22 Nov 2024 11:11:03 -0800 Subject: [PATCH 28/30] #2878 Provide a callback function to allow fixup model before write back (#2890) --- .../lib/coreApi/setContentModel/setContentModel.ts | 2 ++ .../lib/editor/core/createEditorCore.ts | 1 + .../coreApi/setContentModel/setContentModelTest.ts | 5 +++++ .../test/editor/core/createEditorCoreTest.ts | 4 ++++ .../lib/editor/EditorCore.ts | 12 +++++++++++- .../lib/editor/EditorOptions.ts | 12 +++++++++++- 6 files changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/roosterjs-content-model-core/lib/coreApi/setContentModel/setContentModel.ts b/packages/roosterjs-content-model-core/lib/coreApi/setContentModel/setContentModel.ts index beb993f2e97..0400bf8668e 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/setContentModel/setContentModel.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/setContentModel/setContentModel.ts @@ -38,6 +38,8 @@ export const setContentModel: SetContentModel = ( modelToDomContext.onNodeCreated = onNodeCreated; + core.onFixUpModel?.(model); + const selection = contentModelToDom( core.logicalRoot.ownerDocument, core.logicalRoot, diff --git a/packages/roosterjs-content-model-core/lib/editor/core/createEditorCore.ts b/packages/roosterjs-content-model-core/lib/editor/core/createEditorCore.ts index 7dbea590e45..886b03dc7ee 100644 --- a/packages/roosterjs-content-model-core/lib/editor/core/createEditorCore.ts +++ b/packages/roosterjs-content-model-core/lib/editor/core/createEditorCore.ts @@ -47,6 +47,7 @@ export function createEditorCore(contentDiv: HTMLDivElement, options: EditorOpti domHelper: createDOMHelper(contentDiv), ...getPluginState(corePlugins), disposeErrorHandler: options.disposeErrorHandler, + onFixUpModel: options.onFixUpModel, experimentalFeatures: options.experimentalFeatures ? [...options.experimentalFeatures] : [], }; } diff --git a/packages/roosterjs-content-model-core/test/coreApi/setContentModel/setContentModelTest.ts b/packages/roosterjs-content-model-core/test/coreApi/setContentModel/setContentModelTest.ts index 15c33e36ed6..14b8dc1f2b9 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/setContentModel/setContentModelTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/setContentModel/setContentModelTest.ts @@ -117,10 +117,13 @@ describe('setContentModel', () => { const mockedRange = { type: 'image', } as any; + const mockedOnFixUpModel = jasmine.createSpy('fixupModel'); contentModelToDomSpy.and.returnValue(mockedRange); core.environment.modelToDomSettings.builtIn = defaultOption; + (core as any).onFixUpModel = mockedOnFixUpModel; + setContentModel(core, mockedModel, additionalOption); expect(createModelToDomContextSpy).toHaveBeenCalledWith( @@ -136,6 +139,8 @@ describe('setContentModel', () => { mockedContext ); expect(setDOMSelectionSpy).toHaveBeenCalledWith(core, mockedRange); + expect(mockedOnFixUpModel).toHaveBeenCalledWith(mockedModel); + expect(mockedOnFixUpModel).toHaveBeenCalledBefore(contentModelToDomSpy); }); it('no default option, with shadow edit', () => { diff --git a/packages/roosterjs-content-model-core/test/editor/core/createEditorCoreTest.ts b/packages/roosterjs-content-model-core/test/editor/core/createEditorCoreTest.ts index b4af3645965..197c55c1306 100644 --- a/packages/roosterjs-content-model-core/test/editor/core/createEditorCoreTest.ts +++ b/packages/roosterjs-content-model-core/test/editor/core/createEditorCoreTest.ts @@ -101,6 +101,7 @@ describe('createEditorCore', () => { domHelper: mockedDOMHelper, disposeErrorHandler: undefined, experimentalFeatures: [], + onFixUpModel: undefined, ...additionalResult, }); @@ -149,6 +150,7 @@ describe('createEditorCore', () => { const mockedDisposeErrorHandler = 'DISPOSE' as any; const mockedGenerateColorKey = 'KEY' as any; const mockedKnownColors = 'COLORS' as any; + const mockedOnFixUpModel = 'FIXUP' as any; const mockedOptions = { coreApiOverride: { a: 'b', @@ -159,6 +161,7 @@ describe('createEditorCore', () => { disposeErrorHandler: mockedDisposeErrorHandler, generateColorKey: mockedGenerateColorKey, knownColors: mockedKnownColors, + onFixUpModel: mockedOnFixUpModel, } as any; runTest(mockedDiv, mockedOptions, { @@ -181,6 +184,7 @@ describe('createEditorCore', () => { darkColorHandler: mockedDarkColorHandler, trustedHTMLHandler: mockedTrustHtmlHandler, disposeErrorHandler: mockedDisposeErrorHandler, + onFixUpModel: mockedOnFixUpModel, }); expect(DarkColorHandlerImpl.createDarkColorHandler).toHaveBeenCalledWith( diff --git a/packages/roosterjs-content-model-types/lib/editor/EditorCore.ts b/packages/roosterjs-content-model-types/lib/editor/EditorCore.ts index e97c74fc77e..42144b97f61 100644 --- a/packages/roosterjs-content-model-types/lib/editor/EditorCore.ts +++ b/packages/roosterjs-content-model-types/lib/editor/EditorCore.ts @@ -7,7 +7,10 @@ import type { DOMEventRecord } from '../parameter/DOMEventRecord'; import type { Snapshot } from '../parameter/Snapshot'; import type { EntityState } from '../parameter/FormatContentModelContext'; import type { DarkColorHandler } from '../context/DarkColorHandler'; -import type { ContentModelDocument } from '../contentModel/blockGroup/ContentModelDocument'; +import type { + ContentModelDocument, + ReadonlyContentModelDocument, +} from '../contentModel/blockGroup/ContentModelDocument'; import type { DOMSelection } from '../selection/DOMSelection'; import type { DomToModelOptionForCreateModel } from '../context/DomToModelOption'; import type { EditorContext } from '../context/EditorContext'; @@ -376,6 +379,13 @@ export interface EditorCore extends PluginState { */ readonly disposeErrorHandler?: (plugin: EditorPlugin, error: Error) => void; + /** + * An optional callback function that will be invoked before write content model back to editor. + * This is used for make sure model can satisfy some customized requirement + * @param model The model to fix up + */ + readonly onFixUpModel?: (model: ReadonlyContentModelDocument) => void; + /** * Enabled experimental features */ diff --git a/packages/roosterjs-content-model-types/lib/editor/EditorOptions.ts b/packages/roosterjs-content-model-types/lib/editor/EditorOptions.ts index 4bc635e03f1..aad4ea14219 100644 --- a/packages/roosterjs-content-model-types/lib/editor/EditorOptions.ts +++ b/packages/roosterjs-content-model-types/lib/editor/EditorOptions.ts @@ -7,7 +7,10 @@ import type { ContentModelSegmentFormat } from '../contentModel/format/ContentMo import type { CoreApiMap } from './EditorCore'; import type { DomToModelOption } from '../context/DomToModelOption'; import type { ModelToDomOption } from '../context/ModelToDomOption'; -import type { ContentModelDocument } from '../contentModel/blockGroup/ContentModelDocument'; +import type { + ContentModelDocument, + ReadonlyContentModelDocument, +} from '../contentModel/blockGroup/ContentModelDocument'; import type { Snapshots } from '../parameter/Snapshot'; import type { TrustedHTMLHandler } from '../parameter/TrustedHTMLHandler'; @@ -68,6 +71,13 @@ export interface ContentModelOptions { */ defaultSegmentFormat?: ContentModelSegmentFormat; + /** + * An optional callback function that will be invoked before write content model back to editor. + * This is used for make sure model can satisfy some customized requirement + * @param model The model to fix up + */ + onFixUpModel?: (model: ReadonlyContentModelDocument) => void; + /** * @deprecated */ From 0fd9fd16c30c980dda2aa4a1270040d16ed5a9dc Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Fri, 22 Nov 2024 18:33:18 -0300 Subject: [PATCH 29/30] bump --- versions.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/versions.json b/versions.json index a1fc6b3ed0f..c8405504610 100644 --- a/versions.json +++ b/versions.json @@ -1,6 +1,6 @@ { "react": "9.0.0", - "main": "9.14.0", + "main": "9.15.0", "legacyAdapter": "8.62.2", "overrides": {} } From aff99c380dffc4af634f5177a5eb97bd9ab8f0cf Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Fri, 22 Nov 2024 18:46:36 -0300 Subject: [PATCH 30/30] remove test --- .../handlers/handleParagraphTest.ts | 341 ------------------ 1 file changed, 341 deletions(-) diff --git a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleParagraphTest.ts b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleParagraphTest.ts index 3e8dfa58f76..8a0c2ca2d5e 100644 --- a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleParagraphTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleParagraphTest.ts @@ -1147,344 +1147,3 @@ describe('Handle paragraph and adjust selections', () => { ); }); }); - -describe('Handle paragraph and adjust selections', () => { - it('Selection is at beginning, followed by BR', () => { - const paragraph: ContentModelParagraph = { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - { - segmentType: 'Br', - format: {}, - }, - ], - }; - - const parent = document.createElement('div'); - const context = createModelToDomContext(); - - handleParagraph(document, parent, paragraph, context, null); - - expect(parent.innerHTML).toBe('

'); - expect(context.regularSelection.start).toEqual({ - block: parent.firstChild, - segment: parent.firstChild!.firstChild, - }); - expect(context.regularSelection.end).toEqual({ - block: parent.firstChild, - segment: parent.firstChild!.firstChild, - }); - expect(parent.firstChild!.firstChild!.nodeType).toBe(Node.TEXT_NODE); - }); - - it('Selection is at beginning, followed by Text', () => { - const paragraph: ContentModelParagraph = { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - { - segmentType: 'Text', - text: 'test', - format: {}, - }, - ], - }; - - const parent = document.createElement('div'); - const context = createModelToDomContext(); - - handleParagraph(document, parent, paragraph, context, null); - - expect(parent.innerHTML).toBe('
test
'); - expect(context.regularSelection.start).toEqual({ - block: parent.firstChild, - segment: parent.firstChild!.firstChild, - offset: 0, - }); - expect(context.regularSelection.end).toEqual({ - block: parent.firstChild, - segment: parent.firstChild!.firstChild, - offset: 0, - }); - expect(parent.firstChild!.firstChild!.nodeType).toBe(Node.TEXT_NODE); - expect(parent.firstChild!.firstChild).toBe(parent.firstChild!.lastChild); - }); - - it('Selection is in middle of text', () => { - const paragraph: ContentModelParagraph = { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'Text', - text: 'test1', - format: {}, - }, - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - { - segmentType: 'Text', - text: 'test2', - format: {}, - }, - ], - }; - - const parent = document.createElement('div'); - const context = createModelToDomContext(); - - handleParagraph(document, parent, paragraph, context, null); - - expect(parent.innerHTML).toBe('
test1test2
'); - expect(context.regularSelection.start).toEqual({ - block: parent.firstChild, - segment: parent.firstChild!.firstChild, - offset: 5, - }); - expect(context.regularSelection.end).toEqual({ - block: parent.firstChild, - segment: parent.firstChild!.firstChild, - offset: 5, - }); - expect(parent.firstChild!.firstChild!.nodeType).toBe(Node.TEXT_NODE); - expect(parent.firstChild!.firstChild).toBe(parent.firstChild!.lastChild); - }); - - it('Selection is at end of text', () => { - const paragraph: ContentModelParagraph = { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'Text', - text: 'test1', - format: {}, - }, - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - { - segmentType: 'Br', - format: {}, - }, - { - segmentType: 'Text', - text: 'test2', - format: {}, - }, - ], - }; - - const parent = document.createElement('div'); - const context = createModelToDomContext(); - - handleParagraph(document, parent, paragraph, context, null); - - expect(parent.innerHTML).toBe('
test1
test2
'); - expect(context.regularSelection.start).toEqual({ - block: parent.firstChild, - segment: parent.firstChild!.firstChild, - }); - expect(context.regularSelection.end).toEqual({ - block: parent.firstChild, - segment: parent.firstChild!.firstChild, - }); - expect(parent.firstChild!.firstChild!.nodeType).toBe(Node.TEXT_NODE); - expect(parent.firstChild!.lastChild!.nodeType).toBe(Node.TEXT_NODE); - expect(parent.firstChild!.firstChild).not.toBe(parent.firstChild!.lastChild); - }); - - it('Selection is in middle of text, expanded', () => { - const paragraph: ContentModelParagraph = { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'Text', - text: 'test1', - format: {}, - }, - { - segmentType: 'Text', - text: 'test2', - format: {}, - isSelected: true, - }, - { - segmentType: 'Text', - text: 'test3', - format: {}, - }, - ], - }; - - const parent = document.createElement('div'); - const context = createModelToDomContext(); - - handleParagraph(document, parent, paragraph, context, null); - - expect(parent.innerHTML).toBe('
test1test2test3
'); - expect(context.regularSelection.start).toEqual({ - block: parent.firstChild, - segment: parent.firstChild!.firstChild, - offset: 5, - }); - expect(context.regularSelection.end).toEqual({ - block: parent.firstChild, - segment: parent.firstChild!.firstChild, - offset: 10, - }); - expect(parent.firstChild!.firstChild!.nodeType).toBe(Node.TEXT_NODE); - expect(parent.firstChild!.lastChild!.nodeType).toBe(Node.TEXT_NODE); - expect(parent.firstChild!.firstChild).toBe(parent.firstChild!.lastChild); - }); - - it('Selection is in front of text, expanded', () => { - const paragraph: ContentModelParagraph = { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'Text', - text: 'test1', - format: {}, - isSelected: true, - }, - { - segmentType: 'Text', - text: 'test2', - format: {}, - }, - ], - }; - - const parent = document.createElement('div'); - const context = createModelToDomContext(); - - handleParagraph(document, parent, paragraph, context, null); - - expect(parent.innerHTML).toBe('
test1test2
'); - expect(context.regularSelection.start).toEqual({ - block: parent.firstChild, - segment: null, - }); - expect(context.regularSelection.end).toEqual({ - block: parent.firstChild, - segment: parent.firstChild!.firstChild, - offset: 5, - }); - expect(parent.firstChild!.firstChild!.nodeType).toBe(Node.TEXT_NODE); - expect(parent.firstChild!.lastChild!.nodeType).toBe(Node.TEXT_NODE); - expect(parent.firstChild!.firstChild).toBe(parent.firstChild!.lastChild); - }); - - it('Selection is at the end of text, expanded', () => { - const paragraph: ContentModelParagraph = { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'Text', - text: 'test1', - format: {}, - }, - { - segmentType: 'Text', - text: 'test2', - format: {}, - isSelected: true, - }, - ], - }; - - const parent = document.createElement('div'); - const context = createModelToDomContext(); - - handleParagraph(document, parent, paragraph, context, null); - - expect(parent.innerHTML).toBe('
test1test2
'); - expect(context.regularSelection.start).toEqual({ - block: parent.firstChild, - segment: parent.firstChild!.firstChild, - offset: 5, - }); - expect(context.regularSelection.end).toEqual({ - block: parent.firstChild, - segment: parent.firstChild!.firstChild, - }); - expect(parent.firstChild!.firstChild!.nodeType).toBe(Node.TEXT_NODE); - expect(parent.firstChild!.lastChild!.nodeType).toBe(Node.TEXT_NODE); - expect(parent.firstChild!.firstChild).toBe(parent.firstChild!.lastChild); - }); - - it('Selection is in middle of text and BR, expanded', () => { - const paragraph: ContentModelParagraph = { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'Text', - text: 'test1', - format: {}, - }, - { - segmentType: 'Text', - text: 'test2', - format: {}, - isSelected: true, - }, - { - segmentType: 'Br', - format: {}, - }, - { - segmentType: 'Text', - text: 'test3', - format: {}, - isSelected: true, - }, - { - segmentType: 'Text', - text: 'test4', - format: {}, - }, - ], - }; - - const parent = document.createElement('div'); - const context = createModelToDomContext(); - - handleParagraph(document, parent, paragraph, context, null); - - expect(parent.innerHTML).toBe('
test1test2
test3test4
'); - expect(context.regularSelection.start).toEqual({ - block: parent.firstChild, - segment: parent.firstChild!.firstChild, - offset: 5, - }); - expect(context.regularSelection.end).toEqual({ - block: parent.firstChild, - segment: parent.firstChild!.lastChild, - offset: 5, - }); - expect(parent.firstChild!.firstChild!.nodeType).toBe(Node.TEXT_NODE); - expect(parent.firstChild!.lastChild!.nodeType).toBe(Node.TEXT_NODE); - expect(parent.firstChild!.firstChild).not.toBe(parent.firstChild!.lastChild); - }); -});