From 5d48fd949b98b7b2cd9d96c84f005644e8329890 Mon Sep 17 00:00:00 2001 From: Ian Elizondo Date: Fri, 5 Jan 2024 18:01:51 -0600 Subject: [PATCH] Bump content model packages (#2312) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * change image * Content Model: Better hide cursor for table and image selection (#2270) * Standalone Editor: CreateStandaloneEditorCore * Standalone Editor: Port LifecyclePlugin * fix build * fix test * improve * fix test * Standalone Editor: Support keyboard input (init step) * Standalone Editor: Port EntityPlugin * improve * Add test * improve * port selection api * improve * improve * fix build * fix build * fix build * improve * Improve * improve * improve * fix test * improve * add test * remove unused code * Standalone Editor: port ImageSelection plugin * add test * Standalone Editor: Port UndoPlugin * improve * Port undo api * fix test * improve * improve * fix build * Improve * Improve * Improve * fix build * Improve * Add test * fix test * Add undo/redo API * Standalone Editor: Port event core API * fix build * fix build * Standalone Editor: Port transformColor API * Improve * Content Model: Better hide cursor for table and image selection * fix build --------- Co-authored-by: Bryan Valverde U * gif treatment * Move Paste from publicApi to coreApi to leverage the same domToModelOptions #2275 * selection * WIP * fix mac image selection * constant * Standalone Editor: Port paste API step 1 (#2279) * Standalone Editor: Port paste API step 2 (#2280) * Standalone Editor: Port paste API step 1 * Standalone Editor: Port paste API step 2 * improve * improve * Table Fidelity improvement: Width Attribute and Cellpadding attribute (#2284) * init * add test * update name of test * adjust link selection * Standalone Editor: Port paste API step 3 (#2281) * Standalone Editor: Port paste API step 1 * Standalone Editor: Port paste API step 2 * Standalone Editor: Port paste API step 3 * improve * improve * Improve * Improve * Adjust Selection on Cut/Copy first table cell (#2287) * init * fix * address comments * address additional scenario * adjust space for underline * adjust space for underline * fix multiple blocks * fixes * Standalone Editor: Port paste API step 4 (#2282) * Standalone Editor: Port paste API step 1 * Standalone Editor: Port paste API step 2 * Standalone Editor: Port paste API step 3 * Standalone Editor: Port paste API step 4 * improve * improve * fix test * Standalone Editor: Decouple core package from roosterjs-editor-dom (#2283) * Standalone Editor: Port paste API step 1 * Standalone Editor: Port paste API step 2 * Standalone Editor: Port paste API step 3 * Standalone Editor: Port paste API step 4 * Standalone Editor: Decouple core package from roosterjs-editor-dom * Set Deprecated font color to black instead of undefined (#2290) * init * use jasmine anything * Remove negative margins from Word (#2277) * Remove negative margins from Word * remove * Standalone Editor step 1 (#2291) * Fix GetFormatState not returning Font size after paste (#2299) * init * fix build * Standalone Editor step 2 (#2292) * keyboard input on mac * fix test * change isMac for isIME * iscomposing * Support rem unit (#2300) * Support rem unit * Use already existing case * justify-content-api * aligment list item * add justify to map * Content Model: Improve paste and sanitization behavior (#2304) * Fix couple of issues in Word Desktop Paste (#2311) * init * fix build * try fix build * fix again * Add a test * Re-activate tested without model check * Change borderLeft/Right to borderInlineStart/End (#2286) * fix RTL border application * fix table direction change * add tests * Update versions --------- Co-authored-by: Júlia Roldi Co-authored-by: Jiuqing Song Co-authored-by: Bryan Valverde U Co-authored-by: Julia Roldi <87443959+juliaroldi@users.noreply.github.com> Co-authored-by: Andres-CT98 <107568016+Andres-CT98@users.noreply.github.com> --- .../contentModel/ContentModelRibbon.tsx | 2 + .../contentModel/alignJustifyButton.ts | 19 + .../lib/modelApi/block/setModelAlignment.ts | 22 +- .../lib/modelApi/block/setModelDirection.ts | 42 +- .../common/retrieveModelFormatState.ts | 14 +- .../selection/adjustTrailingSpaceSelection.ts | 76 + .../lib/publicApi/block/setAlignment.ts | 2 +- .../lib/publicApi/link/insertLink.ts | 2 + .../lib/publicApi/segment/toggleUnderline.ts | 5 +- .../publicApi/table/applyTableBorderFormat.ts | 54 +- .../modelApi/block/setModelAlignmentTest.ts | 69 + .../modelApi/block/setModelDirectionTest.ts | 226 +- .../common/retrieveModelFormatStateTest.ts | 30 + .../adjustTrailingSpaceSelectionTest.ts | 200 ++ .../test/publicApi/block/setAlignmentTest.ts | 98 +- .../test/publicApi/link/insertLinkTest.ts | 50 + .../publicApi/segment/toggleUnderlineTest.ts | 50 + .../table/applyTableBorderFormatTest.ts | 291 +- .../lib/coreApi/createContentModel.ts | 9 +- .../lib/coreApi/paste.ts | 91 + .../lib/coreApi/setContentModel.ts | 9 +- .../lib/coreApi/setDOMSelection.ts | 23 +- .../lib/corePlugin/ContentModelCachePlugin.ts | 2 +- .../corePlugin/ContentModelCopyPastePlugin.ts | 54 +- .../corePlugin/ContentModelFormatPlugin.ts | 2 +- .../lib/corePlugin/DOMEventPlugin.ts | 56 +- .../lib/corePlugin/EntityPlugin.ts | 20 +- .../lib/corePlugin/LifecyclePlugin.ts | 2 +- .../lib/corePlugin/SelectionPlugin.ts | 27 +- .../lib/corePlugin/UndoPlugin.ts | 2 +- .../corePlugin/utils/applyDefaultFormat.ts | 5 +- .../lib/editor/DarkColorHandlerImpl.ts | 53 +- .../lib/editor/StandaloneEditor.ts | 373 +++ .../lib/editor/createStandaloneEditorCore.ts | 48 +- .../createStandaloneEditorDefaultSettings.ts | 59 +- .../lib/editor/standaloneCoreApiMap.ts | 6 +- .../roosterjs-content-model-core/lib/index.ts | 3 +- .../override/containerWidthFormatParser.ts | 11 + .../lib/override/pasteDisplayFormatParser.ts | 12 + .../lib/override/pasteEntityProcessor.ts | 36 + .../lib/override/pasteGeneralProcessor.ts | 54 + .../lib/override/pasteTextProcessor.ts | 15 + .../lib/publicApi/model/paste.ts | 252 -- .../lib/publicApi/selection/deleteSegment.ts | 2 +- .../lib/utils/paste/convertInlineCss.ts | 26 + .../lib/utils/paste/createPasteFragment.ts | 84 + .../paste/generatePasteOptionFromPlugins.ts | 71 + .../lib/utils/paste/mergePasteContent.ts | 98 + .../lib/utils/paste/retrieveHtmlInfo.ts | 128 + .../lib/utils/sanitizeElement.ts | 397 +++ .../roosterjs-content-model-core/package.json | 1 - .../test/coreApi/createContentModelTest.ts | 2 + .../{publicApi/model => coreApi}/pasteTest.ts | 398 +-- .../test/coreApi/setContentModelTest.ts | 18 +- .../test/coreApi/setDOMSelectionTest.ts | 47 +- .../ContentModelCopyPastePluginTest.ts | 581 +++- .../ContentModelFormatPluginTest.ts | 2 +- .../test/corePlugin/DomEventPluginTest.ts | 73 +- .../test/corePlugin/EntityPluginTest.ts | 14 +- .../test/corePlugin/SelectionPluginTest.ts | 117 +- .../utils/applyDefaultFormatTest.ts | 5 +- .../utils/applyPendingFormatTest.ts | 11 +- .../test/editor/DarkColorHandlerImplTest.ts | 12 +- .../test/editor/StandaloneEditorTest.ts | 732 +++++ .../editor/createStandaloneEditorCoreTest.ts | 351 +++ ...eateStandaloneEditorDefaultSettingsTest.ts | 129 + .../containerWidthFormatParserTest.ts | 37 + .../overrides/pasteDisplayFormatParserTest.ts | 57 + .../overrides/pasteEntityProcessorTest.ts | 91 + .../overrides/pasteGeneralProcessorTest.ts | 190 ++ .../test/overrides/pasteTextProcessorTest.ts | 72 + .../publicApi/color/transformColorTest.ts | 28 +- .../test/utils/paste/convertInlineCssTest.ts | 123 + .../utils/paste/createPasteFragmentTest.ts | 293 ++ .../generatePasteOptionFromPluginsTest.ts | 269 ++ .../test/utils/paste/mergePasteContentTest.ts | 403 +++ .../test/utils/paste/retrieveHtmlInfoTest.ts | 223 ++ .../test/utils/sanitizeElementTest.ts | 312 ++ .../domToModel/processors/textProcessor.ts | 6 +- .../lib/domUtils/entityUtils.ts | 13 - .../lib/domUtils/isWhiteSpacePreserved.ts | 10 + .../block/paddingFormatHandler.ts | 11 +- .../block/whiteSpaceFormatHandler.ts | 3 +- .../common/backgroundColorFormatHandler.ts | 10 +- .../common/borderFormatHandler.ts | 27 +- .../common/sizeFormatHandler.ts | 2 +- .../segment/boldFormatHandler.ts | 3 +- .../segment/letterSpacingFormatHandler.ts | 14 +- .../table/tableSpacingFormatHandler.ts | 6 + .../lib/formatHandlers/utils/color.ts | 4 +- .../utils/parseValueWithUnit.ts | 1 + .../formatHandlers/utils/shouldSetValue.ts | 15 + .../roosterjs-content-model-dom/lib/index.ts | 5 +- .../common/applySegmentFormatToElement.ts | 16 - .../lib/modelApi/common/isEmpty.ts | 3 +- .../modelApi/common/isWhiteSpacePreserved.ts | 16 - .../lib/modelApi/common/normalizeParagraph.ts | 4 +- .../processors/knownElementProcessorTest.ts | 2 - .../isWhiteSpacePreservedTest.ts | 15 +- .../block/paddingFormatHandlerTest.ts | 78 + .../block/whiteSpaceFormatHandlerTest.ts | 6 + .../backgroundColorFormatHandlerTest.ts | 7 + .../common/borderFormatHandlerTest.ts | 18 + .../common/sizeFormatHandlerTest.ts | 11 + .../segment/boldFormatHandlerTest.ts | 15 + .../segment/letterSpacingFormatHandlerTest.ts | 7 + .../table/tableSpacingFormatHandlerTest.ts | 6 + .../utils/parseValueWithUnitTest.ts | 4 + .../utils/shouldSetValueTest.ts | 45 + .../lib/coreApi/coreApiMap.ts | 4 +- .../lib/coreApi/ensureTypeInContainer.ts | 20 +- .../lib/coreApi/getContent.ts | 21 +- .../lib/coreApi/getStyleBasedFormatState.ts | 8 +- .../lib/coreApi/insertNode.ts | 28 +- .../lib/coreApi/setContent.ts | 42 +- .../lib/corePlugins/ContextMenuPlugin.ts | 110 + .../lib/corePlugins/createCorePlugins.ts | 18 +- .../lib/editor/ContentModelEditor.ts | 366 +-- .../lib/editor/createEditorCore.ts | 51 +- .../lib/editor/utils/buildRangeEx.ts | 4 +- .../lib/index.ts | 13 +- .../publicTypes/ContentModelCorePlugins.ts | 28 +- .../lib/publicTypes/ContentModelEditorCore.ts | 158 +- .../lib/publicTypes/ContextMenuPluginState.ts | 11 + .../lib/publicTypes/IContentModelEditor.ts | 18 +- .../test/corePlugins/ContextMenuPluginTest.ts | 93 + .../test/editor/ContentModelEditorTest.ts | 9 +- .../test/editor/createEditorCoreTest.ts | 216 +- .../edit/deleteSteps/deleteWordSelection.ts | 2 +- .../lib/paste/ContentModelPastePlugin.ts | 26 +- .../lib/paste/WacComponents/constants.ts | 23 - .../processPastedContentWacComponents.ts | 10 +- .../lib/paste/WordDesktop/getStyleMetadata.ts | 2 +- .../processPastedContentFromWordDesktop.ts | 35 +- .../lib/paste/WordDesktop/processWordLists.ts | 93 +- .../test/paste/ContentModelPastePluginTest.ts | 27 +- .../paste/e2e/cmPasteFromExcelOnlineTest.ts | 28 +- .../test/paste/e2e/cmPasteFromExcelTest.ts | 30 +- .../test/paste/e2e/cmPasteFromWacTest.ts | 189 +- .../test/paste/e2e/cmPasteFromWordTest.ts | 52 +- .../test/paste/e2e/cmPasteTest.ts | 9 +- .../test/paste/e2e/testUtils.ts | 1 + .../test/paste/getStyleMetadataTest.ts | 26 +- .../paste/processPastedContentFromWacTest.ts | 477 +-- ...processPastedContentFromWordDesktopTest.ts | 2762 ++++++++++++----- .../lib/editor/IStandaloneEditor.ts | 65 +- .../lib/editor/StandaloneEditorCore.ts | 186 +- .../lib/editor/StandaloneEditorOptions.ts | 15 + .../lib/event/ContentModelBeforePasteEvent.ts | 45 +- .../lib/index.ts | 20 +- .../lib/parameter/ClipboardData.ts | 3 +- .../lib/parameter/ValueSanitizer.ts | 10 + .../lib/pluginState/DOMEventPluginState.ts | 7 - .../StandaloneEditorPluginState.ts | 9 - .../lib/plugins/ImageEdit/ImageEdit.ts | 18 +- .../editInfoUtils/tryToConvertGifToPng.ts | 35 - versions.json | 19 +- 157 files changed, 10451 insertions(+), 3247 deletions(-) create mode 100644 demo/scripts/controls/ribbonButtons/contentModel/alignJustifyButton.ts create mode 100644 packages-content-model/roosterjs-content-model-api/lib/modelApi/selection/adjustTrailingSpaceSelection.ts create mode 100644 packages-content-model/roosterjs-content-model-api/test/modelApi/selection/adjustTrailingSpaceSelectionTest.ts create mode 100644 packages-content-model/roosterjs-content-model-core/lib/coreApi/paste.ts create mode 100644 packages-content-model/roosterjs-content-model-core/lib/editor/StandaloneEditor.ts create mode 100644 packages-content-model/roosterjs-content-model-core/lib/override/containerWidthFormatParser.ts create mode 100644 packages-content-model/roosterjs-content-model-core/lib/override/pasteDisplayFormatParser.ts create mode 100644 packages-content-model/roosterjs-content-model-core/lib/override/pasteEntityProcessor.ts create mode 100644 packages-content-model/roosterjs-content-model-core/lib/override/pasteGeneralProcessor.ts create mode 100644 packages-content-model/roosterjs-content-model-core/lib/override/pasteTextProcessor.ts delete mode 100644 packages-content-model/roosterjs-content-model-core/lib/publicApi/model/paste.ts create mode 100644 packages-content-model/roosterjs-content-model-core/lib/utils/paste/convertInlineCss.ts create mode 100644 packages-content-model/roosterjs-content-model-core/lib/utils/paste/createPasteFragment.ts create mode 100644 packages-content-model/roosterjs-content-model-core/lib/utils/paste/generatePasteOptionFromPlugins.ts create mode 100644 packages-content-model/roosterjs-content-model-core/lib/utils/paste/mergePasteContent.ts create mode 100644 packages-content-model/roosterjs-content-model-core/lib/utils/paste/retrieveHtmlInfo.ts create mode 100644 packages-content-model/roosterjs-content-model-core/lib/utils/sanitizeElement.ts rename packages-content-model/roosterjs-content-model-core/test/{publicApi/model => coreApi}/pasteTest.ts (56%) create mode 100644 packages-content-model/roosterjs-content-model-core/test/editor/StandaloneEditorTest.ts create mode 100644 packages-content-model/roosterjs-content-model-core/test/editor/createStandaloneEditorCoreTest.ts create mode 100644 packages-content-model/roosterjs-content-model-core/test/editor/createStandaloneEditorDefaultSettingsTest.ts create mode 100644 packages-content-model/roosterjs-content-model-core/test/overrides/containerWidthFormatParserTest.ts create mode 100644 packages-content-model/roosterjs-content-model-core/test/overrides/pasteDisplayFormatParserTest.ts create mode 100644 packages-content-model/roosterjs-content-model-core/test/overrides/pasteEntityProcessorTest.ts create mode 100644 packages-content-model/roosterjs-content-model-core/test/overrides/pasteGeneralProcessorTest.ts create mode 100644 packages-content-model/roosterjs-content-model-core/test/overrides/pasteTextProcessorTest.ts create mode 100644 packages-content-model/roosterjs-content-model-core/test/utils/paste/convertInlineCssTest.ts create mode 100644 packages-content-model/roosterjs-content-model-core/test/utils/paste/createPasteFragmentTest.ts create mode 100644 packages-content-model/roosterjs-content-model-core/test/utils/paste/generatePasteOptionFromPluginsTest.ts create mode 100644 packages-content-model/roosterjs-content-model-core/test/utils/paste/mergePasteContentTest.ts create mode 100644 packages-content-model/roosterjs-content-model-core/test/utils/paste/retrieveHtmlInfoTest.ts create mode 100644 packages-content-model/roosterjs-content-model-core/test/utils/sanitizeElementTest.ts create mode 100644 packages-content-model/roosterjs-content-model-dom/lib/domUtils/isWhiteSpacePreserved.ts create mode 100644 packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/utils/shouldSetValue.ts delete mode 100644 packages-content-model/roosterjs-content-model-dom/lib/modelApi/common/applySegmentFormatToElement.ts delete mode 100644 packages-content-model/roosterjs-content-model-dom/lib/modelApi/common/isWhiteSpacePreserved.ts rename packages-content-model/roosterjs-content-model-dom/test/{modelApi/common => domUtils}/isWhiteSpacePreservedTest.ts (50%) create mode 100644 packages-content-model/roosterjs-content-model-dom/test/formatHandlers/block/paddingFormatHandlerTest.ts create mode 100644 packages-content-model/roosterjs-content-model-dom/test/formatHandlers/utils/shouldSetValueTest.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/corePlugins/ContextMenuPlugin.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContextMenuPluginState.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/test/corePlugins/ContextMenuPluginTest.ts create mode 100644 packages-content-model/roosterjs-content-model-types/lib/parameter/ValueSanitizer.ts delete mode 100644 packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/editInfoUtils/tryToConvertGifToPng.ts diff --git a/demo/scripts/controls/ribbonButtons/contentModel/ContentModelRibbon.tsx b/demo/scripts/controls/ribbonButtons/contentModel/ContentModelRibbon.tsx index 97ece1f2b28..fdd99c1f47c 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/ContentModelRibbon.tsx +++ b/demo/scripts/controls/ribbonButtons/contentModel/ContentModelRibbon.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import { alignCenterButton } from './alignCenterButton'; +import { alignJustifyButton } from './alignJustifyButton'; import { alignLeftButton } from './alignLeftButton'; import { alignRightButton } from './alignRightButton'; import { backgroundColorButton } from './backgroundColorButton'; @@ -83,6 +84,7 @@ const buttons = [ alignLeftButton, alignCenterButton, alignRightButton, + alignJustifyButton, insertLinkButton, removeLinkButton, insertTableButton, diff --git a/demo/scripts/controls/ribbonButtons/contentModel/alignJustifyButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/alignJustifyButton.ts new file mode 100644 index 00000000000..9b7803f26ff --- /dev/null +++ b/demo/scripts/controls/ribbonButtons/contentModel/alignJustifyButton.ts @@ -0,0 +1,19 @@ +import { isContentModelEditor } from 'roosterjs-content-model-editor'; +import { RibbonButton } from 'roosterjs-react'; +import { setAlignment } from 'roosterjs-content-model-api'; + +/** + * @internal + * "Align justify" button on the format ribbon + */ +export const alignJustifyButton: RibbonButton<'buttonNameAlignJustify'> = { + key: 'buttonNameAlignJustify', + unlocalizedText: 'Align justify', + iconName: 'AlignJustify', + onClick: editor => { + if (isContentModelEditor(editor)) { + setAlignment(editor, 'justify'); + } + return true; + }, +}; diff --git a/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelAlignment.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelAlignment.ts index 7195148b4f1..5a46366499b 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelAlignment.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelAlignment.ts @@ -7,8 +7,8 @@ import type { } from 'roosterjs-content-model-types'; const ResultMap: Record< - 'left' | 'center' | 'right', - Record<'ltr' | 'rtl', 'start' | 'center' | 'end'> + 'left' | 'center' | 'right' | 'justify', + Record<'ltr' | 'rtl', 'start' | 'center' | 'end' | 'justify'> > = { left: { ltr: 'start', @@ -22,6 +22,10 @@ const ResultMap: Record< ltr: 'end', rtl: 'start', }, + justify: { + ltr: 'justify', + rtl: 'justify', + }, }; const TableAlignMap: Record< @@ -47,7 +51,7 @@ const TableAlignMap: Record< */ export function setModelAlignment( model: ContentModelDocument, - alignment: 'left' | 'center' | 'right' + alignment: 'left' | 'center' | 'right' | 'justify' ) { const paragraphOrListItemOrTable = getOperationalBlocks( model, @@ -56,15 +60,21 @@ export function setModelAlignment( ); paragraphOrListItemOrTable.forEach(({ block }) => { - const newAligment = ResultMap[alignment][block.format.direction == 'rtl' ? 'rtl' : 'ltr']; - if (block.blockType === 'Table') { + const newAlignment = ResultMap[alignment][block.format.direction == 'rtl' ? 'rtl' : 'ltr']; + if (block.blockType === 'Table' && alignment !== 'justify') { alignTable( block, TableAlignMap[alignment][block.format.direction == 'rtl' ? 'rtl' : 'ltr'] ); } else if (block) { + if (block.blockType === 'BlockGroup' && block.blockGroupType === 'ListItem') { + block.blocks.forEach(b => { + const { format } = b; + format.textAlign = newAlignment; + }); + } const { format } = block; - format.textAlign = newAligment; + format.textAlign = newAlignment; } }); diff --git a/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelDirection.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelDirection.ts index 3a984275972..c2170883717 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelDirection.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelDirection.ts @@ -1,6 +1,13 @@ import { findListItemsInSameThread } from '../list/findListItemsInSameThread'; -import { getOperationalBlocks, isBlockGroupOfType } from 'roosterjs-content-model-core'; +import { + applyTableFormat, + getOperationalBlocks, + isBlockGroupOfType, + updateTableCellMetadata, +} from 'roosterjs-content-model-core'; import type { + BorderFormat, + ContentModelBlock, ContentModelBlockFormat, ContentModelDocument, ContentModelListItem, @@ -30,14 +37,18 @@ export function setModelDirection(model: ContentModelDocument, direction: 'ltr' item.blocks.forEach(block => internalSetDirection(block.format, direction)); }); } else if (block) { - internalSetDirection(block.format, direction); + internalSetDirection(block.format, direction, block); } }); return paragraphOrListItemOrTable.length > 0; } -function internalSetDirection(format: ContentModelBlockFormat, direction: 'ltr' | 'rtl') { +function internalSetDirection( + format: ContentModelBlockFormat, + direction: 'ltr' | 'rtl', + block?: ContentModelBlock +) { const wasRtl = format.direction == 'rtl'; const isRtl = direction == 'rtl'; @@ -46,7 +57,6 @@ function internalSetDirection(format: ContentModelBlockFormat, direction: 'ltr' // Adjust margin when change direction // TODO: make margin and padding direction-aware, like what we did for textAlign. So no need to adjust them here - // TODO: Do we also need to handle border here? const marginLeft = format.marginLeft; const paddingLeft = format.paddingLeft; @@ -54,12 +64,32 @@ function internalSetDirection(format: ContentModelBlockFormat, direction: 'ltr' setProperty(format, 'marginRight', marginLeft); setProperty(format, 'paddingLeft', format.paddingRight); setProperty(format, 'paddingRight', paddingLeft); + + // If whole Table direction changed, flip cell side borders + if (block && block.blockType == 'Table') { + block.rows.forEach(row => { + row.cells.forEach(cell => { + // Optimise by skipping cells with unchanged borders + updateTableCellMetadata(cell, metadata => { + if (metadata?.borderOverride) { + const storeBorderLeft = cell.format.borderLeft; + setProperty(cell.format, 'borderLeft', cell.format.borderRight); + setProperty(cell.format, 'borderRight', storeBorderLeft); + } + return metadata; + }); + }); + }); + + // Apply changed borders + applyTableFormat(block, undefined /* newFormat */, true /* keepCellShade*/); + } } } function setProperty( - format: MarginFormat & PaddingFormat, - key: keyof (MarginFormat & PaddingFormat), + format: MarginFormat & PaddingFormat & BorderFormat, + key: keyof (MarginFormat & PaddingFormat & BorderFormat), value: string | undefined ) { if (value) { diff --git a/packages-content-model/roosterjs-content-model-api/lib/modelApi/common/retrieveModelFormatState.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/common/retrieveModelFormatState.ts index 76bf4ab95f7..55e499ce428 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/modelApi/common/retrieveModelFormatState.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/modelApi/common/retrieveModelFormatState.ts @@ -1,3 +1,4 @@ +import { parseValueWithUnit } from 'roosterjs-content-model-dom'; import { extractBorderValues, getClosestAncestorBlockGroupIndex, @@ -150,7 +151,13 @@ function retrieveSegmentFormat( mergeValue(result, 'letterSpacing', mergedFormat.letterSpacing, isFirst); mergeValue(result, 'fontName', mergedFormat.fontFamily, isFirst); - mergeValue(result, 'fontSize', mergedFormat.fontSize, isFirst); + mergeValue( + result, + 'fontSize', + mergedFormat.fontSize, + isFirst, + val => parseValueWithUnit(val, undefined, 'pt') + 'pt' + ); mergeValue(result, 'backgroundColor', mergedFormat.backgroundColor, isFirst); mergeValue(result, 'textColor', mergedFormat.textColor, isFirst); mergeValue(result, 'fontWeight', mergedFormat.fontWeight, isFirst); @@ -232,13 +239,14 @@ function mergeValue( format: ContentModelFormatState, key: K, newValue: ContentModelFormatState[K] | undefined, - isFirst: boolean + isFirst: boolean, + parseFn: (val: ContentModelFormatState[K]) => ContentModelFormatState[K] = val => val ) { if (isFirst) { if (newValue !== undefined) { format[key] = newValue; } - } else if (newValue !== format[key]) { + } else if (parseFn(newValue) !== parseFn(format[key])) { delete format[key]; } } diff --git a/packages-content-model/roosterjs-content-model-api/lib/modelApi/selection/adjustTrailingSpaceSelection.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/selection/adjustTrailingSpaceSelection.ts new file mode 100644 index 00000000000..f6d93201843 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-api/lib/modelApi/selection/adjustTrailingSpaceSelection.ts @@ -0,0 +1,76 @@ +import { createText } from 'roosterjs-content-model-dom'; +import { iterateSelections } from 'roosterjs-content-model-core'; +import type { + ContentModelDocument, + ContentModelParagraph, + ContentModelText, +} from 'roosterjs-content-model-types'; + +/** + * If a format cannot be applied to be applied to a trailing space, split the trailing space into a separate segment + * @internal + */ +export function adjustTrailingSpaceSelection(model: ContentModelDocument) { + iterateSelections(model, (_, __, block, segments) => { + if (block?.blockType === 'Paragraph' && segments && segments.length > 0) { + if ( + segments.length === 1 && + segments[0].segmentType === 'Text' && + shouldSplitTrailingSpace(segments[0]) + ) { + splitTextSegment(block, segments[0]); + } else { + const lastTextSegment = + segments[segments.length - 1].segmentType === 'SelectionMarker' + ? segments[segments.length - 2] + : segments[segments.length - 1]; + if ( + lastTextSegment && + lastTextSegment.segmentType === 'Text' && + shouldSplitTrailingSpace(lastTextSegment) + ) { + splitTextSegment(block, lastTextSegment); + } + } + } + + return false; + }); +} + +function shouldSplitTrailingSpace(segment: ContentModelText) { + return segment.isSelected && hasTrailingSpace(segment.text) && !isTrailingSpace(segment.text); +} + +function hasTrailingSpace(text: string) { + return text.trimRight() !== text; +} + +function isTrailingSpace(text: string) { + return text.trimRight().length == 0; +} + +function splitTextSegment(block: ContentModelParagraph, textSegment: Readonly) { + const text = textSegment.text.trimRight(); + const trailingSpace = textSegment.text.substring(text.length); + const newText = createText(text, textSegment.format, textSegment.link, textSegment.code); + newText.isSelected = true; + const trailingSpaceLink = textSegment.link + ? { + ...textSegment.link, + format: { + ...textSegment.link?.format, + underline: false, // Remove underline for trailing space link + }, + } + : undefined; + const trailingSpaceSegment = createText( + trailingSpace, + undefined, + trailingSpaceLink, + textSegment.code + ); + trailingSpaceSegment.isSelected = true; + const index = block.segments.indexOf(textSegment); + block.segments.splice(index, 1, newText, trailingSpaceSegment); +} diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/block/setAlignment.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/block/setAlignment.ts index 64e975c9493..9a9392477ed 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/block/setAlignment.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/block/setAlignment.ts @@ -8,7 +8,7 @@ import type { IStandaloneEditor } from 'roosterjs-content-model-types'; */ export default function setAlignment( editor: IStandaloneEditor, - alignment: 'left' | 'center' | 'right' + alignment: 'left' | 'center' | 'right' | 'justify' ) { editor.focus(); diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/link/insertLink.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/link/insertLink.ts index 3d29e419aae..64b68247a7b 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/link/insertLink.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/link/insertLink.ts @@ -1,3 +1,4 @@ +import { adjustTrailingSpaceSelection } from '../../modelApi/selection/adjustTrailingSpaceSelection'; import { ChangeSource, getSelectedSegments, mergeModel } from 'roosterjs-content-model-core'; import { HtmlSanitizer, matchLink } from 'roosterjs-editor-dom'; import type { ContentModelLink, IStandaloneEditor } from 'roosterjs-content-model-types'; @@ -91,6 +92,7 @@ export default function insertLink( }); } + adjustTrailingSpaceSelection(model); return segments.length > 0; }, { diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/toggleUnderline.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/toggleUnderline.ts index 8614fdd7b1d..b58ef453783 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/toggleUnderline.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/toggleUnderline.ts @@ -1,3 +1,4 @@ +import { adjustTrailingSpaceSelection } from '../../modelApi/selection/adjustTrailingSpaceSelection'; import { formatSegmentWithContentModel } from '../utils/formatSegmentWithContentModel'; import type { IStandaloneEditor } from 'roosterjs-content-model-types'; @@ -18,6 +19,8 @@ export default function toggleUnderline(editor: IStandaloneEditor) { segment.link.format.underline = !!isTurningOn; } }, - (format, segment) => !!format.underline || !!segment?.link?.format?.underline + (format, segment) => !!format.underline || !!segment?.link?.format?.underline, + false /*includingFormatHolder*/, + adjustTrailingSpaceSelection ); } diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/table/applyTableBorderFormat.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/table/applyTableBorderFormat.ts index 938820bb2db..f7c92cc7069 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/table/applyTableBorderFormat.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/table/applyTableBorderFormat.ts @@ -87,6 +87,9 @@ export default function applyTableBorderFormat( borderFormat = `${borderFormat} ${borderColor}`; } + // undefined is treated as Left to Right + const isRtl = tableModel.format.direction == 'rtl'; + if (sel) { const operations: BorderOperations[] = [operation]; while (operations.length) { @@ -132,13 +135,16 @@ export default function applyTableBorderFormat( rowIndex <= sel.lastRow; rowIndex++ ) { - const cell = tableModel.rows[rowIndex].cells[sel.firstColumn]; + const cell = + tableModel.rows[rowIndex].cells[ + isRtl ? sel.lastColumn : sel.firstColumn + ]; // Format cells - Left border applyBorderFormat(cell, borderFormat, leftBorder); } // Format perimeter - perimeter.Left = true; + isRtl ? (perimeter.Right = true) : (perimeter.Left = true); break; case 'rightBorders': const rightBorder: BorderPositions[] = ['borderRight']; @@ -147,13 +153,16 @@ export default function applyTableBorderFormat( rowIndex <= sel.lastRow; rowIndex++ ) { - const cell = tableModel.rows[rowIndex].cells[sel.lastColumn]; + const cell = + tableModel.rows[rowIndex].cells[ + isRtl ? sel.firstColumn : sel.lastColumn + ]; // Format cells - Right border applyBorderFormat(cell, borderFormat, rightBorder); } // Format perimeter - perimeter.Right = true; + isRtl ? (perimeter.Left = true) : (perimeter.Right = true); break; case 'topBorders': const topBorder: BorderPositions[] = ['borderTop']; @@ -222,7 +231,9 @@ export default function applyTableBorderFormat( // Single row selection if (singleRow) { applyBorderFormat( - tableModel.rows[sel.firstRow].cells[sel.firstColumn], + tableModel.rows[sel.firstRow].cells[ + isRtl ? sel.lastColumn : sel.firstColumn + ], borderFormat, ['borderRight'] ); @@ -238,7 +249,9 @@ export default function applyTableBorderFormat( ]); } applyBorderFormat( - tableModel.rows[sel.firstRow].cells[sel.lastColumn], + tableModel.rows[sel.firstRow].cells[ + isRtl ? sel.firstColumn : sel.lastColumn + ], borderFormat, ['borderLeft'] ); @@ -248,25 +261,33 @@ export default function applyTableBorderFormat( // For multiple rows and columns selections // Top left cell applyBorderFormat( - tableModel.rows[sel.firstRow].cells[sel.firstColumn], + tableModel.rows[sel.firstRow].cells[ + isRtl ? sel.lastColumn : sel.firstColumn + ], borderFormat, ['borderBottom', 'borderRight'] ); // Top right cell applyBorderFormat( - tableModel.rows[sel.firstRow].cells[sel.lastColumn], + tableModel.rows[sel.firstRow].cells[ + isRtl ? sel.firstColumn : sel.lastColumn + ], borderFormat, ['borderBottom', 'borderLeft'] ); // Bottom left cell applyBorderFormat( - tableModel.rows[sel.lastRow].cells[sel.firstColumn], + tableModel.rows[sel.lastRow].cells[ + isRtl ? sel.lastColumn : sel.firstColumn + ], borderFormat, ['borderTop', 'borderRight'] ); // Bottom right cell applyBorderFormat( - tableModel.rows[sel.lastRow].cells[sel.lastColumn], + tableModel.rows[sel.lastRow].cells[ + isRtl ? sel.firstColumn : sel.lastColumn + ], borderFormat, ['borderTop', 'borderLeft'] ); @@ -306,7 +327,7 @@ export default function applyTableBorderFormat( applyBorderFormat(cell, borderFormat, [ 'borderTop', 'borderBottom', - 'borderRight', + isRtl ? 'borderLeft' : 'borderRight', ]); } // Last column @@ -319,7 +340,7 @@ export default function applyTableBorderFormat( applyBorderFormat(cell, borderFormat, [ 'borderTop', 'borderBottom', - 'borderLeft', + isRtl ? 'borderRight' : 'borderLeft', ]); } // Inner cells @@ -342,7 +363,7 @@ export default function applyTableBorderFormat( } //Format perimeter if necessary or possible - modifyPerimeter(tableModel, sel, borderFormat, perimeter); + modifyPerimeter(tableModel, sel, borderFormat, perimeter, isRtl); } return true; @@ -395,7 +416,8 @@ function modifyPerimeter( tableModel: ContentModelTable, sel: TableSelectionCoordinates, borderFormat: string, - perimeter: Perimeter + perimeter: Perimeter, + isRtl: boolean ) { // Top of selection if (perimeter.Top && sel.firstRow - 1 >= 0) { @@ -415,14 +437,14 @@ function modifyPerimeter( if (perimeter.Left && sel.firstColumn - 1 >= 0) { for (let rowIndex = sel.firstRow; rowIndex <= sel.lastRow; rowIndex++) { const cell = tableModel.rows[rowIndex].cells[sel.firstColumn - 1]; - applyBorderFormat(cell, borderFormat, ['borderRight']); + applyBorderFormat(cell, borderFormat, [isRtl ? 'borderLeft' : 'borderRight']); } } // Right of selection if (perimeter.Right && sel.lastColumn + 1 < tableModel.rows[0].cells.length) { for (let rowIndex = sel.firstRow; rowIndex <= sel.lastRow; rowIndex++) { const cell = tableModel.rows[rowIndex].cells[sel.lastColumn + 1]; - applyBorderFormat(cell, borderFormat, ['borderLeft']); + applyBorderFormat(cell, borderFormat, [isRtl ? 'borderRight' : 'borderLeft']); } } } diff --git a/packages-content-model/roosterjs-content-model-api/test/modelApi/block/setModelAlignmentTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/block/setModelAlignmentTest.ts index d7f14a394d0..e9f695a57fc 100644 --- a/packages-content-model/roosterjs-content-model-api/test/modelApi/block/setModelAlignmentTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/modelApi/block/setModelAlignmentTest.ts @@ -904,4 +904,73 @@ describe('align left', () => { }); expect(result).toBeTrue(); }); + + it('align justify paragraph', () => { + const group = createContentModelDocument(); + const para = createParagraph(); + const text = createText('test'); + text.isSelected = true; + para.segments.push(text); + + group.blocks.push(para); + + const result = setModelAlignment(group, 'justify'); + expect(group).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: { + textAlign: 'justify', + }, + segments: [text], + }, + ], + }); + expect(result).toBeTruthy(); + }); + + it('align justify list item', () => { + const group = createContentModelDocument(); + const listLevel = createListLevel('OL'); + const listItem = createListItem([listLevel]); + const para = createParagraph(); + const para2 = createParagraph(); + const text = createText('test'); + const text2 = createText('test2'); + text.isSelected = true; + text2.isSelected = true; + para.segments.push(text); + para2.segments.push(text2); + + listItem.blocks.push(para, para2); + + group.blocks.push(listItem); + + const result = setModelAlignment(group, 'justify'); + expect(group).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockGroupType: 'ListItem', + blockType: 'BlockGroup', + levels: [ + { + listType: 'OL', + dataset: {}, + format: {}, + }, + ], + blocks: [para, para2], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { textAlign: 'justify' }, + }, + ], + }); + expect(result).toBeTruthy(); + }); }); diff --git a/packages-content-model/roosterjs-content-model-api/test/modelApi/block/setModelDirectionTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/block/setModelDirectionTest.ts index e2098d3ab20..0b986bbbbbc 100644 --- a/packages-content-model/roosterjs-content-model-api/test/modelApi/block/setModelDirectionTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/modelApi/block/setModelDirectionTest.ts @@ -2,15 +2,25 @@ import { ContentModelDocument } from 'roosterjs-content-model-types'; import { setModelDirection } from '../../../lib/modelApi/block/setModelDirection'; describe('setModelDirection', () => { + const width = '3px'; + const style = 'double'; + const color = '#AABBCC'; + const testBorderString = `${width} ${style} ${color}`; + function runTest( model: ContentModelDocument, direction: 'ltr' | 'rtl', expectedModel: ContentModelDocument, - expectedResult: boolean + expectedResult: boolean, + tableTest?: boolean ) { const result = setModelDirection(model, direction); expect(result).toBe(expectedResult); + + if (tableTest && model.blocks[0].blockType == 'Table') { + model.blocks[0].dataset = {}; + } expect(model).toEqual(expectedModel); } @@ -229,4 +239,218 @@ describe('setModelDirection', () => { true ); }); + + it('flip direction for table - LTR -> RTL', () => { + runTest( + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + rows: [ + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [], + format: { + borderRight: testBorderString, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: { + editingInfo: '{"borderOverride":true}', + }, + isSelected: true, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: { + borderLeft: testBorderString, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: { + editingInfo: '{"borderOverride":true}', + }, + isSelected: true, + }, + ], + }, + ], + format: {}, + widths: [], + dataset: {}, + }, + ], + format: {}, + }, + 'rtl', + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + rows: [ + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [], + format: { + borderLeft: testBorderString, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: { + editingInfo: '{"borderOverride":true}', + }, + isSelected: true, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: { + borderRight: testBorderString, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: { + editingInfo: '{"borderOverride":true}', + }, + isSelected: true, + }, + ], + }, + ], + format: { + direction: 'rtl', + }, + widths: [], + dataset: {}, + }, + ], + format: {}, + }, + true, + true + ); + }); + + it('flip direction for table - RTL -> LTR', () => { + runTest( + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + rows: [ + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [], + format: { + borderRight: testBorderString, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: { + editingInfo: '{"borderOverride":true}', + }, + isSelected: true, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: { + borderLeft: testBorderString, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: { + editingInfo: '{"borderOverride":true}', + }, + isSelected: true, + }, + ], + }, + ], + format: { + direction: 'rtl', + }, + widths: [], + dataset: {}, + }, + ], + format: {}, + }, + 'ltr', + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + rows: [ + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [], + format: { + borderLeft: testBorderString, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: { + editingInfo: '{"borderOverride":true}', + }, + isSelected: true, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: { + borderRight: testBorderString, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: { + editingInfo: '{"borderOverride":true}', + }, + isSelected: true, + }, + ], + }, + ], + format: { + direction: 'ltr', + }, + widths: [], + dataset: {}, + }, + ], + format: {}, + }, + true, + true + ); + }); }); diff --git a/packages-content-model/roosterjs-content-model-api/test/modelApi/common/retrieveModelFormatStateTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/common/retrieveModelFormatStateTest.ts index 45d9439fe76..21dc7d91fd5 100644 --- a/packages-content-model/roosterjs-content-model-api/test/modelApi/common/retrieveModelFormatStateTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/modelApi/common/retrieveModelFormatStateTest.ts @@ -781,4 +781,34 @@ describe('retrieveModelFormatState', () => { canAddImageAltText: false, }); }); + + it('With same format but using px and pt', () => { + const model = createContentModelDocument({}); + const result: ContentModelFormatState = {}; + const para = createParagraph(); + const text1 = createText('test1', { fontSize: '16pt' }); + const text2 = createText('test2', { fontSize: '21.3333px' }); + para.segments.push(text1, text2); + + text1.isSelected = true; + text2.isSelected = true; + + spyOn(iterateSelections, 'iterateSelections').and.callFake((path, callback) => { + callback([path], undefined, para, [text1, text2]); + return false; + }); + + retrieveModelFormatState(model, null, result); + + expect(result).toEqual({ + isBlockQuote: false, + isBold: false, + isSuperscript: false, + isSubscript: false, + fontSize: '16pt', + isCodeInline: false, + canUnlink: false, + canAddImageAltText: false, + }); + }); }); diff --git a/packages-content-model/roosterjs-content-model-api/test/modelApi/selection/adjustTrailingSpaceSelectionTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/selection/adjustTrailingSpaceSelectionTest.ts new file mode 100644 index 00000000000..2b090a60fd1 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-api/test/modelApi/selection/adjustTrailingSpaceSelectionTest.ts @@ -0,0 +1,200 @@ +import { adjustTrailingSpaceSelection } from '../../../lib/modelApi/selection/adjustTrailingSpaceSelection'; +import { ContentModelDocument } from 'roosterjs-content-model-types'; +import { + addSegment, + createContentModelDocument, + createParagraph, + createSelectionMarker, + createText, +} from 'roosterjs-content-model-dom'; + +describe('adjustTrailingSpaceSelection', () => { + function runTest(model: ContentModelDocument, modelResult: ContentModelDocument) { + adjustTrailingSpaceSelection(model); + expect(model).toEqual(modelResult); + } + + it('no trailing space', () => { + const model = createContentModelDocument(); + const text = createText('text'); + text.isSelected = true; + addSegment(model, text); + runTest(model, model); + }); + + it('trailing space', () => { + const model = createContentModelDocument(); + const text = createText('text '); + text.isSelected = true; + addSegment(model, text); + runTest(model, { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + format: {}, + text: 'text', + isSelected: true, + }, + { + segmentType: 'Text', + format: {}, + text: ' ', + isSelected: true, + }, + ], + isImplicit: true, + }, + ], + }); + }); + + it('trailing space multiple blocks', () => { + const model = createContentModelDocument(); + const paragraph = createParagraph(); + const text = createText('text '); + text.isSelected = true; + paragraph.segments.push(text); + const paragraph2 = createParagraph(); + const text2 = createText('text2 '); + text2.isSelected = true; + paragraph2.segments.push(text2); + model.blocks.push(paragraph, paragraph2); + + runTest(model, { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + format: {}, + text: 'text', + isSelected: true, + }, + { + segmentType: 'Text', + format: {}, + text: ' ', + isSelected: true, + }, + ], + }, + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + format: {}, + text: 'text2', + isSelected: true, + }, + { + segmentType: 'Text', + format: {}, + text: ' ', + isSelected: true, + }, + ], + }, + ], + }); + }); + + it('trailing space multiple segments', () => { + const model = createContentModelDocument(); + const paragraph = createParagraph(); + const text = createText('text '); + text.isSelected = true; + const text2 = createText('text2 '); + text2.isSelected = true; + paragraph.segments.push(text, text2); + model.blocks.push(paragraph); + + runTest(model, { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + format: {}, + text: 'text ', + isSelected: true, + }, + { + segmentType: 'Text', + format: {}, + text: 'text2', + isSelected: true, + }, + { + segmentType: 'Text', + format: {}, + text: ' ', + isSelected: true, + }, + ], + }, + ], + }); + }); + + it('trailing space multiple segments and selection marker', () => { + const model = createContentModelDocument(); + const paragraph = createParagraph(); + const text = createText('text '); + text.isSelected = true; + const text2 = createText('text2 '); + text2.isSelected = true; + const marker = createSelectionMarker(); + marker.isSelected = true; + paragraph.segments.push(text, text2, marker); + model.blocks.push(paragraph); + + runTest(model, { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + format: {}, + text: 'text ', + isSelected: true, + }, + { + segmentType: 'Text', + format: {}, + text: 'text2', + isSelected: true, + }, + { + segmentType: 'Text', + format: {}, + text: ' ', + isSelected: true, + }, + { + segmentType: 'SelectionMarker', + format: {}, + + isSelected: true, + }, + ], + }, + ], + }); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/block/setAlignmentTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/block/setAlignmentTest.ts index c6fc09cedb2..ea10eb6806f 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/block/setAlignmentTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/block/setAlignmentTest.ts @@ -845,7 +845,7 @@ describe('setAlignment in list', () => { function runTest( list: ContentModelListItem, - alignment: 'left' | 'right' | 'center', + alignment: 'left' | 'right' | 'center' | 'justify', expectedList: ContentModelListItem | null ) { const model = createContentModelDocument(); @@ -916,7 +916,9 @@ describe('setAlignment in list', () => { blocks: [ { blockType: 'Paragraph', - format: {}, + format: { + textAlign: 'start', + }, segments: [ { segmentType: 'Text', @@ -948,7 +950,9 @@ describe('setAlignment in list', () => { blocks: [ { blockType: 'Paragraph', - format: {}, + format: { + textAlign: 'start', + }, segments: [ { segmentType: 'Text', @@ -1022,7 +1026,9 @@ describe('setAlignment in list', () => { blocks: [ { blockType: 'Paragraph', - format: {}, + format: { + textAlign: 'center', + }, segments: [ { segmentType: 'Text', @@ -1098,7 +1104,9 @@ describe('setAlignment in list', () => { blocks: [ { blockType: 'Paragraph', - format: {}, + format: { + textAlign: 'end', + }, segments: [ { segmentType: 'Text', @@ -1124,4 +1132,84 @@ describe('setAlignment in list', () => { } ); }); + + it('List - apply justify', () => { + runTest( + { + blockGroupType: 'ListItem', + blockType: 'BlockGroup', + levels: [ + { + listType: 'OL', + dataset: {}, + format: {}, + }, + ], + blocks: [ + { + blockType: 'Paragraph', + format: { + textAlign: 'end', + }, + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + formatHolder: { segmentType: 'SelectionMarker', isSelected: true, format: {} }, + format: { + textAlign: 'end', + }, + }, + 'justify', + { + blockGroupType: 'ListItem', + blockType: 'BlockGroup', + levels: [ + { + listType: 'OL', + dataset: {}, + format: {}, + }, + ], + blocks: [ + { + blockType: 'Paragraph', + format: { + textAlign: 'justify', + }, + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + textAlign: 'justify', + }, + } + ); + }); }); diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/link/insertLinkTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/link/insertLinkTest.ts index b3c4f48538f..dc478caac08 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/link/insertLinkTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/link/insertLinkTest.ts @@ -357,4 +357,54 @@ describe('insertLink', () => { document.body.removeChild(div); }); + + it('Valid url with trailing space', () => { + const doc = createContentModelDocument(); + const text = createText('test '); + text.isSelected = true; + addSegment(doc, text); + + runTest(doc, 'http://test.com', { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + isImplicit: true, + segments: [ + { + segmentType: 'Text', + format: {}, + text: 'test', + link: { + dataset: {}, + format: { + href: 'http://test.com', + anchorTitle: undefined, + target: undefined, + underline: true, + }, + }, + isSelected: true, + }, + { + segmentType: 'Text', + format: {}, + text: ' ', + link: { + dataset: {}, + format: { + href: 'http://test.com', + anchorTitle: undefined, + target: undefined, + underline: false, + }, + }, + isSelected: true, + }, + ], + }, + ], + }); + }); }); diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/toggleUnderlineTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/toggleUnderlineTest.ts index ca0b7e65c77..210971c9eb3 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/toggleUnderlineTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/toggleUnderlineTest.ts @@ -470,4 +470,54 @@ describe('toggleUnderline', () => { 1 ); }); + + it('Turn on underline with trailing space', () => { + runTest( + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Test ', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + ], + format: {}, + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Test', + format: { + underline: true, + }, + isSelected: true, + }, + { + segmentType: 'Text', + text: ' ', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + ], + format: {}, + }, + 1 + ); + }); }); diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/table/applyTableBorderFormatTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/table/applyTableBorderFormatTest.ts index 5644056ff92..6d65809ef1b 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/table/applyTableBorderFormatTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/table/applyTableBorderFormatTest.ts @@ -48,7 +48,7 @@ describe('applyTableBorderFormat', () => { function runTest( table: ContentModelTable, - expectedTable: ContentModelTable | null, + expectedTable: ContentModelTable, border: Border, operation: BorderOperations ) { @@ -1561,4 +1561,293 @@ describe('applyTableBorderFormat', () => { 'insideBorders' ); }); + + it('RTL - Right Borders', () => { + const testTable = createTestTable(3, 3, { direction: 'rtl' }); + testTable.format.direction = 'rtl'; + runTest( + testTable, + { + blockType: 'Table', + rows: [ + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [], + format: { + direction: 'rtl', + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: { + direction: 'rtl', + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: { + direction: 'rtl', + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [], + format: { + direction: 'rtl', + borderLeft: testBorderString, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: { + editingInfo: '{"borderOverride":true}', + }, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: { + direction: 'rtl', + + borderRight: testBorderString, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: { + editingInfo: '{"borderOverride":true}', + }, + isSelected: true, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: { + direction: 'rtl', + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [], + format: { + direction: 'rtl', + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: { + direction: 'rtl', + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: { + direction: 'rtl', + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: { + direction: 'rtl', + }, + widths: [], + dataset: {}, + }, + testBorder, + 'rightBorders' + ); + }); + it('RTL - Left Borders', () => { + const testTable = createTestTable(3, 3, { direction: 'rtl' }); + testTable.format.direction = 'rtl'; + runTest( + testTable, + { + blockType: 'Table', + rows: [ + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [], + format: { + direction: 'rtl', + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: { + direction: 'rtl', + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: { + direction: 'rtl', + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [], + format: { + direction: 'rtl', + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: { + direction: 'rtl', + + borderLeft: testBorderString, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: { + editingInfo: '{"borderOverride":true}', + }, + isSelected: true, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: { + direction: 'rtl', + borderRight: testBorderString, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: { + editingInfo: '{"borderOverride":true}', + }, + }, + ], + }, + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [], + format: { + direction: 'rtl', + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: { + direction: 'rtl', + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: { + direction: 'rtl', + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: { + direction: 'rtl', + }, + widths: [], + dataset: {}, + }, + testBorder, + 'leftBorders' + ); + }); }); diff --git a/packages-content-model/roosterjs-content-model-core/lib/coreApi/createContentModel.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/createContentModel.ts index 8d160466b20..3d01c9aeaa5 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/coreApi/createContentModel.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/createContentModel.ts @@ -48,8 +48,13 @@ function internalCreateContentModel( ) { const editorContext = core.api.createEditorContext(core); const domToModelContext = option - ? createDomToModelContext(editorContext, ...(core.defaultDomToModelOptions || []), option) - : createDomToModelContextWithConfig(core.defaultDomToModelConfig, editorContext); + ? createDomToModelContext( + editorContext, + core.domToModelSettings.builtIn, + core.domToModelSettings.customized, + option + ) + : createDomToModelContextWithConfig(core.domToModelSettings.calculated, editorContext); return domToContentModel(core.contentDiv, domToModelContext, selection); } diff --git a/packages-content-model/roosterjs-content-model-core/lib/coreApi/paste.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/paste.ts new file mode 100644 index 00000000000..1d0505b1bd0 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/paste.ts @@ -0,0 +1,91 @@ +import { ChangeSource } from '../constants/ChangeSource'; +import { cloneModel } from '../publicApi/model/cloneModel'; +import { convertInlineCss } from '../utils/paste/convertInlineCss'; +import { createPasteFragment } from '../utils/paste/createPasteFragment'; +import { generatePasteOptionFromPlugins } from '../utils/paste/generatePasteOptionFromPlugins'; +import { mergePasteContent } from '../utils/paste/mergePasteContent'; +import { retrieveHtmlInfo } from '../utils/paste/retrieveHtmlInfo'; +import type { CloneModelOptions } from '../publicApi/model/cloneModel'; +import type { + PasteType, + ClipboardData, + Paste, + StandaloneEditorCore, +} from 'roosterjs-content-model-types'; +import type { TrustedHTMLHandler } from 'roosterjs-editor-types'; + +const CloneOption: CloneModelOptions = { + includeCachedElement: (node, type) => (type == 'cache' ? undefined : node), +}; + +/** + * @internal + * Paste into editor using a clipboardData object + * @param core The StandaloneEditorCore object. + * @param clipboardData Clipboard data retrieved from clipboard + * @param pasteType Type of content to paste. @default normal + */ +export const paste: Paste = ( + core: StandaloneEditorCore, + clipboardData: ClipboardData, + pasteType: PasteType = 'normal' +) => { + core.api.focus(core); + + if (clipboardData.modelBeforePaste) { + core.api.setContentModel(core, cloneModel(clipboardData.modelBeforePaste, CloneOption)); + } else { + clipboardData.modelBeforePaste = cloneModel(core.api.createContentModel(core), CloneOption); + } + + core.api.formatContentModel( + core, + (model, context) => { + // 1. Prepare variables + const doc = createDOMFromHtml(clipboardData.rawHtml, core.trustedHTMLHandler); + + // 2. Handle HTML from clipboard + const htmlFromClipboard = retrieveHtmlInfo(doc, clipboardData); + + // 3. Create target fragment + const sourceFragment = createPasteFragment( + core.contentDiv.ownerDocument, + clipboardData, + pasteType, + (clipboardData.rawHtml == clipboardData.html + ? doc + : createDOMFromHtml(clipboardData.html, core.trustedHTMLHandler) + )?.body + ); + + // 4. Trigger BeforePaste event to allow plugins modify the fragment + const eventResult = generatePasteOptionFromPlugins( + core, + clipboardData, + sourceFragment, + htmlFromClipboard, + pasteType + ); + + // 5. Convert global CSS to inline CSS + convertInlineCss(eventResult.fragment, htmlFromClipboard.globalCssRules); + + // 6. Merge pasted content into main Content Model + mergePasteContent(model, context, eventResult, core.domToModelSettings.customized); + + return true; + }, + { + changeSource: ChangeSource.Paste, + getChangeData: () => clipboardData, + apiName: 'paste', + } + ); +}; + +function createDOMFromHtml( + html: string | null | undefined, + trustedHTMLHandler: TrustedHTMLHandler +): Document | null { + return html ? new DOMParser().parseFromString(trustedHTMLHandler(html), 'text/html') : null; +} diff --git a/packages-content-model/roosterjs-content-model-core/lib/coreApi/setContentModel.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/setContentModel.ts index 68b4be7037c..ef396794bfc 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/coreApi/setContentModel.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/setContentModel.ts @@ -15,8 +15,13 @@ import type { SetContentModel } from 'roosterjs-content-model-types'; export const setContentModel: SetContentModel = (core, model, option, onNodeCreated) => { const editorContext = core.api.createEditorContext(core); const modelToDomContext = option - ? createModelToDomContext(editorContext, ...(core.defaultModelToDomOptions || []), option) - : createModelToDomContextWithConfig(core.defaultModelToDomConfig, editorContext); + ? createModelToDomContext( + editorContext, + core.modelToDomSettings.builtIn, + core.modelToDomSettings.customized, + option + ) + : createModelToDomContextWithConfig(core.modelToDomSettings.calculated, editorContext); const selection = contentModelToDom( core.contentDiv.ownerDocument, diff --git a/packages-content-model/roosterjs-content-model-core/lib/coreApi/setDOMSelection.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/setDOMSelection.ts index 50c413b9ae4..37e969891bd 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/coreApi/setDOMSelection.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/setDOMSelection.ts @@ -12,7 +12,8 @@ const IMAGE_ID = 'image'; const TABLE_ID = 'table'; const CONTENT_DIV_ID = 'contentDiv'; const DEFAULT_SELECTION_BORDER_COLOR = '#DB626C'; -const TABLE_CSS_RULE = '{background-color: rgb(198,198,198) !important; caret-color: transparent}'; +const TABLE_CSS_RULE = '{background-color: rgb(198,198,198) !important;}'; +const CARET_CSS_RULE = '{caret-color: transparent}'; const MAX_RULE_SELECTOR_LENGTH = 9000; /** @@ -37,7 +38,8 @@ export const setDOMSelection: SetDOMSelection = (core, selection, skipSelectionC const image = selection.image; selectionRules = buildImageCSS( - rootSelector + ' #' + addUniqueId(image, IMAGE_ID), + rootSelector, + addUniqueId(image, IMAGE_ID), core.selection.imageSelectionBorderColor ); core.selection.selection = selection; @@ -48,7 +50,8 @@ export const setDOMSelection: SetDOMSelection = (core, selection, skipSelectionC const { table, firstColumn, firstRow } = selection; selectionRules = buildTableCss( - rootSelector + ' #' + addUniqueId(table, TABLE_ID), + rootSelector, + addUniqueId(table, TABLE_ID), selection ); core.selection.selection = selection; @@ -92,15 +95,20 @@ export const setDOMSelection: SetDOMSelection = (core, selection, skipSelectionC } }; -function buildImageCSS(rootSelector: string, borderColor?: string): string[] { +function buildImageCSS(editorSelector: string, imageId: string, borderColor?: string): string[] { const color = borderColor || DEFAULT_SELECTION_BORDER_COLOR; return [ - `${rootSelector} {outline-style:auto!important;outline-color:${color}!important;caret-color:transparent;}`, + `${editorSelector} #${imageId} {outline-style:auto!important;outline-color:${color}!important;}`, + `${editorSelector} ${CARET_CSS_RULE}`, ]; } -function buildTableCss(rootSelector: string, selection: TableSelection): string[] { +function buildTableCss( + editorSelector: string, + tableId: string, + selection: TableSelection +): string[] { const { firstColumn, firstRow, lastColumn, lastRow } = selection; const cells = parseTableCells(selection.table); const isAllTableSelected = @@ -108,11 +116,12 @@ function buildTableCss(rootSelector: string, selection: TableSelection): string[ firstColumn == 0 && lastRow == cells.length - 1 && lastColumn == (cells[lastRow]?.length ?? 0) - 1; + const rootSelector = editorSelector + ' #' + tableId; const selectors = isAllTableSelected ? [rootSelector, `${rootSelector} *`] : handleTableSelected(rootSelector, selection, cells); - const cssRules: string[] = []; + const cssRules: string[] = [`${editorSelector} ${CARET_CSS_RULE}`]; let currentRules: string = ''; for (let i = 0; i < selectors.length; i++) { diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCachePlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCachePlugin.ts index 72d272f827b..6ccff76c84e 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCachePlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCachePlugin.ts @@ -19,7 +19,7 @@ import type { * ContentModel cache plugin manages cached Content Model, and refresh the cache when necessary */ class ContentModelCachePlugin implements PluginWithState { - private editor: (IEditor & IStandaloneEditor) | null = null; + private editor: IStandaloneEditor | null = null; private state: ContentModelCachePluginState; /** diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin.ts index ef3684725c1..7408a547f98 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin.ts @@ -6,7 +6,6 @@ import { deleteSelection } from '../publicApi/selection/deleteSelection'; import { extractClipboardItems } from '../utils/extractClipboardItems'; import { getSelectedCells } from '../publicApi/table/getSelectedCells'; import { iterateSelections } from '../publicApi/selection/iterateSelections'; -import { paste } from '../publicApi/model/paste'; import { PluginEventType } from 'roosterjs-editor-types'; import { transformColor } from '../publicApi/color/transformColor'; import { @@ -27,6 +26,10 @@ import type { IStandaloneEditor, OnNodeCreated, StandaloneEditorOptions, + ContentModelDocument, + ContentModelParagraph, + TableSelectionContext, + ContentModelSegment, } from 'roosterjs-content-model-types'; import type { IEditor, PluginWithState } from 'roosterjs-editor-types'; @@ -34,7 +37,7 @@ import type { IEditor, PluginWithState } from 'roosterjs-editor-types'; * Copy and paste plugin for handling onCopy and onPaste event */ class ContentModelCopyPastePlugin implements PluginWithState { - private editor: (IStandaloneEditor & IEditor) | null = null; + private editor: IStandaloneEditor | null = null; private disposer: (() => void) | null = null; private state: CopyPastePluginState; @@ -107,7 +110,7 @@ class ContentModelCopyPastePlugin implements PluginWithState { - const editor = e as IStandaloneEditor & IEditor; + doc.defaultView?.requestAnimationFrame(() => { + if (!this.editor) { + return; + } cleanUpAndRestoreSelection(tempDiv); - editor.focus(); - editor.setDOMSelection(selection); + this.editor.focus(); + this.editor.setDOMSelection(selection); if (isCut) { - editor.formatContentModel( + this.editor.formatContentModel( (model, context) => { if ( deleteSelection(model, [deleteEmptyList], context) @@ -194,7 +202,7 @@ class ContentModelCopyPastePlugin implements PluginWithState { if (!editor.isDisposed()) { - paste(editor, clipboardData); + editor.pasteFromClipboard(clipboardData); } }); } @@ -251,6 +259,34 @@ class ContentModelCopyPastePlugin implements PluginWithState { + if (selectionMarker) { + if (tableCtxt != tableContext && firstBlock?.segments.includes(selectionMarker)) { + firstBlock.segments.splice(firstBlock.segments.indexOf(selectionMarker), 1); + } + return true; + } + + const marker = segments?.find(segment => segment.segmentType == 'SelectionMarker'); + if (!selectionMarker && marker) { + tableContext = tableCtxt; + firstBlock = block?.blockType == 'Paragraph' ? block : undefined; + selectionMarker = marker; + } + + return false; + }); +} + function cleanUpAndRestoreSelection(tempDiv: HTMLDivElement) { tempDiv.style.backgroundColor = ''; tempDiv.style.color = ''; diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelFormatPlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelFormatPlugin.ts index 6a93175b0a0..3cd4ea2862e 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelFormatPlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelFormatPlugin.ts @@ -19,7 +19,7 @@ const ProcessKey = 'Process'; * 1. Handle pending format changes when selection is collapsed */ class ContentModelFormatPlugin implements PluginWithState { - private editor: (IStandaloneEditor & IEditor) | null = null; + private editor: IStandaloneEditor | null = null; private hasDefaultFormat = false; private state: ContentModelFormatPluginState; diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/DOMEventPlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/DOMEventPlugin.ts index e80f1ac10d9..1ec7ae78567 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/DOMEventPlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/DOMEventPlugin.ts @@ -1,5 +1,6 @@ import { ChangeSource } from '../constants/ChangeSource'; import { isCharacterValue, isCursorMovingKey } from '../publicApi/domUtils/eventUtils'; +import { isNodeOfType } from 'roosterjs-content-model-dom'; import { PluginEventType } from 'roosterjs-editor-types'; import type { DOMEventPluginState, @@ -7,12 +8,7 @@ import type { DOMEventRecord, StandaloneEditorOptions, } from 'roosterjs-content-model-types'; -import type { - ContextMenuProvider, - EditorPlugin, - IEditor, - PluginWithState, -} from 'roosterjs-editor-types'; +import type { IEditor, PluginWithState } from 'roosterjs-editor-types'; /** * DOMEventPlugin handles customized DOM events, including: @@ -26,7 +22,7 @@ import type { * It contains special handling for Safari since Safari cannot get correct selection when onBlur event is triggered in editor. */ class DOMEventPlugin implements PluginWithState { - private editor: (IStandaloneEditor & IEditor) | null = null; + private editor: IStandaloneEditor | null = null; private disposer: (() => void) | null = null; private state: DOMEventPluginState; @@ -39,8 +35,6 @@ class DOMEventPlugin implements PluginWithState { this.state = { isInIME: false, scrollContainer: options.scrollContainer || contentDiv, - contextMenuProviders: - options.plugins?.filter>(isContextMenuProvider) || [], mouseDownX: null, mouseDownY: null, mouseUpEventListerAdded: false, @@ -72,7 +66,6 @@ class DOMEventPlugin implements PluginWithState { // 2. Mouse event mousedown: { beforeDispatch: this.onMouseDown }, - contextmenu: { beforeDispatch: this.onContextMenuEvent }, // 3. IME state management compositionstart: { beforeDispatch: this.onCompositionStart }, @@ -119,17 +112,23 @@ class DOMEventPlugin implements PluginWithState { private onDragStart = (e: Event) => { const dragEvent = e as DragEvent; - const element = this.editor?.getElementAtCursor('*', dragEvent.target as Node); + const node = dragEvent.target as Node; + const element = isNodeOfType(node, 'ELEMENT_NODE') ? node : node.parentElement; if (element && !element.isContentEditable) { dragEvent.preventDefault(); } }; + private onDrop = () => { - this.editor?.runAsync(() => { + const doc = this.editor?.getDocument(); + + doc?.defaultView?.requestAnimationFrame(() => { if (this.editor) { this.editor.takeSnapshot(); - this.editor.triggerContentChangedEvent(ChangeSource.Drop); + this.editor.triggerPluginEvent(PluginEventType.ContentChanged, { + source: ChangeSource.Drop, + }); } }); }; @@ -194,33 +193,6 @@ class DOMEventPlugin implements PluginWithState { } }; - private onContextMenuEvent = (event: MouseEvent) => { - const allItems: any[] = []; - - // TODO: Remove dependency to ContentSearcher - const searcher = this.editor?.getContentSearcherOfCursor(); - const elementBeforeCursor = searcher?.getInlineElementBefore(); - - let eventTargetNode = event.target as Node; - if (event.button != 2 && elementBeforeCursor) { - eventTargetNode = elementBeforeCursor.getContainerNode(); - } - this.state.contextMenuProviders.forEach(provider => { - const items = provider.getContextMenuItems(eventTargetNode) ?? []; - if (items?.length > 0) { - if (allItems.length > 0) { - allItems.push(null); - } - - allItems.push(...items); - } - }); - this.editor?.triggerPluginEvent(PluginEventType.ContextMenu, { - rawEvent: event, - items: allItems, - }); - }; - private onCompositionStart = () => { this.state.isInIME = true; }; @@ -240,10 +212,6 @@ class DOMEventPlugin implements PluginWithState { } } -function isContextMenuProvider(source: EditorPlugin): source is ContextMenuProvider { - return !!(>source)?.getContextMenuItems; -} - /** * @internal * Create a new instance of DOMEventPlugin. diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/EntityPlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/EntityPlugin.ts index 6d9c2629e03..a5fe63ca299 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/EntityPlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/EntityPlugin.ts @@ -1,3 +1,4 @@ +import { EntityOperation as LegacyEntityOperation, PluginEventType } from 'roosterjs-editor-types'; import { findAllEntities } from './utils/findAllEntities'; import { transformColor } from '../publicApi/color/transformColor'; import { @@ -8,7 +9,6 @@ import { isEntityElement, parseEntityClassName, } from 'roosterjs-content-model-dom'; -import { EntityOperation as LegacyEntityOperation, PluginEventType } from 'roosterjs-editor-types'; import type { ChangedEntity, ContentModelContentChangedEvent, @@ -43,7 +43,7 @@ const EntityOperationMap: Record = { * Entity Plugin helps handle all operations related to an entity and generate entity specified events */ class EntityPlugin implements PluginWithState { - private editor: (IEditor & IStandaloneEditor) | null = null; + private editor: IStandaloneEditor | null = null; private state: EntityPluginState; /** @@ -109,12 +109,12 @@ class EntityPlugin implements PluginWithState { } } - private handleMouseUpEvent(editor: IEditor & IStandaloneEditor, event: PluginMouseUpEvent) { + private handleMouseUpEvent(editor: IStandaloneEditor, event: PluginMouseUpEvent) { const { rawEvent, isClicking } = event; let node: Node | null = rawEvent.target as Node; if (isClicking && this.editor) { - while (node && this.editor.contains(node)) { + while (node && this.editor.isNodeInEditor(node)) { if (isEntityElement(node)) { this.triggerEvent(editor, node as HTMLElement, 'click', rawEvent); break; @@ -125,10 +125,7 @@ class EntityPlugin implements PluginWithState { } } - private handleContentChangedEvent( - editor: IStandaloneEditor & IEditor, - event?: ContentChangedEvent - ) { + private handleContentChangedEvent(editor: IStandaloneEditor, event?: ContentChangedEvent) { const cmEvent = event as ContentModelContentChangedEvent | undefined; const modifiedEntities: ChangedEntity[] = cmEvent?.changedEntities ?? this.getChangedEntities(editor); @@ -235,10 +232,7 @@ class EntityPlugin implements PluginWithState { return result; } - private handleExtractContentWithDomEvent( - editor: IEditor & IStandaloneEditor, - root: HTMLElement - ) { + private handleExtractContentWithDomEvent(editor: IStandaloneEditor, root: HTMLElement) { getAllEntityWrappers(root).forEach(element => { element.removeAttribute('contentEditable'); @@ -247,7 +241,7 @@ class EntityPlugin implements PluginWithState { } private triggerEvent( - editor: IEditor & IStandaloneEditor, + editor: IStandaloneEditor, wrapper: HTMLElement, operation: EntityOperation, rawEvent?: Event, diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/LifecyclePlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/LifecyclePlugin.ts index 79a05aac573..daac254a6b8 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/LifecyclePlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/LifecyclePlugin.ts @@ -24,7 +24,7 @@ const DefaultBackColor = '#ffffff'; * Lifecycle plugin handles editor initialization and disposing */ class LifecyclePlugin implements PluginWithState { - private editor: (IStandaloneEditor & IEditor) | null = null; + private editor: IStandaloneEditor | null = null; private state: LifecyclePluginState; private initialModel: ContentModelDocument; private initializer: (() => void) | null = null; diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/SelectionPlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/SelectionPlugin.ts index bc844d0b49e..a6752370240 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/SelectionPlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/SelectionPlugin.ts @@ -10,9 +10,10 @@ import type { } from 'roosterjs-content-model-types'; const MouseMiddleButton = 1; +const MouseRightButton = 2; class SelectionPlugin implements PluginWithState { - private editor: (IStandaloneEditor & IEditor) | null = null; + private editor: IStandaloneEditor | null = null; private state: SelectionPluginState; private disposer: (() => void) | null = null; @@ -105,8 +106,16 @@ class SelectionPlugin implements PluginWithState { case PluginEventType.MouseDown: selection = this.editor.getDOMSelection(); - - if (selection?.type == 'image' && selection.image !== event.rawEvent.target) { + if ( + event.rawEvent.button === MouseRightButton && + (image = this.getClickingImage(event.rawEvent)) && + image.isContentEditable + ) { + this.selectImage(this.editor, image); + } else if ( + selection?.type == 'image' && + selection.image !== event.rawEvent.target + ) { this.selectBeforeImage(this.editor, selection.image); } break; @@ -130,16 +139,6 @@ class SelectionPlugin implements PluginWithState { } } break; - - case PluginEventType.ContextMenu: - selection = this.editor.getDOMSelection(); - - if ( - (image = this.getClickingImage(event.rawEvent)) && - (selection?.type != 'image' || selection.image != image) - ) { - this.selectImage(this.editor, image); - } } } @@ -199,7 +198,7 @@ class SelectionPlugin implements PluginWithState { }; private onMouseDownDocument = (event: MouseEvent) => { - if (this.editor && !this.editor.contains(event.target as Node)) { + if (this.editor && !this.editor.isNodeInEditor(event.target as Node)) { this.onBlur(); } }; diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/UndoPlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/UndoPlugin.ts index f34d2cec6de..8d4dc7668c9 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/UndoPlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/UndoPlugin.ts @@ -23,7 +23,7 @@ const Enter = 'Enter'; * Provides snapshot based undo service for Editor */ class UndoPlugin implements PluginWithState { - private editor: (IStandaloneEditor & IEditor) | null = null; + private editor: IStandaloneEditor | null = null; private state: UndoPluginState; /** diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/applyDefaultFormat.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/applyDefaultFormat.ts index c7cfe090961..56defc297df 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/applyDefaultFormat.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/applyDefaultFormat.ts @@ -1,6 +1,5 @@ import { deleteSelection } from '../../publicApi/selection/deleteSelection'; import { isBlockElement, isNodeOfType, normalizeContentModel } from 'roosterjs-content-model-dom'; -import type { IEditor } from 'roosterjs-editor-types'; import type { ContentModelSegmentFormat, IStandaloneEditor } from 'roosterjs-content-model-types'; /** @@ -10,7 +9,7 @@ import type { ContentModelSegmentFormat, IStandaloneEditor } from 'roosterjs-con * @param defaultFormat The default segment format to apply */ export function applyDefaultFormat( - editor: IStandaloneEditor & IEditor, + editor: IStandaloneEditor, defaultFormat: ContentModelSegmentFormat ) { const selection = editor.getDOMSelection(); @@ -21,7 +20,7 @@ export function applyDefaultFormat( if (posContainer) { let node: Node | null = posContainer; - while (node && editor.contains(node)) { + while (node && editor.isNodeInEditor(node)) { if (isNodeOfType(node, 'ELEMENT_NODE')) { if (node.getAttribute?.('style')) { return; diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/DarkColorHandlerImpl.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/DarkColorHandlerImpl.ts index 0f9687fcc6e..253d4a35af2 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/editor/DarkColorHandlerImpl.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/DarkColorHandlerImpl.ts @@ -1,4 +1,4 @@ -import { getObjectKeys, parseColor, setColor } from 'roosterjs-editor-dom'; +import { getObjectKeys } from 'roosterjs-content-model-dom'; import type { ColorKeyAndValue, DarkColorHandler, @@ -22,6 +22,10 @@ const ColorAttributeName: { [key in ColorAttributeEnum]: string }[] = [ [ColorAttributeEnum.HtmlColor]: 'bgcolor', }, ]; +const HEX3_REGEX = /^#([a-fA-F0-9])([a-fA-F0-9])([a-fA-F0-9])$/; +const HEX6_REGEX = /^#([a-fA-F0-9]{2})([a-fA-F0-9]{2})([a-fA-F0-9]{2})$/; +const RGB_REGEX = /^rgb\(\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*\)$/; +const RGBA_REGEX = /^rgba\(\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*\)$/; /** * @internal @@ -126,11 +130,11 @@ export class DarkColorHandlerImpl implements DarkColorHandler { * @param darkColor The existing dark color */ findLightColorFromDarkColor(darkColor: string): string | null { - const rgbSearch = parseColor(darkColor); + const rgbSearch = this.parseColor(darkColor); if (rgbSearch) { const key = getObjectKeys(this.knownColors).find(key => { - const rgbCurrent = parseColor(this.knownColors[key].darkModeColor); + const rgbCurrent = this.parseColor(this.knownColors[key].darkModeColor); return ( rgbCurrent && @@ -161,13 +165,46 @@ export class DarkColorHandlerImpl implements DarkColorHandler { element.getAttribute(names[ColorAttributeEnum.HtmlColor]), !!fromDarkMode ).lightModeColor; + const transformedColor = + color && color != 'inherit' ? this.registerColor(color, !!toDarkMode) : null; - element.style.setProperty(names[ColorAttributeEnum.CssColor], null); + element.style.setProperty(names[ColorAttributeEnum.CssColor], transformedColor); element.removeAttribute(names[ColorAttributeEnum.HtmlColor]); - - if (color && color != 'inherit') { - setColor(element, color, i != 0, toDarkMode, false /*shouldAdaptFontColor*/, this); - } }); } + + /** + * Parse color string to r/g/b value. + * If the given color is not in a recognized format, return null + */ + private parseColor(color: string): [number, number, number] | null { + color = (color || '').trim(); + + let match: RegExpMatchArray | null; + if ((match = color.match(HEX3_REGEX))) { + return [ + parseInt(match[1] + match[1], 16), + parseInt(match[2] + match[2], 16), + parseInt(match[3] + match[3], 16), + ]; + } else if ((match = color.match(HEX6_REGEX))) { + return [parseInt(match[1], 16), parseInt(match[2], 16), parseInt(match[3], 16)]; + } else if ((match = color.match(RGB_REGEX) || color.match(RGBA_REGEX))) { + return [parseInt(match[1]), parseInt(match[2]), parseInt(match[3])]; + } else { + // CSS color names such as red, green is not included for now. + // If need, we can add those colors from https://www.w3.org/wiki/CSS/Properties/color/keywords + return null; + } + } +} + +/** + * @internal + */ +export function createDarkColorHandler( + contentDiv: HTMLElement, + getDarkColor: (color: string) => string +): DarkColorHandler { + return new DarkColorHandlerImpl(contentDiv, getDarkColor); } diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/StandaloneEditor.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/StandaloneEditor.ts new file mode 100644 index 00000000000..dc1a90734aa --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/StandaloneEditor.ts @@ -0,0 +1,373 @@ +import { ChangeSource } from '../constants/ChangeSource'; +import { createStandaloneEditorCore } from './createStandaloneEditorCore'; +import { PluginEventType } from 'roosterjs-editor-types'; +import { transformColor } from '../publicApi/color/transformColor'; +import type { + DarkColorHandler, + IEditor, + PluginEventData, + PluginEventFromType, +} from 'roosterjs-editor-types'; +import type { CompatiblePluginEventType } from 'roosterjs-editor-types/lib/compatibleTypes'; +import type { + ClipboardData, + ContentModelDocument, + ContentModelFormatter, + ContentModelSegmentFormat, + DOMEventRecord, + DOMSelection, + DomToModelOption, + EditorEnvironment, + FormatWithContentModelOptions, + IStandaloneEditor, + ModelToDomOption, + OnNodeCreated, + PasteType, + Snapshot, + SnapshotsManager, + StandaloneEditorCore, + StandaloneEditorOptions, +} from 'roosterjs-content-model-types'; + +/** + * The standalone editor class based on Content Model + */ +export class StandaloneEditor implements IStandaloneEditor { + private core: StandaloneEditorCore | null = null; + + /** + * Creates an instance of Editor + * @param contentDiv The DIV HTML element which will be the container element of editor + * @param options An optional options object to customize the editor + */ + constructor( + contentDiv: HTMLDivElement, + options: StandaloneEditorOptions = {}, + onBeforeInitializePlugins?: () => void + ) { + this.core = createStandaloneEditorCore(contentDiv, options); + + onBeforeInitializePlugins?.(); + + // TODO: Remove this type cast + const editor: IStandaloneEditor = this; + this.getCore().plugins.forEach(plugin => + plugin.initialize(editor as IStandaloneEditor & IEditor) + ); + } + + /** + * Dispose this editor, dispose all plugins and custom data + */ + dispose() { + const core = this.getCore(); + + for (let i = core.plugins.length - 1; i >= 0; i--) { + const plugin = core.plugins[i]; + + try { + plugin.dispose(); + } catch (e) { + // Cache the error and pass it out, then keep going since dispose should always succeed + core.disposeErrorHandler?.(plugin, e as Error); + } + } + + core.darkColorHandler.reset(); + this.core = null; + } + + /** + * Get whether this editor is disposed + * @returns True if editor is disposed, otherwise false + */ + isDisposed(): boolean { + return !this.core; + } + + /** + * Create Content Model from DOM tree in this editor + * @param option The option to customize the behavior of DOM to Content Model conversion + */ + createContentModel( + option?: DomToModelOption, + selectionOverride?: DOMSelection + ): ContentModelDocument { + const core = this.getCore(); + + return core.api.createContentModel(core, option, selectionOverride); + } + + /** + * Set content with content model + * @param model The content model to set + * @param option Additional options to customize the behavior of Content Model to DOM conversion + * @param onNodeCreated An optional callback that will be called when a DOM node is created + */ + setContentModel( + model: ContentModelDocument, + option?: ModelToDomOption, + onNodeCreated?: OnNodeCreated + ): DOMSelection | null { + const core = this.getCore(); + + return core.api.setContentModel(core, model, option, onNodeCreated); + } + + /** + * Get current running environment, such as if editor is running on Mac + */ + getEnvironment(): EditorEnvironment { + return this.getCore().environment; + } + + /** + * Get current DOM selection + */ + getDOMSelection(): DOMSelection | null { + const core = this.getCore(); + + return core.api.getDOMSelection(core); + } + + /** + * Set DOMSelection into editor content. + * @param selection The selection to set + */ + setDOMSelection(selection: DOMSelection | null) { + const core = this.getCore(); + + core.api.setDOMSelection(core, selection); + } + + /** + * The general API to do format change with Content Model + * It will grab a Content Model for current editor content, and invoke a callback function + * to do format change. Then according to the return value, write back the modified content model into editor. + * If there is cached model, it will be used and updated. + * @param formatter Formatter function, see ContentModelFormatter + * @param options More options, see FormatWithContentModelOptions + */ + formatContentModel( + formatter: ContentModelFormatter, + options?: FormatWithContentModelOptions + ): void { + const core = this.getCore(); + + core.api.formatContentModel(core, formatter, options); + } + + /** + * Get pending format of editor if any, or return null + */ + getPendingFormat(): ContentModelSegmentFormat | null { + return this.getCore().format.pendingFormat?.format ?? null; + } + + /** + * Add a single undo snapshot to undo stack + */ + takeSnapshot(): void { + const core = this.getCore(); + + core.api.addUndoSnapshot(core, false /*canUndoByBackspace*/); + } + + /** + * Restore an undo snapshot into editor + * @param snapshot The snapshot to restore + */ + restoreSnapshot(snapshot: Snapshot): void { + const core = this.getCore(); + + core.api.restoreUndoSnapshot(core, snapshot); + } + + /** + * Get document which contains this editor + * @returns The HTML document which contains this editor + */ + getDocument(): Document { + return this.getCore().contentDiv.ownerDocument; + } + + /** + * Focus to this editor, the selection was restored to where it was before, no unexpected scroll. + */ + focus() { + const core = this.getCore(); + core.api.focus(core); + } + + /** + * Check if focus is in editor now + * @returns true if focus is in editor, otherwise false + */ + hasFocus(): boolean { + const core = this.getCore(); + return core.api.hasFocus(core); + } + + /** + * Trigger an event to be dispatched to all plugins + * @param eventType Type of the event + * @param data data of the event with given type, this is the rest part of PluginEvent with the given type + * @param broadcast indicates if the event needs to be dispatched to all plugins + * True means to all, false means to allow exclusive handling from one plugin unless no one wants that + * @returns the event object which is really passed into plugins. Some plugin may modify the event object so + * the result of this function provides a chance to read the modified result + */ + triggerPluginEvent( + eventType: T, + data: PluginEventData, + broadcast: boolean = false + ): PluginEventFromType { + const core = this.getCore(); + const event = ({ + eventType, + ...data, + } as any) as PluginEventFromType; + core.api.triggerEvent(core, event, broadcast); + + return event; + } + + /** + * Attach a DOM event to the editor content DIV + * @param eventMap A map from event name to its handler + */ + attachDomEvent(eventMap: Record): () => void { + const core = this.getCore(); + return core.api.attachDomEvent(core, eventMap); + } + + /** + * Get undo snapshots manager + */ + getSnapshotsManager(): SnapshotsManager { + const core = this.getCore(); + + return core.undo.snapshotsManager; + } + + /** + * Check if the editor is in dark mode + * @returns True if the editor is in dark mode, otherwise false + */ + isDarkMode(): boolean { + return this.getCore().lifecycle.isDarkMode; + } + + /** + * Set the dark mode state and transforms the content to match the new state. + * @param isDarkMode The next status of dark mode. True if the editor should be in dark mode, false if not. + */ + setDarkModeState(isDarkMode?: boolean) { + const core = this.getCore(); + + if (!!isDarkMode != core.lifecycle.isDarkMode) { + transformColor( + core.contentDiv, + true /*includeSelf*/, + isDarkMode ? 'lightToDark' : 'darkToLight', + core.darkColorHandler + ); + + core.api.triggerEvent( + core, + { + eventType: PluginEventType.ContentChanged, + source: isDarkMode + ? ChangeSource.SwitchToDarkMode + : ChangeSource.SwitchToLightMode, + }, + true + ); + } + } + + /** + * Check if editor is in IME input sequence + * @returns True if editor is in IME input sequence, otherwise false + */ + isInIME(): boolean { + return this.getCore().domEvent.isInIME; + } + + /** + * Check if editor is in Shadow Edit mode + */ + isInShadowEdit() { + return !!this.getCore().lifecycle.shadowEditFragment; + } + + /** + * Make the editor in "Shadow Edit" mode. + * In Shadow Edit mode, all format change will finally be ignored. + * This can be used for building a live preview feature for format button, to allow user + * see format result without really apply it. + * This function can be called repeated. If editor is already in shadow edit mode, we can still + * use this function to do more shadow edit operation. + */ + startShadowEdit() { + const core = this.getCore(); + core.api.switchShadowEdit(core, true /*isOn*/); + } + + /** + * Leave "Shadow Edit" mode, all changes made during shadow edit will be discarded + */ + stopShadowEdit() { + const core = this.getCore(); + core.api.switchShadowEdit(core, false /*isOn*/); + } + + /** + * Paste into editor using a clipboardData object + * @param clipboardData Clipboard data retrieved from clipboard + * @param pasteType Type of paste + */ + pasteFromClipboard(clipboardData: ClipboardData, pasteType: PasteType = 'normal') { + const core = this.getCore(); + + core.api.paste(core, clipboardData, pasteType); + } + + /** + * Get a darkColorHandler object for this editor. + */ + getDarkColorHandler(): DarkColorHandler { + return this.getCore().darkColorHandler; + } + + /** + * Check if the given DOM node is in editor + * @param node The node to check + */ + isNodeInEditor(node: Node): boolean { + const core = this.getCore(); + + return core.contentDiv.contains(node); + } + + /** + * Get current zoom scale, default value is 1 + * When editor is put under a zoomed container, need to pass the zoom scale number using EditorOptions.zoomScale + * to let editor behave correctly especially for those mouse drag/drop behaviors + * @returns current zoom scale number + */ + getZoomScale(): number { + return this.getCore().zoomScale; + } + + /** + * @returns the current StandaloneEditorCore object + * @throws a standard Error if there's no core object + */ + protected getCore(): StandaloneEditorCore { + if (!this.core) { + throw new Error('Editor is already disposed'); + } + return this.core; + } +} diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorCore.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorCore.ts index 103b22a98d5..5161773dca2 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorCore.ts @@ -1,36 +1,34 @@ +import { createDarkColorHandler } from './DarkColorHandlerImpl'; import { createStandaloneEditorCorePlugins } from '../corePlugin/createStandaloneEditorCorePlugins'; -import { createStandaloneEditorDefaultSettings } from './createStandaloneEditorDefaultSettings'; -import { DarkColorHandlerImpl } from './DarkColorHandlerImpl'; import { standaloneCoreApiMap } from './standaloneCoreApiMap'; -import type { EditorPlugin } from 'roosterjs-editor-types'; +import { + createDomToModelSettings, + createModelToDomSettings, +} from './createStandaloneEditorDefaultSettings'; import type { EditorEnvironment, StandaloneEditorCore, StandaloneEditorCorePluginState, StandaloneEditorCorePlugins, StandaloneEditorOptions, - UnportedCoreApiMap, - UnportedCorePluginState, } from 'roosterjs-content-model-types'; /** + * @internal * A temporary function to create Standalone Editor core * @param contentDiv Editor content DIV * @param options Editor options */ export function createStandaloneEditorCore( contentDiv: HTMLDivElement, - options: StandaloneEditorOptions, - unportedCoreApiMap: UnportedCoreApiMap, - unportedCorePluginState: UnportedCorePluginState, - tempPlugins: EditorPlugin[] + options: StandaloneEditorOptions ): StandaloneEditorCore { const corePlugins = createStandaloneEditorCorePlugins(options, contentDiv); return { contentDiv, - api: { ...standaloneCoreApiMap, ...unportedCoreApiMap, ...options.coreApiOverride }, - originalApi: { ...standaloneCoreApiMap, ...unportedCoreApiMap }, + api: { ...standaloneCoreApiMap, ...options.coreApiOverride }, + originalApi: { ...standaloneCoreApiMap }, plugins: [ corePlugins.cache, corePlugins.format, @@ -38,28 +36,31 @@ export function createStandaloneEditorCore( corePlugins.domEvent, corePlugins.selection, corePlugins.entity, - ...tempPlugins, + ...(options.plugins ?? []).filter(x => !!x), corePlugins.undo, corePlugins.lifecycle, ], - environment: createEditorEnvironment(), - darkColorHandler: new DarkColorHandlerImpl( + environment: createEditorEnvironment(contentDiv), + darkColorHandler: createDarkColorHandler( contentDiv, options.getDarkColor ?? getDarkColorFallback ), trustedHTMLHandler: options.trustedHTMLHandler || defaultTrustHtmlHandler, - ...createStandaloneEditorDefaultSettings(options), + domToModelSettings: createDomToModelSettings(options), + modelToDomSettings: createModelToDomSettings(options), ...getPluginState(corePlugins), - ...unportedCorePluginState, + disposeErrorHandler: options.disposeErrorHandler, + zoomScale: (options.zoomScale ?? -1) > 0 ? options.zoomScale! : 1, }; } -function createEditorEnvironment(): EditorEnvironment { - // It is ok to use global window here since the environment should always be the same for all windows in one session - const userAgent = window.navigator.userAgent; +function createEditorEnvironment(contentDiv: HTMLElement): EditorEnvironment { + const navigator = contentDiv.ownerDocument.defaultView?.navigator; + const userAgent = navigator?.userAgent ?? ''; + const appVersion = navigator?.appVersion ?? ''; return { - isMac: window.navigator.appVersion.indexOf('Mac') != -1, + isMac: appVersion.indexOf('Mac') != -1, isAndroid: /android/i.test(userAgent), isSafari: userAgent.indexOf('Safari') >= 0 && @@ -88,7 +89,10 @@ function getPluginState(corePlugins: StandaloneEditorCorePlugins): StandaloneEdi }; } -// A fallback function, always return original color -function getDarkColorFallback(color: string) { +/** + * @internal Export for test only + * A fallback function, always return original color + */ +export function getDarkColorFallback(color: string) { return color; } diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorDefaultSettings.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorDefaultSettings.ts index e7a600fd4bd..bbba2c45b6a 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorDefaultSettings.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorDefaultSettings.ts @@ -2,42 +2,55 @@ import { createDomToModelConfig, createModelToDomConfig } from 'roosterjs-conten import { listItemMetadataApplier, listLevelMetadataApplier } from '../metadata/updateListMetadata'; import { tablePreProcessor } from '../override/tablePreProcessor'; import type { + ContentModelSettings, DomToModelOption, + DomToModelSettings, ModelToDomOption, - StandaloneEditorDefaultSettings, + ModelToDomSettings, StandaloneEditorOptions, } from 'roosterjs-content-model-types'; /** * @internal - * Create default DOM and Content Model conversion settings for a standalone editor + * Create default DOM to Content Model conversion settings for a standalone editor * @param options The editor options */ -export function createStandaloneEditorDefaultSettings( +export function createDomToModelSettings( options: StandaloneEditorOptions -): StandaloneEditorDefaultSettings { - const defaultDomToModelOptions: (DomToModelOption | undefined)[] = [ - { - processorOverride: { - table: tablePreProcessor, - }, +): ContentModelSettings { + const builtIn: DomToModelOption = { + processorOverride: { + table: tablePreProcessor, }, - options.defaultDomToModelOptions, - ]; - const defaultModelToDomOptions: (ModelToDomOption | undefined)[] = [ - { - metadataAppliers: { - listItem: listItemMetadataApplier, - listLevel: listLevelMetadataApplier, - }, + }; + const customized: DomToModelOption = options.defaultDomToModelOptions ?? {}; + + return { + builtIn, + customized, + calculated: createDomToModelConfig([builtIn, customized]), + }; +} + +/** + * @internal + * Create default Content Model to DOM conversion settings for a standalone editor + * @param options The editor options + */ +export function createModelToDomSettings( + options: StandaloneEditorOptions +): ContentModelSettings { + const builtIn: ModelToDomOption = { + metadataAppliers: { + listItem: listItemMetadataApplier, + listLevel: listLevelMetadataApplier, }, - options.defaultModelToDomOptions, - ]; + }; + const customized: ModelToDomOption = options.defaultModelToDomOptions ?? {}; return { - defaultDomToModelOptions, - defaultModelToDomOptions, - defaultDomToModelConfig: createDomToModelConfig(defaultDomToModelOptions), - defaultModelToDomConfig: createModelToDomConfig(defaultModelToDomOptions), + builtIn, + customized, + calculated: createModelToDomConfig([builtIn, customized]), }; } diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/standaloneCoreApiMap.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/standaloneCoreApiMap.ts index 0f53a6121fc..fadf02808ff 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/editor/standaloneCoreApiMap.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/standaloneCoreApiMap.ts @@ -7,18 +7,19 @@ import { formatContentModel } from '../coreApi/formatContentModel'; import { getDOMSelection } from '../coreApi/getDOMSelection'; import { getVisibleViewport } from '../coreApi/getVisibleViewport'; import { hasFocus } from '../coreApi/hasFocus'; +import { paste } from '../coreApi/paste'; import { restoreUndoSnapshot } from '../coreApi/restoreUndoSnapshot'; import { setContentModel } from '../coreApi/setContentModel'; import { setDOMSelection } from '../coreApi/setDOMSelection'; import { switchShadowEdit } from '../coreApi/switchShadowEdit'; import { triggerEvent } from '../coreApi/triggerEvent'; -import type { PortedCoreApiMap } from 'roosterjs-content-model-types'; +import type { StandaloneCoreApiMap } from 'roosterjs-content-model-types'; /** * @internal * Core API map for Standalone Content Model Editor */ -export const standaloneCoreApiMap: PortedCoreApiMap = { +export const standaloneCoreApiMap: StandaloneCoreApiMap = { createContentModel: createContentModel, createEditorContext: createEditorContext, formatContentModel: formatContentModel, @@ -33,4 +34,5 @@ export const standaloneCoreApiMap: PortedCoreApiMap = { restoreUndoSnapshot: restoreUndoSnapshot, attachDomEvent: attachDomEvent, triggerEvent: triggerEvent, + paste: paste, }; diff --git a/packages-content-model/roosterjs-content-model-core/lib/index.ts b/packages-content-model/roosterjs-content-model-core/lib/index.ts index 509c3e5c256..feafe9f9dff 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/index.ts @@ -1,5 +1,4 @@ export { CachedElementHandler, CloneModelOptions, cloneModel } from './publicApi/model/cloneModel'; -export { paste } from './publicApi/model/paste'; export { mergeModel, MergeModelOption } from './publicApi/model/mergeModel'; export { isBlockGroupOfType } from './publicApi/model/isBlockGroupOfType'; export { @@ -58,5 +57,5 @@ export { BulletListType } from './constants/BulletListType'; export { NumberingListType } from './constants/NumberingListType'; export { TableBorderFormat } from './constants/TableBorderFormat'; -export { createStandaloneEditorCore } from './editor/createStandaloneEditorCore'; +export { StandaloneEditor } from './editor/StandaloneEditor'; export { createSnapshotsManager } from './editor/SnapshotsManagerImpl'; diff --git a/packages-content-model/roosterjs-content-model-core/lib/override/containerWidthFormatParser.ts b/packages-content-model/roosterjs-content-model-core/lib/override/containerWidthFormatParser.ts new file mode 100644 index 00000000000..6ea362ffbc1 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/override/containerWidthFormatParser.ts @@ -0,0 +1,11 @@ +import type { FormatParser, SizeFormat } from 'roosterjs-content-model-types'; + +/** + * @internal Do not paste width for Format Containers since it may be generated by browser according to temp div width + */ +export const containerWidthFormatParser: FormatParser = (format, element) => { + // For pasted content, there may be existing width generated by browser from the temp DIV. So we need to remove it. + if (element.tagName == 'DIV') { + delete format.width; + } +}; diff --git a/packages-content-model/roosterjs-content-model-core/lib/override/pasteDisplayFormatParser.ts b/packages-content-model/roosterjs-content-model-core/lib/override/pasteDisplayFormatParser.ts new file mode 100644 index 00000000000..aae54d4a164 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/override/pasteDisplayFormatParser.ts @@ -0,0 +1,12 @@ +import type { DisplayFormat, FormatParser } from 'roosterjs-content-model-types'; + +/** + * @internal + */ +export const pasteDisplayFormatParser: FormatParser = (format, element) => { + const display = element.style.display; + + if (display && display != 'flex') { + format.display = display; + } +}; diff --git a/packages-content-model/roosterjs-content-model-core/lib/override/pasteEntityProcessor.ts b/packages-content-model/roosterjs-content-model-core/lib/override/pasteEntityProcessor.ts new file mode 100644 index 00000000000..8424d684dec --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/override/pasteEntityProcessor.ts @@ -0,0 +1,36 @@ +import { AllowedTags, DisallowedTags, sanitizeElement } from '../utils/sanitizeElement'; +import type { + DomToModelOptionForPaste, + ElementProcessor, + ValueSanitizer, +} from 'roosterjs-content-model-types'; + +const DefaultStyleSanitizers: Readonly> = { + position: false, +}; + +/** + * @internal + */ +export function createPasteEntityProcessor( + options: DomToModelOptionForPaste +): ElementProcessor { + const allowedTags = AllowedTags.concat(options.additionalAllowedTags); + const disallowedTags = DisallowedTags.concat(options.additionalDisallowedTags); + const styleSanitizers = Object.assign({}, DefaultStyleSanitizers, options.styleSanitizers); + const attrSanitizers = options.attributeSanitizers; + + return (group, element, context) => { + const sanitizedElement = sanitizeElement( + element, + allowedTags, + disallowedTags, + styleSanitizers, + attrSanitizers + ); + + if (sanitizedElement) { + context.defaultElementProcessors.entity(group, sanitizedElement, context); + } + }; +} diff --git a/packages-content-model/roosterjs-content-model-core/lib/override/pasteGeneralProcessor.ts b/packages-content-model/roosterjs-content-model-core/lib/override/pasteGeneralProcessor.ts new file mode 100644 index 00000000000..025601b625a --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/override/pasteGeneralProcessor.ts @@ -0,0 +1,54 @@ +import { AllowedTags, createSanitizedElement, DisallowedTags } from '../utils/sanitizeElement'; +import { moveChildNodes } from 'roosterjs-content-model-dom'; +import type { + DomToModelOptionForPaste, + ElementProcessor, + ValueSanitizer, +} from 'roosterjs-content-model-types'; + +/** + * @internal Export for test only + */ +export const removeDisplayFlex: ValueSanitizer = value => { + return value == 'flex' ? null : value; +}; + +const DefaultStyleSanitizers: Readonly> = { + position: false, + display: removeDisplayFlex, +}; + +/** + * @internal + */ +export function createPasteGeneralProcessor( + options: DomToModelOptionForPaste +): ElementProcessor { + const allowedTags = AllowedTags.concat(options.additionalAllowedTags); + const disallowedTags = DisallowedTags.concat(options.additionalDisallowedTags); + const styleSanitizers = Object.assign({}, DefaultStyleSanitizers, options.styleSanitizers); + const attrSanitizers = options.attributeSanitizers; + + return (group, element, context) => { + const tag = element.tagName.toLowerCase(); + const processor: ElementProcessor | undefined = + allowedTags.indexOf(tag) >= 0 + ? (group, element, context) => { + const sanitizedElement = createSanitizedElement( + element.ownerDocument, + element.tagName, + element.attributes, + styleSanitizers, + attrSanitizers + ); + + moveChildNodes(sanitizedElement, element); + context.defaultElementProcessors['*']?.(group, sanitizedElement, context); + } + : disallowedTags.indexOf(tag) >= 0 + ? undefined // Ignore those disallowed tags + : context.defaultElementProcessors.span; // For other unknown tags, treat them as SPAN + + processor?.(group, element, context); + }; +} diff --git a/packages-content-model/roosterjs-content-model-core/lib/override/pasteTextProcessor.ts b/packages-content-model/roosterjs-content-model-core/lib/override/pasteTextProcessor.ts new file mode 100644 index 00000000000..512a7f6b37b --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/override/pasteTextProcessor.ts @@ -0,0 +1,15 @@ +import { isWhiteSpacePreserved } from 'roosterjs-content-model-dom'; +import type { ElementProcessor } from 'roosterjs-content-model-types'; + +/** + * @internal + */ +export const pasteTextProcessor: ElementProcessor = (group, text, context) => { + const whiteSpace = context.blockFormat.whiteSpace; + + if (isWhiteSpacePreserved(whiteSpace)) { + text.nodeValue = text.nodeValue?.replace(/\u0020\u0020/g, '\u0020\u00A0') ?? ''; + } + + context.defaultElementProcessors['#text'](group, text, context); +}; diff --git a/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/paste.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/paste.ts deleted file mode 100644 index 770f2a6a79e..00000000000 --- a/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/paste.ts +++ /dev/null @@ -1,252 +0,0 @@ -import { ChangeSource } from '../../constants/ChangeSource'; -import { GetContentMode, PasteType as OldPasteType, PluginEventType } from 'roosterjs-editor-types'; -import { getSelectedSegments } from '../selection/collectSelections'; -import { mergeModel } from './mergeModel'; -import type { - ContentModelDocument, - ContentModelSegmentFormat, - FormatWithContentModelContext, - InsertPoint, - PasteType, - ContentModelBeforePasteEventData, - ContentModelBeforePasteEvent, - IStandaloneEditor, - ClipboardData, -} from 'roosterjs-content-model-types'; -import type { IEditor } from 'roosterjs-editor-types'; -import { - AllowedEntityClasses, - applySegmentFormatToElement, - createDomToModelContext, - domToContentModel, - moveChildNodes, -} from 'roosterjs-content-model-dom'; -import { - createDefaultHtmlSanitizerOptions, - handleImagePaste, - handleTextPaste, - retrieveMetadataFromClipboard, - sanitizePasteContent, -} from 'roosterjs-editor-dom'; - -// Map new PasteType to old PasteType -// TODO: We can remove this once we have standalone editor -const PasteTypeMap: Record = { - asImage: OldPasteType.AsImage, - asPlainText: OldPasteType.AsPlainText, - mergeFormat: OldPasteType.MergeFormat, - normal: OldPasteType.Normal, -}; -const EmptySegmentFormat: Required = { - backgroundColor: '', - fontFamily: '', - fontSize: '', - fontWeight: '', - italic: false, - letterSpacing: '', - lineHeight: '', - strikethrough: false, - superOrSubScriptSequence: '', - textColor: '', - underline: false, -}; - -/** - * Paste into editor using a clipboardData object - * @param editor The editor to paste content into - * @param clipboardData Clipboard data retrieved from clipboard - * @param pasteType Type of content to paste. @default normal - */ -export function paste( - editor: IStandaloneEditor & IEditor, - clipboardData: ClipboardData, - pasteType: PasteType = 'normal' -) { - if (clipboardData.snapshotBeforePaste) { - // Restore original content before paste a new one - editor.setContent(clipboardData.snapshotBeforePaste); - } else { - clipboardData.snapshotBeforePaste = editor.getContent(GetContentMode.RawHTMLWithSelection); - } - - editor.focus(); - let originalFormat: ContentModelSegmentFormat | undefined; - - editor.formatContentModel( - (model, context) => { - const eventData = createBeforePasteEventData(editor, clipboardData, pasteType); - const currentSegment = getSelectedSegments(model, true /*includingFormatHolder*/)[0]; - const { fontFamily, fontSize, textColor, backgroundColor, letterSpacing, lineHeight } = - currentSegment?.format ?? {}; - const { - domToModelOption, - fragment, - customizedMerge, - } = triggerPluginEventAndCreatePasteFragment( - editor, - clipboardData, - pasteType, - eventData, - { fontFamily, fontSize, textColor, backgroundColor, letterSpacing, lineHeight } - ); - - const pasteModel = domToContentModel( - fragment, - createDomToModelContext(undefined /*editorContext*/, domToModelOption) - ); - - const insertPoint = mergePasteContent( - model, - context, - pasteModel, - pasteType == 'mergeFormat', - customizedMerge - ); - - if (insertPoint) { - originalFormat = insertPoint.marker.format; - } - - if (originalFormat) { - context.newPendingFormat = { - ...EmptySegmentFormat, - ...model.format, - ...originalFormat, - }; // Use empty format as initial value to clear any other format inherits from pasted content - } - - return true; - }, - - { - changeSource: ChangeSource.Paste, - getChangeData: () => clipboardData, - apiName: 'paste', - } - ); -} - -/** - * @internal - * Export only for unit test - */ -export function mergePasteContent( - model: ContentModelDocument, - context: FormatWithContentModelContext, - pasteModel: ContentModelDocument, - applyCurrentFormat: boolean, - customizedMerge: - | undefined - | ((source: ContentModelDocument, target: ContentModelDocument) => InsertPoint | null) -): InsertPoint | null { - return customizedMerge - ? customizedMerge(model, pasteModel) - : 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: IEditor, - clipboardData: ClipboardData, - pasteType: PasteType -): ContentModelBeforePasteEventData { - const options = createDefaultHtmlSanitizerOptions(); - - options.additionalAllowedCssClasses.push(...AllowedEntityClasses); - - // 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: PasteTypeMap[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: IEditor, - clipboardData: ClipboardData, - pasteType: PasteType, - 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(); - - const 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 ( - (pasteType == 'asImage' && imageDataUri) || - (pasteType != 'asPlainText' && !text && imageDataUri) - ) { - // Paste image - handleImagePaste(imageDataUri, fragment); - } else if (pasteType != 'asPlainText' && rawHtml && doc ? doc.body : false) { - moveChildNodes(fragment, doc?.body); - } else if (text) { - // Paste text - handleTextPaste(text, null /*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 (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, null /*position*/); - - return pluginEvent; -} diff --git a/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteSegment.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteSegment.ts index 95ce0c57bba..87ffca8a863 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteSegment.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteSegment.ts @@ -24,7 +24,7 @@ export function deleteSegment( ): boolean { const segments = paragraph.segments; const index = segments.indexOf(segmentToDelete); - const preserveWhiteSpace = isWhiteSpacePreserved(paragraph); + const preserveWhiteSpace = isWhiteSpacePreserved(paragraph.format.whiteSpace); const isForward = direction == 'forward'; const isBackward = direction == 'backward'; diff --git a/packages-content-model/roosterjs-content-model-core/lib/utils/paste/convertInlineCss.ts b/packages-content-model/roosterjs-content-model-core/lib/utils/paste/convertInlineCss.ts new file mode 100644 index 00000000000..1ec7973f0ae --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/utils/paste/convertInlineCss.ts @@ -0,0 +1,26 @@ +import { toArray } from 'roosterjs-content-model-dom'; +import type { CssRule } from './retrieveHtmlInfo'; + +/** + * @internal + */ +export function convertInlineCss(root: ParentNode, cssRules: CssRule[]) { + for (let i = cssRules.length - 1; i >= 0; i--) { + const { selectors, text } = cssRules[i]; + + for (const selector of selectors) { + if (!selector || !selector.trim() || selector.indexOf(':') >= 0) { + continue; + } + + const nodes = toArray(root.querySelectorAll(selector)); + + // Always put existing styles after so that they have higher priority + // Which means if both global style and inline style apply to the same element, + // inline style will have higher priority + nodes.forEach(node => + node.setAttribute('style', text + (node.getAttribute('style') || '')) + ); + } + } +} diff --git a/packages-content-model/roosterjs-content-model-core/lib/utils/paste/createPasteFragment.ts b/packages-content-model/roosterjs-content-model-core/lib/utils/paste/createPasteFragment.ts new file mode 100644 index 00000000000..4c95cd4c570 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/utils/paste/createPasteFragment.ts @@ -0,0 +1,84 @@ +import { moveChildNodes, wrap } from 'roosterjs-content-model-dom'; +import type { ClipboardData, PasteType } from 'roosterjs-content-model-types'; + +const NBSP_HTML = '\u00A0'; +const ENSP_HTML = '\u2002'; +const TAB_SPACES = 6; + +/** + * @internal + */ +export function createPasteFragment( + document: Document, + clipboardData: ClipboardData, + pasteType: PasteType, + root: HTMLElement | undefined +): DocumentFragment { + const { imageDataUri, text } = clipboardData; + const fragment = document.createDocumentFragment(); + + if ( + (pasteType == 'asImage' && imageDataUri) || + (pasteType != 'asPlainText' && !text && imageDataUri) + ) { + // Paste image + const img = document.createElement('img'); + img.style.maxWidth = '100%'; + img.src = imageDataUri; + fragment.appendChild(img); + } else if (pasteType != 'asPlainText' && root) { + moveChildNodes(fragment, root); + } else if (text) { + text.split('\n').forEach((line, index, lines) => { + line = line + .replace(/^ /g, NBSP_HTML) + .replace(/ $/g, NBSP_HTML) + .replace(/\r/g, '') + .replace(/ {2}/g, ' ' + NBSP_HTML); + + if (line.includes('\t')) { + line = transformTabCharacters(line); + } + + const textNode = document.createTextNode(line); + + // There are 3 scenarios: + // 1. Single line: Paste as it is + // 2. Two lines: Add
between the lines + // 3. 3 or More lines, For first and last line, paste as it is. For middle lines, wrap with DIV, and add BR if it is empty line + if (lines.length == 2 && index == 0) { + // 1 of 2 lines scenario, add BR + fragment.appendChild(textNode); + fragment.appendChild(document.createElement('br')); + } else if (index > 0 && index < lines.length - 1) { + // Middle line of >=3 lines scenario, wrap with DIV + fragment.appendChild( + wrap(document, line == '' ? document.createElement('br') : textNode, 'div') + ); + } else { + // All others, paste as it is + fragment.appendChild(textNode); + } + }); + } + + return fragment; +} + +/** + * Transform \t characters into EN SPACE characters + * @param input string NOT containing \n characters + * @example t("\thello", 2) => "    hello" + */ +function transformTabCharacters(input: string, initialOffset: number = 0) { + let line = input; + let tIndex: number; + while ((tIndex = line.indexOf('\t')) != -1) { + const lineBefore = line.slice(0, tIndex); + const lineAfter = line.slice(tIndex + 1); + const tabCount = TAB_SPACES - ((lineBefore.length + initialOffset) % TAB_SPACES); + const tabStr = Array(tabCount).fill(ENSP_HTML).join(''); + line = lineBefore + tabStr + lineAfter; + } + return line; +} diff --git a/packages-content-model/roosterjs-content-model-core/lib/utils/paste/generatePasteOptionFromPlugins.ts b/packages-content-model/roosterjs-content-model-core/lib/utils/paste/generatePasteOptionFromPlugins.ts new file mode 100644 index 00000000000..a0f1a0c346b --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/utils/paste/generatePasteOptionFromPlugins.ts @@ -0,0 +1,71 @@ +import { PasteType as OldPasteType, PluginEventType } from 'roosterjs-editor-types'; +import type { HtmlFromClipboard } from './retrieveHtmlInfo'; +import type { + ClipboardData, + ContentModelBeforePasteEvent, + DomToModelOptionForPaste, + PasteType, + StandaloneEditorCore, +} from 'roosterjs-content-model-types'; + +// Map new PasteType to old PasteType +// TODO: We can remove this once we have standalone editor +const PasteTypeMap: Record = { + asImage: OldPasteType.AsImage, + asPlainText: OldPasteType.AsPlainText, + mergeFormat: OldPasteType.MergeFormat, + normal: OldPasteType.Normal, +}; + +/** + * @internal + */ +export function generatePasteOptionFromPlugins( + core: StandaloneEditorCore, + clipboardData: ClipboardData, + fragment: DocumentFragment, + htmlFromClipboard: HtmlFromClipboard, + pasteType: PasteType +): ContentModelBeforePasteEvent { + const domToModelOption: DomToModelOptionForPaste = { + additionalAllowedTags: [], + additionalDisallowedTags: [], + additionalFormatParsers: {}, + formatParserOverride: {}, + processorOverride: {}, + styleSanitizers: {}, + attributeSanitizers: {}, + }; + + const event: ContentModelBeforePasteEvent = { + eventType: PluginEventType.BeforePaste, + clipboardData, + fragment, + htmlBefore: htmlFromClipboard.htmlBefore ?? '', + htmlAfter: htmlFromClipboard.htmlAfter ?? '', + htmlAttributes: htmlFromClipboard.metadata, + pasteType: PasteTypeMap[pasteType], + domToModelOption, + + // Deprecated + sanitizingOption: { + elementCallbacks: {}, + attributeCallbacks: {}, + cssStyleCallbacks: {}, + additionalTagReplacements: {}, + additionalAllowedAttributes: [], + additionalAllowedCssClasses: [], + additionalDefaultStyleValues: {}, + additionalGlobalStyleNodes: [], + additionalPredefinedCssForElement: {}, + preserveHtmlComments: false, + unknownTagReplacement: null, + }, + }; + + if (pasteType !== 'asPlainText') { + core.api.triggerEvent(core, event, true /* broadcast */); + } + + return event; +} diff --git a/packages-content-model/roosterjs-content-model-core/lib/utils/paste/mergePasteContent.ts b/packages-content-model/roosterjs-content-model-core/lib/utils/paste/mergePasteContent.ts new file mode 100644 index 00000000000..f63843cbbe6 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/utils/paste/mergePasteContent.ts @@ -0,0 +1,98 @@ +import { containerWidthFormatParser } from '../../override/containerWidthFormatParser'; +import { createDomToModelContext, domToContentModel } from 'roosterjs-content-model-dom'; +import { createPasteEntityProcessor } from '../../override/pasteEntityProcessor'; +import { createPasteGeneralProcessor } from '../../override/pasteGeneralProcessor'; +import { getSegmentTextFormat } from '../../publicApi/domUtils/getSegmentTextFormat'; +import { getSelectedSegments } from '../../publicApi/selection/collectSelections'; +import { mergeModel } from '../../publicApi/model/mergeModel'; +import { pasteDisplayFormatParser } from '../../override/pasteDisplayFormatParser'; +import { pasteTextProcessor } from '../../override/pasteTextProcessor'; +import { PasteType } from 'roosterjs-editor-types'; +import type { MergeModelOption } from '../../publicApi/model/mergeModel'; +import type { + ContentModelBeforePasteEvent, + ContentModelDocument, + ContentModelSegmentFormat, + DomToModelOption, + FormatWithContentModelContext, +} from 'roosterjs-content-model-types'; + +const EmptySegmentFormat: Required = { + backgroundColor: '', + fontFamily: '', + fontSize: '', + fontWeight: '', + italic: false, + letterSpacing: '', + lineHeight: '', + strikethrough: false, + superOrSubScriptSequence: '', + textColor: '', + underline: false, +}; + +/** + * @internal + */ +export function mergePasteContent( + model: ContentModelDocument, + context: FormatWithContentModelContext, + eventResult: ContentModelBeforePasteEvent, + defaultDomToModelOptions: DomToModelOption +) { + const { fragment, domToModelOption, customizedMerge, pasteType } = eventResult; + const selectedSegment = getSelectedSegments(model, true /*includeFormatHolder*/)[0]; + const domToModelContext = createDomToModelContext( + undefined /*editorContext*/, + defaultDomToModelOptions, + { + processorOverride: { + '#text': pasteTextProcessor, + entity: createPasteEntityProcessor(domToModelOption), + '*': createPasteGeneralProcessor(domToModelOption), + }, + formatParserOverride: { + display: pasteDisplayFormatParser, + }, + additionalFormatParsers: { + container: [containerWidthFormatParser], + }, + }, + domToModelOption + ); + + domToModelContext.segmentFormat = selectedSegment ? getSegmentTextFormat(selectedSegment) : {}; + + const pasteModel = domToContentModel(fragment, domToModelContext); + const mergeOption: MergeModelOption = { + mergeFormat: pasteType == PasteType.MergeFormat ? 'keepSourceEmphasisFormat' : 'none', + mergeTable: shouldMergeTable(pasteModel), + }; + + const insertPoint = customizedMerge + ? customizedMerge(model, pasteModel) + : mergeModel(model, pasteModel, context, mergeOption); + + if (insertPoint) { + context.newPendingFormat = { + ...EmptySegmentFormat, + ...model.format, + ...insertPoint.marker.format, + }; + } +} + +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'; +} diff --git a/packages-content-model/roosterjs-content-model-core/lib/utils/paste/retrieveHtmlInfo.ts b/packages-content-model/roosterjs-content-model-core/lib/utils/paste/retrieveHtmlInfo.ts new file mode 100644 index 00000000000..e0a1aefe773 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/utils/paste/retrieveHtmlInfo.ts @@ -0,0 +1,128 @@ +import { isNodeOfType, toArray } from 'roosterjs-content-model-dom'; +import type { ClipboardData } from 'roosterjs-content-model-types'; + +const START_FRAGMENT = ''; +const END_FRAGMENT = ''; + +/** + * @internal + */ +export interface CssRule { + selectors: string[]; + text: string; +} + +/** + * @internal + */ +export interface HtmlFromClipboard { + metadata: Record; + globalCssRules: CssRule[]; + htmlBefore?: string; + htmlAfter?: string; +} + +/** + * @internal + */ +export function retrieveHtmlInfo( + doc: Document | null, + clipboardData: Partial +): HtmlFromClipboard { + let result: HtmlFromClipboard = { + metadata: {}, + globalCssRules: [], + }; + + if (doc) { + result = { + ...retrieveHtmlStrings(clipboardData), + globalCssRules: retrieveCssRules(doc), + metadata: retrieveMetadata(doc), + }; + + clipboardData.htmlFirstLevelChildTags = retrieveTopLevelTags(doc); + } + + return result; +} + +function retrieveTopLevelTags(doc: Document): string[] { + const topLevelTags: string[] = []; + + for (let child = doc.body.firstChild; child; child = child.nextSibling) { + if (isNodeOfType(child, 'TEXT_NODE')) { + const trimmedString = child.nodeValue?.replace(/(\r\n|\r|\n)/gm, '').trim(); + + if (trimmedString) { + topLevelTags.push(''); // Push an empty string as tag for text node + } + } else if (isNodeOfType(child, 'ELEMENT_NODE')) { + topLevelTags.push(child.tagName); + } + } + + return topLevelTags; +} + +function retrieveMetadata(doc: Document): Record { + const result: Record = {}; + const attributes = doc.querySelector('html')?.attributes; + + (attributes ? toArray(attributes) : []).forEach(attr => { + result[attr.name] = attr.value; + }); + + toArray(doc.querySelectorAll('meta')).forEach(meta => { + result[meta.name] = meta.content; + }); + + return result; +} + +function retrieveCssRules(doc: Document): CssRule[] { + const styles = toArray(doc.querySelectorAll('style')); + const result: CssRule[] = []; + + styles.forEach(styleNode => { + const sheet = styleNode.sheet as CSSStyleSheet; + + for (let ruleIndex = 0; ruleIndex < sheet.cssRules.length; ruleIndex++) { + const rule = sheet.cssRules[ruleIndex] as CSSStyleRule; + + if (rule.type == CSSRule.STYLE_RULE && rule.selectorText) { + result.push({ + selectors: rule.selectorText.split(','), + text: rule.style.cssText, + }); + } + } + + styleNode.parentNode?.removeChild(styleNode); + }); + + return result; +} + +function retrieveHtmlStrings( + clipboardData: Partial +): { + htmlBefore: string; + htmlAfter: string; +} { + const rawHtml = clipboardData.rawHtml ?? ''; + const startIndex = rawHtml.indexOf(START_FRAGMENT); + const endIndex = rawHtml.lastIndexOf(END_FRAGMENT); + let htmlBefore = ''; + let htmlAfter = ''; + + if (startIndex >= 0 && endIndex >= startIndex + START_FRAGMENT.length) { + htmlBefore = rawHtml.substring(0, startIndex); + htmlAfter = rawHtml.substring(endIndex + END_FRAGMENT.length); + clipboardData.html = rawHtml.substring(startIndex + START_FRAGMENT.length, endIndex); + } else { + clipboardData.html = rawHtml; + } + + return { htmlBefore, htmlAfter }; +} diff --git a/packages-content-model/roosterjs-content-model-core/lib/utils/sanitizeElement.ts b/packages-content-model/roosterjs-content-model-core/lib/utils/sanitizeElement.ts new file mode 100644 index 00000000000..20f75114fa3 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/utils/sanitizeElement.ts @@ -0,0 +1,397 @@ +import { isNodeOfType } from 'roosterjs-content-model-dom'; +import type { ValueSanitizer } from 'roosterjs-content-model-types'; + +/** + * @internal + */ +export const AllowedTags: ReadonlyArray = [ + 'a', + 'abbr', + 'address', + 'area', + 'article', + 'aside', + 'b', + 'bdi', + 'bdo', + 'blockquote', + 'body', + 'br', + 'button', + 'canvas', + 'caption', + 'center', + 'cite', + 'code', + 'col', + 'colgroup', + 'data', + 'datalist', + 'dd', + 'del', + 'details', + 'dfn', + 'dialog', + 'dir', + 'div', + 'dl', + 'dt', + 'em', + 'fieldset', + 'figcaption', + 'figure', + 'font', + 'footer', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'head', + 'header', + 'hgroup', + 'hr', + 'html', + 'i', + 'img', + 'input', + 'ins', + 'kbd', + 'label', + 'legend', + 'li', + 'main', + 'map', + 'mark', + 'menu', + 'menuitem', + 'meter', + 'nav', + 'ol', + 'optgroup', + 'option', + 'output', + 'p', + 'picture', + 'pre', + 'progress', + 'q', + 'rp', + 'rt', + 'ruby', + 's', + 'samp', + 'section', + 'select', + 'small', + 'span', + 'strike', + 'strong', + 'sub', + 'summary', + 'sup', + 'table', + 'tbody', + 'td', + 'textarea', + 'tfoot', + 'th', + 'thead', + 'time', + 'tr', + 'tt', + 'u', + 'ul', + 'var', + 'wbr', + 'xmp', +]; + +/** + * @internal + */ +export const DisallowedTags: ReadonlyArray = [ + 'applet', + 'audio', + 'base', + 'basefont', + 'embed', + 'frame', + 'frameset', + 'iframe', + 'link', + 'meta', + 'noscript', + 'object', + 'param', + 'script', + 'slot', + 'source', + 'style', + 'template', + 'title', + 'track', + 'video', +]; + +const VARIABLE_REGEX = /^\s*var\(\s*[a-zA-Z0-9-_]+\s*(,\s*(.*))?\)\s*$/; +const VARIABLE_PREFIX = 'var('; + +const AllowedAttributes = [ + 'accept', + 'align', + 'alt', + 'checked', + 'cite', + 'class', + 'color', + 'cols', + 'colspan', + 'contextmenu', + 'coords', + 'datetime', + 'default', + 'dir', + 'dirname', + 'disabled', + 'download', + 'face', + 'headers', + 'height', + 'hidden', + 'high', + 'href', + 'hreflang', + 'ismap', + 'kind', + 'label', + 'lang', + 'list', + 'low', + 'max', + 'maxlength', + 'media', + 'min', + 'multiple', + 'open', + 'optimum', + 'pattern', + 'placeholder', + 'readonly', + 'rel', + 'required', + 'reversed', + 'rows', + 'rowspan', + 'scope', + 'selected', + 'shape', + 'size', + 'sizes', + 'span', + 'spellcheck', + 'src', + 'srclang', + 'srcset', + 'start', + 'step', + 'style', + 'tabindex', + 'target', + 'title', + 'translate', + 'type', + 'usemap', + 'valign', + 'value', + 'width', + 'wrap', + 'bgColor', +]; + +const DefaultStyleValue: { [name: string]: string } = { + 'background-color': 'transparent', + 'border-bottom-color': 'rgb(0, 0, 0)', + 'border-bottom-style': 'none', + 'border-bottom-width': '0px', + 'border-image-outset': '0', + 'border-image-repeat': 'stretch', + 'border-image-slice': '100%', + 'border-image-source': 'none', + 'border-image-width': '1', + 'border-left-color': 'rgb(0, 0, 0)', + 'border-left-style': 'none', + 'border-left-width': '0px', + 'border-right-color': 'rgb(0, 0, 0)', + 'border-right-style': 'none', + 'border-right-width': '0px', + 'border-top-color': 'rgb(0, 0, 0)', + 'border-top-style': 'none', + 'border-top-width': '0px', + 'outline-color': 'transparent', + 'outline-style': 'none', + 'outline-width': '0px', + overflow: 'visible', + '-webkit-text-stroke-width': '0px', + 'word-wrap': 'break-word', + 'margin-left': '0px', + 'margin-right': '0px', + padding: '0px', + 'padding-top': '0px', + 'padding-left': '0px', + 'padding-right': '0px', + 'padding-bottom': '0px', + border: '0px', + 'border-top': '0px', + 'border-left': '0px', + 'border-right': '0px', + 'border-bottom': '0px', + 'vertical-align': 'baseline', + float: 'none', + 'font-style': 'normal', + 'font-variant-ligatures': 'normal', + 'font-variant-caps': 'normal', + 'font-weight': '400', + 'letter-spacing': 'normal', + orphans: '2', + 'text-align': 'start', + 'text-indent': '0px', + 'text-transform': 'none', + widows: '2', + 'word-spacing': '0px', + 'white-space': 'normal', +}; + +/** + * @internal + */ +export function sanitizeElement( + element: HTMLElement, + allowedTags: ReadonlyArray, + disallowedTags: ReadonlyArray, + styleSanitizers?: Readonly>, + attributeSanitizers?: Readonly> +): HTMLElement | null { + const tag = element.tagName.toLowerCase(); + const sanitizedElement = + disallowedTags.indexOf(tag) >= 0 + ? null + : createSanitizedElement( + element.ownerDocument, + allowedTags.indexOf(tag) >= 0 ? tag : 'span', + element.attributes, + styleSanitizers, + attributeSanitizers + ); + + if (sanitizedElement) { + for (let child = element.firstChild; child; child = child.nextSibling) { + const newChild = isNodeOfType(child, 'ELEMENT_NODE') + ? sanitizeElement(child, allowedTags, disallowedTags, styleSanitizers) + : isNodeOfType(child, 'TEXT_NODE') + ? child.cloneNode() + : null; + + if (newChild) { + sanitizedElement?.appendChild(newChild); + } + } + } + + return sanitizedElement; +} + +/** + * @internal + */ +export function createSanitizedElement( + doc: Document, + tag: string, + attributes: NamedNodeMap, + styleSanitizers?: Readonly>, + attributeSanitizers?: Readonly> +): HTMLElement { + const element = doc.createElement(tag); + + for (let i = 0; i < attributes.length; i++) { + const attribute = attributes[i]; + const name = attribute.name.toLowerCase().trim(); + const value = attribute.value; + + const sanitizer = attributeSanitizers?.[name]; + const newValue = + name == 'style' + ? processStyles(tag, value, styleSanitizers) + : typeof sanitizer == 'function' + ? sanitizer(value, tag) + : typeof sanitizer === 'boolean' + ? sanitizer + ? value + : null + : AllowedAttributes.indexOf(name) >= 0 || name.indexOf('data-') == 0 + ? value + : null; + + if ( + newValue !== null && + newValue !== undefined && + !newValue.match(/s\n*c\n*r\n*i\n*p\n*t\n*:/i) // match script: with any NewLine inside. Browser will ignore those NewLine char and still treat it as script prefix + ) { + element.setAttribute(name, newValue); + } + } + + return element; +} + +function processStyles( + tagName: string, + value: string, + styleSanitizers?: Readonly> +) { + const pairs = value.split(';'); + const result: string[] = []; + + pairs.forEach(pair => { + const valueIndex = pair.indexOf(':'); + const name = pair.slice(0, valueIndex).trim(); + let value: string = pair.slice(valueIndex + 1).trim(); + + if (name && value) { + if (isCssVariable(value)) { + value = processCssVariable(value); + } + + const sanitizer = styleSanitizers?.[name]; + const sanitizedValue = + typeof sanitizer == 'function' + ? sanitizer(value, tagName) + : sanitizer === false + ? null + : value; + + if ( + !!sanitizedValue && + sanitizedValue != 'inherit' && + sanitizedValue != 'initial' && + sanitizedValue.indexOf('expression') < 0 && + !name.startsWith('-') && + DefaultStyleValue[name] != sanitizedValue + ) { + result.push(`${name}:${sanitizedValue}`); + } + } + }); + + return result.join(';'); +} + +function processCssVariable(value: string): string { + const match = VARIABLE_REGEX.exec(value); + return match?.[2] || ''; // Without fallback value, we don't know what does the original value mean, so ignore it +} + +function isCssVariable(value: string): boolean { + return value.indexOf(VARIABLE_PREFIX) == 0; +} diff --git a/packages-content-model/roosterjs-content-model-core/package.json b/packages-content-model/roosterjs-content-model-core/package.json index c037037dea2..ed1e640d214 100644 --- a/packages-content-model/roosterjs-content-model-core/package.json +++ b/packages-content-model/roosterjs-content-model-core/package.json @@ -4,7 +4,6 @@ "dependencies": { "tslib": "^2.3.1", "roosterjs-editor-types": "", - "roosterjs-editor-dom": "", "roosterjs-content-model-dom": "", "roosterjs-content-model-types": "" }, diff --git a/packages-content-model/roosterjs-content-model-core/test/coreApi/createContentModelTest.ts b/packages-content-model/roosterjs-content-model-core/test/coreApi/createContentModelTest.ts index 16e33c508a5..e9810252e3a 100644 --- a/packages-content-model/roosterjs-content-model-core/test/coreApi/createContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/createContentModelTest.ts @@ -44,6 +44,7 @@ describe('createContentModel', () => { cachedModel: mockedCachedMode, }, lifecycle: {}, + domToModelSettings: {}, } as any) as StandaloneEditorCore & EditorCore; }); @@ -105,6 +106,7 @@ describe('createContentModel with selection', () => { createEditorContext: createEditorContextSpy, }, cache: {}, + domToModelSettings: {}, }; }); diff --git a/packages-content-model/roosterjs-content-model-core/test/publicApi/model/pasteTest.ts b/packages-content-model/roosterjs-content-model-core/test/coreApi/pasteTest.ts similarity index 56% rename from packages-content-model/roosterjs-content-model-core/test/publicApi/model/pasteTest.ts rename to packages-content-model/roosterjs-content-model-core/test/coreApi/pasteTest.ts index d3389adbef3..233bb3de808 100644 --- a/packages-content-model/roosterjs-content-model-core/test/publicApi/model/pasteTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/pasteTest.ts @@ -1,17 +1,19 @@ -import * as addParserF from '../../../../roosterjs-content-model-plugins/lib/paste/utils/addParser'; +import * as addParserF from 'roosterjs-content-model-plugins/lib/paste/utils/addParser'; +import * as cloneModel from '../../lib/publicApi/model/cloneModel'; import * as domToContentModel from 'roosterjs-content-model-dom/lib/domToModel/domToContentModel'; -import * as ExcelF from '../../../../roosterjs-content-model-plugins/lib/paste/Excel/processPastedContentFromExcel'; -import * as getPasteSourceF from '../../../../roosterjs-content-model-plugins/lib/paste/pasteSourceValidations/getPasteSource'; -import * as getSelectedSegmentsF from '../../../lib/publicApi/selection/collectSelections'; -import * as mergeModelFile from '../../../lib/publicApi/model/mergeModel'; -import * as pasteF from '../../../lib/publicApi/model/paste'; -import * as PPT from '../../../../roosterjs-content-model-plugins/lib/paste/PowerPoint/processPastedContentFromPowerPoint'; -import * as setProcessorF from '../../../../roosterjs-content-model-plugins/lib/paste/utils/setProcessor'; -import * as WacComponents from '../../../../roosterjs-content-model-plugins/lib/paste/WacComponents/processPastedContentWacComponents'; -import * as WordDesktopFile from '../../../../roosterjs-content-model-plugins/lib/paste/WordDesktop/processPastedContentFromWordDesktop'; +import * as ExcelF from 'roosterjs-content-model-plugins/lib/paste/Excel/processPastedContentFromExcel'; +import * as getPasteSourceF from 'roosterjs-content-model-plugins/lib/paste/pasteSourceValidations/getPasteSource'; +import * as getSelectedSegmentsF from '../../lib/publicApi/selection/collectSelections'; +import * as mergeModelFile from '../../lib/publicApi/model/mergeModel'; +import * as PPT from 'roosterjs-content-model-plugins/lib/paste/PowerPoint/processPastedContentFromPowerPoint'; +import * as setProcessorF from 'roosterjs-content-model-plugins/lib/paste/utils/setProcessor'; +import * as WacComponents from 'roosterjs-content-model-plugins/lib/paste/WacComponents/processPastedContentWacComponents'; +import * as WordDesktopFile from 'roosterjs-content-model-plugins/lib/paste/WordDesktop/processPastedContentFromWordDesktop'; +import { BeforePasteEvent, PluginEvent, PluginEventType } from 'roosterjs-editor-types'; import { ContentModelEditor } from 'roosterjs-content-model-editor'; -import { ContentModelPastePlugin } from '../../../../roosterjs-content-model-plugins/lib/paste/ContentModelPastePlugin'; -import { createContentModelDocument, tableProcessor } from 'roosterjs-content-model-dom'; +import { ContentModelPastePlugin } from 'roosterjs-content-model-plugins/lib/paste/ContentModelPastePlugin'; +import { expectEqual, initEditor } from 'roosterjs-content-model-plugins/test/paste/e2e/testUtils'; +import { tableProcessor } from 'roosterjs-content-model-dom'; import { ClipboardData, ContentModelDocument, @@ -21,45 +23,32 @@ import { FormatWithContentModelOptions, IStandaloneEditor, } from 'roosterjs-content-model-types'; -import { - expectEqual, - initEditor, -} from '../../../../roosterjs-content-model-plugins/test/paste/e2e/testUtils'; -import { - BeforePasteEvent, - IEditor, - PasteType, - PluginEvent, - PluginEventType, -} from 'roosterjs-editor-types'; let clipboardData: ClipboardData; const DEFAULT_TIMES_ADD_PARSER_CALLED = 4; describe('Paste ', () => { - let editor: IStandaloneEditor & IEditor; + let editor: IStandaloneEditor; let createContentModel: jasmine.Spy; let focus: jasmine.Spy; let mockedModel: ContentModelDocument; let mockedMergeModel: ContentModelDocument; let getFocusedPosition: jasmine.Spy; let getContent: jasmine.Spy; - let getSelectionRange: jasmine.Spy; - let getDocument: jasmine.Spy; - let getTrustedHTMLHandler: jasmine.Spy; - let triggerPluginEvent: jasmine.Spy; let getVisibleViewport: jasmine.Spy; let mergeModelSpy: jasmine.Spy; let formatResult: boolean | undefined; let context: FormatWithContentModelContext | undefined; const mockedPos = 'POS' as any; + const mockedCloneModel = 'CloneModel' as any; let div: HTMLDivElement; beforeEach(() => { spyOn(domToContentModel, 'domToContentModel').and.callThrough(); + spyOn(cloneModel, 'cloneModel').and.returnValue(mockedCloneModel); clipboardData = { types: ['image/png', 'text/html'], text: '', @@ -70,40 +59,17 @@ describe('Paste ', () => { }; div = document.createElement('div'); document.body.appendChild(div); - mockedModel = ({} as any) as ContentModelDocument; + mockedModel = { + blockGroupType: 'Document', + blocks: [], + } as ContentModelDocument; + mockedMergeModel = ({} as any) as ContentModelDocument; createContentModel = jasmine.createSpy('createContentModel').and.returnValue(mockedModel); focus = jasmine.createSpy('focus'); getFocusedPosition = jasmine.createSpy('getFocusedPosition').and.returnValue(mockedPos); getContent = jasmine.createSpy('getContent'); - getDocument = jasmine.createSpy('getDocument').and.returnValue(document); - triggerPluginEvent = jasmine.createSpy('triggerPluginEvent').and.returnValue({ - clipboardData, - fragment: document.createDocumentFragment(), - sanitizingOption: { - elementCallbacks: {}, - attributeCallbacks: {}, - cssStyleCallbacks: {}, - additionalTagReplacements: {}, - additionalAllowedAttributes: [], - additionalAllowedCssClasses: [], - additionalDefaultStyleValues: {}, - additionalGlobalStyleNodes: [], - additionalPredefinedCssForElement: {}, - preserveHtmlComments: false, - unknownTagReplacement: null, - }, - htmlBefore: '', - htmlAfter: '', - htmlAttributes: {}, - domToModelOption: {}, - pasteType: PasteType.Normal, - }); - getTrustedHTMLHandler = jasmine - .createSpy('getTrustedHTMLHandler') - .and.returnValue((html: string) => html); - getVisibleViewport = jasmine.createSpy('getVisibleViewport'); mergeModelSpy = spyOn(mergeModelFile, 'mergeModel').and.callFake(() => { mockedModel = mockedMergeModel; @@ -120,7 +86,11 @@ describe('Paste ', () => { const formatContentModel = jasmine .createSpy('formatContentModel') .and.callFake( - (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { + ( + core: any, + callback: ContentModelFormatter, + options: FormatWithContentModelOptions + ) => { context = { newEntities: [], deletedEntities: [], @@ -133,19 +103,21 @@ describe('Paste ', () => { formatResult = undefined; context = undefined; - editor = ({ - focus, - createContentModel, - getFocusedPosition, - getContent, - getSelectionRange, - getDocument, - getTrustedHTMLHandler, - triggerPluginEvent, - getVisibleViewport, - isDarkMode: () => false, - formatContentModel, - } as any) as IStandaloneEditor & IEditor; + editor = new ContentModelEditor(div, { + plugins: [new ContentModelPastePlugin()], + coreApiOverride: { + focus, + createContentModel, + getVisibleViewport, + formatContentModel, + }, + legacyCoreApiOverride: { + getContent, + }, + }); + + spyOn(editor, 'getDocument').and.callThrough(); + spyOn(editor, 'triggerPluginEvent').and.callThrough(); }); afterEach(() => { @@ -154,25 +126,16 @@ describe('Paste ', () => { }); it('Execute', () => { - pasteF.paste(editor, clipboardData); + editor.pasteFromClipboard(clipboardData); expect(formatResult).toBeTrue(); - expect(focus).toHaveBeenCalled(); - expect(getContent).toHaveBeenCalled(); - expect(triggerPluginEvent).toHaveBeenCalled(); - expect(getDocument).toHaveBeenCalled(); - expect(getTrustedHTMLHandler).toHaveBeenCalled(); expect(mockedModel).toEqual(mockedMergeModel); }); it('Execute | As plain text', () => { - pasteF.paste(editor, clipboardData, 'asPlainText'); + editor.pasteFromClipboard(clipboardData, 'asPlainText'); expect(formatResult).toBeTrue(); - expect(focus).toHaveBeenCalled(); - expect(getContent).toHaveBeenCalled(); - expect(getDocument).toHaveBeenCalled(); - expect(getTrustedHTMLHandler).toHaveBeenCalled(); expect(mockedModel).toEqual(mockedMergeModel); }); @@ -194,7 +157,7 @@ describe('Paste ', () => { }, }); - pasteF.paste(editor, clipboardData); + editor.pasteFromClipboard(clipboardData); editor.createContentModel({ processorOverride: { @@ -256,10 +219,10 @@ describe('paste with content model & paste plugin', () => { spyOn(getPasteSourceF, 'getPasteSource').and.returnValue('wordDesktop'); spyOn(WordDesktopFile, 'processPastedContentFromWordDesktop').and.callThrough(); - pasteF.paste(editor!, clipboardData); + editor?.paste(clipboardData); expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(1); - expect(addParserF.default).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED + 3); + expect(addParserF.default).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED + 4); expect(WordDesktopFile.processPastedContentFromWordDesktop).toHaveBeenCalledTimes(1); }); @@ -267,10 +230,10 @@ describe('paste with content model & paste plugin', () => { spyOn(getPasteSourceF, 'getPasteSource').and.returnValue('wacComponents'); spyOn(WacComponents, 'processPastedContentWacComponents').and.callThrough(); - pasteF.paste(editor!, clipboardData); + editor?.paste(clipboardData); expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(4); - expect(addParserF.default).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED + 5); + expect(addParserF.default).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED + 6); expect(WacComponents.processPastedContentWacComponents).toHaveBeenCalledTimes(1); }); @@ -278,7 +241,7 @@ describe('paste with content model & paste plugin', () => { spyOn(getPasteSourceF, 'getPasteSource').and.returnValue('excelOnline'); spyOn(ExcelF, 'processPastedContentFromExcel').and.callThrough(); - pasteF.paste(editor!, clipboardData); + editor?.paste(clipboardData); expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(1); expect(addParserF.default).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED + 1); @@ -289,7 +252,7 @@ describe('paste with content model & paste plugin', () => { spyOn(getPasteSourceF, 'getPasteSource').and.returnValue('excelDesktop'); spyOn(ExcelF, 'processPastedContentFromExcel').and.callThrough(); - pasteF.paste(editor!, clipboardData); + editor?.paste(clipboardData); expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(1); expect(addParserF.default).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED + 1); @@ -300,7 +263,7 @@ describe('paste with content model & paste plugin', () => { spyOn(getPasteSourceF, 'getPasteSource').and.returnValue('powerPointDesktop'); spyOn(PPT, 'processPastedContentFromPowerPoint').and.callThrough(); - pasteF.paste(editor!, clipboardData); + editor?.paste(clipboardData); expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(0); expect(addParserF.default).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED); @@ -312,7 +275,7 @@ describe('paste with content model & paste plugin', () => { spyOn(getPasteSourceF, 'getPasteSource').and.returnValue('wordDesktop'); spyOn(WordDesktopFile, 'processPastedContentFromWordDesktop').and.callThrough(); - pasteF.paste(editor!, clipboardData, 'asPlainText'); + editor?.paste(clipboardData, true /* pasteAsText */); expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(0); expect(addParserF.default).toHaveBeenCalledTimes(0); @@ -323,7 +286,7 @@ describe('paste with content model & paste plugin', () => { spyOn(getPasteSourceF, 'getPasteSource').and.returnValue('wacComponents'); spyOn(WacComponents, 'processPastedContentWacComponents').and.callThrough(); - pasteF.paste(editor!, clipboardData, 'asPlainText'); + editor?.paste(clipboardData, true /* pasteAsText */); expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(0); expect(addParserF.default).toHaveBeenCalledTimes(0); @@ -334,7 +297,7 @@ describe('paste with content model & paste plugin', () => { spyOn(getPasteSourceF, 'getPasteSource').and.returnValue('excelOnline'); spyOn(ExcelF, 'processPastedContentFromExcel').and.callThrough(); - pasteF.paste(editor!, clipboardData, 'asPlainText'); + editor?.paste(clipboardData, true /* pasteAsText */); expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(0); expect(addParserF.default).toHaveBeenCalledTimes(0); @@ -345,7 +308,7 @@ describe('paste with content model & paste plugin', () => { spyOn(getPasteSourceF, 'getPasteSource').and.returnValue('excelDesktop'); spyOn(ExcelF, 'processPastedContentFromExcel').and.callThrough(); - pasteF.paste(editor!, clipboardData, 'asPlainText'); + editor?.paste(clipboardData, true /* pasteAsText */); expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(0); expect(addParserF.default).toHaveBeenCalledTimes(0); @@ -356,7 +319,7 @@ describe('paste with content model & paste plugin', () => { spyOn(getPasteSourceF, 'getPasteSource').and.returnValue('powerPointDesktop'); spyOn(PPT, 'processPastedContentFromPowerPoint').and.callThrough(); - pasteF.paste(editor!, clipboardData, 'asPlainText'); + editor?.paste(clipboardData, true /* pasteAsText */); expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(0); expect(addParserF.default).toHaveBeenCalledTimes(0); @@ -392,7 +355,7 @@ describe('paste with content model & paste plugin', () => { ], }); - pasteF.paste(editor!, clipboardData); + editor?.paste(clipboardData); expect(eventChecker?.clipboardData).toEqual(clipboardData); expect(eventChecker?.htmlBefore).toBeTruthy(); @@ -401,235 +364,8 @@ describe('paste with content model & paste plugin', () => { }); }); -describe('mergePasteContent', () => { - it('merge table', () => { - // A doc with only one table in content - const pasteModel: ContentModelDocument = { - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Table', - rows: [ - { - height: 0, - format: {}, - cells: [ - { - blockGroupType: 'TableCell', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: 'FromPaste', - format: {}, - }, - ], - format: {}, - isImplicit: true, - }, - ], - format: {}, - spanLeft: false, - spanAbove: false, - isHeader: false, - dataset: {}, - }, - ], - }, - ], - format: { useBorderBox: true, borderCollapse: true }, - widths: [], - dataset: { - editingInfo: '', - }, - }, - ], - }; - - // A doc with a table, and selection marker inside of it. - const sourceModel: ContentModelDocument = { - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Table', - rows: [ - { - height: 22, - format: {}, - cells: [ - { - blockGroupType: 'TableCell', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, - { segmentType: 'Br', format: {} }, - ], - format: {}, - isImplicit: true, - }, - ], - format: {}, - spanLeft: false, - spanAbove: false, - isHeader: false, - dataset: {}, - }, - ], - }, - ], - format: { useBorderBox: true, borderCollapse: true }, - widths: [120, 120], - dataset: { - editingInfo: '', - }, - }, - { - blockType: 'Paragraph', - segments: [{ segmentType: 'Br', format: {} }], - format: {}, - }, - ], - format: {}, - }; - - spyOn(mergeModelFile, 'mergeModel').and.callThrough(); - - pasteF.mergePasteContent( - sourceModel, - { newEntities: [], deletedEntities: [], newImages: [] }, - pasteModel, - false /* applyCurrentFormat */, - undefined /* customizedMerge */ - ); - - expect(mergeModelFile.mergeModel).toHaveBeenCalledWith( - sourceModel, - pasteModel, - { newEntities: [], deletedEntities: [], newImages: [] }, - { - mergeFormat: 'none', - mergeTable: true, - } - ); - expect(sourceModel).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Table', - rows: [ - { - height: 22, - format: {}, - cells: [ - { - blockGroupType: 'TableCell', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: 'FromPaste', - format: {}, - }, - { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, - ], - format: {}, - isImplicit: true, - }, - ], - format: { - useBorderBox: true, - borderTop: '1px solid #ABABAB', - borderRight: '1px solid #ABABAB', - borderBottom: '1px solid #ABABAB', - borderLeft: '1px solid #ABABAB', - }, - spanLeft: false, - spanAbove: false, - isHeader: false, - dataset: {}, - }, - ], - }, - ], - format: { useBorderBox: true, borderCollapse: true }, - widths: [120, 120], - dataset: { - editingInfo: - '{"topBorderColor":"#ABABAB","bottomBorderColor":"#ABABAB","verticalBorderColor":"#ABABAB","hasHeaderRow":false,"hasFirstColumn":false,"hasBandedRows":false,"hasBandedColumns":false,"bgColorEven":null,"bgColorOdd":"#ABABAB20","headerRowColor":"#ABABAB","tableBorderFormat":0,"verticalAlign":null}', - }, - }, - { - blockType: 'Paragraph', - segments: [{ segmentType: 'Br', format: {} }], - format: {}, - }, - ], - format: {}, - }); - }); - - it('customized merge', () => { - const pasteModel: ContentModelDocument = createContentModelDocument(); - const sourceModel: ContentModelDocument = createContentModelDocument(); - - const customizedMerge = jasmine.createSpy('customizedMerge'); - - spyOn(mergeModelFile, 'mergeModel').and.callThrough(); - - pasteF.mergePasteContent( - sourceModel, - { newEntities: [], deletedEntities: [], newImages: [] }, - pasteModel, - false /* applyCurrentFormat */, - customizedMerge /* customizedMerge */ - ); - - expect(mergeModelFile.mergeModel).not.toHaveBeenCalled(); - expect(customizedMerge).toHaveBeenCalledWith(sourceModel, pasteModel); - }); - - it('Apply current format', () => { - const pasteModel: ContentModelDocument = createContentModelDocument(); - const sourceModel: ContentModelDocument = createContentModelDocument(); - - spyOn(mergeModelFile, 'mergeModel').and.callThrough(); - - pasteF.mergePasteContent( - sourceModel, - { newEntities: [], deletedEntities: [], newImages: [] }, - pasteModel, - true /* applyCurrentFormat */, - undefined /* customizedMerge */ - ); - - expect(mergeModelFile.mergeModel).toHaveBeenCalledWith( - sourceModel, - pasteModel, - { newEntities: [], deletedEntities: [], newImages: [] }, - { - mergeFormat: 'keepSourceEmphasisFormat', - mergeTable: false, - } - ); - }); -}); - describe('Paste with clipboardData', () => { - let editor: IEditor & IStandaloneEditor = undefined!; + let editor: IStandaloneEditor = undefined!; const ID = 'EDITOR_ID'; beforeEach(() => { @@ -651,11 +387,11 @@ describe('Paste with clipboardData', () => { document.getElementById(ID)?.remove(); }); - it('Remove windowtext from clipboardContent', () => { + it('Replace windowtext with set black font color from clipboardContent', () => { clipboardData.rawHtml = '

Test

'; - pasteF.paste(editor, clipboardData); + editor.pasteFromClipboard(clipboardData); const model = editor.createContentModel({ processorOverride: { @@ -672,12 +408,16 @@ describe('Paste with clipboardData', () => { { segmentType: 'Text', text: 'Test', - format: {}, + format: { + textColor: 'rgb(0, 0, 0)', + }, }, { segmentType: 'SelectionMarker', isSelected: true, - format: {}, + format: { + textColor: 'rgb(0, 0, 0)', + }, }, ], format: { @@ -698,7 +438,7 @@ describe('Paste with clipboardData', () => { clipboardData.rawHtml = 'Link'; - pasteF.paste(editor, clipboardData); + editor.pasteFromClipboard(clipboardData); const model = editor.createContentModel({ processorOverride: { @@ -730,7 +470,7 @@ describe('Paste with clipboardData', () => { clipboardData.rawHtml = 'Link'; - pasteF.paste(editor, clipboardData); + editor.pasteFromClipboard(clipboardData); const model = editor.createContentModel({ processorOverride: { diff --git a/packages-content-model/roosterjs-content-model-core/test/coreApi/setContentModelTest.ts b/packages-content-model/roosterjs-content-model-core/test/coreApi/setContentModelTest.ts index cf53f6b2df5..0940f354c93 100644 --- a/packages-content-model/roosterjs-content-model-core/test/coreApi/setContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/setContentModelTest.ts @@ -49,8 +49,10 @@ describe('setContentModel', () => { getDOMSelection: getDOMSelectionSpy, }, lifecycle: {}, - defaultModelToDomConfig: mockedConfig, cache: {}, + modelToDomSettings: { + calculated: mockedConfig, + }, } as any) as StandaloneEditorCore & EditorCore; }); @@ -95,12 +97,13 @@ describe('setContentModel', () => { const defaultOption = { o: 'OPTION' } as any; const additionalOption = { o: 'OPTION1', o2: 'OPTION2' } as any; - core.defaultModelToDomOptions = [defaultOption]; + core.modelToDomSettings.builtIn = defaultOption; setContentModel(core, mockedModel, additionalOption); expect(createModelToDomContextSpy).toHaveBeenCalledWith( mockedEditorContext, defaultOption, + undefined, additionalOption ); expect(contentModelToDomSpy).toHaveBeenCalledWith( @@ -141,9 +144,14 @@ describe('setContentModel', () => { ignoreSelection: true, }); - expect(createModelToDomContextSpy).toHaveBeenCalledWith(mockedEditorContext, { - ignoreSelection: true, - }); + expect(createModelToDomContextSpy).toHaveBeenCalledWith( + mockedEditorContext, + undefined, + undefined, + { + ignoreSelection: true, + } + ); expect(contentModelToDomSpy).toHaveBeenCalledWith( mockedDoc, mockedDiv, diff --git a/packages-content-model/roosterjs-content-model-core/test/coreApi/setDOMSelectionTest.ts b/packages-content-model/roosterjs-content-model-core/test/coreApi/setDOMSelectionTest.ts index 358a777722e..2f86441a183 100644 --- a/packages-content-model/roosterjs-content-model-core/test/coreApi/setDOMSelectionTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/setDOMSelectionTest.ts @@ -391,8 +391,10 @@ describe('setDOMSelection', () => { expect(contentDiv.id).toBe('contentDiv_0'); expect(mockedImage.id).toBe('image_0'); expect(deleteRuleSpy).not.toHaveBeenCalled(); + expect(insertRuleSpy).toHaveBeenCalledTimes(2); + expect(insertRuleSpy).toHaveBeenCalledWith('#contentDiv_0 {caret-color: transparent}'); expect(insertRuleSpy).toHaveBeenCalledWith( - '#contentDiv_0 #image_0 {outline-style:auto!important;outline-color:#DB626C!important;caret-color:transparent;}' + '#contentDiv_0 #image_0 {outline-style:auto!important;outline-color:#DB626C!important;}' ); }); @@ -438,8 +440,10 @@ describe('setDOMSelection', () => { expect(contentDiv.id).toBe('contentDiv_0'); expect(mockedImage.id).toBe('image_0_0'); expect(deleteRuleSpy).not.toHaveBeenCalled(); + expect(insertRuleSpy).toHaveBeenCalledTimes(2); + expect(insertRuleSpy).toHaveBeenCalledWith('#contentDiv_0 {caret-color: transparent}'); expect(insertRuleSpy).toHaveBeenCalledWith( - '#contentDiv_0 #image_0_0 {outline-style:auto!important;outline-color:#DB626C!important;caret-color:transparent;}' + '#contentDiv_0 #image_0_0 {outline-style:auto!important;outline-color:#DB626C!important;}' ); }); @@ -485,8 +489,10 @@ describe('setDOMSelection', () => { expect(contentDiv.id).toBe('contentDiv_0'); expect(mockedImage.id).toBe('image_0'); expect(deleteRuleSpy).not.toHaveBeenCalled(); + expect(insertRuleSpy).toHaveBeenCalledTimes(2); + expect(insertRuleSpy).toHaveBeenCalledWith('#contentDiv_0 {caret-color: transparent}'); expect(insertRuleSpy).toHaveBeenCalledWith( - '#contentDiv_0 #image_0 {outline-style:auto!important;outline-color:red!important;caret-color:transparent;}' + '#contentDiv_0 #image_0 {outline-style:auto!important;outline-color:red!important;}' ); }); }); @@ -541,7 +547,8 @@ describe('setDOMSelection', () => { expect(contentDiv.id).toBe('contentDiv_0'); expect(mockedTable.id).toBe('table_0'); expect(deleteRuleSpy).not.toHaveBeenCalled(); - expect(insertRuleSpy).not.toHaveBeenCalled(); + expect(insertRuleSpy).toHaveBeenCalledTimes(1); + expect(insertRuleSpy).toHaveBeenCalledWith('#contentDiv_0 {caret-color: transparent}'); }); function runTest( @@ -550,7 +557,7 @@ describe('setDOMSelection', () => { firstRow: number, lastColumn: number, lastRow: number, - result: string + ...result: string[] ) { const mockedSelection = { type: 'table', @@ -591,7 +598,11 @@ describe('setDOMSelection', () => { expect(contentDiv.id).toBe('contentDiv_0'); expect(mockedTable.id).toBe('table_0'); expect(deleteRuleSpy).not.toHaveBeenCalled(); - expect(insertRuleSpy).toHaveBeenCalledWith(result); + expect(insertRuleSpy).toHaveBeenCalledTimes(result.length); + + result.forEach(rule => { + expect(insertRuleSpy).toHaveBeenCalledWith(rule); + }); } it('Select Table Cells TR under Table Tag', () => { @@ -601,7 +612,8 @@ describe('setDOMSelection', () => { 0, 1, 1, - '#contentDiv_0 #table_0>TBODY> tr:nth-child(1)>TD:nth-child(2),#contentDiv_0 #table_0>TBODY> tr:nth-child(1)>TD:nth-child(2) *,#contentDiv_0 #table_0>TBODY> tr:nth-child(2)>TD:nth-child(2),#contentDiv_0 #table_0>TBODY> tr:nth-child(2)>TD:nth-child(2) * {background-color: rgb(198,198,198) !important; caret-color: transparent}' + '#contentDiv_0 {caret-color: transparent}', + '#contentDiv_0 #table_0>TBODY> tr:nth-child(1)>TD:nth-child(2),#contentDiv_0 #table_0>TBODY> tr:nth-child(1)>TD:nth-child(2) *,#contentDiv_0 #table_0>TBODY> tr:nth-child(2)>TD:nth-child(2),#contentDiv_0 #table_0>TBODY> tr:nth-child(2)>TD:nth-child(2) * {background-color: rgb(198,198,198) !important;}' ); }); @@ -612,7 +624,8 @@ describe('setDOMSelection', () => { 0, 0, 1, - '#contentDiv_0 #table_0> tr:nth-child(1)>TD:nth-child(1),#contentDiv_0 #table_0> tr:nth-child(1)>TD:nth-child(1) *,#contentDiv_0 #table_0> tr:nth-child(2)>TD:nth-child(1),#contentDiv_0 #table_0> tr:nth-child(2)>TD:nth-child(1) * {background-color: rgb(198,198,198) !important; caret-color: transparent}' + '#contentDiv_0 {caret-color: transparent}', + '#contentDiv_0 #table_0> tr:nth-child(1)>TD:nth-child(1),#contentDiv_0 #table_0> tr:nth-child(1)>TD:nth-child(1) *,#contentDiv_0 #table_0> tr:nth-child(2)>TD:nth-child(1),#contentDiv_0 #table_0> tr:nth-child(2)>TD:nth-child(1) * {background-color: rgb(198,198,198) !important;}' ); }); @@ -656,7 +669,8 @@ describe('setDOMSelection', () => { 0, 0, 1, - '#contentDiv_0 #table_0> tr:nth-child(1)>TH:nth-child(1),#contentDiv_0 #table_0> tr:nth-child(1)>TH:nth-child(1) *,#contentDiv_0 #table_0> tr:nth-child(2)>TH:nth-child(1),#contentDiv_0 #table_0> tr:nth-child(2)>TH:nth-child(1) * {background-color: rgb(198,198,198) !important; caret-color: transparent}' + '#contentDiv_0 {caret-color: transparent}', + '#contentDiv_0 #table_0> tr:nth-child(1)>TH:nth-child(1),#contentDiv_0 #table_0> tr:nth-child(1)>TH:nth-child(1) *,#contentDiv_0 #table_0> tr:nth-child(2)>TH:nth-child(1),#contentDiv_0 #table_0> tr:nth-child(2)>TH:nth-child(1) * {background-color: rgb(198,198,198) !important;}' ); }); @@ -667,7 +681,8 @@ describe('setDOMSelection', () => { 1, 2, 2, - '#contentDiv_0 #table_0>THEAD> tr:nth-child(2)>TD:nth-child(2),#contentDiv_0 #table_0>THEAD> tr:nth-child(2)>TD:nth-child(2) *,#contentDiv_0 #table_0>TBODY> tr:nth-child(1)>TD:nth-child(2),#contentDiv_0 #table_0>TBODY> tr:nth-child(1)>TD:nth-child(2) * {background-color: rgb(198,198,198) !important; caret-color: transparent}' + '#contentDiv_0 {caret-color: transparent}', + '#contentDiv_0 #table_0>THEAD> tr:nth-child(2)>TD:nth-child(2),#contentDiv_0 #table_0>THEAD> tr:nth-child(2)>TD:nth-child(2) *,#contentDiv_0 #table_0>TBODY> tr:nth-child(1)>TD:nth-child(2),#contentDiv_0 #table_0>TBODY> tr:nth-child(1)>TD:nth-child(2) * {background-color: rgb(198,198,198) !important;}' ); }); @@ -678,7 +693,8 @@ describe('setDOMSelection', () => { 1, 2, 2, - '#contentDiv_0 #table_0>TBODY> tr:nth-child(2)>TD:nth-child(2),#contentDiv_0 #table_0>TBODY> tr:nth-child(2)>TD:nth-child(2) *,#contentDiv_0 #table_0>TFOOT> tr:nth-child(1)>TD:nth-child(2),#contentDiv_0 #table_0>TFOOT> tr:nth-child(1)>TD:nth-child(2) * {background-color: rgb(198,198,198) !important; caret-color: transparent}' + '#contentDiv_0 {caret-color: transparent}', + '#contentDiv_0 #table_0>TBODY> tr:nth-child(2)>TD:nth-child(2),#contentDiv_0 #table_0>TBODY> tr:nth-child(2)>TD:nth-child(2) *,#contentDiv_0 #table_0>TFOOT> tr:nth-child(1)>TD:nth-child(2),#contentDiv_0 #table_0>TFOOT> tr:nth-child(1)>TD:nth-child(2) * {background-color: rgb(198,198,198) !important;}' ); }); @@ -689,7 +705,8 @@ describe('setDOMSelection', () => { 1, 1, 4, - '#contentDiv_0 #table_0>THEAD> tr:nth-child(2)>TD:nth-child(2),#contentDiv_0 #table_0>THEAD> tr:nth-child(2)>TD:nth-child(2) *,#contentDiv_0 #table_0>TBODY> tr:nth-child(1)>TD:nth-child(2),#contentDiv_0 #table_0>TBODY> tr:nth-child(1)>TD:nth-child(2) *,#contentDiv_0 #table_0>TBODY> tr:nth-child(2)>TD:nth-child(2),#contentDiv_0 #table_0>TBODY> tr:nth-child(2)>TD:nth-child(2) *,#contentDiv_0 #table_0>TFOOT> tr:nth-child(1)>TD:nth-child(2),#contentDiv_0 #table_0>TFOOT> tr:nth-child(1)>TD:nth-child(2) * {background-color: rgb(198,198,198) !important; caret-color: transparent}' + '#contentDiv_0 {caret-color: transparent}', + '#contentDiv_0 #table_0>THEAD> tr:nth-child(2)>TD:nth-child(2),#contentDiv_0 #table_0>THEAD> tr:nth-child(2)>TD:nth-child(2) *,#contentDiv_0 #table_0>TBODY> tr:nth-child(1)>TD:nth-child(2),#contentDiv_0 #table_0>TBODY> tr:nth-child(1)>TD:nth-child(2) *,#contentDiv_0 #table_0>TBODY> tr:nth-child(2)>TD:nth-child(2),#contentDiv_0 #table_0>TBODY> tr:nth-child(2)>TD:nth-child(2) *,#contentDiv_0 #table_0>TFOOT> tr:nth-child(1)>TD:nth-child(2),#contentDiv_0 #table_0>TFOOT> tr:nth-child(1)>TD:nth-child(2) * {background-color: rgb(198,198,198) !important;}' ); }); @@ -700,7 +717,8 @@ describe('setDOMSelection', () => { 1, 1, 2, - '#contentDiv_0 #table_0>THEAD> tr:nth-child(2)>TD:nth-child(2),#contentDiv_0 #table_0>THEAD> tr:nth-child(2)>TD:nth-child(2) *,#contentDiv_0 #table_0>TFOOT> tr:nth-child(1)>TD:nth-child(2),#contentDiv_0 #table_0>TFOOT> tr:nth-child(1)>TD:nth-child(2) * {background-color: rgb(198,198,198) !important; caret-color: transparent}' + '#contentDiv_0 {caret-color: transparent}', + '#contentDiv_0 #table_0>THEAD> tr:nth-child(2)>TD:nth-child(2),#contentDiv_0 #table_0>THEAD> tr:nth-child(2)>TD:nth-child(2) *,#contentDiv_0 #table_0>TFOOT> tr:nth-child(1)>TD:nth-child(2),#contentDiv_0 #table_0>TFOOT> tr:nth-child(1)>TD:nth-child(2) * {background-color: rgb(198,198,198) !important;}' ); }); @@ -711,7 +729,8 @@ describe('setDOMSelection', () => { 0, 1, 1, - '#contentDiv_0 #table_0,#contentDiv_0 #table_0 * {background-color: rgb(198,198,198) !important; caret-color: transparent}' + '#contentDiv_0 {caret-color: transparent}', + '#contentDiv_0 #table_0,#contentDiv_0 #table_0 * {background-color: rgb(198,198,198) !important;}' ); }); }); diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCopyPastePluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCopyPastePluginTest.ts index aed2982e7a9..21ed4713585 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCopyPastePluginTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCopyPastePluginTest.ts @@ -5,7 +5,6 @@ import * as deleteSelectionsFile from '../../lib/publicApi/selection/deleteSelec import * as extractClipboardItemsFile from '../../lib/utils/extractClipboardItems'; import * as iterateSelectionsFile from '../../lib/publicApi/selection/iterateSelections'; import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; -import * as PasteFile from '../../lib/publicApi/model/paste'; import * as transformColor from '../../lib/publicApi/color/transformColor'; import { createModelToDomContext, createTable, createTableCell } from 'roosterjs-content-model-dom'; import { createRange } from 'roosterjs-editor-dom'; @@ -22,6 +21,7 @@ import { CopyPastePluginState, } from 'roosterjs-content-model-types'; import { + adjustSelectionForCopyCut, createContentModelCopyPastePlugin, onNodeCreated, preprocessTable, @@ -34,8 +34,32 @@ const deleteResultValue = 'deleteResult' as any; const allowedCustomPasteType = ['Test']; +describe('ContentModelCopyPastePlugin.Ctor', () => { + it('Ctor without options', () => { + const plugin = createContentModelCopyPastePlugin({}); + const state = plugin.getState(); + + expect(state).toEqual({ + allowedCustomPasteType: [], + tempDiv: null, + }); + }); + + it('Ctor with options', () => { + const plugin = createContentModelCopyPastePlugin({ + allowedCustomPasteType, + }); + const state = plugin.getState(); + + expect(state).toEqual({ + allowedCustomPasteType: allowedCustomPasteType, + tempDiv: null, + }); + }); +}); + describe('ContentModelCopyPastePlugin |', () => { - let editor: IEditor = null!; + let editor: IEditor & IStandaloneEditor = null!; let plugin: PluginWithState; let domEvents: Record = {}; let div: HTMLDivElement; @@ -117,12 +141,19 @@ describe('ContentModelCopyPastePlugin |', () => { getDOMSelection: getDOMSelectionSpy, setDOMSelection: setDOMSelectionSpy, getDocument() { - return document; + return { + createRange: () => document.createRange(), + defaultView: { + requestAnimationFrame: (func: Function) => { + func(); + }, + }, + }; }, isDarkMode: () => { return false; }, - paste: (ar1: any) => { + pasteFromClipboard: (ar1: any) => { pasteSpy(ar1); }, getDarkColorHandler: () => mockedDarkColorHandler, @@ -186,7 +217,7 @@ describe('ContentModelCopyPastePlugin |', () => { ); expect(createContentModelSpy).toHaveBeenCalled(); expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); - expect(iterateSelectionsFile.iterateSelections).not.toHaveBeenCalled(); + expect(iterateSelectionsFile.iterateSelections).toHaveBeenCalled(); expect(focusSpy).toHaveBeenCalled(); expect(setDOMSelectionSpy).toHaveBeenCalledWith(selectionValue); @@ -357,7 +388,7 @@ describe('ContentModelCopyPastePlugin |', () => { // On Cut Spy expect(formatContentModelSpy).not.toHaveBeenCalled(); expect(formatResult).toBeFalsy(); - expect(iterateSelectionsFile.iterateSelections).toHaveBeenCalledTimes(0); + expect(iterateSelectionsFile.iterateSelections).toHaveBeenCalledTimes(1); }); }); @@ -403,6 +434,7 @@ describe('ContentModelCopyPastePlugin |', () => { }; } ); + spyOn(iterateSelectionsFile, 'iterateSelections').and.returnValue(undefined); spyOn(contentModelToDomFile, 'contentModelToDom').and.returnValue(selectionValue); triggerPluginEventSpy.and.callThrough(); @@ -539,7 +571,6 @@ describe('ContentModelCopyPastePlugin |', () => { let clipboardData = {}; it('Handle', () => { - spyOn(PasteFile, 'paste').and.callFake(() => {}); const preventDefaultSpy = jasmine.createSpy('preventDefaultPaste'); let clipboardEvent = { clipboardData: ({ @@ -560,8 +591,36 @@ describe('ContentModelCopyPastePlugin |', () => { domEvents.paste.beforeDispatch?.(clipboardEvent); - expect(pasteSpy).not.toHaveBeenCalledWith(clipboardData); - expect(PasteFile.paste).toHaveBeenCalled(); + expect(pasteSpy).toHaveBeenCalledWith(clipboardData); + expect(extractClipboardItemsFile.extractClipboardItems).toHaveBeenCalledWith( + Array.from(clipboardEvent.clipboardData!.items), + allowedCustomPasteType + ); + expect(preventDefaultSpy).toHaveBeenCalledTimes(1); + }); + + it('Handle with domToModelOptions', () => { + const preventDefaultSpy = jasmine.createSpy('preventDefaultPaste'); + let clipboardEvent = { + clipboardData: ({ + items: [{}], + }), + preventDefault() { + preventDefaultSpy(); + }, + }; + spyOn(extractClipboardItemsFile, 'extractClipboardItems').and.returnValue(< + Promise + >{ + then: (cb: (value: ClipboardData) => void | PromiseLike) => { + cb(clipboardData); + }, + }); + isDisposed.and.returnValue(false); + + domEvents.paste.beforeDispatch?.(clipboardEvent); + + expect(pasteSpy).toHaveBeenCalledWith(clipboardData); expect(extractClipboardItemsFile.extractClipboardItems).toHaveBeenCalledWith( Array.from(clipboardEvent.clipboardData!.items), allowedCustomPasteType @@ -696,4 +755,508 @@ describe('ContentModelCopyPastePlugin |', () => { }); }); }); + + describe('adjustSelectionForCopyCut', () => { + it('adjust the selection when selecting first cell of table', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + segmentFormat: {}, + isImplicit: true, + }, + { + blockType: 'Table', + rows: [ + { + height: 22, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'asd', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: {}, + widths: [120], + dataset: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + adjustSelectionForCopyCut(model); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + segmentFormat: {}, + isImplicit: true, + }, + { + blockType: 'Table', + rows: [ + { + height: 22, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'asd', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: {}, + widths: [120], + dataset: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }); + }); + + it('adjust the selection when selecting first cell of a table nested in another table', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + isImplicit: true, + segments: [ + { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, + ], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + { + widths: [120], + rows: [ + { + height: 44, + cells: [ + { + spanAbove: false, + spanLeft: false, + isHeader: false, + blockGroupType: 'TableCell', + blocks: [ + { + widths: [116.4000015258789], + rows: [ + { + height: 22, + cells: [ + { + spanAbove: false, + spanLeft: false, + isHeader: false, + blockGroupType: 'TableCell', + blocks: [ + { + segments: [ + { + text: 'Test', + segmentType: 'Text', + isSelected: true, + format: {}, + }, + ], + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + dataset: {}, + }, + ], + format: {}, + }, + ], + blockType: 'Table', + format: {}, + dataset: {}, + }, + { + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + dataset: {}, + }, + ], + format: {}, + }, + ], + blockType: 'Table', + format: { + useBorderBox: true, + borderCollapse: true, + }, + dataset: {}, + }, + { + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + }; + + adjustSelectionForCopyCut(model); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + isImplicit: true, + segments: [], + segmentFormat: {}, + blockType: 'Paragraph', + format: {}, + }, + { + widths: [120], + rows: [ + { + height: 44, + cells: [ + { + spanAbove: false, + spanLeft: false, + isHeader: false, + blockGroupType: 'TableCell', + blocks: [ + { + widths: jasmine.anything() as any, + rows: [ + { + height: 22, + cells: [ + { + spanAbove: false, + spanLeft: false, + isHeader: false, + blockGroupType: 'TableCell', + blocks: [ + { + segments: [ + { + text: 'Test', + segmentType: 'Text', + isSelected: true, + format: {}, + }, + ], + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + dataset: {}, + }, + ], + format: {}, + }, + ], + blockType: 'Table', + format: {}, + dataset: {}, + }, + { + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + dataset: {}, + }, + ], + format: {}, + }, + ], + blockType: 'Table', + format: { + useBorderBox: true, + borderCollapse: true, + }, + dataset: {}, + }, + { + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + }); + }); + + it('Adjust selection starting at last cell with no text and finishing on text after table', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + widths: [120.00000762939453], + rows: [ + { + height: 22.000001907348633, + cells: [ + { + spanAbove: false, + spanLeft: false, + isHeader: false, + blockGroupType: 'TableCell', + blocks: [ + { + segments: [ + { + text: 'asd', + segmentType: 'Text', + format: {}, + }, + { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, + ], + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + dataset: {}, + }, + ], + format: {}, + }, + ], + blockType: 'Table', + format: {}, + dataset: {}, + }, + { + segments: [ + { + text: 'asd', + segmentType: 'Text', + format: {}, + isSelected: true, + }, + ], + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + }; + + adjustSelectionForCopyCut(model); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + widths: [120.00000762939453], + rows: [ + { + height: 22.000001907348633, + cells: [ + { + spanAbove: false, + spanLeft: false, + isHeader: false, + blockGroupType: 'TableCell', + blocks: [ + { + segments: [ + { + text: 'asd', + segmentType: 'Text', + format: {}, + }, + ], + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + dataset: {}, + }, + ], + format: {}, + }, + ], + blockType: 'Table', + format: {}, + dataset: {}, + }, + { + segments: [ + { + text: 'asd', + segmentType: 'Text', + format: {}, + isSelected: true, + }, + ], + blockType: 'Paragraph', + format: {}, + }, + ], + format: {}, + }); + }); + + it('Do not adjust when it is not needed', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'asdsadsada', + format: {}, + isSelected: true, + }, + { + segmentType: 'Text', + text: 'sdsad', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + + adjustSelectionForCopyCut(model); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'asdsadsada', + format: {}, + isSelected: true, + }, + { + segmentType: 'Text', + text: 'sdsad', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }); + }); + }); }); diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelFormatPluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelFormatPluginTest.ts index 6d4b534e3f6..a451fe197fe 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelFormatPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelFormatPluginTest.ts @@ -278,7 +278,7 @@ describe('ContentModelFormatPlugin for default format', () => { contentDiv = document.createElement('div'); editor = ({ - contains: (e: Node) => contentDiv != e && contentDiv.contains(e), + isNodeInEditor: (e: Node) => contentDiv != e && contentDiv.contains(e), getDOMSelection, getPendingFormat: getPendingFormatSpy, cacheContentModel: cacheContentModelSpy, diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/DomEventPluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/DomEventPluginTest.ts index 6a5491f8406..844bcd0ad46 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/DomEventPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/DomEventPluginTest.ts @@ -31,7 +31,6 @@ describe('DOMEventPlugin', () => { expect(state).toEqual({ isInIME: false, scrollContainer: div, - contextMenuProviders: [], mouseDownX: null, mouseDownY: null, mouseUpEventListerAdded: false, @@ -80,7 +79,6 @@ describe('DOMEventPlugin', () => { expect(state).toEqual({ isInIME: false, scrollContainer: divScrollContainer, - contextMenuProviders: [], mouseDownX: null, mouseDownY: null, mouseUpEventListerAdded: false, @@ -129,7 +127,6 @@ describe('DOMEventPlugin verify event handlers while disallow keyboard event pro expect(eventMap.keyup.beforeDispatch).toBeDefined(); expect(eventMap.input.beforeDispatch).toBeDefined(); expect(eventMap.mousedown).toBeDefined(); - expect(eventMap.contextmenu).toBeDefined(); expect(eventMap.compositionstart).toBeDefined(); expect(eventMap.compositionend).toBeDefined(); expect(eventMap.dragstart).toBeDefined(); @@ -234,7 +231,6 @@ describe('DOMEventPlugin handle mouse down and mouse up event', () => { expect(plugin.getState()).toEqual({ isInIME: false, scrollContainer: scrollContainer, - contextMenuProviders: [], mouseDownX: 100, mouseDownY: 200, mouseUpEventListerAdded: true, @@ -253,7 +249,6 @@ describe('DOMEventPlugin handle mouse down and mouse up event', () => { expect(plugin.getState()).toEqual({ isInIME: false, scrollContainer: scrollContainer, - contextMenuProviders: [], mouseDownX: 100, mouseDownY: 200, mouseUpEventListerAdded: true, @@ -270,7 +265,6 @@ describe('DOMEventPlugin handle mouse down and mouse up event', () => { expect(plugin.getState()).toEqual({ isInIME: false, scrollContainer: scrollContainer, - contextMenuProviders: [], mouseDownX: 100, mouseDownY: 200, mouseUpEventListerAdded: false, @@ -293,7 +287,6 @@ describe('DOMEventPlugin handle mouse down and mouse up event', () => { expect(plugin.getState()).toEqual({ isInIME: false, scrollContainer: scrollContainer, - contextMenuProviders: [], mouseDownX: 100, mouseDownY: 200, mouseUpEventListerAdded: true, @@ -310,7 +303,6 @@ describe('DOMEventPlugin handle mouse down and mouse up event', () => { expect(plugin.getState()).toEqual({ isInIME: false, scrollContainer: scrollContainer, - contextMenuProviders: [], mouseDownX: 100, mouseDownY: 200, mouseUpEventListerAdded: false, @@ -325,16 +317,14 @@ describe('DOMEventPlugin handle other event', () => { let triggerPluginEvent: jasmine.Spy; let eventMap: Record; let scrollContainer: HTMLElement; - let getElementAtCursorSpy: jasmine.Spy; - let triggerContentChangedEventSpy: jasmine.Spy; + let addEventListenerSpy: jasmine.Spy; let editor: IEditor & IStandaloneEditor; beforeEach(() => { addEventListener = jasmine.createSpy('addEventListener'); removeEventListener = jasmine.createSpy('.removeEventListener'); triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); - getElementAtCursorSpy = jasmine.createSpy('getElementAtCursor'); - triggerContentChangedEventSpy = jasmine.createSpy('triggerContentChangedEvent'); + addEventListenerSpy = jasmine.createSpy('addEventListener'); scrollContainer = { addEventListener: () => {}, @@ -351,6 +341,13 @@ describe('DOMEventPlugin handle other event', () => { getDocument: () => ({ addEventListener, removeEventListener, + defaultView: { + requestAnimationFrame: (callback: Function) => { + callback(); + }, + addEventListener: addEventListenerSpy, + removeEventListener: () => {}, + }, }), triggerPluginEvent, getEnvironment: () => ({}), @@ -358,8 +355,6 @@ describe('DOMEventPlugin handle other event', () => { eventMap = map; return jasmine.createSpy('disposer'); }, - getElementAtCursor: getElementAtCursorSpy, - triggerContentChangedEvent: triggerContentChangedEventSpy, }); plugin.initialize(editor); }); @@ -373,7 +368,6 @@ describe('DOMEventPlugin handle other event', () => { expect(plugin.getState()).toEqual({ isInIME: true, scrollContainer: scrollContainer, - contextMenuProviders: [], mouseDownX: null, mouseDownY: null, mouseUpEventListerAdded: false, @@ -386,7 +380,6 @@ describe('DOMEventPlugin handle other event', () => { expect(plugin.getState()).toEqual({ isInIME: false, scrollContainer: scrollContainer, - contextMenuProviders: [], mouseDownX: null, mouseDownY: null, mouseUpEventListerAdded: false, @@ -394,22 +387,23 @@ describe('DOMEventPlugin handle other event', () => { expect(triggerPluginEvent).toHaveBeenCalledWith(PluginEventType.CompositionEnd, { rawEvent: mockedEvent, }); + expect(addEventListenerSpy).toHaveBeenCalledTimes(2); }); it('Trigger onDragStart event', () => { const preventDefaultSpy = jasmine.createSpy('preventDefault'); const mockedEvent = { preventDefault: preventDefaultSpy, + target: { + nodeType: Node.ELEMENT_NODE, + isContentEditable: true, + }, } as any; - getElementAtCursorSpy.and.returnValue({ - isContentEditable: true, - }); eventMap.dragstart.beforeDispatch(mockedEvent); expect(plugin.getState()).toEqual({ isInIME: false, scrollContainer: scrollContainer, - contextMenuProviders: [], mouseDownX: null, mouseDownY: null, mouseUpEventListerAdded: false, @@ -423,16 +417,16 @@ describe('DOMEventPlugin handle other event', () => { const preventDefaultSpy = jasmine.createSpy('preventDefault'); const mockedEvent = { preventDefault: preventDefaultSpy, + target: { + nodeType: Node.ELEMENT_NODE, + isContentEditable: false, + }, } as any; - getElementAtCursorSpy.and.returnValue({ - isContentEditable: false, - }); eventMap.dragstart.beforeDispatch(mockedEvent); expect(plugin.getState()).toEqual({ isInIME: false, scrollContainer: scrollContainer, - contextMenuProviders: [], mouseDownX: null, mouseDownY: null, mouseUpEventListerAdded: false, @@ -444,46 +438,19 @@ describe('DOMEventPlugin handle other event', () => { it('Trigger onDrop event', () => { const takeSnapshotSpy = jasmine.createSpy('takeSnapshot'); - editor.runAsync = (callback: Function) => callback(editor); editor.takeSnapshot = takeSnapshotSpy; eventMap.drop.beforeDispatch(); expect(plugin.getState()).toEqual({ isInIME: false, scrollContainer: scrollContainer, - contextMenuProviders: [], mouseDownX: null, mouseDownY: null, mouseUpEventListerAdded: false, }); expect(takeSnapshotSpy).toHaveBeenCalledWith(); - expect(triggerContentChangedEventSpy).toHaveBeenCalledWith(ChangeSource.Drop); - }); - - it('Trigger contextmenu event, skip reselect', () => { - editor.getContentSearcherOfCursor = () => null!; - const state = plugin.getState(); - const mockedItems1 = ['Item1', 'Item2']; - const mockedItems2 = ['Item3', 'Item4']; - - state.contextMenuProviders = [ - { - getContextMenuItems: () => mockedItems1, - } as any, - { - getContextMenuItems: () => mockedItems2, - } as any, - ]; - - const mockedEvent = { - target: {}, - }; - - eventMap.contextmenu.beforeDispatch(mockedEvent); - - expect(triggerPluginEvent).toHaveBeenCalledWith(PluginEventType.ContextMenu, { - rawEvent: mockedEvent, - items: ['Item1', 'Item2', null, 'Item3', 'Item4'], + expect(triggerPluginEvent).toHaveBeenCalledWith(PluginEventType.ContentChanged, { + source: ChangeSource.Drop, }); }); }); diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/EntityPluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/EntityPluginTest.ts index 1cb5ce1dab0..0d10f8d8388 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/EntityPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/EntityPluginTest.ts @@ -17,7 +17,7 @@ describe('EntityPlugin', () => { let createContentModelSpy: jasmine.Spy; let triggerPluginEventSpy: jasmine.Spy; let isDarkModeSpy: jasmine.Spy; - let containsSpy: jasmine.Spy; + let isNodeInEditorSpy: jasmine.Spy; let transformColorSpy: jasmine.Spy; let mockedDarkColorHandler: DarkColorHandler; @@ -25,7 +25,7 @@ describe('EntityPlugin', () => { createContentModelSpy = jasmine.createSpy('createContentModel'); triggerPluginEventSpy = jasmine.createSpy('triggerPluginEvent'); isDarkModeSpy = jasmine.createSpy('isDarkMode'); - containsSpy = jasmine.createSpy('contains'); + isNodeInEditorSpy = jasmine.createSpy('isNodeInEditor'); transformColorSpy = spyOn(transformColor, 'transformColor'); mockedDarkColorHandler = 'DARKCOLORHANDLER' as any; @@ -33,7 +33,7 @@ describe('EntityPlugin', () => { createContentModel: createContentModelSpy, triggerPluginEvent: triggerPluginEventSpy, isDarkMode: isDarkModeSpy, - contains: containsSpy, + isNodeInEditor: isNodeInEditorSpy, getDarkColorHandler: () => mockedDarkColorHandler, } as any; plugin = createEntityPlugin(); @@ -561,7 +561,7 @@ describe('EntityPlugin', () => { target: mockedNode, } as any; - containsSpy.and.returnValue(true); + isNodeInEditorSpy.and.returnValue(true); plugin.onPluginEvent({ eventType: PluginEventType.MouseUp, @@ -581,7 +581,7 @@ describe('EntityPlugin', () => { target: mockedNode, } as any; - containsSpy.and.returnValue(true); + isNodeInEditorSpy.and.returnValue(true); spyOn(entityUtils, 'isEntityElement').and.returnValue(true); plugin.onPluginEvent({ @@ -617,7 +617,7 @@ describe('EntityPlugin', () => { target: mockedNode2, } as any; - containsSpy.and.returnValue(true); + isNodeInEditorSpy.and.returnValue(true); spyOn(entityUtils, 'isEntityElement').and.callFake(node => node == mockedNode1); plugin.onPluginEvent({ @@ -649,7 +649,7 @@ describe('EntityPlugin', () => { target: mockedNode, } as any; - containsSpy.and.returnValue(true); + isNodeInEditorSpy.and.returnValue(true); spyOn(entityUtils, 'isEntityElement').and.returnValue(true); plugin.onPluginEvent({ diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/SelectionPluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/SelectionPluginTest.ts index 2a7eaf85ae3..44347183609 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/SelectionPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/SelectionPluginTest.ts @@ -293,6 +293,56 @@ describe('SelectionPlugin handle image selection', () => { expect(setDOMSelectionSpy).toHaveBeenCalledTimes(0); }); + it('Image selection, mouse down to same image right click', () => { + const mockedImage = document.createElement('img'); + + mockedImage.contentEditable = 'true'; + + getDOMSelectionSpy.and.returnValue({ + type: 'image', + image: mockedImage, + }); + + plugin.onPluginEvent({ + eventType: PluginEventType.MouseDown, + rawEvent: { + target: mockedImage, + button: 2, + } as any, + }); + + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + }); + + it('Image selection, mouse down to image right click', () => { + const mockedImage = document.createElement('img'); + + mockedImage.contentEditable = 'true'; + plugin.onPluginEvent({ + eventType: PluginEventType.MouseDown, + rawEvent: { + target: mockedImage, + button: 2, + } as any, + }); + + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + }); + + it('Image selection, mouse down to div right click', () => { + const node = document.createElement('div'); + + plugin.onPluginEvent({ + eventType: PluginEventType.MouseDown, + rawEvent: { + target: node, + button: 2, + } as any, + }); + + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(0); + }); + it('no selection, mouse up to image, is clicking, isEditable', () => { const mockedImage = document.createElement('img'); @@ -514,71 +564,4 @@ describe('SelectionPlugin handle image selection', () => { expect(stopPropagationSpy).not.toHaveBeenCalled(); expect(setDOMSelectionSpy).not.toHaveBeenCalled(); }); - - it('context menu, no selection, click on image', () => { - const mockedImage1 = document.createElement('img'); - - const rawEvent = { - target: mockedImage1, - } as any; - - plugin.onPluginEvent({ - eventType: PluginEventType.ContextMenu, - rawEvent: rawEvent, - } as any); - - expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); - expect(setDOMSelectionSpy).toHaveBeenCalledWith({ - type: 'image', - image: mockedImage1, - }); - }); - - it('context menu, image selection, click on same image', () => { - const mockedImage1 = document.createElement('img'); - - const rawEvent = { - target: mockedImage1, - } as any; - - getDOMSelectionSpy.and.returnValue({ - type: 'image', - image: mockedImage1, - }); - - plugin.onPluginEvent({ - eventType: PluginEventType.ContextMenu, - rawEvent: rawEvent, - } as any); - - expect(setDOMSelectionSpy).not.toHaveBeenCalled(); - }); - - it('context menu, image selection, click on different image', () => { - const mockedImage1 = document.createElement('img'); - const mockedImage2 = document.createElement('img'); - - mockedImage1.id = 'image1'; - mockedImage2.id = 'image2'; - - const rawEvent = { - target: mockedImage1, - } as any; - - getDOMSelectionSpy.and.returnValue({ - type: 'image', - image: mockedImage2, - }); - - plugin.onPluginEvent({ - eventType: PluginEventType.ContextMenu, - rawEvent: rawEvent, - } as any); - - expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); - expect(setDOMSelectionSpy).toHaveBeenCalledWith({ - type: 'image', - image: mockedImage1, - }); - }); }); diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/applyDefaultFormatTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/applyDefaultFormatTest.ts index c02b40d93b4..3c0b2050ddd 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/applyDefaultFormatTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/applyDefaultFormatTest.ts @@ -1,7 +1,6 @@ import * as deleteSelection from '../../../lib/publicApi/selection/deleteSelection'; import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; import { applyDefaultFormat } from '../../../lib/corePlugin/utils/applyDefaultFormat'; -import { IEditor } from 'roosterjs-editor-types'; import { ContentModelDocument, ContentModelFormatter, @@ -21,7 +20,7 @@ import { } from 'roosterjs-content-model-dom'; describe('applyDefaultFormat', () => { - let editor: IStandaloneEditor & IEditor; + let editor: IStandaloneEditor; let getDOMSelectionSpy: jasmine.Spy; let formatContentModelSpy: jasmine.Spy; let deleteSelectionSpy: jasmine.Spy; @@ -65,7 +64,7 @@ describe('applyDefaultFormat', () => { ); editor = { - contains: () => true, + isNodeInEditor: () => true, getDOMSelection: getDOMSelectionSpy, formatContentModel: formatContentModelSpy, takeSnapshot: takeSnapshotSpy, diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/applyPendingFormatTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/applyPendingFormatTest.ts index d05da7ffa3e..01b26ea8e36 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/applyPendingFormatTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/applyPendingFormatTest.ts @@ -1,7 +1,6 @@ import * as iterateSelections from '../../../lib/publicApi/selection/iterateSelections'; import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; import { applyPendingFormat } from '../../../lib/corePlugin/utils/applyPendingFormat'; -import { IEditor } from 'roosterjs-editor-types'; import { ContentModelDocument, ContentModelParagraph, @@ -55,7 +54,7 @@ describe('applyPendingFormat', () => { const editor = ({ formatContentModel: formatContentModelSpy, - } as any) as IStandaloneEditor & IEditor; + } as any) as IStandaloneEditor; spyOn(iterateSelections, 'iterateSelections').and.callFake((_, callback) => { callback([model], undefined, paragraph, [marker]); @@ -129,7 +128,7 @@ describe('applyPendingFormat', () => { const editor = ({ formatContentModel: formatContentModelSpy, - } as any) as IStandaloneEditor & IEditor; + } as any) as IStandaloneEditor; spyOn(iterateSelections, 'iterateSelections').and.callFake((_, callback) => { callback([model], undefined, paragraph, [marker]); @@ -188,7 +187,7 @@ describe('applyPendingFormat', () => { const formatContentModelSpy = jasmine.createSpy('formatContentModel'); const editor = ({ formatContentModel: formatContentModelSpy, - } as any) as IStandaloneEditor & IEditor; + } as any) as IStandaloneEditor; spyOn(iterateSelections, 'iterateSelections').and.callFake((_, callback) => { callback([model], undefined, paragraph, [marker]); @@ -248,7 +247,7 @@ describe('applyPendingFormat', () => { const editor = ({ formatContentModel: formatContentModelSpy, - } as any) as IStandaloneEditor & IEditor; + } as any) as IStandaloneEditor; spyOn(iterateSelections, 'iterateSelections').and.callFake((_, callback) => { callback([model], undefined, paragraph, [text]); @@ -299,7 +298,7 @@ describe('applyPendingFormat', () => { const editor = ({ formatContentModel: formatContentModelSpy, - } as any) as IStandaloneEditor & IEditor; + } as any) as IStandaloneEditor; spyOn(iterateSelections, 'iterateSelections').and.callFake((_, callback) => { callback([model], undefined, paragraph, [marker]); diff --git a/packages-content-model/roosterjs-content-model-core/test/editor/DarkColorHandlerImplTest.ts b/packages-content-model/roosterjs-content-model-core/test/editor/DarkColorHandlerImplTest.ts index 61ca7c9cc99..e479594d65e 100644 --- a/packages-content-model/roosterjs-content-model-core/test/editor/DarkColorHandlerImplTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/editor/DarkColorHandlerImplTest.ts @@ -418,7 +418,7 @@ describe('DarkColorHandlerImpl.transformElementColor', () => { expect(parseColorSpy).toHaveBeenCalledWith('red', false); expect(parseColorSpy).toHaveBeenCalledWith(null, false); expect(registerColorSpy).toHaveBeenCalledTimes(1); - expect(registerColorSpy).toHaveBeenCalledWith('red', true, undefined); + expect(registerColorSpy).toHaveBeenCalledWith('red', true); }); it('Has simple color in CSS, light to dark', () => { @@ -433,7 +433,7 @@ describe('DarkColorHandlerImpl.transformElementColor', () => { expect(parseColorSpy).toHaveBeenCalledWith('red', false); expect(parseColorSpy).toHaveBeenCalledWith(null, false); expect(registerColorSpy).toHaveBeenCalledTimes(1); - expect(registerColorSpy).toHaveBeenCalledWith('red', true, undefined); + expect(registerColorSpy).toHaveBeenCalledWith('red', true); }); it('Has color in both text and background, light to dark', () => { @@ -453,8 +453,8 @@ describe('DarkColorHandlerImpl.transformElementColor', () => { expect(parseColorSpy).toHaveBeenCalledWith('red'); expect(parseColorSpy).toHaveBeenCalledWith('green'); expect(registerColorSpy).toHaveBeenCalledTimes(2); - expect(registerColorSpy).toHaveBeenCalledWith('red', true, undefined); - expect(registerColorSpy).toHaveBeenCalledWith('green', true, undefined); + expect(registerColorSpy).toHaveBeenCalledWith('red', true); + expect(registerColorSpy).toHaveBeenCalledWith('green', true); }); it('Has var-based color, light to dark', () => { @@ -470,7 +470,7 @@ describe('DarkColorHandlerImpl.transformElementColor', () => { expect(parseColorSpy).toHaveBeenCalledWith('red'); expect(parseColorSpy).toHaveBeenCalledWith(null, false); expect(registerColorSpy).toHaveBeenCalledTimes(1); - expect(registerColorSpy).toHaveBeenCalledWith('red', true, undefined); + expect(registerColorSpy).toHaveBeenCalledWith('red', true); }); it('No color, dark to light', () => { @@ -539,6 +539,6 @@ describe('DarkColorHandlerImpl.transformElementColor', () => { expect(parseColorSpy).toHaveBeenCalledWith('red'); expect(parseColorSpy).toHaveBeenCalledWith(null, true); expect(registerColorSpy).toHaveBeenCalledTimes(1); - expect(registerColorSpy).toHaveBeenCalledWith('red', false, undefined); + expect(registerColorSpy).toHaveBeenCalledWith('red', false); }); }); diff --git a/packages-content-model/roosterjs-content-model-core/test/editor/StandaloneEditorTest.ts b/packages-content-model/roosterjs-content-model-core/test/editor/StandaloneEditorTest.ts new file mode 100644 index 00000000000..9c7fd7a93e9 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/test/editor/StandaloneEditorTest.ts @@ -0,0 +1,732 @@ +import * as createStandaloneEditorCore from '../../lib/editor/createStandaloneEditorCore'; +import * as transformColor from '../../lib/publicApi/color/transformColor'; +import { ChangeSource } from '../../lib/constants/ChangeSource'; +import { PluginEventType } from 'roosterjs-editor-types'; +import { StandaloneEditor } from '../../lib/editor/StandaloneEditor'; + +describe('StandaloneEditor', () => { + let createEditorCoreSpy: jasmine.Spy; + + beforeEach(() => { + createEditorCoreSpy = spyOn( + createStandaloneEditorCore, + 'createStandaloneEditorCore' + ).and.callThrough(); + }); + + it('ctor and dispose, no options', () => { + const div = document.createElement('div'); + const editor = new StandaloneEditor(div); + + expect(createEditorCoreSpy).toHaveBeenCalledWith(div, {}); + expect(editor.isDisposed()).toBeFalse(); + expect(editor.getDocument()).toBe(document); + expect(editor.isDarkMode()).toBeFalse(); + expect(editor.isInIME()).toBeFalse(); + expect(editor.isInShadowEdit()).toBeFalse(); + + editor.dispose(); + + expect(editor.isDisposed()).toBeTrue(); + expect(() => { + editor.focus(); + }).toThrow(); + }); + + it('ctor and dispose, with options', () => { + const div = document.createElement('div'); + const initSpy1 = jasmine.createSpy('init1'); + const initSpy2 = jasmine.createSpy('init2'); + const disposeSpy1 = jasmine.createSpy('dispose1'); + const disposeSpy2 = jasmine.createSpy('dispose2').and.throwError('test'); + const mockedPlugin1 = { + initialize: initSpy1, + dispose: disposeSpy1, + } as any; + const mockedPlugin2 = { + initialize: initSpy2, + dispose: disposeSpy2, + } as any; + + const disposeErrorHandlerSpy = jasmine.createSpy('disposeErrorHandler'); + const options = { + plugins: [mockedPlugin1, mockedPlugin2], + disposeErrorHandler: disposeErrorHandlerSpy, + inDarkMode: true, + }; + + const editor = new StandaloneEditor(div, options); + + expect(createEditorCoreSpy).toHaveBeenCalledWith(div, options); + expect(editor.isDisposed()).toBeFalse(); + expect(editor.getDocument()).toBe(document); + expect(editor.isDarkMode()).toBeTrue(); + expect(editor.isInIME()).toBeFalse(); + expect(editor.isInShadowEdit()).toBeFalse(); + + expect(initSpy1).toHaveBeenCalledWith(editor); + expect(initSpy2).toHaveBeenCalledWith(editor); + expect(initSpy1).toHaveBeenCalledBefore(initSpy2); + expect(disposeSpy1).not.toHaveBeenCalled(); + expect(disposeSpy2).not.toHaveBeenCalled(); + + editor.dispose(); + + expect(editor.isDisposed()).toBeTrue(); + expect(() => { + editor.focus(); + }).toThrow(); + + expect(disposeSpy1).toHaveBeenCalled(); + expect(disposeSpy2).toHaveBeenCalled(); + expect(disposeSpy2).toHaveBeenCalledBefore(disposeSpy1); + expect(disposeErrorHandlerSpy).toHaveBeenCalledWith(mockedPlugin2, new Error('test')); + }); + + it('createContentModel', () => { + const div = document.createElement('div'); + const mockedModel = 'MODEL' as any; + const createContentModelSpy = jasmine + .createSpy('createContentModel') + .and.returnValue(mockedModel); + const mockedCore = { + plugins: [], + darkColorHandler: { + reset: () => {}, + }, + api: { + createContentModel: createContentModelSpy, + }, + } as any; + + createEditorCoreSpy.and.returnValue(mockedCore); + + const editor = new StandaloneEditor(div); + + const model1 = editor.createContentModel(); + + expect(model1).toBe(mockedModel); + expect(createContentModelSpy).toHaveBeenCalledWith(mockedCore, undefined, undefined); + + const mockedOptions = 'OPTIONS' as any; + const selectionOverride = 'SELECTION' as any; + + const model2 = editor.createContentModel(mockedOptions, selectionOverride); + + expect(model2).toBe(mockedModel); + expect(createContentModelSpy).toHaveBeenCalledWith( + mockedCore, + mockedOptions, + selectionOverride + ); + + editor.dispose(); + expect(() => editor.createContentModel()).toThrow(); + }); + + it('setContentModel', () => { + const div = document.createElement('div'); + const setContentModelSpy = jasmine.createSpy('setContentModel'); + const mockedCore = { + plugins: [], + darkColorHandler: { + reset: () => {}, + }, + api: { + setContentModel: setContentModelSpy, + }, + } as any; + + createEditorCoreSpy.and.returnValue(mockedCore); + + const mockedModel = 'MODEL' as any; + const editor = new StandaloneEditor(div); + + editor.setContentModel(mockedModel); + + expect(setContentModelSpy).toHaveBeenCalledWith( + mockedCore, + mockedModel, + undefined, + undefined + ); + + const mockedOptions = 'OPTIONS' as any; + const mockedOnNodeCreated = 'ONNODECREATED' as any; + + editor.setContentModel(mockedModel, mockedOptions, mockedOnNodeCreated); + + expect(setContentModelSpy).toHaveBeenCalledWith( + mockedCore, + mockedModel, + mockedOptions, + mockedOnNodeCreated + ); + + editor.dispose(); + expect(() => editor.setContentModel(mockedModel)).toThrow(); + }); + + it('getEnvironment', () => { + const div = document.createElement('div'); + const mockedEnvironment = 'ENVIRONMENT' as any; + const mockedCore = { + plugins: [], + darkColorHandler: { + reset: () => {}, + }, + environment: mockedEnvironment, + } as any; + + createEditorCoreSpy.and.returnValue(mockedCore); + + const editor = new StandaloneEditor(div); + + const result = editor.getEnvironment(); + + expect(result).toBe(mockedEnvironment); + + editor.dispose(); + expect(() => editor.getEnvironment()).toThrow(); + }); + + it('getDOMSelection', () => { + const div = document.createElement('div'); + const mockedSelection = 'SELECTION' as any; + const getDOMSelectionSpy = jasmine + .createSpy('getDOMSelection') + .and.returnValue(mockedSelection); + const mockedCore = { + plugins: [], + darkColorHandler: { + reset: () => {}, + }, + api: { + getDOMSelection: getDOMSelectionSpy, + }, + } as any; + + createEditorCoreSpy.and.returnValue(mockedCore); + + const editor = new StandaloneEditor(div); + + const result = editor.getDOMSelection(); + + expect(result).toBe(mockedSelection); + expect(getDOMSelectionSpy).toHaveBeenCalledWith(mockedCore); + + editor.dispose(); + expect(() => editor.getDOMSelection()).toThrow(); + }); + + it('setDOMSelection', () => { + const div = document.createElement('div'); + const mockedSelection = 'SELECTION' as any; + const setDOMSelectionSpy = jasmine.createSpy('setDOMSelection'); + const mockedCore = { + plugins: [], + darkColorHandler: { + reset: () => {}, + }, + api: { + setDOMSelection: setDOMSelectionSpy, + }, + } as any; + + createEditorCoreSpy.and.returnValue(mockedCore); + + const editor = new StandaloneEditor(div); + + editor.setDOMSelection(null); + + expect(setDOMSelectionSpy).toHaveBeenCalledWith(mockedCore, null); + + editor.setDOMSelection(mockedSelection); + + expect(setDOMSelectionSpy).toHaveBeenCalledWith(mockedCore, mockedSelection); + + editor.dispose(); + expect(() => editor.setDOMSelection(null)).toThrow(); + }); + + it('formatContentModel', () => { + const div = document.createElement('div'); + const mockedFormatter = 'FORMATTER' as any; + const mockedOptions = 'OPTIONS' as any; + const formatContentModelSpy = jasmine.createSpy('formatContentModel'); + const mockedCore = { + plugins: [], + darkColorHandler: { + reset: () => {}, + }, + api: { + formatContentModel: formatContentModelSpy, + }, + } as any; + + createEditorCoreSpy.and.returnValue(mockedCore); + + const editor = new StandaloneEditor(div); + + editor.formatContentModel(mockedFormatter); + + expect(formatContentModelSpy).toHaveBeenCalledWith(mockedCore, mockedFormatter, undefined); + + editor.formatContentModel(mockedFormatter, mockedOptions); + + expect(formatContentModelSpy).toHaveBeenCalledWith( + mockedCore, + mockedFormatter, + mockedOptions + ); + + editor.dispose(); + expect(() => editor.formatContentModel(mockedFormatter)).toThrow(); + }); + + it('getPendingFormat', () => { + const div = document.createElement('div'); + const mockedFormat = 'FORMAT' as any; + const mockedCore = { + plugins: [], + darkColorHandler: { + reset: () => {}, + }, + format: {}, + } as any; + + createEditorCoreSpy.and.returnValue(mockedCore); + + const editor = new StandaloneEditor(div); + + const result1 = editor.getPendingFormat(); + + expect(result1).toBeNull(); + + mockedCore.format.pendingFormat = { + format: mockedFormat, + }; + const result2 = editor.getPendingFormat(); + + expect(result2).toBe(mockedFormat); + + editor.dispose(); + expect(() => editor.getPendingFormat()).toThrow(); + }); + + it('formatContentModel', () => { + const div = document.createElement('div'); + const addUndoSnapshotSpy = jasmine.createSpy('addUndoSnapshot'); + const mockedCore = { + plugins: [], + darkColorHandler: { + reset: () => {}, + }, + api: { + addUndoSnapshot: addUndoSnapshotSpy, + }, + } as any; + + createEditorCoreSpy.and.returnValue(mockedCore); + + const editor = new StandaloneEditor(div); + + editor.takeSnapshot(); + + expect(addUndoSnapshotSpy).toHaveBeenCalledWith(mockedCore, false); + + editor.dispose(); + expect(() => editor.takeSnapshot()).toThrow(); + }); + + it('restoreSnapshot', () => { + const div = document.createElement('div'); + const mockedSnapshot = 'SNAPSHOT' as any; + const restoreUndoSnapshotSpy = jasmine.createSpy('restoreUndoSnapshot'); + const mockedCore = { + plugins: [], + darkColorHandler: { + reset: () => {}, + }, + api: { + restoreUndoSnapshot: restoreUndoSnapshotSpy, + }, + } as any; + + createEditorCoreSpy.and.returnValue(mockedCore); + + const editor = new StandaloneEditor(div); + + editor.restoreSnapshot(mockedSnapshot); + + expect(restoreUndoSnapshotSpy).toHaveBeenCalledWith(mockedCore, mockedSnapshot); + + editor.dispose(); + expect(() => editor.restoreSnapshot(mockedSnapshot)).toThrow(); + }); + + it('focus', () => { + const div = document.createElement('div'); + const focusSpy = jasmine.createSpy('focus'); + const mockedCore = { + plugins: [], + darkColorHandler: { + reset: () => {}, + }, + api: { + focus: focusSpy, + }, + } as any; + + createEditorCoreSpy.and.returnValue(mockedCore); + + const editor = new StandaloneEditor(div); + + editor.focus(); + expect(focusSpy).toHaveBeenCalledWith(mockedCore); + + editor.dispose(); + + expect(() => editor.focus()).toThrow(); + }); + + it('hasFocus', () => { + const div = document.createElement('div'); + const mockedResult = 'RESULT' as any; + const hasFocusSpy = jasmine.createSpy('hasFocus').and.returnValue(mockedResult); + const mockedCore = { + plugins: [], + darkColorHandler: { + reset: () => {}, + }, + api: { + hasFocus: hasFocusSpy, + }, + } as any; + + createEditorCoreSpy.and.returnValue(mockedCore); + + const editor = new StandaloneEditor(div); + + const result = editor.hasFocus(); + + expect(result).toBe(mockedResult); + expect(hasFocusSpy).toHaveBeenCalledWith(mockedCore); + + editor.dispose(); + + expect(() => editor.hasFocus()).toThrow(); + }); + + it('triggerPluginEvent', () => { + const div = document.createElement('div'); + const mockedEventData = { + event: 'Mocked', + } as any; + const triggerEventSpy = jasmine.createSpy('triggerEvent').and.callFake((core, data) => { + data.a = 'b'; + }); + const mockedCore = { + plugins: [], + darkColorHandler: { + reset: () => {}, + }, + api: { + triggerEvent: triggerEventSpy, + }, + } as any; + + createEditorCoreSpy.and.returnValue(mockedCore); + + const editor = new StandaloneEditor(div); + const mockedEventType = 'EVENTTYPE' as any; + + const result = editor.triggerPluginEvent(mockedEventType, mockedEventData, true); + + expect(result).toEqual({ + eventType: mockedEventType, + event: 'Mocked', + a: 'b', + } as any); + expect(triggerEventSpy).toHaveBeenCalledWith( + mockedCore, + { + eventType: mockedEventType, + event: 'Mocked', + a: 'b', + } as any, + true + ); + + editor.dispose(); + + expect(() => editor.triggerPluginEvent(mockedEventType, mockedEventData, true)).toThrow(); + }); + + it('attachDomEvent', () => { + const div = document.createElement('div'); + const mockedDisposer = 'DISPOSER' as any; + const attachDomEventSpy = jasmine + .createSpy('attachDomEvent') + .and.returnValue(mockedDisposer); + const mockedCore = { + plugins: [], + darkColorHandler: { + reset: () => {}, + }, + api: { + attachDomEvent: attachDomEventSpy, + }, + } as any; + + createEditorCoreSpy.and.returnValue(mockedCore); + + const mockedEventMap = 'EVENTMAP' as any; + const editor = new StandaloneEditor(div); + + const result = editor.attachDomEvent(mockedEventMap); + + expect(result).toBe(mockedDisposer); + expect(attachDomEventSpy).toHaveBeenCalledWith(mockedCore, mockedEventMap); + + editor.dispose(); + + expect(() => editor.attachDomEvent(mockedEventMap)).toThrow(); + }); + + it('getSnapshotsManager', () => { + const div = document.createElement('div'); + const mockedSnapshotManager = 'MANAGER' as any; + const mockedCore = { + plugins: [], + darkColorHandler: { + reset: () => {}, + }, + undo: { + snapshotsManager: mockedSnapshotManager, + }, + } as any; + + createEditorCoreSpy.and.returnValue(mockedCore); + + const editor = new StandaloneEditor(div); + + const result = editor.getSnapshotsManager(); + + expect(result).toBe(mockedSnapshotManager); + + editor.dispose(); + + expect(() => editor.getSnapshotsManager()).toThrow(); + }); + + it('shadow edit', () => { + const div = document.createElement('div'); + const switchShadowEditSpy = jasmine + .createSpy('switchShadowEdit') + .and.callFake((core, isOn) => { + mockedCore.lifecycle.shadowEditFragment = isOn; + }); + const mockedCore = { + plugins: [], + darkColorHandler: { + reset: () => {}, + }, + lifecycle: {}, + api: { + switchShadowEdit: switchShadowEditSpy, + }, + } as any; + + createEditorCoreSpy.and.returnValue(mockedCore); + + const editor = new StandaloneEditor(div); + + expect(editor.isInShadowEdit()).toBeFalse(); + + editor.startShadowEdit(); + + expect(editor.isInShadowEdit()).toBeTrue(); + expect(switchShadowEditSpy).toHaveBeenCalledTimes(1); + expect(switchShadowEditSpy).toHaveBeenCalledWith(mockedCore, true); + + editor.stopShadowEdit(); + + expect(editor.isInShadowEdit()).toBeFalse(); + expect(switchShadowEditSpy).toHaveBeenCalledTimes(2); + expect(switchShadowEditSpy).toHaveBeenCalledWith(mockedCore, false); + + editor.dispose(); + + expect(() => editor.isInShadowEdit()).toThrow(); + expect(() => editor.startShadowEdit()).toThrow(); + expect(() => editor.stopShadowEdit()).toThrow(); + }); + + it('pasteFromClipboard', () => { + const div = document.createElement('div'); + const pasteSpy = jasmine.createSpy('paste'); + const mockedCore = { + plugins: [], + darkColorHandler: { + reset: () => {}, + }, + api: { + paste: pasteSpy, + }, + } as any; + + createEditorCoreSpy.and.returnValue(mockedCore); + + const mockedClipboardData = 'ClipboardData' as any; + const mockedPasteType = 'PASTETYPE' as any; + + const editor = new StandaloneEditor(div); + + editor.pasteFromClipboard(mockedClipboardData); + + expect(pasteSpy).toHaveBeenCalledWith(mockedCore, mockedClipboardData, 'normal'); + + editor.pasteFromClipboard(mockedClipboardData, mockedPasteType); + + expect(pasteSpy).toHaveBeenCalledWith(mockedCore, mockedClipboardData, mockedPasteType); + + editor.dispose(); + + expect(() => editor.pasteFromClipboard(mockedClipboardData)).toThrow(); + }); + + it('getDarkColorHandler', () => { + const div = document.createElement('div'); + const resetSpy = jasmine.createSpy('reset'); + const mockedColorHandler = { + reset: resetSpy, + } as any; + const mockedCore = { + plugins: [], + darkColorHandler: mockedColorHandler, + } as any; + + createEditorCoreSpy.and.returnValue(mockedCore); + + const editor = new StandaloneEditor(div); + + const result = editor.getDarkColorHandler(); + + expect(resetSpy).not.toHaveBeenCalled(); + expect(result).toBe(mockedColorHandler); + + editor.dispose(); + + expect(resetSpy).toHaveBeenCalledWith(); + expect(() => editor.getDarkColorHandler()).toThrow(); + }); + + it('isNodeInEditor', () => { + const mockedResult = 'RESULT' as any; + const containsSpy = jasmine.createSpy('contains').and.returnValue(mockedResult); + const div = { + contains: containsSpy, + } as any; + const mockedCore = { + plugins: [], + darkColorHandler: { + reset: () => {}, + }, + contentDiv: div, + } as any; + + createEditorCoreSpy.and.returnValue(mockedCore); + + const editor = new StandaloneEditor(div); + const mockedNode = 'NODE' as any; + + const result = editor.isNodeInEditor(mockedNode); + + expect(result).toBe(mockedResult); + expect(containsSpy).toHaveBeenCalledWith(mockedNode); + + editor.dispose(); + + expect(() => editor.isNodeInEditor(mockedNode)).toThrow(); + }); + + it('dark mode', () => { + const transformColorSpy = spyOn(transformColor, 'transformColor'); + const triggerEventSpy = jasmine.createSpy('triggerEvent').and.callFake((core, event) => { + mockedCore.lifecycle.isDarkMode = event.source == ChangeSource.SwitchToDarkMode; + }); + const div = document.createElement('div'); + const mockedColorHandler = { + reset: () => {}, + } as any; + const mockedCore = { + plugins: [], + darkColorHandler: mockedColorHandler, + contentDiv: div, + lifecycle: { + isDarkMode: false, + }, + api: { + triggerEvent: triggerEventSpy, + }, + } as any; + + createEditorCoreSpy.and.returnValue(mockedCore); + + const editor = new StandaloneEditor(div); + + expect(editor.isDarkMode()).toBeFalse(); + + editor.setDarkModeState(false); + + expect(editor.isDarkMode()).toBeFalse(); + expect(transformColorSpy).not.toHaveBeenCalled(); + expect(triggerEventSpy).not.toHaveBeenCalled(); + + editor.setDarkModeState(true); + + expect(editor.isDarkMode()).toBeTrue(); + expect(transformColorSpy).toHaveBeenCalledTimes(1); + expect(transformColorSpy).toHaveBeenCalledWith( + div, + true, + 'lightToDark', + mockedColorHandler + ); + expect(triggerEventSpy).toHaveBeenCalledTimes(1); + expect(triggerEventSpy).toHaveBeenCalledWith( + mockedCore, + { + eventType: PluginEventType.ContentChanged, + source: ChangeSource.SwitchToDarkMode, + }, + true + ); + + editor.setDarkModeState(false); + + expect(editor.isDarkMode()).toBeFalse(); + expect(transformColorSpy).toHaveBeenCalledTimes(2); + expect(transformColorSpy).toHaveBeenCalledWith( + div, + true, + 'darkToLight', + mockedColorHandler + ); + expect(triggerEventSpy).toHaveBeenCalledTimes(2); + expect(triggerEventSpy).toHaveBeenCalledWith( + mockedCore, + { + eventType: PluginEventType.ContentChanged, + source: ChangeSource.SwitchToLightMode, + }, + true + ); + + editor.dispose(); + + expect(() => editor.isDarkMode()).toThrow(); + expect(() => editor.setDarkModeState()).toThrow(); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-core/test/editor/createStandaloneEditorCoreTest.ts b/packages-content-model/roosterjs-content-model-core/test/editor/createStandaloneEditorCoreTest.ts new file mode 100644 index 00000000000..fcdb91dc7e6 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/test/editor/createStandaloneEditorCoreTest.ts @@ -0,0 +1,351 @@ +import * as createDefaultSettings from '../../lib/editor/createStandaloneEditorDefaultSettings'; +import * as createStandaloneEditorCorePlugins from '../../lib/corePlugin/createStandaloneEditorCorePlugins'; +import * as DarkColorHandlerImpl from '../../lib/editor/DarkColorHandlerImpl'; +import { + createStandaloneEditorCore, + defaultTrustHtmlHandler, + getDarkColorFallback, +} from '../../lib/editor/createStandaloneEditorCore'; +import { standaloneCoreApiMap } from '../../lib/editor/standaloneCoreApiMap'; +import { StandaloneEditorCore, StandaloneEditorOptions } from 'roosterjs-content-model-types'; + +describe('createEditorCore', () => { + function createMockedPlugin(stateName: string): any { + return { + getState: () => stateName, + }; + } + + const mockedCachePlugin = createMockedPlugin('cache'); + const mockedFormatPlugin = createMockedPlugin('format'); + const mockedCopyPastePlugin = createMockedPlugin('copyPaste'); + const mockedDomEventPlugin = createMockedPlugin('domEvent'); + const mockedLifeCyclePlugin = createMockedPlugin('lifecycle'); + const mockedEntityPlugin = createMockedPlugin('entity'); + const mockedSelectionPlugin = createMockedPlugin('selection'); + const mockedUndoPlugin = createMockedPlugin('undo'); + const mockedPlugins = { + cache: mockedCachePlugin, + format: mockedFormatPlugin, + copyPaste: mockedCopyPastePlugin, + domEvent: mockedDomEventPlugin, + lifecycle: mockedLifeCyclePlugin, + entity: mockedEntityPlugin, + selection: mockedSelectionPlugin, + undo: mockedUndoPlugin, + }; + const mockedDarkColorHandler = 'DARKCOLOR' as any; + const mockedDomToModelSettings = 'DOMTOMODEL' as any; + const mockedModelToDomSettings = 'MODELTODOM' as any; + + beforeEach(() => { + spyOn( + createStandaloneEditorCorePlugins, + 'createStandaloneEditorCorePlugins' + ).and.returnValue(mockedPlugins); + spyOn(DarkColorHandlerImpl, 'createDarkColorHandler').and.returnValue( + mockedDarkColorHandler + ); + spyOn(createDefaultSettings, 'createDomToModelSettings').and.returnValue( + mockedDomToModelSettings + ); + spyOn(createDefaultSettings, 'createModelToDomSettings').and.returnValue( + mockedModelToDomSettings + ); + }); + + function runTest( + contentDiv: HTMLDivElement, + options: StandaloneEditorOptions, + additionalResult: Partial + ) { + const core = createStandaloneEditorCore(contentDiv, options); + + expect(core).toEqual({ + contentDiv: contentDiv, + api: standaloneCoreApiMap, + originalApi: standaloneCoreApiMap, + plugins: [ + mockedCachePlugin, + mockedFormatPlugin, + mockedCopyPastePlugin, + mockedDomEventPlugin, + mockedSelectionPlugin, + mockedEntityPlugin, + mockedUndoPlugin, + mockedLifeCyclePlugin, + ], + environment: { + isMac: false, + isAndroid: false, + isSafari: false, + }, + darkColorHandler: mockedDarkColorHandler, + trustedHTMLHandler: defaultTrustHtmlHandler, + domToModelSettings: mockedDomToModelSettings, + modelToDomSettings: mockedModelToDomSettings, + cache: 'cache' as any, + format: 'format' as any, + copyPaste: 'copyPaste' as any, + domEvent: 'domEvent' as any, + lifecycle: 'lifecycle' as any, + entity: 'entity' as any, + selection: 'selection' as any, + undo: 'undo' as any, + disposeErrorHandler: undefined, + zoomScale: 1, + ...additionalResult, + }); + + expect( + createStandaloneEditorCorePlugins.createStandaloneEditorCorePlugins + ).toHaveBeenCalledWith(options, contentDiv); + expect(createDefaultSettings.createDomToModelSettings).toHaveBeenCalledWith(options); + expect(createDefaultSettings.createModelToDomSettings).toHaveBeenCalledWith(options); + } + + it('No options', () => { + const mockedDiv = { + ownerDocument: {}, + attributes: { + a: 'b', + }, + } as any; + runTest( + mockedDiv, + { + name: 'Options', + } as any, + {} + ); + + expect(DarkColorHandlerImpl.createDarkColorHandler).toHaveBeenCalledWith( + mockedDiv, + getDarkColorFallback + ); + }); + + it('With options', () => { + const mockedDiv = { + ownerDocument: {}, + attributes: { + a: 'b', + }, + } as any; + const mockedPlugin1 = 'P1' as any; + const mockedPlugin2 = 'P2' as any; + const mockedGetDarkColor = 'DARK' as any; + const mockedTrustHtmlHandler = 'TRUST' as any; + const mockedDisposeErrorHandler = 'DISPOSE' as any; + const mockedOptions = { + coreApiOverride: { + a: 'b', + }, + plugins: [mockedPlugin1, null, mockedPlugin2], + getDarkColor: mockedGetDarkColor, + trustedHTMLHandler: mockedTrustHtmlHandler, + disposeErrorHandler: mockedDisposeErrorHandler, + zoomScale: 2, + } as any; + + runTest(mockedDiv, mockedOptions, { + contentDiv: mockedDiv, + api: { ...standaloneCoreApiMap, a: 'b' } as any, + plugins: [ + mockedCachePlugin, + mockedFormatPlugin, + mockedCopyPastePlugin, + mockedDomEventPlugin, + mockedSelectionPlugin, + mockedEntityPlugin, + mockedPlugin1, + mockedPlugin2, + mockedUndoPlugin, + mockedLifeCyclePlugin, + ], + darkColorHandler: mockedDarkColorHandler, + trustedHTMLHandler: mockedTrustHtmlHandler, + disposeErrorHandler: mockedDisposeErrorHandler, + zoomScale: 2, + }); + + expect(DarkColorHandlerImpl.createDarkColorHandler).toHaveBeenCalledWith( + mockedDiv, + mockedGetDarkColor + ); + }); + + it('Invalid zoom scale', () => { + const mockedDiv = { + ownerDocument: {}, + attributes: { + a: 'b', + }, + } as any; + const mockedOptions = { + zoomScale: -1, + } as any; + + runTest(mockedDiv, mockedOptions, {}); + + expect(DarkColorHandlerImpl.createDarkColorHandler).toHaveBeenCalledWith( + mockedDiv, + getDarkColorFallback + ); + }); + + it('Android', () => { + const mockedDiv = { + ownerDocument: { + defaultView: { + navigator: { + userAgent: 'Android', + }, + }, + }, + attributes: { + a: 'b', + }, + } as any; + const mockedOptions = { + zoomScale: -1, + } as any; + + runTest(mockedDiv, mockedOptions, { + environment: { + isMac: false, + isAndroid: true, + isSafari: false, + }, + }); + + expect(DarkColorHandlerImpl.createDarkColorHandler).toHaveBeenCalledWith( + mockedDiv, + getDarkColorFallback + ); + }); + + it('Android+Safari', () => { + const mockedDiv = { + ownerDocument: { + defaultView: { + navigator: { + userAgent: 'Android Safari', + }, + }, + }, + attributes: { + a: 'b', + }, + } as any; + const mockedOptions = { + zoomScale: -1, + } as any; + + runTest(mockedDiv, mockedOptions, { + environment: { + isMac: false, + isAndroid: true, + isSafari: false, + }, + }); + + expect(DarkColorHandlerImpl.createDarkColorHandler).toHaveBeenCalledWith( + mockedDiv, + getDarkColorFallback + ); + }); + + it('Mac', () => { + const mockedDiv = { + ownerDocument: { + defaultView: { + navigator: { + appVersion: 'Mac', + }, + }, + }, + attributes: { + a: 'b', + }, + } as any; + const mockedOptions = { + zoomScale: -1, + } as any; + + runTest(mockedDiv, mockedOptions, { + environment: { + isMac: true, + isAndroid: false, + isSafari: false, + }, + }); + + expect(DarkColorHandlerImpl.createDarkColorHandler).toHaveBeenCalledWith( + mockedDiv, + getDarkColorFallback + ); + }); + + it('Safari', () => { + const mockedDiv = { + ownerDocument: { + defaultView: { + navigator: { + userAgent: 'Safari', + }, + }, + }, + attributes: { + a: 'b', + }, + } as any; + const mockedOptions = { + zoomScale: -1, + } as any; + + runTest(mockedDiv, mockedOptions, { + environment: { + isMac: false, + isAndroid: false, + isSafari: true, + }, + }); + + expect(DarkColorHandlerImpl.createDarkColorHandler).toHaveBeenCalledWith( + mockedDiv, + getDarkColorFallback + ); + }); + + it('Chrome', () => { + const mockedDiv = { + ownerDocument: { + defaultView: { + navigator: { + userAgent: 'Safari Chrome', + }, + }, + }, + attributes: { + a: 'b', + }, + } as any; + const mockedOptions = { + zoomScale: -1, + } as any; + + runTest(mockedDiv, mockedOptions, { + environment: { + isMac: false, + isAndroid: false, + isSafari: false, + }, + }); + + expect(DarkColorHandlerImpl.createDarkColorHandler).toHaveBeenCalledWith( + mockedDiv, + getDarkColorFallback + ); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-core/test/editor/createStandaloneEditorDefaultSettingsTest.ts b/packages-content-model/roosterjs-content-model-core/test/editor/createStandaloneEditorDefaultSettingsTest.ts new file mode 100644 index 00000000000..fad2b2eb099 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/test/editor/createStandaloneEditorDefaultSettingsTest.ts @@ -0,0 +1,129 @@ +import * as createDomToModelContext from 'roosterjs-content-model-dom/lib/domToModel/context/createDomToModelContext'; +import * as createModelToDomContext from 'roosterjs-content-model-dom/lib/modelToDom/context/createModelToDomContext'; +import { tablePreProcessor } from '../../lib/override/tablePreProcessor'; +import { + listItemMetadataApplier, + listLevelMetadataApplier, +} from '../../lib/metadata/updateListMetadata'; +import { + createDomToModelSettings, + createModelToDomSettings, +} from '../../lib/editor/createStandaloneEditorDefaultSettings'; + +describe('createDomToModelSettings', () => { + const mockedCalculatedConfig = 'CONFIG' as any; + + beforeEach(() => { + spyOn(createDomToModelContext, 'createDomToModelConfig').and.returnValue( + mockedCalculatedConfig + ); + }); + + it('No options', () => { + const settings = createDomToModelSettings({}); + + expect(settings).toEqual({ + builtIn: { + processorOverride: { + table: tablePreProcessor, + }, + }, + customized: {}, + calculated: mockedCalculatedConfig, + }); + expect(createDomToModelContext.createDomToModelConfig).toHaveBeenCalledWith([ + { + processorOverride: { + table: tablePreProcessor, + }, + }, + {}, + ]); + }); + + it('Has options', () => { + const defaultDomToModelOptions = 'MockedOptions' as any; + const settings = createDomToModelSettings({ + defaultDomToModelOptions: defaultDomToModelOptions, + }); + + expect(settings).toEqual({ + builtIn: { + processorOverride: { + table: tablePreProcessor, + }, + }, + customized: defaultDomToModelOptions, + calculated: mockedCalculatedConfig, + }); + expect(createDomToModelContext.createDomToModelConfig).toHaveBeenCalledWith([ + { + processorOverride: { + table: tablePreProcessor, + }, + }, + defaultDomToModelOptions, + ]); + }); +}); + +describe('createModelToDomSettings', () => { + const mockedCalculatedConfig = 'CONFIG' as any; + + beforeEach(() => { + spyOn(createModelToDomContext, 'createModelToDomConfig').and.returnValue( + mockedCalculatedConfig + ); + }); + + it('No options', () => { + const settings = createModelToDomSettings({}); + + expect(settings).toEqual({ + builtIn: { + metadataAppliers: { + listItem: listItemMetadataApplier, + listLevel: listLevelMetadataApplier, + }, + }, + customized: {}, + calculated: mockedCalculatedConfig, + }); + expect(createModelToDomContext.createModelToDomConfig).toHaveBeenCalledWith([ + { + metadataAppliers: { + listItem: listItemMetadataApplier, + listLevel: listLevelMetadataApplier, + }, + }, + {}, + ]); + }); + + it('Has options', () => { + const defaultModelToDomOptions = 'MockedOptions' as any; + const settings = createModelToDomSettings({ + defaultModelToDomOptions: defaultModelToDomOptions, + }); + + expect(settings).toEqual({ + builtIn: { + metadataAppliers: { + listItem: listItemMetadataApplier, + listLevel: listLevelMetadataApplier, + }, + }, + customized: defaultModelToDomOptions, + calculated: mockedCalculatedConfig, + }); + expect(createModelToDomContext.createModelToDomConfig).toHaveBeenCalledWith([ + { + metadataAppliers: { + listItem: listItemMetadataApplier, + listLevel: listLevelMetadataApplier, + }, + }, + defaultModelToDomOptions, + ]); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-core/test/overrides/containerWidthFormatParserTest.ts b/packages-content-model/roosterjs-content-model-core/test/overrides/containerWidthFormatParserTest.ts new file mode 100644 index 00000000000..14e5e65c9d4 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/test/overrides/containerWidthFormatParserTest.ts @@ -0,0 +1,37 @@ +import { containerWidthFormatParser } from '../../lib/override/containerWidthFormatParser'; +import { SizeFormat } from 'roosterjs-content-model-types'; + +describe('containerWidthFormatParser', () => { + it('DIV without width', () => { + const div = document.createElement('div'); + const format: SizeFormat = {}; + + containerWidthFormatParser(format, div, null!, {}); + + expect(format).toEqual({}); + }); + + it('DIV with width', () => { + const div = document.createElement('div'); + const format: SizeFormat = { + width: '10px', + }; + + containerWidthFormatParser(format, div, null!, {}); + + expect(format).toEqual({}); + }); + + it('SPAN with width', () => { + const div = document.createElement('span'); + const format: SizeFormat = { + width: '10px', + }; + + containerWidthFormatParser(format, div, null!, {}); + + expect(format).toEqual({ + width: '10px', + }); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-core/test/overrides/pasteDisplayFormatParserTest.ts b/packages-content-model/roosterjs-content-model-core/test/overrides/pasteDisplayFormatParserTest.ts new file mode 100644 index 00000000000..6fc53b53f57 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/test/overrides/pasteDisplayFormatParserTest.ts @@ -0,0 +1,57 @@ +import { DisplayFormat } from 'roosterjs-content-model-types'; +import { pasteDisplayFormatParser } from '../../lib/override/pasteDisplayFormatParser'; + +describe('pasteDisplayFormatParser', () => { + it('no display', () => { + const div = document.createElement('div'); + const format: DisplayFormat = {}; + const context: any = {}; + + pasteDisplayFormatParser(format, div, context, {}); + + expect(format).toEqual({}); + }); + + it('display: block', () => { + const div = document.createElement('div'); + + div.style.display = 'block'; + + const format: DisplayFormat = {}; + const context: any = {}; + + pasteDisplayFormatParser(format, div, context, {}); + + expect(format).toEqual({ + display: 'block', + }); + }); + + it('display: inline', () => { + const div = document.createElement('div'); + + div.style.display = 'inline'; + + const format: DisplayFormat = {}; + const context: any = {}; + + pasteDisplayFormatParser(format, div, context, {}); + + expect(format).toEqual({ + display: 'inline', + }); + }); + + it('display: flex', () => { + const div = document.createElement('div'); + + div.style.display = 'flex'; + + const format: DisplayFormat = {}; + const context: any = {}; + + pasteDisplayFormatParser(format, div, context, {}); + + expect(format).toEqual({}); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-core/test/overrides/pasteEntityProcessorTest.ts b/packages-content-model/roosterjs-content-model-core/test/overrides/pasteEntityProcessorTest.ts new file mode 100644 index 00000000000..e79751910f3 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/test/overrides/pasteEntityProcessorTest.ts @@ -0,0 +1,91 @@ +import * as sanitizeElement from '../../lib/utils/sanitizeElement'; +import { ContentModelDocument, DomToModelContext } from 'roosterjs-content-model-types'; +import { createContentModelDocument } from 'roosterjs-content-model-dom'; +import { createPasteEntityProcessor } from '../../lib/override/pasteEntityProcessor'; + +describe('pasteEntityProcessor', () => { + let sanitizeElementSpy: jasmine.Spy; + let entityProcessorSpy: jasmine.Spy; + let context: DomToModelContext; + let sanitizedElement: HTMLElement | undefined; + + beforeEach(() => { + sanitizeElementSpy = spyOn(sanitizeElement, 'sanitizeElement'); + entityProcessorSpy = jasmine + .createSpy('entityProcessor') + .and.callFake((_: ContentModelDocument, element: HTMLElement) => { + sanitizedElement = element; + }); + + context = { + defaultElementProcessors: { + entity: entityProcessorSpy, + }, + } as any; + + sanitizedElement = undefined; + }); + + it('Empty element', () => { + const element = document.createElement('div'); + const group = createContentModelDocument(); + const pasteEntityProcessor = createPasteEntityProcessor({ + additionalAllowedTags: [], + additionalDisallowedTags: [], + styleSanitizers: {}, + attributeSanitizers: {}, + } as any); + + sanitizeElementSpy.and.returnValue(element); + + pasteEntityProcessor(group, element, context); + + expect(sanitizedElement?.outerHTML).toEqual('
'); + expect(sanitizeElementSpy).toHaveBeenCalledTimes(1); + expect(sanitizeElementSpy).toHaveBeenCalledWith( + element, + sanitizeElement.AllowedTags, + sanitizeElement.DisallowedTags, + { + position: false, + }, + {} + ); + expect(entityProcessorSpy).toHaveBeenCalledTimes(1); + expect(entityProcessorSpy).toHaveBeenCalledWith(group, sanitizedElement, context); + }); + + it('Empty element with allowed and disallowed tags', () => { + const element = document.createElement('div'); + const group = createContentModelDocument(); + const pasteEntityProcessor = createPasteEntityProcessor({ + additionalAllowedTags: ['allowed'], + additionalDisallowedTags: ['disallowed'], + styleSanitizers: { + color: true, + }, + attributeSanitizers: { + id: true, + }, + } as any); + + sanitizeElementSpy.and.returnValue(element); + + pasteEntityProcessor(group, element, context); + + expect(sanitizedElement?.outerHTML).toEqual('
'); + expect(sanitizeElementSpy).toHaveBeenCalledTimes(1); + expect(sanitizeElementSpy).toHaveBeenCalledWith( + element, + sanitizeElement.AllowedTags.concat('allowed'), + sanitizeElement.DisallowedTags.concat('disallowed'), + { + position: false, + color: true, + }, + { id: true } + ); + expect(entityProcessorSpy).toHaveBeenCalledTimes(1); + expect(entityProcessorSpy).toHaveBeenCalledWith(group, sanitizedElement, context); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-core/test/overrides/pasteGeneralProcessorTest.ts b/packages-content-model/roosterjs-content-model-core/test/overrides/pasteGeneralProcessorTest.ts new file mode 100644 index 00000000000..7053cf21809 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/test/overrides/pasteGeneralProcessorTest.ts @@ -0,0 +1,190 @@ +import * as sanitizeElement from '../../lib/utils/sanitizeElement'; +import { createContentModelDocument } from 'roosterjs-content-model-dom'; +import { DomToModelContext } from 'roosterjs-content-model-types'; +import { + createPasteGeneralProcessor, + removeDisplayFlex, +} from '../../lib/override/pasteGeneralProcessor'; + +describe('pasteGeneralProcessor', () => { + let createSanitizedElementSpy: jasmine.Spy; + let generalProcessorSpy: jasmine.Spy; + let spanProcessorSpy: jasmine.Spy; + let context: DomToModelContext; + + beforeEach(() => { + createSanitizedElementSpy = spyOn(sanitizeElement, 'createSanitizedElement'); + generalProcessorSpy = jasmine.createSpy('generalProcessor'); + spanProcessorSpy = jasmine.createSpy('spanProcessorSpy'); + + context = { + defaultElementProcessors: { + '*': generalProcessorSpy, + span: spanProcessorSpy, + }, + } as any; + }); + + it('Empty element, DIV', () => { + const element = document.createElement('div'); + const group = createContentModelDocument(); + const pasteGeneralProcessor = createPasteGeneralProcessor({ + additionalAllowedTags: [], + additionalDisallowedTags: [], + styleSanitizers: {}, + attributeSanitizers: {}, + } as any); + + createSanitizedElementSpy.and.returnValue(element); + + pasteGeneralProcessor(group, element, context); + + expect(createSanitizedElementSpy).toHaveBeenCalledTimes(1); + expect(createSanitizedElementSpy).toHaveBeenCalledWith( + document, + 'DIV', + element.attributes, + { + position: false, + display: removeDisplayFlex, + }, + {} + ); + expect(generalProcessorSpy).toHaveBeenCalledTimes(1); + expect(generalProcessorSpy).toHaveBeenCalledWith(group, element, context); + expect(spanProcessorSpy).toHaveBeenCalledTimes(0); + }); + + it('Empty element with unrecognized tag', () => { + const element = document.createElement('test'); + const group = createContentModelDocument(); + const pasteGeneralProcessor = createPasteGeneralProcessor({ + additionalAllowedTags: [], + additionalDisallowedTags: [], + } as any); + + createSanitizedElementSpy.and.returnValue(element); + + pasteGeneralProcessor(group, element, context); + + expect(createSanitizedElementSpy).toHaveBeenCalledTimes(0); + expect(generalProcessorSpy).toHaveBeenCalledTimes(0); + expect(spanProcessorSpy).toHaveBeenCalledTimes(1); + expect(spanProcessorSpy).toHaveBeenCalledWith(group, element, context); + }); + + it('Empty element with unrecognized in allowed list', () => { + const element = document.createElement('test'); + const group = createContentModelDocument(); + const pasteGeneralProcessor = createPasteGeneralProcessor({ + additionalAllowedTags: ['test'], + additionalDisallowedTags: [], + styleSanitizers: {}, + attributeSanitizers: {}, + } as any); + + createSanitizedElementSpy.and.returnValue(element); + + pasteGeneralProcessor(group, element, context); + + expect(createSanitizedElementSpy).toHaveBeenCalledTimes(1); + expect(createSanitizedElementSpy).toHaveBeenCalledWith( + document, + 'TEST', + element.attributes, + { + position: false, + display: removeDisplayFlex, + }, + {} + ); + expect(generalProcessorSpy).toHaveBeenCalledTimes(1); + expect(generalProcessorSpy).toHaveBeenCalledWith(group, element, context); + expect(spanProcessorSpy).toHaveBeenCalledTimes(0); + }); + + it('Empty element with unrecognized in disallowed list', () => { + const element = document.createElement('test'); + const group = createContentModelDocument(); + const pasteGeneralProcessor = createPasteGeneralProcessor({ + additionalAllowedTags: [], + additionalDisallowedTags: ['test'], + } as any); + + createSanitizedElementSpy.and.returnValue(element); + + pasteGeneralProcessor(group, element, context); + + expect(createSanitizedElementSpy).toHaveBeenCalledTimes(0); + expect(generalProcessorSpy).toHaveBeenCalledTimes(0); + expect(spanProcessorSpy).toHaveBeenCalledTimes(0); + }); + + it('Empty element with sanitizers', () => { + const element = document.createElement('div'); + const group = createContentModelDocument(); + const pasteGeneralProcessor = createPasteGeneralProcessor({ + additionalAllowedTags: [], + styleSanitizers: { + color: true, + }, + attributeSanitizers: { + id: true, + }, + } as any); + + createSanitizedElementSpy.and.returnValue(element); + + pasteGeneralProcessor(group, element, context); + + expect(createSanitizedElementSpy).toHaveBeenCalledTimes(1); + expect(createSanitizedElementSpy).toHaveBeenCalledWith( + document, + 'DIV', + element.attributes, + { + position: false, + display: removeDisplayFlex, + color: true, + }, + { id: true } + ); + expect(generalProcessorSpy).toHaveBeenCalledTimes(1); + expect(spanProcessorSpy).toHaveBeenCalledTimes(0); + }); + + it('Element with display:flex', () => { + const element = document.createElement('div'); + + element.style.display = 'flex'; + + const group = createContentModelDocument(); + const pasteGeneralProcessor = createPasteGeneralProcessor({ + additionalAllowedTags: [], + additionalDisallowedTags: ['test'], + styleSanitizers: {}, + attributeSanitizers: {}, + } as any); + + createSanitizedElementSpy.and.callThrough(); + + pasteGeneralProcessor(group, element, context); + + expect(createSanitizedElementSpy).toHaveBeenCalledTimes(1); + expect(createSanitizedElementSpy).toHaveBeenCalledWith( + document, + 'DIV', + element.attributes, + { + position: false, + display: removeDisplayFlex, + }, + {} + ); + expect(generalProcessorSpy).toHaveBeenCalledTimes(1); + expect((generalProcessorSpy.calls.argsFor(0)[1] as any).outerHTML).toEqual( + '
' + ); + expect(spanProcessorSpy).toHaveBeenCalledTimes(0); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-core/test/overrides/pasteTextProcessorTest.ts b/packages-content-model/roosterjs-content-model-core/test/overrides/pasteTextProcessorTest.ts new file mode 100644 index 00000000000..4ded1653556 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/test/overrides/pasteTextProcessorTest.ts @@ -0,0 +1,72 @@ +import * as isWhiteSpacePreserved from 'roosterjs-content-model-dom/lib/domUtils/isWhiteSpacePreserved'; +import { DomToModelContext } from 'roosterjs-content-model-types'; +import { pasteTextProcessor } from '../../lib/override/pasteTextProcessor'; + +describe('pasteTextProcessor', () => { + let isWhiteSpacePreservedSpy: jasmine.Spy; + let defaultProcessorSpy: jasmine.Spy; + let mockedContext: DomToModelContext; + const mockedGroup = 'GROUP' as any; + const mockedWhiteSpace = 'WHITESPACE' as any; + + beforeEach(() => { + isWhiteSpacePreservedSpy = spyOn(isWhiteSpacePreserved, 'isWhiteSpacePreserved'); + defaultProcessorSpy = jasmine.createSpy('#text'); + mockedContext = { + blockFormat: { + whiteSpace: mockedWhiteSpace, + }, + defaultElementProcessors: { + '#text': defaultProcessorSpy, + }, + } as any; + }); + + it('empty text node, isWhiteSpacePreserved=false', () => { + const text = document.createTextNode(''); + + isWhiteSpacePreservedSpy.and.returnValue(false); + + pasteTextProcessor(mockedGroup, text, mockedContext); + + expect(isWhiteSpacePreservedSpy).toHaveBeenCalledWith(mockedWhiteSpace); + expect(defaultProcessorSpy).toHaveBeenCalledWith(mockedGroup, text, mockedContext); + expect(text.nodeValue).toBe(''); + }); + + it('empty text node, isWhiteSpacePreserved=true', () => { + const text = document.createTextNode(''); + + isWhiteSpacePreservedSpy.and.returnValue(true); + + pasteTextProcessor(mockedGroup, text, mockedContext); + + expect(isWhiteSpacePreservedSpy).toHaveBeenCalledWith(mockedWhiteSpace); + expect(defaultProcessorSpy).toHaveBeenCalledWith(mockedGroup, text, mockedContext); + expect(text.nodeValue).toBe(''); + }); + + it('text node with space, isWhiteSpacePreserved=false', () => { + const text = document.createTextNode(' '); + + isWhiteSpacePreservedSpy.and.returnValue(false); + + pasteTextProcessor(mockedGroup, text, mockedContext); + + expect(isWhiteSpacePreservedSpy).toHaveBeenCalledWith(mockedWhiteSpace); + expect(defaultProcessorSpy).toHaveBeenCalledWith(mockedGroup, text, mockedContext); + expect(text.nodeValue).toBe(' '); + }); + + it('text node with space, isWhiteSpacePreserved=true', () => { + const text = document.createTextNode(' '); + + isWhiteSpacePreservedSpy.and.returnValue(true); + + pasteTextProcessor(mockedGroup, text, mockedContext); + + expect(isWhiteSpacePreservedSpy).toHaveBeenCalledWith(mockedWhiteSpace); + expect(defaultProcessorSpy).toHaveBeenCalledWith(mockedGroup, text, mockedContext); + expect(text.nodeValue).toBe(' \u00A0 \u00A0'); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-core/test/publicApi/color/transformColorTest.ts b/packages-content-model/roosterjs-content-model-core/test/publicApi/color/transformColorTest.ts index 58b87118e96..4f3eb8b8d71 100644 --- a/packages-content-model/roosterjs-content-model-core/test/publicApi/color/transformColorTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/publicApi/color/transformColorTest.ts @@ -13,7 +13,7 @@ describe('transform to dark mode', () => { element: HTMLElement, expectedHtml: string, expectedParseValueCalls: string[], - expectedRegisterColorCalls: [string, boolean, string][] + expectedRegisterColorCalls: [string, boolean][] ) { const handler = new DarkColorHandlerImpl(div, getDarkColor); @@ -54,8 +54,8 @@ describe('transform to dark mode', () => { '
', ['red', 'green'], [ - ['blue', true, undefined!], - ['yellow', true, undefined!], + ['blue', true], + ['yellow', true], ] ); }); @@ -70,8 +70,8 @@ describe('transform to dark mode', () => { '
', ['red', 'green'], [ - ['blue', true, undefined!], - ['yellow', true, undefined!], + ['blue', true], + ['yellow', true], ] ); }); @@ -88,8 +88,8 @@ describe('transform to dark mode', () => { '
', ['red', 'green'], [ - ['blue', true, undefined!], - ['yellow', true, undefined!], + ['blue', true], + ['yellow', true], ] ); }); @@ -106,7 +106,7 @@ describe('transform to light mode', () => { element: HTMLElement, expectedHtml: string, expectedParseValueCalls: string[], - expectedRegisterColorCalls: [string, boolean, string][] + expectedRegisterColorCalls: [string, boolean][] ) { const handler = new DarkColorHandlerImpl(div, getDarkColor); const parseColorValue = spyOn(handler, 'parseColorValue').and.callFake((color: string) => ({ @@ -146,8 +146,8 @@ describe('transform to light mode', () => { '
', ['red', 'green'], [ - ['blue', false, undefined!], - ['yellow', false, undefined!], + ['blue', false], + ['yellow', false], ] ); }); @@ -162,8 +162,8 @@ describe('transform to light mode', () => { '
', ['red', 'green'], [ - ['blue', false, undefined!], - ['yellow', false, undefined!], + ['blue', false], + ['yellow', false], ] ); }); @@ -180,8 +180,8 @@ describe('transform to light mode', () => { '
', ['red', 'green'], [ - ['blue', false, undefined!], - ['yellow', false, undefined!], + ['blue', false], + ['yellow', false], ] ); }); diff --git a/packages-content-model/roosterjs-content-model-core/test/utils/paste/convertInlineCssTest.ts b/packages-content-model/roosterjs-content-model-core/test/utils/paste/convertInlineCssTest.ts new file mode 100644 index 00000000000..89fba0c41c8 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/test/utils/paste/convertInlineCssTest.ts @@ -0,0 +1,123 @@ +import { convertInlineCss } from '../../../lib/utils/paste/convertInlineCss'; +import { CssRule } from '../../../lib/utils/paste/retrieveHtmlInfo'; + +describe('convertInlineCss', () => { + it('Empty DOM, empty CSS', () => { + const root = document.createElement('div'); + const cssRules: CssRule[] = []; + + convertInlineCss(root, cssRules); + + expect(root.innerHTML).toEqual(''); + }); + + it('Empty DOM, has CSS', () => { + const root = document.createElement('div'); + const cssRules: CssRule[] = [ + { + selectors: ['div'], + text: '{color:red;}', + }, + ]; + + convertInlineCss(root, cssRules); + + expect(root.innerHTML).toEqual(''); + }); + + it('Has CSS, has node match selector', () => { + const root = document.createElement('div'); + + root.innerHTML = 'test
test2
test3'; + + const cssRules: CssRule[] = [ + { + selectors: ['div'], + text: 'color:red;', + }, + ]; + + convertInlineCss(root, cssRules); + + expect(root.innerHTML).toEqual('test
test2
test3'); + }); + + it('Has CSS, has node match selector with existing CSS', () => { + const root = document.createElement('div'); + + root.innerHTML = 'test
test2
test3'; + + const cssRules: CssRule[] = [ + { + selectors: ['div'], + text: 'color:red;', + }, + ]; + + convertInlineCss(root, cssRules); + + expect(root.innerHTML).toEqual( + 'test
test2
test3' + ); + }); + + it('Has CSS, has node match selector with conflict CSS', () => { + const root = document.createElement('div'); + + root.innerHTML = 'test
test2
test3'; + + const cssRules: CssRule[] = [ + { + selectors: ['div'], + text: 'color:red;', + }, + ]; + + convertInlineCss(root, cssRules); + + expect(root.innerHTML).toEqual('test
test2
test3'); + }); + + it('Has multiple CSS, has node match selector with conflict CSS', () => { + const root = document.createElement('div'); + + root.innerHTML = + 'test
test2
test3test4'; + + const cssRules: CssRule[] = [ + { + selectors: ['div', '.test'], + text: 'color:red;', + }, + { + selectors: ['div'], + text: 'color:blue;', + }, + ]; + + convertInlineCss(root, cssRules); + + expect(root.innerHTML).toEqual( + 'test
test2
test3test4' + ); + }); + + it('Has multiple CSS with complex selector, has node match selector', () => { + const root = document.createElement('div'); + + root.innerHTML = '
test
test2'; + + const cssRules: CssRule[] = [ + { + selectors: ['#div1 span'], + text: 'color:red;', + }, + ]; + + convertInlineCss(root, cssRules); + + expect(root.innerHTML).toEqual( + '
test
test2' + ); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-core/test/utils/paste/createPasteFragmentTest.ts b/packages-content-model/roosterjs-content-model-core/test/utils/paste/createPasteFragmentTest.ts new file mode 100644 index 00000000000..0d66cd3ffa7 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/test/utils/paste/createPasteFragmentTest.ts @@ -0,0 +1,293 @@ +import * as moveChildNodes from 'roosterjs-content-model-dom/lib/domUtils/moveChildNodes'; +import { ClipboardData, PasteType } from 'roosterjs-content-model-types'; +import { createPasteFragment } from '../../../lib/utils/paste/createPasteFragment'; +import { expectHtml } from 'roosterjs-editor-dom/test/DomTestHelper'; + +describe('createPasteFragment', () => { + let moveChildNodesSpy: jasmine.Spy; + + beforeEach(() => { + moveChildNodesSpy = spyOn(moveChildNodes, 'moveChildNodes').and.callThrough(); + }); + + function runTest( + root: HTMLElement, + clipboardData: ClipboardData, + pasteType: PasteType, + expectedHtml: string | string[], + isMoveChildNodesCalled: boolean + ) { + const tempDiv = document.createElement('div'); + + const fragment = createPasteFragment(document, clipboardData, pasteType, root); + + tempDiv.appendChild(fragment); + + expectHtml(tempDiv.innerHTML, expectedHtml); + + if (isMoveChildNodesCalled) { + expect(moveChildNodesSpy).toHaveBeenCalledWith(fragment, root); + } else { + expect(moveChildNodesSpy).not.toHaveBeenCalled(); + } + } + + it('Empty source, paste image', () => { + const root = document.createElement('div'); + + root.innerHTML = 'HTML'; + runTest( + root, + { + types: [], + text: '', + rawHtml: '', + image: null, + customValues: {}, + }, + 'asImage', + 'HTML', + true + ); + }); + + it('Has url, paste image', () => { + runTest( + document.createElement('div'), + { + types: [], + text: '', + rawHtml: '', + image: null, + customValues: {}, + imageDataUri: 'test', + }, + 'asImage', + [ + '', + '', + ], + false + ); + }); + + it('Has url, paste normal, no text', () => { + runTest( + document.createElement('div'), + { + types: [], + text: '', + rawHtml: '', + image: null, + customValues: {}, + imageDataUri: 'test', + }, + 'normal', + [ + '', + '', + ], + false + ); + }); + + it('Has url, paste normal, has text', () => { + const root = document.createElement('div'); + + root.innerHTML = 'HTML'; + runTest( + root, + { + types: [], + text: 'text', + rawHtml: '', + image: null, + customValues: {}, + imageDataUri: 'test', + }, + 'normal', + 'HTML', + true + ); + }); + + it('Has url, paste plain text, no text', () => { + const root = document.createElement('div'); + + root.innerHTML = 'HTML'; + runTest( + root, + { + types: [], + text: '', + rawHtml: '', + image: null, + customValues: {}, + imageDataUri: 'test', + }, + 'asPlainText', + '', + false + ); + }); + + it('Has text, paste normal', () => { + const root = document.createElement('div'); + + root.innerHTML = 'HTML'; + runTest( + root, + { + types: [], + text: 'text', + rawHtml: '', + image: null, + customValues: {}, + imageDataUri: 'test', + }, + 'normal', + 'HTML', + true + ); + }); + + it('Has text, paste text, single line', () => { + const root = document.createElement('div'); + + root.innerHTML = 'HTML'; + runTest( + root, + { + types: [], + text: 'text', + rawHtml: '', + image: null, + customValues: {}, + imageDataUri: 'test', + }, + 'asPlainText', + 'text', + false + ); + }); + + it('Has text, paste text, 2 lines', () => { + const root = document.createElement('div'); + + root.innerHTML = 'HTML'; + runTest( + root, + { + types: [], + text: 'line1\r\nline2', + rawHtml: '', + image: null, + customValues: {}, + imageDataUri: 'test', + }, + 'asPlainText', + 'line1
line2', + false + ); + }); + + it('Has text, paste text, 3 lines', () => { + const root = document.createElement('div'); + + root.innerHTML = 'HTML'; + runTest( + root, + { + types: [], + text: 'line1\r\nline2\r\nline3', + rawHtml: '', + image: null, + customValues: {}, + imageDataUri: 'test', + }, + 'asPlainText', + 'line1
line2
line3', + false + ); + }); + + it('Has text, paste text, 4 lines', () => { + const root = document.createElement('div'); + + root.innerHTML = 'HTML'; + runTest( + root, + { + types: [], + text: 'line1\r\nline2\r\nline3\r\nline4', + rawHtml: '', + image: null, + customValues: {}, + imageDataUri: 'test', + }, + 'asPlainText', + 'line1
line2
line3
line4', + false + ); + }); + + it('Has text, paste text, 1 line, has space', () => { + const root = document.createElement('div'); + + root.innerHTML = 'HTML'; + runTest( + root, + { + types: [], + text: ' line 1 ', + rawHtml: '', + image: null, + customValues: {}, + imageDataUri: 'test', + }, + 'asPlainText', + '  line    1   ', + false + ); + }); + + it('Has text, paste text, 2 line, has tab', () => { + const root = document.createElement('div'); + + root.innerHTML = 'HTML'; + runTest( + root, + { + types: [], + text: '\tline 1\r\n line\t2', + rawHtml: '', + image: null, + customValues: {}, + imageDataUri: 'test', + }, + 'asPlainText', + '      line 1
  line      2', + false + ); + }); + + it('Has text, paste text, 2 line, has 2 tabs', () => { + const root = document.createElement('div'); + + root.innerHTML = 'HTML'; + runTest( + root, + { + types: [], + text: '1\t234\t5', + rawHtml: '', + image: null, + customValues: {}, + imageDataUri: 'test', + }, + 'asPlainText', + '1     234   5', + false + ); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-core/test/utils/paste/generatePasteOptionFromPluginsTest.ts b/packages-content-model/roosterjs-content-model-core/test/utils/paste/generatePasteOptionFromPluginsTest.ts new file mode 100644 index 00000000000..1b0c8eb33ff --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/test/utils/paste/generatePasteOptionFromPluginsTest.ts @@ -0,0 +1,269 @@ +import { generatePasteOptionFromPlugins } from '../../../lib/utils/paste/generatePasteOptionFromPlugins'; +import { PasteType, PluginEventType } from 'roosterjs-editor-types'; +import { StandaloneEditorCore } from 'roosterjs-content-model-types'; + +describe('generatePasteOptionFromPlugins', () => { + let core: StandaloneEditorCore; + let triggerPluginEventSpy: jasmine.Spy; + + const mockedClipboardData = 'CLIPBOARDDATA' as any; + const mockedFragment = 'FRAGMENT' as any; + const htmlBefore = 'HTMLBEFORE'; + const htmlAfter = 'HTMLAFTER'; + const mockedMetadata = 'METADATA' as any; + const mockedCssRule = 'CSSRULE' as any; + const mockedResult = { + fragment: 'FragmentResult', + domToModelOption: 'OptionResult', + pasteType: 'TypeResult', + } as any; + const sanitizingOption: any = { + elementCallbacks: {}, + attributeCallbacks: {}, + cssStyleCallbacks: {}, + additionalTagReplacements: {}, + additionalAllowedAttributes: [], + additionalAllowedCssClasses: [], + additionalDefaultStyleValues: {}, + additionalGlobalStyleNodes: [], + additionalPredefinedCssForElement: {}, + preserveHtmlComments: false, + unknownTagReplacement: null, + }; + + beforeEach(() => { + triggerPluginEventSpy = jasmine.createSpy('triggerPluginEvent'); + core = { + api: { + triggerEvent: triggerPluginEventSpy, + }, + } as any; + }); + + it('PasteType=Normal', () => { + let originalEvent: any; + + triggerPluginEventSpy.and.callFake((core, event) => { + originalEvent = { ...event }; + Object.assign(event, mockedResult); + }); + const result = generatePasteOptionFromPlugins( + core, + mockedClipboardData, + mockedFragment, + { + htmlAfter, + htmlBefore, + metadata: mockedMetadata, + globalCssRules: mockedCssRule, + }, + 'normal' + ); + + expect(result).toEqual({ + fragment: 'FragmentResult', + domToModelOption: 'OptionResult', + pasteType: 'TypeResult', + eventType: PluginEventType.BeforePaste, + clipboardData: mockedClipboardData, + htmlBefore, + htmlAfter, + htmlAttributes: mockedMetadata, + sanitizingOption, + } as any); + expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); + expect(originalEvent).toEqual({ + eventType: PluginEventType.BeforePaste, + clipboardData: mockedClipboardData, + fragment: mockedFragment, + htmlBefore: htmlBefore, + htmlAfter: htmlAfter, + htmlAttributes: mockedMetadata, + pasteType: PasteType.Normal, + domToModelOption: { + additionalAllowedTags: [], + additionalDisallowedTags: [], + additionalFormatParsers: {}, + formatParserOverride: {}, + processorOverride: {}, + styleSanitizers: {}, + attributeSanitizers: {}, + }, + sanitizingOption, + }); + expect(triggerPluginEventSpy).toHaveBeenCalledWith( + core, + { + eventType: PluginEventType.BeforePaste, + clipboardData: mockedClipboardData, + fragment: 'FragmentResult', + htmlBefore: htmlBefore, + htmlAfter: htmlAfter, + htmlAttributes: mockedMetadata, + pasteType: 'TypeResult', + domToModelOption: 'OptionResult', + sanitizingOption, + }, + true + ); + }); + + it('PasteType=asImage, return customizedMerge', () => { + const mockedCustomizedMerge = 'MERGE' as any; + + triggerPluginEventSpy.and.callFake((core, event) => { + event.fragment = 'FragmentResult'; + event.domToModelOption = 'OptionResult'; + event.pasteType = 'TypeResult'; + event.customizedMerge = mockedCustomizedMerge; + }); + + const result = generatePasteOptionFromPlugins( + core, + mockedClipboardData, + mockedFragment, + { + htmlAfter, + htmlBefore, + metadata: mockedMetadata, + globalCssRules: mockedCssRule, + }, + 'asImage' + ); + + expect(result).toEqual({ + fragment: 'FragmentResult', + domToModelOption: 'OptionResult', + pasteType: 'TypeResult', + customizedMerge: mockedCustomizedMerge, + eventType: PluginEventType.BeforePaste, + clipboardData: mockedClipboardData, + htmlBefore, + htmlAfter, + htmlAttributes: mockedMetadata, + sanitizingOption, + } as any); + expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); + expect(triggerPluginEventSpy).toHaveBeenCalledWith( + core, + { + eventType: PluginEventType.BeforePaste, + clipboardData: mockedClipboardData, + fragment: 'FragmentResult', + htmlBefore: htmlBefore, + htmlAfter: htmlAfter, + htmlAttributes: mockedMetadata, + pasteType: 'TypeResult', + domToModelOption: 'OptionResult', + sanitizingOption, + customizedMerge: mockedCustomizedMerge, + }, + true + ); + }); + + it('PasteType=mergeFormat, no htmlBefore and htmlAfter', () => { + let originalEvent: any; + + triggerPluginEventSpy.and.callFake((core, event) => { + originalEvent = { ...event }; + Object.assign(event, mockedResult); + }); + const result = generatePasteOptionFromPlugins( + core, + mockedClipboardData, + mockedFragment, + { + metadata: mockedMetadata, + globalCssRules: mockedCssRule, + }, + 'mergeFormat' + ); + + expect(result).toEqual({ + fragment: 'FragmentResult', + domToModelOption: 'OptionResult', + pasteType: 'TypeResult', + eventType: PluginEventType.BeforePaste, + clipboardData: mockedClipboardData, + htmlBefore: '', + htmlAfter: '', + htmlAttributes: mockedMetadata, + sanitizingOption, + } as any); + expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); + expect(triggerPluginEventSpy).toHaveBeenCalledWith( + core, + { + eventType: PluginEventType.BeforePaste, + clipboardData: mockedClipboardData, + fragment: 'FragmentResult', + htmlBefore: '', + htmlAfter: '', + htmlAttributes: mockedMetadata, + pasteType: 'TypeResult', + domToModelOption: 'OptionResult', + sanitizingOption, + }, + true + ); + expect(originalEvent).toEqual({ + eventType: PluginEventType.BeforePaste, + clipboardData: mockedClipboardData, + fragment: mockedFragment, + htmlBefore: '', + htmlAfter: '', + htmlAttributes: mockedMetadata, + pasteType: PasteType.MergeFormat, + domToModelOption: { + additionalAllowedTags: [], + additionalDisallowedTags: [], + additionalFormatParsers: {}, + formatParserOverride: {}, + processorOverride: {}, + styleSanitizers: {}, + attributeSanitizers: {}, + }, + sanitizingOption, + }); + }); + + it('PasteType=asPlainText', () => { + triggerPluginEventSpy.and.callFake((core, event) => { + Object.assign(event, mockedResult); + }); + const result = generatePasteOptionFromPlugins( + core, + mockedClipboardData, + mockedFragment, + { + htmlAfter, + htmlBefore, + metadata: mockedMetadata, + globalCssRules: mockedCssRule, + }, + 'asPlainText' + ); + + expect(result).toEqual({ + fragment: mockedFragment, + domToModelOption: { + additionalAllowedTags: [], + additionalDisallowedTags: [], + additionalFormatParsers: {}, + formatParserOverride: {}, + processorOverride: {}, + styleSanitizers: {}, + attributeSanitizers: {}, + }, + pasteType: PasteType.AsPlainText, + eventType: PluginEventType.BeforePaste, + clipboardData: mockedClipboardData, + htmlBefore, + htmlAfter, + htmlAttributes: mockedMetadata, + sanitizingOption, + }); + expect(triggerPluginEventSpy).toHaveBeenCalledTimes(0); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-core/test/utils/paste/mergePasteContentTest.ts b/packages-content-model/roosterjs-content-model-core/test/utils/paste/mergePasteContentTest.ts new file mode 100644 index 00000000000..bb4e68be828 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/test/utils/paste/mergePasteContentTest.ts @@ -0,0 +1,403 @@ +import * as createDomToModelContext from 'roosterjs-content-model-dom/lib/domToModel/context/createDomToModelContext'; +import * as createPasteEntityProcessor from '../../../lib/override/pasteEntityProcessor'; +import * as createPasteGeneralProcessor from '../../../lib/override/pasteGeneralProcessor'; +import * as domToContentModel from 'roosterjs-content-model-dom/lib/domToModel/domToContentModel'; +import * as mergeModelFile from '../../../lib/publicApi/model/mergeModel'; +import { containerWidthFormatParser } from '../../../lib/override/containerWidthFormatParser'; +import { createContentModelDocument } from 'roosterjs-content-model-dom'; +import { mergePasteContent } from '../../../lib/utils/paste/mergePasteContent'; +import { pasteDisplayFormatParser } from '../../../lib/override/pasteDisplayFormatParser'; +import { pasteTextProcessor } from '../../../lib/override/pasteTextProcessor'; +import { PasteType } from 'roosterjs-editor-types'; +import { + ContentModelDocument, + ContentModelSegmentFormat, + FormatWithContentModelContext, + InsertPoint, +} from 'roosterjs-content-model-types'; + +describe('mergePasteContent', () => { + it('merge table', () => { + // A doc with only one table in content + const pasteModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + rows: [ + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'FromPaste', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: { useBorderBox: true, borderCollapse: true }, + widths: [], + dataset: { + editingInfo: '', + }, + }, + ], + }; + + // A doc with a table, and selection marker inside of it. + const sourceModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + rows: [ + { + height: 22, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { segmentType: 'Br', format: {} }, + ], + format: {}, + isImplicit: true, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: { useBorderBox: true, borderCollapse: true }, + widths: [120, 120], + dataset: { + editingInfo: '', + }, + }, + { + blockType: 'Paragraph', + segments: [{ segmentType: 'Br', format: {} }], + format: {}, + }, + ], + format: {}, + }; + + spyOn(mergeModelFile, 'mergeModel').and.callThrough(); + spyOn(domToContentModel, 'domToContentModel').and.returnValue(pasteModel); + + const eventResult = { + pasteType: PasteType.Normal, + domToModelOption: { additionalAllowedTags: [] }, + } as any; + + const context: FormatWithContentModelContext = { + newEntities: [], + deletedEntities: [], + newImages: [], + }; + + mergePasteContent(sourceModel, context, eventResult, {}); + + expect(mergeModelFile.mergeModel).toHaveBeenCalledWith(sourceModel, pasteModel, context, { + mergeFormat: 'none', + mergeTable: true, + }); + expect(context).toEqual({ + newEntities: [], + deletedEntities: [], + newImages: [], + newPendingFormat: { + backgroundColor: '', + fontFamily: '', + fontSize: '', + fontWeight: '', + italic: false, + letterSpacing: '', + lineHeight: '', + strikethrough: false, + superOrSubScriptSequence: '', + textColor: '', + underline: false, + }, + }); + expect(sourceModel).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + rows: [ + { + height: 22, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'FromPaste', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + format: { + useBorderBox: true, + borderTop: '1px solid #ABABAB', + borderRight: '1px solid #ABABAB', + borderBottom: '1px solid #ABABAB', + borderLeft: '1px solid #ABABAB', + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: { useBorderBox: true, borderCollapse: true }, + widths: [120, 120], + dataset: { + editingInfo: + '{"topBorderColor":"#ABABAB","bottomBorderColor":"#ABABAB","verticalBorderColor":"#ABABAB","hasHeaderRow":false,"hasFirstColumn":false,"hasBandedRows":false,"hasBandedColumns":false,"bgColorEven":null,"bgColorOdd":"#ABABAB20","headerRowColor":"#ABABAB","tableBorderFormat":0,"verticalAlign":null}', + }, + }, + { + blockType: 'Paragraph', + segments: [{ segmentType: 'Br', format: {} }], + format: {}, + }, + ], + format: {}, + }); + }); + + it('customized merge', () => { + const pasteModel: ContentModelDocument = createContentModelDocument(); + const sourceModel: ContentModelDocument = createContentModelDocument(); + + const customizedMerge = jasmine.createSpy('customizedMerge'); + + spyOn(mergeModelFile, 'mergeModel').and.callThrough(); + spyOn(domToContentModel, 'domToContentModel').and.returnValue(pasteModel); + + const eventResult = { + pasteType: PasteType.Normal, + domToModelOption: { additionalAllowedTags: [] }, + customizedMerge, + } as any; + + mergePasteContent( + sourceModel, + { newEntities: [], deletedEntities: [], newImages: [] }, + eventResult, + {} + ); + + expect(mergeModelFile.mergeModel).not.toHaveBeenCalled(); + expect(customizedMerge).toHaveBeenCalledWith(sourceModel, pasteModel); + }); + + it('Apply current format', () => { + const pasteModel: ContentModelDocument = createContentModelDocument(); + const sourceModel: ContentModelDocument = createContentModelDocument(); + + spyOn(mergeModelFile, 'mergeModel').and.callThrough(); + spyOn(domToContentModel, 'domToContentModel').and.returnValue(pasteModel); + + const eventResult = { + pasteType: PasteType.MergeFormat, + domToModelOption: { additionalAllowedTags: [] }, + } as any; + + mergePasteContent( + sourceModel, + { newEntities: [], deletedEntities: [], newImages: [] }, + eventResult, + {} + ); + + expect(mergeModelFile.mergeModel).toHaveBeenCalledWith( + sourceModel, + pasteModel, + { newEntities: [], deletedEntities: [], newImages: [] }, + { + mergeFormat: 'keepSourceEmphasisFormat', + mergeTable: false, + } + ); + }); + + it('Set pending format after merge', () => { + const pasteModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [], + }; + const targetModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + format: { + italic: true, + lineHeight: '1pt', + }, + text: 'test', + isSelected: true, + }, + ], + }, + ], + format: { + fontFamily: 'Tahoma', + fontSize: '11pt', + }, + }; + const insertPointFormat: ContentModelSegmentFormat = { + fontFamily: 'Arial', + }; + const insertPoint: InsertPoint = { + marker: { + format: insertPointFormat, + isSelected: true, + segmentType: 'SelectionMarker', + }, + paragraph: null!, + path: [], + }; + const mockedPasteGeneralProcessor = 'GENERALPROCESSOR' as any; + const mockedPasteEntityProcessor = 'ENTITYPROCESSOR' as any; + const mockedDomToModelContext = { + name: 'DOMTOMODELCONTEXT', + } as any; + + const domToContentModelSpy = spyOn(domToContentModel, 'domToContentModel').and.returnValue( + pasteModel + ); + const mergeModelSpy = spyOn(mergeModelFile, 'mergeModel').and.returnValue(insertPoint); + const createPasteGeneralProcessorSpy = spyOn( + createPasteGeneralProcessor, + 'createPasteGeneralProcessor' + ).and.returnValue(mockedPasteGeneralProcessor); + const createPasteEntityProcessorSpy = spyOn( + createPasteEntityProcessor, + 'createPasteEntityProcessor' + ).and.returnValue(mockedPasteEntityProcessor); + const createDomToModelContextSpy = spyOn( + createDomToModelContext, + 'createDomToModelContext' + ).and.returnValue(mockedDomToModelContext); + + const context: FormatWithContentModelContext = { + deletedEntities: [], + newEntities: [], + newImages: [], + }; + const mockedDomToModelOptions = 'OPTION1' as any; + const mockedDefaultDomToModelOptions = 'OPTIONS3' as any; + const mockedFragment = 'FRAGMENT' as any; + + mergePasteContent( + targetModel, + context, + { + fragment: mockedFragment, + domToModelOption: mockedDefaultDomToModelOptions, + } as any, + mockedDomToModelOptions + ); + + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + newPendingFormat: { + backgroundColor: '', + fontFamily: 'Arial', + fontSize: '11pt', + fontWeight: '', + italic: false, + letterSpacing: '', + lineHeight: '', + strikethrough: false, + superOrSubScriptSequence: '', + textColor: '', + underline: false, + }, + }); + expect(domToContentModelSpy).toHaveBeenCalledWith(mockedFragment, mockedDomToModelContext); + expect(mergeModelSpy).toHaveBeenCalledWith(targetModel, pasteModel, context, { + mergeFormat: 'none', + mergeTable: false, + }); + expect(createPasteGeneralProcessorSpy).toHaveBeenCalledWith(mockedDefaultDomToModelOptions); + expect(createPasteEntityProcessorSpy).toHaveBeenCalledWith(mockedDefaultDomToModelOptions); + expect(createDomToModelContextSpy).toHaveBeenCalledWith( + undefined, + mockedDomToModelOptions, + { + processorOverride: { + '#text': pasteTextProcessor, + entity: mockedPasteEntityProcessor, + '*': mockedPasteGeneralProcessor, + }, + formatParserOverride: { + display: pasteDisplayFormatParser, + }, + additionalFormatParsers: { + container: [containerWidthFormatParser], + }, + }, + mockedDefaultDomToModelOptions + ); + expect(mockedDomToModelContext.segmentFormat).toEqual({ lineHeight: '1pt' }); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-core/test/utils/paste/retrieveHtmlInfoTest.ts b/packages-content-model/roosterjs-content-model-core/test/utils/paste/retrieveHtmlInfoTest.ts new file mode 100644 index 00000000000..dafa01821e1 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/test/utils/paste/retrieveHtmlInfoTest.ts @@ -0,0 +1,223 @@ +import { ClipboardData } from 'roosterjs-content-model-types'; +import { HtmlFromClipboard, retrieveHtmlInfo } from '../../../lib/utils/paste/retrieveHtmlInfo'; +import { itChromeOnly } from 'roosterjs-editor-dom/test/DomTestHelper'; + +describe('retrieveHtmlInfo', () => { + function runTest( + rawHtml: string | null, + expectedResult: HtmlFromClipboard, + expectedClipboard: Partial, + expectedHtml: string | undefined + ) { + const doc = rawHtml === null ? null : new DOMParser().parseFromString(rawHtml, 'text/html'); + const clipboardData: Partial = { + rawHtml, + }; + + const result = retrieveHtmlInfo(doc, clipboardData); + + expect(result).toEqual(expectedResult); + expect(clipboardData).toEqual({ + rawHtml, + ...expectedClipboard, + }); + expect(doc?.body.innerHTML).toEqual(expectedHtml); + } + + it('Null doc', () => { + runTest( + null, + { + metadata: {}, + globalCssRules: [], + }, + {}, + undefined + ); + }); + + it('Empty doc', () => { + runTest( + '', + { + htmlBefore: '', + htmlAfter: '', + globalCssRules: [], + metadata: {}, + }, + { + htmlFirstLevelChildTags: [], + html: '', + }, + '' + ); + }); + + it('Text node only', () => { + runTest( + 'test', + { + htmlBefore: '', + htmlAfter: '', + globalCssRules: [], + metadata: {}, + }, + { + htmlFirstLevelChildTags: [''], + html: 'test', + }, + 'test' + ); + }); + + it('DIV and text node only', () => { + runTest( + '
test
', + { + htmlBefore: '', + htmlAfter: '', + globalCssRules: [], + metadata: {}, + }, + { + htmlFirstLevelChildTags: ['DIV'], + html: '
test
', + }, + '
test
' + ); + }); + + it('text, DIV, SPAN and comment node only', () => { + runTest( + 'test1
test2
\r\ntest4test5', + { + htmlBefore: '', + htmlAfter: '', + globalCssRules: [], + metadata: {}, + }, + { + htmlFirstLevelChildTags: ['', 'DIV', 'SPAN', ''], + html: 'test1
test2
\r\ntest4test5', + }, + 'test1
test2
\ntest4test5' + ); + }); + + it('Has start fragment only', () => { + runTest( + '
test
', + { + htmlBefore: '', + htmlAfter: '', + globalCssRules: [], + metadata: {}, + }, + { + htmlFirstLevelChildTags: ['DIV'], + html: '
test
', + }, + '
test
' + ); + }); + + it('Has end fragment only', () => { + runTest( + '
test
', + { + htmlBefore: '', + htmlAfter: '', + globalCssRules: [], + metadata: {}, + }, + { + htmlFirstLevelChildTags: ['DIV'], + html: '
test
', + }, + '
test
' + ); + }); + + it('Has fragment comments', () => { + runTest( + '
test
', + { + htmlBefore: '
', + htmlAfter: '
', + globalCssRules: [], + metadata: {}, + }, + { + htmlFirstLevelChildTags: ['DIV'], + html: 'test', + }, + '
test
' + ); + }); + + it('Has metadata', () => { + runTest( + '
test
', + { + htmlBefore: '', + htmlAfter: '', + globalCssRules: [], + metadata: { a: 'b', 'c:d': 'e', f: 'g', h: 'i' }, + }, + { + htmlFirstLevelChildTags: ['DIV'], + html: + '
test
', + }, + '
test
' + ); + }); + + it('Has empty global CSS nodes', () => { + runTest( + '
test
', + { + htmlBefore: '', + htmlAfter: '', + globalCssRules: [], + metadata: {}, + }, + { + htmlFirstLevelChildTags: ['DIV'], + html: '
test
', + }, + '
test
' + ); + }); + + itChromeOnly('Has global CSS rule', () => { + runTest( + '
test
', + { + htmlBefore: '', + htmlAfter: '', + globalCssRules: [ + { + selectors: ['.a'], + text: 'color: red;', + }, + { + selectors: ['.b div', ' .c'], + text: 'font-size: 10pt;', + }, + { + selectors: ['test'], + text: 'border: none;', + }, + ], + metadata: {}, + }, + { + htmlFirstLevelChildTags: ['DIV'], + html: + '
test
', + }, + '
test
' + ); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-core/test/utils/sanitizeElementTest.ts b/packages-content-model/roosterjs-content-model-core/test/utils/sanitizeElementTest.ts new file mode 100644 index 00000000000..c24c35e6749 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/test/utils/sanitizeElementTest.ts @@ -0,0 +1,312 @@ +import { AllowedTags, DisallowedTags, sanitizeElement } from '../../lib/utils/sanitizeElement'; + +describe('sanitizeElement', () => { + it('Allowed element, empty', () => { + const element = document.createElement('div'); + + const result = sanitizeElement(element, AllowedTags, DisallowedTags); + + expect(element.outerHTML).toBe('
'); + expect(result!.outerHTML).toBe('
'); + }); + + it('Allowed element, with child', () => { + const element = document.createElement('div'); + + element.id = 'a'; + element.className = 'b c'; + element.innerHTML = 'test1test2test3'; + + const result = sanitizeElement(element, AllowedTags, DisallowedTags); + + expect(element.outerHTML).toBe( + '
test1test2test3
' + ); + expect(result!.outerHTML).toBe('
test1test2test3
'); + }); + + it('Empty element with disallowed tag', () => { + const element = document.createElement('script'); + + const result = sanitizeElement(element, AllowedTags, DisallowedTags); + + expect(element.outerHTML).toBe(''); + expect(result).toBeNull(); + }); + + it('Empty element with additional disallowed tag', () => { + const element = document.createElement('div'); + + const result = sanitizeElement(element, AllowedTags, DisallowedTags.concat(['div'])); + + expect(element.outerHTML).toBe('
'); + expect(result).toBeNull(); + }); + + it('Empty element with additional allowed tag', () => { + const element = document.createElement('test'); + + const result = sanitizeElement(element, AllowedTags.concat('test'), DisallowedTags); + + expect(element.outerHTML).toBe(''); + expect(result!.outerHTML).toBe(''); + }); + + it('Empty element with unrecognized tag', () => { + const element = document.createElement('test'); + + const result = sanitizeElement(element, AllowedTags, DisallowedTags); + + expect(element.outerHTML).toBe(''); + expect(result!.outerHTML).toBe(''); + }); + + it('Empty element with entity element', () => { + const element = document.createElement('div'); + + element.className = '_Entity _EType_A _EId_B _EReadonly_1'; + + const result = sanitizeElement(element, AllowedTags, DisallowedTags); + + expect(element.outerHTML).toBe('
'); + expect(result!.outerHTML).toBe('
'); + }); + + it('Empty element with child node', () => { + const element = document.createElement('div'); + + element.id = 'a'; + element.style.color = 'red'; + + element.innerHTML = 'testtest2'; + + const result = sanitizeElement(element, AllowedTags, DisallowedTags); + + expect(element.outerHTML).toBe( + '
testtest2
' + ); + expect(result!.outerHTML).toBe( + '
testtest2
' + ); + }); + + it('Empty element with style callback', () => { + const element = document.createElement('div'); + + element.style.color = 'red'; + element.style.position = 'absolute'; + + const positionCallback = jasmine.createSpy('position').and.callFake(() => 'relative'); + + const result = sanitizeElement(element, AllowedTags, DisallowedTags, { + position: positionCallback, + }); + + expect(element.outerHTML).toBe('
'); + expect(result!.outerHTML).toBe('
'); + }); + + it('styleCallbacks', () => { + const element = document.createElement('div'); + const sanitizerSpy = jasmine.createSpy('sanitizer').and.returnValue('green'); + + element.style.color = 'red'; + + const result = sanitizeElement(element, AllowedTags, DisallowedTags, { + color: sanitizerSpy, + }); + + expect(result!.outerHTML).toBe('
'); + expect(sanitizerSpy).toHaveBeenCalledWith('red', 'div'); + }); + + it('styleCallbacks with boolean', () => { + const element = document.createElement('div'); + + element.style.color = 'red'; + + const result = sanitizeElement(element, AllowedTags, DisallowedTags, { + color: false, + }); + + expect(result!.outerHTML).toBe('
'); + }); + + it('attributeCallbacks', () => { + const element = document.createElement('div'); + const sanitizerSpy = jasmine.createSpy('sanitizer').and.returnValue('b'); + + element.id = 'a'; + + const result = sanitizeElement(element, AllowedTags, DisallowedTags, undefined, { + id: sanitizerSpy, + }); + + expect(result!.outerHTML).toBe('
'); + expect(sanitizerSpy).toHaveBeenCalledWith('a', 'div'); + }); + + it('attributeCallbacks with boolean', () => { + const element = document.createElement('div'); + + element.id = 'a'; + + const result = sanitizeElement(element, AllowedTags, DisallowedTags, undefined, { + id: false, + }); + + expect(result!.outerHTML).toBe('
'); + }); +}); + +describe('sanitizeHtml', () => { + function runTest(source: string, exp: string) { + const doc = new DOMParser().parseFromString(source, 'text/html'); + + const result = sanitizeElement(doc.body, AllowedTags, DisallowedTags); + + expect(result!.innerHTML).toEqual(exp); + } + + it('Valid HTML', () => { + runTest('Test', 'Test'); + runTest( + '
test 1test 2test 3
', + '
test 1test 2test 3
' + ); + }); + + it('Invalid HTML', () => { + runTest('', ''); + runTest(' { + runTest('test', 'test'); + runTest('test1test2', 'test1test2'); + runTest( + 'test3ipt>alert("test")test4', + 'test3ipt>alert("test")test4' + ); + }); + + it('Html contains event handler', () => { + runTest('
bb
aa', '
bb
aa'); + runTest('aaccbb', 'aaccbb'); + runTest('aaccbb', 'aaccbb'); + runTest('aa
cc
bb', 'aaccbb'); + }); + + it('Html contains unnecessary CSS', () => { + runTest( + 'aabbcc', + 'aabbcc' + ); + runTest( + 'aabbcc', + 'aabbcc' + ); + }); + + it('Html contains disallowed CSS', () => { + runTest( + 'aa', + 'aa' + ); + runTest( + 'aa', + 'aa' + ); + }); + + it('Html contains disallowed attributes', () => { + runTest( + 'aa', + 'aa' + ); + }); + + it('Html contains comments', () => { + runTest('
aa
bb
', '
aa
bb
'); + }); + + it('Html contains CSS with escaped quoted values', () => { + let testIn: string = + "aa"; + let testOut: string = + 'aa'; + + runTest(testIn, testOut); + }); + + it('Html contains CSS with double quoted values', () => { + let testIn: string = + "aa"; + let testOut: string = + 'aa'; + + runTest(testIn, testOut); + }); + + it('Html contains CSS with single quoted values', () => { + let testIn: string = + 'aa'; + + runTest(testIn, testIn); + }); + + it('handle normal', () => { + runTest( + '
line \n 1
line \n 2
line \n 3
', + '
line \n 1
line \n 2
line \n 3
' + ); + }); + + it('handle nowrap', () => { + runTest( + '
line \n 1
line \n 2
line \n 3
', + '
line \n 1
line \n 2
line \n 3
' + ); + }); + + it('handle pre', () => { + runTest( + '
line \n 1
line \n 2
line \n 3
', + '
line \n 1
line \n 2
line \n 3
' + ); + }); + + it('handle pre-line', () => { + runTest( + '
line \n 1
line \n 2
line \n 3
', + '
line \n 1
line \n 2
line \n 3
' + ); + }); + + it('handle pre-wrap', () => { + runTest( + '
line \n 1
line \n 2
line \n 3
', + '
line \n 1
line \n 2
line \n 3
' + ); + }); + + it('handle PRE tag', () => { + runTest( + '
line \n 1 ' + '
  line  \n  2  
' + ' line \n 3
', + '
line \n 1
  line  \n  2  
line \n 3
' + ); + }); + + it('handle PRE tag with style', () => { + runTest( + '
line \n 1
  line  \n  2  
line \n 3
', + '
line \n 1
  line  \n  2  
line \n 3
' + ); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/textProcessor.ts b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/textProcessor.ts index e2de0dd87e9..29b78ef4637 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/textProcessor.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/textProcessor.ts @@ -5,6 +5,7 @@ import { createText } from '../../modelApi/creators/createText'; import { ensureParagraph } from '../../modelApi/common/ensureParagraph'; import { getRegularSelectionOffsets } from '../utils/getRegularSelectionOffsets'; import { hasSpacesOnly } from '../../modelApi/common/hasSpacesOnly'; +import { isWhiteSpacePreserved } from '../../domUtils/isWhiteSpacePreserved'; import type { ContentModelBlockGroup, ContentModelParagraph, @@ -62,9 +63,6 @@ export const textProcessor: ElementProcessor = ( ); }; -// When we see these values of white-space style, need to preserve spaces and line-breaks and let browser handle it for us. -const WhiteSpaceValuesNeedToHandle = ['pre', 'pre-wrap', 'pre-line', 'break-spaces']; - function addTextSegment( group: ContentModelBlockGroup, text: string, @@ -77,7 +75,7 @@ function addTextSegment( if ( !hasSpacesOnly(text) || (paragraph?.segments.length ?? 0) > 0 || - WhiteSpaceValuesNeedToHandle.indexOf(paragraph?.format.whiteSpace || '') >= 0 + isWhiteSpacePreserved(paragraph?.format.whiteSpace) ) { textModel = createText(text, context.segmentFormat); diff --git a/packages-content-model/roosterjs-content-model-dom/lib/domUtils/entityUtils.ts b/packages-content-model/roosterjs-content-model-dom/lib/domUtils/entityUtils.ts index 10eb0c81c09..dc0a493603e 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/domUtils/entityUtils.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/domUtils/entityUtils.ts @@ -116,16 +116,3 @@ function insertDelimiter(doc: Document, element: Element, isAfter: boolean) { return span; } - -/** - * Allowed CSS selector for entity, used by HtmlSanitizer. - * TODO: Revisit paste logic and check if we can remove HtmlSanitizer - */ -export const AllowedEntityClasses: ReadonlyArray = [ - '^' + ENTITY_INFO_NAME + '$', - '^' + ENTITY_ID_PREFIX, - '^' + ENTITY_TYPE_PREFIX, - '^' + ENTITY_READONLY_PREFIX, - '^' + DELIMITER_BEFORE + '$', - '^' + DELIMITER_AFTER + '$', -]; diff --git a/packages-content-model/roosterjs-content-model-dom/lib/domUtils/isWhiteSpacePreserved.ts b/packages-content-model/roosterjs-content-model-dom/lib/domUtils/isWhiteSpacePreserved.ts new file mode 100644 index 00000000000..21f30644ea0 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-dom/lib/domUtils/isWhiteSpacePreserved.ts @@ -0,0 +1,10 @@ +// According to https://developer.mozilla.org/en-US/docs/Web/CSS/white-space, these style values will need to preserve white spaces +const WHITESPACE_PRE_VALUES = ['pre', 'pre-wrap', 'break-spaces']; + +/** + * Check if the given white-space style value will cause preserving white space + * @param whiteSpace The white-space style value to check + */ +export function isWhiteSpacePreserved(whiteSpace: string | undefined): boolean { + return !!whiteSpace && WHITESPACE_PRE_VALUES.indexOf(whiteSpace) >= 0; +} diff --git a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/block/paddingFormatHandler.ts b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/block/paddingFormatHandler.ts index bbf96aa91ef..33338ca3638 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/block/paddingFormatHandler.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/block/paddingFormatHandler.ts @@ -12,11 +12,16 @@ const PaddingKeys: (keyof PaddingFormat & keyof CSSStyleDeclaration)[] = [ * @internal */ export const paddingFormatHandler: FormatHandler = { - parse: (format, element) => { + parse: (format, element, _, defaultStyle) => { PaddingKeys.forEach(key => { - const value = element.style[key]; + let value = element.style[key]; + const defaultValue = defaultStyle[key] ?? '0px'; - if (value) { + if (value == '0') { + value = '0px'; + } + + if (value && value != defaultValue) { format[key] = value; } }); diff --git a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/block/whiteSpaceFormatHandler.ts b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/block/whiteSpaceFormatHandler.ts index bc3934869b1..03cb6cf5791 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/block/whiteSpaceFormatHandler.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/block/whiteSpaceFormatHandler.ts @@ -1,3 +1,4 @@ +import { shouldSetValue } from '../utils/shouldSetValue'; import type { FormatHandler } from '../FormatHandler'; import type { WhiteSpaceFormat } from 'roosterjs-content-model-types'; @@ -8,7 +9,7 @@ export const whiteSpaceFormatHandler: FormatHandler = { parse: (format, element, _, defaultStyle) => { const whiteSpace = element.style.whiteSpace || defaultStyle.whiteSpace; - if (whiteSpace) { + if (shouldSetValue(whiteSpace, 'normal', format.whiteSpace, defaultStyle.whiteSpace)) { format.whiteSpace = whiteSpace; } }, diff --git a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/common/backgroundColorFormatHandler.ts b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/common/backgroundColorFormatHandler.ts index 408bca73edd..47e1cb439e7 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/common/backgroundColorFormatHandler.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/common/backgroundColorFormatHandler.ts @@ -1,4 +1,5 @@ import { getColor, setColor } from '../utils/color'; +import { shouldSetValue } from '../utils/shouldSetValue'; import type { BackgroundColorFormat } from 'roosterjs-content-model-types'; import type { FormatHandler } from '../FormatHandler'; @@ -15,7 +16,14 @@ export const backgroundColorFormatHandler: FormatHandler !!context.isDarkMode ) || defaultStyle.backgroundColor; - if (backgroundColor) { + if ( + shouldSetValue( + backgroundColor, + 'transparent', + undefined /*existingValue*/, + defaultStyle.backgroundColor + ) + ) { format.backgroundColor = backgroundColor; } }, diff --git a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/common/borderFormatHandler.ts b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/common/borderFormatHandler.ts index 5301c31c358..d03028de2cd 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/common/borderFormatHandler.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/common/borderFormatHandler.ts @@ -9,21 +9,40 @@ export const BorderKeys: (keyof BorderFormat & keyof CSSStyleDeclaration)[] = [ 'borderRight', 'borderBottom', 'borderLeft', - 'borderRadius', +]; + +// This array needs to match BorderKeys array +const BorderWidthKeys: (keyof CSSStyleDeclaration)[] = [ + 'borderTopWidth', + 'borderRightWidth', + 'borderBottomWidth', + 'borderLeftWidth', ]; /** * @internal */ export const borderFormatHandler: FormatHandler = { - parse: (format, element) => { - BorderKeys.forEach(key => { + parse: (format, element, _, defaultStyle) => { + BorderKeys.forEach((key, i) => { const value = element.style[key]; + const defaultWidth = defaultStyle[BorderWidthKeys[i]] ?? '0px'; + let width = element.style[BorderWidthKeys[i]]; - if (value) { + if (width == '0') { + width = '0px'; + } + + if (value && width != defaultWidth) { format[key] = value == 'none' ? '' : value; } }); + + const borderRadius = element.style.borderRadius; + + if (borderRadius) { + format.borderRadius = borderRadius; + } }, apply: (format, element) => { BorderKeys.forEach(key => { diff --git a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/common/sizeFormatHandler.ts b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/common/sizeFormatHandler.ts index 3a43767b51e..c4e4e256ad1 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/common/sizeFormatHandler.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/common/sizeFormatHandler.ts @@ -62,7 +62,7 @@ function tryParseSize(element: HTMLElement, attrName: 'width' | 'height'): strin return attrValue && PercentageRegex.test(attrValue) ? attrValue - : Number.isNaN(value) + : Number.isNaN(value) || value == 0 ? undefined : value + 'px'; } diff --git a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/segment/boldFormatHandler.ts b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/segment/boldFormatHandler.ts index ecabe2b8705..54341feeade 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/segment/boldFormatHandler.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/segment/boldFormatHandler.ts @@ -1,3 +1,4 @@ +import { shouldSetValue } from '../utils/shouldSetValue'; import { wrapAllChildNodes } from '../../domUtils/moveChildNodes'; import type { BoldFormat } from 'roosterjs-content-model-types'; import type { FormatHandler } from '../FormatHandler'; @@ -9,7 +10,7 @@ export const boldFormatHandler: FormatHandler = { parse: (format, element, context, defaultStyle) => { const fontWeight = element.style.fontWeight || defaultStyle.fontWeight; - if (fontWeight) { + if (shouldSetValue(fontWeight, '400', format.fontWeight, defaultStyle.fontWeight)) { format.fontWeight = fontWeight; } }, diff --git a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/segment/letterSpacingFormatHandler.ts b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/segment/letterSpacingFormatHandler.ts index 314da333469..f1a451d4499 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/segment/letterSpacingFormatHandler.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/segment/letterSpacingFormatHandler.ts @@ -1,3 +1,4 @@ +import { shouldSetValue } from '../utils/shouldSetValue'; import type { FormatHandler } from '../FormatHandler'; import type { LetterSpacingFormat } from 'roosterjs-content-model-types'; @@ -5,14 +6,21 @@ import type { LetterSpacingFormat } from 'roosterjs-content-model-types'; * @internal */ export const letterSpacingFormatHandler: FormatHandler = { - parse: (format, element, context, defaultStyle) => { + parse: (format, element, _, defaultStyle) => { const letterSpacing = element.style.letterSpacing || defaultStyle.letterSpacing; - if (letterSpacing) { + if ( + shouldSetValue( + letterSpacing, + 'normal', + format.letterSpacing, + defaultStyle.letterSpacing + ) + ) { format.letterSpacing = letterSpacing; } }, - apply: (format, element, context) => { + apply: (format, element) => { if (format.letterSpacing) { element.style.letterSpacing = format.letterSpacing; } diff --git a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/table/tableSpacingFormatHandler.ts b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/table/tableSpacingFormatHandler.ts index 37ee6c87d6e..df66ef61867 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/table/tableSpacingFormatHandler.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/table/tableSpacingFormatHandler.ts @@ -2,6 +2,7 @@ import type { FormatHandler } from '../FormatHandler'; import type { SpacingFormat } from 'roosterjs-content-model-types'; const BorderCollapsed = 'collapse'; +const CellPadding = 'cellPadding'; /** * @internal @@ -10,6 +11,11 @@ export const tableSpacingFormatHandler: FormatHandler = { parse: (format, element) => { if (element.style.borderCollapse == BorderCollapsed) { format.borderCollapse = true; + } else { + const cellPadding = element.getAttribute(CellPadding); + if (cellPadding) { + format.borderCollapse = true; + } } }, apply: (format, element) => { 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 185190e8289..1030d70d4af 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 @@ -29,6 +29,8 @@ export const DeprecatedColors: string[] = [ 'window', ]; +const BlackColor = 'rgb(0, 0, 0)'; + /** * Get color from given HTML element * @param element The element to get color from @@ -48,7 +50,7 @@ export function getColor( undefined; if (color && DeprecatedColors.indexOf(color) > -1) { - color = undefined; + color = isBackground ? undefined : BlackColor; } if (darkColorHandler) { diff --git a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/utils/parseValueWithUnit.ts b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/utils/parseValueWithUnit.ts index 81a4f4d9091..f5a5a7adea4 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/utils/parseValueWithUnit.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/utils/parseValueWithUnit.ts @@ -26,6 +26,7 @@ export function parseValueWithUnit( result = ptToPx(num); break; case 'em': + case 'rem': result = getFontSize(currentSizePxOrElement) * num; break; case 'ex': diff --git a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/utils/shouldSetValue.ts b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/utils/shouldSetValue.ts new file mode 100644 index 00000000000..bc26ad21ca0 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/utils/shouldSetValue.ts @@ -0,0 +1,15 @@ +/** + * @internal + */ +export function shouldSetValue( + value: string | undefined, + normalValue: string, + existingValue: string | undefined, + defaultValue: string | undefined +): boolean { + return ( + !!value && + value != 'inherit' && + !!(value != normalValue || existingValue || (defaultValue && value != defaultValue)) + ); +} 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 44de6d5e129..c8efa5291b9 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/index.ts @@ -21,7 +21,6 @@ export { default as toArray } from './domUtils/toArray'; export { moveChildNodes, wrapAllChildNodes } from './domUtils/moveChildNodes'; export { wrap } from './domUtils/wrap'; export { - AllowedEntityClasses, isEntityElement, getAllEntityWrappers, parseEntityClassName, @@ -29,6 +28,7 @@ export { addDelimiters, } from './domUtils/entityUtils'; export { reuseCachedElement } from './domUtils/reuseCachedElement'; +export { isWhiteSpacePreserved } from './domUtils/isWhiteSpacePreserved'; export { createBr } from './modelApi/creators/createBr'; export { createListItem } from './modelApi/creators/createListItem'; @@ -55,9 +55,8 @@ export { normalizeContentModel } from './modelApi/common/normalizeContentModel'; export { isGeneralSegment } from './modelApi/common/isGeneralSegment'; export { unwrapBlock } from './modelApi/common/unwrapBlock'; export { addSegment } from './modelApi/common/addSegment'; -export { isWhiteSpacePreserved } from './modelApi/common/isWhiteSpacePreserved'; +export { isEmpty } from './modelApi/common/isEmpty'; export { normalizeSingleSegment } from './modelApi/common/normalizeSegment'; -export { applySegmentFormatToElement } from './modelApi/common/applySegmentFormatToElement'; export { setParagraphNotImplicit } from './modelApi/block/setParagraphNotImplicit'; diff --git a/packages-content-model/roosterjs-content-model-dom/lib/modelApi/common/applySegmentFormatToElement.ts b/packages-content-model/roosterjs-content-model-dom/lib/modelApi/common/applySegmentFormatToElement.ts deleted file mode 100644 index 7a736fc6a5e..00000000000 --- a/packages-content-model/roosterjs-content-model-dom/lib/modelApi/common/applySegmentFormatToElement.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { applyFormat } from '../../modelToDom/utils/applyFormat'; -import { createModelToDomContext } from '../../modelToDom/context/createModelToDomContext'; -import type { ContentModelSegmentFormat } from 'roosterjs-content-model-types'; - -/** - * 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/modelApi/common/isEmpty.ts b/packages-content-model/roosterjs-content-model-dom/lib/modelApi/common/isEmpty.ts index 37efdb34e39..64a1944437b 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/modelApi/common/isEmpty.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/modelApi/common/isEmpty.ts @@ -65,7 +65,8 @@ export function isSegmentEmpty(segment: ContentModelSegment): boolean { } /** - * @internal + * Get whether the model is empty. + * @returns true if the model is empty. */ export function isEmpty( model: ContentModelBlock | ContentModelBlockGroup | ContentModelSegment diff --git a/packages-content-model/roosterjs-content-model-dom/lib/modelApi/common/isWhiteSpacePreserved.ts b/packages-content-model/roosterjs-content-model-dom/lib/modelApi/common/isWhiteSpacePreserved.ts deleted file mode 100644 index b3122e11d17..00000000000 --- a/packages-content-model/roosterjs-content-model-dom/lib/modelApi/common/isWhiteSpacePreserved.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { ContentModelParagraph } from 'roosterjs-content-model-types'; - -// According to https://developer.mozilla.org/en-US/docs/Web/CSS/white-space, these style values will need to preserve white spaces -const WHITESPACE_PRE_VALUES = ['pre', 'pre-wrap', 'break-spaces']; - -/** - * Check if we have white-space to be preserved for a given paragraph - * @param paragraph The paragraph to check - */ -export function isWhiteSpacePreserved(paragraph: ContentModelParagraph): boolean { - return ( - (paragraph.format.whiteSpace && - WHITESPACE_PRE_VALUES.indexOf(paragraph.format.whiteSpace) >= 0) || - false - ); -} diff --git a/packages-content-model/roosterjs-content-model-dom/lib/modelApi/common/normalizeParagraph.ts b/packages-content-model/roosterjs-content-model-dom/lib/modelApi/common/normalizeParagraph.ts index 7853f801ce0..1edd90acb8b 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/modelApi/common/normalizeParagraph.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/modelApi/common/normalizeParagraph.ts @@ -1,7 +1,7 @@ import { areSameFormats } from '../../domToModel/utils/areSameFormats'; import { createBr } from '../creators/createBr'; import { isSegmentEmpty } from './isEmpty'; -import { isWhiteSpacePreserved } from './isWhiteSpacePreserved'; +import { isWhiteSpacePreserved } from '../../domUtils/isWhiteSpacePreserved'; import { normalizeAllSegments } from './normalizeSegment'; import type { ContentModelParagraph } from 'roosterjs-content-model-types'; /** @@ -33,7 +33,7 @@ export function normalizeParagraph(paragraph: ContentModelParagraph) { } } - if (!isWhiteSpacePreserved(paragraph)) { + if (!isWhiteSpacePreserved(paragraph.format.whiteSpace)) { normalizeAllSegments(paragraph); } diff --git a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/knownElementProcessorTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/knownElementProcessorTest.ts index ea61ed51624..1dd5486e2ac 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/knownElementProcessorTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/knownElementProcessorTest.ts @@ -313,8 +313,6 @@ describe('knownElementProcessor', () => { blockType: 'BlockGroup', blockGroupType: 'FormatContainer', format: { - paddingLeft: '0px', - paddingRight: '0px', paddingTop: '20px', paddingBottom: '40px', }, diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelApi/common/isWhiteSpacePreservedTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domUtils/isWhiteSpacePreservedTest.ts similarity index 50% rename from packages-content-model/roosterjs-content-model-dom/test/modelApi/common/isWhiteSpacePreservedTest.ts rename to packages-content-model/roosterjs-content-model-dom/test/domUtils/isWhiteSpacePreservedTest.ts index 06fd459b37b..fc798b2d612 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelApi/common/isWhiteSpacePreservedTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domUtils/isWhiteSpacePreservedTest.ts @@ -1,19 +1,8 @@ -import { ContentModelParagraph } from 'roosterjs-content-model-types'; -import { isWhiteSpacePreserved } from '../../../lib/modelApi/common/isWhiteSpacePreserved'; +import { isWhiteSpacePreserved } from '../../lib/domUtils/isWhiteSpacePreserved'; describe('isWhiteSpacePreserved', () => { function runTest(style: string | undefined, expected: boolean) { - const paragraph: ContentModelParagraph = { - blockType: 'Paragraph', - format: {}, - segments: [], - }; - - if (style) { - paragraph.format.whiteSpace = style; - } - - const result = isWhiteSpacePreserved(paragraph); + const result = isWhiteSpacePreserved(style); expect(result).toBe(expected); } diff --git a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/block/paddingFormatHandlerTest.ts b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/block/paddingFormatHandlerTest.ts new file mode 100644 index 00000000000..501a476b8cd --- /dev/null +++ b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/block/paddingFormatHandlerTest.ts @@ -0,0 +1,78 @@ +import { createDomToModelContext } from '../../../lib/domToModel/context/createDomToModelContext'; +import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext'; +import { DomToModelContext, ModelToDomContext, PaddingFormat } from 'roosterjs-content-model-types'; +import { paddingFormatHandler } from '../../../lib/formatHandlers/block/paddingFormatHandler'; + +describe('paddingFormatHandler.parse', () => { + let div: HTMLElement; + let format: PaddingFormat; + let context: DomToModelContext; + + beforeEach(() => { + div = document.createElement('div'); + format = {}; + context = createDomToModelContext(); + }); + + it('No padding', () => { + paddingFormatHandler.parse(format, div, context, {}); + expect(format).toEqual({}); + }); + + it('Has padding in CSS', () => { + div.style.padding = '1px 2px 3px 4px'; + paddingFormatHandler.parse(format, div, context, {}); + expect(format).toEqual({ + paddingTop: '1px', + paddingRight: '2px', + paddingBottom: '3px', + paddingLeft: '4px', + }); + }); + + it('Overwrite padding values', () => { + div.style.paddingLeft = '15pt'; + format.paddingLeft = '30px'; + paddingFormatHandler.parse(format, div, context, {}); + expect(format).toEqual({ + paddingLeft: '15pt', + }); + }); + + it('0 padding', () => { + div.style.padding = '0 10px 20px 0'; + paddingFormatHandler.parse(format, div, context, {}); + expect(format).toEqual({ + paddingRight: '10px', + paddingBottom: '20px', + }); + }); +}); + +describe('paddingFormatHandler.apply', () => { + let div: HTMLElement; + let format: PaddingFormat; + let context: ModelToDomContext; + + beforeEach(() => { + div = document.createElement('div'); + format = {}; + context = createModelToDomContext(); + }); + + it('No padding', () => { + paddingFormatHandler.apply(format, div, context); + expect(div.outerHTML).toBe('
'); + }); + + it('Has padding', () => { + format.paddingTop = '1px'; + format.paddingRight = '2px'; + format.paddingBottom = '3px'; + format.paddingLeft = '4px'; + + paddingFormatHandler.apply(format, div, context); + + expect(div.outerHTML).toBe('
'); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/block/whiteSpaceFormatHandlerTest.ts b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/block/whiteSpaceFormatHandlerTest.ts index a187c4c06b2..92723a16806 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/block/whiteSpaceFormatHandlerTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/block/whiteSpaceFormatHandlerTest.ts @@ -49,6 +49,12 @@ describe('whiteSpaceFormatHandler.parse', () => { whiteSpace: 'pre', }); }); + + it('White space = normal', () => { + div.style.whiteSpace = 'normal'; + whiteSpaceFormatHandler.parse(format, div, context, {}); + expect(format).toEqual({}); + }); }); describe('whiteSpaceFormatHandler.apply', () => { 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 959beb060f1..210c944beda 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 @@ -37,6 +37,13 @@ describe('backgroundColorFormatHandler.parse', () => { div.style.backgroundColor = 'transparent'; backgroundColorFormatHandler.parse(format, div, context, {}); + expect(format.backgroundColor).toBeUndefined(); + }); + + it('Transparent, different with default value', () => { + div.style.backgroundColor = 'transparent'; + backgroundColorFormatHandler.parse(format, div, context, { backgroundColor: 'red' }); + expect(format.backgroundColor).toBe('transparent'); }); diff --git a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/common/borderFormatHandlerTest.ts b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/common/borderFormatHandlerTest.ts index c0d0fe7d493..17fb9bb5e9b 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/common/borderFormatHandlerTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/common/borderFormatHandlerTest.ts @@ -73,6 +73,24 @@ describe('borderFormatHandler.parse', () => { expect(format).toEqual({}); }); + + it('Has 0 width border', () => { + div.style.border = '0px sold black'; + + borderFormatHandler.parse(format, div, context, {}); + + expect(format).toEqual({}); + }); + + it('Has border radius', () => { + div.style.borderRadius = '10px'; + + borderFormatHandler.parse(format, div, context, {}); + + expect(format).toEqual({ + borderRadius: '10px', + }); + }); }); describe('borderFormatHandler.apply', () => { diff --git a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/common/sizeFormatHandlerTest.ts b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/common/sizeFormatHandlerTest.ts index ae7ff75f41b..3c6591a3124 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/common/sizeFormatHandlerTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/common/sizeFormatHandlerTest.ts @@ -56,6 +56,17 @@ describe('sizeFormatHandler.parse', () => { expect(format).toEqual({ width: '10px', height: '20px' }); }); + it('Element with width and height attributes equal to 0', () => { + const element = document.createElement('div'); + + element.setAttribute('width', '0'); + element.setAttribute('height', '0'); + + sizeFormatHandler.parse(format, element, context, {}); + + expect(format).toEqual({}); + }); + it('Element with width and height in attribute in percentage', () => { const element = document.createElement('div'); diff --git a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/segment/boldFormatHandlerTest.ts b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/segment/boldFormatHandlerTest.ts index f1facbeaf35..9daaf858fd5 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/segment/boldFormatHandlerTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/segment/boldFormatHandlerTest.ts @@ -45,6 +45,21 @@ describe('boldFormatHandler.parse', () => { expect(format.fontWeight).toBe('600'); }); + it('bold 400', () => { + div.style.fontWeight = '400'; + boldFormatHandler.parse(format, div, context, {}); + + expect(format.fontWeight).toBeUndefined(); + }); + + it('bold 400 when it is already in 600', () => { + div.style.fontWeight = '400'; + format.fontWeight = '600'; + boldFormatHandler.parse(format, div, context, {}); + + expect(format.fontWeight).toBe('400'); + }); + it('default style to bold', () => { ['bold', 'bolder', '600', '700'].forEach(value => { boldFormatHandler.parse(format, div, context, { fontWeight: value }); diff --git a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/segment/letterSpacingFormatHandlerTest.ts b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/segment/letterSpacingFormatHandlerTest.ts index c052389033c..3a60e5d8920 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/segment/letterSpacingFormatHandlerTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/segment/letterSpacingFormatHandlerTest.ts @@ -30,6 +30,13 @@ describe('letterSpacingFormatHandler.parse', () => { expect(format.letterSpacing).toBe('1em'); }); + + it('Normal', () => { + div.style.letterSpacing = 'normal'; + letterSpacingFormatHandler.parse(format, div, context, {}); + + expect(format.letterSpacing).toBeUndefined(); + }); }); describe('letterSpacingFormatHandler.apply', () => { diff --git a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/table/tableSpacingFormatHandlerTest.ts b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/table/tableSpacingFormatHandlerTest.ts index 3b15c9c4777..4af7f3d2985 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/table/tableSpacingFormatHandlerTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/table/tableSpacingFormatHandlerTest.ts @@ -30,6 +30,12 @@ describe('tableSpacingFormatHandler.parse', () => { tableSpacingFormatHandler.parse(format, div, context, {}); expect(format).toEqual({}); }); + + it('Set border collapsed if element contains cellpadding attribute', () => { + div.setAttribute('cellPadding', '0'); + tableSpacingFormatHandler.parse(format, div, context, {}); + expect(format).toEqual({ borderCollapse: true }); + }); }); describe('tableSpacingFormatHandler.apply', () => { diff --git a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/utils/parseValueWithUnitTest.ts b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/utils/parseValueWithUnitTest.ts index 29e3a4d05e3..989766c9f4c 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/utils/parseValueWithUnitTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/utils/parseValueWithUnitTest.ts @@ -43,6 +43,10 @@ describe('parseValueWithUnit with element', () => { runTest('ex', [0, 10, 11, -11]); }); + it('rem', () => { + runTest('rem', [0, 20, 22, -22]); + }); + it('no unit', () => { runTest('', [0, 0, 0, 0]); }); diff --git a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/utils/shouldSetValueTest.ts b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/utils/shouldSetValueTest.ts new file mode 100644 index 00000000000..ee708efed2b --- /dev/null +++ b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/utils/shouldSetValueTest.ts @@ -0,0 +1,45 @@ +import { shouldSetValue } from '../../../lib/formatHandlers/utils/shouldSetValue'; + +describe('shouldSetValue', () => { + it('no value', () => { + const result = shouldSetValue(undefined, '', 'existing', ''); + + expect(result).toBeFalsy(); + }); + + it('Empty string value', () => { + const result = shouldSetValue('', '', 'existing', ''); + + expect(result).toBeFalsy(); + }); + + it('Has value, is inherit', () => { + const result = shouldSetValue('inherit', '', 'existing', ''); + + expect(result).toBeFalsy(); + }); + + it('value equals normal value', () => { + const result = shouldSetValue('test', 'test', '', ''); + + expect(result).toBeFalsy(); + }); + + it('Has value, no existing value', () => { + const result = shouldSetValue('test', 'test2', '', ''); + + expect(result).toBeTruthy(); + }); + + it('Has value, value equal to default value', () => { + const result = shouldSetValue('test', 'test2', '', 'test'); + + expect(result).toBeTruthy(); + }); + + it('Has value, no normal value, no existing value, no default value', () => { + const result = shouldSetValue('test', '', '', ''); + + expect(result).toBeTruthy(); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/coreApiMap.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/coreApiMap.ts index e61538c2781..6d8ebc84677 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/coreApiMap.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/coreApiMap.ts @@ -3,12 +3,12 @@ import { getContent } from './getContent'; import { getStyleBasedFormatState } from './getStyleBasedFormatState'; import { insertNode } from './insertNode'; import { setContent } from './setContent'; -import type { UnportedCoreApiMap } from 'roosterjs-content-model-types'; +import type { ContentModelCoreApiMap } from '../publicTypes/ContentModelEditorCore'; /** * @internal */ -export const coreApiMap: UnportedCoreApiMap = { +export const coreApiMap: ContentModelCoreApiMap = { ensureTypeInContainer, getContent, getStyleBasedFormatState, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/ensureTypeInContainer.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/ensureTypeInContainer.ts index 4f845aa26ea..0a92376edc1 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/ensureTypeInContainer.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/ensureTypeInContainer.ts @@ -8,15 +8,21 @@ import { Position, safeInstanceOf, } from 'roosterjs-editor-dom'; -import type { EnsureTypeInContainer } from 'roosterjs-content-model-types'; +import type { EnsureTypeInContainer } from '../publicTypes/ContentModelEditorCore'; /** * @internal * When typing goes directly under content div, many things can go wrong * We fix it by wrapping it with a div and reposition cursor within the div */ -export const ensureTypeInContainer: EnsureTypeInContainer = (core, position, keyboardEvent) => { - const table = findClosestElementAncestor(position.node, core.contentDiv, 'table'); +export const ensureTypeInContainer: EnsureTypeInContainer = ( + core, + innerCore, + position, + keyboardEvent +) => { + const { contentDiv, api } = innerCore; + const table = findClosestElementAncestor(position.node, contentDiv, 'table'); let td: HTMLElement | null; if (table && (td = table.querySelector('td,th'))) { @@ -24,7 +30,7 @@ export const ensureTypeInContainer: EnsureTypeInContainer = (core, position, key } position = position.normalize(); - const block = getBlockElementAtNode(core.contentDiv, position.node); + const block = getBlockElementAtNode(contentDiv, position.node); let formatNode: HTMLElement | null; if (block) { @@ -46,9 +52,9 @@ export const ensureTypeInContainer: EnsureTypeInContainer = (core, position, key // The fix is to add a DIV wrapping, apply default format and move cursor over formatNode = createElement( KnownCreateElementDataIndex.EmptyLine, - core.contentDiv.ownerDocument + contentDiv.ownerDocument ) as HTMLElement; - core.api.insertNode(core, formatNode, { + core.api.insertNode(core, innerCore, formatNode, { position: ContentPosition.End, updateCursor: false, replaceSelection: false, @@ -61,7 +67,7 @@ export const ensureTypeInContainer: EnsureTypeInContainer = (core, position, key // If this is triggered by a keyboard event, let's select the new position if (keyboardEvent) { - core.api.setDOMSelection(core, { + api.setDOMSelection(innerCore, { type: 'range', range: createRange(new Position(position)), }); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getContent.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getContent.ts index bac5e626323..e1994e354cd 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getContent.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getContent.ts @@ -7,7 +7,7 @@ import { getTextContent, safeInstanceOf, } from 'roosterjs-editor-dom'; -import type { GetContent } from 'roosterjs-content-model-types'; +import type { GetContent } from '../publicTypes/ContentModelEditorCore'; /** * @internal @@ -16,14 +16,15 @@ import type { GetContent } from 'roosterjs-content-model-types'; * @param mode specify what kind of HTML content to retrieve * @returns HTML string representing current editor content */ -export const getContent: GetContent = (core, mode): string => { +export const getContent: GetContent = (core, innerCore, mode): string => { let content: string | null = ''; const triggerExtractContentEvent = mode == GetContentMode.CleanHTML; const includeSelectionMarker = mode == GetContentMode.RawHTMLWithSelection; + const { lifecycle, contentDiv, api, darkColorHandler } = innerCore; // When there is fragment for shadow edit, always use the cached fragment as document since HTML node in editor // has been changed by uncommitted shadow edit which should be ignored. - const root = core.lifecycle.shadowEditFragment || core.contentDiv; + const root = lifecycle.shadowEditFragment || contentDiv; if (mode == GetContentMode.PlainTextFast) { content = root.textContent; @@ -33,22 +34,22 @@ export const getContent: GetContent = (core, mode): string => { const clonedRoot = cloneNode(root); clonedRoot.normalize(); - const originalRange = core.api.getDOMSelection(core); + const originalRange = api.getDOMSelection(innerCore); const path = - !includeSelectionMarker || core.lifecycle.shadowEditFragment + !includeSelectionMarker || lifecycle.shadowEditFragment ? null : originalRange?.type == 'range' - ? getSelectionPath(core.contentDiv, originalRange.range) + ? getSelectionPath(contentDiv, originalRange.range) : null; const range = path && createRange(clonedRoot, path.start, path.end); - if (core.lifecycle.isDarkMode) { - transformColor(clonedRoot, false /*includeSelf*/, 'darkToLight', core.darkColorHandler); + if (lifecycle.isDarkMode) { + transformColor(clonedRoot, false /*includeSelf*/, 'darkToLight', darkColorHandler); } if (triggerExtractContentEvent) { - core.api.triggerEvent( - core, + api.triggerEvent( + innerCore, { eventType: PluginEventType.ExtractContentWithDom, clonedRoot, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getStyleBasedFormatState.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getStyleBasedFormatState.ts index 230236ca8e0..aa1d8881a58 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getStyleBasedFormatState.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getStyleBasedFormatState.ts @@ -1,6 +1,6 @@ import { contains, getComputedStyles } from 'roosterjs-editor-dom'; import { NodeType } from 'roosterjs-editor-types'; -import type { GetStyleBasedFormatState } from 'roosterjs-content-model-types'; +import type { GetStyleBasedFormatState } from '../publicTypes/ContentModelEditorCore'; /** * @internal @@ -8,7 +8,7 @@ import type { GetStyleBasedFormatState } from 'roosterjs-content-model-types'; * @param core The StandaloneEditorCore objects * @param node The node to get style from */ -export const getStyleBasedFormatState: GetStyleBasedFormatState = (core, node) => { +export const getStyleBasedFormatState: GetStyleBasedFormatState = (core, innerCore, node) => { if (!node) { return {}; } @@ -27,7 +27,7 @@ export const getStyleBasedFormatState: GetStyleBasedFormatState = (core, node) = 'font-weight', ]) : []; - const { contentDiv, darkColorHandler } = core; + const { contentDiv, darkColorHandler, lifecycle } = innerCore; let styleTextColor: string | undefined; let styleBackColor: string | undefined; @@ -46,7 +46,7 @@ export const getStyleBasedFormatState: GetStyleBasedFormatState = (core, node) = node = node.parentNode; } - if (!core.lifecycle.isDarkMode && node == core.contentDiv) { + if (!lifecycle.isDarkMode && node == contentDiv) { styleTextColor = styleTextColor || styles[2]; styleBackColor = styleBackColor || styles[3]; } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/insertNode.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/insertNode.ts index 0639aa27799..937eda1bcd0 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/insertNode.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/insertNode.ts @@ -16,7 +16,8 @@ import { splitTextNode, splitParentNode, } from 'roosterjs-editor-dom'; -import type { InsertNode, StandaloneEditorCore } from 'roosterjs-content-model-types'; +import type { StandaloneEditorCore } from 'roosterjs-content-model-types'; +import type { InsertNode } from '../publicTypes/ContentModelEditorCore'; function getInitialRange( core: StandaloneEditorCore, @@ -29,6 +30,7 @@ function getInitialRange( const selection = core.api.getDOMSelection(core); let range = selection?.type == 'range' ? selection.range : null; let rangeToRestore = null; + if (option.position == ContentPosition.Range) { rangeToRestore = range; range = option.range; @@ -42,14 +44,10 @@ function getInitialRange( /** * @internal * Insert a DOM node into editor content - * @param core The StandaloneEditorCore object. No op if null. + * @param core The ContentModelEditorCore object. No op if null. * @param option An insert option object to specify how to insert the node */ -export const insertNode: InsertNode = ( - core: StandaloneEditorCore, - node: Node, - option: InsertOption | null -) => { +export const insertNode: InsertNode = (core, innerCore, node, option) => { option = option || { position: ContentPosition.SelectionStart, insertOnNewLine: false, @@ -57,10 +55,10 @@ export const insertNode: InsertNode = ( replaceSelection: true, insertToRegionRoot: false, }; - const contentDiv = core.contentDiv; + const { contentDiv, api, lifecycle, darkColorHandler } = innerCore; if (option.updateCursor) { - core.api.focus(core); + api.focus(innerCore); } if (option.position == ContentPosition.Outside) { @@ -68,8 +66,8 @@ export const insertNode: InsertNode = ( return true; } - if (core.lifecycle.isDarkMode) { - transformColor(node, true /*includeSelf*/, 'lightToDark', core.darkColorHandler); + if (lifecycle.isDarkMode) { + transformColor(node, true /*includeSelf*/, 'lightToDark', darkColorHandler); } switch (option.position) { @@ -134,7 +132,7 @@ export const insertNode: InsertNode = ( break; case ContentPosition.Range: case ContentPosition.SelectionStart: - let { range, rangeToRestore } = getInitialRange(core, option); + let { range, rangeToRestore } = getInitialRange(innerCore, option); if (!range) { break; } @@ -148,12 +146,12 @@ export const insertNode: InsertNode = ( let blockElement: BlockElement | null; if (option.insertOnNewLine && option.insertToRegionRoot) { - pos = adjustInsertPositionRegionRoot(core, range, pos); + pos = adjustInsertPositionRegionRoot(innerCore, range, pos); } else if ( option.insertOnNewLine && (blockElement = getBlockElementAtNode(contentDiv, pos.normalize().node)) ) { - pos = adjustInsertPositionNewLine(blockElement, core, pos); + pos = adjustInsertPositionNewLine(blockElement, innerCore, pos); } else { pos = adjustInsertPosition(contentDiv, node, pos, range); } @@ -171,7 +169,7 @@ export const insertNode: InsertNode = ( } if (rangeToRestore) { - core.api.setDOMSelection(core, { + api.setDOMSelection(innerCore, { type: 'range', range: rangeToRestore, }); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/setContent.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/setContent.ts index 00eb9d59e60..525b5b0c9bf 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/setContent.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/setContent.ts @@ -3,23 +3,33 @@ import { convertMetadataToDOMSelection } from '../editor/utils/selectionConverte import { extractContentMetadata, restoreContentWithEntityPlaceholder } from 'roosterjs-editor-dom'; import { PluginEventType } from 'roosterjs-editor-types'; import type { ContentMetadata } from 'roosterjs-editor-types'; -import type { SetContent, StandaloneEditorCore } from 'roosterjs-content-model-types'; +import type { SetContent } from '../publicTypes/ContentModelEditorCore'; +import type { StandaloneEditorCore } from 'roosterjs-content-model-types'; /** * @internal * Set HTML content to this editor. All existing content will be replaced. A ContentChanged event will be triggered * if triggerContentChangedEvent is set to true - * @param core The StandaloneEditorCore object + * @param core The ContentModelEditorCore object * @param content HTML content to set in * @param triggerContentChangedEvent True to trigger a ContentChanged event. Default value is true * @param metadata @optional Metadata of the content that helps editor know the selection and color mode. * If not passed, we will treat content as in light mode without selection */ -export const setContent: SetContent = (core, content, triggerContentChangedEvent, metadata) => { +export const setContent: SetContent = ( + core, + innerCore, + content, + triggerContentChangedEvent, + metadata +) => { + const { contentDiv, api, entity, trustedHTMLHandler, lifecycle, darkColorHandler } = innerCore; + let contentChanged = false; - if (core.contentDiv.innerHTML != content) { - core.api.triggerEvent( - core, + + if (innerCore.contentDiv.innerHTML != content) { + api.triggerEvent( + innerCore, { eventType: PluginEventType.BeforeSetContent, newContent: content, @@ -27,36 +37,36 @@ export const setContent: SetContent = (core, content, triggerContentChangedEvent true /*broadcast*/ ); - const entities = core.entity.entityMap; + const entities = entity.entityMap; const html = content || ''; const body = new DOMParser().parseFromString( - core.trustedHTMLHandler?.(html) ?? html, + trustedHTMLHandler?.(html) ?? html, 'text/html' ).body; - restoreContentWithEntityPlaceholder(body, core.contentDiv, entities); + restoreContentWithEntityPlaceholder(body, contentDiv, entities); - const metadataFromContent = extractContentMetadata(core.contentDiv); + const metadataFromContent = extractContentMetadata(contentDiv); metadata = metadata || metadataFromContent; - selectContentMetadata(core, metadata); + selectContentMetadata(innerCore, metadata); contentChanged = true; } - const isDarkMode = core.lifecycle.isDarkMode; + const isDarkMode = lifecycle.isDarkMode; if ((!metadata && isDarkMode) || (metadata && !!metadata.isDarkMode != !!isDarkMode)) { transformColor( - core.contentDiv, + contentDiv, false /*includeSelf*/, isDarkMode ? 'lightToDark' : 'darkToLight', - core.darkColorHandler + darkColorHandler ); contentChanged = true; } if (triggerContentChangedEvent && contentChanged) { - core.api.triggerEvent( - core, + api.triggerEvent( + innerCore, { eventType: PluginEventType.ContentChanged, source: ChangeSource.SetContent, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/ContextMenuPlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/ContextMenuPlugin.ts new file mode 100644 index 00000000000..570d9be53c1 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/ContextMenuPlugin.ts @@ -0,0 +1,110 @@ +import { PluginEventType } from 'roosterjs-editor-types'; +import type { ContentModelEditorOptions } from '../publicTypes/IContentModelEditor'; +import type { ContextMenuPluginState } from '../publicTypes/ContextMenuPluginState'; +import type { + ContextMenuProvider, + EditorPlugin, + IEditor, + PluginEvent, + PluginWithState, +} from 'roosterjs-editor-types'; + +/** + * Edit Component helps handle Content edit features + */ +class ContextMenuPlugin implements PluginWithState { + private editor: IEditor | null = null; + private state: ContextMenuPluginState; + private disposer: (() => void) | null = null; + + /** + * Construct a new instance of EditPlugin + * @param options The editor options + */ + constructor(options: ContentModelEditorOptions) { + this.state = { + contextMenuProviders: + options.plugins?.filter>(isContextMenuProvider) || [], + }; + } + + /** + * Get a friendly name of this plugin + */ + getName() { + return 'Edit'; + } + + /** + * Initialize this plugin. This should only be called from Editor + * @param editor Editor instance + */ + initialize(editor: IEditor) { + this.editor = editor; + this.disposer = this.editor.addDomEventHandler('contextmenu', this.onContextMenuEvent); + } + + /** + * Dispose this plugin + */ + dispose() { + this.disposer?.(); + this.disposer = null; + this.editor = null; + } + + /** + * Get plugin state object + */ + getState() { + return this.state; + } + + /** + * Handle events triggered from editor + * @param event PluginEvent object + */ + onPluginEvent(event: PluginEvent) {} + + private onContextMenuEvent = (e: Event) => { + const event = e as MouseEvent; + const allItems: any[] = []; + + // TODO: Remove dependency to ContentSearcher + const searcher = this.editor?.getContentSearcherOfCursor(); + const elementBeforeCursor = searcher?.getInlineElementBefore(); + + let eventTargetNode = event.target as Node; + if (event.button != 2 && elementBeforeCursor) { + eventTargetNode = elementBeforeCursor.getContainerNode(); + } + this.state.contextMenuProviders.forEach(provider => { + const items = provider.getContextMenuItems(eventTargetNode) ?? []; + if (items?.length > 0) { + if (allItems.length > 0) { + allItems.push(null); + } + + allItems.push(...items); + } + }); + this.editor?.triggerPluginEvent(PluginEventType.ContextMenu, { + rawEvent: event, + items: allItems, + }); + }; +} + +function isContextMenuProvider(source: EditorPlugin): source is ContextMenuProvider { + return !!(>source)?.getContextMenuItems; +} + +/** + * @internal + * Create a new instance of EditPlugin. + */ +export function createContextMenuPlugin( + options: ContentModelEditorOptions +): PluginWithState { + return new ContextMenuPlugin(options); +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/createCorePlugins.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/createCorePlugins.ts index 5b7706a9834..13cc54a3078 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/createCorePlugins.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/createCorePlugins.ts @@ -1,8 +1,8 @@ +import { createContextMenuPlugin } from './ContextMenuPlugin'; import { createEditPlugin } from './EditPlugin'; import { createEventTypeTranslatePlugin } from './EventTypeTranslatePlugin'; import { createNormalizeTablePlugin } from './NormalizeTablePlugin'; -import type { UnportedCorePlugins } from '../publicTypes/ContentModelCorePlugins'; -import type { UnportedCorePluginState } from 'roosterjs-content-model-types'; +import type { ContentModelCorePlugins } from '../publicTypes/ContentModelCorePlugins'; import type { ContentModelEditorOptions } from '../publicTypes/IContentModelEditor'; /** @@ -10,7 +10,7 @@ import type { ContentModelEditorOptions } from '../publicTypes/IContentModelEdit * Create Core Plugins * @param options Editor options */ -export function createCorePlugins(options: ContentModelEditorOptions): UnportedCorePlugins { +export function createCorePlugins(options: ContentModelEditorOptions): ContentModelCorePlugins { const map = options.corePluginOverride || {}; // The order matters, some plugin needs to be put before/after others to make sure event @@ -19,16 +19,6 @@ export function createCorePlugins(options: ContentModelEditorOptions): UnportedC eventTranslate: map.eventTranslate || createEventTypeTranslatePlugin(), edit: map.edit || createEditPlugin(), normalizeTable: map.normalizeTable || createNormalizeTablePlugin(), - }; -} - -/** - * @internal - * Get plugin state of core plugins - * @param corePlugins ContentModelCorePlugins object - */ -export function getPluginState(corePlugins: UnportedCorePlugins): UnportedCorePluginState { - return { - edit: corePlugins.edit.getState(), + contextMenu: map.contextMenu || createContextMenuPlugin(options), }; } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts index 495a8b5ee18..e22e8b84d4f 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts @@ -1,8 +1,17 @@ import { buildRangeEx } from './utils/buildRangeEx'; +import { createCorePlugins } from '../corePlugins/createCorePlugins'; import { createEditorCore } from './createEditorCore'; import { getObjectKeys } from 'roosterjs-content-model-dom'; import { getPendableFormatState } from './utils/getPendableFormatState'; -import { isBold, paste, redo, transformColor, undo } from 'roosterjs-content-model-core'; +import type { ContentModelCorePluginState } from '../publicTypes/ContentModelCorePlugins'; +import { + createModelFromHtml, + isBold, + redo, + StandaloneEditor, + transformColor, + undo, +} from 'roosterjs-content-model-core'; import { ChangeSource, ColorTransformDirection, @@ -18,7 +27,6 @@ import type { ContentChangedData, ContentChangedEvent, DOMEventHandler, - DarkColorHandler, DefaultFormat, EditorUndoState, ExperimentalFeatures, @@ -29,8 +37,6 @@ import type { NodePosition, PendableFormatState, PluginEvent, - PluginEventData, - PluginEventFromType, PositionType, Rect, Region, @@ -51,7 +57,6 @@ import type { CompatibleContentPosition, CompatibleExperimentalFeatures, CompatibleGetContentMode, - CompatiblePluginEventType, CompatibleQueryScope, CompatibleRegionType, } from 'roosterjs-editor-types/lib/compatibleTypes'; @@ -78,27 +83,14 @@ import type { ContentModelEditorOptions, IContentModelEditor, } from '../publicTypes/IContentModelEditor'; -import type { - ContentModelDocument, - ContentModelSegmentFormat, - DOMSelection, - DomToModelOption, - ModelToDomOption, - OnNodeCreated, - ContentModelFormatter, - FormatWithContentModelOptions, - EditorEnvironment, - Snapshot, - SnapshotsManager, - DOMEventRecord, -} from 'roosterjs-content-model-types'; +import type { DOMEventRecord } from 'roosterjs-content-model-types'; /** * Editor for Content Model. * (This class is still under development, and may still be changed in the future with some breaking changes) */ -export class ContentModelEditor implements IContentModelEditor { - private core: ContentModelEditorCore | null = null; +export class ContentModelEditor extends StandaloneEditor implements IContentModelEditor { + private contentModelEditorCore: ContentModelEditorCore | undefined; /** * Creates an instance of Editor @@ -106,125 +98,51 @@ export class ContentModelEditor implements IContentModelEditor { * @param options An optional options object to customize the editor */ constructor(contentDiv: HTMLDivElement, options: ContentModelEditorOptions = {}) { - this.core = createEditorCore(contentDiv, options); - this.core.plugins.forEach(plugin => plugin.initialize(this)); - } - - /** - * Create Content Model from DOM tree in this editor - * @param option The option to customize the behavior of DOM to Content Model conversion - */ - createContentModel( - option?: DomToModelOption, - selectionOverride?: DOMSelection - ): ContentModelDocument { - const core = this.getCore(); - - return core.api.createContentModel(core, option, selectionOverride); - } - - /** - * Set content with content model - * @param model The content model to set - * @param option Additional options to customize the behavior of Content Model to DOM conversion - * @param onNodeCreated An optional callback that will be called when a DOM node is created - */ - setContentModel( - model: ContentModelDocument, - option?: ModelToDomOption, - onNodeCreated?: OnNodeCreated - ): DOMSelection | null { - const core = this.getCore(); - - return core.api.setContentModel(core, model, option, onNodeCreated); - } - - /** - * Get current running environment, such as if editor is running on Mac - */ - getEnvironment(): EditorEnvironment { - return this.getCore().environment; - } - - /** - * Get current DOM selection - */ - getDOMSelection(): DOMSelection | null { - const core = this.getCore(); - - return core.api.getDOMSelection(core); - } - - /** - * Set DOMSelection into editor content. - * This is the replacement of IEditor.select. - * @param selection The selection to set - */ - setDOMSelection(selection: DOMSelection | null) { - const core = this.getCore(); - - core.api.setDOMSelection(core, selection); - } - - /** - * The general API to do format change with Content Model - * It will grab a Content Model for current editor content, and invoke a callback function - * to do format change. Then according to the return value, write back the modified content model into editor. - * If there is cached model, it will be used and updated. - * @param formatter Formatter function, see ContentModelFormatter - * @param options More options, see FormatWithContentModelOptions - */ - formatContentModel( - formatter: ContentModelFormatter, - options?: FormatWithContentModelOptions - ): void { - const core = this.getCore(); - - core.api.formatContentModel(core, formatter, options); - } - - /** - * Get pending format of editor if any, or return null - */ - getPendingFormat(): ContentModelSegmentFormat | null { - return this.getCore().format.pendingFormat?.format ?? null; - } - - /** - * Add a single undo snapshot to undo stack - */ - takeSnapshot(): void { - const core = this.getCore(); - - core.api.addUndoSnapshot(core, false /*canUndoByBackspace*/); - } - - /** - * Restore an undo snapshot into editor - * @param snapshot The snapshot to restore - */ - restoreSnapshot(snapshot: Snapshot): void { - const core = this.getCore(); + const corePlugins = createCorePlugins(options); + const plugins = [ + corePlugins.eventTranslate, + corePlugins.edit, + ...(options.plugins ?? []), + corePlugins.contextMenu, + corePlugins.normalizeTable, + ]; + const initContent = options.initialContent ?? contentDiv.innerHTML; + const initialModel = + initContent && !options.initialModel + ? createModelFromHtml( + initContent, + options.defaultDomToModelOptions, + options.trustedHTMLHandler, + options.defaultSegmentFormat + ) + : options.initialModel; + const standaloneEditorOptions: ContentModelEditorOptions = { + ...options, + plugins: plugins, + initialModel, + }; + const corePluginState: ContentModelCorePluginState = { + edit: corePlugins.edit.getState(), + contextMenu: corePlugins.contextMenu.getState(), + }; - core.api.restoreUndoSnapshot(core, snapshot); + super(contentDiv, standaloneEditorOptions, () => { + // Need to create Content Model Editor Core before initialize plugins since some plugins need this object + this.contentModelEditorCore = createEditorCore( + options, + corePluginState, + size => size / this.getCore().zoomScale + ); + }); } /** * Dispose this editor, dispose all plugins and custom data */ dispose(): void { - const core = this.getCore(); + super.dispose(); - for (let i = core.plugins.length - 1; i >= 0; i--) { - const plugin = core.plugins[i]; - - try { - plugin.dispose(); - } catch (e) { - // Cache the error and pass it out, then keep going since dispose should always succeed - core.disposeErrorHandler?.(plugin, e as Error); - } - } + const core = this.getContentModelEditorCore(); getObjectKeys(core.customData).forEach(key => { const data = core.customData[key]; @@ -236,9 +154,7 @@ export class ContentModelEditor implements IContentModelEditor { delete core.customData[key]; }); - core.darkColorHandler.reset(); - - this.core = null; + this.contentModelEditorCore = undefined; } /** @@ -246,7 +162,7 @@ export class ContentModelEditor implements IContentModelEditor { * @returns True if editor is disposed, otherwise false */ isDisposed(): boolean { - return !this.core; + return super.isDisposed() || !this.contentModelEditorCore; } /** @@ -260,8 +176,10 @@ export class ContentModelEditor implements IContentModelEditor { * @returns true if node is inserted. Otherwise false */ insertNode(node: Node, option?: InsertOption): boolean { - const core = this.getCore(); - return node ? core.api.insertNode(core, node, option ?? null) : false; + const core = this.getContentModelEditorCore(); + const innerCore = this.getCore(); + + return node ? core.api.insertNode(core, innerCore, node, option ?? null) : false; } /** @@ -377,8 +295,10 @@ export class ContentModelEditor implements IContentModelEditor { * @returns HTML string representing current editor content */ getContent(mode: GetContentMode | CompatibleGetContentMode = GetContentMode.CleanHTML): string { - const core = this.getCore(); - return core.api.getContent(core, mode); + const core = this.getContentModelEditorCore(); + const innerCore = this.getCore(); + + return core.api.getContent(core, innerCore, mode); } /** @@ -387,8 +307,10 @@ export class ContentModelEditor implements IContentModelEditor { * @param triggerContentChangedEvent True to trigger a ContentChanged event. Default value is true */ setContent(content: string, triggerContentChangedEvent: boolean = true) { - const core = this.getCore(); - core.api.setContent(core, content, triggerContentChangedEvent); + const core = this.getContentModelEditorCore(); + const innerCore = this.getCore(); + + core.api.setContent(core, innerCore, content, triggerContentChangedEvent); } /** @@ -448,8 +370,7 @@ export class ContentModelEditor implements IContentModelEditor { applyCurrentFormat: boolean = false, pasteAsImage: boolean = false ) { - paste( - this, + this.pasteFromClipboard( clipboardData, pasteAsText ? 'asPlainText' @@ -501,23 +422,6 @@ export class ContentModelEditor implements IContentModelEditor { return range && getSelectionPath(this.getCore().contentDiv, range); } - /** - * Check if focus is in editor now - * @returns true if focus is in editor, otherwise false - */ - hasFocus(): boolean { - const core = this.getCore(); - return core.api.hasFocus(core); - } - - /** - * Focus to this editor, the selection was restored to where it was before, no unexpected scroll. - */ - focus() { - const core = this.getCore(); - core.api.focus(core); - } - select( arg1: Range | SelectionRangeEx | NodePosition | Node | SelectionPath | null, arg2?: NodePosition | number | PositionType | TableSelection | null, @@ -611,15 +515,6 @@ export class ContentModelEditor implements IContentModelEditor { //#region EVENT API - /** - * Attach a DOM event to the editor content DIV - * @param eventMap A map from event name to its handler - */ - attachDomEvent(eventMap: Record): () => void { - const core = this.getCore(); - return core.api.attachDomEvent(core, eventMap); - } - addDomEventHandler( nameOrMap: string | Record, handler?: DOMEventHandler @@ -648,30 +543,6 @@ export class ContentModelEditor implements IContentModelEditor { return this.attachDomEvent(eventsMapResult); } - /** - * Trigger an event to be dispatched to all plugins - * @param eventType Type of the event - * @param data data of the event with given type, this is the rest part of PluginEvent with the given type - * @param broadcast indicates if the event needs to be dispatched to all plugins - * True means to all, false means to allow exclusive handling from one plugin unless no one wants that - * @returns the event object which is really passed into plugins. Some plugin may modify the event object so - * the result of this function provides a chance to read the modified result - */ - triggerPluginEvent( - eventType: T, - data: PluginEventData, - broadcast: boolean = false - ): PluginEventFromType { - const core = this.getCore(); - const event = ({ - eventType, - ...data, - } as any) as PluginEventFromType; - core.api.triggerEvent(core, event, broadcast); - - return event; - } - /** * Trigger a ContentChangedEvent * @param source Source of this event, by default is 'SetContent' @@ -691,15 +562,6 @@ export class ContentModelEditor implements IContentModelEditor { //#region Undo API - /** - * Get undo snapshots manager - */ - getSnapshotsManager(): SnapshotsManager { - const core = this.getCore(); - - return core.undo.snapshotsManager; - } - /** * Undo last edit operation */ @@ -811,14 +673,6 @@ export class ContentModelEditor implements IContentModelEditor { //#region Misc - /** - * Get document which contains this editor - * @returns The HTML document which contains this editor - */ - getDocument(): Document { - return this.getCore().contentDiv.ownerDocument; - } - /** * Get the scroll container of the editor */ @@ -835,21 +689,13 @@ export class ContentModelEditor implements IContentModelEditor { * dispose editor. */ getCustomData(key: string, getter?: () => T, disposer?: (value: T) => void): T { - const core = this.getCore(); + const core = this.getContentModelEditorCore(); return (core.customData[key] = core.customData[key] || { value: getter ? getter() : undefined, disposer, }).value as T; } - /** - * Check if editor is in IME input sequence - * @returns True if editor is in IME input sequence, otherwise false - */ - isInIME(): boolean { - return this.getCore().domEvent.isInIME; - } - /** * Get default format of this editor * @returns Default format object of this editor @@ -992,7 +838,7 @@ export class ContentModelEditor implements IContentModelEditor { * @param feature The feature to add */ addContentEditFeature(feature: GenericContentEditFeature) { - const core = this.getCore(); + const core = this.getContentModelEditorCore(); feature?.keys.forEach(key => { const array = core.edit.features[key] || []; array.push(feature); @@ -1005,7 +851,7 @@ export class ContentModelEditor implements IContentModelEditor { * @param feature The feature to remove */ removeContentEditFeature(feature: GenericContentEditFeature) { - const core = this.getCore(); + const core = this.getContentModelEditorCore(); feature?.keys.forEach(key => { const featureSet = core.edit.features[key]; const index = featureSet?.indexOf(feature) ?? -1; @@ -1026,8 +872,10 @@ export class ContentModelEditor implements IContentModelEditor { const range = this.getSelectionRange(); node = (range && Position.getStart(range).normalize().node) ?? undefined; } - const core = this.getCore(); - return core.api.getStyleBasedFormatState(core, node ?? null); + const core = this.getContentModelEditorCore(); + const innerCore = this.getCore(); + + return core.api.getStyleBasedFormatState(core, innerCore, node ?? null); } /** @@ -1046,8 +894,9 @@ export class ContentModelEditor implements IContentModelEditor { * @param keyboardEvent Optional keyboard event object */ ensureTypeInContainer(position: NodePosition, keyboardEvent?: KeyboardEvent) { - const core = this.getCore(); - core.api.ensureTypeInContainer(core, position, keyboardEvent); + const core = this.getContentModelEditorCore(); + const innerCore = this.getCore(); + core.api.ensureTypeInContainer(core, innerCore, position, keyboardEvent); } //#endregion @@ -1078,14 +927,6 @@ export class ContentModelEditor implements IContentModelEditor { ); } - /** - * Check if the editor is in dark mode - * @returns True if the editor is in dark mode, otherwise false - */ - isDarkMode(): boolean { - return this.getCore().lifecycle.isDarkMode; - } - /** * 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 @@ -1107,47 +948,12 @@ export class ContentModelEditor implements IContentModelEditor { ); } - /** - * Get a darkColorHandler object for this editor. - */ - getDarkColorHandler(): DarkColorHandler { - return this.getCore().darkColorHandler; - } - - /** - * Make the editor in "Shadow Edit" mode. - * In Shadow Edit mode, all format change will finally be ignored. - * This can be used for building a live preview feature for format button, to allow user - * see format result without really apply it. - * This function can be called repeated. If editor is already in shadow edit mode, we can still - * use this function to do more shadow edit operation. - */ - startShadowEdit() { - const core = this.getCore(); - core.api.switchShadowEdit(core, true /*isOn*/); - } - - /** - * Leave "Shadow Edit" mode, all changes made during shadow edit will be discarded - */ - stopShadowEdit() { - const core = this.getCore(); - core.api.switchShadowEdit(core, false /*isOn*/); - } - - /** - * Check if editor is in Shadow Edit mode - */ - isInShadowEdit() { - return !!this.getCore().lifecycle.shadowEditFragment; - } - /** * Check if the given experimental feature is enabled * @param feature The feature to check */ isFeatureEnabled(feature: ExperimentalFeatures | CompatibleExperimentalFeatures): boolean { - return this.getCore().experimentalFeatures.indexOf(feature) >= 0; + return this.getContentModelEditorCore().experimentalFeatures.indexOf(feature) >= 0; } /** @@ -1164,17 +970,7 @@ export class ContentModelEditor implements IContentModelEditor { * @deprecated Use getZoomScale() instead */ getSizeTransformer(): SizeTransformer { - return this.getCore().sizeTransformer; - } - - /** - * Get current zoom scale, default value is 1 - * When editor is put under a zoomed container, need to pass the zoom scale number using EditorOptions.zoomScale - * to let editor behave correctly especially for those mouse drag/drop behaviors - * @returns current zoom scale number - */ - getZoomScale(): number { - return this.getCore().zoomScale; + return this.getContentModelEditorCore().sizeTransformer; } /** @@ -1185,6 +981,7 @@ export class ContentModelEditor implements IContentModelEditor { */ setZoomScale(scale: number): void { const core = this.getCore(); + if (scale > 0 && scale <= 10) { const oldValue = core.zoomScale; core.zoomScale = scale; @@ -1215,10 +1012,11 @@ export class ContentModelEditor implements IContentModelEditor { * @returns the current ContentModelEditorCore object * @throws a standard Error if there's no core object */ - private getCore(): ContentModelEditorCore { - if (!this.core) { + private getContentModelEditorCore(): ContentModelEditorCore { + if (!this.contentModelEditorCore) { throw new Error('Editor is already disposed'); } - return this.core; + + return this.contentModelEditorCore; } } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/createEditorCore.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/createEditorCore.ts index 6174bdc7958..099e2068305 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/createEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/createEditorCore.ts @@ -1,57 +1,28 @@ import { coreApiMap } from '../coreApi/coreApiMap'; -import { createCorePlugins, getPluginState } from '../corePlugins/createCorePlugins'; -import { createModelFromHtml, createStandaloneEditorCore } from 'roosterjs-content-model-core'; +import type { ContentModelCorePluginState } from '../publicTypes/ContentModelCorePlugins'; import type { ContentModelEditorCore } from '../publicTypes/ContentModelEditorCore'; import type { ContentModelEditorOptions } from '../publicTypes/IContentModelEditor'; -import type { EditorPlugin } from 'roosterjs-editor-types'; +import type { SizeTransformer } from 'roosterjs-editor-types'; /** * @internal * Create a new instance of Content Model Editor Core * @param contentDiv The DIV HTML element which will be the container element of editor - * @param options An optional options object to customize the editor + * @param corePluginState Core plugin state for Content Model editor + * @param sizeTransformer @deprecated A size transformer function to calculate size when editor is zoomed */ export function createEditorCore( - contentDiv: HTMLDivElement, - options: ContentModelEditorOptions + options: ContentModelEditorOptions, + corePluginState: ContentModelCorePluginState, + sizeTransformer: SizeTransformer ): ContentModelEditorCore { - const corePlugins = createCorePlugins(options); - const pluginState = getPluginState(corePlugins); - const additionalPlugins: EditorPlugin[] = [ - corePlugins.eventTranslate, - corePlugins.edit, - ...(options.plugins ?? []), - corePlugins.normalizeTable, - ].filter(x => !!x); - - const zoomScale: number = (options.zoomScale ?? -1) > 0 ? options.zoomScale! : 1; - const initContent = options.initialContent ?? contentDiv.innerHTML; - - if (initContent && !options.initialModel) { - options.initialModel = createModelFromHtml( - initContent, - options.defaultDomToModelOptions, - options.trustedHTMLHandler, - options.defaultSegmentFormat - ); - } - - const standaloneEditorCore = createStandaloneEditorCore( - contentDiv, - options, - coreApiMap, - pluginState, - additionalPlugins - ); - const core: ContentModelEditorCore = { - ...standaloneEditorCore, - ...pluginState, - zoomScale: zoomScale, - sizeTransformer: (size: number) => size / zoomScale, - disposeErrorHandler: options.disposeErrorHandler, + api: { ...coreApiMap, ...options.legacyCoreApiOverride }, + originalApi: coreApiMap, customData: {}, experimentalFeatures: options.experimentalFeatures ?? [], + sizeTransformer, + ...corePluginState, }; return core; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/buildRangeEx.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/buildRangeEx.ts index 9acba99f71a..72f6affc879 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/buildRangeEx.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/buildRangeEx.ts @@ -1,6 +1,6 @@ import { createRange, safeInstanceOf } from 'roosterjs-editor-dom'; import { SelectionRangeTypes } from 'roosterjs-editor-types'; -import type { ContentModelEditorCore } from '../../publicTypes/ContentModelEditorCore'; +import type { StandaloneEditorCore } from 'roosterjs-content-model-types'; import type { NodePosition, PositionType, @@ -13,7 +13,7 @@ import type { * @internal */ export function buildRangeEx( - core: ContentModelEditorCore, + core: StandaloneEditorCore, arg1: Range | SelectionRangeEx | NodePosition | Node | SelectionPath | null, arg2?: NodePosition | number | PositionType | TableSelection | null, arg3?: Node, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/index.ts b/packages-content-model/roosterjs-content-model-editor/lib/index.ts index ac1905956cb..8d68b1d4181 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/index.ts @@ -1,8 +1,17 @@ -export { ContentModelEditorCore } from './publicTypes/ContentModelEditorCore'; +export { + ContentModelEditorCore, + ContentModelCoreApiMap, + SetContent, + InsertNode, + GetContent, + GetStyleBasedFormatState, + EnsureTypeInContainer, +} from './publicTypes/ContentModelEditorCore'; export { IContentModelEditor, ContentModelEditorOptions } from './publicTypes/IContentModelEditor'; +export { ContextMenuPluginState } from './publicTypes/ContextMenuPluginState'; export { ContentModelCorePlugins, - UnportedCorePlugins, + ContentModelCorePluginState, } from './publicTypes/ContentModelCorePlugins'; export { ContentModelEditor } from './editor/ContentModelEditor'; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelCorePlugins.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelCorePlugins.ts index 73332c80dfc..b3b29f49ad9 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelCorePlugins.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelCorePlugins.ts @@ -1,11 +1,10 @@ -import type { StandaloneEditorCorePlugins } from 'roosterjs-content-model-types'; -import type { EditPluginState, EditorPlugin, PluginWithState } from 'roosterjs-editor-types'; +import type { ContextMenuPluginState } from './ContextMenuPluginState'; +import type { EditorPlugin, EditPluginState, PluginWithState } from 'roosterjs-editor-types'; /** - * An interface for unported core plugins - * TODO: Port these plugins + * An interface for Content Model editor core plugins */ -export interface UnportedCorePlugins { +export interface ContentModelCorePlugins { /** * Translate Standalone editor event type to legacy event type */ @@ -20,9 +19,24 @@ export interface UnportedCorePlugins { * NormalizeTable plugin makes sure each table in editor has TBODY/THEAD/TFOOT tag around TR tags */ readonly normalizeTable: EditorPlugin; + + /** + * ContextMenu plugin handles Context Menu + */ + readonly contextMenu: PluginWithState; } /** - * An interface for Content Model editor core plugins. + * Core plugin state for Content Model Editor */ -export interface ContentModelCorePlugins extends StandaloneEditorCorePlugins, UnportedCorePlugins {} +export interface ContentModelCorePluginState { + /** + * Plugin state of EditPlugin + */ + readonly edit: EditPluginState; + + /** + * Plugin state of ContextMenuPlugin + */ + readonly contextMenu: ContextMenuPluginState; +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelEditorCore.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelEditorCore.ts index 11f1707122f..8c88702cd75 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelEditorCore.ts @@ -1,52 +1,166 @@ -import type { CompatibleExperimentalFeatures } from 'roosterjs-editor-types/lib/compatibleTypes'; +import type { ContentModelCorePluginState } from './ContentModelCorePlugins'; +import type { StandaloneEditorCore } from 'roosterjs-content-model-types'; +import type { + CompatibleGetContentMode, + CompatibleExperimentalFeatures, +} from 'roosterjs-editor-types/lib/compatibleTypes'; import type { CustomData, - EditorPlugin, ExperimentalFeatures, + ContentMetadata, + GetContentMode, + InsertOption, + NodePosition, + StyleBasedFormatState, SizeTransformer, } from 'roosterjs-editor-types'; -import type { StandaloneCoreApiMap, StandaloneEditorCore } from 'roosterjs-content-model-types'; /** - * Represents the core data structure of a Content Model editor + * Set HTML content to this editor. All existing content will be replaced. A ContentChanged event will be triggered + * if triggerContentChangedEvent is set to true + * @param core The ContentModelEditorCore object + * @param innerCore The StandaloneEditorCore object + * @param content HTML content to set in + * @param triggerContentChangedEvent True to trigger a ContentChanged event. Default value is true + */ +export type SetContent = ( + core: ContentModelEditorCore, + innerCore: StandaloneEditorCore, + content: string, + triggerContentChangedEvent: boolean, + metadata?: ContentMetadata +) => void; + +/** + * Get current editor content as HTML string + * @param core The ContentModelEditorCore object + * @param innerCore The StandaloneEditorCore object + * @param mode specify what kind of HTML content to retrieve + * @returns HTML string representing current editor content */ -export interface ContentModelEditorCore extends StandaloneEditorCore { +export type GetContent = ( + core: ContentModelEditorCore, + innerCore: StandaloneEditorCore, + mode: GetContentMode | CompatibleGetContentMode +) => string; + +/** + * Insert a DOM node into editor content + * @param core The ContentModelEditorCore object. No op if null. + * @param innerCore The StandaloneEditorCore object + * @param option An insert option object to specify how to insert the node + */ +export type InsertNode = ( + core: ContentModelEditorCore, + innerCore: StandaloneEditorCore, + node: Node, + option: InsertOption | null +) => boolean; + +/** + * Get style based format state from current selection, including font name/size and colors + * @param core The ContentModelEditorCore objects + * @param innerCore The StandaloneEditorCore object + * @param node The node to get style from + */ +export type GetStyleBasedFormatState = ( + core: ContentModelEditorCore, + innerCore: StandaloneEditorCore, + node: Node | null +) => StyleBasedFormatState; + +/** + * Ensure user will type into a container element rather than into the editor content DIV directly + * @param core The ContentModelEditorCore object. + * @param innerCore The StandaloneEditorCore object + * @param position The position that user is about to type to + * @param keyboardEvent Optional keyboard event object + * @param deprecated Deprecated parameter, not used + */ +export type EnsureTypeInContainer = ( + core: ContentModelEditorCore, + innerCore: StandaloneEditorCore, + position: NodePosition, + keyboardEvent?: KeyboardEvent, + deprecated?: boolean +) => void; + +/** + * Core API map for Content Model editor + */ +export interface ContentModelCoreApiMap { /** - * Core API map of this editor + * Set HTML content to this editor. All existing content will be replaced. A ContentChanged event will be triggered + * if triggerContentChangedEvent is set to true + * @param core The ContentModelEditorCore object + * @param innerCore The StandaloneEditorCore object + * @param content HTML content to set in + * @param triggerContentChangedEvent True to trigger a ContentChanged event. Default value is true */ - readonly api: StandaloneCoreApiMap; + setContent: SetContent; /** - * Original API map of this editor. Overridden core API can use API from this map to call the original version of core API. + * Insert a DOM node into editor content + * @param core The ContentModelEditorCore object. No op if null. + * @param innerCore The StandaloneEditorCore object + * @param option An insert option object to specify how to insert the node */ - readonly originalApi: StandaloneCoreApiMap; + insertNode: InsertNode; - /* - * Current zoom scale, default value is 1 - * When editor is put under a zoomed container, need to pass the zoom scale number using this property - * to let editor behave correctly especially for those mouse drag/drop behaviors + /** + * Get current editor content as HTML string + * @param core The ContentModelEditorCore object + * @param innerCore The StandaloneEditorCore object + * @param mode specify what kind of HTML content to retrieve + * @returns HTML string representing current editor content */ - zoomScale: number; + getContent: GetContent; /** - * @deprecated Use zoomScale instead + * Get style based format state from current selection, including font name/size and colors + * @param core The ContentModelEditorCore objects + * @param innerCore The StandaloneEditorCore object + * @param node The node to get style from + */ + getStyleBasedFormatState: GetStyleBasedFormatState; + + /** + * Ensure user will type into a container element rather than into the editor content DIV directly + * @param core The EditorCore object. + * @param innerCore The StandaloneEditorCore object + * @param position The position that user is about to type to + * @param keyboardEvent Optional keyboard event object + * @param deprecated Deprecated parameter, not used + */ + ensureTypeInContainer: EnsureTypeInContainer; +} + +/** + * Represents the core data structure of a Content Model editor + */ +export interface ContentModelEditorCore extends ContentModelCorePluginState { + /** + * Core API map of this editor */ - sizeTransformer: SizeTransformer; + readonly api: ContentModelCoreApiMap; /** - * A callback to be invoked when any exception is thrown during disposing editor - * @param plugin The plugin that causes exception - * @param error The error object we got + * Original API map of this editor. Overridden core API can use API from this map to call the original version of core API. */ - disposeErrorHandler?: (plugin: EditorPlugin, error: Error) => void; + readonly originalApi: ContentModelCoreApiMap; /** * Custom data of this editor */ - customData: Record; + readonly customData: Record; /** * Enabled experimental features */ - experimentalFeatures: (ExperimentalFeatures | CompatibleExperimentalFeatures)[]; + readonly experimentalFeatures: (ExperimentalFeatures | CompatibleExperimentalFeatures)[]; + + /** + * @deprecated Use zoomScale instead + */ + readonly sizeTransformer: SizeTransformer; } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContextMenuPluginState.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContextMenuPluginState.ts new file mode 100644 index 00000000000..7b2f58a8d66 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContextMenuPluginState.ts @@ -0,0 +1,11 @@ +import type { ContextMenuProvider } from 'roosterjs-editor-types'; + +/** + * The state object for DOMEventPlugin + */ +export interface ContextMenuPluginState { + /** + * Context menu providers, that can provide context menu items + */ + contextMenuProviders: ContextMenuProvider[]; +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts index e25aa2bf649..d1aa0ef21fc 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts @@ -1,5 +1,6 @@ import type { ContentModelCorePlugins } from './ContentModelCorePlugins'; -import type { EditorPlugin, ExperimentalFeatures, IEditor } from 'roosterjs-editor-types'; +import type { ContentModelCoreApiMap } from './ContentModelEditorCore'; +import type { ExperimentalFeatures, IEditor } from 'roosterjs-editor-types'; import type { StandaloneEditorOptions, IStandaloneEditor } from 'roosterjs-content-model-types'; /** @@ -28,18 +29,9 @@ export interface ContentModelEditorOptions extends StandaloneEditorOptions { * Specify the enabled experimental features */ experimentalFeatures?: ExperimentalFeatures[]; - /** - * Current zoom scale, @default value is 1 - * When editor is put under a zoomed container, need to pass the zoom scale number using this property - * to let editor behave correctly especially for those mouse drag/drop behaviors - */ - zoomScale?: number; - - /** - * A callback to be invoked when any exception is thrown during disposing editor - * @param plugin The plugin that causes exception - * @param error The error object we got + * A function map to override default core API implementation + * Default value is null */ - disposeErrorHandler?: (plugin: EditorPlugin, error: Error) => void; + legacyCoreApiOverride?: Partial; } diff --git a/packages-content-model/roosterjs-content-model-editor/test/corePlugins/ContextMenuPluginTest.ts b/packages-content-model/roosterjs-content-model-editor/test/corePlugins/ContextMenuPluginTest.ts new file mode 100644 index 00000000000..9df59088b80 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/test/corePlugins/ContextMenuPluginTest.ts @@ -0,0 +1,93 @@ +import { ContextMenuPluginState } from '../../lib/publicTypes/ContextMenuPluginState'; +import { createContextMenuPlugin } from '../../lib/corePlugins/ContextMenuPlugin'; +import { IEditor, PluginEventType, PluginWithState } from 'roosterjs-editor-types'; +import { IStandaloneEditor } from 'roosterjs-content-model-types'; + +describe('ContextMenu handle other event', () => { + let plugin: PluginWithState; + let addEventListener: jasmine.Spy; + let removeEventListener: jasmine.Spy; + let triggerPluginEvent: jasmine.Spy; + let eventMap: Record; + let getElementAtCursorSpy: jasmine.Spy; + let triggerContentChangedEventSpy: jasmine.Spy; + let editor: IEditor & IStandaloneEditor; + + beforeEach(() => { + addEventListener = jasmine.createSpy('addEventListener'); + removeEventListener = jasmine.createSpy('.removeEventListener'); + triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); + getElementAtCursorSpy = jasmine.createSpy('getElementAtCursor'); + triggerContentChangedEventSpy = jasmine.createSpy('triggerContentChangedEvent'); + + editor = ({ + getDocument: () => ({ + addEventListener, + removeEventListener, + }), + triggerPluginEvent, + getEnvironment: () => ({}), + addDomEventHandler: (name: string, handler: Function) => { + eventMap = { + [name]: { + beforeDispatch: handler, + }, + }; + }, + getElementAtCursor: getElementAtCursorSpy, + triggerContentChangedEvent: triggerContentChangedEventSpy, + }); + }); + + afterEach(() => { + plugin.dispose(); + }); + + it('Ctor with parameter', () => { + const mockedPlugin1 = {} as any; + const mockedPlugin2 = { + getContextMenuItems: () => {}, + } as any; + + plugin = createContextMenuPlugin({ + plugins: [mockedPlugin1, mockedPlugin2], + }); + plugin.initialize(editor); + + const state = plugin.getState(); + + expect(state).toEqual({ + contextMenuProviders: [mockedPlugin2], + }); + }); + + it('Trigger contextmenu event, skip reselect', () => { + plugin = createContextMenuPlugin({}); + plugin.initialize(editor); + + editor.getContentSearcherOfCursor = () => null!; + const state = plugin.getState(); + const mockedItems1 = ['Item1', 'Item2']; + const mockedItems2 = ['Item3', 'Item4']; + + state.contextMenuProviders = [ + { + getContextMenuItems: () => mockedItems1, + } as any, + { + getContextMenuItems: () => mockedItems2, + } as any, + ]; + + const mockedEvent = { + target: {}, + }; + + eventMap.contextmenu.beforeDispatch(mockedEvent); + + expect(triggerPluginEvent).toHaveBeenCalledWith(PluginEventType.ContextMenu, { + rawEvent: mockedEvent, + items: ['Item1', 'Item2', null, 'Item3', 'Item4'], + }); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts index f9f2d178473..3bd949a0d57 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts @@ -3,10 +3,13 @@ import * as createDomToModelContext from 'roosterjs-content-model-dom/lib/domToM import * as createModelToDomContext from 'roosterjs-content-model-dom/lib/modelToDom/context/createModelToDomContext'; import * as domToContentModel from 'roosterjs-content-model-dom/lib/domToModel/domToContentModel'; import * as findAllEntities from 'roosterjs-content-model-core/lib/corePlugin/utils/findAllEntities'; -import { ContentModelDocument, EditorContext } from 'roosterjs-content-model-types'; import { ContentModelEditor } from '../../lib/editor/ContentModelEditor'; -import { ContentModelEditorCore } from '../../lib/publicTypes/ContentModelEditorCore'; import { EditorPlugin, PluginEventType } from 'roosterjs-editor-types'; +import { + ContentModelDocument, + EditorContext, + StandaloneEditorCore, +} from 'roosterjs-content-model-types'; const editorContext: EditorContext = { isDarkMode: false, @@ -267,7 +270,7 @@ describe('ContentModelEditor', () => { it('getPendingFormat', () => { const div = document.createElement('div'); const editor = new ContentModelEditor(div); - const core: ContentModelEditorCore = (editor as any).core; + const core: StandaloneEditorCore = (editor as any).core; const mockedFormat = 'FORMAT' as any; expect(editor.getPendingFormat()).toBeNull(); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/createEditorCoreTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/createEditorCoreTest.ts index ffa4589a29c..63498927e71 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/createEditorCoreTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/createEditorCoreTest.ts @@ -1,195 +1,61 @@ -import * as ContentModelCachePlugin from 'roosterjs-content-model-core/lib/corePlugin/ContentModelCachePlugin'; -import * as ContentModelCopyPastePlugin from 'roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin'; -import * as ContentModelFormatPlugin from 'roosterjs-content-model-core/lib/corePlugin/ContentModelFormatPlugin'; -import * as createStandaloneEditorDefaultSettings from 'roosterjs-content-model-core/lib/editor/createStandaloneEditorDefaultSettings'; -import * as DOMEventPlugin from 'roosterjs-content-model-core/lib/corePlugin/DOMEventPlugin'; -import * as EditPlugin from '../../lib/corePlugins/EditPlugin'; -import * as EntityPlugin from 'roosterjs-content-model-core/lib/corePlugin/EntityPlugin'; -import * as EventTranslate from '../../lib/corePlugins/EventTypeTranslatePlugin'; -import * as LifecyclePlugin from 'roosterjs-content-model-core/lib/corePlugin/LifecyclePlugin'; -import * as NormalizeTablePlugin from '../../lib/corePlugins/NormalizeTablePlugin'; -import * as SelectionPlugin from 'roosterjs-content-model-core/lib/corePlugin/SelectionPlugin'; -import * as UndoPlugin from 'roosterjs-content-model-core/lib/corePlugin/UndoPlugin'; import { coreApiMap } from '../../lib/coreApi/coreApiMap'; import { createEditorCore } from '../../lib/editor/createEditorCore'; -import { defaultTrustHtmlHandler } from 'roosterjs-content-model-core/lib/editor/createStandaloneEditorCore'; -import { standaloneCoreApiMap } from 'roosterjs-content-model-core/lib/editor/standaloneCoreApiMap'; - -const mockedDomEventState = 'DOMEVENTSTATE' as any; -const mockedEditState = 'EDITSTATE' as any; -const mockedLifecycleState = 'LIFECYCLESTATE' as any; -const mockedUndoState = 'UNDOSTATE' as any; -const mockedEntityState = 'ENTITYSTATE' as any; -const mockedCopyPasteState = 'COPYPASTESTATE' as any; -const mockedCacheState = 'CACHESTATE' as any; -const mockedFormatState = 'FORMATSTATE' as any; -const mockedSelectionState = 'SELECTION' as any; - -const mockedFormatPlugin = { - getState: () => mockedFormatState, -} as any; -const mockedCachePlugin = { - getState: () => mockedCacheState, -} as any; -const mockedCopyPastePlugin = { - getState: () => mockedCopyPasteState, -} as any; -const mockedEditPlugin = { - getState: () => mockedEditState, -} as any; -const mockedUndoPlugin = { - getState: () => mockedUndoState, -} as any; -const mockedDOMEventPlugin = { - getState: () => mockedDomEventState, -} as any; -const mockedEntityPlugin = { - getState: () => mockedEntityState, -} as any; -const mockedSelectionPlugin = { - getState: () => mockedSelectionState, -} as any; -const mockedNormalizeTablePlugin = 'NormalizeTablePlugin' as any; -const mockedLifecyclePlugin = { - getState: () => mockedLifecycleState, -} as any; -const mockedEventTranslatePlugin = 'EventTranslate' as any; -const mockedDefaultSettings = { - settings: 'SETTINGS', -} as any; describe('createEditorCore', () => { - let contentDiv: any; + const mockedSizeTransformer = 'TRANSFORMER' as any; + const mockedEditPluginState = 'EDITSTATE' as any; + const mockedContextMenuPluginState = 'CONTEXTMENUSTATE' as any; - beforeEach(() => { - contentDiv = { - style: {}, - } as any; - - spyOn(ContentModelFormatPlugin, 'createContentModelFormatPlugin').and.returnValue( - mockedFormatPlugin - ); - spyOn(ContentModelCachePlugin, 'createContentModelCachePlugin').and.returnValue( - mockedCachePlugin - ); - spyOn(ContentModelCopyPastePlugin, 'createContentModelCopyPastePlugin').and.returnValue( - mockedCopyPastePlugin - ); - spyOn(EditPlugin, 'createEditPlugin').and.returnValue(mockedEditPlugin); - spyOn(UndoPlugin, 'createUndoPlugin').and.returnValue(mockedUndoPlugin); - spyOn(DOMEventPlugin, 'createDOMEventPlugin').and.returnValue(mockedDOMEventPlugin); - spyOn(SelectionPlugin, 'createSelectionPlugin').and.returnValue(mockedSelectionPlugin); - spyOn(EntityPlugin, 'createEntityPlugin').and.returnValue(mockedEntityPlugin); - spyOn(NormalizeTablePlugin, 'createNormalizeTablePlugin').and.returnValue( - mockedNormalizeTablePlugin - ); - spyOn(LifecyclePlugin, 'createLifecyclePlugin').and.returnValue(mockedLifecyclePlugin); - spyOn(EventTranslate, 'createEventTypeTranslatePlugin').and.returnValue( - mockedEventTranslatePlugin + it('No additional option', () => { + const core = createEditorCore( + {}, + { + edit: mockedEditPluginState, + contextMenu: mockedContextMenuPluginState, + }, + mockedSizeTransformer ); - spyOn( - createStandaloneEditorDefaultSettings, - 'createStandaloneEditorDefaultSettings' - ).and.returnValue(mockedDefaultSettings); - }); - it('No additional option', () => { - const core = createEditorCore(contentDiv, {}); expect(core).toEqual({ - contentDiv, - api: { ...coreApiMap, ...standaloneCoreApiMap }, - originalApi: { ...coreApiMap, ...standaloneCoreApiMap }, - plugins: [ - mockedCachePlugin, - mockedFormatPlugin, - mockedCopyPastePlugin, - mockedDOMEventPlugin, - mockedSelectionPlugin, - mockedEntityPlugin, - mockedEventTranslatePlugin, - mockedEditPlugin, - mockedNormalizeTablePlugin, - mockedUndoPlugin, - mockedLifecyclePlugin, - ], - domEvent: mockedDomEventState, - edit: mockedEditState, - lifecycle: mockedLifecycleState, - undo: mockedUndoState, - entity: mockedEntityState, - copyPaste: mockedCopyPasteState, - cache: mockedCacheState, - format: mockedFormatState, - selection: mockedSelectionState, - trustedHTMLHandler: defaultTrustHtmlHandler, - zoomScale: 1, - sizeTransformer: jasmine.anything(), - darkColorHandler: jasmine.anything(), - disposeErrorHandler: undefined, - ...mockedDefaultSettings, - environment: { - isMac: false, - isAndroid: false, - isSafari: false, - }, + api: { ...coreApiMap }, + originalApi: { ...coreApiMap }, customData: {}, experimentalFeatures: [], + edit: mockedEditPluginState, + contextMenu: mockedContextMenuPluginState, + sizeTransformer: mockedSizeTransformer, }); }); - it('With additional option', () => { - const defaultDomToModelOptions = { a: '1' } as any; - const defaultModelToDomOptions = { b: '2' } as any; - - const options = { - defaultDomToModelOptions, - defaultModelToDomOptions, - }; - const core = createEditorCore(contentDiv, options); + it('With additional plugins', () => { + const mockedPlugin1 = 'P1' as any; + const mockedPlugin2 = 'P2' as any; + const mockedFeatures = 'FEATURES' as any; + const mockedCoreApi = { + a: 'b', + } as any; - expect( - createStandaloneEditorDefaultSettings.createStandaloneEditorDefaultSettings - ).toHaveBeenCalledWith(options); + const core = createEditorCore( + { + plugins: [mockedPlugin1, mockedPlugin2], + experimentalFeatures: mockedFeatures, + legacyCoreApiOverride: mockedCoreApi, + }, + { + edit: mockedEditPluginState, + contextMenu: mockedContextMenuPluginState, + }, + mockedSizeTransformer + ); expect(core).toEqual({ - contentDiv, - api: { ...coreApiMap, ...standaloneCoreApiMap }, - originalApi: { ...coreApiMap, ...standaloneCoreApiMap }, - plugins: [ - mockedCachePlugin, - mockedFormatPlugin, - mockedCopyPastePlugin, - mockedDOMEventPlugin, - mockedSelectionPlugin, - mockedEntityPlugin, - mockedEventTranslatePlugin, - mockedEditPlugin, - mockedNormalizeTablePlugin, - mockedUndoPlugin, - mockedLifecyclePlugin, - ], - domEvent: mockedDomEventState, - edit: mockedEditState, - lifecycle: mockedLifecycleState, - undo: mockedUndoState, - entity: mockedEntityState, - copyPaste: mockedCopyPasteState, - cache: mockedCacheState, - format: mockedFormatState, - selection: mockedSelectionState, - trustedHTMLHandler: defaultTrustHtmlHandler, - zoomScale: 1, - sizeTransformer: jasmine.anything(), - darkColorHandler: jasmine.anything(), - disposeErrorHandler: undefined, - ...mockedDefaultSettings, - environment: { - isMac: false, - isAndroid: false, - isSafari: false, - }, + api: { ...coreApiMap, a: 'b' } as any, + originalApi: { ...coreApiMap }, customData: {}, - experimentalFeatures: [], + experimentalFeatures: mockedFeatures, + edit: mockedEditPluginState, + contextMenu: mockedContextMenuPluginState, + sizeTransformer: mockedSizeTransformer, }); }); }); diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteWordSelection.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteWordSelection.ts index 261f93036d8..1217a6648d1 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteWordSelection.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteWordSelection.ts @@ -104,7 +104,7 @@ function* iterateSegments( ): Generator { const step = forward ? 1 : -1; const segments = paragraph.segments; - const preserveWhiteSpace = isWhiteSpacePreserved(paragraph); + const preserveWhiteSpace = isWhiteSpacePreserved(paragraph.format.whiteSpace); for (let i = markerIndex + step; i >= 0 && i < segments.length; i += step) { const segment = segments[i]; diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/paste/ContentModelPastePlugin.ts b/packages-content-model/roosterjs-content-model-plugins/lib/paste/ContentModelPastePlugin.ts index d4637712b46..ed5767a922c 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/paste/ContentModelPastePlugin.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/paste/ContentModelPastePlugin.ts @@ -1,6 +1,5 @@ import addParser from './utils/addParser'; import { BorderKeys } from 'roosterjs-content-model-dom'; -import { chainSanitizerCallback } from 'roosterjs-editor-dom'; import { deprecatedBorderColorParser } from './utils/deprecatedColorParser'; import { getPasteSource } from './pasteSourceValidations/getPasteSource'; import { parseLink } from './utils/linkParser'; @@ -19,12 +18,7 @@ import type { FormatParser, PasteType, } from 'roosterjs-content-model-types'; -import type { - EditorPlugin, - HtmlSanitizerOptions, - IEditor, - PluginEvent, -} from 'roosterjs-editor-types'; +import type { EditorPlugin, IEditor, PluginEvent } from 'roosterjs-editor-types'; // Map old PasteType to new PasteType // TODO: We can remove this once we have standalone editor @@ -51,10 +45,7 @@ export class ContentModelPastePlugin implements EditorPlugin { * @param unknownTagReplacement Replace solution of unknown tags, default behavior is to replace with SPAN * @param allowExcelNoBorderTable Allow table copied from Excel without border */ - constructor( - private unknownTagReplacement: string = 'SPAN', - private allowExcelNoBorderTable?: boolean - ) {} + constructor(private allowExcelNoBorderTable?: boolean) {} /** * Get name of this plugin @@ -122,9 +113,9 @@ export class ContentModelPastePlugin implements EditorPlugin { } break; case 'googleSheets': - ev.sanitizingOption.additionalTagReplacements[ + ev.domToModelOption.additionalAllowedTags.push( PastePropertyNames.GOOGLE_SHEET_NODE_NAME - ] = '*'; + ); break; case 'powerPointDesktop': processPastedContentFromPowerPoint(ev, this.editor.getTrustedHTMLHandler()); @@ -135,14 +126,11 @@ export class ContentModelPastePlugin implements EditorPlugin { addParser(ev.domToModelOption, 'tableCell', deprecatedBorderColorParser); addParser(ev.domToModelOption, 'tableCell', tableBorderParser); addParser(ev.domToModelOption, 'table', deprecatedBorderColorParser); - sanitizeBlockStyles(ev.sanitizingOption); if (pasteType === 'mergeFormat') { addParser(ev.domToModelOption, 'block', blockElementParser); addParser(ev.domToModelOption, 'listLevel', blockElementParser); } - - ev.sanitizingOption.unknownTagReplacement = this.unknownTagReplacement; } } @@ -159,12 +147,6 @@ const blockElementParser: FormatParser = ( } }; -function sanitizeBlockStyles(sanitizingOption: Required) { - chainSanitizerCallback(sanitizingOption.cssStyleCallbacks, 'display', (value: string) => { - return value != 'flex'; // return whether we keep the style - }); -} - const ElementBorderKeys = new Map< keyof BorderFormat, { diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/paste/WacComponents/constants.ts b/packages-content-model/roosterjs-content-model-plugins/lib/paste/WacComponents/constants.ts index 2a3d7d5eb00..027909e7e7f 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/paste/WacComponents/constants.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/paste/WacComponents/constants.ts @@ -37,10 +37,6 @@ export const PARAGRAPH: string = 'Paragraph'; * @internal **/ export const LIST_CONTAINER_ELEMENT_CLASS_NAME: string = 'ListContainerWrapper'; -/** - * @internal - **/ -export const TABLE_CONTAINER: string = 'TableContainer'; /** * @internal **/ @@ -62,22 +58,3 @@ export const TEMP_ELEMENTS_CLASSES: string[] = [ export const WAC_IDENTIFY_SELECTOR: string = `ul[class^="${BULLET_LIST_STYLE}"]>.${OUTLINE_ELEMENT},ol[class^="${NUMBER_LIST_STYLE}"]>.${OUTLINE_ELEMENT},span.${IMAGE_CONTAINER},span.${IMAGE_BORDER},.${COMMENT_HIGHLIGHT_CLASS},.${COMMENT_HIGHLIGHT_CLICKED_CLASS},` + WORD_ONLINE_TABLE_TEMP_ELEMENT_CLASSES.map(c => `table div[class^="${c}"]`).join(','); -/** - * @internal - **/ -export const CLASSES_TO_KEEP: string[] = [ - OUTLINE_ELEMENT, - IMAGE_CONTAINER, - ...TEMP_ELEMENTS_CLASSES, - PARAGRAPH, - IMAGE_BORDER, - TABLE_CONTAINER, - COMMENT_HIGHLIGHT_CLASS, - COMMENT_HIGHLIGHT_CLICKED_CLASS, - 'NumberListStyle', - 'ListContainerWrapper', - 'BulletListStyle', - 'TableCellContent', - 'WACImageContainer', - 'LineBreakBlob', -]; diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/paste/WacComponents/processPastedContentWacComponents.ts b/packages-content-model/roosterjs-content-model-plugins/lib/paste/WacComponents/processPastedContentWacComponents.ts index ab82d145b25..85665ab5b37 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/paste/WacComponents/processPastedContentWacComponents.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/paste/WacComponents/processPastedContentWacComponents.ts @@ -1,11 +1,9 @@ import addParser from '../utils/addParser'; import { setProcessor } from '../utils/setProcessor'; import { - CLASSES_TO_KEEP, COMMENT_HIGHLIGHT_CLASS, COMMENT_HIGHLIGHT_CLICKED_CLASS, LIST_CONTAINER_ELEMENT_CLASS_NAME, - TABLE_CONTAINER, TEMP_ELEMENTS_CLASSES, WAC_IDENTIFY_SELECTOR, } from './constants'; @@ -195,14 +193,14 @@ export function processPastedContentWacComponents(ev: ContentModelBeforePasteEve addParser(ev.domToModelOption, 'segment', wacSubSuperParser); addParser(ev.domToModelOption, 'listItemThread', wacListItemParser); addParser(ev.domToModelOption, 'listLevel', wacListLevelParser); - addParser(ev.domToModelOption, 'container', wacBlockParser); + addParser(ev.domToModelOption, 'container', wacContainerParser); + addParser(ev.domToModelOption, 'table', wacContainerParser); addParser(ev.domToModelOption, 'segment', wacCommentParser); setProcessor(ev.domToModelOption, 'element', wacElementProcessor); setProcessor(ev.domToModelOption, 'li', wacLiElementProcessor); setProcessor(ev.domToModelOption, 'ol', wacListProcessor); setProcessor(ev.domToModelOption, 'ul', wacListProcessor); - ev.sanitizingOption.additionalAllowedCssClasses.push(...CLASSES_TO_KEEP); } /** @@ -247,11 +245,11 @@ const wacListProcessor: ElementProcessor = } }; -const wacBlockParser: FormatParser = ( +const wacContainerParser: FormatParser = ( format: ContentModelBlockFormat, element: HTMLElement ) => { - if (element.classList.contains(TABLE_CONTAINER) && element.style.marginLeft.startsWith('-')) { + if (element.style.marginLeft.startsWith('-')) { delete format.marginLeft; } }; diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/paste/WordDesktop/getStyleMetadata.ts b/packages-content-model/roosterjs-content-model-plugins/lib/paste/WordDesktop/getStyleMetadata.ts index 1c596d23d4b..8d25601a7e7 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/paste/WordDesktop/getStyleMetadata.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/paste/WordDesktop/getStyleMetadata.ts @@ -72,7 +72,7 @@ export default function getStyleMetadata( const data: WordMetadata = { 'mso-level-number-format': record['mso-level-number-format'], - 'mso-level-start-at': record['mso-level-start-at'], + 'mso-level-start-at': record['mso-level-start-at'] || '1', 'mso-level-text': record['mso-level-text'], }; if (getObjectKeys(data).some(key => !!data[key])) { diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/paste/WordDesktop/processPastedContentFromWordDesktop.ts b/packages-content-model/roosterjs-content-model-plugins/lib/paste/WordDesktop/processPastedContentFromWordDesktop.ts index 3bdae38950f..63e66f2009c 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/paste/WordDesktop/processPastedContentFromWordDesktop.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/paste/WordDesktop/processPastedContentFromWordDesktop.ts @@ -1,8 +1,6 @@ import addParser from '../utils/addParser'; import getStyleMetadata from './getStyleMetadata'; -import { chainSanitizerCallback } from 'roosterjs-editor-dom'; import { getStyles } from '../utils/getStyles'; -import { moveChildNodes } from 'roosterjs-content-model-dom'; import { processWordComments } from './processWordComments'; import { processWordList } from './processWordLists'; import { setProcessor } from '../utils/setProcessor'; @@ -10,8 +8,8 @@ import type { WordMetadata } from './WordMetadata'; import type { ContentModelBeforePasteEvent, ContentModelBlockFormat, - ContentModelListItemFormat, ContentModelListItemLevelFormat, + ContentModelTableFormat, DomToModelContext, ElementProcessor, FormatParser, @@ -34,23 +32,8 @@ export function processPastedContentFromWordDesktop( setProcessor(ev.domToModelOption, 'element', wordDesktopElementProcessor(metadataMap)); addParser(ev.domToModelOption, 'block', removeNonValidLineHeight); addParser(ev.domToModelOption, 'listLevel', listLevelParser); - addParser(ev.domToModelOption, 'listItemElement', listItemElementParser); - - // Remove "border:none" for image to fix image resize behavior - // We found a problem that when paste an image with "border:none" then the resize border will be - // displayed incorrectly when resize it. So we need to drop this style - chainSanitizerCallback( - ev.sanitizingOption.cssStyleCallbacks, - 'border', - (value, element) => element.tagName != 'IMG' || value != 'none' - ); - - // Preserve when its innerHTML is " " to avoid dropping an empty line - chainSanitizerCallback(ev.sanitizingOption.elementCallbacks, 'O:P', element => { - moveChildNodes(element); - element.appendChild(element.ownerDocument.createTextNode('\u00A0')); //   - return true; - }); + addParser(ev.domToModelOption, 'container', wordTableParser); + addParser(ev.domToModelOption, 'table', wordTableParser); } const wordDesktopElementProcessor = ( @@ -100,14 +83,8 @@ function listLevelParser( format.marginBottom = undefined; } -const listItemElementParser: FormatParser = ( - format: ContentModelListItemFormat, - element: HTMLElement -): void => { - if (element.style.marginLeft) { - format.marginLeft = undefined; - } - if (element.style.marginRight) { - format.marginRight = undefined; +const wordTableParser: FormatParser = (format): void => { + if (format.marginLeft?.startsWith('-')) { + delete format.marginLeft; } }; diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/paste/WordDesktop/processWordLists.ts b/packages-content-model/roosterjs-content-model-plugins/lib/paste/WordDesktop/processWordLists.ts index 880bc1f39bb..2099511fc90 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/paste/WordDesktop/processWordLists.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/paste/WordDesktop/processWordLists.ts @@ -4,22 +4,23 @@ import { addBlock, createListItem, createListLevel, + isEmpty, parseFormat, } from 'roosterjs-content-model-dom'; import type { ContentModelBlockGroup, + ContentModelListItem, + ContentModelListItemFormat, ContentModelListItemLevelFormat, ContentModelListLevel, DomToModelContext, DomToModelListFormat, - FormatParser, } from 'roosterjs-content-model-types'; /** Word list metadata style name */ const MSO_LIST = 'mso-list'; const MSO_LIST_IGNORE = 'ignore'; const WORD_FIRST_LIST = 'l0'; - const TEMPLATE_VALUE_REGEX = /%[0-9a-zA-Z]+/g; interface WordDesktopListFormat extends DomToModelListFormat { @@ -28,6 +29,10 @@ interface WordDesktopListFormat extends DomToModelListFormat { wordKnownLevels?: Map; } +interface WordListFormat extends ContentModelListItemFormat { + wordList?: string; +} + const BULLET_METADATA = 'bullet'; /** * @internal @@ -78,7 +83,12 @@ export function processWordList( // Create the new level of the list item and parse the format const newLevel: ContentModelListLevel = createListLevel(listType); - parseFormat(element, context.formatParsers.listLevel, newLevel.format, context); + parseFormat( + element, + [...context.formatParsers.listLevel, wordListPaddingParser], + newLevel.format, + context + ); // If the list format is in a different level, update the array so we get the new item // To be in the same level as the provided level metadata. @@ -90,6 +100,8 @@ export function processWordList( listFormat.levels.splice(wordLevel, listFormat.levels.length - 1); listFormat.levels[wordLevel - 1] = newLevel; } + (listFormat.levels[listFormat.levels.length - 1] + .format as WordListFormat).wordList = wordList; listFormat.listParent = group; @@ -132,12 +144,7 @@ function processAsListItem( parseFormat(element, context.formatParsers.listItemElement, listItem.format, context); if (listType == 'OL') { - parseFormat( - element, - [startNumberOverrideParser(listMetadata)], - listItem.levels[listItem.levels.length - 1].format, - context - ); + setStartNumber(listItem, context, listMetadata); } context.elementProcessors.child(listItem, element, context); @@ -193,23 +200,57 @@ function getBulletFromMetadata(listMetadata: WordMetadata | undefined, listType: return getListStyleTypeFromString(listType, templateFinal); } -function startNumberOverrideParser( +function wordListPaddingParser( + format: ContentModelListItemLevelFormat, + element: HTMLElement +): void { + if (element.style.marginLeft && element.style.marginLeft != '0in') { + format.paddingLeft = '0px'; + } + if (element.style.marginRight && element.style.marginRight != '0in') { + format.paddingRight = '0px'; + } +} + +function setStartNumber( + listItem: ContentModelListItem, + context: DomToModelContext, listMetadata: WordMetadata | undefined -): FormatParser | null { - return (format, _, context) => { - const { - wordKnownLevels, - wordLevel, - wordList, - levels, - } = context.listFormat as WordDesktopListFormat; - if (typeof wordLevel === 'number' && wordList) { - const start = parseInt(listMetadata?.['mso-level-start-at'] || '1'); - const knownLevel = wordKnownLevels?.get(wordList) || []; - - if (start != undefined && !isNaN(start) && knownLevel.length != levels.length) { - format.startNumberOverride = start; - } +) { + const { + listParent, + wordList, + wordKnownLevels, + wordLevel, + levels, + } = context.listFormat as WordDesktopListFormat; + + const block = getLastNotEmptyBlock(listParent); + if ( + (block?.blockType != 'BlockGroup' || + block.blockGroupType != 'ListItem' || + (wordLevel && + (block.levels[wordLevel]?.format as WordListFormat)?.wordList != wordList)) && + wordList + ) { + const start = listMetadata?.['mso-level-start-at'] + ? parseInt(listMetadata['mso-level-start-at']) + : NaN; + const knownLevel = wordKnownLevels?.get(wordList) || []; + + if (start != undefined && !isNaN(start) && knownLevel.length != levels.length) { + listItem.levels[listItem.levels.length - 1].format.startNumberOverride = start; } - }; + } +} + +function getLastNotEmptyBlock(listParent: ContentModelBlockGroup | undefined) { + for (let index = (listParent?.blocks.length || 0) - 1; index > 0; index--) { + const result = listParent?.blocks[index]; + if (result && !isEmpty(result)) { + return result; + } + } + + return undefined; } diff --git a/packages-content-model/roosterjs-content-model-plugins/test/paste/ContentModelPastePluginTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/ContentModelPastePluginTest.ts index 40f727fce4d..957efc9e6d0 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/paste/ContentModelPastePluginTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/ContentModelPastePluginTest.ts @@ -1,5 +1,4 @@ import * as addParser from '../../lib/paste/utils/addParser'; -import * as chainSanitizerCallbackFile from 'roosterjs-editor-dom/lib/htmlSanitizer/chainSanitizerCallback'; import * as ExcelFile from '../../lib/paste/Excel/processPastedContentFromExcel'; import * as getPasteSource from '../../lib/paste/pasteSourceValidations/getPasteSource'; import * as PowerPointFile from '../../lib/paste/PowerPoint/processPastedContentFromPowerPoint'; @@ -22,7 +21,6 @@ describe('Content Model Paste Plugin Test', () => { getTrustedHTMLHandler: () => trustedHTMLHandler, } as any) as IContentModelEditor; spyOn(addParser, 'default').and.callThrough(); - spyOn(chainSanitizerCallbackFile, 'default').and.callThrough(); spyOn(setProcessor, 'setProcessor').and.callThrough(); }); @@ -45,7 +43,7 @@ describe('Content Model Paste Plugin Test', () => { htmlBefore: '', htmlAfter: '', htmlAttributes: {}, - domToModelOption: {}, + domToModelOption: { additionalAllowedTags: [], additionalDisallowedTags: [] }, }); describe('onPluginEvent', () => { @@ -56,7 +54,7 @@ describe('Content Model Paste Plugin Test', () => { event = ({ eventType: PluginEventType.BeforePaste, - domToModelOption: {}, + domToModelOption: { additionalAllowedTags: [], additionalDisallowedTags: [] }, sanitizingOption: { elementCallbacks: {}, attributeCallbacks: {}, @@ -84,8 +82,7 @@ describe('Content Model Paste Plugin Test', () => { plugin.initialize(editor); plugin.onPluginEvent(event); - expect(addParser.default).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED + 3); - expect(chainSanitizerCallbackFile.default).toHaveBeenCalledTimes(3); + expect(addParser.default).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED + 4); expect(setProcessor.setProcessor).toHaveBeenCalledTimes(1); }); @@ -104,7 +101,6 @@ describe('Content Model Paste Plugin Test', () => { ); expect(addParser.default).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED + 3); expect(setProcessor.setProcessor).toHaveBeenCalledTimes(1); - expect(chainSanitizerCallbackFile.default).toHaveBeenCalledTimes(1); }); it('Excel | image', () => { @@ -121,7 +117,6 @@ describe('Content Model Paste Plugin Test', () => { undefined /*allowExcelNoBorderTable*/ ); expect(addParser.default).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED); - expect(chainSanitizerCallbackFile.default).toHaveBeenCalledTimes(1); expect(setProcessor.setProcessor).toHaveBeenCalledTimes(0); }); @@ -139,7 +134,6 @@ describe('Content Model Paste Plugin Test', () => { ); expect(addParser.default).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED + 1); expect(setProcessor.setProcessor).toHaveBeenCalledTimes(1); - expect(chainSanitizerCallbackFile.default).toHaveBeenCalledTimes(1); }); it('Excel Online', () => { @@ -156,7 +150,6 @@ describe('Content Model Paste Plugin Test', () => { ); expect(addParser.default).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED + 1); expect(setProcessor.setProcessor).toHaveBeenCalledTimes(1); - expect(chainSanitizerCallbackFile.default).toHaveBeenCalledTimes(1); }); it('Power Point', () => { @@ -172,7 +165,6 @@ describe('Content Model Paste Plugin Test', () => { ); expect(addParser.default).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED); expect(setProcessor.setProcessor).toHaveBeenCalledTimes(0); - expect(chainSanitizerCallbackFile.default).toHaveBeenCalledTimes(1); }); it('Wac', () => { @@ -183,9 +175,8 @@ describe('Content Model Paste Plugin Test', () => { plugin.onPluginEvent(event); expect(WacFile.processPastedContentWacComponents).toHaveBeenCalledWith(event); - expect(addParser.default).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED + 5); + expect(addParser.default).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED + 6); expect(setProcessor.setProcessor).toHaveBeenCalledTimes(4); - expect(chainSanitizerCallbackFile.default).toHaveBeenCalledTimes(1); }); it('Default', () => { @@ -196,7 +187,6 @@ describe('Content Model Paste Plugin Test', () => { expect(addParser.default).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED); expect(setProcessor.setProcessor).toHaveBeenCalledTimes(0); - expect(chainSanitizerCallbackFile.default).toHaveBeenCalledTimes(1); }); it('Google Sheets', () => { @@ -207,12 +197,9 @@ describe('Content Model Paste Plugin Test', () => { expect(addParser.default).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED); expect(setProcessor.setProcessor).toHaveBeenCalledTimes(0); - expect(chainSanitizerCallbackFile.default).toHaveBeenCalledTimes(1); - expect( - event.sanitizingOption.additionalTagReplacements[ - PastePropertyNames.GOOGLE_SHEET_NODE_NAME - ] - ).toEqual('*'); + expect(event.domToModelOption.additionalAllowedTags).toEqual([ + PastePropertyNames.GOOGLE_SHEET_NODE_NAME, + ]); }); }); }); diff --git a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelOnlineTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelOnlineTest.ts index b27b1305ce7..4fd539d34a5 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelOnlineTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelOnlineTest.ts @@ -2,7 +2,6 @@ import * as processPastedContentFromExcel from '../../../lib/paste/Excel/process import { expectEqual, initEditor } from './testUtils'; import { IContentModelEditor } from 'roosterjs-content-model-editor'; import { itChromeOnly } from 'roosterjs-editor-dom/test/DomTestHelper'; -import { paste } from 'roosterjs-content-model-core'; import { tableProcessor } from 'roosterjs-content-model-dom'; import type { ClipboardData } from 'roosterjs-content-model-types'; @@ -35,7 +34,7 @@ describe(ID, () => { it('E2E', () => { spyOn(processPastedContentFromExcel, 'processPastedContentFromExcel').and.callThrough(); - paste(editor, clipboardData); + editor.paste(clipboardData); editor.createContentModel({ processorOverride: { table: tableProcessor, @@ -59,7 +58,7 @@ describe(ID, () => { snapshotBeforePaste: '

', }); - paste(editor, CD); + editor.paste(CD); const model = editor.createContentModel({ processorOverride: { @@ -183,13 +182,11 @@ describe(ID, () => { blockType: 'Paragraph', format: { textAlign: 'center', - whiteSpace: 'normal', }, }, ], format: { textAlign: 'center', - whiteSpace: 'normal', borderTop: '0.5pt solid', borderRight: '0.5pt solid', borderBottom: '0.5pt solid', @@ -223,7 +220,6 @@ describe(ID, () => { format: { fontFamily: 'Calibri, sans-serif', fontSize: '11pt', - fontWeight: '400', textColor: 'black', }, }, @@ -265,7 +261,6 @@ describe(ID, () => { format: { fontFamily: 'Calibri, sans-serif', fontSize: '11pt', - fontWeight: '400', textColor: 'rgb(5, 99, 193)', underline: true, }, @@ -314,7 +309,6 @@ describe(ID, () => { format: { fontFamily: 'Calibri, sans-serif', fontSize: '11pt', - fontWeight: '400', textColor: 'rgb(219, 219, 219)', }, }, @@ -361,7 +355,6 @@ describe(ID, () => { format: { fontFamily: 'Calibri, sans-serif', fontSize: '11pt', - fontWeight: '400', textColor: 'black', }, }, @@ -403,7 +396,6 @@ describe(ID, () => { format: { fontFamily: 'Calibri, sans-serif', fontSize: '11pt', - fontWeight: '400', textColor: 'rgb(5, 99, 193)', underline: true, }, @@ -452,7 +444,6 @@ describe(ID, () => { format: { fontFamily: 'Calibri, sans-serif', fontSize: '11pt', - fontWeight: '400', textColor: 'rgb(219, 219, 219)', }, }, @@ -499,7 +490,6 @@ describe(ID, () => { format: { fontFamily: 'Calibri, sans-serif', fontSize: '11pt', - fontWeight: '400', textColor: 'black', }, }, @@ -541,7 +531,6 @@ describe(ID, () => { format: { fontFamily: 'Calibri, sans-serif', fontSize: '11pt', - fontWeight: '400', textColor: 'rgb(5, 99, 193)', underline: true, }, @@ -590,7 +579,6 @@ describe(ID, () => { format: { fontFamily: 'Calibri, sans-serif', fontSize: '11pt', - fontWeight: '400', textColor: 'rgb(219, 219, 219)', }, }, @@ -637,7 +625,6 @@ describe(ID, () => { format: { fontFamily: 'Calibri, sans-serif', fontSize: '11pt', - fontWeight: '400', textColor: 'black', }, }, @@ -679,7 +666,6 @@ describe(ID, () => { format: { fontFamily: 'Calibri, sans-serif', fontSize: '11pt', - fontWeight: '400', textColor: 'rgb(5, 99, 193)', underline: true, }, @@ -728,7 +714,6 @@ describe(ID, () => { format: { fontFamily: 'Calibri, sans-serif', fontSize: '11pt', - fontWeight: '400', textColor: 'rgb(219, 219, 219)', }, }, @@ -775,7 +760,6 @@ describe(ID, () => { format: { fontFamily: 'Calibri, sans-serif', fontSize: '11pt', - fontWeight: '400', textColor: 'black', }, }, @@ -817,7 +801,6 @@ describe(ID, () => { format: { fontFamily: 'Calibri, sans-serif', fontSize: '11pt', - fontWeight: '400', textColor: 'rgb(5, 99, 193)', underline: true, }, @@ -866,7 +849,6 @@ describe(ID, () => { format: { fontFamily: 'Calibri, sans-serif', fontSize: '11pt', - fontWeight: '400', textColor: 'rgb(219, 219, 219)', }, }, @@ -913,7 +895,6 @@ describe(ID, () => { format: { fontFamily: 'Calibri, sans-serif', fontSize: '11pt', - fontWeight: '400', textColor: 'black', }, }, @@ -955,7 +936,6 @@ describe(ID, () => { format: { fontFamily: 'Calibri, sans-serif', fontSize: '11pt', - fontWeight: '400', textColor: 'rgb(5, 99, 193)', underline: true, }, @@ -1004,7 +984,6 @@ describe(ID, () => { format: { fontFamily: 'Calibri, sans-serif', fontSize: '11pt', - fontWeight: '400', textColor: 'rgb(219, 219, 219)', }, }, @@ -1051,7 +1030,6 @@ describe(ID, () => { format: { fontFamily: 'Calibri, sans-serif', fontSize: '11pt', - fontWeight: '400', textColor: 'black', }, }, @@ -1093,7 +1071,6 @@ describe(ID, () => { format: { fontFamily: 'Calibri, sans-serif', fontSize: '11pt', - fontWeight: '400', textColor: 'rgb(5, 99, 193)', underline: true, }, @@ -1142,7 +1119,6 @@ describe(ID, () => { format: { fontFamily: 'Calibri, sans-serif', fontSize: '11pt', - fontWeight: '400', textColor: 'rgb(219, 219, 219)', }, }, diff --git a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelTest.ts index f5b76bc4990..a6b8cb2035c 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelTest.ts @@ -3,7 +3,6 @@ import { Browser } from 'roosterjs-editor-dom'; import { expectEqual, initEditor } from './testUtils'; import { IContentModelEditor } from 'roosterjs-content-model-editor'; import { itChromeOnly } from 'roosterjs-editor-dom/test/DomTestHelper'; -import { paste } from 'roosterjs-content-model-core'; import { tableProcessor } from 'roosterjs-content-model-dom'; import type { ClipboardData } from 'roosterjs-content-model-types'; @@ -39,7 +38,7 @@ describe(ID, () => { } spyOn(processPastedContentFromExcel, 'processPastedContentFromExcel').and.callThrough(); - paste(editor, clipboardData); + editor.paste(clipboardData); editor.createContentModel({}); expect(processPastedContentFromExcel.processPastedContentFromExcel).toHaveBeenCalled(); @@ -51,7 +50,7 @@ describe(ID, () => { } spyOn(processPastedContentFromExcel, 'processPastedContentFromExcel').and.callThrough(); - paste(editor, clipboardData, 'asImage'); + editor.paste(clipboardData, false, false, true); const model = editor.createContentModel({ processorOverride: { @@ -102,7 +101,7 @@ describe(ID, () => { snapshotBeforePaste: '
', }); - paste(editor, CD); + editor.paste(CD); const model = editor.createContentModel({ processorOverride: { @@ -225,13 +224,11 @@ describe(ID, () => { blockType: 'Paragraph', format: { textAlign: 'center', - whiteSpace: 'normal', }, }, ], format: { textAlign: 'center', - whiteSpace: 'normal', borderTop: '0.5pt solid', borderRight: '0.5pt solid', borderBottom: '0.5pt solid', @@ -264,7 +261,6 @@ describe(ID, () => { format: { fontFamily: 'Calibri, sans-serif', fontSize: '11pt', - fontWeight: '400', textColor: 'black', }, }, @@ -305,7 +301,6 @@ describe(ID, () => { format: { fontFamily: 'Calibri, sans-serif', fontSize: '11pt', - fontWeight: '400', textColor: 'rgb(5, 99, 193)', underline: true, }, @@ -352,7 +347,6 @@ describe(ID, () => { format: { fontFamily: 'Calibri, sans-serif', fontSize: '11pt', - fontWeight: '400', textColor: 'rgb(219, 219, 219)', }, }, @@ -397,7 +391,6 @@ describe(ID, () => { format: { fontFamily: 'Calibri, sans-serif', fontSize: '11pt', - fontWeight: '400', textColor: 'black', }, }, @@ -438,7 +431,6 @@ describe(ID, () => { format: { fontFamily: 'Calibri, sans-serif', fontSize: '11pt', - fontWeight: '400', textColor: 'rgb(5, 99, 193)', underline: true, }, @@ -485,7 +477,6 @@ describe(ID, () => { format: { fontFamily: 'Calibri, sans-serif', fontSize: '11pt', - fontWeight: '400', textColor: 'rgb(219, 219, 219)', }, }, @@ -530,7 +521,6 @@ describe(ID, () => { format: { fontFamily: 'Calibri, sans-serif', fontSize: '11pt', - fontWeight: '400', textColor: 'black', }, }, @@ -571,7 +561,6 @@ describe(ID, () => { format: { fontFamily: 'Calibri, sans-serif', fontSize: '11pt', - fontWeight: '400', textColor: 'rgb(5, 99, 193)', underline: true, }, @@ -618,7 +607,6 @@ describe(ID, () => { format: { fontFamily: 'Calibri, sans-serif', fontSize: '11pt', - fontWeight: '400', textColor: 'rgb(219, 219, 219)', }, }, @@ -663,7 +651,6 @@ describe(ID, () => { format: { fontFamily: 'Calibri, sans-serif', fontSize: '11pt', - fontWeight: '400', textColor: 'black', }, }, @@ -704,7 +691,6 @@ describe(ID, () => { format: { fontFamily: 'Calibri, sans-serif', fontSize: '11pt', - fontWeight: '400', textColor: 'rgb(5, 99, 193)', underline: true, }, @@ -751,7 +737,6 @@ describe(ID, () => { format: { fontFamily: 'Calibri, sans-serif', fontSize: '11pt', - fontWeight: '400', textColor: 'rgb(219, 219, 219)', }, }, @@ -796,7 +781,6 @@ describe(ID, () => { format: { fontFamily: 'Calibri, sans-serif', fontSize: '11pt', - fontWeight: '400', textColor: 'black', }, }, @@ -837,7 +821,6 @@ describe(ID, () => { format: { fontFamily: 'Calibri, sans-serif', fontSize: '11pt', - fontWeight: '400', textColor: 'rgb(5, 99, 193)', underline: true, }, @@ -884,7 +867,6 @@ describe(ID, () => { format: { fontFamily: 'Calibri, sans-serif', fontSize: '11pt', - fontWeight: '400', textColor: 'rgb(219, 219, 219)', }, }, @@ -929,7 +911,6 @@ describe(ID, () => { format: { fontFamily: 'Calibri, sans-serif', fontSize: '11pt', - fontWeight: '400', textColor: 'black', }, }, @@ -970,7 +951,6 @@ describe(ID, () => { format: { fontFamily: 'Calibri, sans-serif', fontSize: '11pt', - fontWeight: '400', textColor: 'rgb(5, 99, 193)', underline: true, }, @@ -1017,7 +997,6 @@ describe(ID, () => { format: { fontFamily: 'Calibri, sans-serif', fontSize: '11pt', - fontWeight: '400', textColor: 'rgb(219, 219, 219)', }, }, @@ -1062,7 +1041,6 @@ describe(ID, () => { format: { fontFamily: 'Calibri, sans-serif', fontSize: '11pt', - fontWeight: '400', textColor: 'black', }, }, @@ -1103,7 +1081,6 @@ describe(ID, () => { format: { fontFamily: 'Calibri, sans-serif', fontSize: '11pt', - fontWeight: '400', textColor: 'rgb(5, 99, 193)', underline: true, }, @@ -1150,7 +1127,6 @@ describe(ID, () => { format: { fontFamily: 'Calibri, sans-serif', fontSize: '11pt', - fontWeight: '400', textColor: 'rgb(219, 219, 219)', }, }, diff --git a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWacTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWacTest.ts index 302b3be33f9..9065ceba71c 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWacTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWacTest.ts @@ -2,27 +2,28 @@ import * as processPastedContentWacComponents from '../../../lib/paste/WacCompon import { ClipboardData, DomToModelOption } from 'roosterjs-content-model-types'; import { expectEqual, initEditor } from './testUtils'; import { IContentModelEditor } from 'roosterjs-content-model-editor'; -import { paste } from 'roosterjs-content-model-core'; import { tableProcessor } from 'roosterjs-content-model-dom'; const ID = 'CM_Paste_From_WORD_Online_E2E'; -const clipboardData = ({ - types: ['text/plain', 'text/html'], - text: 'asd\r\n\r\nTest ', - image: null, - files: [], - customValues: {}, - snapshotBeforePaste: '

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

asd 

  • Test 

', -}); describe(ID, () => { let editor: IContentModelEditor = undefined!; + let clipboardData: ClipboardData; beforeEach(() => { editor = initEditor(ID); + + clipboardData = ({ + types: ['text/plain', 'text/html'], + text: 'asd\r\n\r\nTest ', + image: null, + files: [], + customValues: {}, + snapshotBeforePaste: '

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

asd 

  • Test 

', + }); }); afterEach(() => { @@ -37,7 +38,7 @@ describe(ID, () => { 'processPastedContentWacComponents' ).and.callThrough(); - paste(editor, clipboardData); + editor.paste(clipboardData); editor.createContentModel({ processorOverride: { table: tableProcessor, @@ -53,7 +54,7 @@ describe(ID, () => { clipboardData.rawHtml = '

Test Table 

Test Table 

 

'; - paste(editor, clipboardData); + editor.paste(clipboardData); const model = editor.createContentModel({ processorOverride: { @@ -65,57 +66,82 @@ describe(ID, () => { blockGroupType: 'Document', blocks: [ { + tagName: 'div', blockType: 'BlockGroup', + format: { + backgroundColor: 'rgb(255, 255, 255)', + marginTop: '0px', + marginRight: '0px', + marginBottom: '0px', + marginLeft: '0px', + }, blockGroupType: 'FormatContainer', - tagName: 'div', blocks: [ { + tagName: 'div', blockType: 'BlockGroup', + format: { + marginTop: '2px', + marginRight: '0px', + marginBottom: '2px', + }, blockGroupType: 'FormatContainer', - tagName: 'div', blocks: [ { - blockType: 'Table', + widths: jasmine.anything() as any, rows: [ { height: jasmine.anything() as any, - format: {}, cells: [ { + spanAbove: false, + spanLeft: false, + isHeader: false, blockGroupType: 'TableCell', blocks: [ { + tagName: 'div', blockType: 'BlockGroup', + format: { + direction: 'ltr', + textAlign: 'start', + marginTop: '0px', + marginRight: '0px', + marginBottom: '0px', + marginLeft: '0px', + paddingRight: '7px', + paddingLeft: '7px', + }, blockGroupType: 'FormatContainer', - tagName: 'div', blocks: [ { - blockType: 'Paragraph', segments: [ { - segmentType: 'Text', text: 'Test Table ', + segmentType: 'Text', format: { - letterSpacing: - 'normal', + textColor: + 'rgb(0, 0, 0)', fontFamily: 'Aptos, Aptos_EmbeddedFont, Aptos_MSFontService, sans-serif', fontSize: '11pt', - textColor: - 'rgb(0, 0, 0)', lineHeight: '19.7625px', }, }, ], + segmentFormat: { + textColor: 'rgb(0, 0, 0)', + }, + blockType: 'Paragraph', format: { direction: 'ltr', textAlign: 'start', whiteSpace: 'pre-wrap', - marginLeft: '0px', - marginRight: '0px', marginTop: '0px', + marginRight: '0px', marginBottom: '0px', + marginLeft: '0px', }, decorator: { tagName: 'p', @@ -123,25 +149,11 @@ describe(ID, () => { }, }, ], - 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', @@ -149,48 +161,59 @@ describe(ID, () => { verticalAlign: 'top', width: '312px', }, - spanLeft: false, - spanAbove: false, - isHeader: false, dataset: { celllook: '0', }, }, { + spanAbove: false, + spanLeft: false, + isHeader: false, blockGroupType: 'TableCell', blocks: [ { + tagName: 'div', blockType: 'BlockGroup', + format: { + direction: 'ltr', + textAlign: 'start', + marginTop: '0px', + marginRight: '0px', + marginBottom: '0px', + marginLeft: '0px', + paddingRight: '7px', + paddingLeft: '7px', + }, blockGroupType: 'FormatContainer', - tagName: 'div', blocks: [ { - blockType: 'Paragraph', segments: [ { - segmentType: 'Text', text: 'Test Table ', + segmentType: 'Text', format: { - letterSpacing: - 'normal', + textColor: + 'rgb(0, 0, 0)', fontFamily: 'Aptos, Aptos_EmbeddedFont, Aptos_MSFontService, sans-serif', fontSize: '11pt', - textColor: - 'rgb(0, 0, 0)', lineHeight: '19.7625px', }, }, ], + segmentFormat: { + textColor: 'rgb(0, 0, 0)', + }, + blockType: 'Paragraph', format: { direction: 'ltr', textAlign: 'start', whiteSpace: 'pre-wrap', - marginLeft: '0px', - marginRight: '0px', marginTop: '0px', + marginRight: '0px', marginBottom: '0px', + marginLeft: '0px', }, decorator: { tagName: 'p', @@ -198,25 +221,11 @@ describe(ID, () => { }, }, ], - 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', @@ -224,44 +233,39 @@ describe(ID, () => { verticalAlign: 'top', width: '312px', }, - spanLeft: false, - spanAbove: false, - isHeader: false, dataset: { celllook: '0', }, }, ], + format: {}, }, ], - format: { - useBorderBox: true, + blockType: 'Table', + format: { direction: 'ltr', textAlign: 'start', - whiteSpace: 'normal', - backgroundColor: 'transparent', marginTop: '0px', marginRight: '0px', marginBottom: '0px', marginLeft: '0px', width: '0px', tableLayout: 'fixed', + useBorderBox: true, borderCollapse: true, }, - widths: [jasmine.anything() as any, jasmine.anything() as any], dataset: { tablestyle: 'MsoTableGrid', tablelook: '1696', }, }, ], - format: { - marginTop: '2px', - marginRight: '0px', - marginBottom: '2px', - }, }, ], + }, + { + tagName: 'div', + blockType: 'BlockGroup', format: { backgroundColor: 'rgb(255, 255, 255)', marginTop: '0px', @@ -269,28 +273,26 @@ describe(ID, () => { marginBottom: '0px', marginLeft: '0px', }, - }, - { - blockType: 'BlockGroup', blockGroupType: 'FormatContainer', - tagName: 'div', blocks: [ { - blockType: 'Paragraph', segments: [ { - segmentType: 'Text', text: ' ', + segmentType: 'Text', format: { - letterSpacing: 'normal', + textColor: 'rgb(0, 0, 0)', fontFamily: 'Aptos, Aptos_EmbeddedFont, Aptos_MSFontService, sans-serif', fontSize: '12pt', - textColor: 'rgb(0, 0, 0)', lineHeight: '20.925px', }, }, ], + segmentFormat: { + textColor: 'rgb(0, 0, 0)', + }, + blockType: 'Paragraph', format: { direction: 'ltr', textAlign: 'start', @@ -306,20 +308,12 @@ describe(ID, () => { }, }, ], - format: { - backgroundColor: 'rgb(255, 255, 255)', - marginTop: '0px', - marginRight: '0px', - marginBottom: '0px', - marginLeft: '0px', - }, }, { - blockType: 'Paragraph', segments: [ { - segmentType: 'SelectionMarker', isSelected: true, + segmentType: 'SelectionMarker', format: {}, }, { @@ -327,6 +321,7 @@ describe(ID, () => { format: {}, }, ], + blockType: 'Paragraph', format: {}, }, ], diff --git a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWordTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWordTest.ts index 06eec17884d..b68d8af08ad 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWordTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWordTest.ts @@ -1,6 +1,6 @@ import * as wordFile from '../../../lib/paste/WordDesktop/processPastedContentFromWordDesktop'; import { ClipboardData, DomToModelOption } from 'roosterjs-content-model-types'; -import { cloneModel, paste } from 'roosterjs-content-model-core'; +import { cloneModel } from 'roosterjs-content-model-core'; import { expectEqual, initEditor } from './testUtils'; import { IContentModelEditor } from 'roosterjs-content-model-editor'; import { itChromeOnly } from 'roosterjs-editor-dom/test/DomTestHelper'; @@ -25,7 +25,7 @@ describe(ID, () => { beforeEach(() => { editor = initEditor(ID); spyOn(wordFile, 'processPastedContentFromWordDesktop').and.callThrough(); - delete clipboardData.snapshotBeforePaste; + delete clipboardData.modelBeforePaste; }); afterEach(() => { @@ -35,7 +35,7 @@ describe(ID, () => { 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); + editor.paste(clipboardData); const model = cloneModel( editor.createContentModel({ @@ -57,7 +57,7 @@ describe(ID, () => { isImplicit: undefined, segments: [ { - text: 'Test ', + text: 'Test', segmentType: 'Text', isSelected: undefined, format: { fontFamily: 'Calibri, sans-serif', fontSize: '11pt' }, @@ -103,7 +103,7 @@ describe(ID, () => { clipboardData.rawHtml = '

Asdasdsad

asdadasd

 

asdsadasdasdsadasdsadsad

 

'; - paste(editor, clipboardData); + editor.paste(clipboardData); const model = editor.createContentModel({ processorOverride: { @@ -120,22 +120,20 @@ describe(ID, () => { rows: [ { height: jasmine.anything() as any, + format: {}, cells: [ { - spanAbove: false, - spanLeft: false, - isHeader: false, blockGroupType: 'TableCell', blocks: [ { + blockType: 'Paragraph', segments: [ { - text: 'Asdasdsad ', + text: 'Asdasdsad', segmentType: 'Text', format: {}, }, ], - blockType: 'Paragraph', format: { lineHeight: 'normal', marginTop: '1em', @@ -159,23 +157,23 @@ describe(ID, () => { verticalAlign: 'top', width: '233.75pt', }, + spanLeft: false, + spanAbove: false, + isHeader: false, dataset: {}, }, { - spanAbove: false, - spanLeft: false, - isHeader: false, blockGroupType: 'TableCell', blocks: [ { + blockType: 'Paragraph', segments: [ { - text: 'asdadasd ', + text: 'asdadasd', segmentType: 'Text', format: {}, }, ], - blockType: 'Paragraph', format: { lineHeight: 'normal', marginTop: '1em', @@ -198,28 +196,30 @@ describe(ID, () => { verticalAlign: 'top', width: '233.75pt', }, + spanLeft: false, + spanAbove: false, + isHeader: false, dataset: {}, }, ], - format: {}, }, ], blockType: 'Table', format: { - borderCollapse: true, useBorderBox: true, + borderCollapse: true, }, dataset: {}, }, { + blockType: 'Paragraph', segments: [ { - text: ' ', segmentType: 'Text', + text: ' ', format: {}, }, ], - blockType: 'Paragraph', format: { marginTop: '1em', marginBottom: '1em', @@ -230,14 +230,16 @@ describe(ID, () => { }, }, { + blockType: 'Paragraph', segments: [ { - text: 'asdsadasdasdsadasdsadsad ', + text: 'asdsadasdasdsadasdsadsad', segmentType: 'Text', - format: {}, + format: { + textColor: 'rgb(0, 0, 0)', + }, }, ], - blockType: 'Paragraph', format: { marginTop: '1em', marginBottom: '1em', @@ -248,19 +250,19 @@ describe(ID, () => { }, }, { + blockType: 'Paragraph', segments: [ { - text: ' ', segmentType: 'Text', + text: ' ', format: {}, }, { - isSelected: true, segmentType: 'SelectionMarker', + isSelected: true, format: {}, }, ], - blockType: 'Paragraph', format: { marginTop: '1em', marginBottom: '1em', diff --git a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteTest.ts index 75bc4459657..fe25a51a933 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteTest.ts @@ -3,7 +3,6 @@ import { ClipboardData, DomToModelOption } from 'roosterjs-content-model-types'; import { expectEqual, initEditor } from './testUtils'; import { IContentModelEditor } from 'roosterjs-content-model-editor'; import { itChromeOnly } from 'roosterjs-editor-dom/test/DomTestHelper'; -import { paste } from 'roosterjs-content-model-core'; import { tableProcessor } from 'roosterjs-content-model-dom'; const ID = 'CM_Paste_E2E'; @@ -36,7 +35,7 @@ describe(ID, () => { '
No.
ID
Work Item Type

', }); - paste(editor, clipboardData); + editor.paste(clipboardData); const model = editor.createContentModel({ processorOverride: { @@ -64,7 +63,6 @@ describe(ID, () => { segmentType: 'Text', text: 'No.', format: { - letterSpacing: 'normal', fontFamily: 'Calibri, sans-serif', fontSize: '11pt', fontWeight: '700', @@ -114,7 +112,6 @@ describe(ID, () => { segmentType: 'Text', text: 'ID', format: { - letterSpacing: 'normal', fontFamily: 'Calibri, sans-serif', fontSize: '11pt', fontWeight: '700', @@ -162,7 +159,6 @@ describe(ID, () => { segmentType: 'Text', text: 'Work Item Type', format: { - letterSpacing: 'normal', fontFamily: 'Calibri, sans-serif', fontSize: '11pt', fontWeight: '700', @@ -172,7 +168,6 @@ describe(ID, () => { ], format: { textAlign: 'center', - whiteSpace: 'normal', marginTop: '0px', marginRight: '0px', marginBottom: '0px', @@ -182,7 +177,6 @@ describe(ID, () => { ], format: { textAlign: 'center', - whiteSpace: 'normal', borderTop: '0.5pt solid', borderRight: '0.5pt solid', borderBottom: '0.5pt solid', @@ -205,7 +199,6 @@ describe(ID, () => { ], format: { textAlign: 'start', - whiteSpace: 'normal', backgroundColor: 'rgb(255, 255, 255)', width: '170pt', useBorderBox: true, diff --git a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/testUtils.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/testUtils.ts index 69579d81fb6..3d03d83a631 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/testUtils.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/testUtils.ts @@ -40,5 +40,6 @@ export function expectEqual(model1: ContentModelDocument, model2: ContentModelDo }) ) ); + expect(newModel).toEqual(model2); } diff --git a/packages-content-model/roosterjs-content-model-plugins/test/paste/getStyleMetadataTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/getStyleMetadataTest.ts index 68a66e80f30..c62b569af11 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/paste/getStyleMetadataTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/getStyleMetadataTest.ts @@ -11,39 +11,47 @@ describe('getStyleMetadata', () => { expect(result.get('l0:level1')).toEqual({ 'mso-level-number-format': 'roman-upper', - 'mso-level-start-at': undefined, + 'mso-level-start-at': '1', 'mso-level-text': '%1)', }); expect(result.get('l0:level2')).toEqual({ 'mso-level-number-format': 'alpha-lower', - 'mso-level-start-at': undefined, + 'mso-level-start-at': '1', 'mso-level-text': undefined, }); expect(result.get('l0:level3')).toEqual({ 'mso-level-number-format': 'roman-lower', - 'mso-level-start-at': undefined, + 'mso-level-start-at': '1', + 'mso-level-text': undefined, + }); + expect(result.get('l0:level4')).toEqual({ + 'mso-level-number-format': undefined, + 'mso-level-start-at': '1', 'mso-level-text': undefined, }); - expect(result.get('l0:level4')).toEqual(undefined); expect(result.get('l0:level5')).toEqual({ 'mso-level-number-format': 'alpha-lower', - 'mso-level-start-at': undefined, + 'mso-level-start-at': '1', 'mso-level-text': undefined, }); expect(result.get('l0:level6')).toEqual({ 'mso-level-number-format': 'roman-lower', - 'mso-level-start-at': undefined, + 'mso-level-start-at': '1', + 'mso-level-text': undefined, + }); + expect(result.get('l0:level7')).toEqual({ + 'mso-level-number-format': undefined, + 'mso-level-start-at': '1', 'mso-level-text': undefined, }); - expect(result.get('l0:level7')).toEqual(undefined); expect(result.get('l0:level8')).toEqual({ 'mso-level-number-format': 'alpha-lower', - 'mso-level-start-at': undefined, + 'mso-level-start-at': '1', 'mso-level-text': undefined, }); expect(result.get('l0:level9')).toEqual({ 'mso-level-number-format': 'roman-lower', - 'mso-level-start-at': undefined, + 'mso-level-start-at': '1', 'mso-level-text': undefined, }); expect(result.get('l0:level10')).toEqual(undefined); diff --git a/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromWacTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromWacTest.ts index f5ce08c4c4f..19e081eda9a 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromWacTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromWacTest.ts @@ -2,6 +2,7 @@ import { Browser } from 'roosterjs-editor-dom'; import { ContentModelDocument } from 'roosterjs-content-model-types'; import { createBeforePasteEventMock } from './processPastedContentFromWordDesktopTest'; import { itChromeOnly } from 'roosterjs-editor-dom/test/DomTestHelper'; +import { pasteDisplayFormatParser } from 'roosterjs-content-model-core/lib/override/pasteDisplayFormatParser'; import { processPastedContentWacComponents } from '../../lib/paste/WacComponents/processPastedContentWacComponents'; import { listItemMetadataApplier, @@ -151,7 +152,15 @@ describe('wordOnlineHandler', () => { const model = domToContentModel( fragment, - createDomToModelContext(undefined, event.domToModelOption) + createDomToModelContext( + undefined, + { + formatParserOverride: { + display: pasteDisplayFormatParser, + }, + }, + event.domToModelOption + ) ); if (expectedModel) { expect(model).toEqual(expectedModel); @@ -1357,12 +1366,10 @@ describe('wordOnlineHandler', () => { segmentType: 'Image', src: 'http://www.microsoft.com', format: { - letterSpacing: 'normal', fontFamily: '"Segoe UI", "Segoe UI Web", Arial, Verdana, sans-serif', fontSize: '12px', italic: false, - fontWeight: '400', textColor: 'rgb(0, 0, 0)', backgroundColor: 'rgb(255, 255, 255)', width: '264px', @@ -1371,10 +1378,6 @@ describe('wordOnlineHandler', () => { marginRight: '0px', marginBottom: '0px', marginLeft: '0px', - paddingTop: '0px', - paddingRight: '0px', - paddingBottom: '0px', - paddingLeft: '0px', borderTop: Browser.isFirefox ? 'medium none' : '', borderRight: Browser.isFirefox ? 'medium none' : '', borderBottom: Browser.isFirefox ? 'medium none' : '', @@ -1662,15 +1665,13 @@ describe('wordOnlineHandler', () => { segmentType: 'Text', text: 'ODSP', format: { - letterSpacing: - 'normal', fontFamily: '"Segoe UI", "Segoe UI_EmbeddedFont", "Segoe UI_MSFontService", sans-serif', fontSize: '20pt', italic: false, - fontWeight: 'bold', textColor: 'rgb(255, 255, 255)', + fontWeight: 'bold', lineHeight: '41.85px', }, @@ -1679,16 +1680,14 @@ describe('wordOnlineHandler', () => { segmentType: 'Text', text: ' ', format: { - letterSpacing: - 'normal', fontFamily: 'WordVisiCarriageReturn_MSFontService, "Segoe UI", "Segoe UI_EmbeddedFont", "Segoe UI_MSFontService", sans-serif', fontSize: '20pt', italic: false, - fontWeight: - 'normal', textColor: 'rgb(255, 255, 255)', + fontWeight: + 'normal', lineHeight: '41.85px', }, @@ -1696,16 +1695,14 @@ describe('wordOnlineHandler', () => { { segmentType: 'Br', format: { - letterSpacing: - 'normal', fontFamily: 'WordVisiCarriageReturn_MSFontService, "Segoe UI", "Segoe UI_EmbeddedFont", "Segoe UI_MSFontService", sans-serif', fontSize: '20pt', italic: false, - fontWeight: - 'normal', textColor: 'rgb(255, 255, 255)', + fontWeight: + 'normal', lineHeight: '41.85px', }, @@ -1714,15 +1711,13 @@ describe('wordOnlineHandler', () => { segmentType: 'Text', text: 'xFun', format: { - letterSpacing: - 'normal', fontFamily: '"Segoe UI", "Segoe UI_EmbeddedFont", "Segoe UI_MSFontService", sans-serif', fontSize: '20pt', italic: false, - fontWeight: 'bold', textColor: 'rgb(255, 255, 255)', + fontWeight: 'bold', lineHeight: '41.85px', }, @@ -1731,16 +1726,14 @@ describe('wordOnlineHandler', () => { segmentType: 'Text', text: ' ', format: { - letterSpacing: - 'normal', fontFamily: '"Segoe UI", "Segoe UI_EmbeddedFont", "Segoe UI_MSFontService", sans-serif', fontSize: '20pt', italic: false, - fontWeight: - 'normal', textColor: 'rgb(255, 255, 255)', + fontWeight: + 'normal', lineHeight: '41.85px', }, @@ -1749,21 +1742,16 @@ describe('wordOnlineHandler', () => { format: { direction: 'ltr', textAlign: 'start', - whiteSpace: 'pre-wrap', marginLeft: '0px', marginRight: '0px', + whiteSpace: 'pre-wrap', marginTop: '0px', marginBottom: '0px', - paddingLeft: '0px', - paddingRight: '0px', - backgroundColor: - 'transparent', - paddingTop: '0px', - paddingBottom: '0px', }, segmentFormat: { - fontWeight: 'normal', italic: false, + fontWeight: 'normal', + textColor: 'rgb(0, 0, 0)', }, decorator: { tagName: 'p', @@ -1774,14 +1762,11 @@ describe('wordOnlineHandler', () => { format: { direction: 'ltr', textAlign: 'start', - whiteSpace: 'normal', marginTop: '0px', marginRight: '0px', marginBottom: '0px', marginLeft: '0px', - paddingTop: '0px', paddingRight: '6px', - paddingBottom: '0px', paddingLeft: '6px', }, }, @@ -1789,18 +1774,12 @@ describe('wordOnlineHandler', () => { format: { direction: 'ltr', textAlign: 'start', - whiteSpace: 'normal', - backgroundColor: 'rgb(21, 96, 130)', - width: '312px', borderTop: '1px solid', - borderRight: '0px none', borderBottom: '1px solid rgb(0, 0, 0)', borderLeft: '1px solid', - paddingTop: '0px', - paddingRight: '0px', - paddingBottom: '0px', - paddingLeft: '0px', + backgroundColor: 'rgb(21, 96, 130)', verticalAlign: 'middle', + width: '312px', }, spanLeft: false, spanAbove: false, @@ -1825,15 +1804,13 @@ describe('wordOnlineHandler', () => { text: 'Title of Announcement', format: { - letterSpacing: - 'normal', fontFamily: '"Segoe UI", "Segoe UI_EmbeddedFont", "Segoe UI_MSFontService", sans-serif', fontSize: '21.5pt', italic: false, - fontWeight: 'bold', textColor: 'rgb(255, 255, 255)', + fontWeight: 'bold', lineHeight: '44.175px', }, @@ -1842,16 +1819,14 @@ describe('wordOnlineHandler', () => { segmentType: 'Text', text: ' ', format: { - letterSpacing: - 'normal', fontFamily: '"Segoe UI", "Segoe UI_EmbeddedFont", "Segoe UI_MSFontService", sans-serif', fontSize: '21.5pt', italic: false, - fontWeight: - 'normal', textColor: 'rgb(255, 255, 255)', + fontWeight: + 'normal', lineHeight: '44.175px', }, @@ -1860,21 +1835,16 @@ describe('wordOnlineHandler', () => { format: { direction: 'ltr', textAlign: 'start', - whiteSpace: 'pre-wrap', marginLeft: '0px', marginRight: '0px', + whiteSpace: 'pre-wrap', marginTop: '0px', marginBottom: '0px', - paddingLeft: '0px', - paddingRight: '0px', - backgroundColor: - 'transparent', - paddingTop: '0px', - paddingBottom: '0px', }, segmentFormat: { - fontWeight: 'normal', italic: false, + fontWeight: 'normal', + textColor: 'rgb(0, 0, 0)', }, decorator: { tagName: 'p', @@ -1885,14 +1855,11 @@ describe('wordOnlineHandler', () => { format: { direction: 'ltr', textAlign: 'start', - whiteSpace: 'normal', marginTop: '0px', marginRight: '0px', marginBottom: '0px', marginLeft: '0px', - paddingTop: '0px', paddingRight: '6px', - paddingBottom: '0px', paddingLeft: '6px', }, }, @@ -1900,18 +1867,12 @@ describe('wordOnlineHandler', () => { format: { direction: 'ltr', textAlign: 'start', - whiteSpace: 'normal', - backgroundColor: 'rgb(21, 96, 130)', - width: '312px', borderTop: '1px solid', borderRight: '1px solid', borderBottom: '1px solid rgb(0, 0, 0)', - borderLeft: '0px none', - paddingTop: '0px', - paddingRight: '0px', - paddingBottom: '0px', - paddingLeft: '0px', + backgroundColor: 'rgb(21, 96, 130)', verticalAlign: 'middle', + width: '312px', }, spanLeft: false, spanAbove: false, @@ -1941,15 +1902,13 @@ describe('wordOnlineHandler', () => { segmentType: 'Text', text: 'Announcement ', format: { - letterSpacing: - 'normal', fontFamily: 'Aptos_MSFontService, Aptos_MSFontService_EmbeddedFont, Aptos_MSFontService_MSFontService, sans-serif', fontSize: '14pt', italic: false, - fontWeight: 'bold', textColor: 'rgb(255, 255, 255)', + fontWeight: 'bold', lineHeight: '24.4125px', }, @@ -1958,16 +1917,14 @@ describe('wordOnlineHandler', () => { segmentType: 'Text', text: ' ', format: { - letterSpacing: - 'normal', fontFamily: 'Aptos_MSFontService, Aptos_MSFontService_EmbeddedFont, Aptos_MSFontService_MSFontService, sans-serif', fontSize: '14pt', italic: false, - fontWeight: - 'normal', textColor: 'rgb(255, 255, 255)', + fontWeight: + 'normal', lineHeight: '24.4125px', }, @@ -1976,21 +1933,16 @@ describe('wordOnlineHandler', () => { format: { direction: 'ltr', textAlign: 'start', - whiteSpace: 'pre-wrap', marginLeft: '0px', marginRight: '0px', + whiteSpace: 'pre-wrap', marginTop: '0px', marginBottom: '0px', - paddingLeft: '0px', - paddingRight: '0px', - backgroundColor: - 'transparent', - paddingTop: '0px', - paddingBottom: '0px', }, segmentFormat: { - fontWeight: 'normal', italic: false, + fontWeight: 'normal', + textColor: 'rgb(0, 0, 0)', }, decorator: { tagName: 'p', @@ -2001,14 +1953,11 @@ describe('wordOnlineHandler', () => { format: { direction: 'ltr', textAlign: 'start', - whiteSpace: 'normal', marginTop: '0px', marginRight: '0px', marginBottom: '0px', marginLeft: '0px', - paddingTop: '0px', paddingRight: '6px', - paddingBottom: '0px', paddingLeft: '6px', }, }, @@ -2016,18 +1965,13 @@ describe('wordOnlineHandler', () => { format: { direction: 'ltr', textAlign: 'start', - whiteSpace: 'normal', - backgroundColor: 'rgb(0, 0, 0)', - width: '624px', borderTop: '1px solid rgb(0, 0, 0)', borderRight: '1px solid', borderBottom: '1px solid rgb(0, 0, 0)', borderLeft: '1px solid', - paddingTop: '0px', - paddingRight: '0px', - paddingBottom: '0px', - paddingLeft: '0px', + backgroundColor: 'rgb(0, 0, 0)', verticalAlign: 'middle', + width: '624px', }, spanLeft: false, spanAbove: false, @@ -2042,18 +1986,13 @@ describe('wordOnlineHandler', () => { format: { direction: 'ltr', textAlign: 'start', - whiteSpace: 'normal', - backgroundColor: 'rgb(0, 0, 0)', - width: '624px', borderTop: '1px solid rgb(0, 0, 0)', borderRight: '1px solid', borderBottom: '1px solid rgb(0, 0, 0)', borderLeft: '1px solid', - paddingTop: '0px', - paddingRight: '0px', - paddingBottom: '0px', - paddingLeft: '0px', + backgroundColor: 'rgb(0, 0, 0)', verticalAlign: 'middle', + width: '624px', }, spanLeft: true, spanAbove: false, @@ -2083,16 +2022,14 @@ describe('wordOnlineHandler', () => { segmentType: 'Text', text: 'Hello ', format: { - letterSpacing: - 'normal', fontFamily: '"Segoe UI", "Segoe UI_EmbeddedFont", "Segoe UI_MSFontService", sans-serif', fontSize: '12pt', italic: false, - fontWeight: - 'normal', textColor: 'rgb(0, 0, 0)', + fontWeight: + 'normal', lineHeight: '23.7333px', }, @@ -2101,16 +2038,14 @@ describe('wordOnlineHandler', () => { segmentType: 'Text', text: ' ', format: { - letterSpacing: - 'normal', fontFamily: '"Segoe UI", "Segoe UI_EmbeddedFont", "Segoe UI_MSFontService", sans-serif', fontSize: '12pt', italic: false, - fontWeight: - 'normal', textColor: 'rgb(0, 0, 0)', + fontWeight: + 'normal', lineHeight: '23.7333px', }, @@ -2119,21 +2054,16 @@ describe('wordOnlineHandler', () => { format: { direction: 'ltr', textAlign: 'start', - whiteSpace: 'pre-wrap', marginLeft: '0px', marginRight: '0px', + whiteSpace: 'pre-wrap', marginTop: '0px', marginBottom: '0px', - paddingLeft: '0px', - paddingRight: '0px', - backgroundColor: - 'transparent', - paddingTop: '0px', - paddingBottom: '0px', }, segmentFormat: { - fontWeight: 'normal', italic: false, + fontWeight: 'normal', + textColor: 'rgb(0, 0, 0)', }, decorator: { tagName: 'p', @@ -2147,16 +2077,14 @@ describe('wordOnlineHandler', () => { segmentType: 'Text', text: ' ', format: { - letterSpacing: - 'normal', fontFamily: '"Segoe UI", "Segoe UI_EmbeddedFont", "Segoe UI_MSFontService", sans-serif', fontSize: '12pt', italic: false, - fontWeight: - 'normal', textColor: 'rgb(0, 0, 0)', + fontWeight: + 'normal', lineHeight: '23.7333px', }, @@ -2165,21 +2093,16 @@ describe('wordOnlineHandler', () => { format: { direction: 'ltr', textAlign: 'start', - whiteSpace: 'pre-wrap', marginLeft: '0px', marginRight: '0px', + whiteSpace: 'pre-wrap', marginTop: '0px', marginBottom: '0px', - paddingLeft: '0px', - paddingRight: '0px', - backgroundColor: - 'transparent', - paddingTop: '0px', - paddingBottom: '0px', }, segmentFormat: { - fontWeight: 'normal', italic: false, + fontWeight: 'normal', + textColor: 'rgb(0, 0, 0)', }, decorator: { tagName: 'p', @@ -2194,16 +2117,14 @@ describe('wordOnlineHandler', () => { text: '[Brief description of change]', format: { - letterSpacing: - 'normal', fontFamily: '"Segoe UI", "Segoe UI_EmbeddedFont", "Segoe UI_MSFontService", sans-serif', fontSize: '12pt', italic: false, - fontWeight: - 'normal', textColor: 'rgb(0, 0, 0)', + fontWeight: + 'normal', lineHeight: '23.7333px', }, @@ -2212,16 +2133,14 @@ describe('wordOnlineHandler', () => { segmentType: 'Text', text: ' ', format: { - letterSpacing: - 'normal', fontFamily: 'WordVisiCarriageReturn_MSFontService, "Segoe UI", "Segoe UI_EmbeddedFont", "Segoe UI_MSFontService", sans-serif', fontSize: '12pt', italic: false, - fontWeight: - 'normal', textColor: 'rgb(0, 0, 0)', + fontWeight: + 'normal', lineHeight: '23.7333px', }, @@ -2229,16 +2148,14 @@ describe('wordOnlineHandler', () => { { segmentType: 'Br', format: { - letterSpacing: - 'normal', fontFamily: 'WordVisiCarriageReturn_MSFontService, "Segoe UI", "Segoe UI_EmbeddedFont", "Segoe UI_MSFontService", sans-serif', fontSize: '12pt', italic: false, - fontWeight: - 'normal', textColor: 'rgb(0, 0, 0)', + fontWeight: + 'normal', lineHeight: '23.7333px', }, @@ -2247,16 +2164,14 @@ describe('wordOnlineHandler', () => { segmentType: 'Text', text: ' ', format: { - letterSpacing: - 'normal', fontFamily: '"Segoe UI", "Segoe UI_EmbeddedFont", "Segoe UI_MSFontService", sans-serif', fontSize: '12pt', italic: false, - fontWeight: - 'normal', textColor: 'rgb(0, 0, 0)', + fontWeight: + 'normal', lineHeight: '23.7333px', }, @@ -2265,21 +2180,16 @@ describe('wordOnlineHandler', () => { format: { direction: 'ltr', textAlign: 'start', - whiteSpace: 'pre-wrap', marginLeft: '0px', marginRight: '0px', + whiteSpace: 'pre-wrap', marginTop: '0px', marginBottom: '0px', - paddingLeft: '0px', - paddingRight: '0px', - backgroundColor: - 'transparent', - paddingTop: '0px', - paddingBottom: '0px', }, segmentFormat: { - fontWeight: 'normal', italic: false, + fontWeight: 'normal', + textColor: 'rgb(0, 0, 0)', }, decorator: { tagName: 'p', @@ -2294,35 +2204,30 @@ describe('wordOnlineHandler', () => { text: '[What changed and how it ', format: { - letterSpacing: - 'normal', fontFamily: '"Segoe UI", "Segoe UI_EmbeddedFont", "Segoe UI_MSFontService", sans-serif', fontSize: '12pt', italic: false, - fontWeight: - 'normal', textColor: 'rgb(0, 0, 0)', + fontWeight: + 'normal', lineHeight: '23.7333px', }, }, - { segmentType: 'Text', text: 'benefits', format: { - letterSpacing: - 'normal', fontFamily: '"Segoe UI", "Segoe UI_EmbeddedFont", "Segoe UI_MSFontService", sans-serif', fontSize: '12pt', italic: false, - fontWeight: - 'normal', textColor: 'rgb(0, 0, 0)', + fontWeight: + 'normal', lineHeight: '23.7333px', }, @@ -2331,16 +2236,14 @@ describe('wordOnlineHandler', () => { segmentType: 'Text', text: ' ', format: { - letterSpacing: - 'normal', fontFamily: '"Segoe UI", "Segoe UI_EmbeddedFont", "Segoe UI_MSFontService", sans-serif', fontSize: '12pt', italic: false, - fontWeight: - 'normal', textColor: 'rgb(0, 0, 0)', + fontWeight: + 'normal', lineHeight: '23.7333px', }, @@ -2349,16 +2252,14 @@ describe('wordOnlineHandler', () => { segmentType: 'Text', text: 'devs', format: { - letterSpacing: - 'normal', fontFamily: '"Segoe UI", "Segoe UI_EmbeddedFont", "Segoe UI_MSFontService", sans-serif', fontSize: '12pt', italic: false, - fontWeight: - 'normal', textColor: 'rgb(0, 0, 0)', + fontWeight: + 'normal', lineHeight: '23.7333px', }, @@ -2367,16 +2268,14 @@ describe('wordOnlineHandler', () => { segmentType: 'Text', text: ']', format: { - letterSpacing: - 'normal', fontFamily: '"Segoe UI", "Segoe UI_EmbeddedFont", "Segoe UI_MSFontService", sans-serif', fontSize: '12pt', italic: false, - fontWeight: - 'normal', textColor: 'rgb(0, 0, 0)', + fontWeight: + 'normal', lineHeight: '23.7333px', }, @@ -2385,16 +2284,14 @@ describe('wordOnlineHandler', () => { segmentType: 'Text', text: ' ', format: { - letterSpacing: - 'normal', fontFamily: '"Segoe UI", "Segoe UI_EmbeddedFont", "Segoe UI_MSFontService", sans-serif', fontSize: '12pt', italic: false, - fontWeight: - 'normal', textColor: 'rgb(0, 0, 0)', + fontWeight: + 'normal', lineHeight: '23.7333px', }, @@ -2403,21 +2300,16 @@ describe('wordOnlineHandler', () => { format: { direction: 'ltr', textAlign: 'start', - whiteSpace: 'pre-wrap', marginLeft: '0px', marginRight: '0px', + whiteSpace: 'pre-wrap', marginTop: '0px', marginBottom: '0px', - paddingLeft: '0px', - paddingRight: '0px', - backgroundColor: - 'transparent', - paddingTop: '0px', - paddingBottom: '0px', }, segmentFormat: { - fontWeight: 'normal', italic: false, + fontWeight: 'normal', + textColor: 'rgb(0, 0, 0)', }, decorator: { tagName: 'p', @@ -2431,16 +2323,14 @@ describe('wordOnlineHandler', () => { segmentType: 'Text', text: ' ', format: { - letterSpacing: - 'normal', fontFamily: '"Segoe UI", "Segoe UI_EmbeddedFont", "Segoe UI_MSFontService", sans-serif', fontSize: '12pt', italic: false, - fontWeight: - 'normal', textColor: 'rgb(0, 0, 0)', + fontWeight: + 'normal', lineHeight: '21px', }, }, @@ -2448,21 +2338,16 @@ describe('wordOnlineHandler', () => { format: { direction: 'ltr', textAlign: 'start', - whiteSpace: 'pre-wrap', marginLeft: '0px', marginRight: '0px', + whiteSpace: 'pre-wrap', marginTop: '0px', marginBottom: '0px', - paddingLeft: '0px', - paddingRight: '0px', - backgroundColor: - 'transparent', - paddingTop: '0px', - paddingBottom: '0px', }, segmentFormat: { - fontWeight: 'normal', italic: false, + fontWeight: 'normal', + textColor: 'rgb(0, 0, 0)', }, decorator: { tagName: 'p', @@ -2477,16 +2362,14 @@ describe('wordOnlineHandler', () => { text: '[Any action needed by devs]', format: { - letterSpacing: - 'normal', fontFamily: '"Segoe UI", "Segoe UI_EmbeddedFont", "Segoe UI_MSFontService", sans-serif', fontSize: '12pt', italic: false, - fontWeight: - 'normal', textColor: 'rgb(0, 0, 0)', + fontWeight: + 'normal', lineHeight: '21px', }, }, @@ -2494,16 +2377,14 @@ describe('wordOnlineHandler', () => { segmentType: 'Text', text: ' ', format: { - letterSpacing: - 'normal', fontFamily: '"Segoe UI", "Segoe UI_EmbeddedFont", "Segoe UI_MSFontService", sans-serif', fontSize: '12pt', italic: false, - fontWeight: - 'normal', textColor: 'rgb(0, 0, 0)', + fontWeight: + 'normal', lineHeight: '21px', }, }, @@ -2511,21 +2392,16 @@ describe('wordOnlineHandler', () => { format: { direction: 'ltr', textAlign: 'start', - whiteSpace: 'pre-wrap', marginLeft: '0px', marginRight: '0px', + whiteSpace: 'pre-wrap', marginTop: '0px', marginBottom: '0px', - paddingLeft: '0px', - paddingRight: '0px', - backgroundColor: - 'transparent', - paddingTop: '0px', - paddingBottom: '0px', }, segmentFormat: { - fontWeight: 'normal', italic: false, + fontWeight: 'normal', + textColor: 'rgb(0, 0, 0)', }, decorator: { tagName: 'p', @@ -2539,16 +2415,14 @@ describe('wordOnlineHandler', () => { segmentType: 'Text', text: ' ', format: { - letterSpacing: - 'normal', fontFamily: '"Segoe UI", "Segoe UI_EmbeddedFont", "Segoe UI_MSFontService", sans-serif', fontSize: '12pt', italic: false, - fontWeight: - 'normal', textColor: 'rgb(0, 0, 0)', + fontWeight: + 'normal', lineHeight: '21px', }, }, @@ -2556,21 +2430,16 @@ describe('wordOnlineHandler', () => { format: { direction: 'ltr', textAlign: 'start', - whiteSpace: 'pre-wrap', marginLeft: '0px', marginRight: '0px', + whiteSpace: 'pre-wrap', marginTop: '0px', marginBottom: '0px', - paddingLeft: '0px', - paddingRight: '0px', - backgroundColor: - 'transparent', - paddingTop: '0px', - paddingBottom: '0px', }, segmentFormat: { - fontWeight: 'normal', italic: false, + fontWeight: 'normal', + textColor: 'rgb(0, 0, 0)', }, decorator: { tagName: 'p', @@ -2585,16 +2454,14 @@ describe('wordOnlineHandler', () => { text: '[Link to Documentation ]', format: { - letterSpacing: - 'normal', fontFamily: '"Segoe UI", "Segoe UI_EmbeddedFont", "Segoe UI_MSFontService", sans-serif', fontSize: '12pt', italic: false, - fontWeight: - 'normal', textColor: 'rgb(0, 0, 0)', + fontWeight: + 'normal', lineHeight: '21px', }, }, @@ -2602,32 +2469,28 @@ describe('wordOnlineHandler', () => { segmentType: 'Text', text: ' ', format: { - letterSpacing: - 'normal', fontFamily: 'WordVisiCarriageReturn_MSFontService, "Segoe UI", "Segoe UI_EmbeddedFont", "Segoe UI_MSFontService", sans-serif', fontSize: '12pt', italic: false, - fontWeight: - 'normal', textColor: 'rgb(0, 0, 0)', + fontWeight: + 'normal', lineHeight: '21px', }, }, { segmentType: 'Br', format: { - letterSpacing: - 'normal', fontFamily: 'WordVisiCarriageReturn_MSFontService, "Segoe UI", "Segoe UI_EmbeddedFont", "Segoe UI_MSFontService", sans-serif', fontSize: '12pt', italic: false, - fontWeight: - 'normal', textColor: 'rgb(0, 0, 0)', + fontWeight: + 'normal', lineHeight: '21px', }, }, @@ -2635,16 +2498,14 @@ describe('wordOnlineHandler', () => { segmentType: 'Text', text: ' ', format: { - letterSpacing: - 'normal', fontFamily: '"Segoe UI", "Segoe UI_EmbeddedFont", "Segoe UI_MSFontService", sans-serif', fontSize: '12pt', italic: false, - fontWeight: - 'normal', textColor: 'rgb(0, 0, 0)', + fontWeight: + 'normal', lineHeight: '21px', }, }, @@ -2652,16 +2513,14 @@ describe('wordOnlineHandler', () => { segmentType: 'Text', text: ' ', format: { - letterSpacing: - 'normal', fontFamily: '"Segoe UI", "Segoe UI_EmbeddedFont", "Segoe UI_MSFontService", sans-serif', fontSize: '12pt', italic: false, - fontWeight: - 'normal', textColor: 'rgb(0, 0, 0)', + fontWeight: + 'normal', lineHeight: '21px', }, }, @@ -2669,21 +2528,16 @@ describe('wordOnlineHandler', () => { format: { direction: 'ltr', textAlign: 'start', - whiteSpace: 'pre-wrap', marginLeft: '0px', marginRight: '0px', + whiteSpace: 'pre-wrap', marginTop: '0px', marginBottom: '0px', - paddingLeft: '0px', - paddingRight: '0px', - backgroundColor: - 'transparent', - paddingTop: '0px', - paddingBottom: '0px', }, segmentFormat: { - fontWeight: 'normal', italic: false, + fontWeight: 'normal', + textColor: 'rgb(0, 0, 0)', }, decorator: { tagName: 'p', @@ -2698,16 +2552,14 @@ describe('wordOnlineHandler', () => { text: '[What comes next if something comes next]', format: { - letterSpacing: - 'normal', fontFamily: '"Segoe UI", "Segoe UI_EmbeddedFont", "Segoe UI_MSFontService", sans-serif', fontSize: '12pt', italic: false, - fontWeight: - 'normal', textColor: 'rgb(0, 0, 0)', + fontWeight: + 'normal', lineHeight: '23.7333px', }, @@ -2716,16 +2568,14 @@ describe('wordOnlineHandler', () => { segmentType: 'Text', text: ' ', format: { - letterSpacing: - 'normal', fontFamily: 'WordVisiCarriageReturn_MSFontService, "Segoe UI", "Segoe UI_EmbeddedFont", "Segoe UI_MSFontService", sans-serif', fontSize: '12pt', italic: false, - fontWeight: - 'normal', textColor: 'rgb(0, 0, 0)', + fontWeight: + 'normal', lineHeight: '23.7333px', }, @@ -2733,16 +2583,14 @@ describe('wordOnlineHandler', () => { { segmentType: 'Br', format: { - letterSpacing: - 'normal', fontFamily: 'WordVisiCarriageReturn_MSFontService, "Segoe UI", "Segoe UI_EmbeddedFont", "Segoe UI_MSFontService", sans-serif', fontSize: '12pt', italic: false, - fontWeight: - 'normal', textColor: 'rgb(0, 0, 0)', + fontWeight: + 'normal', lineHeight: '23.7333px', }, @@ -2751,16 +2599,14 @@ describe('wordOnlineHandler', () => { segmentType: 'Text', text: ' ', format: { - letterSpacing: - 'normal', fontFamily: '"Segoe UI", "Segoe UI_EmbeddedFont", "Segoe UI_MSFontService", sans-serif', fontSize: '12pt', italic: false, - fontWeight: - 'normal', textColor: 'rgb(0, 0, 0)', + fontWeight: + 'normal', lineHeight: '23.7333px', }, @@ -2769,21 +2615,16 @@ describe('wordOnlineHandler', () => { format: { direction: 'ltr', textAlign: 'start', - whiteSpace: 'pre-wrap', marginLeft: '0px', marginRight: '0px', + whiteSpace: 'pre-wrap', marginTop: '0px', marginBottom: '0px', - paddingLeft: '0px', - paddingRight: '0px', - backgroundColor: - 'transparent', - paddingTop: '0px', - paddingBottom: '0px', }, segmentFormat: { - fontWeight: 'normal', italic: false, + fontWeight: 'normal', + textColor: 'rgb(0, 0, 0)', }, decorator: { tagName: 'p', @@ -2794,14 +2635,11 @@ describe('wordOnlineHandler', () => { format: { direction: 'ltr', textAlign: 'start', - whiteSpace: 'normal', marginTop: '0px', marginRight: '0px', marginBottom: '0px', marginLeft: '0px', - paddingTop: '0px', paddingRight: '6px', - paddingBottom: '0px', paddingLeft: '6px', }, }, @@ -2809,18 +2647,12 @@ describe('wordOnlineHandler', () => { format: { direction: 'ltr', textAlign: 'start', - whiteSpace: 'normal', borderTop: '1px solid rgb(0, 0, 0)', borderRight: '1px solid rgb(0, 0, 0)', borderBottom: '1px solid rgb(0, 0, 0)', borderLeft: '1px solid rgb(0, 0, 0)', verticalAlign: 'top', width: '624px', - backgroundColor: 'transparent', - paddingTop: '0px', - paddingRight: '0px', - paddingBottom: '0px', - paddingLeft: '0px', }, spanLeft: false, spanAbove: false, @@ -2835,18 +2667,12 @@ describe('wordOnlineHandler', () => { format: { direction: 'ltr', textAlign: 'start', - whiteSpace: 'normal', borderTop: '1px solid rgb(0, 0, 0)', borderRight: '1px solid rgb(0, 0, 0)', borderBottom: '1px solid rgb(0, 0, 0)', borderLeft: '1px solid rgb(0, 0, 0)', verticalAlign: 'top', width: '624px', - backgroundColor: 'transparent', - paddingTop: '0px', - paddingRight: '0px', - paddingBottom: '0px', - paddingLeft: '0px', }, spanLeft: true, spanAbove: false, @@ -2861,8 +2687,6 @@ describe('wordOnlineHandler', () => { format: { direction: 'ltr', textAlign: 'start', - whiteSpace: 'normal', - backgroundColor: 'transparent', marginTop: '0px', marginRight: '0px', marginBottom: '0px', @@ -2870,7 +2694,7 @@ describe('wordOnlineHandler', () => { width: '0px', tableLayout: 'fixed', borderCollapse: true, - } as any, + }, widths: [], dataset: { tablelook: '1696', @@ -2881,31 +2705,20 @@ describe('wordOnlineHandler', () => { format: { direction: 'ltr', textAlign: 'start', - whiteSpace: 'normal', marginTop: '2px', marginRight: '0px', marginBottom: '2px', - display: 'flex', - paddingTop: '0px', - paddingRight: '0px', - paddingBottom: '0px', - paddingLeft: '0px', }, }, ], format: { direction: 'ltr', textAlign: 'start', - whiteSpace: 'normal', backgroundColor: 'rgb(255, 255, 255)', marginTop: '0px', marginRight: '0px', marginBottom: '0px', marginLeft: '0px', - paddingTop: '0px', - paddingRight: '0px', - paddingBottom: '0px', - paddingLeft: '0px', }, }, ], @@ -2951,4 +2764,52 @@ describe('wordOnlineHandler', () => { } ); }); + + it('Remove Negative Left margin from table', () => { + runTest( + '
Test
', + '
Test
', + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + rows: [ + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Test', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: {}, + widths: [], + dataset: {}, + }, + ], + } + ); + }); }); diff --git a/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromWordDesktopTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromWordDesktopTest.ts index 408d2e1163c..b0b8a367f1c 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromWordDesktopTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromWordDesktopTest.ts @@ -1,22 +1,11 @@ import * as getStyleMetadata from '../../lib/paste/WordDesktop/getStyleMetadata'; +import { ClipboardData, ContentModelBeforePasteEvent } from 'roosterjs-content-model-types'; import { expectEqual } from './e2e/testUtils'; -import { expectHtml } from 'roosterjs-editor-api/test/TestHelper'; import { PluginEventType } from 'roosterjs-editor-types'; import { processPastedContentFromWordDesktop } from '../../lib/paste/WordDesktop/processPastedContentFromWordDesktop'; import { WordMetadata } from '../../lib/paste/WordDesktop/WordMetadata'; import { - ClipboardData, - ContentModelBeforePasteEvent, - ContentModelDocument, -} from 'roosterjs-content-model-types'; -import { - listItemMetadataApplier, - listLevelMetadataApplier, -} from 'roosterjs-content-model-core/lib/metadata/updateListMetadata'; -import { - contentModelToDom, createDomToModelContext, - createModelToDomContext, domToContentModel, moveChildNodes, } from 'roosterjs-content-model-dom'; @@ -24,13 +13,12 @@ import { describe('processPastedContentFromWordDesktopTest', () => { let div: HTMLElement; let fragment: DocumentFragment; - let htmlBefore: string = ''; function runTest( source?: string, - expected?: string | string[], - expectedModel?: ContentModelDocument, - removeUndefinedValues?: boolean + expectedModel?: any, + removeUndefinedValues?: boolean, + htmlBefore?: string ) { //Act if (source) { @@ -53,36 +41,12 @@ describe('processPastedContentFromWordDesktopTest', () => { expect(model).toEqual(expectedModel); } } - - contentModelToDom( - document, - div, - model, - createModelToDomContext( - { - isDarkMode: false, - }, - { - metadataAppliers: { - listItem: listItemMetadataApplier, - listLevel: listLevelMetadataApplier, - }, - } - ) - ); - - document.body.appendChild(div); - if (expected) { - expectHtml(div.innerHTML, expected); - } - div.parentElement?.removeChild(div); - htmlBefore = ''; } it('Remove Comment | mso-element:comment-list', () => { let source = '
Test
'; - runTest(source, '', { + runTest(source, { blockGroupType: 'Document', blocks: [], }); @@ -91,7 +55,7 @@ describe('processPastedContentFromWordDesktopTest', () => { it('Remove Comment | #_msocom_', () => { let source = '

[BV11]

'; - runTest(source, '', { + runTest(source, { blockGroupType: 'Document', blocks: [], }); @@ -101,7 +65,7 @@ describe('processPastedContentFromWordDesktopTest', () => { let source = '

TestTest

'; - runTest(source, '

TestTest

', { + runTest(source, { blockGroupType: 'Document', blocks: [ { @@ -133,7 +97,7 @@ describe('processPastedContentFromWordDesktopTest', () => { it('Remove Comment | mso-comment-continuation, remove style 1', () => { let source = 'TestTest'; - runTest(source, 'TestTest', { + runTest(source, { blockGroupType: 'Document', blocks: [ { @@ -159,22 +123,50 @@ describe('processPastedContentFromWordDesktopTest', () => { it('Remove Comment | mso-comment-done, remove style', () => { let source = 'Test'; - runTest(source, 'Test'); + runTest( + source, + { + blockGroupType: 'Document', + blocks: [ + { + isImplicit: true, + segments: [{ text: 'Test', segmentType: 'Text', format: {} }], + blockType: 'Paragraph', + format: {}, + }, + ], + }, + true + ); }); it('Remove Comment | mso-special-character:comment', () => { let source = 'Test'; - runTest(source, ''); + runTest(source, { blockGroupType: 'Document', blocks: [] }, true); }); it('Remove Line height less than default', () => { let source = '

Test

'; - runTest(source, '

Test

'); + runTest( + source, + { + blockGroupType: 'Document', + blocks: [ + { + segments: [{ text: 'Test', segmentType: 'Text', format: {} }], + blockType: 'Paragraph', + format: { marginTop: '1em', marginBottom: '1em' }, + decorator: { tagName: 'p', format: {} }, + }, + ], + }, + true + ); }); it(' Line height, not percentage do not remove', () => { let source = '

Test

'; - runTest(source, undefined /* expected html */, { + runTest(source, { blockGroupType: 'Document', blocks: [ { @@ -198,7 +190,7 @@ describe('processPastedContentFromWordDesktopTest', () => { it('Remove Line height, not percentage 2', () => { let source = '

Test

'; - runTest(source, undefined, { + runTest(source, { blockGroupType: 'Document', blocks: [ { @@ -222,7 +214,7 @@ describe('processPastedContentFromWordDesktopTest', () => { it('Remove Line height, percentage greater than default', () => { let source = '

Test

'; - runTest(source, undefined, { + runTest(source, { blockGroupType: 'Document', blocks: [ { @@ -244,6 +236,53 @@ describe('processPastedContentFromWordDesktopTest', () => { }); }); + it('Remove Negative Left margin from table', () => { + runTest( + '
Test
', + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + rows: [ + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Test', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: {}, + widths: [], + dataset: {}, + }, + ], + } + ); + }); + describe('List Convertion Tests | ', () => { it('List with Headings', () => { const html = @@ -256,7 +295,7 @@ describe('processPastedContentFromWordDesktopTest', () => { new Map().set('l0:level1', dta) ); - runTest(html, undefined /* expected html */, { + runTest(html, { blockGroupType: 'Document', blocks: [ { @@ -283,6 +322,7 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '1em', marginBottom: undefined, + wordList: 'l0', }, }, ], @@ -319,6 +359,7 @@ describe('processPastedContentFromWordDesktopTest', () => { dataset: {}, format: { marginBottom: undefined, + wordList: 'l0', }, }, ], @@ -345,7 +386,7 @@ describe('processPastedContentFromWordDesktopTest', () => { new Map().set('l0:level1', dta).set('l0:level2', dta) ); - runTest(html, undefined, { + runTest(html, { blockGroupType: 'Document', blocks: [ { @@ -372,6 +413,7 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '1em', marginBottom: undefined, + wordList: 'l0', }, }, ], @@ -409,6 +451,7 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '1em', marginBottom: undefined, + wordList: 'l0', }, }, { @@ -416,6 +459,7 @@ describe('processPastedContentFromWordDesktopTest', () => { dataset: {}, format: { marginBottom: undefined, + wordList: 'l0', }, }, ], @@ -442,7 +486,7 @@ describe('processPastedContentFromWordDesktopTest', () => { new Map().set('l0:level1', dta).set('l0:level3', dta) ); - runTest(html, undefined, { + runTest(html, { blockGroupType: 'Document', blocks: [ { @@ -469,6 +513,7 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '1em', marginBottom: undefined, + wordList: 'l0', }, }, ], @@ -506,6 +551,7 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '1em', marginBottom: undefined, + wordList: 'l0', }, }, { @@ -513,6 +559,7 @@ describe('processPastedContentFromWordDesktopTest', () => { dataset: {}, format: { marginBottom: undefined, + wordList: 'l0', }, }, { @@ -520,6 +567,7 @@ describe('processPastedContentFromWordDesktopTest', () => { dataset: {}, format: { marginBottom: undefined, + wordList: 'l0', }, }, ], @@ -545,7 +593,7 @@ describe('processPastedContentFromWordDesktopTest', () => { spyOn(getStyleMetadata, 'default').and.returnValue( new Map().set('l0:level1', dta).set('l1:level3', dta) ); - runTest(html, undefined, { + runTest(html, { blockGroupType: 'Document', blocks: [ { @@ -572,6 +620,7 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '1em', marginBottom: undefined, + wordList: 'l0', }, }, ], @@ -609,6 +658,7 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '1em', marginBottom: undefined, + wordList: 'l0', }, }, { @@ -616,6 +666,7 @@ describe('processPastedContentFromWordDesktopTest', () => { dataset: {}, format: { marginBottom: undefined, + wordList: 'l1', }, }, { @@ -623,6 +674,7 @@ describe('processPastedContentFromWordDesktopTest', () => { dataset: {}, format: { marginBottom: undefined, + wordList: 'l1', }, }, ], @@ -636,6 +688,7 @@ describe('processPastedContentFromWordDesktopTest', () => { ], }); }); + it('Complex list inside a Table cell', () => { const html = '
' + @@ -659,7 +712,7 @@ describe('processPastedContentFromWordDesktopTest', () => { fragment = document.createDocumentFragment(); div.innerHTML = html; moveChildNodes(fragment, div); - runTest(undefined, undefined, { + runTest(undefined, { blockGroupType: 'Document', blocks: [ { @@ -696,6 +749,7 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '1em', marginBottom: undefined, + wordList: 'l1', }, }, { @@ -704,6 +758,7 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '1em', marginBottom: undefined, + wordList: 'l1', }, }, { @@ -712,6 +767,7 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '1em', marginBottom: undefined, + wordList: 'l1', }, }, { @@ -720,6 +776,7 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '1em', marginBottom: undefined, + wordList: 'l1', }, }, ], @@ -754,6 +811,7 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '1em', marginBottom: undefined, + wordList: 'l1', }, }, { @@ -762,6 +820,7 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '1em', marginBottom: undefined, + wordList: 'l1', }, }, { @@ -770,6 +829,7 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '1em', marginBottom: undefined, + wordList: 'l1', }, }, ], @@ -804,6 +864,7 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '1em', marginBottom: undefined, + wordList: 'l1', }, }, { @@ -812,6 +873,7 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '1em', marginBottom: undefined, + wordList: 'l1', }, }, ], @@ -846,6 +908,7 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '1em', marginBottom: undefined, + wordList: 'l1', }, }, { @@ -854,6 +917,7 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '1em', marginBottom: undefined, + wordList: 'l1', }, }, { @@ -862,6 +926,7 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '1em', marginBottom: undefined, + wordList: 'l1', }, }, { @@ -870,6 +935,7 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '1em', marginBottom: undefined, + wordList: 'l1', }, }, { @@ -878,6 +944,7 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '1em', marginBottom: undefined, + wordList: 'l1', }, }, ], @@ -939,66 +1006,58 @@ describe('processPastedContentFromWordDesktopTest', () => { '' + '' + '', - undefined, { blockGroupType: 'Document', blocks: [ { - blockType: 'BlockGroup', - blockGroupType: 'ListItem', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: '123123', - format: {}, - }, - ], - format: {}, - isImplicit: true, - }, - ], + formatHolder: { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, levels: [ { listType: 'OL', format: { - startNumberOverride: 1, + wordList: 'l0', }, dataset: { editingInfo: '{"orderedStyleType":1}', }, }, ], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, - format: {}, - }, - { blockType: 'BlockGroup', + format: { + marginLeft: '0in', + }, blockGroupType: 'ListItem', blocks: [ { - blockType: 'Paragraph', + isImplicit: true, segments: [ { - segmentType: 'Text', text: '123123', + segmentType: 'Text', format: {}, }, ], + blockType: 'Paragraph', format: {}, - isImplicit: true, }, ], + }, + { + formatHolder: { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, levels: [ { listType: 'OL', - format: {}, + format: { + wordList: 'l0', + }, dataset: { editingInfo: '{"orderedStyleType":1}', }, @@ -1006,48 +1065,54 @@ describe('processPastedContentFromWordDesktopTest', () => { { listType: 'OL', format: { - startNumberOverride: 1, + wordList: 'l0', }, dataset: { editingInfo: '{"orderedStyleType":1}', }, }, ], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, - format: {}, - }, - { blockType: 'BlockGroup', + format: { + marginLeft: '0in', + }, blockGroupType: 'ListItem', blocks: [ { - blockType: 'Paragraph', + isImplicit: true, segments: [ { - segmentType: 'Text', text: '123123', + segmentType: 'Text', format: {}, }, ], + blockType: 'Paragraph', format: {}, - isImplicit: true, }, ], + }, + { + formatHolder: { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, levels: [ { listType: 'OL', - format: {}, + format: { + wordList: 'l0', + }, dataset: { editingInfo: '{"orderedStyleType":1}', }, }, { listType: 'OL', - format: {}, + format: { + wordList: 'l0', + }, dataset: { editingInfo: '{"orderedStyleType":1}', }, @@ -1056,50 +1121,57 @@ describe('processPastedContentFromWordDesktopTest', () => { listType: 'OL', format: { marginTop: '1em', + paddingLeft: '0px', + wordList: 'l0', }, dataset: { editingInfo: '{"orderedStyleType":1}', }, }, ], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, + blockType: 'BlockGroup', format: { marginTop: '1em', marginBottom: '1em', + marginLeft: '1.5in', }, - }, - { - blockType: 'BlockGroup', blockGroupType: 'ListItem', blocks: [ { - blockType: 'Paragraph', + isImplicit: true, segments: [ { + text: '123123', segmentType: 'Text', - text: '123123123', format: {}, }, ], + blockType: 'Paragraph', format: {}, - isImplicit: true, }, ], + }, + { + formatHolder: { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, levels: [ { listType: 'OL', - format: {}, + format: { + wordList: 'l0', + }, dataset: { editingInfo: '{"orderedStyleType":1}', }, }, { listType: 'OL', - format: {}, + format: { + wordList: 'l0', + }, dataset: { editingInfo: '{"orderedStyleType":1}', }, @@ -1108,6 +1180,8 @@ describe('processPastedContentFromWordDesktopTest', () => { listType: 'OL', format: { marginTop: '1em', + paddingLeft: '0px', + wordList: 'l0', }, dataset: { editingInfo: '{"orderedStyleType":1}', @@ -1115,18 +1189,33 @@ describe('processPastedContentFromWordDesktopTest', () => { }, { listType: 'OL', - format: {}, + format: { + wordList: 'l0', + }, dataset: { editingInfo: '{"orderedStyleType":1}', }, }, ], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, + blockType: 'BlockGroup', + format: { + marginLeft: '0in', }, - format: {}, + blockGroupType: 'ListItem', + blocks: [ + { + isImplicit: true, + segments: [ + { + text: '123123123', + segmentType: 'Text', + format: {}, + }, + ], + blockType: 'Paragraph', + format: {}, + }, + ], }, ], }, @@ -1141,10 +1230,11 @@ describe('processPastedContentFromWordDesktopTest', () => { spyOn(getStyleMetadata, 'default').and.returnValue( new Map().set('l0:level1', { 'mso-level-number-format': 'bullet', + 'mso-level-start-at': '1', }) ); - runTest(source, undefined, { + runTest(source, { blockGroupType: 'Document', blocks: [ { @@ -1223,6 +1313,8 @@ describe('processPastedContentFromWordDesktopTest', () => { marginRight: '0in', marginBottom: undefined, marginLeft: undefined, + paddingLeft: '0px', + wordList: 'l0', }, dataset: {}, }, @@ -1234,9 +1326,9 @@ describe('processPastedContentFromWordDesktopTest', () => { }, format: { marginTop: '0in', - marginRight: undefined, + marginRight: '0in', marginBottom: '0in', - marginLeft: undefined, + marginLeft: '0.5in', }, }, ], @@ -1251,7 +1343,6 @@ describe('processPastedContentFromWordDesktopTest', () => { it('Word doc created online but edited and copied from Desktop', () => { runTest( '

it went:

1.     Test

2.     Test2

', - undefined, { blockGroupType: 'Document', blocks: [ @@ -1291,7 +1382,7 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '1em', marginBottom: undefined, - startNumberOverride: 1, + wordList: 'l0', }, }, ], @@ -1326,6 +1417,7 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '1em', marginBottom: undefined, + wordList: 'l0', }, }, ], @@ -1374,150 +1466,152 @@ describe('processPastedContentFromWordDesktopTest', () => { * B. */ it('multiple OL lists with different bullet types', () => { - htmlBefore = + const htmlBefore = '\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'; runTest( '\n\n

\x3C!--[if !supportLists]-->       \nI)           \n\x3C!--[endif]-->123123

\n\n

\x3C!--[if !supportLists]-->     \nII)           \n\x3C!--[endif]-->123123

\n\n

\x3C!--[if !supportLists]-->   \nIII)           \n\x3C!--[endif]-->123123

\n\n

123123123

\n\n

\x3C!--[if !supportLists]-->zz)  \n\x3C!--[endif]-->123123

\n\n

\x3C!--[if !supportLists]-->aaa)                    \n\x3C!--[endif]-->12123

\n\n\n\n

 

\n\n

 

\n\n

\x3C!--[if !supportLists]-->a)     \n\x3C!--[endif]-->123

\n\n

\x3C!--[if !supportLists]-->b)     \n\x3C!--[endif]--> 

\n\n

\x3C!--[if !supportLists]-->LXV)           \n\x3C!--[endif]-->123

\n\n

\x3C!--[if !supportLists]-->LXVI)           \n\x3C!--[endif]--> 

\n\n

 

\n\n

\x3C!--[if !supportLists]-->15)  \x3C!--[endif]-->123

\n\n

\x3C!--[if !supportLists]-->16)  \x3C!--[endif]--> 

\n\n

 

\n\n

\x3C!--[if !supportLists]-->       \nI.           \n\x3C!--[endif]-->123

\n\n

\x3C!--[if !supportLists]-->     \nII.           \n\x3C!--[endif]--> 

\n\n

 

\n\n

\x3C!--[if !supportLists]-->a.     \n\x3C!--[endif]-->123

\n\n

\x3C!--[if !supportLists]-->b.     \n\x3C!--[endif]--> 

\n\n

 

\n\n

\x3C!--[if !supportLists]-->1.     \n\x3C!--[endif]-->123

\n\n

\x3C!--[if !supportLists]-->2.     \n\x3C!--[endif]--> 

\n\n

 

\n\n

\x3C!--[if !supportLists]-->       \ni.           \n\x3C!--[endif]-->Asd

\n\n

\x3C!--[if !supportLists]-->     \nii.           \n\x3C!--[endif]--> 

\n\n

 

\n\n

\x3C!--[if !supportLists]-->A.    \n\x3C!--[endif]-->Asd

\n\n', - undefined, { blockGroupType: 'Document', blocks: [ { + formatHolder: { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'OL', + format: { + marginTop: '1em', + wordList: 'l5', + startNumberOverride: 1, + }, + dataset: { + editingInfo: '{"orderedStyleType":18}', + }, + }, + ], blockType: 'BlockGroup', + format: { + marginTop: '1em', + marginBottom: '0in', + }, blockGroupType: 'ListItem', blocks: [ { - blockType: 'Paragraph', + isImplicit: true, segments: [ { - segmentType: 'Text', text: '123123', + segmentType: 'Text', format: { underline: true, textColor: 'rgb(70, 120, 134)', }, }, ], + blockType: 'Paragraph', format: {}, - isImplicit: true, }, ], + }, + { + formatHolder: { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, levels: [ { listType: 'OL', format: { marginTop: '1em', - startNumberOverride: 1, + wordList: 'l5', }, dataset: { editingInfo: '{"orderedStyleType":18}', }, }, ], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, + blockType: 'BlockGroup', format: { marginTop: '1em', marginBottom: '0in', }, - }, - { - blockType: 'BlockGroup', blockGroupType: 'ListItem', blocks: [ { - blockType: 'Paragraph', + isImplicit: true, segments: [ { - segmentType: 'Text', text: '123123', + segmentType: 'Text', format: { underline: true, textColor: 'rgb(70, 120, 134)', }, }, ], + blockType: 'Paragraph', format: {}, - isImplicit: true, }, ], + }, + { + formatHolder: { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, levels: [ { listType: 'OL', format: { marginTop: '1em', + wordList: 'l5', }, dataset: { editingInfo: '{"orderedStyleType":18}', }, }, ], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, + blockType: 'BlockGroup', format: { marginTop: '1em', marginBottom: '0in', }, - }, - { - blockType: 'BlockGroup', blockGroupType: 'ListItem', blocks: [ { - blockType: 'Paragraph', + isImplicit: true, segments: [ { - segmentType: 'Text', text: '123123', + segmentType: 'Text', format: { underline: true, textColor: 'rgb(70, 120, 134)', }, }, ], + blockType: 'Paragraph', format: {}, - isImplicit: true, - }, - ], - levels: [ - { - listType: 'OL', - format: { - marginTop: '1em', - }, - dataset: { - editingInfo: '{"orderedStyleType":18}', - }, }, ], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, - format: { - marginTop: '1em', - marginBottom: '0in', - }, }, { - blockType: 'Paragraph', segments: [ { - segmentType: 'Text', text: '123123123', + segmentType: 'Text', format: { underline: true, textColor: 'rgb(70, 120, 134)', }, }, ], + blockType: 'Paragraph', format: { marginTop: '1em', marginBottom: '0in', @@ -1528,30 +1622,17 @@ describe('processPastedContentFromWordDesktopTest', () => { }, }, { - blockType: 'BlockGroup', - blockGroupType: 'ListItem', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: '123123', - format: { - underline: true, - textColor: 'rgb(70, 120, 134)', - }, - }, - ], - format: {}, - isImplicit: true, - }, - ], + formatHolder: { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, levels: [ { listType: 'OL', format: { marginTop: '1em', + wordList: 'l1', startNumberOverride: 52, }, dataset: { @@ -1559,69 +1640,84 @@ describe('processPastedContentFromWordDesktopTest', () => { }, }, ], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, + blockType: 'BlockGroup', format: { marginTop: '1em', marginBottom: '0in', }, - }, - { - blockType: 'BlockGroup', blockGroupType: 'ListItem', blocks: [ { - blockType: 'Paragraph', + isImplicit: true, segments: [ { + text: '123123', segmentType: 'Text', - text: '12123', format: { underline: true, textColor: 'rgb(70, 120, 134)', }, }, ], + blockType: 'Paragraph', format: {}, - isImplicit: true, }, ], + }, + { + formatHolder: { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, levels: [ { listType: 'OL', format: { marginTop: '1em', + wordList: 'l1', }, dataset: { editingInfo: '{"orderedStyleType":6}', }, }, ], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, + blockType: 'BlockGroup', format: { marginTop: '1em', marginBottom: '0in', }, + blockGroupType: 'ListItem', + blocks: [ + { + isImplicit: true, + segments: [ + { + text: '12123', + segmentType: 'Text', + format: { + underline: true, + textColor: 'rgb(70, 120, 134)', + }, + }, + ], + blockType: 'Paragraph', + format: {}, + }, + ], }, { - blockType: 'Paragraph', segments: [ { - segmentType: 'Text', text: ' ', + segmentType: 'Text', format: { fontSize: '11pt', lineHeight: '116%', }, }, ], + blockType: 'Paragraph', format: { marginTop: '1em', marginBottom: '0in', @@ -1632,14 +1728,14 @@ describe('processPastedContentFromWordDesktopTest', () => { }, }, { - blockType: 'Paragraph', segments: [ { - segmentType: 'Text', text: ' ', + segmentType: 'Text', format: {}, }, ], + blockType: 'Paragraph', format: { marginTop: '1em', marginBottom: '1em', @@ -1650,27 +1746,17 @@ describe('processPastedContentFromWordDesktopTest', () => { }, }, { - blockType: 'BlockGroup', - blockGroupType: 'ListItem', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: '123', - format: {}, - }, - ], - format: {}, - isImplicit: true, - }, - ], + formatHolder: { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, levels: [ { listType: 'OL', format: { marginTop: '1em', + wordList: 'l8', startNumberOverride: 1, }, dataset: { @@ -1678,76 +1764,78 @@ describe('processPastedContentFromWordDesktopTest', () => { }, }, ], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, + blockType: 'BlockGroup', format: { marginTop: '1em', marginBottom: '1em', }, - }, - { - blockType: 'BlockGroup', blockGroupType: 'ListItem', blocks: [ { - blockType: 'Paragraph', + isImplicit: true, segments: [ { + text: '123', segmentType: 'Text', - text: ' ', format: {}, }, ], + blockType: 'Paragraph', format: {}, - isImplicit: true, }, ], + }, + { + formatHolder: { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, levels: [ { listType: 'OL', format: { marginTop: '1em', + wordList: 'l8', }, dataset: { editingInfo: '{"orderedStyleType":6}', }, }, ], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, + blockType: 'BlockGroup', format: { marginTop: '1em', marginBottom: '1em', }, - }, - { - blockType: 'BlockGroup', blockGroupType: 'ListItem', blocks: [ { - blockType: 'Paragraph', + isImplicit: true, segments: [ { + text: ' ', segmentType: 'Text', - text: '123', format: {}, }, ], + blockType: 'Paragraph', format: {}, - isImplicit: true, }, ], + }, + { + formatHolder: { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, levels: [ { listType: 'OL', format: { marginTop: '1em', + wordList: 'l6', startNumberOverride: 65, }, dataset: { @@ -1755,63 +1843,75 @@ describe('processPastedContentFromWordDesktopTest', () => { }, }, ], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, + blockType: 'BlockGroup', format: { marginTop: '1em', marginBottom: '1em', }, - }, - { - blockType: 'BlockGroup', blockGroupType: 'ListItem', blocks: [ { - blockType: 'Paragraph', + isImplicit: true, segments: [ { + text: '123', segmentType: 'Text', - text: ' ', format: {}, }, ], + blockType: 'Paragraph', format: {}, - isImplicit: true, }, ], + }, + { + formatHolder: { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, levels: [ { listType: 'OL', format: { marginTop: '1em', + wordList: 'l6', }, dataset: { editingInfo: '{"orderedStyleType":18}', }, }, ], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, + blockType: 'BlockGroup', format: { marginTop: '1em', marginBottom: '1em', }, + blockGroupType: 'ListItem', + blocks: [ + { + isImplicit: true, + segments: [ + { + text: ' ', + segmentType: 'Text', + format: {}, + }, + ], + blockType: 'Paragraph', + format: {}, + }, + ], }, { - blockType: 'Paragraph', segments: [ { - segmentType: 'Text', text: ' ', + segmentType: 'Text', format: {}, }, ], + blockType: 'Paragraph', format: { marginTop: '1em', marginBottom: '1em', @@ -1822,27 +1922,17 @@ describe('processPastedContentFromWordDesktopTest', () => { }, }, { - blockType: 'BlockGroup', - blockGroupType: 'ListItem', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: '123', - format: {}, - }, - ], - format: {}, - isImplicit: true, - }, - ], + formatHolder: { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, levels: [ { listType: 'OL', format: { marginTop: '1em', + wordList: 'l4', startNumberOverride: 15, }, dataset: { @@ -1850,63 +1940,75 @@ describe('processPastedContentFromWordDesktopTest', () => { }, }, ], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, + blockType: 'BlockGroup', format: { marginTop: '1em', marginBottom: '1em', }, - }, - { - blockType: 'BlockGroup', blockGroupType: 'ListItem', blocks: [ { - blockType: 'Paragraph', + isImplicit: true, segments: [ { + text: '123', segmentType: 'Text', - text: ' ', format: {}, }, ], + blockType: 'Paragraph', format: {}, - isImplicit: true, }, ], + }, + { + formatHolder: { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, levels: [ { listType: 'OL', format: { marginTop: '1em', + wordList: 'l4', }, dataset: { editingInfo: '{"orderedStyleType":3}', }, }, ], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, + blockType: 'BlockGroup', format: { marginTop: '1em', marginBottom: '1em', }, + blockGroupType: 'ListItem', + blocks: [ + { + isImplicit: true, + segments: [ + { + text: ' ', + segmentType: 'Text', + format: {}, + }, + ], + blockType: 'Paragraph', + format: {}, + }, + ], }, { - blockType: 'Paragraph', segments: [ { - segmentType: 'Text', text: ' ', + segmentType: 'Text', format: {}, }, ], + blockType: 'Paragraph', format: { marginTop: '1em', marginBottom: '1em', @@ -1917,27 +2019,17 @@ describe('processPastedContentFromWordDesktopTest', () => { }, }, { - blockType: 'BlockGroup', - blockGroupType: 'ListItem', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: '123', - format: {}, - }, - ], - format: {}, - isImplicit: true, - }, - ], + formatHolder: { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, levels: [ { listType: 'OL', format: { marginTop: '1em', + wordList: 'l0', startNumberOverride: 1, }, dataset: { @@ -1945,63 +2037,75 @@ describe('processPastedContentFromWordDesktopTest', () => { }, }, ], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, + blockType: 'BlockGroup', format: { marginTop: '1em', marginBottom: '1em', }, - }, - { - blockType: 'BlockGroup', blockGroupType: 'ListItem', blocks: [ { - blockType: 'Paragraph', + isImplicit: true, segments: [ { + text: '123', segmentType: 'Text', - text: ' ', format: {}, }, ], + blockType: 'Paragraph', format: {}, - isImplicit: true, }, ], + }, + { + formatHolder: { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, levels: [ { listType: 'OL', format: { marginTop: '1em', + wordList: 'l0', }, dataset: { editingInfo: '{"orderedStyleType":17}', }, }, ], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, + blockType: 'BlockGroup', format: { marginTop: '1em', marginBottom: '1em', }, + blockGroupType: 'ListItem', + blocks: [ + { + isImplicit: true, + segments: [ + { + text: ' ', + segmentType: 'Text', + format: {}, + }, + ], + blockType: 'Paragraph', + format: {}, + }, + ], }, { - blockType: 'Paragraph', segments: [ { - segmentType: 'Text', text: ' ', + segmentType: 'Text', format: {}, }, ], + blockType: 'Paragraph', format: { marginTop: '1em', marginBottom: '1em', @@ -2012,27 +2116,17 @@ describe('processPastedContentFromWordDesktopTest', () => { }, }, { - blockType: 'BlockGroup', - blockGroupType: 'ListItem', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: '123', - format: {}, - }, - ], - format: {}, - isImplicit: true, - }, - ], + formatHolder: { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, levels: [ { listType: 'OL', format: { marginTop: '1em', + wordList: 'l7', startNumberOverride: 1, }, dataset: { @@ -2040,63 +2134,75 @@ describe('processPastedContentFromWordDesktopTest', () => { }, }, ], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, + blockType: 'BlockGroup', format: { marginTop: '1em', marginBottom: '1em', }, - }, - { - blockType: 'BlockGroup', blockGroupType: 'ListItem', blocks: [ { - blockType: 'Paragraph', + isImplicit: true, segments: [ { + text: '123', segmentType: 'Text', - text: ' ', format: {}, }, ], + blockType: 'Paragraph', format: {}, - isImplicit: true, }, ], + }, + { + formatHolder: { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, levels: [ { listType: 'OL', format: { marginTop: '1em', + wordList: 'l7', }, dataset: { editingInfo: '{"orderedStyleType":5}', }, }, ], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, + blockType: 'BlockGroup', format: { marginTop: '1em', marginBottom: '1em', }, + blockGroupType: 'ListItem', + blocks: [ + { + isImplicit: true, + segments: [ + { + text: ' ', + segmentType: 'Text', + format: {}, + }, + ], + blockType: 'Paragraph', + format: {}, + }, + ], }, { - blockType: 'Paragraph', segments: [ { - segmentType: 'Text', text: ' ', + segmentType: 'Text', format: {}, }, ], + blockType: 'Paragraph', format: { marginTop: '1em', marginBottom: '1em', @@ -2107,27 +2213,17 @@ describe('processPastedContentFromWordDesktopTest', () => { }, }, { - blockType: 'BlockGroup', - blockGroupType: 'ListItem', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: '123', - format: {}, - }, - ], - format: {}, - isImplicit: true, - }, - ], + formatHolder: { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, levels: [ { listType: 'OL', format: { marginTop: '1em', + wordList: 'l9', startNumberOverride: 1, }, dataset: { @@ -2135,63 +2231,75 @@ describe('processPastedContentFromWordDesktopTest', () => { }, }, ], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, + blockType: 'BlockGroup', format: { marginTop: '1em', marginBottom: '1em', }, - }, - { - blockType: 'BlockGroup', blockGroupType: 'ListItem', blocks: [ { - blockType: 'Paragraph', + isImplicit: true, segments: [ { + text: '123', segmentType: 'Text', - text: ' ', format: {}, }, ], + blockType: 'Paragraph', format: {}, - isImplicit: true, }, ], + }, + { + formatHolder: { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, levels: [ { listType: 'OL', format: { marginTop: '1em', + wordList: 'l9', }, dataset: { editingInfo: '{"orderedStyleType":1}', }, }, ], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, + blockType: 'BlockGroup', format: { marginTop: '1em', marginBottom: '1em', }, + blockGroupType: 'ListItem', + blocks: [ + { + isImplicit: true, + segments: [ + { + text: ' ', + segmentType: 'Text', + format: {}, + }, + ], + blockType: 'Paragraph', + format: {}, + }, + ], }, { - blockType: 'Paragraph', segments: [ { - segmentType: 'Text', text: ' ', + segmentType: 'Text', format: {}, }, ], + blockType: 'Paragraph', format: { marginTop: '1em', marginBottom: '1em', @@ -2202,27 +2310,17 @@ describe('processPastedContentFromWordDesktopTest', () => { }, }, { - blockType: 'BlockGroup', - blockGroupType: 'ListItem', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: 'Asd', - format: {}, - }, - ], - format: {}, - isImplicit: true, - }, - ], - levels: [ + formatHolder: { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ { listType: 'OL', format: { marginTop: '1em', + wordList: 'l3', startNumberOverride: 1, }, dataset: { @@ -2230,63 +2328,75 @@ describe('processPastedContentFromWordDesktopTest', () => { }, }, ], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, + blockType: 'BlockGroup', format: { marginTop: '1em', marginBottom: '1em', }, - }, - { - blockType: 'BlockGroup', blockGroupType: 'ListItem', blocks: [ { - blockType: 'Paragraph', + isImplicit: true, segments: [ { + text: 'Asd', segmentType: 'Text', - text: ' ', format: {}, }, ], + blockType: 'Paragraph', format: {}, - isImplicit: true, }, ], + }, + { + formatHolder: { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, levels: [ { listType: 'OL', format: { marginTop: '1em', + wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":13}', }, }, ], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, + blockType: 'BlockGroup', format: { marginTop: '1em', marginBottom: '1em', }, + blockGroupType: 'ListItem', + blocks: [ + { + isImplicit: true, + segments: [ + { + text: ' ', + segmentType: 'Text', + format: {}, + }, + ], + blockType: 'Paragraph', + format: {}, + }, + ], }, { - blockType: 'Paragraph', segments: [ { - segmentType: 'Text', text: ' ', + segmentType: 'Text', format: {}, }, ], + blockType: 'Paragraph', format: { marginTop: '1em', marginBottom: '1em', @@ -2297,27 +2407,17 @@ describe('processPastedContentFromWordDesktopTest', () => { }, }, { - blockType: 'BlockGroup', - blockGroupType: 'ListItem', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: 'Asd', - format: {}, - }, - ], - format: {}, - isImplicit: true, - }, - ], + formatHolder: { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, levels: [ { listType: 'OL', format: { marginTop: '1em', + wordList: 'l2', startNumberOverride: 1, }, dataset: { @@ -2325,19 +2425,31 @@ describe('processPastedContentFromWordDesktopTest', () => { }, }, ], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, + blockType: 'BlockGroup', format: { marginTop: '1em', marginBottom: '1em', }, + blockGroupType: 'ListItem', + blocks: [ + { + isImplicit: true, + segments: [ + { + text: 'Asd', + segmentType: 'Text', + format: {}, + }, + ], + blockType: 'Paragraph', + format: {}, + }, + ], }, ], }, - true + true, + htmlBefore ); }); @@ -2353,40 +2465,27 @@ describe('processPastedContentFromWordDesktopTest', () => { * vi. */ it('9 Depth list', () => { - htmlBefore = + const htmlBefore = '\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\x3C!--[if gte mso 9]>\r\n \r\n \r\n \r\n\r\n\r\n\r\n\x3C!--[if gte mso 9]>\r\n \r\n Normal\r\n 0\r\n \r\n \r\n \r\n \r\n false\r\n false\r\n false\r\n \r\n EN-US\r\n JA\r\n AR-SA\r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n\x3C!--[if gte mso 9]>\r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \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\x3C!--[if gte mso 10]>\r\n\r\n\r\n\r\n\r\n\r\n'; runTest( '

100.                    \n123

a.     \n123

                                                             \ni.     \n123

1.     \n123

1)     \n213

                                                                                                                                     \ni.     \n123

                                                                                                                                                       \ni.           \n123

ffffffffffffffffffff.         \n213

                                                                                                                                                                                                          \nvi.     \n213

gggggggggggggggggggg.     123

                                                                                                                                                     \nii.           \n123

                                                                                                                                   \nii.     \n123

2)     \n123

2.     \n123

                                                           \nii.     \n123

b.     \n123

502.                    \n123

', - undefined, { blockGroupType: 'Document', blocks: [ { - blockType: 'BlockGroup', - blockGroupType: 'ListItem', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: '123', - format: { - fontFamily: 'Aptos, sans-serif', - fontSize: '12pt', - }, - }, - ], - format: {}, - isImplicit: true, - }, - ], + formatHolder: { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, levels: [ { listType: 'OL', format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l0', startNumberOverride: 100, }, dataset: { @@ -2394,43 +2493,50 @@ describe('processPastedContentFromWordDesktopTest', () => { }, }, ], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, + blockType: 'BlockGroup', format: { lineHeight: '116%', marginTop: '0in', + marginRight: '0in', marginBottom: '0in', + marginLeft: '0.5in', }, - }, - { - blockType: 'BlockGroup', blockGroupType: 'ListItem', blocks: [ { - blockType: 'Paragraph', + isImplicit: true, segments: [ { - segmentType: 'Text', text: '123', + segmentType: 'Text', format: { fontFamily: 'Aptos, sans-serif', fontSize: '12pt', }, }, ], + blockType: 'Paragraph', format: {}, - isImplicit: true, }, ], + }, + { + formatHolder: { + isSelected: true, + segmentType: 'SelectionMarker', + format: { + fontFamily: 'Aptos, sans-serif', + fontSize: '12pt', + }, + }, levels: [ { listType: 'OL', format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l0', }, dataset: { editingInfo: '{"orderedStyleType":1}', @@ -2441,6 +2547,8 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l1', startNumberOverride: 1, }, dataset: { @@ -2448,46 +2556,50 @@ describe('processPastedContentFromWordDesktopTest', () => { }, }, ], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: true, - format: { - fontFamily: 'Aptos, sans-serif', - fontSize: '12pt', - }, - }, + blockType: 'BlockGroup', format: { lineHeight: '116%', marginTop: '0in', + marginRight: '0in', marginBottom: '0in', + marginLeft: '1in', }, - }, - { - blockType: 'BlockGroup', blockGroupType: 'ListItem', blocks: [ { - blockType: 'Paragraph', + isImplicit: true, segments: [ { - segmentType: 'Text', text: '123', + segmentType: 'Text', format: { fontFamily: 'Aptos, sans-serif', fontSize: '12pt', }, }, ], + blockType: 'Paragraph', format: {}, - isImplicit: true, }, ], + }, + { + formatHolder: { + isSelected: true, + segmentType: 'SelectionMarker', + format: { + fontFamily: 'Aptos, sans-serif', + fontSize: '12pt', + }, + }, levels: [ { listType: 'OL', format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l0', }, dataset: { editingInfo: '{"orderedStyleType":1}', @@ -2498,6 +2610,8 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l1', }, dataset: { editingInfo: '{"orderedStyleType":5}', @@ -2508,6 +2622,8 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l2', startNumberOverride: 1, }, dataset: { @@ -2515,46 +2631,50 @@ describe('processPastedContentFromWordDesktopTest', () => { }, }, ], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: true, - format: { - fontFamily: 'Aptos, sans-serif', - fontSize: '12pt', - }, - }, + blockType: 'BlockGroup', format: { lineHeight: '116%', marginTop: '0in', + marginRight: '0in', marginBottom: '0in', + marginLeft: '1.5in', }, - }, - { - blockType: 'BlockGroup', blockGroupType: 'ListItem', blocks: [ { - blockType: 'Paragraph', + isImplicit: true, segments: [ { - segmentType: 'Text', text: '123', + segmentType: 'Text', format: { fontFamily: 'Aptos, sans-serif', fontSize: '12pt', }, }, ], + blockType: 'Paragraph', format: {}, - isImplicit: true, }, ], + }, + { + formatHolder: { + isSelected: true, + segmentType: 'SelectionMarker', + format: { + fontFamily: 'Aptos, sans-serif', + fontSize: '12pt', + }, + }, levels: [ { listType: 'OL', format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l0', }, dataset: { editingInfo: '{"orderedStyleType":1}', @@ -2565,6 +2685,8 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l1', }, dataset: { editingInfo: '{"orderedStyleType":5}', @@ -2575,6 +2697,8 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l2', }, dataset: { editingInfo: '{"orderedStyleType":13}', @@ -2585,6 +2709,8 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l3', startNumberOverride: 1, }, dataset: { @@ -2592,46 +2718,50 @@ describe('processPastedContentFromWordDesktopTest', () => { }, }, ], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: true, - format: { - fontFamily: 'Aptos, sans-serif', - fontSize: '12pt', - }, - }, + blockType: 'BlockGroup', format: { lineHeight: '116%', marginTop: '0in', + marginRight: '0in', marginBottom: '0in', + marginLeft: '2in', }, - }, - { - blockType: 'BlockGroup', blockGroupType: 'ListItem', blocks: [ { - blockType: 'Paragraph', + isImplicit: true, segments: [ { + text: '123', segmentType: 'Text', - text: '213', format: { fontFamily: 'Aptos, sans-serif', fontSize: '12pt', }, }, ], + blockType: 'Paragraph', format: {}, - isImplicit: true, }, ], + }, + { + formatHolder: { + isSelected: true, + segmentType: 'SelectionMarker', + format: { + fontFamily: 'Aptos, sans-serif', + fontSize: '12pt', + }, + }, levels: [ { listType: 'OL', format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l0', }, dataset: { editingInfo: '{"orderedStyleType":1}', @@ -2642,6 +2772,8 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l1', }, dataset: { editingInfo: '{"orderedStyleType":5}', @@ -2652,6 +2784,8 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l2', }, dataset: { editingInfo: '{"orderedStyleType":13}', @@ -2662,6 +2796,8 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":1}', @@ -2672,6 +2808,8 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l3', startNumberOverride: 1, }, dataset: { @@ -2679,46 +2817,50 @@ describe('processPastedContentFromWordDesktopTest', () => { }, }, ], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: true, - format: { - fontFamily: 'Aptos, sans-serif', - fontSize: '12pt', - }, - }, + blockType: 'BlockGroup', format: { lineHeight: '116%', marginTop: '0in', + marginRight: '0in', marginBottom: '0in', + marginLeft: '2.5in', }, - }, - { - blockType: 'BlockGroup', blockGroupType: 'ListItem', blocks: [ { - blockType: 'Paragraph', + isImplicit: true, segments: [ { + text: '213', segmentType: 'Text', - text: '123', format: { fontFamily: 'Aptos, sans-serif', fontSize: '12pt', }, }, ], + blockType: 'Paragraph', format: {}, - isImplicit: true, }, ], - levels: [ - { - listType: 'OL', - format: { + }, + { + formatHolder: { + isSelected: true, + segmentType: 'SelectionMarker', + format: { + fontFamily: 'Aptos, sans-serif', + fontSize: '12pt', + }, + }, + levels: [ + { + listType: 'OL', + format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l0', }, dataset: { editingInfo: '{"orderedStyleType":1}', @@ -2729,6 +2871,8 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l1', }, dataset: { editingInfo: '{"orderedStyleType":5}', @@ -2739,6 +2883,8 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l2', }, dataset: { editingInfo: '{"orderedStyleType":13}', @@ -2749,6 +2895,8 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":1}', @@ -2759,6 +2907,8 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":3}', @@ -2769,6 +2919,8 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l3', startNumberOverride: 1, }, dataset: { @@ -2776,46 +2928,50 @@ describe('processPastedContentFromWordDesktopTest', () => { }, }, ], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: true, - format: { - fontFamily: 'Aptos, sans-serif', - fontSize: '12pt', - }, - }, + blockType: 'BlockGroup', format: { lineHeight: '116%', marginTop: '0in', + marginRight: '0in', marginBottom: '0in', + marginLeft: '3in', }, - }, - { - blockType: 'BlockGroup', blockGroupType: 'ListItem', blocks: [ { - blockType: 'Paragraph', + isImplicit: true, segments: [ { - segmentType: 'Text', text: '123', + segmentType: 'Text', format: { fontFamily: 'Aptos, sans-serif', fontSize: '12pt', }, }, ], + blockType: 'Paragraph', format: {}, - isImplicit: true, }, ], + }, + { + formatHolder: { + isSelected: true, + segmentType: 'SelectionMarker', + format: { + fontFamily: 'Aptos, sans-serif', + fontSize: '12pt', + }, + }, levels: [ { listType: 'OL', format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l0', }, dataset: { editingInfo: '{"orderedStyleType":1}', @@ -2826,6 +2982,8 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l1', }, dataset: { editingInfo: '{"orderedStyleType":5}', @@ -2836,6 +2994,8 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l2', }, dataset: { editingInfo: '{"orderedStyleType":13}', @@ -2846,6 +3006,8 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":1}', @@ -2856,6 +3018,8 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":3}', @@ -2866,6 +3030,8 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":13}', @@ -2876,6 +3042,8 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l3', startNumberOverride: 1, }, dataset: { @@ -2883,46 +3051,50 @@ describe('processPastedContentFromWordDesktopTest', () => { }, }, ], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: true, - format: { - fontFamily: 'Aptos, sans-serif', - fontSize: '12pt', - }, - }, + blockType: 'BlockGroup', format: { lineHeight: '116%', marginTop: '0in', + marginRight: '0in', marginBottom: '0in', + marginLeft: '3.5in', }, - }, - { - blockType: 'BlockGroup', blockGroupType: 'ListItem', blocks: [ { - blockType: 'Paragraph', + isImplicit: true, segments: [ { + text: '123', segmentType: 'Text', - text: '213', format: { fontFamily: 'Aptos, sans-serif', fontSize: '12pt', }, }, ], + blockType: 'Paragraph', format: {}, - isImplicit: true, }, ], + }, + { + formatHolder: { + isSelected: true, + segmentType: 'SelectionMarker', + format: { + fontFamily: 'Aptos, sans-serif', + fontSize: '12pt', + }, + }, levels: [ { listType: 'OL', format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l0', }, dataset: { editingInfo: '{"orderedStyleType":1}', @@ -2933,6 +3105,8 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l1', }, dataset: { editingInfo: '{"orderedStyleType":5}', @@ -2943,6 +3117,8 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l2', }, dataset: { editingInfo: '{"orderedStyleType":13}', @@ -2953,6 +3129,8 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":1}', @@ -2963,6 +3141,8 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":3}', @@ -2973,6 +3153,8 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":13}', @@ -2983,6 +3165,8 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":13}', @@ -2993,6 +3177,8 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l3', startNumberOverride: 500, }, dataset: { @@ -3000,46 +3186,50 @@ describe('processPastedContentFromWordDesktopTest', () => { }, }, ], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: true, - format: { - fontFamily: 'Aptos, sans-serif', - fontSize: '12pt', - }, - }, + blockType: 'BlockGroup', format: { lineHeight: '116%', marginTop: '0in', + marginRight: '0in', marginBottom: '0in', + marginLeft: '4in', }, - }, - { - blockType: 'BlockGroup', blockGroupType: 'ListItem', blocks: [ { - blockType: 'Paragraph', + isImplicit: true, segments: [ { - segmentType: 'Text', text: '213', + segmentType: 'Text', format: { fontFamily: 'Aptos, sans-serif', fontSize: '12pt', }, }, ], + blockType: 'Paragraph', format: {}, - isImplicit: true, }, ], + }, + { + formatHolder: { + isSelected: true, + segmentType: 'SelectionMarker', + format: { + fontFamily: 'Aptos, sans-serif', + fontSize: '12pt', + }, + }, levels: [ { listType: 'OL', format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l0', }, dataset: { editingInfo: '{"orderedStyleType":1}', @@ -3050,6 +3240,8 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l1', }, dataset: { editingInfo: '{"orderedStyleType":5}', @@ -3060,6 +3252,8 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l2', }, dataset: { editingInfo: '{"orderedStyleType":13}', @@ -3070,6 +3264,8 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":1}', @@ -3080,6 +3276,8 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":3}', @@ -3090,6 +3288,8 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":13}', @@ -3100,6 +3300,8 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":13}', @@ -3110,6 +3312,8 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":5}', @@ -3120,6 +3324,8 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l3', startNumberOverride: 6, }, dataset: { @@ -3127,46 +3333,50 @@ describe('processPastedContentFromWordDesktopTest', () => { }, }, ], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: true, - format: { - fontFamily: 'Aptos, sans-serif', - fontSize: '12pt', - }, - }, + blockType: 'BlockGroup', format: { lineHeight: '116%', marginTop: '0in', + marginRight: '0in', marginBottom: '0in', + marginLeft: '4.5in', }, - }, - { - blockType: 'BlockGroup', blockGroupType: 'ListItem', blocks: [ { - blockType: 'Paragraph', + isImplicit: true, segments: [ { + text: '213', segmentType: 'Text', - text: '123', format: { fontFamily: 'Aptos, sans-serif', fontSize: '12pt', }, }, ], + blockType: 'Paragraph', format: {}, - isImplicit: true, }, ], + }, + { + formatHolder: { + isSelected: true, + segmentType: 'SelectionMarker', + format: { + fontFamily: 'Aptos, sans-serif', + fontSize: '12pt', + }, + }, levels: [ { listType: 'OL', format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l0', }, dataset: { editingInfo: '{"orderedStyleType":1}', @@ -3177,6 +3387,8 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l1', }, dataset: { editingInfo: '{"orderedStyleType":5}', @@ -3187,6 +3399,8 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l2', }, dataset: { editingInfo: '{"orderedStyleType":13}', @@ -3197,6 +3411,8 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":1}', @@ -3207,6 +3423,8 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":3}', @@ -3217,6 +3435,8 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":13}', @@ -3227,6 +3447,8 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":13}', @@ -3237,53 +3459,58 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - startNumberOverride: 500, + paddingLeft: '0px', + wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":5}', }, }, ], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: true, - format: { - fontFamily: 'Aptos, sans-serif', - fontSize: '12pt', - }, - }, + blockType: 'BlockGroup', format: { lineHeight: '116%', marginTop: '0in', + marginRight: '0in', marginBottom: '0in', + marginLeft: '4in', }, - }, - { - blockType: 'BlockGroup', blockGroupType: 'ListItem', blocks: [ { - blockType: 'Paragraph', + isImplicit: true, segments: [ { - segmentType: 'Text', text: '123', + segmentType: 'Text', format: { fontFamily: 'Aptos, sans-serif', fontSize: '12pt', }, }, ], + blockType: 'Paragraph', format: {}, - isImplicit: true, }, ], + }, + { + formatHolder: { + isSelected: true, + segmentType: 'SelectionMarker', + format: { + fontFamily: 'Aptos, sans-serif', + fontSize: '12pt', + }, + }, levels: [ { listType: 'OL', format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l0', }, dataset: { editingInfo: '{"orderedStyleType":1}', @@ -3294,6 +3521,8 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l1', }, dataset: { editingInfo: '{"orderedStyleType":5}', @@ -3304,6 +3533,8 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l2', }, dataset: { editingInfo: '{"orderedStyleType":13}', @@ -3314,6 +3545,8 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":1}', @@ -3324,6 +3557,8 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":3}', @@ -3334,6 +3569,8 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":13}', @@ -3344,53 +3581,58 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - startNumberOverride: 1, + paddingLeft: '0px', + wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":13}', }, }, ], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: true, - format: { - fontFamily: 'Aptos, sans-serif', - fontSize: '12pt', - }, - }, + blockType: 'BlockGroup', format: { lineHeight: '116%', marginTop: '0in', + marginRight: '0in', marginBottom: '0in', + marginLeft: '3.5in', }, - }, - { - blockType: 'BlockGroup', blockGroupType: 'ListItem', blocks: [ { - blockType: 'Paragraph', + isImplicit: true, segments: [ { - segmentType: 'Text', text: '123', + segmentType: 'Text', format: { fontFamily: 'Aptos, sans-serif', fontSize: '12pt', }, }, ], + blockType: 'Paragraph', format: {}, - isImplicit: true, }, ], + }, + { + formatHolder: { + isSelected: true, + segmentType: 'SelectionMarker', + format: { + fontFamily: 'Aptos, sans-serif', + fontSize: '12pt', + }, + }, levels: [ { listType: 'OL', format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l0', }, dataset: { editingInfo: '{"orderedStyleType":1}', @@ -3401,6 +3643,8 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l1', }, dataset: { editingInfo: '{"orderedStyleType":5}', @@ -3411,6 +3655,8 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l2', }, dataset: { editingInfo: '{"orderedStyleType":13}', @@ -3421,6 +3667,8 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":1}', @@ -3431,6 +3679,8 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":3}', @@ -3441,53 +3691,58 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - startNumberOverride: 1, + paddingLeft: '0px', + wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":13}', }, }, ], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: true, - format: { - fontFamily: 'Aptos, sans-serif', - fontSize: '12pt', - }, - }, + blockType: 'BlockGroup', format: { lineHeight: '116%', marginTop: '0in', + marginRight: '0in', marginBottom: '0in', + marginLeft: '3in', }, - }, - { - blockType: 'BlockGroup', blockGroupType: 'ListItem', blocks: [ { - blockType: 'Paragraph', + isImplicit: true, segments: [ { - segmentType: 'Text', text: '123', + segmentType: 'Text', format: { fontFamily: 'Aptos, sans-serif', fontSize: '12pt', }, }, ], + blockType: 'Paragraph', format: {}, - isImplicit: true, }, ], + }, + { + formatHolder: { + isSelected: true, + segmentType: 'SelectionMarker', + format: { + fontFamily: 'Aptos, sans-serif', + fontSize: '12pt', + }, + }, levels: [ { listType: 'OL', format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l0', }, dataset: { editingInfo: '{"orderedStyleType":1}', @@ -3498,6 +3753,8 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l1', }, dataset: { editingInfo: '{"orderedStyleType":5}', @@ -3508,6 +3765,8 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l2', }, dataset: { editingInfo: '{"orderedStyleType":13}', @@ -3518,6 +3777,8 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":1}', @@ -3528,53 +3789,58 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - startNumberOverride: 1, + paddingLeft: '0px', + wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":3}', }, }, ], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: true, - format: { - fontFamily: 'Aptos, sans-serif', - fontSize: '12pt', - }, - }, + blockType: 'BlockGroup', format: { lineHeight: '116%', marginTop: '0in', + marginRight: '0in', marginBottom: '0in', + marginLeft: '2.5in', }, - }, - { - blockType: 'BlockGroup', blockGroupType: 'ListItem', blocks: [ { - blockType: 'Paragraph', + isImplicit: true, segments: [ { - segmentType: 'Text', text: '123', + segmentType: 'Text', format: { fontFamily: 'Aptos, sans-serif', fontSize: '12pt', }, }, ], + blockType: 'Paragraph', format: {}, - isImplicit: true, }, ], + }, + { + formatHolder: { + isSelected: true, + segmentType: 'SelectionMarker', + format: { + fontFamily: 'Aptos, sans-serif', + fontSize: '12pt', + }, + }, levels: [ { listType: 'OL', format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l0', }, dataset: { editingInfo: '{"orderedStyleType":1}', @@ -3585,6 +3851,8 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l1', }, dataset: { editingInfo: '{"orderedStyleType":5}', @@ -3595,6 +3863,8 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l2', }, dataset: { editingInfo: '{"orderedStyleType":13}', @@ -3605,53 +3875,58 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - startNumberOverride: 1, + paddingLeft: '0px', + wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":1}', }, }, ], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: true, - format: { - fontFamily: 'Aptos, sans-serif', - fontSize: '12pt', - }, - }, + blockType: 'BlockGroup', format: { lineHeight: '116%', marginTop: '0in', + marginRight: '0in', marginBottom: '0in', + marginLeft: '2in', }, - }, - { - blockType: 'BlockGroup', blockGroupType: 'ListItem', blocks: [ { - blockType: 'Paragraph', + isImplicit: true, segments: [ { - segmentType: 'Text', text: '123', + segmentType: 'Text', format: { fontFamily: 'Aptos, sans-serif', fontSize: '12pt', }, }, ], + blockType: 'Paragraph', format: {}, - isImplicit: true, }, ], + }, + { + formatHolder: { + isSelected: true, + segmentType: 'SelectionMarker', + format: { + fontFamily: 'Aptos, sans-serif', + fontSize: '12pt', + }, + }, levels: [ { listType: 'OL', format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l0', }, dataset: { editingInfo: '{"orderedStyleType":1}', @@ -3662,6 +3937,8 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l1', }, dataset: { editingInfo: '{"orderedStyleType":5}', @@ -3672,53 +3949,58 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - startNumberOverride: 1, + paddingLeft: '0px', + wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":13}', }, }, ], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: true, - format: { - fontFamily: 'Aptos, sans-serif', - fontSize: '12pt', - }, - }, + blockType: 'BlockGroup', format: { lineHeight: '116%', marginTop: '0in', + marginRight: '0in', marginBottom: '0in', + marginLeft: '1.5in', }, - }, - { - blockType: 'BlockGroup', blockGroupType: 'ListItem', blocks: [ { - blockType: 'Paragraph', + isImplicit: true, segments: [ { - segmentType: 'Text', text: '123', + segmentType: 'Text', format: { fontFamily: 'Aptos, sans-serif', fontSize: '12pt', }, }, ], + blockType: 'Paragraph', format: {}, - isImplicit: true, }, ], + }, + { + formatHolder: { + isSelected: true, + segmentType: 'SelectionMarker', + format: { + fontFamily: 'Aptos, sans-serif', + fontSize: '12pt', + }, + }, levels: [ { listType: 'OL', format: { marginTop: '0in', marginRight: '0in', + paddingLeft: '0px', + wordList: 'l0', }, dataset: { editingInfo: '{"orderedStyleType":1}', @@ -3729,77 +4011,1117 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - startNumberOverride: 1, + paddingLeft: '0px', + wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":5}', }, }, ], + blockType: 'BlockGroup', + format: { + lineHeight: '116%', + marginTop: '0in', + marginRight: '0in', + marginBottom: '0in', + marginLeft: '1in', + }, + blockGroupType: 'ListItem', + blocks: [ + { + isImplicit: true, + segments: [ + { + text: '123', + segmentType: 'Text', + format: { + fontFamily: 'Aptos, sans-serif', + fontSize: '12pt', + }, + }, + ], + blockType: 'Paragraph', + format: {}, + }, + ], + }, + { formatHolder: { - segmentType: 'SelectionMarker', isSelected: true, + segmentType: 'SelectionMarker', format: { fontFamily: 'Aptos, sans-serif', fontSize: '12pt', }, }, + levels: [ + { + listType: 'OL', + format: { + marginTop: '0in', + marginRight: '0in', + paddingLeft: '0px', + wordList: 'l3', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + blockType: 'BlockGroup', format: { lineHeight: '116%', marginTop: '0in', - marginBottom: '0in', + marginRight: '0in', + marginBottom: '8pt', + marginLeft: '0.5in', }, - }, - { - blockType: 'BlockGroup', blockGroupType: 'ListItem', blocks: [ { - blockType: 'Paragraph', + isImplicit: true, segments: [ { - segmentType: 'Text', text: '123', + segmentType: 'Text', format: { fontFamily: 'Aptos, sans-serif', fontSize: '12pt', }, }, ], + blockType: 'Paragraph', + format: {}, + }, + ], + }, + ], + }, + true, + htmlBefore + ); + }); + + /** + * mso-list: 10 + * 1. text + * * text + * .... + * + * Text + * + * mso-list: l0 (Should reset back to marker 1) + * 1. text + */ + it('Multiple lists with the same mso-list', () => { + const htmlBefore = + '\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\x3C!--[if gte mso 9]>\r\n \r\n \r\n \r\n\r\n\r\n\r\n\x3C!--[if gte mso 9]>\r\n \r\n Normal\r\n 0\r\n \r\n \r\n \r\n \r\n false\r\n false\r\n false\r\n \r\n EN-US\r\n JA\r\n X-NONE\r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n\x3C!--[if gte mso 9]>\r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \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\x3C!--[if gte mso 10]>\r\n\r\n\r\n\r\n\r\n\r\n'; + + runTest( + '\r\n\r\n

Text

\r\n\r\n

1.      text.

\r\n\r\n

2.     \r\ntext

\r\n\r\n

o  \r\ntext

\r\n\r\n

3.     \r\ntext

\r\n\r\n

o  \r\ntext

\r\n\r\n

o  \r\ntext

\r\n\r\n

4.     \r\ntext

\r\n\r\n

o  \r\ntext

\r\n\r\n

Text

\r\n\r\n

text

\r\n\r\n

text

\r\n\r\n

1.      \r\ntext

\r\n\r\n

o  \r\ntext

\r\n\r\n

2.      \r\ntext

\r\n\r\n

o  \r\ntext 

\r\n\r\n', + { + blockGroupType: 'Document', + blocks: [ + { + segments: [ + { + text: 'Text', + segmentType: 'Text', format: {}, - isImplicit: true, }, ], + blockType: 'Paragraph', + format: {}, + decorator: { + tagName: 'h2', + format: { + fontSize: '1.5em', + fontWeight: 'bold', + }, + }, + }, + { + formatHolder: { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, levels: [ { listType: 'OL', format: { - marginTop: '0in', - marginRight: '0in', - startNumberOverride: 501, + marginTop: '1em', + wordList: 'l2', + startNumberOverride: 1, }, dataset: { editingInfo: '{"orderedStyleType":1}', }, }, ], + blockType: 'BlockGroup', + format: { + lineHeight: '107%', + marginTop: '1em', + marginBottom: '8pt', + }, + blockGroupType: 'ListItem', + blocks: [ + { + isImplicit: true, + segments: [ + { + text: 'text', + segmentType: 'Text', + format: {}, + }, + { + text: '.', + segmentType: 'Text', + format: {}, + }, + ], + blockType: 'Paragraph', + format: {}, + }, + ], + }, + { formatHolder: { - segmentType: 'SelectionMarker', isSelected: true, - format: { - fontFamily: 'Aptos, sans-serif', - fontSize: '12pt', + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'OL', + format: { + marginTop: '1em', + wordList: 'l2', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + blockType: 'BlockGroup', + format: { + lineHeight: '107%', + marginTop: '1em', + marginBottom: '8pt', + }, + blockGroupType: 'ListItem', + blocks: [ + { + isImplicit: true, + segments: [ + { + text: 'text', + segmentType: 'Text', + format: {}, + }, + ], + blockType: 'Paragraph', + format: {}, }, + ], + }, + { + formatHolder: { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, }, + levels: [ + { + listType: 'OL', + format: { + marginTop: '1em', + wordList: 'l2', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + { + listType: 'UL', + format: { + marginTop: '0in', + marginRight: '0in', + paddingLeft: '0px', + wordList: 'l2', + }, + dataset: {}, + }, + ], + blockType: 'BlockGroup', format: { - lineHeight: '116%', + lineHeight: '107%', marginTop: '0in', + marginRight: '0in', marginBottom: '8pt', + marginLeft: '1in', }, + blockGroupType: 'ListItem', + blocks: [ + { + isImplicit: true, + segments: [ + { + text: 'text', + segmentType: 'Text', + format: {}, + }, + ], + blockType: 'Paragraph', + format: {}, + }, + ], }, - ], - }, - true + { + formatHolder: { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'OL', + format: { + marginTop: '1em', + wordList: 'l2', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + blockType: 'BlockGroup', + format: { + lineHeight: '107%', + marginTop: '1em', + marginBottom: '8pt', + }, + blockGroupType: 'ListItem', + blocks: [ + { + isImplicit: true, + segments: [ + { + text: 'text', + segmentType: 'Text', + format: {}, + }, + ], + blockType: 'Paragraph', + format: {}, + }, + ], + }, + { + formatHolder: { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'OL', + format: { + marginTop: '1em', + wordList: 'l2', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + { + listType: 'UL', + format: { + marginTop: '0in', + marginRight: '0in', + paddingLeft: '0px', + wordList: 'l2', + }, + dataset: {}, + }, + ], + blockType: 'BlockGroup', + format: { + lineHeight: '107%', + marginTop: '0in', + marginRight: '0in', + marginBottom: '8pt', + marginLeft: '1in', + }, + blockGroupType: 'ListItem', + blocks: [ + { + isImplicit: true, + segments: [ + { + text: 'text', + segmentType: 'Text', + format: {}, + }, + ], + blockType: 'Paragraph', + format: {}, + }, + ], + }, + { + formatHolder: { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'OL', + format: { + marginTop: '1em', + wordList: 'l2', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + { + listType: 'UL', + format: { + marginTop: '0in', + marginRight: '0in', + paddingLeft: '0px', + wordList: 'l2', + }, + dataset: {}, + }, + { + listType: 'UL', + format: { + marginTop: '0in', + marginRight: '0in', + paddingLeft: '0px', + wordList: 'l1', + }, + dataset: {}, + }, + ], + blockType: 'BlockGroup', + format: { + lineHeight: '107%', + marginTop: '0in', + marginRight: '0in', + marginBottom: '8pt', + marginLeft: '117pt', + }, + blockGroupType: 'ListItem', + blocks: [ + { + isImplicit: true, + segments: [ + { + text: 'text', + segmentType: 'Text', + format: {}, + }, + ], + blockType: 'Paragraph', + format: {}, + }, + ], + }, + { + formatHolder: { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'OL', + format: { + marginTop: '1em', + wordList: 'l2', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + blockType: 'BlockGroup', + format: { + lineHeight: '107%', + marginTop: '1em', + marginBottom: '8pt', + }, + blockGroupType: 'ListItem', + blocks: [ + { + isImplicit: true, + segments: [ + { + text: 'text', + segmentType: 'Text', + format: {}, + }, + ], + blockType: 'Paragraph', + format: {}, + }, + ], + }, + { + formatHolder: { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'OL', + format: { + marginTop: '1em', + wordList: 'l2', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + { + listType: 'UL', + format: { + marginTop: '0in', + marginRight: '0in', + paddingLeft: '0px', + wordList: 'l2', + }, + dataset: {}, + }, + ], + blockType: 'BlockGroup', + format: { + lineHeight: '107%', + marginTop: '0in', + marginRight: '0in', + marginBottom: '8pt', + marginLeft: '1in', + }, + blockGroupType: 'ListItem', + blocks: [ + { + isImplicit: true, + segments: [ + { + text: 'text', + segmentType: 'Text', + format: {}, + }, + ], + blockType: 'Paragraph', + format: {}, + }, + ], + }, + { + segments: [ + { + text: 'Text', + segmentType: 'Text', + format: { + fontSize: '16pt', + }, + }, + ], + blockType: 'Paragraph', + format: { + marginTop: '1em', + marginBottom: '1em', + }, + decorator: { + tagName: 'p', + format: {}, + }, + }, + { + segments: [ + { + text: 'text', + segmentType: 'Text', + format: {}, + }, + ], + blockType: 'Paragraph', + format: { + marginTop: '1em', + marginBottom: '1em', + }, + decorator: { + tagName: 'p', + format: {}, + }, + }, + { + segments: [ + { + text: 'text', + segmentType: 'Text', + format: {}, + }, + ], + blockType: 'Paragraph', + format: { + marginTop: '1em', + marginBottom: '1em', + }, + decorator: { + tagName: 'p', + format: {}, + }, + }, + { + formatHolder: { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'OL', + format: { + marginTop: '1em', + wordList: 'l0', + startNumberOverride: 1, + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + blockType: 'BlockGroup', + format: { + lineHeight: '107%', + marginTop: '1em', + marginBottom: '8pt', + }, + blockGroupType: 'ListItem', + blocks: [ + { + isImplicit: true, + segments: [ + { + text: 'text', + segmentType: 'Text', + format: {}, + }, + ], + blockType: 'Paragraph', + format: {}, + }, + ], + }, + { + formatHolder: { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'OL', + format: { + marginTop: '1em', + wordList: 'l0', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + { + listType: 'UL', + format: { + marginTop: '0in', + marginRight: '0in', + paddingLeft: '0px', + wordList: 'l0', + }, + dataset: {}, + }, + ], + blockType: 'BlockGroup', + format: { + lineHeight: '105%', + marginTop: '0in', + marginRight: '0in', + marginBottom: '8pt', + marginLeft: '1in', + }, + blockGroupType: 'ListItem', + blocks: [ + { + isImplicit: true, + segments: [ + { + text: 'text', + segmentType: 'Text', + format: {}, + }, + ], + blockType: 'Paragraph', + format: {}, + }, + ], + }, + { + formatHolder: { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'OL', + format: { + marginTop: '1em', + wordList: 'l0', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + blockType: 'BlockGroup', + format: { + lineHeight: '105%', + marginTop: '1em', + marginBottom: '8pt', + }, + blockGroupType: 'ListItem', + blocks: [ + { + isImplicit: true, + segments: [ + { + text: 'text', + segmentType: 'Text', + format: {}, + }, + ], + blockType: 'Paragraph', + format: {}, + }, + ], + }, + { + formatHolder: { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'OL', + format: { + marginTop: '1em', + wordList: 'l0', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + { + listType: 'UL', + format: { + marginTop: '0in', + marginRight: '0in', + paddingLeft: '0px', + wordList: 'l0', + }, + dataset: {}, + }, + ], + blockType: 'BlockGroup', + format: { + lineHeight: '105%', + marginTop: '0in', + marginRight: '0in', + marginBottom: '8pt', + marginLeft: '1in', + }, + blockGroupType: 'ListItem', + blocks: [ + { + isImplicit: true, + segments: [ + { + text: 'text', + segmentType: 'Text', + format: {}, + }, + { + text: ' ', + segmentType: 'Text', + format: {}, + }, + ], + blockType: 'Paragraph', + format: {}, + }, + ], + }, + ], + }, + true, + htmlBefore + ); + }); + + /** + * 1. Text + * Dummy List Item + * 2. List + * Dummy List Item + */ + it('List with dummy list from Word Desktop', () => { + const htmlBefore = + '\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\x3C!--[if gte mso 9]>\r\n \r\n \r\n \r\n\r\n\r\n\r\n\x3C!--[if gte mso 9]>\r\n \r\n Normal\r\n 0\r\n \r\n \r\n \r\n \r\n false\r\n false\r\n false\r\n \r\n EN-US\r\n JA\r\n X-NONE\r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n\x3C!--[if gte mso 9]>\r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \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\x3C!--[if gte mso 10]>\r\n\r\n\r\n\r\n\r\n\r\n'; + + runTest( + '\r\n\r\n

1.     \r\nList 1

\r\n\r\n

2.     \r\nList 2

\r\n\r\n

List without bullet

\r\n\r\n

 

\r\n\r\n

3.     \r\nList

\r\n\r\n

Text

\r\n\r\n

a.     \r\nList

\r\n\r\n

Text

\r\n\r\n

                                                              \r\ni.     \r\nList

\r\n\r\n

Text

\r\n\r\n', + { + blockGroupType: 'Document', + blocks: [ + { + formatHolder: { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'OL', + format: { + marginTop: '1em', + wordList: 'l0', + startNumberOverride: 1, + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + blockType: 'BlockGroup', + format: { + marginTop: '1em', + marginBottom: '1em', + }, + blockGroupType: 'ListItem', + blocks: [ + { + isImplicit: true, + segments: [ + { + text: 'List 1', + segmentType: 'Text', + format: {}, + }, + ], + blockType: 'Paragraph', + format: {}, + }, + ], + }, + { + formatHolder: { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'OL', + format: { + marginTop: '1em', + wordList: 'l0', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + blockType: 'BlockGroup', + format: { + marginTop: '1em', + marginBottom: '1em', + }, + blockGroupType: 'ListItem', + blocks: [ + { + isImplicit: true, + segments: [ + { + text: 'List 2', + segmentType: 'Text', + format: {}, + }, + ], + blockType: 'Paragraph', + format: {}, + }, + ], + }, + { + segments: [ + { + text: 'List without bullet', + segmentType: 'Text', + format: {}, + }, + ], + blockType: 'Paragraph', + format: { + marginTop: '1em', + marginBottom: '1em', + }, + decorator: { + tagName: 'p', + format: {}, + }, + }, + { + segments: [ + { + text: ' ', + segmentType: 'Text', + format: {}, + }, + ], + blockType: 'Paragraph', + format: { + marginTop: '1em', + marginBottom: '1em', + }, + decorator: { + tagName: 'p', + format: {}, + }, + }, + { + formatHolder: { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'OL', + format: { + marginTop: '1em', + wordList: 'l0', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + blockType: 'BlockGroup', + format: { + marginTop: '1em', + marginBottom: '1em', + }, + blockGroupType: 'ListItem', + blocks: [ + { + isImplicit: true, + segments: [ + { + text: 'List', + segmentType: 'Text', + format: {}, + }, + ], + blockType: 'Paragraph', + format: {}, + }, + ], + }, + { + segments: [ + { + text: 'Text', + segmentType: 'Text', + format: {}, + }, + ], + blockType: 'Paragraph', + format: { + marginTop: '1em', + marginBottom: '1em', + }, + decorator: { + tagName: 'p', + format: {}, + }, + }, + { + formatHolder: { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'OL', + format: { + marginTop: '1em', + wordList: 'l0', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + { + listType: 'OL', + format: { + marginTop: '1em', + paddingLeft: '0px', + wordList: 'l0', + startNumberOverride: 1, + }, + dataset: { + editingInfo: '{"orderedStyleType":5}', + }, + }, + ], + blockType: 'BlockGroup', + format: { + marginTop: '1em', + marginBottom: '1em', + marginLeft: '1in', + }, + blockGroupType: 'ListItem', + blocks: [ + { + isImplicit: true, + segments: [ + { + text: 'List', + segmentType: 'Text', + format: {}, + }, + ], + blockType: 'Paragraph', + format: {}, + }, + ], + }, + { + segments: [ + { + text: 'Text', + segmentType: 'Text', + format: {}, + }, + ], + blockType: 'Paragraph', + format: { + marginTop: '1em', + marginBottom: '1em', + marginLeft: '1in', + }, + decorator: { + tagName: 'p', + format: {}, + }, + }, + { + formatHolder: { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'OL', + format: { + marginTop: '1em', + wordList: 'l0', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + { + listType: 'OL', + format: { + marginTop: '1em', + paddingLeft: '0px', + wordList: 'l0', + }, + dataset: { + editingInfo: '{"orderedStyleType":5}', + }, + }, + { + listType: 'OL', + format: { + marginTop: '1em', + paddingLeft: '0px', + wordList: 'l0', + startNumberOverride: 1, + }, + dataset: { + editingInfo: '{"orderedStyleType":13}', + }, + }, + ], + blockType: 'BlockGroup', + format: { + marginTop: '1em', + marginBottom: '1em', + marginLeft: '1.5in', + }, + blockGroupType: 'ListItem', + blocks: [ + { + isImplicit: true, + segments: [ + { + text: 'List', + segmentType: 'Text', + format: {}, + }, + ], + blockType: 'Paragraph', + format: {}, + }, + ], + }, + { + segments: [ + { + text: 'Text', + segmentType: 'Text', + format: {}, + }, + ], + blockType: 'Paragraph', + format: { + marginTop: '1em', + marginBottom: '1em', + marginLeft: '1.5in', + }, + decorator: { + tagName: 'p', + format: {}, + }, + }, + ], + }, + true, + htmlBefore ); }); }); @@ -3826,7 +5148,7 @@ export function createBeforePasteEventMock(fragment: DocumentFragment, htmlBefor htmlBefore, htmlAfter: '', htmlAttributes: {}, - domToModelOption: {}, + domToModelOption: { additionalAllowedTags: [], additionalDisallowedTags: [] }, } as any) as ContentModelBeforePasteEvent; } diff --git a/packages-content-model/roosterjs-content-model-types/lib/editor/IStandaloneEditor.ts b/packages-content-model/roosterjs-content-model-types/lib/editor/IStandaloneEditor.ts index f744b8c4339..eef3ea1d137 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/editor/IStandaloneEditor.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/editor/IStandaloneEditor.ts @@ -1,3 +1,5 @@ +import type { PasteType } from '../enum/PasteType'; +import type { ClipboardData } from '../parameter/ClipboardData'; import type { DOMEventRecord } from '../parameter/DOMEventRecord'; import type { SnapshotsManager } from '../parameter/SnapshotsManager'; import type { Snapshot } from '../parameter/Snapshot'; @@ -13,7 +15,12 @@ import type { ContentModelFormatter, FormatWithContentModelOptions, } from '../parameter/FormatWithContentModelOptions'; -import type { PluginEventData, PluginEventFromType, PluginEventType } from 'roosterjs-editor-types'; +import type { + DarkColorHandler, + PluginEventData, + PluginEventFromType, + PluginEventType, +} from 'roosterjs-editor-types'; /** * An interface of standalone Content Model editor. @@ -80,8 +87,6 @@ export interface IStandaloneEditor { */ getPendingFormat(): ContentModelSegmentFormat | null; - //#region Editor API copied from legacy editor, will be ported to use Content Model instead - /** * Get whether this editor is disposed * @returns True if editor is disposed, otherwise false @@ -125,6 +130,12 @@ export interface IStandaloneEditor { */ isDarkMode(): boolean; + /** + * Set the dark mode state and transforms the content to match the new state. + * @param isDarkMode The next status of dark mode. True if the editor should be in dark mode, false if not. + */ + setDarkModeState(isDarkMode?: boolean): void; + /** * Get current zoom scale, default value is 1 * When editor is put under a zoomed container, need to pass the zoom scale number using EditorOptions.zoomScale @@ -156,5 +167,51 @@ export interface IStandaloneEditor { */ attachDomEvent(eventMap: Record): () => void; - //#endregion + /** + * Check if editor is in Shadow Edit mode + */ + isInShadowEdit(): boolean; + + /** + * Make the editor in "Shadow Edit" mode. + * In Shadow Edit mode, all format change will finally be ignored. + * This can be used for building a live preview feature for format button, to allow user + * see format result without really apply it. + * This function can be called repeated. If editor is already in shadow edit mode, we can still + * use this function to do more shadow edit operation. + */ + startShadowEdit(): void; + + /** + * Leave "Shadow Edit" mode, all changes made during shadow edit will be discarded + */ + stopShadowEdit(): void; + + /** + * Check if the given DOM node is in editor + * @param node The node to check + */ + isNodeInEditor(node: Node): boolean; + + /** + * Paste into editor using a clipboardData object + * @param clipboardData Clipboard data retrieved from clipboard + * @param pasteType Type of paste + */ + pasteFromClipboard(clipboardData: ClipboardData, pasteType?: PasteType): void; + + /** + * Get a darkColorHandler object for this editor. + */ + getDarkColorHandler(): DarkColorHandler; + + /** + * Dispose this editor, dispose all plugins and custom data + */ + dispose(): void; + /** + * Check if focus is in editor now + * @returns true if focus is in editor, otherwise false + */ + hasFocus(): boolean; } diff --git a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts index d7a547875b7..f53963adad3 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts @@ -1,24 +1,17 @@ +import type { ClipboardData } from '../parameter/ClipboardData'; +import type { PasteType } from '../enum/PasteType'; import type { DOMEventRecord } from '../parameter/DOMEventRecord'; import type { Snapshot } from '../parameter/Snapshot'; import type { EntityState } from '../parameter/FormatWithContentModelContext'; -import type { CompatibleGetContentMode } from 'roosterjs-editor-types/lib/compatibleTypes'; import type { - ContentMetadata, DarkColorHandler, EditorPlugin, - GetContentMode, - InsertOption, - NodePosition, PluginEvent, Rect, - StyleBasedFormatState, TrustedHTMLHandler, } from 'roosterjs-editor-types'; import type { ContentModelDocument } from '../group/ContentModelDocument'; -import type { - StandaloneEditorCorePluginState, - UnportedCorePluginState, -} from '../pluginState/StandaloneEditorPluginState'; +import type { StandaloneEditorCorePluginState } from '../pluginState/StandaloneEditorPluginState'; import type { DOMSelection } from '../selection/DOMSelection'; import type { DomToModelOption } from '../context/DomToModelOption'; import type { DomToModelSettings } from '../context/DomToModelSettings'; @@ -135,20 +128,6 @@ export type AddUndoSnapshot = ( */ export type GetVisibleViewport = (core: StandaloneEditorCore) => Rect | null; -/** - * Set HTML content to this editor. All existing content will be replaced. A ContentChanged event will be triggered - * if triggerContentChangedEvent is set to true - * @param core The StandaloneEditorCore object - * @param content HTML content to set in - * @param triggerContentChangedEvent True to trigger a ContentChanged event. Default value is true - */ -export type SetContent = ( - core: StandaloneEditorCore, - content: string, - triggerContentChangedEvent: boolean, - metadata?: ContentMetadata -) => void; - /** * Check if the editor has focus now * @param core The StandaloneEditorCore object @@ -162,17 +141,6 @@ export type HasFocus = (core: StandaloneEditorCore) => boolean; */ export type Focus = (core: StandaloneEditorCore) => void; -/** - * Insert a DOM node into editor content - * @param core The StandaloneEditorCore object. No op if null. - * @param option An insert option object to specify how to insert the node - */ -export type InsertNode = ( - core: StandaloneEditorCore, - node: Node, - option: InsertOption | null -) => boolean; - /** * Attach a DOM event to the editor content DIV * @param core The StandaloneEditorCore object @@ -183,27 +151,6 @@ export type AttachDomEvent = ( eventMap: Record ) => () => void; -/** - * Get current editor content as HTML string - * @param core The StandaloneEditorCore object - * @param mode specify what kind of HTML content to retrieve - * @returns HTML string representing current editor content - */ -export type GetContent = ( - core: StandaloneEditorCore, - mode: GetContentMode | CompatibleGetContentMode -) => string; - -/** - * Get style based format state from current selection, including font name/size and colors - * @param core The StandaloneEditorCore objects - * @param node The node to get style from - */ -export type GetStyleBasedFormatState = ( - core: StandaloneEditorCore, - node: Node | null -) => StyleBasedFormatState; - /** * Restore an undo snapshot into editor * @param core The StandaloneEditorCore object @@ -212,24 +159,22 @@ export type GetStyleBasedFormatState = ( export type RestoreUndoSnapshot = (core: StandaloneEditorCore, snapshot: Snapshot) => void; /** - * Ensure user will type into a container element rather than into the editor content DIV directly + * Paste into editor using a clipboardData object * @param core The StandaloneEditorCore object. - * @param position The position that user is about to type to - * @param keyboardEvent Optional keyboard event object - * @param deprecated Deprecated parameter, not used + * @param clipboardData Clipboard data retrieved from clipboard + * @param pasteType Type of content to paste. @default normal */ -export type EnsureTypeInContainer = ( +export type Paste = ( core: StandaloneEditorCore, - position: NodePosition, - keyboardEvent?: KeyboardEvent, - deprecated?: boolean + clipboardData: ClipboardData, + pasteType: PasteType ) => void; /** - * Temp interface - * TODO: Port other core API + * The interface for the map of core API for Content Model editor. + * Editor can call call API from this map under StandaloneEditorCore object */ -export interface PortedCoreApiMap { +export interface StandaloneCoreApiMap { /** * Create a EditorContext object used by ContentModel API * @param core The StandaloneEditorCore object @@ -333,67 +278,20 @@ export interface PortedCoreApiMap { * @param broadcast Set to true to skip the shouldHandleEventExclusively check */ triggerEvent: TriggerEvent; -} -/** - * Temp interface - * TODO: Port these core API - */ -export interface UnportedCoreApiMap { /** - * Set HTML content to this editor. All existing content will be replaced. A ContentChanged event will be triggered - * if triggerContentChangedEvent is set to true - * @param core The StandaloneEditorCore object - * @param content HTML content to set in - * @param triggerContentChangedEvent True to trigger a ContentChanged event. Default value is true + * Paste into editor using a clipboardData object + * @param editor The editor to paste content into + * @param clipboardData Clipboard data retrieved from clipboard + * @param pasteType Type of content to paste. @default normal */ - setContent: SetContent; - - /** - * Insert a DOM node into editor content - * @param core The StandaloneEditorCore object. No op if null. - * @param option An insert option object to specify how to insert the node - */ - insertNode: InsertNode; - - /** - * Get current editor content as HTML string - * @param core The StandaloneEditorCore object - * @param mode specify what kind of HTML content to retrieve - * @returns HTML string representing current editor content - */ - getContent: GetContent; - - /** - * Get style based format state from current selection, including font name/size and colors - * @param core The StandaloneEditorCore objects - * @param node The node to get style from - */ - getStyleBasedFormatState: GetStyleBasedFormatState; - - /** - * Ensure user will type into a container element rather than into the editor content DIV directly - * @param core The EditorCore object. - * @param position The position that user is about to type to - * @param keyboardEvent Optional keyboard event object - * @param deprecated Deprecated parameter, not used - */ - ensureTypeInContainer: EnsureTypeInContainer; + paste: Paste; } -/** - * The interface for the map of core API for Content Model editor. - * Editor can call call API from this map under StandaloneEditorCore object - */ -export interface StandaloneCoreApiMap extends PortedCoreApiMap, UnportedCoreApiMap {} - /** * Represents the core data structure of a Content Model editor */ -export interface StandaloneEditorCore - extends StandaloneEditorCorePluginState, - UnportedCorePluginState, - StandaloneEditorDefaultSettings { +export interface StandaloneEditorCore extends StandaloneEditorCorePluginState { /** * The content DIV element of this editor */ @@ -414,6 +312,16 @@ export interface StandaloneEditorCore */ readonly plugins: EditorPlugin[]; + /** + * Settings used by DOM to Content Model conversion + */ + readonly domToModelSettings: ContentModelSettings; + + /** + * Settings used by Content Model to DOM conversion + */ + readonly modelToDomSettings: ContentModelSettings; + /** * Editor running environment */ @@ -431,31 +339,41 @@ export interface StandaloneEditorCore * To override, pass your own trusted HTML handler to EditorOptions.trustedHTMLHandler */ readonly trustedHTMLHandler: TrustedHTMLHandler; + + /** + * A callback to be invoked when any exception is thrown during disposing editor + * @param plugin The plugin that causes exception + * @param error The error object we got + */ + readonly disposeErrorHandler?: (plugin: EditorPlugin, error: Error) => void; + + /** + * @deprecated Will be removed soon. + * Current zoom scale, default value is 1 + * When editor is put under a zoomed container, need to pass the zoom scale number using this property + * to let editor behave correctly especially for those mouse drag/drop behaviors + */ + zoomScale: number; } /** * Default DOM and Content Model conversion settings for an editor */ -export interface StandaloneEditorDefaultSettings { - /** - * Default DOM to Content Model options - */ - defaultDomToModelOptions: (DomToModelOption | undefined)[]; - +export interface ContentModelSettings { /** - * Default Content Model to DOM options + * Built in options used by editor */ - defaultModelToDomOptions: (ModelToDomOption | undefined)[]; + builtIn: OptionType; /** - * Default DOM to Content Model config, calculated from defaultDomToModelOptions, - * will be used for creating content model if there is no other customized options + * Customize options passed in from Editor Options, used for overwrite default option. + * This will also be used by copy/paste */ - defaultDomToModelConfig: DomToModelSettings; + customized: OptionType; /** - * Default Content Model to DOM config, calculated from defaultModelToDomOptions, - * will be used for setting content model if there is no other customized options + * Configuration calculated from default and customized options. + * This is a cached object so that we don't need to cache it every time when we use Content Model */ - defaultModelToDomConfig: ModelToDomSettings; + calculated: ConfigType; } diff --git a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorOptions.ts b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorOptions.ts index 0168cc7477f..5150595b221 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorOptions.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorOptions.ts @@ -96,4 +96,19 @@ export interface StandaloneEditorOptions { * When this property is set, value of undoSnapshotService will be ignored. */ snapshotsManager?: SnapshotsManager; + + /** + * A callback to be invoked when any exception is thrown during disposing editor + * @param plugin The plugin that causes exception + * @param error The error object we got + */ + disposeErrorHandler?: (plugin: EditorPlugin, error: Error) => void; + + /** + * @deprecated + * Current zoom scale, @default value is 1 + * When editor is put under a zoomed container, need to pass the zoom scale number using this property + * to let editor behave correctly especially for those mouse drag/drop behaviors + */ + zoomScale?: number; } diff --git a/packages-content-model/roosterjs-content-model-types/lib/event/ContentModelBeforePasteEvent.ts b/packages-content-model/roosterjs-content-model-types/lib/event/ContentModelBeforePasteEvent.ts index 5e28cc48be8..3a4f8d90711 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/event/ContentModelBeforePasteEvent.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/event/ContentModelBeforePasteEvent.ts @@ -1,3 +1,4 @@ +import type { ValueSanitizer } from '../parameter/ValueSanitizer'; import type { DomToModelOption } from '../context/DomToModelOption'; import type { ContentModelDocument } from '../group/ContentModelDocument'; import type { InsertPoint } from '../selection/InsertPoint'; @@ -7,6 +8,42 @@ import type { CompatibleBeforePasteEvent, } from 'roosterjs-editor-types'; +/** + * Options for DOM to Content Model conversion for paste only + */ +export interface DomToModelOptionForPaste extends Required { + /** + * Additional allowed HTML tags in lower case. Element with these tags will be preserved + */ + additionalAllowedTags: Lowercase[]; + + /** + * Additional disallowed HTML tags in lower case. Elements with these tags will be dropped + */ + additionalDisallowedTags: Lowercase[]; + + /** + * Additional sanitizers for CSS styles + */ + styleSanitizers: Record; + + /** + * Additional sanitizers for CSS styles + */ + attributeSanitizers: Record; +} + +/** + * A function type used by merging pasted content into current Content Model + * @param target Target Content Model to merge into + * @param source Source Content Model to merge from + * @returns Insert point after merge + */ +export type MergePastedContentFunc = ( + target: ContentModelDocument, + source: ContentModelDocument +) => InsertPoint | null; + /** * Data of ContentModelBeforePasteEvent */ @@ -14,14 +51,12 @@ export interface ContentModelBeforePasteEventData extends BeforePasteEventData { /** * domToModel Options to use when creating the content model from the paste fragment */ - domToModelOption: Partial; + domToModelOption: DomToModelOptionForPaste; + /** * customizedMerge Customized merge function to use when merging the paste fragment into the editor */ - customizedMerge?: ( - target: ContentModelDocument, - source: ContentModelDocument - ) => InsertPoint | null; + customizedMerge?: MergePastedContentFunc; } /** diff --git a/packages-content-model/roosterjs-content-model-types/lib/index.ts b/packages-content-model/roosterjs-content-model-types/lib/index.ts index fdf5d62948a..01953755fbb 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/index.ts @@ -205,40 +205,31 @@ export { FormatContentModel, StandaloneCoreApiMap, StandaloneEditorCore, - StandaloneEditorDefaultSettings, + ContentModelSettings, SwitchShadowEdit, TriggerEvent, AddUndoSnapshot, - PortedCoreApiMap, - UnportedCoreApiMap, - SetContent, HasFocus, Focus, - InsertNode, AttachDomEvent, - GetContent, - GetStyleBasedFormatState, RestoreUndoSnapshot, - EnsureTypeInContainer, GetVisibleViewport, + Paste, } from './editor/StandaloneEditorCore'; export { StandaloneEditorCorePlugins } from './editor/StandaloneEditorCorePlugins'; export { ContentModelCachePluginState } from './pluginState/ContentModelCachePluginState'; -export { - StandaloneEditorCorePluginState, - UnportedCorePluginState, -} from './pluginState/StandaloneEditorPluginState'; +export { StandaloneEditorCorePluginState } from './pluginState/StandaloneEditorPluginState'; export { ContentModelFormatPluginState, PendingFormat, } from './pluginState/ContentModelFormatPluginState'; +export { CopyPastePluginState } from './pluginState/CopyPastePluginState'; export { DOMEventPluginState } from './pluginState/DOMEventPluginState'; export { LifecyclePluginState } from './pluginState/LifecyclePluginState'; export { EntityPluginState, KnownEntityItem } from './pluginState/EntityPluginState'; export { SelectionPluginState } from './pluginState/SelectionPluginState'; export { UndoPluginState } from './pluginState/UndoPluginState'; -export { CopyPastePluginState } from './pluginState/CopyPastePluginState'; export { EditorEnvironment } from './parameter/EditorEnvironment'; export { @@ -273,8 +264,11 @@ export { SnapshotsManager } from './parameter/SnapshotsManager'; export { DOMEventHandlerFunction, DOMEventRecord } from './parameter/DOMEventRecord'; export { EdgeLinkPreview } from './parameter/EdgeLinkPreview'; export { ClipboardData } from './parameter/ClipboardData'; +export { ValueSanitizer } from './parameter/ValueSanitizer'; export { + MergePastedContentFunc, + DomToModelOptionForPaste, ContentModelBeforePasteEvent, ContentModelBeforePasteEventData, CompatibleContentModelBeforePasteEvent, diff --git a/packages-content-model/roosterjs-content-model-types/lib/parameter/ClipboardData.ts b/packages-content-model/roosterjs-content-model-types/lib/parameter/ClipboardData.ts index cf5bf15ae2b..a81bfe39f26 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/parameter/ClipboardData.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/parameter/ClipboardData.ts @@ -1,3 +1,4 @@ +import type { ContentModelDocument } from '../group/ContentModelDocument'; import type { EdgeLinkPreview } from './EdgeLinkPreview'; /** @@ -44,7 +45,7 @@ export interface ClipboardData { /** * An editor content snapshot before pasting happens. This is used for changing paste format */ - snapshotBeforePaste?: string; + modelBeforePaste?: ContentModelDocument; /** * BASE64 encoded data uri of the image if any diff --git a/packages-content-model/roosterjs-content-model-types/lib/parameter/ValueSanitizer.ts b/packages-content-model/roosterjs-content-model-types/lib/parameter/ValueSanitizer.ts new file mode 100644 index 00000000000..5a4a6099f44 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-types/lib/parameter/ValueSanitizer.ts @@ -0,0 +1,10 @@ +/** + * Specify how to sanitize a value, can be a callback function or a boolean value. + * True: Keep this value + * False: Remove this value + * A callback: Let the callback function to decide how to deal this value. + * @param value The original value + * @param tagName Tag name of the element of this value + * @returns Return a non-empty string means use this value to replace the original value. Otherwise remove this value + */ +export type ValueSanitizer = ((value: string, tagName: string) => string | null) | boolean; diff --git a/packages-content-model/roosterjs-content-model-types/lib/pluginState/DOMEventPluginState.ts b/packages-content-model/roosterjs-content-model-types/lib/pluginState/DOMEventPluginState.ts index 1c578e0edc5..895fc2da3a1 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/pluginState/DOMEventPluginState.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/pluginState/DOMEventPluginState.ts @@ -1,5 +1,3 @@ -import type { ContextMenuProvider } from 'roosterjs-editor-types'; - /** * The state object for DOMEventPlugin */ @@ -14,11 +12,6 @@ export interface DOMEventPluginState { */ scrollContainer: HTMLElement; - /** - * Context menu providers, that can provide context menu items - */ - contextMenuProviders: ContextMenuProvider[]; - /** * Whether mouse up event handler is added */ diff --git a/packages-content-model/roosterjs-content-model-types/lib/pluginState/StandaloneEditorPluginState.ts b/packages-content-model/roosterjs-content-model-types/lib/pluginState/StandaloneEditorPluginState.ts index 81b4865899a..fed7e413701 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/pluginState/StandaloneEditorPluginState.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/pluginState/StandaloneEditorPluginState.ts @@ -1,7 +1,6 @@ import type { CopyPastePluginState } from './CopyPastePluginState'; import type { UndoPluginState } from './UndoPluginState'; import type { SelectionPluginState } from './SelectionPluginState'; -import type { EditPluginState } from 'roosterjs-editor-types'; import type { ContentModelCachePluginState } from './ContentModelCachePluginState'; import type { ContentModelFormatPluginState } from './ContentModelFormatPluginState'; import type { DOMEventPluginState } from './DOMEventPluginState'; @@ -53,11 +52,3 @@ export interface StandaloneEditorCorePluginState { */ undo: UndoPluginState; } - -/** - * Temporary core plugin state for Content Model editor (unported part) - * TODO: Port these plugins - */ -export interface UnportedCorePluginState { - edit: EditPluginState; -} diff --git a/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/ImageEdit.ts b/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/ImageEdit.ts index eb3caa489ea..875761bcae3 100644 --- a/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/ImageEdit.ts +++ b/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/ImageEdit.ts @@ -7,7 +7,6 @@ import { deleteEditInfo, getEditInfoFromImage } from './editInfoUtils/editInfo'; import { getRotateHTML, Rotator, updateRotateHandleState } from './imageEditors/Rotator'; import { ImageEditElementClass } from './types/ImageEditElementClass'; import { MIN_HEIGHT_WIDTH } from './constants/constants'; -import { tryToConvertGifToPng } from './editInfoUtils/tryToConvertGifToPng'; import type { DNDDirectionX, DnDDirectionY } from './types/DragAndDropContext'; import type DragAndDropContext from './types/DragAndDropContext'; import type DragAndDropHandler from '../../pluginUtils/DragAndDropHandler'; @@ -135,11 +134,6 @@ export default class ImageEdit implements EditorPlugin { */ private isCropping: boolean = false; - /** - * If the image is a gif, this is the png source of the gif image - */ - private pngSource: string | null = null; - /** * Create a new instance of ImageEdit * @param options Image editing options @@ -301,12 +295,6 @@ export default class ImageEdit implements EditorPlugin { // When there is image in editing, clean up any cached objects and elements this.clearDndHelpers(); - // If the image is a gif we change the editing image to a new png image, then we need to change the - // image source to the original gif image - if (this.pngSource) { - this.clonedImage.src = this.editInfo.src; - } - // Apply the changes, and add undo snapshot if necessary applyChange( this.editor, @@ -326,7 +314,6 @@ export default class ImageEdit implements EditorPlugin { this.editor.select(this.image); } - this.pngSource = null; this.image = null; this.editInfo = null; this.lastSrc = null; @@ -342,9 +329,6 @@ export default class ImageEdit implements EditorPlugin { // Get initial edit info this.editInfo = getEditInfoFromImage(image); - //Check if the image is a gif and convert it to a png - this.pngSource = tryToConvertGifToPng(this.editInfo); - //Check if the image was resized by the user this.wasResized = checkIfImageWasResized(this.image); @@ -444,7 +428,7 @@ export default class ImageEdit implements EditorPlugin { // Set image src to original src to help show editing UI, also it will be used when regenerate image dataURL after editing if (this.clonedImage) { - this.clonedImage.src = this.pngSource ?? this.editInfo.src; + this.clonedImage.src = this.editInfo.src; this.clonedImage.style.position = 'absolute'; } diff --git a/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/editInfoUtils/tryToConvertGifToPng.ts b/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/editInfoUtils/tryToConvertGifToPng.ts deleted file mode 100644 index 68eb410d8bd..00000000000 --- a/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/editInfoUtils/tryToConvertGifToPng.ts +++ /dev/null @@ -1,35 +0,0 @@ -import generateDataURL from './generateDataURL'; -import type ImageEditInfo from '../types/ImageEditInfo'; - -/** - * @internal - * Check if the image is a gif, if true, use canvas to convert it to a png. - * If the image is not a gif, return null. - * @param image to be converted - * @returns the converted image data url or null, if the image is not a gif - */ -export function tryToConvertGifToPng(editInfo: ImageEditInfo) { - const { src, widthPx, heightPx, naturalHeight, naturalWidth } = editInfo; - if (src.indexOf('.gif') > -1 || src.indexOf('image/gif') > -1) { - try { - const image = document.createElement('img'); - image.src = src; - const newEditInfo = { - src: src, - widthPx: widthPx, - heightPx: heightPx, - naturalWidth: naturalWidth, - naturalHeight: naturalHeight, - leftPercent: 0, - rightPercent: 0, - topPercent: 0, - bottomPercent: 0, - angleRad: 0, - }; - return generateDataURL(image, newEditInfo); - } catch { - return null; - } - } - return null; -} diff --git a/versions.json b/versions.json index 8133f570b5d..96ad60000ef 100644 --- a/versions.json +++ b/versions.json @@ -1,10 +1,9 @@ -{ - "packages": "8.59.0", - "packages-ui": "8.54.0", - "packages-content-model": "0.22.0", - "overrides": { - "roosterjs-editor-core": "8.59.1", - "roosterjs-editor-plugins": "8.59.3", - "roosterjs-content-model-plugins": "0.22.1" - } -} +{ + "packages": "8.59.0", + "packages-ui": "8.54.0", + "packages-content-model": "0.23.0", + "overrides": { + "roosterjs-editor-core": "8.59.1", + "roosterjs-editor-plugins": "8.59.3" + } +}