From d6a390c0fe935b4fb3ef9e0522bca128a2240c3d Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Fri, 4 Aug 2023 13:05:10 -0700 Subject: [PATCH 01/75] Content Model: Improve cache behavior (#1999) * Content Model: Improve cache behavior * fix build * fix comment --- .../context/createDomToModelContext.ts | 1 - .../domToModel/processors/entityProcessor.ts | 17 +- .../lib/modelToDom/contentModelToDom.ts | 2 +- .../lib/modelToDom/handlers/handleDivider.ts | 7 +- .../lib/modelToDom/handlers/handleEntity.ts | 10 +- .../handlers/handleFormatContainer.ts | 7 +- .../modelToDom/handlers/handleParagraph.ts | 6 +- .../lib/modelToDom/handlers/handleTable.ts | 23 ++- .../context/createDomToModelContextTest.ts | 4 - .../processors/entityProcessorTest.ts | 65 -------- .../processors/tableProcessorTest.ts | 89 +--------- .../test/endToEndTest.ts | 8 +- .../handlers/handleBlockGroupChildrenTest.ts | 13 +- .../modelToDom/handlers/handleDividerTest.ts | 1 + .../modelToDom/handlers/handleEntityTest.ts | 4 +- .../handlers/handleFormatContainerTest.ts | 23 +-- .../handlers/handleParagraphTest.ts | 13 +- .../modelToDom/handlers/handleTableTest.ts | 2 +- .../lib/editor/coreApi/createContentModel.ts | 2 +- .../lib/editor/coreApi/createEditorContext.ts | 1 + .../ContentModelCopyPastePlugin.ts | 47 +++--- .../lib/modelApi/common/cloneModel.ts | 153 +++++++++++++----- .../lib/publicApi/utils/paste.ts | 1 - .../reducedModelChildProcessorTest.ts | 1 - .../editor/coreApi/createContentModelTest.ts | 7 +- .../editor/coreApi/createEditorContextTest.ts | 6 + .../ContentModelCopyPastePluginTest.ts | 42 ++--- .../plugins/paste/e2e/cmPasteFromExcelTest.ts | 4 - .../processPastedContentFromExcelTest.ts | 1 - .../paste/processPastedContentFromWacTest.ts | 1 - ...processPastedContentFromWordDesktopTest.ts | 1 - .../test/modelApi/common/cloneModelTest.ts | 40 +++-- .../lib/context/DomToModelFormatContext.ts | 6 - .../lib/context/DomToModelOption.ts | 6 - .../lib/context/EditorContext.ts | 6 + 35 files changed, 286 insertions(+), 334 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/context/createDomToModelContext.ts b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/context/createDomToModelContext.ts index 3724793dfb7..b6e1c77bf07 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/context/createDomToModelContext.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/context/createDomToModelContext.ts @@ -55,7 +55,6 @@ export function createDomToModelContext( defaultElementProcessors: defaultProcessorMap, defaultFormatParsers: defaultFormatParsers, - allowCacheElement: !options?.disableCacheElement, }; if (editorContext?.isRootRtl) { diff --git a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/entityProcessor.ts b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/entityProcessor.ts index 3ca9318e4ef..c09a27845f6 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/entityProcessor.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/entityProcessor.ts @@ -23,22 +23,7 @@ export const entityProcessor: ElementProcessor = (group, element, c context, { segment: isBlockEntity ? 'empty' : undefined, paragraph: 'empty' }, () => { - const wrapperToUse = context.allowCacheElement - ? element - : (element.cloneNode(true /* deep */) as HTMLElement); - - if (!context.allowCacheElement) { - wrapperToUse.style.backgroundColor = element.style.backgroundColor || 'inherit'; - wrapperToUse.style.color = element.style.color || 'inherit'; - } - - const entityModel = createEntity( - wrapperToUse, - isReadonly, - context.segmentFormat, - id, - type - ); + const entityModel = createEntity(element, isReadonly, context.segmentFormat, id, type); // TODO: Need to handle selection for editable entity if (context.isInSelection) { diff --git a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/contentModelToDom.ts b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/contentModelToDom.ts index 133fffbf2cc..450ce903c76 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/contentModelToDom.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/contentModelToDom.ts @@ -33,7 +33,7 @@ export function contentModelToDom( doc: Document, root: Node, model: ContentModelDocument, - editorContext: EditorContext, + editorContext?: EditorContext, option?: ModelToDomOption ): SelectionRangeEx | null { const modelToDomContext = createModelToDomContext(editorContext, option); diff --git a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleDivider.ts b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleDivider.ts index 949edee3e47..1f0ad2817c7 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleDivider.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleDivider.ts @@ -16,14 +16,17 @@ export const handleDivider: ContentModelBlockHandler = ( context: ModelToDomContext, refNode: Node | null ) => { - let element = divider.cachedElement; + let element = context.allowCacheElement ? divider.cachedElement : undefined; if (element) { refNode = reuseCachedElement(parent, element, refNode); } else { element = doc.createElement(divider.tagName); - divider.cachedElement = element; + if (context.allowCacheElement) { + divider.cachedElement = element; + } + parent.insertBefore(element, refNode); applyFormat(element, context.formatAppliers.divider, divider.format, context); diff --git a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleEntity.ts b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleEntity.ts index 363fc335901..82f419f3fb1 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleEntity.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleEntity.ts @@ -24,7 +24,15 @@ export const handleEntity: ContentModelBlockHandler = ( context: ModelToDomContext, refNode: Node | null ) => { - const { wrapper, id, type, isReadonly, format } = entityModel; + const { id, type, isReadonly, format } = entityModel; + let wrapper = entityModel.wrapper; + + if (!context.allowCacheElement) { + wrapper = wrapper.cloneNode(true /*deep*/) as HTMLElement; + wrapper.style.color = wrapper.style.color || 'inherit'; + wrapper.style.backgroundColor = wrapper.style.backgroundColor || 'inherit'; + } + const entity: Entity | null = id && type ? { diff --git a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleFormatContainer.ts b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleFormatContainer.ts index a8cd8726dee..035ca50891f 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleFormatContainer.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleFormatContainer.ts @@ -19,7 +19,7 @@ export const handleFormatContainer: ContentModelBlockHandler { - let element = container.cachedElement; + let element = context.allowCacheElement ? container.cachedElement : undefined; if (element) { refNode = reuseCachedElement(parent, element, refNode); @@ -28,7 +28,10 @@ export const handleFormatContainer: ContentModelBlockHandler { diff --git a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleParagraph.ts b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleParagraph.ts index 8714fec5f5a..2b3fbc13ed2 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleParagraph.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleParagraph.ts @@ -21,7 +21,7 @@ export const handleParagraph: ContentModelBlockHandler = context: ModelToDomContext, refNode: Node | null ) => { - let container = paragraph.cachedElement; + let container = context.allowCacheElement ? paragraph.cachedElement : undefined; if (container) { refNode = reuseCachedElement(parent, container, refNode); @@ -102,7 +102,9 @@ export const handleParagraph: ContentModelBlockHandler = refNode = container.nextSibling; if (needParagraphWrapper) { - paragraph.cachedElement = container; + if (context.allowCacheElement) { + paragraph.cachedElement = container; + } } else { unwrap(container); } diff --git a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleTable.ts b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleTable.ts index fc9cdcab121..b8a4e64658c 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleTable.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleTable.ts @@ -24,7 +24,7 @@ export const handleTable: ContentModelBlockHandler = ( return refNode; } - let tableNode = table.cachedElement; + let tableNode = context.allowCacheElement ? table.cachedElement : undefined; if (tableNode) { refNode = reuseCachedElement(parent, tableNode, refNode); @@ -33,7 +33,10 @@ export const handleTable: ContentModelBlockHandler = ( } else { tableNode = doc.createElement('table'); - table.cachedElement = tableNode; + if (context.allowCacheElement) { + table.cachedElement = tableNode; + } + parent.insertBefore(tableNode, refNode); applyFormat(tableNode, context.formatAppliers.block, table.format, context); @@ -55,12 +58,15 @@ export const handleTable: ContentModelBlockHandler = ( continue; } - const tr = tableRow.cachedElement || doc.createElement('tr'); + const tr = (context.allowCacheElement && tableRow.cachedElement) || doc.createElement('tr'); tbody.appendChild(tr); moveChildNodes(tr); if (!tableRow.cachedElement) { - tableRow.cachedElement = tr; + if (context.allowCacheElement) { + tableRow.cachedElement = tr; + } + applyFormat(tr, context.formatAppliers.tableRow, tableRow.format, context); } @@ -85,7 +91,9 @@ export const handleTable: ContentModelBlockHandler = ( } if (!cell.spanAbove && !cell.spanLeft) { - let td = cell.cachedElement || doc.createElement(cell.isHeader ? 'th' : 'td'); + let td = + (context.allowCacheElement && cell.cachedElement) || + doc.createElement(cell.isHeader ? 'th' : 'td'); tr.appendChild(td); @@ -120,7 +128,10 @@ export const handleTable: ContentModelBlockHandler = ( } if (!cell.cachedElement) { - cell.cachedElement = td; + if (context.allowCacheElement) { + cell.cachedElement = td; + } + applyFormat(td, context.formatAppliers.block, cell.format, context); applyFormat(td, context.formatAppliers.tableCell, cell.format, context); applyFormat(td, context.formatAppliers.tableCellBorder, cell.format, context); diff --git a/packages-content-model/roosterjs-content-model-dom/test/domToModel/context/createDomToModelContextTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domToModel/context/createDomToModelContextTest.ts index 5570f2ece6b..e422c03519b 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/domToModel/context/createDomToModelContextTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domToModel/context/createDomToModelContextTest.ts @@ -40,7 +40,6 @@ describe('createDomToModelContext', () => { format: {}, tagName: '', }, - allowCacheElement: true, ...contextOptions, }); }); @@ -69,7 +68,6 @@ describe('createDomToModelContext', () => { format: {}, tagName: '', }, - allowCacheElement: true, ...contextOptions, }); }); @@ -98,7 +96,6 @@ describe('createDomToModelContext', () => { format: {}, tagName: '', }, - allowCacheElement: true, ...contextOptions, }); }); @@ -123,7 +120,6 @@ describe('createDomToModelContext', () => { format: {}, tagName: '', }, - allowCacheElement: true, ...contextOptions, rangeEx: selectionContext, }); diff --git a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/entityProcessorTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/entityProcessorTest.ts index ef82a06342e..72ee02b35a1 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/entityProcessorTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/entityProcessorTest.ts @@ -197,69 +197,4 @@ describe('entityProcessor', () => { lineHeight: '20px', }); }); - - it('Block element entity, clone element', () => { - const group = createContentModelDocument(); - const div = document.createElement('div'); - - const clonedDiv = div.cloneNode(true /* deep */) as HTMLDivElement; - spyOn(Node.prototype, 'cloneNode').and.returnValue(clonedDiv); - context.allowCacheElement = false; - - commitEntity(div, 'entity', true, 'entity_1'); - - entityProcessor(group, div, context); - - expect(group).toEqual({ - blockGroupType: 'Document', - - blocks: [ - { - blockType: 'Entity', - segmentType: 'Entity', - format: {}, - id: 'entity_1', - type: 'entity', - isReadonly: true, - wrapper: clonedDiv, - }, - ], - }); - }); - - it('Inline element entity, clone entity element', () => { - const group = createContentModelDocument(); - const span = document.createElement('span'); - - const clonedSpan = span.cloneNode(true /* deep */) as HTMLDivElement; - spyOn(Node.prototype, 'cloneNode').and.returnValue(clonedSpan); - context.allowCacheElement = false; - - commitEntity(span, 'entity', true, 'entity_1'); - - entityProcessor(group, span, context); - - expect(group).toEqual({ - blockGroupType: 'Document', - - blocks: [ - { - blockType: 'Paragraph', - isImplicit: true, - segments: [ - { - blockType: 'Entity', - segmentType: 'Entity', - format: {}, - id: 'entity_1', - type: 'entity', - isReadonly: true, - wrapper: clonedSpan, - }, - ], - format: {}, - }, - ], - }); - }); }); diff --git a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/tableProcessorTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/tableProcessorTest.ts index ce86a2d02f1..5a56d125714 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/tableProcessorTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/tableProcessorTest.ts @@ -23,7 +23,6 @@ describe('tableProcessor', () => { processorOverride: { child: childProcessor, }, - disableCacheElement: false, }); spyOn(getBoundingClientRect, 'getBoundingClientRect').and.returnValue(({ @@ -51,7 +50,6 @@ describe('tableProcessor', () => { blockType: 'Table', rows: [ { - cachedElement: div.querySelector('#tr1') as HTMLTableRowElement, format: {}, height: 200, cells: [ @@ -63,7 +61,6 @@ describe('tableProcessor', () => { blocks: [], format: {}, dataset: {}, - cachedElement: div.querySelector('#td1') as HTMLTableCellElement, }, ], }, @@ -71,7 +68,6 @@ describe('tableProcessor', () => { format: {}, widths: [100], dataset: {}, - cachedElement: div.querySelector('.tb1') as HTMLTableElement, }; }); }); @@ -85,22 +81,15 @@ describe('tableProcessor', () => { const tdModel4 = createTableCell(1, 1, false); runTest(tableHTML, div => { - tdModel1.cachedElement = div.querySelector('#td1') as HTMLTableCellElement; - tdModel2.cachedElement = div.querySelector('#td2') as HTMLTableCellElement; - tdModel3.cachedElement = div.querySelector('#td3') as HTMLTableCellElement; - tdModel4.cachedElement = div.querySelector('#td4') as HTMLTableCellElement; - return { blockType: 'Table', rows: [ { - cachedElement: div.querySelector('#tr1') as HTMLTableRowElement, format: {}, height: 200, cells: [tdModel1, tdModel2], }, { - cachedElement: div.querySelector('#tr2') as HTMLTableRowElement, format: {}, height: 200, cells: [tdModel3, tdModel4], @@ -109,7 +98,6 @@ describe('tableProcessor', () => { format: {}, widths: [100, 100], dataset: {}, - cachedElement: div.querySelector('.tb1') as HTMLTableElement, }; }); }); @@ -123,21 +111,15 @@ describe('tableProcessor', () => { const tdModel4 = createTableCell(2, 1, false); runTest(tableHTML, div => { - tdModel1.cachedElement = div.querySelector('#td1') as HTMLTableCellElement; - tdModel2.cachedElement = div.querySelector('#td2') as HTMLTableCellElement; - tdModel3.cachedElement = div.querySelector('#td3') as HTMLTableCellElement; - return { blockType: 'Table', rows: [ { - cachedElement: div.querySelector('#tr1') as HTMLTableRowElement, format: {}, height: 200, cells: [tdModel1, tdModel2], }, { - cachedElement: div.querySelector('#tr2') as HTMLTableRowElement, format: {}, height: 200, cells: [tdModel3, tdModel4], @@ -146,7 +128,6 @@ describe('tableProcessor', () => { format: {}, widths: [100, 100], dataset: {}, - cachedElement: div.querySelector('.tb1') as HTMLTableElement, }; }); }); @@ -157,19 +138,16 @@ describe('tableProcessor', () => { runTest(tableHTML, div => { const tdModel1 = createTableCell(1, 1, false); - tdModel1.cachedElement = div.querySelector('#td1') as HTMLTableCellElement; return { blockType: 'Table', rows: [ { - cachedElement: div.querySelector('#tr1') as HTMLTableRowElement, format: {}, height: 200, cells: [tdModel1, createTableCell(2, 1, false)], }, { - cachedElement: div.querySelector('#tr2') as HTMLTableRowElement, format: {}, height: 0, cells: [createTableCell(1, 2, false), createTableCell(2, 2, false)], @@ -178,7 +156,6 @@ describe('tableProcessor', () => { format: {}, widths: [100, 0], dataset: {}, - cachedElement: div.querySelector('.tb1') as HTMLTableElement, }; }); }); @@ -188,13 +165,10 @@ describe('tableProcessor', () => { const tdModel = createTableCell(1, 1, false); runTest(tableHTML, div => { - tdModel.cachedElement = div.querySelector('#td1') as HTMLTableCellElement; - return { blockType: 'Table', rows: [ { - cachedElement: div.querySelector('#tr1') as HTMLTableRowElement, format: {}, height: 200, cells: [tdModel], @@ -203,7 +177,6 @@ describe('tableProcessor', () => { format: {}, widths: [100], dataset: {}, - cachedElement: div.querySelector('.tb1') as HTMLTableElement, }; }); @@ -217,14 +190,10 @@ describe('tableProcessor', () => { const tdModel2 = createTableCell(1, 1, false); runTest(tableHTML, div => { - tdModel1.cachedElement = div.querySelector('#td1') as HTMLTableCellElement; - tdModel2.cachedElement = div.querySelector('#td2') as HTMLTableCellElement; - return { blockType: 'Table', rows: [ { - cachedElement: div.querySelector('#tr1') as HTMLTableRowElement, format: {}, height: 200, cells: [tdModel1, tdModel2], @@ -233,7 +202,6 @@ describe('tableProcessor', () => { format: {}, widths: [100, 100], dataset: {}, - cachedElement: div.querySelector('.tb1') as HTMLTableElement, }; }); @@ -247,13 +215,10 @@ describe('tableProcessor', () => { const tdModel2 = createTableCell(2, 1, false); runTest(tableHTML, div => { - tdModel1.cachedElement = div.querySelector('#td1') as HTMLTableCellElement; - return { blockType: 'Table', rows: [ { - cachedElement: div.querySelector('#tr1') as HTMLTableRowElement, format: {}, height: 200, cells: [tdModel1, tdModel2], @@ -262,7 +227,6 @@ describe('tableProcessor', () => { format: {}, widths: [100, 0], dataset: {}, - cachedElement: div.querySelector('.tb1') as HTMLTableElement, }; }); @@ -297,10 +261,6 @@ describe('tableProcessor', () => { tdModel2.isSelected = true; tdModel4.isSelected = true; - tdModel1.cachedElement = div.querySelector('#td1') as HTMLTableCellElement; - tdModel2.cachedElement = div.querySelector('#td2') as HTMLTableCellElement; - tdModel3.cachedElement = div.querySelector('#td3') as HTMLTableCellElement; - tdModel4.cachedElement = div.querySelector('#td4') as HTMLTableCellElement; tableProcessor(doc, div.firstChild as HTMLTableElement, context); @@ -308,13 +268,11 @@ describe('tableProcessor', () => { blockType: 'Table', rows: [ { - cachedElement: div.querySelector('#tr1') as HTMLTableRowElement, format: {}, height: 200, cells: [tdModel1, tdModel2], }, { - cachedElement: div.querySelector('#tr2') as HTMLTableRowElement, format: {}, height: 200, cells: [tdModel3, tdModel4], @@ -323,7 +281,6 @@ describe('tableProcessor', () => { format: {}, widths: [100, 100], dataset: {}, - cachedElement: div.querySelector('.tb1') as HTMLTableElement, }); expect(childProcessor).toHaveBeenCalledTimes(4); @@ -336,8 +293,6 @@ describe('tableProcessor with format', () => { beforeEach(() => { context = createDomToModelContext(); - context.allowCacheElement = true; - spyOn(getBoundingClientRect, 'getBoundingClientRect').and.returnValue(({ width: 100, height: 200, @@ -386,7 +341,6 @@ describe('tableProcessor with format', () => { blockType: 'Table', rows: [ { - cachedElement: tr, format: {}, height: 200, cells: [ @@ -409,7 +363,6 @@ describe('tableProcessor with format', () => { format: {}, }, ], - cachedElement: td, spanLeft: false, spanAbove: false, isHeader: false, @@ -426,7 +379,6 @@ describe('tableProcessor with format', () => { format1: 'table', } as any, dataset: {}, - cachedElement: table, }, ], }); @@ -469,7 +421,6 @@ describe('tableProcessor with format', () => { format: {}, rows: [ { - cachedElement: mockedTr as any, format: {}, height: 100, cells: [ @@ -481,13 +432,11 @@ describe('tableProcessor with format', () => { spanLeft: false, isHeader: false, dataset: {}, - cachedElement: mockedTd, }, ], }, ], dataset: {}, - cachedElement: mockedTable, }, ], }); @@ -582,7 +531,6 @@ describe('tableProcessor', () => { processorOverride: { child: childProcessor, }, - disableCacheElement: false, }); spyOn(getBoundingClientRect, 'getBoundingClientRect').and.returnValue(({ @@ -681,7 +629,6 @@ describe('tableProcessor', () => { widths: [100], rows: [ { - cachedElement: mockedTr, format: {}, height: 200, cells: [ @@ -695,12 +642,10 @@ describe('tableProcessor', () => { spanLeft: false, isHeader: false, dataset: {}, - cachedElement: mockedTd, }, ], }, ], - cachedElement: mockedTable, }, ], }); @@ -737,7 +682,6 @@ describe('tableProcessor', () => { dataset: {}, widths: [], rows: [], - cachedElement: mockedTable, }, ], }); @@ -767,7 +711,6 @@ describe('tableProcessor', () => { blockType: 'Table', rows: [ { - cachedElement: tr, format: {}, height: 200, cells: [ @@ -794,7 +737,6 @@ describe('tableProcessor', () => { ], }, ], - cachedElement: td, }, ], }, @@ -802,7 +744,6 @@ describe('tableProcessor', () => { format: {}, dataset: {}, widths: [100], - cachedElement: table, }, ], }); @@ -829,7 +770,6 @@ describe('tableProcessor', () => { }, dataset: {}, widths: [], - cachedElement: table, }, ], }); @@ -855,7 +795,6 @@ describe('tableProcessor', () => { blockType: 'Table', rows: [ { - cachedElement: tr, format: {}, height: 200, cells: [ @@ -867,7 +806,6 @@ describe('tableProcessor', () => { spanLeft: false, isHeader: false, dataset: {}, - cachedElement: td, }, ], }, @@ -875,7 +813,6 @@ describe('tableProcessor', () => { format: {}, dataset: {}, widths: [100], - cachedElement: table, }, ], }); @@ -904,7 +841,6 @@ describe('tableProcessor', () => { blockType: 'Table', rows: [ { - cachedElement: tr, format: {}, height: 200, cells: [ @@ -921,7 +857,6 @@ describe('tableProcessor', () => { spanLeft: false, isHeader: false, dataset: {}, - cachedElement: td, }, ], }, @@ -929,7 +864,6 @@ describe('tableProcessor', () => { format: {}, dataset: {}, widths: [100], - cachedElement: table, }, ], }); @@ -963,7 +897,7 @@ describe('tableProcessor', () => { { format: {}, height: 200, - cachedElement: tr, + cells: [ { blockGroupType: 'TableCell', @@ -989,7 +923,6 @@ describe('tableProcessor', () => { spanLeft: false, isHeader: false, dataset: {}, - cachedElement: td, }, ], }, @@ -997,7 +930,6 @@ describe('tableProcessor', () => { format: {}, dataset: {}, widths: [100], - cachedElement: table, }, ], }); @@ -1028,7 +960,6 @@ describe('tableProcessor', () => { blockType: 'Table', rows: [ { - cachedElement: tr, format: {}, height: 200, cells: [ @@ -1055,7 +986,6 @@ describe('tableProcessor', () => { spanLeft: false, isHeader: false, dataset: {}, - cachedElement: td, }, ], }, @@ -1063,7 +993,6 @@ describe('tableProcessor', () => { format: {}, dataset: {}, widths: [100], - cachedElement: table, }, ], }); @@ -1107,10 +1036,9 @@ describe('tableProcessor', () => { blockType: 'Table', widths: [100], dataset: {}, - cachedElement: table, + rows: [ { - cachedElement: tr, format: {}, height: 200, cells: [ @@ -1127,7 +1055,6 @@ describe('tableProcessor', () => { spanLeft: false, isHeader: false, dataset: {}, - cachedElement: td, }, ], }, @@ -1172,10 +1099,9 @@ describe('tableProcessor', () => { blockType: 'Table', widths: [100], dataset: {}, - cachedElement: table, + rows: [ { - cachedElement: tr, format: {}, height: 200, cells: [ @@ -1187,7 +1113,6 @@ describe('tableProcessor', () => { spanLeft: false, isHeader: false, dataset: {}, - cachedElement: td, }, ], }, @@ -1231,10 +1156,9 @@ describe('tableProcessor', () => { blockType: 'Table', widths: [100], dataset: {}, - cachedElement: table, + rows: [ { - cachedElement: tr, format: {}, height: 200, cells: [ @@ -1248,7 +1172,6 @@ describe('tableProcessor', () => { spanLeft: false, isHeader: false, dataset: {}, - cachedElement: td, }, ], }, @@ -1291,10 +1214,9 @@ describe('tableProcessor', () => { blockType: 'Table', widths: [100], dataset: {}, - cachedElement: table, + rows: [ { - cachedElement: tr, format: { backgroundColor: 'red', }, @@ -1308,7 +1230,6 @@ describe('tableProcessor', () => { spanLeft: false, isHeader: false, dataset: {}, - cachedElement: td, }, ], }, diff --git a/packages-content-model/roosterjs-content-model-dom/test/endToEndTest.ts b/packages-content-model/roosterjs-content-model-dom/test/endToEndTest.ts index 2e76b12a80a..a5693f7a613 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/endToEndTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/endToEndTest.ts @@ -29,13 +29,7 @@ describe('End to end test for DOM => Model', () => { const div1 = document.createElement('div'); div1.innerHTML = html; - const model = domToContentModel( - div1, - { - disableCacheElement: true, - }, - context - ); + const model = domToContentModel(div1, undefined, context); expect(model).toEqual(expectedModel); diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleBlockGroupChildrenTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleBlockGroupChildrenTest.ts index 75edd6a57bb..08c8857d360 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleBlockGroupChildrenTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleBlockGroupChildrenTest.ts @@ -20,11 +20,16 @@ describe('handleBlockGroupChildren', () => { beforeEach(() => { handleBlock = jasmine.createSpy('handleBlock').and.callFake(originalHandleBlock); - context = createModelToDomContext(undefined, { - modelHandlerOverride: { - block: handleBlock, + context = createModelToDomContext( + { + allowCacheElement: true, }, - }); + { + modelHandlerOverride: { + block: handleBlock, + }, + } + ); parent = document.createElement('div'); }); diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleDividerTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleDividerTest.ts index b0f915ebb59..a9520509234 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleDividerTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleDividerTest.ts @@ -7,6 +7,7 @@ describe('handleDivider', () => { beforeEach(() => { context = createModelToDomContext(); + context.allowCacheElement = true; }); it('Simple HR', () => { diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleEntityTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleEntityTest.ts index cb33c058ace..6424fedd84e 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleEntityTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleEntityTest.ts @@ -7,7 +7,9 @@ describe('handleEntity', () => { let context: ModelToDomContext; beforeEach(() => { - context = createModelToDomContext(); + context = createModelToDomContext({ + allowCacheElement: true, + }); spyOn(addDelimiters, 'default').and.callThrough(); }); diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleFormatContainerTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleFormatContainerTest.ts index b65a115364d..f4c8c748d43 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleFormatContainerTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleFormatContainerTest.ts @@ -1,14 +1,14 @@ -import { - ContentModelBlockGroup, - ContentModelHandler, - ModelToDomContext, -} from 'roosterjs-content-model-types'; import { createFormatContainer } from '../../../lib/modelApi/creators/createFormatContainer'; import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext'; import { createParagraph } from '../../../lib/modelApi/creators/createParagraph'; import { createText } from '../../../lib/modelApi/creators/createText'; import { handleBlockGroupChildren as originalHandleBlockGroupChildren } from '../../../lib/modelToDom/handlers/handleBlockGroupChildren'; import { handleFormatContainer } from '../../../lib/modelToDom/handlers/handleFormatContainer'; +import { + ContentModelBlockGroup, + ContentModelHandler, + ModelToDomContext, +} from 'roosterjs-content-model-types'; describe('handleFormatContainer', () => { let context: ModelToDomContext; @@ -16,11 +16,16 @@ describe('handleFormatContainer', () => { beforeEach(() => { handleBlockGroupChildren = jasmine.createSpy('handleBlockGroupChildren'); - context = createModelToDomContext(undefined, { - modelHandlerOverride: { - blockGroupChildren: handleBlockGroupChildren, + context = createModelToDomContext( + { + allowCacheElement: true, }, - }); + { + modelHandlerOverride: { + blockGroupChildren: handleBlockGroupChildren, + }, + } + ); }); it('Empty quote', () => { diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleParagraphTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleParagraphTest.ts index b775e17c1ea..32cbe0cb717 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleParagraphTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleParagraphTest.ts @@ -20,11 +20,16 @@ describe('handleParagraph', () => { beforeEach(() => { parent = document.createElement('div'); handleSegment = jasmine.createSpy('handleSegment'); - context = createModelToDomContext(undefined, { - modelHandlerOverride: { - segment: handleSegment, + context = createModelToDomContext( + { + allowCacheElement: true, }, - }); + { + modelHandlerOverride: { + segment: handleSegment, + }, + } + ); }); function runTest( diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleTableTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleTableTest.ts index 975f716ef5d..08642467bf4 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleTableTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleTableTest.ts @@ -11,7 +11,7 @@ describe('handleTable', () => { beforeEach(() => { spyOn(handleBlock, 'handleBlock'); - context = createModelToDomContext(); + context = createModelToDomContext({ allowCacheElement: true }); context.darkColorHandler = new DarkColorHandlerImpl(null!, s => 'darkMock: ' + s); }); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/createContentModel.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/createContentModel.ts index e21d011cca7..fb84d0350f9 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/createContentModel.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/createContentModel.ts @@ -17,7 +17,7 @@ export const createContentModel: CreateContentModel = (core, option) => { if (cachedModel && core.lifecycle.shadowEditFragment) { // When in shadow edit, use a cloned model so we won't pollute the cached one - cachedModel = cloneModel(cachedModel); + cachedModel = cloneModel(cachedModel, { includeCachedElement: true }); } return cachedModel || internalCreateContentModel(core, option); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/createEditorContext.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/createEditorContext.ts index 33af8997f97..69006c7e55d 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/createEditorContext.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/createEditorContext.ts @@ -13,6 +13,7 @@ export const createEditorContext: CreateEditorContext = core => { defaultFormat: defaultFormat, darkColorHandler: darkColorHandler, addDelimiterForEntity: addDelimiterForEntity, + allowCacheElement: true, }; checkRootRtl(contentDiv, context); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCopyPastePlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCopyPastePlugin.ts index 4a83e2813ec..49e0f8b610a 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCopyPastePlugin.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCopyPastePlugin.ts @@ -2,6 +2,7 @@ import paste from '../../publicApi/utils/paste'; import { cloneModel } from '../../modelApi/common/cloneModel'; import { contentModelToDom } from 'roosterjs-content-model-dom'; import { deleteSelection } from '../../modelApi/edit/deleteSelection'; +import { formatWithContentModel } from '../../publicApi/utils/formatWithContentModel'; import { getOnDeleteEntityCallback } from '../utils/handleKeyboardEventCommon'; import { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; import { iterateSelections } from '../../modelApi/selection/iterateSelections'; @@ -92,9 +93,7 @@ export default class ContentModelCopyPastePlugin implements PluginWithState { cleanUpAndRestoreSelection(tempDiv); editor.focus(); - if (selectionAfterPaste) { - this.editor?.select(selectionAfterPaste); - } + editor.select(selection); + if (isCut) { - editor.addUndoSnapshot(() => { - deleteSelection( - model, - getOnDeleteEntityCallback(editor as IContentModelEditor) - ); - this.editor?.setContentModel(model); - }, ChangeSource.Cut); + formatWithContentModel( + editor as IContentModelEditor, + 'cut', + model => { + deleteSelection( + model, + getOnDeleteEntityCallback(editor as IContentModelEditor) + ); + + return true; + }, + { + changeSource: ChangeSource.Cut, + } + ); } }); } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/cloneModel.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/cloneModel.ts index 378166ddde4..9c8a0fb3012 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/cloneModel.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/cloneModel.ts @@ -29,9 +29,26 @@ import type { /** * @internal + * Options for cloneModel API */ -export function cloneModel(model: ContentModelDocument): ContentModelDocument { - const newModel: ContentModelDocument = cloneBlockGroupBase(model); +export interface CloneModelOptions { + /** + * When pass false or not passed, the cloned model will not have cached element even they exist in original model. + * For entity and general model, a cloned wrapper element will be added into cloned model. So that the cloned model will be fully disconnected from the original one + * When pass true, cloned model will have the same cached element and element wrapper with the original model + * @default true + */ + includeCachedElement?: boolean; +} + +/** + * @internal + */ +export function cloneModel( + model: ContentModelDocument, + options?: CloneModelOptions +): ContentModelDocument { + const newModel: ContentModelDocument = cloneBlockGroupBase(model, options || {}); if (model.format) { newModel.format = Object.assign({}, model.format); @@ -40,37 +57,40 @@ export function cloneModel(model: ContentModelDocument): ContentModelDocument { return newModel; } -function cloneBlock(block: ContentModelBlock): ContentModelBlock { +function cloneBlock(block: ContentModelBlock, options: CloneModelOptions): ContentModelBlock { switch (block.blockType) { case 'BlockGroup': switch (block.blockGroupType) { case 'FormatContainer': - return cloneFormatContainer(block); + return cloneFormatContainer(block, options); case 'General': - return cloneGeneralBlock(block); + return cloneGeneralBlock(block, options); case 'ListItem': - return cloneListItem(block); + return cloneListItem(block, options); } break; case 'Divider': - return cloneDivider(block); + return cloneDivider(block, options); case 'Entity': - return cloneEntity(block); + return cloneEntity(block, options); case 'Paragraph': - return cloneParagraph(block); + return cloneParagraph(block, options); case 'Table': - return cloneTable(block); + return cloneTable(block, options); } } -function cloneSegment(segment: ContentModelSegment): ContentModelSegment { +function cloneSegment( + segment: ContentModelSegment, + options: CloneModelOptions +): ContentModelSegment { switch (segment.segmentType) { case 'Br': return cloneSegmentBase(segment); case 'Entity': - return cloneEntity(segment); + return cloneEntity(segment, options); case 'General': - return cloneGeneralSegment(segment); + return cloneGeneralSegment(segment, options); case 'Image': return cloneImage(segment); case 'SelectionMarker': @@ -108,13 +128,14 @@ function cloneBlockBase( } function cloneBlockGroupBase( - group: ContentModelBlockGroupBase + group: ContentModelBlockGroupBase, + options: CloneModelOptions ): ContentModelBlockGroupBase { const { blockGroupType, blocks } = group; return { blockGroupType: blockGroupType, - blocks: blocks.map(cloneBlock), + blocks: blocks.map(block => cloneBlock(block, options)), }; } @@ -141,24 +162,34 @@ function cloneSegmentBase( return newSegment; } -function cloneEntity(entity: ContentModelEntity): ContentModelEntity { +function cloneEntity(entity: ContentModelEntity, options: CloneModelOptions): ContentModelEntity { const { wrapper, isReadonly, type, id } = entity; return Object.assign( - { wrapper, isReadonly, type, id }, + { + wrapper: options.includeCachedElement + ? wrapper + : (wrapper.cloneNode(true /*deep*/) as HTMLElement), + isReadonly, + type, + id, + }, cloneBlockBase(entity), cloneSegmentBase(entity) ); } -function cloneParagraph(paragraph: ContentModelParagraph): ContentModelParagraph { +function cloneParagraph( + paragraph: ContentModelParagraph, + options: CloneModelOptions +): ContentModelParagraph { const { cachedElement, segments, isImplicit, decorator, segmentFormat } = paragraph; const newParagraph: ContentModelParagraph = Object.assign( { - cachedElement, + cachedElement: options.includeCachedElement ? cachedElement : undefined, isImplicit, - segments: segments.map(cloneSegment), + segments: segments.map(segment => cloneSegment(segment, options)), segmentFormat: segmentFormat ? { ...segmentFormat } : undefined, }, cloneBlockBase(paragraph), @@ -177,50 +208,65 @@ function cloneParagraph(paragraph: ContentModelParagraph): ContentModelParagraph return newParagraph; } -function cloneTable(table: ContentModelTable): ContentModelTable { +function cloneTable(table: ContentModelTable, options: CloneModelOptions): ContentModelTable { const { cachedElement, widths, rows } = table; return Object.assign( { - cachedElement, + cachedElement: options.includeCachedElement ? cachedElement : undefined, widths: Array.from(widths), - rows: rows.map(cloneTableRow), + rows: rows.map(row => cloneTableRow(row, options)), }, cloneBlockBase(table), cloneModelWithDataset(table) ); } -function cloneTableRow(row: ContentModelTableRow): ContentModelTableRow { +function cloneTableRow( + row: ContentModelTableRow, + options: CloneModelOptions +): ContentModelTableRow { const { height, cells, cachedElement } = row; return Object.assign( { height, - cachedElement, - cells: cells.map(cloneTableCell), + cachedElement: options.includeCachedElement ? cachedElement : undefined, + cells: cells.map(cell => cloneTableCell(cell, options)), }, cloneModelWithFormat(row) ); } -function cloneTableCell(cell: ContentModelTableCell): ContentModelTableCell { +function cloneTableCell( + cell: ContentModelTableCell, + options: CloneModelOptions +): ContentModelTableCell { const { cachedElement, isSelected, spanAbove, spanLeft, isHeader } = cell; return Object.assign( - { cachedElement, isSelected, spanAbove, spanLeft, isHeader }, - cloneBlockGroupBase(cell), + { + cachedElement: options.includeCachedElement ? cachedElement : undefined, + isSelected, + spanAbove, + spanLeft, + isHeader, + }, + cloneBlockGroupBase(cell, options), cloneModelWithFormat(cell), cloneModelWithDataset(cell) ); } -function cloneFormatContainer(container: ContentModelFormatContainer): ContentModelFormatContainer { +function cloneFormatContainer( + container: ContentModelFormatContainer, + options: CloneModelOptions +): ContentModelFormatContainer { const { tagName, cachedElement } = container; const newContainer: ContentModelFormatContainer = Object.assign( - { tagName, cachedElement }, + { tagName, cachedElement: options.includeCachedElement ? cachedElement : undefined }, cloneBlockBase(container), - cloneBlockGroupBase(container) + cloneBlockGroupBase(container, options) ); if (container.zeroFontSize) { @@ -230,7 +276,10 @@ function cloneFormatContainer(container: ContentModelFormatContainer): ContentMo return newContainer; } -function cloneListItem(item: ContentModelListItem): ContentModelListItem { +function cloneListItem( + item: ContentModelListItem, + options: CloneModelOptions +): ContentModelListItem { const { formatHolder, levels } = item; return Object.assign( @@ -239,7 +288,7 @@ function cloneListItem(item: ContentModelListItem): ContentModelListItem { levels: levels.map(cloneListLevel), }, cloneBlockBase(item), - cloneBlockGroupBase(item) + cloneBlockGroupBase(item, options) ); } @@ -248,16 +297,37 @@ function cloneListLevel(level: ContentModelListLevel): ContentModelListLevel { return Object.assign({ listType }, cloneModelWithFormat(level), cloneModelWithDataset(level)); } -function cloneDivider(divider: ContentModelDivider): ContentModelDivider { +function cloneDivider( + divider: ContentModelDivider, + options: CloneModelOptions +): ContentModelDivider { const { tagName, isSelected, cachedElement } = divider; - return Object.assign({ isSelected, tagName, cachedElement }, cloneBlockBase(divider)); + return Object.assign( + { + isSelected, + tagName, + cachedElement: options.includeCachedElement ? cachedElement : undefined, + }, + cloneBlockBase(divider) + ); } -function cloneGeneralBlock(general: ContentModelGeneralBlock): ContentModelGeneralBlock { +function cloneGeneralBlock( + general: ContentModelGeneralBlock, + options: CloneModelOptions +): ContentModelGeneralBlock { const { element } = general; - return Object.assign({ element }, cloneBlockBase(general), cloneBlockGroupBase(general)); + return Object.assign( + { + element: options.includeCachedElement + ? element + : (element.cloneNode(true /*deep*/) as HTMLElement), + }, + cloneBlockBase(general), + cloneBlockGroupBase(general, options) + ); } function cloneSelectionMarker(marker: ContentModelSelectionMarker): ContentModelSelectionMarker { @@ -274,8 +344,11 @@ function cloneImage(image: ContentModelImage): ContentModelImage { ); } -function cloneGeneralSegment(general: ContentModelGeneralSegment): ContentModelGeneralSegment { - return Object.assign(cloneGeneralBlock(general), cloneSegmentBase(general)); +function cloneGeneralSegment( + general: ContentModelGeneralSegment, + options: CloneModelOptions +): ContentModelGeneralSegment { + return Object.assign(cloneGeneralBlock(general, options), cloneSegmentBase(general)); } function cloneText(textSegment: ContentModelText): ContentModelText { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/paste.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/paste.ts index e7201027454..d8e6a3b6770 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/paste.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/paste.ts @@ -64,7 +64,6 @@ export default function paste( const pasteModel = domToContentModel(fragment, { ...pluginEvent.domToModelOption, - disableCacheElement: true, additionalFormatParsers: { ...pluginEvent.domToModelOption.additionalFormatParsers, block: [ diff --git a/packages-content-model/roosterjs-content-model-editor/test/domToModel/processors/reducedModelChildProcessorTest.ts b/packages-content-model/roosterjs-content-model-editor/test/domToModel/processors/reducedModelChildProcessorTest.ts index b51c67cb269..bd8766b45b7 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/domToModel/processors/reducedModelChildProcessorTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/domToModel/processors/reducedModelChildProcessorTest.ts @@ -8,7 +8,6 @@ describe('reducedModelChildProcessor', () => { beforeEach(() => { context = createDomToModelContext(undefined, { - disableCacheElement: true, processorOverride: { child: reducedModelChildProcessor, }, diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/createContentModelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/createContentModelTest.ts index 407cd547685..ef735e679a4 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/createContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/createContentModelTest.ts @@ -42,7 +42,7 @@ describe('createContentModel', () => { }); it('Reuse model, no cache, no shadow edit', () => { - const option: DomToModelOption = { disableCacheElement: false }; + const option: DomToModelOption = {}; core.cachedModel = undefined; @@ -53,7 +53,6 @@ describe('createContentModel', () => { expect(domToContentModelSpy).toHaveBeenCalledWith( mockedDiv, { - disableCacheElement: false, processorOverride: { table: tablePreProcessor, }, @@ -81,7 +80,9 @@ describe('createContentModel', () => { const model = createContentModel(core, option); - expect(cloneModelSpy).toHaveBeenCalledWith(mockedCachedMode); + expect(cloneModelSpy).toHaveBeenCalledWith(mockedCachedMode, { + includeCachedElement: true, + }); expect(createEditorContext).not.toHaveBeenCalled(); expect(getSelectionRangeEx).not.toHaveBeenCalled(); expect(domToContentModelSpy).not.toHaveBeenCalled(); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/createEditorContextTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/createEditorContextTest.ts index 659c83de790..f0b6708b0a3 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/createEditorContextTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/createEditorContextTest.ts @@ -36,6 +36,7 @@ describe('createEditorContext', () => { darkColorHandler, defaultFormat, addDelimiterForEntity, + allowCacheElement: true, }); }); }); @@ -87,6 +88,7 @@ describe('createEditorContext - checkZoomScale', () => { darkColorHandler, addDelimiterForEntity, zoomScale: 1, + allowCacheElement: true, }); }); @@ -104,6 +106,7 @@ describe('createEditorContext - checkZoomScale', () => { darkColorHandler, addDelimiterForEntity, zoomScale: 2, + allowCacheElement: true, }); }); @@ -121,6 +124,7 @@ describe('createEditorContext - checkZoomScale', () => { darkColorHandler, addDelimiterForEntity, zoomScale: 0.5, + allowCacheElement: true, }); }); }); @@ -170,6 +174,7 @@ describe('createEditorContext - checkRootDir', () => { defaultFormat, darkColorHandler, addDelimiterForEntity, + allowCacheElement: true, }); }); @@ -186,6 +191,7 @@ describe('createEditorContext - checkRootDir', () => { darkColorHandler, addDelimiterForEntity, isRootRtl: true, + allowCacheElement: true, }); }); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCopyPastePluginTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCopyPastePluginTest.ts index 7c0cd70f0c5..0ea13d4a601 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCopyPastePluginTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCopyPastePluginTest.ts @@ -177,10 +177,7 @@ describe('ContentModelCopyPastePlugin |', () => { document, div, pasteModelValue, - { - isDarkMode: false, - darkColorHandler: darkColorHandler, - }, + undefined, { onNodeCreated } ); expect(createContentModelSpy).toHaveBeenCalled(); @@ -237,10 +234,7 @@ describe('ContentModelCopyPastePlugin |', () => { document, div, pasteModelValue, - { - isDarkMode: false, - darkColorHandler: darkColorHandler, - }, + undefined, { onNodeCreated } ); expect(createContentModelSpy).toHaveBeenCalled(); @@ -292,10 +286,7 @@ describe('ContentModelCopyPastePlugin |', () => { document, div, pasteModelValue, - { - isDarkMode: false, - darkColorHandler: darkColorHandler, - }, + undefined, { onNodeCreated } ); expect(createContentModelSpy).toHaveBeenCalled(); @@ -380,10 +371,7 @@ describe('ContentModelCopyPastePlugin |', () => { document, div, pasteModelValue, - { - isDarkMode: false, - darkColorHandler: darkColorHandler, - }, + undefined, { onNodeCreated } ); expect(createContentModelSpy).toHaveBeenCalled(); @@ -398,7 +386,9 @@ describe('ContentModelCopyPastePlugin |', () => { // On Cut Spy expect(undoSnapShotSpy).toHaveBeenCalled(); - expect(setContentModelSpy).toHaveBeenCalledWith(modelValue, undefined); + expect(setContentModelSpy).toHaveBeenCalledWith(modelValue, { + onNodeCreated: undefined, + }); }); it('Selection not Collapsed and table selection', () => { @@ -437,10 +427,7 @@ describe('ContentModelCopyPastePlugin |', () => { document, div, pasteModelValue, - { - isDarkMode: false, - darkColorHandler: darkColorHandler, - }, + undefined, { onNodeCreated } ); expect(createContentModelSpy).toHaveBeenCalled(); @@ -457,7 +444,9 @@ describe('ContentModelCopyPastePlugin |', () => { // On Cut Spy expect(undoSnapShotSpy).toHaveBeenCalled(); expect(deleteSelectionsFile.deleteSelection).toHaveBeenCalled(); - expect(setContentModelSpy).toHaveBeenCalledWith(modelValue, undefined); + expect(setContentModelSpy).toHaveBeenCalledWith(modelValue, { + onNodeCreated: undefined, + }); }); it('Selection not Collapsed and image selection', () => { @@ -492,10 +481,7 @@ describe('ContentModelCopyPastePlugin |', () => { document, div, pasteModelValue, - { - isDarkMode: false, - darkColorHandler: darkColorHandler, - }, + undefined, { onNodeCreated } ); expect(createContentModelSpy).toHaveBeenCalled(); @@ -511,7 +497,9 @@ describe('ContentModelCopyPastePlugin |', () => { // On Cut Spy expect(undoSnapShotSpy).toHaveBeenCalled(); expect(deleteSelectionsFile.deleteSelection).toHaveBeenCalled(); - expect(setContentModelSpy).toHaveBeenCalledWith(modelValue, undefined); + expect(setContentModelSpy).toHaveBeenCalledWith(modelValue, { + onNodeCreated: undefined, + }); }); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromExcelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromExcelTest.ts index f449f977639..f6e10ff5bf2 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromExcelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromExcelTest.ts @@ -18,9 +18,6 @@ export function initEditor(id: string) { let options: ContentModelEditorOptions = { plugins: [new ContentModelPastePlugin()], experimentalFeatures: [ExperimentalFeatures.ContentModelPaste], - defaultDomToModelOptions: { - disableCacheElement: true, - }, }; let editor = new ContentModelEditor(node as HTMLDivElement, options); @@ -80,7 +77,6 @@ describe(ID, () => { processorOverride: { table: tableProcessor, }, - disableCacheElement: true, }); expect(model).toEqual({ diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/processPastedContentFromExcelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/processPastedContentFromExcelTest.ts index e0cc430d002..bc27ff924c2 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/processPastedContentFromExcelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/processPastedContentFromExcelTest.ts @@ -24,7 +24,6 @@ describe('processPastedContentFromExcelTest', () => { const model = domToContentModel(fragment, { ...event.domToModelOption, - disableCacheElement: true, }); if (expectedModel) { expect(model).toEqual(expectedModel); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/processPastedContentFromWacTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/processPastedContentFromWacTest.ts index 5053a5dba1a..dbecb4da44a 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/processPastedContentFromWacTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/processPastedContentFromWacTest.ts @@ -126,7 +126,6 @@ describe('wordOnlineHandler', () => { const model = domToContentModel(fragment, { ...event.domToModelOption, - disableCacheElement: true, }); if (expectedModel) { expect(model).toEqual(expectedModel); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/processPastedContentFromWordDesktopTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/processPastedContentFromWordDesktopTest.ts index b8f53cbf6b3..ff88b07d850 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/processPastedContentFromWordDesktopTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/processPastedContentFromWordDesktopTest.ts @@ -27,7 +27,6 @@ describe('processPastedContentFromWordDesktopTest', () => { const model = domToContentModel(fragment, { ...event.domToModelOption, - disableCacheElement: true, }); if (expectedModel) { expect(model).toEqual(expectedModel); diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/cloneModelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/cloneModelTest.ts index c866275e0b7..4299f581179 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/cloneModelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/cloneModelTest.ts @@ -2,7 +2,7 @@ import { cloneModel } from '../../../lib/modelApi/common/cloneModel'; import { ContentModelDocument } from 'roosterjs-content-model-types'; describe('cloneModel', () => { - function compareObjects(o1: any, o2: any) { + function compareObjects(o1: any, o2: any, allowCache: boolean, path: string = '/') { expect(typeof o2).toBe(typeof o1); if (typeof o1 == 'boolean' || typeof o1 == 'number' || typeof o1 == 'string') { @@ -12,23 +12,41 @@ describe('cloneModel', () => { } else if (typeof o1 == 'object') { if (Array.isArray(o1)) { expect(Array.isArray(o2)).toBeTrue(); - expect(o2).not.toBe(o1); - expect(o2.length).toBe(o1.length); + expect(o2).not.toBe(o1, path); + expect(o2.length).toBe(o1.length, path); for (let i = 0; i < o1.length; i++) { - compareObjects(o1[i], o2[i]); + compareObjects(o1[i], o2[i], allowCache, path + `[${i}]/`); } } else if (o1 instanceof Node) { - expect(o2).toBe(o1); + expect(o2).toBe(o1, path); } else if (o1 === null) { - expect(o2).toBeNull(); + expect(o2).toBeNull(path); } else { - expect(o2).not.toBe(o1); + expect(o2).not.toBe(o1, path); const keys = new Set([...Object.keys(o1), ...Object.keys(o2)]); keys.forEach(key => { - compareObjects(o1[key], o2[key]); + if (allowCache) { + compareObjects(o1[key], o2[key], allowCache, path + key + '/'); + } else { + switch (key) { + case 'cachedElement': + expect(o2[key]).toBeUndefined(path); + break; + + case 'wrapper': + case 'element': + expect(o2[key]).not.toBe(o1[key], path); + expect(o2[key]).toEqual(o1[key], path); + break; + + default: + compareObjects(o1[key], o2[key], allowCache, path + key + '/'); + break; + } + } }); } } else { @@ -37,9 +55,11 @@ describe('cloneModel', () => { } function runTest(model: ContentModelDocument) { - const clone = cloneModel(model); + const cloneWithCache = cloneModel(model, { includeCachedElement: true }); + const cloneWithoutCache = cloneModel(model); - compareObjects(model, clone); + compareObjects(model, cloneWithCache, true); + compareObjects(model, cloneWithoutCache, false); } it('Empty model', () => { diff --git a/packages-content-model/roosterjs-content-model-types/lib/context/DomToModelFormatContext.ts b/packages-content-model/roosterjs-content-model-types/lib/context/DomToModelFormatContext.ts index c5934c0e6a5..303965d7a31 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/context/DomToModelFormatContext.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/context/DomToModelFormatContext.ts @@ -44,12 +44,6 @@ export interface DomToModelFormatContext { * Context of list that is currently processing */ listFormat: DomToModelListFormat; - - /** - * Whether put the source element into Content Model when possible. - * When pass true, this cached element will be used to create DOM tree back when convert Content Model to DOM - */ - allowCacheElement?: boolean; } /** diff --git a/packages-content-model/roosterjs-content-model-types/lib/context/DomToModelOption.ts b/packages-content-model/roosterjs-content-model-types/lib/context/DomToModelOption.ts index 9a40f7e7906..7814b6f8ad4 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/context/DomToModelOption.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/context/DomToModelOption.ts @@ -28,10 +28,4 @@ export interface DomToModelOption { * Provide additional format parsers for each format type */ additionalFormatParsers?: Partial; - - /** - * Whether put the source element into Content Model when possible. - * When pass true, this cached element will be used to create DOM tree back when convert Content Model to DOM - */ - disableCacheElement?: boolean; } diff --git a/packages-content-model/roosterjs-content-model-types/lib/context/EditorContext.ts b/packages-content-model/roosterjs-content-model-types/lib/context/EditorContext.ts index b6411e231d2..09999c9b121 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/context/EditorContext.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/context/EditorContext.ts @@ -34,4 +34,10 @@ export interface EditorContext { * Whether the content is in Right-to-left from root level */ isRootRtl?: boolean; + + /** + * Whether put the source element into Content Model when possible. + * When pass true, this cached element will be used to create DOM tree back when convert Content Model to DOM + */ + allowCacheElement?: boolean; } From 795c00d740c357752db89509b8c84cbc5b8d3972 Mon Sep 17 00:00:00 2001 From: Bryan Valverde U Date: Tue, 8 Aug 2023 10:04:10 -0600 Subject: [PATCH 02/75] Skip Trigger plugin event on plain text paste (#2011) * init * remove unneeded change * Update return type --- .../lib/publicApi/utils/paste.ts | 39 ++-- .../test/publicApi/utils/pasteTest.ts | 191 ++++++++++++++++-- .../lib/coreApi/createPasteFragment.ts | 6 +- .../test/coreApi/createPasteFragmentTest.ts | 21 ++ 4 files changed, 226 insertions(+), 31 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/paste.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/paste.ts index d8e6a3b6770..0d4b921dda7 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/paste.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/paste.ts @@ -53,7 +53,11 @@ export default function paste( getPasteType(pasteAsText, applyCurrentFormat, pasteAsImage) ); - const { pluginEvent, fragment } = triggerPluginEventAndCreatePasteFragment( + const { + domToModelOption, + fragment, + customizedMerge, + } = triggerPluginEventAndCreatePasteFragment( editor, clipboardData, null /* position */, @@ -63,15 +67,15 @@ export default function paste( ); const pasteModel = domToContentModel(fragment, { - ...pluginEvent.domToModelOption, + ...domToModelOption, additionalFormatParsers: { - ...pluginEvent.domToModelOption.additionalFormatParsers, + ...domToModelOption.additionalFormatParsers, block: [ - ...(pluginEvent.domToModelOption.additionalFormatParsers?.block || []), + ...(domToModelOption.additionalFormatParsers?.block || []), ...(applyCurrentFormat ? [blockElementParser] : []), ], listLevel: [ - ...(pluginEvent.domToModelOption.additionalFormatParsers?.listLevel || []), + ...(domToModelOption.additionalFormatParsers?.listLevel || []), ...(applyCurrentFormat ? [blockElementParser] : []), ], }, @@ -82,8 +86,8 @@ export default function paste( editor, 'Paste', model => { - if (pluginEvent.customizedMerge) { - pluginEvent.customizedMerge(model, pasteModel); + if (customizedMerge) { + customizedMerge(model, pasteModel); } else { mergeModel(model, pasteModel, getOnDeleteEntityCallback(editor), { mergeFormat: applyCurrentFormat ? 'keepSourceEmphasisFormat' : 'none', @@ -120,7 +124,7 @@ function createBeforePasteEventData( htmlAfter: '', htmlAttributes: {}, domToModelOption: {}, - pasteType: pasteType, + pasteType, }; } @@ -135,7 +139,7 @@ function triggerPluginEventAndCreatePasteFragment( pasteAsText: boolean, pasteAsImage: boolean, eventData: ContentModelBeforePasteEventData -): { pluginEvent: ContentModelBeforePasteEvent; fragment: DocumentFragment } { +): ContentModelBeforePasteEventData { const event = { eventType: PluginEventType.BeforePaste, ...eventData, @@ -163,17 +167,20 @@ function triggerPluginEventAndCreatePasteFragment( handleTextPaste(text, position, fragment); } - // Step 4: Trigger BeforePasteEvent so that plugins can do proper change before paste - const pluginEvent = editor.triggerPluginEvent( - PluginEventType.BeforePaste, - eventData, - true /* broadcast */ - ) as ContentModelBeforePasteEvent; + let pluginEvent: ContentModelBeforePasteEvent | undefined = undefined; + // Step 4: Trigger BeforePasteEvent so that plugins can do proper change before paste, when the type of paste is different than Plain Text + if (event.pasteType !== PasteType.AsPlainText) { + pluginEvent = editor.triggerPluginEvent( + PluginEventType.BeforePaste, + eventData, + true /* broadcast */ + ) as ContentModelBeforePasteEvent; + } // Step 5. Sanitize the fragment before paste to make sure the content is safe sanitizePasteContent(event, position); - return { fragment, pluginEvent }; + return pluginEvent || eventData; } /** diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts index 885815ae29f..99ace99056c 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts @@ -1,10 +1,21 @@ +import * as addParserF from '../../../lib/editor/plugins/PastePlugin/utils/addParser'; import * as domToContentModel from 'roosterjs-content-model-dom/lib/domToModel/domToContentModel'; +import * as ExcelF from '../../../lib/editor/plugins/PastePlugin/Excel/processPastedContentFromExcel'; +import * as getPasteSourceF from 'roosterjs-editor-dom/lib/pasteSourceValidations/getPasteSource'; import * as mergeModelFile from '../../../lib/modelApi/common/mergeModel'; -import paste from '../../../lib/publicApi/utils/paste'; -import { ClipboardData, PasteType } from 'roosterjs-editor-types'; +import * as pasteF from '../../../lib/publicApi/utils/paste'; +import * as PPT from '../../../lib/editor/plugins/PastePlugin/PowerPoint/processPastedContentFromPowerPoint'; +import * as setProcessorF from '../../../lib/editor/plugins/PastePlugin/utils/setProcessor'; +import * as WacComponents from '../../../lib/editor/plugins/PastePlugin/WacComponents/processPastedContentWacComponents'; +import * as WordDesktopFile from '../../../lib/editor/plugins/PastePlugin/WordDesktop/processPastedContentFromWordDesktop'; +import ContentModelEditor from '../../../lib/editor/ContentModelEditor'; +import ContentModelPastePlugin from '../../../lib/editor/plugins/PastePlugin/ContentModelPastePlugin'; +import { ClipboardData, KnownPasteSourceType, PasteType } from 'roosterjs-editor-types'; import { ContentModelDocument } from 'roosterjs-content-model-types'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; +let clipboardData: ClipboardData; + describe('Paste ', () => { let editor: IContentModelEditor; let addUndoSnapshot: jasmine.Spy; @@ -25,18 +36,16 @@ describe('Paste ', () => { let div: HTMLDivElement; - const clipboardData: ClipboardData = { - types: ['image/png', 'text/html'], - text: '', - image: null!, - rawHtml: '\r\nteststringteststring\r\n', - customValues: {}, - imageDataUri: null!, - }; - beforeEach(() => { spyOn(domToContentModel, 'domToContentModel').and.callThrough(); - + clipboardData = { + types: ['image/png', 'text/html'], + text: '', + image: null!, + rawHtml: '\r\nteststringteststring\r\n', + customValues: {}, + imageDataUri: null!, + }; div = document.createElement('div'); document.body.appendChild(div); mockedModel = ({} as any) as ContentModelDocument; @@ -98,7 +107,7 @@ describe('Paste ', () => { }); it('Execute', () => { - paste(editor, clipboardData, false, false, false); + pasteF.default(editor, clipboardData, false, false, false); expect(setContentModel).toHaveBeenCalled(); expect(focus).toHaveBeenCalled(); @@ -111,4 +120,160 @@ describe('Paste ', () => { expect(mockedModel).toEqual(mockedMergeModel); expect(clipboardData).toEqual(undoSnapshotResult); }); + + it('Execute | As plain text', () => { + pasteF.default(editor, clipboardData, true /* asPlainText */, false, false); + + expect(setContentModel).toHaveBeenCalled(); + expect(focus).toHaveBeenCalled(); + expect(addUndoSnapshot).toHaveBeenCalled(); + expect(getFocusedPosition).not.toHaveBeenCalled(); + expect(getContent).toHaveBeenCalled(); + expect(triggerPluginEvent).not.toHaveBeenCalled(); + expect(getDocument).toHaveBeenCalled(); + expect(getTrustedHTMLHandler).toHaveBeenCalled(); + expect(mockedModel).toEqual(mockedMergeModel); + expect(clipboardData).toEqual(undoSnapshotResult); + }); +}); + +describe('paste with content model & paste plugin', () => { + let editor: ContentModelEditor | undefined; + let div: HTMLDivElement | undefined; + + beforeEach(() => { + div = document.createElement('div'); + document.body.appendChild(div); + editor = new ContentModelEditor(div, { + plugins: [new ContentModelPastePlugin()], + }); + spyOn(addParserF, 'default').and.callThrough(); + spyOn(setProcessorF, 'setProcessor').and.callThrough(); + clipboardData = { + types: ['image/png', 'text/html'], + text: '', + image: null!, + rawHtml: '\r\nteststringteststring\r\n', + customValues: {}, + imageDataUri: null!, + }; + }); + + afterEach(() => { + editor?.dispose(); + editor = undefined; + div?.remove(); + div = undefined; + }); + + it('Word Desktop', () => { + spyOn(getPasteSourceF, 'default').and.returnValue(KnownPasteSourceType.WordDesktop); + spyOn(WordDesktopFile, 'processPastedContentFromWordDesktop').and.callThrough(); + + pasteF.default(editor!, clipboardData); + + expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(1); + expect(addParserF.default).toHaveBeenCalledTimes(4); + expect(WordDesktopFile.processPastedContentFromWordDesktop).toHaveBeenCalledTimes(1); + }); + + it('Word Online', () => { + spyOn(getPasteSourceF, 'default').and.returnValue(KnownPasteSourceType.WacComponents); + spyOn(WacComponents, 'processPastedContentWacComponents').and.callThrough(); + + pasteF.default(editor!, clipboardData); + + expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(4); + expect(addParserF.default).toHaveBeenCalledTimes(5); + expect(WacComponents.processPastedContentWacComponents).toHaveBeenCalledTimes(1); + }); + + it('Excel Online', () => { + spyOn(getPasteSourceF, 'default').and.returnValue(KnownPasteSourceType.ExcelOnline); + spyOn(ExcelF, 'processPastedContentFromExcel').and.callThrough(); + + pasteF.default(editor!, clipboardData); + + expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(0); + expect(addParserF.default).toHaveBeenCalledTimes(2); + expect(ExcelF.processPastedContentFromExcel).toHaveBeenCalledTimes(1); + }); + + it('Excel Desktop', () => { + spyOn(getPasteSourceF, 'default').and.returnValue(KnownPasteSourceType.ExcelDesktop); + spyOn(ExcelF, 'processPastedContentFromExcel').and.callThrough(); + + pasteF.default(editor!, clipboardData); + + expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(0); + expect(addParserF.default).toHaveBeenCalledTimes(2); + expect(ExcelF.processPastedContentFromExcel).toHaveBeenCalledTimes(1); + }); + + it('PowerPoint', () => { + spyOn(getPasteSourceF, 'default').and.returnValue(KnownPasteSourceType.PowerPointDesktop); + spyOn(PPT, 'processPastedContentFromPowerPoint').and.callThrough(); + + pasteF.default(editor!, clipboardData); + + expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(0); + expect(addParserF.default).toHaveBeenCalledTimes(1); + expect(PPT.processPastedContentFromPowerPoint).toHaveBeenCalledTimes(1); + }); + + // Plain Text + it('Word Desktop | Plain Text', () => { + spyOn(getPasteSourceF, 'default').and.returnValue(KnownPasteSourceType.WordDesktop); + spyOn(WordDesktopFile, 'processPastedContentFromWordDesktop').and.callThrough(); + + pasteF.default(editor!, clipboardData, true /* pasteAsText */); + + expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(0); + expect(addParserF.default).toHaveBeenCalledTimes(0); + expect(WordDesktopFile.processPastedContentFromWordDesktop).toHaveBeenCalledTimes(0); + }); + + it('Word Online | Plain Text', () => { + spyOn(getPasteSourceF, 'default').and.returnValue(KnownPasteSourceType.WacComponents); + spyOn(WacComponents, 'processPastedContentWacComponents').and.callThrough(); + + pasteF.default(editor!, clipboardData, true /* pasteAsText */); + + expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(0); + expect(addParserF.default).toHaveBeenCalledTimes(0); + expect(WacComponents.processPastedContentWacComponents).toHaveBeenCalledTimes(0); + }); + + it('Excel Online | Plain Text', () => { + spyOn(getPasteSourceF, 'default').and.returnValue(KnownPasteSourceType.ExcelOnline); + spyOn(ExcelF, 'processPastedContentFromExcel').and.callThrough(); + + pasteF.default(editor!, clipboardData, true /* pasteAsText */); + + expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(0); + expect(addParserF.default).toHaveBeenCalledTimes(0); + expect(ExcelF.processPastedContentFromExcel).toHaveBeenCalledTimes(0); + }); + + it('Excel Desktop | Plain Text', () => { + spyOn(getPasteSourceF, 'default').and.returnValue(KnownPasteSourceType.ExcelDesktop); + spyOn(ExcelF, 'processPastedContentFromExcel').and.callThrough(); + + pasteF.default(editor!, clipboardData, true /* pasteAsText */); + + expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(0); + expect(addParserF.default).toHaveBeenCalledTimes(0); + expect(ExcelF.processPastedContentFromExcel).toHaveBeenCalledTimes(0); + }); + + it('PowerPoint | Plain Text', () => { + spyOn(getPasteSourceF, 'default').and.returnValue(KnownPasteSourceType.PowerPointDesktop); + spyOn(PPT, 'processPastedContentFromPowerPoint').and.callThrough(); + + pasteF.default(editor!, clipboardData, true /* pasteAsText */); + + expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(0); + expect(addParserF.default).toHaveBeenCalledTimes(0); + expect(PPT.processPastedContentFromPowerPoint).toHaveBeenCalledTimes(0); + }); }); diff --git a/packages/roosterjs-editor-core/lib/coreApi/createPasteFragment.ts b/packages/roosterjs-editor-core/lib/coreApi/createPasteFragment.ts index e687aa251ef..7a1b98e8bc3 100644 --- a/packages/roosterjs-editor-core/lib/coreApi/createPasteFragment.ts +++ b/packages/roosterjs-editor-core/lib/coreApi/createPasteFragment.ts @@ -125,8 +125,10 @@ function createFragmentFromClipboardData( handleTextPaste(text, position, fragment); } - // Step 4: Trigger BeforePasteEvent so that plugins can do proper change before paste - core.api.triggerEvent(core, event, true /*broadcast*/); + // Step 4: Trigger BeforePasteEvent so that plugins can do proper change before paste, when the type of paste is different than Plain Text + if (event.pasteType !== PasteType.AsPlainText) { + core.api.triggerEvent(core, event, true /*broadcast*/); + } // Step 5. Sanitize the fragment before paste to make sure the content is safe sanitizePasteContent(event, position); diff --git a/packages/roosterjs-editor-core/test/coreApi/createPasteFragmentTest.ts b/packages/roosterjs-editor-core/test/coreApi/createPasteFragmentTest.ts index 8cf5f6fb80c..60df016dbb3 100644 --- a/packages/roosterjs-editor-core/test/coreApi/createPasteFragmentTest.ts +++ b/packages/roosterjs-editor-core/test/coreApi/createPasteFragmentTest.ts @@ -480,6 +480,27 @@ describe('createPasteFragment', () => { expect(html.trim()).toBe('teststringteststring'); expect(clipboardData.htmlFirstLevelChildTags).toEqual(['', 'IMG', '']); }); + + it('Skip triggerEvent on Plain text paste', () => { + const triggerEvent = jasmine.createSpy(); + const core = createEditorCore(div, { + coreApiOverride: { + triggerEvent, + }, + }); + + const clipboardData: ClipboardData = { + types: ['image/png', 'text/html'], + text: '', + image: null, + rawHtml: '\r\nteststringteststring\r\n', + customValues: {}, + imageDataUri: null, + }; + createPasteFragment(core, clipboardData, null, true /* plainText */, false, false); + + expect(triggerEvent).not.toHaveBeenCalled(); + }); }); function getHTML(fragment: DocumentFragment) { From 468050694a28a117990aaf96de6d9bc4ea83080e Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Tue, 8 Aug 2023 09:47:10 -0700 Subject: [PATCH 03/75] Fix 218869: Do not allow dragging on readonly content (#2010) * Fix 218869: Do not allow dragging readonly content * fix test --- .../lib/corePlugins/DOMEventPlugin.ts | 11 ++++- .../lib/corePlugins/EntityPlugin.ts | 16 -------- .../test/corePlugins/entityPluginTest.ts | 40 ------------------- 3 files changed, 10 insertions(+), 57 deletions(-) diff --git a/packages/roosterjs-editor-core/lib/corePlugins/DOMEventPlugin.ts b/packages/roosterjs-editor-core/lib/corePlugins/DOMEventPlugin.ts index f5f964b74a6..2c1fa9da916 100644 --- a/packages/roosterjs-editor-core/lib/corePlugins/DOMEventPlugin.ts +++ b/packages/roosterjs-editor-core/lib/corePlugins/DOMEventPlugin.ts @@ -84,7 +84,8 @@ export default class DOMEventPlugin implements PluginWithState { + const dragEvent = e as DragEvent; + const element = this.editor?.getElementAtCursor('*', dragEvent.target as Node); + + if (element && !element.isContentEditable) { + dragEvent.preventDefault(); + } + }; private onDrop = () => { this.editor?.runAsync(editor => { editor.addUndoSnapshot(() => {}, ChangeSource.Drop); diff --git a/packages/roosterjs-editor-core/lib/corePlugins/EntityPlugin.ts b/packages/roosterjs-editor-core/lib/corePlugins/EntityPlugin.ts index 3453ed07081..434edcdcfa5 100644 --- a/packages/roosterjs-editor-core/lib/corePlugins/EntityPlugin.ts +++ b/packages/roosterjs-editor-core/lib/corePlugins/EntityPlugin.ts @@ -64,7 +64,6 @@ const REMOVE_ENTITY_OPERATIONS: (EntityOperation | CompatibleEntityOperation)[] export default class EntityPlugin implements PluginWithState { private editor: IEditor | null = null; private state: EntityPluginState; - private disposer: (() => void) | null = null; /** * Construct a new instance of EntityPlugin @@ -88,15 +87,12 @@ export default class EntityPlugin implements PluginWithState */ initialize(editor: IEditor) { this.editor = editor; - this.disposer = this.editor.addDomEventHandler('dragstart', this.onDragStart); } /** * Dispose this plugin */ dispose() { - this.disposer?.(); - this.disposer = null; this.editor = null; this.state.entityMap = {}; } @@ -277,18 +273,6 @@ export default class EntityPlugin implements PluginWithState }); } - private onDragStart = (e: Event) => { - const dragEvent = e as DragEvent; - const entityWrapper = this.editor?.getElementAtCursor( - getEntitySelector(), - dragEvent.target as Node - ); - - if (entityWrapper && getEntityFromElement(entityWrapper)?.isReadonly) { - dragEvent.preventDefault(); - } - }; - private checkRemoveEntityForRange(event: Event) { const editableEntityElements: HTMLElement[] = []; const selector = getEntitySelector(); diff --git a/packages/roosterjs-editor-core/test/corePlugins/entityPluginTest.ts b/packages/roosterjs-editor-core/test/corePlugins/entityPluginTest.ts index 63bab07d794..219f1eaa072 100644 --- a/packages/roosterjs-editor-core/test/corePlugins/entityPluginTest.ts +++ b/packages/roosterjs-editor-core/test/corePlugins/entityPluginTest.ts @@ -20,25 +20,17 @@ describe('EntityPlugin', () => { let triggerPluginEvent: jasmine.Spy; let state: EntityPluginState; let editor: IEditor; - let addDomEventHandler: jasmine.Spy; - let onDragStartFunc: (e: any) => void; beforeEach(() => { triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); plugin = new EntityPlugin(); state = plugin.getState(); - addDomEventHandler = jasmine - .createSpy('addDomEventHandler') - .and.callFake((eventName, onDragStart) => { - onDragStartFunc = onDragStart; - }); editor = ({ getDocument: () => document, getElementAtCursor: (selector: string, node: Node) => node, addContentEditFeature: () => {}, triggerPluginEvent, isFeatureEnabled: () => false, - addDomEventHandler, }); plugin.initialize(editor); }); @@ -662,36 +654,4 @@ describe('EntityPlugin', () => { }); }); }); - - it('Do Not allow dragging readonly entity', () => { - const wrapper = document.createElement('div'); - const preventDefault = jasmine.createSpy('preventDefault'); - - commitEntity.default(wrapper, 'TestEntity', true); - - expect(addDomEventHandler).toHaveBeenCalledTimes(1); - expect(addDomEventHandler.calls.argsFor(0)[0]).toBe('dragstart'); - - onDragStartFunc({ - target: wrapper, - preventDefault, - }); - - expect(preventDefault).toHaveBeenCalledTimes(1); - }); - - it('Still allow dragging when click normal node', () => { - const wrapper = document.createElement('div'); - const preventDefault = jasmine.createSpy('preventDefault'); - - expect(addDomEventHandler).toHaveBeenCalledTimes(1); - expect(addDomEventHandler.calls.argsFor(0)[0]).toBe('dragstart'); - - onDragStartFunc({ - target: wrapper, - preventDefault, - }); - - expect(preventDefault).not.toHaveBeenCalled(); - }); }); From aa49e6779f85f429168018515a8270d6d2ab3515 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Tue, 8 Aug 2023 09:54:35 -0700 Subject: [PATCH 04/75] Content Model: Fix 194024 and 220289 (#2012) --- .../domToModel/context/defaultProcessors.ts | 1 + .../segment/fontSizeFormatHandler.ts | 53 ++++++++- .../utils/parseValueWithUnit.ts | 26 +++-- .../segment/fontSizeFormatHandlerTest.ts | 108 ++++++++++++++++++ .../utils/parseValueWithUnitTest.ts | 61 +++++++++- 5 files changed, 236 insertions(+), 13 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/context/defaultProcessors.ts b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/context/defaultProcessors.ts index a6d27bb0ab4..631dc2a3f51 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/context/defaultProcessors.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/context/defaultProcessors.ts @@ -45,6 +45,7 @@ export const defaultProcessorMap: ElementProcessorMap = { p: pProcessor, pre: formatContainerProcessor, s: knownElementProcessor, + section: knownElementProcessor, span: knownElementProcessor, strike: knownElementProcessor, strong: knownElementProcessor, diff --git a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/segment/fontSizeFormatHandler.ts b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/segment/fontSizeFormatHandler.ts index 4b5405630a0..bccfa63fd0e 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/segment/fontSizeFormatHandler.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/segment/fontSizeFormatHandler.ts @@ -1,6 +1,7 @@ import { FontSizeFormat } from 'roosterjs-content-model-types'; import { FormatHandler } from '../FormatHandler'; import { isSuperOrSubScript } from './superOrSubScriptFormatHandler'; +import { parseValueWithUnit } from '../utils/parseValueWithUnit'; /** * @internal @@ -13,7 +14,11 @@ export const fontSizeFormatHandler: FormatHandler = { // when font size is 'smaller' and the style is for superscript/subscript, // the font size will be handled by superOrSubScript handler if (fontSize && !isSuperOrSubScript(fontSize, verticalAlign) && fontSize != 'inherit') { - format.fontSize = fontSize; + if (element.style.fontSize) { + format.fontSize = normalizeFontSize(fontSize, context.segmentFormat.fontSize); + } else if (defaultStyle.fontSize) { + format.fontSize = fontSize; + } } }, apply: (format, element, context) => { @@ -22,3 +27,49 @@ export const fontSizeFormatHandler: FormatHandler = { } }, }; + +// https://developer.mozilla.org/en-US/docs/Web/CSS/font-size +const KnownFontSizes: Record = { + 'xx-small': '6.75pt', + 'x-small': '7.5pt', + small: '9.75pt', + medium: '12pt', + large: '13.5pt', + 'x-large': '18pt', + 'xx-large': '24pt', + 'xxx-large': '36pt', +}; + +function normalizeFontSize(fontSize: string, contextFont: string | undefined): string | undefined { + const knownFontSize = KnownFontSizes[fontSize]; + + if (knownFontSize) { + return knownFontSize; + } else if ( + fontSize == 'smaller' || + fontSize == 'larger' || + fontSize.endsWith('em') || + fontSize.endsWith('%') + ) { + if (!contextFont) { + return undefined; + } else { + const existingFontSize = parseValueWithUnit(contextFont, undefined /*element*/, 'px'); + + if (existingFontSize) { + switch (fontSize) { + case 'smaller': + return Math.round((existingFontSize * 500) / 6) / 100 + 'px'; + case 'larger': + return Math.round((existingFontSize * 600) / 5) / 100 + 'px'; + default: + return parseValueWithUnit(fontSize, existingFontSize, 'px') + 'px'; + } + } + } + } else if (fontSize == 'inherit' || fontSize == 'revert' || fontSize == 'unset') { + return undefined; + } else { + return fontSize; + } +} diff --git a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/utils/parseValueWithUnit.ts b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/utils/parseValueWithUnit.ts index 72f61e137cb..ab12c0710ec 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/utils/parseValueWithUnit.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/utils/parseValueWithUnit.ts @@ -5,12 +5,12 @@ const MarginValueRegex = /(-?\d+(\.\d+)?)([a-z]+|%)/; /** * Parse unit value with its unit * @param value The source value to parse - * @param element The source element which has this unit value. + * @param currentSizePxOrElement The source element which has this unit value, or current font size (in px) from context. * @param resultUnit Unit for result, can be px or pt. @default px */ export function parseValueWithUnit( value: string = '', - element?: HTMLElement, + currentSizePxOrElement?: number | HTMLElement, resultUnit: 'px' | 'pt' = 'px' ): number { const match = MarginValueRegex.exec(value); @@ -28,13 +28,13 @@ export function parseValueWithUnit( result = ptToPx(num); break; case 'em': - result = element ? getFontSize(element) * num : 0; + result = getFontSize(currentSizePxOrElement) * num; break; case 'ex': - result = element ? (getFontSize(element) * num) / 2 : 0; + result = (getFontSize(currentSizePxOrElement) * num) / 2; break; case '%': - result = element ? (element.offsetWidth * num) / 100 : 0; + result = (getFontSize(currentSizePxOrElement) * num) / 100; break; default: // TODO: Support more unit if need @@ -49,12 +49,18 @@ export function parseValueWithUnit( return result; } -function getFontSize(element: HTMLElement) { - const styleInPt = getComputedStyle(element, 'font-size'); - const floatInPt = parseFloat(styleInPt); - const floatInPx = ptToPx(floatInPt); +function getFontSize(currentSizeOrElement?: number | HTMLElement): number { + if (typeof currentSizeOrElement === 'undefined') { + return 0; + } else if (typeof currentSizeOrElement === 'number') { + return currentSizeOrElement; + } else { + const styleInPt = getComputedStyle(currentSizeOrElement, 'font-size'); + const floatInPt = parseFloat(styleInPt); + const floatInPx = ptToPx(floatInPt); - return floatInPx; + return floatInPx; + } } function ptToPx(pt: number): number { diff --git a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/segment/fontSizeFormatHandlerTest.ts b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/segment/fontSizeFormatHandlerTest.ts index 0ab9100592b..3c2b55781bd 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/segment/fontSizeFormatHandlerTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/segment/fontSizeFormatHandlerTest.ts @@ -59,6 +59,114 @@ describe('fontSizeFormatHandler.parse', () => { expect(format.fontSize).toBe('20px'); }); + + it('xx-small', () => { + div.style.fontSize = 'xx-small'; + fontSizeFormatHandler.parse(format, div, context, {}); + + expect(format.fontSize).toBe('6.75pt'); + }); + it('x-small', () => { + div.style.fontSize = 'x-small'; + fontSizeFormatHandler.parse(format, div, context, {}); + + expect(format.fontSize).toBe('7.5pt'); + }); + it('small', () => { + div.style.fontSize = 'small'; + fontSizeFormatHandler.parse(format, div, context, {}); + + expect(format.fontSize).toBe('9.75pt'); + }); + it('medium', () => { + div.style.fontSize = 'medium'; + fontSizeFormatHandler.parse(format, div, context, {}); + + expect(format.fontSize).toBe('12pt'); + }); + it('large', () => { + div.style.fontSize = 'large'; + fontSizeFormatHandler.parse(format, div, context, {}); + + expect(format.fontSize).toBe('13.5pt'); + }); + it('x-large', () => { + div.style.fontSize = 'x-large'; + fontSizeFormatHandler.parse(format, div, context, {}); + + expect(format.fontSize).toBe('18pt'); + }); + it('xx-large', () => { + div.style.fontSize = 'xx-large'; + fontSizeFormatHandler.parse(format, div, context, {}); + + expect(format.fontSize).toBe('24pt'); + }); + it('xxx-large', () => { + div.style.fontSize = 'xxx-large'; + fontSizeFormatHandler.parse(format, div, context, {}); + + expect(format.fontSize).toBe('36pt'); + }); + + it('smaller without context', () => { + div.style.fontSize = 'smaller'; + fontSizeFormatHandler.parse(format, div, context, {}); + + expect(format.fontSize).toBe(undefined); + }); + + it('smaller with context', () => { + div.style.fontSize = 'smaller'; + context.segmentFormat.fontSize = '12pt'; + fontSizeFormatHandler.parse(format, div, context, {}); + + expect(format.fontSize).toBe('13.33px'); + }); + + it('larger without context', () => { + div.style.fontSize = 'larger'; + fontSizeFormatHandler.parse(format, div, context, {}); + + expect(format.fontSize).toBe(undefined); + }); + + it('larger with context', () => { + div.style.fontSize = 'larger'; + context.segmentFormat.fontSize = '10pt'; + fontSizeFormatHandler.parse(format, div, context, {}); + + expect(format.fontSize).toBe('16px'); + }); + + it('em without context', () => { + div.style.fontSize = '2em'; + fontSizeFormatHandler.parse(format, div, context, {}); + + expect(format.fontSize).toBe(undefined); + }); + + it('em with context', () => { + div.style.fontSize = '2em'; + context.segmentFormat.fontSize = '12pt'; + fontSizeFormatHandler.parse(format, div, context, {}); + + expect(format.fontSize).toBe('32px'); + }); + + it('% without context', () => { + div.style.fontSize = '50%'; + fontSizeFormatHandler.parse(format, div, context, {}); + + expect(format.fontSize).toBe(undefined); + }); + it('% with context', () => { + div.style.fontSize = '50%'; + context.segmentFormat.fontSize = '12pt'; + fontSizeFormatHandler.parse(format, div, context, {}); + + expect(format.fontSize).toBe('8px'); + }); }); describe('fontSizeFormatHandler.apply', () => { diff --git a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/utils/parseValueWithUnitTest.ts b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/utils/parseValueWithUnitTest.ts index 02f95a345d7..50fca53d4e1 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/utils/parseValueWithUnitTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/utils/parseValueWithUnitTest.ts @@ -1,7 +1,7 @@ import * as getComputedStyles from 'roosterjs-editor-dom/lib/utils/getComputedStyles'; import { parseValueWithUnit } from '../../../lib/formatHandlers/utils/parseValueWithUnit'; -describe('parseValueWithUnit', () => { +describe('parseValueWithUnit with element', () => { function runTest(unit: string, results: number[]) { const mockedElement = { offsetWidth: 1000, @@ -50,7 +50,64 @@ describe('parseValueWithUnit', () => { }); it('%', () => { - runTest('% ', [0, 10, 11, -11]); + runTest('% ', [0, 0.2, 0.22, -0.22]); + }); + + it('px to pt', () => { + const result = parseValueWithUnit('16px', undefined, 'pt'); + + expect(result).toBe(12); + }); + + it('pt to pt', () => { + const result = parseValueWithUnit('16pt', undefined, 'pt'); + + expect(result).toBe(16); + }); +}); + +describe('parseValueWithUnit with number', () => { + function runTest(unit: string, results: number[]) { + ['0', '1', '1.1', '-1.1'].forEach((value, i) => { + const input = value + unit; + const result = parseValueWithUnit(input, 20); + + expect(result).toBe(results[i], input); + }); + } + + it('empty', () => { + expect(parseValueWithUnit()).toBe(0); + expect(parseValueWithUnit('')).toBe(0); + expect(parseValueWithUnit('', {} as HTMLElement)).toBe(0); + }); + + it('px', () => { + runTest('px', [0, 1, 1.1, -1.1]); + }); + + it('pt', () => { + runTest('pt', [0, 1.333, 1.467, -1.467]); + }); + + it('em', () => { + runTest('em', [0, 20, 22, -22]); + }); + + it('ex', () => { + runTest('ex', [0, 10, 11, -11]); + }); + + it('no unit', () => { + runTest('', [0, 0, 0, 0]); + }); + + it('unknown unit', () => { + runTest('unknown', [0, 0, 0, 0]); + }); + + it('%', () => { + runTest('% ', [0, 0.2, 0.22, -0.22]); }); it('px to pt', () => { From e8d253635c505ce7a64f923828a1452940c08808 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Tue, 8 Aug 2023 10:52:57 -0700 Subject: [PATCH 05/75] Content Model: Fix 221290 Support float for image (#2013) --- .../format/formatPart/FloatFormatRenderer.ts | 11 ++++ .../model/ContentModelImageView.tsx | 2 + .../common/floatFormatHandler.ts | 20 +++++++ .../formatHandlers/defaultFormatHandlers.ts | 14 ++++- .../common/floatFormatHandlerTest.ts | 60 +++++++++++++++++++ .../lib/format/ContentModelImageFormat.ts | 4 +- .../lib/format/FormatHandlerTypeMap.ts | 6 ++ .../lib/format/formatParts/FloatFormat.ts | 9 +++ .../lib/index.ts | 1 + 9 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 demo/scripts/controls/contentModel/components/format/formatPart/FloatFormatRenderer.ts create mode 100644 packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/common/floatFormatHandler.ts create mode 100644 packages-content-model/roosterjs-content-model-dom/test/formatHandlers/common/floatFormatHandlerTest.ts create mode 100644 packages-content-model/roosterjs-content-model-types/lib/format/formatParts/FloatFormat.ts diff --git a/demo/scripts/controls/contentModel/components/format/formatPart/FloatFormatRenderer.ts b/demo/scripts/controls/contentModel/components/format/formatPart/FloatFormatRenderer.ts new file mode 100644 index 00000000000..0b29f44b958 --- /dev/null +++ b/demo/scripts/controls/contentModel/components/format/formatPart/FloatFormatRenderer.ts @@ -0,0 +1,11 @@ +import { createTextFormatRenderer } from '../utils/createTextFormatRenderer'; +import { FloatFormat } from 'roosterjs-content-model-types'; +import { FormatRenderer } from '../utils/FormatRenderer'; + +export const FloatFormatRenderer: FormatRenderer = createTextFormatRenderer< + FloatFormat +>( + 'Float', + format => format.float, + (format, value) => (format.float = value) +); diff --git a/demo/scripts/controls/contentModel/components/model/ContentModelImageView.tsx b/demo/scripts/controls/contentModel/components/model/ContentModelImageView.tsx index e00fd88d057..948a6390a1a 100644 --- a/demo/scripts/controls/contentModel/components/model/ContentModelImageView.tsx +++ b/demo/scripts/controls/contentModel/components/model/ContentModelImageView.tsx @@ -3,6 +3,7 @@ import { ContentModelCodeView } from './ContentModelCodeView'; import { ContentModelImage, ContentModelImageFormat } from 'roosterjs-content-model-types'; import { ContentModelLinkView } from './ContentModelLinkView'; import { ContentModelView } from '../ContentModelView'; +import { FloatFormatRenderer } from '../format/formatPart/FloatFormatRenderer'; import { FormatRenderer } from '../format/utils/FormatRenderer'; import { FormatView } from '../format/FormatView'; import { IdFormatRenderer } from '../format/formatPart/IdFormatRenderer'; @@ -22,6 +23,7 @@ const ImageFormatRenderers: FormatRenderer[] = [ ...SizeFormatRenderers, MarginFormatRenderer, PaddingFormatRenderer, + FloatFormatRenderer, ]; export function ContentModelImageView(props: { image: ContentModelImage }) { diff --git a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/common/floatFormatHandler.ts b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/common/floatFormatHandler.ts new file mode 100644 index 00000000000..538e174ab89 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/common/floatFormatHandler.ts @@ -0,0 +1,20 @@ +import { FloatFormat } from 'roosterjs-content-model-types'; +import { FormatHandler } from '../FormatHandler'; + +/** + * @internal + */ +export const floatFormatHandler: FormatHandler = { + parse: (format, element) => { + const float = element.style.float || element.getAttribute('align'); + + if (float) { + format.float = float; + } + }, + apply: (format, element) => { + if (format.float) { + element.style.float = format.float; + } + }, +}; diff --git a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/defaultFormatHandlers.ts b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/defaultFormatHandlers.ts index 9748ea9a197..b793af848d2 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/defaultFormatHandlers.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/defaultFormatHandlers.ts @@ -6,6 +6,7 @@ import { boxShadowFormatHandler } from './common/boxShadowFormatHandler'; import { datasetFormatHandler } from './common/datasetFormatHandler'; import { directionFormatHandler } from './block/directionFormatHandler'; import { displayFormatHandler } from './block/displayFormatHandler'; +import { floatFormatHandler } from './common/floatFormatHandler'; import { fontFamilyFormatHandler } from './segment/fontFamilyFormatHandler'; import { fontSizeFormatHandler } from './segment/fontSizeFormatHandler'; import { FormatHandler } from './FormatHandler'; @@ -58,6 +59,7 @@ const defaultFormatHandlerMap: FormatHandlers = { dataset: datasetFormatHandler, direction: directionFormatHandler, display: displayFormatHandler, + float: floatFormatHandler, fontFamily: fontFamilyFormatHandler, fontSize: fontSizeFormatHandler, htmlAlign: htmlAlignFormatHandler, @@ -164,7 +166,17 @@ const defaultFormatKeysPerCategory: { ], tableBorder: ['borderBox', 'tableSpacing'], tableCellBorder: ['borderBox'], - image: ['id', 'size', 'margin', 'padding', 'borderBox', 'border', 'boxShadow', 'display'], + image: [ + 'id', + 'size', + 'margin', + 'padding', + 'borderBox', + 'border', + 'boxShadow', + 'display', + 'float', + ], link: [ 'link', 'textColor', diff --git a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/common/floatFormatHandlerTest.ts b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/common/floatFormatHandlerTest.ts new file mode 100644 index 00000000000..1ea9466f24e --- /dev/null +++ b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/common/floatFormatHandlerTest.ts @@ -0,0 +1,60 @@ +import { createDomToModelContext } from '../../../lib/domToModel/context/createDomToModelContext'; +import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext'; +import { DomToModelContext, FloatFormat, ModelToDomContext } from 'roosterjs-content-model-types'; +import { floatFormatHandler } from '../../../lib/formatHandlers/common/floatFormatHandler'; + +describe('floatFormatHandler.parse', () => { + let div: HTMLElement; + let format: FloatFormat; + let context: DomToModelContext; + + beforeEach(() => { + div = document.createElement('div'); + format = {}; + context = createDomToModelContext(); + }); + + it('No float', () => { + floatFormatHandler.parse(format, div, context, {}); + expect(format).toEqual({}); + }); + + it('Float left', () => { + div.style.float = 'left'; + floatFormatHandler.parse(format, div, context, {}); + expect(format).toEqual({ + float: 'left', + }); + }); + + it('Float left from attribute', () => { + div.setAttribute('align', 'left'); + floatFormatHandler.parse(format, div, context, {}); + expect(format).toEqual({ + float: 'left', + }); + }); +}); + +describe('floatFormatHandler.apply', () => { + let div: HTMLElement; + let format: FloatFormat; + let context: ModelToDomContext; + + beforeEach(() => { + div = document.createElement('div'); + format = {}; + context = createModelToDomContext(); + }); + + it('No float', () => { + floatFormatHandler.apply(format, div, context); + expect(div.outerHTML).toBe('
'); + }); + + it('Float: left', () => { + format.float = 'left'; + floatFormatHandler.apply(format, div, context); + expect(div.outerHTML).toBe('
'); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-types/lib/format/ContentModelImageFormat.ts b/packages-content-model/roosterjs-content-model-types/lib/format/ContentModelImageFormat.ts index 5693df671ae..d4f70c76492 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/format/ContentModelImageFormat.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/format/ContentModelImageFormat.ts @@ -2,6 +2,7 @@ import { BorderFormat } from './formatParts/BorderFormat'; import { BoxShadowFormat } from './formatParts/BoxShadowFormat'; import { ContentModelSegmentFormat } from './ContentModelSegmentFormat'; import { DisplayFormat } from './formatParts/DisplayFormat'; +import { FloatFormat } from './formatParts/FloatFormat'; import { IdFormat } from './formatParts/IdFormat'; import { MarginFormat } from './formatParts/MarginFormat'; import { PaddingFormat } from './formatParts/PaddingFormat'; @@ -17,4 +18,5 @@ export type ContentModelImageFormat = ContentModelSegmentFormat & PaddingFormat & BorderFormat & BoxShadowFormat & - DisplayFormat; + DisplayFormat & + FloatFormat; diff --git a/packages-content-model/roosterjs-content-model-types/lib/format/FormatHandlerTypeMap.ts b/packages-content-model/roosterjs-content-model-types/lib/format/FormatHandlerTypeMap.ts index 483fe79386d..8936c886c75 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/format/FormatHandlerTypeMap.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/format/FormatHandlerTypeMap.ts @@ -6,6 +6,7 @@ import { BoxShadowFormat } from './formatParts/BoxShadowFormat'; import { DatasetFormat } from './metadata/DatasetFormat'; import { DirectionFormat } from './formatParts/DirectionFormat'; import { DisplayFormat } from './formatParts/DisplayFormat'; +import { FloatFormat } from './formatParts/FloatFormat'; import { FontFamilyFormat } from './formatParts/FontFamilyFormat'; import { FontSizeFormat } from './formatParts/FontSizeFormat'; import { HtmlAlignFormat } from './formatParts/HtmlAlignFormat'; @@ -74,6 +75,11 @@ export interface FormatHandlerTypeMap { */ display: DisplayFormat; + /** + * Format for FloatFormat + */ + float: FloatFormat; + /** * Format for FontFamilyFormat */ diff --git a/packages-content-model/roosterjs-content-model-types/lib/format/formatParts/FloatFormat.ts b/packages-content-model/roosterjs-content-model-types/lib/format/formatParts/FloatFormat.ts new file mode 100644 index 00000000000..ce59dc748c3 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-types/lib/format/formatParts/FloatFormat.ts @@ -0,0 +1,9 @@ +/** + * Format of float + */ +export type FloatFormat = { + /** + * Float style + */ + float?: string; +}; diff --git a/packages-content-model/roosterjs-content-model-types/lib/index.ts b/packages-content-model/roosterjs-content-model-types/lib/index.ts index 51657498387..22e154bf4a5 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/index.ts @@ -45,6 +45,7 @@ export { SizeFormat } from './format/formatParts/SizeFormat'; export { BoxShadowFormat } from './format/formatParts/BoxShadowFormat'; export { ListThreadFormat } from './format/formatParts/ListThreadFormat'; export { ListStylePositionFormat } from './format/formatParts/ListStylePositionFormat'; +export { FloatFormat } from './format/formatParts/FloatFormat'; export { DatasetFormat } from './format/metadata/DatasetFormat'; export { TableMetadataFormat } from './format/metadata/TableMetadataFormat'; From 6eecbc73983e075add8c16617f3611df4d6f58c6 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Tue, 8 Aug 2023 14:07:42 -0700 Subject: [PATCH 06/75] Content Model: improve formatWithContentModel 1 (#2001) * Content Model: Improve cache behavior * fix build * Content Model: improve formatWithContentModel --- .../ContentModelFormatPainterPlugin.ts | 27 +- .../contentModel/ContentModelRibbonPlugin.ts | 14 +- .../processors/reducedModelChildProcessor.ts | 95 ------ .../lib/index.ts | 1 - .../common/retrieveModelFormatState.ts | 1 + .../lib/publicApi/format/getFormatState.ts | 119 ++++++- .../lib/publicApi/format/getSegmentFormat.ts | 41 --- .../publicApi/utils/formatWithContentModel.ts | 35 +- .../formatState/ContentModelFormatState.ts | 5 + .../reducedModelChildProcessorTest.ts | 320 ------------------ .../plugins/ContentModelFormatPluginTest.ts | 2 + .../publicApi/block/setIndentationTest.ts | 1 + .../publicApi/block/toggleBlockQuoteTest.ts | 1 + .../publicApi/format/getFormatStateTest.ts | 320 +++++++++++++++++- .../publicApi/format/getSegmentFormatTest.ts | 94 ----- .../utils/formatWithContentModelTest.ts | 2 +- 16 files changed, 473 insertions(+), 605 deletions(-) delete mode 100644 packages-content-model/roosterjs-content-model-editor/lib/domToModel/processors/reducedModelChildProcessor.ts delete mode 100644 packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/getSegmentFormat.ts delete mode 100644 packages-content-model/roosterjs-content-model-editor/test/domToModel/processors/reducedModelChildProcessorTest.ts delete mode 100644 packages-content-model/roosterjs-content-model-editor/test/publicApi/format/getSegmentFormatTest.ts diff --git a/demo/scripts/controls/contentModel/plugins/ContentModelFormatPainterPlugin.ts b/demo/scripts/controls/contentModel/plugins/ContentModelFormatPainterPlugin.ts index 22d7a9a5691..e9f2fee28a9 100644 --- a/demo/scripts/controls/contentModel/plugins/ContentModelFormatPainterPlugin.ts +++ b/demo/scripts/controls/contentModel/plugins/ContentModelFormatPainterPlugin.ts @@ -2,7 +2,7 @@ import { ContentModelSegmentFormat } from 'roosterjs-content-model-types'; import { EditorPlugin, IEditor, PluginEvent, PluginEventType } from 'roosterjs-editor-types'; import { applySegmentFormat, - getSegmentFormat, + getFormatState, IContentModelEditor, } from 'roosterjs-content-model-editor'; @@ -53,13 +53,13 @@ export default class ContentModelFormatPainterPlugin implements EditorPlugin { } } -function getFormatHolder(editor: IEditor): FormatPainterFormatHolder { +function getFormatHolder(editor: IContentModelEditor): FormatPainterFormatHolder { return editor.getCustomData('__FormatPainterFormat', () => { return {} as FormatPainterFormatHolder; }); } -function setFormatPainterCursor(editor: IEditor, isOn: boolean) { +function setFormatPainterCursor(editor: IContentModelEditor, isOn: boolean) { let styles = editor.getEditorDomAttribute('style') || ''; styles = styles.replace(CURSOR_REGEX, ''); @@ -69,3 +69,24 @@ function setFormatPainterCursor(editor: IEditor, isOn: boolean) { editor.setEditorDomAttribute('style', styles); } + +function getSegmentFormat(editor: IContentModelEditor): ContentModelSegmentFormat { + const formatState = getFormatState(editor); + + return { + backgroundColor: formatState.backgroundColor, + fontFamily: formatState.fontName, + fontSize: formatState.fontSize, + fontWeight: formatState.isBold ? 'bold' : 'normal', + italic: formatState.isItalic, + letterSpacing: formatState.letterSpacing, + strikethrough: formatState.isStrikeThrough, + superOrSubScriptSequence: formatState.isSubscript + ? 'sub' + : formatState.isSuperscript + ? 'super' + : '', + textColor: formatState.textColor, + underline: formatState.isUnderline, + }; +} diff --git a/demo/scripts/controls/ribbonButtons/contentModel/ContentModelRibbonPlugin.ts b/demo/scripts/controls/ribbonButtons/contentModel/ContentModelRibbonPlugin.ts index c1d2ce4f978..dd36d92f4c8 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/ContentModelRibbonPlugin.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/ContentModelRibbonPlugin.ts @@ -1,13 +1,17 @@ -import { FormatState, PluginEvent, PluginEventType } from 'roosterjs-editor-types'; -import { getFormatState, IContentModelEditor } from 'roosterjs-content-model-editor'; import { getObjectKeys } from 'roosterjs-editor-dom'; import { LocalizedStrings, RibbonButton, RibbonPlugin, UIUtilities } from 'roosterjs-react'; +import { PluginEvent, PluginEventType } from 'roosterjs-editor-types'; +import { + ContentModelFormatState, + getFormatState, + IContentModelEditor, +} from 'roosterjs-content-model-editor'; export class ContentModelRibbonPlugin implements RibbonPlugin { private editor: IContentModelEditor | null = null; - private onFormatChanged: ((formatState: FormatState) => void) | null = null; + private onFormatChanged: ((formatState: ContentModelFormatState) => void) | null = null; private timer = 0; - private formatState: FormatState | null = null; + private formatState: ContentModelFormatState | null = null; private uiUtilities: UIUtilities | null = null; /** @@ -68,7 +72,7 @@ export class ContentModelRibbonPlugin implements RibbonPlugin { /** * Register a callback to be invoked when format state of editor is changed, returns a disposer function. */ - registerFormatChangedCallback(callback: (formatState: FormatState) => void) { + registerFormatChangedCallback(callback: (formatState: ContentModelFormatState) => void) { this.onFormatChanged = callback; return () => { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/domToModel/processors/reducedModelChildProcessor.ts b/packages-content-model/roosterjs-content-model-editor/lib/domToModel/processors/reducedModelChildProcessor.ts deleted file mode 100644 index 3eb7dd78cd9..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/domToModel/processors/reducedModelChildProcessor.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { contains, getTagOfNode } from 'roosterjs-editor-dom'; -import { ContentModelBlockGroup, DomToModelContext } from 'roosterjs-content-model-types'; -import { getSelectionRootNode } from '../../modelApi/selection/getSelectionRootNode'; -import { - getRegularSelectionOffsets, - handleRegularSelection, - processChildNode, -} from 'roosterjs-content-model-dom'; - -/** - * @internal - */ -export interface FormatStateContext extends DomToModelContext { - /** - * An optional stack of parent elements to process. When provided, the child nodes of current parent element will be ignored, - * but use the top element in this stack instead in childProcessor. - */ - nodeStack?: Node[]; -} - -/** - * @internal - * In order to get format, we can still use the regular child processor. However, to improve performance, we don't need to create - * content model for the whole doc, instead we only need to traverse the tree path that can arrive current selected node. - * This "reduced" child processor will first create a node stack that stores DOM node from root to current common ancestor node of selection, - * then use this stack as a faked DOM tree to create a reduced content model which we can use to retrieve format state - */ -export function reducedModelChildProcessor( - group: ContentModelBlockGroup, - parent: ParentNode, - context: FormatStateContext -) { - const selectionRootNode = getSelectionRootNode(context.rangeEx); - - if (selectionRootNode) { - if (!context.nodeStack) { - context.nodeStack = createNodeStack(parent, selectionRootNode); - } - - const stackChild = context.nodeStack.pop(); - - if (stackChild) { - const [nodeStartOffset, nodeEndOffset] = getRegularSelectionOffsets(context, parent); - - // If selection is not on this node, skip getting node index to save some time since we don't need it here - const index = - nodeStartOffset >= 0 || nodeEndOffset >= 0 ? getChildIndex(parent, stackChild) : -1; - - if (index >= 0) { - handleRegularSelection(index, context, group, nodeStartOffset, nodeEndOffset); - } - - processChildNode(group, stackChild, context); - - if (index >= 0) { - handleRegularSelection(index + 1, context, group, nodeStartOffset, nodeEndOffset); - } - } else { - // No child node from node stack, that means we have reached the deepest node of selection. - // Now we can use default child processor to perform full sub tree scanning for content model, - // So that all selected node will be included. - context.defaultElementProcessors.child(group, parent, context); - } - } -} - -function createNodeStack(root: Node, startNode: Node): Node[] { - const result: Node[] = []; - let node: Node | null = startNode; - - while (node && contains(root, node)) { - if (getTagOfNode(node) == 'TABLE') { - // For table, we can't do a reduced model creation since we need to handle their cells and indexes, - // so clean up whatever we already have, and just put table into the stack - result.splice(0, result.length, node); - } else { - result.push(node); - } - - node = node.parentNode; - } - - return result; -} - -function getChildIndex(parent: ParentNode, stackChild: Node) { - let index = 0; - let child = parent.firstChild; - - while (child && child != stackChild) { - index++; - child = child.nextSibling; - } - return index; -} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/index.ts b/packages-content-model/roosterjs-content-model-editor/lib/index.ts index b125c62979b..597c4749045 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/index.ts @@ -53,7 +53,6 @@ export { default as setImageBorder } from './publicApi/image/setImageBorder'; export { default as setImageBoxShadow } from './publicApi/image/setImageBoxShadow'; export { default as changeImage } from './publicApi/image/changeImage'; export { default as getFormatState } from './publicApi/format/getFormatState'; -export { default as getSegmentFormat } from './publicApi/format/getSegmentFormat'; export { default as applyPendingFormat } from './publicApi/format/applyPendingFormat'; export { default as clearFormat } from './publicApi/format/clearFormat'; export { default as insertLink } from './publicApi/link/insertLink'; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/retrieveModelFormatState.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/retrieveModelFormatState.ts index 803c123478f..e5ba9a01029 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/retrieveModelFormatState.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/retrieveModelFormatState.ts @@ -136,6 +136,7 @@ function retrieveSegmentFormat( 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, 'fontName', mergedFormat.fontFamily, isFirst); mergeValue(result, 'fontSize', mergedFormat.fontSize, isFirst); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/getFormatState.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/getFormatState.ts index b77a4007a2e..42dbb47f7e3 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/getFormatState.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/getFormatState.ts @@ -1,35 +1,122 @@ -import { FormatState } from 'roosterjs-editor-types'; -import { formatWithContentModel } from '../utils/formatWithContentModel'; +import { contains, getTagOfNode } from 'roosterjs-editor-dom'; +import { ContentModelBlockGroup, DomToModelContext } from 'roosterjs-content-model-types'; +import { ContentModelFormatState } from '../../publicTypes/format/formatState/ContentModelFormatState'; import { getPendingFormat } from '../../modelApi/format/pendingFormat'; +import { getSelectionRootNode } from '../../modelApi/selection/getSelectionRootNode'; import { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; import { retrieveModelFormatState } from '../../modelApi/common/retrieveModelFormatState'; +import { + getRegularSelectionOffsets, + handleRegularSelection, + processChildNode, +} from 'roosterjs-content-model-dom'; /** * Get current format state * @param editor The editor to get format from */ -export default function getFormatState(editor: IContentModelEditor): FormatState { - let result: FormatState = { +export default function getFormatState(editor: IContentModelEditor): ContentModelFormatState { + const pendingFormat = getPendingFormat(editor); + const model = editor.createContentModel({ + processorOverride: { + child: reducedModelChildProcessor, + }, + }); + const result: ContentModelFormatState = { ...editor.getUndoState(), - isDarkMode: editor.isDarkMode(), zoomScale: editor.getZoomScale(), }; - formatWithContentModel( - editor, - 'getFormatState', - model => { - const pendingFormat = getPendingFormat(editor); + retrieveModelFormatState(model, pendingFormat, result); - retrieveModelFormatState(model, pendingFormat, result); + return result; +} - return false; - }, - { - useReducedModel: true, +/** + * @internal + */ +interface FormatStateContext extends DomToModelContext { + /** + * An optional stack of parent elements to process. When provided, the child nodes of current parent element will be ignored, + * but use the top element in this stack instead in childProcessor. + */ + nodeStack?: Node[]; +} + +/** + * @internal + * Export for test only + * In order to get format, we can still use the regular child processor. However, to improve performance, we don't need to create + * content model for the whole doc, instead we only need to traverse the tree path that can arrive current selected node. + * This "reduced" child processor will first create a node stack that stores DOM node from root to current common ancestor node of selection, + * then use this stack as a faked DOM tree to create a reduced content model which we can use to retrieve format state + */ +export function reducedModelChildProcessor( + group: ContentModelBlockGroup, + parent: ParentNode, + context: FormatStateContext +) { + const selectionRootNode = getSelectionRootNode(context.rangeEx); + + if (selectionRootNode) { + if (!context.nodeStack) { + context.nodeStack = createNodeStack(parent, selectionRootNode); } - ); + + const stackChild = context.nodeStack.pop(); + + if (stackChild) { + const [nodeStartOffset, nodeEndOffset] = getRegularSelectionOffsets(context, parent); + + // If selection is not on this node, skip getting node index to save some time since we don't need it here + const index = + nodeStartOffset >= 0 || nodeEndOffset >= 0 ? getChildIndex(parent, stackChild) : -1; + + if (index >= 0) { + handleRegularSelection(index, context, group, nodeStartOffset, nodeEndOffset); + } + + processChildNode(group, stackChild, context); + + if (index >= 0) { + handleRegularSelection(index + 1, context, group, nodeStartOffset, nodeEndOffset); + } + } else { + // No child node from node stack, that means we have reached the deepest node of selection. + // Now we can use default child processor to perform full sub tree scanning for content model, + // So that all selected node will be included. + context.defaultElementProcessors.child(group, parent, context); + } + } +} + +function createNodeStack(root: Node, startNode: Node): Node[] { + const result: Node[] = []; + let node: Node | null = startNode; + + while (node && contains(root, node)) { + if (getTagOfNode(node) == 'TABLE') { + // For table, we can't do a reduced model creation since we need to handle their cells and indexes, + // so clean up whatever we already have, and just put table into the stack + result.splice(0, result.length, node); + } else { + result.push(node); + } + + node = node.parentNode; + } return result; } + +function getChildIndex(parent: ParentNode, stackChild: Node) { + let index = 0; + let child = parent.firstChild; + + while (child && child != stackChild) { + index++; + child = child.nextSibling; + } + return index; +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/getSegmentFormat.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/getSegmentFormat.ts deleted file mode 100644 index a11c42ae891..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/getSegmentFormat.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { ContentModelSegmentFormat } from 'roosterjs-content-model-types'; -import { formatWithContentModel } from '../utils/formatWithContentModel'; -import { getPendingFormat } from '../../modelApi/format/pendingFormat'; -import { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; -import { iterateSelections } from '../../modelApi/selection/iterateSelections'; - -/** - * Get current segment format. This is usually used by format painter - * @param editor The editor to get format from - */ -export default function getSegmentFormat( - editor: IContentModelEditor -): ContentModelSegmentFormat | null { - let result = getPendingFormat(editor); - - if (!result) { - formatWithContentModel( - editor, - 'getSegmentFormat', - model => { - iterateSelections( - [model], - (path, tableContext, block, segments) => { - result = segments?.[0]?.format || null; - return true; - }, - { - includeListFormatHolder: 'never', - } - ); - - return false; - }, - { - useReducedModel: true, - } - ); - } - - return result; -} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatWithContentModel.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatWithContentModel.ts index 8791c50ef3b..b680075db13 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatWithContentModel.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatWithContentModel.ts @@ -1,22 +1,12 @@ import { ChangeSource } from 'roosterjs-editor-types'; -import { - ContentModelDocument, - DomToModelOption, - OnNodeCreated, -} from 'roosterjs-content-model-types'; +import { ContentModelDocument, OnNodeCreated } from 'roosterjs-content-model-types'; import { getPendingFormat, setPendingFormat } from '../../modelApi/format/pendingFormat'; import { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; -import { reducedModelChildProcessor } from '../../domToModel/processors/reducedModelChildProcessor'; /** * @internal */ export interface FormatWithContentModelOptions { - /** - * When set to true, it will only create Content Model for selected content - */ - useReducedModel?: boolean; - /** * When set to true, if there is pending format, it will be preserved after this format operation is done */ @@ -54,26 +44,15 @@ export function formatWithContentModel( callback: (model: ContentModelDocument) => boolean, options?: FormatWithContentModelOptions ) { - const { - useReducedModel, - onNodeCreated, - preservePendingFormat, - getChangeData, - skipUndoSnapshot, - changeSource, - } = options || {}; - const domToModelOption: DomToModelOption | undefined = useReducedModel - ? { - processorOverride: { - child: reducedModelChildProcessor, - }, - } - : undefined; - const model = editor.createContentModel(domToModelOption); + const { onNodeCreated, preservePendingFormat, getChangeData, skipUndoSnapshot, changeSource } = + options || {}; + + editor.focus(); + + const model = editor.createContentModel(); if (callback(model)) { const callback = () => { - editor.focus(); if (model) { editor.setContentModel(model, { onNodeCreated }); } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/format/formatState/ContentModelFormatState.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/format/formatState/ContentModelFormatState.ts index 14248e6ce83..82eb6de52f7 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/format/formatState/ContentModelFormatState.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/format/formatState/ContentModelFormatState.ts @@ -9,4 +9,9 @@ export interface ContentModelFormatState extends FormatState { * Format of image, if there is table at cursor position */ imageFormat?: ImageFormatState; + + /** + * Letter spacing + */ + letterSpacing?: string; } diff --git a/packages-content-model/roosterjs-content-model-editor/test/domToModel/processors/reducedModelChildProcessorTest.ts b/packages-content-model/roosterjs-content-model-editor/test/domToModel/processors/reducedModelChildProcessorTest.ts deleted file mode 100644 index bd8766b45b7..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/test/domToModel/processors/reducedModelChildProcessorTest.ts +++ /dev/null @@ -1,320 +0,0 @@ -import { createContentModelDocument, createDomToModelContext } from 'roosterjs-content-model-dom'; -import { DomToModelContext } from 'roosterjs-content-model-types'; -import { reducedModelChildProcessor } from '../../../lib/domToModel/processors/reducedModelChildProcessor'; -import { SelectionRangeTypes } from 'roosterjs-editor-types'; - -describe('reducedModelChildProcessor', () => { - let context: DomToModelContext; - - beforeEach(() => { - context = createDomToModelContext(undefined, { - processorOverride: { - child: reducedModelChildProcessor, - }, - }); - }); - - it('Empty DOM', () => { - const doc = createContentModelDocument(); - const div = document.createElement('div'); - - reducedModelChildProcessor(doc, div, context); - - expect(doc).toEqual({ - blockGroupType: 'Document', - blocks: [], - }); - }); - - it('Single child node, with selected Node in context', () => { - const doc = createContentModelDocument(); - const div = document.createElement('div'); - const span = document.createElement('span'); - - div.appendChild(span); - span.textContent = 'test'; - context.rangeEx = { - type: SelectionRangeTypes.Normal, - ranges: [ - { - commonAncestorContainer: span, - } as any, - ], - areAllCollapsed: false, - }; - - reducedModelChildProcessor(doc, div, context); - - expect(doc).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - isImplicit: true, - segments: [ - { - segmentType: 'Text', - text: 'test', - format: {}, - }, - ], - }, - ], - }); - }); - - it('Multiple child nodes, with selected Node in context', () => { - const doc = createContentModelDocument(); - const div = document.createElement('div'); - const span1 = document.createElement('span'); - const span2 = document.createElement('span'); - const span3 = document.createElement('span'); - - div.appendChild(span1); - div.appendChild(span2); - div.appendChild(span3); - span1.textContent = 'test1'; - span2.textContent = 'test2'; - span3.textContent = 'test3'; - context.rangeEx = { - type: SelectionRangeTypes.Normal, - ranges: [ - { - commonAncestorContainer: span2, - } as any, - ], - areAllCollapsed: false, - }; - - reducedModelChildProcessor(doc, div, context); - - expect(doc).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - isImplicit: true, - segments: [ - { - segmentType: 'Text', - text: 'test2', - format: {}, - }, - ], - }, - ], - }); - }); - - it('Multiple child nodes, with selected Node in context, with more child nodes under selected node', () => { - const doc = createContentModelDocument(); - const div = document.createElement('div'); - const span1 = document.createElement('span'); - const span2 = document.createElement('span'); - const span3 = document.createElement('span'); - - div.appendChild(span1); - div.appendChild(span2); - div.appendChild(span3); - span1.textContent = 'test1'; - span2.innerHTML = '
line1
line2
'; - span3.textContent = 'test3'; - context.rangeEx = { - type: SelectionRangeTypes.Normal, - ranges: [ - { - commonAncestorContainer: span2, - } as any, - ], - areAllCollapsed: false, - }; - - reducedModelChildProcessor(doc, div, context); - - expect(doc).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: 'line1', - format: {}, - }, - ], - format: {}, - }, - { - blockType: 'Paragraph', - segments: [], - format: {}, - isImplicit: true, - }, - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: 'line2', - format: {}, - }, - ], - format: {}, - }, - { - blockType: 'Paragraph', - segments: [], - format: {}, - isImplicit: true, - }, - ], - }); - }); - - it('Multiple layer with multiple child nodes, with selected Node in context, with more child nodes under selected node', () => { - const doc = createContentModelDocument(); - const div1 = document.createElement('div'); - const div2 = document.createElement('div'); - const div3 = document.createElement('div'); - const span1 = document.createElement('span'); - const span2 = document.createElement('span'); - const span3 = document.createElement('span'); - - div3.appendChild(span1); - div3.appendChild(span2); - div3.appendChild(span3); - div1.appendChild(div2); - div2.appendChild(div3); - span1.textContent = 'test1'; - span2.innerHTML = '
line1
line2
'; - span3.textContent = 'test3'; - - context.rangeEx = { - type: SelectionRangeTypes.Normal, - ranges: [ - { - commonAncestorContainer: span2, - } as any, - ], - areAllCollapsed: false, - }; - - reducedModelChildProcessor(doc, div1, context); - - expect(doc).toEqual({ - blockGroupType: 'Document', - blocks: [ - { blockType: 'Paragraph', segments: [], format: {} }, - { - blockType: 'Paragraph', - segments: [], - format: {}, - }, - { - blockType: 'Paragraph', - segments: [{ segmentType: 'Text', text: 'line1', format: {} }], - format: {}, - }, - { blockType: 'Paragraph', segments: [], format: {}, isImplicit: true }, - { - blockType: 'Paragraph', - segments: [{ segmentType: 'Text', text: 'line2', format: {} }], - format: {}, - }, - { - blockType: 'Paragraph', - segments: [], - format: {}, - isImplicit: true, - }, - { blockType: 'Paragraph', segments: [], format: {}, isImplicit: true }, - { blockType: 'Paragraph', segments: [], format: {}, isImplicit: true }, - ], - }); - }); - - it('With table, need to do format for all table cells', () => { - const doc = createContentModelDocument(); - const div = document.createElement('div'); - div.innerHTML = - 'aa
test1test2
bb'; - context.rangeEx = { - type: SelectionRangeTypes.Normal, - ranges: [ - { - commonAncestorContainer: div.querySelector('#selection') as HTMLElement, - } as any, - ], - areAllCollapsed: false, - }; - - reducedModelChildProcessor(doc, div, context); - - expect(doc).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Table', - rows: [ - { - format: {}, - height: 0, - cells: [ - { - blockGroupType: 'TableCell', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: 'test1', - format: {}, - }, - ], - format: {}, - isImplicit: true, - }, - ], - format: {}, - spanLeft: false, - spanAbove: false, - isHeader: false, - dataset: {}, - }, - { - blockGroupType: 'TableCell', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: 'test2', - format: {}, - }, - ], - format: {}, - isImplicit: true, - }, - ], - format: {}, - spanLeft: false, - spanAbove: false, - isHeader: false, - dataset: {}, - }, - ], - }, - ], - format: {}, - widths: [], - dataset: {}, - }, - ], - }); - }); -}); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelFormatPluginTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelFormatPluginTest.ts index 403ac5d358e..d25c9c4bff7 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelFormatPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelFormatPluginTest.ts @@ -40,6 +40,7 @@ describe('ContentModelFormatPlugin', () => { const setContentModel = jasmine.createSpy('setContentModel'); const editor = ({ + focus: jasmine.createSpy('focus'), createContentModel: () => model, setContentModel, isInIME: () => false, @@ -104,6 +105,7 @@ describe('ContentModelFormatPlugin', () => { addSegment(model, marker); const editor = ({ + focus: jasmine.createSpy('focus'), createContentModel: () => model, setContentModel, isInIME: () => false, diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setIndentationTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setIndentationTest.ts index 488d932812d..d8dda555f8e 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setIndentationTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setIndentationTest.ts @@ -10,6 +10,7 @@ describe('setIndentation', () => { beforeEach(() => { editor = ({ createContentModel: () => fakeModel, + focus: jasmine.createSpy('focus'), } as any) as IContentModelEditor; }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/toggleBlockQuoteTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/toggleBlockQuoteTest.ts index 3dfd07d7878..e1516101856 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/toggleBlockQuoteTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/toggleBlockQuoteTest.ts @@ -9,6 +9,7 @@ describe('toggleBlockQuote', () => { beforeEach(() => { editor = ({ + focus: jasmine.createSpy('focus'), createContentModel: () => fakeModel, } as any) as IContentModelEditor; }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/getFormatStateTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/getFormatStateTest.ts index 648e903b823..bb228f4fae5 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/getFormatStateTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/getFormatStateTest.ts @@ -1,6 +1,9 @@ import * as getPendingFormat from '../../../lib/modelApi/format/pendingFormat'; import * as retrieveModelFormatState from '../../../lib/modelApi/common/retrieveModelFormatState'; -import getFormatState from '../../../lib/publicApi/format/getFormatState'; +import getFormatState, { + reducedModelChildProcessor, +} from '../../../lib/publicApi/format/getFormatState'; +import { DomToModelContext } from 'roosterjs-content-model-types'; import { FormatState, SelectionRangeTypes } from 'roosterjs-editor-types'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { @@ -180,3 +183,318 @@ describe('getFormatState', () => { ); }); }); +describe('reducedModelChildProcessor', () => { + let context: DomToModelContext; + + beforeEach(() => { + context = createDomToModelContext(undefined, { + processorOverride: { + child: reducedModelChildProcessor, + }, + }); + }); + + it('Empty DOM', () => { + const doc = createContentModelDocument(); + const div = document.createElement('div'); + + reducedModelChildProcessor(doc, div, context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [], + }); + }); + + it('Single child node, with selected Node in context', () => { + const doc = createContentModelDocument(); + const div = document.createElement('div'); + const span = document.createElement('span'); + + div.appendChild(span); + span.textContent = 'test'; + context.rangeEx = { + type: SelectionRangeTypes.Normal, + ranges: [ + { + commonAncestorContainer: span, + } as any, + ], + areAllCollapsed: false, + }; + + reducedModelChildProcessor(doc, div, context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + isImplicit: true, + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + }, + ], + }); + }); + + it('Multiple child nodes, with selected Node in context', () => { + const doc = createContentModelDocument(); + const div = document.createElement('div'); + const span1 = document.createElement('span'); + const span2 = document.createElement('span'); + const span3 = document.createElement('span'); + + div.appendChild(span1); + div.appendChild(span2); + div.appendChild(span3); + span1.textContent = 'test1'; + span2.textContent = 'test2'; + span3.textContent = 'test3'; + context.rangeEx = { + type: SelectionRangeTypes.Normal, + ranges: [ + { + commonAncestorContainer: span2, + } as any, + ], + areAllCollapsed: false, + }; + + reducedModelChildProcessor(doc, div, context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + isImplicit: true, + segments: [ + { + segmentType: 'Text', + text: 'test2', + format: {}, + }, + ], + }, + ], + }); + }); + + it('Multiple child nodes, with selected Node in context, with more child nodes under selected node', () => { + const doc = createContentModelDocument(); + const div = document.createElement('div'); + const span1 = document.createElement('span'); + const span2 = document.createElement('span'); + const span3 = document.createElement('span'); + + div.appendChild(span1); + div.appendChild(span2); + div.appendChild(span3); + span1.textContent = 'test1'; + span2.innerHTML = '
line1
line2
'; + span3.textContent = 'test3'; + context.rangeEx = { + type: SelectionRangeTypes.Normal, + ranges: [ + { + commonAncestorContainer: span2, + } as any, + ], + areAllCollapsed: false, + }; + + reducedModelChildProcessor(doc, div, context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'line1', + format: {}, + }, + ], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [], + format: {}, + isImplicit: true, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'line2', + format: {}, + }, + ], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [], + format: {}, + isImplicit: true, + }, + ], + }); + }); + + it('Multiple layer with multiple child nodes, with selected Node in context, with more child nodes under selected node', () => { + const doc = createContentModelDocument(); + const div1 = document.createElement('div'); + const div2 = document.createElement('div'); + const div3 = document.createElement('div'); + const span1 = document.createElement('span'); + const span2 = document.createElement('span'); + const span3 = document.createElement('span'); + + div3.appendChild(span1); + div3.appendChild(span2); + div3.appendChild(span3); + div1.appendChild(div2); + div2.appendChild(div3); + span1.textContent = 'test1'; + span2.innerHTML = '
line1
line2
'; + span3.textContent = 'test3'; + + context.rangeEx = { + type: SelectionRangeTypes.Normal, + ranges: [ + { + commonAncestorContainer: span2, + } as any, + ], + areAllCollapsed: false, + }; + + reducedModelChildProcessor(doc, div1, context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { blockType: 'Paragraph', segments: [], format: {} }, + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [{ segmentType: 'Text', text: 'line1', format: {} }], + format: {}, + }, + { blockType: 'Paragraph', segments: [], format: {}, isImplicit: true }, + { + blockType: 'Paragraph', + segments: [{ segmentType: 'Text', text: 'line2', format: {} }], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [], + format: {}, + isImplicit: true, + }, + { blockType: 'Paragraph', segments: [], format: {}, isImplicit: true }, + { blockType: 'Paragraph', segments: [], format: {}, isImplicit: true }, + ], + }); + }); + + it('With table, need to do format for all table cells', () => { + const doc = createContentModelDocument(); + const div = document.createElement('div'); + div.innerHTML = + 'aa
test1test2
bb'; + context.rangeEx = { + type: SelectionRangeTypes.Normal, + ranges: [ + { + commonAncestorContainer: div.querySelector('#selection') as HTMLElement, + } as any, + ], + areAllCollapsed: false, + }; + + reducedModelChildProcessor(doc, div, context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + rows: [ + { + format: {}, + height: 0, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test1', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test2', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: {}, + widths: [], + dataset: {}, + }, + ], + }); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/getSegmentFormatTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/getSegmentFormatTest.ts deleted file mode 100644 index e0f923216c3..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/getSegmentFormatTest.ts +++ /dev/null @@ -1,94 +0,0 @@ -import * as getPendingFormat from '../../../lib/modelApi/format/pendingFormat'; -import getSegmentFormat from '../../../lib/publicApi/format/getSegmentFormat'; -import { ContentModelSegmentFormat, DomToModelOption } from 'roosterjs-content-model-types'; -import { createRange } from 'roosterjs-editor-dom'; -import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; -import { PositionType, SelectionRangeTypes } from 'roosterjs-editor-types'; -import { - createContentModelDocument, - createDomToModelContext, - normalizeContentModel, -} from 'roosterjs-content-model-dom'; - -const selectedNodeId = 'Selected'; - -describe('getSegmentFormat', () => { - function runTest( - html: string, - pendingFormat: ContentModelSegmentFormat | null, - expectedFormat: ContentModelSegmentFormat | null - ) { - spyOn(getPendingFormat, 'getPendingFormat').and.returnValue(pendingFormat); - - const editor = ({ - getUndoState: () => ({ - canUndo: false, - canRedo: false, - }), - isDarkMode: () => false, - getZoomScale: () => 1, - createContentModel: (options: DomToModelOption) => { - const model = createContentModelDocument(); - const editorDiv = document.createElement('div'); - - editorDiv.innerHTML = html; - - const selectedNode = editorDiv.querySelector('#' + selectedNodeId); - const range = - selectedNode && - createRange(selectedNode, PositionType.Begin, selectedNode, PositionType.End); - const context = createDomToModelContext( - undefined, - options, - range - ? { - type: SelectionRangeTypes.Normal, - ranges: [range], - areAllCollapsed: range.collapsed, - } - : undefined - ); - - context.elementProcessors.child(model, editorDiv, context); - - normalizeContentModel(model); - - return model; - }, - } as any) as IContentModelEditor; - const result = getSegmentFormat(editor); - - expect(result).toEqual(expectedFormat); - } - - it('Empty model', () => { - runTest('', null, null); - }); - - it('Single node', () => { - runTest(`test`, null, { - fontSize: '10px', - textColor: 'red', - }); - }); - - it('Multiple node', () => { - runTest( - `
test1
test2
test3
`, - null, - { fontSize: '10px', textColor: 'red' } - ); - }); - - it('Multiple node, has child under selection', () => { - runTest( - `
test1
line1
line2
test3
`, - null, - { fontSize: '10px', textColor: 'red' } - ); - }); - - it('Has pending format', () => { - runTest('', { fontSize: '10px', textColor: 'red' }, { fontSize: '10px', textColor: 'red' }); - }); -}); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatWithContentModelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatWithContentModelTest.ts index 765394800e3..3b940d92159 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatWithContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatWithContentModelTest.ts @@ -49,7 +49,7 @@ describe('formatWithContentModel', () => { expect(createContentModel).toHaveBeenCalledTimes(1); expect(addUndoSnapshot).not.toHaveBeenCalled(); expect(setContentModel).not.toHaveBeenCalled(); - expect(focus).not.toHaveBeenCalled(); + expect(focus).toHaveBeenCalled(); }); it('Callback return true', () => { From 991fed16d6521f401b4eead6643414e7140cd62b Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Tue, 8 Aug 2023 23:06:05 -0700 Subject: [PATCH 07/75] Content Model: improve formatWithContentModel 2 (#2002) * Content Model: Improve cache behavior * fix build * Content Model: improve formatWithContentModel * Content Model: improve formatWithContentModel 2 * fix format --- .../ContentModelCopyPastePlugin.ts | 19 +- .../editor/plugins/ContentModelEditPlugin.ts | 33 +- .../editor/utils/handleKeyboardEventCommon.ts | 45 +-- .../lib/index.ts | 7 + .../lib/modelApi/common/mergeModel.ts | 6 +- .../lib/modelApi/edit/deleteSelection.ts | 10 +- .../deleteSteps/deleteAllSegmentBefore.ts | 4 +- .../deleteSteps/deleteCollapsedSelection.ts | 8 +- .../edit/utils/DeleteSelectionStep.ts | 30 +- .../lib/modelApi/edit/utils/deleteBlock.ts | 10 +- .../edit/utils/deleteExpandedSelection.ts | 10 +- .../lib/modelApi/edit/utils/deleteSegment.ts | 10 +- .../publicApi/editing/handleKeyDownEvent.ts | 21 +- .../publicApi/format/applyPendingFormat.ts | 79 ++-- .../lib/publicApi/image/insertImage.ts | 5 +- .../lib/publicApi/link/insertLink.ts | 5 +- .../lib/publicApi/table/insertTable.ts | 8 +- .../publicApi/utils/formatWithContentModel.ts | 87 +++-- .../lib/publicApi/utils/paste.ts | 5 +- .../FormatWithContentModelContext.ts | 86 +++++ .../plugins/ContentModelEditPluginTest.ts | 33 +- .../utils/handleKeyboardEventCommonTest.ts | 151 +------- .../test/modelApi/common/mergeModelTest.ts | 157 +++++--- .../test/modelApi/edit/deleteSelectionTest.ts | 363 ++++++------------ .../publicApi/editing/editingTestCommon.ts | 3 + .../editing/handleKeyDownEventTest.ts | 54 +-- .../format/applyPendingFormatTest.ts | 14 +- .../test/publicApi/format/clearFormatTest.ts | 2 +- .../publicApi/list/setListStartNumberTest.ts | 2 +- .../test/publicApi/list/setListStyleTest.ts | 2 +- .../utils/formatWithContentModelTest.ts | 100 ++++- 31 files changed, 613 insertions(+), 756 deletions(-) create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/publicTypes/parameter/FormatWithContentModelContext.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCopyPastePlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCopyPastePlugin.ts index 49e0f8b610a..ebb60327368 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCopyPastePlugin.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCopyPastePlugin.ts @@ -3,7 +3,6 @@ import { cloneModel } from '../../modelApi/common/cloneModel'; import { contentModelToDom } from 'roosterjs-content-model-dom'; import { deleteSelection } from '../../modelApi/edit/deleteSelection'; import { formatWithContentModel } from '../../publicApi/utils/formatWithContentModel'; -import { getOnDeleteEntityCallback } from '../utils/handleKeyboardEventCommon'; import { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; import { iterateSelections } from '../../modelApi/selection/iterateSelections'; import type { @@ -20,7 +19,6 @@ import { createRange, extractClipboardItems, toArray, - Browser, wrap, safeInstanceOf, } from 'roosterjs-editor-dom'; @@ -146,11 +144,8 @@ export default class ContentModelCopyPastePlugin implements PluginWithState { - deleteSelection( - model, - getOnDeleteEntityCallback(editor as IContentModelEditor) - ); + (model, context) => { + deleteSelection(model, [], context); return true; }, @@ -180,7 +175,6 @@ export default class ContentModelCopyPastePlugin implements PluginWithState { if (!editor.isDisposed()) { - removeContentForAndroid(editor); paste(editor, clipboardData); } }); @@ -221,16 +215,11 @@ function cleanUpAndRestoreSelection(tempDiv: HTMLDivElement) { tempDiv.style.display = 'none'; moveChildNodes(tempDiv); } + function isClipboardEvent(event: Event): event is ClipboardEvent { return !!(event as ClipboardEvent).clipboardData; } -function removeContentForAndroid(editor: IContentModelEditor) { - if (Browser.isAndroid) { - const model = editor.createContentModel(); - deleteSelection(model, getOnDeleteEntityCallback(editor)); - editor.setContentModel(model); - } -} + function selectionExToRange( selection: SelectionRangeEx | null, tempDiv: HTMLDivElement diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/ContentModelEditPlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/ContentModelEditPlugin.ts index 11c4c051776..5f7e3e54ede 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/ContentModelEditPlugin.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/ContentModelEditPlugin.ts @@ -3,13 +3,11 @@ import { ContentModelSegmentFormat } from 'roosterjs-content-model-types'; import { DeleteResult } from '../../modelApi/edit/utils/DeleteSelectionStep'; import { deleteSelection } from '../../modelApi/edit/deleteSelection'; import { formatWithContentModel } from '../../publicApi/utils/formatWithContentModel'; -import { getOnDeleteEntityCallback } from '../utils/handleKeyboardEventCommon'; import { getPendingFormat, setPendingFormat } from '../../modelApi/format/pendingFormat'; import { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; import { isNodeOfType, normalizeContentModel } from 'roosterjs-content-model-dom'; import { EditorPlugin, - EntityOperationEvent, IEditor, Keys, NodePosition, @@ -38,7 +36,6 @@ const ProcessKey = 'Process'; */ export default class ContentModelEditPlugin implements EditorPlugin { private editor: IContentModelEditor | null = null; - private triggeredEntityEvents: EntityOperationEvent[] = []; private hasDefaultFormat = false; /** @@ -82,10 +79,6 @@ export default class ContentModelEditPlugin implements EditorPlugin { onPluginEvent(event: PluginEvent) { if (this.editor) { switch (event.eventType) { - case PluginEventType.EntityOperation: - this.handleEntityOperationEvent(this.editor, event); - break; - case PluginEventType.KeyDown: this.handleKeyDownEvent(this.editor, event); break; @@ -99,15 +92,6 @@ export default class ContentModelEditPlugin implements EditorPlugin { } } - private handleEntityOperationEvent(editor: IContentModelEditor, event: EntityOperationEvent) { - if (event.rawEvent?.type == 'keydown') { - // If we see an entity operation event triggered from keydown event, it means the event can be triggered from original - // EntityFeatures or EntityPlugin, so we don't need to trigger the same event again from ContentModel. - // TODO: This is a temporary solution. Once Content Model can fully replace Entity Features, we can remove this. - this.triggeredEntityEvents.push(event); - } - } - private handleKeyDownEvent(editor: IContentModelEditor, event: PluginKeyDownEvent) { const rawEvent = event.rawEvent; const which = rawEvent.which; @@ -125,7 +109,7 @@ export default class ContentModelEditPlugin implements EditorPlugin { rangeEx.type == SelectionRangeTypes.Normal ? rangeEx.ranges[0] : null; if (this.shouldDeleteWithContentModel(range, rawEvent)) { - handleKeyDownEvent(editor, rawEvent, this.triggeredEntityEvents); + handleKeyDownEvent(editor, rawEvent); } else { editor.cacheContentModel(null); } @@ -144,10 +128,6 @@ export default class ContentModelEditPlugin implements EditorPlugin { break; } } - - if (this.triggeredEntityEvents.length > 0) { - this.triggeredEntityEvents = []; - } } private tryApplyDefaultFormat(editor: IContentModelEditor) { @@ -166,15 +146,8 @@ export default class ContentModelEditPlugin implements EditorPlugin { } } - formatWithContentModel(editor, 'input', model => { - const result = deleteSelection( - model, - getOnDeleteEntityCallback( - editor, - undefined /*rawEvent*/, - this.triggeredEntityEvents - ) - ); + formatWithContentModel(editor, 'input', (model, context) => { + const result = deleteSelection(model, [], context); if (result.deleteResult == DeleteResult.Range) { normalizeContentModel(model); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/handleKeyboardEventCommon.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/handleKeyboardEventCommon.ts index 1782497f58c..c6e81e6238a 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/handleKeyboardEventCommon.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/handleKeyboardEventCommon.ts @@ -1,41 +1,9 @@ import { ContentModelDocument } from 'roosterjs-content-model-types'; -import { DeleteResult, OnDeleteEntity } from '../../modelApi/edit/utils/DeleteSelectionStep'; -import { EntityOperationEvent, PluginEventType } from 'roosterjs-editor-types'; +import { DeleteResult } from '../../modelApi/edit/utils/DeleteSelectionStep'; +import { FormatWithContentModelContext } from '../../publicTypes/parameter/FormatWithContentModelContext'; import { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; import { normalizeContentModel } from 'roosterjs-content-model-dom'; - -/** - * @internal - */ -export function getOnDeleteEntityCallback( - editor: IContentModelEditor, - rawEvent?: KeyboardEvent, - triggeredEntityEvents: EntityOperationEvent[] = [] -): OnDeleteEntity { - return (entity, operation) => { - if (entity.id && entity.type) { - // Only trigger entity operation event when the same event was not triggered before. - // TODO: This is a temporary solution as the event deletion is handled by both original EntityPlugin/EntityFeatures and ContentModel. - // Later when Content Model can fully replace Content Edit Features, we can remove this check. - if (!triggeredEntityEvents.some(x => x.entity.wrapper == entity.wrapper)) { - editor.triggerPluginEvent(PluginEventType.EntityOperation, { - entity: { - id: entity.id, - isReadonly: entity.isReadonly, - type: entity.type, - wrapper: entity.wrapper, - }, - operation, - rawEvent: rawEvent, - }); - } - } - - // If entity is still in editor and default behavior of event is prevented, that means plugin wants to keep this entity - // Return true to tell caller we should keep it. - return !!rawEvent?.defaultPrevented && editor.contains(entity.wrapper); - }; -} +import { PluginEventType } from 'roosterjs-editor-types'; /** * @internal @@ -45,8 +13,11 @@ export function handleKeyboardEventResult( editor: IContentModelEditor, model: ContentModelDocument, rawEvent: KeyboardEvent, - result: DeleteResult + result: DeleteResult, + context: FormatWithContentModelContext ): boolean { + context.skipUndoSnapshot = true; + switch (result) { case DeleteResult.NotDeleted: // We have not delete anything, we will let browser handle this event, so clear cached model if any since the content will be changed by browser @@ -66,7 +37,7 @@ export function handleKeyboardEventResult( if (result == DeleteResult.Range) { // A range is about to be deleted, so add an undo snapshot immediately - editor.addUndoSnapshot(); + context.skipUndoSnapshot = false; } // Trigger an event to let plugins know the content is about to be changed by Content Model keyboard editing. diff --git a/packages-content-model/roosterjs-content-model-editor/lib/index.ts b/packages-content-model/roosterjs-content-model-editor/lib/index.ts index 597c4749045..4312d9c548e 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/index.ts @@ -16,6 +16,12 @@ export { export { IContentModelEditor, ContentModelEditorOptions } from './publicTypes/IContentModelEditor'; export { InsertPoint } from './publicTypes/selection/InsertPoint'; export { TableSelectionContext } from './publicTypes/selection/TableSelectionContext'; +export { + DeletedEntity, + FormatWithContentModelContext, + FormatWithContentModelOptions, + ContentModelFormatter, +} from './publicTypes/parameter/FormatWithContentModelContext'; export { default as insertTable } from './publicApi/table/insertTable'; export { default as formatTable } from './publicApi/table/formatTable'; @@ -63,6 +69,7 @@ export { default as adjustImageSelection } from './publicApi/image/adjustImageSe export { default as setParagraphMargin } from './publicApi/block/setParagraphMargin'; export { default as toggleCode } from './publicApi/segment/toggleCode'; export { default as paste } from './publicApi/utils/paste'; +export { formatWithContentModel } from './publicApi/utils/formatWithContentModel'; export { default as ContentModelEditor } from './editor/ContentModelEditor'; export { default as isContentModelEditor } from './editor/isContentModelEditor'; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/mergeModel.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/mergeModel.ts index 4196d976ea3..f15cf469f52 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/mergeModel.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/mergeModel.ts @@ -1,11 +1,11 @@ import { addSegment } from 'roosterjs-content-model-dom'; import { applyTableFormat } from '../table/applyTableFormat'; import { deleteSelection } from '../edit/deleteSelection'; +import { FormatWithContentModelContext } from '../../publicTypes/parameter/FormatWithContentModelContext'; import { getClosestAncestorBlockGroupIndex } from './getClosestAncestorBlockGroupIndex'; import { getObjectKeys } from 'roosterjs-editor-dom'; import { InsertPoint } from '../../publicTypes/selection/InsertPoint'; import { normalizeTable } from '../table/normalizeTable'; -import { OnDeleteEntity } from '../edit/utils/DeleteSelectionStep'; import { createListItem, createParagraph, @@ -62,11 +62,11 @@ export interface MergeModelOption { export function mergeModel( target: ContentModelDocument, source: ContentModelDocument, - onDeleteEntity: OnDeleteEntity, + context?: FormatWithContentModelContext, options?: MergeModelOption ) { const insertPosition = - options?.insertPosition ?? deleteSelection(target, onDeleteEntity).insertPoint; + options?.insertPosition ?? deleteSelection(target, [], context).insertPoint; if (insertPosition) { if (options?.mergeFormat && options.mergeFormat != 'none') { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/deleteSelection.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/deleteSelection.ts index 4819e2f4d46..26d4b9a9236 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/deleteSelection.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/deleteSelection.ts @@ -1,12 +1,12 @@ import { ContentModelDocument } from 'roosterjs-content-model-types'; import { deleteExpandedSelection } from './utils/deleteExpandedSelection'; +import { FormatWithContentModelContext } from '../../publicTypes/parameter/FormatWithContentModelContext'; import { DeleteResult, DeleteSelectionContext, DeleteSelectionResult, DeleteSelectionStep, ValidDeleteSelectionContext, - OnDeleteEntity, } from './utils/DeleteSelectionStep'; /** @@ -14,10 +14,10 @@ import { */ export function deleteSelection( model: ContentModelDocument, - onDeleteEntity: OnDeleteEntity, - additionalSteps: (DeleteSelectionStep | null)[] = [] + additionalSteps: (DeleteSelectionStep | null)[] = [], + formatContext?: FormatWithContentModelContext ): DeleteSelectionResult { - const context = deleteExpandedSelection(model, onDeleteEntity); + const context = deleteExpandedSelection(model, formatContext); additionalSteps.forEach(step => { if ( @@ -25,7 +25,7 @@ export function deleteSelection( isValidDeleteSelectionContext(context) && context.deleteResult == DeleteResult.NotDeleted ) { - step(context, onDeleteEntity); + step(context); } }); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/deleteSteps/deleteAllSegmentBefore.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/deleteSteps/deleteAllSegmentBefore.ts index 2586e2b7282..ecc9a18fac8 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/deleteSteps/deleteAllSegmentBefore.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/deleteSteps/deleteAllSegmentBefore.ts @@ -4,7 +4,7 @@ import { deleteSegment } from '../utils/deleteSegment'; /** * @internal */ -export const deleteAllSegmentBefore: DeleteSelectionStep = (context, onDeleteEntity) => { +export const deleteAllSegmentBefore: DeleteSelectionStep = context => { const { paragraph, marker } = context.insertPoint; const index = paragraph.segments.indexOf(marker); @@ -13,7 +13,7 @@ export const deleteAllSegmentBefore: DeleteSelectionStep = (context, onDeleteEnt segment.isSelected = true; - if (deleteSegment(paragraph, segment, onDeleteEntity)) { + if (deleteSegment(paragraph, segment, context.formatContext)) { context.deleteResult = DeleteResult.Range; } } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/deleteSteps/deleteCollapsedSelection.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/deleteSteps/deleteCollapsedSelection.ts index 8f4c08a329b..3512044aeab 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/deleteSteps/deleteCollapsedSelection.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/deleteSteps/deleteCollapsedSelection.ts @@ -7,7 +7,7 @@ import { deleteSegment } from '../utils/deleteSegment'; import { setParagraphNotImplicit } from 'roosterjs-content-model-dom'; function getDeleteCollapsedSelection(direction: 'forward' | 'backward'): DeleteSelectionStep { - return (context, onDeleteEntity) => { + return context => { const isForward = direction == 'forward'; const { paragraph, marker, path, tableContext } = context.insertPoint; const segments = paragraph.segments; @@ -19,7 +19,7 @@ function getDeleteCollapsedSelection(direction: 'forward' | 'backward'): DeleteS let blockToDelete: BlockAndPath | null; if (segmentToDelete) { - if (deleteSegment(paragraph, segmentToDelete, onDeleteEntity, direction)) { + if (deleteSegment(paragraph, segmentToDelete, context.formatContext, direction)) { context.deleteResult = DeleteResult.SingleChar; // It is possible that we have deleted everything from this paragraph, so we need to mark it as not implicit @@ -32,7 +32,7 @@ function getDeleteCollapsedSelection(direction: 'forward' | 'backward'): DeleteS if (block.blockType == 'Paragraph') { if (siblingSegment) { // When selection is under general segment, need to check if it has a sibling sibling, and delete from it - if (deleteSegment(block, siblingSegment, onDeleteEntity, direction)) { + if (deleteSegment(block, siblingSegment, context.formatContext, direction)) { context.deleteResult = DeleteResult.Range; } } else { @@ -58,8 +58,8 @@ function getDeleteCollapsedSelection(direction: 'forward' | 'backward'): DeleteS deleteBlock( path[0].blocks, block, - onDeleteEntity, undefined /*replacement*/, + context.formatContext, direction ) ) { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/DeleteSelectionStep.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/DeleteSelectionStep.ts index f004d28586f..64bd1300ab3 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/DeleteSelectionStep.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/DeleteSelectionStep.ts @@ -1,8 +1,7 @@ -import { ContentModelEntity, ContentModelParagraph } from 'roosterjs-content-model-types'; -import { EntityOperation } from 'roosterjs-editor-types'; +import { ContentModelParagraph } from 'roosterjs-content-model-types'; +import { FormatWithContentModelContext } from '../../../publicTypes/parameter/FormatWithContentModelContext'; import { InsertPoint } from '../../../publicTypes/selection/InsertPoint'; import { TableSelectionContext } from '../../../publicTypes/selection/TableSelectionContext'; -import type { CompatibleEntityOperation } from 'roosterjs-editor-types/lib/compatibleTypes'; /** * @internal @@ -28,6 +27,7 @@ export interface DeleteSelectionResult { export interface DeleteSelectionContext extends DeleteSelectionResult { lastParagraph?: ContentModelParagraph; lastTableContext?: TableSelectionContext; + formatContext?: FormatWithContentModelContext; } /** @@ -39,27 +39,5 @@ export interface ValidDeleteSelectionContext extends DeleteSelectionContext { /** * @internal - * A callback for deleteSelection API to decide how to handle an entity - * @param entity The entity to delete - * @param operation The operation of entity - * @returns True means we want to keep this entity, so deleteSelection() will not remove it. Otherwise false, - * the entity will be removed from Content Model */ -export type OnDeleteEntity = ( - entity: ContentModelEntity, - operation: - | EntityOperation.RemoveFromStart - | EntityOperation.RemoveFromEnd - | EntityOperation.Overwrite - | CompatibleEntityOperation.RemoveFromStart - | CompatibleEntityOperation.RemoveFromEnd - | CompatibleEntityOperation.Overwrite -) => boolean; - -/** - * @internal - */ -export type DeleteSelectionStep = ( - context: ValidDeleteSelectionContext, - onDeleteEntity: OnDeleteEntity -) => void; +export type DeleteSelectionStep = (context: ValidDeleteSelectionContext) => void; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteBlock.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteBlock.ts index 87ca5cd7aa4..fdc5f6ce501 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteBlock.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteBlock.ts @@ -1,6 +1,6 @@ import { ContentModelBlock } from 'roosterjs-content-model-types'; import { EntityOperation } from 'roosterjs-editor-types'; -import { OnDeleteEntity } from './DeleteSelectionStep'; +import { FormatWithContentModelContext } from '../../../publicTypes/parameter/FormatWithContentModelContext'; /** * @internal @@ -8,8 +8,8 @@ import { OnDeleteEntity } from './DeleteSelectionStep'; export function deleteBlock( blocks: ContentModelBlock[], blockToDelete: ContentModelBlock, - onDeleteEntity: OnDeleteEntity, replacement?: ContentModelBlock, + context?: FormatWithContentModelContext, direction?: 'forward' | 'backward' ): boolean { const index = blocks.indexOf(blockToDelete); @@ -29,8 +29,12 @@ export function deleteBlock( ? EntityOperation.RemoveFromEnd : undefined; - if (operation !== undefined && !onDeleteEntity(blockToDelete, operation)) { + if (operation !== undefined) { replacement ? blocks.splice(index, 1, replacement) : blocks.splice(index, 1); + context?.deletedEntities.push({ + entity: blockToDelete, + operation, + }); } return true; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteExpandedSelection.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteExpandedSelection.ts index 23ca5f22065..9b4998b6aba 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteExpandedSelection.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteExpandedSelection.ts @@ -1,8 +1,9 @@ import { ContentModelDocument } from 'roosterjs-content-model-types'; import { createInsertPoint } from '../utils/createInsertPoint'; import { deleteBlock } from '../utils/deleteBlock'; -import { DeleteResult, DeleteSelectionContext, OnDeleteEntity } from '../utils/DeleteSelectionStep'; +import { DeleteResult, DeleteSelectionContext } from '../utils/DeleteSelectionStep'; import { deleteSegment } from '../utils/deleteSegment'; +import { FormatWithContentModelContext } from '../../../publicTypes/parameter/FormatWithContentModelContext'; import { iterateSelections, IterateSelectionsOption } from '../../selection/iterateSelections'; import { createBr, @@ -24,11 +25,12 @@ const DeleteSelectionIteratingOptions: IterateSelectionsOption = { */ export function deleteExpandedSelection( model: ContentModelDocument, - onDeleteEntity: OnDeleteEntity + formatContext?: FormatWithContentModelContext ): DeleteSelectionContext { const context: DeleteSelectionContext = { deleteResult: DeleteResult.NotDeleted, insertPoint: null, + formatContext, }; iterateSelections( @@ -70,7 +72,7 @@ export function deleteExpandedSelection( path, tableContext ); - } else if (deleteSegment(block, segment, onDeleteEntity)) { + } else if (deleteSegment(block, segment, context.formatContext)) { context.deleteResult = DeleteResult.Range; } }); @@ -86,7 +88,7 @@ export function deleteExpandedSelection( // Delete a whole block (divider, table, ...) const blocks = path[0].blocks; - if (deleteBlock(blocks, block, onDeleteEntity, paragraph)) { + if (deleteBlock(blocks, block, paragraph, context.formatContext)) { context.deleteResult = DeleteResult.Range; } } else if (tableContext) { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteSegment.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteSegment.ts index a0b014f0d5b..35f33db56ba 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteSegment.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteSegment.ts @@ -1,9 +1,9 @@ import { ContentModelParagraph, ContentModelSegment } from 'roosterjs-content-model-types'; import { deleteSingleChar } from './deleteSingleChar'; import { EntityOperation } from 'roosterjs-editor-types'; +import { FormatWithContentModelContext } from '../../../publicTypes/parameter/FormatWithContentModelContext'; import { isWhiteSpacePreserved, normalizeSingleSegment } from 'roosterjs-content-model-dom'; import { normalizeText } from '../../../domUtils/stringUtil'; -import { OnDeleteEntity } from './DeleteSelectionStep'; /** * @internal @@ -11,7 +11,7 @@ import { OnDeleteEntity } from './DeleteSelectionStep'; export function deleteSegment( paragraph: ContentModelParagraph, segmentToDelete: ContentModelSegment, - onDeleteEntity: OnDeleteEntity, + context?: FormatWithContentModelContext, direction?: 'forward' | 'backward' ): boolean { const segments = paragraph.segments; @@ -39,8 +39,12 @@ export function deleteSegment( : isBackward ? EntityOperation.RemoveFromEnd : undefined; - if (operation !== undefined && !onDeleteEntity(segmentToDelete, operation)) { + if (operation !== undefined) { segments.splice(index, 1); + context?.deletedEntities.push({ + entity: segmentToDelete, + operation, + }); } return true; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/editing/handleKeyDownEvent.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/editing/handleKeyDownEvent.ts index fd3a4262b1b..6b477dd2e42 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/editing/handleKeyDownEvent.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/editing/handleKeyDownEvent.ts @@ -1,12 +1,11 @@ import { Browser } from 'roosterjs-editor-dom'; -import { ChangeSource, EntityOperationEvent, Keys } from 'roosterjs-editor-types'; +import { ChangeSource, Keys } from 'roosterjs-editor-types'; import { deleteAllSegmentBefore } from '../../modelApi/edit/deleteSteps/deleteAllSegmentBefore'; import { deleteSelection } from '../../modelApi/edit/deleteSelection'; import { DeleteSelectionStep } from '../../modelApi/edit/utils/DeleteSelectionStep'; import { formatWithContentModel } from '../utils/formatWithContentModel'; import { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; import { - getOnDeleteEntityCallback, handleKeyboardEventResult, shouldDeleteAllSegmentsBefore, shouldDeleteWord, @@ -25,27 +24,19 @@ import { * Handle KeyDown event * Currently only DELETE and BACKSPACE keys are supported */ -export default function handleKeyDownEvent( - editor: IContentModelEditor, - rawEvent: KeyboardEvent, - triggeredEntityEvents: EntityOperationEvent[] -) { +export default function handleKeyDownEvent(editor: IContentModelEditor, rawEvent: KeyboardEvent) { const which = rawEvent.which; formatWithContentModel( editor, which == Keys.DELETE ? 'handleDeleteKey' : 'handleBackspaceKey', - model => { - const result = deleteSelection( - model, - getOnDeleteEntityCallback(editor, rawEvent, triggeredEntityEvents), - getDeleteSteps(rawEvent) - ).deleteResult; + (model, context) => { + const result = deleteSelection(model, getDeleteSteps(rawEvent), context).deleteResult; - return handleKeyboardEventResult(editor, model, rawEvent, result); + return handleKeyboardEventResult(editor, model, rawEvent, result, context); }, { - skipUndoSnapshot: true, // No need to add undo snapshot for each key down event. We will trigger a ContentChanged event and let UndoPlugin decide when to add undo snapshot + rawEvent, changeSource: ChangeSource.Keyboard, getChangeData: () => which, } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/applyPendingFormat.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/applyPendingFormat.ts index ad7c79eb773..50293fb72a6 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/applyPendingFormat.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/applyPendingFormat.ts @@ -22,58 +22,49 @@ export default function applyPendingFormat(editor: IContentModelEditor, data: st if (format) { let isChanged = false; - formatWithContentModel( - editor, - 'applyPendingFormat', - model => { - iterateSelections([model], (_, __, block, segments) => { - if ( - block?.blockType == 'Paragraph' && - segments?.length == 1 && - segments[0].segmentType == 'SelectionMarker' - ) { - const marker = segments[0]; - const index = block.segments.indexOf(marker); - const previousSegment = block.segments[index - 1]; + formatWithContentModel(editor, 'applyPendingFormat', (model, context) => { + iterateSelections([model], (_, __, block, segments) => { + if ( + block?.blockType == 'Paragraph' && + segments?.length == 1 && + segments[0].segmentType == 'SelectionMarker' + ) { + const marker = segments[0]; + const index = block.segments.indexOf(marker); + const previousSegment = block.segments[index - 1]; - if (previousSegment?.segmentType == 'Text') { - const text = previousSegment.text; - const subStr = text.substr(-data.length, data.length); + if (previousSegment?.segmentType == 'Text') { + const text = previousSegment.text; + const subStr = text.substr(-data.length, data.length); - // For space, there can be (space) or   ( ), we treat them as the same - if ( - subStr == data || - (data == ANSI_SPACE && subStr == NON_BREAK_SPACE) - ) { - marker.format = { ...format }; - previousSegment.text = text.substring(0, text.length - data.length); + // For space, there can be (space) or   ( ), we treat them as the same + if (subStr == data || (data == ANSI_SPACE && subStr == NON_BREAK_SPACE)) { + marker.format = { ...format }; + previousSegment.text = text.substring(0, text.length - data.length); - 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, + ...format, + } + ); - block.segments.splice(index, 0, newText); - setParagraphNotImplicit(block); - isChanged = true; - } + block.segments.splice(index, 0, newText); + setParagraphNotImplicit(block); + isChanged = true; } } - return true; - }); - - if (isChanged) { - normalizeContentModel(model); } + return true; + }); - return isChanged; - }, - { - skipUndoSnapshot: true, + if (isChanged) { + normalizeContentModel(model); + context.skipUndoSnapshot = true; } - ); + + return isChanged; + }); } } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/image/insertImage.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/image/insertImage.ts index fefa1833923..b3a47c880df 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/image/insertImage.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/image/insertImage.ts @@ -1,6 +1,5 @@ import { addSegment, createContentModelDocument, createImage } from 'roosterjs-content-model-dom'; import { formatWithContentModel } from '../utils/formatWithContentModel'; -import { getOnDeleteEntityCallback } from '../../editor/utils/handleKeyboardEventCommon'; import { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; import { mergeModel } from '../../modelApi/common/mergeModel'; import { readFile } from 'roosterjs-editor-dom'; @@ -23,12 +22,12 @@ export default function insertImage(editor: IContentModelEditor, imageFileOrSrc: } function insertImageWithSrc(editor: IContentModelEditor, src: string) { - formatWithContentModel(editor, 'insertImage', model => { + formatWithContentModel(editor, 'insertImage', (model, context) => { const image = createImage(src, { backgroundColor: '' }); const doc = createContentModelDocument(); addSegment(doc, image); - mergeModel(model, doc, getOnDeleteEntityCallback(editor), { + mergeModel(model, doc, context, { mergeFormat: 'mergeAll', }); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/link/insertLink.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/link/insertLink.ts index d2dc3b872b3..fd61d92252f 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/link/insertLink.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/link/insertLink.ts @@ -2,7 +2,6 @@ import getSelectedSegments from '../selection/getSelectedSegments'; import { ChangeSource } from 'roosterjs-editor-types'; import { ContentModelLink } from 'roosterjs-content-model-types'; import { formatWithContentModel } from '../utils/formatWithContentModel'; -import { getOnDeleteEntityCallback } from '../../editor/utils/handleKeyboardEventCommon'; import { getPendingFormat } from '../../modelApi/format/pendingFormat'; import { HtmlSanitizer, matchLink } from 'roosterjs-editor-dom'; import { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; @@ -60,7 +59,7 @@ export default function insertLink( formatWithContentModel( editor, 'insertLink', - model => { + (model, context) => { const segments = getSelectedSegments(model, false /*includingFormatHolder*/); const originalText = segments .map(x => (x.segmentType == 'Text' ? x.text : '')) @@ -95,7 +94,7 @@ export default function insertLink( links.push(segment.link); } - mergeModel(model, doc, getOnDeleteEntityCallback(editor), { + mergeModel(model, doc, context, { mergeFormat: 'mergeAll', }); } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/insertTable.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/insertTable.ts index 6536ed69b23..530d2a46785 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/insertTable.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/insertTable.ts @@ -3,7 +3,6 @@ import { createContentModelDocument, createSelectionMarker } from 'roosterjs-con import { createTableStructure } from '../../modelApi/table/createTableStructure'; import { deleteSelection } from '../../modelApi/edit/deleteSelection'; import { formatWithContentModel } from '../utils/formatWithContentModel'; -import { getOnDeleteEntityCallback } from '../../editor/utils/handleKeyboardEventCommon'; import { getPendingFormat } from '../../modelApi/format/pendingFormat'; import { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; import { mergeModel } from '../../modelApi/common/mergeModel'; @@ -26,9 +25,8 @@ export default function insertTable( rows: number, format?: Partial ) { - formatWithContentModel(editor, 'insertTable', model => { - const onDeleteEntity = getOnDeleteEntityCallback(editor); - const insertPosition = deleteSelection(model, onDeleteEntity).insertPoint; + formatWithContentModel(editor, 'insertTable', (model, context) => { + const insertPosition = deleteSelection(model, [], context).insertPoint; if (insertPosition) { const doc = createContentModelDocument(); @@ -38,7 +36,7 @@ export default function insertTable( // Assign default vertical align format = format || { verticalAlign: 'top' }; applyTableFormat(table, format); - mergeModel(model, doc, onDeleteEntity, { + mergeModel(model, doc, context, { insertPosition, mergeFormat: 'mergeAll', }); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatWithContentModel.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatWithContentModel.ts index b680075db13..208c277492a 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatWithContentModel.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatWithContentModel.ts @@ -1,58 +1,43 @@ -import { ChangeSource } from 'roosterjs-editor-types'; -import { ContentModelDocument, OnNodeCreated } from 'roosterjs-content-model-types'; +import { ChangeSource, PluginEventType } from 'roosterjs-editor-types'; import { getPendingFormat, setPendingFormat } from '../../modelApi/format/pendingFormat'; import { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; +import { + ContentModelFormatter, + FormatWithContentModelContext, + FormatWithContentModelOptions, +} from '../../publicTypes/parameter/FormatWithContentModelContext'; /** - * @internal - */ -export interface FormatWithContentModelOptions { - /** - * When set to true, if there is pending format, it will be preserved after this format operation is done - */ - preservePendingFormat?: boolean; - - /** - * When pass true, skip adding undo snapshot when write Content Model back to DOM - */ - skipUndoSnapshot?: boolean; - - /** - * Change source used for triggering a ContentChanged event. @default ChangeSource.Format. - */ - changeSource?: string; - - /** - * An optional callback that will be called when a DOM node is created - * @param modelElement The related Content Model element - * @param node The node created for this model element - */ - onNodeCreated?: OnNodeCreated; - - /** - * Optional callback to get an object used for change data in ContentChangedEvent - */ - getChangeData?: () => any; -} - -/** - * @internal + * The general API to do format change with Content Model + * It will grab a Content Model for current editor content, and invoke a callback function + * to do format change. Then according to the return value, write back the modified content model into editor. + * If there is cached model, it will be used and updated. + * @param editor Content Model editor + * @param apiName Name of the format API + * @param formatter Formatter function, see ContentModelFormatter + * @param options More options, see FormatWithContentModelOptions */ export function formatWithContentModel( editor: IContentModelEditor, apiName: string, - callback: (model: ContentModelDocument) => boolean, + formatter: ContentModelFormatter, options?: FormatWithContentModelOptions ) { - const { onNodeCreated, preservePendingFormat, getChangeData, skipUndoSnapshot, changeSource } = + const { onNodeCreated, preservePendingFormat, getChangeData, changeSource, rawEvent } = options || {}; editor.focus(); const model = editor.createContentModel(); + const context: FormatWithContentModelContext = { + deletedEntities: [], + rawEvent, + }; - if (callback(model)) { + if (formatter(model, context)) { const callback = () => { + handleDeletedEntities(editor, context); + if (model) { editor.setContentModel(model, { onNodeCreated }); } @@ -69,11 +54,11 @@ export function formatWithContentModel( return getChangeData?.(); }; - if (skipUndoSnapshot) { - callback(); + if (context.skipUndoSnapshot) { + const contentChangedEventData = callback(); if (changeSource) { - editor.triggerContentChangedEvent(changeSource, getChangeData?.()); + editor.triggerContentChangedEvent(changeSource, contentChangedEventData); } } else { editor.addUndoSnapshot( @@ -89,3 +74,23 @@ export function formatWithContentModel( editor.cacheContentModel?.(model); } } + +function handleDeletedEntities( + editor: IContentModelEditor, + context: FormatWithContentModelContext +) { + context.deletedEntities.forEach(({ entity, operation }) => { + if (entity.id && entity.type) { + editor.triggerPluginEvent(PluginEventType.EntityOperation, { + entity: { + id: entity.id, + isReadonly: entity.isReadonly, + type: entity.type, + wrapper: entity.wrapper, + }, + operation, + rawEvent: context.rawEvent, + }); + } + }); +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/paste.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/paste.ts index 0d4b921dda7..51037e98421 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/paste.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/paste.ts @@ -1,7 +1,6 @@ import { ContentModelBlockFormat, FormatParser } from 'roosterjs-content-model-types'; import { domToContentModel } from 'roosterjs-content-model-dom'; import { formatWithContentModel } from './formatWithContentModel'; -import { getOnDeleteEntityCallback } from '../../editor/utils/handleKeyboardEventCommon'; import { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; import { mergeModel } from '../../modelApi/common/mergeModel'; import { NodePosition } from 'roosterjs-editor-types'; @@ -85,11 +84,11 @@ export default function paste( formatWithContentModel( editor, 'Paste', - model => { + (model, context) => { if (customizedMerge) { customizedMerge(model, pasteModel); } else { - mergeModel(model, pasteModel, getOnDeleteEntityCallback(editor), { + mergeModel(model, pasteModel, context, { mergeFormat: applyCurrentFormat ? 'keepSourceEmphasisFormat' : 'none', mergeTable: pasteModel.blocks.length === 1 && diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/parameter/FormatWithContentModelContext.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/parameter/FormatWithContentModelContext.ts new file mode 100644 index 00000000000..8d98e8d3d38 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/parameter/FormatWithContentModelContext.ts @@ -0,0 +1,86 @@ +import { + ContentModelDocument, + ContentModelEntity, + OnNodeCreated, +} from 'roosterjs-content-model-types'; +import { EntityOperation } from 'roosterjs-editor-types'; +import type { CompatibleEntityOperation } from 'roosterjs-editor-types/lib/compatibleTypes'; + +/** + * Represents an entity that is deleted by a specified entity operation + */ +export interface DeletedEntity { + entity: ContentModelEntity; + operation: + | EntityOperation.RemoveFromStart + | EntityOperation.RemoveFromEnd + | EntityOperation.Overwrite + | CompatibleEntityOperation.RemoveFromStart + | CompatibleEntityOperation.RemoveFromEnd + | CompatibleEntityOperation.Overwrite; +} + +/** + * Context object for API formatWithContentModel + */ +export interface FormatWithContentModelContext { + /** + * Entities got deleted during formatting. Need to be set by the formatter function + */ + readonly deletedEntities: DeletedEntity[]; + + /** + * Raw Event that triggers this format call + */ + readonly rawEvent?: Event; + + /** + * @optional + * When pass true, skip adding undo snapshot when write Content Model back to DOM. + * Need to be set by the formatter function + */ + skipUndoSnapshot?: boolean; +} + +/** + * Options for API formatWithContentModel + */ +export interface FormatWithContentModelOptions { + /** + * When set to true, if there is pending format, it will be preserved after this format operation is done + */ + preservePendingFormat?: boolean; + + /** + * Raw event object that triggers this call + */ + rawEvent?: Event; + + /** + * Change source used for triggering a ContentChanged event. @default ChangeSource.Format. + */ + changeSource?: string; + + /** + * An optional callback that will be called when a DOM node is created + * @param modelElement The related Content Model element + * @param node The node created for this model element + */ + onNodeCreated?: OnNodeCreated; + + /** + * Optional callback to get an object used for change data in ContentChangedEvent + */ + getChangeData?: () => any; +} + +/** + * Type of formatter used for format Content Model. + * @param model The source Content Model to format + * @param context A context object used for pass in and out more parameters + * @returns True means the model is changed and need to write back to editor, otherwise false + */ +export type ContentModelFormatter = ( + model: ContentModelDocument, + context: FormatWithContentModelContext +) => boolean; diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelEditPluginTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelEditPluginTest.ts index 758a81aefa7..10a9e296223 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelEditPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelEditPluginTest.ts @@ -50,7 +50,7 @@ describe('ContentModelEditPlugin', () => { rawEvent, }); - expect(handleKeyDownEventSpy).toHaveBeenCalledWith(editor, rawEvent, []); + expect(handleKeyDownEventSpy).toHaveBeenCalledWith(editor, rawEvent); expect(cacheContentModel).not.toHaveBeenCalled(); }); @@ -65,7 +65,7 @@ describe('ContentModelEditPlugin', () => { rawEvent, }); - expect(handleKeyDownEventSpy).toHaveBeenCalledWith(editor, rawEvent, []); + expect(handleKeyDownEventSpy).toHaveBeenCalledWith(editor, rawEvent); expect(cacheContentModel).not.toHaveBeenCalled(); }); @@ -118,20 +118,9 @@ describe('ContentModelEditPlugin', () => { rawEvent: { which: Keys.DELETE } as any, }); - expect(handleKeyDownEventSpy).toHaveBeenCalledWith( - editor, - { which: Keys.DELETE } as any, - [ - { - eventType: PluginEventType.EntityOperation, - operation: EntityOperation.Overwrite, - rawEvent: { - type: 'keydown', - } as any, - entity: wrapper, - }, - ] - ); + expect(handleKeyDownEventSpy).toHaveBeenCalledWith(editor, { + which: Keys.DELETE, + } as any); plugin.onPluginEvent({ eventType: PluginEventType.KeyDown, @@ -139,11 +128,9 @@ describe('ContentModelEditPlugin', () => { }); expect(handleKeyDownEventSpy).toHaveBeenCalledTimes(2); - expect(handleKeyDownEventSpy).toHaveBeenCalledWith( - editor, - { which: Keys.DELETE } as any, - [] - ); + expect(handleKeyDownEventSpy).toHaveBeenCalledWith(editor, { + which: Keys.DELETE, + } as any); expect(cacheContentModel).not.toHaveBeenCalled(); }); @@ -230,7 +217,7 @@ describe('ContentModelEditPlugin', () => { rawEvent, }); - expect(handleKeyDownEventSpy).toHaveBeenCalledWith(editor, rawEvent, []); + expect(handleKeyDownEventSpy).toHaveBeenCalledWith(editor, rawEvent); expect(cacheContentModel).not.toHaveBeenCalled(); }); @@ -251,7 +238,7 @@ describe('ContentModelEditPlugin', () => { rawEvent, }); - expect(handleKeyDownEventSpy).toHaveBeenCalledWith(editor, rawEvent, []); + expect(handleKeyDownEventSpy).toHaveBeenCalledWith(editor, rawEvent); expect(cacheContentModel).not.toHaveBeenCalled(); }); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/utils/handleKeyboardEventCommonTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/utils/handleKeyboardEventCommonTest.ts index df5d1296372..613500dc825 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/utils/handleKeyboardEventCommonTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/utils/handleKeyboardEventCommonTest.ts @@ -1,133 +1,14 @@ import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; import { DeleteResult } from '../../../lib/modelApi/edit/utils/DeleteSelectionStep'; -import { EntityOperation, PluginEventType } from 'roosterjs-editor-types'; +import { FormatWithContentModelContext } from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; +import { PluginEventType } from 'roosterjs-editor-types'; import { - getOnDeleteEntityCallback, handleKeyboardEventResult, shouldDeleteAllSegmentsBefore, shouldDeleteWord, } from '../../../lib/editor/utils/handleKeyboardEventCommon'; -describe('getOnDeleteEntityCallback', () => { - let mockedEditor: IContentModelEditor; - let mockedEvent: KeyboardEvent; - let triggerPluginEvent: jasmine.Spy; - let contains: jasmine.Spy; - - beforeEach(() => { - triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); - contains = jasmine.createSpy('contains').and.returnValue(true); - - mockedEditor = ({ - triggerPluginEvent, - contains, - } as any) as IContentModelEditor; - mockedEvent = ({ - defaultPrevented: false, - } as any) as KeyboardEvent; - }); - - it('Entity without id', () => { - const func = getOnDeleteEntityCallback(mockedEditor, mockedEvent, []); - - const result = func( - { - blockType: 'Entity', - segmentType: 'Entity', - format: {}, - isReadonly: true, - wrapper: {} as any, - }, - EntityOperation.RemoveFromStart - ); - - expect(result).toBeFalse(); - expect(triggerPluginEvent).not.toHaveBeenCalled(); - }); - - it('Entity with id and type', () => { - const func = getOnDeleteEntityCallback(mockedEditor, mockedEvent, []); - - const result = func( - { - blockType: 'Entity', - segmentType: 'Entity', - format: {}, - isReadonly: true, - wrapper: {} as any, - id: '1', - type: '2', - }, - EntityOperation.RemoveFromStart - ); - - expect(result).toBeFalse(); - expect(triggerPluginEvent).toHaveBeenCalledWith(PluginEventType.EntityOperation, { - entity: { - id: '1', - type: '2', - isReadonly: true, - wrapper: {} as any, - }, - operation: EntityOperation.RemoveFromStart, - rawEvent: mockedEvent, - }); - }); - - it('Entity with id and type and change defaultPrevented', () => { - triggerPluginEvent.and.callFake((_1: any, param: any) => { - param.rawEvent.defaultPrevented = true; - }); - - const func = getOnDeleteEntityCallback(mockedEditor, mockedEvent, []); - - const result = func( - { - blockType: 'Entity', - segmentType: 'Entity', - format: {}, - isReadonly: true, - wrapper: {} as any, - id: '1', - type: '2', - }, - EntityOperation.RemoveFromStart - ); - - expect(result).toBeTrue(); - expect(triggerPluginEvent).toHaveBeenCalledWith(PluginEventType.EntityOperation, { - entity: { - id: '1', - type: '2', - isReadonly: true, - wrapper: {} as any, - }, - operation: EntityOperation.RemoveFromStart, - rawEvent: mockedEvent, - }); - }); - - it('Call with triggeredEntityEvents', () => { - const wrapper = 'WRAPPER'; - const entity = { - wrapper, - } as any; - const func = getOnDeleteEntityCallback(mockedEditor, mockedEvent, [ - { - eventType: PluginEventType.EntityOperation, - operation: EntityOperation.Overwrite, - entity, - }, - ]); - - const result = func({ wrapper } as any, EntityOperation.Overwrite); - - expect(result).toBeFalse(); - expect(triggerPluginEvent).not.toHaveBeenCalled(); - }); -}); - describe('handleKeyboardEventResult', () => { let mockedEditor: IContentModelEditor; let mockedEvent: KeyboardEvent; @@ -161,12 +42,13 @@ describe('handleKeyboardEventResult', () => { const mockedModel = 'MODEL' as any; const which = 'WHICH' as any; (mockedEvent).which = which; - + const context: FormatWithContentModelContext = { deletedEntities: [] }; const result = handleKeyboardEventResult( mockedEditor, mockedModel, mockedEvent, - DeleteResult.SingleChar + DeleteResult.SingleChar, + context ); expect(result).toBeTrue(); @@ -177,17 +59,18 @@ describe('handleKeyboardEventResult', () => { expect(triggerPluginEvent).toHaveBeenCalledWith(PluginEventType.BeforeKeyboardEditing, { rawEvent: mockedEvent, }); - expect(addUndoSnapshot).not.toHaveBeenCalled(); + expect(context.skipUndoSnapshot).toBeTrue(); }); it('DeleteResult.NotDeleted', () => { const mockedModel = 'MODEL' as any; - + const context: FormatWithContentModelContext = { deletedEntities: [] }; const result = handleKeyboardEventResult( mockedEditor, mockedModel, mockedEvent, - DeleteResult.NotDeleted + DeleteResult.NotDeleted, + context ); expect(result).toBeFalse(); @@ -196,17 +79,18 @@ describe('handleKeyboardEventResult', () => { expect(normalizeContentModel.normalizeContentModel).not.toHaveBeenCalled(); expect(cacheContentModel).toHaveBeenCalledWith(null); expect(triggerPluginEvent).not.toHaveBeenCalled(); - expect(addUndoSnapshot).not.toHaveBeenCalled(); + expect(context.skipUndoSnapshot).toBeTrue(); }); it('DeleteResult.Range', () => { const mockedModel = 'MODEL' as any; - + const context: FormatWithContentModelContext = { deletedEntities: [] }; const result = handleKeyboardEventResult( mockedEditor, mockedModel, mockedEvent, - DeleteResult.Range + DeleteResult.Range, + context ); expect(result).toBeTrue(); @@ -217,17 +101,18 @@ describe('handleKeyboardEventResult', () => { expect(triggerPluginEvent).toHaveBeenCalledWith(PluginEventType.BeforeKeyboardEditing, { rawEvent: mockedEvent, }); - expect(addUndoSnapshot).toHaveBeenCalled(); + expect(context.skipUndoSnapshot).toBeFalse(); }); it('DeleteResult.NothingToDelete', () => { const mockedModel = 'MODEL' as any; - + const context: FormatWithContentModelContext = { deletedEntities: [] }; const result = handleKeyboardEventResult( mockedEditor, mockedModel, mockedEvent, - DeleteResult.NothingToDelete + DeleteResult.NothingToDelete, + context ); expect(result).toBeFalse(); @@ -236,7 +121,7 @@ describe('handleKeyboardEventResult', () => { expect(normalizeContentModel.normalizeContentModel).not.toHaveBeenCalled(); expect(cacheContentModel).not.toHaveBeenCalled(); expect(triggerPluginEvent).not.toHaveBeenCalled(); - expect(addUndoSnapshot).not.toHaveBeenCalled(); + expect(context.skipUndoSnapshot).toBeTrue(); }); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/mergeModelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/mergeModelTest.ts index 766928e3335..ded796939fc 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/mergeModelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/mergeModelTest.ts @@ -14,10 +14,6 @@ import { createText, } from 'roosterjs-content-model-dom'; -function onDeleteEntityMock() { - return false; -} - describe('mergeModel', () => { it('empty to single selection', () => { const majorModel = createContentModelDocument(); @@ -28,7 +24,7 @@ describe('mergeModel', () => { para.segments.push(marker); majorModel.blocks.push(para); - mergeModel(majorModel, sourceModel, onDeleteEntityMock); + mergeModel(majorModel, sourceModel, { deletedEntities: [] }); expect(majorModel).toEqual({ blockGroupType: 'Document', @@ -68,7 +64,7 @@ describe('mergeModel', () => { para2.segments.push(text1, text2); sourceModel.blocks.push(para2); - mergeModel(majorModel, sourceModel, onDeleteEntityMock); + mergeModel(majorModel, sourceModel, { deletedEntities: [] }); expect(majorModel).toEqual({ blockGroupType: 'Document', @@ -118,7 +114,7 @@ describe('mergeModel', () => { majorModel.blocks.push(para1); sourceModel.blocks.push(para2); - mergeModel(majorModel, sourceModel, onDeleteEntityMock); + mergeModel(majorModel, sourceModel, { deletedEntities: [] }); expect(majorModel).toEqual({ blockGroupType: 'Document', @@ -197,7 +193,7 @@ describe('mergeModel', () => { sourceModel.blocks.push(newPara1); sourceModel.blocks.push(newPara2); - mergeModel(majorModel, sourceModel, onDeleteEntityMock); + mergeModel(majorModel, sourceModel, { deletedEntities: [] }); expect(majorModel).toEqual({ blockGroupType: 'Document', @@ -292,7 +288,7 @@ describe('mergeModel', () => { sourceModel.blocks.push(newPara2); sourceModel.blocks.push(newPara3); - mergeModel(majorModel, sourceModel, onDeleteEntityMock); + mergeModel(majorModel, sourceModel, { deletedEntities: [] }); expect(majorModel).toEqual({ blockGroupType: 'Document', @@ -439,7 +435,7 @@ describe('mergeModel', () => { sourceModel.blocks.push(newList1); sourceModel.blocks.push(newList2); - mergeModel(majorModel, sourceModel, onDeleteEntityMock); + mergeModel(majorModel, sourceModel, { deletedEntities: [] }); expect(majorModel).toEqual({ blockGroupType: 'Document', @@ -605,7 +601,7 @@ describe('mergeModel', () => { sourceModel.blocks.push(newList1); sourceModel.blocks.push(newList2); - mergeModel(majorModel, sourceModel, onDeleteEntityMock); + mergeModel(majorModel, sourceModel, { deletedEntities: [] }); expect(majorModel).toEqual({ blockGroupType: 'Document', @@ -793,7 +789,7 @@ describe('mergeModel', () => { sourceModel.blocks.push(newTable1); - mergeModel(majorModel, sourceModel, onDeleteEntityMock); + mergeModel(majorModel, sourceModel, { deletedEntities: [] }); expect(majorModel).toEqual({ blockGroupType: 'Document', @@ -894,7 +890,7 @@ describe('mergeModel', () => { spyOn(applyTableFormat, 'applyTableFormat'); spyOn(normalizeTable, 'normalizeTable'); - mergeModel(majorModel, sourceModel, onDeleteEntityMock); + mergeModel(majorModel, sourceModel, { deletedEntities: [] }); expect(normalizeTable.normalizeTable).not.toHaveBeenCalled(); expect(majorModel).toEqual({ @@ -1011,9 +1007,14 @@ describe('mergeModel', () => { spyOn(applyTableFormat, 'applyTableFormat'); spyOn(normalizeTable, 'normalizeTable'); - mergeModel(majorModel, sourceModel, onDeleteEntityMock, { - mergeTable: true, - }); + mergeModel( + majorModel, + sourceModel, + { deletedEntities: [] }, + { + mergeTable: true, + } + ); expect(normalizeTable.normalizeTable).toHaveBeenCalledTimes(1); expect(majorModel).toEqual({ @@ -1148,9 +1149,14 @@ describe('mergeModel', () => { spyOn(applyTableFormat, 'applyTableFormat'); spyOn(normalizeTable, 'normalizeTable'); - mergeModel(majorModel, sourceModel, onDeleteEntityMock, { - mergeTable: true, - }); + mergeModel( + majorModel, + sourceModel, + { deletedEntities: [] }, + { + mergeTable: true, + } + ); expect(normalizeTable.normalizeTable).toHaveBeenCalledTimes(1); expect(majorModel).toEqual({ @@ -1274,9 +1280,14 @@ describe('mergeModel', () => { spyOn(applyTableFormat, 'applyTableFormat'); spyOn(normalizeTable, 'normalizeTable'); - mergeModel(majorModel, sourceModel, onDeleteEntityMock, { - mergeTable: true, - }); + mergeModel( + majorModel, + sourceModel, + { deletedEntities: [] }, + { + mergeTable: true, + } + ); expect(normalizeTable.normalizeTable).toHaveBeenCalledTimes(1); expect(majorModel).toEqual({ @@ -1383,13 +1394,18 @@ describe('mergeModel', () => { newPara.segments.push(newText); sourceModel.blocks.push(newPara); - mergeModel(majorModel, sourceModel, onDeleteEntityMock, { - insertPosition: { - marker: marker2, - paragraph: para1, - path: [majorModel], - }, - }); + mergeModel( + majorModel, + sourceModel, + { deletedEntities: [] }, + { + insertPosition: { + marker: marker2, + paragraph: para1, + path: [majorModel], + }, + } + ); expect(majorModel).toEqual({ blockGroupType: 'Document', @@ -1461,9 +1477,14 @@ describe('mergeModel', () => { para1.segments.push(marker); majorModel.blocks.push(para1); - mergeModel(majorModel, sourceModel, onDeleteEntityMock, { - mergeFormat: 'mergeAll', - }); + mergeModel( + majorModel, + sourceModel, + { deletedEntities: [] }, + { + mergeFormat: 'mergeAll', + } + ); expect(majorModel).toEqual({ blockGroupType: 'Document', @@ -1518,9 +1539,14 @@ describe('mergeModel', () => { para1.segments.push(marker); majorModel.blocks.push(para1); - mergeModel(majorModel, sourceModel, onDeleteEntityMock, { - mergeFormat: 'keepSourceEmphasisFormat', - }); + mergeModel( + majorModel, + sourceModel, + { deletedEntities: [] }, + { + mergeFormat: 'keepSourceEmphasisFormat', + } + ); expect(majorModel).toEqual({ blockGroupType: 'Document', @@ -1581,9 +1607,14 @@ describe('mergeModel', () => { para1.segments.push(marker); majorModel.blocks.push(para1); - mergeModel(majorModel, sourceModel, onDeleteEntityMock, { - mergeFormat: 'keepSourceEmphasisFormat', - }); + mergeModel( + majorModel, + sourceModel, + { deletedEntities: [] }, + { + mergeFormat: 'keepSourceEmphasisFormat', + } + ); expect(majorModel).toEqual({ blockGroupType: 'Document', @@ -1671,9 +1702,14 @@ describe('mergeModel', () => { para1.segments.push(marker); majorModel.blocks.push(para1); - mergeModel(majorModel, sourceModel, onDeleteEntityMock, { - mergeFormat: 'keepSourceEmphasisFormat', - }); + mergeModel( + majorModel, + sourceModel, + { deletedEntities: [] }, + { + mergeFormat: 'keepSourceEmphasisFormat', + } + ); expect(majorModel).toEqual({ blockGroupType: 'Document', @@ -1745,7 +1781,7 @@ describe('mergeModel', () => { sourceModel.blocks.push(divider); - mergeModel(majorModel, sourceModel, onDeleteEntityMock); + mergeModel(majorModel, sourceModel, { deletedEntities: [] }); expect(majorModel).toEqual({ blockGroupType: 'Document', @@ -1812,7 +1848,7 @@ describe('mergeModel', () => { sourceModel.blocks.push(newPara1); sourceModel.blocks.push(newPara2); - mergeModel(majorModel, sourceModel, onDeleteEntityMock); + mergeModel(majorModel, sourceModel, { deletedEntities: [] }); expect(majorModel).toEqual({ blockGroupType: 'Document', @@ -1909,9 +1945,14 @@ describe('mergeModel', () => { para1.segments.push(marker); majorModel.blocks.push(para1); - mergeModel(majorModel, sourceModel, onDeleteEntityMock, { - mergeFormat: 'keepSourceEmphasisFormat', - }); + mergeModel( + majorModel, + sourceModel, + { deletedEntities: [] }, + { + mergeFormat: 'keepSourceEmphasisFormat', + } + ); expect(majorModel).toEqual({ blockGroupType: 'Document', @@ -1985,9 +2026,14 @@ describe('mergeModel', () => { para1.segments.push(marker); majorModel.blocks.push(para1); - mergeModel(majorModel, sourceModel, onDeleteEntityMock, { - mergeFormat: 'mergeAll', - }); + mergeModel( + majorModel, + sourceModel, + { deletedEntities: [] }, + { + mergeFormat: 'mergeAll', + } + ); expect(majorModel).toEqual({ blockGroupType: 'Document', @@ -2164,9 +2210,14 @@ describe('mergeModel', () => { para1.segments.push(marker); majorModel.blocks.push(para1); - mergeModel(majorModel, sourceModel, onDeleteEntityMock, { - mergeFormat: 'mergeAll', - }); + mergeModel( + majorModel, + sourceModel, + { deletedEntities: [] }, + { + mergeFormat: 'mergeAll', + } + ); expect(majorModel).toEqual({ blockGroupType: 'Document', @@ -2334,7 +2385,7 @@ describe('mergeModel', () => { sourceModel.blocks.push(heading); - mergeModel(majorModel, sourceModel, onDeleteEntityMock); + mergeModel(majorModel, sourceModel); expect(majorModel).toEqual({ blockGroupType: 'Document', diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/edit/deleteSelectionTest.ts b/packages-content-model/roosterjs-content-model-editor/test/modelApi/edit/deleteSelectionTest.ts index 4fe35c07587..d85e654052e 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/edit/deleteSelectionTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/modelApi/edit/deleteSelectionTest.ts @@ -1,4 +1,5 @@ import { ContentModelSelectionMarker } from 'roosterjs-content-model-types'; +import { DeletedEntity } from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; import { DeleteResult } from '../../../lib/modelApi/edit/utils/DeleteSelectionStep'; import { deleteSelection } from '../../../lib/modelApi/edit/deleteSelection'; import { EntityOperation } from 'roosterjs-editor-types'; @@ -27,10 +28,6 @@ import { forwardDeleteCollapsedSelection, } from '../../../lib/modelApi/edit/deleteSteps/deleteCollapsedSelection'; -function onDeleteEntityMock() { - return false; -} - describe('deleteSelection - selectionOnly', () => { it('empty selection', () => { const model = createContentModelDocument(); @@ -38,7 +35,7 @@ describe('deleteSelection - selectionOnly', () => { model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock); + const result = deleteSelection(model); expect(model).toEqual({ blockGroupType: 'Document', @@ -63,7 +60,7 @@ describe('deleteSelection - selectionOnly', () => { para.segments.push(marker); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock); + const result = deleteSelection(model); expect(result.deleteResult).toBe(DeleteResult.NotDeleted); expect(result.insertPoint).toEqual({ @@ -103,7 +100,7 @@ describe('deleteSelection - selectionOnly', () => { para.segments.push(text); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock); + const result = deleteSelection(model); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -152,7 +149,7 @@ describe('deleteSelection - selectionOnly', () => { model.blocks.push(para1); model.blocks.push(para2); - const result = deleteSelection(model, onDeleteEntityMock); + const result = deleteSelection(model); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -200,7 +197,7 @@ describe('deleteSelection - selectionOnly', () => { divider.isSelected = true; model.blocks.push(divider); - const result = deleteSelection(model, onDeleteEntityMock); + const result = deleteSelection(model); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -254,7 +251,7 @@ describe('deleteSelection - selectionOnly', () => { divider2.isSelected = true; model.blocks.push(para1, divider1, divider2, para2); - const result = deleteSelection(model, onDeleteEntityMock); + const result = deleteSelection(model); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -324,7 +321,7 @@ describe('deleteSelection - selectionOnly', () => { table.rows[0].cells.push(cell1, cell2); model.blocks.push(table); - const result = deleteSelection(model, onDeleteEntityMock); + const result = deleteSelection(model); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -425,7 +422,7 @@ describe('deleteSelection - selectionOnly', () => { table.rows[0].cells.push(cell); model.blocks.push(table); - const result = deleteSelection(model, onDeleteEntityMock); + const result = deleteSelection(model); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -477,7 +474,7 @@ describe('deleteSelection - selectionOnly', () => { entity.isSelected = true; - const result = deleteSelection(model, onDeleteEntityMock); + const result = deleteSelection(model); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -525,12 +522,12 @@ describe('deleteSelection - selectionOnly', () => { const model = createContentModelDocument(); const wrapper = 'WRAPPER' as any; const entity = createEntity(wrapper, true); + const deletedEntities: DeletedEntity[] = []; model.blocks.push(entity); entity.isSelected = true; - const onDeleteEntity = jasmine.createSpy('onDeleteEntity').and.returnValue(false); - const result = deleteSelection(model, onDeleteEntity, []); + const result = deleteSelection(model, [], { deletedEntities }); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -573,7 +570,7 @@ describe('deleteSelection - selectionOnly', () => { ], }); - expect(onDeleteEntity).toHaveBeenCalledWith(entity, EntityOperation.Overwrite); + expect(deletedEntities).toEqual([{ entity, operation: EntityOperation.Overwrite }]); }); it('Entity selection, callback returns true', () => { @@ -584,8 +581,8 @@ describe('deleteSelection - selectionOnly', () => { entity.isSelected = true; - const onDeleteEntity = jasmine.createSpy('onDeleteEntity').and.returnValue(true); - const result = deleteSelection(model, onDeleteEntity, []); + const deletedEntities: DeletedEntity[] = []; + const result = deleteSelection(model, [], { deletedEntities }); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -614,19 +611,21 @@ describe('deleteSelection - selectionOnly', () => { blockGroupType: 'Document', blocks: [ { - blockType: 'Entity', - segmentType: 'Entity', - wrapper: wrapper, + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], format: {}, - isReadonly: true, - id: undefined, - type: undefined, - isSelected: true, + isImplicit: false, }, ], }); - expect(onDeleteEntity).toHaveBeenCalledWith(entity, EntityOperation.Overwrite); + expect(deletedEntities).toEqual([{ entity, operation: EntityOperation.Overwrite }]); }); it('delete with default format', () => { @@ -638,7 +637,7 @@ describe('deleteSelection - selectionOnly', () => { divider.isSelected = true; model.blocks.push(divider); - const result = deleteSelection(model, onDeleteEntityMock); + const result = deleteSelection(model); const marker: ContentModelSelectionMarker = { segmentType: 'SelectionMarker', format: { fontSize: '10pt' }, @@ -681,7 +680,7 @@ describe('deleteSelection - selectionOnly', () => { general.isSelected = true; model.blocks.push(general); - const result = deleteSelection(model, onDeleteEntityMock); + const result = deleteSelection(model); const marker: ContentModelSelectionMarker = { segmentType: 'SelectionMarker', format: {}, @@ -723,7 +722,7 @@ describe('deleteSelection - selectionOnly', () => { general.isSelected = true; model.blocks.push(divider, general); - const result = deleteSelection(model, onDeleteEntityMock); + const result = deleteSelection(model); const marker: ContentModelSelectionMarker = { segmentType: 'SelectionMarker', format: {}, @@ -771,7 +770,7 @@ describe('deleteSelection - selectionOnly', () => { para.segments.push(general); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock); + const result = deleteSelection(model); const marker: ContentModelSelectionMarker = { segmentType: 'SelectionMarker', format: {}, @@ -813,7 +812,7 @@ describe('deleteSelection - selectionOnly', () => { para.segments.push(general, text); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock); + const result = deleteSelection(model); const marker: ContentModelSelectionMarker = { segmentType: 'SelectionMarker', format: {}, @@ -854,7 +853,7 @@ describe('deleteSelection - selectionOnly', () => { para.segments.push(text, image); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock); + const result = deleteSelection(model); const marker: ContentModelSelectionMarker = { segmentType: 'SelectionMarker', format: {}, @@ -894,7 +893,7 @@ describe('deleteSelection - selectionOnly', () => { para.segments.push(text); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock); + const result = deleteSelection(model); const marker: ContentModelSelectionMarker = { segmentType: 'SelectionMarker', format: {}, @@ -934,7 +933,7 @@ describe('deleteSelection - selectionOnly', () => { divider.isSelected = true; model.blocks.push(divider); - const result = deleteSelection(model, onDeleteEntityMock); + const result = deleteSelection(model); const marker: ContentModelSelectionMarker = { segmentType: 'SelectionMarker', format: { fontFamily: 'Arial' }, @@ -978,9 +977,7 @@ describe('deleteSelection - forward', () => { model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock, [ - forwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); expect(model).toEqual({ blockGroupType: 'Document', @@ -1005,9 +1002,7 @@ describe('deleteSelection - forward', () => { para.segments.push(marker); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock, [ - forwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.NothingToDelete); expect(result.insertPoint).toEqual({ @@ -1047,9 +1042,7 @@ describe('deleteSelection - forward', () => { para.segments.push(marker, segment); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock, [ - forwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.SingleChar); expect(result.insertPoint).toEqual({ @@ -1097,9 +1090,7 @@ describe('deleteSelection - forward', () => { para2.segments.push(text2); model.blocks.push(para1, para2); - const result = deleteSelection(model, onDeleteEntityMock, [ - forwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -1157,9 +1148,7 @@ describe('deleteSelection - forward', () => { para2.segments.push(text); model.blocks.push(para1, para2); - const result = deleteSelection(model, onDeleteEntityMock, [ - forwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -1213,9 +1202,7 @@ describe('deleteSelection - forward', () => { para2.segments.push(text); model.blocks.push(para1, para2); - const result = deleteSelection(model, onDeleteEntityMock, [ - forwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.SingleChar); expect(result.insertPoint).toEqual({ @@ -1274,9 +1261,7 @@ describe('deleteSelection - forward', () => { para2.segments.push(marker2, text2); model.blocks.push(para1, para2); - const result = deleteSelection(model, onDeleteEntityMock, [ - forwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -1331,9 +1316,7 @@ describe('deleteSelection - forward', () => { para.segments.push(marker, image); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock, [ - forwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.SingleChar); expect(result.insertPoint).toEqual({ @@ -1375,9 +1358,7 @@ describe('deleteSelection - forward', () => { para.segments.push(marker, br); model.blocks.push(para, table); - const result = deleteSelection(model, onDeleteEntityMock, [ - forwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -1418,9 +1399,7 @@ describe('deleteSelection - forward', () => { para.segments.push(marker, br); model.blocks.push(para, divider); - const result = deleteSelection(model, onDeleteEntityMock, [ - forwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -1462,9 +1441,7 @@ describe('deleteSelection - forward', () => { para.segments.push(marker, br); model.blocks.push(para, entity); - const result = deleteSelection(model, onDeleteEntityMock, [ - forwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -1506,8 +1483,10 @@ describe('deleteSelection - forward', () => { para.segments.push(marker, br); model.blocks.push(para, entity); - const onDeleteEntity = jasmine.createSpy('onDeleteEntity').and.returnValue(false); - const result = deleteSelection(model, onDeleteEntity, [forwardDeleteCollapsedSelection]); + const deletedEntities: DeletedEntity[] = []; + const result = deleteSelection(model, [forwardDeleteCollapsedSelection], { + deletedEntities, + }); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -1536,7 +1515,7 @@ describe('deleteSelection - forward', () => { }, ], }); - expect(onDeleteEntity).toHaveBeenCalledWith(entity, EntityOperation.RemoveFromStart); + expect(deletedEntities).toEqual([{ entity, operation: EntityOperation.RemoveFromStart }]); }); it('Single selection marker before entity, with callback returns true', () => { @@ -1550,8 +1529,10 @@ describe('deleteSelection - forward', () => { para.segments.push(marker, br); model.blocks.push(para, entity); - const onDeleteEntity = jasmine.createSpy('onDeleteEntity').and.returnValue(true); - const result = deleteSelection(model, onDeleteEntity, [forwardDeleteCollapsedSelection]); + const deletedEntities: DeletedEntity[] = []; + const result = deleteSelection(model, [forwardDeleteCollapsedSelection], { + deletedEntities, + }); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -1578,18 +1559,9 @@ describe('deleteSelection - forward', () => { }, ], }, - { - blockType: 'Entity', - segmentType: 'Entity', - format: {}, - wrapper: wrapper, - isReadonly: true, - id: undefined, - type: undefined, - }, ], }); - expect(onDeleteEntity).toHaveBeenCalledWith(entity, EntityOperation.RemoveFromStart); + expect(deletedEntities).toEqual([{ entity, operation: EntityOperation.RemoveFromStart }]); }); it('Single selection marker before list item', () => { @@ -1606,9 +1578,7 @@ describe('deleteSelection - forward', () => { listItem.blocks.push(para2); model.blocks.push(para1, listItem); - const result = deleteSelection(model, onDeleteEntityMock, [ - forwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -1676,9 +1646,7 @@ describe('deleteSelection - forward', () => { quote.blocks.push(para2); model.blocks.push(para1, quote); - const result = deleteSelection(model, onDeleteEntityMock, [ - forwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -1741,9 +1709,7 @@ describe('deleteSelection - forward', () => { quote.blocks.push(para1); model.blocks.push(quote, para2); - const result = deleteSelection(model, onDeleteEntityMock, [ - forwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -1808,9 +1774,7 @@ describe('deleteSelection - forward', () => { listItem.blocks.push(para2); model.blocks.push(quote, listItem); - const result = deleteSelection(model, onDeleteEntityMock, [ - forwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -1882,9 +1846,7 @@ describe('deleteSelection - forward', () => { para.segments.push(text1, text2); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock, [ - forwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -1938,9 +1900,7 @@ describe('deleteSelection - forward', () => { model.blocks.push(para1); model.blocks.push(para2); - const result = deleteSelection(model, onDeleteEntityMock, [ - forwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -1988,9 +1948,7 @@ describe('deleteSelection - forward', () => { divider.isSelected = true; model.blocks.push(divider); - const result = deleteSelection(model, onDeleteEntityMock, [ - forwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -2044,9 +2002,7 @@ describe('deleteSelection - forward', () => { divider2.isSelected = true; model.blocks.push(para1, divider1, divider2, para2); - const result = deleteSelection(model, onDeleteEntityMock, [ - forwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -2116,9 +2072,7 @@ describe('deleteSelection - forward', () => { table.rows[0].cells.push(cell1, cell2); model.blocks.push(table); - const result = deleteSelection(model, onDeleteEntityMock, [ - forwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -2219,9 +2173,7 @@ describe('deleteSelection - forward', () => { table.rows[0].cells.push(cell); model.blocks.push(table); - const result = deleteSelection(model, onDeleteEntityMock, [ - forwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -2274,9 +2226,7 @@ describe('deleteSelection - forward', () => { divider.isSelected = true; model.blocks.push(divider); - const result = deleteSelection(model, onDeleteEntityMock, [ - forwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); const marker: ContentModelSelectionMarker = { segmentType: 'SelectionMarker', format: { fontSize: '10pt' }, @@ -2324,9 +2274,7 @@ describe('deleteSelection - forward', () => { parentParagraph.segments.push(general); model.blocks.push(parentParagraph); - const result = deleteSelection(model, onDeleteEntityMock, [ - forwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.NothingToDelete); @@ -2377,9 +2325,7 @@ describe('deleteSelection - forward', () => { parentParagraph.segments.push(general, text); model.blocks.push(parentParagraph); - const result = deleteSelection(model, onDeleteEntityMock, [ - forwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); @@ -2431,9 +2377,7 @@ describe('deleteSelection - forward', () => { para.segments.push(marker, text); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock, [ - forwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.SingleChar); @@ -2477,9 +2421,7 @@ describe('deleteSelection - forward', () => { para.segments.push(marker, text); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock, [ - forwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.SingleChar); @@ -2525,9 +2467,7 @@ describe('deleteSelection - forward', () => { para.segments.push(text1, marker, text2); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock, [ - forwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.SingleChar); @@ -2576,9 +2516,7 @@ describe('deleteSelection - forward', () => { para.segments.push(text1, marker, text2); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock, [ - forwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.SingleChar); @@ -2623,7 +2561,7 @@ describe('deleteSelection - forward', () => { para.segments.push(marker, text1, text2, text3); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock, [forwardDeleteWordSelection]); + const result = deleteSelection(model, [forwardDeleteWordSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); @@ -2666,7 +2604,7 @@ describe('deleteSelection - forward', () => { para.segments.push(marker, text1); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock, [forwardDeleteWordSelection]); + const result = deleteSelection(model, [forwardDeleteWordSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); @@ -2709,7 +2647,7 @@ describe('deleteSelection - forward', () => { para.segments.push(marker, text1); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock, [forwardDeleteWordSelection]); + const result = deleteSelection(model, [forwardDeleteWordSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); @@ -2752,7 +2690,7 @@ describe('deleteSelection - forward', () => { para.segments.push(marker, text1); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock, [forwardDeleteWordSelection]); + const result = deleteSelection(model, [forwardDeleteWordSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); @@ -2794,9 +2732,7 @@ describe('deleteSelection - backward', () => { model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock, [ - backwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); expect(model).toEqual({ blockGroupType: 'Document', @@ -2821,9 +2757,7 @@ describe('deleteSelection - backward', () => { para.segments.push(marker); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock, [ - backwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.NothingToDelete); expect(result.insertPoint).toEqual({ @@ -2863,9 +2797,7 @@ describe('deleteSelection - backward', () => { para.segments.push(segment, marker); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock, [ - backwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.SingleChar); expect(result.insertPoint).toEqual({ @@ -2913,9 +2845,7 @@ describe('deleteSelection - backward', () => { para2.segments.push(marker, text2); model.blocks.push(para1, para2); - const result = deleteSelection(model, onDeleteEntityMock, [ - backwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -2973,9 +2903,7 @@ describe('deleteSelection - backward', () => { para2.segments.push(marker, text); model.blocks.push(para1, para2); - const result = deleteSelection(model, onDeleteEntityMock, [ - backwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -3029,9 +2957,7 @@ describe('deleteSelection - backward', () => { para2.segments.push(marker, text); model.blocks.push(para1, para2); - const result = deleteSelection(model, onDeleteEntityMock, [ - backwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -3089,9 +3015,7 @@ describe('deleteSelection - backward', () => { para2.segments.push(marker2, text2); model.blocks.push(para1, para2); - const result = deleteSelection(model, onDeleteEntityMock, [ - backwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -3146,9 +3070,7 @@ describe('deleteSelection - backward', () => { para.segments.push(image, marker); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock, [ - backwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.SingleChar); expect(result.insertPoint).toEqual({ @@ -3190,9 +3112,7 @@ describe('deleteSelection - backward', () => { para.segments.push(marker, br); model.blocks.push(table, para); - const result = deleteSelection(model, onDeleteEntityMock, [ - backwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -3233,9 +3153,7 @@ describe('deleteSelection - backward', () => { para.segments.push(marker, br); model.blocks.push(divider, para); - const result = deleteSelection(model, onDeleteEntityMock, [ - backwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -3277,9 +3195,7 @@ describe('deleteSelection - backward', () => { para.segments.push(marker, br); model.blocks.push(entity, para); - const result = deleteSelection(model, onDeleteEntityMock, [ - backwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -3321,8 +3237,10 @@ describe('deleteSelection - backward', () => { para.segments.push(marker, br); model.blocks.push(entity, para); - const onDeleteEntity = jasmine.createSpy('onDeleteEntity').and.returnValue(false); - const result = deleteSelection(model, onDeleteEntity, [backwardDeleteCollapsedSelection]); + const deletedEntities: DeletedEntity[] = []; + const result = deleteSelection(model, [backwardDeleteCollapsedSelection], { + deletedEntities, + }); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -3351,7 +3269,7 @@ describe('deleteSelection - backward', () => { }, ], }); - expect(onDeleteEntity).toHaveBeenCalledWith(entity, EntityOperation.RemoveFromEnd); + expect(deletedEntities).toEqual([{ entity, operation: EntityOperation.RemoveFromEnd }]); }); it('Single selection marker after entity, with callback returns true', () => { @@ -3365,8 +3283,10 @@ describe('deleteSelection - backward', () => { para.segments.push(marker, br); model.blocks.push(entity, para); - const onDeleteEntity = jasmine.createSpy('onDeleteEntity').and.returnValue(true); - const result = deleteSelection(model, onDeleteEntity, [backwardDeleteCollapsedSelection]); + const deletedEntities: DeletedEntity[] = []; + const result = deleteSelection(model, [backwardDeleteCollapsedSelection], { + deletedEntities, + }); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -3382,15 +3302,6 @@ describe('deleteSelection - backward', () => { expect(model).toEqual({ blockGroupType: 'Document', blocks: [ - { - blockType: 'Entity', - segmentType: 'Entity', - format: {}, - wrapper: wrapper, - isReadonly: true, - id: undefined, - type: undefined, - }, { blockType: 'Paragraph', format: {}, @@ -3404,7 +3315,7 @@ describe('deleteSelection - backward', () => { }, ], }); - expect(onDeleteEntity).toHaveBeenCalledWith(entity, EntityOperation.RemoveFromEnd); + expect(deletedEntities).toEqual([{ entity, operation: EntityOperation.RemoveFromEnd }]); }); it('Single selection marker after list item', () => { @@ -3421,9 +3332,7 @@ describe('deleteSelection - backward', () => { listItem.blocks.push(para2); model.blocks.push(listItem, para1); - const result = deleteSelection(model, onDeleteEntityMock, [ - backwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -3491,9 +3400,7 @@ describe('deleteSelection - backward', () => { quote.blocks.push(para2); model.blocks.push(quote, para1); - const result = deleteSelection(model, onDeleteEntityMock, [ - backwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -3556,9 +3463,7 @@ describe('deleteSelection - backward', () => { quote.blocks.push(para1); model.blocks.push(para2, quote); - const result = deleteSelection(model, onDeleteEntityMock, [ - backwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -3623,9 +3528,7 @@ describe('deleteSelection - backward', () => { listItem.blocks.push(para2); model.blocks.push(listItem, quote); - const result = deleteSelection(model, onDeleteEntityMock, [ - backwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -3697,9 +3600,7 @@ describe('deleteSelection - backward', () => { para.segments.push(text1, text2); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock, [ - backwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -3753,9 +3654,7 @@ describe('deleteSelection - backward', () => { model.blocks.push(para1); model.blocks.push(para2); - const result = deleteSelection(model, onDeleteEntityMock, [ - backwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -3803,9 +3702,7 @@ describe('deleteSelection - backward', () => { divider.isSelected = true; model.blocks.push(divider); - const result = deleteSelection(model, onDeleteEntityMock, [ - backwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -3859,9 +3756,7 @@ describe('deleteSelection - backward', () => { divider2.isSelected = true; model.blocks.push(para1, divider1, divider2, para2); - const result = deleteSelection(model, onDeleteEntityMock, [ - backwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -3931,9 +3826,7 @@ describe('deleteSelection - backward', () => { table.rows[0].cells.push(cell1, cell2); model.blocks.push(table); - const result = deleteSelection(model, onDeleteEntityMock, [ - backwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -4034,9 +3927,7 @@ describe('deleteSelection - backward', () => { table.rows[0].cells.push(cell); model.blocks.push(table); - const result = deleteSelection(model, onDeleteEntityMock, [ - backwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -4089,9 +3980,7 @@ describe('deleteSelection - backward', () => { divider.isSelected = true; model.blocks.push(divider); - const result = deleteSelection(model, onDeleteEntityMock, [ - backwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); const marker: ContentModelSelectionMarker = { segmentType: 'SelectionMarker', format: { fontSize: '10pt' }, @@ -4139,9 +4028,7 @@ describe('deleteSelection - backward', () => { parentParagraph.segments.push(general); model.blocks.push(parentParagraph); - const result = deleteSelection(model, onDeleteEntityMock, [ - backwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.NothingToDelete); @@ -4192,9 +4079,7 @@ describe('deleteSelection - backward', () => { parentParagraph.segments.push(text, general); model.blocks.push(parentParagraph); - const result = deleteSelection(model, onDeleteEntityMock, [ - backwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); @@ -4246,9 +4131,7 @@ describe('deleteSelection - backward', () => { para.segments.push(text, marker); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock, [ - backwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.SingleChar); @@ -4292,9 +4175,7 @@ describe('deleteSelection - backward', () => { para.segments.push(text, marker); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock, [ - backwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.SingleChar); @@ -4340,9 +4221,7 @@ describe('deleteSelection - backward', () => { para.segments.push(text1, text2, marker); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock, [ - backwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.SingleChar); @@ -4391,9 +4270,7 @@ describe('deleteSelection - backward', () => { para.segments.push(text1, text2, marker); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock, [ - backwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.SingleChar); @@ -4438,7 +4315,7 @@ describe('deleteSelection - backward', () => { para.segments.push(text1, text2, text3, marker); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock, [backwardDeleteWordSelection]); + const result = deleteSelection(model, [backwardDeleteWordSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); @@ -4486,7 +4363,7 @@ describe('deleteSelection - backward', () => { para.segments.push(text1, marker); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock, [backwardDeleteWordSelection]); + const result = deleteSelection(model, [backwardDeleteWordSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); @@ -4529,7 +4406,7 @@ describe('deleteSelection - backward', () => { para.segments.push(text1, marker); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock, [backwardDeleteWordSelection]); + const result = deleteSelection(model, [backwardDeleteWordSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); @@ -4572,7 +4449,7 @@ describe('deleteSelection - backward', () => { para.segments.push(text1, marker); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock, [backwardDeleteWordSelection]); + const result = deleteSelection(model, [backwardDeleteWordSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); @@ -4617,7 +4494,7 @@ describe('deleteSelection - backward', () => { para.segments.push(text1, text2, marker, text3); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock, [backwardDeleteWordSelection]); + const result = deleteSelection(model, [backwardDeleteWordSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); @@ -4661,9 +4538,7 @@ describe('deleteSelection - backward', () => { para.segments.push(text1, marker); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock, [ - backwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.SingleChar); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/editingTestCommon.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/editingTestCommon.ts index 1d1dbd81a8c..42b6e0b051f 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/editingTestCommon.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/editingTestCommon.ts @@ -13,6 +13,7 @@ export function editingTestCommon( spyOn(pendingFormat, 'setPendingFormat'); spyOn(pendingFormat, 'getPendingFormat').and.returnValue(null); + const triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); const triggerContentChangedEvent = jasmine.createSpy('triggerContentChangedEvent'); const addUndoSnapshot = jasmine @@ -29,9 +30,11 @@ export function editingTestCommon( }); const editor = ({ createContentModel: () => model, + cacheContentModel: jasmine.createSpy('cacheContentModel'), addUndoSnapshot, focus: jasmine.createSpy(), setContentModel, + triggerPluginEvent, isDisposed: () => false, getFocusedPosition: () => null as NodePosition, triggerContentChangedEvent, diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/handleKeyDownEventTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/handleKeyDownEventTest.ts index f92a61cc037..eef61461c7b 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/handleKeyDownEventTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/handleKeyDownEventTest.ts @@ -6,6 +6,7 @@ import { ChangeSource, Keys } from 'roosterjs-editor-types'; import { ContentModelDocument } from 'roosterjs-content-model-types'; import { deleteAllSegmentBefore } from '../../../lib/modelApi/edit/deleteSteps/deleteAllSegmentBefore'; import { editingTestCommon } from './editingTestCommon'; +import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { backwardDeleteWordSelection, forwardDeleteWordSelection, @@ -21,17 +22,8 @@ import { describe('handleKeyDownEvent', () => { let deleteSelectionSpy: jasmine.Spy; - let mockedCallback = 'CALLBACK' as any; - let handleKeyboardEventResultSpy: jasmine.Spy; beforeEach(() => { - handleKeyboardEventResultSpy = spyOn( - handleKeyboardEventResult, - 'handleKeyboardEventResult' - ); - spyOn(handleKeyboardEventResult, 'getOnDeleteEntityCallback').and.returnValue( - mockedCallback - ); deleteSelectionSpy = spyOn(deleteSelection, 'deleteSelection'); }); @@ -46,13 +38,12 @@ describe('handleKeyDownEvent', () => { deleteSelectionSpy.and.returnValue({ deleteResult: expectedDelete, }); - handleKeyboardEventResultSpy.and.returnValue( - expectedDelete == DeleteResult.Range || expectedDelete == DeleteResult.SingleChar - ); - const mockedEvent = { + const preventDefault = jasmine.createSpy('preventDefault'); + const mockedEvent = ({ which: key, - } as KeyboardEvent; + preventDefault, + } as any) as KeyboardEvent; let editor: any; @@ -60,25 +51,18 @@ describe('handleKeyDownEvent', () => { 'handleBackspaceKey', newEditor => { editor = newEditor; - handleKeyDownEvent(editor, mockedEvent, []); + handleKeyDownEvent(editor, mockedEvent); }, input, expectedResult, calledTimes ); - expect(handleKeyboardEventResult.getOnDeleteEntityCallback).toHaveBeenCalledWith( - editor, - mockedEvent, - [] - ); - expect(deleteSelectionSpy).toHaveBeenCalledWith(input, mockedCallback, expectedSteps); - expect(handleKeyboardEventResult.handleKeyboardEventResult).toHaveBeenCalledWith( - editor, - input, - mockedEvent, - expectedDelete - ); + expect(deleteSelectionSpy).toHaveBeenCalledWith(input, expectedSteps, { + deletedEntities: [], + rawEvent: mockedEvent, + skipUndoSnapshot: true, + }); } it('Empty model, forward', () => { @@ -377,38 +361,40 @@ describe('handleKeyDownEvent', () => { it('Check parameter of formatWithContentModel, forward', () => { const spy = spyOn(formatWithContentModel, 'formatWithContentModel'); + const addUndoSnapshot = jasmine.createSpy('addUndoSnapshot'); - const editor = 'EDITOR' as any; + const editor = ({ + addUndoSnapshot, + } as any) as IContentModelEditor; const which = Keys.DELETE; const event = { which, } as any; - const triggeredEvents = 'EVENTS' as any; - handleKeyDownEvent(editor, event, triggeredEvents); + handleKeyDownEvent(editor, event); expect(spy.calls.argsFor(0)[0]).toBe(editor); expect(spy.calls.argsFor(0)[1]).toBe('handleDeleteKey'); - expect(spy.calls.argsFor(0)[3]?.skipUndoSnapshot).toBe(true); + expect(addUndoSnapshot).not.toHaveBeenCalled(); expect(spy.calls.argsFor(0)[3]?.changeSource).toBe(ChangeSource.Keyboard); expect(spy.calls.argsFor(0)[3]?.getChangeData?.()).toBe(which); }); it('Check parameter of formatWithContentModel, backward', () => { const spy = spyOn(formatWithContentModel, 'formatWithContentModel'); + const preventDefault = jasmine.createSpy('preventDefault'); const editor = 'EDITOR' as any; const which = Keys.BACKSPACE; const event = { which, + preventDefault, } as any; - const triggeredEvents = 'EVENTS' as any; - handleKeyDownEvent(editor, event, triggeredEvents); + handleKeyDownEvent(editor, event); expect(spy.calls.argsFor(0)[0]).toBe(editor); expect(spy.calls.argsFor(0)[1]).toBe('handleBackspaceKey'); - expect(spy.calls.argsFor(0)[3]?.skipUndoSnapshot).toBe(true); expect(spy.calls.argsFor(0)[3]?.changeSource).toBe(ChangeSource.Keyboard); expect(spy.calls.argsFor(0)[3]?.getChangeData?.()).toBe(which); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/applyPendingFormatTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/applyPendingFormatTest.ts index 2a50eab85bc..40e2e030e61 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/applyPendingFormatTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/applyPendingFormatTest.ts @@ -47,7 +47,9 @@ describe('applyPendingFormat', () => { spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( (_, apiName, callback) => { expect(apiName).toEqual('applyPendingFormat'); - callback(model); + callback(model, { + deletedEntities: [], + }); } ); spyOn(iterateSelections, 'iterateSelections').and.callFake((_, callback) => { @@ -116,7 +118,7 @@ describe('applyPendingFormat', () => { spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( (_, apiName, callback) => { expect(apiName).toEqual('applyPendingFormat'); - callback(model); + callback(model, { deletedEntities: [] }); } ); spyOn(iterateSelections, 'iterateSelections').and.callFake((_, callback) => { @@ -176,7 +178,7 @@ describe('applyPendingFormat', () => { spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( (_, apiName, callback) => { expect(apiName).toEqual('applyPendingFormat'); - callback(model); + callback(model, { deletedEntities: [] }); } ); spyOn(iterateSelections, 'iterateSelections').and.callFake((_, callback) => { @@ -234,7 +236,7 @@ describe('applyPendingFormat', () => { spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( (_, apiName, callback) => { expect(apiName).toEqual('applyPendingFormat'); - callback(model); + callback(model, { deletedEntities: [] }); } ); spyOn(iterateSelections, 'iterateSelections').and.callFake((_, callback) => { @@ -279,7 +281,9 @@ describe('applyPendingFormat', () => { spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( (_, apiName, callback) => { expect(apiName).toEqual('applyPendingFormat'); - callback(model); + callback(model, { + deletedEntities: [], + }); } ); spyOn(iterateSelections, 'iterateSelections').and.callFake((_, callback) => { diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/clearFormatTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/clearFormatTest.ts index f9f3f7ba53e..65a87687c0e 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/clearFormatTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/clearFormatTest.ts @@ -13,7 +13,7 @@ describe('clearFormat', () => { spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( (_, apiName, callback) => { expect(apiName).toEqual('clearFormat'); - callback(model); + callback(model, { deletedEntities: [] }); } ); spyOn(clearModelFormat, 'clearModelFormat'); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/setListStartNumberTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/setListStartNumberTest.ts index 006537762d4..b78705d7134 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/setListStartNumberTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/setListStartNumberTest.ts @@ -11,7 +11,7 @@ describe('setListStartNumber', () => { spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( (editor, apiName, callback) => { expect(apiName).toBe('setListStartNumber'); - const result = callback(input); + const result = callback(input, { deletedEntities: [] }); expect(result).toBe(expectedResult); } diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/setListStyleTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/setListStyleTest.ts index 74ec67b7252..2212976a091 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/setListStyleTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/setListStyleTest.ts @@ -12,7 +12,7 @@ describe('setListStyle', () => { spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( (editor, apiName, callback) => { expect(apiName).toBe('setListStyle'); - const result = callback(input); + const result = callback(input, { deletedEntities: [] }); expect(result).toBe(expectedResult); } diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatWithContentModelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatWithContentModelTest.ts index 3b940d92159..9a8833a0e75 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatWithContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatWithContentModelTest.ts @@ -1,5 +1,5 @@ import * as pendingFormat from '../../../lib/modelApi/format/pendingFormat'; -import { ChangeSource } from 'roosterjs-editor-types'; +import { ChangeSource, EntityOperation, PluginEventType } from 'roosterjs-editor-types'; import { ContentModelDocument } from 'roosterjs-content-model-types'; import { formatWithContentModel } from '../../../lib/publicApi/utils/formatWithContentModel'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; @@ -14,6 +14,7 @@ describe('formatWithContentModel', () => { let cacheContentModel: jasmine.Spy; let getFocusedPosition: jasmine.Spy; let triggerContentChangedEvent: jasmine.Spy; + let triggerPluginEvent: jasmine.Spy; const apiName = 'mockedApi'; const mockedPos = 'POS' as any; @@ -28,6 +29,7 @@ describe('formatWithContentModel', () => { cacheContentModel = jasmine.createSpy('cacheContentModel'); getFocusedPosition = jasmine.createSpy('getFocusedPosition').and.returnValue(mockedPos); triggerContentChangedEvent = jasmine.createSpy('triggerContentChangedEvent'); + triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); editor = ({ focus, @@ -37,6 +39,7 @@ describe('formatWithContentModel', () => { cacheContentModel, getFocusedPosition, triggerContentChangedEvent, + triggerPluginEvent, } as any) as IContentModelEditor; }); @@ -45,7 +48,10 @@ describe('formatWithContentModel', () => { formatWithContentModel(editor, apiName, callback); - expect(callback).toHaveBeenCalledWith(mockedModel); + expect(callback).toHaveBeenCalledWith(mockedModel, { + deletedEntities: [], + rawEvent: undefined, + }); expect(createContentModel).toHaveBeenCalledTimes(1); expect(addUndoSnapshot).not.toHaveBeenCalled(); expect(setContentModel).not.toHaveBeenCalled(); @@ -57,7 +63,10 @@ describe('formatWithContentModel', () => { formatWithContentModel(editor, apiName, callback); - expect(callback).toHaveBeenCalledWith(mockedModel); + expect(callback).toHaveBeenCalledWith(mockedModel, { + deletedEntities: [], + rawEvent: undefined, + }); expect(createContentModel).toHaveBeenCalledTimes(1); expect(addUndoSnapshot).toHaveBeenCalledTimes(1); expect(addUndoSnapshot.calls.argsFor(0)[1]).toBe(ChangeSource.Format); @@ -81,7 +90,10 @@ describe('formatWithContentModel', () => { preservePendingFormat: true, }); - expect(callback).toHaveBeenCalledWith(mockedModel); + expect(callback).toHaveBeenCalledWith(mockedModel, { + deletedEntities: [], + rawEvent: undefined, + }); expect(createContentModel).toHaveBeenCalledTimes(1); expect(addUndoSnapshot).toHaveBeenCalledTimes(1); expect(addUndoSnapshot.calls.argsFor(0)[1]).toBe(ChangeSource.Format); @@ -98,17 +110,22 @@ describe('formatWithContentModel', () => { }); it('Skip undo snapshot', () => { - const callback = jasmine.createSpy('callback').and.returnValue(true); + const callback = jasmine.createSpy('callback').and.callFake((model, context) => { + context.skipUndoSnapshot = true; + return true; + }); const mockedFormat = 'FORMAT' as any; spyOn(pendingFormat, 'getPendingFormat').and.returnValue(mockedFormat); spyOn(pendingFormat, 'setPendingFormat'); - formatWithContentModel(editor, apiName, callback, { + formatWithContentModel(editor, apiName, callback); + + expect(callback).toHaveBeenCalledWith(mockedModel, { + deletedEntities: [], + rawEvent: undefined, skipUndoSnapshot: true, }); - - expect(callback).toHaveBeenCalledWith(mockedModel); expect(createContentModel).toHaveBeenCalledTimes(1); expect(addUndoSnapshot).not.toHaveBeenCalled(); }); @@ -118,22 +135,30 @@ describe('formatWithContentModel', () => { formatWithContentModel(editor, apiName, callback, { changeSource: 'TEST' }); - expect(callback).toHaveBeenCalledWith(mockedModel); + expect(callback).toHaveBeenCalledWith(mockedModel, { + deletedEntities: [], + rawEvent: undefined, + }); expect(createContentModel).toHaveBeenCalledTimes(1); expect(addUndoSnapshot).toHaveBeenCalled(); expect(addUndoSnapshot.calls.argsFor(0)[1]).toBe('TEST'); }); it('Customize change source and skip undo snapshot', () => { - const callback = jasmine.createSpy('callback').and.returnValue(true); - + const callback = jasmine.createSpy('callback').and.callFake((model, context) => { + context.skipUndoSnapshot = true; + return true; + }); formatWithContentModel(editor, apiName, callback, { changeSource: 'TEST', - skipUndoSnapshot: true, getChangeData: () => 'DATA', }); - expect(callback).toHaveBeenCalledWith(mockedModel); + expect(callback).toHaveBeenCalledWith(mockedModel, { + deletedEntities: [], + rawEvent: undefined, + skipUndoSnapshot: true, + }); expect(createContentModel).toHaveBeenCalledTimes(1); expect(addUndoSnapshot).not.toHaveBeenCalled(); expect(triggerContentChangedEvent).toHaveBeenCalledWith('TEST', 'DATA'); @@ -145,7 +170,10 @@ describe('formatWithContentModel', () => { formatWithContentModel(editor, apiName, callback, { onNodeCreated: onNodeCreated }); - expect(callback).toHaveBeenCalledWith(mockedModel); + expect(callback).toHaveBeenCalledWith(mockedModel, { + deletedEntities: [], + rawEvent: undefined, + }); expect(createContentModel).toHaveBeenCalledTimes(1); expect(addUndoSnapshot).toHaveBeenCalled(); expect(setContentModel).toHaveBeenCalledWith(mockedModel, { onNodeCreated }); @@ -158,7 +186,10 @@ describe('formatWithContentModel', () => { formatWithContentModel(editor, apiName, callback, { getChangeData }); - expect(callback).toHaveBeenCalledWith(mockedModel); + expect(callback).toHaveBeenCalledWith(mockedModel, { + deletedEntities: [], + rawEvent: undefined, + }); expect(createContentModel).toHaveBeenCalledTimes(1); expect(setContentModel).toHaveBeenCalledWith(mockedModel, { onNodeCreated: undefined }); expect(addUndoSnapshot).toHaveBeenCalled(); @@ -169,4 +200,43 @@ describe('formatWithContentModel', () => { expect(getChangeData).toHaveBeenCalled(); expect(result).toBe(mockedData); }); + + it('Has entity got deleted', () => { + const entity1 = { id: 'E1', type: 'E', wrapper: {}, isReadonly: true } as any; + const entity2 = { id: 'E2', type: 'E', wrapper: {}, isReadonly: true } as any; + const rawEvent = 'RawEvent' as any; + + formatWithContentModel( + editor, + apiName, + (model, context) => { + context.deletedEntities.push( + { + entity: entity1, + operation: EntityOperation.RemoveFromStart, + }, + { + entity: entity2, + operation: EntityOperation.RemoveFromEnd, + } + ); + return true; + }, + { + rawEvent: rawEvent, + } + ); + + expect(triggerPluginEvent).toHaveBeenCalledTimes(2); + expect(triggerPluginEvent).toHaveBeenCalledWith(PluginEventType.EntityOperation, { + entity: entity1, + operation: EntityOperation.RemoveFromStart, + rawEvent: rawEvent, + }); + expect(triggerPluginEvent).toHaveBeenCalledWith(PluginEventType.EntityOperation, { + entity: entity2, + operation: EntityOperation.RemoveFromEnd, + rawEvent: rawEvent, + }); + }); }); From 0ea0095f88a69d53bc32445157b10ed580408513 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Wed, 9 Aug 2023 17:18:48 -0300 Subject: [PATCH 08/75] WIP --- .../lib/plugins/ImageEdit/ImageEdit.ts | 13 ++- .../plugins/ImageEdit/imageEditors/Rotator.ts | 33 +++++-- .../test/imageEdit/imageEditTest.ts | 16 ++-- .../test/imageEdit/rotatorTest.ts | 93 ++++++++++++++++--- 4 files changed, 122 insertions(+), 33 deletions(-) diff --git a/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/ImageEdit.ts b/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/ImageEdit.ts index 7714936ed52..991fc44cc46 100644 --- a/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/ImageEdit.ts +++ b/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/ImageEdit.ts @@ -270,7 +270,7 @@ export default class ImageEdit implements EditorPlugin { */ setEditingImage(image: null, selectImage?: boolean): void; - setEditingImage( + async setEditingImage( image: HTMLImageElement | null, operationOrSelect?: ImageEditOperation | CompatibleImageEditOperation | boolean ) { @@ -331,7 +331,7 @@ export default class ImageEdit implements EditorPlugin { this.editInfo = getEditInfoFromImage(image); //Check if the image is a gif and convert it to a png - this.pngSource = tryToConvertGifToPng(this.editInfo); + this.pngSource = await tryToConvertGifToPng(this.editInfo); //Check if the image was resized by the user this.wasResized = checkIfImageWasResized(this.image); @@ -596,7 +596,14 @@ export default class ImageEdit implements EditorPlugin { const viewport = this.editor?.getVisibleViewport(); const isSmall = isASmallImage(targetWidth, targetHeight); if (rotateHandle && rotateCenter && viewport) { - updateRotateHandleState(viewport, rotateCenter, rotateHandle, isSmall); + updateRotateHandleState( + viewport, + angleRad, + wrapper, + rotateCenter, + rotateHandle, + isSmall + ); } updateSideHandlesVisibility(resizeHandles, isSmall); diff --git a/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/imageEditors/Rotator.ts b/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/imageEditors/Rotator.ts index 9616c242bd1..ee3a3be0923 100644 --- a/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/imageEditors/Rotator.ts +++ b/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/imageEditors/Rotator.ts @@ -50,6 +50,8 @@ export const Rotator: DragAndDropHandler = { */ export function updateRotateHandleState( editorRect: Rect, + angleRad: number, + wrapper: HTMLElement, rotateCenter: HTMLElement, rotateHandle: HTMLElement, isSmallImage: boolean @@ -62,20 +64,33 @@ export function updateRotateHandleState( rotateCenter.style.display = ''; rotateHandle.style.display = ''; const rotateHandleRect = rotateHandle.getBoundingClientRect(); + const wrapperRect = wrapper.getBoundingClientRect(); - if (rotateHandleRect) { - const top = rotateHandleRect.top - editorRect.top; - const left = rotateHandleRect.left - editorRect.left; - const right = rotateHandleRect.right - editorRect.right; - const bottom = rotateHandleRect.bottom - editorRect.bottom; + if (rotateHandleRect && wrapperRect) { let adjustedDistance = Number.MAX_SAFE_INTEGER; - if (top <= 0) { + const angle = angleRad * DEG_PER_RAD; + if (angle < 45 && angle > -45 && wrapperRect.top - editorRect.top < ROTATE_GAP) { + const top = rotateHandleRect.top - editorRect.top; adjustedDistance = top; - } else if (left <= 0) { + } else if ( + angle <= -80 && + angle >= -100 && + wrapperRect.left - editorRect.left < ROTATE_GAP + ) { + const left = rotateHandleRect.left - editorRect.left; adjustedDistance = left; - } else if (right >= 0) { + } else if ( + angle >= 80 && + angle <= 100 && + editorRect.right - wrapperRect.right < ROTATE_GAP + ) { + const right = rotateHandleRect.right - editorRect.right; adjustedDistance = right; - } else if (bottom >= 0) { + } else if ( + (angle <= -160 || angle >= 160) && + editorRect.bottom - wrapperRect.bottom < ROTATE_GAP + ) { + const bottom = rotateHandleRect.bottom - editorRect.bottom; adjustedDistance = bottom; } diff --git a/packages/roosterjs-editor-plugins/test/imageEdit/imageEditTest.ts b/packages/roosterjs-editor-plugins/test/imageEdit/imageEditTest.ts index 04582424529..0fb62991af1 100644 --- a/packages/roosterjs-editor-plugins/test/imageEdit/imageEditTest.ts +++ b/packages/roosterjs-editor-plugins/test/imageEdit/imageEditTest.ts @@ -25,7 +25,7 @@ describe('ImageEdit | rotate and flip', () => { }); function runRotateTest(angle: number, editInfo?: ImageEditInfo) { - const IMG_ID = 'IMAGE_ID'; + const IMG_ID = 'IMAGE_ID_ROTATION'; const content = ``; editor.setContent(content); const image = document.getElementById(IMG_ID) as HTMLImageElement; @@ -46,7 +46,7 @@ describe('ImageEdit | rotate and flip', () => { flippedVertical?: boolean, editInfo?: ImageEditInfo ) { - const IMG_ID = 'IMAGE_ID'; + const IMG_ID = 'IMAGE_ID_FLIP'; const content = ``; editor.setContent(content); const image = document.getElementById(IMG_ID) as HTMLImageElement; @@ -212,7 +212,7 @@ describe('ImageEdit | rotate and flip', () => { }); it('start image editing', () => { - const IMG_ID = 'IMAGE_ID'; + const IMG_ID = 'IMAGE_ID_EDITING'; const content = ``; editor.setContent(content); const image = document.getElementById(IMG_ID) as HTMLImageElement; @@ -271,7 +271,7 @@ describe('ImageEdit | plugin events | quitting', () => { }; it('image selection quit editing', () => { - const IMG_ID = 'IMAGE_ID'; + const IMG_ID = 'IMAGE_ID_QUIT'; const SPAN_ID = 'SPAN_ID'; const content = ``; editor.setContent(content); @@ -286,7 +286,7 @@ describe('ImageEdit | plugin events | quitting', () => { }); it('mousedown quit editing', () => { - const IMG_ID = 'IMAGE_ID'; + const IMG_ID = 'IMAGE_ID_MOUSE'; const SPAN_ID = 'SPAN_ID'; const content = ``; editor.setContent(content); @@ -314,7 +314,7 @@ describe('ImageEdit | plugin events | quitting', () => { describe('ImageEdit | wrapper', () => { let editor: IEditor; - const TEST_ID = 'imageEditTest'; + const TEST_ID = 'imageEditTestWrapper'; let plugin: ImageEdit; beforeEach(() => { @@ -331,7 +331,7 @@ describe('ImageEdit | wrapper', () => { }); it('image selection, remove max-width', () => { - const IMG_ID = 'IMAGE_ID'; + const IMG_ID = 'IMAGE_ID_SELECTION'; const content = ``; editor.setContent(content); const image = document.getElementById(IMG_ID) as HTMLImageElement; @@ -345,7 +345,7 @@ describe('ImageEdit | wrapper', () => { }); it('image selection, cloned image should use style width/height attributes', () => { - const IMG_ID = 'IMAGE_ID'; + const IMG_ID = 'IMAGE_ID_SELECTION_2'; const content = ``; editor.setContent(content); const image = document.getElementById(IMG_ID) as HTMLImageElement; diff --git a/packages/roosterjs-editor-plugins/test/imageEdit/rotatorTest.ts b/packages/roosterjs-editor-plugins/test/imageEdit/rotatorTest.ts index 58f790c1004..5159be20093 100644 --- a/packages/roosterjs-editor-plugins/test/imageEdit/rotatorTest.ts +++ b/packages/roosterjs-editor-plugins/test/imageEdit/rotatorTest.ts @@ -92,7 +92,7 @@ describe('Rotate: rotate only', () => { describe('updateRotateHandlePosition', () => { let editor: IEditor; - const TEST_ID = 'imageEditTest'; + const TEST_ID = 'imageEditTest_rotateHandlePosition'; let plugin: ImageEdit; let editorGetVisibleViewport: any; beforeEach(() => { @@ -119,19 +119,25 @@ describe('updateRotateHandlePosition', () => { rotatePosition: DOMRect, rotateCenterTop: string, rotateCenterHeight: string, - rotateHandleTop: string + rotateHandleTop: string, + wrapperPosition: DOMRect, + angle: number ) { - const IMG_ID = 'IMAGE_ID'; - const content = ``; + const IMG_ID = 'IMAGE_ID_ROTATION'; + const WRAPPER_ID = 'WRAPPER_ID_ROTATION'; + const content = ``; editor.setContent(content); const image = document.getElementById(IMG_ID) as HTMLImageElement; plugin.setEditingImage(image, ImageEditOperation.Rotate); const rotate = getRotateHTML(options)[0]; const rotateHTML = createElement(rotate, document); - image.parentElement!.appendChild(rotateHTML!); + const imageParent = image.parentElement; + imageParent!.appendChild(rotateHTML!); + const wrapper = document.getElementById(WRAPPER_ID) as HTMLElement; const rotateCenter = document.getElementsByClassName('r_rotateC')[0] as HTMLElement; const rotateHandle = document.getElementsByClassName('r_rotateH')[0] as HTMLElement; spyOn(rotateHandle, 'getBoundingClientRect').and.returnValues(rotatePosition); + spyOn(wrapper, 'getBoundingClientRect').and.returnValues(wrapperPosition); const viewport: Rect = { top: 1, bottom: 200, @@ -139,8 +145,9 @@ describe('updateRotateHandlePosition', () => { right: 200, }; editorGetVisibleViewport.and.returnValue(viewport); + const angleRad = angle / DEG_PER_RAD; - updateRotateHandleState(viewport, rotateCenter, rotateHandle, false); + updateRotateHandleState(viewport, angleRad, wrapper, rotateCenter, rotateHandle, false); expect(rotateCenter.style.top).toBe(rotateCenterTop); expect(rotateCenter.style.height).toBe(rotateCenterHeight); @@ -162,7 +169,19 @@ describe('updateRotateHandlePosition', () => { }, '-6px', '0px', - '0px' + '0px', + { + top: 2, + bottom: 3, + left: 2, + right: 5, + height: 2, + width: 2, + x: 1, + y: 3, + toJSON: () => {}, + }, + 0 ); }); @@ -181,7 +200,19 @@ describe('updateRotateHandlePosition', () => { }, '-21px', '15px', - '-32px' + '-32px', + { + top: 0, + bottom: 20, + left: 3, + right: 5, + height: 2, + width: 2, + x: 1, + y: 3, + toJSON: () => {}, + }, + 50 ); }); @@ -190,7 +221,7 @@ describe('updateRotateHandlePosition', () => { { top: 2, bottom: 3, - left: 0, + left: 2, right: 5, height: 2, width: 2, @@ -198,9 +229,21 @@ describe('updateRotateHandlePosition', () => { y: 3, toJSON: () => {}, }, - '-6px', + '-7px', + '1px', '0px', - '0px' + { + top: 2, + bottom: 3, + left: 2, + right: 5, + height: 2, + width: 2, + x: 1, + y: 3, + toJSON: () => {}, + }, + -90 ); }); @@ -219,7 +262,19 @@ describe('updateRotateHandlePosition', () => { }, '-6px', '0px', - '0px' + '0px', + { + top: 0, + bottom: 190, + left: 3, + right: 190, + height: 2, + width: 2, + x: 1, + y: 3, + toJSON: () => {}, + }, + 180 ); }); @@ -238,7 +293,19 @@ describe('updateRotateHandlePosition', () => { }, '-6px', '0px', - '0px' + '0px', + { + top: 0, + bottom: 190, + left: 3, + right: 190, + height: 2, + width: 2, + x: 1, + y: 3, + toJSON: () => {}, + }, + 90 ); }); }); From be1ffcb93831e81ab3abc990952bcf03bcb89349 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Wed, 9 Aug 2023 17:20:36 -0300 Subject: [PATCH 09/75] fix handles --- .../lib/plugins/ImageEdit/ImageEdit.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/ImageEdit.ts b/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/ImageEdit.ts index 991fc44cc46..b9e8f516e2e 100644 --- a/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/ImageEdit.ts +++ b/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/ImageEdit.ts @@ -270,7 +270,7 @@ export default class ImageEdit implements EditorPlugin { */ setEditingImage(image: null, selectImage?: boolean): void; - async setEditingImage( + setEditingImage( image: HTMLImageElement | null, operationOrSelect?: ImageEditOperation | CompatibleImageEditOperation | boolean ) { @@ -331,7 +331,7 @@ export default class ImageEdit implements EditorPlugin { this.editInfo = getEditInfoFromImage(image); //Check if the image is a gif and convert it to a png - this.pngSource = await tryToConvertGifToPng(this.editInfo); + this.pngSource = tryToConvertGifToPng(this.editInfo); //Check if the image was resized by the user this.wasResized = checkIfImageWasResized(this.image); From 72f0c87a3c22d4d4c9b75676c73e3b70a5f97c2b Mon Sep 17 00:00:00 2001 From: Bryan Valverde U Date: Wed, 9 Aug 2023 15:16:07 -0600 Subject: [PATCH 10/75] MergeModel, do not inherit the styles of table when splitting the param (#2016) * init * init * address comment * update test names --- .../lib/modelApi/common/mergeModel.ts | 3 +- .../test/modelApi/common/mergeModelTest.ts | 390 ++++++++++++++++++ 2 files changed, 392 insertions(+), 1 deletion(-) diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/mergeModel.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/mergeModel.ts index f15cf469f52..0e28c4c3847 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/mergeModel.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/mergeModel.ts @@ -287,7 +287,8 @@ function splitParagraph(markerPosition: InsertPoint, newParaFormat: ContentModel function insertBlock(markerPosition: InsertPoint, block: ContentModelBlock) { const { path } = markerPosition; - const newPara = splitParagraph(markerPosition, block.format); + const newParaFormat = block.blockType !== 'Paragraph' ? {} : block.format; + const newPara = splitParagraph(markerPosition, newParaFormat); const blockIndex = path[0].blocks.indexOf(newPara); if (blockIndex >= 0) { diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/mergeModelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/mergeModelTest.ts index ded796939fc..3412059adae 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/mergeModelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/mergeModelTest.ts @@ -5,6 +5,7 @@ import { mergeModel } from '../../../lib/modelApi/common/mergeModel'; import { createContentModelDocument, createDivider, + createEntity, createListItem, createListLevel, createParagraph, @@ -2418,4 +2419,393 @@ describe('mergeModel', () => { ], }); }); + + it('Merge Table with styles into paragraph, paragraph after table should not inherit styles from table', () => { + const majorModel = createContentModelDocument(); + const para1 = createParagraph(false, undefined, { + fontFamily: 'Arial', + fontSize: '15px', + backgroundColor: 'red', + textColor: 'blue', + italic: false, + }); + const marker = createSelectionMarker(); + const text1 = createText('test1'); + const text2 = createText('test2'); + + para1.segments.push(text1, marker, text2); + majorModel.blocks.push(para1); + + const sourceModel: ContentModelDocument = createContentModelDocument(); + const newPara1 = createParagraph(); + const newText1 = createText('newText1'); + const newCell1 = createTableCell(false, false); + const newTable1 = createTable(1, { + textAlign: 'start', + whiteSpace: 'normal', + borderTop: '1px solid black', + borderRight: '1px solid black', + borderBottom: '1px solid black', + borderLeft: '1px solid black', + backgroundColor: 'rgb(255, 255, 255)', + useBorderBox: true, + borderCollapse: true, + }); + + newPara1.segments.push(newText1); + newCell1.blocks.push(newPara1); + newTable1.rows[0].cells.push(newCell1); + sourceModel.blocks.push(newTable1); + + mergeModel(majorModel, sourceModel); + + expect(majorModel).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [{ segmentType: 'Text', text: 'test1', format: {} }], + format: {}, + segmentFormat: { + fontFamily: 'Arial', + fontSize: '15px', + backgroundColor: 'red', + textColor: 'blue', + italic: false, + }, + }, + { + blockType: 'Table', + rows: [ + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'newText1', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: { + textAlign: 'start', + whiteSpace: 'normal', + borderTop: '1px solid black', + borderRight: '1px solid black', + borderBottom: '1px solid black', + borderLeft: '1px solid black', + backgroundColor: 'rgb(255, 255, 255)', + useBorderBox: true, + borderCollapse: true, + }, + widths: [], + dataset: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { segmentType: 'Text', text: 'test2', format: {} }, + ], + format: {}, + segmentFormat: { + fontFamily: 'Arial', + fontSize: '15px', + backgroundColor: 'red', + textColor: 'blue', + italic: false, + }, + }, + ], + }); + }); + + it('Merge Divider with styles into paragraph, paragraph after table should not inherit styles from Divider', () => { + const majorModel = createContentModelDocument(); + const para1 = createParagraph(false, undefined, { + fontFamily: 'Arial', + fontSize: '15px', + backgroundColor: 'red', + textColor: 'blue', + italic: false, + }); + const marker = createSelectionMarker(); + const text1 = createText('test1'); + const text2 = createText('test2'); + + para1.segments.push(text1, marker, text2); + majorModel.blocks.push(para1); + + const sourceModel: ContentModelDocument = createContentModelDocument(); + const newDiv = createDivider('div', { + textAlign: 'start', + whiteSpace: 'normal', + borderTop: '1px solid black', + borderRight: '1px solid black', + borderBottom: '1px solid black', + borderLeft: '1px solid black', + backgroundColor: 'rgb(255, 255, 255)', + }); + + sourceModel.blocks.push(newDiv); + mergeModel(majorModel, sourceModel); + + expect(majorModel).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [{ segmentType: 'Text', text: 'test1', format: {} }], + format: {}, + segmentFormat: { + fontFamily: 'Arial', + fontSize: '15px', + backgroundColor: 'red', + textColor: 'blue', + italic: false, + }, + }, + { + blockType: 'Divider', + tagName: 'div', + format: { + textAlign: 'start', + whiteSpace: 'normal', + borderTop: '1px solid black', + borderRight: '1px solid black', + borderBottom: '1px solid black', + borderLeft: '1px solid black', + backgroundColor: 'rgb(255, 255, 255)', + }, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { segmentType: 'Text', text: 'test2', format: {} }, + ], + format: {}, + segmentFormat: { + fontFamily: 'Arial', + fontSize: '15px', + backgroundColor: 'red', + textColor: 'blue', + italic: false, + }, + }, + ], + }); + }); + + it('Merge ListItem with styles into paragraph, paragraph after table should not inherit styles from ListItem', () => { + const majorModel = createContentModelDocument(); + const para1 = createParagraph(false, undefined, { + fontFamily: 'Arial', + fontSize: '15px', + backgroundColor: 'red', + textColor: 'blue', + italic: false, + }); + const marker = createSelectionMarker(); + const text1 = createText('test1'); + const text2 = createText('test2'); + + para1.segments.push(text1, marker, text2); + majorModel.blocks.push(para1); + + const sourceModel: ContentModelDocument = createContentModelDocument(); + const newList = createListItem([ + createListLevel('OL', { + marginBottom: '100px', + }), + ]); + const para2 = createParagraph(false, undefined, { + fontFamily: 'Arial', + fontSize: '15px', + backgroundColor: 'red', + textColor: 'blue', + italic: false, + }); + const text3 = createText('test1'); + newList.blocks.push(para2); + para2.segments.push(text3); + + sourceModel.blocks.push(newList); + mergeModel(majorModel, sourceModel); + + expect(majorModel).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [{ segmentType: 'Text', text: 'test1', format: {} }], + format: {}, + segmentFormat: { + fontFamily: 'Arial', + fontSize: '15px', + backgroundColor: 'red', + textColor: 'blue', + italic: false, + }, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [{ segmentType: 'Text', text: 'test1', format: {} }], + format: {}, + segmentFormat: { + fontFamily: 'Arial', + fontSize: '15px', + backgroundColor: 'red', + textColor: 'blue', + italic: false, + }, + }, + ], + levels: [ + { + listType: 'OL', + format: { marginBottom: '100px' }, + dataset: {}, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { segmentType: 'Text', text: 'test2', format: {} }, + ], + format: {}, + segmentFormat: { + fontFamily: 'Arial', + fontSize: '15px', + backgroundColor: 'red', + textColor: 'blue', + italic: false, + }, + }, + ], + }); + }); + + it('Merge Entity with styles into paragraph, paragraph after table should not inherit styles from Entity', () => { + const majorModel = createContentModelDocument(); + const para1 = createParagraph(false, undefined, { + fontFamily: 'Arial', + fontSize: '15px', + backgroundColor: 'red', + textColor: 'blue', + italic: false, + }); + const marker = createSelectionMarker(); + const text1 = createText('test1'); + const text2 = createText('test2'); + + para1.segments.push(text1, marker, text2); + majorModel.blocks.push(para1); + + const sourceModel: ContentModelDocument = createContentModelDocument(); + const newEntity = createEntity(document.createElement('div'), false, { + fontFamily: 'Corbel', + fontSize: '20px', + backgroundColor: 'blue', + textColor: 'aliceblue', + italic: true, + }); + + sourceModel.blocks.push(newEntity); + mergeModel(majorModel, sourceModel); + + expect(majorModel).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [{ segmentType: 'Text', text: 'test1', format: {} }], + format: {}, + segmentFormat: { + fontFamily: 'Arial', + fontSize: '15px', + backgroundColor: 'red', + textColor: 'blue', + italic: false, + }, + }, + { + segmentType: 'Entity', + blockType: 'Entity', + format: { + fontFamily: 'Corbel', + fontSize: '20px', + backgroundColor: 'blue', + textColor: 'aliceblue', + italic: true, + }, + id: undefined, + type: undefined, + isReadonly: false, + wrapper: newEntity.wrapper, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { segmentType: 'Text', text: 'test2', format: {} }, + ], + format: {}, + segmentFormat: { + fontFamily: 'Arial', + fontSize: '15px', + backgroundColor: 'red', + textColor: 'blue', + italic: false, + }, + }, + ], + }); + }); }); From ce9f802a122c4cce98645b567b3f3325ab9e9cc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Wed, 9 Aug 2023 18:27:35 -0300 Subject: [PATCH 11/75] fixes --- .../lib/plugins/ImageEdit/imageEditors/Rotator.ts | 5 +++-- .../roosterjs-editor-plugins/test/imageEdit/imageEditTest.ts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/imageEditors/Rotator.ts b/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/imageEditors/Rotator.ts index ee3a3be0923..3625d74afd8 100644 --- a/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/imageEditors/Rotator.ts +++ b/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/imageEditors/Rotator.ts @@ -69,6 +69,7 @@ export function updateRotateHandleState( if (rotateHandleRect && wrapperRect) { let adjustedDistance = Number.MAX_SAFE_INTEGER; const angle = angleRad * DEG_PER_RAD; + if (angle < 45 && angle > -45 && wrapperRect.top - editorRect.top < ROTATE_GAP) { const top = rotateHandleRect.top - editorRect.top; adjustedDistance = top; @@ -85,13 +86,13 @@ export function updateRotateHandleState( editorRect.right - wrapperRect.right < ROTATE_GAP ) { const right = rotateHandleRect.right - editorRect.right; - adjustedDistance = right; + adjustedDistance = Math.min(editorRect.right - wrapperRect.right, right); } else if ( (angle <= -160 || angle >= 160) && editorRect.bottom - wrapperRect.bottom < ROTATE_GAP ) { const bottom = rotateHandleRect.bottom - editorRect.bottom; - adjustedDistance = bottom; + adjustedDistance = Math.min(editorRect.bottom - wrapperRect.bottom, bottom); } const rotateGap = Math.max(Math.min(ROTATE_GAP, adjustedDistance), 0); diff --git a/packages/roosterjs-editor-plugins/test/imageEdit/imageEditTest.ts b/packages/roosterjs-editor-plugins/test/imageEdit/imageEditTest.ts index 0fb62991af1..72dd5b694c8 100644 --- a/packages/roosterjs-editor-plugins/test/imageEdit/imageEditTest.ts +++ b/packages/roosterjs-editor-plugins/test/imageEdit/imageEditTest.ts @@ -220,7 +220,7 @@ describe('ImageEdit | rotate and flip', () => { editor.select(image); plugin.setEditingImage(image, ImageEditOperation.Resize); expect(editor.getContent()).toBe( - '' + '' ); }); }); From a1a7b81be6a30dd9c050058e5c6afdc22f94c0c8 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Thu, 10 Aug 2023 11:29:36 -0700 Subject: [PATCH 12/75] Demo site: Fix insert link button in Content Model ribbon (#2018) --- .../contentModel/insertLinkButton.ts | 2 +- packages-ui/roosterjs-react/lib/index.ts | 1 + .../roosterjs-react/lib/inputDialog/index.ts | 2 ++ .../lib/inputDialog/type/DialogItem.ts | 17 ++++++++++++++++- .../lib/inputDialog/utils/showInputDialog.tsx | 8 +++++++- 5 files changed, 27 insertions(+), 3 deletions(-) create mode 100644 packages-ui/roosterjs-react/lib/inputDialog/index.ts diff --git a/demo/scripts/controls/ribbonButtons/contentModel/insertLinkButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/insertLinkButton.ts index 90f7ead23ed..e8f971511dc 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/insertLinkButton.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/insertLinkButton.ts @@ -1,5 +1,5 @@ -import showInputDialog from 'roosterjs-react/lib/inputDialog/utils/showInputDialog'; import { InsertLinkButtonStringKey, RibbonButton } from 'roosterjs-react'; +import { showInputDialog } from 'roosterjs-react/lib/inputDialog'; import { adjustLinkSelection, insertLink, diff --git a/packages-ui/roosterjs-react/lib/index.ts b/packages-ui/roosterjs-react/lib/index.ts index af9e5f7ddd9..57d3e4a8ce2 100644 --- a/packages-ui/roosterjs-react/lib/index.ts +++ b/packages-ui/roosterjs-react/lib/index.ts @@ -5,3 +5,4 @@ export * from './contextMenu/index'; export * from './pasteOptions/index'; export * from './colorPicker/index'; export * from './emoji/index'; +export * from './inputDialog/index'; diff --git a/packages-ui/roosterjs-react/lib/inputDialog/index.ts b/packages-ui/roosterjs-react/lib/inputDialog/index.ts new file mode 100644 index 00000000000..d64da44e8d3 --- /dev/null +++ b/packages-ui/roosterjs-react/lib/inputDialog/index.ts @@ -0,0 +1,2 @@ +export { default as showInputDialog } from './utils/showInputDialog'; +export { default as DialogItem } from './type/DialogItem'; diff --git a/packages-ui/roosterjs-react/lib/inputDialog/type/DialogItem.ts b/packages-ui/roosterjs-react/lib/inputDialog/type/DialogItem.ts index 0c93299ee47..b01318be33d 100644 --- a/packages-ui/roosterjs-react/lib/inputDialog/type/DialogItem.ts +++ b/packages-ui/roosterjs-react/lib/inputDialog/type/DialogItem.ts @@ -1,9 +1,24 @@ /** - * @internal + * Item of input dialog */ export default interface DialogItem { + /** + * Localized string key of the input item name + */ labelKey: Strings | null; + + /** + * Unlocalized string for the label text. This will be used when a valid localized string is not found using the given string key + */ unlocalizedLabel: string | null; + + /** + * Initial value of this item + */ initValue: string; + + /** + * Whether focus should be put into this item automatically + */ autoFocus?: boolean; } diff --git a/packages-ui/roosterjs-react/lib/inputDialog/utils/showInputDialog.tsx b/packages-ui/roosterjs-react/lib/inputDialog/utils/showInputDialog.tsx index 2b11eac8047..6a7ea3cee78 100644 --- a/packages-ui/roosterjs-react/lib/inputDialog/utils/showInputDialog.tsx +++ b/packages-ui/roosterjs-react/lib/inputDialog/utils/showInputDialog.tsx @@ -10,7 +10,13 @@ import { } from '../../common/index'; /** - * @internal + * Show a dialog with input items + * @param uiUtilities UI utilities to help render the dialog + * @param dialogTitleKey Localized string key for title of this dialog + * @param unlocalizedTitle Unlocalized title string of this dialog. It will be used if a valid localized string is not found using dialogTitleKey + * @param items Input items in this dialog + * @param strings Localized strings + * @param onChange An optional callback that will be invoked when input item value is changed */ export default function showInputDialog( uiUtilities: UIUtilities, From a8a9592d5fb64d84d2441b40ec70890cc33e7142 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Thu, 10 Aug 2023 23:01:03 -0700 Subject: [PATCH 13/75] Content Model: insertEntity API (#1800) * Content Model insertEntity * improve * improve * Content Model: Improve cache behavior * fix build * Content Model: improve formatWithContentModel * Content Model: improve formatWithContentModel 2 * Improve * fix build * fix build * improve * add test * add test * add test * add test * fix dark color * fix test * fix build and test --- .../controls/ContentModelEditorMainPane.tsx | 2 +- .../contentModelApiPlayground/ApiPaneProps.ts | 10 + .../ApiPlaygroundPane.scss | 4 + .../ApiPlaygroundPane.tsx | 69 + .../ApiPlaygroundPlugin.ts | 27 + .../contentModelApiPlayground/apiEntries.ts | 27 + .../insertEntity/InsertEntityPane.scss | 6 + .../insertEntity/InsertEntityPane.tsx | 177 ++ .../domToModel/processors/entityProcessor.ts | 2 +- .../lib/modelApi/creators/createEntity.ts | 12 +- .../test/modelApi/creators/creatorsTest.ts | 6 +- .../lib/editor/ContentModelEditor.ts | 8 +- .../lib/editor/coreApi/createContentModel.ts | 12 +- .../lib/index.ts | 5 + .../lib/modelApi/entity/insertEntityModel.ts | 98 + .../lib/publicApi/entity/insertEntity.ts | 105 + .../publicApi/utils/formatWithContentModel.ts | 12 +- .../lib/publicTypes/ContentModelEditorCore.ts | 3 +- .../lib/publicTypes/IContentModelEditor.ts | 8 +- .../FormatWithContentModelContext.ts | 7 +- .../parameter/InsertEntityOptions.ts | 33 + .../test/modelApi/common/mergeModelTest.ts | 2 +- .../modelApi/entity/insertEntityModelTest.ts | 2181 +++++++++++++++++ .../test/publicApi/entity/insertEntityTest.ts | 232 ++ .../utils/formatWithContentModelTest.ts | 10 + 25 files changed, 3031 insertions(+), 27 deletions(-) create mode 100644 demo/scripts/controls/sidePane/contentModelApiPlayground/ApiPaneProps.ts create mode 100644 demo/scripts/controls/sidePane/contentModelApiPlayground/ApiPlaygroundPane.scss create mode 100644 demo/scripts/controls/sidePane/contentModelApiPlayground/ApiPlaygroundPane.tsx create mode 100644 demo/scripts/controls/sidePane/contentModelApiPlayground/ApiPlaygroundPlugin.ts create mode 100644 demo/scripts/controls/sidePane/contentModelApiPlayground/apiEntries.ts create mode 100644 demo/scripts/controls/sidePane/contentModelApiPlayground/insertEntity/InsertEntityPane.scss create mode 100644 demo/scripts/controls/sidePane/contentModelApiPlayground/insertEntity/InsertEntityPane.tsx create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/modelApi/entity/insertEntityModel.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/publicApi/entity/insertEntity.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/publicTypes/parameter/InsertEntityOptions.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/test/modelApi/entity/insertEntityModelTest.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/test/publicApi/entity/insertEntityTest.ts diff --git a/demo/scripts/controls/ContentModelEditorMainPane.tsx b/demo/scripts/controls/ContentModelEditorMainPane.tsx index 9bbc2a0b77d..2117597746d 100644 --- a/demo/scripts/controls/ContentModelEditorMainPane.tsx +++ b/demo/scripts/controls/ContentModelEditorMainPane.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import * as ReactDOM from 'react-dom'; -import ApiPlaygroundPlugin from './sidePane/apiPlayground/ApiPlaygroundPlugin'; +import ApiPlaygroundPlugin from './sidePane/contentModelApiPlayground/ApiPlaygroundPlugin'; import ContentModelEditorOptionsPlugin from './sidePane/editorOptions/ContentModelEditorOptionsPlugin'; import ContentModelFormatPainterPlugin from './contentModel/plugins/ContentModelFormatPainterPlugin'; import ContentModelFormatStatePlugin from './sidePane/formatState/ContentModelFormatStatePlugin'; diff --git a/demo/scripts/controls/sidePane/contentModelApiPlayground/ApiPaneProps.ts b/demo/scripts/controls/sidePane/contentModelApiPlayground/ApiPaneProps.ts new file mode 100644 index 00000000000..434149b960c --- /dev/null +++ b/demo/scripts/controls/sidePane/contentModelApiPlayground/ApiPaneProps.ts @@ -0,0 +1,10 @@ +import { IEditor, PluginEvent } from 'roosterjs-editor-types'; +import { SidePaneElementProps } from '../SidePaneElement'; + +export default interface ApiPaneProps extends SidePaneElementProps { + getEditor: () => IEditor; +} + +export interface ApiPlaygroundComponent { + onPluginEvent?: (e: PluginEvent) => void; +} diff --git a/demo/scripts/controls/sidePane/contentModelApiPlayground/ApiPlaygroundPane.scss b/demo/scripts/controls/sidePane/contentModelApiPlayground/ApiPlaygroundPane.scss new file mode 100644 index 00000000000..f9cce7e9015 --- /dev/null +++ b/demo/scripts/controls/sidePane/contentModelApiPlayground/ApiPlaygroundPane.scss @@ -0,0 +1,4 @@ +.header { + flex: 0 0 auto; + padding-bottom: 5px; +} diff --git a/demo/scripts/controls/sidePane/contentModelApiPlayground/ApiPlaygroundPane.tsx b/demo/scripts/controls/sidePane/contentModelApiPlayground/ApiPlaygroundPane.tsx new file mode 100644 index 00000000000..52c3bd3ee26 --- /dev/null +++ b/demo/scripts/controls/sidePane/contentModelApiPlayground/ApiPlaygroundPane.tsx @@ -0,0 +1,69 @@ +import * as React from 'react'; +import apiEntries, { ApiPlaygroundReactComponent } from './apiEntries'; +import ApiPaneProps from './ApiPaneProps'; +import { getObjectKeys } from 'roosterjs-editor-dom'; +import { PluginEvent } from 'roosterjs-editor-types'; +import { SidePaneElement } from '../SidePaneElement'; + +const styles = require('./ApiPlaygroundPane.scss'); + +export interface ApiPlaygroundPaneState { + current: string; +} + +export default class ApiPlaygroundPane extends React.Component + implements SidePaneElement { + private select = React.createRef(); + private pane = React.createRef(); + constructor(props: ApiPaneProps) { + super(props); + this.state = { current: 'empty' }; + } + + render() { + let componentClass = apiEntries[this.state.current].component; + let pane: JSX.Element = null; + if (componentClass) { + pane = React.createElement(componentClass, { ...this.props, ref: this.pane }); + } + + return ( + <> +
+

Select an API to try

+ + +
+ {pane} + + ); + } + + onPluginEvent(e: PluginEvent) { + if (this.pane.current && this.pane.current.onPluginEvent) { + this.pane.current.onPluginEvent(e); + } + } + + setHashPath(path: string[]) { + let paneName = path && getObjectKeys(apiEntries).indexOf(path[0]) >= 0 ? path[0] : null; + + if (paneName && paneName != this.state.current) { + this.setState({ + current: paneName, + }); + } else { + this.props.updateHash(null, [this.state.current]); + } + } + + private onChange = () => { + this.props.updateHash(null, [this.select.current.value]); + }; +} diff --git a/demo/scripts/controls/sidePane/contentModelApiPlayground/ApiPlaygroundPlugin.ts b/demo/scripts/controls/sidePane/contentModelApiPlayground/ApiPlaygroundPlugin.ts new file mode 100644 index 00000000000..2d503575e6f --- /dev/null +++ b/demo/scripts/controls/sidePane/contentModelApiPlayground/ApiPlaygroundPlugin.ts @@ -0,0 +1,27 @@ +import ApiPaneProps from './ApiPaneProps'; +import ApiPlaygroundPane from './ApiPlaygroundPane'; +import SidePanePluginImpl from '../SidePanePluginImpl'; +import { PluginEvent } from 'roosterjs-editor-types'; +import { SidePaneElementProps } from '../SidePaneElement'; + +export default class ApiPlaygroundPlugin extends SidePanePluginImpl< + ApiPlaygroundPane, + ApiPaneProps +> { + constructor() { + super(ApiPlaygroundPane, 'api', 'API Playground'); + } + + getComponentProps(base: SidePaneElementProps) { + return { + ...base, + getEditor: () => { + return this.editor; + }, + }; + } + + onPluginEvent(e: PluginEvent) { + this.getComponent(component => component.onPluginEvent(e)); + } +} diff --git a/demo/scripts/controls/sidePane/contentModelApiPlayground/apiEntries.ts b/demo/scripts/controls/sidePane/contentModelApiPlayground/apiEntries.ts new file mode 100644 index 00000000000..4bd35162a73 --- /dev/null +++ b/demo/scripts/controls/sidePane/contentModelApiPlayground/apiEntries.ts @@ -0,0 +1,27 @@ +import * as React from 'react'; +import ApiPaneProps, { ApiPlaygroundComponent } from './ApiPaneProps'; +import InsertEntityPane from './insertEntity/InsertEntityPane'; + +export interface ApiPlaygroundReactComponent + extends React.Component, + ApiPlaygroundComponent {} + +interface ApiEntry { + name: string; + component?: { new (prpos: ApiPaneProps): ApiPlaygroundReactComponent }; +} + +const apiEntries: { [key: string]: ApiEntry } = { + empty: { + name: 'Please select', + }, + entity: { + name: 'Insert Entity', + component: InsertEntityPane, + }, + more: { + name: 'Coming soon...', + }, +}; + +export default apiEntries; diff --git a/demo/scripts/controls/sidePane/contentModelApiPlayground/insertEntity/InsertEntityPane.scss b/demo/scripts/controls/sidePane/contentModelApiPlayground/insertEntity/InsertEntityPane.scss new file mode 100644 index 00000000000..7ba4da96ccd --- /dev/null +++ b/demo/scripts/controls/sidePane/contentModelApiPlayground/insertEntity/InsertEntityPane.scss @@ -0,0 +1,6 @@ +.textarea { + outline: none; + resize: none; + min-height: 40px; + width: 90%; +} diff --git a/demo/scripts/controls/sidePane/contentModelApiPlayground/insertEntity/InsertEntityPane.tsx b/demo/scripts/controls/sidePane/contentModelApiPlayground/insertEntity/InsertEntityPane.tsx new file mode 100644 index 00000000000..5fbebf4588d --- /dev/null +++ b/demo/scripts/controls/sidePane/contentModelApiPlayground/insertEntity/InsertEntityPane.tsx @@ -0,0 +1,177 @@ +import * as React from 'react'; +import ApiPaneProps from '../ApiPaneProps'; +import { Entity } from 'roosterjs-editor-types'; +import { getEntityFromElement, getEntitySelector } from 'roosterjs-editor-dom'; +import { trustedHTMLHandler } from '../../../../utils/trustedHTMLHandler'; +import { + IContentModelEditor, + insertEntity, + InsertEntityOptions, +} from 'roosterjs-content-model-editor'; + +const styles = require('./InsertEntityPane.scss'); + +interface InsertEntityPaneState { + entities: Entity[]; +} + +export default class InsertEntityPane extends React.Component { + private entityType = React.createRef(); + private html = React.createRef(); + private styleInline = React.createRef(); + private styleBlock = React.createRef(); + private focusAfterEntity = React.createRef(); + + private posFocus = React.createRef(); + private posTop = React.createRef(); + private posBottom = React.createRef(); + private posRegionRoot = React.createRef(); + + constructor(props: ApiPaneProps) { + super(props); + this.state = { + entities: [], + }; + } + + render() { + return ( + <> +
+ Type: +
+
+ HTML: +
+
+ Style: + + + + +
+
+ Position: +
+ + +
+ + +
+ + +
+ + +
+
+
+ + +
+
+ +
+
+
+ +
+
+ {this.state.entities.map(entity => ( + + ))} +
+ + ); + } + + private insertEntity = () => { + const entityType = this.entityType.current.value; + const node = document.createElement('span'); + node.innerHTML = trustedHTMLHandler(this.html.current.value); + const isBlock = this.styleBlock.current.checked; + const focusAfterEntity = this.focusAfterEntity.current.checked; + const insertAtTop = this.posTop.current.checked; + const insertAtBottom = this.posBottom.current.checked; + const insertAtRoot = this.posRegionRoot.current.checked; + + if (node) { + const editor = this.props.getEditor(); + + editor.addUndoSnapshot(() => { + const options: InsertEntityOptions = { + contentNode: node, + focusAfterEntity: focusAfterEntity, + }; + + if (isBlock) { + insertEntity( + editor as IContentModelEditor, + entityType, + true, + insertAtRoot + ? 'root' + : insertAtTop + ? 'begin' + : insertAtBottom + ? 'end' + : 'focus', + options + ); + } else { + insertEntity( + editor as IContentModelEditor, + entityType, + isBlock, + insertAtTop ? 'begin' : insertAtBottom ? 'end' : 'focus', + options + ); + } + }); + } + }; + + private onGetEntities = () => { + const selector = getEntitySelector(); + const nodes = this.props.getEditor().queryElements(selector); + const allEntities = nodes.map(node => getEntityFromElement(node)); + + this.setState({ + entities: allEntities.filter(e => !!e), + }); + }; +} + +function EntityButton({ entity }: { entity: Entity }) { + let background = ''; + const onMouseOver = React.useCallback(() => { + background = entity.wrapper.style.backgroundColor; + entity.wrapper.style.backgroundColor = 'blue'; + }, [entity]); + + const onMouseOut = React.useCallback(() => { + entity.wrapper.style.backgroundColor = background; + }, [entity]); + + return ( +
+ Type: {entity.type} +
+ Id: {entity.id} +
+ Readonly: {entity.isReadonly ? 'True' : 'False'} +
+
+ ); +} diff --git a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/entityProcessor.ts b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/entityProcessor.ts index c09a27845f6..f3b8a842568 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/entityProcessor.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/entityProcessor.ts @@ -23,7 +23,7 @@ export const entityProcessor: ElementProcessor = (group, element, c context, { segment: isBlockEntity ? 'empty' : undefined, paragraph: 'empty' }, () => { - const entityModel = createEntity(element, isReadonly, context.segmentFormat, id, type); + const entityModel = createEntity(element, isReadonly, type, context.segmentFormat, id); // TODO: Need to handle selection for editable entity if (context.isInSelection) { diff --git a/packages-content-model/roosterjs-content-model-dom/lib/modelApi/creators/createEntity.ts b/packages-content-model/roosterjs-content-model-dom/lib/modelApi/creators/createEntity.ts index 45b9703b9c1..a02dbed9899 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/modelApi/creators/createEntity.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/modelApi/creators/createEntity.ts @@ -4,23 +4,21 @@ import { ContentModelEntity, ContentModelSegmentFormat } from 'roosterjs-content * Create a ContentModelEntity model * @param wrapper Wrapper element of this entity * @param isReadonly Whether this is a readonly entity - * @param segmentFormat Segment format of this entity - * @param id @optional Id of this entity * @param type @optional Type of this entity + * @param segmentFormat @optional Segment format of this entity + * @param id @optional Id of this entity */ export function createEntity( wrapper: HTMLElement, isReadonly: boolean, + type?: string, segmentFormat?: ContentModelSegmentFormat, - id?: string, - type?: string + id?: string ): ContentModelEntity { return { segmentType: 'Entity', blockType: 'Entity', - format: { - ...(segmentFormat || {}), - }, + format: { ...segmentFormat }, id, type, isReadonly, diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelApi/creators/creatorsTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelApi/creators/creatorsTest.ts index 261290abbed..b1d91fdcb3b 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelApi/creators/creatorsTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelApi/creators/creatorsTest.ts @@ -432,7 +432,7 @@ describe('Creators', () => { const type = 'entity'; const isReadonly = true; const wrapper = document.createElement('div'); - const entityModel = createEntity(wrapper, isReadonly, undefined, id, type); + const entityModel = createEntity(wrapper, isReadonly, type, undefined, id); expect(entityModel).toEqual({ blockType: 'Entity', @@ -453,11 +453,11 @@ describe('Creators', () => { const entityModel = createEntity( wrapper, isReadonly, + type, { fontSize: '10pt', }, - id, - type + id ); expect(entityModel).toEqual({ diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts index 630a3a7cf4e..7384aaf62ba 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts @@ -2,6 +2,7 @@ import { ContentModelEditorCore } from '../publicTypes/ContentModelEditorCore'; import { ContentModelEditorOptions, IContentModelEditor } from '../publicTypes/IContentModelEditor'; import { createContentModelEditorCore } from './createContentModelEditorCore'; import { EditorBase } from 'roosterjs-editor-core'; +import { SelectionRangeEx } from 'roosterjs-editor-types'; import { ContentModelDocument, ContentModelSegmentFormat, @@ -29,10 +30,13 @@ export default class ContentModelEditor * Create Content Model from DOM tree in this editor * @param option The option to customize the behavior of DOM to Content Model conversion */ - createContentModel(option?: DomToModelOption): ContentModelDocument { + createContentModel( + option?: DomToModelOption, + selectionOverride?: SelectionRangeEx + ): ContentModelDocument { const core = this.getCore(); - return core.api.createContentModel(core, option); + return core.api.createContentModel(core, option, selectionOverride); } /** diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/createContentModel.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/createContentModel.ts index fb84d0350f9..b95b71a5288 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/createContentModel.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/createContentModel.ts @@ -1,6 +1,7 @@ import { cloneModel } from '../../modelApi/common/cloneModel'; import { domToContentModel } from 'roosterjs-content-model-dom'; import { DomToModelOption } from 'roosterjs-content-model-types'; +import { SelectionRangeEx } from 'roosterjs-editor-types'; import { tablePreProcessor } from '../../domToModel/processors/tablePreProcessor'; import { ContentModelEditorCore, @@ -12,20 +13,21 @@ import { * Create Content Model from DOM tree in this editor * @param option The option to customize the behavior of DOM to Content Model conversion */ -export const createContentModel: CreateContentModel = (core, option) => { - let cachedModel = core.cachedModel; +export const createContentModel: CreateContentModel = (core, option, selectionOverride) => { + let cachedModel = selectionOverride ? null : core.cachedModel; if (cachedModel && core.lifecycle.shadowEditFragment) { // When in shadow edit, use a cloned model so we won't pollute the cached one cachedModel = cloneModel(cachedModel, { includeCachedElement: true }); } - return cachedModel || internalCreateContentModel(core, option); + return cachedModel || internalCreateContentModel(core, option, selectionOverride); }; function internalCreateContentModel( core: ContentModelEditorCore, - option: DomToModelOption | undefined + option: DomToModelOption | undefined, + selectionOverride?: SelectionRangeEx ) { const context: DomToModelOption = { ...core.defaultDomToModelOptions, @@ -42,6 +44,6 @@ function internalCreateContentModel( core.contentDiv, context, core.api.createEditorContext(core), - core.api.getSelectionRangeEx(core) + selectionOverride || core.api.getSelectionRangeEx(core) ); } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/index.ts b/packages-content-model/roosterjs-content-model-editor/lib/index.ts index 4312d9c548e..888aed6741e 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/index.ts @@ -22,6 +22,10 @@ export { FormatWithContentModelOptions, ContentModelFormatter, } from './publicTypes/parameter/FormatWithContentModelContext'; +export { + InsertEntityOptions, + InsertEntityPosition, +} from './publicTypes/parameter/InsertEntityOptions'; export { default as insertTable } from './publicApi/table/insertTable'; export { default as formatTable } from './publicApi/table/formatTable'; @@ -69,6 +73,7 @@ export { default as adjustImageSelection } from './publicApi/image/adjustImageSe export { default as setParagraphMargin } from './publicApi/block/setParagraphMargin'; export { default as toggleCode } from './publicApi/segment/toggleCode'; export { default as paste } from './publicApi/utils/paste'; +export { default as insertEntity } from './publicApi/entity/insertEntity'; export { formatWithContentModel } from './publicApi/utils/formatWithContentModel'; export { default as ContentModelEditor } from './editor/ContentModelEditor'; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/entity/insertEntityModel.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/entity/insertEntityModel.ts new file mode 100644 index 00000000000..82f7f292066 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/entity/insertEntityModel.ts @@ -0,0 +1,98 @@ +import { createBr, createParagraph, createSelectionMarker } from 'roosterjs-content-model-dom'; +import { deleteSelection } from '../edit/deleteSelection'; +import { FormatWithContentModelContext } from '../../publicTypes/parameter/FormatWithContentModelContext'; +import { getClosestAncestorBlockGroupIndex } from '../common/getClosestAncestorBlockGroupIndex'; +import { InsertEntityPosition } from '../../publicTypes/parameter/InsertEntityOptions'; +import { InsertPoint } from '../../publicTypes/selection/InsertPoint'; +import { setSelection } from '../selection/setSelection'; +import { + ContentModelBlock, + ContentModelBlockGroup, + ContentModelDocument, + ContentModelEntity, + ContentModelParagraph, +} from 'roosterjs-content-model-types'; + +/** + * @internal + */ +export function insertEntityModel( + model: ContentModelDocument, + entityModel: ContentModelEntity, + position: InsertEntityPosition, + isBlock: boolean, + focusAfterEntity?: boolean, + context?: FormatWithContentModelContext +) { + let blockParent: ContentModelBlockGroup | undefined; + let blockIndex = -1; + let insertPoint: InsertPoint | null; + + if (position == 'begin' || position == 'end') { + blockParent = model; + blockIndex = position == 'begin' ? 0 : model.blocks.length; + } else if ((insertPoint = deleteSelection(model, [], context).insertPoint)) { + const { marker, paragraph, path } = insertPoint; + + if (!isBlock) { + const index = paragraph.segments.indexOf(marker); + + if (index >= 0) { + paragraph.segments.splice(focusAfterEntity ? index : index + 1, 0, entityModel); + } + } else { + const pathIndex = + position == 'root' + ? getClosestAncestorBlockGroupIndex(path, ['TableCell', 'Document']) + : 0; + blockParent = path[pathIndex]; + const child = path[pathIndex - 1]; + const directChild: ContentModelBlock = + child?.blockGroupType == 'FormatContainer' || + child?.blockGroupType == 'General' || + child?.blockGroupType == 'ListItem' + ? child + : paragraph; + const childIndex = blockParent.blocks.indexOf(directChild); + blockIndex = childIndex >= 0 ? childIndex + 1 : -1; + } + } + + if (blockIndex >= 0 && blockParent) { + const blocksToInsert: ContentModelBlock[] = []; + let nextParagraph: ContentModelParagraph | undefined; + + if (isBlock) { + const nextBlock = blockParent.blocks[blockIndex]; + + blocksToInsert.push(entityModel); + + if (nextBlock?.blockType == 'Paragraph') { + nextParagraph = nextBlock; + } else if (!nextBlock || nextBlock.blockType == 'Entity' || focusAfterEntity) { + nextParagraph = createParagraph(false /*isImplicit*/, {}, model.format); + nextParagraph.segments.push(createBr(model.format)); + blocksToInsert.push(nextParagraph); + } + } else { + nextParagraph = createParagraph( + false /*isImplicit*/, + undefined /*format*/, + model.format + ); + + nextParagraph.segments.push(entityModel); + blocksToInsert.push(nextParagraph); + } + + blockParent.blocks.splice(blockIndex, 0, ...blocksToInsert); + + if (focusAfterEntity && nextParagraph) { + const marker = createSelectionMarker(nextParagraph.segments[0]?.format || model.format); + const segments = nextParagraph.segments; + + isBlock ? segments.unshift(marker) : segments.push(marker); + setSelection(model, marker, marker); + } + } +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/entity/insertEntity.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/entity/insertEntity.ts new file mode 100644 index 00000000000..7fafcddff5e --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/entity/insertEntity.ts @@ -0,0 +1,105 @@ +import { ChangeSource, Entity, SelectionRangeEx } from 'roosterjs-editor-types'; +import { commitEntity, getEntityFromElement } from 'roosterjs-editor-dom'; +import { createEntity } from 'roosterjs-content-model-dom'; +import { formatWithContentModel } from '../utils/formatWithContentModel'; +import { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; +import { insertEntityModel } from '../../modelApi/entity/insertEntityModel'; +import { + InsertEntityOptions, + InsertEntityPosition, +} from '../../publicTypes/parameter/InsertEntityOptions'; + +const BlockEntityTag = 'div'; +const InlineEntityTag = 'span'; + +/** + * Insert an entity into editor + * @param editor The Content Model editor + * @param type Type of entity + * @param isBlock True to insert a block entity, false to insert an inline entity + * @param position Position of the entity to insert. It can be + * Value of InsertEntityPosition: see InsertEntityPosition + * selectionRangeEx: Use this range instead of current focus position to insert. After insert, focus will be moved to + * the beginning of this range (when focusAfterEntity is not set to true) or after the new entity (when focusAfterEntity is set to true) + * @param options Move options to insert. See InsertEntityOptions + */ +export default function insertEntity( + editor: IContentModelEditor, + type: string, + isBlock: boolean, + position: 'focus' | 'begin' | 'end' | SelectionRangeEx, + options?: InsertEntityOptions +): Entity | null; + +/** + * Insert a block entity into editor + * @param editor The Content Model editor + * @param type Type of entity + * @param isBlock Must be true for a block entity + * @param position Position of the entity to insert. It can be + * Value of InsertEntityPosition: see InsertEntityPosition + * selectionRangeEx: Use this range instead of current focus position to insert. After insert, focus will be moved to + * the beginning of this range (when focusAfterEntity is not set to true) or after the new entity (when focusAfterEntity is set to true) + * @param options Move options to insert. See InsertEntityOptions + */ +export default function insertEntity( + editor: IContentModelEditor, + type: string, + isBlock: true, + position: InsertEntityPosition | SelectionRangeEx, + options?: InsertEntityOptions +): Entity | null; + +export default function insertEntity( + editor: IContentModelEditor, + type: string, + isBlock: boolean, + position?: InsertEntityPosition | SelectionRangeEx, + options?: InsertEntityOptions +): Entity | null { + const { contentNode, focusAfterEntity, wrapperDisplay, skipUndoSnapshot } = options || {}; + const wrapper = editor.getDocument().createElement(isBlock ? BlockEntityTag : InlineEntityTag); + const display = wrapperDisplay ?? (isBlock ? undefined : 'inline-block'); + + wrapper.style.setProperty('display', display || null); + + if (contentNode) { + wrapper.appendChild(contentNode); + } + + commitEntity(wrapper, type, true /*isReadonly*/); + + const entityModel = createEntity(wrapper, true /*isReadonly*/, type); + + formatWithContentModel( + editor, + 'insertEntity', + (model, context) => { + insertEntityModel( + model, + entityModel, + typeof position == 'string' ? position : 'focus', + isBlock, + isBlock ? focusAfterEntity : true, + context + ); + + context.skipUndoSnapshot = skipUndoSnapshot; + + return true; + }, + { + selectionOverride: typeof position === 'object' ? position : undefined, + } + ); + + if (editor.isDarkMode()) { + editor.transformToDarkColor(wrapper); + } + + const newEntity = getEntityFromElement(wrapper); + + editor.triggerContentChangedEvent(ChangeSource.InsertEntity, newEntity); + + return newEntity; +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatWithContentModel.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatWithContentModel.ts index 208c277492a..1d3d9ec6a4d 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatWithContentModel.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatWithContentModel.ts @@ -23,12 +23,18 @@ export function formatWithContentModel( formatter: ContentModelFormatter, options?: FormatWithContentModelOptions ) { - const { onNodeCreated, preservePendingFormat, getChangeData, changeSource, rawEvent } = - options || {}; + const { + onNodeCreated, + preservePendingFormat, + getChangeData, + changeSource, + rawEvent, + selectionOverride, + } = options || {}; editor.focus(); - const model = editor.createContentModel(); + const model = editor.createContentModel(undefined /*option*/, selectionOverride); const context: FormatWithContentModelContext = { deletedEntities: [], rawEvent, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelEditorCore.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelEditorCore.ts index a0e48c67474..01a8bedd271 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelEditorCore.ts @@ -20,7 +20,8 @@ export type CreateEditorContext = (core: ContentModelEditorCore) => EditorContex */ export type CreateContentModel = ( core: ContentModelEditorCore, - option?: DomToModelOption + option?: DomToModelOption, + selectionOverride?: SelectionRangeEx ) => ContentModelDocument; /** diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts index e1e5968ce59..e62c05de571 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts @@ -1,10 +1,10 @@ +import { EditorOptions, IEditor, SelectionRangeEx } from 'roosterjs-editor-types'; import { ContentModelDocument, ContentModelSegmentFormat, DomToModelOption, ModelToDomOption, } from 'roosterjs-content-model-types'; -import { EditorOptions, IEditor } from 'roosterjs-editor-types'; /** * An interface of editor with Content Model support. @@ -16,8 +16,12 @@ export interface IContentModelEditor extends IEditor { * @param rootNode Optional start node. If provided, Content Model will be created from this node (including itself), * otherwise it will create Content Model for the whole content in editor. * @param option The options to customize the behavior of DOM to Content Model conversion + * @param selectionOverride When specified, use this selection to override existing selection inside editor */ - createContentModel(option?: DomToModelOption): ContentModelDocument; + createContentModel( + option?: DomToModelOption, + selectionOverride?: SelectionRangeEx + ): ContentModelDocument; /** * Set content with content model diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/parameter/FormatWithContentModelContext.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/parameter/FormatWithContentModelContext.ts index 8d98e8d3d38..da2f5627b76 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/parameter/FormatWithContentModelContext.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/parameter/FormatWithContentModelContext.ts @@ -1,9 +1,9 @@ +import { EntityOperation, SelectionRangeEx } from 'roosterjs-editor-types'; import { ContentModelDocument, ContentModelEntity, OnNodeCreated, } from 'roosterjs-content-model-types'; -import { EntityOperation } from 'roosterjs-editor-types'; import type { CompatibleEntityOperation } from 'roosterjs-editor-types/lib/compatibleTypes'; /** @@ -72,6 +72,11 @@ export interface FormatWithContentModelOptions { * Optional callback to get an object used for change data in ContentChangedEvent */ getChangeData?: () => any; + + /** + * When specified, use this selection range to override current selection inside editor + */ + selectionOverride?: SelectionRangeEx; } /** diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/parameter/InsertEntityOptions.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/parameter/InsertEntityOptions.ts new file mode 100644 index 00000000000..bac29b89210 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/parameter/InsertEntityOptions.ts @@ -0,0 +1,33 @@ +/** + * Options for insertEntity API + */ +export interface InsertEntityOptions { + /** + * Content node of the entity. If not passed, an empty entity will be created + */ + contentNode?: Node; + + /** + * Whether move focus after entity after insert + */ + focusAfterEntity?: boolean; + + /** + * "Display" value of the entity wrapper. By default, block entity will have no display, inline entity will have display: inline-block + */ + wrapperDisplay?: 'inline' | 'block' | 'none' | 'inline-block'; + + /** + * Whether skip adding an undo snapshot around + */ + skipUndoSnapshot?: boolean; +} + +/** + * Define the position of the entity to insert. It can be: + * "focus": insert at current focus. If insert a block entity, it will be inserted under the paragraph where focus is + * "begin": insert at beginning of content. When insert an inline entity, it will be wrapped with a paragraph. + * "end": insert at end of content. When insert an inline entity, it will be wrapped with a paragraph. + * "root": insert at the root level of current region + */ +export type InsertEntityPosition = 'focus' | 'begin' | 'end' | 'root'; diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/mergeModelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/mergeModelTest.ts index 3412059adae..3e43abcc795 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/mergeModelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/mergeModelTest.ts @@ -2745,7 +2745,7 @@ describe('mergeModel', () => { majorModel.blocks.push(para1); const sourceModel: ContentModelDocument = createContentModelDocument(); - const newEntity = createEntity(document.createElement('div'), false, { + const newEntity = createEntity(document.createElement('div'), false, undefined, { fontFamily: 'Corbel', fontSize: '20px', backgroundColor: 'blue', diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/entity/insertEntityModelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/modelApi/entity/insertEntityModelTest.ts new file mode 100644 index 00000000000..a6ea985581d --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/test/modelApi/entity/insertEntityModelTest.ts @@ -0,0 +1,2181 @@ +import { ContentModelDocument } from 'roosterjs-content-model-types'; +import { insertEntityModel } from '../../../lib/modelApi/entity/insertEntityModel'; +import { InsertEntityPosition } from '../../../lib/publicTypes/parameter/InsertEntityOptions'; +import { + createBr, + createContentModelDocument, + createDivider, + createEntity, + createParagraph, + createSelectionMarker, + createText, +} from 'roosterjs-content-model-dom'; + +const Entity = 'Entity' as any; + +function runTestGlobal( + model: ContentModelDocument, + pos: InsertEntityPosition, + expectedResult: ContentModelDocument, + isBlock: boolean, + focusAfterEntity: boolean +) { + insertEntityModel(model, Entity, pos, isBlock, focusAfterEntity); + + expect(model).toEqual(expectedResult, pos); +} + +describe('insertEntityModel, block element, not focus after entity', () => { + const marker = createSelectionMarker(); + + function runTest( + createModel: () => ContentModelDocument, + topResult: ContentModelDocument, + bottomResult: ContentModelDocument, + focusResult: ContentModelDocument, + rootResult: ContentModelDocument + ) { + runTestGlobal(createModel(), 'begin', topResult, true, false); + runTestGlobal(createModel(), 'end', bottomResult, true, false); + runTestGlobal(createModel(), 'focus', focusResult, true, false); + runTestGlobal(createModel(), 'root', rootResult, true, false); + } + + it('no selection', () => { + const br = createBr(); + + runTest( + () => { + const model = createContentModelDocument(); + const para = createParagraph(); + + model.blocks.push(para); + + return model; + }, + { + blockGroupType: 'Document', + blocks: [ + Entity, + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [br], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + ], + } + ); + }); + + it('collapsed selection', () => { + const txt1 = createText('test1'); + const txt2 = createText('test2'); + const br = createBr(); + + runTest( + () => { + const model = createContentModelDocument(); + const para = createParagraph(); + + para.segments.push(txt1, marker, txt2); + model.blocks.push(para); + + return model; + }, + { + blockGroupType: 'Document', + blocks: [ + Entity, + { + blockType: 'Paragraph', + segments: [txt1, marker, txt2], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, marker, txt2], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [br], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, marker, txt2], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [br], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, marker, txt2], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [br], + format: {}, + }, + ], + } + ); + }); + + it('Expanded selection', () => { + const txt1 = createText('test1'); + const txt2 = createText('test2'); + const br = createBr(); + + txt2.isSelected = true; + + runTest( + () => { + const model = createContentModelDocument(); + const para = createParagraph(); + + para.segments.push(txt1, txt2); + model.blocks.push(para); + + return model; + }, + { + blockGroupType: 'Document', + blocks: [ + Entity, + { + blockType: 'Paragraph', + segments: [txt1, txt2], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, txt2], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [br], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, marker], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [br], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, marker], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [br], + format: {}, + }, + ], + } + ); + }); + + it('Before another paragraph', () => { + const br = createBr(); + + runTest( + () => { + const model = createContentModelDocument(); + const para1 = createParagraph(); + const para2 = createParagraph(); + + para1.segments.push(marker); + model.blocks.push(para1, para2); + + return model; + }, + { + blockGroupType: 'Document', + blocks: [ + Entity, + { + blockType: 'Paragraph', + segments: [marker], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [marker], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [br], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [marker], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [marker], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + ], + } + ); + }); + + it('Before another divider', () => { + const divider = createDivider('hr'); + const br = createBr(); + + runTest( + () => { + const model = createContentModelDocument(); + const para1 = createParagraph(); + + para1.segments.push(marker); + model.blocks.push(para1, divider); + + return model; + }, + { + blockGroupType: 'Document', + blocks: [ + Entity, + { + blockType: 'Paragraph', + segments: [marker], + format: {}, + }, + divider, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [marker], + format: {}, + }, + divider, + Entity, + { + blockType: 'Paragraph', + segments: [br], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [marker], + format: {}, + }, + Entity, + divider, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [marker], + format: {}, + }, + Entity, + divider, + ], + } + ); + }); + + it('Before another entity', () => { + const entity2 = createEntity({} as any, true); + const br = createBr(); + + runTest( + () => { + const model = createContentModelDocument(); + const para1 = createParagraph(); + + para1.segments.push(marker); + model.blocks.push(para1, entity2); + + return model; + }, + { + blockGroupType: 'Document', + blocks: [ + Entity, + { + blockType: 'Paragraph', + segments: [marker], + format: {}, + }, + entity2, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [marker], + format: {}, + }, + entity2, + Entity, + { + blockType: 'Paragraph', + segments: [br], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [marker], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [br], + format: {}, + }, + entity2, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [marker], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [br], + format: {}, + }, + entity2, + ], + } + ); + }); + + it('With default format', () => { + const txt1 = createText('test1'); + const txt2 = createText('test2'); + const format = { + fontSize: '10px', + }; + const br = createBr(format); + + runTest( + () => { + const model = createContentModelDocument(format); + const para = createParagraph(); + + para.segments.push(txt1, marker, txt2); + model.blocks.push(para); + + return model; + }, + { + blockGroupType: 'Document', + blocks: [ + Entity, + { + blockType: 'Paragraph', + segments: [txt1, marker, txt2], + format: {}, + }, + ], + format, + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, marker, txt2], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [br], + segmentFormat: format, + format: {}, + }, + ], + format, + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, marker, txt2], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [br], + segmentFormat: format, + format: {}, + }, + ], + format, + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, marker, txt2], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [br], + segmentFormat: format, + format: {}, + }, + ], + format, + } + ); + }); +}); + +describe('insertEntityModel, block element, focus after entity', () => { + const br = createBr(); + const marker = createSelectionMarker(); + + function runTest( + createModel: () => ContentModelDocument, + topResult: ContentModelDocument, + bottomResult: ContentModelDocument, + focusResult: ContentModelDocument, + rootResult: ContentModelDocument + ) { + runTestGlobal(createModel(), 'begin', topResult, true, true); + runTestGlobal(createModel(), 'end', bottomResult, true, true); + runTestGlobal(createModel(), 'focus', focusResult, true, true); + runTestGlobal(createModel(), 'root', rootResult, true, true); + } + + it('no selection', () => { + runTest( + () => { + const model = createContentModelDocument(); + const para = createParagraph(); + + model.blocks.push(para); + + return model; + }, + { + blockGroupType: 'Document', + blocks: [ + Entity, + { + blockType: 'Paragraph', + segments: [marker], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [marker, br], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + ], + } + ); + }); + + it('collapsed selection', () => { + const txt1 = createText('test1'); + const txt2 = createText('test2'); + + runTest( + () => { + const model = createContentModelDocument(); + const para = createParagraph(); + + para.segments.push(txt1, marker, txt2); + model.blocks.push(para); + + return model; + }, + { + blockGroupType: 'Document', + blocks: [ + Entity, + { + blockType: 'Paragraph', + segments: [marker, txt1, txt2], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, txt2], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [marker, br], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, txt2], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [marker, br], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, txt2], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [marker, br], + format: {}, + }, + ], + } + ); + }); + + it('Expanded selection', () => { + const txt1 = createText('test1'); + const txt2 = createText('test2'); + + runTest( + () => { + const model = createContentModelDocument(); + const para = createParagraph(); + + txt2.isSelected = true; + + para.segments.push(txt1, txt2); + model.blocks.push(para); + + return model; + }, + { + blockGroupType: 'Document', + blocks: [ + Entity, + { + blockType: 'Paragraph', + segments: [marker, txt1, txt2], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, txt2], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [marker, br], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [marker, br], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [marker, br], + format: {}, + }, + ], + } + ); + }); + + it('Before another paragraph', () => { + runTest( + () => { + const model = createContentModelDocument(); + const para1 = createParagraph(); + const para2 = createParagraph(); + + para1.segments.push(marker); + model.blocks.push(para1, para2); + + return model; + }, + { + blockGroupType: 'Document', + blocks: [ + Entity, + { + blockType: 'Paragraph', + segments: [marker], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [marker, br], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [marker], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [marker], + format: {}, + }, + ], + } + ); + }); + + it('Before another divider', () => { + const divider = createDivider('hr'); + + runTest( + () => { + const model = createContentModelDocument(); + const para1 = createParagraph(); + + para1.segments.push(marker); + model.blocks.push(para1, divider); + + return model; + }, + { + blockGroupType: 'Document', + blocks: [ + Entity, + { + blockType: 'Paragraph', + segments: [marker], + format: {}, + }, + divider, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + divider, + Entity, + { + blockType: 'Paragraph', + segments: [marker, br], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [marker, br], + format: {}, + }, + divider, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [marker, br], + format: {}, + }, + divider, + ], + } + ); + }); + + it('Before another entity', () => { + const entity2 = createEntity({} as any, true); + + runTest( + () => { + const model = createContentModelDocument(); + const para1 = createParagraph(); + + para1.segments.push(marker); + model.blocks.push(para1, entity2); + + return model; + }, + { + blockGroupType: 'Document', + blocks: [ + Entity, + { + blockType: 'Paragraph', + segments: [marker], + format: {}, + }, + entity2, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + entity2, + Entity, + { + blockType: 'Paragraph', + segments: [marker, br], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [marker, br], + format: {}, + }, + entity2, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [marker, br], + format: {}, + }, + entity2, + ], + } + ); + }); + + it('With default format', () => { + const txt1 = createText('test1'); + const txt2 = createText('test2'); + const format = { + fontSize: '10px', + }; + const br = createBr(format); + const marker2 = createSelectionMarker(format); + + runTest( + () => { + const model = createContentModelDocument(format); + const para = createParagraph(); + + para.segments.push(txt1, marker, txt2); + model.blocks.push(para); + + return model; + }, + { + blockGroupType: 'Document', + blocks: [ + Entity, + { + blockType: 'Paragraph', + segments: [marker, txt1, txt2], + format: {}, + }, + ], + format, + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, txt2], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [marker2, br], + segmentFormat: format, + format: {}, + }, + ], + format, + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, txt2], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [marker2, br], + segmentFormat: format, + format: {}, + }, + ], + format, + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, txt2], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [marker2, br], + segmentFormat: format, + format: {}, + }, + ], + format, + } + ); + }); +}); + +describe('insertEntityModel, inline element, not focus after entity', () => { + const marker = createSelectionMarker(); + + function runTest( + createModel: () => ContentModelDocument, + topResult: ContentModelDocument, + bottomResult: ContentModelDocument, + focusResult: ContentModelDocument, + rootResult: ContentModelDocument + ) { + runTestGlobal(createModel(), 'begin', topResult, false, false); + runTestGlobal(createModel(), 'end', bottomResult, false, false); + runTestGlobal(createModel(), 'focus', focusResult, false, false); + runTestGlobal(createModel(), 'root', rootResult, false, false); + } + + it('no selection', () => { + runTest( + () => { + const model = createContentModelDocument(); + const para = createParagraph(); + + model.blocks.push(para); + + return model; + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [Entity], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [Entity], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + ], + } + ); + }); + + it('collapsed selection', () => { + const txt1 = createText('test1'); + const txt2 = createText('test2'); + + runTest( + () => { + const model = createContentModelDocument(); + const para = createParagraph(); + + para.segments.push(txt1, marker, txt2); + model.blocks.push(para); + + return model; + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [Entity], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [txt1, marker, txt2], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, marker, txt2], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [Entity], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, marker, Entity, txt2], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, marker, Entity, txt2], + format: {}, + }, + ], + } + ); + }); + + it('Expanded selection', () => { + const txt1 = createText('test1'); + const txt2 = createText('test2'); + + runTest( + () => { + const model = createContentModelDocument(); + const para = createParagraph(); + + txt2.isSelected = true; + + para.segments.push(txt1, txt2); + model.blocks.push(para); + + return model; + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [Entity], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [txt1, txt2], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, txt2], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [Entity], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, marker, Entity], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, marker, Entity], + format: {}, + }, + ], + } + ); + }); + + it('Before another paragraph', () => { + runTest( + () => { + const model = createContentModelDocument(); + const para1 = createParagraph(); + const para2 = createParagraph(); + + para1.segments.push(marker); + model.blocks.push(para1, para2); + + return model; + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [Entity], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [marker], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [marker], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [Entity], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [marker, Entity], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [marker, Entity], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + ], + } + ); + }); + + it('Before another divider', () => { + const divider = createDivider('hr'); + + runTest( + () => { + const model = createContentModelDocument(); + const para1 = createParagraph(); + + para1.segments.push(marker); + model.blocks.push(para1, divider); + + return model; + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [Entity], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [marker], + format: {}, + }, + divider, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [marker], + format: {}, + }, + divider, + { + blockType: 'Paragraph', + segments: [Entity], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [marker, Entity], + format: {}, + }, + divider, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [marker, Entity], + format: {}, + }, + divider, + ], + } + ); + }); + + it('Before another entity', () => { + const entity2 = createEntity({} as any, true); + + runTest( + () => { + const model = createContentModelDocument(); + const para1 = createParagraph(); + + para1.segments.push(marker); + model.blocks.push(para1, entity2); + + return model; + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [Entity], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [marker], + format: {}, + }, + entity2, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [marker], + format: {}, + }, + entity2, + { + blockType: 'Paragraph', + segments: [Entity], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [marker, Entity], + format: {}, + }, + entity2, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [marker, Entity], + format: {}, + }, + entity2, + ], + } + ); + }); + + it('With default format', () => { + const txt1 = createText('test1'); + const txt2 = createText('test2'); + const format = { + fontSize: '10px', + }; + + runTest( + () => { + const model = createContentModelDocument(format); + const para = createParagraph(); + + para.segments.push(txt1, marker, txt2); + model.blocks.push(para); + + return model; + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [Entity], + format: {}, + segmentFormat: format, + }, + { + blockType: 'Paragraph', + segments: [txt1, marker, txt2], + format: {}, + }, + ], + format, + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, marker, txt2], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [Entity], + format: {}, + segmentFormat: format, + }, + ], + format, + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, marker, Entity, txt2], + format: {}, + }, + ], + format, + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, marker, Entity, txt2], + format: {}, + }, + ], + format, + } + ); + }); +}); + +describe('insertEntityModel, inline element, focus after entity', () => { + const marker = createSelectionMarker(); + + function runTest( + createModel: () => ContentModelDocument, + topResult: ContentModelDocument, + bottomResult: ContentModelDocument, + focusResult: ContentModelDocument, + rootResult: ContentModelDocument + ) { + runTestGlobal(createModel(), 'begin', topResult, false, true); + runTestGlobal(createModel(), 'end', bottomResult, false, true); + runTestGlobal(createModel(), 'focus', focusResult, false, true); + runTestGlobal(createModel(), 'root', rootResult, false, true); + } + + it('no selection', () => { + runTest( + () => { + const model = createContentModelDocument(); + const para = createParagraph(); + + model.blocks.push(para); + + return model; + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [Entity, marker], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [Entity, marker], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + ], + } + ); + }); + + it('collapsed selection', () => { + const txt1 = createText('test1'); + const txt2 = createText('test2'); + + runTest( + () => { + const model = createContentModelDocument(); + const para = createParagraph(); + + para.segments.push(txt1, marker, txt2); + model.blocks.push(para); + + return model; + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [Entity, marker], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [txt1, txt2], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, txt2], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [Entity, marker], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, Entity, marker, txt2], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, Entity, marker, txt2], + format: {}, + }, + ], + } + ); + }); + + it('Expanded selection', () => { + const txt1 = createText('test1'); + const txt2 = createText('test2'); + + runTest( + () => { + const model = createContentModelDocument(); + const para = createParagraph(); + + txt2.isSelected = true; + + para.segments.push(txt1, txt2); + model.blocks.push(para); + + return model; + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [Entity, marker], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [txt1, txt2], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, txt2], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [Entity, marker], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, Entity, marker], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, Entity, marker], + format: {}, + }, + ], + } + ); + }); + + it('Before another paragraph', () => { + runTest( + () => { + const model = createContentModelDocument(); + const para1 = createParagraph(); + const para2 = createParagraph(); + + para1.segments.push(marker); + model.blocks.push(para1, para2); + + return model; + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [Entity, marker], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [Entity, marker], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [Entity, marker], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [Entity, marker], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + ], + } + ); + }); + + it('Before another divider', () => { + const divider = createDivider('hr'); + + runTest( + () => { + const model = createContentModelDocument(); + const para1 = createParagraph(); + + para1.segments.push(marker); + model.blocks.push(para1, divider); + + return model; + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [Entity, marker], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + divider, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + divider, + { + blockType: 'Paragraph', + segments: [Entity, marker], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [Entity, marker], + format: {}, + }, + divider, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [Entity, marker], + format: {}, + }, + divider, + ], + } + ); + }); + + it('Before another entity', () => { + const entity2 = createEntity({} as any, true); + + runTest( + () => { + const model = createContentModelDocument(); + const para1 = createParagraph(); + + para1.segments.push(marker); + model.blocks.push(para1, entity2); + + return model; + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [Entity, marker], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + entity2, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + entity2, + { + blockType: 'Paragraph', + segments: [Entity, marker], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [Entity, marker], + format: {}, + }, + entity2, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [Entity, marker], + format: {}, + }, + entity2, + ], + } + ); + }); + + it('With default format', () => { + const txt1 = createText('test1'); + const txt2 = createText('test2'); + const format = { + fontSize: '10px', + }; + const marker2 = createSelectionMarker(format); + + runTest( + () => { + const model = createContentModelDocument(format); + const para = createParagraph(); + + para.segments.push(txt1, marker, txt2); + model.blocks.push(para); + + return model; + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [Entity, marker2], + format: {}, + segmentFormat: format, + }, + { + blockType: 'Paragraph', + segments: [txt1, txt2], + format: {}, + }, + ], + format, + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, txt2], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [Entity, marker2], + format: {}, + segmentFormat: format, + }, + ], + format, + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, Entity, marker, txt2], + format: {}, + }, + ], + format, + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, Entity, marker, txt2], + format: {}, + }, + ], + format, + } + ); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/entity/insertEntityTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/entity/insertEntityTest.ts new file mode 100644 index 00000000000..1699a15787b --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/entity/insertEntityTest.ts @@ -0,0 +1,232 @@ +import * as commitEntity from 'roosterjs-editor-dom/lib/entity/commitEntity'; +import * as formatWithContentModel from '../../../lib/publicApi/utils/formatWithContentModel'; +import * as getEntityFromElement from 'roosterjs-editor-dom/lib/entity/getEntityFromElement'; +import * as insertEntityModel from '../../../lib/modelApi/entity/insertEntityModel'; +import insertEntity from '../../../lib/publicApi/entity/insertEntity'; +import { ChangeSource } from 'roosterjs-editor-types'; +import { FormatWithContentModelContext } from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; +import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; + +describe('insertEntity', () => { + let editor: IContentModelEditor; + let context: FormatWithContentModelContext; + let wrapper: HTMLElement; + const model = 'MockedModel' as any; + const newEntity = 'MockedEntity' as any; + + let formatWithContentModelSpy: jasmine.Spy; + let getEntityFromElementSpy: jasmine.Spy; + let triggerContentChangedEventSpy: jasmine.Spy; + let getDocumentSpy: jasmine.Spy; + let createElementSpy: jasmine.Spy; + let commitEntitySpy: jasmine.Spy; + let setPropertySpy: jasmine.Spy; + let appendChildSpy: jasmine.Spy; + let insertEntityModelSpy: jasmine.Spy; + let isDarkModeSpy: jasmine.Spy; + let transformToDarkColorSpy: jasmine.Spy; + + const type = 'Entity'; + const apiName = 'insertEntity'; + + beforeEach(() => { + context = { + deletedEntities: [], + }; + + setPropertySpy = jasmine.createSpy('setPropertySpy'); + appendChildSpy = jasmine.createSpy('appendChildSpy'); + insertEntityModelSpy = spyOn(insertEntityModel, 'insertEntityModel'); + isDarkModeSpy = jasmine.createSpy('isDarkMode'); + transformToDarkColorSpy = jasmine.createSpy('transformToDarkColor'); + + wrapper = { + style: { + setProperty: setPropertySpy, + }, + appendChild: appendChildSpy, + } as any; + + formatWithContentModelSpy = spyOn( + formatWithContentModel, + 'formatWithContentModel' + ).and.callFake((editor, apiName, formatter, options) => { + formatter(model, context); + }); + getEntityFromElementSpy = spyOn(getEntityFromElement, 'default').and.returnValue(newEntity); + commitEntitySpy = spyOn(commitEntity, 'default'); + triggerContentChangedEventSpy = jasmine.createSpy('triggerContentChangedEventSpy'); + createElementSpy = jasmine.createSpy('createElementSpy').and.returnValue(wrapper); + getDocumentSpy = jasmine.createSpy('getDocumentSpy').and.returnValue({ + createElement: createElementSpy, + }); + + editor = { + triggerContentChangedEvent: triggerContentChangedEventSpy, + getDocument: getDocumentSpy, + isDarkMode: isDarkModeSpy, + transformToDarkColor: transformToDarkColorSpy, + } as any; + }); + + it('insert inline entity to top', () => { + const entity = insertEntity(editor, type, false, 'begin'); + + expect(createElementSpy).toHaveBeenCalledWith('span'); + expect(setPropertySpy).toHaveBeenCalledWith('display', 'inline-block'); + expect(appendChildSpy).not.toHaveBeenCalled(); + expect(commitEntitySpy).toHaveBeenCalledWith(wrapper, type, true); + expect(formatWithContentModelSpy.calls.argsFor(0)[0]).toBe(editor); + expect(formatWithContentModelSpy.calls.argsFor(0)[1]).toBe(apiName); + expect(formatWithContentModelSpy.calls.argsFor(0)[3]).toEqual({ + selectionOverride: undefined, + }); + expect(insertEntityModelSpy).toHaveBeenCalledWith( + model, + { + segmentType: 'Entity', + blockType: 'Entity', + format: {}, + id: undefined, + type: type, + isReadonly: true, + wrapper: wrapper, + }, + 'begin', + false, + true, + context + ); + expect(getEntityFromElementSpy).toHaveBeenCalledWith(wrapper); + expect(triggerContentChangedEventSpy).toHaveBeenCalledWith( + ChangeSource.InsertEntity, + newEntity + ); + expect(transformToDarkColorSpy).not.toHaveBeenCalled(); + + expect(entity).toBe(newEntity); + }); + + it('block inline entity to root', () => { + const entity = insertEntity(editor, type, true, 'root'); + + expect(createElementSpy).toHaveBeenCalledWith('div'); + expect(setPropertySpy).toHaveBeenCalledWith('display', null); + expect(appendChildSpy).not.toHaveBeenCalled(); + expect(commitEntitySpy).toHaveBeenCalledWith(wrapper, type, true); + expect(formatWithContentModelSpy.calls.argsFor(0)[0]).toBe(editor); + expect(formatWithContentModelSpy.calls.argsFor(0)[1]).toBe(apiName); + expect(formatWithContentModelSpy.calls.argsFor(0)[3]).toEqual({ + selectionOverride: undefined, + }); + expect(insertEntityModelSpy).toHaveBeenCalledWith( + model, + { + segmentType: 'Entity', + blockType: 'Entity', + format: {}, + id: undefined, + type: type, + isReadonly: true, + wrapper: wrapper, + }, + 'root', + true, + undefined, + context + ); + expect(getEntityFromElementSpy).toHaveBeenCalledWith(wrapper); + expect(triggerContentChangedEventSpy).toHaveBeenCalledWith( + ChangeSource.InsertEntity, + newEntity + ); + expect(transformToDarkColorSpy).not.toHaveBeenCalled(); + + expect(entity).toBe(newEntity); + }); + + it('block inline entity with more options', () => { + const range = { range: 'RangeEx' } as any; + const contentNode = 'ContentNode' as any; + const entity = insertEntity(editor, type, true, range, { + contentNode: contentNode, + focusAfterEntity: true, + skipUndoSnapshot: true, + wrapperDisplay: 'none', + }); + + expect(createElementSpy).toHaveBeenCalledWith('div'); + expect(setPropertySpy).toHaveBeenCalledWith('display', 'none'); + expect(appendChildSpy).toHaveBeenCalledWith(contentNode); + expect(commitEntitySpy).toHaveBeenCalledWith(wrapper, type, true); + expect(formatWithContentModelSpy.calls.argsFor(0)[0]).toBe(editor); + expect(formatWithContentModelSpy.calls.argsFor(0)[1]).toBe(apiName); + expect(formatWithContentModelSpy.calls.argsFor(0)[3]).toEqual({ + selectionOverride: range, + }); + expect(insertEntityModelSpy).toHaveBeenCalledWith( + model, + { + segmentType: 'Entity', + blockType: 'Entity', + format: {}, + id: undefined, + type: type, + isReadonly: true, + wrapper: wrapper, + }, + 'focus', + true, + true, + context + ); + expect(getEntityFromElementSpy).toHaveBeenCalledWith(wrapper); + expect(triggerContentChangedEventSpy).toHaveBeenCalledWith( + ChangeSource.InsertEntity, + newEntity + ); + expect(transformToDarkColorSpy).not.toHaveBeenCalled(); + + expect(entity).toBe(newEntity); + }); + + it('In dark mode', () => { + isDarkModeSpy.and.returnValue(true); + + const entity = insertEntity(editor, type, false, 'begin'); + + expect(createElementSpy).toHaveBeenCalledWith('span'); + expect(setPropertySpy).toHaveBeenCalledWith('display', 'inline-block'); + expect(appendChildSpy).not.toHaveBeenCalled(); + expect(commitEntitySpy).toHaveBeenCalledWith(wrapper, type, true); + expect(formatWithContentModelSpy.calls.argsFor(0)[0]).toBe(editor); + expect(formatWithContentModelSpy.calls.argsFor(0)[1]).toBe(apiName); + expect(formatWithContentModelSpy.calls.argsFor(0)[3]).toEqual({ + selectionOverride: undefined, + }); + expect(insertEntityModelSpy).toHaveBeenCalledWith( + model, + { + segmentType: 'Entity', + blockType: 'Entity', + format: {}, + id: undefined, + type: type, + isReadonly: true, + wrapper: wrapper, + }, + 'begin', + false, + true, + context + ); + expect(getEntityFromElementSpy).toHaveBeenCalledWith(wrapper); + expect(triggerContentChangedEventSpy).toHaveBeenCalledWith( + ChangeSource.InsertEntity, + newEntity + ); + expect(transformToDarkColorSpy).toHaveBeenCalled(); + + expect(entity).toBe(newEntity); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatWithContentModelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatWithContentModelTest.ts index 9a8833a0e75..ee12942f250 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatWithContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatWithContentModelTest.ts @@ -239,4 +239,14 @@ describe('formatWithContentModel', () => { rawEvent: rawEvent, }); }); + + it('With selectionOverride', () => { + const range = 'MockedRangeEx' as any; + + formatWithContentModel(editor, apiName, () => true, { + selectionOverride: range, + }); + + expect(createContentModel).toHaveBeenCalledWith(undefined, range); + }); }); From dec835d20e09061225e7541336197c4d2b925984 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Fri, 11 Aug 2023 11:44:04 -0300 Subject: [PATCH 14/75] crop --- .../lib/plugins/ImageEdit/constants/constants.ts | 7 +++++-- .../lib/plugins/ImageEdit/imageEditors/Cropper.ts | 12 ++++++++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/constants/constants.ts b/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/constants/constants.ts index 754ad0f4a08..075c4aabf06 100644 --- a/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/constants/constants.ts +++ b/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/constants/constants.ts @@ -14,11 +14,14 @@ export const ROTATION: Record = { ne: 180, se: 270, }; +export const Xs: DNDDirectionX[] = ['w', '', 'e']; +export const Ys: DnDDirectionY[] = ['s', '', 'n']; + export const ROTATE_WIDTH = 1; export const ROTATE_HANDLE_TOP = ROTATE_GAP + RESIZE_HANDLE_MARGIN; export const CROP_HANDLE_SIZE = 22; export const CROP_HANDLE_WIDTH = 7; -export const Xs: DNDDirectionX[] = ['w', '', 'e']; -export const Ys: DnDDirectionY[] = ['s', '', 'n']; +export const Xs_CROP: DNDDirectionX[] = ['w', 'e']; +export const Ys_CROP: DnDDirectionY[] = ['s', 'n']; export const MIN_HEIGHT_WIDTH = 3 * RESIZE_HANDLE_SIZE + 2 * RESIZE_HANDLE_MARGIN; diff --git a/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/imageEditors/Cropper.ts b/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/imageEditors/Cropper.ts index fe3e4a786f6..f0de4cbe15a 100644 --- a/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/imageEditors/Cropper.ts +++ b/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/imageEditors/Cropper.ts @@ -1,10 +1,16 @@ import DragAndDropContext, { DNDDirectionX, DnDDirectionY } from '../types/DragAndDropContext'; import DragAndDropHandler from '../../../pluginUtils/DragAndDropHandler'; import { CreateElementData } from 'roosterjs-editor-types'; -import { CROP_HANDLE_SIZE, CROP_HANDLE_WIDTH, ROTATION, Xs, Ys } from '../constants/constants'; import { CropInfo } from '../types/ImageEditInfo'; import { ImageEditElementClass } from '../types/ImageEditElementClass'; import { rotateCoordinate } from './Resizer'; +import { + CROP_HANDLE_SIZE, + CROP_HANDLE_WIDTH, + ROTATION, + Xs_CROP, + Ys_CROP, +} from '../constants/constants'; /** * @internal @@ -96,7 +102,9 @@ export function getCropHTML(): CreateElementData[] { children: [], }; if (containerHTML) { - Xs.forEach(x => Ys.forEach(y => containerHTML.children?.push(getCropHTMLInternal(x, y)))); + Xs_CROP.forEach(x => + Ys_CROP.forEach(y => containerHTML.children?.push(getCropHTMLInternal(x, y))) + ); } return [containerHTML, overlayHTML, overlayHTML, overlayHTML, overlayHTML]; } From f44cc25c0f2a092438a47a3ef8c04ee42b86bb5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Fri, 11 Aug 2023 12:04:30 -0300 Subject: [PATCH 15/75] fix xase --- .../lib/plugins/ImageEdit/constants/constants.ts | 4 ++-- .../lib/plugins/ImageEdit/imageEditors/Cropper.ts | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/constants/constants.ts b/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/constants/constants.ts index 075c4aabf06..ab693701b73 100644 --- a/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/constants/constants.ts +++ b/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/constants/constants.ts @@ -21,7 +21,7 @@ export const ROTATE_WIDTH = 1; export const ROTATE_HANDLE_TOP = ROTATE_GAP + RESIZE_HANDLE_MARGIN; export const CROP_HANDLE_SIZE = 22; export const CROP_HANDLE_WIDTH = 7; -export const Xs_CROP: DNDDirectionX[] = ['w', 'e']; -export const Ys_CROP: DnDDirectionY[] = ['s', 'n']; +export const XS_CROP: DNDDirectionX[] = ['w', 'e']; +export const YS_CROP: DnDDirectionY[] = ['s', 'n']; export const MIN_HEIGHT_WIDTH = 3 * RESIZE_HANDLE_SIZE + 2 * RESIZE_HANDLE_MARGIN; diff --git a/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/imageEditors/Cropper.ts b/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/imageEditors/Cropper.ts index f0de4cbe15a..181d53796c3 100644 --- a/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/imageEditors/Cropper.ts +++ b/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/imageEditors/Cropper.ts @@ -8,8 +8,8 @@ import { CROP_HANDLE_SIZE, CROP_HANDLE_WIDTH, ROTATION, - Xs_CROP, - Ys_CROP, + XS_CROP, + YS_CROP, } from '../constants/constants'; /** @@ -102,8 +102,8 @@ export function getCropHTML(): CreateElementData[] { children: [], }; if (containerHTML) { - Xs_CROP.forEach(x => - Ys_CROP.forEach(y => containerHTML.children?.push(getCropHTMLInternal(x, y))) + XS_CROP.forEach(x => + YS_CROP.forEach(y => containerHTML.children?.push(getCropHTMLInternal(x, y))) ); } return [containerHTML, overlayHTML, overlayHTML, overlayHTML, overlayHTML]; From 4d37ad2bf346cfc8b55bab4ecffa55e6110947c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Fri, 11 Aug 2023 18:16:51 -0300 Subject: [PATCH 16/75] check cell exist --- .../lib/modelToDom/handlers/handleTable.ts | 111 ++++++++++-------- 1 file changed, 59 insertions(+), 52 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleTable.ts b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleTable.ts index b8a4e64658c..318341d5a36 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleTable.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleTable.ts @@ -75,72 +75,79 @@ export const handleTable: ContentModelBlockHandler = ( for (let col = 0; col < tableRow.cells.length; col++) { const cell = tableRow.cells[col]; - if (cell.isSelected) { - context.tableSelection = context.tableSelection || { - table: tableNode, - firstCell: { x: col, y: row }, - lastCell: { x: col, y: row }, - }; - - if (context.tableSelection.table == tableNode) { - const lastCell = context.tableSelection.lastCell; - - lastCell.x = Math.max(lastCell.x, col); - lastCell.y = Math.max(lastCell.y, row); + if (cell) { + if (cell.isSelected) { + context.tableSelection = context.tableSelection || { + table: tableNode, + firstCell: { x: col, y: row }, + lastCell: { x: col, y: row }, + }; + + if (context.tableSelection.table == tableNode) { + const lastCell = context.tableSelection.lastCell; + + lastCell.x = Math.max(lastCell.x, col); + lastCell.y = Math.max(lastCell.y, row); + } } - } - - if (!cell.spanAbove && !cell.spanLeft) { - let td = - (context.allowCacheElement && cell.cachedElement) || - doc.createElement(cell.isHeader ? 'th' : 'td'); - - tr.appendChild(td); - let rowSpan = 1; - let colSpan = 1; - let width = table.widths[col]; - let height = tableRow.height; + if (!cell.spanAbove && !cell.spanLeft) { + let td = + (context.allowCacheElement && cell.cachedElement) || + doc.createElement(cell.isHeader ? 'th' : 'td'); - for (; table.rows[row + rowSpan]?.cells[col]?.spanAbove; rowSpan++) { - height += table.rows[row + rowSpan].height; - } - for (; tableRow.cells[col + colSpan]?.spanLeft; colSpan++) { - width += table.widths[col + colSpan]; - } + tr.appendChild(td); - if (rowSpan > 1) { - td.rowSpan = rowSpan; - } + let rowSpan = 1; + let colSpan = 1; + let width = table.widths[col]; + let height = tableRow.height; - if (colSpan > 1) { - td.colSpan = colSpan; - } + for (; table.rows[row + rowSpan]?.cells[col]?.spanAbove; rowSpan++) { + height += table.rows[row + rowSpan].height; + } + for (; tableRow.cells[col + colSpan]?.spanLeft; colSpan++) { + width += table.widths[col + colSpan]; + } - if (!cell.cachedElement || (cell.format.useBorderBox && hasMetadata(table))) { - if (width > 0 && !td.style.width) { - td.style.width = width + 'px'; + if (rowSpan > 1) { + td.rowSpan = rowSpan; } - if (height > 0 && !td.style.height) { - td.style.height = height + 'px'; + if (colSpan > 1) { + td.colSpan = colSpan; } - } - if (!cell.cachedElement) { - if (context.allowCacheElement) { - cell.cachedElement = td; + if (!cell.cachedElement || (cell.format.useBorderBox && hasMetadata(table))) { + if (width > 0 && !td.style.width) { + td.style.width = width + 'px'; + } + + if (height > 0 && !td.style.height) { + td.style.height = height + 'px'; + } } - applyFormat(td, context.formatAppliers.block, cell.format, context); - applyFormat(td, context.formatAppliers.tableCell, cell.format, context); - applyFormat(td, context.formatAppliers.tableCellBorder, cell.format, context); - applyFormat(td, context.formatAppliers.dataset, cell.dataset, context); - } + if (!cell.cachedElement) { + if (context.allowCacheElement) { + cell.cachedElement = td; + } + + applyFormat(td, context.formatAppliers.block, cell.format, context); + applyFormat(td, context.formatAppliers.tableCell, cell.format, context); + applyFormat( + td, + context.formatAppliers.tableCellBorder, + cell.format, + context + ); + applyFormat(td, context.formatAppliers.dataset, cell.dataset, context); + } - context.modelHandlers.blockGroupChildren(doc, td, cell, context); + context.modelHandlers.blockGroupChildren(doc, td, cell, context); - context.onNodeCreated?.(cell, td); + context.onNodeCreated?.(cell, td); + } } } } From 7e722e5f6b49ae1c3961b339f623aeb38973dfc7 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Fri, 11 Aug 2023 14:55:14 -0700 Subject: [PATCH 17/75] Fix #1752, rename header to heading (#2020) --- .../contentModel/ContentModelRibbon.tsx | 4 +- .../contentModel/setHeaderLevelButton.ts | 36 ------ .../contentModel/setHeadingLevelButton.ts | 36 ++++++ .../sidePane/formatState/FormatStatePane.tsx | 4 +- .../domToModel/processors/headingProcessor.ts | 4 +- .../lib/formatHandlers/utils/defaultStyles.ts | 2 +- .../processors/headingProcessorTest.ts | 2 +- .../lib/index.ts | 2 +- .../common/retrieveModelFormatState.ts | 7 +- .../{setHeaderLevel.ts => setHeadingLevel.ts} | 32 +++--- .../common/retrieveModelFormatStateTest.ts | 5 +- ...derLevelTest.ts => setHeadingLevelTest.ts} | 18 +-- .../lib/block/ContentModelParagraph.ts | 2 +- .../ContentModelParagraphDecorator.ts | 4 +- .../lib/ribbon/component/buttons/header.ts | 42 ------- .../lib/ribbon/component/buttons/heading.ts | 42 +++++++ .../lib/ribbon/component/getButtons.ts | 7 +- .../roosterjs-react/lib/ribbon/index.ts | 1 + .../lib/ribbon/type/KnownRibbonButton.ts | 10 +- .../lib/ribbon/type/RibbonButtonStringKeys.ts | 19 +++- .../lib/format/getFormatState.ts | 14 ++- .../{toggleHeader.ts => setHeadingLevel.ts} | 106 +++++++++--------- packages/roosterjs-editor-api/lib/index.ts | 2 +- .../convertPastedContentFromWord.ts | 2 +- .../lib/interface/FormatState.ts | 8 +- 25 files changed, 225 insertions(+), 186 deletions(-) delete mode 100644 demo/scripts/controls/ribbonButtons/contentModel/setHeaderLevelButton.ts create mode 100644 demo/scripts/controls/ribbonButtons/contentModel/setHeadingLevelButton.ts rename packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/{setHeaderLevel.ts => setHeadingLevel.ts} (56%) rename packages-content-model/roosterjs-content-model-editor/test/publicApi/block/{setHeaderLevelTest.ts => setHeadingLevelTest.ts} (96%) delete mode 100644 packages-ui/roosterjs-react/lib/ribbon/component/buttons/header.ts create mode 100644 packages-ui/roosterjs-react/lib/ribbon/component/buttons/heading.ts rename packages/roosterjs-editor-api/lib/format/{toggleHeader.ts => setHeadingLevel.ts} (74%) diff --git a/demo/scripts/controls/ribbonButtons/contentModel/ContentModelRibbon.tsx b/demo/scripts/controls/ribbonButtons/contentModel/ContentModelRibbon.tsx index 8313c34391f..a30c6ccba48 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/ContentModelRibbon.tsx +++ b/demo/scripts/controls/ribbonButtons/contentModel/ContentModelRibbon.tsx @@ -37,7 +37,7 @@ import { removeLinkButton } from './removeLinkButton'; import { Ribbon, RibbonButton, RibbonPlugin } from 'roosterjs-react'; import { rtlButton } from './rtlButton'; import { setBulletedListStyleButton } from './setBulletedListStyleButton'; -import { setHeaderLevelButton } from './setHeaderLevelButton'; +import { setHeadingLevelButton } from './setHeadingLevelButton'; import { setNumberedListStyleButton } from './setNumberedListStyleButton'; import { setTableCellShadeButton } from './setTableCellShadeButton'; import { setTableHeaderButton } from './setTableHeaderButton'; @@ -84,7 +84,7 @@ const buttons = [ superscriptButton, subscriptButton, strikethroughButton, - setHeaderLevelButton, + setHeadingLevelButton, codeButton, ltrButton, rtlButton, diff --git a/demo/scripts/controls/ribbonButtons/contentModel/setHeaderLevelButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/setHeaderLevelButton.ts deleted file mode 100644 index d251e5d27f0..00000000000 --- a/demo/scripts/controls/ribbonButtons/contentModel/setHeaderLevelButton.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { isContentModelEditor, setHeaderLevel } from 'roosterjs-content-model-editor'; -import { - getButtons, - HeaderButtonStringKey, - KnownRibbonButtonKey, - RibbonButton, -} from 'roosterjs-react'; - -const originalHeadersButton: RibbonButton = getButtons([ - KnownRibbonButtonKey.Header, -])[0] as RibbonButton; -const keys: HeaderButtonStringKey[] = [ - 'buttonNameNoHeader', - 'buttonNameHeader1', - 'buttonNameHeader2', - 'buttonNameHeader3', - 'buttonNameHeader4', - 'buttonNameHeader5', - 'buttonNameHeader6', -]; - -export const setHeaderLevelButton: RibbonButton = { - dropDownMenu: { - ...originalHeadersButton.dropDownMenu, - }, - key: 'buttonNameHeader', - unlocalizedText: 'Header', - iconName: 'Header1', - onClick: (editor, key) => { - const headerLevel = keys.indexOf(key); - - if (isContentModelEditor(editor) && headerLevel >= 0) { - setHeaderLevel(editor, headerLevel as 0 | 1 | 2 | 3 | 4 | 5 | 6); - } - }, -}; diff --git a/demo/scripts/controls/ribbonButtons/contentModel/setHeadingLevelButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/setHeadingLevelButton.ts new file mode 100644 index 00000000000..03c44f0dbb7 --- /dev/null +++ b/demo/scripts/controls/ribbonButtons/contentModel/setHeadingLevelButton.ts @@ -0,0 +1,36 @@ +import { isContentModelEditor, setHeadingLevel } from 'roosterjs-content-model-editor'; +import { + getButtons, + HeadingButtonStringKey, + KnownRibbonButtonKey, + RibbonButton, +} from 'roosterjs-react'; + +const originalHeadingButton: RibbonButton = getButtons([ + KnownRibbonButtonKey.Heading, +])[0] as RibbonButton; +const keys: HeadingButtonStringKey[] = [ + 'buttonNameNoHeading', + 'buttonNameHeading1', + 'buttonNameHeading2', + 'buttonNameHeading3', + 'buttonNameHeading4', + 'buttonNameHeading5', + 'buttonNameHeading6', +]; + +export const setHeadingLevelButton: RibbonButton = { + dropDownMenu: { + ...originalHeadingButton.dropDownMenu, + }, + key: 'buttonNameHeading', + unlocalizedText: 'Heading', + iconName: 'Header1', + onClick: (editor, key) => { + const headingLevel = keys.indexOf(key); + + if (isContentModelEditor(editor) && headingLevel >= 0) { + setHeadingLevel(editor, headingLevel as 0 | 1 | 2 | 3 | 4 | 5 | 6); + } + }, +}; diff --git a/demo/scripts/controls/sidePane/formatState/FormatStatePane.tsx b/demo/scripts/controls/sidePane/formatState/FormatStatePane.tsx index b7964758f41..32b82e43621 100644 --- a/demo/scripts/controls/sidePane/formatState/FormatStatePane.tsx +++ b/demo/scripts/controls/sidePane/formatState/FormatStatePane.tsx @@ -83,8 +83,8 @@ export default class FormatStatePane extends React.Component< {this.renderSpan(format.tableHasHeader, 'Table Has Header')} {`Header ${format.headerLevel}`} + format.headingLevel == 0 && styles.inactive + }>{`Heading ${format.headingLevel}`} diff --git a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/headingProcessor.ts b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/headingProcessor.ts index b097a834cea..2183c717f8c 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/headingProcessor.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/headingProcessor.ts @@ -20,8 +20,8 @@ export const headingProcessor: ElementProcessor = (group, el parseFormat(element, context.formatParsers.segmentOnBlock, segmentFormat, context); // These formats are already declared on heading element, no need to keep them in context. - // And we should not duplicate them in context, either. Because when we want to turn off header, - // inner text should not keep those text format from header. + // And we should not duplicate them in context, either. Because when we want to turn off heading, + // inner text should not keep those text format from heading. getObjectKeys(segmentFormat).forEach(key => { delete context.segmentFormat[key]; }); diff --git a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/utils/defaultStyles.ts b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/utils/defaultStyles.ts index 71bffc02c5a..7eecb8fd775 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/utils/defaultStyles.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/utils/defaultStyles.ts @@ -161,7 +161,7 @@ export const defaultImplicitFormatMap: DefaultImplicitFormatMap = { }, h4: { fontWeight: 'bold', - fontSize: '1em', // Set this default value here to overwrite existing font size when change header level + fontSize: '1em', // Set this default value here to overwrite existing font size when change heading level }, h5: { fontWeight: 'bold', diff --git a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/headingProcessorTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/headingProcessorTest.ts index 84666a1d7bb..e1d156c062f 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/headingProcessorTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/headingProcessorTest.ts @@ -87,7 +87,7 @@ describe('headingProcessor', () => { }); }); - it('header with format from context', () => { + it('heading with format from context', () => { const group = createContentModelDocument(); const h1 = document.createElement('h1'); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/index.ts b/packages-content-model/roosterjs-content-model-editor/lib/index.ts index 888aed6741e..67531e9a789 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/index.ts @@ -56,7 +56,7 @@ export { default as getSelectedSegments } from './publicApi/selection/getSelecte export { default as setIndentation } from './publicApi/block/setIndentation'; export { default as setAlignment } from './publicApi/block/setAlignment'; export { default as setDirection } from './publicApi/block/setDirection'; -export { default as setHeaderLevel } from './publicApi/block/setHeaderLevel'; +export { default as setHeadingLevel } from './publicApi/block/setHeadingLevel'; export { default as toggleBlockQuote } from './publicApi/block/toggleBlockQuote'; export { default as setSpacing } from './publicApi/block/setSpacing'; export { default as setImageBorder } from './publicApi/image/setImageBorder'; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/retrieveModelFormatState.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/retrieveModelFormatState.ts index e5ba9a01029..560dc4fe087 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/retrieveModelFormatState.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/retrieveModelFormatState.ts @@ -152,12 +152,13 @@ function retrieveParagraphFormat( paragraph: ContentModelParagraph, isFirst: boolean ) { - const headerLevel = parseInt((paragraph.decorator?.tagName || '').substring(1)); - const validHeaderLevel = headerLevel >= 1 && headerLevel <= 6 ? headerLevel : undefined; + 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, 'headerLevel', validHeaderLevel, isFirst); + mergeValue(result, 'headingLevel', validHeadingLevel, isFirst); + mergeValue(result, 'headerLevel', validHeadingLevel, isFirst); mergeValue(result, 'textAlign', paragraph.format.textAlign, isFirst); mergeValue(result, 'direction', paragraph.format.direction, isFirst); } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/setHeaderLevel.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/setHeadingLevel.ts similarity index 56% rename from packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/setHeaderLevel.ts rename to packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/setHeadingLevel.ts index 3cb2e57f4ef..cbb797b2107 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/setHeaderLevel.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/setHeadingLevel.ts @@ -6,29 +6,29 @@ import { ContentModelSegmentFormat, } from 'roosterjs-content-model-types'; -type HeaderLevelTags = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; +type HeadingLevelTags = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; /** - * Set header level of selected paragraphs - * @param editor The editor to set header level to - * @param headerLevel Level of header, from 1 to 6. Set to 0 means set it back to a regular paragraph + * Set heading level of selected paragraphs + * @param editor The editor to set heading level to + * @param headingLevel Level of heading, from 1 to 6. Set to 0 means set it back to a regular paragraph */ -export default function setHeaderLevel( +export default function setHeadingLevel( editor: IContentModelEditor, - headerLevel: 0 | 1 | 2 | 3 | 4 | 5 | 6 + headingLevel: 0 | 1 | 2 | 3 | 4 | 5 | 6 ) { - formatParagraphWithContentModel(editor, 'setHeaderLevel', para => { + formatParagraphWithContentModel(editor, 'setHeadingLevel', para => { const tagName = - headerLevel > 0 - ? (('h' + headerLevel) as HeaderLevelTags | null) - : getExistingHeaderHeaderTag(para.decorator); - const headerStyle = + headingLevel > 0 + ? (('h' + headingLevel) as HeadingLevelTags | null) + : getExistingHeadingTag(para.decorator); + const headingStyle = (tagName && (defaultImplicitFormatMap[tagName] as ContentModelSegmentFormat)) || {}; - if (headerLevel > 0) { + if (headingLevel > 0) { para.decorator = { tagName: tagName!, - format: { ...headerStyle }, + format: { ...headingStyle }, }; // Remove existing formats since tags have default font size and weight @@ -42,11 +42,11 @@ export default function setHeaderLevel( }); } -function getExistingHeaderHeaderTag( +function getExistingHeadingTag( decorator?: ContentModelParagraphDecorator -): HeaderLevelTags | null { +): HeadingLevelTags | null { const tag = decorator?.tagName || ''; const level = parseInt(tag.substring(1)); - return level >= 1 && level <= 6 ? (tag as HeaderLevelTags) : null; + return level >= 1 && level <= 6 ? (tag as HeadingLevelTags) : null; } diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/retrieveModelFormatStateTest.ts b/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/retrieveModelFormatStateTest.ts index 2ad038367d5..9e700715aaa 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/retrieveModelFormatStateTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/retrieveModelFormatStateTest.ts @@ -139,7 +139,7 @@ describe('retrieveModelFormatState', () => { }); }); - it('Single selection with header', () => { + it('Single selection with heading', () => { const model = createContentModelDocument(); const result: ContentModelFormatState = {}; const para = createParagraph(false, undefined, undefined, { @@ -157,6 +157,7 @@ describe('retrieveModelFormatState', () => { expect(result).toEqual({ ...baseFormatResult, + headingLevel: 1, headerLevel: 1, isBlockQuote: false, isCodeInline: false, @@ -275,7 +276,7 @@ describe('retrieveModelFormatState', () => { }); }); - it('With table header', () => { + it('With table heading', () => { const model = createContentModelDocument(); const result: ContentModelFormatState = {}; const table = createTable(1); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setHeaderLevelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setHeadingLevelTest.ts similarity index 96% rename from packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setHeaderLevelTest.ts rename to packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setHeadingLevelTest.ts index e5f743c22ac..2d818e50f9f 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setHeaderLevelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setHeadingLevelTest.ts @@ -1,16 +1,16 @@ -import setHeaderLevel from '../../../lib/publicApi/block/setHeaderLevel'; +import setHeadingLevel from '../../../lib/publicApi/block/setHeadingLevel'; import { ContentModelDocument } from 'roosterjs-content-model-types'; import { paragraphTestCommon } from './paragraphTestCommon'; -describe('setHeaderLevel to 1', () => { +describe('setHeadingLevel to 1', () => { function runTest( model: ContentModelDocument, result: ContentModelDocument, calledTimes: number ) { paragraphTestCommon( - 'setHeaderLevel', - editor => setHeaderLevel(editor, 1), + 'setHeadingLevel', + editor => setHeadingLevel(editor, 1), model, result, calledTimes @@ -171,7 +171,7 @@ describe('setHeaderLevel to 1', () => { ); }); - it('With existing header', () => { + it('With existing heading', () => { runTest( { blockGroupType: 'Document', @@ -229,15 +229,15 @@ describe('setHeaderLevel to 1', () => { }); }); -describe('setHeaderLevel to 0', () => { +describe('setHeadingLevel to 0', () => { function runTest( model: ContentModelDocument, result: ContentModelDocument, calledTimes: number ) { paragraphTestCommon( - 'setHeaderLevel', - editor => setHeaderLevel(editor, 0), + 'setHeadingLevel', + editor => setHeadingLevel(editor, 0), model, result, calledTimes @@ -344,7 +344,7 @@ describe('setHeaderLevel to 0', () => { ); }); - it('With existing header', () => { + it('With existing heading', () => { runTest( { blockGroupType: 'Document', diff --git a/packages-content-model/roosterjs-content-model-types/lib/block/ContentModelParagraph.ts b/packages-content-model/roosterjs-content-model-types/lib/block/ContentModelParagraph.ts index 8966f66b649..8eb809ee954 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/block/ContentModelParagraph.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/block/ContentModelParagraph.ts @@ -21,7 +21,7 @@ export interface ContentModelParagraph segmentFormat?: ContentModelSegmentFormat; /** - * Header info for this paragraph if it is a header + * Decorator info for this paragraph, used by heading and P tags */ decorator?: ContentModelParagraphDecorator; diff --git a/packages-content-model/roosterjs-content-model-types/lib/decorator/ContentModelParagraphDecorator.ts b/packages-content-model/roosterjs-content-model-types/lib/decorator/ContentModelParagraphDecorator.ts index b90583ceef5..1f58635f03c 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/decorator/ContentModelParagraphDecorator.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/decorator/ContentModelParagraphDecorator.ts @@ -3,8 +3,8 @@ import { ContentModelWithFormat } from '../format/ContentModelWithFormat'; /** * Represent decorator for a paragraph in Content Model - * A decorator of paragraph can represent a header, or a P tag that act likes a paragraph but with some extra format info - * since header is also a kind of paragraph, with some extra information + * A decorator of paragraph can represent a heading, or a P tag that act likes a paragraph but with some extra format info + * since heading is also a kind of paragraph, with some extra information */ export interface ContentModelParagraphDecorator extends ContentModelWithFormat { diff --git a/packages-ui/roosterjs-react/lib/ribbon/component/buttons/header.ts b/packages-ui/roosterjs-react/lib/ribbon/component/buttons/header.ts deleted file mode 100644 index 896abba0621..00000000000 --- a/packages-ui/roosterjs-react/lib/ribbon/component/buttons/header.ts +++ /dev/null @@ -1,42 +0,0 @@ -import RibbonButton from '../../type/RibbonButton'; -import { getObjectKeys } from 'roosterjs-editor-dom'; -import { HeaderButtonStringKey } from '../../type/RibbonButtonStringKeys'; -import { toggleHeader } from 'roosterjs-editor-api'; - -const headers: Partial> = { - buttonNameHeader1: 'Header 1', - buttonNameHeader2: 'Header 2', - buttonNameHeader3: 'Header 3', - buttonNameHeader4: 'Header 4', - buttonNameHeader5: 'Header 5', - buttonNameHeader6: 'Header 6', - '-': '-', - buttonNameNoHeader: 'No header', -}; - -/** - * @internal - * "Header" button on the format ribbon - */ -export const header: RibbonButton = { - key: 'buttonNameHeader', - unlocalizedText: 'Header', - iconName: 'Header1', - dropDownMenu: { - items: headers, - getSelectedItemKey: formatState => { - return (formatState.headerLevel ?? 0) > 0 - ? 'header' + formatState.headerLevel - : 'noHeader'; - }, - }, - onClick: (editor, key) => { - const index = getObjectKeys(headers).indexOf(key) + 1; - - if (index > 6) { - toggleHeader(editor, 0); - } else if (index > 0) { - toggleHeader(editor, index); - } - }, -}; diff --git a/packages-ui/roosterjs-react/lib/ribbon/component/buttons/heading.ts b/packages-ui/roosterjs-react/lib/ribbon/component/buttons/heading.ts new file mode 100644 index 00000000000..e9b51df3b61 --- /dev/null +++ b/packages-ui/roosterjs-react/lib/ribbon/component/buttons/heading.ts @@ -0,0 +1,42 @@ +import RibbonButton from '../../type/RibbonButton'; +import { getObjectKeys } from 'roosterjs-editor-dom'; +import { HeadingButtonStringKey } from '../../type/RibbonButtonStringKeys'; +import { setHeadingLevel } from 'roosterjs-editor-api'; + +const headings: Partial> = { + buttonNameHeading1: 'Heading 1', + buttonNameHeading2: 'Heading 2', + buttonNameHeading3: 'Heading 3', + buttonNameHeading4: 'Heading 4', + buttonNameHeading5: 'Heading 5', + buttonNameHeading6: 'Heading 6', + '-': '-', + buttonNameNoHeading: 'No heading', +}; + +/** + * @internal + * "Heading" button on the format ribbon + */ +export const heading: RibbonButton = { + key: 'buttonNameHeading', + unlocalizedText: 'Heading', + iconName: 'Header1', + dropDownMenu: { + items: headings, + getSelectedItemKey: formatState => { + return (formatState.headingLevel ?? 0) > 0 + ? 'heading' + formatState.headingLevel + : 'noHeading'; + }, + }, + onClick: (editor, key) => { + const index = getObjectKeys(headings).indexOf(key) + 1; + + if (index > 6) { + setHeadingLevel(editor, 0); + } else if (index > 0) { + setHeadingLevel(editor, index); + } + }, +}; diff --git a/packages-ui/roosterjs-react/lib/ribbon/component/getButtons.ts b/packages-ui/roosterjs-react/lib/ribbon/component/getButtons.ts index 487b6905b5f..ecf922228a4 100644 --- a/packages-ui/roosterjs-react/lib/ribbon/component/getButtons.ts +++ b/packages-ui/roosterjs-react/lib/ribbon/component/getButtons.ts @@ -12,7 +12,7 @@ import { decreaseFontSize } from './buttons/decreaseFontSize'; import { decreaseIndent } from './buttons/decreaseIndent'; import { font } from './buttons/font'; import { fontSize } from './buttons/fontSize'; -import { header } from './buttons/header'; +import { heading } from './buttons/heading'; import { increaseFontSize } from './buttons/increaseFontSize'; import { increaseIndent } from './buttons/increaseIndent'; import { insertImage } from './buttons/insertImage'; @@ -58,7 +58,8 @@ const KnownRibbonButtons = getTagOfNode(cell) == 'TH') : undefined; + const headingLevel = (headingTag && parseInt(headingTag[1])) || 0; return { isBullet: listTag == 'UL', isNumbering: listTag == 'OL', isMultilineSelection: multiline, - headerLevel: (headerTag && parseInt(headerTag[1])) || 0, + headingLevel: headingLevel, + headerLevel: headingLevel, canUnlink: !!editor.queryElements('a[href]', QueryScope.OnSelection)[0], canAddImageAltText: !!editor.queryElements('img', QueryScope.OnSelection)[0], isBlockQuote: !!editor.queryElements('blockquote', QueryScope.OnSelection)[0], @@ -56,7 +58,7 @@ export function getElementBasedFormatState( isCodeBlock: !!editor.queryElements('pre>code', QueryScope.OnSelection)[0], isInTable: !!table, tableFormat: tableFormat || {}, - tableHasHeader: hasHeader, + tableHasHeader: hasTableHeader, canMergeTableCell: canMergeTableCell(editor), }; } @@ -67,7 +69,7 @@ export function getElementBasedFormatState( * bold, italic, underline, font name, font size, etc. * @param editor The editor instance * @param event (Optional) The plugin event, it stores the event cached data for looking up. - * In this function the event cache is used to get list state and header level. If not passed, + * In this function the event cache is used to get list state and heading level. If not passed, * it will query the node within selection to get the info * @returns The format state at cursor */ diff --git a/packages/roosterjs-editor-api/lib/format/toggleHeader.ts b/packages/roosterjs-editor-api/lib/format/setHeadingLevel.ts similarity index 74% rename from packages/roosterjs-editor-api/lib/format/toggleHeader.ts rename to packages/roosterjs-editor-api/lib/format/setHeadingLevel.ts index eb7070bb73d..174278ee849 100644 --- a/packages/roosterjs-editor-api/lib/format/toggleHeader.ts +++ b/packages/roosterjs-editor-api/lib/format/setHeadingLevel.ts @@ -1,50 +1,56 @@ -import formatUndoSnapshot from '../utils/formatUndoSnapshot'; -import { DocumentCommand, IEditor, QueryScope } from 'roosterjs-editor-types'; -import { HtmlSanitizer, moveChildNodes } from 'roosterjs-editor-dom'; - -/** - * Toggle header at selection - * @param editor The editor instance - * @param level The header level, can be a number from 0 to 6, in which 1 ~ 6 refers to - * the HTML header element <H1> to <H6>, 0 means no header - * if passed in param is outside the range, will be rounded to nearest number in the range - */ -export default function toggleHeader(editor: IEditor, level: number) { - level = Math.min(Math.max(Math.round(level), 0), 6); - - formatUndoSnapshot( - editor, - () => { - editor.focus(); - - let wrapped = false; - editor.queryElements('H1,H2,H3,H4,H5,H6', QueryScope.OnSelection, header => { - if (!wrapped) { - editor.getDocument().execCommand(DocumentCommand.FormatBlock, false, '
'); - wrapped = true; - } - - const div = editor.getDocument().createElement('div'); - moveChildNodes(div, header); - editor.replaceNode(header, div); - }); - - if (level > 0) { - let traverser = editor.getSelectionTraverser(); - let blockElement = traverser?.currentBlockElement; - let sanitizer = new HtmlSanitizer({ - cssStyleCallbacks: { - 'font-size': () => false, - }, - }); - while (blockElement) { - let element = blockElement.collapseToSingleElement(); - sanitizer.sanitize(element); - blockElement = traverser?.getNextBlockElement(); - } - editor.getDocument().execCommand(DocumentCommand.FormatBlock, false, ``); - } - }, - 'toggleHeader' - ); -} +import formatUndoSnapshot from '../utils/formatUndoSnapshot'; +import { DocumentCommand, IEditor, QueryScope } from 'roosterjs-editor-types'; +import { HtmlSanitizer, moveChildNodes } from 'roosterjs-editor-dom'; + +/** + * Set heading level at selection + * @param editor The editor instance + * @param level The heading level, can be a number from 0 to 6, in which 1 ~ 6 refers to + * the HTML heading element <H1> to <H6>, 0 means no heading + * if passed in param is outside the range, will be rounded to nearest number in the range + */ +export default function setHeadingLevel(editor: IEditor, level: number) { + level = Math.min(Math.max(Math.round(level), 0), 6); + + formatUndoSnapshot( + editor, + () => { + editor.focus(); + + let wrapped = false; + editor.queryElements('H1,H2,H3,H4,H5,H6', QueryScope.OnSelection, heading => { + if (!wrapped) { + editor.getDocument().execCommand(DocumentCommand.FormatBlock, false, '
'); + wrapped = true; + } + + const div = editor.getDocument().createElement('div'); + moveChildNodes(div, heading); + editor.replaceNode(heading, div); + }); + + if (level > 0) { + let traverser = editor.getSelectionTraverser(); + let blockElement = traverser?.currentBlockElement; + let sanitizer = new HtmlSanitizer({ + cssStyleCallbacks: { + 'font-size': () => false, + }, + }); + while (blockElement) { + let element = blockElement.collapseToSingleElement(); + sanitizer.sanitize(element); + blockElement = traverser?.getNextBlockElement(); + } + editor.getDocument().execCommand(DocumentCommand.FormatBlock, false, ``); + } + }, + 'toggleHeader' + ); +} + +/** + * @deprecated Use setHeadingLevel instead + * Keep this for compatibility only, will be removed in next major release + */ +export const toggleHeader = setHeadingLevel; diff --git a/packages/roosterjs-editor-api/lib/index.ts b/packages/roosterjs-editor-api/lib/index.ts index 5c39f16f71d..cec4c5e78be 100644 --- a/packages/roosterjs-editor-api/lib/index.ts +++ b/packages/roosterjs-editor-api/lib/index.ts @@ -31,7 +31,7 @@ export { default as toggleStrikethrough } from './format/toggleStrikethrough'; export { default as toggleSubscript } from './format/toggleSubscript'; export { default as toggleSuperscript } from './format/toggleSuperscript'; export { default as toggleUnderline } from './format/toggleUnderline'; -export { default as toggleHeader } from './format/toggleHeader'; +export { default as setHeadingLevel, toggleHeader } from './format/setHeadingLevel'; export { default as applyCellShading } from './table/applyCellShading'; export { default as toggleListType } from './utils/toggleListType'; diff --git a/packages/roosterjs-editor-plugins/lib/plugins/Paste/wordConverter/convertPastedContentFromWord.ts b/packages/roosterjs-editor-plugins/lib/plugins/Paste/wordConverter/convertPastedContentFromWord.ts index b9fa0a45e8c..f3b897faf07 100644 --- a/packages/roosterjs-editor-plugins/lib/plugins/Paste/wordConverter/convertPastedContentFromWord.ts +++ b/packages/roosterjs-editor-plugins/lib/plugins/Paste/wordConverter/convertPastedContentFromWord.ts @@ -26,7 +26,7 @@ export default function convertPastedContentFromWord(event: BeforePasteEvent) { let wordConverter = createWordConverter(); // First find all the nodes that we need to check for list item information - // This call will return all the p and header elements under the root node.. These are the elements that + // This call will return all the p and heading elements under the root node.. These are the elements that // Word uses a list items, so we'll only process them and avoid walking the whole tree. let elements = fragment.querySelectorAll(LIST_ELEMENTS_SELECTOR) as NodeListOf; if (elements.length > 0) { diff --git a/packages/roosterjs-editor-types/lib/interface/FormatState.ts b/packages/roosterjs-editor-types/lib/interface/FormatState.ts index 350e1c577d7..de2bd0bc932 100644 --- a/packages/roosterjs-editor-types/lib/interface/FormatState.ts +++ b/packages/roosterjs-editor-types/lib/interface/FormatState.ts @@ -85,7 +85,13 @@ export interface ElementBasedFormatState { canAddImageAltText?: boolean; /** - * Header level (0-6, 0 means no header) + * Heading level (0-6, 0 means no heading) + */ + headingLevel?: number; + + /** + * @deprecated Use headingLevel instead + * Heading level (0-6, 0 means no heading) */ headerLevel?: number; From 06736fa3a1059964d6d6c460008b4c1945f08364 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Mon, 14 Aug 2023 15:03:54 -0300 Subject: [PATCH 18/75] fix cell empty cells --- .../domToModel/processors/tableProcessor.ts | 11 ++ .../lib/modelToDom/handlers/handleTable.ts | 111 ++++++++---------- .../roosterjs-editor-dom/lib/table/VTable.ts | 11 ++ 3 files changed, 74 insertions(+), 59 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/tableProcessor.ts b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/tableProcessor.ts index 4000189240d..54ce2661852 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/tableProcessor.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/tableProcessor.ts @@ -235,6 +235,17 @@ export const tableProcessor: ElementProcessor = ( ); } }); + + for (let col = 0; col < tableRow.cells.length; col++) { + if (!tableRow.cells[col]) { + tableRow.cells[col] = createTableCell( + false, + false, + false, + context.blockFormat + ); + } + } } table.widths = calcSizes(columnPositions); diff --git a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleTable.ts b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleTable.ts index 318341d5a36..b8a4e64658c 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleTable.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleTable.ts @@ -75,79 +75,72 @@ export const handleTable: ContentModelBlockHandler = ( for (let col = 0; col < tableRow.cells.length; col++) { const cell = tableRow.cells[col]; - if (cell) { - if (cell.isSelected) { - context.tableSelection = context.tableSelection || { - table: tableNode, - firstCell: { x: col, y: row }, - lastCell: { x: col, y: row }, - }; - - if (context.tableSelection.table == tableNode) { - const lastCell = context.tableSelection.lastCell; - - lastCell.x = Math.max(lastCell.x, col); - lastCell.y = Math.max(lastCell.y, row); - } + if (cell.isSelected) { + context.tableSelection = context.tableSelection || { + table: tableNode, + firstCell: { x: col, y: row }, + lastCell: { x: col, y: row }, + }; + + if (context.tableSelection.table == tableNode) { + const lastCell = context.tableSelection.lastCell; + + lastCell.x = Math.max(lastCell.x, col); + lastCell.y = Math.max(lastCell.y, row); } + } - if (!cell.spanAbove && !cell.spanLeft) { - let td = - (context.allowCacheElement && cell.cachedElement) || - doc.createElement(cell.isHeader ? 'th' : 'td'); - - tr.appendChild(td); + if (!cell.spanAbove && !cell.spanLeft) { + let td = + (context.allowCacheElement && cell.cachedElement) || + doc.createElement(cell.isHeader ? 'th' : 'td'); - let rowSpan = 1; - let colSpan = 1; - let width = table.widths[col]; - let height = tableRow.height; + tr.appendChild(td); - for (; table.rows[row + rowSpan]?.cells[col]?.spanAbove; rowSpan++) { - height += table.rows[row + rowSpan].height; - } - for (; tableRow.cells[col + colSpan]?.spanLeft; colSpan++) { - width += table.widths[col + colSpan]; - } + let rowSpan = 1; + let colSpan = 1; + let width = table.widths[col]; + let height = tableRow.height; - if (rowSpan > 1) { - td.rowSpan = rowSpan; - } + for (; table.rows[row + rowSpan]?.cells[col]?.spanAbove; rowSpan++) { + height += table.rows[row + rowSpan].height; + } + for (; tableRow.cells[col + colSpan]?.spanLeft; colSpan++) { + width += table.widths[col + colSpan]; + } - if (colSpan > 1) { - td.colSpan = colSpan; - } + if (rowSpan > 1) { + td.rowSpan = rowSpan; + } - if (!cell.cachedElement || (cell.format.useBorderBox && hasMetadata(table))) { - if (width > 0 && !td.style.width) { - td.style.width = width + 'px'; - } + if (colSpan > 1) { + td.colSpan = colSpan; + } - if (height > 0 && !td.style.height) { - td.style.height = height + 'px'; - } + if (!cell.cachedElement || (cell.format.useBorderBox && hasMetadata(table))) { + if (width > 0 && !td.style.width) { + td.style.width = width + 'px'; } - if (!cell.cachedElement) { - if (context.allowCacheElement) { - cell.cachedElement = td; - } - - applyFormat(td, context.formatAppliers.block, cell.format, context); - applyFormat(td, context.formatAppliers.tableCell, cell.format, context); - applyFormat( - td, - context.formatAppliers.tableCellBorder, - cell.format, - context - ); - applyFormat(td, context.formatAppliers.dataset, cell.dataset, context); + if (height > 0 && !td.style.height) { + td.style.height = height + 'px'; } + } - context.modelHandlers.blockGroupChildren(doc, td, cell, context); + if (!cell.cachedElement) { + if (context.allowCacheElement) { + cell.cachedElement = td; + } - context.onNodeCreated?.(cell, td); + applyFormat(td, context.formatAppliers.block, cell.format, context); + applyFormat(td, context.formatAppliers.tableCell, cell.format, context); + applyFormat(td, context.formatAppliers.tableCellBorder, cell.format, context); + applyFormat(td, context.formatAppliers.dataset, cell.dataset, context); } + + context.modelHandlers.blockGroupChildren(doc, td, cell, context); + + context.onNodeCreated?.(cell, td); } } } diff --git a/packages/roosterjs-editor-dom/lib/table/VTable.ts b/packages/roosterjs-editor-dom/lib/table/VTable.ts index 7ec1f85be17..5fe335aedce 100644 --- a/packages/roosterjs-editor-dom/lib/table/VTable.ts +++ b/packages/roosterjs-editor-dom/lib/table/VTable.ts @@ -111,6 +111,17 @@ export default class VTable { } } } + for (let col = 0; col < this.cells![rowIndex].length; col++) { + if (!this.cells![rowIndex][col]) { + this.cells![rowIndex][col] = { + td: null, + spanLeft: false, + spanAbove: false, + width: undefined, + height: undefined, + }; + } + } }); this.formatInfo = getTableFormatInfo(this.table); if (normalizeSize) { From 6d610cd2d552aadfe17c9f913ebc6b52376d2450 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Mon, 14 Aug 2023 17:15:29 -0300 Subject: [PATCH 19/75] fix flipped image --- .../lib/plugins/ImageEdit/ImageEdit.ts | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/ImageEdit.ts b/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/ImageEdit.ts index b9e8f516e2e..7173d0e0f49 100644 --- a/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/ImageEdit.ts +++ b/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/ImageEdit.ts @@ -432,11 +432,6 @@ export default class ImageEdit implements EditorPlugin { // Set image src to original src to help show editing UI, also it will be used when regenerate image dataURL after editing if (this.clonedImage) { this.clonedImage.src = this.pngSource ?? this.editInfo.src; - setFlipped( - this.clonedImage, - this.editInfo.flippedHorizontal, - this.editInfo.flippedVertical - ); this.clonedImage.style.position = 'absolute'; } @@ -525,6 +520,8 @@ export default class ImageEdit implements EditorPlugin { leftPercent, rightPercent, topPercent, + flippedHorizontal, + flippedVertical, } = this.editInfo; // Width/height of the image @@ -557,6 +554,9 @@ export default class ImageEdit implements EditorPlugin { this.clonedImage.style.width = getPx(originalWidth); this.clonedImage.style.height = getPx(originalHeight); + //Update flip direction + setFlipped(this.clonedImage.parentElement, flippedHorizontal, flippedVertical); + if (this.isCropping) { // For crop, we also need to set position of the overlays setSize( @@ -777,11 +777,13 @@ function getColorString(color: string | ModeIndependentColor, isDarkMode: boolea } function setFlipped( - element: HTMLImageElement, + element: HTMLElement | null, flippedHorizontally?: boolean, flippedVertically?: boolean ) { - element.style.transform = `scale(${flippedHorizontally ? '-1' : '1'}, ${ - flippedVertically ? '-1' : '1' - })`; + if (element) { + element.style.transform = `scale(${flippedHorizontally ? -1 : 1}, ${ + flippedVertically ? -1 : 1 + })`; + } } From a2b98040ee61c51bd079c51f4b55dc18055f9f39 Mon Sep 17 00:00:00 2001 From: Bryan Valverde U Date: Tue, 15 Aug 2023 09:31:32 -0600 Subject: [PATCH 20/75] Update logic to decide if we need to merge a table on paste. (#2022) * Fix TableSelectionCopy * update unit tests * Fix 2 * address comment --- .../lib/publicApi/utils/paste.ts | 61 +++-- .../ContentModelCopyPastePluginTest.ts | 13 +- .../test/publicApi/utils/pasteTest.ts | 228 ++++++++++++++++++ 3 files changed, 286 insertions(+), 16 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/paste.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/paste.ts index 51037e98421..bd8b7d9b448 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/paste.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/paste.ts @@ -1,9 +1,14 @@ -import { ContentModelBlockFormat, FormatParser } from 'roosterjs-content-model-types'; import { domToContentModel } from 'roosterjs-content-model-dom'; import { formatWithContentModel } from './formatWithContentModel'; +import { FormatWithContentModelContext } from '../../publicTypes/parameter/FormatWithContentModelContext'; import { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; import { mergeModel } from '../../modelApi/common/mergeModel'; import { NodePosition } from 'roosterjs-editor-types'; +import { + ContentModelBlockFormat, + ContentModelDocument, + FormatParser, +} from 'roosterjs-content-model-types'; import ContentModelBeforePasteEvent, { ContentModelBeforePasteEventData, } from '../../publicTypes/event/ContentModelBeforePasteEvent'; @@ -84,19 +89,8 @@ export default function paste( formatWithContentModel( editor, 'Paste', - (model, context) => { - if (customizedMerge) { - customizedMerge(model, pasteModel); - } else { - mergeModel(model, pasteModel, context, { - mergeFormat: applyCurrentFormat ? 'keepSourceEmphasisFormat' : 'none', - mergeTable: - pasteModel.blocks.length === 1 && - pasteModel.blocks[0].blockType === 'Table', - }); - } - return true; - }, + (model, context) => + mergePasteContent(model, context, pasteModel, applyCurrentFormat, customizedMerge), { changeSource: ChangeSource.Paste, getChangeData: () => clipboardData, @@ -105,6 +99,45 @@ export default function paste( } } +/** + * @internal + * Export only for unit test + */ +export function mergePasteContent( + model: ContentModelDocument, + context: FormatWithContentModelContext, + pasteModel: ContentModelDocument, + applyCurrentFormat: boolean, + customizedMerge: + | undefined + | ((source: ContentModelDocument, target: ContentModelDocument) => void) +): boolean { + if (customizedMerge) { + customizedMerge(model, pasteModel); + } else { + mergeModel(model, pasteModel, context, { + mergeFormat: applyCurrentFormat ? 'keepSourceEmphasisFormat' : 'none', + mergeTable: shouldMergeTable(pasteModel), + }); + } + return true; +} + +function shouldMergeTable(pasteModel: ContentModelDocument): boolean | undefined { + // If model contains a table and a paragraph element after the table with a single BR segment, remove the Paragraph after the table + if ( + pasteModel.blocks.length == 2 && + pasteModel.blocks[0].blockType === 'Table' && + pasteModel.blocks[1].blockType === 'Paragraph' && + pasteModel.blocks[1].segments.length === 1 && + pasteModel.blocks[1].segments[0].segmentType === 'Br' + ) { + pasteModel.blocks.splice(1); + } + // Only merge table when the document contain a single table. + return pasteModel.blocks.length === 1 && pasteModel.blocks[0].blockType === 'Table'; +} + function createBeforePasteEventData( editor: IContentModelEditor, clipboardData: ClipboardData, diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCopyPastePluginTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCopyPastePluginTest.ts index 0ea13d4a601..ca3e49f2632 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCopyPastePluginTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCopyPastePluginTest.ts @@ -1,5 +1,6 @@ import * as cloneModelFile from '../../../lib/modelApi/common/cloneModel'; import * as contentModelToDomFile from 'roosterjs-content-model-dom/lib/modelToDom/contentModelToDom'; +import * as createRangeF from 'roosterjs-editor-dom/lib/selection/createRange'; import * as deleteSelectionsFile from '../../../lib/modelApi/edit/deleteSelection'; import * as extractClipboardItemsFile from 'roosterjs-editor-dom/lib/clipboard/extractClipboardItems'; import * as iterateSelectionsFile from '../../../lib/modelApi/selection/iterateSelections'; @@ -199,7 +200,7 @@ describe('ContentModelCopyPastePlugin |', () => { it('Selection not Collapsed and table selection', () => { // Arrange const table = document.createElement('table'); - table.id = 'image'; + table.id = 'table'; // Arrange selectionRangeExValue = { type: SelectionRangeTypes.TableSelection, @@ -209,6 +210,7 @@ describe('ContentModelCopyPastePlugin |', () => { table, }; + spyOn(createRangeF, 'default').and.callThrough(); spyOn(deleteSelectionsFile, 'deleteSelection'); spyOn(contentModelToDomFile, 'contentModelToDom').and.callFake(() => { const container = document.createElement('div'); @@ -228,6 +230,7 @@ describe('ContentModelCopyPastePlugin |', () => { domEvents.copy?.({}); // Assert + expect(createRangeF.default).toHaveBeenCalledWith(table.parentElement); expect(getSelectionRangeEx).toHaveBeenCalled(); expect(deleteSelectionsFile.deleteSelection).not.toHaveBeenCalled(); expect(contentModelToDomFile.contentModelToDom).toHaveBeenCalledWith( @@ -264,6 +267,7 @@ describe('ContentModelCopyPastePlugin |', () => { image, }; + spyOn(createRangeF, 'default').and.callThrough(); spyOn(deleteSelectionsFile, 'deleteSelection'); spyOn(contentModelToDomFile, 'contentModelToDom').and.callFake(() => { div.appendChild(image); @@ -280,6 +284,7 @@ describe('ContentModelCopyPastePlugin |', () => { domEvents.copy?.({}); // Assert + expect(createRangeF.default).toHaveBeenCalledWith(image); expect(getSelectionRangeEx).toHaveBeenCalled(); expect(deleteSelectionsFile.deleteSelection).not.toHaveBeenCalled(); expect(contentModelToDomFile.contentModelToDom).toHaveBeenCalledWith( @@ -394,7 +399,7 @@ describe('ContentModelCopyPastePlugin |', () => { it('Selection not Collapsed and table selection', () => { // Arrange const table = document.createElement('table'); - table.id = 'image'; + table.id = 'table'; selectionRangeExValue = { type: SelectionRangeTypes.TableSelection, ranges: [new Range()], @@ -403,6 +408,7 @@ describe('ContentModelCopyPastePlugin |', () => { table, }; + spyOn(createRangeF, 'default').and.callThrough(); spyOn(deleteSelectionsFile, 'deleteSelection'); spyOn(contentModelToDomFile, 'contentModelToDom').and.callFake(() => { const container = document.createElement('div'); @@ -422,6 +428,7 @@ describe('ContentModelCopyPastePlugin |', () => { domEvents.cut?.({}); // Assert + expect(createRangeF.default).toHaveBeenCalledWith(table.parentElement); expect(getSelectionRangeEx).toHaveBeenCalled(); expect(contentModelToDomFile.contentModelToDom).toHaveBeenCalledWith( document, @@ -460,6 +467,7 @@ describe('ContentModelCopyPastePlugin |', () => { image, }; + spyOn(createRangeF, 'default').and.callThrough(); spyOn(deleteSelectionsFile, 'deleteSelection'); spyOn(contentModelToDomFile, 'contentModelToDom').and.callFake(() => { div.appendChild(image); @@ -476,6 +484,7 @@ describe('ContentModelCopyPastePlugin |', () => { domEvents.cut?.({}); // Assert + expect(createRangeF.default).toHaveBeenCalledWith(image); expect(getSelectionRangeEx).toHaveBeenCalled(); expect(contentModelToDomFile.contentModelToDom).toHaveBeenCalledWith( document, diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts index 99ace99056c..4e23cfb9969 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts @@ -12,6 +12,7 @@ import ContentModelEditor from '../../../lib/editor/ContentModelEditor'; import ContentModelPastePlugin from '../../../lib/editor/plugins/PastePlugin/ContentModelPastePlugin'; import { ClipboardData, KnownPasteSourceType, PasteType } from 'roosterjs-editor-types'; import { ContentModelDocument } from 'roosterjs-content-model-types'; +import { createContentModelDocument } from 'roosterjs-content-model-dom'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; let clipboardData: ClipboardData; @@ -277,3 +278,230 @@ describe('paste with content model & paste plugin', () => { expect(PPT.processPastedContentFromPowerPoint).toHaveBeenCalledTimes(0); }); }); + +describe('mergePasteContent', () => { + it('merge table', () => { + // A doc with only one table in content + const pasteModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + rows: [ + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'FromPaste', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: { useBorderBox: true, borderCollapse: true }, + widths: [], + dataset: { + editingInfo: '', + }, + }, + ], + }; + + // A doc with a table, and selection marker inside of it. + const sourceModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + rows: [ + { + height: 22, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { segmentType: 'Br', format: {} }, + ], + format: {}, + isImplicit: true, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: { useBorderBox: true, borderCollapse: true }, + widths: [120, 120], + dataset: { + editingInfo: '', + }, + }, + { + blockType: 'Paragraph', + segments: [{ segmentType: 'Br', format: {} }], + format: {}, + }, + ], + format: {}, + }; + + spyOn(mergeModelFile, 'mergeModel').and.callThrough(); + + pasteF.mergePasteContent( + sourceModel, + { deletedEntities: [] }, + pasteModel, + false /* applyCurrentFormat */, + undefined /* customizedMerge */ + ); + + expect(mergeModelFile.mergeModel).toHaveBeenCalledWith( + sourceModel, + pasteModel, + { deletedEntities: [] }, + { + mergeFormat: 'none', + mergeTable: true, + } + ); + expect(sourceModel).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + rows: [ + { + height: 22, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'FromPaste', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + format: { + useBorderBox: true, + borderTop: '1px solid #ABABAB', + borderRight: '1px solid #ABABAB', + borderBottom: '1px solid #ABABAB', + borderLeft: '1px solid #ABABAB', + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: { useBorderBox: true, borderCollapse: true }, + widths: [120, 120], + 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":null}', + }, + }, + { + blockType: 'Paragraph', + segments: [{ segmentType: 'Br', format: {} }], + format: {}, + }, + ], + format: {}, + }); + }); + + it('customized merge', () => { + const pasteModel: ContentModelDocument = createContentModelDocument(); + const sourceModel: ContentModelDocument = createContentModelDocument(); + + const customizedMerge = jasmine.createSpy('customizedMerge'); + + spyOn(mergeModelFile, 'mergeModel').and.callThrough(); + + pasteF.mergePasteContent( + sourceModel, + { deletedEntities: [] }, + pasteModel, + false /* applyCurrentFormat */, + customizedMerge /* customizedMerge */ + ); + + expect(mergeModelFile.mergeModel).not.toHaveBeenCalled(); + expect(customizedMerge).toHaveBeenCalledWith(sourceModel, pasteModel); + }); + + it('Apply current format', () => { + const pasteModel: ContentModelDocument = createContentModelDocument(); + const sourceModel: ContentModelDocument = createContentModelDocument(); + + spyOn(mergeModelFile, 'mergeModel').and.callThrough(); + + pasteF.mergePasteContent( + sourceModel, + { deletedEntities: [] }, + pasteModel, + true /* applyCurrentFormat */, + undefined /* customizedMerge */ + ); + + expect(mergeModelFile.mergeModel).toHaveBeenCalledWith( + sourceModel, + pasteModel, + { deletedEntities: [] }, + { + mergeFormat: 'keepSourceEmphasisFormat', + mergeTable: false, + } + ); + }); +}); From ce15217097ced5acec5979c13c5232f348ebedd1 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Tue, 15 Aug 2023 12:17:00 -0700 Subject: [PATCH 21/75] Content Model: Rename a test file (#2029) --- .../test/publicApi/segment/{toggleCode.ts => toggleCodeTest.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/{toggleCode.ts => toggleCodeTest.ts} (100%) diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/toggleCode.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/toggleCodeTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/toggleCode.ts rename to packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/toggleCodeTest.ts From 560db30b12a0c22770e4130623db3cfe67ceb1b8 Mon Sep 17 00:00:00 2001 From: Andres-CT98 <107568016+Andres-CT98@users.noreply.github.com> Date: Tue, 15 Aug 2023 13:48:08 -0600 Subject: [PATCH 22/75] Fix Triple clicking a single cell selecting more than one (#2024) * fix triple click, optimisation --- .../mouseUtils/handleMouseDownEvent.ts | 98 ++++++++++--------- 1 file changed, 51 insertions(+), 47 deletions(-) diff --git a/packages/roosterjs-editor-plugins/lib/plugins/TableCellSelection/mouseUtils/handleMouseDownEvent.ts b/packages/roosterjs-editor-plugins/lib/plugins/TableCellSelection/mouseUtils/handleMouseDownEvent.ts index b68a93cfd92..26d97110906 100644 --- a/packages/roosterjs-editor-plugins/lib/plugins/TableCellSelection/mouseUtils/handleMouseDownEvent.ts +++ b/packages/roosterjs-editor-plugins/lib/plugins/TableCellSelection/mouseUtils/handleMouseDownEvent.ts @@ -24,8 +24,9 @@ export function handleMouseDownEvent( state: TableCellSelectionState, editor: IEditor ) { - const { which, shiftKey, target } = event.rawEvent; + const { which, shiftKey, target, detail } = event.rawEvent; const table = editor.getElementAtCursor('table', target as Node, event); + const tripleClick = detail >= 3; if (table && !table.isContentEditable) { return; @@ -58,60 +59,63 @@ export function handleMouseDownEvent( } } } - if (which == LEFT_CLICK && !shiftKey) { - clearState(state, editor); + if (which == LEFT_CLICK) { + if (!shiftKey && !tripleClick) { + clearState(state, editor); - if (getTableAtCursor(editor, event.rawEvent.target)) { - const doc = editor.getDocument() || document; + if (getTableAtCursor(editor, event.rawEvent.target)) { + const doc = editor.getDocument() || document; - const mouseUpListener = getOnMouseUp(state); - const mouseMoveListener = onMouseMove(state, editor); - doc.addEventListener('mouseup', mouseUpListener, true /*setCapture*/); - doc.addEventListener('mousemove', mouseMoveListener, true /*setCapture*/); + const mouseUpListener = getOnMouseUp(state); + const mouseMoveListener = onMouseMove(state, editor); + doc.addEventListener('mouseup', mouseUpListener, true /*setCapture*/); + doc.addEventListener('mousemove', mouseMoveListener, true /*setCapture*/); - state.mouseMoveDisposer = () => { - doc.removeEventListener('mouseup', mouseUpListener, true /*setCapture*/); - doc.removeEventListener('mousemove', mouseMoveListener, true /*setCapture*/); - }; + state.mouseMoveDisposer = () => { + doc.removeEventListener('mouseup', mouseUpListener, true /*setCapture*/); + doc.removeEventListener('mousemove', mouseMoveListener, true /*setCapture*/); + }; - state.startedSelection = true; + state.startedSelection = true; + } } - } - if (which == LEFT_CLICK && shiftKey) { - editor.runAsync(editor => { - const sel = editor.getDocument().defaultView?.getSelection(); - const first = getCellAtCursor(editor, sel?.anchorNode); - const last = getCellAtCursor(editor, sel?.focusNode); - const firstTable = getTableAtCursor(editor, first); - const targetTable = getTableAtCursor(editor, first); - if ( - firstTable! == targetTable! && - safeInstanceOf(first, 'HTMLTableCellElement') && - safeInstanceOf(last, 'HTMLTableCellElement') - ) { - state.vTable = new VTable(first); - const firstCord = getCellCoordinates(state.vTable, first); - const lastCord = getCellCoordinates(state.vTable, last); + if (shiftKey || tripleClick) { + editor.runAsync(editor => { + const sel = editor.getDocument().defaultView?.getSelection(); + const first = getCellAtCursor(editor, sel?.anchorNode); + // Triple clicking a cell will select that cell only + // Assign last the same as first to make sure we can select the cell + const last = tripleClick ? first : getCellAtCursor(editor, sel?.focusNode); + const firstTable = getTableAtCursor(editor, first); + if ( + firstTable && + safeInstanceOf(first, 'HTMLTableCellElement') && + safeInstanceOf(last, 'HTMLTableCellElement') + ) { + state.vTable = new VTable(first); + const firstCord = getCellCoordinates(state.vTable, first); + const lastCord = getCellCoordinates(state.vTable, last); + + if (!firstCord || !lastCord) { + return; + } + state.vTable.selection = { + firstCell: firstCord, + lastCell: lastCord, + }; + + state.firstTarget = first; + state.lastTarget = last; + selectTable(editor, state); - if (!firstCord || !lastCord) { - return; + state.tableSelection = true; + state.firstTable = firstTable as HTMLTableElement; + state.targetTable = firstTable; + updateSelection(editor, first, 0); } - state.vTable.selection = { - firstCell: firstCord, - lastCell: lastCord, - }; - - state.firstTarget = first; - state.lastTarget = last; - selectTable(editor, state); - - state.tableSelection = true; - state.firstTable = firstTable as HTMLTableElement; - state.targetTable = targetTable; - updateSelection(editor, first, 0); - } - }); + }); + } } } From 8c7fa2b39621bc1e23f50847349a25dbbc4795db Mon Sep 17 00:00:00 2001 From: Bryan Valverde U Date: Wed, 16 Aug 2023 16:13:57 -0600 Subject: [PATCH 23/75] Remove `display: flex` style on paste (#2031) * init * Fix --- .../PastePlugin/ContentModelPastePlugin.ts | 10 +- .../plugins/ContentModelPastePluginTest.ts | 143 +++++++++++++++++- .../lib/plugins/Paste/Paste.ts | 11 +- 3 files changed, 155 insertions(+), 9 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/ContentModelPastePlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/ContentModelPastePlugin.ts index 7360aed2410..e5b708e1152 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/ContentModelPastePlugin.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/ContentModelPastePlugin.ts @@ -1,6 +1,6 @@ import addParser from './utils/addParser'; import ContentModelBeforePasteEvent from '../../../publicTypes/event/ContentModelBeforePasteEvent'; -import { getPasteSource } from 'roosterjs-editor-dom'; +import { chainSanitizerCallback, getPasteSource } from 'roosterjs-editor-dom'; import { IContentModelEditor } from '../../../publicTypes/IContentModelEditor'; import { parseDeprecatedColor } from './utils/deprecatedColorParser'; import { parseLink } from './utils/linkParser'; @@ -10,6 +10,7 @@ import { processPastedContentFromWordDesktop } from './WordDesktop/processPasted import { processPastedContentWacComponents } from './WacComponents/processPastedContentWacComponents'; import { EditorPlugin, + HtmlSanitizerOptions, IEditor, KnownPasteSourceType, PasteType, @@ -106,7 +107,14 @@ export default class ContentModelPastePlugin implements EditorPlugin { addParser(ev.domToModelOption, 'link', parseLink); parseDeprecatedColor(ev.sanitizingOption); + sanitizeBlockStyles(ev.sanitizingOption); event.sanitizingOption.unknownTagReplacement = this.unknownTagReplacement; } } + +function sanitizeBlockStyles(sanitizingOption: Required) { + chainSanitizerCallback(sanitizingOption.cssStyleCallbacks, 'display', (value: string) => { + return value != 'flex'; // return whether we keep the style + }); +} diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelPastePluginTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelPastePluginTest.ts index db69a5b2e82..24e371f8210 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelPastePluginTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelPastePluginTest.ts @@ -1,16 +1,29 @@ import * as addParser from '../../../lib/editor/plugins/PastePlugin/utils/addParser'; +import * as chainSanitizerCallbackFile from 'roosterjs-editor-dom/lib/htmlSanitizer/chainSanitizerCallback'; +import * as ExcelFile from '../../../lib/editor/plugins/PastePlugin/Excel/processPastedContentFromExcel'; import * as getPasteSource from 'roosterjs-editor-dom/lib/pasteSourceValidations/getPasteSource'; +import * as PowerPointFile from '../../../lib/editor/plugins/PastePlugin/PowerPoint/processPastedContentFromPowerPoint'; +import * as setProcessor from '../../../lib/editor/plugins/PastePlugin/utils/setProcessor'; +import * as WacFile from '../../../lib/editor/plugins/PastePlugin/WacComponents/processPastedContentWacComponents'; import * as WordDesktopFile from '../../../lib/editor/plugins/PastePlugin/WordDesktop/processPastedContentFromWordDesktop'; import ContentModelBeforePasteEvent from '../../../lib/publicTypes/event/ContentModelBeforePasteEvent'; import ContentModelPastePlugin from '../../../lib/editor/plugins/PastePlugin/ContentModelPastePlugin'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; -import { KnownPasteSourceType, PluginEventType } from 'roosterjs-editor-types'; +import { KnownPasteSourceType, PasteType, PluginEventType } from 'roosterjs-editor-types'; + +const trustedHTMLHandler = 'mock'; +const GOOGLE_SHEET_NODE_NAME = 'google-sheets-html-origin'; describe('Paste', () => { let editor: IContentModelEditor; beforeEach(() => { - editor = ({} as any) as IContentModelEditor; + editor = ({ + getTrustedHTMLHandler: () => trustedHTMLHandler, + } as any) as IContentModelEditor; + spyOn(addParser, 'default').and.callThrough(); + spyOn(chainSanitizerCallbackFile, 'default').and.callThrough(); + spyOn(setProcessor, 'setProcessor').and.callThrough(); }); let event: ContentModelBeforePasteEvent = ({ @@ -57,6 +70,11 @@ describe('Paste', () => { preserveHtmlComments: false, unknownTagReplacement: null, }, + pasteType: PasteType.Normal, + clipboardData: { + html: '', + }, + fragment: document.createDocumentFragment(), }); }); @@ -71,18 +89,131 @@ describe('Paste', () => { expect(event.domToModelOption.processorOverride?.element).toBe( WordDesktopFile.wordDesktopElementProcessor ); + expect(addParser.default).toHaveBeenCalledTimes(4); + expect(chainSanitizerCallbackFile.default).toHaveBeenCalledTimes(5); + expect(setProcessor.setProcessor).toHaveBeenCalledTimes(1); + }); + + it('Excel | merge format', () => { + spyOn(getPasteSource, 'default').and.returnValue(KnownPasteSourceType.ExcelDesktop); + spyOn(ExcelFile, 'processPastedContentFromExcel').and.callThrough(); + + (event).pasteType = PasteType.MergeFormat; + plugin.initialize(editor); + plugin.onPluginEvent(event); + + expect(ExcelFile.processPastedContentFromExcel).toHaveBeenCalledWith( + event, + trustedHTMLHandler + ); + expect(addParser.default).toHaveBeenCalledTimes(2); + expect(setProcessor.setProcessor).toHaveBeenCalledTimes(0); + expect(chainSanitizerCallbackFile.default).toHaveBeenCalledTimes(3); + }); + + it('Excel | image', () => { + spyOn(getPasteSource, 'default').and.returnValue(KnownPasteSourceType.ExcelDesktop); + spyOn(ExcelFile, 'processPastedContentFromExcel').and.callThrough(); + + (event).pasteType = PasteType.AsImage; + plugin.initialize(editor); + plugin.onPluginEvent(event); + + expect(ExcelFile.processPastedContentFromExcel).not.toHaveBeenCalledWith( + event, + trustedHTMLHandler + ); + expect(addParser.default).toHaveBeenCalledTimes(1); + expect(chainSanitizerCallbackFile.default).toHaveBeenCalledTimes(3); + expect(setProcessor.setProcessor).toHaveBeenCalledTimes(0); + }); + + it('Excel', () => { + spyOn(getPasteSource, 'default').and.returnValue(KnownPasteSourceType.ExcelDesktop); + spyOn(ExcelFile, 'processPastedContentFromExcel').and.callThrough(); + + plugin.initialize(editor); + plugin.onPluginEvent(event); + + expect(ExcelFile.processPastedContentFromExcel).toHaveBeenCalledWith( + event, + trustedHTMLHandler + ); + expect(addParser.default).toHaveBeenCalledTimes(2); + expect(setProcessor.setProcessor).toHaveBeenCalledTimes(0); + expect(chainSanitizerCallbackFile.default).toHaveBeenCalledTimes(3); + }); + + it('Excel Online', () => { + spyOn(getPasteSource, 'default').and.returnValue(KnownPasteSourceType.ExcelOnline); + spyOn(ExcelFile, 'processPastedContentFromExcel').and.callThrough(); + + plugin.initialize(editor); + plugin.onPluginEvent(event); + + expect(ExcelFile.processPastedContentFromExcel).toHaveBeenCalledWith( + event, + trustedHTMLHandler + ); + expect(addParser.default).toHaveBeenCalledTimes(2); + expect(setProcessor.setProcessor).toHaveBeenCalledTimes(0); + expect(chainSanitizerCallbackFile.default).toHaveBeenCalledTimes(3); + }); + + it('Power Point', () => { + spyOn(getPasteSource, 'default').and.returnValue( + KnownPasteSourceType.PowerPointDesktop + ); + spyOn(PowerPointFile, 'processPastedContentFromPowerPoint').and.callThrough(); + + plugin.initialize(editor); + plugin.onPluginEvent(event); + + expect(PowerPointFile.processPastedContentFromPowerPoint).toHaveBeenCalledWith( + event, + trustedHTMLHandler + ); + expect(addParser.default).toHaveBeenCalledTimes(1); + expect(setProcessor.setProcessor).toHaveBeenCalledTimes(0); + expect(chainSanitizerCallbackFile.default).toHaveBeenCalledTimes(3); + }); + + it('Wac', () => { + spyOn(getPasteSource, 'default').and.returnValue(KnownPasteSourceType.WacComponents); + spyOn(WacFile, 'processPastedContentWacComponents').and.callThrough(); + + plugin.initialize(editor); + plugin.onPluginEvent(event); + + expect(WacFile.processPastedContentWacComponents).toHaveBeenCalledWith(event); + expect(addParser.default).toHaveBeenCalledTimes(5); + expect(setProcessor.setProcessor).toHaveBeenCalledTimes(4); + expect(chainSanitizerCallbackFile.default).toHaveBeenCalledTimes(3); }); it('Default', () => { spyOn(getPasteSource, 'default').and.returnValue(KnownPasteSourceType.Default); - spyOn(WordDesktopFile, 'processPastedContentFromWordDesktop'); - spyOn(addParser, 'default').and.callThrough(); plugin.initialize(editor); plugin.onPluginEvent(event); - expect(WordDesktopFile.processPastedContentFromWordDesktop).not.toHaveBeenCalled(); - expect(event.domToModelOption.processorOverride?.element).toBeUndefined(); + expect(addParser.default).toHaveBeenCalledTimes(1); + expect(setProcessor.setProcessor).toHaveBeenCalledTimes(0); + expect(chainSanitizerCallbackFile.default).toHaveBeenCalledTimes(3); + }); + + it('Google Sheets', () => { + spyOn(getPasteSource, 'default').and.returnValue(KnownPasteSourceType.GoogleSheets); + + plugin.initialize(editor); + plugin.onPluginEvent(event); + + expect(addParser.default).toHaveBeenCalledTimes(1); + expect(setProcessor.setProcessor).toHaveBeenCalledTimes(0); + expect(chainSanitizerCallbackFile.default).toHaveBeenCalledTimes(3); + expect( + event.sanitizingOption.additionalTagReplacements[GOOGLE_SHEET_NODE_NAME] + ).toEqual('*'); }); }); }); diff --git a/packages/roosterjs-editor-plugins/lib/plugins/Paste/Paste.ts b/packages/roosterjs-editor-plugins/lib/plugins/Paste/Paste.ts index 4e14e79a39d..bbfdb8faabf 100644 --- a/packages/roosterjs-editor-plugins/lib/plugins/Paste/Paste.ts +++ b/packages/roosterjs-editor-plugins/lib/plugins/Paste/Paste.ts @@ -7,8 +7,8 @@ import convertPastedContentFromWord from './wordConverter/convertPastedContentFr import handleLineMerge from './lineMerge/handleLineMerge'; import sanitizeHtmlColorsFromPastedContent from './sanitizeHtmlColorsFromPastedContent/sanitizeHtmlColorsFromPastedContent'; import sanitizeLinks from './sanitizeLinks/sanitizeLinks'; -import { getPasteSource } from 'roosterjs-editor-dom'; -import { KnownPasteSourceType } from 'roosterjs-editor-types'; +import { chainSanitizerCallback, getPasteSource } from 'roosterjs-editor-dom'; +import { HtmlSanitizerOptions, KnownPasteSourceType } from 'roosterjs-editor-types'; import { EditorPlugin, IEditor, @@ -104,9 +104,16 @@ export default class Paste implements EditorPlugin { } sanitizeLinks(sanitizingOption); sanitizeHtmlColorsFromPastedContent(sanitizingOption); + sanitizeBlockStyles(sanitizingOption); // Replace unknown tags with SPAN sanitizingOption.unknownTagReplacement = this.unknownTagReplacement; } } } + +function sanitizeBlockStyles(sanitizingOption: Required) { + chainSanitizerCallback(sanitizingOption.cssStyleCallbacks, 'display', (value: string) => { + return value != 'flex'; // return whether we keep the style + }); +} From f03f701b3693ccdcba4220d8f97c41880e3624c0 Mon Sep 17 00:00:00 2001 From: Andres-CT98 <107568016+Andres-CT98@users.noreply.github.com> Date: Thu, 17 Aug 2023 11:57:48 -0600 Subject: [PATCH 24/75] Replace first cell content if input while on cell selection (#2030) * select first cell content and empty, add undo if change --- .../TableCellSelection/keyUtils/handleKeyDownEvent.ts | 11 +++++++++-- .../TableCellSelection/keyUtils/handleKeyUpEvent.ts | 4 ++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/roosterjs-editor-plugins/lib/plugins/TableCellSelection/keyUtils/handleKeyDownEvent.ts b/packages/roosterjs-editor-plugins/lib/plugins/TableCellSelection/keyUtils/handleKeyDownEvent.ts index e6e1354c0d2..a8f7ac8fa75 100644 --- a/packages/roosterjs-editor-plugins/lib/plugins/TableCellSelection/keyUtils/handleKeyDownEvent.ts +++ b/packages/roosterjs-editor-plugins/lib/plugins/TableCellSelection/keyUtils/handleKeyDownEvent.ts @@ -9,6 +9,7 @@ import { TableCellSelectionState } from '../TableCellSelectionState'; import { updateSelection } from '../utils/updateSelection'; import { contains, + createRange, isCtrlOrMetaPressed, Position, safeInstanceOf, @@ -37,6 +38,7 @@ export function handleKeyDownEvent( return; } + const range = editor.getSelectionRangeEx(); if (shiftKey) { if (!state.firstTarget) { const pos = editor.getFocusedPosition(); @@ -70,10 +72,15 @@ export function handleKeyDownEvent( } }); } else if ( - editor.getSelectionRangeEx()?.type == SelectionRangeTypes.TableSelection && + range?.type == SelectionRangeTypes.TableSelection && (!isCtrlOrMetaPressed(event.rawEvent) || which == Keys.HOME || which == Keys.END) ) { - editor.select(null); + // Select all content in the first cell + const row = range.ranges[0]; + const firstCell = row.startContainer.childNodes[row.startOffset]; + const children = firstCell.childNodes; + const contentRange = createRange(children[0], children[children.length - 1]); + editor.select(contentRange); } } diff --git a/packages/roosterjs-editor-plugins/lib/plugins/TableCellSelection/keyUtils/handleKeyUpEvent.ts b/packages/roosterjs-editor-plugins/lib/plugins/TableCellSelection/keyUtils/handleKeyUpEvent.ts index 617c86b4ca5..62530408ee9 100644 --- a/packages/roosterjs-editor-plugins/lib/plugins/TableCellSelection/keyUtils/handleKeyUpEvent.ts +++ b/packages/roosterjs-editor-plugins/lib/plugins/TableCellSelection/keyUtils/handleKeyUpEvent.ts @@ -1,5 +1,6 @@ import { clearState } from '../utils/clearState'; import { IEditor, Keys, PluginKeyUpEvent } from 'roosterjs-editor-types'; +import { isCharacterValue } from 'roosterjs-editor-dom'; import { TableCellSelectionState } from '../TableCellSelectionState'; const IGNORE_KEY_UP_KEYS = [ @@ -26,6 +27,9 @@ export function handleKeyUpEvent( !state.preventKeyUp && IGNORE_KEY_UP_KEYS.indexOf(which) == -1 ) { + if (isCharacterValue(event.rawEvent)) { + editor.addUndoSnapshot(); + } clearState(state, editor); } state.preventKeyUp = false; From 2875a23983e38dbf8bcb7515aecebbe68ce2d50b Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Thu, 17 Aug 2023 11:52:33 -0700 Subject: [PATCH 25/75] Content Model: Fix 222135 (#2035) * Fix 222135 * fix build --- .../ContentModelCopyPastePlugin.ts | 10 ++++++++-- .../lib/modelApi/entity/insertEntityModel.ts | 19 ++++++++++++++----- .../ContentModelCopyPastePluginTest.ts | 16 ++++++++++++++-- 3 files changed, 36 insertions(+), 9 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCopyPastePlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCopyPastePlugin.ts index ebb60327368..aa8bcaca30c 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCopyPastePlugin.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCopyPastePlugin.ts @@ -1,6 +1,7 @@ import paste from '../../publicApi/utils/paste'; import { cloneModel } from '../../modelApi/common/cloneModel'; -import { contentModelToDom } from 'roosterjs-content-model-dom'; +import { contentModelToDom, normalizeContentModel } from 'roosterjs-content-model-dom'; +import { DeleteResult } from '../../modelApi/edit/utils/DeleteSelectionStep'; import { deleteSelection } from '../../modelApi/edit/deleteSelection'; import { formatWithContentModel } from '../../publicApi/utils/formatWithContentModel'; import { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; @@ -145,7 +146,12 @@ export default class ContentModelCopyPastePlugin implements PluginWithState { - deleteSelection(model, [], context); + if ( + deleteSelection(model, [], context).deleteResult == + DeleteResult.Range + ) { + normalizeContentModel(model); + } return true; }, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/entity/insertEntityModel.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/entity/insertEntityModel.ts index 82f7f292066..fe5b5a6f0ec 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/entity/insertEntityModel.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/entity/insertEntityModel.ts @@ -1,9 +1,14 @@ -import { createBr, createParagraph, createSelectionMarker } from 'roosterjs-content-model-dom'; +import { + createBr, + createParagraph, + createSelectionMarker, + normalizeContentModel, +} from 'roosterjs-content-model-dom'; +import { DeleteResult, DeleteSelectionResult } from '../edit/utils/DeleteSelectionStep'; import { deleteSelection } from '../edit/deleteSelection'; import { FormatWithContentModelContext } from '../../publicTypes/parameter/FormatWithContentModelContext'; import { getClosestAncestorBlockGroupIndex } from '../common/getClosestAncestorBlockGroupIndex'; import { InsertEntityPosition } from '../../publicTypes/parameter/InsertEntityOptions'; -import { InsertPoint } from '../../publicTypes/selection/InsertPoint'; import { setSelection } from '../selection/setSelection'; import { ContentModelBlock, @@ -26,13 +31,17 @@ export function insertEntityModel( ) { let blockParent: ContentModelBlockGroup | undefined; let blockIndex = -1; - let insertPoint: InsertPoint | null; + let deleteResult: DeleteSelectionResult; if (position == 'begin' || position == 'end') { blockParent = model; blockIndex = position == 'begin' ? 0 : model.blocks.length; - } else if ((insertPoint = deleteSelection(model, [], context).insertPoint)) { - const { marker, paragraph, path } = insertPoint; + } else if ((deleteResult = deleteSelection(model, [], context)).insertPoint) { + const { marker, paragraph, path } = deleteResult.insertPoint; + + if (deleteResult.deleteResult == DeleteResult.Range) { + normalizeContentModel(model); + } if (!isBlock) { const index = paragraph.segments.indexOf(marker); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCopyPastePluginTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCopyPastePluginTest.ts index ca3e49f2632..639319f96b4 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCopyPastePluginTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCopyPastePluginTest.ts @@ -4,7 +4,9 @@ import * as createRangeF from 'roosterjs-editor-dom/lib/selection/createRange'; import * as deleteSelectionsFile from '../../../lib/modelApi/edit/deleteSelection'; import * as extractClipboardItemsFile from 'roosterjs-editor-dom/lib/clipboard/extractClipboardItems'; import * as iterateSelectionsFile from '../../../lib/modelApi/selection/iterateSelections'; +import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; import * as PasteFile from '../../../lib/publicApi/utils/paste'; +import { DeleteResult } from '../../../lib/modelApi/edit/utils/DeleteSelectionStep'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import ContentModelCopyPastePlugin, { onNodeCreated, @@ -409,7 +411,10 @@ describe('ContentModelCopyPastePlugin |', () => { }; spyOn(createRangeF, 'default').and.callThrough(); - spyOn(deleteSelectionsFile, 'deleteSelection'); + spyOn(deleteSelectionsFile, 'deleteSelection').and.returnValue({ + deleteResult: DeleteResult.Range, + insertPoint: null!, + }); spyOn(contentModelToDomFile, 'contentModelToDom').and.callFake(() => { const container = document.createElement('div'); container.append(table); @@ -418,6 +423,7 @@ describe('ContentModelCopyPastePlugin |', () => { return selectionRangeExValue; }); spyOn(iterateSelectionsFile, 'iterateSelections').and.returnValue(undefined); + spyOn(normalizeContentModel, 'normalizeContentModel'); triggerPluginEventSpy.and.callThrough(); focusSpy.and.callThrough(); @@ -454,6 +460,7 @@ describe('ContentModelCopyPastePlugin |', () => { expect(setContentModelSpy).toHaveBeenCalledWith(modelValue, { onNodeCreated: undefined, }); + expect(normalizeContentModel.normalizeContentModel).toHaveBeenCalledWith(modelValue); }); it('Selection not Collapsed and image selection', () => { @@ -468,12 +475,16 @@ describe('ContentModelCopyPastePlugin |', () => { }; spyOn(createRangeF, 'default').and.callThrough(); - spyOn(deleteSelectionsFile, 'deleteSelection'); + spyOn(deleteSelectionsFile, 'deleteSelection').and.returnValue({ + deleteResult: DeleteResult.Range, + insertPoint: null!, + }); spyOn(contentModelToDomFile, 'contentModelToDom').and.callFake(() => { div.appendChild(image); return selectionRangeExValue; }); spyOn(iterateSelectionsFile, 'iterateSelections').and.returnValue(undefined); + spyOn(normalizeContentModel, 'normalizeContentModel'); triggerPluginEventSpy.and.callThrough(); focusSpy.and.callThrough(); @@ -509,6 +520,7 @@ describe('ContentModelCopyPastePlugin |', () => { expect(setContentModelSpy).toHaveBeenCalledWith(modelValue, { onNodeCreated: undefined, }); + expect(normalizeContentModel.normalizeContentModel).toHaveBeenCalledWith(modelValue); }); }); From 14386b6cb5985752fb64c1872ff15a4c95b2c1b8 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Thu, 17 Aug 2023 12:01:45 -0700 Subject: [PATCH 26/75] Content Model: Fix 219312 (#2036) --- .../domToModel/processors/tableProcessor.ts | 7 ++- .../processors/tableProcessorTest.ts | 54 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/tableProcessor.ts b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/tableProcessor.ts index 54ce2661852..80711b09a80 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/tableProcessor.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/tableProcessor.ts @@ -3,6 +3,7 @@ import { createTable } from '../../modelApi/creators/createTable'; import { createTableCell } from '../../modelApi/creators/createTableCell'; import { getBoundingClientRect } from '../utils/getBoundingClientRect'; import { parseFormat } from '../utils/parseFormat'; +import { safeInstanceOf } from 'roosterjs-editor-dom'; import { SelectionRangeTypes } from 'roosterjs-editor-types'; import { stackFormat } from '../utils/stackFormat'; import { @@ -74,7 +75,11 @@ export const tableProcessor: ElementProcessor = ( const tr = tableElement.rows[row]; const tableRow = table.rows[row]; - if (context.allowCacheElement) { + const tbody = tr.parentNode; + + if (safeInstanceOf(tbody, 'HTMLTableSectionElement')) { + parseFormat(tbody, context.formatParsers.tableRow, tableRow.format, context); + } else if (context.allowCacheElement) { tableRow.cachedElement = tr; } diff --git a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/tableProcessorTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/tableProcessorTest.ts index 5a56d125714..4b2cb122340 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/tableProcessorTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/tableProcessorTest.ts @@ -1239,4 +1239,58 @@ describe('tableProcessor', () => { ], }); }); + + it('Respect background on tbody', () => { + const group = createContentModelDocument(); + const table = document.createElement('table'); + const tbody = document.createElement('tbody'); + const tr = document.createElement('tr'); + const td = document.createElement('td'); + + tbody.style.backgroundColor = 'red'; + + table.appendChild(tbody); + tbody.appendChild(tr); + tr.appendChild(td); + + childProcessor.and.callFake(() => { + expect(context.blockFormat).toEqual({}); + expect(context.segmentFormat).toEqual({}); + }); + + tableProcessor(group, table, context); + + expect(childProcessor).toHaveBeenCalledTimes(1); + expect(group).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + widths: [100], + dataset: {}, + + rows: [ + { + format: { + backgroundColor: 'red', + }, + height: 200, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanAbove: false, + spanLeft: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: {}, + }, + ], + }); + }); }); From 8a200e7c210d194eda48d6668396721327d4cd1c Mon Sep 17 00:00:00 2001 From: Bryan Valverde U Date: Fri, 18 Aug 2023 09:58:47 -0600 Subject: [PATCH 27/75] Fix regression when creating the BeforePasteEvent (#2039) * init * fix build --- .../lib/publicApi/utils/paste.ts | 8 ++-- .../test/publicApi/utils/pasteTest.ts | 46 ++++++++++++++++++- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/paste.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/paste.ts index bd8b7d9b448..30aefea1e4f 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/paste.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/paste.ts @@ -186,7 +186,7 @@ function triggerPluginEventAndCreatePasteFragment( : undefined; // Step 2: Retrieve Metadata from Html and the Html that was copied. - retrieveMetadataFromClipboard(doc, event, editor.getTrustedHTMLHandler()); + retrieveMetadataFromClipboard(doc, event, trustedHTMLHandler); // Step 3: Fill the BeforePasteEvent object, especially the fragment for paste if ((pasteAsImage && imageDataUri) || (!pasteAsText && !text && imageDataUri)) { @@ -199,12 +199,12 @@ function triggerPluginEventAndCreatePasteFragment( handleTextPaste(text, position, fragment); } - let pluginEvent: ContentModelBeforePasteEvent | undefined = undefined; + let pluginEvent: ContentModelBeforePasteEvent = event; // Step 4: Trigger BeforePasteEvent so that plugins can do proper change before paste, when the type of paste is different than Plain Text if (event.pasteType !== PasteType.AsPlainText) { pluginEvent = editor.triggerPluginEvent( PluginEventType.BeforePaste, - eventData, + event, true /* broadcast */ ) as ContentModelBeforePasteEvent; } @@ -212,7 +212,7 @@ function triggerPluginEventAndCreatePasteFragment( // Step 5. Sanitize the fragment before paste to make sure the content is safe sanitizePasteContent(event, position); - return pluginEvent || eventData; + return pluginEvent; } /** diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts index 4e23cfb9969..c9c03cce21c 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts @@ -10,10 +10,17 @@ import * as WacComponents from '../../../lib/editor/plugins/PastePlugin/WacCompo import * as WordDesktopFile from '../../../lib/editor/plugins/PastePlugin/WordDesktop/processPastedContentFromWordDesktop'; import ContentModelEditor from '../../../lib/editor/ContentModelEditor'; import ContentModelPastePlugin from '../../../lib/editor/plugins/PastePlugin/ContentModelPastePlugin'; -import { ClipboardData, KnownPasteSourceType, PasteType } from 'roosterjs-editor-types'; import { ContentModelDocument } from 'roosterjs-content-model-types'; import { createContentModelDocument } from 'roosterjs-content-model-dom'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; +import { + BeforePasteEvent, + ClipboardData, + KnownPasteSourceType, + PasteType, + PluginEvent, + PluginEventType, +} from 'roosterjs-editor-types'; let clipboardData: ClipboardData; @@ -277,6 +284,43 @@ describe('paste with content model & paste plugin', () => { expect(addParserF.default).toHaveBeenCalledTimes(0); expect(PPT.processPastedContentFromPowerPoint).toHaveBeenCalledTimes(0); }); + + it('Verify the event data is not lost', () => { + clipboardData = { + types: ['image/png', 'text/plain', 'text/html'], + text: 'Flight\tDescription\r\n', + image: {}, + files: [], + rawHtml: + '\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n \r\n \r\n \r\n\r\n \r\n \r\n\r\n \r\n
FlightDescription
\r\n\r\n\r\n\r\n\r\n', + customValues: {}, + pasteNativeEvent: true, + imageDataUri: '', + }; + + let eventChecker: BeforePasteEvent = {}; + editor = new ContentModelEditor(div!, { + plugins: [ + { + initialize: () => {}, + dispose: () => {}, + getName: () => 'test', + onPluginEvent(event: PluginEvent) { + if (event.eventType === PluginEventType.BeforePaste) { + eventChecker = event; + } + }, + }, + ], + }); + + pasteF.default(editor!, clipboardData); + + expect(eventChecker?.clipboardData).toEqual(clipboardData); + expect(eventChecker?.htmlBefore).toBeTruthy(); + expect(eventChecker?.htmlAfter).toBeTruthy(); + expect(eventChecker?.pasteType).toEqual(0); + }); }); describe('mergePasteContent', () => { From dbf5617dca89cf6bceb6ac5c89c9a89ce82cd7bd Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Fri, 18 Aug 2023 09:20:00 -0700 Subject: [PATCH 28/75] Content Model: Fix 220050 (#2037) * Content Model: Fix 220050 * Fix build * improve * improve --- .../modelApi/selection/collectSelections.ts | 15 +- .../table/ensureFocusableParagraphForTable.ts | 74 ++++++ .../lib/publicApi/table/editTable.ts | 69 +----- .../selection/collectSelectionsTest.ts | 43 +++- .../ensureFocusableParagraphForTableTest.ts | 222 ++++++++++++++++++ 5 files changed, 352 insertions(+), 71 deletions(-) create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/ensureFocusableParagraphForTable.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/test/modelApi/table/ensureFocusableParagraphForTableTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/collectSelections.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/collectSelections.ts index 2cbab5bd76e..ef737bdab62 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/collectSelections.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/collectSelections.ts @@ -115,10 +115,10 @@ export function getOperationalBlocks( */ export function getFirstSelectedTable( model: ContentModelDocument -): [ContentModelTable | undefined, ContentModelBlockGroup | undefined] { +): [ContentModelTable | undefined, ContentModelBlockGroup[]] { const selections = collectSelections(model, { includeListFormatHolder: 'never' }); let table: ContentModelTable | undefined; - let parent: ContentModelBlockGroup | undefined; + let resultPath: ContentModelBlockGroup[] = []; removeUnmeaningfulSelections(selections); @@ -126,15 +126,20 @@ export function getFirstSelectedTable( if (!table) { if (block?.blockType == 'Table') { table = block; - parent = path[0]; + resultPath = [...path]; } else if (tableContext?.table) { table = tableContext.table; - parent = path.filter(group => group.blocks.indexOf(tableContext.table) >= 0)[0]; + + const parent = path.filter( + group => group.blocks.indexOf(tableContext.table) >= 0 + )[0]; + const index = path.indexOf(parent); + resultPath = index >= 0 ? path.slice(index) : []; } } }); - return [table, parent]; + return [table, resultPath]; } /** diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/ensureFocusableParagraphForTable.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/ensureFocusableParagraphForTable.ts new file mode 100644 index 00000000000..81e72372f23 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/ensureFocusableParagraphForTable.ts @@ -0,0 +1,74 @@ +import { createBr, createParagraph } from 'roosterjs-content-model-dom'; +import { + ContentModelBlock, + ContentModelBlockGroup, + ContentModelDocument, + ContentModelParagraph, + ContentModelTable, +} from 'roosterjs-content-model-types'; + +/** + * @internal + * After edit table, it maybe in a abnormal state, e.g. selected table cell is removed, or all rows are removed causes no place to put cursor. + * We need to make sure table is in normal state, and there is a place to put cursor. + * @returns a new paragraph that can but put focus in, or undefined if not needed + */ +export function ensureFocusableParagraphForTable( + model: ContentModelDocument, + path: ContentModelBlockGroup[], + table: ContentModelTable +): ContentModelParagraph | undefined { + let paragraph: ContentModelParagraph | undefined; + const firstCell = table.rows.filter(row => row.cells.length > 0)[0]?.cells[0]; + + if (firstCell) { + // When there is a valid cell to put focus, use it + paragraph = firstCell.blocks.filter( + (block): block is ContentModelParagraph => block.blockType == 'Paragraph' + )[0]; + + if (!paragraph) { + // If there is not a paragraph under this cell, create one + paragraph = createEmptyParagraph(model); + firstCell.blocks.push(paragraph); + } + } else { + // No table cell at all, which means the whole table is deleted. So we need to remove it from content model. + let block: ContentModelBlock = table; + let parent: ContentModelBlockGroup | undefined; + paragraph = createEmptyParagraph(model); + + // If the table is the only block of its parent and parent is a FormatContainer, remove the parent as well. + // We need to do this in a loop in case there are multiple layer of FormatContainer that match this case + while ((parent = path.shift())) { + const index = parent.blocks.indexOf(block) ?? -1; + + if (parent && index >= 0) { + parent.blocks.splice(index, 1, paragraph); + } + + if ( + parent.blockGroupType == 'FormatContainer' && + parent.blocks.length == 1 && + parent.blocks[0] == paragraph + ) { + // If the new paragraph is the only child of parent format container, unwrap parent as well + block = parent; + } else { + // Otherwise, just stop here and keep processing the new paragraph + break; + } + } + } + + return paragraph; +} + +function createEmptyParagraph(model: ContentModelDocument) { + const newPara = createParagraph(false /*isImplicit*/, undefined /*blockFormat*/, model.format); + const br = createBr(model.format); + + newPara.segments.push(br); + + return newPara; +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/editTable.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/editTable.ts index aab8911c72c..f6c2e5fa93f 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/editTable.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/editTable.ts @@ -5,6 +5,7 @@ import { applyTableFormat } from '../../modelApi/table/applyTableFormat'; import { deleteTable } from '../../modelApi/table/deleteTable'; import { deleteTableColumn } from '../../modelApi/table/deleteTableColumn'; import { deleteTableRow } from '../../modelApi/table/deleteTableRow'; +import { ensureFocusableParagraphForTable } from '../../modelApi/table/ensureFocusableParagraphForTable'; import { formatWithContentModel } from '../utils/formatWithContentModel'; import { getFirstSelectedTable } from '../../modelApi/selection/collectSelections'; import { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; @@ -19,14 +20,6 @@ import { splitTableCellHorizontally } from '../../modelApi/table/splitTableCellH import { splitTableCellVertically } from '../../modelApi/table/splitTableCellVertically'; import { TableOperation } from 'roosterjs-editor-types'; import { - ContentModelBlockGroup, - ContentModelDocument, - ContentModelParagraph, - ContentModelTable, -} from 'roosterjs-content-model-types'; -import { - createBr, - createParagraph, createSelectionMarker, hasMetadata, setParagraphNotImplicit, @@ -39,7 +32,7 @@ import { */ export default function editTable(editor: IContentModelEditor, operation: TableOperation) { formatWithContentModel(editor, 'editTable', model => { - const [tableModel, parent] = getFirstSelectedTable(model); + const [tableModel, path] = getFirstSelectedTable(model); if (tableModel) { switch (operation) { @@ -104,7 +97,17 @@ export default function editTable(editor: IContentModelEditor, operation: TableO break; } - ensureTableSelection(model, parent, tableModel); + if (!hasSelectionInBlock(tableModel)) { + const paragraph = ensureFocusableParagraphForTable(model, path, tableModel); + + if (paragraph) { + const marker = createSelectionMarker(model.format); + + paragraph.segments.unshift(marker); + setParagraphNotImplicit(paragraph); + setSelection(model, marker); + } + } normalizeTable(tableModel); @@ -118,49 +121,3 @@ export default function editTable(editor: IContentModelEditor, operation: TableO } }); } - -function ensureTableSelection( - model: ContentModelDocument, - parent: ContentModelBlockGroup | undefined, - table: ContentModelTable -) { - if (!hasSelectionInBlock(table)) { - let paragraph: ContentModelParagraph | undefined; - const firstCell = table.rows.filter(row => row.cells.length > 0)[0]?.cells[0]; - - if (firstCell) { - paragraph = firstCell.blocks.filter( - (block): block is ContentModelParagraph => block.blockType == 'Paragraph' - )[0]; - - if (!paragraph) { - paragraph = createEmptyParagraph(model); - firstCell.blocks.push(paragraph); - } - } else if (parent) { - const index = parent.blocks.indexOf(table); - - if (index >= 0) { - paragraph = createEmptyParagraph(model); - parent.blocks.splice(index, 1, paragraph); - } - } - - if (paragraph) { - const marker = createSelectionMarker(model.format); - - paragraph.segments.unshift(marker); - setParagraphNotImplicit(paragraph); - setSelection(model, marker); - } - } -} - -function createEmptyParagraph(model: ContentModelDocument) { - const newPara = createParagraph(false /*isImplicit*/, undefined /*blockFormat*/, model.format); - const br = createBr(model.format); - - newPara.segments.push(br); - - return newPara; -} diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/selection/collectSelectionsTest.ts b/packages-content-model/roosterjs-content-model-editor/test/modelApi/selection/collectSelectionsTest.ts index 1e88ac3f0f4..cb7806b1f0d 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/selection/collectSelectionsTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/modelApi/selection/collectSelectionsTest.ts @@ -374,7 +374,7 @@ describe('getSelectedParagraphs', () => { describe('getFirstSelectedTable', () => { function runTest( selections: SelectionInfo[], - expectedResult: [ContentModelTable | undefined, ContentModelBlockGroup | undefined] + expectedResult: [ContentModelTable | undefined, ContentModelBlockGroup[]] ) { spyOn(iterateSelections, 'iterateSelections').and.callFake((_, callback) => { selections.forEach(({ path, tableContext, block, segments }) => { @@ -390,7 +390,7 @@ describe('getFirstSelectedTable', () => { } it('Empty selection', () => { - runTest([], [undefined, undefined]); + runTest([], [undefined, []]); }); it('Single table selection in context', () => { @@ -408,7 +408,7 @@ describe('getFirstSelectedTable', () => { }, }, ], - [table, undefined] + [table, []] ); }); @@ -422,7 +422,7 @@ describe('getFirstSelectedTable', () => { block: table, }, ], - [table, undefined] + [table, []] ); }); @@ -443,7 +443,7 @@ describe('getFirstSelectedTable', () => { }, }, ], - [table1, undefined] + [table1, []] ); }); @@ -462,7 +462,7 @@ describe('getFirstSelectedTable', () => { block: table2, }, ], - [table1, undefined] + [table1, []] ); }); @@ -491,7 +491,7 @@ describe('getFirstSelectedTable', () => { }, }, ], - [table1, undefined] + [table1, []] ); }); @@ -510,7 +510,7 @@ describe('getFirstSelectedTable', () => { block: table1, }, ], - [table1, undefined] + [table1, []] ); }); @@ -526,7 +526,7 @@ describe('getFirstSelectedTable', () => { const result = getFirstSelectedTable(doc); - expect(result).toEqual([table1, doc]); + expect(result).toEqual([table1, [doc]]); }); it('With parent, things under table is selected', () => { @@ -545,7 +545,30 @@ describe('getFirstSelectedTable', () => { const result = getFirstSelectedTable(doc); - expect(result).toEqual([table1, doc]); + expect(result).toEqual([table1, [doc]]); + }); + + it('With deep parent, deep things under table is selected', () => { + const table1 = createTable(1); + const cell = createTableCell(); + const doc = createContentModelDocument(); + + const para = createParagraph(); + const marker = createSelectionMarker(); + + const container1 = createFormatContainer('div'); + const container2 = createFormatContainer('div'); + + para.segments.push(marker); + container2.blocks.push(para); + cell.blocks.push(container2); + table1.rows[0].cells.push(cell); + container1.blocks.push(table1); + doc.blocks.push(container1); + + const result = getFirstSelectedTable(doc); + + expect(result).toEqual([table1, [container1, doc]]); }); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/table/ensureFocusableParagraphForTableTest.ts b/packages-content-model/roosterjs-content-model-editor/test/modelApi/table/ensureFocusableParagraphForTableTest.ts new file mode 100644 index 00000000000..4b242677c2b --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/test/modelApi/table/ensureFocusableParagraphForTableTest.ts @@ -0,0 +1,222 @@ +import { ContentModelParagraph } from 'roosterjs-content-model-types'; +import { ensureFocusableParagraphForTable } from '../../../lib/modelApi/table/ensureFocusableParagraphForTable'; +import { + createContentModelDocument, + createDivider, + createFormatContainer, + createParagraph, + createTable, + createTableCell, +} from 'roosterjs-content-model-dom'; + +describe('ensureFocusableParagraphForTable', () => { + it('Table has cell with paragraph', () => { + const table = createTable(1); + const cell = createTableCell(); + const paragraph = createParagraph(); + const model = createContentModelDocument(); + + cell.blocks.push(paragraph); + table.rows[0].cells.push(cell); + model.blocks.push(table); + + const result = ensureFocusableParagraphForTable(model, [model], table); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + rows: [ + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [paragraph], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: {}, + widths: [], + dataset: {}, + }, + ], + }); + expect(result).toBe(paragraph); + }); + + it('Table has cell without paragraph', () => { + const table = createTable(1); + const cell = createTableCell(); + const divider = createDivider('div'); + const model = createContentModelDocument(); + + cell.blocks.push(divider); + table.rows[0].cells.push(cell); + model.blocks.push(table); + + const result = ensureFocusableParagraphForTable(model, [model], table); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + rows: [ + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + divider, + { + blockType: 'Paragraph', + segments: [{ segmentType: 'Br', format: {} }], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: {}, + widths: [], + dataset: {}, + }, + ], + }); + expect(result).toEqual({ + blockType: 'Paragraph', + segments: [{ segmentType: 'Br', format: {} }], + format: {}, + }); + }); + + it('Table has no cell', () => { + const table = createTable(0); + const model = createContentModelDocument(); + + model.blocks.push(table); + + const result = ensureFocusableParagraphForTable(model, [model], table); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [{ segmentType: 'Br', format: {} }], + format: {}, + }, + ], + }); + expect(result).toBe(model.blocks[0] as ContentModelParagraph); + }); + + it('Table has no cell, parent is format container', () => { + const table = createTable(0); + const container = createFormatContainer('div'); + const model = createContentModelDocument(); + + container.blocks.push(table); + model.blocks.push(container); + + const result = ensureFocusableParagraphForTable(model, [container, model], table); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [{ segmentType: 'Br', format: {} }], + format: {}, + }, + ], + }); + expect(result).toBe(model.blocks[0] as ContentModelParagraph); + }); + + it('Table has no cell, parent is format container, and has other block', () => { + const table = createTable(0); + const paragraph = createParagraph(); + const container = createFormatContainer('div'); + const model = createContentModelDocument(); + + container.blocks.push(paragraph); + container.blocks.push(table); + model.blocks.push(container); + + const result = ensureFocusableParagraphForTable(model, [container, model], table); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'FormatContainer', + tagName: 'div', + blocks: [ + { blockType: 'Paragraph', segments: [], format: {} }, + { + blockType: 'Paragraph', + segments: [{ segmentType: 'Br', format: {} }], + format: {}, + }, + ], + format: {}, + }, + ], + }); + expect(result).toBe(container.blocks[1] as ContentModelParagraph); + }); + + it('Table has no cell, parent is format container in another container', () => { + const table = createTable(0); + const container1 = createFormatContainer('div'); + const container2 = createFormatContainer('div'); + const para1 = createParagraph(); + const para2 = createParagraph(); + const model = createContentModelDocument(); + + container1.blocks.push(table); + container2.blocks.push(container1); + model.blocks.push(para1); + model.blocks.push(container2); + model.blocks.push(para2); + + const result = ensureFocusableParagraphForTable( + model, + [container1, container2, model], + table + ); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { blockType: 'Paragraph', segments: [], format: {} }, + { + blockType: 'Paragraph', + segments: [{ segmentType: 'Br', format: {} }], + format: {}, + }, + { blockType: 'Paragraph', segments: [], format: {} }, + ], + }); + expect(result).toBe(model.blocks[1] as ContentModelParagraph); + }); +}); From 665ab684a1020870434161f93ee2d87955010622 Mon Sep 17 00:00:00 2001 From: Bryan Valverde U Date: Fri, 18 Aug 2023 12:10:35 -0600 Subject: [PATCH 29/75] Simplify the domToModel call in `paste.ts` (#2040) * add more changes * fix build * fix test --- .../PastePlugin/ContentModelPastePlugin.ts | 19 ++++++++++ .../lib/publicApi/utils/paste.ts | 35 ++----------------- .../plugins/ContentModelPastePluginTest.ts | 2 +- 3 files changed, 22 insertions(+), 34 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/ContentModelPastePlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/ContentModelPastePlugin.ts index e5b708e1152..a86c294362d 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/ContentModelPastePlugin.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/ContentModelPastePlugin.ts @@ -1,6 +1,7 @@ import addParser from './utils/addParser'; import ContentModelBeforePasteEvent from '../../../publicTypes/event/ContentModelBeforePasteEvent'; import { chainSanitizerCallback, getPasteSource } from 'roosterjs-editor-dom'; +import { ContentModelBlockFormat, FormatParser } from 'roosterjs-content-model-types'; import { IContentModelEditor } from '../../../publicTypes/IContentModelEditor'; import { parseDeprecatedColor } from './utils/deprecatedColorParser'; import { parseLink } from './utils/linkParser'; @@ -109,10 +110,28 @@ export default class ContentModelPastePlugin implements EditorPlugin { parseDeprecatedColor(ev.sanitizingOption); sanitizeBlockStyles(ev.sanitizingOption); + if (event.pasteType === PasteType.MergeFormat) { + addParser(ev.domToModelOption, 'block', blockElementParser); + addParser(ev.domToModelOption, 'listLevel', blockElementParser); + } + event.sanitizingOption.unknownTagReplacement = this.unknownTagReplacement; } } +/** + * For block elements that have background color style, remove the background color when user selects the merge current format + * paste option + */ +const blockElementParser: FormatParser = ( + format: ContentModelBlockFormat, + element: HTMLElement +) => { + if (element.style.backgroundColor) { + delete format.backgroundColor; + } +}; + function sanitizeBlockStyles(sanitizingOption: Required) { chainSanitizerCallback(sanitizingOption.cssStyleCallbacks, 'display', (value: string) => { return value != 'flex'; // return whether we keep the style diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/paste.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/paste.ts index 30aefea1e4f..0196320f264 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/paste.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/paste.ts @@ -4,11 +4,7 @@ import { FormatWithContentModelContext } from '../../publicTypes/parameter/Forma import { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; import { mergeModel } from '../../modelApi/common/mergeModel'; import { NodePosition } from 'roosterjs-editor-types'; -import { - ContentModelBlockFormat, - ContentModelDocument, - FormatParser, -} from 'roosterjs-content-model-types'; +import { ContentModelDocument } from 'roosterjs-content-model-types'; import ContentModelBeforePasteEvent, { ContentModelBeforePasteEventData, } from '../../publicTypes/event/ContentModelBeforePasteEvent'; @@ -70,20 +66,7 @@ export default function paste( eventData ); - const pasteModel = domToContentModel(fragment, { - ...domToModelOption, - additionalFormatParsers: { - ...domToModelOption.additionalFormatParsers, - block: [ - ...(domToModelOption.additionalFormatParsers?.block || []), - ...(applyCurrentFormat ? [blockElementParser] : []), - ], - listLevel: [ - ...(domToModelOption.additionalFormatParsers?.listLevel || []), - ...(applyCurrentFormat ? [blockElementParser] : []), - ], - }, - }); + const pasteModel = domToContentModel(fragment, domToModelOption); if (pasteModel) { formatWithContentModel( @@ -214,17 +197,3 @@ function triggerPluginEventAndCreatePasteFragment( return pluginEvent; } - -/** - * For block elements that have background color style, remove the background color when user selects the merge current format - * paste option - */ -const blockElementParser: FormatParser = ( - format: ContentModelBlockFormat, - element: HTMLElement -) => { - if (element.style.backgroundColor) { - element.style.backgroundColor = ''; - delete format.backgroundColor; - } -}; diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelPastePluginTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelPastePluginTest.ts index 24e371f8210..d6ab2e4b8bf 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelPastePluginTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelPastePluginTest.ts @@ -106,7 +106,7 @@ describe('Paste', () => { event, trustedHTMLHandler ); - expect(addParser.default).toHaveBeenCalledTimes(2); + expect(addParser.default).toHaveBeenCalledTimes(4); expect(setProcessor.setProcessor).toHaveBeenCalledTimes(0); expect(chainSanitizerCallbackFile.default).toHaveBeenCalledTimes(3); }); From 7c6a32010a2bcf7ffbb720e2c2895055f6153a5e Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Fri, 18 Aug 2023 11:18:53 -0700 Subject: [PATCH 30/75] Content Model: Support vertical-align for image (#2041) * Content Model: Support vertical-align for image * fix build and test --------- Co-authored-by: Bryan Valverde U --- .../lib/formatHandlers/common/verticalAlignFormatHandler.ts | 4 ++++ .../lib/formatHandlers/defaultFormatHandlers.ts | 1 + .../formatHandlers/common/verticalAlignFormatHandlerTest.ts | 1 + .../editor/plugins/paste/processPastedContentFromWacTest.ts | 5 +++++ .../lib/format/ContentModelImageFormat.ts | 4 +++- 5 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/common/verticalAlignFormatHandler.ts b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/common/verticalAlignFormatHandler.ts index a98131bb560..8a47f8455e4 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/common/verticalAlignFormatHandler.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/common/verticalAlignFormatHandler.ts @@ -22,6 +22,10 @@ export const verticalAlignFormatHandler: FormatHandler = { case 'bottom': format.verticalAlign = 'bottom'; break; + + case 'middle': + format.verticalAlign = 'middle'; + break; } }, apply: (format, element) => { diff --git a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/defaultFormatHandlers.ts b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/defaultFormatHandlers.ts index b793af848d2..010fb3b5dba 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/defaultFormatHandlers.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/defaultFormatHandlers.ts @@ -176,6 +176,7 @@ const defaultFormatKeysPerCategory: { 'boxShadow', 'display', 'float', + 'verticalAlign', ], link: [ 'link', diff --git a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/common/verticalAlignFormatHandlerTest.ts b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/common/verticalAlignFormatHandlerTest.ts index ab763132b97..d7d7f6ae6a5 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/common/verticalAlignFormatHandlerTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/common/verticalAlignFormatHandlerTest.ts @@ -63,6 +63,7 @@ describe('verticalAlignFormatHandler.parse', () => { runTest('text-top', null, 'top'); runTest('text-bottom', null, 'top'); runTest('bottom', null, 'bottom'); + runTest('middle', null, 'middle'); }); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/processPastedContentFromWacTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/processPastedContentFromWacTest.ts index dbecb4da44a..0a94771ef6e 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/processPastedContentFromWacTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/processPastedContentFromWacTest.ts @@ -1346,6 +1346,7 @@ describe('wordOnlineHandler', () => { borderRight: Browser.isFirefox ? 'medium none' : '', borderBottom: Browser.isFirefox ? 'medium none' : '', borderLeft: Browser.isFirefox ? 'medium none' : '', + verticalAlign: 'top', }, dataset: {}, alt: @@ -1767,6 +1768,7 @@ describe('wordOnlineHandler', () => { paddingRight: '0px', paddingBottom: '0px', paddingLeft: '0px', + verticalAlign: 'middle', }, spanLeft: false, spanAbove: false, @@ -1878,6 +1880,7 @@ describe('wordOnlineHandler', () => { paddingRight: '0px', paddingBottom: '0px', paddingLeft: '0px', + verticalAlign: 'middle', }, spanLeft: false, spanAbove: false, @@ -1994,6 +1997,7 @@ describe('wordOnlineHandler', () => { paddingRight: '0px', paddingBottom: '0px', paddingLeft: '0px', + verticalAlign: 'middle', }, spanLeft: false, spanAbove: false, @@ -2019,6 +2023,7 @@ describe('wordOnlineHandler', () => { paddingRight: '0px', paddingBottom: '0px', paddingLeft: '0px', + verticalAlign: 'middle', }, spanLeft: true, spanAbove: false, diff --git a/packages-content-model/roosterjs-content-model-types/lib/format/ContentModelImageFormat.ts b/packages-content-model/roosterjs-content-model-types/lib/format/ContentModelImageFormat.ts index d4f70c76492..f344c54931b 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/format/ContentModelImageFormat.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/format/ContentModelImageFormat.ts @@ -7,6 +7,7 @@ import { IdFormat } from './formatParts/IdFormat'; import { MarginFormat } from './formatParts/MarginFormat'; import { PaddingFormat } from './formatParts/PaddingFormat'; import { SizeFormat } from './formatParts/SizeFormat'; +import { VerticalAlignFormat } from './formatParts/VerticalAlignFormat'; /** * The format object for an image in Content Model @@ -19,4 +20,5 @@ export type ContentModelImageFormat = ContentModelSegmentFormat & BorderFormat & BoxShadowFormat & DisplayFormat & - FloatFormat; + FloatFormat & + VerticalAlignFormat; From 0439b5ad60fe4be57882c15ab2ea8cdf80e4da71 Mon Sep 17 00:00:00 2001 From: Bryan Valverde U Date: Tue, 29 Aug 2023 08:18:51 -0600 Subject: [PATCH 31/75] Remove deprecated colors from borders, text and background. (#2045) * Support more border styles * init * Revert unrelated change * Fix dependencies * address comments * try fix build --- .../lib/formatHandlers/utils/color.ts | 33 ++ .../roosterjs-content-model-dom/lib/index.ts | 1 + .../backgroundColorFormatHandlerTest.ts | 11 + .../segment/textColorFormatHandlerTest.ts | 11 + .../PastePlugin/ContentModelPastePlugin.ts | 18 +- .../utils/deprecatedColorParser.ts | 52 +-- .../plugins/ContentModelPastePluginTest.ts | 39 +-- .../paste/deprecatedColorParserTest.ts | 41 +++ .../paste/e2e/cmPasteFromExcelOnlineTest.ts | 4 +- .../plugins/paste/e2e/cmPasteFromExcelTest.ts | 25 +- .../plugins/paste/e2e/cmPasteFromWacTest.ts | 320 +++++++++++++++++- .../plugins/paste/e2e/cmPasteFromWordTest.ts | 269 ++++++++++++++- .../editor/plugins/paste/e2e/testUtils.ts | 37 ++ .../paste/processPastedContentFromWacTest.ts | 12 - .../test/publicApi/utils/pasteTest.ts | 157 ++++++++- 15 files changed, 901 insertions(+), 129 deletions(-) create mode 100644 packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/deprecatedColorParserTest.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/testUtils.ts diff --git a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/utils/color.ts b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/utils/color.ts index 9680bf64a8d..ed9e2ec522d 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/utils/color.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/utils/color.ts @@ -1,6 +1,35 @@ import { DarkColorHandler } from 'roosterjs-editor-types'; import { getTagOfNode } from 'roosterjs-editor-dom'; +/** + * List of deprecated colors + */ +export const DeprecatedColors: string[] = [ + 'inactiveborder', + 'activeborder', + 'inactivecaptiontext', + 'inactivecaption', + 'activecaption', + 'appworkspace', + 'infobackground', + 'background', + 'buttonhighlight', + 'buttonshadow', + 'captiontext', + 'infotext', + 'menutext', + 'menu', + 'scrollbar', + 'threeddarkshadow', + 'threedface', + 'threedhighlight', + 'threedlightshadow', + 'threedfhadow', + 'windowtext', + 'windowframe', + 'window', +]; + /** * @internal */ @@ -21,6 +50,10 @@ export function getColor( undefined; } + if (color && DeprecatedColors.indexOf(color) > -1) { + color = undefined; + } + if (darkColorHandler) { color = darkColorHandler.parseColorValue(color).lightModeColor; } diff --git a/packages-content-model/roosterjs-content-model-dom/lib/index.ts b/packages-content-model/roosterjs-content-model-dom/lib/index.ts index 76431921855..76f505f1e49 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/index.ts @@ -47,6 +47,7 @@ export { setParagraphNotImplicit } from './modelApi/block/setParagraphNotImplici export { parseValueWithUnit } from './formatHandlers/utils/parseValueWithUnit'; export { BorderKeys } from './formatHandlers/common/borderFormatHandler'; +export { DeprecatedColors } from './formatHandlers/utils/color'; export { defaultImplicitFormatMap } from './formatHandlers/utils/defaultStyles'; export { createDomToModelContext } from './domToModel/context/createDomToModelContext'; diff --git a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/common/backgroundColorFormatHandlerTest.ts b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/common/backgroundColorFormatHandlerTest.ts index fe411cc63c3..4e8b02318c8 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/common/backgroundColorFormatHandlerTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/common/backgroundColorFormatHandlerTest.ts @@ -2,6 +2,7 @@ import DarkColorHandlerImpl from 'roosterjs-editor-core/lib/editor/DarkColorHand import { backgroundColorFormatHandler } from '../../../lib/formatHandlers/common/backgroundColorFormatHandler'; import { createDomToModelContext } from '../../../lib/domToModel/context/createDomToModelContext'; import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext'; +import { DeprecatedColors } from '../../../lib/formatHandlers/utils/color'; import { expectHtml } from 'roosterjs-editor-dom/test/DomTestHelper'; import { BackgroundColorFormat, @@ -62,6 +63,16 @@ describe('backgroundColorFormatHandler.parse', () => { expect(format.backgroundColor).toBe('red'); }); + + DeprecatedColors.forEach(color => { + it('Remove deprecated color ' + color, () => { + div.style.backgroundColor = color; + + backgroundColorFormatHandler.parse(format, div, context, {}); + + expect(format.backgroundColor).toBe(undefined); + }); + }); }); describe('backgroundColorFormatHandler.apply', () => { diff --git a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/segment/textColorFormatHandlerTest.ts b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/segment/textColorFormatHandlerTest.ts index dc60a6702ee..1e7bcb1a68b 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/segment/textColorFormatHandlerTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/segment/textColorFormatHandlerTest.ts @@ -1,6 +1,7 @@ import DarkColorHandlerImpl from 'roosterjs-editor-core/lib/editor/DarkColorHandlerImpl'; import { createDomToModelContext } from '../../../lib/domToModel/context/createDomToModelContext'; import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext'; +import { DeprecatedColors } from '../../../lib'; import { expectHtml } from 'roosterjs-editor-dom/test/DomTestHelper'; import { textColorFormatHandler } from '../../../lib/formatHandlers/segment/textColorFormatHandler'; import { @@ -87,6 +88,16 @@ describe('textColorFormatHandler.parse', () => { expect(format.textColor).toBe('red'); }); + + DeprecatedColors.forEach(color => { + it('Remove deprecated color ' + color, () => { + div.style.backgroundColor = color; + + textColorFormatHandler.parse(format, div, context, {}); + + expect(format.textColor).toBe(undefined); + }); + }); }); describe('textColorFormatHandler.apply', () => { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/ContentModelPastePlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/ContentModelPastePlugin.ts index a86c294362d..70455d24cf9 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/ContentModelPastePlugin.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/ContentModelPastePlugin.ts @@ -2,8 +2,8 @@ import addParser from './utils/addParser'; import ContentModelBeforePasteEvent from '../../../publicTypes/event/ContentModelBeforePasteEvent'; import { chainSanitizerCallback, getPasteSource } from 'roosterjs-editor-dom'; import { ContentModelBlockFormat, FormatParser } from 'roosterjs-content-model-types'; +import { deprecatedBorderColorParser } from './utils/deprecatedColorParser'; import { IContentModelEditor } from '../../../publicTypes/IContentModelEditor'; -import { parseDeprecatedColor } from './utils/deprecatedColorParser'; import { parseLink } from './utils/linkParser'; import { processPastedContentFromExcel } from './Excel/processPastedContentFromExcel'; import { processPastedContentFromPowerPoint } from './PowerPoint/processPastedContentFromPowerPoint'; @@ -80,7 +80,7 @@ export default class ContentModelPastePlugin implements EditorPlugin { if (!ev.domToModelOption) { return; } - const pasteSource = getPasteSource(event, false); + const pasteSource = getPasteSource(ev, false); switch (pasteSource) { case KnownPasteSourceType.WordDesktop: processPastedContentFromWordDesktop(ev); @@ -90,16 +90,13 @@ export default class ContentModelPastePlugin implements EditorPlugin { break; case KnownPasteSourceType.ExcelOnline: case KnownPasteSourceType.ExcelDesktop: - if ( - event.pasteType === PasteType.Normal || - event.pasteType === PasteType.MergeFormat - ) { + if (ev.pasteType === PasteType.Normal || ev.pasteType === PasteType.MergeFormat) { // Handle HTML copied from Excel processPastedContentFromExcel(ev, this.editor.getTrustedHTMLHandler()); } break; case KnownPasteSourceType.GoogleSheets: - event.sanitizingOption.additionalTagReplacements[GOOGLE_SHEET_NODE_NAME] = '*'; + ev.sanitizingOption.additionalTagReplacements[GOOGLE_SHEET_NODE_NAME] = '*'; break; case KnownPasteSourceType.PowerPointDesktop: processPastedContentFromPowerPoint(ev, this.editor.getTrustedHTMLHandler()); @@ -107,15 +104,16 @@ export default class ContentModelPastePlugin implements EditorPlugin { } addParser(ev.domToModelOption, 'link', parseLink); - parseDeprecatedColor(ev.sanitizingOption); + addParser(ev.domToModelOption, 'tableCell', deprecatedBorderColorParser); + addParser(ev.domToModelOption, 'table', deprecatedBorderColorParser); sanitizeBlockStyles(ev.sanitizingOption); - if (event.pasteType === PasteType.MergeFormat) { + if (ev.pasteType === PasteType.MergeFormat) { addParser(ev.domToModelOption, 'block', blockElementParser); addParser(ev.domToModelOption, 'listLevel', blockElementParser); } - event.sanitizingOption.unknownTagReplacement = this.unknownTagReplacement; + ev.sanitizingOption.unknownTagReplacement = this.unknownTagReplacement; } } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/utils/deprecatedColorParser.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/utils/deprecatedColorParser.ts index cd036ca0355..1165c50f121 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/utils/deprecatedColorParser.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/utils/deprecatedColorParser.ts @@ -1,41 +1,21 @@ -import { chainSanitizerCallback } from 'roosterjs-editor-dom'; -import { HtmlSanitizerOptions } from 'roosterjs-editor-types'; - -const DeprecatedColorList: string[] = [ - 'activeborder', - 'activecaption', - 'appworkspace', - 'background', - 'buttonhighlight', - 'buttonshadow', - 'captiontext', - 'inactiveborder', - 'inactivecaption', - 'inactivecaptiontext', - 'infobackground', - 'infotext', - 'menu', - 'menutext', - 'scrollbar', - 'threeddarkshadow', - 'threedface', - 'threedhighlight', - 'threedlightshadow', - 'threedfhadow', - 'window', - 'windowframe', - 'windowtext', -]; +import { BorderFormat, FormatParser } from 'roosterjs-content-model-types'; +import { BorderKeys, DeprecatedColors } from 'roosterjs-content-model-dom'; /** * @internal */ -export function parseDeprecatedColor(sanitizingOption: Required) { - ['color', 'background-color'].forEach(property => { - chainSanitizerCallback( - sanitizingOption.cssStyleCallbacks, - property, - (value: string) => DeprecatedColorList.indexOf(value) < 0 - ); +export const deprecatedBorderColorParser: FormatParser = ( + format: BorderFormat +): void => { + BorderKeys.forEach(key => { + const value = format[key]; + let color: string = ''; + if ( + value && + DeprecatedColors.some(dColor => value.indexOf(dColor) > -1 && (color = dColor)) + ) { + const newValue = value.replace(color, '').trimRight(); + format[key] = newValue; + } }); -} +}; diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelPastePluginTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelPastePluginTest.ts index d6ab2e4b8bf..93c4849ef4e 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelPastePluginTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelPastePluginTest.ts @@ -13,8 +13,9 @@ import { KnownPasteSourceType, PasteType, PluginEventType } from 'roosterjs-edit const trustedHTMLHandler = 'mock'; const GOOGLE_SHEET_NODE_NAME = 'google-sheets-html-origin'; +const DEFAULT_TIMES_ADD_PARSER_CALLED = 3; -describe('Paste', () => { +describe('Content Model Paste Plugin Test', () => { let editor: IContentModelEditor; beforeEach(() => { @@ -89,8 +90,8 @@ describe('Paste', () => { expect(event.domToModelOption.processorOverride?.element).toBe( WordDesktopFile.wordDesktopElementProcessor ); - expect(addParser.default).toHaveBeenCalledTimes(4); - expect(chainSanitizerCallbackFile.default).toHaveBeenCalledTimes(5); + expect(addParser.default).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED + 3); + expect(chainSanitizerCallbackFile.default).toHaveBeenCalledTimes(3); expect(setProcessor.setProcessor).toHaveBeenCalledTimes(1); }); @@ -106,9 +107,9 @@ describe('Paste', () => { event, trustedHTMLHandler ); - expect(addParser.default).toHaveBeenCalledTimes(4); + expect(addParser.default).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED + 3); expect(setProcessor.setProcessor).toHaveBeenCalledTimes(0); - expect(chainSanitizerCallbackFile.default).toHaveBeenCalledTimes(3); + expect(chainSanitizerCallbackFile.default).toHaveBeenCalledTimes(1); }); it('Excel | image', () => { @@ -123,8 +124,8 @@ describe('Paste', () => { event, trustedHTMLHandler ); - expect(addParser.default).toHaveBeenCalledTimes(1); - expect(chainSanitizerCallbackFile.default).toHaveBeenCalledTimes(3); + expect(addParser.default).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED); + expect(chainSanitizerCallbackFile.default).toHaveBeenCalledTimes(1); expect(setProcessor.setProcessor).toHaveBeenCalledTimes(0); }); @@ -139,9 +140,9 @@ describe('Paste', () => { event, trustedHTMLHandler ); - expect(addParser.default).toHaveBeenCalledTimes(2); + expect(addParser.default).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED + 1); expect(setProcessor.setProcessor).toHaveBeenCalledTimes(0); - expect(chainSanitizerCallbackFile.default).toHaveBeenCalledTimes(3); + expect(chainSanitizerCallbackFile.default).toHaveBeenCalledTimes(1); }); it('Excel Online', () => { @@ -155,9 +156,9 @@ describe('Paste', () => { event, trustedHTMLHandler ); - expect(addParser.default).toHaveBeenCalledTimes(2); + expect(addParser.default).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED + 1); expect(setProcessor.setProcessor).toHaveBeenCalledTimes(0); - expect(chainSanitizerCallbackFile.default).toHaveBeenCalledTimes(3); + expect(chainSanitizerCallbackFile.default).toHaveBeenCalledTimes(1); }); it('Power Point', () => { @@ -173,9 +174,9 @@ describe('Paste', () => { event, trustedHTMLHandler ); - expect(addParser.default).toHaveBeenCalledTimes(1); + expect(addParser.default).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED); expect(setProcessor.setProcessor).toHaveBeenCalledTimes(0); - expect(chainSanitizerCallbackFile.default).toHaveBeenCalledTimes(3); + expect(chainSanitizerCallbackFile.default).toHaveBeenCalledTimes(1); }); it('Wac', () => { @@ -186,9 +187,9 @@ describe('Paste', () => { plugin.onPluginEvent(event); expect(WacFile.processPastedContentWacComponents).toHaveBeenCalledWith(event); - expect(addParser.default).toHaveBeenCalledTimes(5); + expect(addParser.default).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED + 4); expect(setProcessor.setProcessor).toHaveBeenCalledTimes(4); - expect(chainSanitizerCallbackFile.default).toHaveBeenCalledTimes(3); + expect(chainSanitizerCallbackFile.default).toHaveBeenCalledTimes(1); }); it('Default', () => { @@ -197,9 +198,9 @@ describe('Paste', () => { plugin.initialize(editor); plugin.onPluginEvent(event); - expect(addParser.default).toHaveBeenCalledTimes(1); + expect(addParser.default).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED); expect(setProcessor.setProcessor).toHaveBeenCalledTimes(0); - expect(chainSanitizerCallbackFile.default).toHaveBeenCalledTimes(3); + expect(chainSanitizerCallbackFile.default).toHaveBeenCalledTimes(1); }); it('Google Sheets', () => { @@ -208,9 +209,9 @@ describe('Paste', () => { plugin.initialize(editor); plugin.onPluginEvent(event); - expect(addParser.default).toHaveBeenCalledTimes(1); + expect(addParser.default).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED); expect(setProcessor.setProcessor).toHaveBeenCalledTimes(0); - expect(chainSanitizerCallbackFile.default).toHaveBeenCalledTimes(3); + expect(chainSanitizerCallbackFile.default).toHaveBeenCalledTimes(1); expect( event.sanitizingOption.additionalTagReplacements[GOOGLE_SHEET_NODE_NAME] ).toEqual('*'); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/deprecatedColorParserTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/deprecatedColorParserTest.ts new file mode 100644 index 00000000000..23fe6db6960 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/deprecatedColorParserTest.ts @@ -0,0 +1,41 @@ +import { deprecatedBorderColorParser } from '../../../../lib/editor/plugins/PastePlugin/utils/deprecatedColorParser'; + +const DeprecatedColors: string[] = [ + 'activeborder', + 'activecaption', + 'appworkspace', + 'background', + 'buttonhighlight', + 'buttonshadow', + 'captiontext', + 'inactiveborder', + 'inactivecaption', + 'inactivecaptiontext', + 'infobackground', + 'infotext', + 'menu', + 'menutext', + 'scrollbar', + 'threeddarkshadow', + 'threedface', + 'threedfhadow', + 'threedhighlight', + 'threedlightshadow', + 'window', + 'windowframe', + 'windowtext', +]; + +describe('deprecateColorParserTests |', () => { + DeprecatedColors.forEach(color => { + it('Remove ' + color + ' in borderTop', () => { + const format = { borderTop: '1pt solid ' + color }; + + deprecatedBorderColorParser(format, null, null, null); + + expect(format).toEqual({ + borderTop: '1pt solid', + }); + }); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromExcelOnlineTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromExcelOnlineTest.ts index 7e66eb2f79a..657f14506eb 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromExcelOnlineTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromExcelOnlineTest.ts @@ -2,7 +2,7 @@ import * as processPastedContentFromExcel from '../../../../../lib/editor/plugin import paste from '../../../../../lib/publicApi/utils/paste'; import { ClipboardData } from 'roosterjs-editor-types'; import { IContentModelEditor } from '../../../../../lib/publicTypes/IContentModelEditor'; -import { initEditor } from './cmPasteFromExcelTest'; +import { initEditor } from './testUtils'; import { tableProcessor } from 'roosterjs-content-model-dom'; const ID = 'CM_Paste_From_ExcelOnline_E2E'; @@ -14,7 +14,7 @@ const clipboardData = ({ rawHtml: "\r\n\r\n
TestTest
\r\n\r\n", customValues: {}, - snapshotBeforePaste: '
', + snapshotBeforePaste: '

', htmlFirstLevelChildTags: ['DIV'], html: "
TestTest
", diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromExcelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromExcelTest.ts index f6e10ff5bf2..41931b35e92 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromExcelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromExcelTest.ts @@ -1,29 +1,10 @@ import * as processPastedContentFromExcel from '../../../../../lib/editor/plugins/PastePlugin/Excel/processPastedContentFromExcel'; -import ContentModelEditor from '../../../../../lib/editor/ContentModelEditor'; -import ContentModelPastePlugin from '../../../../../lib/editor/plugins/PastePlugin/ContentModelPastePlugin'; import paste from '../../../../../lib/publicApi/utils/paste'; import { Browser } from 'roosterjs-editor-dom'; -import { ClipboardData, ExperimentalFeatures } from 'roosterjs-editor-types'; +import { ClipboardData } from 'roosterjs-editor-types'; +import { IContentModelEditor } from '../../../../../lib/publicTypes/IContentModelEditor'; +import { initEditor } from './testUtils'; import { tableProcessor } from 'roosterjs-content-model-dom'; -import { - ContentModelEditorOptions, - IContentModelEditor, -} from '../../../../../lib/publicTypes/IContentModelEditor'; - -export function initEditor(id: string) { - let node = document.createElement('div'); - node.id = id; - document.body.insertBefore(node, document.body.childNodes[0]); - - let options: ContentModelEditorOptions = { - plugins: [new ContentModelPastePlugin()], - experimentalFeatures: [ExperimentalFeatures.ContentModelPaste], - }; - - let editor = new ContentModelEditor(node as HTMLDivElement, options); - - return editor as IContentModelEditor; -} const ID = 'CM_Paste_From_Excel_E2E'; const clipboardData = ({ diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromWacTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromWacTest.ts index a2bfefe78bf..74e44490a06 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromWacTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromWacTest.ts @@ -1,8 +1,9 @@ import * as processPastedContentWacComponents from '../../../../../lib/editor/plugins/PastePlugin/WacComponents/processPastedContentWacComponents'; import paste from '../../../../../lib/publicApi/utils/paste'; import { ClipboardData } from 'roosterjs-editor-types'; +import { DomToModelOption } from 'roosterjs-content-model-types'; +import { expectEqual, initEditor } from './testUtils'; import { IContentModelEditor } from '../../../../../lib/publicTypes/IContentModelEditor'; -import { initEditor } from './cmPasteFromExcelTest'; import { tableProcessor } from 'roosterjs-content-model-dom'; const ID = 'CM_Paste_From_WORD_Online_E2E'; @@ -11,11 +12,8 @@ const clipboardData = ({ text: 'asd\r\n\r\nTest ', image: null, files: [], - rawHtml: - '\r\n\r\n

asd 

  • Test 

\r\n\r\n', customValues: {}, - snapshotBeforePaste: - '
Test 
', + snapshotBeforePaste: '

', htmlFirstLevelChildTags: ['DIV', 'DIV'], html: '

asd 

  • Test 

', @@ -33,6 +31,8 @@ describe(ID, () => { }); it('E2E', () => { + clipboardData.rawHtml = + '\r\n\r\n

asd 

  • Test 

\r\n\r\n'; spyOn( processPastedContentWacComponents, 'processPastedContentWacComponents' @@ -49,4 +49,314 @@ describe(ID, () => { processPastedContentWacComponents.processPastedContentWacComponents ).toHaveBeenCalled(); }); + + it('Content from Word Online with table', () => { + clipboardData.rawHtml = + '

Test Table 

Test Table 

 

'; + + paste(editor, clipboardData); + + const model = editor.createContentModel({ + processorOverride: { + table: tableProcessor, + }, + }); + + expectEqual(model, { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'FormatContainer', + tagName: 'div', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'FormatContainer', + tagName: 'div', + blocks: [ + { + blockType: 'Table', + rows: [ + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'FormatContainer', + tagName: 'div', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Test Table ', + format: { + letterSpacing: + 'normal', + fontFamily: + 'Aptos, Aptos_EmbeddedFont, Aptos_MSFontService, sans-serif', + fontSize: '11pt', + italic: false, + fontWeight: + 'normal', + textColor: + 'rgb(0, 0, 0)', + lineHeight: + '19.7625px', + }, + }, + ], + format: { + direction: 'ltr', + textAlign: 'start', + whiteSpace: 'pre-wrap', + marginLeft: '0px', + marginRight: '0px', + marginTop: '0px', + marginBottom: '0px', + }, + segmentFormat: { + fontWeight: 'normal', + }, + decorator: { + tagName: 'p', + format: {}, + }, + }, + ], + format: { + direction: 'ltr', + textAlign: 'start', + whiteSpace: 'normal', + marginTop: '0px', + marginRight: '0px', + marginBottom: '0px', + marginLeft: '0px', + paddingTop: '0px', + paddingRight: '7px', + paddingBottom: '0px', + paddingLeft: '7px', + }, + }, + ], + format: { + direction: 'ltr', + textAlign: 'start', + whiteSpace: 'normal', + borderTop: '1px solid', + borderRight: '1px solid', + borderBottom: '1px solid', + borderLeft: '1px solid', + verticalAlign: 'top', + width: '312px', + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: { + celllook: '0', + }, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'FormatContainer', + tagName: 'div', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Test Table ', + format: { + letterSpacing: + 'normal', + fontFamily: + 'Aptos, Aptos_EmbeddedFont, Aptos_MSFontService, sans-serif', + fontSize: '11pt', + italic: false, + fontWeight: + 'normal', + textColor: + 'rgb(0, 0, 0)', + lineHeight: + '19.7625px', + }, + }, + ], + format: { + direction: 'ltr', + textAlign: 'start', + whiteSpace: 'pre-wrap', + marginLeft: '0px', + marginRight: '0px', + marginTop: '0px', + marginBottom: '0px', + }, + segmentFormat: { + fontWeight: 'normal', + }, + decorator: { + tagName: 'p', + format: {}, + }, + }, + ], + format: { + direction: 'ltr', + textAlign: 'start', + whiteSpace: 'normal', + marginTop: '0px', + marginRight: '0px', + marginBottom: '0px', + marginLeft: '0px', + paddingTop: '0px', + paddingRight: '7px', + paddingBottom: '0px', + paddingLeft: '7px', + }, + }, + ], + format: { + direction: 'ltr', + textAlign: 'start', + whiteSpace: 'normal', + borderTop: '1px solid', + borderRight: '1px solid', + borderBottom: '1px solid', + borderLeft: '1px solid', + verticalAlign: 'top', + width: '312px', + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: { + celllook: '0', + }, + }, + ], + }, + ], + format: { + direction: 'ltr', + textAlign: 'start', + whiteSpace: 'normal', + backgroundColor: 'transparent', + marginTop: '0px', + marginRight: '0px', + marginBottom: '0px', + marginLeft: '0px', + width: '0px', + tableLayout: 'fixed', + borderCollapse: true, + }, + widths: [], + dataset: { + tablestyle: 'MsoTableGrid', + tablelook: '1696', + }, + }, + ], + format: { + direction: 'ltr', + textAlign: 'start', + whiteSpace: 'normal', + marginTop: '2px', + marginRight: '0px', + marginBottom: '2px', + }, + }, + ], + format: { + direction: 'ltr', + textAlign: 'start', + whiteSpace: 'normal', + backgroundColor: 'rgb(255, 255, 255)', + marginTop: '0px', + marginRight: '0px', + marginBottom: '0px', + marginLeft: '0px', + }, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'FormatContainer', + tagName: 'div', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: ' ', + format: { + letterSpacing: 'normal', + fontFamily: + 'Aptos, Aptos_EmbeddedFont, Aptos_MSFontService, sans-serif', + fontSize: '12pt', + italic: false, + fontWeight: 'normal', + textColor: 'rgb(0, 0, 0)', + lineHeight: '20.925px', + }, + }, + ], + format: { + direction: 'ltr', + textAlign: 'start', + whiteSpace: 'pre-wrap', + marginTop: '0px', + marginRight: '0px', + marginBottom: '0px', + marginLeft: '0px', + }, + segmentFormat: { + fontWeight: 'normal', + }, + decorator: { + tagName: 'p', + format: {}, + }, + }, + ], + format: { + direction: 'ltr', + textAlign: 'start', + whiteSpace: 'normal', + backgroundColor: 'rgb(255, 255, 255)', + marginTop: '0px', + marginRight: '0px', + marginBottom: '0px', + marginLeft: '0px', + }, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }); + }); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromWordTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromWordTest.ts index 09564a147cd..f95dee1e6ec 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromWordTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromWordTest.ts @@ -1,9 +1,11 @@ -import * as processPastedContentFromWordDesktop from '../../../../../lib/editor/plugins/PastePlugin/WordDesktop/processPastedContentFromWordDesktop'; +import * as wordFile from '../../../../../lib/editor/plugins/PastePlugin/WordDesktop/processPastedContentFromWordDesktop'; import paste from '../../../../../lib/publicApi/utils/paste'; import { ClipboardData } from 'roosterjs-editor-types'; +import { cloneModel } from '../../../../../lib/modelApi/common/cloneModel'; import { DomToModelOption } from 'roosterjs-content-model-types'; +import { expectEqual, initEditor } from './testUtils'; import { IContentModelEditor } from '../../../../../lib/publicTypes/IContentModelEditor'; -import { initEditor } from './cmPasteFromExcelTest'; +import { itChromeOnly } from 'roosterjs-editor-dom/test/DomTestHelper'; import { tableProcessor } from 'roosterjs-content-model-dom'; const ID = 'CM_Paste_From_WORD_E2E'; @@ -12,11 +14,8 @@ const clipboardData = ({ text: 'Test\r\nasdsad\r\n', image: null, files: [], - rawHtml: - '\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n

Test

\r\n\r\n

asdsad

\r\n\r\n\r\n\r\n\r\n\r\n', + rawHtml: '', customValues: {}, - snapshotBeforePaste: - '

Test

', htmlFirstLevelChildTags: ['P', 'P'], html: '

Test

asdsad

', @@ -27,27 +26,267 @@ describe(ID, () => { beforeEach(() => { editor = initEditor(ID); + spyOn(wordFile, 'processPastedContentFromWordDesktop').and.callThrough(); + delete clipboardData.snapshotBeforePaste; }); afterEach(() => { document.getElementById(ID)?.remove(); }); - it('E2E', () => { - spyOn( - processPastedContentFromWordDesktop, - 'processPastedContentFromWordDesktop' - ).and.callThrough(); + itChromeOnly('E2E', () => { + clipboardData.rawHtml = + '\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n

Test

\r\n\r\n

asdsad

\r\n\r\n\r\n\r\n'; + paste(editor, clipboardData); + + const model = cloneModel( + editor.createContentModel({ + processorOverride: { + table: tableProcessor, + }, + }), + { + includeCachedElement: false, + } + ); + + expect(wordFile.processPastedContentFromWordDesktop).toHaveBeenCalled(); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + cachedElement: undefined, + isImplicit: undefined, + segments: [ + { + text: 'Test ', + segmentType: 'Text', + isSelected: undefined, + format: { fontFamily: 'Calibri, sans-serif', fontSize: '11pt' }, + }, + ], + segmentFormat: undefined, + blockType: 'Paragraph', + format: {}, + decorator: { tagName: 'p', format: {} }, + }, + { + cachedElement: undefined, + isImplicit: undefined, + segments: [ + { + text: 'asdsad', + segmentType: 'Text', + isSelected: undefined, + format: { fontFamily: 'Calibri, sans-serif', fontSize: '11pt' }, + }, + { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, + ], + segmentFormat: undefined, + blockType: 'Paragraph', + format: { + lineHeight: undefined, + marginTop: '0in', + marginRight: '0in', + marginBottom: '8pt', + marginLeft: '0in', + }, + decorator: { tagName: 'p', format: {} }, + }, + ], + format: { + fontWeight: undefined, + italic: undefined, + underline: undefined, + fontFamily: undefined, + fontSize: undefined, + textColor: undefined, + backgroundColor: undefined, + }, + }); + }); + + itChromeOnly('E2E Content with Table, borders and text using windowtext', () => { + clipboardData.rawHtml = + '

Asdasdsad

asdadasd

 

asdsadasdasdsadasdsadsad

 

'; paste(editor, clipboardData); - editor.createContentModel({ + + const model = editor.createContentModel({ processorOverride: { table: tableProcessor, }, }); - expect( - processPastedContentFromWordDesktop.processPastedContentFromWordDesktop - ).toHaveBeenCalled(); + expect(wordFile.processPastedContentFromWordDesktop).toHaveBeenCalled(); + expectEqual(model, { + blockGroupType: 'Document', + blocks: [ + { + widths: [], + rows: [ + { + height: 0, + cells: [ + { + spanAbove: false, + spanLeft: false, + isHeader: false, + blockGroupType: 'TableCell', + blocks: [ + { + segments: [ + { + text: 'Asdasdsad ', + segmentType: 'Text', + format: {}, + }, + ], + blockType: 'Paragraph', + format: { + lineHeight: 'normal', + marginTop: '1em', + marginBottom: '0in', + }, + decorator: { + tagName: 'p', + format: {}, + }, + }, + ], + format: { + borderTop: '1pt solid', + borderRight: '1pt solid', + borderBottom: '1pt solid', + borderLeft: '1pt solid', + paddingTop: '0in', + paddingRight: '5.4pt', + paddingBottom: '0in', + paddingLeft: '5.4pt', + verticalAlign: 'top', + width: '233.75pt', + }, + dataset: {}, + }, + { + spanAbove: false, + spanLeft: false, + isHeader: false, + blockGroupType: 'TableCell', + blocks: [ + { + segments: [ + { + text: 'asdadasd ', + segmentType: 'Text', + format: {}, + }, + ], + blockType: 'Paragraph', + format: { + lineHeight: 'normal', + marginTop: '1em', + marginBottom: '0in', + }, + decorator: { + tagName: 'p', + format: {}, + }, + }, + ], + format: { + borderTop: '1pt solid', + borderRight: '1pt solid', + borderBottom: '1pt solid', + borderLeft: '', + paddingTop: '0in', + paddingRight: '5.4pt', + paddingBottom: '0in', + paddingLeft: '5.4pt', + verticalAlign: 'top', + width: '233.75pt', + }, + dataset: {}, + }, + ], + format: {}, + }, + ], + blockType: 'Table', + format: { + borderTop: '', + borderRight: '', + borderBottom: '', + borderLeft: '', + borderCollapse: true, + }, + dataset: {}, + }, + { + segments: [ + { + text: ' ', + segmentType: 'Text', + format: {}, + }, + ], + blockType: 'Paragraph', + format: { + marginTop: '1em', + marginBottom: '1em', + }, + decorator: { + tagName: 'p', + format: {}, + }, + }, + { + segments: [ + { + text: 'asdsadasdasdsadasdsadsad ', + segmentType: 'Text', + format: {}, + }, + ], + blockType: 'Paragraph', + format: { + marginTop: '1em', + marginBottom: '1em', + }, + decorator: { + tagName: 'p', + format: {}, + }, + }, + { + segments: [ + { + text: ' ', + segmentType: 'Text', + format: {}, + }, + { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, + ], + blockType: 'Paragraph', + format: { + marginTop: '1em', + marginBottom: '1em', + }, + decorator: { + tagName: 'p', + format: {}, + }, + }, + ], + format: {}, + }); }); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/testUtils.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/testUtils.ts new file mode 100644 index 00000000000..8780c350cce --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/testUtils.ts @@ -0,0 +1,37 @@ +import ContentModelEditor from '../../../../../lib/editor/ContentModelEditor'; +import ContentModelPastePlugin from '../../../../../lib/editor/plugins/PastePlugin/ContentModelPastePlugin'; +import { cloneModel } from '../../../../../lib/modelApi/common/cloneModel'; +import { ContentModelDocument } from 'roosterjs-content-model-types'; +import { ExperimentalFeatures } from 'roosterjs-editor-types'; +import { + ContentModelEditorOptions, + IContentModelEditor, +} from '../../../../../lib/publicTypes/IContentModelEditor'; + +export function initEditor(id: string) { + let node = document.createElement('div'); + node.id = id; + document.body.insertBefore(node, document.body.childNodes[0]); + + let options: ContentModelEditorOptions = { + plugins: [new ContentModelPastePlugin()], + experimentalFeatures: [ExperimentalFeatures.ContentModelPaste], + }; + + let editor = new ContentModelEditor(node as HTMLDivElement, options); + + return editor as IContentModelEditor; +} + +export function expectEqual(model1: ContentModelDocument, model2: ContentModelDocument) { + expect( + /// Remove Cached elements and undefined properties + JSON.parse( + JSON.stringify( + cloneModel(model1, { + includeCachedElement: false, + }) + ) + ) + ).toEqual(model2); +} diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/processPastedContentFromWacTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/processPastedContentFromWacTest.ts index 0a94771ef6e..19453740ffb 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/processPastedContentFromWacTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/processPastedContentFromWacTest.ts @@ -1730,7 +1730,6 @@ describe('wordOnlineHandler', () => { }, segmentFormat: { fontWeight: 'normal', - textColor: 'windowtext', italic: false, }, decorator: { @@ -1842,7 +1841,6 @@ describe('wordOnlineHandler', () => { }, segmentFormat: { fontWeight: 'normal', - textColor: 'windowtext', italic: false, }, decorator: { @@ -1959,7 +1957,6 @@ describe('wordOnlineHandler', () => { }, segmentFormat: { fontWeight: 'normal', - textColor: 'windowtext', italic: false, }, decorator: { @@ -2085,7 +2082,6 @@ describe('wordOnlineHandler', () => { }, segmentFormat: { fontWeight: 'normal', - textColor: 'windowtext', italic: false, }, decorator: { @@ -2132,7 +2128,6 @@ describe('wordOnlineHandler', () => { }, segmentFormat: { fontWeight: 'normal', - textColor: 'windowtext', italic: false, }, decorator: { @@ -2233,7 +2228,6 @@ describe('wordOnlineHandler', () => { }, segmentFormat: { fontWeight: 'normal', - textColor: 'windowtext', italic: false, }, decorator: { @@ -2281,7 +2275,6 @@ describe('wordOnlineHandler', () => { }, segmentFormat: { fontWeight: 'normal', - textColor: 'windowtext', italic: false, }, decorator: { @@ -2327,7 +2320,6 @@ describe('wordOnlineHandler', () => { }, segmentFormat: { fontWeight: 'normal', - textColor: 'windowtext', italic: false, }, decorator: { @@ -2374,7 +2366,6 @@ describe('wordOnlineHandler', () => { }, segmentFormat: { fontWeight: 'normal', - textColor: 'windowtext', italic: false, }, decorator: { @@ -2420,7 +2411,6 @@ describe('wordOnlineHandler', () => { }, segmentFormat: { fontWeight: 'normal', - textColor: 'windowtext', italic: false, }, decorator: { @@ -2517,7 +2507,6 @@ describe('wordOnlineHandler', () => { }, segmentFormat: { fontWeight: 'normal', - textColor: 'windowtext', italic: false, }, decorator: { @@ -2618,7 +2607,6 @@ describe('wordOnlineHandler', () => { }, segmentFormat: { fontWeight: 'normal', - textColor: 'windowtext', italic: false, }, decorator: { diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts index c9c03cce21c..8f6bed17ffb 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts @@ -3,16 +3,17 @@ import * as domToContentModel from 'roosterjs-content-model-dom/lib/domToModel/d import * as ExcelF from '../../../lib/editor/plugins/PastePlugin/Excel/processPastedContentFromExcel'; import * as getPasteSourceF from 'roosterjs-editor-dom/lib/pasteSourceValidations/getPasteSource'; import * as mergeModelFile from '../../../lib/modelApi/common/mergeModel'; -import * as pasteF from '../../../lib/publicApi/utils/paste'; import * as PPT from '../../../lib/editor/plugins/PastePlugin/PowerPoint/processPastedContentFromPowerPoint'; import * as setProcessorF from '../../../lib/editor/plugins/PastePlugin/utils/setProcessor'; import * as WacComponents from '../../../lib/editor/plugins/PastePlugin/WacComponents/processPastedContentWacComponents'; import * as WordDesktopFile from '../../../lib/editor/plugins/PastePlugin/WordDesktop/processPastedContentFromWordDesktop'; import ContentModelEditor from '../../../lib/editor/ContentModelEditor'; import ContentModelPastePlugin from '../../../lib/editor/plugins/PastePlugin/ContentModelPastePlugin'; -import { ContentModelDocument } from 'roosterjs-content-model-types'; -import { createContentModelDocument } from 'roosterjs-content-model-dom'; +import { ContentModelDocument, DomToModelOption } from 'roosterjs-content-model-types'; +import { createContentModelDocument, tableProcessor } from 'roosterjs-content-model-dom'; +import { expectEqual, initEditor } from '../../editor/plugins/paste/e2e/testUtils'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; +import paste, * as pasteF from '../../../lib/publicApi/utils/paste'; import { BeforePasteEvent, ClipboardData, @@ -24,6 +25,8 @@ import { let clipboardData: ClipboardData; +const DEFAULT_TIMES_ADD_PARSER_CALLED = 3; + describe('Paste ', () => { let editor: IContentModelEditor; let addUndoSnapshot: jasmine.Spy; @@ -181,7 +184,7 @@ describe('paste with content model & paste plugin', () => { pasteF.default(editor!, clipboardData); expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(1); - expect(addParserF.default).toHaveBeenCalledTimes(4); + expect(addParserF.default).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED + 3); expect(WordDesktopFile.processPastedContentFromWordDesktop).toHaveBeenCalledTimes(1); }); @@ -192,7 +195,7 @@ describe('paste with content model & paste plugin', () => { pasteF.default(editor!, clipboardData); expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(4); - expect(addParserF.default).toHaveBeenCalledTimes(5); + expect(addParserF.default).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED + 4); expect(WacComponents.processPastedContentWacComponents).toHaveBeenCalledTimes(1); }); @@ -203,7 +206,7 @@ describe('paste with content model & paste plugin', () => { pasteF.default(editor!, clipboardData); expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(0); - expect(addParserF.default).toHaveBeenCalledTimes(2); + expect(addParserF.default).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED + 1); expect(ExcelF.processPastedContentFromExcel).toHaveBeenCalledTimes(1); }); @@ -214,7 +217,7 @@ describe('paste with content model & paste plugin', () => { pasteF.default(editor!, clipboardData); expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(0); - expect(addParserF.default).toHaveBeenCalledTimes(2); + expect(addParserF.default).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED + 1); expect(ExcelF.processPastedContentFromExcel).toHaveBeenCalledTimes(1); }); @@ -225,7 +228,7 @@ describe('paste with content model & paste plugin', () => { pasteF.default(editor!, clipboardData); expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(0); - expect(addParserF.default).toHaveBeenCalledTimes(1); + expect(addParserF.default).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED); expect(PPT.processPastedContentFromPowerPoint).toHaveBeenCalledTimes(1); }); @@ -549,3 +552,141 @@ describe('mergePasteContent', () => { ); }); }); + +describe('Paste with clipboardData', () => { + let editor: IContentModelEditor = undefined!; + const ID = 'EDITOR_ID'; + + beforeEach(() => { + editor = initEditor(ID); + clipboardData = ({ + types: ['text/plain', 'text/html'], + text: 'Test\r\nasdsad\r\n', + image: null, + files: [], + rawHtml: '', + customValues: {}, + htmlFirstLevelChildTags: ['P', 'P'], + html: '', + }); + }); + + afterEach(() => { + document.getElementById(ID)?.remove(); + }); + + it('Remove windowtext from clipboardContent', () => { + clipboardData.rawHtml = + '

Test

'; + + paste(editor, clipboardData); + + const model = editor.createContentModel({ + processorOverride: { + table: tableProcessor, + }, + }); + + expectEqual(model, { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Test', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + decorator: { + tagName: 'p', + format: {}, + }, + }, + ], + format: {}, + }); + }); + + it('Remove unsupported url of link from clipboardContent', () => { + clipboardData.rawHtml = + 'Link'; + + paste(editor, clipboardData); + + const model = editor.createContentModel({ + processorOverride: { + table: tableProcessor, + }, + }); + + expectEqual(model, { + blockGroupType: 'Document', + blocks: [ + { + segments: [ + { text: 'Link', segmentType: 'Text', format: {} }, + { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, + ], + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + }); + }); + + it('Keep supported url of link from clipboardContent', () => { + clipboardData.rawHtml = + 'Link'; + + paste(editor, clipboardData); + + const model = editor.createContentModel({ + processorOverride: { + table: tableProcessor, + }, + }); + + expectEqual(model, { + blockGroupType: 'Document', + blocks: [ + { + segments: [ + { + text: 'Link', + segmentType: 'Text', + format: {}, + link: { + format: { + underline: true, + href: 'https://github.com/microsoft/roosterjs', + }, + dataset: {}, + }, + }, + { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, + ], + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + }); + }); +}); From 2aec57c32d2e6a92b39c338b07407d99656744d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Mon, 4 Sep 2023 15:32:38 -0400 Subject: [PATCH 32/75] reselection --- .../lib/plugins/ImageEdit/ImageEdit.ts | 8 +++- .../test/imageEdit/imageEditTest.ts | 42 +++++++++++++------ 2 files changed, 37 insertions(+), 13 deletions(-) diff --git a/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/ImageEdit.ts b/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/ImageEdit.ts index 7173d0e0f49..ea2435f5053 100644 --- a/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/ImageEdit.ts +++ b/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/ImageEdit.ts @@ -218,6 +218,13 @@ export default class ImageEdit implements EditorPlugin { this.setEditingImage(null); } break; + case PluginEventType.MouseUp: + // When mouse up, if the image and the shadow span exists, the editing mode is on. + // To make sure the selection did not jump to the shadow root, reselect the image. + if (this.image && this.shadowSpan) { + this.editor?.select(this.image); + } + break; case PluginEventType.KeyDown: this.setEditingImage(null); break; @@ -351,7 +358,6 @@ export default class ImageEdit implements EditorPlugin { ...this.createDndHelpers(ImageEditElementClass.CropHandle, Cropper), ...this.createDndHelpers(ImageEditElementClass.CropContainer, Cropper), ]; - this.editor.select(this.image); } } diff --git a/packages/roosterjs-editor-plugins/test/imageEdit/imageEditTest.ts b/packages/roosterjs-editor-plugins/test/imageEdit/imageEditTest.ts index 72dd5b694c8..6a317d352f2 100644 --- a/packages/roosterjs-editor-plugins/test/imageEdit/imageEditTest.ts +++ b/packages/roosterjs-editor-plugins/test/imageEdit/imageEditTest.ts @@ -1,7 +1,13 @@ import * as TestHelper from '../TestHelper'; import ImageEditInfo from '../../lib/plugins/ImageEdit/types/ImageEditInfo'; -import { IEditor, ImageEditOperation, PluginEvent, PluginEventType } from 'roosterjs-editor-types'; import { ImageEdit } from '../../lib/ImageEdit'; +import { + IEditor, + ImageEditOperation, + PluginEvent, + PluginEventType, + SelectionRangeTypes, +} from 'roosterjs-editor-types'; import { getEditInfoFromImage, saveEditInfo, @@ -225,7 +231,7 @@ describe('ImageEdit | rotate and flip', () => { }); }); -describe('ImageEdit | plugin events | quitting', () => { +describe('ImageEdit | plugin events | ', () => { let editor: IEditor; const TEST_ID = 'imageEditTest'; let plugin: ImageEdit; @@ -270,22 +276,34 @@ describe('ImageEdit | plugin events | quitting', () => { target.dispatchEvent(event); }; - it('image selection quit editing', () => { - const IMG_ID = 'IMAGE_ID_QUIT'; + const mouseUp = (target: HTMLElement, keyNumber: number) => { + const rect = target.getBoundingClientRect(); + const event = new MouseEvent('mouseup', { + view: window, + bubbles: true, + cancelable: true, + clientX: rect.left, + clientY: rect.top, + shiftKey: false, + button: keyNumber, + }); + target.dispatchEvent(event); + }; + + it('mouse up | keep image selected if click in a image', () => { + const IMG_ID = 'IMAGE_ID_MOUSE'; const SPAN_ID = 'SPAN_ID'; const content = ``; editor.setContent(content); const image = document.getElementById(IMG_ID) as HTMLImageElement; editor.focus(); editor.select(image); - expect(setEditingImageSpy).toHaveBeenCalled(); - expect(setEditingImageSpy).toHaveBeenCalledWith( - image as any, - ImageEditOperation.ResizeAndRotate as any - ); + mouseUp(image, 0); + const selection = editor.getSelectionRangeEx(); + expect(selection.type).toBe(SelectionRangeTypes.ImageSelection); }); - it('mousedown quit editing', () => { + it('quitting | mousedown quit editing', () => { const IMG_ID = 'IMAGE_ID_MOUSE'; const SPAN_ID = 'SPAN_ID'; const content = ``; @@ -294,12 +312,12 @@ describe('ImageEdit | plugin events | quitting', () => { const span = document.getElementById(SPAN_ID) as HTMLImageElement; editor.focus(); editor.select(image); - mouseDown(span, 0); + mouseDown(span, 2); expect(setEditingImageSpy).toHaveBeenCalled(); expect(setEditingImageSpy).toHaveBeenCalledWith(null); }); - it('keydown quit editing', () => { + it('quitting | keydown quit editing', () => { const IMG_ID = 'IMAGE_ID'; const content = ``; editor.setContent(content); From d38fa7272ca5e6c8688e7e3a703944f64bb79b63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Mon, 4 Sep 2023 15:40:32 -0400 Subject: [PATCH 33/75] add space --- .../roosterjs-editor-plugins/lib/plugins/ImageEdit/ImageEdit.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/ImageEdit.ts b/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/ImageEdit.ts index ea2435f5053..92b48098683 100644 --- a/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/ImageEdit.ts +++ b/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/ImageEdit.ts @@ -358,6 +358,7 @@ export default class ImageEdit implements EditorPlugin { ...this.createDndHelpers(ImageEditElementClass.CropHandle, Cropper), ...this.createDndHelpers(ImageEditElementClass.CropContainer, Cropper), ]; + this.editor.select(this.image); } } From 4f5bd685c8afa6927196434c4699a812dcc7750b Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Tue, 5 Sep 2023 10:29:43 -0700 Subject: [PATCH 34/75] Content Model: Improve insertEntity (#2047) * Content Model: Improve insertEntity * fix test --- .../lib/publicApi/entity/insertEntity.ts | 2 +- .../test/publicApi/entity/insertEntityTest.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/entity/insertEntity.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/entity/insertEntity.ts index 7fafcddff5e..fb65075c93f 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/entity/insertEntity.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/entity/insertEntity.ts @@ -80,7 +80,7 @@ export default function insertEntity( entityModel, typeof position == 'string' ? position : 'focus', isBlock, - isBlock ? focusAfterEntity : true, + focusAfterEntity, context ); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/entity/insertEntityTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/entity/insertEntityTest.ts index 1699a15787b..78d865ad001 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/entity/insertEntityTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/entity/insertEntityTest.ts @@ -94,7 +94,7 @@ describe('insertEntity', () => { }, 'begin', false, - true, + undefined, context ); expect(getEntityFromElementSpy).toHaveBeenCalledWith(wrapper); @@ -217,7 +217,7 @@ describe('insertEntity', () => { }, 'begin', false, - true, + undefined, context ); expect(getEntityFromElementSpy).toHaveBeenCalledWith(wrapper); From 6c1c78f75af7bb44ca385bc392e8e18f6b538c42 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Thu, 7 Sep 2023 21:24:54 -0700 Subject: [PATCH 35/75] Content Model: Fix selection of entity (#2051) --- .../lib/modelToDom/handlers/handleEntity.ts | 2 ++ .../modelToDom/handlers/handleEntityTest.ts | 27 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleEntity.ts b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleEntity.ts index 82f419f3fb1..7d3c7c8e810 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleEntity.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleEntity.ts @@ -61,6 +61,8 @@ export const handleEntity: ContentModelBlockHandler = ( const [after] = addDelimiters(wrapper); context.regularSelection.current.segment = after; + } else if (isInlineEntity) { + context.regularSelection.current.segment = wrapper; } context.onNodeCreated?.(entityModel, wrapper); diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleEntityTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleEntityTest.ts index 6424fedd84e..3347d504c2f 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleEntityTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleEntityTest.ts @@ -174,6 +174,33 @@ describe('handleEntity', () => { expect(context.regularSelection.current.segment).toBe(span.nextSibling); }); + it('Entity without delimiter', () => { + const span = document.createElement('span'); + const entityModel: ContentModelEntity = { + blockType: 'Entity', + segmentType: 'Entity', + format: {}, + id: 'entity_1', + type: 'entity', + isReadonly: true, + wrapper: span, + }; + + span.textContent = 'test'; + + const parent = document.createElement('div'); + const result = handleEntity(document, parent, entityModel, context, null); + + expect(parent.innerHTML).toBe( + 'test' + ); + expect(span.outerHTML).toBe( + 'test' + ); + expect(result).toBe(null); + expect(context.regularSelection.current.segment).toBe(span); + }); + it('With onNodeCreated', () => { const entityDiv = document.createElement('div'); const entityModel: ContentModelEntity = { From d29815faf5a240597564c04a54b4f495f6c106cc Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Thu, 7 Sep 2023 21:30:53 -0700 Subject: [PATCH 36/75] Content Model: Always return size in pt when getFormatState (#2052) --- .../lib/modelApi/common/retrieveModelFormatState.ts | 13 +++++++++++++ .../modelApi/common/retrieveModelFormatStateTest.ts | 10 +++++----- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/retrieveModelFormatState.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/retrieveModelFormatState.ts index 560dc4fe087..ea1df7077df 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/retrieveModelFormatState.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/retrieveModelFormatState.ts @@ -121,6 +121,10 @@ export function retrieveModelFormatState( includeListFormatHolder: 'never', } ); + + if (formatState.fontSize) { + formatState.fontSize = px2Pt(formatState.fontSize); + } } function retrieveSegmentFormat( @@ -231,3 +235,12 @@ function mergeValue( delete format[key]; } } + +function px2Pt(px: string) { + if (px && px.indexOf('px') == px.length - 2) { + // Edge may not handle the floating computing well which causes the calculated value is a little less than actual value + // So add 0.05 to fix it + return Math.round(parseFloat(px) * 75 + 0.05) / 100 + 'pt'; + } + return px; +} diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/retrieveModelFormatStateTest.ts b/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/retrieveModelFormatStateTest.ts index 9e700715aaa..135e32a41dd 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/retrieveModelFormatStateTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/retrieveModelFormatStateTest.ts @@ -35,7 +35,7 @@ describe('retrieveModelFormatState', () => { const baseFormatResult: ContentModelFormatState = { backgroundColor: 'red', fontName: 'Arial', - fontSize: '10px', + fontSize: '7.5pt', isBold: true, isItalic: true, isStrikeThrough: true, @@ -329,7 +329,7 @@ describe('retrieveModelFormatState', () => { isStrikeThrough: true, fontName: 'Arial', isSuperscript: true, - fontSize: '10px', + fontSize: '7.5pt', backgroundColor: 'red', textColor: 'green', }); @@ -490,7 +490,7 @@ describe('retrieveModelFormatState', () => { isItalic: true, isUnderline: true, isStrikeThrough: true, - fontSize: '10px', + fontSize: '7.5pt', }); }); @@ -701,7 +701,7 @@ describe('retrieveModelFormatState', () => { isSuperscript: false, isSubscript: false, fontName: 'Arial', - fontSize: '12px', + fontSize: '9pt', isCodeInline: false, canUnlink: false, canAddImageAltText: false, @@ -734,7 +734,7 @@ describe('retrieveModelFormatState', () => { isBold: false, isSuperscript: false, isSubscript: false, - fontSize: '12px', + fontSize: '9pt', isCodeInline: false, canUnlink: false, canAddImageAltText: false, From 2fc7830d19d0db5f3540adff617e09046709ba0a Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Thu, 7 Sep 2023 23:03:45 -0700 Subject: [PATCH 37/75] Content Model: trigger ShadowEdit events (#2053) * Content Model: trigger ShadowEdit events * improve --- .../lib/editor/coreApi/switchShadowEdit.ts | 30 +++++++++-- .../editor/coreApi/switchShadowEditTest.ts | 51 +++++++++++++++++++ 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/switchShadowEdit.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/switchShadowEdit.ts index 0abc929e901..7533f70963c 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/switchShadowEdit.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/switchShadowEdit.ts @@ -1,6 +1,6 @@ import { ContentModelEditorCore } from '../../publicTypes/ContentModelEditorCore'; import { getSelectionPath } from 'roosterjs-editor-dom'; -import { SwitchShadowEdit } from 'roosterjs-editor-types'; +import { PluginEventType, SwitchShadowEdit } from 'roosterjs-editor-types'; /** * @internal @@ -20,13 +20,35 @@ export const switchShadowEdit: SwitchShadowEdit = (editorCore, isOn): void => { const range = core.api.getSelectionRange(core, true /*tryGetFromCache*/); - core.lifecycle.shadowEditSelectionPath = - range && getSelectionPath(core.contentDiv, range); - core.lifecycle.shadowEditFragment = core.contentDiv.ownerDocument.createDocumentFragment(); + // Fake object, not used in Content Model Editor, just to satisfy original editor code + // TODO: we can remove them once we have standalone Content Model Editor + const fragment = core.contentDiv.ownerDocument.createDocumentFragment(); + const selectionPath = range && getSelectionPath(core.contentDiv, range); + + core.api.triggerEvent( + core, + { + eventType: PluginEventType.EnteredShadowEdit, + fragment, + selectionPath, + }, + false /*broadcast*/ + ); + + core.lifecycle.shadowEditSelectionPath = selectionPath; + core.lifecycle.shadowEditFragment = fragment; } else { core.lifecycle.shadowEditFragment = null; core.lifecycle.shadowEditSelectionPath = null; + core.api.triggerEvent( + core, + { + eventType: PluginEventType.LeavingShadowEdit, + }, + false /*broadcast*/ + ); + if (core.cachedModel) { core.api.setContentModel(core, core.cachedModel); } diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/switchShadowEditTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/switchShadowEditTest.ts index 36275c82607..8ec02383909 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/switchShadowEditTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/switchShadowEditTest.ts @@ -1,4 +1,5 @@ import { ContentModelEditorCore } from '../../../lib/publicTypes/ContentModelEditorCore'; +import { PluginEventType } from 'roosterjs-editor-types'; import { switchShadowEdit } from '../../../lib/editor/coreApi/switchShadowEdit'; const mockedModel = 'MODEL' as any; @@ -9,17 +10,20 @@ describe('switchShadowEdit', () => { let createContentModel: jasmine.Spy; let setContentModel: jasmine.Spy; let getSelectionRange: jasmine.Spy; + let triggerEvent: jasmine.Spy; beforeEach(() => { createContentModel = jasmine.createSpy('createContentModel').and.returnValue(mockedModel); setContentModel = jasmine.createSpy('setContentModel'); getSelectionRange = jasmine.createSpy('getSelectionRange'); + triggerEvent = jasmine.createSpy('triggerEvent'); core = ({ api: { createContentModel, setContentModel, getSelectionRange, + triggerEvent, }, lifecycle: {}, contentDiv: document.createElement('div'), @@ -33,6 +37,16 @@ describe('switchShadowEdit', () => { expect(createContentModel).toHaveBeenCalledWith(core); expect(setContentModel).not.toHaveBeenCalled(); expect(core.cachedModel).toBe(mockedModel); + expect(triggerEvent).toHaveBeenCalledTimes(1); + expect(triggerEvent).toHaveBeenCalledWith( + core, + { + eventType: PluginEventType.EnteredShadowEdit, + fragment: document.createDocumentFragment(), + selectionPath: undefined, + }, + false + ); }); it('with cache, isOn', () => { @@ -43,6 +57,17 @@ describe('switchShadowEdit', () => { expect(createContentModel).not.toHaveBeenCalled(); expect(setContentModel).not.toHaveBeenCalled(); expect(core.cachedModel).toBe(mockedCachedModel); + + expect(triggerEvent).toHaveBeenCalledTimes(1); + expect(triggerEvent).toHaveBeenCalledWith( + core, + { + eventType: PluginEventType.EnteredShadowEdit, + fragment: document.createDocumentFragment(), + selectionPath: undefined, + }, + false + ); }); it('no cache, isOff', () => { @@ -51,6 +76,8 @@ describe('switchShadowEdit', () => { expect(createContentModel).not.toHaveBeenCalled(); expect(setContentModel).not.toHaveBeenCalled(); expect(core.cachedModel).toBe(undefined); + + expect(triggerEvent).not.toHaveBeenCalled(); }); it('with cache, isOff', () => { @@ -61,6 +88,8 @@ describe('switchShadowEdit', () => { expect(createContentModel).not.toHaveBeenCalled(); expect(setContentModel).not.toHaveBeenCalled(); expect(core.cachedModel).toBe(mockedCachedModel); + + expect(triggerEvent).not.toHaveBeenCalled(); }); }); @@ -75,6 +104,8 @@ describe('switchShadowEdit', () => { expect(createContentModel).not.toHaveBeenCalled(); expect(setContentModel).not.toHaveBeenCalled(); expect(core.cachedModel).toBe(undefined); + + expect(triggerEvent).not.toHaveBeenCalled(); }); it('with cache, isOn', () => { @@ -85,6 +116,8 @@ describe('switchShadowEdit', () => { expect(createContentModel).not.toHaveBeenCalled(); expect(setContentModel).not.toHaveBeenCalled(); expect(core.cachedModel).toBe(mockedCachedModel); + + expect(triggerEvent).not.toHaveBeenCalled(); }); it('no cache, isOff', () => { @@ -93,6 +126,15 @@ describe('switchShadowEdit', () => { expect(createContentModel).not.toHaveBeenCalled(); expect(setContentModel).not.toHaveBeenCalled(); expect(core.cachedModel).toBe(undefined); + + expect(triggerEvent).toHaveBeenCalledTimes(1); + expect(triggerEvent).toHaveBeenCalledWith( + core, + { + eventType: PluginEventType.LeavingShadowEdit, + }, + false + ); }); it('with cache, isOff', () => { @@ -103,6 +145,15 @@ describe('switchShadowEdit', () => { expect(createContentModel).not.toHaveBeenCalled(); expect(setContentModel).toHaveBeenCalledWith(core, mockedCachedModel); expect(core.cachedModel).toBe(mockedCachedModel); + + expect(triggerEvent).toHaveBeenCalledTimes(1); + expect(triggerEvent).toHaveBeenCalledWith( + core, + { + eventType: PluginEventType.LeavingShadowEdit, + }, + false + ); }); }); }); From fb0febca1724ed791d5bf816627ec3b00fb45400 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Fri, 8 Sep 2023 13:02:10 -0700 Subject: [PATCH 38/75] Content Model: Add solid paragraph in new table cell (#2055) --- .../lib/modelApi/table/normalizeTable.ts | 10 +++++++++- .../test/modelApi/table/normalizeTableTest.ts | 4 +--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/normalizeTable.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/normalizeTable.ts index ac53ef20bd7..e150418fa9e 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/normalizeTable.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/normalizeTable.ts @@ -1,4 +1,4 @@ -import { addSegment, createBr } from 'roosterjs-content-model-dom'; +import { addBlock, addSegment, createBr, createParagraph } from 'roosterjs-content-model-dom'; import { arrayPush } from 'roosterjs-editor-dom'; import { ContentModelSegment, @@ -30,6 +30,14 @@ export function normalizeTable( table.rows.forEach((row, rowIndex) => { row.cells.forEach((cell, colIndex) => { if (cell.blocks.length == 0) { + addBlock( + cell, + createParagraph( + undefined /*isImplicit*/, + undefined /*blockFormat*/, + defaultSegmentFormat + ) + ); addSegment(cell, createBr(defaultSegmentFormat)); } diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/table/normalizeTableTest.ts b/packages-content-model/roosterjs-content-model-editor/test/modelApi/table/normalizeTableTest.ts index 0378bc1c830..f2379aa2f28 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/table/normalizeTableTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/modelApi/table/normalizeTableTest.ts @@ -78,7 +78,6 @@ describe('normalizeTable', () => { blocks: [ { blockType: 'Paragraph', - isImplicit: true, segments: [ { segmentType: 'Br', @@ -678,7 +677,6 @@ describe('normalizeTable', () => { blocks: [ { blockType: 'Paragraph', - isImplicit: true, segments: [ { segmentType: 'Br', @@ -688,6 +686,7 @@ describe('normalizeTable', () => { }, ], format: {}, + segmentFormat: { fontSize: '10px' }, }, ], dataset: {}, @@ -725,7 +724,6 @@ describe('normalizeTable', () => { const block: ContentModelParagraph = { blockType: 'Paragraph', - isImplicit: true, segments: [ { segmentType: 'Br', From f3f683159d616fe4ae22207ad5cfa05ed0491956 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Fri, 8 Sep 2023 13:44:36 -0700 Subject: [PATCH 39/75] Content Model: Do color transform for entity when copy/paste (#2056) * Content Model: Do color transform for entity when copy/paste * Call normalizeContentModel --- .../ContentModelCopyPastePlugin.ts | 20 +- .../lib/modelApi/common/cloneModel.ts | 75 ++++-- .../lib/modelApi/common/mergeModel.ts | 19 +- .../lib/publicApi/entity/insertEntity.ts | 9 +- .../publicApi/utils/formatWithContentModel.ts | 14 ++ .../FormatWithContentModelContext.ts | 5 + .../ContentModelCopyPastePluginTest.ts | 88 ++++++- .../plugins/ContentModelFormatPluginTest.ts | 3 + .../utils/handleKeyboardEventCommonTest.ts | 8 +- .../test/modelApi/common/cloneModelTest.ts | 230 ++++++++++++++++++ .../test/modelApi/common/mergeModelTest.ts | 112 +++++++-- .../test/modelApi/edit/deleteSelectionTest.ts | 11 +- .../publicApi/block/paragraphTestCommon.ts | 1 + .../test/publicApi/block/setAlignmentTest.ts | 2 + .../publicApi/editing/editingTestCommon.ts | 1 + .../editing/handleKeyDownEventTest.ts | 1 + .../test/publicApi/entity/insertEntityTest.ts | 21 +- .../format/applyPendingFormatTest.ts | 11 +- .../test/publicApi/format/clearFormatTest.ts | 2 +- .../test/publicApi/image/changeImageTest.ts | 1 + .../test/publicApi/image/insertImageTest.ts | 1 + .../publicApi/link/adjustLinkSelectionTest.ts | 1 + .../test/publicApi/link/insertLinkTest.ts | 1 + .../test/publicApi/link/removeLinkTest.ts | 1 + .../publicApi/list/setListStartNumberTest.ts | 2 +- .../test/publicApi/list/setListStyleTest.ts | 2 +- .../test/publicApi/list/toggleBulletTest.ts | 1 + .../publicApi/list/toggleNumberingTest.ts | 1 + .../publicApi/segment/changeFontSizeTest.ts | 1 + .../publicApi/segment/segmentTestCommon.ts | 3 +- .../publicApi/table/setTableCellShadeTest.ts | 1 + .../utils/formatImageWithContentModelTest.ts | 1 + .../formatParagraphWithContentModelTest.ts | 5 +- .../formatSegmentWithContentModelTest.ts | 1 + .../utils/formatWithContentModelTest.ts | 38 +++ .../test/publicApi/utils/pasteTest.ts | 11 +- .../lib/editor/EditorBase.ts | 17 +- .../lib/interface/IEditor.ts | 8 +- 38 files changed, 645 insertions(+), 85 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCopyPastePlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCopyPastePlugin.ts index aa8bcaca30c..f23f8a65870 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCopyPastePlugin.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCopyPastePlugin.ts @@ -33,6 +33,7 @@ import { ClipboardData, SelectionRangeTypes, SelectionRangeEx, + ColorTransformDirection, } from 'roosterjs-editor-types'; /** @@ -94,7 +95,24 @@ export default class ContentModelCopyPastePlugin implements PluginWithState { + if (type == 'cache') { + return undefined; + } else { + const result = node.cloneNode(true /*deep*/) as HTMLElement; + + this.editor?.transformToDarkColor( + result, + ColorTransformDirection.DarkToLight + ); + + return result; + } + } + : false, + }); if (selection.type === SelectionRangeTypes.TableSelection) { iterateSelections([pasteModel], (path, tableContext) => { if (tableContext?.table) { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/cloneModel.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/cloneModel.ts index 9c8a0fb3012..e038963c0c4 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/cloneModel.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/cloneModel.ts @@ -27,18 +27,27 @@ import type { ContentModelListLevel, } from 'roosterjs-content-model-types'; +/** + * @internal + */ +export type CachedElementHandler = ( + node: HTMLElement, + type: 'general' | 'entity' | 'cache' +) => HTMLElement | undefined; + /** * @internal * Options for cloneModel API */ export interface CloneModelOptions { /** - * When pass false or not passed, the cloned model will not have cached element even they exist in original model. - * For entity and general model, a cloned wrapper element will be added into cloned model. So that the cloned model will be fully disconnected from the original one - * When pass true, cloned model will have the same cached element and element wrapper with the original model - * @default true + * Specify how to deal with cached element, including cached block element, element in General Model, and wrapper element in Entity + * - True: Cloned model will have the same reference to the cached element + * - False/Not passed: For cached block element, cached element will be undefined. For General Model and Entity, the element will have deep clone and assign to the cloned model + * - A callback: invoke the callback with the source cached element and a string to specify model type, let the callback return the expected value of cached element. + * For General Model and Entity, the callback must return a valid element, otherwise there will be exception thrown. */ - includeCachedElement?: boolean; + includeCachedElement?: boolean | CachedElementHandler; } /** @@ -167,9 +176,7 @@ function cloneEntity(entity: ContentModelEntity, options: CloneModelOptions): Co return Object.assign( { - wrapper: options.includeCachedElement - ? wrapper - : (wrapper.cloneNode(true /*deep*/) as HTMLElement), + wrapper: handleCachedElement(wrapper, 'entity', options), isReadonly, type, id, @@ -187,7 +194,7 @@ function cloneParagraph( const newParagraph: ContentModelParagraph = Object.assign( { - cachedElement: options.includeCachedElement ? cachedElement : undefined, + cachedElement: handleCachedElement(cachedElement, 'cache', options), isImplicit, segments: segments.map(segment => cloneSegment(segment, options)), segmentFormat: segmentFormat ? { ...segmentFormat } : undefined, @@ -213,7 +220,7 @@ function cloneTable(table: ContentModelTable, options: CloneModelOptions): Conte return Object.assign( { - cachedElement: options.includeCachedElement ? cachedElement : undefined, + cachedElement: handleCachedElement(cachedElement, 'cache', options), widths: Array.from(widths), rows: rows.map(row => cloneTableRow(row, options)), }, @@ -231,7 +238,7 @@ function cloneTableRow( return Object.assign( { height, - cachedElement: options.includeCachedElement ? cachedElement : undefined, + cachedElement: handleCachedElement(cachedElement, 'cache', options), cells: cells.map(cell => cloneTableCell(cell, options)), }, cloneModelWithFormat(row) @@ -246,7 +253,7 @@ function cloneTableCell( return Object.assign( { - cachedElement: options.includeCachedElement ? cachedElement : undefined, + cachedElement: handleCachedElement(cachedElement, 'cache', options), isSelected, spanAbove, spanLeft, @@ -264,7 +271,7 @@ function cloneFormatContainer( ): ContentModelFormatContainer { const { tagName, cachedElement } = container; const newContainer: ContentModelFormatContainer = Object.assign( - { tagName, cachedElement: options.includeCachedElement ? cachedElement : undefined }, + { tagName, cachedElement: handleCachedElement(cachedElement, 'cache', options) }, cloneBlockBase(container), cloneBlockGroupBase(container, options) ); @@ -307,7 +314,7 @@ function cloneDivider( { isSelected, tagName, - cachedElement: options.includeCachedElement ? cachedElement : undefined, + cachedElement: handleCachedElement(cachedElement, 'cache', options), }, cloneBlockBase(divider) ); @@ -321,9 +328,7 @@ function cloneGeneralBlock( return Object.assign( { - element: options.includeCachedElement - ? element - : (element.cloneNode(true /*deep*/) as HTMLElement), + element: handleCachedElement(element, 'general', options), }, cloneBlockBase(general), cloneBlockGroupBase(general, options) @@ -355,3 +360,39 @@ function cloneText(textSegment: ContentModelText): ContentModelText { const { text } = textSegment; return Object.assign({ text }, cloneSegmentBase(textSegment)); } + +function handleCachedElement( + node: T, + type: 'general' | 'entity', + options: CloneModelOptions +): T; + +function handleCachedElement( + node: T | undefined, + type: 'cache', + options: CloneModelOptions +): T | undefined; + +function handleCachedElement( + node: T | undefined, + type: 'general' | 'entity' | 'cache', + options: CloneModelOptions +): T | undefined { + const { includeCachedElement } = options; + + if (!node) { + return undefined; + } else if (!includeCachedElement) { + return type == 'cache' ? undefined : (node.cloneNode(true /*deep*/) as T); + } else if (includeCachedElement === true) { + return node; + } else { + const result = includeCachedElement(node, type) as T | undefined; + + if ((type == 'general' || type == 'entity') && !result) { + throw new Error('Entity and General Model must has wrapper element'); + } + + return result; + } +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/mergeModel.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/mergeModel.ts index 0e28c4c3847..01ce919a8ba 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/mergeModel.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/mergeModel.ts @@ -83,12 +83,16 @@ export function mergeModel( switch (block.blockType) { case 'Paragraph': - mergeParagraph(insertPosition, block, i == 0); + mergeParagraph(insertPosition, block, i == 0, context); break; case 'Divider': + insertBlock(insertPosition, block); + break; + case 'Entity': insertBlock(insertPosition, block); + context?.newEntities.push(block); break; case 'Table': @@ -120,7 +124,8 @@ export function mergeModel( function mergeParagraph( markerPosition: InsertPoint, newPara: ContentModelParagraph, - mergeToCurrentParagraph: boolean + mergeToCurrentParagraph: boolean, + context?: FormatWithContentModelContext ) { const { paragraph, marker } = markerPosition; const newParagraph = mergeToCurrentParagraph @@ -129,7 +134,15 @@ function mergeParagraph( const segmentIndex = newParagraph.segments.indexOf(marker); if (segmentIndex >= 0) { - newParagraph.segments.splice(segmentIndex, 0, ...newPara.segments); + for (let i = 0; i < newPara.segments.length; i++) { + const segment = newPara.segments[i]; + + newParagraph.segments.splice(segmentIndex + i, 0, segment); + + if (context && segment.segmentType == 'Entity') { + context.newEntities.push(segment); + } + } } if (newPara.decorator) { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/entity/insertEntity.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/entity/insertEntity.ts index fb65075c93f..00559f98260 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/entity/insertEntity.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/entity/insertEntity.ts @@ -1,6 +1,6 @@ import { ChangeSource, Entity, SelectionRangeEx } from 'roosterjs-editor-types'; import { commitEntity, getEntityFromElement } from 'roosterjs-editor-dom'; -import { createEntity } from 'roosterjs-content-model-dom'; +import { createEntity, normalizeContentModel } from 'roosterjs-content-model-dom'; import { formatWithContentModel } from '../utils/formatWithContentModel'; import { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; import { insertEntityModel } from '../../modelApi/entity/insertEntityModel'; @@ -84,7 +84,10 @@ export default function insertEntity( context ); + normalizeContentModel(model); + context.skipUndoSnapshot = skipUndoSnapshot; + context.newEntities.push(entityModel); return true; }, @@ -93,10 +96,6 @@ export default function insertEntity( } ); - if (editor.isDarkMode()) { - editor.transformToDarkColor(wrapper); - } - const newEntity = getEntityFromElement(wrapper); editor.triggerContentChangedEvent(ChangeSource.InsertEntity, newEntity); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatWithContentModel.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatWithContentModel.ts index 1d3d9ec6a4d..4979e5d3e4c 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatWithContentModel.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatWithContentModel.ts @@ -36,12 +36,14 @@ export function formatWithContentModel( const model = editor.createContentModel(undefined /*option*/, selectionOverride); const context: FormatWithContentModelContext = { + newEntities: [], deletedEntities: [], rawEvent, }; if (formatter(model, context)) { const callback = () => { + handleNewEntities(editor, context); handleDeletedEntities(editor, context); if (model) { @@ -81,6 +83,18 @@ export function formatWithContentModel( } } +function handleNewEntities(editor: IContentModelEditor, context: FormatWithContentModelContext) { + // TODO: Ideally we can trigger NewEntity event here. But to be compatible with original editor code, we don't do it here for now. + // Once Content Model Editor can be standalone, we can change this behavior to move triggering NewEntity event code + // from EntityPlugin to here + + if (editor.isDarkMode()) { + context.newEntities.forEach(entity => { + editor.transformToDarkColor(entity.wrapper); + }); + } +} + function handleDeletedEntities( editor: IContentModelEditor, context: FormatWithContentModelContext diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/parameter/FormatWithContentModelContext.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/parameter/FormatWithContentModelContext.ts index da2f5627b76..37e7b13284e 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/parameter/FormatWithContentModelContext.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/parameter/FormatWithContentModelContext.ts @@ -24,6 +24,11 @@ export interface DeletedEntity { * Context object for API formatWithContentModel */ export interface FormatWithContentModelContext { + /** + * New entities added during the format process + */ + readonly newEntities: ContentModelEntity[]; + /** * Entities got deleted during formatting. Need to be set by the formatter function */ diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCopyPastePluginTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCopyPastePluginTest.ts index 639319f96b4..11d282ee014 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCopyPastePluginTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCopyPastePluginTest.ts @@ -1,18 +1,20 @@ import * as cloneModelFile from '../../../lib/modelApi/common/cloneModel'; import * as contentModelToDomFile from 'roosterjs-content-model-dom/lib/modelToDom/contentModelToDom'; -import * as createRangeF from 'roosterjs-editor-dom/lib/selection/createRange'; import * as deleteSelectionsFile from '../../../lib/modelApi/edit/deleteSelection'; import * as extractClipboardItemsFile from 'roosterjs-editor-dom/lib/clipboard/extractClipboardItems'; import * as iterateSelectionsFile from '../../../lib/modelApi/selection/iterateSelections'; import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; import * as PasteFile from '../../../lib/publicApi/utils/paste'; +import { commitEntity } from 'roosterjs-editor-dom'; import { DeleteResult } from '../../../lib/modelApi/edit/utils/DeleteSelectionStep'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; +import createRange, * as createRangeF from 'roosterjs-editor-dom/lib/selection/createRange'; import ContentModelCopyPastePlugin, { onNodeCreated, } from '../../../lib/editor/corePlugins/ContentModelCopyPastePlugin'; import { ClipboardData, + ColorTransformDirection, DOMEventHandlerFunction, IEditor, SelectionRangeEx, @@ -45,6 +47,8 @@ describe('ContentModelCopyPastePlugin |', () => { let isDisposed: jasmine.Spy; let pasteSpy: jasmine.Spy; + let cloneModelSpy: jasmine.Spy; + let transformToDarkColorSpy: jasmine.Spy; beforeEach(() => { div = document.createElement('div'); @@ -63,7 +67,10 @@ describe('ContentModelCopyPastePlugin |', () => { pasteSpy = jasmine.createSpy('paste_'); isDisposed = jasmine.createSpy('isDisposed'); - spyOn(cloneModelFile, 'cloneModel').and.callFake((model: any) => pasteModelValue); + cloneModelSpy = spyOn(cloneModelFile, 'cloneModel').and.callFake( + (model: any) => pasteModelValue + ); + transformToDarkColorSpy = jasmine.createSpy('transformToDarkColor'); plugin = new ContentModelCopyPastePlugin({ allowedCustomPasteType, @@ -119,6 +126,7 @@ describe('ContentModelCopyPastePlugin |', () => { paste: (ar1: any) => { pasteSpy(ar1); }, + transformToDarkColor: transformToDarkColorSpy, isDisposed, }); @@ -311,6 +319,82 @@ describe('ContentModelCopyPastePlugin |', () => { expect(setContentModelSpy).not.toHaveBeenCalledWith(); expect(iterateSelectionsFile.iterateSelections).toHaveBeenCalledTimes(0); }); + + it('Selection not Collapsed and entity selection in Dark mode', () => { + // Arrange + const wrapper = document.createElement('span'); + + document.body.appendChild(wrapper); + + commitEntity(wrapper, 'Entity', true, 'Entity'); + selectionRangeExValue = { + type: SelectionRangeTypes.Normal, + ranges: [createRange(wrapper)], + areAllCollapsed: false, + }; + + spyOn(deleteSelectionsFile, 'deleteSelection'); + spyOn(contentModelToDomFile, 'contentModelToDom').and.callFake(() => { + div.appendChild(wrapper); + return selectionRangeExValue; + }); + spyOn(iterateSelectionsFile, 'iterateSelections').and.returnValue(undefined); + + triggerPluginEventSpy.and.callThrough(); + focusSpy.and.callThrough(); + selectSpy.and.callThrough(); + setContentModelSpy.and.callThrough(); + + editor.isDarkMode = () => true; + + cloneModelSpy.and.callFake((model, options) => { + expect(model).toEqual(modelValue); + expect(typeof options.includeCachedElement).toBe('function'); + + const cloneCache = options.includeCachedElement(wrapper, 'cache'); + const cloneEntity = options.includeCachedElement(wrapper, 'entity'); + + expect(cloneCache).toBeUndefined(); + expect(cloneEntity).toEqual(wrapper); + expect(cloneEntity).not.toBe(wrapper); + expect(transformToDarkColorSpy).toHaveBeenCalledTimes(1); + expect(transformToDarkColorSpy).toHaveBeenCalledWith( + cloneEntity, + ColorTransformDirection.DarkToLight + ); + + return pasteModelValue; + }); + + // Act + domEvents.copy?.({}); + + // Assert + expect(getSelectionRangeEx).toHaveBeenCalled(); + expect(deleteSelectionsFile.deleteSelection).not.toHaveBeenCalled(); + expect(contentModelToDomFile.contentModelToDom).toHaveBeenCalledWith( + document, + div, + pasteModelValue, + undefined, + { onNodeCreated } + ); + expect(createContentModelSpy).toHaveBeenCalled(); + expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); + expect(focusSpy).toHaveBeenCalled(); + expect(selectSpy).toHaveBeenCalledWith( + selectionRangeExValue, + undefined, + undefined, + undefined + ); + expect(cloneModelSpy).toHaveBeenCalledTimes(1); + + // On Cut Spy + expect(undoSnapShotSpy).not.toHaveBeenCalled(); + expect(setContentModelSpy).not.toHaveBeenCalledWith(); + expect(iterateSelectionsFile.iterateSelections).toHaveBeenCalledTimes(0); + }); }); describe('Cut |', () => { diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelFormatPluginTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelFormatPluginTest.ts index d25c9c4bff7..8e0651cf8ce 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelFormatPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelFormatPluginTest.ts @@ -16,6 +16,7 @@ describe('ContentModelFormatPlugin', () => { const editor = ({ cacheContentModel: () => {}, + isDarkMode: () => false, } as any) as IContentModelEditor; const plugin = new ContentModelFormatPlugin(); @@ -149,6 +150,7 @@ describe('ContentModelFormatPlugin', () => { callback(); }, cacheContentModel: () => {}, + isDarkMode: () => false, } as any) as IContentModelEditor; const plugin = new ContentModelFormatPlugin(); @@ -211,6 +213,7 @@ describe('ContentModelFormatPlugin', () => { callback(); }, cacheContentModel: () => {}, + isDarkMode: () => false, } as any) as IContentModelEditor; const plugin = new ContentModelFormatPlugin(); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/utils/handleKeyboardEventCommonTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/utils/handleKeyboardEventCommonTest.ts index 613500dc825..faa405fd541 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/utils/handleKeyboardEventCommonTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/utils/handleKeyboardEventCommonTest.ts @@ -42,7 +42,7 @@ describe('handleKeyboardEventResult', () => { const mockedModel = 'MODEL' as any; const which = 'WHICH' as any; (mockedEvent).which = which; - const context: FormatWithContentModelContext = { deletedEntities: [] }; + const context: FormatWithContentModelContext = { newEntities: [], deletedEntities: [] }; const result = handleKeyboardEventResult( mockedEditor, mockedModel, @@ -64,7 +64,7 @@ describe('handleKeyboardEventResult', () => { it('DeleteResult.NotDeleted', () => { const mockedModel = 'MODEL' as any; - const context: FormatWithContentModelContext = { deletedEntities: [] }; + const context: FormatWithContentModelContext = { newEntities: [], deletedEntities: [] }; const result = handleKeyboardEventResult( mockedEditor, mockedModel, @@ -84,7 +84,7 @@ describe('handleKeyboardEventResult', () => { it('DeleteResult.Range', () => { const mockedModel = 'MODEL' as any; - const context: FormatWithContentModelContext = { deletedEntities: [] }; + const context: FormatWithContentModelContext = { newEntities: [], deletedEntities: [] }; const result = handleKeyboardEventResult( mockedEditor, mockedModel, @@ -106,7 +106,7 @@ describe('handleKeyboardEventResult', () => { it('DeleteResult.NothingToDelete', () => { const mockedModel = 'MODEL' as any; - const context: FormatWithContentModelContext = { deletedEntities: [] }; + const context: FormatWithContentModelContext = { newEntities: [], deletedEntities: [] }; const result = handleKeyboardEventResult( mockedEditor, mockedModel, diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/cloneModelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/cloneModelTest.ts index 4299f581179..c83e226848c 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/cloneModelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/cloneModelTest.ts @@ -1,5 +1,6 @@ import { cloneModel } from '../../../lib/modelApi/common/cloneModel'; import { ContentModelDocument } from 'roosterjs-content-model-types'; +import { createEntity } from 'roosterjs-content-model-dom'; describe('cloneModel', () => { function compareObjects(o1: any, o2: any, allowCache: boolean, path: string = '/') { @@ -281,4 +282,233 @@ describe('cloneModel', () => { ], }); }); + + describe('Clone with callback', () => { + it('Paragraph without cache', () => { + const callback = jasmine + .createSpy('callback') + .and.callFake((node: Node, type: string) => { + return undefined; + }); + const cloneWithCallback = cloneModel( + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segmentFormat: { fontSize: '20px' }, + segments: [], + }, + ], + }, + { includeCachedElement: callback } + ); + + expect(cloneWithCallback).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segmentFormat: { fontSize: '20px' }, + segments: [], + cachedElement: undefined, + isImplicit: undefined, + }, + ], + }); + expect(callback).not.toHaveBeenCalled(); + }); + + it('Paragraph with cache, return undefined', () => { + const callback = jasmine + .createSpy('callback') + .and.callFake((node: Node, type: string) => { + return undefined; + }); + const div = document.createElement('div'); + const cloneWithCallback = cloneModel( + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segmentFormat: { fontSize: '20px' }, + segments: [], + cachedElement: div, + }, + ], + }, + { includeCachedElement: callback } + ); + + expect(cloneWithCallback).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segmentFormat: { fontSize: '20px' }, + segments: [], + cachedElement: undefined, + isImplicit: undefined, + }, + ], + }); + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith(div, 'cache'); + }); + + it('Paragraph with cache, return span', () => { + const div = document.createElement('div'); + const span = document.createElement('span'); + const callback = jasmine + .createSpy('callback') + .and.callFake((node: Node, type: string) => { + return span; + }); + const cloneWithCallback = cloneModel( + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segmentFormat: { fontSize: '20px' }, + segments: [], + cachedElement: div, + }, + ], + }, + { includeCachedElement: callback } + ); + + expect(cloneWithCallback).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segmentFormat: { fontSize: '20px' }, + segments: [], + cachedElement: span, + isImplicit: undefined, + }, + ], + }); + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith(div, 'cache'); + }); + + it('Entity, return undefined', () => { + const div = document.createElement('div'); + const callback = jasmine + .createSpy('callback') + .and.callFake((node: Node, type: string) => { + return undefined; + }); + expect(() => + cloneModel( + { + blockGroupType: 'Document', + blocks: [createEntity(div, true)], + }, + { includeCachedElement: callback } + ) + ).toThrow(); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith(div, 'entity'); + }); + }); + + it('Entity, return span', () => { + const div = document.createElement('div'); + const span = document.createElement('span'); + const callback = jasmine.createSpy('callback').and.callFake((node: Node, type: string) => { + return span; + }); + const cloneWithCallback = cloneModel( + { + blockGroupType: 'Document', + blocks: [createEntity(div, true)], + }, + { includeCachedElement: callback } + ); + + expect(cloneWithCallback).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Entity', + format: {}, + wrapper: span, + isReadonly: true, + type: undefined, + id: undefined, + segmentType: 'Entity', + isSelected: undefined, + }, + ], + }); + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith(div, 'entity'); + }); + + it('Inline entity, return span', () => { + const div1 = document.createElement('div'); + const div2 = document.createElement('div'); + + div1.id = 'div1'; + div2.id = 'div2'; + + const span = document.createElement('span'); + const callback = jasmine.createSpy('callback').and.callFake((node: Node, type: string) => { + return node == div1 ? span : node; + }); + const cloneWithCallback = cloneModel( + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [createEntity(div1, true)], + cachedElement: div2, + }, + ], + }, + { includeCachedElement: callback } + ); + + expect(cloneWithCallback).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + blockType: 'Entity', + format: {}, + wrapper: span, + isReadonly: true, + type: undefined, + id: undefined, + segmentType: 'Entity', + isSelected: undefined, + }, + ], + cachedElement: div2, + isImplicit: undefined, + segmentFormat: undefined, + }, + ], + }); + expect(callback).toHaveBeenCalledTimes(2); + expect(callback).toHaveBeenCalledWith(div1, 'entity'); + expect(callback).toHaveBeenCalledWith(div2, 'cache'); + }); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/mergeModelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/mergeModelTest.ts index 3e43abcc795..a3a98253fc3 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/mergeModelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/mergeModelTest.ts @@ -1,8 +1,11 @@ import * as applyTableFormat from '../../../lib/modelApi/table/applyTableFormat'; import * as normalizeTable from '../../../lib/modelApi/table/normalizeTable'; import { ContentModelDocument } from 'roosterjs-content-model-types'; +import { EntityOperation } from 'roosterjs-editor-types'; +import { FormatWithContentModelContext } from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; import { mergeModel } from '../../../lib/modelApi/common/mergeModel'; import { + createBr, createContentModelDocument, createDivider, createEntity, @@ -25,7 +28,7 @@ describe('mergeModel', () => { para.segments.push(marker); majorModel.blocks.push(para); - mergeModel(majorModel, sourceModel, { deletedEntities: [] }); + mergeModel(majorModel, sourceModel, { newEntities: [], deletedEntities: [] }); expect(majorModel).toEqual({ blockGroupType: 'Document', @@ -65,7 +68,7 @@ describe('mergeModel', () => { para2.segments.push(text1, text2); sourceModel.blocks.push(para2); - mergeModel(majorModel, sourceModel, { deletedEntities: [] }); + mergeModel(majorModel, sourceModel, { newEntities: [], deletedEntities: [] }); expect(majorModel).toEqual({ blockGroupType: 'Document', @@ -115,7 +118,7 @@ describe('mergeModel', () => { majorModel.blocks.push(para1); sourceModel.blocks.push(para2); - mergeModel(majorModel, sourceModel, { deletedEntities: [] }); + mergeModel(majorModel, sourceModel, { newEntities: [], deletedEntities: [] }); expect(majorModel).toEqual({ blockGroupType: 'Document', @@ -194,7 +197,7 @@ describe('mergeModel', () => { sourceModel.blocks.push(newPara1); sourceModel.blocks.push(newPara2); - mergeModel(majorModel, sourceModel, { deletedEntities: [] }); + mergeModel(majorModel, sourceModel, { newEntities: [], deletedEntities: [] }); expect(majorModel).toEqual({ blockGroupType: 'Document', @@ -289,7 +292,7 @@ describe('mergeModel', () => { sourceModel.blocks.push(newPara2); sourceModel.blocks.push(newPara3); - mergeModel(majorModel, sourceModel, { deletedEntities: [] }); + mergeModel(majorModel, sourceModel, { newEntities: [], deletedEntities: [] }); expect(majorModel).toEqual({ blockGroupType: 'Document', @@ -436,7 +439,7 @@ describe('mergeModel', () => { sourceModel.blocks.push(newList1); sourceModel.blocks.push(newList2); - mergeModel(majorModel, sourceModel, { deletedEntities: [] }); + mergeModel(majorModel, sourceModel, { newEntities: [], deletedEntities: [] }); expect(majorModel).toEqual({ blockGroupType: 'Document', @@ -602,7 +605,7 @@ describe('mergeModel', () => { sourceModel.blocks.push(newList1); sourceModel.blocks.push(newList2); - mergeModel(majorModel, sourceModel, { deletedEntities: [] }); + mergeModel(majorModel, sourceModel, { newEntities: [], deletedEntities: [] }); expect(majorModel).toEqual({ blockGroupType: 'Document', @@ -790,7 +793,7 @@ describe('mergeModel', () => { sourceModel.blocks.push(newTable1); - mergeModel(majorModel, sourceModel, { deletedEntities: [] }); + mergeModel(majorModel, sourceModel, { newEntities: [], deletedEntities: [] }); expect(majorModel).toEqual({ blockGroupType: 'Document', @@ -891,7 +894,7 @@ describe('mergeModel', () => { spyOn(applyTableFormat, 'applyTableFormat'); spyOn(normalizeTable, 'normalizeTable'); - mergeModel(majorModel, sourceModel, { deletedEntities: [] }); + mergeModel(majorModel, sourceModel, { newEntities: [], deletedEntities: [] }); expect(normalizeTable.normalizeTable).not.toHaveBeenCalled(); expect(majorModel).toEqual({ @@ -1011,7 +1014,7 @@ describe('mergeModel', () => { mergeModel( majorModel, sourceModel, - { deletedEntities: [] }, + { newEntities: [], deletedEntities: [] }, { mergeTable: true, } @@ -1153,7 +1156,7 @@ describe('mergeModel', () => { mergeModel( majorModel, sourceModel, - { deletedEntities: [] }, + { newEntities: [], deletedEntities: [] }, { mergeTable: true, } @@ -1284,7 +1287,7 @@ describe('mergeModel', () => { mergeModel( majorModel, sourceModel, - { deletedEntities: [] }, + { newEntities: [], deletedEntities: [] }, { mergeTable: true, } @@ -1398,7 +1401,7 @@ describe('mergeModel', () => { mergeModel( majorModel, sourceModel, - { deletedEntities: [] }, + { newEntities: [], deletedEntities: [] }, { insertPosition: { marker: marker2, @@ -1481,7 +1484,7 @@ describe('mergeModel', () => { mergeModel( majorModel, sourceModel, - { deletedEntities: [] }, + { newEntities: [], deletedEntities: [] }, { mergeFormat: 'mergeAll', } @@ -1543,7 +1546,7 @@ describe('mergeModel', () => { mergeModel( majorModel, sourceModel, - { deletedEntities: [] }, + { newEntities: [], deletedEntities: [] }, { mergeFormat: 'keepSourceEmphasisFormat', } @@ -1611,7 +1614,7 @@ describe('mergeModel', () => { mergeModel( majorModel, sourceModel, - { deletedEntities: [] }, + { newEntities: [], deletedEntities: [] }, { mergeFormat: 'keepSourceEmphasisFormat', } @@ -1706,7 +1709,7 @@ describe('mergeModel', () => { mergeModel( majorModel, sourceModel, - { deletedEntities: [] }, + { newEntities: [], deletedEntities: [] }, { mergeFormat: 'keepSourceEmphasisFormat', } @@ -1782,7 +1785,7 @@ describe('mergeModel', () => { sourceModel.blocks.push(divider); - mergeModel(majorModel, sourceModel, { deletedEntities: [] }); + mergeModel(majorModel, sourceModel, { newEntities: [], deletedEntities: [] }); expect(majorModel).toEqual({ blockGroupType: 'Document', @@ -1849,7 +1852,7 @@ describe('mergeModel', () => { sourceModel.blocks.push(newPara1); sourceModel.blocks.push(newPara2); - mergeModel(majorModel, sourceModel, { deletedEntities: [] }); + mergeModel(majorModel, sourceModel, { newEntities: [], deletedEntities: [] }); expect(majorModel).toEqual({ blockGroupType: 'Document', @@ -1949,7 +1952,7 @@ describe('mergeModel', () => { mergeModel( majorModel, sourceModel, - { deletedEntities: [] }, + { newEntities: [], deletedEntities: [] }, { mergeFormat: 'keepSourceEmphasisFormat', } @@ -2030,7 +2033,7 @@ describe('mergeModel', () => { mergeModel( majorModel, sourceModel, - { deletedEntities: [] }, + { newEntities: [], deletedEntities: [] }, { mergeFormat: 'mergeAll', } @@ -2214,7 +2217,7 @@ describe('mergeModel', () => { mergeModel( majorModel, sourceModel, - { deletedEntities: [] }, + { newEntities: [], deletedEntities: [] }, { mergeFormat: 'mergeAll', } @@ -2752,9 +2755,13 @@ describe('mergeModel', () => { textColor: 'aliceblue', italic: true, }); + const context: FormatWithContentModelContext = { + deletedEntities: [], + newEntities: [], + }; sourceModel.blocks.push(newEntity); - mergeModel(majorModel, sourceModel); + mergeModel(majorModel, sourceModel, context); expect(majorModel).toEqual({ blockGroupType: 'Document', @@ -2807,5 +2814,64 @@ describe('mergeModel', () => { }, ], }); + expect(context).toEqual({ + newEntities: [newEntity], + deletedEntities: [], + }); + }); + + it('Merge and replace inline entities', () => { + const majorModel = createContentModelDocument(); + const para1 = createParagraph(); + const sourceEntity = createEntity('wrapper1' as any, true, 'E0'); + const sourceBr = createBr(); + + sourceEntity.isSelected = true; + para1.segments.push(sourceEntity, sourceBr); + majorModel.blocks.push(para1); + + const sourceModel: ContentModelDocument = createContentModelDocument(); + const newPara = createParagraph(); + const newEntity1 = createEntity('wrapper2' as any, true, 'E1'); + const newEntity2 = createEntity('wrapper2' as any, true, 'E2'); + const text = createText('test'); + + newPara.segments.push(newEntity1, text, newEntity2); + sourceModel.blocks.push(newPara); + + const context: FormatWithContentModelContext = { + deletedEntities: [], + newEntities: [], + }; + mergeModel(majorModel, sourceModel, context); + + expect(majorModel).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + newEntity1, + text, + newEntity2, + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + }); + expect(context).toEqual({ + newEntities: [newEntity1, newEntity2], + deletedEntities: [ + { + entity: sourceEntity, + operation: EntityOperation.Overwrite, + }, + ], + }); }); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/edit/deleteSelectionTest.ts b/packages-content-model/roosterjs-content-model-editor/test/modelApi/edit/deleteSelectionTest.ts index d85e654052e..abf0459143e 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/edit/deleteSelectionTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/modelApi/edit/deleteSelectionTest.ts @@ -1,4 +1,4 @@ -import { ContentModelSelectionMarker } from 'roosterjs-content-model-types'; +import { ContentModelEntity, ContentModelSelectionMarker } from 'roosterjs-content-model-types'; import { DeletedEntity } from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; import { DeleteResult } from '../../../lib/modelApi/edit/utils/DeleteSelectionStep'; import { deleteSelection } from '../../../lib/modelApi/edit/deleteSelection'; @@ -527,7 +527,7 @@ describe('deleteSelection - selectionOnly', () => { entity.isSelected = true; - const result = deleteSelection(model, [], { deletedEntities }); + const result = deleteSelection(model, [], { newEntities: [], deletedEntities }); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -582,7 +582,7 @@ describe('deleteSelection - selectionOnly', () => { entity.isSelected = true; const deletedEntities: DeletedEntity[] = []; - const result = deleteSelection(model, [], { deletedEntities }); + const result = deleteSelection(model, [], { newEntities: [], deletedEntities }); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -1485,6 +1485,7 @@ describe('deleteSelection - forward', () => { const deletedEntities: DeletedEntity[] = []; const result = deleteSelection(model, [forwardDeleteCollapsedSelection], { + newEntities: [], deletedEntities, }); @@ -1531,6 +1532,7 @@ describe('deleteSelection - forward', () => { const deletedEntities: DeletedEntity[] = []; const result = deleteSelection(model, [forwardDeleteCollapsedSelection], { + newEntities: [], deletedEntities, }); @@ -3239,6 +3241,7 @@ describe('deleteSelection - backward', () => { const deletedEntities: DeletedEntity[] = []; const result = deleteSelection(model, [backwardDeleteCollapsedSelection], { + newEntities: [], deletedEntities, }); @@ -3284,7 +3287,9 @@ describe('deleteSelection - backward', () => { model.blocks.push(entity, para); const deletedEntities: DeletedEntity[] = []; + const newEntities: ContentModelEntity[] = []; const result = deleteSelection(model, [backwardDeleteCollapsedSelection], { + newEntities, deletedEntities, }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/paragraphTestCommon.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/paragraphTestCommon.ts index 3d03ff1451d..ad3bae0da25 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/paragraphTestCommon.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/paragraphTestCommon.ts @@ -25,6 +25,7 @@ export function paragraphTestCommon( setContentModel, getCustomData: () => ({}), getFocusedPosition: () => ({}), + isDarkMode: () => false, } as any) as IContentModelEditor; executionCallback(editor); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setAlignmentTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setAlignmentTest.ts index 1aff35e23a8..dbf14ce2738 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setAlignmentTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setAlignmentTest.ts @@ -426,6 +426,7 @@ describe('setAlignment in table', () => { addUndoSnapshot: (callback: Function) => callback(), setContentModel, createContentModel, + isDarkMode: () => false, } as any) as IContentModelEditor; }); @@ -817,6 +818,7 @@ describe('setAlignment in list', () => { addUndoSnapshot: (callback: Function) => callback(), setContentModel, createContentModel, + isDarkMode: () => false, } as any) as IContentModelEditor; }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/editingTestCommon.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/editingTestCommon.ts index 42b6e0b051f..be7642aab81 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/editingTestCommon.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/editingTestCommon.ts @@ -38,6 +38,7 @@ export function editingTestCommon( isDisposed: () => false, getFocusedPosition: () => null as NodePosition, triggerContentChangedEvent, + isDarkMode: () => false, } as any) as IContentModelEditor; executionCallback(editor); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/handleKeyDownEventTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/handleKeyDownEventTest.ts index eef61461c7b..9e54e3e3a24 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/handleKeyDownEventTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/handleKeyDownEventTest.ts @@ -59,6 +59,7 @@ describe('handleKeyDownEvent', () => { ); expect(deleteSelectionSpy).toHaveBeenCalledWith(input, expectedSteps, { + newEntities: [], deletedEntities: [], rawEvent: mockedEvent, skipUndoSnapshot: true, diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/entity/insertEntityTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/entity/insertEntityTest.ts index 78d865ad001..583cf71d2e0 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/entity/insertEntityTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/entity/insertEntityTest.ts @@ -2,6 +2,7 @@ import * as commitEntity from 'roosterjs-editor-dom/lib/entity/commitEntity'; import * as formatWithContentModel from '../../../lib/publicApi/utils/formatWithContentModel'; import * as getEntityFromElement from 'roosterjs-editor-dom/lib/entity/getEntityFromElement'; import * as insertEntityModel from '../../../lib/modelApi/entity/insertEntityModel'; +import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; import insertEntity from '../../../lib/publicApi/entity/insertEntity'; import { ChangeSource } from 'roosterjs-editor-types'; import { FormatWithContentModelContext } from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; @@ -25,12 +26,14 @@ describe('insertEntity', () => { let insertEntityModelSpy: jasmine.Spy; let isDarkModeSpy: jasmine.Spy; let transformToDarkColorSpy: jasmine.Spy; + let normalizeContentModelSpy: jasmine.Spy; const type = 'Entity'; const apiName = 'insertEntity'; beforeEach(() => { context = { + newEntities: [], deletedEntities: [], }; @@ -60,6 +63,7 @@ describe('insertEntity', () => { getDocumentSpy = jasmine.createSpy('getDocumentSpy').and.returnValue({ createElement: createElementSpy, }); + normalizeContentModelSpy = spyOn(normalizeContentModel, 'normalizeContentModel'); editor = { triggerContentChangedEvent: triggerContentChangedEventSpy, @@ -103,6 +107,7 @@ describe('insertEntity', () => { newEntity ); expect(transformToDarkColorSpy).not.toHaveBeenCalled(); + expect(normalizeContentModelSpy).toHaveBeenCalled(); expect(entity).toBe(newEntity); }); @@ -141,6 +146,7 @@ describe('insertEntity', () => { newEntity ); expect(transformToDarkColorSpy).not.toHaveBeenCalled(); + expect(normalizeContentModelSpy).toHaveBeenCalled(); expect(entity).toBe(newEntity); }); @@ -186,6 +192,7 @@ describe('insertEntity', () => { newEntity ); expect(transformToDarkColorSpy).not.toHaveBeenCalled(); + expect(normalizeContentModelSpy).toHaveBeenCalled(); expect(entity).toBe(newEntity); }); @@ -225,7 +232,19 @@ describe('insertEntity', () => { ChangeSource.InsertEntity, newEntity ); - expect(transformToDarkColorSpy).toHaveBeenCalled(); + expect(normalizeContentModelSpy).toHaveBeenCalled(); + + expect(context.newEntities).toEqual([ + { + segmentType: 'Entity', + blockType: 'Entity', + format: {}, + id: undefined, + type: 'Entity', + isReadonly: true, + wrapper, + }, + ]); expect(entity).toBe(newEntity); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/applyPendingFormatTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/applyPendingFormatTest.ts index 40e2e030e61..89d881397da 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/applyPendingFormatTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/applyPendingFormatTest.ts @@ -48,6 +48,7 @@ describe('applyPendingFormat', () => { (_, apiName, callback) => { expect(apiName).toEqual('applyPendingFormat'); callback(model, { + newEntities: [], deletedEntities: [], }); } @@ -118,7 +119,7 @@ describe('applyPendingFormat', () => { spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( (_, apiName, callback) => { expect(apiName).toEqual('applyPendingFormat'); - callback(model, { deletedEntities: [] }); + callback(model, { newEntities: [], deletedEntities: [] }); } ); spyOn(iterateSelections, 'iterateSelections').and.callFake((_, callback) => { @@ -178,7 +179,7 @@ describe('applyPendingFormat', () => { spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( (_, apiName, callback) => { expect(apiName).toEqual('applyPendingFormat'); - callback(model, { deletedEntities: [] }); + callback(model, { newEntities: [], deletedEntities: [] }); } ); spyOn(iterateSelections, 'iterateSelections').and.callFake((_, callback) => { @@ -236,7 +237,7 @@ describe('applyPendingFormat', () => { spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( (_, apiName, callback) => { expect(apiName).toEqual('applyPendingFormat'); - callback(model, { deletedEntities: [] }); + callback(model, { newEntities: [], deletedEntities: [] }); } ); spyOn(iterateSelections, 'iterateSelections').and.callFake((_, callback) => { @@ -281,9 +282,7 @@ describe('applyPendingFormat', () => { spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( (_, apiName, callback) => { expect(apiName).toEqual('applyPendingFormat'); - callback(model, { - deletedEntities: [], - }); + callback(model, { newEntities: [], deletedEntities: [] }); } ); spyOn(iterateSelections, 'iterateSelections').and.callFake((_, callback) => { diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/clearFormatTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/clearFormatTest.ts index 65a87687c0e..e1c986404bf 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/clearFormatTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/clearFormatTest.ts @@ -13,7 +13,7 @@ describe('clearFormat', () => { spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( (_, apiName, callback) => { expect(apiName).toEqual('clearFormat'); - callback(model, { deletedEntities: [] }); + callback(model, { newEntities: [], deletedEntities: [] }); } ); spyOn(clearModelFormat, 'clearModelFormat'); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/image/changeImageTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/image/changeImageTest.ts index 95466be864c..3bba5147859 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/image/changeImageTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/image/changeImageTest.ts @@ -50,6 +50,7 @@ describe('changeImage', () => { getDocument: () => document, getSelectionRangeEx, triggerPluginEvent, + isDarkMode: () => false, } as any) as IContentModelEditor; executionCallback(editor); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/image/insertImageTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/image/insertImageTest.ts index c52438ad26a..935871f07cb 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/image/insertImageTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/image/insertImageTest.ts @@ -37,6 +37,7 @@ describe('insertImage', () => { setContentModel, isDisposed: () => false, getDocument: () => document, + isDarkMode: () => false, } as any) as IContentModelEditor; executionCallback(editor); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/adjustLinkSelectionTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/adjustLinkSelectionTest.ts index 485077fc64c..b0dc6c99104 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/adjustLinkSelectionTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/adjustLinkSelectionTest.ts @@ -25,6 +25,7 @@ describe('adjustLinkSelection', () => { addUndoSnapshot: (callback: Function) => callback(), setContentModel, createContentModel, + isDarkMode: () => false, } as any) as IContentModelEditor; }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/insertLinkTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/insertLinkTest.ts index 2a46ec23de4..772c861e11a 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/insertLinkTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/insertLinkTest.ts @@ -27,6 +27,7 @@ describe('insertLink', () => { createContentModel, getCustomData: () => ({}), getFocusedPosition: () => ({}), + isDarkMode: () => false, } as any) as IContentModelEditor; }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/removeLinkTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/removeLinkTest.ts index 63eb83eaf86..41a82f6be53 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/removeLinkTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/removeLinkTest.ts @@ -23,6 +23,7 @@ describe('removeLink', () => { addUndoSnapshot: (callback: Function) => callback(), setContentModel, createContentModel, + isDarkMode: () => false, } as any) as IContentModelEditor; }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/setListStartNumberTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/setListStartNumberTest.ts index b78705d7134..360653666db 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/setListStartNumberTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/setListStartNumberTest.ts @@ -11,7 +11,7 @@ describe('setListStartNumber', () => { spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( (editor, apiName, callback) => { expect(apiName).toBe('setListStartNumber'); - const result = callback(input, { deletedEntities: [] }); + const result = callback(input, { newEntities: [], deletedEntities: [] }); expect(result).toBe(expectedResult); } diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/setListStyleTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/setListStyleTest.ts index 2212976a091..fd10b6896f3 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/setListStyleTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/setListStyleTest.ts @@ -12,7 +12,7 @@ describe('setListStyle', () => { spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( (editor, apiName, callback) => { expect(apiName).toBe('setListStyle'); - const result = callback(input, { deletedEntities: [] }); + const result = callback(input, { newEntities: [], deletedEntities: [] }); expect(result).toBe(expectedResult); } diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/toggleBulletTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/toggleBulletTest.ts index cef67708fb4..f92a13c6639 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/toggleBulletTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/toggleBulletTest.ts @@ -26,6 +26,7 @@ describe('toggleBullet', () => { setContentModel, getCustomData: () => ({}), getFocusedPosition: () => ({}), + isDarkMode: () => false, } as any) as IContentModelEditor; spyOn(setListType, 'setListType').and.returnValue(true); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/toggleNumberingTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/toggleNumberingTest.ts index 143c00c7130..60641a7cef1 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/toggleNumberingTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/toggleNumberingTest.ts @@ -26,6 +26,7 @@ describe('toggleNumbering', () => { setContentModel, getCustomData: () => ({}), getFocusedPosition: () => ({}), + isDarkMode: () => false, } as any) as IContentModelEditor; spyOn(setListType, 'setListType').and.returnValue(true); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/changeFontSizeTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/changeFontSizeTest.ts index 25846d896db..d1aa4f7b317 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/changeFontSizeTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/changeFontSizeTest.ts @@ -351,6 +351,7 @@ describe('changeFontSize', () => { addUndoSnapshot, focus: jasmine.createSpy(), setContentModel, + isDarkMode: () => false, } as any) as IContentModelEditor; changeFontSize(editor, 'increase'); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/segmentTestCommon.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/segmentTestCommon.ts index 1e3264a3025..d090ec0ed72 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/segmentTestCommon.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/segmentTestCommon.ts @@ -1,7 +1,7 @@ import * as pendingFormat from '../../../lib/modelApi/format/pendingFormat'; +import { ContentModelDocument } from 'roosterjs-content-model-types'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { NodePosition } from 'roosterjs-editor-types'; -import { ContentModelDocument } from 'roosterjs-content-model-types'; export function segmentTestCommon( apiName: string, @@ -30,6 +30,7 @@ export function segmentTestCommon( setContentModel, isDisposed: () => false, getFocusedPosition: () => null as NodePosition, + isDarkMode: () => false, } as any) as IContentModelEditor; executionCallback(editor); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/table/setTableCellShadeTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/table/setTableCellShadeTest.ts index f037f540a66..4ac166616ab 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/table/setTableCellShadeTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/table/setTableCellShadeTest.ts @@ -20,6 +20,7 @@ describe('setTableCellShade', () => { addUndoSnapshot: (callback: Function) => callback(), setContentModel, createContentModel, + isDarkMode: () => false, } as any) as IContentModelEditor; }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatImageWithContentModelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatImageWithContentModelTest.ts index fc76f5f9ca1..d0aa129e122 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatImageWithContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatImageWithContentModelTest.ts @@ -225,6 +225,7 @@ function segmentTestForPluginEvent( isDisposed: () => false, getFocusedPosition: () => null as NodePosition, triggerPluginEvent, + isDarkMode: () => false, } as any) as IContentModelEditor; executionCallback(editor); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatParagraphWithContentModelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatParagraphWithContentModelTest.ts index 2812d94f958..cb955206700 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatParagraphWithContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatParagraphWithContentModelTest.ts @@ -1,12 +1,12 @@ import * as pendingFormat from '../../../lib/modelApi/format/pendingFormat'; import { ContentModelDocument } from 'roosterjs-content-model-types'; +import { formatParagraphWithContentModel } from '../../../lib/publicApi/utils/formatParagraphWithContentModel'; +import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { createContentModelDocument, createParagraph, createText, } from 'roosterjs-content-model-dom'; -import { formatParagraphWithContentModel } from '../../../lib/publicApi/utils/formatParagraphWithContentModel'; -import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; describe('formatParagraphWithContentModel', () => { let editor: IContentModelEditor; @@ -27,6 +27,7 @@ describe('formatParagraphWithContentModel', () => { addUndoSnapshot, createContentModel: () => model, setContentModel, + isDarkMode: () => false, getCustomData: () => ({}), getFocusedPosition: () => 'NewPosition', } as any) as IContentModelEditor; diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatSegmentWithContentModelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatSegmentWithContentModelTest.ts index 9cd9442b220..ed70deb83d4 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatSegmentWithContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatSegmentWithContentModelTest.ts @@ -35,6 +35,7 @@ describe('formatSegmentWithContentModel', () => { createContentModel: () => model, setContentModel, getFocusedPosition: () => null as NodePosition, + isDarkMode: () => false, } as any) as IContentModelEditor; }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatWithContentModelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatWithContentModelTest.ts index ee12942f250..292ceb6dc16 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatWithContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatWithContentModelTest.ts @@ -40,6 +40,7 @@ describe('formatWithContentModel', () => { getFocusedPosition, triggerContentChangedEvent, triggerPluginEvent, + isDarkMode: () => false, } as any) as IContentModelEditor; }); @@ -49,6 +50,7 @@ describe('formatWithContentModel', () => { formatWithContentModel(editor, apiName, callback); expect(callback).toHaveBeenCalledWith(mockedModel, { + newEntities: [], deletedEntities: [], rawEvent: undefined, }); @@ -64,6 +66,7 @@ describe('formatWithContentModel', () => { formatWithContentModel(editor, apiName, callback); expect(callback).toHaveBeenCalledWith(mockedModel, { + newEntities: [], deletedEntities: [], rawEvent: undefined, }); @@ -91,6 +94,7 @@ describe('formatWithContentModel', () => { }); expect(callback).toHaveBeenCalledWith(mockedModel, { + newEntities: [], deletedEntities: [], rawEvent: undefined, }); @@ -122,6 +126,7 @@ describe('formatWithContentModel', () => { formatWithContentModel(editor, apiName, callback); expect(callback).toHaveBeenCalledWith(mockedModel, { + newEntities: [], deletedEntities: [], rawEvent: undefined, skipUndoSnapshot: true, @@ -136,6 +141,7 @@ describe('formatWithContentModel', () => { formatWithContentModel(editor, apiName, callback, { changeSource: 'TEST' }); expect(callback).toHaveBeenCalledWith(mockedModel, { + newEntities: [], deletedEntities: [], rawEvent: undefined, }); @@ -155,6 +161,7 @@ describe('formatWithContentModel', () => { }); expect(callback).toHaveBeenCalledWith(mockedModel, { + newEntities: [], deletedEntities: [], rawEvent: undefined, skipUndoSnapshot: true, @@ -171,6 +178,7 @@ describe('formatWithContentModel', () => { formatWithContentModel(editor, apiName, callback, { onNodeCreated: onNodeCreated }); expect(callback).toHaveBeenCalledWith(mockedModel, { + newEntities: [], deletedEntities: [], rawEvent: undefined, }); @@ -187,6 +195,7 @@ describe('formatWithContentModel', () => { formatWithContentModel(editor, apiName, callback, { getChangeData }); expect(callback).toHaveBeenCalledWith(mockedModel, { + newEntities: [], deletedEntities: [], rawEvent: undefined, }); @@ -240,6 +249,35 @@ describe('formatWithContentModel', () => { }); }); + it('Has new entity in dark mode', () => { + const wrapper1 = 'W1' as any; + const wrapper2 = 'W2' as any; + const entity1 = { id: 'E1', type: 'E', wrapper: wrapper1, isReadonly: true } as any; + const entity2 = { id: 'E2', type: 'E', wrapper: wrapper2, isReadonly: true } as any; + const rawEvent = 'RawEvent' as any; + const transformToDarkColorSpy = jasmine.createSpy('transformToDarkColor'); + + editor.isDarkMode = () => true; + editor.transformToDarkColor = transformToDarkColorSpy; + + formatWithContentModel( + editor, + apiName, + (model, context) => { + context.newEntities.push(entity1, entity2); + return true; + }, + { + rawEvent: rawEvent, + } + ); + + expect(triggerPluginEvent).not.toHaveBeenCalled(); + expect(transformToDarkColorSpy).toHaveBeenCalledTimes(2); + expect(transformToDarkColorSpy).toHaveBeenCalledWith(wrapper1); + expect(transformToDarkColorSpy).toHaveBeenCalledWith(wrapper2); + }); + it('With selectionOverride', () => { const range = 'MockedRangeEx' as any; diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts index 8f6bed17ffb..1d7fe68b040 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts @@ -109,6 +109,7 @@ describe('Paste ', () => { getDocument, getTrustedHTMLHandler, triggerPluginEvent, + isDarkMode: () => false, } as any) as IContentModelEditor; }); @@ -429,7 +430,7 @@ describe('mergePasteContent', () => { pasteF.mergePasteContent( sourceModel, - { deletedEntities: [] }, + { newEntities: [], deletedEntities: [] }, pasteModel, false /* applyCurrentFormat */, undefined /* customizedMerge */ @@ -438,7 +439,7 @@ describe('mergePasteContent', () => { expect(mergeModelFile.mergeModel).toHaveBeenCalledWith( sourceModel, pasteModel, - { deletedEntities: [] }, + { newEntities: [], deletedEntities: [] }, { mergeFormat: 'none', mergeTable: true, @@ -517,7 +518,7 @@ describe('mergePasteContent', () => { pasteF.mergePasteContent( sourceModel, - { deletedEntities: [] }, + { newEntities: [], deletedEntities: [] }, pasteModel, false /* applyCurrentFormat */, customizedMerge /* customizedMerge */ @@ -535,7 +536,7 @@ describe('mergePasteContent', () => { pasteF.mergePasteContent( sourceModel, - { deletedEntities: [] }, + { newEntities: [], deletedEntities: [] }, pasteModel, true /* applyCurrentFormat */, undefined /* customizedMerge */ @@ -544,7 +545,7 @@ describe('mergePasteContent', () => { expect(mergeModelFile.mergeModel).toHaveBeenCalledWith( sourceModel, pasteModel, - { deletedEntities: [] }, + { newEntities: [], deletedEntities: [] }, { mergeFormat: 'keepSourceEmphasisFormat', mergeTable: false, diff --git a/packages/roosterjs-editor-core/lib/editor/EditorBase.ts b/packages/roosterjs-editor-core/lib/editor/EditorBase.ts index 861d4146acc..4b949700a32 100644 --- a/packages/roosterjs-editor-core/lib/editor/EditorBase.ts +++ b/packages/roosterjs-editor-core/lib/editor/EditorBase.ts @@ -59,6 +59,7 @@ import { } from 'roosterjs-editor-dom'; import type { CompatibleChangeSource, + CompatibleColorTransformDirection, CompatibleContentPosition, CompatibleExperimentalFeatures, CompatibleGetContentMode, @@ -899,16 +900,16 @@ export class EditorBase Date: Fri, 8 Sep 2023 13:53:32 -0700 Subject: [PATCH 40/75] Content Model: Paste plain text applies current format (#2057) * Content Model: Paste plain text applies current format * fix build --- .../roosterjs-content-model-dom/lib/index.ts | 1 + .../common/applySegmentFormatToElement.ts | 16 ++++ .../lib/publicApi/utils/paste.ts | 85 +++++++++++-------- .../test/publicApi/utils/pasteTest.ts | 9 ++ 4 files changed, 75 insertions(+), 36 deletions(-) create mode 100644 packages-content-model/roosterjs-content-model-dom/lib/modelApi/common/applySegmentFormatToElement.ts diff --git a/packages-content-model/roosterjs-content-model-dom/lib/index.ts b/packages-content-model/roosterjs-content-model-dom/lib/index.ts index 76f505f1e49..2ea7dec17ab 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/index.ts @@ -42,6 +42,7 @@ export { unwrapBlock } from './modelApi/common/unwrapBlock'; export { addSegment } from './modelApi/common/addSegment'; export { isWhiteSpacePreserved } from './modelApi/common/isWhiteSpacePreserved'; export { normalizeSingleSegment } from './modelApi/common/normalizeSegment'; +export { applySegmentFormatToElement } from './modelApi/common/applySegmentFormatToElement'; export { setParagraphNotImplicit } from './modelApi/block/setParagraphNotImplicit'; diff --git a/packages-content-model/roosterjs-content-model-dom/lib/modelApi/common/applySegmentFormatToElement.ts b/packages-content-model/roosterjs-content-model-dom/lib/modelApi/common/applySegmentFormatToElement.ts new file mode 100644 index 00000000000..a3a78e44d00 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-dom/lib/modelApi/common/applySegmentFormatToElement.ts @@ -0,0 +1,16 @@ +import { applyFormat } from '../../modelToDom/utils/applyFormat'; +import { ContentModelSegmentFormat } from 'roosterjs-content-model-types'; +import { createModelToDomContext } from '../../modelToDom/context/createModelToDomContext'; + +/** + * Format an existing HTML element using Segment Format + * @param element The element to format + * @param format The format to apply + */ +export function applySegmentFormatToElement( + element: HTMLElement, + format: ContentModelSegmentFormat +) { + const context = createModelToDomContext(); + applyFormat(element, context.formatAppliers.segment, format, context); +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/paste.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/paste.ts index 0196320f264..1ce040ccae8 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/paste.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/paste.ts @@ -1,10 +1,11 @@ -import { domToContentModel } from 'roosterjs-content-model-dom'; +import getSelectedSegments from '../selection/getSelectedSegments'; +import { applySegmentFormatToElement, domToContentModel } from 'roosterjs-content-model-dom'; +import { ContentModelDocument, ContentModelSegmentFormat } from 'roosterjs-content-model-types'; import { formatWithContentModel } from './formatWithContentModel'; import { FormatWithContentModelContext } from '../../publicTypes/parameter/FormatWithContentModelContext'; import { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; import { mergeModel } from '../../modelApi/common/mergeModel'; import { NodePosition } from 'roosterjs-editor-types'; -import { ContentModelDocument } from 'roosterjs-content-model-types'; import ContentModelBeforePasteEvent, { ContentModelBeforePasteEventData, } from '../../publicTypes/event/ContentModelBeforePasteEvent'; @@ -47,39 +48,44 @@ export default function paste( clipboardData.snapshotBeforePaste = editor.getContent(GetContentMode.RawHTMLWithSelection); } - const eventData = createBeforePasteEventData( + formatWithContentModel( editor, - clipboardData, - getPasteType(pasteAsText, applyCurrentFormat, pasteAsImage) - ); - - const { - domToModelOption, - fragment, - customizedMerge, - } = triggerPluginEventAndCreatePasteFragment( - editor, - clipboardData, - null /* position */, - pasteAsText, - pasteAsImage, - eventData + 'Paste', + (model, context) => { + const eventData = createBeforePasteEventData( + editor, + clipboardData, + getPasteType(pasteAsText, applyCurrentFormat, pasteAsImage) + ); + const currentSegment = getSelectedSegments(model, true /*includingFormatHolder*/)[0]; + const { fontFamily, fontSize, textColor, backgroundColor, letterSpacing, lineHeight } = + currentSegment?.format ?? {}; + const { + domToModelOption, + fragment, + customizedMerge, + } = triggerPluginEventAndCreatePasteFragment( + editor, + clipboardData, + null /* position */, + pasteAsText, + pasteAsImage, + eventData, + { fontFamily, fontSize, textColor, backgroundColor, letterSpacing, lineHeight } + ); + + const pasteModel = domToContentModel(fragment, domToModelOption); + + mergePasteContent(model, context, pasteModel, applyCurrentFormat, customizedMerge); + + return true; + }, + + { + changeSource: ChangeSource.Paste, + getChangeData: () => clipboardData, + } ); - - const pasteModel = domToContentModel(fragment, domToModelOption); - - if (pasteModel) { - formatWithContentModel( - editor, - 'Paste', - (model, context) => - mergePasteContent(model, context, pasteModel, applyCurrentFormat, customizedMerge), - { - changeSource: ChangeSource.Paste, - getChangeData: () => clipboardData, - } - ); - } } /** @@ -94,7 +100,7 @@ export function mergePasteContent( customizedMerge: | undefined | ((source: ContentModelDocument, target: ContentModelDocument) => void) -): boolean { +) { if (customizedMerge) { customizedMerge(model, pasteModel); } else { @@ -103,7 +109,6 @@ export function mergePasteContent( mergeTable: shouldMergeTable(pasteModel), }); } - return true; } function shouldMergeTable(pasteModel: ContentModelDocument): boolean | undefined { @@ -153,7 +158,8 @@ function triggerPluginEventAndCreatePasteFragment( position: NodePosition | null, pasteAsText: boolean, pasteAsImage: boolean, - eventData: ContentModelBeforePasteEventData + eventData: ContentModelBeforePasteEventData, + currentFormat: ContentModelSegmentFormat ): ContentModelBeforePasteEventData { const event = { eventType: PluginEventType.BeforePaste, @@ -182,6 +188,13 @@ function triggerPluginEventAndCreatePasteFragment( handleTextPaste(text, position, fragment); } + const formatContainer = fragment.ownerDocument.createElement('span'); + + moveChildNodes(formatContainer, fragment); + fragment.appendChild(formatContainer); + + applySegmentFormatToElement(formatContainer, currentFormat); + let pluginEvent: ContentModelBeforePasteEvent = event; // Step 4: Trigger BeforePasteEvent so that plugins can do proper change before paste, when the type of paste is different than Plain Text if (event.pasteType !== PasteType.AsPlainText) { diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts index 1d7fe68b040..9ed35c2eaef 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts @@ -2,6 +2,7 @@ import * as addParserF from '../../../lib/editor/plugins/PastePlugin/utils/addPa import * as domToContentModel from 'roosterjs-content-model-dom/lib/domToModel/domToContentModel'; import * as ExcelF from '../../../lib/editor/plugins/PastePlugin/Excel/processPastedContentFromExcel'; import * as getPasteSourceF from 'roosterjs-editor-dom/lib/pasteSourceValidations/getPasteSource'; +import * as getSelectedSegmentsF from '../../../lib/publicApi/selection/getSelectedSegments'; import * as mergeModelFile from '../../../lib/modelApi/common/mergeModel'; import * as PPT from '../../../lib/editor/plugins/PastePlugin/PowerPoint/processPastedContentFromPowerPoint'; import * as setProcessorF from '../../../lib/editor/plugins/PastePlugin/utils/setProcessor'; @@ -97,6 +98,14 @@ describe('Paste ', () => { .createSpy('getTrustedHTMLHandler') .and.returnValue((html: string) => html); spyOn(mergeModelFile, 'mergeModel').and.callFake(() => (mockedModel = mockedMergeModel)); + spyOn(getSelectedSegmentsF, 'default').and.returnValue([ + { + format: { + fontSize: '1pt', + fontFamily: 'Arial', + }, + } as any, + ]); editor = ({ focus, From b77c854edaf8d8390d547f5cbbbc6cef773121a7 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Fri, 8 Sep 2023 14:34:22 -0700 Subject: [PATCH 41/75] Content Model: Fix a regression of shadow edit (#2058) --- .../lib/editor/coreApi/switchShadowEdit.ts | 11 +++++++---- .../test/editor/coreApi/switchShadowEditTest.ts | 1 + 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/switchShadowEdit.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/switchShadowEdit.ts index 7533f70963c..860232f5dcd 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/switchShadowEdit.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/switchShadowEdit.ts @@ -14,10 +14,7 @@ export const switchShadowEdit: SwitchShadowEdit = (editorCore, isOn): void => { if (isOn != !!core.lifecycle.shadowEditFragment) { if (isOn) { - if (!core.cachedModel) { - core.cachedModel = core.api.createContentModel(core); - } - + const model = !core.cachedModel ? core.api.createContentModel(core) : null; const range = core.api.getSelectionRange(core, true /*tryGetFromCache*/); // Fake object, not used in Content Model Editor, just to satisfy original editor code @@ -35,6 +32,12 @@ export const switchShadowEdit: SwitchShadowEdit = (editorCore, isOn): void => { false /*broadcast*/ ); + // This need to be done after EnteredShadowEdit event is triggered since EnteredShadowEdit event will cause a SelectionChanged event + // if current selection is table selection or image selection + if (!core.cachedModel && model) { + core.cachedModel = model; + } + core.lifecycle.shadowEditSelectionPath = selectionPath; core.lifecycle.shadowEditFragment = fragment; } else { diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/switchShadowEditTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/switchShadowEditTest.ts index 8ec02383909..e2c79d1f08c 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/switchShadowEditTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/switchShadowEditTest.ts @@ -32,6 +32,7 @@ describe('switchShadowEdit', () => { describe('was off', () => { it('no cache, isOn', () => { + core.cachedModel = undefined; switchShadowEdit(core, true); expect(createContentModel).toHaveBeenCalledWith(core); From d047ca31d1b0d8027778c1fa15d69d05ba487ce1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Mon, 11 Sep 2023 14:52:22 -0300 Subject: [PATCH 42/75] deprecate auto format list --- .../ContentModelEditorOptionsPlugin.ts | 1 - .../ContentModelExperimentalFeatures.tsx | 2 -- .../editorOptions/EditorOptionsPlugin.ts | 5 +---- .../editorOptions/ExperimentalFeatures.tsx | 2 -- .../lib/utils/toggleListType.ts | 4 +--- .../test/utils/toggleListTypeTest.ts | 19 ++++--------------- .../ContentEdit/features/listFeatures.ts | 14 +++----------- .../lib/enum/ExperimentalFeatures.ts | 1 + 8 files changed, 10 insertions(+), 38 deletions(-) diff --git a/demo/scripts/controls/sidePane/editorOptions/ContentModelEditorOptionsPlugin.ts b/demo/scripts/controls/sidePane/editorOptions/ContentModelEditorOptionsPlugin.ts index 36c0169e7b5..78d964f43d7 100644 --- a/demo/scripts/controls/sidePane/editorOptions/ContentModelEditorOptionsPlugin.ts +++ b/demo/scripts/controls/sidePane/editorOptions/ContentModelEditorOptionsPlugin.ts @@ -29,7 +29,6 @@ const initialState: BuildInPluginState = { watermarkText: 'Type content here ...', forcePreserveRatio: false, experimentalFeatures: [ - ExperimentalFeatures.AutoFormatList, ExperimentalFeatures.InlineEntityReadOnlyDelimiters, ExperimentalFeatures.ContentModelPaste, ], diff --git a/demo/scripts/controls/sidePane/editorOptions/ContentModelExperimentalFeatures.tsx b/demo/scripts/controls/sidePane/editorOptions/ContentModelExperimentalFeatures.tsx index bcb2cabe1b6..97f1f8df0b6 100644 --- a/demo/scripts/controls/sidePane/editorOptions/ContentModelExperimentalFeatures.tsx +++ b/demo/scripts/controls/sidePane/editorOptions/ContentModelExperimentalFeatures.tsx @@ -10,8 +10,6 @@ export interface ExperimentalFeaturesProps { const FeatureNames: Partial> = { [ExperimentalFeatures.TabKeyTextFeatures]: 'Additional functionality to Tab Key', - [ExperimentalFeatures.AutoFormatList]: - 'Trigger formatting by a especial characters. Ex: (A), 1. i).', [ExperimentalFeatures.ReuseAllAncestorListElements]: "Reuse ancestor list elements even if they don't match the types from the list item.", [ExperimentalFeatures.DeleteTableWithBackspace]: diff --git a/demo/scripts/controls/sidePane/editorOptions/EditorOptionsPlugin.ts b/demo/scripts/controls/sidePane/editorOptions/EditorOptionsPlugin.ts index 3deb1f82968..efc0aa9a051 100644 --- a/demo/scripts/controls/sidePane/editorOptions/EditorOptionsPlugin.ts +++ b/demo/scripts/controls/sidePane/editorOptions/EditorOptionsPlugin.ts @@ -28,10 +28,7 @@ const initialState: BuildInPluginState = { linkTitle: 'Ctrl+Click to follow the link:' + UrlPlaceholder, watermarkText: 'Type content here ...', forcePreserveRatio: false, - experimentalFeatures: [ - ExperimentalFeatures.AutoFormatList, - ExperimentalFeatures.InlineEntityReadOnlyDelimiters, - ], + experimentalFeatures: [ExperimentalFeatures.InlineEntityReadOnlyDelimiters], isRtl: false, tableFeaturesContainerSelector: '#' + 'EditorContainer', }; diff --git a/demo/scripts/controls/sidePane/editorOptions/ExperimentalFeatures.tsx b/demo/scripts/controls/sidePane/editorOptions/ExperimentalFeatures.tsx index 31fed768291..21bbb750c59 100644 --- a/demo/scripts/controls/sidePane/editorOptions/ExperimentalFeatures.tsx +++ b/demo/scripts/controls/sidePane/editorOptions/ExperimentalFeatures.tsx @@ -10,8 +10,6 @@ export interface ExperimentalFeaturesProps { const FeatureNames: Partial> = { [ExperimentalFeatures.TabKeyTextFeatures]: 'Additional functionality to Tab Key', - [ExperimentalFeatures.AutoFormatList]: - 'Trigger formatting by a especial characters. Ex: (A), 1. i).', [ExperimentalFeatures.ReuseAllAncestorListElements]: "Reuse ancestor list elements even if they don't match the types from the list item.", [ExperimentalFeatures.DeleteTableWithBackspace]: diff --git a/packages/roosterjs-editor-api/lib/utils/toggleListType.ts b/packages/roosterjs-editor-api/lib/utils/toggleListType.ts index 4ce85d3aa84..e268bf7deba 100644 --- a/packages/roosterjs-editor-api/lib/utils/toggleListType.ts +++ b/packages/roosterjs-editor-api/lib/utils/toggleListType.ts @@ -64,9 +64,7 @@ export default function toggleListType( if (vList && start && end) { vList.changeListType(start, end, listType); - if (editor.isFeatureEnabled(ExperimentalFeatures.AutoFormatList)) { - vList.setListStyleType(orderedStyle, unorderedStyle); - } + vList.setListStyleType(orderedStyle, unorderedStyle); vList.writeBack( editor.isFeatureEnabled(ExperimentalFeatures.ReuseAllAncestorListElements), editor.isFeatureEnabled(ExperimentalFeatures.DisableListChain) diff --git a/packages/roosterjs-editor-api/test/utils/toggleListTypeTest.ts b/packages/roosterjs-editor-api/test/utils/toggleListTypeTest.ts index 4321b3066eb..db43cb22ac7 100644 --- a/packages/roosterjs-editor-api/test/utils/toggleListTypeTest.ts +++ b/packages/roosterjs-editor-api/test/utils/toggleListTypeTest.ts @@ -32,7 +32,7 @@ describe('toggleListTypeTest()', () => { // Assert expect(editor.getContent()).toBe( - '
default format

  • test
' + '
default format

  • test
' ); }); @@ -53,7 +53,7 @@ describe('toggleListTypeTest()', () => { // Assert expect(editor.getContent()).toBe( - '
default format

  • test
  • test
' + '
default format

  • test
  • test
' ); }); @@ -74,12 +74,7 @@ describe('toggleListTypeTest()', () => { // Assert expect(editor.getContent()).toBe( - '
default format
' + - '

' + - '
' + - '
  • test
  • test
' + - '

' + - '
' + '
default format

  • test
  • test

' ); }); @@ -100,13 +95,7 @@ describe('toggleListTypeTest()', () => { // Assert expect(editor.getContent()).toBe( - '
default format
' + - '

' + - '
' + - '
  • test
' + - '

' + - '
  • test
' + - '
' + '
default format

  • test

  • test
' ); }); }); diff --git a/packages/roosterjs-editor-plugins/lib/plugins/ContentEdit/features/listFeatures.ts b/packages/roosterjs-editor-plugins/lib/plugins/ContentEdit/features/listFeatures.ts index 0b262fd1970..0fefd6db684 100644 --- a/packages/roosterjs-editor-plugins/lib/plugins/ContentEdit/features/listFeatures.ts +++ b/packages/roosterjs-editor-plugins/lib/plugins/ContentEdit/features/listFeatures.ts @@ -258,7 +258,6 @@ const AutoBullet: BuildInEditFeature = { let searcher: IPositionContentSearcher | null; if ( !cacheGetListElement(event, editor) && - !editor.isFeatureEnabled(ExperimentalFeatures.AutoFormatList) && (searcher = editor.getContentSearcherOfCursor(event)) ) { let textBeforeCursor = searcher.getSubStringBefore(4); @@ -305,19 +304,16 @@ const AutoBullet: BuildInEditFeature = { true /*canUndoByBackspace*/ ); }, + defaultDisabled: true, }; /** - * Requires @see ExperimentalFeatures.AutoFormatList to be enabled * AutoBulletList edit feature, provides the ability to automatically convert current line into a bullet list. */ const AutoBulletList: BuildInEditFeature = { keys: [Keys.SPACE], shouldHandleEvent: (event, editor) => { - if ( - !cacheGetListElement(event, editor) && - editor.isFeatureEnabled(ExperimentalFeatures.AutoFormatList) - ) { + if (!cacheGetListElement(event, editor)) { return shouldTriggerList(event, editor, getAutoBulletListStyle, ListType.Unordered); } return false; @@ -352,16 +348,12 @@ const AutoBulletList: BuildInEditFeature = { }; /** - * Requires @see ExperimentalFeatures.AutoFormatList to be enabled * AutoNumberingList edit feature, provides the ability to automatically convert current line into a numbering list. */ const AutoNumberingList: BuildInEditFeature = { keys: [Keys.SPACE], shouldHandleEvent: (event, editor) => { - if ( - !cacheGetListElement(event, editor) && - editor.isFeatureEnabled(ExperimentalFeatures.AutoFormatList) - ) { + if (!cacheGetListElement(event, editor)) { return shouldTriggerList(event, editor, getAutoNumberingListStyle, ListType.Ordered); } return false; diff --git a/packages/roosterjs-editor-types/lib/enum/ExperimentalFeatures.ts b/packages/roosterjs-editor-types/lib/enum/ExperimentalFeatures.ts index 410fa620c0a..203ba16a598 100644 --- a/packages/roosterjs-editor-types/lib/enum/ExperimentalFeatures.ts +++ b/packages/roosterjs-editor-types/lib/enum/ExperimentalFeatures.ts @@ -144,6 +144,7 @@ export const enum ExperimentalFeatures { TabKeyTextFeatures = 'TabKeyTextFeatures', /** + * @deprecated This feature is always enabled * Trigger formatting by a especial characters. Ex: (A), 1. i). */ AutoFormatList = 'AutoFormatList', From e47e6399445d08bc6b80efafcbf1d740d6d36ad3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Mon, 11 Sep 2023 15:38:09 -0300 Subject: [PATCH 43/75] remove code --- .../ContentEdit/features/listFeatures.ts | 56 +------------------ .../lib/enum/ExperimentalFeatures.ts | 12 ++-- 2 files changed, 9 insertions(+), 59 deletions(-) diff --git a/packages/roosterjs-editor-plugins/lib/plugins/ContentEdit/features/listFeatures.ts b/packages/roosterjs-editor-plugins/lib/plugins/ContentEdit/features/listFeatures.ts index 0fefd6db684..b9182dcdf6f 100644 --- a/packages/roosterjs-editor-plugins/lib/plugins/ContentEdit/features/listFeatures.ts +++ b/packages/roosterjs-editor-plugins/lib/plugins/ContentEdit/features/listFeatures.ts @@ -248,62 +248,12 @@ function isAListPattern(textBeforeCursor: string) { } /** - * AutoBullet edit feature, provides the ability to automatically convert current line into a list. - * When user input "1. ", convert into a numbering list - * When user input "- " or "* ", convert into a bullet list + * @deprecated Use AutoBulletList and AutoNumberingList instead */ const AutoBullet: BuildInEditFeature = { keys: [Keys.SPACE], - shouldHandleEvent: (event, editor) => { - let searcher: IPositionContentSearcher | null; - if ( - !cacheGetListElement(event, editor) && - (searcher = editor.getContentSearcherOfCursor(event)) - ) { - let textBeforeCursor = searcher.getSubStringBefore(4); - - // Auto list is triggered if: - // 1. Text before cursor exactly matches '*', '-' or '1.' - // 2. There's no non-text inline entities before cursor - return isAListPattern(textBeforeCursor) && !searcher.getNearestNonTextInlineElement(); - } - return false; - }, - handleEvent: (event, editor) => { - editor.insertContent(' '); - event.rawEvent.preventDefault(); - editor.addUndoSnapshot( - () => { - let regions: RegionBase[]; - let searcher = editor.getContentSearcherOfCursor(); - if (!searcher) { - return; - } - let textBeforeCursor = searcher.getSubStringBefore(4); - let textRange = searcher.getRangeFromText(textBeforeCursor, true /*exactMatch*/); - - if (!textRange) { - // no op if the range can't be found - } else if ( - textBeforeCursor.indexOf('*') == 0 || - textBeforeCursor.indexOf('-') == 0 - ) { - prepareAutoBullet(editor, textRange); - toggleBullet(editor); - } else if (isAListPattern(textBeforeCursor)) { - prepareAutoBullet(editor, textRange); - toggleNumbering(editor); - } else if ((regions = editor.getSelectedRegions()) && regions.length == 1) { - const num = parseInt(textBeforeCursor); - prepareAutoBullet(editor, textRange); - toggleNumbering(editor, num); - } - searcher.getRangeFromText(textBeforeCursor, true /*exactMatch*/)?.deleteContents(); - }, - undefined /*changeSource*/, - true /*canUndoByBackspace*/ - ); - }, + shouldHandleEvent: (event, editor) => {}, + handleEvent: (event, editor) => {}, defaultDisabled: true, }; diff --git a/packages/roosterjs-editor-types/lib/enum/ExperimentalFeatures.ts b/packages/roosterjs-editor-types/lib/enum/ExperimentalFeatures.ts index 203ba16a598..c1efaa5fec3 100644 --- a/packages/roosterjs-editor-types/lib/enum/ExperimentalFeatures.ts +++ b/packages/roosterjs-editor-types/lib/enum/ExperimentalFeatures.ts @@ -136,6 +136,12 @@ export const enum ExperimentalFeatures { */ EditWithContentModel = 'EditWithContentModel', + /** + * @deprecated This feature is always enabled + * Trigger formatting by a especial characters. Ex: (A), 1. i). + */ + AutoFormatList = 'AutoFormatList', + //#endregion /** @@ -143,12 +149,6 @@ export const enum ExperimentalFeatures { */ TabKeyTextFeatures = 'TabKeyTextFeatures', - /** - * @deprecated This feature is always enabled - * Trigger formatting by a especial characters. Ex: (A), 1. i). - */ - AutoFormatList = 'AutoFormatList', - /** * With this feature enabled, when writing back a list item we will re-use all * ancestor list elements, even if they don't match the types currently in the From b05498440d70cbc4158e01dfd1360d67e29e93dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Mon, 11 Sep 2023 15:50:00 -0300 Subject: [PATCH 44/75] fix build --- .../ContentEdit/features/listFeatures.ts | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/packages/roosterjs-editor-plugins/lib/plugins/ContentEdit/features/listFeatures.ts b/packages/roosterjs-editor-plugins/lib/plugins/ContentEdit/features/listFeatures.ts index b9182dcdf6f..80533c6c19b 100644 --- a/packages/roosterjs-editor-plugins/lib/plugins/ContentEdit/features/listFeatures.ts +++ b/packages/roosterjs-editor-plugins/lib/plugins/ContentEdit/features/listFeatures.ts @@ -34,13 +34,11 @@ import { Keys, PluginKeyboardEvent, QueryScope, - RegionBase, ListType, ExperimentalFeatures, PositionType, NumberingListType, BulletListType, - IPositionContentSearcher, } from 'roosterjs-editor-types'; const PREVIOUS_BLOCK_CACHE_KEY = 'previousBlock'; @@ -236,23 +234,14 @@ const OutdentWhenEnterOnEmptyLine: BuildInEditFeature = { defaultDisabled: !Browser.isIE && !Browser.isChrome, }; -/** - * Validate if a block of text is considered a list pattern - * The regex expression will look for patterns of the form: - * 1. 1> 1) 1- (1) - * @returns if a text is considered a list pattern - */ -function isAListPattern(textBeforeCursor: string) { - const REGEX: RegExp = /^(\*|-|[0-9]{1,2}\.|[0-9]{1,2}\>|[0-9]{1,2}\)|[0-9]{1,2}\-|\([0-9]{1,2}\))$/; - return REGEX.test(textBeforeCursor); -} - /** * @deprecated Use AutoBulletList and AutoNumberingList instead */ const AutoBullet: BuildInEditFeature = { keys: [Keys.SPACE], - shouldHandleEvent: (event, editor) => {}, + shouldHandleEvent: (event, editor) => { + return false; + }, handleEvent: (event, editor) => {}, defaultDisabled: true, }; From 34b0eb681739034c0bb870e88cdf0be19a24d33f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Mon, 11 Sep 2023 16:16:30 -0300 Subject: [PATCH 45/75] remove tests --- .../ContentEdit/features/listFeaturesTest.ts | 34 ------------------- 1 file changed, 34 deletions(-) diff --git a/packages/roosterjs-editor-plugins/test/ContentEdit/features/listFeaturesTest.ts b/packages/roosterjs-editor-plugins/test/ContentEdit/features/listFeaturesTest.ts index 6699268a8b1..3240773bc73 100644 --- a/packages/roosterjs-editor-plugins/test/ContentEdit/features/listFeaturesTest.ts +++ b/packages/roosterjs-editor-plugins/test/ContentEdit/features/listFeaturesTest.ts @@ -37,18 +37,6 @@ describe('listFeatures | AutoBullet', () => { editor.dispose(); }); - function runListPatternTest(text: string, expectedResult: boolean) { - const root = document.createElement('div'); - const mockedPosition = new PositionContentSearcher(root, new Position(root, 4)); - spyOn(mockedPosition, 'getSubStringBefore').and.returnValue(text); - editorSearchCursorSpy.and.returnValue(mockedPosition); - editorIsFeatureEnabled.and.returnValue(false); - const isAutoBulletTriggered = ListFeatures.autoBullet.shouldHandleEvent(null, editor, false) - ? true - : false; - expect(isAutoBulletTriggered).toBe(expectedResult); - } - function runTestWithNumberingStyles(text: string, expectedResult: boolean) { const wrapper = document.createElement('div'); const root = document.createElement('div'); @@ -100,20 +88,6 @@ describe('listFeatures | AutoBullet', () => { expect(isAutoBulletTriggered).toBe(expectedResult); } - it('AutoBullet detects the correct patterns', () => { - runListPatternTest('1.', true); - runListPatternTest('2.', true); - runListPatternTest('1)', true); - runListPatternTest('2)', true); - runListPatternTest('90)', true); - runListPatternTest('1-', true); - runListPatternTest('2-', true); - runListPatternTest('90-', true); - runListPatternTest('(1)', true); - runListPatternTest('(2)', true); - runListPatternTest('(90)', true); - }); - it('AutoBulletList detects the correct patterns', () => { runTestWithBulletStyles('*', true); runTestWithBulletStyles('-', true); @@ -148,14 +122,6 @@ describe('listFeatures | AutoBullet', () => { runTestWithNumberingStyles('(a)', true); }); - it('AutoBullet with ignores incorrect not valid patterns', () => { - runListPatternTest('1=', false); - runListPatternTest('1/', false); - runListPatternTest('1#', false); - runListPatternTest(' ', false); - runListPatternTest('', false); - }); - it('AutoBulletList with ignores incorrect not valid patterns', () => { runTestWithBulletStyles('1=', false); runTestWithBulletStyles('1/', false); From c98ea0bf3af20e48aa5a4a1f603bb1537797227a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Fri, 15 Sep 2023 15:20:51 -0300 Subject: [PATCH 46/75] fix rotator --- .../plugins/ImageEdit/imageEditors/Rotator.ts | 22 +++++++++---------- .../test/imageEdit/rotatorTest.ts | 4 ++-- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/imageEditors/Rotator.ts b/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/imageEditors/Rotator.ts index 3625d74afd8..b40195e4ec8 100644 --- a/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/imageEditors/Rotator.ts +++ b/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/imageEditors/Rotator.ts @@ -63,35 +63,35 @@ export function updateRotateHandleState( } else { rotateCenter.style.display = ''; rotateHandle.style.display = ''; - const rotateHandleRect = rotateHandle.getBoundingClientRect(); + const rotateCenterRect = rotateCenter.getBoundingClientRect(); const wrapperRect = wrapper.getBoundingClientRect(); - - if (rotateHandleRect && wrapperRect) { + const ROTATOR_HEIGHT = ROTATE_SIZE + ROTATE_GAP + RESIZE_HANDLE_MARGIN; + if (rotateCenterRect && wrapperRect) { let adjustedDistance = Number.MAX_SAFE_INTEGER; const angle = angleRad * DEG_PER_RAD; - if (angle < 45 && angle > -45 && wrapperRect.top - editorRect.top < ROTATE_GAP) { - const top = rotateHandleRect.top - editorRect.top; + if (angle < 45 && angle > -45 && wrapperRect.top - editorRect.top < ROTATOR_HEIGHT) { + const top = rotateCenterRect.top - editorRect.top; adjustedDistance = top; } else if ( angle <= -80 && angle >= -100 && - wrapperRect.left - editorRect.left < ROTATE_GAP + wrapperRect.left - editorRect.left < ROTATOR_HEIGHT ) { - const left = rotateHandleRect.left - editorRect.left; + const left = rotateCenterRect.left - editorRect.left; adjustedDistance = left; } else if ( angle >= 80 && angle <= 100 && - editorRect.right - wrapperRect.right < ROTATE_GAP + editorRect.right - wrapperRect.right < ROTATOR_HEIGHT ) { - const right = rotateHandleRect.right - editorRect.right; + const right = rotateCenterRect.right - editorRect.right; adjustedDistance = Math.min(editorRect.right - wrapperRect.right, right); } else if ( (angle <= -160 || angle >= 160) && - editorRect.bottom - wrapperRect.bottom < ROTATE_GAP + editorRect.bottom - wrapperRect.bottom < ROTATOR_HEIGHT ) { - const bottom = rotateHandleRect.bottom - editorRect.bottom; + const bottom = rotateCenterRect.bottom - editorRect.bottom; adjustedDistance = Math.min(editorRect.bottom - wrapperRect.bottom, bottom); } diff --git a/packages/roosterjs-editor-plugins/test/imageEdit/rotatorTest.ts b/packages/roosterjs-editor-plugins/test/imageEdit/rotatorTest.ts index 5159be20093..4f34ca2dc34 100644 --- a/packages/roosterjs-editor-plugins/test/imageEdit/rotatorTest.ts +++ b/packages/roosterjs-editor-plugins/test/imageEdit/rotatorTest.ts @@ -229,8 +229,8 @@ describe('updateRotateHandlePosition', () => { y: 3, toJSON: () => {}, }, - '-7px', - '1px', + '-6px', + '0px', '0px', { top: 2, From b27157cccabed72ebbaa7ff85c2c047bfbeacaeb Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Fri, 15 Sep 2023 15:50:32 -0700 Subject: [PATCH 47/75] Content Model: Fix line space when reduce font size (#2059) --- .../lib/publicApi/segment/changeFontSize.ts | 10 ++-- .../lib/publicApi/segment/setFontSize.ts | 30 +++++++++-- .../publicApi/segment/changeFontSizeTest.ts | 43 +++++++++++++++ .../test/publicApi/segment/setFontSizeTest.ts | 52 +++++++++++++++++++ 4 files changed, 128 insertions(+), 7 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/changeFontSize.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/changeFontSize.ts index 0832f626851..10868f8b4b8 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/changeFontSize.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/changeFontSize.ts @@ -1,7 +1,8 @@ -import { ContentModelSegmentFormat } from 'roosterjs-content-model-types'; +import { ContentModelParagraph, ContentModelSegmentFormat } from 'roosterjs-content-model-types'; import { formatSegmentWithContentModel } from '../utils/formatSegmentWithContentModel'; import { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; import { parseValueWithUnit } from 'roosterjs-content-model-dom'; +import { setFontSizeInternal } from './setFontSize'; /** * Default font size sequence, in pt. Suggest editor UI use this sequence as your font size list, @@ -24,15 +25,16 @@ export default function changeFontSize( formatSegmentWithContentModel( editor, 'changeFontSize', - format => changeFontSizeInternal(format, change), + (format, _, __, paragraph) => changeFontSizeInternal(change, format, paragraph), undefined /* segmentHasStyleCallback*/, true /*includingFormatHandler*/ ); } function changeFontSizeInternal( + change: 'increase' | 'decrease', format: ContentModelSegmentFormat, - change: 'increase' | 'decrease' + paragraph: ContentModelParagraph | null ) { if (format.fontSize) { let sizeInPt = parseValueWithUnit(format.fontSize, undefined /*element*/, 'pt'); @@ -40,7 +42,7 @@ function changeFontSizeInternal( if (sizeInPt > 0) { const newSize = getNewFontSize(sizeInPt, change == 'increase' ? 1 : -1, FONT_SIZES); - format.fontSize = newSize + 'pt'; + setFontSizeInternal(newSize + 'pt', format, paragraph); } } } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/setFontSize.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/setFontSize.ts index 87fe60c764f..c718710e088 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/setFontSize.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/setFontSize.ts @@ -1,3 +1,4 @@ +import { ContentModelParagraph, ContentModelSegmentFormat } from 'roosterjs-content-model-types'; import { formatSegmentWithContentModel } from '../utils/formatSegmentWithContentModel'; import { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; @@ -10,10 +11,33 @@ export default function setFontSize(editor: IContentModelEditor, fontSize: strin formatSegmentWithContentModel( editor, 'setFontSize', - format => { - format.fontSize = fontSize; - }, + (format, _, __, paragraph) => setFontSizeInternal(fontSize, format, paragraph), undefined /* segmentHasStyleCallback*/, true /*includingFormatHandler*/ ); } + +/** + * @internal + * Internal set font function shared by setFontSize and changeFontSize + */ +export function setFontSizeInternal( + fontSize: string, + format: ContentModelSegmentFormat, + paragraph: ContentModelParagraph | null +) { + format.fontSize = fontSize; + + // Since we have set font size to segment, it can be smaller than the one in paragraph format, so delete font size from paragraph + if (paragraph?.segmentFormat?.fontSize) { + const size = paragraph.segmentFormat.fontSize; + + paragraph.segments.forEach(segment => { + if (!segment.format.fontSize) { + segment.format.fontSize = size; + } + }); + + delete paragraph.segmentFormat.fontSize; + } +} diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/changeFontSizeTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/changeFontSizeTest.ts index d1aa4f7b317..c50faa49a45 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/changeFontSizeTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/changeFontSizeTest.ts @@ -378,4 +378,47 @@ describe('changeFontSize', () => { { onNodeCreated: undefined } ); }); + + it('Paragraph has font size', () => { + runTest( + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segmentFormat: { fontSize: '20pt' }, + segments: [ + { + segmentType: 'Text', + text: 'test', + format: { fontSize: '10pt' }, + isSelected: true, + }, + ], + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segmentFormat: {}, + segments: [ + { + segmentType: 'Text', + text: 'test', + format: { fontSize: '11pt' }, + isSelected: true, + }, + ], + }, + ], + }, + 1, + 'increase' + ); + }); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/setFontSizeTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/setFontSizeTest.ts index 09db567a658..78ee1274cb3 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/setFontSizeTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/setFontSizeTest.ts @@ -318,4 +318,56 @@ describe('setFontSize', () => { 1 ); }); + + it('With font size in paragraph', () => { + runTest( + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segmentFormat: { fontSize: '8pt', fontFamily: 'Arial' }, + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + isSelected: true, + }, + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segmentFormat: { fontFamily: 'Arial' }, + segments: [ + { + segmentType: 'Text', + text: 'test', + format: { fontSize: '10pt' }, + isSelected: true, + }, + { + segmentType: 'Text', + text: 'test', + format: { fontSize: '8pt' }, + }, + ], + }, + ], + }, + 1 + ); + }); }); From 1589a3a81db3bf2ccf06b1e29ff9a9d72b869a6a Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Fri, 15 Sep 2023 16:18:54 -0700 Subject: [PATCH 48/75] Content Model Customization refactor 0 (#2068) * Content Model Customization refactor 0 * fix build --- .../config/defaultContentModelFormatMap.ts | 54 ++++++++++++++ .../defaultHTMLStyleMap.ts} | 70 +------------------ .../context/createDomToModelContext.ts | 6 -- .../lib/domToModel/utils/getDefaultStyle.ts | 3 +- .../roosterjs-content-model-dom/lib/index.ts | 2 +- .../context/createModelToDomContext.ts | 6 -- .../handlers/handleFormatContainer.ts | 10 ++- .../lib/modelToDom/utils/stackFormat.ts | 3 +- .../context/createDomToModelContextTest.ts | 7 -- .../domToModel/utils/getDefaultStyleTest.ts | 16 ----- .../domToModel/utils/isBlockElementTest.ts | 46 ------------ .../segment/linkFormatHandlerTest.ts | 7 +- .../segment/textColorFormatHandlerTest.ts | 3 +- .../context/createModelToDomContextTest.ts | 7 -- .../lib/publicApi/block/setHeadingLevel.ts | 24 ++++--- .../lib/context/DomToModelOption.ts | 12 +--- .../lib/context/DomToModelSettings.ts | 5 -- .../lib/context/ModelToDomOption.ts | 6 -- .../lib/context/ModelToDomSettings.ts | 5 -- 19 files changed, 92 insertions(+), 200 deletions(-) create mode 100644 packages-content-model/roosterjs-content-model-dom/lib/config/defaultContentModelFormatMap.ts rename packages-content-model/roosterjs-content-model-dom/lib/{formatHandlers/utils/defaultStyles.ts => config/defaultHTMLStyleMap.ts} (58%) diff --git a/packages-content-model/roosterjs-content-model-dom/lib/config/defaultContentModelFormatMap.ts b/packages-content-model/roosterjs-content-model-dom/lib/config/defaultContentModelFormatMap.ts new file mode 100644 index 00000000000..18c40bdfcf8 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-dom/lib/config/defaultContentModelFormatMap.ts @@ -0,0 +1,54 @@ +import { DefaultImplicitFormatMap } from 'roosterjs-content-model-types'; + +/** + * @internal + * A map from tag name to its default implicit formats + */ +export const defaultContentModelFormatMap: DefaultImplicitFormatMap = { + a: { + underline: true, + }, + blockquote: { + marginTop: '1em', + marginBottom: '1em', + marginLeft: '40px', + marginRight: '40px', + }, + code: { + fontFamily: 'monospace', + }, + h1: { + fontWeight: 'bold', + fontSize: '2em', + }, + h2: { + fontWeight: 'bold', + fontSize: '1.5em', + }, + h3: { + fontWeight: 'bold', + fontSize: '1.17em', + }, + h4: { + fontWeight: 'bold', + fontSize: '1em', // Set this default value here to overwrite existing font size when change heading level + }, + h5: { + fontWeight: 'bold', + fontSize: '0.83em', + }, + h6: { + fontWeight: 'bold', + fontSize: '0.67em', + }, + p: { + marginTop: '1em', + marginBottom: '1em', + }, + pre: { + fontFamily: 'monospace', + whiteSpace: 'pre', + marginTop: '1em', + marginBottom: '1em', + }, +}; diff --git a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/utils/defaultStyles.ts b/packages-content-model/roosterjs-content-model-dom/lib/config/defaultHTMLStyleMap.ts similarity index 58% rename from packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/utils/defaultStyles.ts rename to packages-content-model/roosterjs-content-model-dom/lib/config/defaultHTMLStyleMap.ts index 7eecb8fd775..73b487e852c 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/utils/defaultStyles.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/config/defaultHTMLStyleMap.ts @@ -1,4 +1,4 @@ -import { DefaultImplicitFormatMap, DefaultStyleMap } from 'roosterjs-content-model-types'; +import { DefaultStyleMap } from 'roosterjs-content-model-types'; const blockElement: Partial = { display: 'block', @@ -7,7 +7,7 @@ const blockElement: Partial = { /** * @internal */ -export const defaultStyleMap: DefaultStyleMap = { +export const defaultHTMLStyleMap: DefaultStyleMap = { address: blockElement, article: blockElement, aside: blockElement, @@ -123,69 +123,3 @@ export const defaultStyleMap: DefaultStyleMap = { }, ul: blockElement, }; - -/** - * @internal - */ -export const enum PseudoTagNames { - childOfPre = 'pre *', // This value is not a CSS selector, it just to tell this will impact elements under PRE tag. Any unique value here can work actually -} - -/** - * A map from tag name to its default implicit formats - */ -export const defaultImplicitFormatMap: DefaultImplicitFormatMap = { - a: { - underline: true, - }, - blockquote: { - marginTop: '1em', - marginBottom: '1em', - marginLeft: '40px', - marginRight: '40px', - }, - code: { - fontFamily: 'monospace', - }, - h1: { - fontWeight: 'bold', - fontSize: '2em', - }, - h2: { - fontWeight: 'bold', - fontSize: '1.5em', - }, - h3: { - fontWeight: 'bold', - fontSize: '1.17em', - }, - h4: { - fontWeight: 'bold', - fontSize: '1em', // Set this default value here to overwrite existing font size when change heading level - }, - h5: { - fontWeight: 'bold', - fontSize: '0.83em', - }, - h6: { - fontWeight: 'bold', - fontSize: '0.67em', - }, - p: { - marginTop: '1em', - marginBottom: '1em', - }, - pre: { - fontFamily: 'monospace', - whiteSpace: 'pre', - marginTop: '1em', - marginBottom: '1em', - }, - - // For PRE tag, the following styles will be included from the PRE tag. - // Adding this implicit style here so no need to generate these style for child elements - [PseudoTagNames.childOfPre]: { - fontFamily: 'monospace', - whiteSpace: 'pre', - }, -}; diff --git a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/context/createDomToModelContext.ts b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/context/createDomToModelContext.ts index b6e1c77bf07..92cd5343980 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/context/createDomToModelContext.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/context/createDomToModelContext.ts @@ -1,6 +1,5 @@ import { defaultFormatParsers, getFormatParsers } from '../../formatHandlers/defaultFormatHandlers'; import { defaultProcessorMap } from './defaultProcessors'; -import { defaultStyleMap } from '../../formatHandlers/utils/defaultStyles'; import { DomToModelContext, DomToModelOption, EditorContext } from 'roosterjs-content-model-types'; import { SelectionRangeEx } from 'roosterjs-editor-types'; @@ -43,11 +42,6 @@ export function createDomToModelContext( ...(options?.processorOverride || {}), }, - defaultStyles: { - ...defaultStyleMap, - ...(options?.defaultStyleOverride || {}), - }, - formatParsers: getFormatParsers( options?.formatParserOverride, options?.additionalFormatParsers diff --git a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/utils/getDefaultStyle.ts b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/utils/getDefaultStyle.ts index 8c64f57905b..2bd50107c05 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/utils/getDefaultStyle.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/utils/getDefaultStyle.ts @@ -1,3 +1,4 @@ +import { defaultHTMLStyleMap } from '../../config/defaultHTMLStyleMap'; import { DefaultStyleMap, DomToModelContext } from 'roosterjs-content-model-types'; /** @@ -13,5 +14,5 @@ export function getDefaultStyle( ): Partial { let tag = element.tagName.toLowerCase() as keyof DefaultStyleMap; - return context.defaultStyles[tag] || {}; + return defaultHTMLStyleMap[tag] || {}; } diff --git a/packages-content-model/roosterjs-content-model-dom/lib/index.ts b/packages-content-model/roosterjs-content-model-dom/lib/index.ts index 2ea7dec17ab..d9a94a2ba56 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/index.ts @@ -49,6 +49,6 @@ export { setParagraphNotImplicit } from './modelApi/block/setParagraphNotImplici export { parseValueWithUnit } from './formatHandlers/utils/parseValueWithUnit'; export { BorderKeys } from './formatHandlers/common/borderFormatHandler'; export { DeprecatedColors } from './formatHandlers/utils/color'; -export { defaultImplicitFormatMap } from './formatHandlers/utils/defaultStyles'; export { createDomToModelContext } from './domToModel/context/createDomToModelContext'; +export { createModelToDomContext } from './modelToDom/context/createModelToDomContext'; diff --git a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/context/createModelToDomContext.ts b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/context/createModelToDomContext.ts index 290fbce8d00..e66fb1e9952 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/context/createModelToDomContext.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/context/createModelToDomContext.ts @@ -1,5 +1,4 @@ import { defaultContentModelHandlers } from './defaultContentModelHandlers'; -import { defaultImplicitFormatMap } from '../../formatHandlers/utils/defaultStyles'; import { EditorContext, ModelToDomContext, ModelToDomOption } from 'roosterjs-content-model-types'; import { defaultFormatAppliers, @@ -7,7 +6,6 @@ import { } from '../../formatHandlers/defaultFormatHandlers'; /** - * @internal * @param editorContext * @returns */ @@ -39,10 +37,6 @@ export function createModelToDomContext( ...defaultContentModelHandlers, ...(options.modelHandlerOverride || {}), }, - defaultImplicitFormatMap: { - ...defaultImplicitFormatMap, - ...(options.defaultImplicitFormatOverride || {}), - }, defaultModelHandlers: defaultContentModelHandlers, defaultFormatAppliers: defaultFormatAppliers, diff --git a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleFormatContainer.ts b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleFormatContainer.ts index 035ca50891f..5eddc15464a 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleFormatContainer.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleFormatContainer.ts @@ -1,14 +1,20 @@ import { applyFormat } from '../utils/applyFormat'; import { isBlockGroupEmpty } from '../../modelApi/common/isEmpty'; -import { PseudoTagNames } from '../../formatHandlers/utils/defaultStyles'; import { reuseCachedElement } from '../utils/reuseCachedElement'; import { stackFormat } from '../utils/stackFormat'; import { + ContentModelBlockFormat, ContentModelBlockHandler, ContentModelFormatContainer, + ContentModelSegmentFormat, ModelToDomContext, } from 'roosterjs-content-model-types'; +const PreChildFormat: ContentModelSegmentFormat & ContentModelBlockFormat = { + fontFamily: 'monospace', + whiteSpace: 'pre', +}; + /** * @internal */ @@ -47,7 +53,7 @@ export const handleFormatContainer: ContentModelBlockHandler { + stackFormat(context, PreChildFormat, () => { context.modelHandlers.blockGroupChildren(doc, containerNode, container, context); }); } else { diff --git a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/utils/stackFormat.ts b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/utils/stackFormat.ts index 0d07b36a3d4..666eef9047d 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/utils/stackFormat.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/utils/stackFormat.ts @@ -1,3 +1,4 @@ +import { defaultContentModelFormatMap } from '../../config/defaultContentModelFormatMap'; import { ContentModelBlockFormat, ContentModelSegmentFormat, @@ -14,7 +15,7 @@ export function stackFormat( ) { const newFormat = typeof tagNameOrFormat === 'string' - ? context.defaultImplicitFormatMap[tagNameOrFormat] + ? defaultContentModelFormatMap[tagNameOrFormat] : tagNameOrFormat; if (newFormat) { diff --git a/packages-content-model/roosterjs-content-model-dom/test/domToModel/context/createDomToModelContextTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domToModel/context/createDomToModelContextTest.ts index e422c03519b..341a78734fe 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/domToModel/context/createDomToModelContextTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domToModel/context/createDomToModelContextTest.ts @@ -1,6 +1,5 @@ import { createDomToModelContext } from '../../../lib/domToModel/context/createDomToModelContext'; import { defaultProcessorMap } from '../../../lib/domToModel/context/defaultProcessors'; -import { defaultStyleMap } from '../../../lib/formatHandlers/utils/defaultStyles'; import { DomToModelListFormat, EditorContext } from 'roosterjs-content-model-types'; import { defaultFormatParsers, @@ -15,7 +14,6 @@ describe('createDomToModelContext', () => { }; const contextOptions = { elementProcessors: defaultProcessorMap, - defaultStyles: defaultStyleMap, formatParsers: getFormatParsers(), defaultElementProcessors: defaultProcessorMap, defaultFormatParsers: defaultFormatParsers, @@ -127,16 +125,12 @@ describe('createDomToModelContext', () => { it('with override', () => { const mockedAProcessor = 'a' as any; - const mockedOlStyle = 'ol' as any; const mockedBoldParser = 'bold' as any; const mockedBlockParser = 'block' as any; const context = createDomToModelContext(undefined, { processorOverride: { a: mockedAProcessor, }, - defaultStyleOverride: { - ol: mockedOlStyle, - }, formatParserOverride: { bold: mockedBoldParser, }, @@ -146,7 +140,6 @@ describe('createDomToModelContext', () => { }); expect(context.elementProcessors.a).toBe(mockedAProcessor); - expect(context.defaultStyles.ol).toBe(mockedOlStyle); expect(context.formatParsers.segment.indexOf(mockedBoldParser)).toBeGreaterThanOrEqual(0); expect(context.formatParsers.block).toEqual([ ...getFormatParsers().block, diff --git a/packages-content-model/roosterjs-content-model-dom/test/domToModel/utils/getDefaultStyleTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domToModel/utils/getDefaultStyleTest.ts index 83accf6c736..7ba866f7e6e 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/domToModel/utils/getDefaultStyleTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domToModel/utils/getDefaultStyleTest.ts @@ -18,22 +18,6 @@ describe('getDefaultStyle', () => { }); }); - it('Get customized default style of DIV', () => { - context = createDomToModelContext(undefined, { - defaultStyleOverride: { - div: { - color: 'red', - }, - }, - }); - const div = document.createElement('div'); - const style = getDefaultStyle(div, context); - - expect(style).toEqual({ - color: 'red', - }); - }); - it('Get default style of customized element', () => { const test = document.createElement('test'); const style = getDefaultStyle(test, context); diff --git a/packages-content-model/roosterjs-content-model-dom/test/domToModel/utils/isBlockElementTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domToModel/utils/isBlockElementTest.ts index 54793073af7..b2b5c73df0a 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/domToModel/utils/isBlockElementTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domToModel/utils/isBlockElementTest.ts @@ -67,52 +67,6 @@ describe('isBlockElement', () => { expect(result).toBeTrue(); }); - it('Override DIV default style', () => { - const div = document.createElement('div'); - - context = createDomToModelContext(undefined, { - defaultStyleOverride: { - div: { - display: 'inline', - }, - }, - }); - - const result = isBlockElement(div, context); - expect(result).toBeFalse(); - }); - - it('Override SPAN default style', () => { - const span = document.createElement('span'); - - context = createDomToModelContext(undefined, { - defaultStyleOverride: { - span: { - display: 'block', - }, - }, - }); - - const result = isBlockElement(span, context); - expect(result).toBeTrue(); - }); - - it('Double override SPAN', () => { - const span = document.createElement('span'); - span.style.display = 'inline'; - - context = createDomToModelContext(undefined, { - defaultStyleOverride: { - span: { - display: 'block', - }, - }, - }); - - const result = isBlockElement(span, context); - expect(result).toBeFalse(); - }); - it('display = flex', () => { const div = document.createElement('div'); div.style.display = 'flex'; diff --git a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/segment/linkFormatHandlerTest.ts b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/segment/linkFormatHandlerTest.ts index d34d548ec1b..9398dac5a91 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/segment/linkFormatHandlerTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/segment/linkFormatHandlerTest.ts @@ -1,5 +1,6 @@ import { createDomToModelContext } from '../../../lib/domToModel/context/createDomToModelContext'; import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext'; +import { defaultHTMLStyleMap } from '../../../lib/config/defaultHTMLStyleMap'; import { DomToModelContext, LinkFormat, ModelToDomContext } from 'roosterjs-content-model-types'; import { linkFormatHandler } from '../../../lib/formatHandlers/segment/linkFormatHandler'; @@ -17,7 +18,7 @@ describe('linkFormatHandler.parse', () => { div.setAttribute('href', '/test'); - linkFormatHandler.parse(format, div, context, context.defaultStyles.a!); + linkFormatHandler.parse(format, div, context, defaultHTMLStyleMap.a!); expect(format).toEqual({}); }); @@ -27,7 +28,7 @@ describe('linkFormatHandler.parse', () => { a.href = '/test'; - linkFormatHandler.parse(format, a, context, context.defaultStyles.a!); + linkFormatHandler.parse(format, a, context, defaultHTMLStyleMap.a!); expect(format).toEqual({ href: '/test', @@ -45,7 +46,7 @@ describe('linkFormatHandler.parse', () => { a.target = 'target'; a.name = 'name'; - linkFormatHandler.parse(format, a, context, context.defaultStyles.a!); + linkFormatHandler.parse(format, a, context, defaultHTMLStyleMap.a!); expect(format).toEqual({ anchorClass: 'class', diff --git a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/segment/textColorFormatHandlerTest.ts b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/segment/textColorFormatHandlerTest.ts index 1e7bcb1a68b..00feb9fd174 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/segment/textColorFormatHandlerTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/segment/textColorFormatHandlerTest.ts @@ -1,6 +1,7 @@ import DarkColorHandlerImpl from 'roosterjs-editor-core/lib/editor/DarkColorHandlerImpl'; import { createDomToModelContext } from '../../../lib/domToModel/context/createDomToModelContext'; import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext'; +import { defaultHTMLStyleMap } from '../../../lib/config/defaultHTMLStyleMap'; import { DeprecatedColors } from '../../../lib'; import { expectHtml } from 'roosterjs-editor-dom/test/DomTestHelper'; import { textColorFormatHandler } from '../../../lib/formatHandlers/segment/textColorFormatHandler'; @@ -74,7 +75,7 @@ describe('textColorFormatHandler.parse', () => { it('Color from hyperlink with override', () => { div.style.color = 'red'; - textColorFormatHandler.parse(format, div, context, context.defaultStyles.a!); + textColorFormatHandler.parse(format, div, context, defaultHTMLStyleMap.a!); expect(format).toEqual({ textColor: 'red', diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/context/createModelToDomContextTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/context/createModelToDomContextTest.ts index c029262fdf3..52ef94319c0 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/context/createModelToDomContextTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/context/createModelToDomContextTest.ts @@ -1,6 +1,5 @@ import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext'; import { defaultContentModelHandlers } from '../../../lib/modelToDom/context/defaultContentModelHandlers'; -import { defaultImplicitFormatMap } from '../../../lib/formatHandlers/utils/defaultStyles'; import { EditorContext, ModelToDomContext } from 'roosterjs-content-model-types'; import { defaultFormatAppliers, @@ -24,7 +23,6 @@ describe('createModelToDomContext', () => { implicitFormat: {}, formatAppliers: getFormatAppliers(), modelHandlers: defaultContentModelHandlers, - defaultImplicitFormatMap: defaultImplicitFormatMap, defaultModelHandlers: defaultContentModelHandlers, defaultFormatAppliers: defaultFormatAppliers, onNodeCreated: undefined, @@ -52,7 +50,6 @@ describe('createModelToDomContext', () => { const mockedBoldApplier = 'bold' as any; const mockedBlockApplier = 'block' as any; const mockedBrHandler = 'br' as any; - const mockedAStyle = 'a' as any; const onNodeCreated = 'OnNodeCreated' as any; const context = createModelToDomContext(undefined, { formatApplierOverride: { @@ -64,9 +61,6 @@ describe('createModelToDomContext', () => { modelHandlerOverride: { br: mockedBrHandler, }, - defaultImplicitFormatOverride: { - a: mockedAStyle, - }, onNodeCreated, }); @@ -86,7 +80,6 @@ describe('createModelToDomContext', () => { mockedBlockApplier, ]); expect(context.modelHandlers.br).toBe(mockedBrHandler); - expect(context.defaultImplicitFormatMap.a).toEqual(mockedAStyle); expect(context.defaultModelHandlers).toEqual(defaultContentModelHandlers); expect(context.defaultFormatAppliers).toEqual(defaultFormatAppliers); expect(context.onNodeCreated).toBe(onNodeCreated); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/setHeadingLevel.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/setHeadingLevel.ts index cbb797b2107..83fd603ba54 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/setHeadingLevel.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/setHeadingLevel.ts @@ -1,13 +1,18 @@ -import { defaultImplicitFormatMap } from 'roosterjs-content-model-dom'; +import { ContentModelParagraphDecorator } from 'roosterjs-content-model-types'; import { formatParagraphWithContentModel } from '../utils/formatParagraphWithContentModel'; import { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; -import { - ContentModelParagraphDecorator, - ContentModelSegmentFormat, -} from 'roosterjs-content-model-types'; type HeadingLevelTags = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; +const HeaderFontSizes: Record = { + h1: '2em', + h2: '1.5em', + h3: '1.17em', + h4: '1em', + h5: '0.83em', + h6: '0.67em', +}; + /** * Set heading level of selected paragraphs * @param editor The editor to set heading level to @@ -22,13 +27,16 @@ export default function setHeadingLevel( headingLevel > 0 ? (('h' + headingLevel) as HeadingLevelTags | null) : getExistingHeadingTag(para.decorator); - const headingStyle = - (tagName && (defaultImplicitFormatMap[tagName] as ContentModelSegmentFormat)) || {}; if (headingLevel > 0) { para.decorator = { tagName: tagName!, - format: { ...headingStyle }, + format: tagName + ? { + fontWeight: 'bold', + fontSize: HeaderFontSizes[tagName], + } + : {}, }; // Remove existing formats since tags have default font size and weight diff --git a/packages-content-model/roosterjs-content-model-types/lib/context/DomToModelOption.ts b/packages-content-model/roosterjs-content-model-types/lib/context/DomToModelOption.ts index 7814b6f8ad4..9a358ca0b9f 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/context/DomToModelOption.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/context/DomToModelOption.ts @@ -1,9 +1,4 @@ -import { - DefaultStyleMap, - ElementProcessorMap, - FormatParsers, - FormatParsersPerCategory, -} from './DomToModelSettings'; +import { ElementProcessorMap, FormatParsers, FormatParsersPerCategory } from './DomToModelSettings'; /** * Options for creating DomToModelContext @@ -14,11 +9,6 @@ export interface DomToModelOption { */ processorOverride?: Partial; - /** - * Overrides default element styles - */ - defaultStyleOverride?: DefaultStyleMap; - /** * Overrides default format handlers */ diff --git a/packages-content-model/roosterjs-content-model-types/lib/context/DomToModelSettings.ts b/packages-content-model/roosterjs-content-model-types/lib/context/DomToModelSettings.ts index 0041452c466..d89fb59478e 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/context/DomToModelSettings.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/context/DomToModelSettings.ts @@ -107,11 +107,6 @@ export interface DomToModelSettings { */ elementProcessors: ElementProcessorMap; - /** - * Map of default styles - */ - defaultStyles: DefaultStyleMap; - /** * Map of format parsers */ diff --git a/packages-content-model/roosterjs-content-model-types/lib/context/ModelToDomOption.ts b/packages-content-model/roosterjs-content-model-types/lib/context/ModelToDomOption.ts index 86fae8a178c..cc7218c100c 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/context/ModelToDomOption.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/context/ModelToDomOption.ts @@ -1,6 +1,5 @@ import { ContentModelHandlerMap, - DefaultImplicitFormatMap, FormatAppliers, FormatAppliersPerCategory, OnNodeCreated, @@ -25,11 +24,6 @@ export interface ModelToDomOption { */ modelHandlerOverride?: Partial; - /** - * Overrides default element styles - */ - defaultImplicitFormatOverride?: DefaultImplicitFormatMap; - /** * An optional callback that will be called when a DOM node is created * @param modelElement The related Content Model element diff --git a/packages-content-model/roosterjs-content-model-types/lib/context/ModelToDomSettings.ts b/packages-content-model/roosterjs-content-model-types/lib/context/ModelToDomSettings.ts index b96a7867480..aac581401cb 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/context/ModelToDomSettings.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/context/ModelToDomSettings.ts @@ -164,11 +164,6 @@ export interface ModelToDomSettings { */ formatAppliers: FormatAppliersPerCategory; - /** - * Map of default implicit format for segment - */ - defaultImplicitFormatMap: DefaultImplicitFormatMap; - /** * Default Content Model to DOM handlers before overriding. * This provides a way to call original handler from an overridden handler function From ff47d9fcc35ff644d83bfb953a0a51d287694072 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Mon, 18 Sep 2023 10:11:46 -0700 Subject: [PATCH 49/75] Content Model Customization refactor 1 (#2054) * Content Model Customization refactor * fix build * improve * fix build * Improve --- .../context/createDomToModelContext.ts | 116 +++++++++++---- .../lib/domToModel/domToContentModel.ts | 19 +-- .../formatHandlers/defaultFormatHandlers.ts | 57 +------- .../lib/modelToDom/contentModelToDom.ts | 23 ++- .../context/createModelToDomContext.ts | 93 +++++++++--- .../context/createDomToModelContextTest.ts | 136 ++++++++---------- .../test/domToModel/domToContentModelTest.ts | 27 +--- .../processors/blockProcessorTest.ts | 7 +- .../processors/childProcessorTest.ts | 6 +- .../processors/elementProcessorTest.ts | 2 +- .../formatContainerProcessorTest.ts | 16 ++- .../processors/generalProcessorTest.ts | 2 +- .../processors/imageProcessorTest.ts | 8 +- .../processors/listProcessorTest.ts | 6 +- .../processors/tableProcessorTest.ts | 16 ++- .../test/endToEndTest.ts | 16 +-- .../context/createModelToDomContextTest.ts | 119 ++++++++------- .../modelToDom/handlers/handleTableTest.ts | 11 +- .../utils/handleSegmentCommonTest.ts | 11 +- .../lib/editor/ContentModelEditor.ts | 10 +- .../lib/editor/coreApi/createContentModel.ts | 25 ++-- .../lib/editor/coreApi/setContentModel.ts | 17 ++- .../ContentModelCopyPastePlugin.ts | 12 +- .../editor/createContentModelEditorCore.ts | 13 +- .../overrides}/tablePreProcessor.ts | 0 .../publicApi/utils/formatWithContentModel.ts | 2 +- .../lib/publicApi/utils/paste.ts | 11 +- .../lib/publicTypes/ContentModelEditorCore.ts | 10 +- .../lib/publicTypes/IContentModelEditor.ts | 8 +- .../test/editor/ContentModelEditorTest.ts | 74 ++++++---- .../editor/coreApi/createContentModelTest.ts | 66 ++------- .../editor/coreApi/setContentModelTest.ts | 33 +++-- .../createContentModelEditorCoreTest.ts | 36 +++-- .../overrides}/tablePreProcessorTest.ts | 2 +- .../ContentModelCopyPastePluginTest.ts | 41 +++--- .../plugins/ContentModelFormatPluginTest.ts | 6 +- .../editor/plugins/paste/linkParserTest.ts | 18 ++- .../processPastedContentFromExcelTest.ts | 19 ++- .../paste/processPastedContentFromWacTest.ts | 31 ++-- ...processPastedContentFromWordDesktopTest.ts | 19 ++- .../test/publicApi/block/setAlignmentTest.ts | 6 +- .../publicApi/link/adjustLinkSelectionTest.ts | 4 +- .../test/publicApi/link/insertLinkTest.ts | 2 +- .../test/publicApi/link/removeLinkTest.ts | 4 +- .../publicApi/segment/changeFontSizeTest.ts | 7 +- .../publicApi/table/setTableCellShadeTest.ts | 3 +- .../utils/formatWithContentModelTest.ts | 6 +- .../lib/context/ModelToDomOption.ts | 8 -- tslint.json | 1 - 49 files changed, 646 insertions(+), 539 deletions(-) rename packages-content-model/roosterjs-content-model-editor/lib/{domToModel/processors => editor/overrides}/tablePreProcessor.ts (100%) rename packages-content-model/roosterjs-content-model-editor/test/{domToModel/processors => editor/overrides}/tablePreProcessorTest.ts (98%) diff --git a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/context/createDomToModelContext.ts b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/context/createDomToModelContext.ts index 92cd5343980..ebdbaa134cb 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/context/createDomToModelContext.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/context/createDomToModelContext.ts @@ -1,30 +1,62 @@ -import { defaultFormatParsers, getFormatParsers } from '../../formatHandlers/defaultFormatHandlers'; import { defaultProcessorMap } from './defaultProcessors'; -import { DomToModelContext, DomToModelOption, EditorContext } from 'roosterjs-content-model-types'; -import { SelectionRangeEx } from 'roosterjs-editor-types'; +import { getObjectKeys } from 'roosterjs-editor-dom'; +import { + defaultFormatKeysPerCategory, + defaultFormatParsers, +} from '../../formatHandlers/defaultFormatHandlers'; +import { + ContentModelBlockFormat, + DomToModelContext, + DomToModelDecoratorContext, + DomToModelFormatContext, + DomToModelOption, + DomToModelSelectionContext, + DomToModelSettings, + EditorContext, + FormatParser, + FormatParsers, + FormatParsersPerCategory, +} from 'roosterjs-content-model-types'; /** - * Create context object form DOM to Content Model conversion + * Create context object for DOM to Content Model conversion * @param editorContext Context of editor - * @param options Options for this context - * @param selection Selection that already exists in content + * @param options Option array to customize the DOM to Model conversion behavior */ export function createDomToModelContext( editorContext?: EditorContext, - options?: DomToModelOption, - selection?: SelectionRangeEx + ...options: (DomToModelOption | undefined)[] ): DomToModelContext { - const context: DomToModelContext = { - ...editorContext, + return Object.assign( + {}, + editorContext, + createDomToModelSelectionContext(), + createDomToModelFormatContext(editorContext?.isRootRtl), + createDomToModelDecoratorContext(), + createDomToModelSettings(options) + ); +} + +function createDomToModelSelectionContext(): DomToModelSelectionContext { + return { isInSelection: false }; +} - blockFormat: {}, +function createDomToModelFormatContext(isRootRtl?: boolean): DomToModelFormatContext { + const blockFormat: ContentModelBlockFormat = isRootRtl ? { direction: 'rtl' } : {}; + + return { + blockFormat, segmentFormat: {}, - isInSelection: false, listFormat: { levels: [], threadItemCounts: [], }, + }; +} + +function createDomToModelDecoratorContext(): DomToModelDecoratorContext { + return { link: { format: {}, dataset: {}, @@ -36,28 +68,54 @@ export function createDomToModelContext( format: {}, tagName: '', }, + }; +} - elementProcessors: { - ...defaultProcessorMap, - ...(options?.processorOverride || {}), - }, - - formatParsers: getFormatParsers( - options?.formatParserOverride, - options?.additionalFormatParsers +function createDomToModelSettings(options: (DomToModelOption | undefined)[]): DomToModelSettings { + return { + elementProcessors: Object.assign( + {}, + defaultProcessorMap, + ...options.map(x => x?.processorOverride) + ), + formatParsers: buildFormatParsers( + options.map(x => x?.formatParserOverride), + options.map(x => x?.additionalFormatParsers) ), - defaultElementProcessors: defaultProcessorMap, - defaultFormatParsers: defaultFormatParsers, + defaultFormatParsers, }; +} + +/** + * @internal Export for test only + * Build format parsers used by DOM to Content Model conversion + * @param override + * @param additionalParsersArray + * @returns + */ +export function buildFormatParsers( + overrides: (Partial | undefined)[] = [], + additionalParsersArray: (Partial | undefined)[] = [] +): FormatParsersPerCategory { + const combinedOverrides = Object.assign({}, ...overrides); - if (editorContext?.isRootRtl) { - context.blockFormat.direction = 'rtl'; - } + return getObjectKeys(defaultFormatKeysPerCategory).reduce((result, key) => { + const value = defaultFormatKeysPerCategory[key] + .map( + formatKey => + (combinedOverrides[formatKey] === undefined + ? defaultFormatParsers[formatKey] + : combinedOverrides[formatKey]) as FormatParser + ) + .concat( + ...additionalParsersArray.map( + parsers => (parsers?.[key] ?? []) as FormatParser[] + ) + ); - if (selection) { - context.rangeEx = selection; - } + result[key] = value; - return context; + return result; + }, {} as FormatParsersPerCategory); } diff --git a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/domToContentModel.ts b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/domToContentModel.ts index 874a82291dc..5b41db3d4f7 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/domToContentModel.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/domToContentModel.ts @@ -1,30 +1,23 @@ +import { ContentModelDocument, DomToModelContext } from 'roosterjs-content-model-types'; import { createContentModelDocument } from '../modelApi/creators/createContentModelDocument'; -import { createDomToModelContext } from './context/createDomToModelContext'; import { normalizeContentModel } from '../modelApi/common/normalizeContentModel'; import { SelectionRangeEx } from 'roosterjs-editor-types'; -import { - ContentModelDocument, - DomToModelOption, - EditorContext, -} from 'roosterjs-content-model-types'; /** * Create Content Model from DOM tree in this editor * @param root Root element of DOM tree to create Content Model from - * @param option The option to customize the behavior of DOM to Content Model conversion - * @param editorContext Context of content model editor - * @param selection Existing selection range in editor + * @param context Context object for DOM to Content Model conversion + * @param selection Selection that already exists in content * @returns A ContentModelDocument object that contains all the models created from the give root element */ export function domToContentModel( root: HTMLElement | DocumentFragment, - option?: DomToModelOption, - editorContext?: EditorContext, + context: DomToModelContext, selection?: SelectionRangeEx ): ContentModelDocument { - const model = createContentModelDocument(editorContext?.defaultFormat); - const context = createDomToModelContext(editorContext, option, selection); + const model = createContentModelDocument(context.defaultFormat); + context.rangeEx = selection; context.elementProcessors.child(model, root, context); normalizeContentModel(model); diff --git a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/defaultFormatHandlers.ts b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/defaultFormatHandlers.ts index 010fb3b5dba..e4557daafb7 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/defaultFormatHandlers.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/defaultFormatHandlers.ts @@ -36,14 +36,12 @@ import { whiteSpaceFormatHandler } from './block/whiteSpaceFormatHandler'; import { wordBreakFormatHandler } from './common/wordBreakFormatHandler'; import { ContentModelFormatMap, - FormatHandlerTypeMap, - FormatKey, FormatApplier, FormatAppliers, - FormatAppliersPerCategory, + FormatHandlerTypeMap, + FormatKey, FormatParser, FormatParsers, - FormatParsersPerCategory, } from 'roosterjs-content-model-types'; type FormatHandlers = { @@ -113,7 +111,10 @@ const sharedContainerFormats: (keyof FormatHandlerTypeMap)[] = [ 'border', ]; -const defaultFormatKeysPerCategory: { +/** + * @internal + */ +export const defaultFormatKeysPerCategory: { [key in keyof ContentModelFormatMap]: (keyof FormatHandlerTypeMap)[]; } = { block: sharedBlockFormats, @@ -218,49 +219,3 @@ export const defaultFormatAppliers: FormatAppliers = getObjectKeys(defaultFormat }, {} ); - -/** - * @internal - */ -export function getFormatParsers( - override: Partial = {}, - additionalParsers: Partial = {} -): FormatParsersPerCategory { - return getObjectKeys(defaultFormatKeysPerCategory).reduce((result, key) => { - const value = defaultFormatKeysPerCategory[key] - .map( - formatKey => - (override[formatKey] === undefined - ? defaultFormatParsers[formatKey] - : override[formatKey]) as FormatParser - ) - .concat((additionalParsers[key] as FormatParser[]) || []); - - result[key] = value; - - return result; - }, {} as FormatParsersPerCategory); -} - -/** - * @internal - */ -export function getFormatAppliers( - override: Partial = {}, - additionalAppliers: Partial = {} -): FormatAppliersPerCategory { - return getObjectKeys(defaultFormatKeysPerCategory).reduce((result, key) => { - const value = defaultFormatKeysPerCategory[key] - .map( - formatKey => - (override[formatKey] === undefined - ? defaultFormatAppliers[formatKey] - : override[formatKey]) as FormatApplier - ) - .concat((additionalAppliers[key] as FormatApplier[]) || []); - - result[key] = value; - - return result; - }, {} as FormatAppliersPerCategory); -} diff --git a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/contentModelToDom.ts b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/contentModelToDom.ts index 450ce903c76..e247153b549 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/contentModelToDom.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/contentModelToDom.ts @@ -1,12 +1,10 @@ -import { createModelToDomContext } from './context/createModelToDomContext'; import { createRange, Position, toArray } from 'roosterjs-editor-dom'; import { isNodeOfType } from '../domUtils/isNodeOfType'; import { ContentModelDocument, - EditorContext, ModelToDomBlockAndSegmentNode, ModelToDomContext, - ModelToDomOption, + OnNodeCreated, } from 'roosterjs-content-model-types'; import { NodePosition, @@ -22,25 +20,22 @@ import { * When a DOM node with existing node is passed, it will be merged with content model so that unchanged blocks * won't be touched. * @param model The content model document to generate DOM tree from - * @param editorContext Content for Content Model editor - * @param option Additional options to customize the behavior of Content Model to DOM conversion - * @returns A tuple of the following 3 objects: - * 1. Document Fragment that contains the DOM tree generated from the given model - * 2. A SelectionRangeEx object that contains selection info from the model if any, or null - * 3. An array entity DOM wrapper and its placeholder node pair for reusable root level entities. + * @param context The context object for Content Model to DOM conversion + * @param onNodeCreated Callback invoked when a DOM node is created + * @returns The selection range created in DOM tree from this model, or null when there is no selection */ export function contentModelToDom( doc: Document, root: Node, model: ContentModelDocument, - editorContext?: EditorContext, - option?: ModelToDomOption + context: ModelToDomContext, + onNodeCreated?: OnNodeCreated ): SelectionRangeEx | null { - const modelToDomContext = createModelToDomContext(editorContext, option); + context.onNodeCreated = onNodeCreated; - modelToDomContext.modelHandlers.blockGroupChildren(doc, root, model, modelToDomContext); + context.modelHandlers.blockGroupChildren(doc, root, model, context); - const range = extractSelectionRange(modelToDomContext); + const range = extractSelectionRange(context); root.normalize(); diff --git a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/context/createModelToDomContext.ts b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/context/createModelToDomContext.ts index e66fb1e9952..f9ae5723d32 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/context/createModelToDomContext.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/context/createModelToDomContext.ts @@ -1,45 +1,102 @@ import { defaultContentModelHandlers } from './defaultContentModelHandlers'; -import { EditorContext, ModelToDomContext, ModelToDomOption } from 'roosterjs-content-model-types'; +import { getObjectKeys } from 'roosterjs-editor-dom'; import { defaultFormatAppliers, - getFormatAppliers, + defaultFormatKeysPerCategory, } from '../../formatHandlers/defaultFormatHandlers'; +import { + EditorContext, + FormatApplier, + FormatAppliers, + FormatAppliersPerCategory, + ModelToDomContext, + ModelToDomFormatContext, + ModelToDomOption, + ModelToDomSelectionContext, + ModelToDomSettings, +} from 'roosterjs-content-model-types'; /** - * @param editorContext - * @returns + * Create context object fro Content Model to DOM conversion + * @param editorContext Context of editor + * @param options Option array to customize the Model to DOM conversion behavior */ export function createModelToDomContext( editorContext?: EditorContext, - options?: ModelToDomOption + ...options: (ModelToDomOption | undefined)[] ): ModelToDomContext { - options = options || {}; + return Object.assign( + {}, + editorContext, + createModelToDomSelectionContext(), + createModelToDomFormatContext(), + createModelToDomSettings(options) + ); +} +function createModelToDomSelectionContext(): ModelToDomSelectionContext { return { - ...editorContext, - regularSelection: { current: { block: null, segment: null, }, }, + }; +} + +function createModelToDomFormatContext(): ModelToDomFormatContext { + return { listFormat: { threadItemCounts: [], nodeStack: [], }, implicitFormat: {}, - formatAppliers: getFormatAppliers( - options.formatApplierOverride, - options.additionalFormatAppliers - ), - modelHandlers: { - ...defaultContentModelHandlers, - ...(options.modelHandlerOverride || {}), - }, + }; +} +function createModelToDomSettings(options: (ModelToDomOption | undefined)[]): ModelToDomSettings { + return { + modelHandlers: Object.assign( + {}, + defaultContentModelHandlers, + ...options.map(x => x?.modelHandlerOverride) + ), + formatAppliers: buildFormatAppliers( + options.map(x => x?.formatApplierOverride), + options.map(x => x?.additionalFormatAppliers) + ), defaultModelHandlers: defaultContentModelHandlers, - defaultFormatAppliers: defaultFormatAppliers, - onNodeCreated: options.onNodeCreated, + defaultFormatAppliers, }; } + +/** + * @internal Export for test only + * Build format appliers used by Content Model to DOM conversion + */ +export function buildFormatAppliers( + overrides: (Partial | undefined)[] = [], + additionalAppliersArray: (Partial | undefined)[] = [] +): FormatAppliersPerCategory { + const combinedOverrides = Object.assign({}, ...overrides); + + return getObjectKeys(defaultFormatKeysPerCategory).reduce((result, key) => { + const value = defaultFormatKeysPerCategory[key] + .map( + formatKey => + (combinedOverrides[formatKey] === undefined + ? defaultFormatAppliers[formatKey] + : combinedOverrides[formatKey]) as FormatApplier + ) + .concat( + ...additionalAppliersArray.map( + appliers => (appliers?.[key] ?? []) as FormatApplier[] + ) + ); + + result[key] = value; + + return result; + }, {} as FormatAppliersPerCategory); +} diff --git a/packages-content-model/roosterjs-content-model-dom/test/domToModel/context/createDomToModelContextTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domToModel/context/createDomToModelContextTest.ts index 341a78734fe..5093bba938d 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/domToModel/context/createDomToModelContextTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domToModel/context/createDomToModelContextTest.ts @@ -1,32 +1,23 @@ -import { createDomToModelContext } from '../../../lib/domToModel/context/createDomToModelContext'; +import { defaultFormatParsers } from '../../../lib/formatHandlers/defaultFormatHandlers'; import { defaultProcessorMap } from '../../../lib/domToModel/context/defaultProcessors'; -import { DomToModelListFormat, EditorContext } from 'roosterjs-content-model-types'; +import { EditorContext } from 'roosterjs-content-model-types'; import { - defaultFormatParsers, - getFormatParsers, -} from '../../../lib/formatHandlers/defaultFormatHandlers'; + buildFormatParsers, + createDomToModelContext, +} from '../../../lib/domToModel/context/createDomToModelContext'; describe('createDomToModelContext', () => { - const editorContext: EditorContext = {}; - const listFormat: DomToModelListFormat = { - threadItemCounts: [], - levels: [], - }; - const contextOptions = { - elementProcessors: defaultProcessorMap, - formatParsers: getFormatParsers(), - defaultElementProcessors: defaultProcessorMap, - defaultFormatParsers: defaultFormatParsers, - }; it('no param', () => { const context = createDomToModelContext(); expect(context).toEqual({ - ...editorContext, - segmentFormat: {}, - blockFormat: {}, isInSelection: false, - listFormat, + blockFormat: {}, + segmentFormat: {}, + listFormat: { + threadItemCounts: [], + levels: [], + }, link: { format: {}, dataset: {}, @@ -38,11 +29,14 @@ describe('createDomToModelContext', () => { format: {}, tagName: '', }, - ...contextOptions, + elementProcessors: defaultProcessorMap, + formatParsers: buildFormatParsers(), + defaultElementProcessors: defaultProcessorMap, + defaultFormatParsers, }); }); - it('with content model context', () => { + it('with editor context', () => { const editorContext: EditorContext = { isDarkMode: true, }; @@ -51,10 +45,13 @@ describe('createDomToModelContext', () => { expect(context).toEqual({ ...editorContext, - segmentFormat: {}, - blockFormat: {}, isInSelection: false, - listFormat, + blockFormat: {}, + segmentFormat: {}, + listFormat: { + threadItemCounts: [], + levels: [], + }, link: { format: {}, dataset: {}, @@ -66,47 +63,45 @@ describe('createDomToModelContext', () => { format: {}, tagName: '', }, - ...contextOptions, + elementProcessors: defaultProcessorMap, + formatParsers: buildFormatParsers(), + defaultElementProcessors: defaultProcessorMap, + defaultFormatParsers, }); }); - it('with content model context', () => { - const editorContext: EditorContext = { - isDarkMode: true, - }; - - const context = createDomToModelContext(editorContext); - - expect(context).toEqual({ - ...editorContext, - segmentFormat: {}, - blockFormat: {}, - isInSelection: false, - listFormat, - link: { - format: {}, - dataset: {}, + it('with override', () => { + const mockedAProcessor = 'a' as any; + const mockedBoldParser = 'bold' as any; + const mockedBlockParser = 'block' as any; + const context = createDomToModelContext(undefined, undefined, { + processorOverride: { + a: mockedAProcessor, }, - code: { - format: {}, + formatParserOverride: { + bold: mockedBoldParser, }, - blockDecorator: { - format: {}, - tagName: '', + additionalFormatParsers: { + block: mockedBlockParser, }, - ...contextOptions, }); - }); - it('with selection context', () => { - const selectionContext = { name: 'SelectionContext' } as any; - const context = createDomToModelContext(undefined, undefined, selectionContext); + const parsers = buildFormatParsers(); + + parsers.block[4] = mockedBlockParser; + parsers.elementBasedSegment[4] = mockedBoldParser; + parsers.segment[7] = mockedBoldParser; + parsers.segmentOnBlock[7] = mockedBoldParser; + parsers.segmentOnTableCell[7] = mockedBoldParser; expect(context).toEqual({ - segmentFormat: {}, - blockFormat: {}, isInSelection: false, - listFormat, + blockFormat: {}, + segmentFormat: {}, + listFormat: { + threadItemCounts: [], + levels: [], + }, link: { format: {}, dataset: {}, @@ -118,32 +113,13 @@ describe('createDomToModelContext', () => { format: {}, tagName: '', }, - ...contextOptions, - rangeEx: selectionContext, - }); - }); - - it('with override', () => { - const mockedAProcessor = 'a' as any; - const mockedBoldParser = 'bold' as any; - const mockedBlockParser = 'block' as any; - const context = createDomToModelContext(undefined, { - processorOverride: { + elementProcessors: { + ...defaultProcessorMap, a: mockedAProcessor, - }, - formatParserOverride: { - bold: mockedBoldParser, - }, - additionalFormatParsers: { - block: mockedBlockParser, - }, + } as any, + formatParsers: parsers, + defaultElementProcessors: defaultProcessorMap, + defaultFormatParsers, }); - - expect(context.elementProcessors.a).toBe(mockedAProcessor); - expect(context.formatParsers.segment.indexOf(mockedBoldParser)).toBeGreaterThanOrEqual(0); - expect(context.formatParsers.block).toEqual([ - ...getFormatParsers().block, - mockedBlockParser, - ]); }); }); diff --git a/packages-content-model/roosterjs-content-model-dom/test/domToModel/domToContentModelTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domToModel/domToContentModelTest.ts index 889ff65b742..399a2e138c5 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/domToModel/domToContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domToModel/domToContentModelTest.ts @@ -1,11 +1,6 @@ -import * as createDomToModelContext from '../../lib/domToModel/context/createDomToModelContext'; import * as normalizeContentModel from '../../lib/modelApi/common/normalizeContentModel'; import { domToContentModel } from '../../lib/domToModel/domToContentModel'; -import { - ContentModelDocument, - DomToModelContext, - EditorContext, -} from 'roosterjs-content-model-types'; +import { ContentModelDocument, DomToModelContext } from 'roosterjs-content-model-types'; describe('domToContentModel', () => { it('Not include root', () => { @@ -18,20 +13,16 @@ describe('domToContentModel', () => { }, defaultStyles: {}, segmentFormat: {}, + isDarkMode: false, + defaultFormat: { + fontSize: '10pt', + }, } as any) as DomToModelContext; - spyOn(createDomToModelContext, 'createDomToModelContext').and.returnValue(mockContext); spyOn(normalizeContentModel, 'normalizeContentModel'); const rootElement = document.createElement('div'); - const options = {}; - const editorContext: EditorContext = { - isDarkMode: false, - defaultFormat: { - fontSize: '10pt', - }, - }; - const model = domToContentModel(rootElement, options, editorContext); + const model = domToContentModel(rootElement, mockContext); const result: ContentModelDocument = { blockGroupType: 'Document', blocks: [], @@ -41,12 +32,6 @@ describe('domToContentModel', () => { }; expect(model).toEqual(result); - expect(createDomToModelContext.createDomToModelContext).toHaveBeenCalledTimes(1); - expect(createDomToModelContext.createDomToModelContext).toHaveBeenCalledWith( - editorContext, - options, - undefined - ); expect(elementProcessor).not.toHaveBeenCalled(); expect(childProcessor).toHaveBeenCalledTimes(1); expect(childProcessor).toHaveBeenCalledWith(result, rootElement, mockContext); diff --git a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/blockProcessorTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/blockProcessorTest.ts index a08131451e3..d8bf718faa2 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/blockProcessorTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/blockProcessorTest.ts @@ -14,11 +14,14 @@ describe('blockProcessor', () => { let childSpy: jasmine.Spy; beforeEach(() => { - context = createDomToModelContext(); group = createContentModelDocument(); childSpy = jasmine.createSpy('child'); - context.elementProcessors.child = childSpy; + context = createDomToModelContext(undefined, { + processorOverride: { + child: childSpy, + }, + }); }); function runTest( diff --git a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/childProcessorTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/childProcessorTest.ts index 4b34c2083bb..4a92fb4a3c8 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/childProcessorTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/childProcessorTest.ts @@ -352,7 +352,11 @@ describe('childProcessor', () => { div.innerHTML = '
  1. test1
test2
  1. test3
'; - context.elementProcessors.div = generalProcessor; + context = createDomToModelContext(undefined, { + processorOverride: { + div: generalProcessor, + }, + }); childProcessor(doc, div, context); diff --git a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/elementProcessorTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/elementProcessorTest.ts index 86f47fa2171..219b3804f45 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/elementProcessorTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/elementProcessorTest.ts @@ -15,7 +15,7 @@ describe('elementProcessor', () => { let divProcessor: jasmine.Spy>; let generalProcessor: jasmine.Spy>; let entityProcessor: jasmine.Spy>; - let delimiterProcessor: jasmine.Spy>; + let delimiterProcessor: jasmine.Spy>; beforeEach(() => { group = createContentModelDocument(); diff --git a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/formatContainerProcessorTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/formatContainerProcessorTest.ts index 06a58f5eb7c..8ca837a7264 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/formatContainerProcessorTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/formatContainerProcessorTest.ts @@ -189,9 +189,15 @@ describe('formatContainerProcessor', () => { quote.style.color = 'blue'; quote.style.borderLeft = 'solid 1px black'; + + context = createDomToModelContext(undefined, { + processorOverride: { + child: childProcessor, + }, + }); + context.segmentFormat.textColor = 'green'; context.segmentFormat.fontSize = '20px'; - context.elementProcessors.child = childProcessor; formatContainerProcessor(group, quote, context); @@ -225,14 +231,18 @@ describe('formatContainerProcessor', () => { quote.style.borderLeft = 'solid 1px black'; + context = createDomToModelContext(undefined, { + processorOverride: { + child: childProcessor, + }, + }); + context.blockFormat.backgroundColor = 'red'; context.blockFormat.htmlAlign = 'center'; context.blockFormat.lineHeight = '2'; context.blockFormat.whiteSpace = 'pre'; context.blockFormat.direction = 'rtl'; - context.elementProcessors.child = childProcessor; - formatContainerProcessor(group, quote, context); expect(group).toEqual({ diff --git a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/generalProcessorTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/generalProcessorTest.ts index 904613857e0..7e72033bc69 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/generalProcessorTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/generalProcessorTest.ts @@ -14,7 +14,7 @@ import { describe('generalProcessor', () => { let context: DomToModelContext; - let childProcessor: jasmine.Spy>; + let childProcessor: jasmine.Spy>; beforeEach(() => { childProcessor = jasmine.createSpy(); diff --git a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/imageProcessorTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/imageProcessorTest.ts index a6b4848e0ce..0ad7ef4521f 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/imageProcessorTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/imageProcessorTest.ts @@ -215,7 +215,9 @@ describe('imageProcessor', () => { img.src = 'http://test.com/testSrc'; - context.formatParsers.dataset = [datasetParser]; + context = createDomToModelContext(undefined, { + formatParserOverride: { dataset: datasetParser }, + }); imageProcessor(doc, img, context); @@ -251,7 +253,9 @@ describe('imageProcessor', () => { img.src = 'http://test.com/testSrc'; - context.formatParsers.dataset = [datasetParser]; + context = createDomToModelContext(undefined, { + formatParserOverride: { dataset: datasetParser }, + }); imageProcessor(doc, img, context); diff --git a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/listProcessorTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/listProcessorTest.ts index 653fcd523fb..b4b7a593612 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/listProcessorTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/listProcessorTest.ts @@ -8,7 +8,7 @@ import { listProcessor } from '../../../lib/domToModel/processors/listProcessor' describe('listProcessor', () => { let context: DomToModelContext; - let childProcessor: jasmine.Spy>; + let childProcessor: jasmine.Spy>; beforeEach(() => { childProcessor = jasmine.createSpy(); @@ -269,7 +269,7 @@ describe('listProcessor', () => { }); describe('listProcessor without format handlers', () => { - let childProcessor: jasmine.Spy>; + let childProcessor: jasmine.Spy>; let context: DomToModelContext; beforeEach(() => { @@ -447,7 +447,7 @@ describe('listProcessor without format handlers', () => { describe('listProcessor process metadata', () => { let context: DomToModelContext; - let childProcessor: jasmine.Spy>; + let childProcessor: jasmine.Spy>; beforeEach(() => { childProcessor = jasmine.createSpy(); diff --git a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/tableProcessorTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/tableProcessorTest.ts index 4b2cb122340..41ed7ca04b0 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/tableProcessorTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/tableProcessorTest.ts @@ -15,7 +15,7 @@ import { describe('tableProcessor', () => { let context: DomToModelContext; - let childProcessor: jasmine.Spy>; + let childProcessor: jasmine.Spy>; beforeEach(() => { childProcessor = jasmine.createSpy(); @@ -470,7 +470,9 @@ describe('tableProcessor with format', () => { const doc = createContentModelDocument(); const datasetParser = jasmine.createSpy('datasetParser'); - context.formatParsers.dataset = [datasetParser]; + context = createDomToModelContext(undefined, { + formatParserOverride: { dataset: datasetParser }, + }); tableProcessor(doc, mockedTable, context); @@ -511,7 +513,9 @@ describe('tableProcessor with format', () => { const doc = createContentModelDocument(); const datasetParser = jasmine.createSpy('datasetParser'); - context.formatParsers.dataset = [datasetParser]; + context = createDomToModelContext(undefined, { + formatParserOverride: { dataset: datasetParser }, + }); tableProcessor(doc, mockedTable, context); @@ -523,14 +527,12 @@ describe('tableProcessor with format', () => { describe('tableProcessor', () => { let context: DomToModelContext; - let childProcessor: jasmine.Spy>; + let childProcessor: jasmine.Spy>; beforeEach(() => { childProcessor = jasmine.createSpy(); context = createDomToModelContext(undefined, { - processorOverride: { - child: childProcessor, - }, + processorOverride: { child: childProcessor }, }); spyOn(getBoundingClientRect, 'getBoundingClientRect').and.returnValue(({ diff --git a/packages-content-model/roosterjs-content-model-dom/test/endToEndTest.ts b/packages-content-model/roosterjs-content-model-dom/test/endToEndTest.ts index a5693f7a613..e5caf9f84c9 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/endToEndTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/endToEndTest.ts @@ -1,25 +1,15 @@ import * as createGeneralBlock from '../lib/modelApi/creators/createGeneralBlock'; -import DarkColorHandlerImpl from 'roosterjs-editor-core/lib/editor/DarkColorHandlerImpl'; import { contentModelToDom } from '../lib/modelToDom/contentModelToDom'; +import { createDomToModelContext, createModelToDomContext } from '../lib'; import { domToContentModel } from '../lib/domToModel/domToContentModel'; import { expectHtml } from 'roosterjs-editor-api/test/TestHelper'; import { ContentModelBlockFormat, ContentModelDocument, ContentModelGeneralBlock, - EditorContext, } from 'roosterjs-content-model-types'; describe('End to end test for DOM => Model', () => { - let context: EditorContext; - - beforeEach(() => { - context = { - isDarkMode: false, - darkColorHandler: new DarkColorHandlerImpl({} as any, s => 'darkMock: ' + s), - }; - }); - function runTest( html: string, expectedModel: ContentModelDocument, @@ -29,13 +19,13 @@ describe('End to end test for DOM => Model', () => { const div1 = document.createElement('div'); div1.innerHTML = html; - const model = domToContentModel(div1, undefined, context); + const model = domToContentModel(div1, createDomToModelContext()); expect(model).toEqual(expectedModel); const div2 = document.createElement('div'); - contentModelToDom(document, div2, model, context); + contentModelToDom(document, div2, model, createModelToDomContext()); const possibleHTML = [ expectedHtml, //chrome or firefox expectedHTMLFirefox, //firefox diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/context/createModelToDomContextTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/context/createModelToDomContextTest.ts index 52ef94319c0..658fa16c5fe 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/context/createModelToDomContextTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/context/createModelToDomContextTest.ts @@ -1,39 +1,35 @@ -import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext'; import { defaultContentModelHandlers } from '../../../lib/modelToDom/context/defaultContentModelHandlers'; -import { EditorContext, ModelToDomContext } from 'roosterjs-content-model-types'; +import { defaultFormatAppliers } from '../../../lib/formatHandlers/defaultFormatHandlers'; +import { EditorContext } from 'roosterjs-content-model-types'; import { - defaultFormatAppliers, - getFormatAppliers, -} from '../../../lib/formatHandlers/defaultFormatHandlers'; + buildFormatAppliers, + createModelToDomContext, +} from '../../../lib/modelToDom/context/createModelToDomContext'; describe('createModelToDomContext', () => { - const editorContext: EditorContext = {}; - const defaultResult: ModelToDomContext = { - ...editorContext, - regularSelection: { - current: { - block: null, - segment: null, - }, - }, - listFormat: { - threadItemCounts: [], - nodeStack: [], - }, - implicitFormat: {}, - formatAppliers: getFormatAppliers(), - modelHandlers: defaultContentModelHandlers, - defaultModelHandlers: defaultContentModelHandlers, - defaultFormatAppliers: defaultFormatAppliers, - onNodeCreated: undefined, - }; it('no param', () => { const context = createModelToDomContext(); - expect(context).toEqual(defaultResult); + expect(context).toEqual({ + regularSelection: { + current: { + block: null, + segment: null, + }, + }, + listFormat: { + threadItemCounts: [], + nodeStack: [], + }, + implicitFormat: {}, + modelHandlers: defaultContentModelHandlers, + formatAppliers: buildFormatAppliers(), + defaultModelHandlers: defaultContentModelHandlers, + defaultFormatAppliers, + }); }); - it('with content model context', () => { + it('with editor context', () => { const editorContext: EditorContext = { isDarkMode: true, }; @@ -41,8 +37,22 @@ describe('createModelToDomContext', () => { const context = createModelToDomContext(editorContext); expect(context).toEqual({ - ...defaultResult, - ...editorContext, + isDarkMode: true, + regularSelection: { + current: { + block: null, + segment: null, + }, + }, + listFormat: { + threadItemCounts: [], + nodeStack: [], + }, + implicitFormat: {}, + modelHandlers: defaultContentModelHandlers, + formatAppliers: buildFormatAppliers(), + defaultModelHandlers: defaultContentModelHandlers, + defaultFormatAppliers, }); }); @@ -50,38 +60,45 @@ describe('createModelToDomContext', () => { const mockedBoldApplier = 'bold' as any; const mockedBlockApplier = 'block' as any; const mockedBrHandler = 'br' as any; - const onNodeCreated = 'OnNodeCreated' as any; const context = createModelToDomContext(undefined, { + modelHandlerOverride: { + br: mockedBrHandler, + }, formatApplierOverride: { bold: mockedBoldApplier, }, additionalFormatAppliers: { block: [mockedBlockApplier], }, - modelHandlerOverride: { - br: mockedBrHandler, - }, - onNodeCreated, }); - expect(context.regularSelection).toEqual({ - current: { - block: null, - segment: null, + const appliers = buildFormatAppliers(); + + appliers.block[4] = mockedBlockApplier; + appliers.elementBasedSegment[4] = mockedBoldApplier; + appliers.segment[7] = mockedBoldApplier; + appliers.segmentOnBlock[7] = mockedBoldApplier; + appliers.segmentOnTableCell[7] = mockedBoldApplier; + + expect(context).toEqual({ + regularSelection: { + current: { + block: null, + segment: null, + }, }, + listFormat: { + threadItemCounts: [], + nodeStack: [], + }, + implicitFormat: {}, + modelHandlers: { + ...defaultContentModelHandlers, + br: mockedBrHandler, + } as any, + formatAppliers: appliers, + defaultModelHandlers: defaultContentModelHandlers, + defaultFormatAppliers, }); - expect(context.listFormat).toEqual({ - threadItemCounts: [], - nodeStack: [], - }); - expect(context.implicitFormat).toEqual({}); - expect(context.formatAppliers.block).toEqual([ - ...getFormatAppliers().block, - mockedBlockApplier, - ]); - expect(context.modelHandlers.br).toBe(mockedBrHandler); - expect(context.defaultModelHandlers).toEqual(defaultContentModelHandlers); - expect(context.defaultFormatAppliers).toEqual(defaultFormatAppliers); - expect(context.onNodeCreated).toBe(onNodeCreated); }); }); diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleTableTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleTableTest.ts index 08642467bf4..f0af2442968 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleTableTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleTableTest.ts @@ -234,7 +234,16 @@ describe('handleTable', () => { it('Regular 1 * 1 table, handle dataset', () => { const datasetApplier = jasmine.createSpy('datasetApplier'); - context.formatAppliers.dataset = [datasetApplier]; + context = createModelToDomContext( + { + darkColorHandler: context.darkColorHandler, + }, + { + formatApplierOverride: { + dataset: datasetApplier, + }, + } + ); const div = document.createElement('div'); handleTable( diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/utils/handleSegmentCommonTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/utils/handleSegmentCommonTest.ts index 4c49f72a74b..7f9cde63d16 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/utils/handleSegmentCommonTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/utils/handleSegmentCommonTest.ts @@ -14,9 +14,9 @@ describe('handleSegmentCommon', () => { fontWeight: 'bold', }); const onNodeCreated = jasmine.createSpy('onNodeCreated'); - const context = createModelToDomContext(undefined, { - onNodeCreated, - }); + const context = createModelToDomContext(); + + context.onNodeCreated = onNodeCreated; context.darkColorHandler = new DarkColorHandlerImpl( document.createElement('div'), s => 'darkMock: ' + s @@ -47,10 +47,9 @@ describe('handleSegmentCommon', () => { const container = document.createElement('span'); const segment = createText('test', {}); const onNodeCreated = jasmine.createSpy('onNodeCreated'); - const context = createModelToDomContext(undefined, { - onNodeCreated, - }); + const context = createModelToDomContext(); + context.onNodeCreated = onNodeCreated; handleSegmentCommon(document, parent, container, segment, context); expect(context.regularSelection.current.segment).toBe(null); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts index 7384aaf62ba..48d29bd5e15 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts @@ -8,6 +8,7 @@ import { ContentModelSegmentFormat, DomToModelOption, ModelToDomOption, + OnNodeCreated, } from 'roosterjs-content-model-types'; /** @@ -43,11 +44,16 @@ export default class ContentModelEditor * Set content with content model * @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 */ - setContentModel(model: ContentModelDocument, option?: ModelToDomOption) { + setContentModel( + model: ContentModelDocument, + option?: ModelToDomOption, + onNodeCreated?: OnNodeCreated + ) { const core = this.getCore(); - core.api.setContentModel(core, model, option); + core.api.setContentModel(core, model, option, onNodeCreated); } /** diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/createContentModel.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/createContentModel.ts index b95b71a5288..8e86b9030a9 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/createContentModel.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/createContentModel.ts @@ -1,8 +1,7 @@ import { cloneModel } from '../../modelApi/common/cloneModel'; -import { domToContentModel } from 'roosterjs-content-model-dom'; +import { createDomToModelContext, domToContentModel } from 'roosterjs-content-model-dom'; import { DomToModelOption } from 'roosterjs-content-model-types'; import { SelectionRangeEx } from 'roosterjs-editor-types'; -import { tablePreProcessor } from '../../domToModel/processors/tablePreProcessor'; import { ContentModelEditorCore, CreateContentModel, @@ -11,7 +10,9 @@ import { /** * @internal * Create Content Model from DOM tree in this editor + * @param core The editor core object * @param option The option to customize the behavior of DOM to Content Model conversion + * @param selectionOverride When passed, use this selection range instead of current selection in editor */ export const createContentModel: CreateContentModel = (core, option, selectionOverride) => { let cachedModel = selectionOverride ? null : core.cachedModel; @@ -26,24 +27,16 @@ export const createContentModel: CreateContentModel = (core, option, selectionOv function internalCreateContentModel( core: ContentModelEditorCore, - option: DomToModelOption | undefined, + option?: DomToModelOption, selectionOverride?: SelectionRangeEx ) { - const context: DomToModelOption = { - ...core.defaultDomToModelOptions, - ...option, - }; - - context.processorOverride = { - table: tablePreProcessor, - ...context.processorOverride, - ...option?.processorOverride, - }; - return domToContentModel( core.contentDiv, - context, - core.api.createEditorContext(core), + createDomToModelContext( + core.api.createEditorContext(core), + ...(core.defaultDomToModelOptions || []), + option + ), selectionOverride || core.api.getSelectionRangeEx(core) ); } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/setContentModel.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/setContentModel.ts index a3b89e2e617..bb49bef7e6d 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/setContentModel.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/setContentModel.ts @@ -1,22 +1,25 @@ -import { contentModelToDom } from 'roosterjs-content-model-dom'; +import { contentModelToDom, createModelToDomContext } from 'roosterjs-content-model-dom'; import { SetContentModel } from '../../publicTypes/ContentModelEditorCore'; /** * @internal * Set content with content model + * @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 */ -export const setContentModel: SetContentModel = (core, model, option) => { +export const setContentModel: SetContentModel = (core, model, option, onNodeCreated) => { const range = contentModelToDom( core.contentDiv.ownerDocument, core.contentDiv, model, - core.api.createEditorContext(core), - { - ...core.defaultModelToDomOptions, - ...(option || {}), - } + createModelToDomContext( + core.api.createEditorContext(core), + ...(core.defaultModelToDomOptions || []), + option + ), + onNodeCreated ); if (!core.lifecycle.shadowEditFragment) { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCopyPastePlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCopyPastePlugin.ts index f23f8a65870..a3a5eedd2cb 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCopyPastePlugin.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCopyPastePlugin.ts @@ -1,11 +1,15 @@ import paste from '../../publicApi/utils/paste'; import { cloneModel } from '../../modelApi/common/cloneModel'; -import { contentModelToDom, normalizeContentModel } from 'roosterjs-content-model-dom'; import { DeleteResult } from '../../modelApi/edit/utils/DeleteSelectionStep'; import { deleteSelection } from '../../modelApi/edit/deleteSelection'; import { formatWithContentModel } from '../../publicApi/utils/formatWithContentModel'; import { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; import { iterateSelections } from '../../modelApi/selection/iterateSelections'; +import { + contentModelToDom, + createModelToDomContext, + normalizeContentModel, +} from 'roosterjs-content-model-dom'; import type { ContentModelBlock, ContentModelBlockGroup, @@ -135,10 +139,8 @@ export default class ContentModelCopyPastePlugin implements PluginWithState EditorContex * Create Content Model from DOM tree in this editor * @param core The ContentModelEditorCore object * @param option The option to customize the behavior of DOM to Content Model conversion + * @param selectionOverride When passed, use this selection range instead of current selection in editor */ export type CreateContentModel = ( core: ContentModelEditorCore, @@ -29,11 +31,13 @@ export type CreateContentModel = ( * @param core The ContentModelEditorCore 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 */ export type SetContentModel = ( core: ContentModelEditorCore, model: ContentModelDocument, - option?: ModelToDomOption + option?: ModelToDomOption, + onNodeCreated?: OnNodeCreated ) => void; /** @@ -95,12 +99,12 @@ export interface ContentModelEditorCore extends EditorCore { /** * Default DOM to Content Model options */ - defaultDomToModelOptions: DomToModelOption; + defaultDomToModelOptions: (DomToModelOption | undefined)[]; /** * Default Content Model to DOM options */ - defaultModelToDomOptions: ModelToDomOption; + defaultModelToDomOptions: (ModelToDomOption | undefined)[]; /** * Whether adding delimiter for entity is allowed diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts index e62c05de571..4b002f5bdb7 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts @@ -4,6 +4,7 @@ import { ContentModelSegmentFormat, DomToModelOption, ModelToDomOption, + OnNodeCreated, } from 'roosterjs-content-model-types'; /** @@ -27,8 +28,13 @@ export interface IContentModelEditor extends IEditor { * Set content with content model * @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 */ - setContentModel(model: ContentModelDocument, option?: ModelToDomOption): void; + setContentModel( + model: ContentModelDocument, + option?: ModelToDomOption, + onNodeCreated?: OnNodeCreated + ): void; /** * Cache a content model object. Next time when format with content model, we can reuse it. diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts index 6f7b95debcc..56cb821dddd 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts @@ -1,9 +1,11 @@ import * as contentModelToDom from 'roosterjs-content-model-dom/lib/modelToDom/contentModelToDom'; +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 * as domToContentModel from 'roosterjs-content-model-dom/lib/domToModel/domToContentModel'; import ContentModelEditor from '../../lib/editor/ContentModelEditor'; import { ContentModelDocument, EditorContext } from 'roosterjs-content-model-types'; import { EditorPlugin, PluginEventType, SelectionRangeTypes } from 'roosterjs-editor-types'; -import { tablePreProcessor } from '../../lib/domToModel/processors/tablePreProcessor'; +import { tablePreProcessor } from '../../lib/editor/overrides/tablePreProcessor'; const editorContext: EditorContext = { isDarkMode: false, @@ -12,60 +14,64 @@ const editorContext: EditorContext = { describe('ContentModelEditor', () => { it('domToContentModel', () => { + const mockedResult = 'Result' as any; + const mockedContext = 'MockedContext' as any; + + spyOn(domToContentModel, 'domToContentModel').and.returnValue(mockedResult); + spyOn(createDomToModelContext, 'createDomToModelContext').and.returnValue(mockedContext); + const div = document.createElement('div'); const editor = new ContentModelEditor(div); - const mockedResult = 'Result' as any; - spyOn((editor as any).core.api, 'createEditorContext').and.returnValue(editorContext); - spyOn(domToContentModel, 'domToContentModel').and.returnValue(mockedResult); const model = editor.createContentModel(); expect(model).toBe(mockedResult); expect(domToContentModel.domToContentModel).toHaveBeenCalledTimes(1); - expect(domToContentModel.domToContentModel).toHaveBeenCalledWith( - div, + expect(domToContentModel.domToContentModel).toHaveBeenCalledWith(div, mockedContext, { + type: SelectionRangeTypes.Normal, + ranges: [], + areAllCollapsed: true, + }); + expect(createDomToModelContext.createDomToModelContext).toHaveBeenCalledWith( + editorContext, { processorOverride: { table: tablePreProcessor, }, }, - editorContext, - { - type: SelectionRangeTypes.Normal, - ranges: [], - areAllCollapsed: true, - } + undefined, + undefined ); }); - it('domToContentModel, with Reuse Content Model dont add disableCacheElement option', () => { + it('domToContentModel, with Reuse Content Model do not add disableCacheElement option', () => { const div = document.createElement('div'); const editor = new ContentModelEditor(div); - const mockedResult = 'Result' as any; + const mockedContext = 'MockedContext' as any; spyOn((editor as any).core.api, 'createEditorContext').and.returnValue(editorContext); spyOn(domToContentModel, 'domToContentModel').and.returnValue(mockedResult); + spyOn(createDomToModelContext, 'createDomToModelContext').and.returnValue(mockedContext); const model = editor.createContentModel(); expect(model).toBe(mockedResult); expect(domToContentModel.domToContentModel).toHaveBeenCalledTimes(1); - expect(domToContentModel.domToContentModel).toHaveBeenCalledWith( - div, - { - processorOverride: { - table: tablePreProcessor, - }, - }, + expect(domToContentModel.domToContentModel).toHaveBeenCalledWith(div, mockedContext, { + type: SelectionRangeTypes.Normal, + ranges: [], + areAllCollapsed: true, + }); + expect(createDomToModelContext.createDomToModelContext).toHaveBeenCalledWith( editorContext, { - type: SelectionRangeTypes.Normal, - ranges: [], - areAllCollapsed: true, - } + processorOverride: { table: tablePreProcessor }, + }, + undefined, + undefined ); }); @@ -81,9 +87,11 @@ describe('ContentModelEditor', () => { const mockedResult = [mockedFragment, mockedRange, mockedPairs] as any; const mockedModel = 'MockedModel' as any; + const mockedContext = 'MockedContext' as any; spyOn((editor as any).core.api, 'createEditorContext').and.returnValue(editorContext); spyOn(contentModelToDom, 'contentModelToDom').and.returnValue(mockedResult); + spyOn(createModelToDomContext, 'createModelToDomContext').and.returnValue(mockedContext); editor.setContentModel(mockedModel); @@ -92,8 +100,13 @@ describe('ContentModelEditor', () => { document, div, mockedModel, + mockedContext, + undefined + ); + expect(createModelToDomContext.createModelToDomContext).toHaveBeenCalledWith( editorContext, - {} + undefined, + undefined ); }); @@ -109,9 +122,11 @@ describe('ContentModelEditor', () => { const mockedResult = [mockedFragment, mockedRange, mockedPairs] as any; const mockedModel = 'MockedModel' as any; + const mockedContext = 'MockedContext' as any; spyOn((editor as any).core.api, 'createEditorContext').and.returnValue(editorContext); spyOn(contentModelToDom, 'contentModelToDom').and.returnValue(mockedResult); + spyOn(createModelToDomContext, 'createModelToDomContext').and.returnValue(mockedContext); editor.setContentModel(mockedModel); @@ -120,8 +135,13 @@ describe('ContentModelEditor', () => { document, div, mockedModel, + mockedContext, + undefined + ); + expect(createModelToDomContext.createModelToDomContext).toHaveBeenCalledWith( editorContext, - {} + undefined, + undefined ); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/createContentModelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/createContentModelTest.ts index ef735e679a4..c9eec2c8b1c 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/createContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/createContentModelTest.ts @@ -2,9 +2,8 @@ import * as cloneModel from '../../../lib/modelApi/common/cloneModel'; import * as domToContentModel from 'roosterjs-content-model-dom/lib/domToModel/domToContentModel'; import { ContentModelEditorCore } from '../../../lib/publicTypes/ContentModelEditorCore'; import { createContentModel } from '../../../lib/editor/coreApi/createContentModel'; -import { DomToModelOption } from 'roosterjs-content-model-types'; +import { createDomToModelContext } from 'roosterjs-content-model-dom'; import { SelectionRangeTypes } from 'roosterjs-editor-types'; -import { tablePreProcessor } from '../../../lib/domToModel/processors/tablePreProcessor'; const mockedEditorContext = 'EDITORCONTEXT' as any; const mockedModel = 'MODEL' as any; @@ -42,30 +41,22 @@ describe('createContentModel', () => { }); it('Reuse model, no cache, no shadow edit', () => { - const option: DomToModelOption = {}; - core.cachedModel = undefined; - const model = createContentModel(core, option); + const model = createContentModel(core); expect(createEditorContext).toHaveBeenCalledWith(core); expect(getSelectionRangeEx).toHaveBeenCalledWith(core); expect(domToContentModelSpy).toHaveBeenCalledWith( mockedDiv, - { - processorOverride: { - table: tablePreProcessor, - }, - }, - mockedEditorContext, + createDomToModelContext(mockedEditorContext), null ); expect(model).toBe(mockedModel); }); it('Reuse model, no shadow edit', () => { - const option: DomToModelOption = {}; - const model = createContentModel(core, option); + const model = createContentModel(core); expect(createEditorContext).not.toHaveBeenCalled(); expect(getSelectionRangeEx).not.toHaveBeenCalled(); @@ -74,11 +65,9 @@ describe('createContentModel', () => { }); it('Reuse model, with cache, with shadow edit', () => { - const option: DomToModelOption = {}; - core.lifecycle.shadowEditFragment = {} as any; - const model = createContentModel(core, option); + const model = createContentModel(core); expect(cloneModelSpy).toHaveBeenCalledWith(mockedCachedMode, { includeCachedElement: true, @@ -128,16 +117,11 @@ describe('createContentModel with selection', () => { expect(domToContentModelSpy).toHaveBeenCalledTimes(1); expect(domToContentModelSpy).toHaveBeenCalledWith( MockedDiv, - { - processorOverride: { - table: tablePreProcessor, - }, - }, - undefined, + createDomToModelContext(undefined), { type: SelectionRangeTypes.Normal, ranges: [MockedRange], - } + } as any ); }); @@ -160,12 +144,7 @@ describe('createContentModel with selection', () => { expect(domToContentModelSpy).toHaveBeenCalledTimes(1); expect(domToContentModelSpy).toHaveBeenCalledWith( MockedDiv, - { - processorOverride: { - table: tablePreProcessor, - }, - }, - undefined, + createDomToModelContext(undefined), { type: SelectionRangeTypes.TableSelection, table: MockedContainer, @@ -173,7 +152,7 @@ describe('createContentModel with selection', () => { firstCell: MockedFirstCell, lastCell: MockedLastCell, }, - } + } as any ); }); @@ -190,16 +169,11 @@ describe('createContentModel with selection', () => { expect(domToContentModelSpy).toHaveBeenCalledTimes(1); expect(domToContentModelSpy).toHaveBeenCalledWith( MockedDiv, - { - processorOverride: { - table: tablePreProcessor, - }, - }, - undefined, + createDomToModelContext(undefined), { type: SelectionRangeTypes.ImageSelection, image: MockedContainer, - } + } as any ); }); @@ -214,16 +188,11 @@ describe('createContentModel with selection', () => { expect(domToContentModelSpy).toHaveBeenCalledTimes(1); expect(domToContentModelSpy).toHaveBeenCalledWith( MockedDiv, - { - processorOverride: { - table: tablePreProcessor, - }, - }, - undefined, + createDomToModelContext(undefined), { type: SelectionRangeTypes.Normal, ranges: [], - } + } as any ); }); @@ -237,15 +206,10 @@ describe('createContentModel with selection', () => { expect(domToContentModelSpy).toHaveBeenCalledTimes(1); expect(domToContentModelSpy).toHaveBeenCalledWith( MockedDiv, - { - processorOverride: { - table: tablePreProcessor, - }, - }, - undefined, + createDomToModelContext(undefined), { type: SelectionRangeTypes.TableSelection, - } + } as any ); }); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/setContentModelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/setContentModelTest.ts index 6d4c8ddaaea..9bfc8754374 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/setContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/setContentModelTest.ts @@ -1,10 +1,12 @@ import * as contentModelToDom from 'roosterjs-content-model-dom/lib/modelToDom/contentModelToDom'; +import * as createModelToDomContext from 'roosterjs-content-model-dom/lib/modelToDom/context/createModelToDomContext'; import { ContentModelEditorCore } from '../../../lib/publicTypes/ContentModelEditorCore'; import { setContentModel } from '../../../lib/editor/coreApi/setContentModel'; const mockedRange = 'RANGE' as any; const mockedDoc = 'DOCUMENT' as any; const mockedModel = 'MODEL' as any; +const mockedEditorContext = 'EDITORCONTEXT' as any; const mockedContext = 'CONTEXT' as any; const mockedDiv = { ownerDocument: mockedDoc } as any; @@ -12,6 +14,7 @@ describe('setContentModel', () => { let core: ContentModelEditorCore; let contentModelToDomSpy: jasmine.Spy; let createEditorContext: jasmine.Spy; + let createModelToDomContextSpy: jasmine.Spy; let select: jasmine.Spy; let getSelectionRange: jasmine.Spy; @@ -21,7 +24,11 @@ describe('setContentModel', () => { ); createEditorContext = jasmine .createSpy('createEditorContext') - .and.returnValue(mockedContext); + .and.returnValue(mockedEditorContext); + createModelToDomContextSpy = spyOn( + createModelToDomContext, + 'createModelToDomContext' + ).and.returnValue(mockedContext); select = jasmine.createSpy('select'); getSelectionRange = jasmine.createSpy('getSelectionRange'); @@ -39,29 +46,27 @@ describe('setContentModel', () => { it('no default option, no shadow edit', () => { setContentModel(core, mockedModel); - expect(createEditorContext).toHaveBeenCalledWith(core); + expect(createModelToDomContextSpy).toHaveBeenCalledWith(mockedEditorContext, undefined); expect(contentModelToDomSpy).toHaveBeenCalledWith( mockedDoc, mockedDiv, mockedModel, mockedContext, - {} + undefined ); expect(select).toHaveBeenCalledWith(core, mockedRange); }); it('with default option, no shadow edit', () => { - const defaultOption = { o: 'OPTION' } as any; - core.defaultModelToDomOptions = defaultOption; setContentModel(core, mockedModel); - expect(createEditorContext).toHaveBeenCalledWith(core); + expect(createModelToDomContextSpy).toHaveBeenCalledWith(mockedEditorContext, undefined); expect(contentModelToDomSpy).toHaveBeenCalledWith( mockedDoc, mockedDiv, mockedModel, mockedContext, - defaultOption + undefined ); expect(select).toHaveBeenCalledWith(core, mockedRange); }); @@ -70,16 +75,20 @@ describe('setContentModel', () => { const defaultOption = { o: 'OPTION' } as any; const additionalOption = { o: 'OPTION1', o2: 'OPTION2' } as any; - core.defaultModelToDomOptions = defaultOption; + core.defaultModelToDomOptions = [defaultOption]; setContentModel(core, mockedModel, additionalOption); - expect(createEditorContext).toHaveBeenCalledWith(core); + expect(createModelToDomContextSpy).toHaveBeenCalledWith( + mockedEditorContext, + defaultOption, + additionalOption + ); expect(contentModelToDomSpy).toHaveBeenCalledWith( mockedDoc, mockedDiv, mockedModel, mockedContext, - additionalOption + undefined ); expect(select).toHaveBeenCalledWith(core, mockedRange); }); @@ -89,13 +98,13 @@ describe('setContentModel', () => { setContentModel(core, mockedModel); - expect(createEditorContext).toHaveBeenCalledWith(core); + expect(createModelToDomContextSpy).toHaveBeenCalledWith(mockedEditorContext, undefined); expect(contentModelToDomSpy).toHaveBeenCalledWith( mockedDoc, mockedDiv, mockedModel, mockedContext, - {} + undefined ); expect(select).not.toHaveBeenCalled(); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/createContentModelEditorCoreTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/createContentModelEditorCoreTest.ts index 635b67a9f2d..9a9655fc909 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/createContentModelEditorCoreTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/createContentModelEditorCoreTest.ts @@ -10,6 +10,7 @@ import { ExperimentalFeatures } from 'roosterjs-editor-types'; import { getSelectionRangeEx } from '../../lib/editor/coreApi/getSelectionRangeEx'; import { setContentModel } from '../../lib/editor/coreApi/setContentModel'; import { switchShadowEdit } from '../../lib/editor/coreApi/switchShadowEdit'; +import { tablePreProcessor } from '../../lib/editor/overrides/tablePreProcessor'; const mockedSwitchShadowEdit = 'SHADOWEDIT' as any; @@ -76,8 +77,11 @@ describe('createContentModelEditorCore', () => { createContentModel, setContentModel, }, - defaultDomToModelOptions: {}, - defaultModelToDomOptions: {}, + defaultDomToModelOptions: [ + { processorOverride: { table: tablePreProcessor } }, + undefined, + ], + defaultModelToDomOptions: [undefined], defaultFormat: { fontWeight: undefined, italic: undefined, @@ -135,8 +139,11 @@ describe('createContentModelEditorCore', () => { createContentModel, setContentModel, }, - defaultDomToModelOptions, - defaultModelToDomOptions, + defaultDomToModelOptions: [ + { processorOverride: { table: tablePreProcessor } }, + defaultDomToModelOptions, + ], + defaultModelToDomOptions: [defaultModelToDomOptions], defaultFormat: { fontWeight: undefined, italic: undefined, @@ -205,8 +212,11 @@ describe('createContentModelEditorCore', () => { createContentModel, setContentModel, }, - defaultDomToModelOptions: {}, - defaultModelToDomOptions: {}, + defaultDomToModelOptions: [ + { processorOverride: { table: tablePreProcessor } }, + undefined, + ], + defaultModelToDomOptions: [undefined], defaultFormat: { fontWeight: 'bold', italic: true, @@ -257,8 +267,11 @@ describe('createContentModelEditorCore', () => { createContentModel, setContentModel, }, - defaultDomToModelOptions: {}, - defaultModelToDomOptions: {}, + defaultDomToModelOptions: [ + { processorOverride: { table: tablePreProcessor } }, + undefined, + ], + defaultModelToDomOptions: [undefined], defaultFormat: { fontWeight: undefined, italic: undefined, @@ -317,8 +330,11 @@ describe('createContentModelEditorCore', () => { createContentModel, setContentModel, }, - defaultDomToModelOptions: {}, - defaultModelToDomOptions: {}, + defaultDomToModelOptions: [ + { processorOverride: { table: tablePreProcessor } }, + undefined, + ], + defaultModelToDomOptions: [undefined], defaultFormat: { fontWeight: undefined, italic: undefined, diff --git a/packages-content-model/roosterjs-content-model-editor/test/domToModel/processors/tablePreProcessorTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/overrides/tablePreProcessorTest.ts similarity index 98% rename from packages-content-model/roosterjs-content-model-editor/test/domToModel/processors/tablePreProcessorTest.ts rename to packages-content-model/roosterjs-content-model-editor/test/editor/overrides/tablePreProcessorTest.ts index 348a0688197..89441aa5f49 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/domToModel/processors/tablePreProcessorTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/overrides/tablePreProcessorTest.ts @@ -1,7 +1,7 @@ import * as tableProcessor from 'roosterjs-content-model-dom/lib/domToModel/processors/tableProcessor'; import { createContentModelDocument, createDomToModelContext } from 'roosterjs-content-model-dom'; import { SelectionRangeTypes } from 'roosterjs-editor-types'; -import { tablePreProcessor } from '../../../lib/domToModel/processors/tablePreProcessor'; +import { tablePreProcessor } from '../../../lib/editor/overrides/tablePreProcessor'; describe('tablePreProcessor', () => { it('Table without metadata, use Entity', () => { diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCopyPastePluginTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCopyPastePluginTest.ts index 11d282ee014..faaa05d06f9 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCopyPastePluginTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCopyPastePluginTest.ts @@ -6,6 +6,7 @@ import * as iterateSelectionsFile from '../../../lib/modelApi/selection/iterateS import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; import * as PasteFile from '../../../lib/publicApi/utils/paste'; import { commitEntity } from 'roosterjs-editor-dom'; +import { createModelToDomContext } from 'roosterjs-content-model-dom'; import { DeleteResult } from '../../../lib/modelApi/edit/utils/DeleteSelectionStep'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import createRange, * as createRangeF from 'roosterjs-editor-dom/lib/selection/createRange'; @@ -188,8 +189,8 @@ describe('ContentModelCopyPastePlugin |', () => { document, div, pasteModelValue, - undefined, - { onNodeCreated } + createModelToDomContext(), + onNodeCreated ); expect(createContentModelSpy).toHaveBeenCalled(); expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); @@ -247,8 +248,8 @@ describe('ContentModelCopyPastePlugin |', () => { document, div, pasteModelValue, - undefined, - { onNodeCreated } + createModelToDomContext(), + onNodeCreated ); expect(createContentModelSpy).toHaveBeenCalled(); expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); @@ -301,8 +302,8 @@ describe('ContentModelCopyPastePlugin |', () => { document, div, pasteModelValue, - undefined, - { onNodeCreated } + createModelToDomContext(), + onNodeCreated ); expect(createContentModelSpy).toHaveBeenCalled(); expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); @@ -376,8 +377,8 @@ describe('ContentModelCopyPastePlugin |', () => { document, div, pasteModelValue, - undefined, - { onNodeCreated } + createModelToDomContext(), + onNodeCreated ); expect(createContentModelSpy).toHaveBeenCalled(); expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); @@ -462,8 +463,8 @@ describe('ContentModelCopyPastePlugin |', () => { document, div, pasteModelValue, - undefined, - { onNodeCreated } + createModelToDomContext(), + onNodeCreated ); expect(createContentModelSpy).toHaveBeenCalled(); expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); @@ -477,9 +478,7 @@ describe('ContentModelCopyPastePlugin |', () => { // On Cut Spy expect(undoSnapShotSpy).toHaveBeenCalled(); - expect(setContentModelSpy).toHaveBeenCalledWith(modelValue, { - onNodeCreated: undefined, - }); + expect(setContentModelSpy).toHaveBeenCalledWith(modelValue, undefined); }); it('Selection not Collapsed and table selection', () => { @@ -524,8 +523,8 @@ describe('ContentModelCopyPastePlugin |', () => { document, div, pasteModelValue, - undefined, - { onNodeCreated } + createModelToDomContext(), + onNodeCreated ); expect(createContentModelSpy).toHaveBeenCalled(); expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); @@ -541,9 +540,7 @@ describe('ContentModelCopyPastePlugin |', () => { // On Cut Spy expect(undoSnapShotSpy).toHaveBeenCalled(); expect(deleteSelectionsFile.deleteSelection).toHaveBeenCalled(); - expect(setContentModelSpy).toHaveBeenCalledWith(modelValue, { - onNodeCreated: undefined, - }); + expect(setContentModelSpy).toHaveBeenCalledWith(modelValue, undefined); expect(normalizeContentModel.normalizeContentModel).toHaveBeenCalledWith(modelValue); }); @@ -585,8 +582,8 @@ describe('ContentModelCopyPastePlugin |', () => { document, div, pasteModelValue, - undefined, - { onNodeCreated } + createModelToDomContext(), + onNodeCreated ); expect(createContentModelSpy).toHaveBeenCalled(); expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); @@ -601,9 +598,7 @@ describe('ContentModelCopyPastePlugin |', () => { // On Cut Spy expect(undoSnapShotSpy).toHaveBeenCalled(); expect(deleteSelectionsFile.deleteSelection).toHaveBeenCalled(); - expect(setContentModelSpy).toHaveBeenCalledWith(modelValue, { - onNodeCreated: undefined, - }); + expect(setContentModelSpy).toHaveBeenCalledWith(modelValue, undefined); expect(normalizeContentModel.normalizeContentModel).toHaveBeenCalledWith(modelValue); }); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelFormatPluginTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelFormatPluginTest.ts index 8e0651cf8ce..a40cd0fb142 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelFormatPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelFormatPluginTest.ts @@ -185,7 +185,8 @@ describe('ContentModelFormatPlugin', () => { }, ], }, - { onNodeCreated: undefined } + undefined, + undefined ); expect(pendingFormat.clearPendingFormat).toHaveBeenCalledTimes(1); expect(pendingFormat.clearPendingFormat).toHaveBeenCalledWith(editor); @@ -253,7 +254,8 @@ describe('ContentModelFormatPlugin', () => { }, ], }, - { onNodeCreated: undefined } + undefined, + undefined ); expect(pendingFormat.clearPendingFormat).toHaveBeenCalledTimes(1); expect(pendingFormat.clearPendingFormat).toHaveBeenCalledWith(editor); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/linkParserTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/linkParserTest.ts index afc5e6f2e16..8f6a00b31f4 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/linkParserTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/linkParserTest.ts @@ -1,8 +1,13 @@ import { ContentModelDocument } from 'roosterjs-content-model-types'; -import { contentModelToDom, domToContentModel } from 'roosterjs-content-model-dom'; import { createBeforePasteEventMock } from './processPastedContentFromWordDesktopTest'; import { moveChildNodes } from 'roosterjs-editor-dom'; import { parseLink } from '../../../../lib/editor/plugins/PastePlugin/utils/linkParser'; +import { + contentModelToDom, + createDomToModelContext, + createModelToDomContext, + domToContentModel, +} from 'roosterjs-content-model-dom'; let div: HTMLElement; let fragment: DocumentFragment; @@ -22,7 +27,11 @@ describe('link parser test', () => { link: [parseLink], }; - const model = domToContentModel(fragment, event.domToModelOption); + const model = domToContentModel( + fragment, + createDomToModelContext(undefined, event.domToModelOption) + ); + if (expectedModel) { expect(model).toEqual(expectedModel); } @@ -31,10 +40,9 @@ describe('link parser test', () => { document, div, model, - { + createModelToDomContext({ isDarkMode: false, - }, - {} + }) ); //Assert diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/processPastedContentFromExcelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/processPastedContentFromExcelTest.ts index bc27ff924c2..aea052822e4 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/processPastedContentFromExcelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/processPastedContentFromExcelTest.ts @@ -1,9 +1,14 @@ import * as PastePluginFile from '../../../../lib/editor/plugins/PastePlugin/Excel/processPastedContentFromExcel'; import { Browser, moveChildNodes } from 'roosterjs-editor-dom'; import { ContentModelDocument } from 'roosterjs-content-model-types'; -import { contentModelToDom, domToContentModel } from 'roosterjs-content-model-dom'; import { createBeforePasteEventMock } from './processPastedContentFromWordDesktopTest'; import { processPastedContentFromExcel } from '../../../../lib/editor/plugins/PastePlugin/Excel/processPastedContentFromExcel'; +import { + contentModelToDom, + createDomToModelContext, + createModelToDomContext, + domToContentModel, +} from 'roosterjs-content-model-dom'; let div: HTMLElement; let fragment: DocumentFragment; @@ -22,9 +27,10 @@ describe('processPastedContentFromExcelTest', () => { event.clipboardData.html = source; processPastedContentFromExcel(event, (s: string) => s); - const model = domToContentModel(fragment, { - ...event.domToModelOption, - }); + const model = domToContentModel( + fragment, + createDomToModelContext(undefined, event.domToModelOption) + ); if (expectedModel) { expect(model).toEqual(expectedModel); } @@ -33,10 +39,9 @@ describe('processPastedContentFromExcelTest', () => { document, div, model, - { + createModelToDomContext({ isDarkMode: false, - }, - {} + }) ); //Assert diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/processPastedContentFromWacTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/processPastedContentFromWacTest.ts index 19453740ffb..b20f5c1981a 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/processPastedContentFromWacTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/processPastedContentFromWacTest.ts @@ -1,9 +1,14 @@ import { Browser, moveChildNodes } from 'roosterjs-editor-dom'; import { ContentModelDocument } from 'roosterjs-content-model-types'; -import { contentModelToDom, domToContentModel } from 'roosterjs-content-model-dom'; import { createBeforePasteEventMock } from './processPastedContentFromWordDesktopTest'; import { itChromeOnly } from 'roosterjs-editor-dom/test/DomTestHelper'; import { processPastedContentWacComponents } from '../../../../lib/editor/plugins/PastePlugin/WacComponents/processPastedContentWacComponents'; +import { + contentModelToDom, + createDomToModelContext, + createModelToDomContext, + domToContentModel, +} from 'roosterjs-content-model-dom'; let div: HTMLElement; let fragment: DocumentFragment; @@ -20,9 +25,10 @@ describe('processPastedContentFromWacTest', () => { const event = createBeforePasteEventMock(fragment); processPastedContentWacComponents(event); - const model = domToContentModel(fragment, { - ...event.domToModelOption, - }); + const model = domToContentModel( + fragment, + createDomToModelContext(undefined, event.domToModelOption) + ); if (expectedModel) { expect(model).toEqual(expectedModel); } @@ -31,10 +37,9 @@ describe('processPastedContentFromWacTest', () => { document, div, model, - { + createModelToDomContext({ isDarkMode: false, - }, - {} + }) ); //Assert @@ -124,9 +129,10 @@ describe('wordOnlineHandler', () => { const event = createBeforePasteEventMock(fragment); processPastedContentWacComponents(event); - const model = domToContentModel(fragment, { - ...event.domToModelOption, - }); + const model = domToContentModel( + fragment, + createDomToModelContext(undefined, event.domToModelOption) + ); if (expectedModel) { expect(model).toEqual(expectedModel); } @@ -135,10 +141,9 @@ describe('wordOnlineHandler', () => { document, div, model, - { + createModelToDomContext({ isDarkMode: false, - }, - {} + }) ); //Assert diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/processPastedContentFromWordDesktopTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/processPastedContentFromWordDesktopTest.ts index ff88b07d850..61f5e9d4514 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/processPastedContentFromWordDesktopTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/processPastedContentFromWordDesktopTest.ts @@ -1,10 +1,15 @@ import ContentModelBeforePasteEvent from '../../../../lib/publicTypes/event/ContentModelBeforePasteEvent'; import { ClipboardData, PluginEventType } from 'roosterjs-editor-types'; import { ContentModelDocument } from 'roosterjs-content-model-types'; -import { contentModelToDom, domToContentModel } from 'roosterjs-content-model-dom'; import { expectHtml } from 'roosterjs-editor-api/test/TestHelper'; import { moveChildNodes } from 'roosterjs-editor-dom'; import { processPastedContentFromWordDesktop } from '../../../../lib/editor/plugins/PastePlugin/WordDesktop/processPastedContentFromWordDesktop'; +import { + contentModelToDom, + createDomToModelContext, + createModelToDomContext, + domToContentModel, +} from 'roosterjs-content-model-dom'; describe('processPastedContentFromWordDesktopTest', () => { let div: HTMLElement; @@ -25,9 +30,10 @@ describe('processPastedContentFromWordDesktopTest', () => { const event = createBeforePasteEventMock(fragment); processPastedContentFromWordDesktop(event); - const model = domToContentModel(fragment, { - ...event.domToModelOption, - }); + const model = domToContentModel( + fragment, + createDomToModelContext(undefined, event.domToModelOption) + ); if (expectedModel) { expect(model).toEqual(expectedModel); } @@ -36,10 +42,9 @@ describe('processPastedContentFromWordDesktopTest', () => { document, div, model, - { + createModelToDomContext({ isDarkMode: false, - }, - {} + }) ); //Assert diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setAlignmentTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setAlignmentTest.ts index dbf14ce2738..65d46879f71 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setAlignmentTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setAlignmentTest.ts @@ -449,7 +449,8 @@ describe('setAlignment in table', () => { blockGroupType: 'Document', blocks: [expectedTable], }, - { onNodeCreated: undefined } + undefined, + undefined ); } } @@ -841,7 +842,8 @@ describe('setAlignment in list', () => { blockGroupType: 'Document', blocks: [expectedList], }, - { onNodeCreated: undefined } + undefined, + undefined ); } } diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/adjustLinkSelectionTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/adjustLinkSelectionTest.ts index b0dc6c99104..be894434da8 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/adjustLinkSelectionTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/adjustLinkSelectionTest.ts @@ -41,9 +41,7 @@ describe('adjustLinkSelection', () => { if (expectedModel) { expect(setContentModel).toHaveBeenCalledTimes(1); - expect(setContentModel).toHaveBeenCalledWith(expectedModel, { - onNodeCreated: undefined, - }); + expect(setContentModel).toHaveBeenCalledWith(expectedModel, undefined, undefined); } else { expect(setContentModel).not.toHaveBeenCalled(); } diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/insertLinkTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/insertLinkTest.ts index 772c861e11a..15cd4db694f 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/insertLinkTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/insertLinkTest.ts @@ -46,7 +46,7 @@ describe('insertLink', () => { if (expectedModel) { expect(setContentModel).toHaveBeenCalledTimes(1); expect(setContentModel.calls.argsFor(0)[0]).toEqual(expectedModel); - expect(typeof setContentModel.calls.argsFor(0)[1]!.onNodeCreated).toEqual('function'); + expect(typeof setContentModel.calls.argsFor(0)[2]).toEqual('function'); } else { expect(setContentModel).not.toHaveBeenCalled(); } diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/removeLinkTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/removeLinkTest.ts index 41a82f6be53..1003ac59b32 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/removeLinkTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/removeLinkTest.ts @@ -34,9 +34,7 @@ describe('removeLink', () => { if (expectedModel) { expect(setContentModel).toHaveBeenCalledTimes(1); - expect(setContentModel).toHaveBeenCalledWith(expectedModel, { - onNodeCreated: undefined, - }); + expect(setContentModel).toHaveBeenCalledWith(expectedModel, undefined, undefined); } else { expect(setContentModel).not.toHaveBeenCalled(); } diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/changeFontSizeTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/changeFontSizeTest.ts index c50faa49a45..5cd91426361 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/changeFontSizeTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/changeFontSizeTest.ts @@ -1,8 +1,8 @@ import * as pendingFormat from '../../../lib/modelApi/format/pendingFormat'; import changeFontSize from '../../../lib/publicApi/segment/changeFontSize'; import { ContentModelDocument } from 'roosterjs-content-model-types'; +import { createDomToModelContext, domToContentModel } from 'roosterjs-content-model-dom'; import { createRange } from 'roosterjs-editor-dom'; -import { domToContentModel } from 'roosterjs-content-model-dom'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { segmentTestCommon } from './segmentTestCommon'; import { SelectionRangeTypes } from 'roosterjs-editor-types'; @@ -343,7 +343,7 @@ describe('changeFontSize', () => { const editor = ({ createContentModel: (option: any) => - domToContentModel(div, option, undefined, { + domToContentModel(div, createDomToModelContext(undefined), { type: SelectionRangeTypes.Normal, ranges: [createRange(sub)], areAllCollapsed: false, @@ -375,7 +375,8 @@ describe('changeFontSize', () => { }, ], }, - { onNodeCreated: undefined } + undefined, + undefined ); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/table/setTableCellShadeTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/table/setTableCellShadeTest.ts index 4ac166616ab..b74e2ee86eb 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/table/setTableCellShadeTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/table/setTableCellShadeTest.ts @@ -43,7 +43,8 @@ describe('setTableCellShade', () => { blockGroupType: 'Document', blocks: [expectedTable], }, - { onNodeCreated: undefined } + undefined, + undefined ); } else { expect(setContentModel).not.toHaveBeenCalled(); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatWithContentModelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatWithContentModelTest.ts index 292ceb6dc16..6f091e3a69e 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatWithContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatWithContentModelTest.ts @@ -78,7 +78,7 @@ describe('formatWithContentModel', () => { formatApiName: apiName, }); expect(setContentModel).toHaveBeenCalledTimes(1); - expect(setContentModel).toHaveBeenCalledWith(mockedModel, { onNodeCreated: undefined }); + expect(setContentModel).toHaveBeenCalledWith(mockedModel, undefined, undefined); expect(focus).toHaveBeenCalledTimes(1); }); @@ -184,7 +184,7 @@ describe('formatWithContentModel', () => { }); expect(createContentModel).toHaveBeenCalledTimes(1); expect(addUndoSnapshot).toHaveBeenCalled(); - expect(setContentModel).toHaveBeenCalledWith(mockedModel, { onNodeCreated }); + expect(setContentModel).toHaveBeenCalledWith(mockedModel, undefined, onNodeCreated); }); it('Has getChangeData', () => { @@ -200,7 +200,7 @@ describe('formatWithContentModel', () => { rawEvent: undefined, }); expect(createContentModel).toHaveBeenCalledTimes(1); - expect(setContentModel).toHaveBeenCalledWith(mockedModel, { onNodeCreated: undefined }); + expect(setContentModel).toHaveBeenCalledWith(mockedModel, undefined, undefined); expect(addUndoSnapshot).toHaveBeenCalled(); const wrappedCallback = addUndoSnapshot.calls.argsFor(0)[0] as any; diff --git a/packages-content-model/roosterjs-content-model-types/lib/context/ModelToDomOption.ts b/packages-content-model/roosterjs-content-model-types/lib/context/ModelToDomOption.ts index cc7218c100c..953ee74ea47 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/context/ModelToDomOption.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/context/ModelToDomOption.ts @@ -2,7 +2,6 @@ import { ContentModelHandlerMap, FormatAppliers, FormatAppliersPerCategory, - OnNodeCreated, } from './ModelToDomSettings'; /** @@ -23,11 +22,4 @@ export interface ModelToDomOption { * Overrides default model handlers */ modelHandlerOverride?: Partial; - - /** - * An optional callback that will be called when a DOM node is created - * @param modelElement The related Content Model element - * @param node The node created for this model element - */ - onNodeCreated?: OnNodeCreated; } diff --git a/tslint.json b/tslint.json index ce9ab3a8f39..7f8426e4fe8 100644 --- a/tslint.json +++ b/tslint.json @@ -2,7 +2,6 @@ "rules": { "use-isnan": true, "jquery-deferred-must-complete": true, - "missing-optional-annotation": true, "no-backbone-get-set-outside-model": false, "no-banned-terms": true, "no-constant-condition": true, From e8e04626df106be20c2c04c7ed3d37c7646c2e64 Mon Sep 17 00:00:00 2001 From: Bryan Valverde U Date: Mon, 18 Sep 2023 13:03:20 -0600 Subject: [PATCH 50/75] Remove Segment format from the pasted content when the format merge option is equal to `none` (#2073) * init * Remove unneeded change * Revert "Remove unneeded change" This reverts commit 2219d3565335781eaa80a54773837d93c07020c8. * fix build --- .../lib/modelApi/common/mergeModel.ts | 11 +- .../test/modelApi/common/mergeModelTest.ts | 103 ++++++++++++++++++ 2 files changed, 112 insertions(+), 2 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/mergeModel.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/mergeModel.ts index 01ce919a8ba..d68454351e8 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/mergeModel.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/mergeModel.ts @@ -83,7 +83,7 @@ export function mergeModel( switch (block.blockType) { case 'Paragraph': - mergeParagraph(insertPosition, block, i == 0, context); + mergeParagraph(insertPosition, block, i == 0, context, options); break; case 'Divider': @@ -125,7 +125,8 @@ function mergeParagraph( markerPosition: InsertPoint, newPara: ContentModelParagraph, mergeToCurrentParagraph: boolean, - context?: FormatWithContentModelContext + context?: FormatWithContentModelContext, + option?: MergeModelOption ) { const { paragraph, marker } = markerPosition; const newParagraph = mergeToCurrentParagraph @@ -133,6 +134,12 @@ function mergeParagraph( : splitParagraph(markerPosition, newPara.format); const segmentIndex = newParagraph.segments.indexOf(marker); + if (option?.mergeFormat == 'none' && mergeToCurrentParagraph) { + newParagraph.segments.forEach(segment => { + segment.format = { ...(newParagraph.segmentFormat || {}), ...segment.format }; + }); + delete newParagraph.segmentFormat; + } if (segmentIndex >= 0) { for (let i = 0; i < newPara.segments.length; i++) { const segment = newPara.segments[i]; diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/mergeModelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/mergeModelTest.ts index a3a98253fc3..b0cd8ab9fda 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/mergeModelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/mergeModelTest.ts @@ -2077,6 +2077,109 @@ describe('mergeModel', () => { }); }); + it('Merge with default format paragraph and paragraph, mergeFormat: none', () => { + const MockedFormat = { + fontFamily: 'sourceSegmentFormatFontFamily', + italic: 'sourceSegmentFormatItalic', + underline: 'sourceSegmentFormatUnderline', + fontSize: 'sourceSegmentFormatFontSize', + } as any; + const majorModel = createContentModelDocument(MockedFormat); + majorModel.blocks.push({ + blockType: 'Paragraph', + segmentFormat: MockedFormat, + segments: [ + { + segmentType: 'Text', + text: 'test', + format: { + fontFamily: 'sourceFontFamily', + } as any, + }, + createSelectionMarker(), + ], + format: {}, + }); + const sourceModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: { + fontFamily: 'sourceFontFamily', + italic: 'sourceItalic', + underline: 'sourceUnderline', + fontSize: 'sourcefontSize', + } as any, + }, + ], + format: {}, + }, + ], + }; + const para1 = createParagraph(); + const marker = createSelectionMarker(); + + para1.segments.push(marker); + majorModel.blocks.push(para1); + + mergeModel( + majorModel, + sourceModel, + { newEntities: [], deletedEntities: [] }, + { + mergeFormat: 'none', + } + ); + + expect(majorModel).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + Object({ + segmentType: 'Text', + text: 'test', + format: Object({ + fontFamily: 'sourceFontFamily', + italic: 'sourceSegmentFormatItalic', + underline: 'sourceSegmentFormatUnderline', + fontSize: 'sourceSegmentFormatFontSize', + }), + }), + Object({ + segmentType: 'Text', + text: 'test', + format: Object({ + fontFamily: 'sourceFontFamily', + italic: 'sourceItalic', + underline: 'sourceUnderline', + fontSize: 'sourcefontSize', + }), + }), + Object({ + segmentType: 'SelectionMarker', + isSelected: true, + format: Object({ + fontFamily: 'sourceSegmentFormatFontFamily', + italic: 'sourceSegmentFormatItalic', + underline: 'sourceSegmentFormatUnderline', + fontSize: 'sourceSegmentFormatFontSize', + }), + }), + ], + format: {}, + }, + ], + format: MockedFormat, + }); + }); + it('Merge Table + Paragraph', () => { const majorModel = createContentModelDocument(); const sourceModel: ContentModelDocument = { From e21406311b7f5a2cc0672eeb322075298053fc9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Mon, 18 Sep 2023 17:45:48 -0300 Subject: [PATCH 51/75] fix image link deletion --- .../lib/modelApi/edit/utils/deleteSegment.ts | 9 +++++++-- .../lib/plugins/ImageEdit/ImageEdit.ts | 2 +- .../test/imageEdit/imageEditTest.ts | 2 +- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteSegment.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteSegment.ts index 35f33db56ba..7d9ed3ca483 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteSegment.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteSegment.ts @@ -26,11 +26,16 @@ export function deleteSegment( switch (segmentToDelete.segmentType) { case 'Br': - case 'Image': case 'SelectionMarker': segments.splice(index, 1); return true; - + case 'Image': + if (segmentToDelete.link) { + segments.splice(index, 2); + } else { + segments.splice(index, 1); + } + return true; case 'Entity': const operation = segmentToDelete.isSelected ? EntityOperation.Overwrite diff --git a/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/ImageEdit.ts b/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/ImageEdit.ts index 92b48098683..4ef80aabcd7 100644 --- a/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/ImageEdit.ts +++ b/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/ImageEdit.ts @@ -479,7 +479,7 @@ export default class ImageEdit implements EditorPlugin { }); this.shadowSpan.style.verticalAlign = 'bottom'; - this.shadowSpan.style.fontSize = '24px'; + wrapper.style.fontSize = '24px'; shadowRoot.appendChild(wrapper); } diff --git a/packages/roosterjs-editor-plugins/test/imageEdit/imageEditTest.ts b/packages/roosterjs-editor-plugins/test/imageEdit/imageEditTest.ts index 6a317d352f2..6fb333f8131 100644 --- a/packages/roosterjs-editor-plugins/test/imageEdit/imageEditTest.ts +++ b/packages/roosterjs-editor-plugins/test/imageEdit/imageEditTest.ts @@ -226,7 +226,7 @@ describe('ImageEdit | rotate and flip', () => { editor.select(image); plugin.setEditingImage(image, ImageEditOperation.Resize); expect(editor.getContent()).toBe( - '' + '' ); }); }); From ad07b90218aa3f89743a6bc7c93fdd434c144f21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Mon, 18 Sep 2023 18:00:33 -0300 Subject: [PATCH 52/75] refactor --- .../lib/modelApi/edit/utils/deleteSegment.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteSegment.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteSegment.ts index 7d9ed3ca483..76c80932136 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteSegment.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteSegment.ts @@ -30,11 +30,7 @@ export function deleteSegment( segments.splice(index, 1); return true; case 'Image': - if (segmentToDelete.link) { - segments.splice(index, 2); - } else { - segments.splice(index, 1); - } + segments.splice(index, segmentToDelete.link ? 2 : 1); return true; case 'Entity': const operation = segmentToDelete.isSelected From 98a4487f8074bc27f4b958e264ccba8f82bd459b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Mon, 18 Sep 2023 19:40:33 -0300 Subject: [PATCH 53/75] remove empty links --- .../lib/modelApi/common/normalizeParagraph.ts | 11 ++++++ .../modelApi/common/normalizeParagraphTest.ts | 38 +++++++++++++++++++ .../lib/modelApi/edit/utils/deleteSegment.ts | 4 +- 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-dom/lib/modelApi/common/normalizeParagraph.ts b/packages-content-model/roosterjs-content-model-dom/lib/modelApi/common/normalizeParagraph.ts index e5a6681d75c..09ed8db3d43 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/modelApi/common/normalizeParagraph.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/modelApi/common/normalizeParagraph.ts @@ -37,6 +37,8 @@ export function normalizeParagraph(paragraph: ContentModelParagraph) { normalizeAllSegments(paragraph); } + removeEmptyLinks(paragraph); + removeEmptySegments(paragraph); } @@ -47,3 +49,12 @@ function removeEmptySegments(block: ContentModelParagraph) { } } } + +function removeEmptyLinks(paragraph: ContentModelParagraph) { + const segments = paragraph.segments; + const noMarkerSegments = segments.filter(x => x.segmentType != 'SelectionMarker'); + const marker = segments.find(x => x.segmentType == 'SelectionMarker'); + if (marker && marker.link && noMarkerSegments.every(x => !x.link)) { + delete marker.link; + } +} diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelApi/common/normalizeParagraphTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelApi/common/normalizeParagraphTest.ts index 1dd7f0ed78d..a74793a4d16 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelApi/common/normalizeParagraphTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelApi/common/normalizeParagraphTest.ts @@ -315,4 +315,42 @@ describe('Normalize text that contains space', () => { ], }); }); + + it('Remove empty links', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker(); + const text = createText('test'); + marker.link = { + dataset: {}, + format: {}, + }; + + para.segments.push(text, marker); + model.blocks.push(para); + + normalizeContentModel(model); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + }, + ], + }); + }); }); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteSegment.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteSegment.ts index 76c80932136..10d2ebfb2f9 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteSegment.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteSegment.ts @@ -26,12 +26,10 @@ export function deleteSegment( switch (segmentToDelete.segmentType) { case 'Br': + case 'Image': case 'SelectionMarker': segments.splice(index, 1); return true; - case 'Image': - segments.splice(index, segmentToDelete.link ? 2 : 1); - return true; case 'Entity': const operation = segmentToDelete.isSelected ? EntityOperation.Overwrite From aae5e4ece016d818f7b099b1e623b35ed419114d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Mon, 18 Sep 2023 19:41:37 -0300 Subject: [PATCH 54/75] space --- .../lib/modelApi/edit/utils/deleteSegment.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteSegment.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteSegment.ts index 10d2ebfb2f9..35f33db56ba 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteSegment.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteSegment.ts @@ -30,6 +30,7 @@ export function deleteSegment( case 'SelectionMarker': segments.splice(index, 1); return true; + case 'Entity': const operation = segmentToDelete.isSelected ? EntityOperation.Overwrite From a76905c9e0fbdd93ffd75390b50edc24e78db3ca Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Mon, 18 Sep 2023 23:44:35 -0700 Subject: [PATCH 55/75] Content Model Customization refactor 2 (#2064) * Content Model Customization refactor * fix build * improve * Content Model Customization refactor 2: Add default config * fix build * Improve --- .../context/createDomToModelContext.ts | 22 ++++- .../roosterjs-content-model-dom/lib/index.ts | 12 ++- .../context/createModelToDomContext.ts | 22 ++++- .../lib/editor/coreApi/createContentModel.ts | 17 ++-- .../lib/editor/coreApi/setContentModel.ts | 16 ++-- .../editor/createContentModelEditorCore.ts | 3 + .../lib/publicTypes/ContentModelEditorCore.ts | 14 +++ .../test/editor/ContentModelEditorTest.ts | 85 ++++++++++--------- .../editor/coreApi/createContentModelTest.ts | 83 ++++++++---------- .../editor/coreApi/setContentModelTest.ts | 23 ++++- .../createContentModelEditorCoreTest.ts | 26 ++++++ 11 files changed, 215 insertions(+), 108 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/context/createDomToModelContext.ts b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/context/createDomToModelContext.ts index ebdbaa134cb..8023f8c1794 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/context/createDomToModelContext.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/context/createDomToModelContext.ts @@ -27,13 +27,25 @@ export function createDomToModelContext( editorContext?: EditorContext, ...options: (DomToModelOption | undefined)[] ): DomToModelContext { + return createDomToModelContextWithConfig(createDomToModelConfig(options), editorContext); +} + +/** + * Create context object for DOM to Content Model conversion with an existing configure + * @param config A full config object to define how to convert DOM tree to Content Model + * @param editorContext Context of editor + */ +export function createDomToModelContextWithConfig( + config: DomToModelSettings, + editorContext?: EditorContext +) { return Object.assign( {}, editorContext, createDomToModelSelectionContext(), createDomToModelFormatContext(editorContext?.isRootRtl), createDomToModelDecoratorContext(), - createDomToModelSettings(options) + config ); } @@ -71,7 +83,13 @@ function createDomToModelDecoratorContext(): DomToModelDecoratorContext { }; } -function createDomToModelSettings(options: (DomToModelOption | undefined)[]): DomToModelSettings { +/** + * Create Dom to Content Model Config object + * @param options All customizations of content model creation + */ +export function createDomToModelConfig( + options: (DomToModelOption | undefined)[] +): DomToModelSettings { return { elementProcessors: Object.assign( {}, diff --git a/packages-content-model/roosterjs-content-model-dom/lib/index.ts b/packages-content-model/roosterjs-content-model-dom/lib/index.ts index d9a94a2ba56..f40d65322a4 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/index.ts @@ -50,5 +50,13 @@ export { parseValueWithUnit } from './formatHandlers/utils/parseValueWithUnit'; export { BorderKeys } from './formatHandlers/common/borderFormatHandler'; export { DeprecatedColors } from './formatHandlers/utils/color'; -export { createDomToModelContext } from './domToModel/context/createDomToModelContext'; -export { createModelToDomContext } from './modelToDom/context/createModelToDomContext'; +export { + createDomToModelContext, + createDomToModelContextWithConfig, + createDomToModelConfig, +} from './domToModel/context/createDomToModelContext'; +export { + createModelToDomContext, + createModelToDomContextWithConfig, + createModelToDomConfig, +} from './modelToDom/context/createModelToDomContext'; diff --git a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/context/createModelToDomContext.ts b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/context/createModelToDomContext.ts index f9ae5723d32..f94061dc0b4 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/context/createModelToDomContext.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/context/createModelToDomContext.ts @@ -25,12 +25,24 @@ export function createModelToDomContext( editorContext?: EditorContext, ...options: (ModelToDomOption | undefined)[] ): ModelToDomContext { + return createModelToDomContextWithConfig(createModelToDomConfig(options), editorContext); +} + +/** + * Create context object for Content Model to DOM conversion with an existing configure + * @param config A full config object to define how to convert Content Model to DOM tree + * @param editorContext Context of editor + */ +export function createModelToDomContextWithConfig( + config: ModelToDomSettings, + editorContext?: EditorContext +) { return Object.assign( {}, editorContext, createModelToDomSelectionContext(), createModelToDomFormatContext(), - createModelToDomSettings(options) + config ); } @@ -55,7 +67,13 @@ function createModelToDomFormatContext(): ModelToDomFormatContext { }; } -function createModelToDomSettings(options: (ModelToDomOption | undefined)[]): ModelToDomSettings { +/** + * Create Content Model to DOM Config object + * @param options All customizations of DOM creation + */ +export function createModelToDomConfig( + options: (ModelToDomOption | undefined)[] +): ModelToDomSettings { return { modelHandlers: Object.assign( {}, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/createContentModel.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/createContentModel.ts index 8e86b9030a9..76ef01b64f0 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/createContentModel.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/createContentModel.ts @@ -1,7 +1,11 @@ import { cloneModel } from '../../modelApi/common/cloneModel'; -import { createDomToModelContext, domToContentModel } from 'roosterjs-content-model-dom'; import { DomToModelOption } from 'roosterjs-content-model-types'; import { SelectionRangeEx } from 'roosterjs-editor-types'; +import { + createDomToModelContext, + createDomToModelContextWithConfig, + domToContentModel, +} from 'roosterjs-content-model-dom'; import { ContentModelEditorCore, CreateContentModel, @@ -30,13 +34,14 @@ function internalCreateContentModel( option?: DomToModelOption, selectionOverride?: SelectionRangeEx ) { + const editorContext = core.api.createEditorContext(core); + const domToModelContext = option + ? createDomToModelContext(editorContext, ...(core.defaultDomToModelOptions || []), option) + : createDomToModelContextWithConfig(core.defaultDomToModelConfig, editorContext); + return domToContentModel( core.contentDiv, - createDomToModelContext( - core.api.createEditorContext(core), - ...(core.defaultDomToModelOptions || []), - option - ), + domToModelContext, selectionOverride || core.api.getSelectionRangeEx(core) ); } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/setContentModel.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/setContentModel.ts index bb49bef7e6d..d9e5a1dbfff 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/setContentModel.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/setContentModel.ts @@ -1,5 +1,9 @@ -import { contentModelToDom, createModelToDomContext } from 'roosterjs-content-model-dom'; import { SetContentModel } from '../../publicTypes/ContentModelEditorCore'; +import { + contentModelToDom, + createModelToDomContext, + createModelToDomContextWithConfig, +} from 'roosterjs-content-model-dom'; /** * @internal @@ -10,15 +14,15 @@ import { SetContentModel } from '../../publicTypes/ContentModelEditorCore'; * @param onNodeCreated An optional callback that will be called when a DOM node is created */ export const setContentModel: SetContentModel = (core, model, option, onNodeCreated) => { + const editorContext = core.api.createEditorContext(core); + const modelToDomContext = option + ? createModelToDomContext(editorContext, ...(core.defaultModelToDomOptions || []), option) + : createModelToDomContextWithConfig(core.defaultModelToDomConfig, editorContext); const range = contentModelToDom( core.contentDiv.ownerDocument, core.contentDiv, model, - createModelToDomContext( - core.api.createEditorContext(core), - ...(core.defaultModelToDomOptions || []), - option - ), + modelToDomContext, onNodeCreated ); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/createContentModelEditorCore.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/createContentModelEditorCore.ts index ec5e62e8f85..772ce7cb9df 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/createContentModelEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/createContentModelEditorCore.ts @@ -7,6 +7,7 @@ import { ContentModelEditorOptions } from '../publicTypes/IContentModelEditor'; import { ContentModelSegmentFormat } from 'roosterjs-content-model-types'; import { CoreCreator, EditorCore, ExperimentalFeatures } from 'roosterjs-editor-types'; import { createContentModel } from './coreApi/createContentModel'; +import { createDomToModelConfig, createModelToDomConfig } from 'roosterjs-content-model-dom'; import { createEditorContext } from './coreApi/createEditorContext'; import { createEditorCore, isFeatureEnabled } from 'roosterjs-editor-core'; import { getSelectionRangeEx } from './coreApi/getSelectionRangeEx'; @@ -85,6 +86,8 @@ function promoteContentModelInfo( options.defaultDomToModelOptions, ]; cmCore.defaultModelToDomOptions = [options.defaultModelToDomOptions]; + cmCore.defaultDomToModelConfig = createDomToModelConfig(cmCore.defaultDomToModelOptions); + cmCore.defaultModelToDomConfig = createModelToDomConfig(cmCore.defaultModelToDomOptions); cmCore.addDelimiterForEntity = isFeatureEnabled( experimentalFeatures, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelEditorCore.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelEditorCore.ts index 6244a7e5f66..d0d05679a74 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelEditorCore.ts @@ -3,8 +3,10 @@ import { ContentModelDocument, ContentModelSegmentFormat, DomToModelOption, + DomToModelSettings, EditorContext, ModelToDomOption, + ModelToDomSettings, OnNodeCreated, } from 'roosterjs-content-model-types'; @@ -106,6 +108,18 @@ export interface ContentModelEditorCore extends EditorCore { */ defaultModelToDomOptions: (ModelToDomOption | undefined)[]; + /** + * Default DOM to Content Model config, calculated from defaultDomToModelOptions, + * will be used for creating content model if there is no other customized options + */ + defaultDomToModelConfig: DomToModelSettings; + + /** + * Default Content Model to DOM config, calculated from defaultModelToDomOptions, + * will be used for setting content model if there is no other customized options + */ + defaultModelToDomConfig: ModelToDomSettings; + /** * Whether adding delimiter for entity is allowed */ diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts index 56cb821dddd..8658b765505 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts @@ -5,7 +5,6 @@ import * as domToContentModel from 'roosterjs-content-model-dom/lib/domToModel/d import ContentModelEditor from '../../lib/editor/ContentModelEditor'; import { ContentModelDocument, EditorContext } from 'roosterjs-content-model-types'; import { EditorPlugin, PluginEventType, SelectionRangeTypes } from 'roosterjs-editor-types'; -import { tablePreProcessor } from '../../lib/editor/overrides/tablePreProcessor'; const editorContext: EditorContext = { isDarkMode: false, @@ -16,9 +15,13 @@ describe('ContentModelEditor', () => { it('domToContentModel', () => { const mockedResult = 'Result' as any; const mockedContext = 'MockedContext' as any; + const mockedConfig = 'MockedConfig' as any; spyOn(domToContentModel, 'domToContentModel').and.returnValue(mockedResult); - spyOn(createDomToModelContext, 'createDomToModelContext').and.returnValue(mockedContext); + spyOn(createDomToModelContext, 'createDomToModelContextWithConfig').and.returnValue( + mockedContext + ); + spyOn(createDomToModelContext, 'createDomToModelConfig').and.returnValue(mockedConfig); const div = document.createElement('div'); const editor = new ContentModelEditor(div); @@ -34,27 +37,27 @@ describe('ContentModelEditor', () => { ranges: [], areAllCollapsed: true, }); - expect(createDomToModelContext.createDomToModelContext).toHaveBeenCalledWith( - editorContext, - { - processorOverride: { - table: tablePreProcessor, - }, - }, - undefined, - undefined + expect(createDomToModelContext.createDomToModelContextWithConfig).toHaveBeenCalledWith( + mockedConfig, + editorContext ); }); it('domToContentModel, with Reuse Content Model do not add disableCacheElement option', () => { - const div = document.createElement('div'); - const editor = new ContentModelEditor(div); const mockedResult = 'Result' as any; const mockedContext = 'MockedContext' as any; + const mockedConfig = 'MockedConfig' as any; - spyOn((editor as any).core.api, 'createEditorContext').and.returnValue(editorContext); spyOn(domToContentModel, 'domToContentModel').and.returnValue(mockedResult); - spyOn(createDomToModelContext, 'createDomToModelContext').and.returnValue(mockedContext); + spyOn(createDomToModelContext, 'createDomToModelContextWithConfig').and.returnValue( + mockedContext + ); + spyOn(createDomToModelContext, 'createDomToModelConfig').and.returnValue(mockedConfig); + + const div = document.createElement('div'); + const editor = new ContentModelEditor(div); + + spyOn((editor as any).core.api, 'createEditorContext').and.returnValue(editorContext); const model = editor.createContentModel(); @@ -65,19 +68,13 @@ describe('ContentModelEditor', () => { ranges: [], areAllCollapsed: true, }); - expect(createDomToModelContext.createDomToModelContext).toHaveBeenCalledWith( - editorContext, - { - processorOverride: { table: tablePreProcessor }, - }, - undefined, - undefined + expect(createDomToModelContext.createDomToModelContextWithConfig).toHaveBeenCalledWith( + mockedConfig, + editorContext ); }); it('setContentModel with normal selection', () => { - const div = document.createElement('div'); - const editor = new ContentModelEditor(div); const mockedFragment = 'Fragment' as any; const mockedRange = { type: SelectionRangeTypes.Normal, @@ -88,10 +85,18 @@ describe('ContentModelEditor', () => { const mockedResult = [mockedFragment, mockedRange, mockedPairs] as any; const mockedModel = 'MockedModel' as any; const mockedContext = 'MockedContext' as any; + const mockedConfig = 'MockedConfig' as any; - spyOn((editor as any).core.api, 'createEditorContext').and.returnValue(editorContext); spyOn(contentModelToDom, 'contentModelToDom').and.returnValue(mockedResult); - spyOn(createModelToDomContext, 'createModelToDomContext').and.returnValue(mockedContext); + spyOn(createModelToDomContext, 'createModelToDomContextWithConfig').and.returnValue( + mockedContext + ); + spyOn(createModelToDomContext, 'createModelToDomConfig').and.returnValue(mockedConfig); + + const div = document.createElement('div'); + const editor = new ContentModelEditor(div); + + spyOn((editor as any).core.api, 'createEditorContext').and.returnValue(editorContext); editor.setContentModel(mockedModel); @@ -103,16 +108,13 @@ describe('ContentModelEditor', () => { mockedContext, undefined ); - expect(createModelToDomContext.createModelToDomContext).toHaveBeenCalledWith( - editorContext, - undefined, - undefined + expect(createModelToDomContext.createModelToDomContextWithConfig).toHaveBeenCalledWith( + mockedConfig, + editorContext ); }); it('setContentModel', () => { - const div = document.createElement('div'); - const editor = new ContentModelEditor(div); const mockedFragment = 'Fragment' as any; const mockedRange = { type: SelectionRangeTypes.Normal, @@ -123,10 +125,18 @@ describe('ContentModelEditor', () => { const mockedResult = [mockedFragment, mockedRange, mockedPairs] as any; const mockedModel = 'MockedModel' as any; const mockedContext = 'MockedContext' as any; + const mockedConfig = 'MockedConfig' as any; - spyOn((editor as any).core.api, 'createEditorContext').and.returnValue(editorContext); spyOn(contentModelToDom, 'contentModelToDom').and.returnValue(mockedResult); - spyOn(createModelToDomContext, 'createModelToDomContext').and.returnValue(mockedContext); + spyOn(createModelToDomContext, 'createModelToDomContextWithConfig').and.returnValue( + mockedContext + ); + spyOn(createModelToDomContext, 'createModelToDomConfig').and.returnValue(mockedConfig); + + const div = document.createElement('div'); + const editor = new ContentModelEditor(div); + + spyOn((editor as any).core.api, 'createEditorContext').and.returnValue(editorContext); editor.setContentModel(mockedModel); @@ -138,10 +148,9 @@ describe('ContentModelEditor', () => { mockedContext, undefined ); - expect(createModelToDomContext.createModelToDomContext).toHaveBeenCalledWith( - editorContext, - undefined, - undefined + expect(createModelToDomContext.createModelToDomContextWithConfig).toHaveBeenCalledWith( + mockedConfig, + editorContext ); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/createContentModelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/createContentModelTest.ts index c9eec2c8b1c..df6eee1d475 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/createContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/createContentModelTest.ts @@ -1,11 +1,12 @@ import * as cloneModel from '../../../lib/modelApi/common/cloneModel'; +import * as createDomToModelContext from 'roosterjs-content-model-dom/lib/domToModel/context/createDomToModelContext'; import * as domToContentModel from 'roosterjs-content-model-dom/lib/domToModel/domToContentModel'; import { ContentModelEditorCore } from '../../../lib/publicTypes/ContentModelEditorCore'; import { createContentModel } from '../../../lib/editor/coreApi/createContentModel'; -import { createDomToModelContext } from 'roosterjs-content-model-dom'; import { SelectionRangeTypes } from 'roosterjs-editor-types'; const mockedEditorContext = 'EDITORCONTEXT' as any; +const mockedContext = 'CONTEXT' as any; const mockedModel = 'MODEL' as any; const mockedDiv = 'DIV' as any; const mockedCachedMode = 'CACHEDMODEL' as any; @@ -29,6 +30,10 @@ describe('createContentModel', () => { ); cloneModelSpy = spyOn(cloneModel, 'cloneModel').and.returnValue(mockedClonedModel); + spyOn(createDomToModelContext, 'createDomToModelContextWithConfig').and.returnValue( + mockedContext + ); + core = ({ contentDiv: mockedDiv, api: { @@ -47,11 +52,7 @@ describe('createContentModel', () => { expect(createEditorContext).toHaveBeenCalledWith(core); expect(getSelectionRangeEx).toHaveBeenCalledWith(core); - expect(domToContentModelSpy).toHaveBeenCalledWith( - mockedDiv, - createDomToModelContext(mockedEditorContext), - null - ); + expect(domToContentModelSpy).toHaveBeenCalledWith(mockedDiv, mockedContext, null); expect(model).toBe(mockedModel); }); @@ -91,6 +92,10 @@ describe('createContentModel with selection', () => { domToContentModelSpy = spyOn(domToContentModel, 'domToContentModel'); createEditorContextSpy = jasmine.createSpy('createEditorContext'); + spyOn(createDomToModelContext, 'createDomToModelContextWithConfig').and.returnValue( + mockedContext + ); + core = { contentDiv: MockedDiv, api: { @@ -115,14 +120,10 @@ describe('createContentModel with selection', () => { createContentModel(core); expect(domToContentModelSpy).toHaveBeenCalledTimes(1); - expect(domToContentModelSpy).toHaveBeenCalledWith( - MockedDiv, - createDomToModelContext(undefined), - { - type: SelectionRangeTypes.Normal, - ranges: [MockedRange], - } as any - ); + expect(domToContentModelSpy).toHaveBeenCalledWith(MockedDiv, mockedContext, { + type: SelectionRangeTypes.Normal, + ranges: [MockedRange], + } as any); }); it('Table selection', () => { @@ -142,18 +143,14 @@ describe('createContentModel with selection', () => { createContentModel(core); expect(domToContentModelSpy).toHaveBeenCalledTimes(1); - expect(domToContentModelSpy).toHaveBeenCalledWith( - MockedDiv, - createDomToModelContext(undefined), - { - type: SelectionRangeTypes.TableSelection, - table: MockedContainer, - coordinates: { - firstCell: MockedFirstCell, - lastCell: MockedLastCell, - }, - } as any - ); + expect(domToContentModelSpy).toHaveBeenCalledWith(MockedDiv, mockedContext, { + type: SelectionRangeTypes.TableSelection, + table: MockedContainer, + coordinates: { + firstCell: MockedFirstCell, + lastCell: MockedLastCell, + }, + } as any); }); it('Image selection', () => { @@ -167,14 +164,10 @@ describe('createContentModel with selection', () => { createContentModel(core); expect(domToContentModelSpy).toHaveBeenCalledTimes(1); - expect(domToContentModelSpy).toHaveBeenCalledWith( - MockedDiv, - createDomToModelContext(undefined), - { - type: SelectionRangeTypes.ImageSelection, - image: MockedContainer, - } as any - ); + expect(domToContentModelSpy).toHaveBeenCalledWith(MockedDiv, mockedContext, { + type: SelectionRangeTypes.ImageSelection, + image: MockedContainer, + } as any); }); it('Incorrect regular selection', () => { @@ -186,14 +179,10 @@ describe('createContentModel with selection', () => { createContentModel(core); expect(domToContentModelSpy).toHaveBeenCalledTimes(1); - expect(domToContentModelSpy).toHaveBeenCalledWith( - MockedDiv, - createDomToModelContext(undefined), - { - type: SelectionRangeTypes.Normal, - ranges: [], - } as any - ); + expect(domToContentModelSpy).toHaveBeenCalledWith(MockedDiv, mockedContext, { + type: SelectionRangeTypes.Normal, + ranges: [], + } as any); }); it('Incorrect table selection', () => { @@ -204,12 +193,8 @@ describe('createContentModel with selection', () => { createContentModel(core); expect(domToContentModelSpy).toHaveBeenCalledTimes(1); - expect(domToContentModelSpy).toHaveBeenCalledWith( - MockedDiv, - createDomToModelContext(undefined), - { - type: SelectionRangeTypes.TableSelection, - } as any - ); + expect(domToContentModelSpy).toHaveBeenCalledWith(MockedDiv, mockedContext, { + type: SelectionRangeTypes.TableSelection, + } as any); }); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/setContentModelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/setContentModelTest.ts index 9bfc8754374..42c48cd32fa 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/setContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/setContentModelTest.ts @@ -9,12 +9,14 @@ const mockedModel = 'MODEL' as any; const mockedEditorContext = 'EDITORCONTEXT' as any; const mockedContext = 'CONTEXT' as any; const mockedDiv = { ownerDocument: mockedDoc } as any; +const mockedConfig = 'CONFIG' as any; describe('setContentModel', () => { let core: ContentModelEditorCore; let contentModelToDomSpy: jasmine.Spy; let createEditorContext: jasmine.Spy; let createModelToDomContextSpy: jasmine.Spy; + let createModelToDomContextWithConfigSpy: jasmine.Spy; let select: jasmine.Spy; let getSelectionRange: jasmine.Spy; @@ -29,6 +31,10 @@ describe('setContentModel', () => { createModelToDomContext, 'createModelToDomContext' ).and.returnValue(mockedContext); + createModelToDomContextWithConfigSpy = spyOn( + createModelToDomContext, + 'createModelToDomContextWithConfig' + ).and.returnValue(mockedContext); select = jasmine.createSpy('select'); getSelectionRange = jasmine.createSpy('getSelectionRange'); @@ -40,13 +46,18 @@ describe('setContentModel', () => { getSelectionRange, }, lifecycle: {}, + defaultModelToDomConfig: mockedConfig, } as any) as ContentModelEditorCore; }); it('no default option, no shadow edit', () => { setContentModel(core, mockedModel); - expect(createModelToDomContextSpy).toHaveBeenCalledWith(mockedEditorContext, undefined); + expect(createModelToDomContextSpy).not.toHaveBeenCalled(); + expect(createModelToDomContextWithConfigSpy).toHaveBeenCalledWith( + mockedConfig, + mockedEditorContext + ); expect(contentModelToDomSpy).toHaveBeenCalledWith( mockedDoc, mockedDiv, @@ -60,7 +71,10 @@ describe('setContentModel', () => { it('with default option, no shadow edit', () => { setContentModel(core, mockedModel); - expect(createModelToDomContextSpy).toHaveBeenCalledWith(mockedEditorContext, undefined); + expect(createModelToDomContextWithConfigSpy).toHaveBeenCalledWith( + mockedConfig, + mockedEditorContext + ); expect(contentModelToDomSpy).toHaveBeenCalledWith( mockedDoc, mockedDiv, @@ -98,7 +112,10 @@ describe('setContentModel', () => { setContentModel(core, mockedModel); - expect(createModelToDomContextSpy).toHaveBeenCalledWith(mockedEditorContext, undefined); + expect(createModelToDomContextWithConfigSpy).toHaveBeenCalledWith( + mockedConfig, + mockedEditorContext + ); expect(contentModelToDomSpy).toHaveBeenCalledWith( mockedDoc, mockedDiv, diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/createContentModelEditorCoreTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/createContentModelEditorCoreTest.ts index 9a9655fc909..849f5842b49 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/createContentModelEditorCoreTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/createContentModelEditorCoreTest.ts @@ -1,4 +1,6 @@ +import * as createDomToModelContext from 'roosterjs-content-model-dom/lib/domToModel/context/createDomToModelContext'; import * as createEditorCore from 'roosterjs-editor-core/lib/editor/createEditorCore'; +import * as createModelToDomContext from 'roosterjs-content-model-dom/lib/modelToDom/context/createModelToDomContext'; import * as isFeatureEnabled from 'roosterjs-editor-core/lib/editor/isFeatureEnabled'; import ContentModelEditPlugin from '../../lib/editor/plugins/ContentModelEditPlugin'; import ContentModelFormatPlugin from '../../lib/editor/plugins/ContentModelFormatPlugin'; @@ -13,6 +15,12 @@ import { switchShadowEdit } from '../../lib/editor/coreApi/switchShadowEdit'; import { tablePreProcessor } from '../../lib/editor/overrides/tablePreProcessor'; const mockedSwitchShadowEdit = 'SHADOWEDIT' as any; +const mockedDomToModelConfig = { + config: 'mockedDomToModelConfig', +} as any; +const mockedModelToDomConfig = { + config: 'mockedModelToDomConfig', +} as any; describe('createContentModelEditorCore', () => { let createEditorCoreSpy: jasmine.Spy; @@ -42,6 +50,13 @@ describe('createContentModelEditorCore', () => { createEditorCoreSpy = spyOn(createEditorCore, 'createEditorCore').and.returnValue( mockedCore ); + + spyOn(createDomToModelContext, 'createDomToModelConfig').and.returnValue( + mockedDomToModelConfig + ); + spyOn(createModelToDomContext, 'createModelToDomConfig').and.returnValue( + mockedModelToDomConfig + ); }); it('No additional option', () => { @@ -82,6 +97,8 @@ describe('createContentModelEditorCore', () => { undefined, ], defaultModelToDomOptions: [undefined], + defaultDomToModelConfig: mockedDomToModelConfig, + defaultModelToDomConfig: mockedModelToDomConfig, defaultFormat: { fontWeight: undefined, italic: undefined, @@ -144,6 +161,8 @@ describe('createContentModelEditorCore', () => { defaultDomToModelOptions, ], defaultModelToDomOptions: [defaultModelToDomOptions], + defaultDomToModelConfig: mockedDomToModelConfig, + defaultModelToDomConfig: mockedModelToDomConfig, defaultFormat: { fontWeight: undefined, italic: undefined, @@ -217,6 +236,8 @@ describe('createContentModelEditorCore', () => { undefined, ], defaultModelToDomOptions: [undefined], + defaultDomToModelConfig: mockedDomToModelConfig, + defaultModelToDomConfig: mockedModelToDomConfig, defaultFormat: { fontWeight: 'bold', italic: true, @@ -281,6 +302,9 @@ describe('createContentModelEditorCore', () => { textColor: undefined, backgroundColor: undefined, }, + defaultDomToModelConfig: mockedDomToModelConfig, + defaultModelToDomConfig: mockedModelToDomConfig, + addDelimiterForEntity: false, contentDiv: { style: {}, @@ -335,6 +359,8 @@ describe('createContentModelEditorCore', () => { undefined, ], defaultModelToDomOptions: [undefined], + defaultDomToModelConfig: mockedDomToModelConfig, + defaultModelToDomConfig: mockedModelToDomConfig, defaultFormat: { fontWeight: undefined, italic: undefined, From a364e4da9b45cf2475fd6edc82a2d567cf57701b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Tue, 19 Sep 2023 11:22:34 -0300 Subject: [PATCH 56/75] refactor --- .../lib/modelApi/common/normalizeParagraph.ts | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-dom/lib/modelApi/common/normalizeParagraph.ts b/packages-content-model/roosterjs-content-model-dom/lib/modelApi/common/normalizeParagraph.ts index 09ed8db3d43..031b49a95c1 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/modelApi/common/normalizeParagraph.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/modelApi/common/normalizeParagraph.ts @@ -1,9 +1,9 @@ +import { areSameFormats } from '../../domToModel/utils/areSameFormats'; import { ContentModelParagraph } from 'roosterjs-content-model-types'; import { createBr } from '../creators/createBr'; import { isSegmentEmpty } from './isEmpty'; import { isWhiteSpacePreserved } from './isWhiteSpacePreserved'; import { normalizeAllSegments } from './normalizeSegment'; - /** * @internal */ @@ -51,10 +51,24 @@ function removeEmptySegments(block: ContentModelParagraph) { } function removeEmptyLinks(paragraph: ContentModelParagraph) { - const segments = paragraph.segments; - const noMarkerSegments = segments.filter(x => x.segmentType != 'SelectionMarker'); - const marker = segments.find(x => x.segmentType == 'SelectionMarker'); - if (marker && marker.link && noMarkerSegments.every(x => !x.link)) { - delete marker.link; + const marker = paragraph.segments.find(x => x.segmentType == 'SelectionMarker'); + if (marker) { + const markerIndex = paragraph.segments.indexOf(marker); + const prev = paragraph.segments[markerIndex - 1]; + const next = paragraph.segments[markerIndex + 1]; + if ( + (prev && + !prev.link && + areSameFormats(prev.format, marker.format) && + (!next || (!next.link && areSameFormats(next.format, marker.format))) && + marker.link) || + (!prev && + marker.link && + next && + !next.link && + areSameFormats(next.format, marker.format)) + ) { + delete marker.link; + } } } From f37889a666f0615e9477d759d9512a60364139c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Tue, 19 Sep 2023 16:54:37 -0300 Subject: [PATCH 57/75] fix table cell alignment --- .../lib/modelApi/table/alignTableCell.ts | 6 ++++++ .../test/modelApi/table/alignTableCellTest.ts | 3 +++ 2 files changed, 9 insertions(+) diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/alignTableCell.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/alignTableCell.ts index da1fc7bbe92..476bad16d32 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/alignTableCell.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/alignTableCell.ts @@ -59,6 +59,12 @@ export function alignTableCell( return metadata; }); } + + cell.blocks.forEach(block => { + if (block.blockType === 'Paragraph') { + delete block.format.textAlign; + } + }); } } } diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/table/alignTableCellTest.ts b/packages-content-model/roosterjs-content-model-editor/test/modelApi/table/alignTableCellTest.ts index 1998b3ec256..4b37d305c22 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/table/alignTableCellTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/modelApi/table/alignTableCellTest.ts @@ -51,6 +51,9 @@ describe('alignTableCell', () => { expect(table.rows[1].cells[0].cachedElement).toEqual({} as any); expect(table.rows[1].cells[1].cachedElement).toBeUndefined(); expect(table.rows[1].cells[2].cachedElement).toBeUndefined(); + table.rows[0].cells[1].blocks.forEach(block => { + expect(block.format.textAlign).toEqual(undefined); + }); } it('empty table', () => { From 51eb85ccc6ce6de4c2f434419590a5578896fbf2 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Tue, 19 Sep 2023 13:41:44 -0700 Subject: [PATCH 58/75] Content Model Cache improvement 1: Create ContentModelCachePlugin (#2066) * Content Model Customization refactor * fix build * improve * Content Model Customization refactor 2: Add default config * fix build * Content Model: Persist cache 1 * fix build * improve * Content Model: Cache 2 * Fix test * Fix build * improve * Improve * improve * Improve * fix test * Improve --- .../lib/editor/ContentModelEditor.ts | 9 +- .../lib/editor/coreApi/createContentModel.ts | 14 +- .../lib/editor/coreApi/getSelectionRangeEx.ts | 2 +- .../lib/editor/coreApi/setContentModel.ts | 7 +- .../lib/editor/coreApi/switchShadowEdit.ts | 10 +- .../corePlugins/ContentModelCachePlugin.ts | 121 +++++ .../editor/createContentModelEditorCore.ts | 34 +- .../editor/plugins/ContentModelEditPlugin.ts | 170 +------ .../plugins/ContentModelFormatPlugin.ts | 25 + .../editor/utils/handleKeyboardEventCommon.ts | 3 +- .../lib/index.ts | 6 + .../publicApi/editing/handleKeyDownEvent.ts | 59 --- .../lib/publicApi/editing/keyboardDelete.ts | 99 ++++ .../publicApi/format/applyDefaultFormat.ts | 94 ++++ .../publicApi/utils/formatWithContentModel.ts | 2 - .../lib/publicTypes/ContentModelEditorCore.ts | 13 +- .../lib/publicTypes/IContentModelEditor.ts | 5 +- .../ContentModelCachePluginState.ts | 17 + .../pluginState/ContentModelPluginState.ts | 18 + .../test/editor/ContentModelEditorTest.ts | 16 +- .../editor/coreApi/createContentModelTest.ts | 7 +- .../editor/coreApi/setContentModelTest.ts | 1 + .../editor/coreApi/switchShadowEditTest.ts | 27 +- .../createContentModelEditorCoreTest.ts | 37 +- .../plugins/ContentModelEditPluginTest.ts | 481 +----------------- .../plugins/ContentModelFormatPluginTest.ts | 350 ++++++++++++- .../plugins/paste/e2e/cmPasteFromExcelTest.ts | 2 - .../plugins/paste/e2e/cmPasteFromWacTest.ts | 31 +- .../plugins/paste/e2e/cmPasteFromWordTest.ts | 15 +- .../utils/handleKeyboardEventCommonTest.ts | 2 +- .../publicApi/editing/editingTestCommon.ts | 2 +- ...DownEventTest.ts => keyboardDeleteTest.ts} | 132 ++++- .../test/publicApi/utils/pasteTest.ts | 13 +- 33 files changed, 1023 insertions(+), 801 deletions(-) create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCachePlugin.ts delete mode 100644 packages-content-model/roosterjs-content-model-editor/lib/publicApi/editing/handleKeyDownEvent.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/publicApi/editing/keyboardDelete.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/applyDefaultFormat.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/publicTypes/pluginState/ContentModelCachePluginState.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/publicTypes/pluginState/ContentModelPluginState.ts rename packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/{handleKeyDownEventTest.ts => keyboardDeleteTest.ts} (74%) diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts index 48d29bd5e15..d8e57a63c4e 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts @@ -57,15 +57,14 @@ export default class ContentModelEditor } /** - * Cache a content model object. Next time when format with content model, we can reuse it. - * @param model + * Notify editor the current cache may be invalid */ - cacheContentModel(model: ContentModelDocument | null) { + invalidateCache() { const core = this.getCore(); if (!core.lifecycle.shadowEditFragment) { - core.cachedModel = model || undefined; - core.cachedRangeEx = undefined; + core.cache.cachedModel = undefined; + core.cache.cachedRangeEx = undefined; } } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/createContentModel.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/createContentModel.ts index 76ef01b64f0..4cf8925e2b3 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/createContentModel.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/createContentModel.ts @@ -19,14 +19,24 @@ import { * @param selectionOverride When passed, use this selection range instead of current selection in editor */ export const createContentModel: CreateContentModel = (core, option, selectionOverride) => { - let cachedModel = selectionOverride ? null : core.cachedModel; + let cachedModel = selectionOverride ? null : core.cache.cachedModel; if (cachedModel && core.lifecycle.shadowEditFragment) { // When in shadow edit, use a cloned model so we won't pollute the cached one cachedModel = cloneModel(cachedModel, { includeCachedElement: true }); } - return cachedModel || internalCreateContentModel(core, option, selectionOverride); + if (cachedModel) { + return cachedModel; + } else { + const model = internalCreateContentModel(core, option, selectionOverride); + + if (!option && !selectionOverride) { + core.cache.cachedModel = model; + } + + return model; + } }; function internalCreateContentModel( diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/getSelectionRangeEx.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/getSelectionRangeEx.ts index 9f6c871b169..45df9c490c0 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/getSelectionRangeEx.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/getSelectionRangeEx.ts @@ -7,5 +7,5 @@ import { GetSelectionRangeEx } from 'roosterjs-editor-types'; export const getSelectionRangeEx: GetSelectionRangeEx = core => { const contentModelCore = core as ContentModelEditorCore; - return contentModelCore.cachedRangeEx ?? core.originalApi.getSelectionRangeEx(core); + return contentModelCore.cache.cachedRangeEx ?? core.originalApi.getSelectionRangeEx(core); }; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/setContentModel.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/setContentModel.ts index d9e5a1dbfff..4aad709db70 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/setContentModel.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/setContentModel.ts @@ -28,8 +28,13 @@ export const setContentModel: SetContentModel = (core, model, option, onNodeCrea if (!core.lifecycle.shadowEditFragment) { core.api.select(core, range); - core.cachedRangeEx = range || undefined; + + if (range) { + core.cache.cachedRangeEx = range; + } } + // TODO: Reconcile selection text node cache + return range; }; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/switchShadowEdit.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/switchShadowEdit.ts index 860232f5dcd..feb4d828d94 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/switchShadowEdit.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/switchShadowEdit.ts @@ -14,7 +14,7 @@ export const switchShadowEdit: SwitchShadowEdit = (editorCore, isOn): void => { if (isOn != !!core.lifecycle.shadowEditFragment) { if (isOn) { - const model = !core.cachedModel ? core.api.createContentModel(core) : null; + const model = !core.cache.cachedModel ? core.api.createContentModel(core) : null; const range = core.api.getSelectionRange(core, true /*tryGetFromCache*/); // Fake object, not used in Content Model Editor, just to satisfy original editor code @@ -34,8 +34,8 @@ export const switchShadowEdit: SwitchShadowEdit = (editorCore, isOn): void => { // This need to be done after EnteredShadowEdit event is triggered since EnteredShadowEdit event will cause a SelectionChanged event // if current selection is table selection or image selection - if (!core.cachedModel && model) { - core.cachedModel = model; + if (!core.cache.cachedModel && model) { + core.cache.cachedModel = model; } core.lifecycle.shadowEditSelectionPath = selectionPath; @@ -52,8 +52,8 @@ export const switchShadowEdit: SwitchShadowEdit = (editorCore, isOn): void => { false /*broadcast*/ ); - if (core.cachedModel) { - core.api.setContentModel(core, core.cachedModel); + if (core.cache.cachedModel) { + core.api.setContentModel(core, core.cache.cachedModel); } } } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCachePlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCachePlugin.ts new file mode 100644 index 00000000000..8102176eef5 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCachePlugin.ts @@ -0,0 +1,121 @@ +import { ContentModelCachePluginState } from '../../publicTypes/pluginState/ContentModelCachePluginState'; +import { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; +import { + IEditor, + Keys, + PluginEvent, + PluginEventType, + PluginWithState, + SelectionRangeEx, +} from 'roosterjs-editor-types'; + +/** + * ContentModel cache plugin manages cached Content Model, and refresh the cache when necessary + */ +export default class ContentModelCachePlugin + implements PluginWithState { + private editor: IContentModelEditor | null = null; + + /** + * Construct a new instance of ContentModelEditPlugin class + * @param state State of this plugin + */ + constructor(private state: ContentModelCachePluginState) { + // TODO: Remove tempState parameter once we have standalone Content Model editor + } + + /** + * Get name of this plugin + */ + getName() { + return 'ContentModelCache'; + } + + /** + * The first method that editor will call to a plugin when editor is initializing. + * It will pass in the editor instance, plugin should take this chance to save the + * editor reference so that it can call to any editor method or format API later. + * @param editor The editor object + */ + initialize(editor: IEditor) { + // TODO: Later we may need a different interface for Content Model editor plugin + this.editor = editor as IContentModelEditor; + this.editor.getDocument().addEventListener('selectionchange', this.onNativeSelectionChange); + } + + /** + * The last method that editor will call to a plugin before it is disposed. + * Plugin can take this chance to clear the reference to editor. After this method is + * called, plugin should not call to any editor method since it will result in error. + */ + dispose() { + if (this.editor) { + this.editor + .getDocument() + .removeEventListener('selectionchange', this.onNativeSelectionChange); + this.editor = null; + } + } + + /** + * Get plugin state object + */ + getState(): ContentModelCachePluginState { + return this.state; + } + + /** + * Core method for a plugin. Once an event happens in editor, editor will call this + * method of each plugin to handle the event as long as the event is not handled + * exclusively by another plugin. + * @param event The event to handle: + */ + onPluginEvent(event: PluginEvent) { + if (!this.editor) { + return; + } + + switch (event.eventType) { + case PluginEventType.KeyDown: + switch (event.rawEvent.which) { + case Keys.ENTER: + // ENTER key will create new paragraph, so need to update cache to reflect this change + // TODO: Handle ENTER key to better reuse content model + this.editor.invalidateCache(); + + break; + } + break; + + case PluginEventType.Input: + case PluginEventType.SelectionChanged: + this.reconcileSelection(this.editor); + break; + + case PluginEventType.ContentChanged: + this.editor.invalidateCache(); + break; + } + } + + private onNativeSelectionChange = () => { + if (this.editor?.hasFocus()) { + this.reconcileSelection(this.editor); + } + }; + + private reconcileSelection(editor: IContentModelEditor, newRangeEx?: SelectionRangeEx) { + // TODO: Really do reconcile selection + editor.invalidateCache(); + } +} + +/** + * @internal + * Create a new instance of ContentModelCachePlugin class. + * This is mostly for unit test + * @param state State of this plugin + */ +export function createContentModelCachePlugin(state: ContentModelCachePluginState) { + return new ContentModelCachePlugin(state); +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/createContentModelEditorCore.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/createContentModelEditorCore.ts index 772ce7cb9df..1cee2be7ee2 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/createContentModelEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/createContentModelEditorCore.ts @@ -1,12 +1,14 @@ import ContentModelCopyPastePlugin from './corePlugins/ContentModelCopyPastePlugin'; -import ContentModelEditPlugin from './plugins/ContentModelEditPlugin'; -import ContentModelFormatPlugin from './plugins/ContentModelFormatPlugin'; import ContentModelTypeInContainerPlugin from './corePlugins/ContentModelTypeInContainerPlugin'; import { ContentModelEditorCore } from '../publicTypes/ContentModelEditorCore'; import { ContentModelEditorOptions } from '../publicTypes/IContentModelEditor'; +import { ContentModelPluginState } from '../publicTypes/pluginState/ContentModelPluginState'; import { ContentModelSegmentFormat } from 'roosterjs-content-model-types'; import { CoreCreator, EditorCore, ExperimentalFeatures } from 'roosterjs-editor-types'; import { createContentModel } from './coreApi/createContentModel'; +import { createContentModelCachePlugin } from './corePlugins/ContentModelCachePlugin'; +import { createContentModelEditPlugin } from './plugins/ContentModelEditPlugin'; +import { createContentModelFormatPlugin } from './plugins/ContentModelFormatPlugin'; import { createDomToModelConfig, createModelToDomConfig } from 'roosterjs-content-model-dom'; import { createEditorContext } from './coreApi/createEditorContext'; import { createEditorCore, isFeatureEnabled } from 'roosterjs-editor-core'; @@ -22,12 +24,19 @@ export const createContentModelEditorCore: CoreCreator< ContentModelEditorCore, ContentModelEditorOptions > = (contentDiv, options) => { + const pluginState: ContentModelPluginState = { + cache: {}, + copyPaste: { + allowedCustomPasteType: options.allowedCustomPasteType || [], + }, + }; const modifiedOptions: ContentModelEditorOptions = { ...options, plugins: [ + createContentModelCachePlugin(pluginState.cache), ...(options.plugins || []), - new ContentModelFormatPlugin(), - new ContentModelEditPlugin(), + createContentModelFormatPlugin(), + createContentModelEditPlugin(), ], corePluginOverride: { typeInContainer: new ContentModelTypeInContainerPlugin(), @@ -35,9 +44,7 @@ export const createContentModelEditorCore: CoreCreator< options.experimentalFeatures, ExperimentalFeatures.ContentModelPaste ) - ? new ContentModelCopyPastePlugin({ - allowedCustomPasteType: options.allowedCustomPasteType || [], - }) + ? new ContentModelCopyPastePlugin(pluginState.copyPaste) : undefined, ...(options.corePluginOverride || {}), }, @@ -45,7 +52,7 @@ export const createContentModelEditorCore: CoreCreator< const core = createEditorCore(contentDiv, modifiedOptions) as ContentModelEditorCore; - promoteToContentModelEditorCore(core, modifiedOptions); + promoteToContentModelEditorCore(core, modifiedOptions, pluginState); return core; }; @@ -57,15 +64,24 @@ export const createContentModelEditorCore: CoreCreator< */ export function promoteToContentModelEditorCore( core: EditorCore, - options: ContentModelEditorOptions + options: ContentModelEditorOptions, + pluginState: ContentModelPluginState ) { const cmCore = core as ContentModelEditorCore; + promoteCorePluginState(cmCore, pluginState); promoteDefaultFormat(cmCore); promoteContentModelInfo(cmCore, options); promoteCoreApi(cmCore); } +function promoteCorePluginState( + cmCore: ContentModelEditorCore, + pluginState: ContentModelPluginState +) { + Object.assign(cmCore, pluginState); +} + function promoteDefaultFormat(cmCore: ContentModelEditorCore) { cmCore.lifecycle.defaultFormat = cmCore.lifecycle.defaultFormat || {}; cmCore.defaultFormat = getDefaultSegmentFormat(cmCore); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/ContentModelEditPlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/ContentModelEditPlugin.ts index 5f7e3e54ede..0258cc14ae9 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/ContentModelEditPlugin.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/ContentModelEditPlugin.ts @@ -1,32 +1,13 @@ -import handleKeyDownEvent from '../../publicApi/editing/handleKeyDownEvent'; -import { ContentModelSegmentFormat } from 'roosterjs-content-model-types'; -import { DeleteResult } from '../../modelApi/edit/utils/DeleteSelectionStep'; -import { deleteSelection } from '../../modelApi/edit/deleteSelection'; -import { formatWithContentModel } from '../../publicApi/utils/formatWithContentModel'; -import { getPendingFormat, setPendingFormat } from '../../modelApi/format/pendingFormat'; +import keyboardDelete from '../../publicApi/editing/keyboardDelete'; import { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; -import { isNodeOfType, normalizeContentModel } from 'roosterjs-content-model-dom'; import { EditorPlugin, IEditor, Keys, - NodePosition, - NodeType, PluginEvent, PluginEventType, PluginKeyDownEvent, - SelectionRangeTypes, } from 'roosterjs-editor-types'; -import { - getObjectKeys, - isBlockElement, - isCharacterValue, - isModifierKey, - Position, -} from 'roosterjs-editor-dom'; - -// During IME input, KeyDown event will have "Process" as key -const ProcessKey = 'Process'; /** * ContentModel plugins helps editor to do editing operation on top of content model. @@ -36,7 +17,6 @@ const ProcessKey = 'Process'; */ export default class ContentModelEditPlugin implements EditorPlugin { private editor: IContentModelEditor | null = null; - private hasDefaultFormat = false; /** * Get name of this plugin @@ -54,11 +34,6 @@ export default class ContentModelEditPlugin implements EditorPlugin { initialize(editor: IEditor) { // TODO: Later we may need a different interface for Content Model editor plugin this.editor = editor as IContentModelEditor; - - const defaultFormat = this.editor.getContentModelDefaultFormat(); - this.hasDefaultFormat = - getObjectKeys(defaultFormat).filter(x => typeof defaultFormat[x] !== 'undefined') - .length > 0; } /** @@ -82,12 +57,6 @@ export default class ContentModelEditPlugin implements EditorPlugin { case PluginEventType.KeyDown: this.handleKeyDownEvent(this.editor, event); break; - - case PluginEventType.ContentChanged: - case PluginEventType.MouseUp: - case PluginEventType.SelectionChanged: - this.editor.cacheContentModel(null); - break; } } } @@ -98,139 +67,26 @@ export default class ContentModelEditPlugin implements EditorPlugin { if (rawEvent.defaultPrevented || event.handledByEditFeature) { // Other plugins already handled this event, so it is most likely content is already changed, we need to clear cached content model - editor.cacheContentModel(null /*model*/); + editor.invalidateCache(); } else { // TODO: Consider use ContentEditFeature and need to hide other conflict features that are not based on Content Model switch (which) { case Keys.BACKSPACE: case Keys.DELETE: - const rangeEx = editor.getSelectionRangeEx(); - const range = - rangeEx.type == SelectionRangeTypes.Normal ? rangeEx.ranges[0] : null; - - if (this.shouldDeleteWithContentModel(range, rawEvent)) { - handleKeyDownEvent(editor, rawEvent); - } else { - editor.cacheContentModel(null); - } - - break; - - default: - if ( - (isCharacterValue(rawEvent) || rawEvent.key == ProcessKey) && - this.hasDefaultFormat - ) { - this.tryApplyDefaultFormat(editor); - } - - editor.cacheContentModel(null); + // Use our API to handle BACKSPACE/DELETE key. + // No need to clear cache here since if we rely on browser's behavior, there will be Input event and its handler will reconcile cache + keyboardDelete(editor, rawEvent); break; } } } +} - private tryApplyDefaultFormat(editor: IContentModelEditor) { - const rangeEx = editor.getSelectionRangeEx(); - const range = rangeEx?.type == SelectionRangeTypes.Normal ? rangeEx.ranges[0] : null; - const startPos = range ? Position.getStart(range) : null; - let node: Node | null = startPos?.node ?? null; - - while (node && editor.contains(node)) { - if (isNodeOfType(node, NodeType.Element) && node.getAttribute?.('style')) { - return; - } else if (isBlockElement(node)) { - break; - } else { - node = node.parentNode; - } - } - - formatWithContentModel(editor, 'input', (model, context) => { - const result = deleteSelection(model, [], context); - - if (result.deleteResult == DeleteResult.Range) { - normalizeContentModel(model); - editor.addUndoSnapshot(); - - return true; - } else if ( - result.deleteResult == DeleteResult.NotDeleted && - result.insertPoint && - startPos - ) { - const { paragraph, path, marker } = result.insertPoint; - const blocks = path[0].blocks; - const blockCount = blocks.length; - const blockIndex = blocks.indexOf(paragraph); - - if ( - paragraph.isImplicit && - paragraph.segments.length == 1 && - paragraph.segments[0] == marker && - blockCount > 0 && - blockIndex == blockCount - 1 - ) { - // Focus is in the last paragraph which is implicit and there is not other segments. - // This can happen when focus is moved after all other content under current block group. - // We need to check if browser will merge focus into previous paragraph by checking if - // previous block is block. If previous block is paragraph, browser will most likely merge - // the input into previous paragraph, then nothing need to do here. Otherwise we need to - // apply pending format since this input event will start a new real paragraph. - const previousBlock = blocks[blockIndex - 1]; - - if (previousBlock?.blockType != 'Paragraph') { - this.applyDefaultFormat(editor, marker.format, startPos); - } - } else if (paragraph.segments.every(x => x.segmentType != 'Text')) { - this.applyDefaultFormat(editor, marker.format, startPos); - } - - // We didn't do any change but just apply default format to pending format, so no need to write back - return false; - } else { - return false; - } - }); - } - - private applyDefaultFormat( - editor: IContentModelEditor, - currentFormat: ContentModelSegmentFormat, - startPos: NodePosition - ) { - const pendingFormat = getPendingFormat(editor) || {}; - const defaultFormat = editor.getContentModelDefaultFormat(); - const newFormat: ContentModelSegmentFormat = { - ...defaultFormat, - ...pendingFormat, - ...currentFormat, - }; - - setPendingFormat(editor, newFormat, startPos); - } - - private shouldDeleteWithContentModel(range: Range | null, rawEvent: KeyboardEvent) { - return !( - range?.collapsed && - range.startContainer.nodeType == NodeType.Text && - !isModifierKey(rawEvent) && - (this.canDeleteBefore(rawEvent, range) || this.canDeleteAfter(rawEvent, range)) - ); - } - - private canDeleteBefore(rawEvent: KeyboardEvent, range: Range) { - return ( - rawEvent.which == Keys.BACKSPACE && - (range.startOffset > 1 || range.startContainer.previousSibling) - ); - } - - private canDeleteAfter(rawEvent: KeyboardEvent, range: Range) { - return ( - rawEvent.which == Keys.DELETE && - (range.startOffset < (range.startContainer.nodeValue?.length ?? 0) - 1 || - range.startContainer.nextSibling) - ); - } +/** + * @internal + * Create a new instance of ContentModelEditPlugin class. + * This is mostly for unit test + */ +export function createContentModelEditPlugin() { + return new ContentModelEditPlugin(); } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/ContentModelFormatPlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/ContentModelFormatPlugin.ts index 76ef9b2870b..5b615d322ee 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/ContentModelFormatPlugin.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/ContentModelFormatPlugin.ts @@ -1,8 +1,13 @@ +import applyDefaultFormat from '../../publicApi/format/applyDefaultFormat'; import applyPendingFormat from '../../publicApi/format/applyPendingFormat'; import { canApplyPendingFormat, clearPendingFormat } from '../../modelApi/format/pendingFormat'; import { EditorPlugin, IEditor, Keys, PluginEvent, PluginEventType } from 'roosterjs-editor-types'; +import { getObjectKeys, isCharacterValue } from 'roosterjs-editor-dom'; import { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; +// During IME input, KeyDown event will have "Process" as key +const ProcessKey = 'Process'; + /** * ContentModelFormat plugins helps editor to do formatting on top of content model. * This includes: @@ -10,6 +15,7 @@ import { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; */ export default class ContentModelFormatPlugin implements EditorPlugin { private editor: IContentModelEditor | null = null; + private hasDefaultFormat = false; /** * Get name of this plugin @@ -27,6 +33,11 @@ export default class ContentModelFormatPlugin implements EditorPlugin { initialize(editor: IEditor) { // TODO: Later we may need a different interface for Content Model editor plugin this.editor = editor as IContentModelEditor; + + const defaultFormat = this.editor.getContentModelDefaultFormat(); + this.hasDefaultFormat = + getObjectKeys(defaultFormat).filter(x => typeof defaultFormat[x] !== 'undefined') + .length > 0; } /** @@ -65,6 +76,11 @@ export default class ContentModelFormatPlugin implements EditorPlugin { case PluginEventType.KeyDown: if (event.rawEvent.which >= Keys.PAGEUP && event.rawEvent.which <= Keys.DOWN) { clearPendingFormat(this.editor); + } else if ( + this.hasDefaultFormat && + (isCharacterValue(event.rawEvent) || event.rawEvent.key == ProcessKey) + ) { + applyDefaultFormat(this.editor); } break; @@ -85,3 +101,12 @@ export default class ContentModelFormatPlugin implements EditorPlugin { } } } + +/** + * @internal + * Create a new instance of ContentModelFormatPlugin. + * This is mostly for unit test + */ +export function createContentModelFormatPlugin() { + return new ContentModelFormatPlugin(); +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/handleKeyboardEventCommon.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/handleKeyboardEventCommon.ts index c6e81e6238a..c13919d1aa8 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/handleKeyboardEventCommon.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/handleKeyboardEventCommon.ts @@ -20,8 +20,7 @@ export function handleKeyboardEventResult( switch (result) { case DeleteResult.NotDeleted: - // We have not delete anything, we will let browser handle this event, so clear cached model if any since the content will be changed by browser - editor.cacheContentModel(null); + // We have not delete anything, we will let browser handle this event return false; case DeleteResult.NothingToDelete: diff --git a/packages-content-model/roosterjs-content-model-editor/lib/index.ts b/packages-content-model/roosterjs-content-model-editor/lib/index.ts index 67531e9a789..b07c19724a5 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/index.ts @@ -75,6 +75,7 @@ export { default as toggleCode } from './publicApi/segment/toggleCode'; export { default as paste } from './publicApi/utils/paste'; export { default as insertEntity } from './publicApi/entity/insertEntity'; export { formatWithContentModel } from './publicApi/utils/formatWithContentModel'; +export { default as keyboardDelete } from './publicApi/editing/keyboardDelete'; export { default as ContentModelEditor } from './editor/ContentModelEditor'; export { default as isContentModelEditor } from './editor/isContentModelEditor'; @@ -83,6 +84,8 @@ export { default as ContentModelEditPlugin } from './editor/plugins/ContentModel export { default as ContentModelPastePlugin } from './editor/plugins/PastePlugin/ContentModelPastePlugin'; export { default as ContentModelTypeInContainerPlugin } from './editor/corePlugins/ContentModelTypeInContainerPlugin'; export { default as ContentModelCopyPastePlugin } from './editor/corePlugins/ContentModelCopyPastePlugin'; +export { default as ContentModelCachePlugin } from './editor/corePlugins/ContentModelCachePlugin'; + export { createContentModelEditorCore, promoteToContentModelEditorCore, @@ -91,3 +94,6 @@ export { combineBorderValue, extractBorderValues } from './domUtils/borderValues export { updateImageMetadata } from './domUtils/metadata/updateImageMetadata'; export { updateTableCellMetadata } from './domUtils/metadata/updateTableCellMetadata'; export { updateTableMetadata } from './domUtils/metadata/updateTableMetadata'; + +export { ContentModelCachePluginState } from './publicTypes/pluginState/ContentModelCachePluginState'; +export { ContentModelPluginState } from './publicTypes/pluginState/ContentModelPluginState'; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/editing/handleKeyDownEvent.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/editing/handleKeyDownEvent.ts deleted file mode 100644 index 6b477dd2e42..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/editing/handleKeyDownEvent.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { Browser } from 'roosterjs-editor-dom'; -import { ChangeSource, Keys } from 'roosterjs-editor-types'; -import { deleteAllSegmentBefore } from '../../modelApi/edit/deleteSteps/deleteAllSegmentBefore'; -import { deleteSelection } from '../../modelApi/edit/deleteSelection'; -import { DeleteSelectionStep } from '../../modelApi/edit/utils/DeleteSelectionStep'; -import { formatWithContentModel } from '../utils/formatWithContentModel'; -import { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; -import { - handleKeyboardEventResult, - shouldDeleteAllSegmentsBefore, - shouldDeleteWord, -} from '../../editor/utils/handleKeyboardEventCommon'; -import { - backwardDeleteWordSelection, - forwardDeleteWordSelection, -} from '../../modelApi/edit/deleteSteps/deleteWordSelection'; -import { - backwardDeleteCollapsedSelection, - forwardDeleteCollapsedSelection, -} from '../../modelApi/edit/deleteSteps/deleteCollapsedSelection'; - -/** - * @internal - * Handle KeyDown event - * Currently only DELETE and BACKSPACE keys are supported - */ -export default function handleKeyDownEvent(editor: IContentModelEditor, rawEvent: KeyboardEvent) { - const which = rawEvent.which; - - formatWithContentModel( - editor, - which == Keys.DELETE ? 'handleDeleteKey' : 'handleBackspaceKey', - (model, context) => { - const result = deleteSelection(model, getDeleteSteps(rawEvent), context).deleteResult; - - return handleKeyboardEventResult(editor, model, rawEvent, result, context); - }, - { - rawEvent, - changeSource: ChangeSource.Keyboard, - getChangeData: () => which, - } - ); -} - -function getDeleteSteps(rawEvent: KeyboardEvent): (DeleteSelectionStep | null)[] { - const isForward = rawEvent.which == Keys.DELETE; - const deleteAllSegmentBeforeStep = - shouldDeleteAllSegmentsBefore(rawEvent) && !isForward ? deleteAllSegmentBefore : null; - const deleteWordSelection = shouldDeleteWord(rawEvent, !!Browser.isMac) - ? isForward - ? forwardDeleteWordSelection - : backwardDeleteWordSelection - : null; - const deleteCollapsedSelection = isForward - ? forwardDeleteCollapsedSelection - : backwardDeleteCollapsedSelection; - return [deleteAllSegmentBeforeStep, deleteWordSelection, deleteCollapsedSelection]; -} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/editing/keyboardDelete.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/editing/keyboardDelete.ts new file mode 100644 index 00000000000..3259fcdbc0f --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/editing/keyboardDelete.ts @@ -0,0 +1,99 @@ +import { Browser, isModifierKey } from 'roosterjs-editor-dom'; +import { ChangeSource, Keys, NodeType, SelectionRangeTypes } from 'roosterjs-editor-types'; +import { deleteAllSegmentBefore } from '../../modelApi/edit/deleteSteps/deleteAllSegmentBefore'; +import { DeleteResult, DeleteSelectionStep } from '../../modelApi/edit/utils/DeleteSelectionStep'; +import { deleteSelection } from '../../modelApi/edit/deleteSelection'; +import { formatWithContentModel } from '../utils/formatWithContentModel'; +import { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; +import { + handleKeyboardEventResult, + shouldDeleteAllSegmentsBefore, + shouldDeleteWord, +} from '../../editor/utils/handleKeyboardEventCommon'; +import { + backwardDeleteWordSelection, + forwardDeleteWordSelection, +} from '../../modelApi/edit/deleteSteps/deleteWordSelection'; +import { + backwardDeleteCollapsedSelection, + forwardDeleteCollapsedSelection, +} from '../../modelApi/edit/deleteSteps/deleteCollapsedSelection'; + +/** + * Do keyboard event handling for DELETE/BACKSPACE key + * @param editor The Content Model Editor + * @param rawEvent DOM keyboard event + * @returns True if the event is handled with this function, otherwise false + */ +export default function keyboardDelete( + editor: IContentModelEditor, + rawEvent: KeyboardEvent +): boolean { + const which = rawEvent.which; + const rangeEx = editor.getSelectionRangeEx(); + const range = rangeEx.type == SelectionRangeTypes.Normal ? rangeEx.ranges[0] : null; + let isDeleted = false; + + if (shouldDeleteWithContentModel(range, rawEvent)) { + formatWithContentModel( + editor, + which == Keys.DELETE ? 'handleDeleteKey' : 'handleBackspaceKey', + (model, context) => { + const result = deleteSelection(model, getDeleteSteps(rawEvent), context) + .deleteResult; + + isDeleted = result != DeleteResult.NotDeleted; + + return handleKeyboardEventResult(editor, model, rawEvent, result, context); + }, + { + rawEvent, + changeSource: ChangeSource.Keyboard, + getChangeData: () => which, + } + ); + + return true; + } + + return isDeleted; +} + +function getDeleteSteps(rawEvent: KeyboardEvent): (DeleteSelectionStep | null)[] { + const isForward = rawEvent.which == Keys.DELETE; + const deleteAllSegmentBeforeStep = + shouldDeleteAllSegmentsBefore(rawEvent) && !isForward ? deleteAllSegmentBefore : null; + const deleteWordSelection = shouldDeleteWord(rawEvent, !!Browser.isMac) + ? isForward + ? forwardDeleteWordSelection + : backwardDeleteWordSelection + : null; + const deleteCollapsedSelection = isForward + ? forwardDeleteCollapsedSelection + : backwardDeleteCollapsedSelection; + return [deleteAllSegmentBeforeStep, deleteWordSelection, deleteCollapsedSelection]; +} + +function shouldDeleteWithContentModel(range: Range | null, rawEvent: KeyboardEvent) { + return !( + range?.collapsed && + range.startContainer.nodeType == NodeType.Text && + !isModifierKey(rawEvent) && + (canDeleteBefore(rawEvent, range) || canDeleteAfter(rawEvent, range)) + ); +} + +function canDeleteBefore(rawEvent: KeyboardEvent, range: Range) { + return ( + rawEvent.which == Keys.BACKSPACE && + (range.startOffset > 1 || range.startContainer.previousSibling) + ); +} + +function canDeleteAfter(rawEvent: KeyboardEvent, range: Range) { + return ( + rawEvent.which == Keys.DELETE && + (range.startOffset < (range.startContainer.nodeValue?.length ?? 0) - 1 || + range.startContainer.nextSibling) + ); +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/applyDefaultFormat.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/applyDefaultFormat.ts new file mode 100644 index 00000000000..84f88c319b7 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/applyDefaultFormat.ts @@ -0,0 +1,94 @@ +import { ContentModelSegmentFormat } from 'roosterjs-content-model-types'; +import { DeleteResult } from '../../modelApi/edit/utils/DeleteSelectionStep'; +import { deleteSelection } from '../../modelApi/edit/deleteSelection'; +import { formatWithContentModel } from '../utils/formatWithContentModel'; +import { getPendingFormat, setPendingFormat } from '../../modelApi/format/pendingFormat'; +import { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; +import { isBlockElement, Position } from 'roosterjs-editor-dom'; +import { isNodeOfType, normalizeContentModel } from 'roosterjs-content-model-dom'; +import { NodePosition, NodeType, SelectionRangeTypes } from 'roosterjs-editor-types'; + +/** + * @internal + * When necessary, set default format as current pending format so it will be applied when Input event is fired + * @param editor The Content Model Editor + */ +export default function applyDefaultFormat(editor: IContentModelEditor) { + const rangeEx = editor.getSelectionRangeEx(); + const range = rangeEx?.type == SelectionRangeTypes.Normal ? rangeEx.ranges[0] : null; + const startPos = range ? Position.getStart(range) : null; + let node: Node | null = startPos?.node ?? null; + + while (node && editor.contains(node)) { + if (isNodeOfType(node, NodeType.Element) && node.getAttribute?.('style')) { + return; + } else if (isBlockElement(node)) { + break; + } else { + node = node.parentNode; + } + } + + formatWithContentModel(editor, 'input', (model, context) => { + const result = deleteSelection(model, [], context); + + if (result.deleteResult == DeleteResult.Range) { + normalizeContentModel(model); + editor.addUndoSnapshot(); + + return true; + } else if ( + result.deleteResult == DeleteResult.NotDeleted && + result.insertPoint && + startPos + ) { + const { paragraph, path, marker } = result.insertPoint; + const blocks = path[0].blocks; + const blockCount = blocks.length; + const blockIndex = blocks.indexOf(paragraph); + + if ( + paragraph.isImplicit && + paragraph.segments.length == 1 && + paragraph.segments[0] == marker && + blockCount > 0 && + blockIndex == blockCount - 1 + ) { + // Focus is in the last paragraph which is implicit and there is not other segments. + // This can happen when focus is moved after all other content under current block group. + // We need to check if browser will merge focus into previous paragraph by checking if + // previous block is block. If previous block is paragraph, browser will most likely merge + // the input into previous paragraph, then nothing need to do here. Otherwise we need to + // apply pending format since this input event will start a new real paragraph. + const previousBlock = blocks[blockIndex - 1]; + + if (previousBlock?.blockType != 'Paragraph') { + internalApplyDefaultFormat(editor, marker.format, startPos); + } + } else if (paragraph.segments.every(x => x.segmentType != 'Text')) { + internalApplyDefaultFormat(editor, marker.format, startPos); + } + + // We didn't do any change but just apply default format to pending format, so no need to write back + return false; + } else { + return false; + } + }); +} + +function internalApplyDefaultFormat( + editor: IContentModelEditor, + currentFormat: ContentModelSegmentFormat, + startPos: NodePosition +) { + const pendingFormat = getPendingFormat(editor) || {}; + const defaultFormat = editor.getContentModelDefaultFormat(); + const newFormat: ContentModelSegmentFormat = { + ...defaultFormat, + ...pendingFormat, + ...currentFormat, + }; + + setPendingFormat(editor, newFormat, startPos); +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatWithContentModel.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatWithContentModel.ts index c5ef00c1274..327d33b0c58 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatWithContentModel.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatWithContentModel.ts @@ -78,8 +78,6 @@ export function formatWithContentModel( } ); } - - editor.cacheContentModel?.(model); } } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelEditorCore.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelEditorCore.ts index d0d05679a74..b3176b9b775 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelEditorCore.ts @@ -1,3 +1,4 @@ +import { ContentModelPluginState } from './pluginState/ContentModelPluginState'; import { CoreApiMap, EditorCore, SelectionRangeEx } from 'roosterjs-editor-types'; import { ContentModelDocument, @@ -72,7 +73,7 @@ export interface ContentModelCoreApiMap extends CoreApiMap { /** * Represents the core data structure of a Content Model editor */ -export interface ContentModelEditorCore extends EditorCore { +export interface ContentModelEditorCore extends EditorCore, ContentModelPluginState { /** * Core API map of this editor */ @@ -83,16 +84,6 @@ export interface ContentModelEditorCore extends EditorCore { */ readonly originalApi: ContentModelCoreApiMap; - /** - * When reuse Content Model is allowed, we cache the Content Model object here after created - */ - cachedModel?: ContentModelDocument; - - /** - * Cached selection range ex. This range only exist when cached model exists and it has selection - */ - cachedRangeEx?: SelectionRangeEx; - /** * Default format used by Content Model. This is calculated from lifecycle.defaultFormat */ diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts index 4b002f5bdb7..3c84db57c89 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts @@ -37,10 +37,9 @@ export interface IContentModelEditor extends IEditor { ): void; /** - * Cache a content model object. Next time when format with content model, we can reuse it. - * @param model + * Notify editor the current cache may be invalid */ - cacheContentModel(model: ContentModelDocument | null): void; + invalidateCache(): void; /** * Get default format as ContentModelSegmentFormat. diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/pluginState/ContentModelCachePluginState.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/pluginState/ContentModelCachePluginState.ts new file mode 100644 index 00000000000..ee20ac33156 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/pluginState/ContentModelCachePluginState.ts @@ -0,0 +1,17 @@ +import { ContentModelDocument } from 'roosterjs-content-model-types'; +import { SelectionRangeEx } from 'roosterjs-editor-types'; + +/** + * Plugin state for ContentModelEditPlugin + */ +export interface ContentModelCachePluginState { + /** + * Cached selection range + */ + cachedRangeEx?: SelectionRangeEx | undefined; + + /** + * When reuse Content Model is allowed, we cache the Content Model object here after created + */ + cachedModel?: ContentModelDocument; +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/pluginState/ContentModelPluginState.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/pluginState/ContentModelPluginState.ts new file mode 100644 index 00000000000..63e8ccd721d --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/pluginState/ContentModelPluginState.ts @@ -0,0 +1,18 @@ +import { ContentModelCachePluginState } from './ContentModelCachePluginState'; +import { CopyPastePluginState } from 'roosterjs-editor-types'; + +/** + * Temporary core plugin state for Content Model editor + * TODO: Create Content Model plugin state from all core plugins once we have standalone Content Model Editor + */ +export interface ContentModelPluginState { + /** + * Plugin state for ContentModelCachePlugin + */ + cache: ContentModelCachePluginState; + + /** + * Plugin state for ContentModelCopyPastePlugin + */ + copyPaste: CopyPastePluginState; +} diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts index 8658b765505..7c2c90ad4ac 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts @@ -198,7 +198,7 @@ describe('ContentModelEditor', () => { const editor = new ContentModelEditor(div); const cachedModel = 'MODEL' as any; - (editor as any).core.cachedModel = cachedModel; + (editor as any).core.cache.cachedModel = cachedModel; spyOn(domToContentModel, 'domToContentModel'); @@ -208,20 +208,6 @@ describe('ContentModelEditor', () => { expect(domToContentModel.domToContentModel).not.toHaveBeenCalled(); }); - it('cache model', () => { - const div = document.createElement('div'); - const editor = new ContentModelEditor(div); - const cachedModel = 'MODEL' as any; - - editor.cacheContentModel(cachedModel); - - expect((editor as any).core.cachedModel).toBe(cachedModel); - - editor.cacheContentModel(null); - - expect((editor as any).core.cachedModel).toBe(undefined); - }); - it('default format', () => { const div = document.createElement('div'); const editor = new ContentModelEditor(div, { diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/createContentModelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/createContentModelTest.ts index df6eee1d475..61cf54bfa8c 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/createContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/createContentModelTest.ts @@ -40,13 +40,15 @@ describe('createContentModel', () => { createEditorContext, getSelectionRangeEx, }, - cachedModel: mockedCachedMode, + cache: { + cachedModel: mockedCachedMode, + }, lifecycle: {}, } as any) as ContentModelEditorCore; }); it('Reuse model, no cache, no shadow edit', () => { - core.cachedModel = undefined; + core.cache.cachedModel = undefined; const model = createContentModel(core); @@ -102,6 +104,7 @@ describe('createContentModel with selection', () => { getSelectionRangeEx: getSelectionRangeExSpy, createEditorContext: createEditorContextSpy, }, + cache: {}, }; }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/setContentModelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/setContentModelTest.ts index 42c48cd32fa..91afa5bd6b9 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/setContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/setContentModelTest.ts @@ -47,6 +47,7 @@ describe('setContentModel', () => { }, lifecycle: {}, defaultModelToDomConfig: mockedConfig, + cache: {}, } as any) as ContentModelEditorCore; }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/switchShadowEditTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/switchShadowEditTest.ts index e2c79d1f08c..0991c08f96d 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/switchShadowEditTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/switchShadowEditTest.ts @@ -27,17 +27,18 @@ describe('switchShadowEdit', () => { }, lifecycle: {}, contentDiv: document.createElement('div'), + cache: {}, } as any) as ContentModelEditorCore; }); describe('was off', () => { it('no cache, isOn', () => { - core.cachedModel = undefined; + core.cache.cachedModel = undefined; switchShadowEdit(core, true); expect(createContentModel).toHaveBeenCalledWith(core); expect(setContentModel).not.toHaveBeenCalled(); - expect(core.cachedModel).toBe(mockedModel); + expect(core.cache.cachedModel).toBe(mockedModel); expect(triggerEvent).toHaveBeenCalledTimes(1); expect(triggerEvent).toHaveBeenCalledWith( core, @@ -51,13 +52,13 @@ describe('switchShadowEdit', () => { }); it('with cache, isOn', () => { - core.cachedModel = mockedCachedModel; + core.cache.cachedModel = mockedCachedModel; switchShadowEdit(core, true); expect(createContentModel).not.toHaveBeenCalled(); expect(setContentModel).not.toHaveBeenCalled(); - expect(core.cachedModel).toBe(mockedCachedModel); + expect(core.cache.cachedModel).toBe(mockedCachedModel); expect(triggerEvent).toHaveBeenCalledTimes(1); expect(triggerEvent).toHaveBeenCalledWith( @@ -76,19 +77,19 @@ describe('switchShadowEdit', () => { expect(createContentModel).not.toHaveBeenCalled(); expect(setContentModel).not.toHaveBeenCalled(); - expect(core.cachedModel).toBe(undefined); + expect(core.cache.cachedModel).toBe(undefined); expect(triggerEvent).not.toHaveBeenCalled(); }); it('with cache, isOff', () => { - core.cachedModel = mockedCachedModel; + core.cache.cachedModel = mockedCachedModel; switchShadowEdit(core, false); expect(createContentModel).not.toHaveBeenCalled(); expect(setContentModel).not.toHaveBeenCalled(); - expect(core.cachedModel).toBe(mockedCachedModel); + expect(core.cache.cachedModel).toBe(mockedCachedModel); expect(triggerEvent).not.toHaveBeenCalled(); }); @@ -104,19 +105,19 @@ describe('switchShadowEdit', () => { expect(createContentModel).not.toHaveBeenCalled(); expect(setContentModel).not.toHaveBeenCalled(); - expect(core.cachedModel).toBe(undefined); + expect(core.cache.cachedModel).toBe(undefined); expect(triggerEvent).not.toHaveBeenCalled(); }); it('with cache, isOn', () => { - core.cachedModel = mockedCachedModel; + core.cache.cachedModel = mockedCachedModel; switchShadowEdit(core, true); expect(createContentModel).not.toHaveBeenCalled(); expect(setContentModel).not.toHaveBeenCalled(); - expect(core.cachedModel).toBe(mockedCachedModel); + expect(core.cache.cachedModel).toBe(mockedCachedModel); expect(triggerEvent).not.toHaveBeenCalled(); }); @@ -126,7 +127,7 @@ describe('switchShadowEdit', () => { expect(createContentModel).not.toHaveBeenCalled(); expect(setContentModel).not.toHaveBeenCalled(); - expect(core.cachedModel).toBe(undefined); + expect(core.cache.cachedModel).toBe(undefined); expect(triggerEvent).toHaveBeenCalledTimes(1); expect(triggerEvent).toHaveBeenCalledWith( @@ -139,13 +140,13 @@ describe('switchShadowEdit', () => { }); it('with cache, isOff', () => { - core.cachedModel = mockedCachedModel; + core.cache.cachedModel = mockedCachedModel; switchShadowEdit(core, false); expect(createContentModel).not.toHaveBeenCalled(); expect(setContentModel).toHaveBeenCalledWith(core, mockedCachedModel); - expect(core.cachedModel).toBe(mockedCachedModel); + expect(core.cache.cachedModel).toBe(mockedCachedModel); expect(triggerEvent).toHaveBeenCalledTimes(1); expect(triggerEvent).toHaveBeenCalledWith( diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/createContentModelEditorCoreTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/createContentModelEditorCoreTest.ts index 849f5842b49..a046a61d533 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/createContentModelEditorCoreTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/createContentModelEditorCoreTest.ts @@ -1,9 +1,10 @@ +import * as ContentModelCachePlugin from '../../lib/editor/corePlugins/ContentModelCachePlugin'; +import * as ContentModelEditPlugin from '../../lib/editor/plugins/ContentModelEditPlugin'; +import * as ContentModelFormatPlugin from '../../lib/editor/plugins/ContentModelFormatPlugin'; import * as createDomToModelContext from 'roosterjs-content-model-dom/lib/domToModel/context/createDomToModelContext'; import * as createEditorCore from 'roosterjs-editor-core/lib/editor/createEditorCore'; import * as createModelToDomContext from 'roosterjs-content-model-dom/lib/modelToDom/context/createModelToDomContext'; import * as isFeatureEnabled from 'roosterjs-editor-core/lib/editor/isFeatureEnabled'; -import ContentModelEditPlugin from '../../lib/editor/plugins/ContentModelEditPlugin'; -import ContentModelFormatPlugin from '../../lib/editor/plugins/ContentModelFormatPlugin'; import ContentModelTypeInContainerPlugin from '../../lib/editor/corePlugins/ContentModelTypeInContainerPlugin'; import { createContentModel } from '../../lib/editor/coreApi/createContentModel'; import { createContentModelEditorCore } from '../../lib/editor/createContentModelEditorCore'; @@ -21,6 +22,9 @@ const mockedDomToModelConfig = { const mockedModelToDomConfig = { config: 'mockedModelToDomConfig', } as any; +const mockedFormatPlugin = 'FORMATPLUGIN' as any; +const mockedEditPlugin = 'EDITPLUGIN' as any; +const mockedCachePlugin = 'CACHPLUGIN' as any; describe('createContentModelEditorCore', () => { let createEditorCoreSpy: jasmine.Spy; @@ -50,6 +54,15 @@ describe('createContentModelEditorCore', () => { createEditorCoreSpy = spyOn(createEditorCore, 'createEditorCore').and.returnValue( mockedCore ); + spyOn(ContentModelFormatPlugin, 'createContentModelFormatPlugin').and.returnValue( + mockedFormatPlugin + ); + spyOn(ContentModelEditPlugin, 'createContentModelEditPlugin').and.returnValue( + mockedEditPlugin + ); + spyOn(ContentModelCachePlugin, 'createContentModelCachePlugin').and.returnValue( + mockedCachePlugin + ); spyOn(createDomToModelContext, 'createDomToModelConfig').and.returnValue( mockedDomToModelConfig @@ -68,7 +81,7 @@ describe('createContentModelEditorCore', () => { const core = createContentModelEditorCore(contentDiv, options); expect(createEditorCoreSpy).toHaveBeenCalledWith(contentDiv, { - plugins: [new ContentModelFormatPlugin(), new ContentModelEditPlugin()], + plugins: [mockedCachePlugin, mockedFormatPlugin, mockedEditPlugin], corePluginOverride: { typeInContainer: new ContentModelTypeInContainerPlugin(), copyPaste: copyPastePlugin, @@ -112,6 +125,8 @@ describe('createContentModelEditorCore', () => { contentDiv: { style: {}, }, + cache: {}, + copyPaste: { allowedCustomPasteType: [] }, } as any); }); @@ -131,7 +146,7 @@ describe('createContentModelEditorCore', () => { expect(createEditorCoreSpy).toHaveBeenCalledWith(contentDiv, { defaultDomToModelOptions, defaultModelToDomOptions, - plugins: [new ContentModelFormatPlugin(), new ContentModelEditPlugin()], + plugins: [mockedCachePlugin, mockedFormatPlugin, mockedEditPlugin], corePluginOverride: { typeInContainer: new ContentModelTypeInContainerPlugin(), copyPaste: copyPastePlugin, @@ -176,6 +191,8 @@ describe('createContentModelEditorCore', () => { contentDiv: { style: {}, }, + cache: {}, + copyPaste: { allowedCustomPasteType: [] }, } as any); }); @@ -199,7 +216,7 @@ describe('createContentModelEditorCore', () => { const core = createContentModelEditorCore(contentDiv, options); expect(createEditorCoreSpy).toHaveBeenCalledWith(contentDiv, { - plugins: [new ContentModelFormatPlugin(), new ContentModelEditPlugin()], + plugins: [mockedCachePlugin, mockedFormatPlugin, mockedEditPlugin], corePluginOverride: { typeInContainer: new ContentModelTypeInContainerPlugin(), copyPaste: copyPastePlugin, @@ -251,6 +268,8 @@ describe('createContentModelEditorCore', () => { contentDiv: { style: {}, }, + cache: {}, + copyPaste: { allowedCustomPasteType: [] }, } as any); }); @@ -264,7 +283,7 @@ describe('createContentModelEditorCore', () => { const core = createContentModelEditorCore(contentDiv, options); expect(createEditorCoreSpy).toHaveBeenCalledWith(contentDiv, { - plugins: [new ContentModelFormatPlugin(), new ContentModelEditPlugin()], + plugins: [mockedCachePlugin, mockedFormatPlugin, mockedEditPlugin], corePluginOverride: { typeInContainer: new ContentModelTypeInContainerPlugin(), copyPaste: copyPastePlugin, @@ -309,6 +328,8 @@ describe('createContentModelEditorCore', () => { contentDiv: { style: {}, }, + cache: {}, + copyPaste: { allowedCustomPasteType: [] }, } as any); }); @@ -330,7 +351,7 @@ describe('createContentModelEditorCore', () => { const core = createContentModelEditorCore(contentDiv, options); expect(createEditorCoreSpy).toHaveBeenCalledWith(contentDiv, { - plugins: [new ContentModelFormatPlugin(), new ContentModelEditPlugin()], + plugins: [mockedCachePlugin, mockedFormatPlugin, mockedEditPlugin], corePluginOverride: { typeInContainer: new ContentModelTypeInContainerPlugin(), copyPaste: copyPastePlugin, @@ -374,6 +395,8 @@ describe('createContentModelEditorCore', () => { contentDiv: { style: {}, }, + cache: {}, + copyPaste: { allowedCustomPasteType: [] }, } as any); }); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelEditPluginTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelEditPluginTest.ts index 10a9e296223..b1a482b0f6a 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelEditPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelEditPluginTest.ts @@ -1,30 +1,17 @@ -import * as formatWithContentModel from '../../../lib/publicApi/utils/formatWithContentModel'; -import * as handleKeyDownEvent from '../../../lib/publicApi/editing/handleKeyDownEvent'; -import * as pendingFormat from '../../../lib/modelApi/format/pendingFormat'; +import * as keyboardDelete from '../../../lib/publicApi/editing/keyboardDelete'; import ContentModelEditPlugin from '../../../lib/editor/plugins/ContentModelEditPlugin'; +import { EntityOperation, Keys, PluginEventType } from 'roosterjs-editor-types'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; -import { Position } from 'roosterjs-editor-dom'; -import { - EntityOperation, - Keys, - PluginEventType, - SelectionRangeTypes, -} from 'roosterjs-editor-types'; describe('ContentModelEditPlugin', () => { let editor: IContentModelEditor; - let cacheContentModel: jasmine.Spy; - let getContentModelDefaultFormat: jasmine.Spy; + let invalidateCache: jasmine.Spy; beforeEach(() => { - cacheContentModel = jasmine.createSpy('cacheContentModel'); - getContentModelDefaultFormat = jasmine - .createSpy('getContentModelDefaultFormat') - .and.returnValue({}); + invalidateCache = jasmine.createSpy('invalidateCache'); editor = ({ - cacheContentModel, - getContentModelDefaultFormat, + invalidateCache, getSelectionRangeEx: () => ({ type: -1, @@ -33,10 +20,10 @@ describe('ContentModelEditPlugin', () => { }); describe('onPluginEvent', () => { - let handleKeyDownEventSpy: jasmine.Spy; + let keyboardDeleteSpy: jasmine.Spy; beforeEach(() => { - handleKeyDownEventSpy = spyOn(handleKeyDownEvent, 'default'); + keyboardDeleteSpy = spyOn(keyboardDelete, 'default').and.returnValue(true); }); it('Backspace', () => { @@ -50,8 +37,8 @@ describe('ContentModelEditPlugin', () => { rawEvent, }); - expect(handleKeyDownEventSpy).toHaveBeenCalledWith(editor, rawEvent); - expect(cacheContentModel).not.toHaveBeenCalled(); + expect(keyboardDeleteSpy).toHaveBeenCalledWith(editor, rawEvent); + expect(invalidateCache).not.toHaveBeenCalled(); }); it('Delete', () => { @@ -65,8 +52,8 @@ describe('ContentModelEditPlugin', () => { rawEvent, }); - expect(handleKeyDownEventSpy).toHaveBeenCalledWith(editor, rawEvent); - expect(cacheContentModel).not.toHaveBeenCalled(); + expect(keyboardDeleteSpy).toHaveBeenCalledWith(editor, rawEvent); + expect(invalidateCache).not.toHaveBeenCalled(); }); it('Other key', () => { @@ -80,8 +67,8 @@ describe('ContentModelEditPlugin', () => { rawEvent, }); - expect(handleKeyDownEventSpy).not.toHaveBeenCalled(); - expect(cacheContentModel).toHaveBeenCalledWith(null); + expect(keyboardDeleteSpy).not.toHaveBeenCalled(); + expect(invalidateCache).not.toHaveBeenCalled(); }); it('Default prevented', () => { @@ -94,8 +81,8 @@ describe('ContentModelEditPlugin', () => { rawEvent, }); - expect(handleKeyDownEventSpy).not.toHaveBeenCalled(); - expect(cacheContentModel).toHaveBeenCalledWith(null); + expect(keyboardDeleteSpy).not.toHaveBeenCalled(); + expect(invalidateCache).toHaveBeenCalled(); }); it('Trigger entity event first', () => { @@ -118,7 +105,7 @@ describe('ContentModelEditPlugin', () => { rawEvent: { which: Keys.DELETE } as any, }); - expect(handleKeyDownEventSpy).toHaveBeenCalledWith(editor, { + expect(keyboardDeleteSpy).toHaveBeenCalledWith(editor, { which: Keys.DELETE, } as any); @@ -127,11 +114,11 @@ describe('ContentModelEditPlugin', () => { rawEvent: { which: Keys.DELETE } as any, }); - expect(handleKeyDownEventSpy).toHaveBeenCalledTimes(2); - expect(handleKeyDownEventSpy).toHaveBeenCalledWith(editor, { + expect(keyboardDeleteSpy).toHaveBeenCalledTimes(2); + expect(keyboardDeleteSpy).toHaveBeenCalledWith(editor, { which: Keys.DELETE, } as any); - expect(cacheContentModel).not.toHaveBeenCalled(); + expect(invalidateCache).not.toHaveBeenCalled(); }); it('SelectionChanged event should clear cached model', () => { @@ -143,439 +130,21 @@ describe('ContentModelEditPlugin', () => { selectionRangeEx: null!, }); - expect(cacheContentModel).toHaveBeenCalledWith(null); + expect(invalidateCache).not.toHaveBeenCalled(); }); - }); - - describe('onPluginEvent, no need to go through Content Model', () => { - let handleKeyDownEventSpy: jasmine.Spy; - let range: any; - - beforeEach(() => { - handleKeyDownEventSpy = spyOn(handleKeyDownEvent, 'default'); - range = { - collapsed: true, - startContainer: document.createTextNode('test'), - startOffset: 2, - }; - - editor.getSelectionRangeEx = () => - ({ - type: SelectionRangeTypes.Normal, - areAllCollapsed: true, - ranges: [range], - } as any); - }); - - it('Backspace', () => { + it('keyboardDelete returns false', () => { const plugin = new ContentModelEditPlugin(); - const rawEvent = { which: Keys.BACKSPACE } as any; - - plugin.initialize(editor); - - plugin.onPluginEvent({ - eventType: PluginEventType.KeyDown, - rawEvent, - }); - expect(handleKeyDownEventSpy).not.toHaveBeenCalled(); - expect(cacheContentModel).toHaveBeenCalledTimes(1); - expect(cacheContentModel).toHaveBeenCalledWith(null); - }); - - it('Delete', () => { - const plugin = new ContentModelEditPlugin(); - const rawEvent = { which: Keys.DELETE } as any; + keyboardDeleteSpy.and.returnValue(false); plugin.initialize(editor); - plugin.onPluginEvent({ - eventType: PluginEventType.KeyDown, - rawEvent, - }); - - expect(handleKeyDownEventSpy).not.toHaveBeenCalled(); - expect(cacheContentModel).toHaveBeenCalledTimes(1); - expect(cacheContentModel).toHaveBeenCalledWith(null); - }); - - it('Backspace from the beginning', () => { - const plugin = new ContentModelEditPlugin(); - const rawEvent = { which: Keys.BACKSPACE } as any; - - plugin.initialize(editor); - - range = { - collapsed: true, - startContainer: document.createTextNode('test'), - startOffset: 0, - }; - - plugin.onPluginEvent({ - eventType: PluginEventType.KeyDown, - rawEvent, - }); - - expect(handleKeyDownEventSpy).toHaveBeenCalledWith(editor, rawEvent); - expect(cacheContentModel).not.toHaveBeenCalled(); - }); - - it('Delete from the last', () => { - const plugin = new ContentModelEditPlugin(); - const rawEvent = { which: Keys.DELETE } as any; - - plugin.initialize(editor); - - range = { - collapsed: true, - startContainer: document.createTextNode('test'), - startOffset: 4, - }; - - plugin.onPluginEvent({ - eventType: PluginEventType.KeyDown, - rawEvent, + eventType: PluginEventType.SelectionChanged, + selectionRangeEx: null!, }); - expect(handleKeyDownEventSpy).toHaveBeenCalledWith(editor, rawEvent); - expect(cacheContentModel).not.toHaveBeenCalled(); - }); - }); -}); - -describe('ContentModelEditPlugin for default format', () => { - let editor: IContentModelEditor; - let contentDiv: HTMLDivElement; - let getSelectionRangeEx: jasmine.Spy; - let getPendingFormatSpy: jasmine.Spy; - let setPendingFormatSpy: jasmine.Spy; - let cacheContentModelSpy: jasmine.Spy; - let addUndoSnapshotSpy: jasmine.Spy; - - beforeEach(() => { - setPendingFormatSpy = spyOn(pendingFormat, 'setPendingFormat'); - getPendingFormatSpy = spyOn(pendingFormat, 'getPendingFormat'); - getSelectionRangeEx = jasmine.createSpy('getSelectionRangeEx'); - cacheContentModelSpy = jasmine.createSpy('cacheContentModel'); - addUndoSnapshotSpy = jasmine.createSpy('addUndoSnapshot'); - - contentDiv = document.createElement('div'); - - editor = ({ - contains: (e: Node) => contentDiv != e && contentDiv.contains(e), - getSelectionRangeEx, - getContentModelDefaultFormat: () => ({ - fontFamily: 'Arial', - }), - cacheContentModel: cacheContentModelSpy, - addUndoSnapshot: addUndoSnapshotSpy, - } as any) as IContentModelEditor; - }); - - it('Collapsed range, text input, under editor directly', () => { - const plugin = new ContentModelEditPlugin(); - const rawEvent = { key: 'a' } as any; - - getSelectionRangeEx.and.returnValue({ - type: SelectionRangeTypes.Normal, - ranges: [ - { - collapsed: true, - startContainer: contentDiv, - startOffset: 0, - }, - ], - }); - - spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( - (_1: any, _2: any, callback: Function) => { - callback({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - isImplicit: true, - segments: [ - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - ], - }, - ], - }); - } - ); - - plugin.initialize(editor); - - plugin.onPluginEvent({ - eventType: PluginEventType.KeyDown, - rawEvent, + expect(invalidateCache).not.toHaveBeenCalled(); }); - - expect(setPendingFormatSpy).toHaveBeenCalledWith( - editor, - { fontFamily: 'Arial' }, - new Position(contentDiv, 0) - ); - }); - - it('Expanded range, text input, under editor directly', () => { - const plugin = new ContentModelEditPlugin(); - const rawEvent = { key: 'a' } as any; - - getSelectionRangeEx.and.returnValue({ - type: SelectionRangeTypes.Normal, - ranges: [ - { - collapsed: false, - startContainer: contentDiv, - startOffset: 0, - }, - ], - }); - - spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( - (_1: any, _2: any, callback: Function) => { - callback({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - isImplicit: true, - segments: [ - { - segmentType: 'Text', - format: {}, - text: 'test', - isSelected: true, - }, - ], - }, - ], - }); - } - ); - - plugin.initialize(editor); - - plugin.onPluginEvent({ - eventType: PluginEventType.KeyDown, - rawEvent, - }); - - expect(setPendingFormatSpy).not.toHaveBeenCalled(); - expect(addUndoSnapshotSpy).toHaveBeenCalledTimes(1); - }); - - it('Collapsed range, IME input, under editor directly', () => { - const plugin = new ContentModelEditPlugin(); - const rawEvent = { key: 'Process' } as any; - - getSelectionRangeEx.and.returnValue({ - type: SelectionRangeTypes.Normal, - ranges: [ - { - collapsed: true, - startContainer: contentDiv, - startOffset: 0, - }, - ], - }); - - spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( - (_1: any, _2: any, callback: Function) => { - callback({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - isImplicit: true, - segments: [ - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - ], - }, - ], - }); - } - ); - - plugin.initialize(editor); - - plugin.onPluginEvent({ - eventType: PluginEventType.KeyDown, - rawEvent, - }); - - expect(setPendingFormatSpy).toHaveBeenCalledWith( - editor, - { fontFamily: 'Arial' }, - new Position(contentDiv, 0) - ); - }); - - it('Collapsed range, other input, under editor directly', () => { - const plugin = new ContentModelEditPlugin(); - const rawEvent = { key: 'Up' } as any; - - getSelectionRangeEx.and.returnValue({ - type: SelectionRangeTypes.Normal, - ranges: [ - { - collapsed: true, - startContainer: contentDiv, - startOffset: 0, - }, - ], - }); - - spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( - (_1: any, _2: any, callback: Function) => { - callback({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - isImplicit: true, - segments: [ - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - ], - }, - ], - }); - } - ); - - plugin.initialize(editor); - - plugin.onPluginEvent({ - eventType: PluginEventType.KeyDown, - rawEvent, - }); - - expect(setPendingFormatSpy).not.toHaveBeenCalled(); - }); - - it('Collapsed range, normal input, not under editor directly, no style', () => { - const plugin = new ContentModelEditPlugin(); - const rawEvent = { key: 'a' } as any; - const div = document.createElement('div'); - - contentDiv.appendChild(div); - - getSelectionRangeEx.and.returnValue({ - type: SelectionRangeTypes.Normal, - ranges: [ - { - collapsed: true, - startContainer: div, - startOffset: 0, - }, - ], - }); - - spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( - (_1: any, _2: any, callback: Function) => { - callback({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - ], - }, - ], - }); - } - ); - - plugin.initialize(editor); - - plugin.onPluginEvent({ - eventType: PluginEventType.KeyDown, - rawEvent, - }); - - expect(setPendingFormatSpy).toHaveBeenCalledWith( - editor, - { fontFamily: 'Arial' }, - new Position(div, 0) - ); - }); - - it('Collapsed range, text input, under editor directly, has pending format', () => { - const plugin = new ContentModelEditPlugin(); - const rawEvent = { key: 'a' } as any; - - getSelectionRangeEx.and.returnValue({ - type: SelectionRangeTypes.Normal, - ranges: [ - { - collapsed: true, - startContainer: contentDiv, - startOffset: 0, - }, - ], - }); - - spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( - (_1: any, _2: any, callback: Function) => { - callback({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - isImplicit: true, - segments: [ - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - ], - }, - ], - }); - } - ); - - getPendingFormatSpy.and.returnValue({ - fontSize: '10pt', - }); - - plugin.initialize(editor); - - plugin.onPluginEvent({ - eventType: PluginEventType.KeyDown, - rawEvent, - }); - - expect(setPendingFormatSpy).toHaveBeenCalledWith( - editor, - { fontFamily: 'Arial', fontSize: '10pt' }, - new Position(contentDiv, 0) - ); }); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelFormatPluginTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelFormatPluginTest.ts index a40cd0fb142..b56b989f1d8 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelFormatPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelFormatPluginTest.ts @@ -1,7 +1,9 @@ +import * as formatWithContentModel from '../../../lib/publicApi/utils/formatWithContentModel'; import * as pendingFormat from '../../../lib/modelApi/format/pendingFormat'; import ContentModelFormatPlugin from '../../../lib/editor/plugins/ContentModelFormatPlugin'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; -import { PluginEventType } from 'roosterjs-editor-types'; +import { PluginEventType, SelectionRangeTypes } from 'roosterjs-editor-types'; +import { Position } from 'roosterjs-editor-dom'; import { addSegment, createContentModelDocument, @@ -17,6 +19,7 @@ describe('ContentModelFormatPlugin', () => { const editor = ({ cacheContentModel: () => {}, isDarkMode: () => false, + getContentModelDefaultFormat: () => ({}), } as any) as IContentModelEditor; const plugin = new ContentModelFormatPlugin(); @@ -46,6 +49,7 @@ describe('ContentModelFormatPlugin', () => { setContentModel, isInIME: () => false, cacheContentModel: () => {}, + getContentModelDefaultFormat: () => ({}), } as any) as IContentModelEditor; const plugin = new ContentModelFormatPlugin(); const model = createContentModelDocument(); @@ -79,6 +83,7 @@ describe('ContentModelFormatPlugin', () => { createContentModel: () => model, setContentModel, cacheContentModel: () => {}, + getContentModelDefaultFormat: () => ({}), } as any) as IContentModelEditor; const plugin = new ContentModelFormatPlugin(); @@ -111,6 +116,7 @@ describe('ContentModelFormatPlugin', () => { setContentModel, isInIME: () => false, cacheContentModel: () => {}, + getContentModelDefaultFormat: () => ({}), } as any) as IContentModelEditor; const plugin = new ContentModelFormatPlugin(); @@ -151,6 +157,7 @@ describe('ContentModelFormatPlugin', () => { }, cacheContentModel: () => {}, isDarkMode: () => false, + getContentModelDefaultFormat: () => ({}), } as any) as IContentModelEditor; const plugin = new ContentModelFormatPlugin(); @@ -215,6 +222,7 @@ describe('ContentModelFormatPlugin', () => { }, cacheContentModel: () => {}, isDarkMode: () => false, + getContentModelDefaultFormat: () => ({}), } as any) as IContentModelEditor; const plugin = new ContentModelFormatPlugin(); @@ -274,6 +282,7 @@ describe('ContentModelFormatPlugin', () => { createContentModel: () => model, setContentModel, cacheContentModel: () => {}, + getContentModelDefaultFormat: () => ({}), } as any) as IContentModelEditor; const plugin = new ContentModelFormatPlugin(); @@ -306,6 +315,7 @@ describe('ContentModelFormatPlugin', () => { callback(); }, cacheContentModel: () => {}, + getContentModelDefaultFormat: () => ({}), } as any) as IContentModelEditor; const plugin = new ContentModelFormatPlugin(); @@ -336,6 +346,7 @@ describe('ContentModelFormatPlugin', () => { createContentModel: () => model, setContentModel, cacheContentModel: () => {}, + getContentModelDefaultFormat: () => ({}), } as any) as IContentModelEditor; const plugin = new ContentModelFormatPlugin(); @@ -366,6 +377,7 @@ describe('ContentModelFormatPlugin', () => { createContentModel: () => model, setContentModel, cacheContentModel: () => {}, + getContentModelDefaultFormat: () => ({}), } as any) as IContentModelEditor; const plugin = new ContentModelFormatPlugin(); @@ -381,3 +393,339 @@ describe('ContentModelFormatPlugin', () => { expect(pendingFormat.canApplyPendingFormat).toHaveBeenCalledTimes(1); }); }); + +describe('ContentModelFormatPlugin for default format', () => { + let editor: IContentModelEditor; + let contentDiv: HTMLDivElement; + let getSelectionRangeEx: jasmine.Spy; + let getPendingFormatSpy: jasmine.Spy; + let setPendingFormatSpy: jasmine.Spy; + let cacheContentModelSpy: jasmine.Spy; + let addUndoSnapshotSpy: jasmine.Spy; + + beforeEach(() => { + setPendingFormatSpy = spyOn(pendingFormat, 'setPendingFormat'); + getPendingFormatSpy = spyOn(pendingFormat, 'getPendingFormat'); + getSelectionRangeEx = jasmine.createSpy('getSelectionRangeEx'); + cacheContentModelSpy = jasmine.createSpy('cacheContentModel'); + addUndoSnapshotSpy = jasmine.createSpy('addUndoSnapshot'); + + contentDiv = document.createElement('div'); + + editor = ({ + contains: (e: Node) => contentDiv != e && contentDiv.contains(e), + getSelectionRangeEx, + getContentModelDefaultFormat: () => ({ + fontFamily: 'Arial', + }), + cacheContentModel: cacheContentModelSpy, + addUndoSnapshot: addUndoSnapshotSpy, + } as any) as IContentModelEditor; + }); + + it('Collapsed range, text input, under editor directly', () => { + const plugin = new ContentModelFormatPlugin(); + const rawEvent = { key: 'a' } as any; + + getSelectionRangeEx.and.returnValue({ + type: SelectionRangeTypes.Normal, + ranges: [ + { + collapsed: true, + startContainer: contentDiv, + startOffset: 0, + }, + ], + }); + + spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( + (_1: any, _2: any, callback: Function) => { + callback({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + isImplicit: true, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + }); + } + ); + + plugin.initialize(editor); + + plugin.onPluginEvent({ + eventType: PluginEventType.KeyDown, + rawEvent, + }); + + expect(setPendingFormatSpy).toHaveBeenCalledWith( + editor, + { fontFamily: 'Arial' }, + new Position(contentDiv, 0) + ); + }); + + it('Expanded range, text input, under editor directly', () => { + const plugin = new ContentModelFormatPlugin(); + const rawEvent = { key: 'a' } as any; + + getSelectionRangeEx.and.returnValue({ + type: SelectionRangeTypes.Normal, + ranges: [ + { + collapsed: false, + startContainer: contentDiv, + startOffset: 0, + }, + ], + }); + + spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( + (_1: any, _2: any, callback: Function) => { + callback({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + isImplicit: true, + segments: [ + { + segmentType: 'Text', + format: {}, + text: 'test', + isSelected: true, + }, + ], + }, + ], + }); + } + ); + + plugin.initialize(editor); + + plugin.onPluginEvent({ + eventType: PluginEventType.KeyDown, + rawEvent, + }); + + expect(setPendingFormatSpy).not.toHaveBeenCalled(); + expect(addUndoSnapshotSpy).toHaveBeenCalledTimes(1); + }); + + it('Collapsed range, IME input, under editor directly', () => { + const plugin = new ContentModelFormatPlugin(); + const rawEvent = { key: 'Process' } as any; + + getSelectionRangeEx.and.returnValue({ + type: SelectionRangeTypes.Normal, + ranges: [ + { + collapsed: true, + startContainer: contentDiv, + startOffset: 0, + }, + ], + }); + + spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( + (_1: any, _2: any, callback: Function) => { + callback({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + isImplicit: true, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + }); + } + ); + + plugin.initialize(editor); + + plugin.onPluginEvent({ + eventType: PluginEventType.KeyDown, + rawEvent, + }); + + expect(setPendingFormatSpy).toHaveBeenCalledWith( + editor, + { fontFamily: 'Arial' }, + new Position(contentDiv, 0) + ); + }); + + it('Collapsed range, other input, under editor directly', () => { + const plugin = new ContentModelFormatPlugin(); + const rawEvent = { key: 'Up' } as any; + + getSelectionRangeEx.and.returnValue({ + type: SelectionRangeTypes.Normal, + ranges: [ + { + collapsed: true, + startContainer: contentDiv, + startOffset: 0, + }, + ], + }); + + spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( + (_1: any, _2: any, callback: Function) => { + callback({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + isImplicit: true, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + }); + } + ); + + plugin.initialize(editor); + + plugin.onPluginEvent({ + eventType: PluginEventType.KeyDown, + rawEvent, + }); + + expect(setPendingFormatSpy).not.toHaveBeenCalled(); + }); + + it('Collapsed range, normal input, not under editor directly, no style', () => { + const plugin = new ContentModelFormatPlugin(); + const rawEvent = { key: 'a' } as any; + const div = document.createElement('div'); + + contentDiv.appendChild(div); + + getSelectionRangeEx.and.returnValue({ + type: SelectionRangeTypes.Normal, + ranges: [ + { + collapsed: true, + startContainer: div, + startOffset: 0, + }, + ], + }); + + spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( + (_1: any, _2: any, callback: Function) => { + callback({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + }); + } + ); + + plugin.initialize(editor); + + plugin.onPluginEvent({ + eventType: PluginEventType.KeyDown, + rawEvent, + }); + + expect(setPendingFormatSpy).toHaveBeenCalledWith( + editor, + { fontFamily: 'Arial' }, + new Position(div, 0) + ); + }); + + it('Collapsed range, text input, under editor directly, has pending format', () => { + const plugin = new ContentModelFormatPlugin(); + const rawEvent = { key: 'a' } as any; + + getSelectionRangeEx.and.returnValue({ + type: SelectionRangeTypes.Normal, + ranges: [ + { + collapsed: true, + startContainer: contentDiv, + startOffset: 0, + }, + ], + }); + + spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( + (_1: any, _2: any, callback: Function) => { + callback({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + isImplicit: true, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + }); + } + ); + + getPendingFormatSpy.and.returnValue({ + fontSize: '10pt', + }); + + plugin.initialize(editor); + + plugin.onPluginEvent({ + eventType: PluginEventType.KeyDown, + rawEvent, + }); + + expect(setPendingFormatSpy).toHaveBeenCalledWith( + editor, + { fontFamily: 'Arial', fontSize: '10pt' }, + new Position(contentDiv, 0) + ); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromExcelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromExcelTest.ts index 41931b35e92..c5db86d346e 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromExcelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromExcelTest.ts @@ -52,8 +52,6 @@ describe(ID, () => { paste(editor, clipboardData, false, false, true); - editor.cacheContentModel(null); - const model = editor.createContentModel({ processorOverride: { table: tableProcessor, diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromWacTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromWacTest.ts index 74e44490a06..cc5eddaedb3 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromWacTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromWacTest.ts @@ -79,7 +79,7 @@ describe(ID, () => { blockType: 'Table', rows: [ { - height: 0, + height: jasmine.anything() as any, format: {}, cells: [ { @@ -102,9 +102,6 @@ describe(ID, () => { fontFamily: 'Aptos, Aptos_EmbeddedFont, Aptos_MSFontService, sans-serif', fontSize: '11pt', - italic: false, - fontWeight: - 'normal', textColor: 'rgb(0, 0, 0)', lineHeight: @@ -121,9 +118,6 @@ describe(ID, () => { marginTop: '0px', marginBottom: '0px', }, - segmentFormat: { - fontWeight: 'normal', - }, decorator: { tagName: 'p', format: {}, @@ -183,9 +177,6 @@ describe(ID, () => { fontFamily: 'Aptos, Aptos_EmbeddedFont, Aptos_MSFontService, sans-serif', fontSize: '11pt', - italic: false, - fontWeight: - 'normal', textColor: 'rgb(0, 0, 0)', lineHeight: @@ -202,9 +193,6 @@ describe(ID, () => { marginTop: '0px', marginBottom: '0px', }, - segmentFormat: { - fontWeight: 'normal', - }, decorator: { tagName: 'p', format: {}, @@ -248,6 +236,7 @@ describe(ID, () => { }, ], format: { + useBorderBox: true, direction: 'ltr', textAlign: 'start', whiteSpace: 'normal', @@ -260,7 +249,7 @@ describe(ID, () => { tableLayout: 'fixed', borderCollapse: true, }, - widths: [], + widths: [jasmine.anything() as any, jasmine.anything() as any], dataset: { tablestyle: 'MsoTableGrid', tablelook: '1696', @@ -268,9 +257,6 @@ describe(ID, () => { }, ], format: { - direction: 'ltr', - textAlign: 'start', - whiteSpace: 'normal', marginTop: '2px', marginRight: '0px', marginBottom: '2px', @@ -278,9 +264,6 @@ describe(ID, () => { }, ], format: { - direction: 'ltr', - textAlign: 'start', - whiteSpace: 'normal', backgroundColor: 'rgb(255, 255, 255)', marginTop: '0px', marginRight: '0px', @@ -304,8 +287,6 @@ describe(ID, () => { fontFamily: 'Aptos, Aptos_EmbeddedFont, Aptos_MSFontService, sans-serif', fontSize: '12pt', - italic: false, - fontWeight: 'normal', textColor: 'rgb(0, 0, 0)', lineHeight: '20.925px', }, @@ -320,9 +301,6 @@ describe(ID, () => { marginBottom: '0px', marginLeft: '0px', }, - segmentFormat: { - fontWeight: 'normal', - }, decorator: { tagName: 'p', format: {}, @@ -330,9 +308,6 @@ describe(ID, () => { }, ], format: { - direction: 'ltr', - textAlign: 'start', - whiteSpace: 'normal', backgroundColor: 'rgb(255, 255, 255)', marginTop: '0px', marginRight: '0px', diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromWordTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromWordTest.ts index f95dee1e6ec..7a44986901e 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromWordTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromWordTest.ts @@ -67,7 +67,7 @@ describe(ID, () => { ], segmentFormat: undefined, blockType: 'Paragraph', - format: {}, + format: { marginTop: '0px', marginBottom: '0px' }, decorator: { tagName: 'p', format: {} }, }, { @@ -83,13 +83,12 @@ describe(ID, () => { { isSelected: true, segmentType: 'SelectionMarker', - format: {}, + format: { fontFamily: 'Calibri, sans-serif', fontSize: '11pt' }, }, ], segmentFormat: undefined, blockType: 'Paragraph', format: { - lineHeight: undefined, marginTop: '0in', marginRight: '0in', marginBottom: '8pt', @@ -127,10 +126,10 @@ describe(ID, () => { blockGroupType: 'Document', blocks: [ { - widths: [], + widths: [jasmine.anything() as any, jasmine.anything() as any], rows: [ { - height: 0, + height: jasmine.anything() as any, cells: [ { spanAbove: false, @@ -202,7 +201,6 @@ describe(ID, () => { borderTop: '1pt solid', borderRight: '1pt solid', borderBottom: '1pt solid', - borderLeft: '', paddingTop: '0in', paddingRight: '5.4pt', paddingBottom: '0in', @@ -218,11 +216,8 @@ describe(ID, () => { ], blockType: 'Table', format: { - borderTop: '', - borderRight: '', - borderBottom: '', - borderLeft: '', borderCollapse: true, + useBorderBox: true, }, dataset: {}, }, diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/utils/handleKeyboardEventCommonTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/utils/handleKeyboardEventCommonTest.ts index faa405fd541..c98997eef24 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/utils/handleKeyboardEventCommonTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/utils/handleKeyboardEventCommonTest.ts @@ -77,7 +77,7 @@ describe('handleKeyboardEventResult', () => { expect(preventDefault).not.toHaveBeenCalled(); expect(triggerContentChangedEvent).not.toHaveBeenCalled(); expect(normalizeContentModel.normalizeContentModel).not.toHaveBeenCalled(); - expect(cacheContentModel).toHaveBeenCalledWith(null); + expect(cacheContentModel).not.toHaveBeenCalledWith(null); expect(triggerPluginEvent).not.toHaveBeenCalled(); expect(context.skipUndoSnapshot).toBeTrue(); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/editingTestCommon.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/editingTestCommon.ts index be7642aab81..3ae6329b1b8 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/editingTestCommon.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/editingTestCommon.ts @@ -36,7 +36,7 @@ export function editingTestCommon( setContentModel, triggerPluginEvent, isDisposed: () => false, - getFocusedPosition: () => null as NodePosition, + getFocusedPosition: () => null! as NodePosition, triggerContentChangedEvent, isDarkMode: () => false, } as any) as IContentModelEditor; diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/handleKeyDownEventTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/keyboardDeleteTest.ts similarity index 74% rename from packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/handleKeyDownEventTest.ts rename to packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/keyboardDeleteTest.ts index 9e54e3e3a24..f94c51cbdb5 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/handleKeyDownEventTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/keyboardDeleteTest.ts @@ -1,8 +1,8 @@ import * as deleteSelection from '../../../lib/modelApi/edit/deleteSelection'; import * as formatWithContentModel from '../../../lib/publicApi/utils/formatWithContentModel'; import * as handleKeyboardEventResult from '../../../lib/editor/utils/handleKeyboardEventCommon'; -import handleKeyDownEvent from '../../../lib/publicApi/editing/handleKeyDownEvent'; -import { ChangeSource, Keys } from 'roosterjs-editor-types'; +import keyboardDelete from '../../../lib/publicApi/editing/keyboardDelete'; +import { ChangeSource, Keys, SelectionRangeEx, SelectionRangeTypes } from 'roosterjs-editor-types'; import { ContentModelDocument } from 'roosterjs-content-model-types'; import { deleteAllSegmentBefore } from '../../../lib/modelApi/edit/deleteSteps/deleteAllSegmentBefore'; import { editingTestCommon } from './editingTestCommon'; @@ -20,7 +20,7 @@ import { forwardDeleteCollapsedSelection, } from '../../../lib/modelApi/edit/deleteSteps/deleteCollapsedSelection'; -describe('handleKeyDownEvent', () => { +describe('keyboardDelete', () => { let deleteSelectionSpy: jasmine.Spy; beforeEach(() => { @@ -51,7 +51,18 @@ describe('handleKeyDownEvent', () => { 'handleBackspaceKey', newEditor => { editor = newEditor; - handleKeyDownEvent(editor, mockedEvent); + + editor.getSelectionRangeEx = () => ({ + type: SelectionRangeTypes.Normal, + ranges: [ + { + collapsed: false, + }, + ], + }); + const result = keyboardDelete(editor, mockedEvent); + + expect(result).toBeTrue(); }, input, expectedResult, @@ -366,13 +377,17 @@ describe('handleKeyDownEvent', () => { const editor = ({ addUndoSnapshot, + getSelectionRangeEx: () => ({ + type: SelectionRangeTypes.Normal, + ranges: [{ collapsed: false }], + }), } as any) as IContentModelEditor; const which = Keys.DELETE; const event = { which, } as any; - handleKeyDownEvent(editor, event); + keyboardDelete(editor, event); expect(spy.calls.argsFor(0)[0]).toBe(editor); expect(spy.calls.argsFor(0)[1]).toBe('handleDeleteKey'); @@ -385,18 +400,121 @@ describe('handleKeyDownEvent', () => { const spy = spyOn(formatWithContentModel, 'formatWithContentModel'); const preventDefault = jasmine.createSpy('preventDefault'); - const editor = 'EDITOR' as any; + const editor = { + getSelectionRangeEx: () => ({ + type: SelectionRangeTypes.Normal, + ranges: [{ collapsed: false }], + }), + } as any; const which = Keys.BACKSPACE; const event = { which, preventDefault, } as any; - handleKeyDownEvent(editor, event); + keyboardDelete(editor, event); expect(spy.calls.argsFor(0)[0]).toBe(editor); expect(spy.calls.argsFor(0)[1]).toBe('handleBackspaceKey'); expect(spy.calls.argsFor(0)[3]?.changeSource).toBe(ChangeSource.Keyboard); expect(spy.calls.argsFor(0)[3]?.getChangeData?.()).toBe(which); }); + + it('No need to delete - Backspace', () => { + const rawEvent = { which: Keys.BACKSPACE } as any; + const range: SelectionRangeEx = { + type: SelectionRangeTypes.Normal, + ranges: [ + ({ + collapsed: true, + startContainer: document.createTextNode('test'), + startOffset: 2, + } as any) as Range, + ], + areAllCollapsed: true, + }; + const editor = { + getSelectionRangeEx: () => range, + } as any; + const formatWithContentModelSpy = spyOn(formatWithContentModel, 'formatWithContentModel'); + + const result = keyboardDelete(editor, rawEvent); + + expect(result).toBeFalse(); + expect(formatWithContentModelSpy).not.toHaveBeenCalled(); + }); + + it('No need to delete - Delete', () => { + const rawEvent = { which: Keys.DELETE } as any; + const range: SelectionRangeEx = { + type: SelectionRangeTypes.Normal, + ranges: [ + ({ + collapsed: true, + startContainer: document.createTextNode('test'), + startOffset: 2, + } as any) as Range, + ], + areAllCollapsed: true, + }; + const editor = { + getSelectionRangeEx: () => range, + } as any; + const formatWithContentModelSpy = spyOn(formatWithContentModel, 'formatWithContentModel'); + + const result = keyboardDelete(editor, rawEvent); + + expect(result).toBeFalse(); + expect(formatWithContentModelSpy).not.toHaveBeenCalled(); + }); + + it('Backspace from the beginning', () => { + const rawEvent = { which: Keys.BACKSPACE } as any; + const range: SelectionRangeEx = { + type: SelectionRangeTypes.Normal, + ranges: [ + ({ + collapsed: true, + startContainer: document.createTextNode('test'), + startOffset: 0, + } as any) as Range, + ], + areAllCollapsed: true, + }; + + const editor = { + getSelectionRangeEx: () => range, + } as any; + const formatWithContentModelSpy = spyOn(formatWithContentModel, 'formatWithContentModel'); + + const result = keyboardDelete(editor, rawEvent); + + expect(result).toBeTrue(); + expect(formatWithContentModelSpy).toHaveBeenCalledTimes(1); + }); + + it('Delete from the last', () => { + const rawEvent = { which: Keys.DELETE } as any; + const range: SelectionRangeEx = { + type: SelectionRangeTypes.Normal, + ranges: [ + ({ + collapsed: true, + startContainer: document.createTextNode('test'), + startOffset: 4, + } as any) as Range, + ], + areAllCollapsed: true, + }; + + const editor = { + getSelectionRangeEx: () => range, + } as any; + const formatWithContentModelSpy = spyOn(formatWithContentModel, 'formatWithContentModel'); + + const result = keyboardDelete(editor, rawEvent); + + expect(result).toBeTrue(); + expect(formatWithContentModelSpy).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts index 9ed35c2eaef..7d7e5bda988 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts @@ -582,6 +582,7 @@ describe('Paste with clipboardData', () => { }); afterEach(() => { + editor.dispose(); document.getElementById(ID)?.remove(); }); @@ -614,7 +615,10 @@ describe('Paste with clipboardData', () => { format: {}, }, ], - format: {}, + format: { + marginTop: '0px', + marginBottom: '0px', + }, decorator: { tagName: 'p', format: {}, @@ -690,6 +694,13 @@ describe('Paste with clipboardData', () => { isSelected: true, segmentType: 'SelectionMarker', format: {}, + link: { + format: { + underline: true, + href: 'https://github.com/microsoft/roosterjs', + }, + dataset: {}, + }, }, ], blockType: 'Paragraph', From 5cef7bad860a21fc1c3b3cd2841affd8e4c9c472 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Tue, 19 Sep 2023 14:50:14 -0700 Subject: [PATCH 59/75] Content Model: pass out segment nodes (#2079) --- .../context/defaultContentModelHandlers.ts | 10 +- .../lib/modelToDom/handlers/handleBlock.ts | 4 +- .../lib/modelToDom/handlers/handleBr.ts | 19 ++-- .../lib/modelToDom/handlers/handleEntity.ts | 91 +++++++++++-------- .../modelToDom/handlers/handleGeneralModel.ts | 43 ++++++--- .../lib/modelToDom/handlers/handleImage.ts | 19 ++-- .../modelToDom/handlers/handleParagraph.ts | 6 +- .../lib/modelToDom/handlers/handleSegment.ts | 27 +++--- .../handlers/handleSegmentDecorator.ts | 11 ++- .../lib/modelToDom/handlers/handleText.ts | 19 ++-- .../modelToDom/utils/handleSegmentCommon.ts | 6 +- .../modelToDom/handlers/handleBlockTest.ts | 12 +-- .../test/modelToDom/handlers/handleBrTest.ts | 41 ++++++++- .../modelToDom/handlers/handleEntityTest.ts | 85 ++++++++++++++--- .../handlers/handleGeneralModelTest.ts | 50 ++++++++-- .../modelToDom/handlers/handleImageTest.ts | 25 ++++- .../handlers/handleParagraphTest.ts | 15 +-- .../handlers/handleSegmentDecoratorTest.ts | 53 ++++++++--- .../modelToDom/handlers/handleSegmentTest.ts | 75 +++++++++++---- .../modelToDom/handlers/handleTextTest.ts | 29 ++++-- .../utils/handleSegmentCommonTest.ts | 17 +++- .../lib/context/ContentModelHandler.ts | 18 ++++ .../lib/context/ModelToDomSettings.ts | 31 +++++-- .../lib/index.ts | 6 +- 24 files changed, 508 insertions(+), 204 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/context/defaultContentModelHandlers.ts b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/context/defaultContentModelHandlers.ts index d05cf307ebd..b43cf1c7af3 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/context/defaultContentModelHandlers.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/context/defaultContentModelHandlers.ts @@ -3,9 +3,9 @@ import { handleBlock } from '../handlers/handleBlock'; import { handleBlockGroupChildren } from '../handlers/handleBlockGroupChildren'; import { handleBr } from '../handlers/handleBr'; import { handleDivider } from '../handlers/handleDivider'; -import { handleEntity } from '../handlers/handleEntity'; +import { handleEntityBlock, handleEntitySegment } from '../handlers/handleEntity'; import { handleFormatContainer } from '../handlers/handleFormatContainer'; -import { handleGeneralModel } from '../handlers/handleGeneralModel'; +import { handleGeneralBlock, handleGeneralSegment } from '../handlers/handleGeneralModel'; import { handleImage } from '../handlers/handleImage'; import { handleList } from '../handlers/handleList'; import { handleListItem } from '../handlers/handleListItem'; @@ -22,8 +22,10 @@ export const defaultContentModelHandlers: ContentModelHandlerMap = { block: handleBlock, blockGroupChildren: handleBlockGroupChildren, br: handleBr, - entity: handleEntity, - general: handleGeneralModel, + entityBlock: handleEntityBlock, + entitySegment: handleEntitySegment, + generalBlock: handleGeneralBlock, + generalSegment: handleGeneralSegment, divider: handleDivider, image: handleImage, list: handleList, diff --git a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleBlock.ts b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleBlock.ts index 10a3760a00e..b45e9bb05cb 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleBlock.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleBlock.ts @@ -24,7 +24,7 @@ export const handleBlock: ContentModelBlockHandler = ( refNode = handlers.paragraph(doc, parent, block, context, refNode); break; case 'Entity': - refNode = handlers.entity(doc, parent, block, context, refNode); + refNode = handlers.entityBlock(doc, parent, block, context, refNode); break; case 'Divider': refNode = handlers.divider(doc, parent, block, context, refNode); @@ -32,7 +32,7 @@ export const handleBlock: ContentModelBlockHandler = ( case 'BlockGroup': switch (block.blockGroupType) { case 'General': - refNode = handlers.general(doc, parent, block, context, refNode); + refNode = handlers.generalBlock(doc, parent, block, context, refNode); break; case 'FormatContainer': diff --git a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleBr.ts b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleBr.ts index 0eb370d1360..e3decb24b96 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleBr.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleBr.ts @@ -1,23 +1,20 @@ +import { ContentModelBr, ContentModelSegmentHandler } from 'roosterjs-content-model-types'; import { handleSegmentCommon } from '../utils/handleSegmentCommon'; -import { - ContentModelBr, - ContentModelHandler, - ModelToDomContext, -} from 'roosterjs-content-model-types'; /** * @internal */ -export const handleBr: ContentModelHandler = ( - doc: Document, - parent: Node, - segment: ContentModelBr, - context: ModelToDomContext +export const handleBr: ContentModelSegmentHandler = ( + doc, + parent, + segment, + context, + segmentNodes ) => { const br = doc.createElement('br'); const element = doc.createElement('span'); element.appendChild(br); parent.appendChild(element); - handleSegmentCommon(doc, br, element, segment, context); + handleSegmentCommon(doc, br, element, segment, context, segmentNodes); }; diff --git a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleEntity.ts b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleEntity.ts index 7d3c7c8e810..0b48eaf731a 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleEntity.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleEntity.ts @@ -1,31 +1,68 @@ +import { addDelimiters, commitEntity, getObjectKeys, wrap } from 'roosterjs-editor-dom'; import { applyFormat } from '../utils/applyFormat'; import { Entity } from 'roosterjs-editor-types'; import { reuseCachedElement } from '../utils/reuseCachedElement'; import { ContentModelBlockHandler, ContentModelEntity, + ContentModelSegmentHandler, ModelToDomContext, } from 'roosterjs-content-model-types'; -import { - addDelimiters, - commitEntity, - getObjectKeys, - isBlockElement, - wrap, -} from 'roosterjs-editor-dom'; /** * @internal */ -export const handleEntity: ContentModelBlockHandler = ( - doc: Document, - parent: Node, - entityModel: ContentModelEntity, - context: ModelToDomContext, - refNode: Node | null +export const handleEntityBlock: ContentModelBlockHandler = ( + _, + parent, + entityModel, + context, + refNode +) => { + const wrapper = preprocessEntity(entityModel, context); + + refNode = reuseCachedElement(parent, wrapper, refNode); + context.onNodeCreated?.(entityModel, wrapper); + + return refNode; +}; + +/** + * @internal + */ +export const handleEntitySegment: ContentModelSegmentHandler = ( + _, + parent, + entityModel, + context, + newSegments ) => { - const { id, type, isReadonly, format } = entityModel; - let wrapper = entityModel.wrapper; + const wrapper = preprocessEntity(entityModel, context); + const { format, isReadonly } = entityModel; + + parent.appendChild(wrapper); + newSegments?.push(wrapper); + + if (getObjectKeys(format).length > 0) { + const span = wrap(wrapper, 'span'); + + applyFormat(span, context.formatAppliers.segment, format, context); + } + + if (context.addDelimiterForEntity && isReadonly) { + const [after, before] = addDelimiters(wrapper); + + newSegments?.push(after, before); + context.regularSelection.current.segment = after; + } else { + context.regularSelection.current.segment = wrapper; + } + + context.onNodeCreated?.(entityModel, wrapper); +}; + +function preprocessEntity(entityModel: ContentModelEntity, context: ModelToDomContext) { + let { id, type, isReadonly, wrapper } = entityModel; if (!context.allowCacheElement) { wrapper = wrapper.cloneNode(true /*deep*/) as HTMLElement; @@ -42,30 +79,10 @@ export const handleEntity: ContentModelBlockHandler = ( isReadonly: !!isReadonly, } : null; - const isInlineEntity = !isBlockElement(wrapper); if (entity) { // Commit the entity attributes in case there is any change commitEntity(wrapper, entity.type, entity.isReadonly, entity.id); } - - refNode = reuseCachedElement(parent, wrapper, refNode); - - if (isInlineEntity && getObjectKeys(format).length > 0) { - const span = wrap(wrapper, 'span'); - - applyFormat(span, context.formatAppliers.segment, format, context); - } - - if (context.addDelimiterForEntity && isInlineEntity && isReadonly) { - const [after] = addDelimiters(wrapper); - - context.regularSelection.current.segment = after; - } else if (isInlineEntity) { - context.regularSelection.current.segment = wrapper; - } - - context.onNodeCreated?.(entityModel, wrapper); - - return refNode; -}; + return wrapper; +} diff --git a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleGeneralModel.ts b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleGeneralModel.ts index 7fb1c37aa97..30f04d2200d 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleGeneralModel.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleGeneralModel.ts @@ -1,5 +1,4 @@ import { handleSegmentCommon } from '../utils/handleSegmentCommon'; -import { isGeneralSegment } from '../../modelApi/common/isGeneralSegment'; import { isNodeOfType } from '../../domUtils/isNodeOfType'; import { NodeType } from 'roosterjs-editor-types'; import { reuseCachedElement } from '../utils/reuseCachedElement'; @@ -7,18 +6,19 @@ import { wrap } from 'roosterjs-editor-dom'; import { ContentModelBlockHandler, ContentModelGeneralBlock, - ModelToDomContext, + ContentModelGeneralSegment, + ContentModelSegmentHandler, } from 'roosterjs-content-model-types'; /** * @internal */ -export const handleGeneralModel: ContentModelBlockHandler = ( - doc: Document, - parent: Node, - group: ContentModelGeneralBlock, - context: ModelToDomContext, - refNode: Node | null +export const handleGeneralBlock: ContentModelBlockHandler = ( + doc, + parent, + group, + context, + refNode ) => { let node: Node = group.element; @@ -31,15 +31,32 @@ export const handleGeneralModel: ContentModelBlockHandler = ( + doc, + parent, + group, + context, + segmentNodes +) => { + const node = group.element.cloneNode() as HTMLElement; + group.element = node; + parent.appendChild(node); + + if (isNodeOfType(node, NodeType.Element)) { const element = wrap(node, 'span'); - handleSegmentCommon(doc, node, element, group, context); - } else { + handleSegmentCommon(doc, node, element, group, context, segmentNodes); context.onNodeCreated?.(group, node); } context.modelHandlers.blockGroupChildren(doc, node, group, context); - - return refNode; }; diff --git a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleImage.ts b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleImage.ts index 342b81d3013..70c7f593c83 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleImage.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleImage.ts @@ -1,20 +1,17 @@ import { applyFormat } from '../utils/applyFormat'; +import { ContentModelImage, ContentModelSegmentHandler } from 'roosterjs-content-model-types'; import { handleSegmentCommon } from '../utils/handleSegmentCommon'; import { parseValueWithUnit } from '../../formatHandlers/utils/parseValueWithUnit'; -import { - ContentModelHandler, - ContentModelImage, - ModelToDomContext, -} from 'roosterjs-content-model-types'; /** * @internal */ -export const handleImage: ContentModelHandler = ( - doc: Document, - parent: Node, - imageModel: ContentModelImage, - context: ModelToDomContext +export const handleImage: ContentModelSegmentHandler = ( + doc, + parent, + imageModel, + context, + segmentNodes ) => { const img = doc.createElement('img'); const element = document.createElement('span'); @@ -53,5 +50,5 @@ export const handleImage: ContentModelHandler = ( }; } - handleSegmentCommon(doc, img, element, imageModel, context); + handleSegmentCommon(doc, img, element, imageModel, context, segmentNodes); }; diff --git a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleParagraph.ts b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleParagraph.ts index 2b3fbc13ed2..eca8d501e7c 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleParagraph.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleParagraph.ts @@ -66,12 +66,14 @@ export const handleParagraph: ContentModelBlockHandler = segmentType: 'Text', text: '', }, - context + context, + [] ); } paragraph.segments.forEach(segment => { - context.modelHandlers.segment(doc, parent, segment, context); + const newSegments: Node[] = []; + context.modelHandlers.segment(doc, parent, segment, context, newSegments); }); } }; diff --git a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleSegment.ts b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleSegment.ts index 83f96817f09..4376dd6f750 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleSegment.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleSegment.ts @@ -1,17 +1,14 @@ -import { - ContentModelHandler, - ContentModelSegment, - ModelToDomContext, -} from 'roosterjs-content-model-types'; +import { ContentModelSegment, ContentModelSegmentHandler } from 'roosterjs-content-model-types'; /** * @internal */ -export const handleSegment: ContentModelHandler = ( - doc: Document, - parent: Node, - segment: ContentModelSegment, - context: ModelToDomContext +export const handleSegment: ContentModelSegmentHandler = ( + doc, + parent, + segment, + context, + segmentNodes ) => { const regularSelection = context.regularSelection; @@ -24,23 +21,23 @@ export const handleSegment: ContentModelHandler = ( switch (segment.segmentType) { case 'Text': - context.modelHandlers.text(doc, parent, segment, context); + context.modelHandlers.text(doc, parent, segment, context, segmentNodes); break; case 'Br': - context.modelHandlers.br(doc, parent, segment, context); + context.modelHandlers.br(doc, parent, segment, context, segmentNodes); break; case 'Image': - context.modelHandlers.image(doc, parent, segment, context); + context.modelHandlers.image(doc, parent, segment, context, segmentNodes); break; case 'General': - context.modelHandlers.general(doc, parent, segment, context, null /*refNode*/); + context.modelHandlers.generalSegment(doc, parent, segment, context, segmentNodes); break; case 'Entity': - context.modelHandlers.entity(doc, parent, segment, context, null /*refNode*/); + context.modelHandlers.entitySegment(doc, parent, segment, context, segmentNodes); break; } diff --git a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleSegmentDecorator.ts b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleSegmentDecorator.ts index 55bd39fd691..933b8b6eac1 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleSegmentDecorator.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleSegmentDecorator.ts @@ -1,16 +1,17 @@ import { applyFormat } from '../utils/applyFormat'; -import { ContentModelHandler, ContentModelSegment } from 'roosterjs-content-model-types'; +import { ContentModelSegment, ContentModelSegmentHandler } from 'roosterjs-content-model-types'; import { moveChildNodes } from 'roosterjs-editor-dom'; import { stackFormat } from '../utils/stackFormat'; /** * @internal */ -export const handleSegmentDecorator: ContentModelHandler = ( - doc, +export const handleSegmentDecorator: ContentModelSegmentHandler = ( + _, parent, segment, - context + context, + segmentNodes ) => { const { code, link } = segment; @@ -24,6 +25,7 @@ export const handleSegmentDecorator: ContentModelHandler = applyFormat(a, context.formatAppliers.link, link.format, context); applyFormat(a, context.formatAppliers.dataset, link.dataset, context); + segmentNodes?.push(a); context.onNodeCreated?.(link, a); }); } @@ -37,6 +39,7 @@ export const handleSegmentDecorator: ContentModelHandler = applyFormat(codeNode, context.formatAppliers.code, code.format, context); + segmentNodes?.push(codeNode); context.onNodeCreated?.(code, codeNode); }); } diff --git a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleText.ts b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleText.ts index c54458cccfd..5974db1cb0a 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleText.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleText.ts @@ -1,18 +1,15 @@ +import { ContentModelSegmentHandler, ContentModelText } from 'roosterjs-content-model-types'; import { handleSegmentCommon } from '../utils/handleSegmentCommon'; -import { - ContentModelHandler, - ContentModelText, - ModelToDomContext, -} from 'roosterjs-content-model-types'; /** * @internal */ -export const handleText: ContentModelHandler = ( - doc: Document, - parent: Node, - segment: ContentModelText, - context: ModelToDomContext +export const handleText: ContentModelSegmentHandler = ( + doc, + parent, + segment, + context, + segmentNodes ) => { const txt = doc.createTextNode(segment.text); const element = doc.createElement('span'); @@ -20,5 +17,5 @@ export const handleText: ContentModelHandler = ( parent.appendChild(element); element.appendChild(txt); - handleSegmentCommon(doc, txt, element, segment, context); + handleSegmentCommon(doc, txt, element, segment, context, segmentNodes); }; diff --git a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/utils/handleSegmentCommon.ts b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/utils/handleSegmentCommon.ts index 921804d0fda..46d5ad698bd 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/utils/handleSegmentCommon.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/utils/handleSegmentCommon.ts @@ -9,7 +9,8 @@ export function handleSegmentCommon( segmentNode: Node, containerNode: HTMLElement, segment: ContentModelSegment, - context: ModelToDomContext + context: ModelToDomContext, + segmentNodes: Node[] ) { if (!segmentNode.firstChild) { context.regularSelection.current.segment = segmentNode; @@ -17,7 +18,8 @@ export function handleSegmentCommon( applyFormat(containerNode, context.formatAppliers.styleBasedSegment, segment.format, context); - context.modelHandlers.segmentDecorator(doc, containerNode, segment, context); + segmentNodes?.push(segmentNode); + context.modelHandlers.segmentDecorator(doc, containerNode, segment, context, segmentNodes); applyFormat(containerNode, context.formatAppliers.elementBasedSegment, segment.format, context); diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleBlockTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleBlockTest.ts index eea6ccd1fc3..6f04077a554 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleBlockTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleBlockTest.ts @@ -15,8 +15,8 @@ import { ContentModelListItem, ContentModelParagraph, ContentModelBlockHandler, - ContentModelHandler, ModelToDomContext, + ContentModelHandler, } from 'roosterjs-content-model-types'; describe('handleBlock', () => { @@ -33,7 +33,7 @@ describe('handleBlock', () => { context = createModelToDomContext(undefined, { modelHandlerOverride: { - entity: handleEntity, + entityBlock: handleEntity, paragraph: handleParagraph, divider: handleDivider, }, @@ -128,12 +128,12 @@ describe('handleBlock', () => { spyOn(applyFormat, 'applyFormat'); handleBlock(document, parent, block, context, null); - expect(parent.innerHTML).toBe(''); + expect(parent.innerHTML).toBe(''); expect(parent.firstChild).not.toBe(element); expect(context.regularSelection.current.segment).toBe(parent.firstChild!.firstChild); - expect(applyFormat.applyFormat).toHaveBeenCalled(); + expect(applyFormat.applyFormat).not.toHaveBeenCalled(); - runTestWithRefNode(block, '
'); + runTestWithRefNode(block, '
'); }); it('Entity block', () => { @@ -193,7 +193,7 @@ describe('handleBlockGroup', () => { blockGroupChildren: handleBlockGroupChildren, listItem: handleListItem, formatContainer: handleQuote, - general: handleGeneralModel, + generalBlock: handleGeneralModel, }, }); parent = document.createElement('div'); diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleBrTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleBrTest.ts index 7119dfe6c03..9f3dfbc414f 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleBrTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleBrTest.ts @@ -19,7 +19,7 @@ describe('handleSegment', () => { format: {}, }; - handleBr(document, parent, br, context); + handleBr(document, parent, br, context, []); expect(parent.innerHTML).toBe('
'); }); @@ -30,7 +30,7 @@ describe('handleSegment', () => { format: { textColor: 'red' }, }; - handleBr(document, parent, br, context); + handleBr(document, parent, br, context, []); expect(parent.innerHTML).toBe('
'); }); @@ -43,10 +43,45 @@ describe('handleSegment', () => { const onNodeCreated = jasmine.createSpy('onNodeCreated'); context.onNodeCreated = onNodeCreated; - handleBr(document, parent, br, context); + handleBr(document, parent, br, context, []); expect(parent.innerHTML).toBe('
'); expect(onNodeCreated.calls.argsFor(0)[0]).toBe(br); expect(onNodeCreated.calls.argsFor(0)[1]).toBe(parent.querySelector('br')); }); + + it('With segmentNodes', () => { + const br: ContentModelBr = { + segmentType: 'Br', + format: {}, + }; + const newSegments: Node[] = []; + + handleBr(document, parent, br, context, newSegments); + + expect(parent.innerHTML).toBe('
'); + expect(newSegments.length).toBe(1); + expect((newSegments[0] as HTMLElement).outerHTML).toBe('
'); + }); + + it('With segmentNodes and decorator', () => { + const br: ContentModelBr = { + segmentType: 'Br', + format: {}, + link: { + dataset: {}, + format: { + href: '/test', + }, + }, + }; + const newSegments: Node[] = []; + + handleBr(document, parent, br, context, newSegments); + + expect(parent.innerHTML).toBe('
'); + expect(newSegments.length).toBe(2); + expect((newSegments[0] as HTMLElement).outerHTML).toBe('
'); + expect((newSegments[1] as HTMLElement).outerHTML).toBe('
'); + }); }); diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleEntityTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleEntityTest.ts index 3347d504c2f..b6352985ff7 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleEntityTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleEntityTest.ts @@ -1,7 +1,10 @@ import * as addDelimiters from 'roosterjs-editor-dom/lib/delimiter/addDelimiters'; import { ContentModelEntity, ModelToDomContext } from 'roosterjs-content-model-types'; import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext'; -import { handleEntity } from '../../../lib/modelToDom/handlers/handleEntity'; +import { + handleEntityBlock, + handleEntitySegment, +} from '../../../lib/modelToDom/handlers/handleEntity'; describe('handleEntity', () => { let context: ModelToDomContext; @@ -28,7 +31,7 @@ describe('handleEntity', () => { const parent = document.createElement('div'); context.addDelimiterForEntity = false; - handleEntity(document, parent, entityModel, context, null); + handleEntityBlock(document, parent, entityModel, context, null); expect(parent.innerHTML).toBe( '
' @@ -53,7 +56,7 @@ describe('handleEntity', () => { const parent = document.createElement('div'); - handleEntity(document, parent, entityModel, context, null); + handleEntityBlock(document, parent, entityModel, context, null); expect(parent.innerHTML).toBe('
test
'); expect(div.outerHTML).toBe('
test
'); @@ -74,7 +77,7 @@ describe('handleEntity', () => { const parent = document.createElement('div'); context.addDelimiterForEntity = true; - handleEntity(document, parent, entityModel, context, null); + handleEntitySegment(document, parent, entityModel, context, []); expect(parent.innerHTML).toBe( '​​' @@ -103,7 +106,7 @@ describe('handleEntity', () => { const br = document.createElement('br'); parent.appendChild(br); - const result = handleEntity(document, parent, entityModel, context, br); + const result = handleEntityBlock(document, parent, entityModel, context, br); expect(parent.innerHTML).toBe( '
test

' @@ -136,7 +139,7 @@ describe('handleEntity', () => { entityDiv.textContent = 'test'; - const result = handleEntity(document, parent, entityModel, context, entityDiv); + const result = handleEntityBlock(document, parent, entityModel, context, entityDiv); expect(insertBefore).not.toHaveBeenCalled(); expect(result).toBe(br); @@ -162,15 +165,14 @@ describe('handleEntity', () => { context.addDelimiterForEntity = true; - const result = handleEntity(document, parent, entityModel, context, br); + handleEntitySegment(document, parent, entityModel, context, []); expect(parent.innerHTML).toBe( - '​test​
' + '
​test​' ); expect(span.outerHTML).toBe( 'test' ); - expect(result).toBe(br); expect(context.regularSelection.current.segment).toBe(span.nextSibling); }); @@ -189,7 +191,7 @@ describe('handleEntity', () => { span.textContent = 'test'; const parent = document.createElement('div'); - const result = handleEntity(document, parent, entityModel, context, null); + handleEntitySegment(document, parent, entityModel, context, []); expect(parent.innerHTML).toBe( 'test' @@ -197,7 +199,6 @@ describe('handleEntity', () => { expect(span.outerHTML).toBe( 'test' ); - expect(result).toBe(null); expect(context.regularSelection.current.segment).toBe(span); }); @@ -218,7 +219,7 @@ describe('handleEntity', () => { context.onNodeCreated = onNodeCreated; - handleEntity(document, parent, entityModel, context, null); + handleEntityBlock(document, parent, entityModel, context, null); expect(parent.innerHTML).toBe( '
' @@ -226,4 +227,64 @@ describe('handleEntity', () => { expect(onNodeCreated.calls.argsFor(0)[0]).toBe(entityModel); expect(onNodeCreated.calls.argsFor(0)[1]).toBe(parent.querySelector('div')); }); + + it('Inline entity with newSegments and delimiter', () => { + const span = document.createElement('span'); + const entityModel: ContentModelEntity = { + blockType: 'Entity', + segmentType: 'Entity', + format: {}, + id: 'entity_1', + type: 'entity', + isReadonly: true, + wrapper: span, + }; + + const parent = document.createElement('div'); + const newSegments: Node[] = []; + + context.addDelimiterForEntity = true; + handleEntitySegment(document, parent, entityModel, context, newSegments); + + expect(parent.innerHTML).toBe( + '​​' + ); + expect(span.outerHTML).toBe( + '' + ); + expect(addDelimiters.default).toHaveBeenCalledTimes(1); + expect(newSegments.length).toBe(3); + expect(newSegments[0]).toBe(span); + expect(newSegments[1]).toBe(span.nextSibling!); + expect(newSegments[2]).toBe(span.previousSibling!); + }); + + it('Inline entity with newSegments but no delimiter', () => { + const span = document.createElement('span'); + const entityModel: ContentModelEntity = { + blockType: 'Entity', + segmentType: 'Entity', + format: {}, + id: 'entity_1', + type: 'entity', + isReadonly: true, + wrapper: span, + }; + + const parent = document.createElement('div'); + const newSegments: Node[] = []; + + context.addDelimiterForEntity = false; + handleEntitySegment(document, parent, entityModel, context, newSegments); + + expect(parent.innerHTML).toBe( + '' + ); + expect(span.outerHTML).toBe( + '' + ); + expect(addDelimiters.default).toHaveBeenCalledTimes(0); + expect(newSegments.length).toBe(1); + expect(newSegments[0]).toBe(span); + }); }); diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleGeneralModelTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleGeneralModelTest.ts index 0f4ee2649e7..b8aade3edeb 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleGeneralModelTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleGeneralModelTest.ts @@ -3,7 +3,10 @@ import * as stackFormat from '../../../lib/modelToDom/utils/stackFormat'; import { createGeneralBlock } from '../../../lib/modelApi/creators/createGeneralBlock'; import { createGeneralSegment } from '../../../lib/modelApi/creators/createGeneralSegment'; import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext'; -import { handleGeneralModel } from '../../../lib/modelToDom/handlers/handleGeneralModel'; +import { + handleGeneralBlock, + handleGeneralSegment, +} from '../../../lib/modelToDom/handlers/handleGeneralModel'; import { ContentModelBlockGroup, ContentModelFormatContainer, @@ -44,7 +47,7 @@ describe('handleBlockGroup', () => { spyOn(applyFormat, 'applyFormat'); - handleGeneralModel(document, parent, group, context, null); + handleGeneralBlock(document, parent, group, context, null); expect(parent.outerHTML).toBe('
'); expect(typeof parent.firstChild).toBe('object'); @@ -69,7 +72,7 @@ describe('handleBlockGroup', () => { spyOn(applyFormat, 'applyFormat'); - handleGeneralModel(document, parent, group, context, null); + handleGeneralSegment(document, parent, group, context, []); expect(parent.outerHTML).toBe('
'); expect(context.regularSelection.current.segment).toBe(clonedChild); @@ -96,7 +99,7 @@ describe('handleBlockGroup', () => { spyOn(applyFormat, 'applyFormat'); - handleGeneralModel(document, parent, group, context, null); + handleGeneralSegment(document, parent, group, context, []); expect(parent.outerHTML).toBe('
'); expect(context.regularSelection.current.segment).toBe(clonedChild); @@ -131,7 +134,7 @@ describe('handleBlockGroup', () => { spyOn(applyFormat, 'applyFormat').and.callThrough(); - handleGeneralModel(document, parent, group, context, null); + handleGeneralSegment(document, parent, group, context, []); expect(parent.outerHTML).toBe('
'); expect(context.regularSelection.current.segment).toBe(clonedChild); @@ -165,7 +168,7 @@ describe('handleBlockGroup', () => { spyOn(stackFormat, 'stackFormat').and.callThrough(); - handleGeneralModel(document, parent, group, context, null); + handleGeneralSegment(document, parent, group, context, []); expect(stackFormat.stackFormat).toHaveBeenCalledTimes(1); expect((stackFormat.stackFormat).calls.argsFor(0)[1]).toBe('a'); @@ -183,7 +186,7 @@ describe('handleBlockGroup', () => { const br = document.createElement('br'); parent.appendChild(br); - const result = handleGeneralModel(document, parent, group, context, br); + const result = handleGeneralBlock(document, parent, group, context, br); expect(parent.outerHTML).toBe('

'); expect(typeof parent.firstChild).toBe('object'); @@ -209,7 +212,7 @@ describe('handleBlockGroup', () => { parent.appendChild(node); parent.appendChild(br); - const result = handleGeneralModel(document, parent, group, context, node); + const result = handleGeneralBlock(document, parent, group, context, node); expect(parent.outerHTML).toBe('

'); expect(parent.firstChild).toBe(node); @@ -229,11 +232,40 @@ describe('handleBlockGroup', () => { context.onNodeCreated = onNodeCreated; - handleGeneralModel(document, parent, group, context, null); + handleGeneralBlock(document, parent, group, context, null); expect(parent.innerHTML).toBe(''); expect(onNodeCreated).toHaveBeenCalledTimes(1); expect(onNodeCreated.calls.argsFor(0)[0]).toBe(group); expect(onNodeCreated.calls.argsFor(0)[1]).toBe(parent.querySelector('span')); }); + + it('General segment and newElements', () => { + const clonedChild = document.createElement('span'); + const childMock = ({ + cloneNode: () => clonedChild, + } as any) as HTMLElement; + const group = createGeneralSegment(childMock); + const newElements: Node[] = []; + + spyOn(applyFormat, 'applyFormat'); + + handleGeneralSegment(document, parent, group, context, newElements); + + expect(parent.outerHTML).toBe('
'); + expect(context.regularSelection.current.segment).toBe(clonedChild); + expect(typeof parent.firstChild).toBe('object'); + expect(parent.firstChild?.firstChild).toBe(clonedChild); + expect(context.listFormat.nodeStack).toEqual([]); + expect(handleBlockGroupChildren).toHaveBeenCalledTimes(1); + expect(handleBlockGroupChildren).toHaveBeenCalledWith( + document, + clonedChild, + group, + context + ); + expect(applyFormat.applyFormat).toHaveBeenCalled(); + expect(newElements.length).toBe(1); + expect(newElements[0]).toBe(clonedChild); + }); }); diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleImageTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleImageTest.ts index 9dae22c4f25..f9c0a109424 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleImageTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleImageTest.ts @@ -30,7 +30,7 @@ describe('handleSegment', () => { ) { parent = document.createElement('div'); - handleImage(document, parent, segment, context); + handleImage(document, parent, segment, context, []); expect(parent.innerHTML).toBe(expectedInnerHTML); expect(handleBlock).toHaveBeenCalledTimes(expectedCreateBlockFromContentModelCalledTimes); @@ -152,7 +152,7 @@ describe('handleSegment', () => { context.onNodeCreated = onNodeCreated; - handleImage(document, parent, segment, context); + handleImage(document, parent, segment, context, []); expect(parent.innerHTML).toBe(''); expect(onNodeCreated).toHaveBeenCalledTimes(1); @@ -169,10 +169,29 @@ describe('handleSegment', () => { }; const parent = document.createElement('div'); - handleImage(document, parent, segment, context); + handleImage(document, parent, segment, context, []); expect(parent.innerHTML).toBe( '' ); }); + + it('With segmentNodes', () => { + const segment: ContentModelImage = { + segmentType: 'Image', + src: 'http://test.com/test', + format: { display: 'block' }, + dataset: {}, + }; + const parent = document.createElement('div'); + const segmentNodes: Node[] = []; + + handleImage(document, parent, segment, context, segmentNodes); + + expect(parent.innerHTML).toBe( + '' + ); + expect(segmentNodes.length).toBe(1); + expect(segmentNodes[0]).toBe(parent.firstChild!.firstChild!); + }); }); diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleParagraphTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleParagraphTest.ts index 32cbe0cb717..57b45c46787 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleParagraphTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleParagraphTest.ts @@ -6,16 +6,16 @@ import { handleParagraph } from '../../../lib/modelToDom/handlers/handleParagrap import { handleSegment as originalHandleSegment } from '../../../lib/modelToDom/handlers/handleSegment'; import { optimize } from '../../../lib/modelToDom/optimizers/optimize'; import { - ContentModelHandler, ContentModelParagraph, ContentModelSegment, + ContentModelSegmentHandler, ModelToDomContext, } from 'roosterjs-content-model-types'; describe('handleParagraph', () => { let parent: HTMLElement; let context: ModelToDomContext; - let handleSegment: jasmine.Spy>; + let handleSegment: jasmine.Spy>; beforeEach(() => { parent = document.createElement('div'); @@ -89,7 +89,8 @@ describe('handleParagraph', () => { document, parent.firstChild as HTMLElement, segment, - context + context, + [] ); }); @@ -110,7 +111,7 @@ describe('handleParagraph', () => { 1 ); - expect(handleSegment).toHaveBeenCalledWith(document, parent, segment, context); + expect(handleSegment).toHaveBeenCalledWith(document, parent, segment, context, []); }); it('Handle multiple segments', () => { @@ -141,13 +142,15 @@ describe('handleParagraph', () => { document, parent.firstChild as HTMLElement, segment1, - context + context, + [] ); expect(handleSegment).toHaveBeenCalledWith( document, parent.firstChild as HTMLElement, segment2, - context + context, + [] ); }); diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleSegmentDecoratorTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleSegmentDecoratorTest.ts index e2b9c8d90c0..242074a48f1 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleSegmentDecoratorTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleSegmentDecoratorTest.ts @@ -1,5 +1,6 @@ import DarkColorHandlerImpl from 'roosterjs-editor-core/lib/editor/DarkColorHandlerImpl'; import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext'; +import { expectHtml } from 'roosterjs-editor-dom/test/DomTestHelper'; import { handleSegmentDecorator } from '../../../lib/modelToDom/handlers/handleSegmentDecorator'; import { ContentModelCode, @@ -20,7 +21,8 @@ describe('handleSegmentDecorator', () => { function runTest( link: ContentModelLink | undefined, code: ContentModelCode | undefined, - expectedInnerHTML: string + expectedInnerHTML: string, + expectedSegmentNodesHTML: (string | string[])[] ) { parent = document.createElement('span'); parent.textContent = 'test'; @@ -31,10 +33,15 @@ describe('handleSegmentDecorator', () => { link: link, code: code, }; + const segmentNodes: Node[] = []; - handleSegmentDecorator(document, parent, segment, context); + 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); + }); } it('simple link', () => { @@ -46,7 +53,9 @@ describe('handleSegmentDecorator', () => { dataset: {}, }; - runTest(link, undefined, 'test'); + runTest(link, undefined, 'test', [ + 'test', + ]); }); it('link with color', () => { @@ -61,7 +70,9 @@ describe('handleSegmentDecorator', () => { context.darkColorHandler = new DarkColorHandlerImpl({} as any, s => 'darkMock: ' + s); - runTest(link, undefined, 'test'); + runTest(link, undefined, 'test', [ + 'test', + ]); }); it('link without underline', () => { @@ -76,7 +87,8 @@ describe('handleSegmentDecorator', () => { runTest( link, undefined, - 'test' + 'test', + ['test'] ); }); @@ -92,7 +104,9 @@ describe('handleSegmentDecorator', () => { }, }; - runTest(link, undefined, 'test'); + runTest(link, undefined, 'test', [ + 'test', + ]); }); it('simple code', () => { @@ -102,7 +116,7 @@ describe('handleSegmentDecorator', () => { }, }; - runTest(undefined, code, 'test'); + runTest(undefined, code, 'test', ['test']); }); it('code with font', () => { @@ -112,7 +126,9 @@ describe('handleSegmentDecorator', () => { }, }; - runTest(undefined, code, 'test'); + runTest(undefined, code, 'test', [ + 'test', + ]); }); it('link and code', () => { @@ -129,7 +145,10 @@ describe('handleSegmentDecorator', () => { }, }; - runTest(link, code, 'test'); + runTest(link, code, 'test', [ + 'test', + 'test', + ]); }); it('Link with onNodeCreated', () => { @@ -156,7 +175,7 @@ describe('handleSegmentDecorator', () => { context.onNodeCreated = onNodeCreated; - handleSegmentDecorator(document, span, segment, context); + handleSegmentDecorator(document, span, segment, context, []); expect(parent.innerHTML).toBe( '' @@ -178,7 +197,12 @@ describe('handleSegmentDecorator', () => { dataset: {}, }; - runTest(link, undefined, 'test'); + runTest( + link, + undefined, + 'test', + ['test'] + ); }); it('code with display: block', () => { @@ -189,7 +213,9 @@ describe('handleSegmentDecorator', () => { }, }; - runTest(undefined, code, 'test'); + runTest(undefined, code, 'test', [ + 'test', + ]); }); it('link with background color', () => { @@ -206,7 +232,8 @@ describe('handleSegmentDecorator', () => { runTest( link, undefined, - 'test' + 'test', + ['test'] ); }); }); diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleSegmentTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleSegmentTest.ts index dafbf64b120..0c6adfc2de2 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleSegmentTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleSegmentTest.ts @@ -9,32 +9,41 @@ import { ContentModelText, ModelToDomContext, ContentModelBlockHandler, - ContentModelHandler, + ContentModelSegmentHandler, + ContentModelGeneralSegment, } from 'roosterjs-content-model-types'; describe('handleSegment', () => { let parent: HTMLElement; let context: ModelToDomContext; - let handleBr: jasmine.Spy>; - let handleText: jasmine.Spy>; - let handleGeneralModel: jasmine.Spy>; - let handleEntity: jasmine.Spy>; - let handleImage: jasmine.Spy>; + let handleBr: jasmine.Spy>; + let handleText: jasmine.Spy>; + let handleGeneralBlock: jasmine.Spy>; + let handleGeneralSegment: jasmine.Spy>; + let handleEntityBlock: jasmine.Spy>; + let handleEntitySegment: jasmine.Spy>; + let handleImage: jasmine.Spy>; + let mockedSegmentNodes: any; beforeEach(() => { parent = document.createElement('div'); handleBr = jasmine.createSpy('handleBr'); handleText = jasmine.createSpy('handleText'); - handleGeneralModel = jasmine.createSpy('handleGeneralModel'); - handleEntity = jasmine.createSpy('handleEntity'); + handleGeneralBlock = jasmine.createSpy('handleGeneralBlock'); + handleEntityBlock = jasmine.createSpy('handleEntityBlock'); + handleGeneralSegment = jasmine.createSpy('handleGeneralSegment'); + handleEntitySegment = jasmine.createSpy('handleEntitySegment'); handleImage = jasmine.createSpy('handleImage'); + mockedSegmentNodes = 'SEGMENTNODES' as any; context = createModelToDomContext(undefined, { modelHandlerOverride: { br: handleBr, text: handleText, - general: handleGeneralModel, - entity: handleEntity, + generalSegment: handleGeneralSegment, + entitySegment: handleEntitySegment, + generalBlock: handleGeneralBlock, + entityBlock: handleEntityBlock, image: handleImage, }, }); @@ -47,9 +56,15 @@ describe('handleSegment', () => { format: {}, }; - handleSegment(document, parent, text, context); + handleSegment(document, parent, text, context, mockedSegmentNodes); - expect(handleText).toHaveBeenCalledWith(document, parent, text, context); + expect(handleText).toHaveBeenCalledWith( + document, + parent, + text, + context, + mockedSegmentNodes + ); expect(parent.innerHTML).toBe(''); }); @@ -58,10 +73,10 @@ describe('handleSegment', () => { segmentType: 'Br', format: {}, }; - handleSegment(document, parent, br, context); + handleSegment(document, parent, br, context, mockedSegmentNodes); expect(parent.innerHTML).toBe(''); - expect(handleBr).toHaveBeenCalledWith(document, parent, br, context); + expect(handleBr).toHaveBeenCalledWith(document, parent, br, context, mockedSegmentNodes); }); it('general segment', () => { @@ -74,9 +89,16 @@ describe('handleSegment', () => { format: {}, }; - handleSegment(document, parent, segment, context); + handleSegment(document, parent, segment, context, mockedSegmentNodes); expect(parent.innerHTML).toBe(''); - expect(handleGeneralModel).toHaveBeenCalledWith(document, parent, segment, context, null); + expect(handleGeneralSegment).toHaveBeenCalledWith( + document, + parent, + segment, + context, + mockedSegmentNodes + ); + expect(handleGeneralBlock).not.toHaveBeenCalled(); }); it('entity segment', () => { @@ -91,9 +113,16 @@ describe('handleSegment', () => { isReadonly: true, }; - handleSegment(document, parent, segment, context); + handleSegment(document, parent, segment, context, mockedSegmentNodes); expect(parent.innerHTML).toBe(''); - expect(handleEntity).toHaveBeenCalledWith(document, parent, segment, context, null); + expect(handleEntitySegment).toHaveBeenCalledWith( + document, + parent, + segment, + context, + mockedSegmentNodes + ); + expect(handleEntityBlock).not.toHaveBeenCalled(); }); it('image segment', () => { @@ -104,8 +133,14 @@ describe('handleSegment', () => { dataset: {}, }; - handleSegment(document, parent, segment, context); + handleSegment(document, parent, segment, context, mockedSegmentNodes); expect(parent.innerHTML).toBe(''); - expect(handleImage).toHaveBeenCalledWith(document, parent, segment, context); + expect(handleImage).toHaveBeenCalledWith( + document, + parent, + segment, + context, + mockedSegmentNodes + ); }); }); diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleTextTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleTextTest.ts index df8c501aa94..4737c071f3d 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleTextTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleTextTest.ts @@ -20,7 +20,7 @@ describe('handleText', () => { format: {}, }; - handleText(document, parent, text, context); + handleText(document, parent, text, context, []); expect(parent.innerHTML).toBe('test'); }); @@ -33,7 +33,7 @@ describe('handleText', () => { }; context.darkColorHandler = new DarkColorHandlerImpl({} as any, s => 'darkMock: ' + s); - handleText(document, parent, text, context); + handleText(document, parent, text, context, []); expect(parent.innerHTML).toBe('test'); }); @@ -46,7 +46,7 @@ describe('handleText', () => { link: { format: { href: '/test', underline: true }, dataset: {} }, }; - handleText(document, parent, text, context); + handleText(document, parent, text, context, []); expect(parent.innerHTML).toBe('test'); }); @@ -63,7 +63,7 @@ describe('handleText', () => { }, }; - handleText(document, parent, text, context); + handleText(document, parent, text, context, []); expect(parent.innerHTML).toBe('test'); }); @@ -78,7 +78,7 @@ describe('handleText', () => { spyOn(stackFormat, 'stackFormat').and.callThrough(); - handleText(document, parent, text, context); + handleText(document, parent, text, context, []); expect(parent.innerHTML).toBe('test'); expect(stackFormat.stackFormat).toHaveBeenCalledTimes(1); @@ -97,7 +97,7 @@ describe('handleText', () => { context.onNodeCreated = onNodeCreated; - handleText(document, parent, text, context); + handleText(document, parent, text, context, []); expect(parent.innerHTML).toBe('test'); expect(onNodeCreated).toHaveBeenCalledTimes(1); @@ -120,8 +120,23 @@ describe('handleText', () => { }, }; - handleText(document, parent, text, context); + handleText(document, parent, text, context, []); expect(parent.innerHTML).toBe('test'); }); + + it('Text segment with segmentNodes', () => { + const text: ContentModelText = { + segmentType: 'Text', + text: 'test', + format: {}, + }; + const segmentNodes: Node[] = []; + + handleText(document, parent, text, context, segmentNodes); + + expect(parent.innerHTML).toBe('test'); + expect(segmentNodes.length).toBe(1); + expect(segmentNodes[0]).toBe(parent.firstChild!.firstChild!); + }); }); diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/utils/handleSegmentCommonTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/utils/handleSegmentCommonTest.ts index 7f9cde63d16..5ed00d3f49f 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/utils/handleSegmentCommonTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/utils/handleSegmentCommonTest.ts @@ -28,14 +28,19 @@ describe('handleSegmentCommon', () => { href: 'href', }, }; + container.appendChild(txt); + const segmentNodes: Node[] = []; - handleSegmentCommon(document, txt, container, segment, context); + handleSegmentCommon(document, txt, container, segment, context, segmentNodes); expect(context.regularSelection.current.segment).toBe(txt); expect(container.outerHTML).toBe( - '' + 'test' ); expect(onNodeCreated).toHaveBeenCalledWith(segment, txt); + expect(segmentNodes.length).toBe(2); + expect(segmentNodes[0]).toBe(txt); + expect(segmentNodes[1]).toBe(txt.parentNode!); }); it('element with child', () => { @@ -48,12 +53,16 @@ describe('handleSegmentCommon', () => { const segment = createText('test', {}); const onNodeCreated = jasmine.createSpy('onNodeCreated'); const context = createModelToDomContext(); + const segmentNodes: Node[] = []; context.onNodeCreated = onNodeCreated; - handleSegmentCommon(document, parent, container, segment, context); + container.appendChild(parent); + handleSegmentCommon(document, parent, container, segment, context, segmentNodes); expect(context.regularSelection.current.segment).toBe(null); - expect(container.outerHTML).toBe(''); + expect(container.outerHTML).toBe('child'); expect(onNodeCreated).toHaveBeenCalledWith(segment, parent); + expect(segmentNodes.length).toBe(1); + expect(segmentNodes[0]).toBe(parent); }); }); diff --git a/packages-content-model/roosterjs-content-model-types/lib/context/ContentModelHandler.ts b/packages-content-model/roosterjs-content-model-types/lib/context/ContentModelHandler.ts index 74dbb5578c8..ad68a9422cf 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/context/ContentModelHandler.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/context/ContentModelHandler.ts @@ -31,3 +31,21 @@ export type ContentModelBlockHandler Node | null; + +/** + * Type of Content Model to DOM handler for block + * @param doc Target HTML Document object + * @param parent Parent HTML node to append the new node from the given model + * @param model The Content Model to handle + * @param context The context object to provide related information + * @param segmentNodes Nodes that created to represent this segment. In most cases there will be one node returned, except + * - For segments with decorators: decorator elements will also be included + * - For inline entity segment, the delimiter SPANs will also be included + */ +export type ContentModelSegmentHandler = ( + doc: Document, + parent: Node, + model: T, + context: ModelToDomContext, + segmentNodes: Node[] +) => void; diff --git a/packages-content-model/roosterjs-content-model-types/lib/context/ModelToDomSettings.ts b/packages-content-model/roosterjs-content-model-types/lib/context/ModelToDomSettings.ts index aac581401cb..4802f068898 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/context/ModelToDomSettings.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/context/ModelToDomSettings.ts @@ -1,7 +1,6 @@ import { ContentModelBlock } from '../block/ContentModelBlock'; import { ContentModelBlockFormat } from '../format/ContentModelBlockFormat'; import { ContentModelBlockGroup } from '../group/ContentModelBlockGroup'; -import { ContentModelBlockHandler, ContentModelHandler } from './ContentModelHandler'; import { ContentModelBr } from '../segment/ContentModelBr'; import { ContentModelDecorator } from '../decorator/ContentModelDecorator'; import { ContentModelDivider } from '../block/ContentModelDivider'; @@ -10,6 +9,7 @@ import { ContentModelFormatBase } from '../format/ContentModelFormatBase'; import { ContentModelFormatContainer } from '../group/ContentModelFormatContainer'; import { ContentModelFormatMap } from '../format/ContentModelFormatMap'; import { ContentModelGeneralBlock } from '../group/ContentModelGeneralBlock'; +import { ContentModelGeneralSegment } from '../segment/ContentModelGeneralSegment'; import { ContentModelImage } from '../segment/ContentModelImage'; import { ContentModelListItem } from '../group/ContentModelListItem'; import { ContentModelParagraph } from '../block/ContentModelParagraph'; @@ -20,6 +20,11 @@ import { ContentModelTableRow } from '../block/ContentModelTableRow'; import { ContentModelText } from '../segment/ContentModelText'; import { FormatHandlerTypeMap, FormatKey } from '../format/FormatHandlerTypeMap'; import { ModelToDomContext } from './ModelToDomContext'; +import { + ContentModelHandler, + ContentModelBlockHandler, + ContentModelSegmentHandler, +} from './ContentModelHandler'; /** * Default implicit format map from tag name (lower case) to segment format @@ -72,17 +77,27 @@ export type ContentModelHandlerMap = { /** * Content Model type for ContentModelBr */ - br: ContentModelHandler; + br: ContentModelSegmentHandler; /** * Content Model type for child models of ContentModelEntity */ - entity: ContentModelBlockHandler; + entityBlock: ContentModelBlockHandler; + + /** + * Content Model type for child models of ContentModelEntity + */ + entitySegment: ContentModelSegmentHandler; + + /** + * Content Model type for ContentModelGeneralBlock + */ + generalBlock: ContentModelBlockHandler; /** * Content Model type for ContentModelGeneralBlock */ - general: ContentModelBlockHandler; + generalSegment: ContentModelSegmentHandler; /** * Content Model type for ContentModelHR @@ -92,7 +107,7 @@ export type ContentModelHandlerMap = { /** * Content Model type for ContentModelImage */ - image: ContentModelHandler; + image: ContentModelSegmentHandler; /** * Content Model type for list group of ContentModelListItem @@ -117,12 +132,12 @@ export type ContentModelHandlerMap = { /** * Content Model type for ContentModelSegment */ - segment: ContentModelHandler; + segment: ContentModelSegmentHandler; /** * Content Model type for ContentModelCode */ - segmentDecorator: ContentModelHandler; + segmentDecorator: ContentModelSegmentHandler; /** * Content Model type for ContentModelTable @@ -132,7 +147,7 @@ export type ContentModelHandlerMap = { /** * Content Model type for ContentModelText */ - text: ContentModelHandler; + text: ContentModelSegmentHandler; }; /** diff --git a/packages-content-model/roosterjs-content-model-types/lib/index.ts b/packages-content-model/roosterjs-content-model-types/lib/index.ts index 22e154bf4a5..e2a4ad5d7c6 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/index.ts @@ -134,6 +134,10 @@ export { ModelToDomListContext, ModelToDomFormatContext, } from './context/ModelToDomFormatContext'; -export { ContentModelHandler, ContentModelBlockHandler } from './context/ContentModelHandler'; +export { + ContentModelHandler, + ContentModelSegmentHandler, + ContentModelBlockHandler, +} from './context/ContentModelHandler'; export { DomToModelOption } from './context/DomToModelOption'; export { ModelToDomOption } from './context/ModelToDomOption'; From 187e032cd6cb30f28d9e8d555911f9ce5aee8274 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Wed, 20 Sep 2023 13:25:52 -0700 Subject: [PATCH 60/75] Do not restore cached selection when call select (#2075) --- .../lib/coreApi/select.ts | 113 +++++++++++------- .../lib/corePlugins/DOMEventPlugin.ts | 18 +-- .../test/corePlugins/domEventPluginTest.ts | 10 ++ .../corePluginState/DOMEventPluginState.ts | 5 + 4 files changed, 95 insertions(+), 51 deletions(-) diff --git a/packages/roosterjs-editor-core/lib/coreApi/select.ts b/packages/roosterjs-editor-core/lib/coreApi/select.ts index 50f0a340234..24ca95f8687 100644 --- a/packages/roosterjs-editor-core/lib/coreApi/select.ts +++ b/packages/roosterjs-editor-core/lib/coreApi/select.ts @@ -1,5 +1,6 @@ import { contains, createRange, safeInstanceOf } from 'roosterjs-editor-dom'; import { + EditorCore, NodePosition, PluginEventType, PositionType, @@ -21,6 +22,35 @@ import { * @param arg4 (optional) An offset number, or a PositionType */ export const select: Select = (core, arg1, arg2, arg3, arg4) => { + let rangeEx = buildRangeEx(core, arg1, arg2, arg3, arg4); + + if (rangeEx) { + const skipReselectOnFocus = core.domEvent.skipReselectOnFocus; + + // We are applying a new selection, so we don't need to apply cached selection in DOMEventPlugin. + // Set skipReselectOnFocus to skip this behavior + core.domEvent.skipReselectOnFocus = true; + + try { + applyRangeEx(core, rangeEx); + } finally { + core.domEvent.skipReselectOnFocus = skipReselectOnFocus; + } + } else { + core.domEvent.tableSelectionRange = core.api.selectTable(core, null); + core.domEvent.imageSelectionRange = core.api.selectImage(core, null); + } + + return !!rangeEx; +}; + +function buildRangeEx( + core: EditorCore, + arg1: Range | SelectionRangeEx | NodePosition | Node | SelectionPath | null, + arg2?: NodePosition | number | PositionType | TableSelection | null, + arg3?: Node, + arg4?: number | PositionType +) { let rangeEx: SelectionRangeEx | null = null; if (isSelectionRangeEx(arg1)) { @@ -65,53 +95,50 @@ export const select: Select = (core, arg1, arg2, arg3, arg4) => { : null; } - if (rangeEx) { - switch (rangeEx.type) { - case SelectionRangeTypes.TableSelection: - if (contains(core.contentDiv, rangeEx.table)) { - core.domEvent.imageSelectionRange = core.api.selectImage(core, null); - core.domEvent.tableSelectionRange = core.api.selectTable( - core, - rangeEx.table, - rangeEx.coordinates - ); - rangeEx = core.domEvent.tableSelectionRange; - } - break; - case SelectionRangeTypes.ImageSelection: - if (contains(core.contentDiv, rangeEx.image)) { - core.domEvent.tableSelectionRange = core.api.selectTable(core, null); - core.domEvent.imageSelectionRange = core.api.selectImage(core, rangeEx.image); - rangeEx = core.domEvent.imageSelectionRange; - } - break; - case SelectionRangeTypes.Normal: - core.domEvent.tableSelectionRange = core.api.selectTable(core, null); - core.domEvent.imageSelectionRange = core.api.selectImage(core, null); + return rangeEx; +} - if (contains(core.contentDiv, rangeEx.ranges[0])) { - core.api.selectRange(core, rangeEx.ranges[0]); - } else { - rangeEx = null; - } - break; - } +function applyRangeEx(core: EditorCore, rangeEx: SelectionRangeEx | null) { + switch (rangeEx?.type) { + case SelectionRangeTypes.TableSelection: + if (contains(core.contentDiv, rangeEx.table)) { + core.domEvent.imageSelectionRange = core.api.selectImage(core, null); + core.domEvent.tableSelectionRange = core.api.selectTable( + core, + rangeEx.table, + rangeEx.coordinates + ); + rangeEx = core.domEvent.tableSelectionRange; + } + break; + case SelectionRangeTypes.ImageSelection: + if (contains(core.contentDiv, rangeEx.image)) { + core.domEvent.tableSelectionRange = core.api.selectTable(core, null); + core.domEvent.imageSelectionRange = core.api.selectImage(core, rangeEx.image); + rangeEx = core.domEvent.imageSelectionRange; + } + break; + case SelectionRangeTypes.Normal: + core.domEvent.tableSelectionRange = core.api.selectTable(core, null); + core.domEvent.imageSelectionRange = core.api.selectImage(core, null); - core.api.triggerEvent( - core, - { - eventType: PluginEventType.SelectionChanged, - selectionRangeEx: rangeEx, - }, - true /** broadcast **/ - ); - } else { - core.domEvent.tableSelectionRange = core.api.selectTable(core, null); - core.domEvent.imageSelectionRange = core.api.selectImage(core, null); + if (contains(core.contentDiv, rangeEx.ranges[0])) { + core.api.selectRange(core, rangeEx.ranges[0]); + } else { + rangeEx = null; + } + break; } - return !!rangeEx; -}; + core.api.triggerEvent( + core, + { + eventType: PluginEventType.SelectionChanged, + selectionRangeEx: rangeEx, + }, + true /** broadcast **/ + ); +} function isSelectionRangeEx(obj: any): obj is SelectionRangeEx { const rangeEx = obj as SelectionRangeEx; diff --git a/packages/roosterjs-editor-core/lib/corePlugins/DOMEventPlugin.ts b/packages/roosterjs-editor-core/lib/corePlugins/DOMEventPlugin.ts index 2c1fa9da916..9f0e9306113 100644 --- a/packages/roosterjs-editor-core/lib/corePlugins/DOMEventPlugin.ts +++ b/packages/roosterjs-editor-core/lib/corePlugins/DOMEventPlugin.ts @@ -162,15 +162,17 @@ export default class DOMEventPlugin implements PluginWithState { - const { table, coordinates } = this.state.tableSelectionRange || {}; - const { image } = this.state.imageSelectionRange || {}; + if (!this.state.skipReselectOnFocus) { + const { table, coordinates } = this.state.tableSelectionRange || {}; + const { image } = this.state.imageSelectionRange || {}; - if (table && coordinates) { - this.editor?.select(table, coordinates); - } else if (image) { - this.editor?.select(image); - } else if (this.state.selectionRange) { - this.editor?.select(this.state.selectionRange); + if (table && coordinates) { + this.editor?.select(table, coordinates); + } else if (image) { + this.editor?.select(image); + } else if (this.state.selectionRange) { + this.editor?.select(this.state.selectionRange); + } } this.state.selectionRange = null; diff --git a/packages/roosterjs-editor-core/test/corePlugins/domEventPluginTest.ts b/packages/roosterjs-editor-core/test/corePlugins/domEventPluginTest.ts index cb17db99cff..72bdd7c47c9 100644 --- a/packages/roosterjs-editor-core/test/corePlugins/domEventPluginTest.ts +++ b/packages/roosterjs-editor-core/test/corePlugins/domEventPluginTest.ts @@ -188,6 +188,16 @@ describe('DOMEventPlugin verify event handlers while allow keyboard event propag expect(select.calls.argsFor(0)[0]).toBe(range); expect(state.selectionRange).toBeNull(); }); + + it('Skip applying selection when skipReselectOnFocus is true', () => { + const range = document.createRange(); + state.selectionRange = range; + state.skipReselectOnFocus = true; + eventMap.focus({}); + + expect(select).not.toHaveBeenCalled(); + expect(state.selectionRange).toBeNull(); + }); }); describe('DOMEventPlugin verify event handlers while disallow keyboard event propagation', () => { diff --git a/packages/roosterjs-editor-types/lib/corePluginState/DOMEventPluginState.ts b/packages/roosterjs-editor-types/lib/corePluginState/DOMEventPluginState.ts index dbc727eeabb..596b4b0ff4a 100644 --- a/packages/roosterjs-editor-types/lib/corePluginState/DOMEventPluginState.ts +++ b/packages/roosterjs-editor-types/lib/corePluginState/DOMEventPluginState.ts @@ -39,4 +39,9 @@ export default interface DOMEventPluginState { * Image selection range */ imageSelectionRange: ImageSelectionRange | null; + + /** + * When set to true, onFocus event will not trigger reselect cached range + */ + skipReselectOnFocus?: boolean; } From b4ca8e2c2704546dcdc3dae9392b91028c86de48 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Thu, 21 Sep 2023 08:45:29 -0700 Subject: [PATCH 61/75] Improve "checkDependency" (#2085) --- tools/buildTools/checkDependency.js | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tools/buildTools/checkDependency.js b/tools/buildTools/checkDependency.js index 6d96776822f..2c292a30b3f 100644 --- a/tools/buildTools/checkDependency.js +++ b/tools/buildTools/checkDependency.js @@ -12,7 +12,7 @@ function getPossibleNames(dir, objectName) { ]; } -function processFile(dir, filename, files, externalDependencies) { +function processFile(dir, filename, files, externalDependencies, fromFile) { if ( externalDependencies.some(d => (typeof d === 'string' ? d == filename : d.test(filename))) ) { @@ -27,7 +27,8 @@ function processFile(dir, filename, files, externalDependencies) { filename + ' under ' + dir + - ': File not found' + ': File not found. Source file: ' + + fromFile ); } @@ -54,7 +55,7 @@ function processFile(dir, filename, files, externalDependencies) { while ((match = reg.exec(content))) { var nextFile = match[1]; if (nextFile) { - processFile(dir, nextFile, files, externalDependencies); + processFile(dir, nextFile, files, externalDependencies, thisFilename); } } @@ -95,11 +96,13 @@ function checkDependency() { var peerDependencies = packageJson.peerDependencies ? Object.keys(packageJson.peerDependencies) : []; + const startFile = path.join(packageName, 'lib/index'); processFile( packageRoot, - path.join(packageName, 'lib/index'), + startFile, [], - dependencies.concat(peerDependencies).concat(GlobalAllowedCrossPackageDependency) + dependencies.concat(peerDependencies).concat(GlobalAllowedCrossPackageDependency), + startFile + '.ts' ); }); } From bee1d4208d4d905af49aba5362513db333a17631 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Thu, 21 Sep 2023 09:06:50 -0700 Subject: [PATCH 62/75] Content Model: Improve adjustWordSelection (#2087) --- .../lib/modelApi/creators/createText.ts | 29 ++++++- .../test/modelApi/creators/creatorsTest.ts | 43 ++++++++++ .../modelApi/selection/adjustWordSelection.ts | 63 +++++++------- .../selection/adjustWordSelectionTest.ts | 86 +++++++++++++++++++ 4 files changed, 188 insertions(+), 33 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-dom/lib/modelApi/creators/createText.ts b/packages-content-model/roosterjs-content-model-dom/lib/modelApi/creators/createText.ts index e6d1afc2762..3e4f1f63b0f 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/modelApi/creators/createText.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/modelApi/creators/createText.ts @@ -1,14 +1,37 @@ -import { ContentModelSegmentFormat, ContentModelText } from 'roosterjs-content-model-types'; +import { addCode, addLink } from '../common/addDecorators'; +import { + ContentModelCode, + ContentModelLink, + ContentModelSegmentFormat, + ContentModelText, +} from 'roosterjs-content-model-types'; /** * Create a ContentModelText model * @param text Text of this model * @param format @optional The format of this model + * @param link @optional The link decorator + * @param code @option The code decorator */ -export function createText(text: string, format?: ContentModelSegmentFormat): ContentModelText { - return { +export function createText( + text: string, + format?: ContentModelSegmentFormat, + link?: ContentModelLink, + code?: ContentModelCode +): ContentModelText { + const result: ContentModelText = { segmentType: 'Text', text: text, format: format ? { ...format } : {}, }; + + if (link) { + addLink(result, link); + } + + if (code) { + addCode(result, code); + } + + return result; } diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelApi/creators/creatorsTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelApi/creators/creatorsTest.ts index b1d91fdcb3b..42f484fae4c 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelApi/creators/creatorsTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelApi/creators/creatorsTest.ts @@ -15,6 +15,8 @@ import { createTable } from '../../../lib/modelApi/creators/createTable'; import { createTableCell } from '../../../lib/modelApi/creators/createTableCell'; import { createText } from '../../../lib/modelApi/creators/createText'; import { + ContentModelCode, + ContentModelLink, ContentModelListLevel, ContentModelSegmentFormat, ContentModelTableCellFormat, @@ -188,6 +190,47 @@ describe('Creators', () => { expect(format).toEqual({ a: 1 }); }); + it('createText with decorators', () => { + const format = { a: 1 } as any; + const text = 'test'; + const link: ContentModelLink = { + dataset: {}, + format: { + href: 'test', + }, + }; + const code: ContentModelCode = { + format: { fontFamily: 'test' }, + }; + const result = createText(text, format, link, code); + + expect(result).toEqual({ + segmentType: 'Text', + format: { a: 1 } as any, + text: text, + link, + code, + }); + expect(result.link).not.toBe(link); + expect(result.code).not.toBe(code); + + result.link!.dataset.a = 'b'; + result.link!.format.href = 'test2'; + + expect(link).toEqual({ + dataset: {}, + format: { + href: 'test', + }, + }); + + result.code!.format.fontFamily = 'test2'; + + expect(code).toEqual({ + format: { fontFamily: 'test' }, + }); + }); + it('createTable', () => { const tableModel = createTable(2); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/adjustWordSelection.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/adjustWordSelection.ts index 450c4387902..4db23ddadf2 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/adjustWordSelection.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/adjustWordSelection.ts @@ -25,28 +25,34 @@ export function adjustWordSelection( return true; }); - if (markerBlock) { + const tempSegments = markerBlock ? [...markerBlock.segments] : undefined; + + if (tempSegments && markerBlock) { const segments: ContentModelSegment[] = []; - let markerSelectionIndex = markerBlock.segments.indexOf(marker); + let markerSelectionIndex = tempSegments.indexOf(marker); for (let i = markerSelectionIndex - 1; i >= 0; i--) { - const currentSegment = markerBlock.segments[i]; + const currentSegment = tempSegments[i]; if (currentSegment.segmentType == 'Text') { const found = findDelimiter(currentSegment, false /*moveRightward*/); if (found > -1) { if (found == currentSegment.text.length) { break; } - splitTextSegment(markerBlock.segments, currentSegment, i, found); - segments.push(markerBlock.segments[i + 1]); + + splitTextSegment(tempSegments, currentSegment, i, found); + + segments.push(tempSegments[i + 1]); + break; } else { - segments.push(markerBlock.segments[i]); + segments.push(tempSegments[i]); } } else { break; } } - markerSelectionIndex = markerBlock.segments.indexOf(marker); + + markerSelectionIndex = tempSegments.indexOf(marker); segments.push(marker); // Marker is at start of word @@ -54,19 +60,19 @@ export function adjustWordSelection( return segments; } - for (let i = markerSelectionIndex + 1; i < markerBlock.segments.length; i++) { - const currentSegment = markerBlock.segments[i]; + for (let i = markerSelectionIndex + 1; i < tempSegments.length; i++) { + const currentSegment = tempSegments[i]; if (currentSegment.segmentType == 'Text') { const found = findDelimiter(currentSegment, true /*moveRightward*/); if (found > -1) { if (found == 0) { break; } - splitTextSegment(markerBlock.segments, currentSegment, i, found); - segments.push(markerBlock.segments[i]); + splitTextSegment(tempSegments, currentSegment, i, found); + segments.push(tempSegments[i]); break; } else { - segments.push(markerBlock.segments[i]); + segments.push(tempSegments[i]); } } else { break; @@ -78,6 +84,7 @@ export function adjustWordSelection( return [marker]; } + markerBlock.segments = tempSegments; return segments; } else { return [marker]; @@ -123,26 +130,22 @@ function findDelimiter(segment: ContentModelText, moveRightward: boolean): numbe function splitTextSegment( segments: ContentModelSegment[], - textSegment: ContentModelText, + textSegment: Readonly, index: number, found: number ) { const text = textSegment.text; - const newSegment = createText(text.substring(0, found), segments[index].format); - - if (textSegment.code) { - newSegment.code = { - format: { ...textSegment.code.format }, - }; - } - - if (textSegment.link) { - newSegment.link = { - format: { ...textSegment.link.format }, - dataset: { ...textSegment.link.dataset }, - }; - } - - textSegment.text = text.substring(found, text.length); - segments.splice(index, 0, newSegment); + const newSegmentLeft = createText( + text.substring(0, found), + textSegment.format, + textSegment.link, + textSegment.code + ); + const newSegmentRight = createText( + text.substring(found, text.length), + textSegment.format, + textSegment.link, + textSegment.code + ); + segments.splice(index, 1, newSegmentLeft, newSegmentRight); } diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/selection/adjustWordSelectionTest.ts b/packages-content-model/roosterjs-content-model-editor/test/modelApi/selection/adjustWordSelectionTest.ts index 62aa185f584..23517eb65c5 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/selection/adjustWordSelectionTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/modelApi/selection/adjustWordSelectionTest.ts @@ -1,4 +1,10 @@ import { adjustWordSelection } from '../../../lib/modelApi/selection/adjustWordSelection'; +import { + createContentModelDocument, + createParagraph, + createSelectionMarker, + createText, +} from 'roosterjs-content-model-dom'; import { ContentModelBlock, ContentModelDocument, @@ -885,4 +891,84 @@ describe('adjustWordSelection', () => { ); }); }); + + it('Do not modify segments array if no word is selected: marker before text', () => { + const text = createText('Word1 Word2'); + const marker = createSelectionMarker(); + const paragraph = createParagraph(); + const model = createContentModelDocument(); + + paragraph.segments.push(marker, text); + model.blocks.push(paragraph); + + adjustWordSelection(model, marker); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [marker, text], + format: {}, + }, + ], + }); + expect(model.blocks[0]).toBe(paragraph); + expect(paragraph.segments[0]).toBe(marker); + expect(paragraph.segments[1]).toBe(text); + }); + + it('Do not modify segments array if no word is selected: marker after text', () => { + const text = createText('Word1 Word2'); + const marker = createSelectionMarker(); + const paragraph = createParagraph(); + const model = createContentModelDocument(); + + paragraph.segments.push(text, marker); + model.blocks.push(paragraph); + + adjustWordSelection(model, marker); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [text, marker], + format: {}, + }, + ], + }); + expect(model.blocks[0]).toBe(paragraph); + expect(paragraph.segments[0]).toBe(text); + expect(paragraph.segments[1]).toBe(marker); + }); + + it('Do not modify segments array if no word is selected: marker between text', () => { + const text1 = createText('Word1 '); + const text2 = createText(' Word2'); + const marker = createSelectionMarker(); + const paragraph = createParagraph(); + const model = createContentModelDocument(); + + paragraph.segments.push(text1, marker, text2); + model.blocks.push(paragraph); + + adjustWordSelection(model, marker); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [text1, marker, text2], + format: {}, + }, + ], + }); + expect(model.blocks[0]).toBe(paragraph); + expect(paragraph.segments[0]).toBe(text1); + expect(paragraph.segments[1]).toBe(marker); + expect(paragraph.segments[2]).toBe(text2); + }); }); From f8e309624e7643b5440d8876f8dcada0b2d38fac Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Thu, 21 Sep 2023 09:55:16 -0700 Subject: [PATCH 63/75] Fix #2084 (#2089) --- .../roosterjs-react/lib/emoji/components/EmojiPane.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages-ui/roosterjs-react/lib/emoji/components/EmojiPane.tsx b/packages-ui/roosterjs-react/lib/emoji/components/EmojiPane.tsx index 07965962acd..aee2375e147 100644 --- a/packages-ui/roosterjs-react/lib/emoji/components/EmojiPane.tsx +++ b/packages-ui/roosterjs-react/lib/emoji/components/EmojiPane.tsx @@ -225,7 +225,10 @@ const EmojiPane = React.forwardRef(function EmojiPaneFunc( [mode, currentFamily, currentEmojiList] ); - const getEmojiIconId = React.useCallback((emoji: Emoji) => `${listId}-${emoji.key}`, [listId]); + const getEmojiIconId = React.useCallback( + (emoji: Emoji) => (emoji ? `${listId}-${emoji.key}` : ''), + [listId] + ); React.useImperativeHandle( ref, From cd26ced2f98a89c1189432b30254bbc284766934 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Thu, 21 Sep 2023 17:14:25 -0300 Subject: [PATCH 64/75] fix text color in tables --- .../lib/modelApi/table/applyTableFormat.ts | 21 ++++++++++++++++--- .../lib/modelApi/table/normalizeTable.ts | 17 +++++++++------ .../table/setTableCellBackgroundColor.ts | 20 +++++++++++++++++- .../test/modelApi/table/normalizeTableTest.ts | 11 +++++++--- 4 files changed, 56 insertions(+), 13 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/applyTableFormat.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/applyTableFormat.ts index 802045a48a2..ce4c668f8cb 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/applyTableFormat.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/applyTableFormat.ts @@ -206,7 +206,12 @@ function formatCells( : bgColorEven : bgColorEven; /* bgColorEven is the default color */ - setTableCellBackgroundColor(cell, color); + setTableCellBackgroundColor( + cell, + color, + false /*isColorOverride*/, + true /*overrideTextColor*/ + ); } // Format Vertical Align @@ -229,7 +234,12 @@ function setFirstColumnFormat( if (rowIndex !== 0 && !metaOverrides.bgColorOverrides[rowIndex][cellIndex]) { setBorderColor(cell.format, 'borderTop'); - setTableCellBackgroundColor(cell, null /*color*/); + setTableCellBackgroundColor( + cell, + null /*color*/, + false /*isColorOverride*/, + true /*overrideTextColor*/ + ); } if (rowIndex !== rows.length - 1 && rowIndex !== 0) { @@ -254,7 +264,12 @@ function setHeaderRowFormat( if (format.hasHeaderRow && format.headerRowColor) { if (!metaOverrides.bgColorOverrides[rowIndex][cellIndex]) { - setTableCellBackgroundColor(cell, format.headerRowColor); + setTableCellBackgroundColor( + cell, + format.headerRowColor, + false /*isColorOverride*/, + true /*overrideTextColor*/ + ); } setBorderColor(cell.format, 'borderTop', format.headerRowColor); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/normalizeTable.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/normalizeTable.ts index e150418fa9e..d682dfbd0da 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/normalizeTable.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/normalizeTable.ts @@ -32,13 +32,18 @@ export function normalizeTable( if (cell.blocks.length == 0) { addBlock( cell, - createParagraph( - undefined /*isImplicit*/, - undefined /*blockFormat*/, - defaultSegmentFormat - ) + createParagraph(undefined /*isImplicit*/, undefined /*format*/, { + ...defaultSegmentFormat, + textColor: cell.format.textColor, + }) + ); + addSegment( + cell, + createBr({ + ...defaultSegmentFormat, + textColor: cell.format.textColor, + }) ); - addSegment(cell, createBr(defaultSegmentFormat)); } if (rowIndex == 0) { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/setTableCellBackgroundColor.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/setTableCellBackgroundColor.ts index f4a6139942a..f6612dbe572 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/setTableCellBackgroundColor.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/setTableCellBackgroundColor.ts @@ -16,7 +16,8 @@ const Black = '#000000'; export function setTableCellBackgroundColor( cell: ContentModelTableCell, color: string | null | undefined, - isColorOverride?: boolean + isColorOverride?: boolean, + overrideTextColor?: boolean ) { if (color) { cell.format.backgroundColor = color; @@ -38,6 +39,23 @@ export function setTableCellBackgroundColor( } else { delete cell.format.textColor; } + + if (overrideTextColor) { + cell.blocks.forEach(block => { + if (block.blockType == 'Paragraph') { + block.segmentFormat = { + ...block.segmentFormat, + textColor: cell.format.textColor, + }; + block.segments.forEach(segment => { + segment.format = { + ...segment.format, + textColor: cell.format.textColor, + }; + }); + } + }); + } } else { delete cell.format.backgroundColor; delete cell.format.textColor; diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/table/normalizeTableTest.ts b/packages-content-model/roosterjs-content-model-editor/test/modelApi/table/normalizeTableTest.ts index f2379aa2f28..43139341a84 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/table/normalizeTableTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/modelApi/table/normalizeTableTest.ts @@ -81,10 +81,11 @@ describe('normalizeTable', () => { segments: [ { segmentType: 'Br', - format: {}, + format: { textColor: undefined }, }, ], format: {}, + segmentFormat: { textColor: undefined }, }, ], dataset: {}, @@ -682,11 +683,12 @@ describe('normalizeTable', () => { segmentType: 'Br', format: { fontSize: '10px', + textColor: undefined, }, }, ], format: {}, - segmentFormat: { fontSize: '10px' }, + segmentFormat: { fontSize: '10px', textColor: undefined }, }, ], dataset: {}, @@ -727,9 +729,12 @@ describe('normalizeTable', () => { segments: [ { segmentType: 'Br', - format: {}, + format: { + textColor: undefined, + }, }, ], + segmentFormat: { textColor: undefined }, format: {}, }; From 47dd150d6a6efd8af03a9f93e9ade864ecd55202 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Thu, 21 Sep 2023 17:24:10 -0300 Subject: [PATCH 65/75] fix test --- .../lib/modelApi/table/normalizeTable.ts | 20 +++++++++---------- .../lib/publicApi/table/insertTable.ts | 2 +- .../test/modelApi/table/normalizeTableTest.ts | 8 ++++---- 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/normalizeTable.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/normalizeTable.ts index d682dfbd0da..07bb23f3efa 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/normalizeTable.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/normalizeTable.ts @@ -1,5 +1,6 @@ import { addBlock, addSegment, createBr, createParagraph } from 'roosterjs-content-model-dom'; import { arrayPush } from 'roosterjs-editor-dom'; +import { format } from 'path'; import { ContentModelSegment, ContentModelSegmentFormat, @@ -30,20 +31,17 @@ export function normalizeTable( table.rows.forEach((row, rowIndex) => { row.cells.forEach((cell, colIndex) => { if (cell.blocks.length == 0) { + const format = cell.format.textColor + ? { + ...defaultSegmentFormat, + textColor: cell.format.textColor, + } + : defaultSegmentFormat; addBlock( cell, - createParagraph(undefined /*isImplicit*/, undefined /*format*/, { - ...defaultSegmentFormat, - textColor: cell.format.textColor, - }) - ); - addSegment( - cell, - createBr({ - ...defaultSegmentFormat, - textColor: cell.format.textColor, - }) + createParagraph(undefined /*isImplicit*/, undefined /*format*/, format) ); + addSegment(cell, createBr(format)); } if (rowIndex == 0) { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/insertTable.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/insertTable.ts index 530d2a46785..d1bdc8348ae 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/insertTable.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/insertTable.ts @@ -32,7 +32,7 @@ export default function insertTable( const doc = createContentModelDocument(); const table = createTableStructure(doc, columns, rows); - normalizeTable(table, getPendingFormat(editor) || insertPosition.marker.format); + normalizeTable(table); // Assign default vertical align format = format || { verticalAlign: 'top' }; applyTableFormat(table, format); diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/table/normalizeTableTest.ts b/packages-content-model/roosterjs-content-model-editor/test/modelApi/table/normalizeTableTest.ts index 43139341a84..e5af536bae2 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/table/normalizeTableTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/modelApi/table/normalizeTableTest.ts @@ -81,11 +81,11 @@ describe('normalizeTable', () => { segments: [ { segmentType: 'Br', - format: { textColor: undefined }, + format: {}, }, ], format: {}, - segmentFormat: { textColor: undefined }, + segmentFormat: {}, }, ], dataset: {}, @@ -688,7 +688,7 @@ describe('normalizeTable', () => { }, ], format: {}, - segmentFormat: { fontSize: '10px', textColor: undefined }, + segmentFormat: { fontSize: '10px' }, }, ], dataset: {}, @@ -734,7 +734,7 @@ describe('normalizeTable', () => { }, }, ], - segmentFormat: { textColor: undefined }, + segmentFormat: {}, format: {}, }; From 750dab21d1303bb9409642f4cb42af4963fc8b83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Thu, 21 Sep 2023 17:39:01 -0300 Subject: [PATCH 66/75] fix tests --- .../lib/modelApi/table/normalizeTable.ts | 1 - .../lib/modelApi/table/setTableCellBackgroundColor.ts | 2 +- .../test/modelApi/table/normalizeTableTest.ts | 7 +------ 3 files changed, 2 insertions(+), 8 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/normalizeTable.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/normalizeTable.ts index 07bb23f3efa..954ebc51819 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/normalizeTable.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/normalizeTable.ts @@ -1,6 +1,5 @@ import { addBlock, addSegment, createBr, createParagraph } from 'roosterjs-content-model-dom'; import { arrayPush } from 'roosterjs-editor-dom'; -import { format } from 'path'; import { ContentModelSegment, ContentModelSegmentFormat, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/setTableCellBackgroundColor.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/setTableCellBackgroundColor.ts index f6612dbe572..a6420778e96 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/setTableCellBackgroundColor.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/setTableCellBackgroundColor.ts @@ -40,7 +40,7 @@ export function setTableCellBackgroundColor( delete cell.format.textColor; } - if (overrideTextColor) { + if (overrideTextColor && cell.format.textColor) { cell.blocks.forEach(block => { if (block.blockType == 'Paragraph') { block.segmentFormat = { diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/table/normalizeTableTest.ts b/packages-content-model/roosterjs-content-model-editor/test/modelApi/table/normalizeTableTest.ts index e5af536bae2..f2379aa2f28 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/table/normalizeTableTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/modelApi/table/normalizeTableTest.ts @@ -85,7 +85,6 @@ describe('normalizeTable', () => { }, ], format: {}, - segmentFormat: {}, }, ], dataset: {}, @@ -683,7 +682,6 @@ describe('normalizeTable', () => { segmentType: 'Br', format: { fontSize: '10px', - textColor: undefined, }, }, ], @@ -729,12 +727,9 @@ describe('normalizeTable', () => { segments: [ { segmentType: 'Br', - format: { - textColor: undefined, - }, + format: {}, }, ], - segmentFormat: {}, format: {}, }; From b7bb78beed9ed5c39093e0e88cfda0d4c2029380 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Thu, 21 Sep 2023 17:42:46 -0300 Subject: [PATCH 67/75] remove code --- .../lib/publicApi/table/insertTable.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/insertTable.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/insertTable.ts index d1bdc8348ae..530d2a46785 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/insertTable.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/insertTable.ts @@ -32,7 +32,7 @@ export default function insertTable( const doc = createContentModelDocument(); const table = createTableStructure(doc, columns, rows); - normalizeTable(table); + normalizeTable(table, getPendingFormat(editor) || insertPosition.marker.format); // Assign default vertical align format = format || { verticalAlign: 'top' }; applyTableFormat(table, format); From 3585d2225c4865a3925a5e96fe9ac68307694008 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Thu, 21 Sep 2023 17:43:38 -0300 Subject: [PATCH 68/75] remove code --- .../lib/modelApi/table/normalizeTable.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/normalizeTable.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/normalizeTable.ts index 954ebc51819..41c28233876 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/normalizeTable.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/normalizeTable.ts @@ -38,7 +38,7 @@ export function normalizeTable( : defaultSegmentFormat; addBlock( cell, - createParagraph(undefined /*isImplicit*/, undefined /*format*/, format) + createParagraph(undefined /*isImplicit*/, undefined /*blockFormat*/, format) ); addSegment(cell, createBr(format)); } From e808fbfa02e8595a6e610f5d1200975e9e3342c3 Mon Sep 17 00:00:00 2001 From: Bryan Valverde U Date: Thu, 21 Sep 2023 15:28:30 -0600 Subject: [PATCH 69/75] Do not consider Bold, Italic and Underline from ContentModelDocument when retrieving the FormatState (#2081) * init * fix test --- .../common/retrieveModelFormatState.ts | 7 +++- .../common/retrieveModelFormatStateTest.ts | 35 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/retrieveModelFormatState.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/retrieveModelFormatState.ts index ea1df7077df..859fb89f1ba 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/retrieveModelFormatState.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/retrieveModelFormatState.ts @@ -52,12 +52,17 @@ export function retrieveModelFormatState( // Segment formats segments?.forEach(segment => { if (isFirstSegment || segment.segmentType != 'SelectionMarker') { + const modelFormat = Object.assign({}, model.format); + delete modelFormat?.italic; + delete modelFormat?.underline; + delete modelFormat?.fontWeight; + retrieveSegmentFormat( formatState, isFirst, Object.assign( {}, - model.format, + modelFormat, block.format, block.decorator?.format, segment.format, diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/retrieveModelFormatStateTest.ts b/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/retrieveModelFormatStateTest.ts index 135e32a41dd..d35344a4148 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/retrieveModelFormatStateTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/retrieveModelFormatStateTest.ts @@ -708,6 +708,41 @@ describe('retrieveModelFormatState', () => { }); }); + it('With default format and other different format', () => { + const model = createContentModelDocument({ + fontFamily: 'Arial', + fontSize: '12px', + underline: true, + fontWeight: 'bold', + italic: true, + }); + const result: ContentModelFormatState = {}; + const para = createParagraph(); + const text1 = createText('test1', { fontFamily: 'Tahoma', fontSize: '15px' }); + para.segments.push(text1); + + text1.isSelected = true; + + spyOn(iterateSelections, 'iterateSelections').and.callFake((path, callback) => { + callback(path, undefined, para, [text1]); + return false; + }); + + retrieveModelFormatState(model, null, result); + + expect(result).toEqual({ + isBlockQuote: false, + isBold: false, + isSuperscript: false, + isSubscript: false, + fontSize: '11.25pt', + isCodeInline: false, + canUnlink: false, + canAddImageAltText: false, + fontName: 'Tahoma', + }); + }); + it('With default format and other different format', () => { const model = createContentModelDocument({ fontFamily: 'Arial', From f152042e98b6f046a6565e08464a23fe52c7e2c8 Mon Sep 17 00:00:00 2001 From: Roma Shah Date: Thu, 21 Sep 2023 14:31:34 -0700 Subject: [PATCH 70/75] Update RoosterJS version to 8.56 and content model to 0.16 --- versions.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/versions.json b/versions.json index 140dc48f39b..53f56a1651d 100644 --- a/versions.json +++ b/versions.json @@ -1,5 +1,5 @@ { - "packages": "8.55.0", + "packages": "8.56.0", "packages-ui": "8.51.0", - "packages-content-model": "0.15.0" + "packages-content-model": "0.16.0" } From 4fc1f3ad677067c42d8606c95ba7da253b61ca0d Mon Sep 17 00:00:00 2001 From: Roma Shah Date: Thu, 21 Sep 2023 15:45:27 -0700 Subject: [PATCH 71/75] Fix files that were merged wrongly --- .../lib/editor/coreApi/switchShadowEdit.ts | 12 ++++++++-- .../lib/publicApi/block/setHeadingLevel.ts | 24 ++++++++++++------- .../test/imageEdit/rotatorTest.ts | 1 + 3 files changed, 27 insertions(+), 10 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/switchShadowEdit.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/switchShadowEdit.ts index 55ea53a46a3..feb4d828d94 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/switchShadowEdit.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/switchShadowEdit.ts @@ -44,8 +44,16 @@ export const switchShadowEdit: SwitchShadowEdit = (editorCore, isOn): void => { core.lifecycle.shadowEditFragment = null; core.lifecycle.shadowEditSelectionPath = null; - if (core.cachedModel) { - core.api.setContentModel(core, core.cachedModel); + core.api.triggerEvent( + core, + { + eventType: PluginEventType.LeavingShadowEdit, + }, + false /*broadcast*/ + ); + + if (core.cache.cachedModel) { + core.api.setContentModel(core, core.cache.cachedModel); } } } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/setHeadingLevel.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/setHeadingLevel.ts index cbb797b2107..83fd603ba54 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/setHeadingLevel.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/setHeadingLevel.ts @@ -1,13 +1,18 @@ -import { defaultImplicitFormatMap } from 'roosterjs-content-model-dom'; +import { ContentModelParagraphDecorator } from 'roosterjs-content-model-types'; import { formatParagraphWithContentModel } from '../utils/formatParagraphWithContentModel'; import { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; -import { - ContentModelParagraphDecorator, - ContentModelSegmentFormat, -} from 'roosterjs-content-model-types'; type HeadingLevelTags = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; +const HeaderFontSizes: Record = { + h1: '2em', + h2: '1.5em', + h3: '1.17em', + h4: '1em', + h5: '0.83em', + h6: '0.67em', +}; + /** * Set heading level of selected paragraphs * @param editor The editor to set heading level to @@ -22,13 +27,16 @@ export default function setHeadingLevel( headingLevel > 0 ? (('h' + headingLevel) as HeadingLevelTags | null) : getExistingHeadingTag(para.decorator); - const headingStyle = - (tagName && (defaultImplicitFormatMap[tagName] as ContentModelSegmentFormat)) || {}; if (headingLevel > 0) { para.decorator = { tagName: tagName!, - format: { ...headingStyle }, + format: tagName + ? { + fontWeight: 'bold', + fontSize: HeaderFontSizes[tagName], + } + : {}, }; // Remove existing formats since tags have default font size and weight diff --git a/packages/roosterjs-editor-plugins/test/imageEdit/rotatorTest.ts b/packages/roosterjs-editor-plugins/test/imageEdit/rotatorTest.ts index 57c2e02b2e0..4f34ca2dc34 100644 --- a/packages/roosterjs-editor-plugins/test/imageEdit/rotatorTest.ts +++ b/packages/roosterjs-editor-plugins/test/imageEdit/rotatorTest.ts @@ -231,6 +231,7 @@ describe('updateRotateHandlePosition', () => { }, '-6px', '0px', + '0px', { top: 2, bottom: 3, From 46ffb8ab56c339de33cc20fa88d595f83f12b9b1 Mon Sep 17 00:00:00 2001 From: Roma Shah Date: Thu, 21 Sep 2023 15:46:44 -0700 Subject: [PATCH 72/75] Also bumping UI package version --- versions.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/versions.json b/versions.json index 53f56a1651d..c9be6fb802f 100644 --- a/versions.json +++ b/versions.json @@ -1,5 +1,5 @@ { "packages": "8.56.0", - "packages-ui": "8.51.0", + "packages-ui": "8.52.0", "packages-content-model": "0.16.0" } From 43da112146dd794f81f7e87b9bfcb33cc7473085 Mon Sep 17 00:00:00 2001 From: Andres-CT98 <107568016+Andres-CT98@users.noreply.github.com> Date: Thu, 21 Sep 2023 17:43:34 -0600 Subject: [PATCH 73/75] select new row/column (#2094) --- .../TableResize/editors/TableInserter.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/roosterjs-editor-plugins/lib/plugins/TableResize/editors/TableInserter.ts b/packages/roosterjs-editor-plugins/lib/plugins/TableResize/editors/TableInserter.ts index 2fdf6af3639..3c5209b39e8 100644 --- a/packages/roosterjs-editor-plugins/lib/plugins/TableResize/editors/TableInserter.ts +++ b/packages/roosterjs-editor-plugins/lib/plugins/TableResize/editors/TableInserter.ts @@ -1,7 +1,7 @@ import Disposable from '../../../pluginUtils/Disposable'; import TableEditFeature from './TableEditorFeature'; import { createElement, getIntersectedRect, normalizeRect, VTable } from 'roosterjs-editor-dom'; -import { CreateElementData, IEditor, TableOperation } from 'roosterjs-editor-types'; +import { CreateElementData, IEditor, TableOperation, TableSelection } from 'roosterjs-editor-types'; const INSERTER_COLOR = '#4A4A4A'; const INSERTER_COLOR_DARK_MODE = 'white'; @@ -118,6 +118,21 @@ class TableInsertHandler implements Disposable { vtable.writeBack(); this.onInsert(vtable.table); + + // Select newly inserted row or column + if (vtable.row != undefined && vtable.col != undefined && vtable.cells) { + const inserted: TableSelection = this.isHorizontal + ? { + firstCell: { x: 0, y: vtable.row + 1 }, + lastCell: { x: vtable.cells[vtable.row].length - 1, y: vtable.row + 1 }, + } + : { + firstCell: { x: vtable.col + 1, y: 0 }, + lastCell: { x: vtable.col + 1, y: vtable.cells.length - 1 }, + }; + + this.editor.select(vtable.table, inserted); + } }; } From 31fa011a65c55b750d0d04511769808bdd3f72d8 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Thu, 21 Sep 2023 23:15:09 -0700 Subject: [PATCH 74/75] Content Model: Add model into ContentChangedEvent (#2076) * Content Model: Add model into ContentChangedEvent * fix build * add demo site page * fix build * fix test * Fix entity issue --------- Co-authored-by: Bryan Valverde U --- .../controls/ContentModelEditorMainPane.tsx | 6 +- .../eventViewer/ContentModelEventViewPane.tsx | 324 ++++++++++++++++++ .../ContentModelEventViewPlugin.ts | 21 ++ .../lib/editor/ContentModelEditor.ts | 4 +- .../lib/index.ts | 6 + .../lib/publicApi/entity/insertEntity.ts | 10 +- .../publicApi/utils/formatWithContentModel.ts | 34 +- .../lib/publicTypes/ContentModelEditorCore.ts | 2 +- .../lib/publicTypes/IContentModelEditor.ts | 2 +- .../event/ContentModelContentChangedEvent.ts | 36 ++ .../test/editor/ContentModelEditorTest.ts | 18 +- .../ContentModelCopyPastePluginTest.ts | 6 +- .../plugins/ContentModelFormatPluginTest.ts | 14 +- .../publicApi/block/paragraphTestCommon.ts | 4 +- .../test/publicApi/block/setAlignmentTest.ts | 6 + .../test/publicApi/entity/insertEntityTest.ts | 46 +-- .../test/publicApi/image/changeImageTest.ts | 2 +- .../test/publicApi/image/insertImageTest.ts | 4 +- .../publicApi/link/adjustLinkSelectionTest.ts | 3 + .../test/publicApi/link/insertLinkTest.ts | 5 + .../test/publicApi/link/removeLinkTest.ts | 3 + .../test/publicApi/list/toggleBulletTest.ts | 3 + .../publicApi/list/toggleNumberingTest.ts | 3 + .../publicApi/segment/changeFontSizeTest.ts | 2 + .../publicApi/segment/segmentTestCommon.ts | 4 +- .../publicApi/table/setTableCellShadeTest.ts | 3 + .../utils/formatImageWithContentModelTest.ts | 4 +- .../formatParagraphWithContentModelTest.ts | 3 + .../formatSegmentWithContentModelTest.ts | 3 + .../utils/formatWithContentModelTest.ts | 31 +- .../test/publicApi/utils/pasteTest.ts | 19 +- 31 files changed, 535 insertions(+), 96 deletions(-) create mode 100644 demo/scripts/controls/sidePane/eventViewer/ContentModelEventViewPane.tsx create mode 100644 demo/scripts/controls/sidePane/eventViewer/ContentModelEventViewPlugin.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/publicTypes/event/ContentModelContentChangedEvent.ts diff --git a/demo/scripts/controls/ContentModelEditorMainPane.tsx b/demo/scripts/controls/ContentModelEditorMainPane.tsx index 2117597746d..9ffa0397c7e 100644 --- a/demo/scripts/controls/ContentModelEditorMainPane.tsx +++ b/demo/scripts/controls/ContentModelEditorMainPane.tsx @@ -2,11 +2,11 @@ import * as React from 'react'; import * as ReactDOM from 'react-dom'; import ApiPlaygroundPlugin from './sidePane/contentModelApiPlayground/ApiPlaygroundPlugin'; import ContentModelEditorOptionsPlugin from './sidePane/editorOptions/ContentModelEditorOptionsPlugin'; +import ContentModelEventViewPlugin from './sidePane/eventViewer/ContentModelEventViewPlugin'; import ContentModelFormatPainterPlugin from './contentModel/plugins/ContentModelFormatPainterPlugin'; import ContentModelFormatStatePlugin from './sidePane/formatState/ContentModelFormatStatePlugin'; import ContentModelPanePlugin from './sidePane/contentModel/ContentModelPanePlugin'; import ContentModelRibbon from './ribbonButtons/contentModel/ContentModelRibbon'; -import EventViewPlugin from './sidePane/eventViewer/EventViewPlugin'; import getToggleablePlugins from './getToggleablePlugins'; import MainPaneBase from './MainPaneBase'; import SampleEntityPlugin from './sampleEntity/SampleEntityPlugin'; @@ -84,7 +84,7 @@ const DarkTheme: PartialTheme = { class ContentModelEditorMainPane extends MainPaneBase { private formatStatePlugin: ContentModelFormatStatePlugin; private editorOptionPlugin: ContentModelEditorOptionsPlugin; - private eventViewPlugin: EventViewPlugin; + private eventViewPlugin: ContentModelEventViewPlugin; private apiPlaygroundPlugin: ApiPlaygroundPlugin; private ContentModelPanePlugin: ContentModelPanePlugin; private ribbonPlugin: RibbonPlugin; @@ -100,7 +100,7 @@ class ContentModelEditorMainPane extends MainPaneBase { this.formatStatePlugin = new ContentModelFormatStatePlugin(); this.editorOptionPlugin = new ContentModelEditorOptionsPlugin(); - this.eventViewPlugin = new EventViewPlugin(); + this.eventViewPlugin = new ContentModelEventViewPlugin(); this.apiPlaygroundPlugin = new ApiPlaygroundPlugin(); this.snapshotPlugin = new SnapshotPlugin(); this.ContentModelPanePlugin = new ContentModelPanePlugin(); diff --git a/demo/scripts/controls/sidePane/eventViewer/ContentModelEventViewPane.tsx b/demo/scripts/controls/sidePane/eventViewer/ContentModelEventViewPane.tsx new file mode 100644 index 00000000000..1e552ff9937 --- /dev/null +++ b/demo/scripts/controls/sidePane/eventViewer/ContentModelEventViewPane.tsx @@ -0,0 +1,324 @@ +import * as React from 'react'; +import { ContentModelContentChangedEvent } from 'roosterjs-content-model-editor'; +import { EntityOperation, PluginEvent, PluginEventType } from 'roosterjs-editor-types'; +import { SidePaneElementProps } from '../SidePaneElement'; +import { + getObjectKeys, + getTagOfNode, + HtmlSanitizer, + readFile, + safeInstanceOf, +} from 'roosterjs-editor-dom'; + +const styles = require('./EventViewPane.scss'); + +export interface EventEntry { + index: number; + time: Date; + event: PluginEvent; +} + +export interface EventViewPaneState { + displayCount: number; + currentIndex: number; +} + +const EventTypeMap: { [key in PluginEventType]: string } = { + [PluginEventType.BeforeDispose]: 'BeforeDispose', + [PluginEventType.BeforePaste]: 'BeforePaste', + [PluginEventType.CompositionEnd]: 'CompositionEnd', + [PluginEventType.ContentChanged]: 'ContentChanged', + [PluginEventType.EditorReady]: 'EditorReady', + [PluginEventType.EntityOperation]: 'EntityOperation', + [PluginEventType.ExtractContentWithDom]: 'ExtractContentWithDom', + [PluginEventType.KeyDown]: 'KeyDown', + [PluginEventType.KeyPress]: 'KeyPress', + [PluginEventType.KeyUp]: 'KeyUp', + [PluginEventType.MouseDown]: 'MouseDown', + [PluginEventType.MouseUp]: 'MouseUp', + [PluginEventType.Input]: 'Input', + [PluginEventType.PendingFormatStateChanged]: 'PendingFormatStateChanged', + [PluginEventType.Scroll]: 'Scroll', + [PluginEventType.BeforeCutCopy]: 'BeforeCutCopy', + [PluginEventType.ContextMenu]: 'ContextMenu', + [PluginEventType.EnteredShadowEdit]: 'EnteredShadowEdit', + [PluginEventType.LeavingShadowEdit]: 'LeavingShadowEdit', + [PluginEventType.EditImage]: 'EditImage', + [PluginEventType.BeforeSetContent]: 'BeforeSetContent', + [PluginEventType.ZoomChanged]: 'ZoomChanged', + [PluginEventType.SelectionChanged]: 'SelectionChanged', + [PluginEventType.BeforeKeyboardEditing]: 'BeforeKeyboardEditing', +}; + +const EntityOperationMap: { [key in EntityOperation]: string } = { + [EntityOperation.AddShadowRoot]: 'AddShadowRoot', + [EntityOperation.RemoveShadowRoot]: 'RemoveShadowRoot', + [EntityOperation.Click]: 'Click', + [EntityOperation.ContextMenu]: 'ContextMenu', + [EntityOperation.Escape]: 'Escape', + [EntityOperation.NewEntity]: 'NewEntity', + [EntityOperation.Overwrite]: 'Overwrite', + [EntityOperation.PartialOverwrite]: 'PartialOverwrite', + [EntityOperation.RemoveFromEnd]: 'RemoveFromEnd', + [EntityOperation.RemoveFromStart]: 'RemoveFromStart', + [EntityOperation.ReplaceTemporaryContent]: 'ReplaceTemporaryContent', + [EntityOperation.UpdateEntityState]: 'UpdateEntityState', +}; + +export default class ContentModelEventViewPane extends React.Component< + SidePaneElementProps, + EventViewPaneState +> { + private events: EventEntry[] = []; + private displayCount = React.createRef(); + private lastIndex = 0; + + constructor(props: SidePaneElementProps) { + super(props); + this.state = { + displayCount: 20, + currentIndex: -1, + }; + } + + render() { + let displayCount = Math.min(this.events.length, this.state.displayCount); + let displayedEvents = + displayCount > 0 ? this.events.slice(this.events.length - displayCount) : []; + displayedEvents = displayedEvents.reverse(); + + return ( + <> +
+ Show item count: + {' '} + +
+
+ {displayedEvents.map(event => ( +
+ + {`${event.time.getHours()}:${event.time.getMinutes()}:${event.time.getSeconds()}.${event.time.getMilliseconds()} `} + {EventTypeMap[event.event.eventType]} + +
+ {this.renderEvent(event.event)} +
+
+ ))} +
+ + ); + } + + addEvent(event: PluginEvent) { + if (this.state.displayCount > 0) { + if (event.eventType == PluginEventType.BeforePaste) { + const sanitizer = new HtmlSanitizer(event.sanitizingOption); + const fragment = event.fragment.cloneNode(true /*deep*/) as DocumentFragment; + + sanitizer.convertGlobalCssToInlineCss(fragment); + sanitizer.sanitize(fragment); + (event.clipboardData as any).html = this.getHtml(fragment); + } + + this.events.push({ + time: new Date(), + event: event, + index: this.lastIndex++, + }); + + while (this.events.length > 100) { + this.events.shift(); + } + this.setState({ + currentIndex: this.lastIndex, + }); + } + } + + private renderEvent(event: PluginEvent): JSX.Element { + switch (event.eventType) { + case PluginEventType.KeyDown: + case PluginEventType.KeyPress: + case PluginEventType.KeyUp: + return ( + + Key= + {event.rawEvent.which} + + ); + + case PluginEventType.MouseDown: + case PluginEventType.MouseUp: + case PluginEventType.ContextMenu: + return ( + + Button= + {event.rawEvent.button}, SrcElement= + {event.rawEvent.target && getTagOfNode(event.rawEvent.target as Node)}, + PageX= + {event.rawEvent.pageX}, PageY= + {event.rawEvent.pageY} + + ); + + case PluginEventType.ContentChanged: + return ( + + Source= + {event.source}, Data= + {event.data && event.data.toString && event.data.toString()} + {!!(event as ContentModelContentChangedEvent).contentModel && ( +
+ Content Model +
+                                    {JSON.stringify(
+                                        (event as ContentModelContentChangedEvent).contentModel,
+                                        (key, value) =>
+                                            safeInstanceOf(value, 'Node')
+                                                ? Object.prototype.toString.apply(value)
+                                                : key == 'src'
+                                                ? value.length > 100
+                                                    ? value.substring(0, 97) + '...'
+                                                    : value
+                                                : value,
+                                        2
+                                    )}
+                                
+
+ )} +
+ ); + + case PluginEventType.BeforePaste: + return ( + + Types= + {event.clipboardData.types.join()} + {this.renderPasteContent('Plain text', event.clipboardData.text)} + {this.renderPasteContent( + 'Sanitized HTML', + (event.clipboardData as any).html + )} + {this.renderPasteContent('Original HTML', event.clipboardData.rawHtml)} + {this.renderPasteContent('Image', event.clipboardData.image, img => ( + ref && this.renderImage(ref, img)} + className={styles.img} + /> + ))} + {this.renderPasteContent( + 'LinkPreview', + event.clipboardData.linkPreview + ? JSON.stringify(event.clipboardData.linkPreview) + : '' + )} + Paste from keyboard or native context menu: + {event.clipboardData.pasteNativeEvent ? ' true' : ' false'} + {getObjectKeys(event.clipboardData.customValues).map(contentType => + this.renderPasteContent( + contentType, + event.clipboardData.customValues[contentType] + ) + )} + + ); + case PluginEventType.PendingFormatStateChanged: + const formatState = event.formatState; + const keys = getObjectKeys(formatState); + return {keys.map(key => `${key}=${event.formatState[key]}; `)}; + + case PluginEventType.EntityOperation: + const { + operation, + entity: { id, type }, + } = event; + return ( + + Operation={EntityOperationMap[operation]} Type={type}; Id={id} + + ); + + case PluginEventType.BeforeCutCopy: + const { isCut } = event; + return isCut={isCut ? 'true' : 'false'}; + + case PluginEventType.EditImage: + return ( + <> + new src={event.newSrc.substr(0, 100)} + + ); + + case PluginEventType.ZoomChanged: + return ( + + Old value={event.oldZoomScale} New value={event.newZoomScale} + + ); + + case PluginEventType.BeforeKeyboardEditing: + return Key code={event.rawEvent.which}; + + default: + return null; + } + } + + private clear = () => { + this.events = []; + this.setState({ + currentIndex: -1, + }); + }; + + private renderImage = (img: HTMLImageElement, imageFile: File) => { + readFile(imageFile, dataUrl => (img.src = dataUrl)); + }; + + private onDisplayCountChanged = () => { + let value = parseInt(this.displayCount.current.value); + this.setState({ + displayCount: value, + }); + }; + + private renderPasteContent( + title: string, + content: any, + renderer: (content: any) => JSX.Element = content => {content} + ): JSX.Element { + return ( + content && ( +
+ {title} +
{renderer(content)}
+
+ ) + ); + } + + private getHtml(fragment: DocumentFragment) { + const stringArray: string[] = []; + for (let child = fragment.firstChild; child; child = child.nextSibling) { + stringArray.push( + safeInstanceOf(child, 'HTMLElement') + ? child.outerHTML + : safeInstanceOf(child, 'Text') + ? child.nodeValue + : '' + ); + } + + return stringArray.join(''); + } +} diff --git a/demo/scripts/controls/sidePane/eventViewer/ContentModelEventViewPlugin.ts b/demo/scripts/controls/sidePane/eventViewer/ContentModelEventViewPlugin.ts new file mode 100644 index 00000000000..0f43ff32ff0 --- /dev/null +++ b/demo/scripts/controls/sidePane/eventViewer/ContentModelEventViewPlugin.ts @@ -0,0 +1,21 @@ +import ContentModelEventViewPane from './ContentModelEventViewPane'; +import SidePanePluginImpl from '../SidePanePluginImpl'; +import { PluginEvent } from 'roosterjs-editor-types'; +import { SidePaneElementProps } from '../SidePaneElement'; + +export default class ContentModelEventViewPlugin extends SidePanePluginImpl< + ContentModelEventViewPane, + SidePaneElementProps +> { + constructor() { + super(ContentModelEventViewPane, 'event', 'Event Viewer'); + } + + onPluginEvent(e: PluginEvent) { + this.getComponent(component => component.addEvent(e)); + } + + getComponentProps(base: SidePaneElementProps) { + return base; + } +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts index d8e57a63c4e..7c3e9ffad4a 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts @@ -50,10 +50,10 @@ export default class ContentModelEditor model: ContentModelDocument, option?: ModelToDomOption, onNodeCreated?: OnNodeCreated - ) { + ): SelectionRangeEx | null { const core = this.getCore(); - core.api.setContentModel(core, model, option, onNodeCreated); + return core.api.setContentModel(core, model, option, onNodeCreated); } /** diff --git a/packages-content-model/roosterjs-content-model-editor/lib/index.ts b/packages-content-model/roosterjs-content-model-editor/lib/index.ts index b07c19724a5..66ccdd8cdea 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/index.ts @@ -13,6 +13,12 @@ export { ContentModelBeforePasteEventData, CompatibleContentModelBeforePasteEvent, } from './publicTypes/event/ContentModelBeforePasteEvent'; +export { + default as ContentModelContentChangedEvent, + CompatibleContentModelContentChangedEvent, + ContentModelContentChangedEventData, +} from './publicTypes/event/ContentModelContentChangedEvent'; + export { IContentModelEditor, ContentModelEditorOptions } from './publicTypes/IContentModelEditor'; export { InsertPoint } from './publicTypes/selection/InsertPoint'; export { TableSelectionContext } from './publicTypes/selection/TableSelectionContext'; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/entity/insertEntity.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/entity/insertEntity.ts index 00559f98260..2d7774da420 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/entity/insertEntity.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/entity/insertEntity.ts @@ -70,6 +70,7 @@ export default function insertEntity( commitEntity(wrapper, type, true /*isReadonly*/); const entityModel = createEntity(wrapper, true /*isReadonly*/, type); + let newEntity: Entity | null = null; formatWithContentModel( editor, @@ -93,12 +94,13 @@ export default function insertEntity( }, { selectionOverride: typeof position === 'object' ? position : undefined, + changeSource: ChangeSource.InsertEntity, + getChangeData: () => { + newEntity = getEntityFromElement(wrapper); + return newEntity; + }, } ); - const newEntity = getEntityFromElement(wrapper); - - editor.triggerContentChangedEvent(ChangeSource.InsertEntity, newEntity); - return newEntity; } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatWithContentModel.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatWithContentModel.ts index 327d33b0c58..d47922f5134 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatWithContentModel.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatWithContentModel.ts @@ -1,4 +1,5 @@ -import { ChangeSource, PluginEventType } from 'roosterjs-editor-types'; +import { ChangeSource, PluginEventType, SelectionRangeEx } from 'roosterjs-editor-types'; +import { ContentModelContentChangedEventData } from '../../publicTypes/event/ContentModelContentChangedEvent'; import { getPendingFormat, setPendingFormat } from '../../modelApi/format/pendingFormat'; import { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; import { @@ -40,15 +41,15 @@ export function formatWithContentModel( deletedEntities: [], rawEvent, }; + let rangeEx: SelectionRangeEx | undefined; if (formatter(model, context)) { - const callback = () => { + const writeBack = () => { handleNewEntities(editor, context); handleDeletedEntities(editor, context); - if (model) { - editor.setContentModel(model, undefined /*options*/, onNodeCreated); - } + rangeEx = + editor.setContentModel(model, undefined /*options*/, onNodeCreated) || undefined; if (preservePendingFormat) { const pendingFormat = getPendingFormat(editor); @@ -58,26 +59,31 @@ export function formatWithContentModel( setPendingFormat(editor, pendingFormat, pos); } } - - return getChangeData?.(); }; if (context.skipUndoSnapshot) { - const contentChangedEventData = callback(); - - if (changeSource) { - editor.triggerContentChangedEvent(changeSource, contentChangedEventData); - } + writeBack(); } else { editor.addUndoSnapshot( - callback, - changeSource || ChangeSource.Format, + writeBack, + undefined /*changeSource, passing undefined here to avoid triggering ContentChangedEvent. We will trigger it using it with Content Model below */, false /*canUndoByBackspace*/, { formatApiName: apiName, } ); } + + const eventData: ContentModelContentChangedEventData = { + contentModel: model, + rangeEx: rangeEx, + source: changeSource || ChangeSource.Format, + data: getChangeData?.(), + additionalData: { + formatApiName: apiName, + }, + }; + editor.triggerPluginEvent(PluginEventType.ContentChanged, eventData); } } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelEditorCore.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelEditorCore.ts index b3176b9b775..cab3015c2bb 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelEditorCore.ts @@ -41,7 +41,7 @@ export type SetContentModel = ( model: ContentModelDocument, option?: ModelToDomOption, onNodeCreated?: OnNodeCreated -) => void; +) => SelectionRangeEx | null; /** * The interface for the map of core API for Content Model editor. diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts index 3c84db57c89..a66976dbbc8 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts @@ -34,7 +34,7 @@ export interface IContentModelEditor extends IEditor { model: ContentModelDocument, option?: ModelToDomOption, onNodeCreated?: OnNodeCreated - ): void; + ): SelectionRangeEx | null; /** * Notify editor the current cache may be invalid diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/event/ContentModelContentChangedEvent.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/event/ContentModelContentChangedEvent.ts new file mode 100644 index 00000000000..debe522cd9a --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/event/ContentModelContentChangedEvent.ts @@ -0,0 +1,36 @@ +import { ContentModelDocument } from 'roosterjs-content-model-types'; +import { + CompatibleContentChangedEvent, + ContentChangedEvent, + ContentChangedEventData, + SelectionRangeEx, +} from 'roosterjs-editor-types'; + +/** + * Data of ContentModelContentChangedEvent + */ +export interface ContentModelContentChangedEventData extends ContentChangedEventData { + /** + * The content model that is applied which causes this content changed event + */ + contentModel: ContentModelDocument; + + /** + * Selection range applied to the document + */ + rangeEx?: SelectionRangeEx; +} + +/** + * Represents a change to the editor made by another plugin with content model inside + */ +export default interface ContentModelContentChangedEvent + extends ContentChangedEvent, + ContentModelContentChangedEventData {} + +/** + * Represents a change to the editor made by another plugin with content model inside + */ +export interface CompatibleContentModelContentChangedEvent + extends CompatibleContentChangedEvent, + ContentModelContentChangedEventData {} diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts index 7c2c90ad4ac..dd834130598 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts @@ -75,19 +75,15 @@ describe('ContentModelEditor', () => { }); it('setContentModel with normal selection', () => { - const mockedFragment = 'Fragment' as any; const mockedRange = { type: SelectionRangeTypes.Normal, ranges: [document.createRange()], } as any; - const mockedPairs = 'Pairs' as any; - - const mockedResult = [mockedFragment, mockedRange, mockedPairs] as any; const mockedModel = 'MockedModel' as any; const mockedContext = 'MockedContext' as any; const mockedConfig = 'MockedConfig' as any; - spyOn(contentModelToDom, 'contentModelToDom').and.returnValue(mockedResult); + spyOn(contentModelToDom, 'contentModelToDom').and.returnValue(mockedRange); spyOn(createModelToDomContext, 'createModelToDomContextWithConfig').and.returnValue( mockedContext ); @@ -98,7 +94,7 @@ describe('ContentModelEditor', () => { spyOn((editor as any).core.api, 'createEditorContext').and.returnValue(editorContext); - editor.setContentModel(mockedModel); + const rangeEx = editor.setContentModel(mockedModel); expect(contentModelToDom.contentModelToDom).toHaveBeenCalledTimes(1); expect(contentModelToDom.contentModelToDom).toHaveBeenCalledWith( @@ -112,22 +108,19 @@ describe('ContentModelEditor', () => { mockedConfig, editorContext ); + expect(rangeEx).toBe(mockedRange); }); it('setContentModel', () => { - const mockedFragment = 'Fragment' as any; const mockedRange = { type: SelectionRangeTypes.Normal, ranges: [document.createRange()], } as any; - const mockedPairs = 'Pairs' as any; - - const mockedResult = [mockedFragment, mockedRange, mockedPairs] as any; const mockedModel = 'MockedModel' as any; const mockedContext = 'MockedContext' as any; const mockedConfig = 'MockedConfig' as any; - spyOn(contentModelToDom, 'contentModelToDom').and.returnValue(mockedResult); + spyOn(contentModelToDom, 'contentModelToDom').and.returnValue(mockedRange); spyOn(createModelToDomContext, 'createModelToDomContextWithConfig').and.returnValue( mockedContext ); @@ -138,7 +131,7 @@ describe('ContentModelEditor', () => { spyOn((editor as any).core.api, 'createEditorContext').and.returnValue(editorContext); - editor.setContentModel(mockedModel); + const rangeEx = editor.setContentModel(mockedModel); expect(contentModelToDom.contentModelToDom).toHaveBeenCalledTimes(1); expect(contentModelToDom.contentModelToDom).toHaveBeenCalledWith( @@ -152,6 +145,7 @@ describe('ContentModelEditor', () => { mockedConfig, editorContext ); + expect(rangeEx).toBe(mockedRange); }); it('createContentModel in EditorReady event', () => { diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCopyPastePluginTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCopyPastePluginTest.ts index faaa05d06f9..18b40d796db 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCopyPastePluginTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCopyPastePluginTest.ts @@ -467,7 +467,7 @@ describe('ContentModelCopyPastePlugin |', () => { onNodeCreated ); expect(createContentModelSpy).toHaveBeenCalled(); - expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); + expect(triggerPluginEventSpy).toHaveBeenCalledTimes(2); expect(focusSpy).toHaveBeenCalled(); expect(selectSpy).toHaveBeenCalledWith( selectionRangeExValue, @@ -527,7 +527,7 @@ describe('ContentModelCopyPastePlugin |', () => { onNodeCreated ); expect(createContentModelSpy).toHaveBeenCalled(); - expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); + expect(triggerPluginEventSpy).toHaveBeenCalledTimes(2); expect(iterateSelectionsFile.iterateSelections).toHaveBeenCalled(); expect(focusSpy).toHaveBeenCalled(); expect(selectSpy).toHaveBeenCalledWith( @@ -586,7 +586,7 @@ describe('ContentModelCopyPastePlugin |', () => { onNodeCreated ); expect(createContentModelSpy).toHaveBeenCalled(); - expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); + expect(triggerPluginEventSpy).toHaveBeenCalledTimes(2); expect(focusSpy).toHaveBeenCalled(); expect(selectSpy).toHaveBeenCalledWith( selectionRangeExValue, diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelFormatPluginTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelFormatPluginTest.ts index b56b989f1d8..c882c102b8b 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelFormatPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelFormatPluginTest.ts @@ -1,8 +1,8 @@ import * as formatWithContentModel from '../../../lib/publicApi/utils/formatWithContentModel'; import * as pendingFormat from '../../../lib/modelApi/format/pendingFormat'; import ContentModelFormatPlugin from '../../../lib/editor/plugins/ContentModelFormatPlugin'; +import { ChangeSource, PluginEventType, SelectionRangeTypes } from 'roosterjs-editor-types'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; -import { PluginEventType, SelectionRangeTypes } from 'roosterjs-editor-types'; import { Position } from 'roosterjs-editor-dom'; import { addSegment, @@ -157,6 +157,7 @@ describe('ContentModelFormatPlugin', () => { }, cacheContentModel: () => {}, isDarkMode: () => false, + triggerPluginEvent: jasmine.createSpy('triggerPluginEvent'), getContentModelDefaultFormat: () => ({}), } as any) as IContentModelEditor; const plugin = new ContentModelFormatPlugin(); @@ -206,6 +207,7 @@ describe('ContentModelFormatPlugin', () => { }); const setContentModel = jasmine.createSpy('setContentModel'); + const triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); const model = createContentModelDocument(); const text = createText('test a test', { fontFamily: 'Arial' }); const marker = createSelectionMarker(); @@ -223,6 +225,7 @@ describe('ContentModelFormatPlugin', () => { cacheContentModel: () => {}, isDarkMode: () => false, getContentModelDefaultFormat: () => ({}), + triggerPluginEvent, } as any) as IContentModelEditor; const plugin = new ContentModelFormatPlugin(); @@ -233,6 +236,15 @@ describe('ContentModelFormatPlugin', () => { }); plugin.dispose(); + expect(triggerPluginEvent).toHaveBeenCalledWith(PluginEventType.ContentChanged, { + contentModel: model, + rangeEx: undefined, + data: undefined, + source: ChangeSource.Format, + additionalData: { + formatApiName: 'applyPendingFormat', + }, + }); expect(setContentModel).toHaveBeenCalledTimes(1); expect(setContentModel).toHaveBeenCalledWith( { diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/paragraphTestCommon.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/paragraphTestCommon.ts index ad3bae0da25..19b1e82ac6d 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/paragraphTestCommon.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/paragraphTestCommon.ts @@ -11,13 +11,14 @@ export function paragraphTestCommon( const addUndoSnapshot = jasmine .createSpy() .and.callFake((callback: () => void, source: string, canUndoByBackspace, param: any) => { - expect(source).toBe('Format'); + expect(source).toBe(undefined!); expect(param.formatApiName).toBe(apiName); callback(); }); const setContentModel = jasmine.createSpy().and.callFake((model: ContentModelDocument) => { expect(model).toEqual(result); }); + const triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); const editor = ({ createContentModel: () => model, addUndoSnapshot, @@ -26,6 +27,7 @@ export function paragraphTestCommon( getCustomData: () => ({}), getFocusedPosition: () => ({}), isDarkMode: () => false, + triggerPluginEvent, } as any) as IContentModelEditor; executionCallback(editor); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setAlignmentTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setAlignmentTest.ts index 65d46879f71..071a5d55e1c 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setAlignmentTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setAlignmentTest.ts @@ -414,10 +414,12 @@ describe('setAlignment in table', () => { let editor: IContentModelEditor; let setContentModel: jasmine.Spy; let createContentModel: jasmine.Spy; + let triggerPluginEvent: jasmine.Spy; beforeEach(() => { setContentModel = jasmine.createSpy('setContentModel'); createContentModel = jasmine.createSpy('createContentModel'); + triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); spyOn(normalizeTable, 'normalizeTable'); @@ -427,6 +429,7 @@ describe('setAlignment in table', () => { setContentModel, createContentModel, isDarkMode: () => false, + triggerPluginEvent, } as any) as IContentModelEditor; }); @@ -809,10 +812,12 @@ describe('setAlignment in list', () => { let editor: IContentModelEditor; let setContentModel: jasmine.Spy; let createContentModel: jasmine.Spy; + let triggerPluginEvent: jasmine.Spy; beforeEach(() => { setContentModel = jasmine.createSpy('setContentModel'); createContentModel = jasmine.createSpy('createContentModel'); + triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); editor = ({ focus: () => {}, @@ -820,6 +825,7 @@ describe('setAlignment in list', () => { setContentModel, createContentModel, isDarkMode: () => false, + triggerPluginEvent, } as any) as IContentModelEditor; }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/entity/insertEntityTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/entity/insertEntityTest.ts index 583cf71d2e0..66ec3b8aa7c 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/entity/insertEntityTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/entity/insertEntityTest.ts @@ -55,6 +55,7 @@ describe('insertEntity', () => { 'formatWithContentModel' ).and.callFake((editor, apiName, formatter, options) => { formatter(model, context); + options?.getChangeData?.(); }); getEntityFromElementSpy = spyOn(getEntityFromElement, 'default').and.returnValue(newEntity); commitEntitySpy = spyOn(commitEntity, 'default'); @@ -82,9 +83,9 @@ describe('insertEntity', () => { expect(commitEntitySpy).toHaveBeenCalledWith(wrapper, type, true); expect(formatWithContentModelSpy.calls.argsFor(0)[0]).toBe(editor); expect(formatWithContentModelSpy.calls.argsFor(0)[1]).toBe(apiName); - expect(formatWithContentModelSpy.calls.argsFor(0)[3]).toEqual({ - selectionOverride: undefined, - }); + expect(formatWithContentModelSpy.calls.argsFor(0)[3].changeSource).toEqual( + ChangeSource.InsertEntity + ); expect(insertEntityModelSpy).toHaveBeenCalledWith( model, { @@ -102,10 +103,7 @@ describe('insertEntity', () => { context ); expect(getEntityFromElementSpy).toHaveBeenCalledWith(wrapper); - expect(triggerContentChangedEventSpy).toHaveBeenCalledWith( - ChangeSource.InsertEntity, - newEntity - ); + expect(triggerContentChangedEventSpy).not.toHaveBeenCalled(); expect(transformToDarkColorSpy).not.toHaveBeenCalled(); expect(normalizeContentModelSpy).toHaveBeenCalled(); @@ -121,9 +119,9 @@ describe('insertEntity', () => { expect(commitEntitySpy).toHaveBeenCalledWith(wrapper, type, true); expect(formatWithContentModelSpy.calls.argsFor(0)[0]).toBe(editor); expect(formatWithContentModelSpy.calls.argsFor(0)[1]).toBe(apiName); - expect(formatWithContentModelSpy.calls.argsFor(0)[3]).toEqual({ - selectionOverride: undefined, - }); + expect(formatWithContentModelSpy.calls.argsFor(0)[3].changeSource).toEqual( + ChangeSource.InsertEntity + ); expect(insertEntityModelSpy).toHaveBeenCalledWith( model, { @@ -141,10 +139,7 @@ describe('insertEntity', () => { context ); expect(getEntityFromElementSpy).toHaveBeenCalledWith(wrapper); - expect(triggerContentChangedEventSpy).toHaveBeenCalledWith( - ChangeSource.InsertEntity, - newEntity - ); + expect(triggerContentChangedEventSpy).not.toHaveBeenCalled(); expect(transformToDarkColorSpy).not.toHaveBeenCalled(); expect(normalizeContentModelSpy).toHaveBeenCalled(); @@ -167,9 +162,10 @@ describe('insertEntity', () => { expect(commitEntitySpy).toHaveBeenCalledWith(wrapper, type, true); expect(formatWithContentModelSpy.calls.argsFor(0)[0]).toBe(editor); expect(formatWithContentModelSpy.calls.argsFor(0)[1]).toBe(apiName); - expect(formatWithContentModelSpy.calls.argsFor(0)[3]).toEqual({ - selectionOverride: range, - }); + expect(formatWithContentModelSpy.calls.argsFor(0)[3].changeSource).toEqual( + ChangeSource.InsertEntity + ); + expect(insertEntityModelSpy).toHaveBeenCalledWith( model, { @@ -187,10 +183,7 @@ describe('insertEntity', () => { context ); expect(getEntityFromElementSpy).toHaveBeenCalledWith(wrapper); - expect(triggerContentChangedEventSpy).toHaveBeenCalledWith( - ChangeSource.InsertEntity, - newEntity - ); + expect(triggerContentChangedEventSpy).not.toHaveBeenCalled(); expect(transformToDarkColorSpy).not.toHaveBeenCalled(); expect(normalizeContentModelSpy).toHaveBeenCalled(); @@ -208,9 +201,9 @@ describe('insertEntity', () => { expect(commitEntitySpy).toHaveBeenCalledWith(wrapper, type, true); expect(formatWithContentModelSpy.calls.argsFor(0)[0]).toBe(editor); expect(formatWithContentModelSpy.calls.argsFor(0)[1]).toBe(apiName); - expect(formatWithContentModelSpy.calls.argsFor(0)[3]).toEqual({ - selectionOverride: undefined, - }); + expect(formatWithContentModelSpy.calls.argsFor(0)[3].changeSource).toEqual( + ChangeSource.InsertEntity + ); expect(insertEntityModelSpy).toHaveBeenCalledWith( model, { @@ -228,10 +221,7 @@ describe('insertEntity', () => { context ); expect(getEntityFromElementSpy).toHaveBeenCalledWith(wrapper); - expect(triggerContentChangedEventSpy).toHaveBeenCalledWith( - ChangeSource.InsertEntity, - newEntity - ); + expect(triggerContentChangedEventSpy).not.toHaveBeenCalled(); expect(normalizeContentModelSpy).toHaveBeenCalled(); expect(context.newEntities).toEqual([ diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/image/changeImageTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/image/changeImageTest.ts index 3bba5147859..ef2114b91e4 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/image/changeImageTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/image/changeImageTest.ts @@ -24,7 +24,7 @@ describe('changeImage', () => { .createSpy() .and.callFake( (callback: () => void, source: string, canUndoByBackspace, param: any) => { - expect(source).toBe('Format'); + expect(source).toBe(undefined!); expect(param.formatApiName).toBe('changeImage'); callback(); } diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/image/insertImageTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/image/insertImageTest.ts index 935871f07cb..ff99ffad3e3 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/image/insertImageTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/image/insertImageTest.ts @@ -22,7 +22,7 @@ describe('insertImage', () => { .createSpy() .and.callFake( (callback: () => void, source: string, canUndoByBackspace, param: any) => { - expect(source).toBe('Format'); + expect(source).toBe(undefined!); expect(param.formatApiName).toBe(apiName); callback(); } @@ -30,6 +30,7 @@ describe('insertImage', () => { const setContentModel = jasmine.createSpy().and.callFake((model: ContentModelDocument) => { expect(model).toEqual(result); }); + const triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); const editor = ({ createContentModel: () => model, addUndoSnapshot, @@ -38,6 +39,7 @@ describe('insertImage', () => { isDisposed: () => false, getDocument: () => document, isDarkMode: () => false, + triggerPluginEvent, } as any) as IContentModelEditor; executionCallback(editor); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/adjustLinkSelectionTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/adjustLinkSelectionTest.ts index be894434da8..097c0b4e358 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/adjustLinkSelectionTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/adjustLinkSelectionTest.ts @@ -15,10 +15,12 @@ describe('adjustLinkSelection', () => { let editor: IContentModelEditor; let setContentModel: jasmine.Spy; let createContentModel: jasmine.Spy; + let triggerPluginEvent: jasmine.Spy; beforeEach(() => { setContentModel = jasmine.createSpy('setContentModel'); createContentModel = jasmine.createSpy('createContentModel'); + triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); editor = ({ focus: () => {}, @@ -26,6 +28,7 @@ describe('adjustLinkSelection', () => { setContentModel, createContentModel, isDarkMode: () => false, + triggerPluginEvent, } as any) as IContentModelEditor; }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/insertLinkTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/insertLinkTest.ts index 15cd4db694f..c66c2a55a03 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/insertLinkTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/insertLinkTest.ts @@ -15,10 +15,12 @@ describe('insertLink', () => { let editor: IContentModelEditor; let setContentModel: jasmine.Spy; let createContentModel: jasmine.Spy; + let triggerPluginEvent: jasmine.Spy; beforeEach(() => { setContentModel = jasmine.createSpy('setContentModel'); createContentModel = jasmine.createSpy('createContentModel'); + triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); editor = ({ focus: () => {}, @@ -28,6 +30,7 @@ describe('insertLink', () => { getCustomData: () => ({}), getFocusedPosition: () => ({}), isDarkMode: () => false, + triggerPluginEvent, } as any) as IContentModelEditor; }); @@ -331,6 +334,8 @@ describe('insertLink', () => { additionalData: { formatApiName: 'insertLink', }, + contentModel: jasmine.anything(), + rangeEx: jasmine.anything(), }); document.body.removeChild(div); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/removeLinkTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/removeLinkTest.ts index 1003ac59b32..122885ca79f 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/removeLinkTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/removeLinkTest.ts @@ -13,10 +13,12 @@ describe('removeLink', () => { let editor: IContentModelEditor; let setContentModel: jasmine.Spy; let createContentModel: jasmine.Spy; + let triggerPluginEvent: jasmine.Spy; beforeEach(() => { setContentModel = jasmine.createSpy('setContentModel'); createContentModel = jasmine.createSpy('createContentModel'); + triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); editor = ({ focus: () => {}, @@ -24,6 +26,7 @@ describe('removeLink', () => { setContentModel, createContentModel, isDarkMode: () => false, + triggerPluginEvent, } as any) as IContentModelEditor; }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/toggleBulletTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/toggleBulletTest.ts index f92a13c6639..0dd1152f83b 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/toggleBulletTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/toggleBulletTest.ts @@ -10,6 +10,7 @@ describe('toggleBullet', () => { let setContentModel: jasmine.Spy; let focus: jasmine.Spy; let mockedModel: ContentModelDocument; + let triggerPluginEvent: jasmine.Spy; beforeEach(() => { mockedModel = ({} as any) as ContentModelDocument; @@ -17,6 +18,7 @@ describe('toggleBullet', () => { addUndoSnapshot = jasmine.createSpy('addUndoSnapshot').and.callFake(callback => callback()); createContentModel = jasmine.createSpy('createContentModel').and.returnValue(mockedModel); setContentModel = jasmine.createSpy('setContentModel'); + triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); focus = jasmine.createSpy('focus'); editor = ({ @@ -27,6 +29,7 @@ describe('toggleBullet', () => { getCustomData: () => ({}), getFocusedPosition: () => ({}), isDarkMode: () => false, + triggerPluginEvent, } as any) as IContentModelEditor; spyOn(setListType, 'setListType').and.returnValue(true); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/toggleNumberingTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/toggleNumberingTest.ts index 60641a7cef1..c1a2a884cbb 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/toggleNumberingTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/toggleNumberingTest.ts @@ -8,6 +8,7 @@ describe('toggleNumbering', () => { let addUndoSnapshot: jasmine.Spy; let createContentModel: jasmine.Spy; let setContentModel: jasmine.Spy; + let triggerPluginEvent: jasmine.Spy; let focus: jasmine.Spy; let mockedModel: ContentModelDocument; @@ -17,6 +18,7 @@ describe('toggleNumbering', () => { addUndoSnapshot = jasmine.createSpy('addUndoSnapshot').and.callFake(callback => callback()); createContentModel = jasmine.createSpy('createContentModel').and.returnValue(mockedModel); setContentModel = jasmine.createSpy('setContentModel'); + triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); focus = jasmine.createSpy('focus'); editor = ({ @@ -27,6 +29,7 @@ describe('toggleNumbering', () => { getCustomData: () => ({}), getFocusedPosition: () => ({}), isDarkMode: () => false, + triggerPluginEvent, } as any) as IContentModelEditor; spyOn(setListType, 'setListType').and.returnValue(true); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/changeFontSizeTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/changeFontSizeTest.ts index 5cd91426361..96fee777b2b 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/changeFontSizeTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/changeFontSizeTest.ts @@ -329,6 +329,7 @@ describe('changeFontSize', () => { it('Test format parser', () => { spyOn(pendingFormat, 'setPendingFormat'); spyOn(pendingFormat, 'getPendingFormat').and.returnValue(null); + const triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); const addUndoSnapshot = jasmine.createSpy().and.callFake((callback: () => void) => { callback(); @@ -352,6 +353,7 @@ describe('changeFontSize', () => { focus: jasmine.createSpy(), setContentModel, isDarkMode: () => false, + triggerPluginEvent, } as any) as IContentModelEditor; changeFontSize(editor, 'increase'); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/segmentTestCommon.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/segmentTestCommon.ts index d090ec0ed72..b2f1adc235a 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/segmentTestCommon.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/segmentTestCommon.ts @@ -16,13 +16,14 @@ export function segmentTestCommon( const addUndoSnapshot = jasmine .createSpy() .and.callFake((callback: () => void, source: string, canUndoByBackspace, param: any) => { - expect(source).toBe('Format'); + expect(source).toBe(undefined!); expect(param.formatApiName).toBe(apiName); callback(); }); const setContentModel = jasmine.createSpy().and.callFake((model: ContentModelDocument) => { expect(model).toEqual(result); }); + const triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); const editor = ({ createContentModel: () => model, addUndoSnapshot, @@ -31,6 +32,7 @@ export function segmentTestCommon( isDisposed: () => false, getFocusedPosition: () => null as NodePosition, isDarkMode: () => false, + triggerPluginEvent, } as any) as IContentModelEditor; executionCallback(editor); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/table/setTableCellShadeTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/table/setTableCellShadeTest.ts index b74e2ee86eb..422b6778015 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/table/setTableCellShadeTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/table/setTableCellShadeTest.ts @@ -8,10 +8,12 @@ describe('setTableCellShade', () => { let editor: IContentModelEditor; let setContentModel: jasmine.Spy; let createContentModel: jasmine.Spy; + let triggerPluginEvent: jasmine.Spy; beforeEach(() => { setContentModel = jasmine.createSpy('setContentModel'); createContentModel = jasmine.createSpy('createContentModel'); + triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); spyOn(normalizeTable, 'normalizeTable'); @@ -21,6 +23,7 @@ describe('setTableCellShade', () => { setContentModel, createContentModel, isDarkMode: () => false, + triggerPluginEvent, } as any) as IContentModelEditor; }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatImageWithContentModelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatImageWithContentModelTest.ts index d0aa129e122..734b87fd67e 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatImageWithContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatImageWithContentModelTest.ts @@ -209,7 +209,7 @@ function segmentTestForPluginEvent( const addUndoSnapshot = jasmine .createSpy() .and.callFake((callback: () => void, source: string, canUndoByBackspace, param: any) => { - expect(source).toBe('Format'); + expect(source).toBe(undefined!); expect(param.formatApiName).toBe(apiName); callback(); }); @@ -230,7 +230,7 @@ function segmentTestForPluginEvent( executionCallback(editor); if (shouldCallPluginEvent) { - expect(triggerPluginEvent).toHaveBeenCalledTimes(calledTimes); + expect(triggerPluginEvent).toHaveBeenCalled(); } expect(addUndoSnapshot).toHaveBeenCalledTimes(calledTimes); expect(setContentModel).toHaveBeenCalledTimes(calledTimes); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatParagraphWithContentModelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatParagraphWithContentModelTest.ts index cb955206700..64e5bfe0f64 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatParagraphWithContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatParagraphWithContentModelTest.ts @@ -12,6 +12,7 @@ describe('formatParagraphWithContentModel', () => { let editor: IContentModelEditor; let addUndoSnapshot: jasmine.Spy; let setContentModel: jasmine.Spy; + let triggerPluginEvent: jasmine.Spy; let focus: jasmine.Spy; let model: ContentModelDocument; @@ -20,6 +21,7 @@ describe('formatParagraphWithContentModel', () => { beforeEach(() => { addUndoSnapshot = jasmine.createSpy('addUndoSnapshot').and.callFake(callback => callback()); setContentModel = jasmine.createSpy('setContentModel'); + triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); focus = jasmine.createSpy('focus'); editor = ({ @@ -30,6 +32,7 @@ describe('formatParagraphWithContentModel', () => { isDarkMode: () => false, getCustomData: () => ({}), getFocusedPosition: () => 'NewPosition', + triggerPluginEvent, } as any) as IContentModelEditor; }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatSegmentWithContentModelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatSegmentWithContentModelTest.ts index ed70deb83d4..65e2906d7e5 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatSegmentWithContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatSegmentWithContentModelTest.ts @@ -18,12 +18,14 @@ describe('formatSegmentWithContentModel', () => { let model: ContentModelDocument; let getPendingFormat: jasmine.Spy; let setPendingFormat: jasmine.Spy; + let triggerPluginEvent: jasmine.Spy; const apiName = 'mockedApi'; beforeEach(() => { addUndoSnapshot = jasmine.createSpy('addUndoSnapshot').and.callFake(callback => callback()); setContentModel = jasmine.createSpy('setContentModel'); + triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); focus = jasmine.createSpy('focus'); setPendingFormat = spyOn(pendingFormat, 'setPendingFormat'); @@ -36,6 +38,7 @@ describe('formatSegmentWithContentModel', () => { setContentModel, getFocusedPosition: () => null as NodePosition, isDarkMode: () => false, + triggerPluginEvent, } as any) as IContentModelEditor; }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatWithContentModelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatWithContentModelTest.ts index 6f091e3a69e..ec82c1f75b7 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatWithContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatWithContentModelTest.ts @@ -13,7 +13,6 @@ describe('formatWithContentModel', () => { let mockedModel: ContentModelDocument; let cacheContentModel: jasmine.Spy; let getFocusedPosition: jasmine.Spy; - let triggerContentChangedEvent: jasmine.Spy; let triggerPluginEvent: jasmine.Spy; const apiName = 'mockedApi'; @@ -28,7 +27,6 @@ describe('formatWithContentModel', () => { focus = jasmine.createSpy('focus'); cacheContentModel = jasmine.createSpy('cacheContentModel'); getFocusedPosition = jasmine.createSpy('getFocusedPosition').and.returnValue(mockedPos); - triggerContentChangedEvent = jasmine.createSpy('triggerContentChangedEvent'); triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); editor = ({ @@ -38,7 +36,6 @@ describe('formatWithContentModel', () => { setContentModel, cacheContentModel, getFocusedPosition, - triggerContentChangedEvent, triggerPluginEvent, isDarkMode: () => false, } as any) as IContentModelEditor; @@ -72,7 +69,7 @@ describe('formatWithContentModel', () => { }); expect(createContentModel).toHaveBeenCalledTimes(1); expect(addUndoSnapshot).toHaveBeenCalledTimes(1); - expect(addUndoSnapshot.calls.argsFor(0)[1]).toBe(ChangeSource.Format); + expect(addUndoSnapshot.calls.argsFor(0)[1]).toBe(undefined); expect(addUndoSnapshot.calls.argsFor(0)[2]).toBe(false); expect(addUndoSnapshot.calls.argsFor(0)[3]).toEqual({ formatApiName: apiName, @@ -100,7 +97,7 @@ describe('formatWithContentModel', () => { }); expect(createContentModel).toHaveBeenCalledTimes(1); expect(addUndoSnapshot).toHaveBeenCalledTimes(1); - expect(addUndoSnapshot.calls.argsFor(0)[1]).toBe(ChangeSource.Format); + expect(addUndoSnapshot.calls.argsFor(0)[1]).toBe(undefined!); expect(addUndoSnapshot.calls.argsFor(0)[2]).toBe(false); expect(addUndoSnapshot.calls.argsFor(0)[3]).toEqual({ formatApiName: apiName, @@ -147,7 +144,8 @@ describe('formatWithContentModel', () => { }); expect(createContentModel).toHaveBeenCalledTimes(1); expect(addUndoSnapshot).toHaveBeenCalled(); - expect(addUndoSnapshot.calls.argsFor(0)[1]).toBe('TEST'); + expect(addUndoSnapshot.calls.argsFor(0)[1]).toBe(undefined!); + expect(triggerPluginEvent).toHaveBeenCalled(); }); it('Customize change source and skip undo snapshot', () => { @@ -168,7 +166,6 @@ describe('formatWithContentModel', () => { }); expect(createContentModel).toHaveBeenCalledTimes(1); expect(addUndoSnapshot).not.toHaveBeenCalled(); - expect(triggerContentChangedEvent).toHaveBeenCalledWith('TEST', 'DATA'); }); it('Has onNodeCreated', () => { @@ -202,12 +199,7 @@ describe('formatWithContentModel', () => { expect(createContentModel).toHaveBeenCalledTimes(1); expect(setContentModel).toHaveBeenCalledWith(mockedModel, undefined, undefined); expect(addUndoSnapshot).toHaveBeenCalled(); - - const wrappedCallback = addUndoSnapshot.calls.argsFor(0)[0] as any; - const result = wrappedCallback(); - expect(getChangeData).toHaveBeenCalled(); - expect(result).toBe(mockedData); }); it('Has entity got deleted', () => { @@ -236,7 +228,7 @@ describe('formatWithContentModel', () => { } ); - expect(triggerPluginEvent).toHaveBeenCalledTimes(2); + expect(triggerPluginEvent).toHaveBeenCalledTimes(3); expect(triggerPluginEvent).toHaveBeenCalledWith(PluginEventType.EntityOperation, { entity: entity1, operation: EntityOperation.RemoveFromStart, @@ -256,6 +248,7 @@ describe('formatWithContentModel', () => { const entity2 = { id: 'E2', type: 'E', wrapper: wrapper2, isReadonly: true } as any; const rawEvent = 'RawEvent' as any; const transformToDarkColorSpy = jasmine.createSpy('transformToDarkColor'); + const mockedData = 'DATA'; editor.isDarkMode = () => true; editor.transformToDarkColor = transformToDarkColorSpy; @@ -269,10 +262,20 @@ describe('formatWithContentModel', () => { }, { rawEvent: rawEvent, + getChangeData: () => mockedData, } ); - expect(triggerPluginEvent).not.toHaveBeenCalled(); + expect(triggerPluginEvent).toHaveBeenCalledTimes(1); + expect(triggerPluginEvent).toHaveBeenCalledWith(PluginEventType.ContentChanged, { + contentModel: mockedModel, + rangeEx: undefined, + source: ChangeSource.Format, + data: mockedData, + additionalData: { + formatApiName: apiName, + }, + }); expect(transformToDarkColorSpy).toHaveBeenCalledTimes(2); expect(transformToDarkColorSpy).toHaveBeenCalledWith(wrapper1); expect(transformToDarkColorSpy).toHaveBeenCalledWith(wrapper2); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts index 7d7e5bda988..f8693667102 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts @@ -17,6 +17,7 @@ import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEdito import paste, * as pasteF from '../../../lib/publicApi/utils/paste'; import { BeforePasteEvent, + ChangeSource, ClipboardData, KnownPasteSourceType, PasteType, @@ -42,7 +43,6 @@ describe('Paste ', () => { let getDocument: jasmine.Spy; let getTrustedHTMLHandler: jasmine.Spy; let triggerPluginEvent: jasmine.Spy; - let undoSnapshotResult: any; const mockedPos = 'POS' as any; @@ -63,9 +63,7 @@ describe('Paste ', () => { mockedModel = ({} as any) as ContentModelDocument; mockedMergeModel = ({} as any) as ContentModelDocument; - addUndoSnapshot = jasmine - .createSpy('addUndoSnapshot') - .and.callFake(callback => (undoSnapshotResult = callback())); + addUndoSnapshot = jasmine.createSpy('addUndoSnapshot').and.callFake(callback => callback()); createContentModel = jasmine.createSpy('createContentModel').and.returnValue(mockedModel); setContentModel = jasmine.createSpy('setContentModel'); focus = jasmine.createSpy('focus'); @@ -139,7 +137,6 @@ describe('Paste ', () => { expect(getDocument).toHaveBeenCalled(); expect(getTrustedHTMLHandler).toHaveBeenCalled(); expect(mockedModel).toEqual(mockedMergeModel); - expect(clipboardData).toEqual(undoSnapshotResult); }); it('Execute | As plain text', () => { @@ -150,11 +147,19 @@ describe('Paste ', () => { expect(addUndoSnapshot).toHaveBeenCalled(); expect(getFocusedPosition).not.toHaveBeenCalled(); expect(getContent).toHaveBeenCalled(); - expect(triggerPluginEvent).not.toHaveBeenCalled(); + expect(triggerPluginEvent).toHaveBeenCalledTimes(1); + expect(triggerPluginEvent).toHaveBeenCalledWith(PluginEventType.ContentChanged, { + contentModel: mockedModel, + rangeEx: undefined, + data: clipboardData, + source: ChangeSource.Paste, + additionalData: { + formatApiName: 'Paste', + }, + }); expect(getDocument).toHaveBeenCalled(); expect(getTrustedHTMLHandler).toHaveBeenCalled(); expect(mockedModel).toEqual(mockedMergeModel); - expect(clipboardData).toEqual(undoSnapshotResult); }); }); From 3d640fa2f404ce8cc63fa69638942c305f456bdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Fri, 22 Sep 2023 14:50:07 -0300 Subject: [PATCH 75/75] rename variable --- .../lib/modelApi/table/applyTableFormat.ts | 6 +++--- .../lib/modelApi/table/setTableCellBackgroundColor.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/applyTableFormat.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/applyTableFormat.ts index ce4c668f8cb..d180db17ea1 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/applyTableFormat.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/applyTableFormat.ts @@ -210,7 +210,7 @@ function formatCells( cell, color, false /*isColorOverride*/, - true /*overrideTextColor*/ + true /*applyToSegments*/ ); } @@ -238,7 +238,7 @@ function setFirstColumnFormat( cell, null /*color*/, false /*isColorOverride*/, - true /*overrideTextColor*/ + true /*applyToSegments*/ ); } @@ -268,7 +268,7 @@ function setHeaderRowFormat( cell, format.headerRowColor, false /*isColorOverride*/, - true /*overrideTextColor*/ + true /*applyToSegments*/ ); } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/setTableCellBackgroundColor.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/setTableCellBackgroundColor.ts index a6420778e96..4523426c7a0 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/setTableCellBackgroundColor.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/setTableCellBackgroundColor.ts @@ -17,7 +17,7 @@ export function setTableCellBackgroundColor( cell: ContentModelTableCell, color: string | null | undefined, isColorOverride?: boolean, - overrideTextColor?: boolean + applyToSegments?: boolean ) { if (color) { cell.format.backgroundColor = color; @@ -40,7 +40,7 @@ export function setTableCellBackgroundColor( delete cell.format.textColor; } - if (overrideTextColor && cell.format.textColor) { + if (applyToSegments && cell.format.textColor) { cell.blocks.forEach(block => { if (block.blockType == 'Paragraph') { block.segmentFormat = {