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..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,11 +42,13 @@ 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'; 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/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-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/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-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 = { 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..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 @@ -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 @@ -14,19 +14,44 @@ 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*/); - 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*/ + ); + + // 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 { 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/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/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/lib/modelApi/common/cloneModel.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/cloneModel.ts index 9c8a0fb3012..01eee7d6922 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,28 @@ import type { ContentModelListLevel, } from 'roosterjs-content-model-types'; +/** + * @internal + * Options for cloneModel API + */ +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 +177,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 +195,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 +221,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 +239,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 +254,7 @@ function cloneTableCell( return Object.assign( { - cachedElement: options.includeCachedElement ? cachedElement : undefined, + cachedElement: handleCachedElement(cachedElement, 'cache', options), isSelected, spanAbove, spanLeft, @@ -264,7 +272,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 +315,7 @@ function cloneDivider( { isSelected, tagName, - cachedElement: options.includeCachedElement ? cachedElement : undefined, + cachedElement: handleCachedElement(cachedElement, 'cache', options), }, cloneBlockBase(divider) ); @@ -321,9 +329,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 +361,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/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/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/lib/publicApi/entity/insertEntity.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/entity/insertEntity.ts index 7fafcddff5e..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'; @@ -80,11 +80,14 @@ export default function insertEntity( entityModel, typeof position == 'string' ? position : 'focus', isBlock, - isBlock ? focusAfterEntity : true, + focusAfterEntity, 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/publicApi/utils/paste.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/paste.ts index 0196320f264..a740afc8762 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,199 +1,212 @@ -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 { ContentModelDocument } from 'roosterjs-content-model-types'; -import ContentModelBeforePasteEvent, { - ContentModelBeforePasteEventData, -} from '../../publicTypes/event/ContentModelBeforePasteEvent'; -import { - createDefaultHtmlSanitizerOptions, - getPasteType, - handleImagePaste, - handleTextPaste, - moveChildNodes, - retrieveMetadataFromClipboard, - sanitizePasteContent, -} from 'roosterjs-editor-dom'; -import { - ChangeSource, - ClipboardData, - GetContentMode, - PasteType, - PluginEventType, -} from 'roosterjs-editor-types'; - -/** - * Paste into editor using a clipboardData object - * @param clipboardData Clipboard data retrieved from clipboard - * @param pasteAsText Force pasting as plain text. Default value is false - * @param applyCurrentStyle True if apply format of current selection to the pasted content, - * false to keep original format. Default value is false. When pasteAsText is true, this parameter is ignored - * @param pasteAsImage: When set to true, if the clipboardData contains a imageDataUri will paste the image to the editor - */ -export default function paste( - editor: IContentModelEditor, - clipboardData: ClipboardData, - pasteAsText: boolean = false, - applyCurrentFormat: boolean = false, - pasteAsImage: boolean = false -) { - if (clipboardData.snapshotBeforePaste) { - // Restore original content before paste a new one - editor.setContent(clipboardData.snapshotBeforePaste); - } else { - clipboardData.snapshotBeforePaste = editor.getContent(GetContentMode.RawHTMLWithSelection); - } - - const eventData = createBeforePasteEventData( - editor, - clipboardData, - getPasteType(pasteAsText, applyCurrentFormat, pasteAsImage) - ); - - const { - domToModelOption, - fragment, - customizedMerge, - } = triggerPluginEventAndCreatePasteFragment( - editor, - clipboardData, - null /* position */, - pasteAsText, - pasteAsImage, - eventData - ); - - const pasteModel = domToContentModel(fragment, domToModelOption); - - if (pasteModel) { - formatWithContentModel( - editor, - 'Paste', - (model, context) => - mergePasteContent(model, context, pasteModel, applyCurrentFormat, customizedMerge), - { - changeSource: ChangeSource.Paste, - getChangeData: () => clipboardData, - } - ); - } -} - -/** - * @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, - pasteType: PasteType -): ContentModelBeforePasteEventData { - const options = createDefaultHtmlSanitizerOptions(); - - // Remove "caret-color" style generated by Safari to make sure caret shows in right color after paste - options.cssStyleCallbacks['caret-color'] = () => false; - - return { - clipboardData, - fragment: editor.getDocument().createDocumentFragment(), - sanitizingOption: options, - htmlBefore: '', - htmlAfter: '', - htmlAttributes: {}, - domToModelOption: {}, - pasteType, - }; -} - -/** - * This function is used to create a BeforePasteEvent object after trigger the event, so other plugins can modify the event object - * This function will also create a DocumentFragment for paste. - */ -function triggerPluginEventAndCreatePasteFragment( - editor: IContentModelEditor, - clipboardData: ClipboardData, - position: NodePosition | null, - pasteAsText: boolean, - pasteAsImage: boolean, - eventData: ContentModelBeforePasteEventData -): ContentModelBeforePasteEventData { - const event = { - eventType: PluginEventType.BeforePaste, - ...eventData, - } as ContentModelBeforePasteEvent; - - const { fragment } = event; - const { rawHtml, text, imageDataUri } = clipboardData; - const trustedHTMLHandler = editor.getTrustedHTMLHandler(); - - let doc: Document | undefined = rawHtml - ? new DOMParser().parseFromString(trustedHTMLHandler(rawHtml), 'text/html') - : undefined; - - // Step 2: Retrieve Metadata from Html and the Html that was copied. - retrieveMetadataFromClipboard(doc, event, trustedHTMLHandler); - - // Step 3: Fill the BeforePasteEvent object, especially the fragment for paste - if ((pasteAsImage && imageDataUri) || (!pasteAsText && !text && imageDataUri)) { - // Paste image - handleImagePaste(imageDataUri, fragment); - } else if (!pasteAsText && rawHtml && doc ? doc.body : false) { - moveChildNodes(fragment, doc?.body); - } else if (text) { - // Paste text - handleTextPaste(text, position, fragment); - } - - 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, - event, - true /* broadcast */ - ) as ContentModelBeforePasteEvent; - } - - // Step 5. Sanitize the fragment before paste to make sure the content is safe - sanitizePasteContent(event, position); - - return pluginEvent; -} +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 ContentModelBeforePasteEvent, { + ContentModelBeforePasteEventData, +} from '../../publicTypes/event/ContentModelBeforePasteEvent'; +import { + createDefaultHtmlSanitizerOptions, + getPasteType, + handleImagePaste, + handleTextPaste, + moveChildNodes, + retrieveMetadataFromClipboard, + sanitizePasteContent, +} from 'roosterjs-editor-dom'; +import { + ChangeSource, + ClipboardData, + GetContentMode, + PasteType, + PluginEventType, +} from 'roosterjs-editor-types'; + +/** + * Paste into editor using a clipboardData object + * @param clipboardData Clipboard data retrieved from clipboard + * @param pasteAsText Force pasting as plain text. Default value is false + * @param applyCurrentStyle True if apply format of current selection to the pasted content, + * false to keep original format. Default value is false. When pasteAsText is true, this parameter is ignored + * @param pasteAsImage: When set to true, if the clipboardData contains a imageDataUri will paste the image to the editor + */ +export default function paste( + editor: IContentModelEditor, + clipboardData: ClipboardData, + pasteAsText: boolean = false, + applyCurrentFormat: boolean = false, + pasteAsImage: boolean = false +) { + if (clipboardData.snapshotBeforePaste) { + // Restore original content before paste a new one + editor.setContent(clipboardData.snapshotBeforePaste); + } else { + clipboardData.snapshotBeforePaste = editor.getContent(GetContentMode.RawHTMLWithSelection); + } + + formatWithContentModel( + editor, + '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, + } + ); +} + +/** + * @internal + * Export only for unit test + */ +export function mergePasteContent( + model: ContentModelDocument, + context: FormatWithContentModelContext, + pasteModel: ContentModelDocument, + applyCurrentFormat: boolean, + customizedMerge: + | undefined + | ((source: ContentModelDocument, target: ContentModelDocument) => void) +) { + if (customizedMerge) { + customizedMerge(model, pasteModel); + } else { + mergeModel(model, pasteModel, context, { + mergeFormat: applyCurrentFormat ? 'keepSourceEmphasisFormat' : 'none', + mergeTable: shouldMergeTable(pasteModel), + }); + } +} + +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, + pasteType: PasteType +): ContentModelBeforePasteEventData { + const options = createDefaultHtmlSanitizerOptions(); + + // Remove "caret-color" style generated by Safari to make sure caret shows in right color after paste + options.cssStyleCallbacks['caret-color'] = () => false; + + return { + clipboardData, + fragment: editor.getDocument().createDocumentFragment(), + sanitizingOption: options, + htmlBefore: '', + htmlAfter: '', + htmlAttributes: {}, + domToModelOption: {}, + pasteType, + }; +} + +/** + * This function is used to create a BeforePasteEvent object after trigger the event, so other plugins can modify the event object + * This function will also create a DocumentFragment for paste. + */ +function triggerPluginEventAndCreatePasteFragment( + editor: IContentModelEditor, + clipboardData: ClipboardData, + position: NodePosition | null, + pasteAsText: boolean, + pasteAsImage: boolean, + eventData: ContentModelBeforePasteEventData, + currentFormat: ContentModelSegmentFormat +): ContentModelBeforePasteEventData { + const event = { + eventType: PluginEventType.BeforePaste, + ...eventData, + } as ContentModelBeforePasteEvent; + + const { fragment } = event; + const { rawHtml, text, imageDataUri } = clipboardData; + const trustedHTMLHandler = editor.getTrustedHTMLHandler(); + + let doc: Document | undefined = rawHtml + ? new DOMParser().parseFromString(trustedHTMLHandler(rawHtml), 'text/html') + : undefined; + + // Step 2: Retrieve Metadata from Html and the Html that was copied. + retrieveMetadataFromClipboard(doc, event, trustedHTMLHandler); + + // Step 3: Fill the BeforePasteEvent object, especially the fragment for paste + if ((pasteAsImage && imageDataUri) || (!pasteAsText && !text && imageDataUri)) { + // Paste image + handleImagePaste(imageDataUri, fragment); + } else if (!pasteAsText && rawHtml && doc ? doc.body : false) { + moveChildNodes(fragment, doc?.body); + } else if (text) { + // Paste text + 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) { + pluginEvent = editor.triggerPluginEvent( + PluginEventType.BeforePaste, + event, + true /* broadcast */ + ) as ContentModelBeforePasteEvent; + } + + // Step 5. Sanitize the fragment before paste to make sure the content is safe + sanitizePasteContent(event, position); + + return pluginEvent; +} 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/coreApi/switchShadowEditTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/switchShadowEditTest.ts index 36275c82607..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 @@ -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'), @@ -28,11 +32,22 @@ describe('switchShadowEdit', () => { describe('was off', () => { it('no cache, isOn', () => { + core.cachedModel = undefined; switchShadowEdit(core, true); 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 +58,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 +77,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 +89,8 @@ describe('switchShadowEdit', () => { expect(createContentModel).not.toHaveBeenCalled(); expect(setContentModel).not.toHaveBeenCalled(); expect(core.cachedModel).toBe(mockedCachedModel); + + expect(triggerEvent).not.toHaveBeenCalled(); }); }); @@ -75,6 +105,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 +117,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 +127,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 +146,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 + ); }); }); }); 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..fd21a17e69e 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,594 +1,678 @@ -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 { DeleteResult } from '../../../lib/modelApi/edit/utils/DeleteSelectionStep'; -import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; -import ContentModelCopyPastePlugin, { - onNodeCreated, -} from '../../../lib/editor/corePlugins/ContentModelCopyPastePlugin'; -import { - ClipboardData, - DOMEventHandlerFunction, - IEditor, - SelectionRangeEx, - SelectionRangeTypes, -} from 'roosterjs-editor-types'; - -const modelValue = 'model' as any; -const darkColorHandler = 'darkColorHandler' as any; -const pasteModelValue = 'pasteModelValue' as any; -const insertPointValue = 'insertPoint' as any; -const deleteResultValue = 'deleteResult' as any; - -const allowedCustomPasteType = ['Test']; - -describe('ContentModelCopyPastePlugin |', () => { - let editor: IEditor = null!; - let plugin: ContentModelCopyPastePlugin; - let domEvents: Record = {}; - let div: HTMLDivElement; - - let selectionRangeExValue: SelectionRangeEx; - let getSelectionRangeEx: jasmine.Spy; - let createContentModelSpy: jasmine.Spy; - let triggerPluginEventSpy: jasmine.Spy; - let focusSpy: jasmine.Spy; - let undoSnapShotSpy: jasmine.Spy; - let selectSpy: jasmine.Spy; - let setContentModelSpy: jasmine.Spy; - let getSelectionRange: jasmine.Spy; - - let isDisposed: jasmine.Spy; - let pasteSpy: jasmine.Spy; - - beforeEach(() => { - div = document.createElement('div'); - getSelectionRangeEx = jasmine - .createSpy('selectRangeExSpy') - .and.callFake(() => selectionRangeExValue); - createContentModelSpy = jasmine - .createSpy('createContentModelSpy') - .and.returnValue(modelValue); - triggerPluginEventSpy = jasmine.createSpy('triggerPluginEventSpy'); - focusSpy = jasmine.createSpy('focusSpy'); - undoSnapShotSpy = jasmine.createSpy('undoSnapShotSpy'); - selectSpy = jasmine.createSpy('selectSpy'); - setContentModelSpy = jasmine.createSpy('setContentModelSpy'); - getSelectionRange = jasmine.createSpy('selectionRange'); - pasteSpy = jasmine.createSpy('paste_'); - isDisposed = jasmine.createSpy('isDisposed'); - - spyOn(cloneModelFile, 'cloneModel').and.callFake((model: any) => pasteModelValue); - - plugin = new ContentModelCopyPastePlugin({ - allowedCustomPasteType, - }); - editor = ({ - getSelectionRange, - addDomEventHandler: ( - nameOrMap: string | Record, - handler?: DOMEventHandlerFunction - ) => { - domEvents = ((typeof nameOrMap == 'string' - ? { [nameOrMap]: handler! } - : nameOrMap) as any) as Record; - }, - getSelectionRangeEx, - createContentModel: (options: any) => createContentModelSpy(options), - triggerPluginEvent(eventType: any, data: any, broadcast: any) { - triggerPluginEventSpy(eventType, data, broadcast); - return data; - }, - runAsync(callback: any) { - callback(editor); - }, - focus() { - focusSpy(); - }, - addUndoSnapshot(callback: any, changeSource: any, canUndoByBackspace: any) { - callback?.(); - undoSnapShotSpy(callback, changeSource, canUndoByBackspace); - }, - select(a1: any, a2: any, a3: any, a4: any) { - selectSpy(a1, a2, a3, a4); - }, - setContentModel(model: any, option: any) { - setContentModelSpy(model, option); - }, - getDocument() { - return document; - }, - getCustomData( - key: string, - getter?: (() => HTMLDivElement) | undefined, - disposer?: ((value: HTMLDivElement) => void) | undefined - ) { - return div; - }, - getDarkColorHandler: () => { - return darkColorHandler; - }, - isDarkMode: () => { - return false; - }, - paste: (ar1: any) => { - pasteSpy(ar1); - }, - isDisposed, - }); - - plugin.initialize(editor); - }); - - describe('Copy |', () => { - it('Selection Collapsed', () => { - selectionRangeExValue = { - type: SelectionRangeTypes.Normal, - ranges: [], - areAllCollapsed: true, - }; - - createContentModelSpy.and.callThrough(); - triggerPluginEventSpy.and.callThrough(); - focusSpy.and.callThrough(); - undoSnapShotSpy.and.callThrough(); - selectSpy.and.callThrough(); - setContentModelSpy.and.callThrough(); - - domEvents.copy?.({}); - - expect(getSelectionRangeEx).toHaveBeenCalled(); - expect(createContentModelSpy).not.toHaveBeenCalled(); - expect(triggerPluginEventSpy).not.toHaveBeenCalled(); - expect(focusSpy).not.toHaveBeenCalled(); - expect(undoSnapShotSpy).not.toHaveBeenCalled(); - expect(selectSpy).not.toHaveBeenCalled(); - expect(setContentModelSpy).not.toHaveBeenCalled(); - }); - - it('Selection not Collapsed and normal selection', () => { - // Arrange - selectionRangeExValue = { - type: SelectionRangeTypes.Normal, - ranges: [new Range()], - areAllCollapsed: false, - }; - - spyOn(deleteSelectionsFile, 'deleteSelection'); - spyOn(contentModelToDomFile, 'contentModelToDom').and.returnValue( - selectionRangeExValue - ); - spyOn(iterateSelectionsFile, 'iterateSelections').and.returnValue(undefined); - - triggerPluginEventSpy.and.callThrough(); - focusSpy.and.callThrough(); - selectSpy.and.callThrough(); - setContentModelSpy.and.callThrough(); - - // 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(iterateSelectionsFile.iterateSelections).not.toHaveBeenCalled(); - expect(focusSpy).toHaveBeenCalled(); - expect(selectSpy).toHaveBeenCalledWith( - selectionRangeExValue, - undefined, - undefined, - undefined - ); - - // On Cut Spy - expect(undoSnapShotSpy).not.toHaveBeenCalled(); - expect(setContentModelSpy).not.toHaveBeenCalledWith(); - }); - - it('Selection not Collapsed and table selection', () => { - // Arrange - const table = document.createElement('table'); - table.id = 'table'; - // Arrange - selectionRangeExValue = { - type: SelectionRangeTypes.TableSelection, - ranges: [new Range()], - areAllCollapsed: false, - coordinates: {}, - table, - }; - - spyOn(createRangeF, 'default').and.callThrough(); - spyOn(deleteSelectionsFile, 'deleteSelection'); - spyOn(contentModelToDomFile, 'contentModelToDom').and.callFake(() => { - const container = document.createElement('div'); - container.append(table); - - div.appendChild(container); - return selectionRangeExValue; - }); - spyOn(iterateSelectionsFile, 'iterateSelections').and.returnValue(undefined); - - triggerPluginEventSpy.and.callThrough(); - focusSpy.and.callThrough(); - selectSpy.and.callThrough(); - setContentModelSpy.and.callThrough(); - - // Act - domEvents.copy?.({}); - - // Assert - expect(createRangeF.default).toHaveBeenCalledWith(table.parentElement); - 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(iterateSelectionsFile.iterateSelections).toHaveBeenCalled(); - expect(focusSpy).toHaveBeenCalled(); - expect(selectSpy).toHaveBeenCalledWith( - selectionRangeExValue, - undefined, - undefined, - undefined - ); - - // On Cut Spy - expect(undoSnapShotSpy).not.toHaveBeenCalled(); - expect(setContentModelSpy).not.toHaveBeenCalledWith(); - }); - - it('Selection not Collapsed and image selection', () => { - // Arrange - const image = document.createElement('image'); - image.id = 'image'; - selectionRangeExValue = { - type: SelectionRangeTypes.ImageSelection, - ranges: [new Range()], - areAllCollapsed: false, - image, - }; - - spyOn(createRangeF, 'default').and.callThrough(); - spyOn(deleteSelectionsFile, 'deleteSelection'); - spyOn(contentModelToDomFile, 'contentModelToDom').and.callFake(() => { - div.appendChild(image); - return selectionRangeExValue; - }); - spyOn(iterateSelectionsFile, 'iterateSelections').and.returnValue(undefined); - - triggerPluginEventSpy.and.callThrough(); - focusSpy.and.callThrough(); - selectSpy.and.callThrough(); - setContentModelSpy.and.callThrough(); - - // Act - domEvents.copy?.({}); - - // Assert - expect(createRangeF.default).toHaveBeenCalledWith(image); - 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 - ); - - // On Cut Spy - expect(undoSnapShotSpy).not.toHaveBeenCalled(); - expect(setContentModelSpy).not.toHaveBeenCalledWith(); - expect(iterateSelectionsFile.iterateSelections).toHaveBeenCalledTimes(0); - }); - }); - - describe('Cut |', () => { - it('Selection Collapsed', () => { - // Arrange - selectionRangeExValue = { - type: SelectionRangeTypes.Normal, - ranges: [], - areAllCollapsed: true, - }; - - createContentModelSpy.and.callThrough(); - triggerPluginEventSpy.and.callThrough(); - focusSpy.and.callThrough(); - undoSnapShotSpy.and.callThrough(); - selectSpy.and.callThrough(); - setContentModelSpy.and.callThrough(); - - // Act - domEvents.cut?.({}); - - // Assert - expect(getSelectionRangeEx).toHaveBeenCalled(); - expect(createContentModelSpy).not.toHaveBeenCalled(); - expect(triggerPluginEventSpy).not.toHaveBeenCalled(); - expect(focusSpy).not.toHaveBeenCalled(); - expect(undoSnapShotSpy).not.toHaveBeenCalled(); - expect(selectSpy).not.toHaveBeenCalled(); - expect(setContentModelSpy).not.toHaveBeenCalled(); - }); - - it('Selection not Collapsed', () => { - // Arrange - selectionRangeExValue = { - type: SelectionRangeTypes.Normal, - ranges: [new Range()], - areAllCollapsed: false, - }; - - const deleteSelectionSpy = spyOn(deleteSelectionsFile, 'deleteSelection').and.callFake( - (model: any, steps: any, options: any) => { - return { - deletedModel: pasteModelValue, - insertPoint: insertPointValue, - deleteResult: deleteResultValue, - }; - } - ); - spyOn(contentModelToDomFile, 'contentModelToDom').and.returnValue( - selectionRangeExValue - ); - - triggerPluginEventSpy.and.callThrough(); - focusSpy.and.callThrough(); - selectSpy.and.callThrough(); - setContentModelSpy.and.callThrough(); - - // Act - domEvents.cut?.({}); - - // Assert - expect(getSelectionRangeEx).toHaveBeenCalled(); - expect(deleteSelectionSpy.calls.argsFor(0)[0]).toEqual(modelValue); - 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 - ); - - // On Cut Spy - expect(undoSnapShotSpy).toHaveBeenCalled(); - expect(setContentModelSpy).toHaveBeenCalledWith(modelValue, { - onNodeCreated: undefined, - }); - }); - - it('Selection not Collapsed and table selection', () => { - // Arrange - const table = document.createElement('table'); - table.id = 'table'; - selectionRangeExValue = { - type: SelectionRangeTypes.TableSelection, - ranges: [new Range()], - areAllCollapsed: false, - coordinates: {}, - table, - }; - - spyOn(createRangeF, 'default').and.callThrough(); - spyOn(deleteSelectionsFile, 'deleteSelection').and.returnValue({ - deleteResult: DeleteResult.Range, - insertPoint: null!, - }); - spyOn(contentModelToDomFile, 'contentModelToDom').and.callFake(() => { - const container = document.createElement('div'); - container.append(table); - - div.appendChild(container); - return selectionRangeExValue; - }); - spyOn(iterateSelectionsFile, 'iterateSelections').and.returnValue(undefined); - spyOn(normalizeContentModel, 'normalizeContentModel'); - - triggerPluginEventSpy.and.callThrough(); - focusSpy.and.callThrough(); - selectSpy.and.callThrough(); - setContentModelSpy.and.callThrough(); - - // Act - domEvents.cut?.({}); - - // Assert - expect(createRangeF.default).toHaveBeenCalledWith(table.parentElement); - expect(getSelectionRangeEx).toHaveBeenCalled(); - expect(contentModelToDomFile.contentModelToDom).toHaveBeenCalledWith( - document, - div, - pasteModelValue, - undefined, - { onNodeCreated } - ); - expect(createContentModelSpy).toHaveBeenCalled(); - expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); - expect(iterateSelectionsFile.iterateSelections).toHaveBeenCalled(); - expect(focusSpy).toHaveBeenCalled(); - expect(selectSpy).toHaveBeenCalledWith( - selectionRangeExValue, - undefined, - undefined, - undefined - ); - - // On Cut Spy - expect(undoSnapShotSpy).toHaveBeenCalled(); - expect(deleteSelectionsFile.deleteSelection).toHaveBeenCalled(); - expect(setContentModelSpy).toHaveBeenCalledWith(modelValue, { - onNodeCreated: undefined, - }); - expect(normalizeContentModel.normalizeContentModel).toHaveBeenCalledWith(modelValue); - }); - - it('Selection not Collapsed and image selection', () => { - // Arrange - const image = document.createElement('image'); - image.id = 'image'; - selectionRangeExValue = { - type: SelectionRangeTypes.ImageSelection, - ranges: [new Range()], - areAllCollapsed: false, - image, - }; - - spyOn(createRangeF, 'default').and.callThrough(); - 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(); - selectSpy.and.callThrough(); - setContentModelSpy.and.callThrough(); - - // Act - domEvents.cut?.({}); - - // Assert - expect(createRangeF.default).toHaveBeenCalledWith(image); - expect(getSelectionRangeEx).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 - ); - - // On Cut Spy - expect(undoSnapShotSpy).toHaveBeenCalled(); - expect(deleteSelectionsFile.deleteSelection).toHaveBeenCalled(); - expect(setContentModelSpy).toHaveBeenCalledWith(modelValue, { - onNodeCreated: undefined, - }); - expect(normalizeContentModel.normalizeContentModel).toHaveBeenCalledWith(modelValue); - }); - }); - - describe('Paste |', () => { - let clipboardData = {}; - - it('Handle', () => { - editor.isFeatureEnabled = () => true; - spyOn(PasteFile, 'default').and.callFake(() => {}); - const preventDefaultSpy = jasmine.createSpy('preventDefaultPaste'); - let clipboardEvent = { - clipboardData: ({ - items: [{}], - }), - preventDefault() { - preventDefaultSpy(); - }, - }; - spyOn(extractClipboardItemsFile, 'default').and.returnValue(>{ - then: (cb: (value: ClipboardData) => void | PromiseLike) => { - cb(clipboardData); - }, - }); - isDisposed.and.returnValue(false); - - domEvents.paste?.(clipboardEvent); - - expect(pasteSpy).not.toHaveBeenCalledWith(clipboardData); - expect(PasteFile.default).toHaveBeenCalled(); - expect(extractClipboardItemsFile.default).toHaveBeenCalledWith( - Array.from(clipboardEvent.clipboardData!.items), - { - allowedCustomPasteType, - }, - true - ); - expect(preventDefaultSpy).toHaveBeenCalledTimes(1); - }); - - it('Handle, editor is disposed', () => { - const preventDefaultSpy = jasmine.createSpy('preventDefaultPaste'); - let clipboardEvent = { - clipboardData: ({ - items: [{}], - }), - preventDefault() { - preventDefaultSpy(); - }, - }; - - spyOn(extractClipboardItemsFile, 'default').and.returnValue(>{ - then: (cb: (value: ClipboardData) => void | PromiseLike) => { - cb(clipboardData); - }, - }); - isDisposed.and.returnValue(true); - - domEvents.paste?.(clipboardEvent); - - expect(pasteSpy).not.toHaveBeenCalled(); - expect(extractClipboardItemsFile.default).toHaveBeenCalledWith( - Array.from(clipboardEvent.clipboardData!.items), - { - allowedCustomPasteType, - }, - true - ); - expect(preventDefaultSpy).toHaveBeenCalledTimes(1); - }); - }); -}); +import * as cloneModelFile from '../../../lib/modelApi/common/cloneModel'; +import * as contentModelToDomFile from 'roosterjs-content-model-dom/lib/modelToDom/contentModelToDom'; +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, + SelectionRangeTypes, +} from 'roosterjs-editor-types'; + +const modelValue = 'model' as any; +const darkColorHandler = 'darkColorHandler' as any; +const pasteModelValue = 'pasteModelValue' as any; +const insertPointValue = 'insertPoint' as any; +const deleteResultValue = 'deleteResult' as any; + +const allowedCustomPasteType = ['Test']; + +describe('ContentModelCopyPastePlugin |', () => { + let editor: IEditor = null!; + let plugin: ContentModelCopyPastePlugin; + let domEvents: Record = {}; + let div: HTMLDivElement; + + let selectionRangeExValue: SelectionRangeEx; + let getSelectionRangeEx: jasmine.Spy; + let createContentModelSpy: jasmine.Spy; + let triggerPluginEventSpy: jasmine.Spy; + let focusSpy: jasmine.Spy; + let undoSnapShotSpy: jasmine.Spy; + let selectSpy: jasmine.Spy; + let setContentModelSpy: jasmine.Spy; + let getSelectionRange: jasmine.Spy; + + let isDisposed: jasmine.Spy; + let pasteSpy: jasmine.Spy; + let cloneModelSpy: jasmine.Spy; + let transformToDarkColorSpy: jasmine.Spy; + + beforeEach(() => { + div = document.createElement('div'); + getSelectionRangeEx = jasmine + .createSpy('selectRangeExSpy') + .and.callFake(() => selectionRangeExValue); + createContentModelSpy = jasmine + .createSpy('createContentModelSpy') + .and.returnValue(modelValue); + triggerPluginEventSpy = jasmine.createSpy('triggerPluginEventSpy'); + focusSpy = jasmine.createSpy('focusSpy'); + undoSnapShotSpy = jasmine.createSpy('undoSnapShotSpy'); + selectSpy = jasmine.createSpy('selectSpy'); + setContentModelSpy = jasmine.createSpy('setContentModelSpy'); + getSelectionRange = jasmine.createSpy('selectionRange'); + pasteSpy = jasmine.createSpy('paste_'); + isDisposed = jasmine.createSpy('isDisposed'); + + cloneModelSpy = spyOn(cloneModelFile, 'cloneModel').and.callFake( + (model: any) => pasteModelValue + ); + transformToDarkColorSpy = jasmine.createSpy('transformToDarkColor'); + + plugin = new ContentModelCopyPastePlugin({ + allowedCustomPasteType, + }); + editor = ({ + getSelectionRange, + addDomEventHandler: ( + nameOrMap: string | Record, + handler?: DOMEventHandlerFunction + ) => { + domEvents = ((typeof nameOrMap == 'string' + ? { [nameOrMap]: handler! } + : nameOrMap) as any) as Record; + }, + getSelectionRangeEx, + createContentModel: (options: any) => createContentModelSpy(options), + triggerPluginEvent(eventType: any, data: any, broadcast: any) { + triggerPluginEventSpy(eventType, data, broadcast); + return data; + }, + runAsync(callback: any) { + callback(editor); + }, + focus() { + focusSpy(); + }, + addUndoSnapshot(callback: any, changeSource: any, canUndoByBackspace: any) { + callback?.(); + undoSnapShotSpy(callback, changeSource, canUndoByBackspace); + }, + select(a1: any, a2: any, a3: any, a4: any) { + selectSpy(a1, a2, a3, a4); + }, + setContentModel(model: any, option: any) { + setContentModelSpy(model, option); + }, + getDocument() { + return document; + }, + getCustomData( + key: string, + getter?: (() => HTMLDivElement) | undefined, + disposer?: ((value: HTMLDivElement) => void) | undefined + ) { + return div; + }, + getDarkColorHandler: () => { + return darkColorHandler; + }, + isDarkMode: () => { + return false; + }, + paste: (ar1: any) => { + pasteSpy(ar1); + }, + transformToDarkColor: transformToDarkColorSpy, + isDisposed, + }); + + plugin.initialize(editor); + }); + + describe('Copy |', () => { + it('Selection Collapsed', () => { + selectionRangeExValue = { + type: SelectionRangeTypes.Normal, + ranges: [], + areAllCollapsed: true, + }; + + createContentModelSpy.and.callThrough(); + triggerPluginEventSpy.and.callThrough(); + focusSpy.and.callThrough(); + undoSnapShotSpy.and.callThrough(); + selectSpy.and.callThrough(); + setContentModelSpy.and.callThrough(); + + domEvents.copy?.({}); + + expect(getSelectionRangeEx).toHaveBeenCalled(); + expect(createContentModelSpy).not.toHaveBeenCalled(); + expect(triggerPluginEventSpy).not.toHaveBeenCalled(); + expect(focusSpy).not.toHaveBeenCalled(); + expect(undoSnapShotSpy).not.toHaveBeenCalled(); + expect(selectSpy).not.toHaveBeenCalled(); + expect(setContentModelSpy).not.toHaveBeenCalled(); + }); + + it('Selection not Collapsed and normal selection', () => { + // Arrange + selectionRangeExValue = { + type: SelectionRangeTypes.Normal, + ranges: [new Range()], + areAllCollapsed: false, + }; + + spyOn(deleteSelectionsFile, 'deleteSelection'); + spyOn(contentModelToDomFile, 'contentModelToDom').and.returnValue( + selectionRangeExValue + ); + spyOn(iterateSelectionsFile, 'iterateSelections').and.returnValue(undefined); + + triggerPluginEventSpy.and.callThrough(); + focusSpy.and.callThrough(); + selectSpy.and.callThrough(); + setContentModelSpy.and.callThrough(); + + // 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(iterateSelectionsFile.iterateSelections).not.toHaveBeenCalled(); + expect(focusSpy).toHaveBeenCalled(); + expect(selectSpy).toHaveBeenCalledWith( + selectionRangeExValue, + undefined, + undefined, + undefined + ); + + // On Cut Spy + expect(undoSnapShotSpy).not.toHaveBeenCalled(); + expect(setContentModelSpy).not.toHaveBeenCalledWith(); + }); + + it('Selection not Collapsed and table selection', () => { + // Arrange + const table = document.createElement('table'); + table.id = 'table'; + // Arrange + selectionRangeExValue = { + type: SelectionRangeTypes.TableSelection, + ranges: [new Range()], + areAllCollapsed: false, + coordinates: {}, + table, + }; + + spyOn(createRangeF, 'default').and.callThrough(); + spyOn(deleteSelectionsFile, 'deleteSelection'); + spyOn(contentModelToDomFile, 'contentModelToDom').and.callFake(() => { + const container = document.createElement('div'); + container.append(table); + + div.appendChild(container); + return selectionRangeExValue; + }); + spyOn(iterateSelectionsFile, 'iterateSelections').and.returnValue(undefined); + + triggerPluginEventSpy.and.callThrough(); + focusSpy.and.callThrough(); + selectSpy.and.callThrough(); + setContentModelSpy.and.callThrough(); + + // Act + domEvents.copy?.({}); + + // Assert + expect(createRangeF.default).toHaveBeenCalledWith(table.parentElement); + 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(iterateSelectionsFile.iterateSelections).toHaveBeenCalled(); + expect(focusSpy).toHaveBeenCalled(); + expect(selectSpy).toHaveBeenCalledWith( + selectionRangeExValue, + undefined, + undefined, + undefined + ); + + // On Cut Spy + expect(undoSnapShotSpy).not.toHaveBeenCalled(); + expect(setContentModelSpy).not.toHaveBeenCalledWith(); + }); + + it('Selection not Collapsed and image selection', () => { + // Arrange + const image = document.createElement('image'); + image.id = 'image'; + selectionRangeExValue = { + type: SelectionRangeTypes.ImageSelection, + ranges: [new Range()], + areAllCollapsed: false, + image, + }; + + spyOn(createRangeF, 'default').and.callThrough(); + spyOn(deleteSelectionsFile, 'deleteSelection'); + spyOn(contentModelToDomFile, 'contentModelToDom').and.callFake(() => { + div.appendChild(image); + return selectionRangeExValue; + }); + spyOn(iterateSelectionsFile, 'iterateSelections').and.returnValue(undefined); + + triggerPluginEventSpy.and.callThrough(); + focusSpy.and.callThrough(); + selectSpy.and.callThrough(); + setContentModelSpy.and.callThrough(); + + // Act + domEvents.copy?.({}); + + // Assert + expect(createRangeF.default).toHaveBeenCalledWith(image); + 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 + ); + + // On Cut Spy + expect(undoSnapShotSpy).not.toHaveBeenCalled(); + 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 |', () => { + it('Selection Collapsed', () => { + // Arrange + selectionRangeExValue = { + type: SelectionRangeTypes.Normal, + ranges: [], + areAllCollapsed: true, + }; + + createContentModelSpy.and.callThrough(); + triggerPluginEventSpy.and.callThrough(); + focusSpy.and.callThrough(); + undoSnapShotSpy.and.callThrough(); + selectSpy.and.callThrough(); + setContentModelSpy.and.callThrough(); + + // Act + domEvents.cut?.({}); + + // Assert + expect(getSelectionRangeEx).toHaveBeenCalled(); + expect(createContentModelSpy).not.toHaveBeenCalled(); + expect(triggerPluginEventSpy).not.toHaveBeenCalled(); + expect(focusSpy).not.toHaveBeenCalled(); + expect(undoSnapShotSpy).not.toHaveBeenCalled(); + expect(selectSpy).not.toHaveBeenCalled(); + expect(setContentModelSpy).not.toHaveBeenCalled(); + }); + + it('Selection not Collapsed', () => { + // Arrange + selectionRangeExValue = { + type: SelectionRangeTypes.Normal, + ranges: [new Range()], + areAllCollapsed: false, + }; + + const deleteSelectionSpy = spyOn(deleteSelectionsFile, 'deleteSelection').and.callFake( + (model: any, steps: any, options: any) => { + return { + deletedModel: pasteModelValue, + insertPoint: insertPointValue, + deleteResult: deleteResultValue, + }; + } + ); + spyOn(contentModelToDomFile, 'contentModelToDom').and.returnValue( + selectionRangeExValue + ); + + triggerPluginEventSpy.and.callThrough(); + focusSpy.and.callThrough(); + selectSpy.and.callThrough(); + setContentModelSpy.and.callThrough(); + + // Act + domEvents.cut?.({}); + + // Assert + expect(getSelectionRangeEx).toHaveBeenCalled(); + expect(deleteSelectionSpy.calls.argsFor(0)[0]).toEqual(modelValue); + 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 + ); + + // On Cut Spy + expect(undoSnapShotSpy).toHaveBeenCalled(); + expect(setContentModelSpy).toHaveBeenCalledWith(modelValue, { + onNodeCreated: undefined, + }); + }); + + it('Selection not Collapsed and table selection', () => { + // Arrange + const table = document.createElement('table'); + table.id = 'table'; + selectionRangeExValue = { + type: SelectionRangeTypes.TableSelection, + ranges: [new Range()], + areAllCollapsed: false, + coordinates: {}, + table, + }; + + spyOn(createRangeF, 'default').and.callThrough(); + spyOn(deleteSelectionsFile, 'deleteSelection').and.returnValue({ + deleteResult: DeleteResult.Range, + insertPoint: null!, + }); + spyOn(contentModelToDomFile, 'contentModelToDom').and.callFake(() => { + const container = document.createElement('div'); + container.append(table); + + div.appendChild(container); + return selectionRangeExValue; + }); + spyOn(iterateSelectionsFile, 'iterateSelections').and.returnValue(undefined); + spyOn(normalizeContentModel, 'normalizeContentModel'); + + triggerPluginEventSpy.and.callThrough(); + focusSpy.and.callThrough(); + selectSpy.and.callThrough(); + setContentModelSpy.and.callThrough(); + + // Act + domEvents.cut?.({}); + + // Assert + expect(createRangeF.default).toHaveBeenCalledWith(table.parentElement); + expect(getSelectionRangeEx).toHaveBeenCalled(); + expect(contentModelToDomFile.contentModelToDom).toHaveBeenCalledWith( + document, + div, + pasteModelValue, + undefined, + { onNodeCreated } + ); + expect(createContentModelSpy).toHaveBeenCalled(); + expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); + expect(iterateSelectionsFile.iterateSelections).toHaveBeenCalled(); + expect(focusSpy).toHaveBeenCalled(); + expect(selectSpy).toHaveBeenCalledWith( + selectionRangeExValue, + undefined, + undefined, + undefined + ); + + // On Cut Spy + expect(undoSnapShotSpy).toHaveBeenCalled(); + expect(deleteSelectionsFile.deleteSelection).toHaveBeenCalled(); + expect(setContentModelSpy).toHaveBeenCalledWith(modelValue, { + onNodeCreated: undefined, + }); + expect(normalizeContentModel.normalizeContentModel).toHaveBeenCalledWith(modelValue); + }); + + it('Selection not Collapsed and image selection', () => { + // Arrange + const image = document.createElement('image'); + image.id = 'image'; + selectionRangeExValue = { + type: SelectionRangeTypes.ImageSelection, + ranges: [new Range()], + areAllCollapsed: false, + image, + }; + + spyOn(createRangeF, 'default').and.callThrough(); + 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(); + selectSpy.and.callThrough(); + setContentModelSpy.and.callThrough(); + + // Act + domEvents.cut?.({}); + + // Assert + expect(createRangeF.default).toHaveBeenCalledWith(image); + expect(getSelectionRangeEx).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 + ); + + // On Cut Spy + expect(undoSnapShotSpy).toHaveBeenCalled(); + expect(deleteSelectionsFile.deleteSelection).toHaveBeenCalled(); + expect(setContentModelSpy).toHaveBeenCalledWith(modelValue, { + onNodeCreated: undefined, + }); + expect(normalizeContentModel.normalizeContentModel).toHaveBeenCalledWith(modelValue); + }); + }); + + describe('Paste |', () => { + let clipboardData = {}; + + it('Handle', () => { + editor.isFeatureEnabled = () => true; + spyOn(PasteFile, 'default').and.callFake(() => {}); + const preventDefaultSpy = jasmine.createSpy('preventDefaultPaste'); + let clipboardEvent = { + clipboardData: ({ + items: [{}], + }), + preventDefault() { + preventDefaultSpy(); + }, + }; + spyOn(extractClipboardItemsFile, 'default').and.returnValue(>{ + then: (cb: (value: ClipboardData) => void | PromiseLike) => { + cb(clipboardData); + }, + }); + isDisposed.and.returnValue(false); + + domEvents.paste?.(clipboardEvent); + + expect(pasteSpy).not.toHaveBeenCalledWith(clipboardData); + expect(PasteFile.default).toHaveBeenCalled(); + expect(extractClipboardItemsFile.default).toHaveBeenCalledWith( + Array.from(clipboardEvent.clipboardData!.items), + { + allowedCustomPasteType, + }, + true + ); + expect(preventDefaultSpy).toHaveBeenCalledTimes(1); + }); + + it('Handle, editor is disposed', () => { + const preventDefaultSpy = jasmine.createSpy('preventDefaultPaste'); + let clipboardEvent = { + clipboardData: ({ + items: [{}], + }), + preventDefault() { + preventDefaultSpy(); + }, + }; + + spyOn(extractClipboardItemsFile, 'default').and.returnValue(>{ + then: (cb: (value: ClipboardData) => void | PromiseLike) => { + cb(clipboardData); + }, + }); + isDisposed.and.returnValue(true); + + domEvents.paste?.(clipboardEvent); + + expect(pasteSpy).not.toHaveBeenCalled(); + expect(extractClipboardItemsFile.default).toHaveBeenCalledWith( + Array.from(clipboardEvent.clipboardData!.items), + { + allowedCustomPasteType, + }, + true + ); + expect(preventDefaultSpy).toHaveBeenCalledTimes(1); + }); + }); +}); 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/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/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/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, 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/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', 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 1699a15787b..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, @@ -94,7 +98,7 @@ describe('insertEntity', () => { }, 'begin', false, - true, + undefined, context ); expect(getEntityFromElementSpy).toHaveBeenCalledWith(wrapper); @@ -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); }); @@ -217,7 +224,7 @@ describe('insertEntity', () => { }, 'begin', false, - true, + undefined, context ); expect(getEntityFromElementSpy).toHaveBeenCalledWith(wrapper); @@ -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 c9c03cce21c..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,17 +2,19 @@ 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 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 +26,8 @@ import { let clipboardData: ClipboardData; +const DEFAULT_TIMES_ADD_PARSER_CALLED = 3; + describe('Paste ', () => { let editor: IContentModelEditor; let addUndoSnapshot: jasmine.Spy; @@ -94,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, @@ -106,6 +118,7 @@ describe('Paste ', () => { getDocument, getTrustedHTMLHandler, triggerPluginEvent, + isDarkMode: () => false, } as any) as IContentModelEditor; }); @@ -181,7 +194,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 +205,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 +216,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 +227,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 +238,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); }); @@ -426,7 +439,7 @@ describe('mergePasteContent', () => { pasteF.mergePasteContent( sourceModel, - { deletedEntities: [] }, + { newEntities: [], deletedEntities: [] }, pasteModel, false /* applyCurrentFormat */, undefined /* customizedMerge */ @@ -435,7 +448,7 @@ describe('mergePasteContent', () => { expect(mergeModelFile.mergeModel).toHaveBeenCalledWith( sourceModel, pasteModel, - { deletedEntities: [] }, + { newEntities: [], deletedEntities: [] }, { mergeFormat: 'none', mergeTable: true, @@ -514,7 +527,7 @@ describe('mergePasteContent', () => { pasteF.mergePasteContent( sourceModel, - { deletedEntities: [] }, + { newEntities: [], deletedEntities: [] }, pasteModel, false /* applyCurrentFormat */, customizedMerge /* customizedMerge */ @@ -532,7 +545,7 @@ describe('mergePasteContent', () => { pasteF.mergePasteContent( sourceModel, - { deletedEntities: [] }, + { newEntities: [], deletedEntities: [] }, pasteModel, true /* applyCurrentFormat */, undefined /* customizedMerge */ @@ -541,7 +554,7 @@ describe('mergePasteContent', () => { expect(mergeModelFile.mergeModel).toHaveBeenCalledWith( sourceModel, pasteModel, - { deletedEntities: [] }, + { newEntities: [], deletedEntities: [] }, { mergeFormat: 'keepSourceEmphasisFormat', mergeTable: false, @@ -549,3 +562,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: {}, + }); + }); +}); 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 { }); }); -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); diff --git a/packages/roosterjs-editor-types/lib/interface/IEditor.ts b/packages/roosterjs-editor-types/lib/interface/IEditor.ts index f5ead36f91a..077c7733219 100644 --- a/packages/roosterjs-editor-types/lib/interface/IEditor.ts +++ b/packages/roosterjs-editor-types/lib/interface/IEditor.ts @@ -11,6 +11,7 @@ import Region from './Region'; import SelectionPath from './SelectionPath'; import TableSelection from './TableSelection'; import { ChangeSource } from '../enum/ChangeSource'; +import { ColorTransformDirection } from '../enum/ColorTransformDirection'; import { ContentPosition } from '../enum/ContentPosition'; import { DOMEventHandler } from '../type/domEventHandler'; import { EditorUndoState, PendableFormatState, StyleBasedFormatState } from './FormatState'; @@ -34,6 +35,7 @@ import type { CompatibleExperimentalFeatures } from '../compatibleEnum/Experimen import type { CompatibleGetContentMode } from '../compatibleEnum/GetContentMode'; import type { CompatibleQueryScope } from '../compatibleEnum/QueryScope'; import type { CompatibleRegionType } from '../compatibleEnum/RegionType'; +import type { CompatibleColorTransformDirection } from '../compatibleEnum/ColorTransformDirection'; /** * Interface of roosterjs editor object @@ -594,8 +596,12 @@ export default interface IEditor { /** * Transform the given node and all its child nodes to dark mode color if editor is in dark mode * @param node The node to transform + * @param direction The transform direction. @default ColorTransformDirection.LightToDark */ - transformToDarkColor(node: Node): void; + transformToDarkColor( + node: Node, + direction?: ColorTransformDirection | CompatibleColorTransformDirection + ): void; /** * Get a darkColorHandler object for this editor. diff --git a/versions.json b/versions.json index 41180a1fa5a..140dc48f39b 100644 --- a/versions.json +++ b/versions.json @@ -1,5 +1,5 @@ -{ - "packages": "8.54.0", - "packages-ui": "8.51.0", - "packages-content-model": "0.14.0" -} +{ + "packages": "8.55.0", + "packages-ui": "8.51.0", + "packages-content-model": "0.15.0" +}