From fb93567796e82198b7c4e84b2fa1315a92d97ee3 Mon Sep 17 00:00:00 2001 From: Andres-CT98 <107568016+Andres-CT98@users.noreply.github.com> Date: Fri, 2 Feb 2024 15:40:31 -0600 Subject: [PATCH] Version bump content model packages to 0.25.0 (#2392) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * wip * wip * refactor * fix dependecies * fix dependecies * fix dependecies * WIP * add test * add tests * types * add comment * add comment * add comment * add comment * add comment * comments * fix build * fixes * fixes * refactor * fixes * Add shortcut support Ctrl+K for insertLink (#2333) * Add shortcut support Ctrl+K for insertLink * Add metakey and alt key checks * Add insertButton check and disable param * add options to ribbon and decouple onButtonClick * update * update fix * add break statement * remove duplicate imports --------- Co-authored-by: Ghanem10 <107857762+Ghanem10@users.noreply.github.com> Co-authored-by: Jiuqing Song * Content Model: Add back ContentModelBeforePasteEvent (#2347) * Use margin-top and bottom for list margin (#2346) * Standalone Editor: Improve cache (3rd try) (#2344) Co-authored-by: Bryan Valverde U * fixes * fix test * Fix safari selection issue (#2332) * try fix safari selection issue * fix content model editor, add test * fix focus bug * Standalone Editor: Remove dependency to old code from api and plugins package (#2349) * Content Model: Decouple test code from old code (#2351) * Fix demo site (#2353) * Content Model: Fix table text color (#2359) * Fix pending format (#2354) * Fix pending format * improve * Standalone Editor: Provide a DOMHelper to allow access DOM tree (#2363) * Fix shadow edit (#2355) * Fix 2 shadow edit issue (#2356) * Support unit "inch" when parse value (#2357) * Content Model: Support inch when parse unit value * add comment * Code clean up: small refactor to paste code (#2365) * Refactor paste * add test * Code clean up: Remove IStandaloneEditor.setContentModel (#2364) * Add support to cursor around Block entities. (#2350) * rotator space * Port ContextMenu plugin (#2366) * Port ContextMenu plugin * add test * Content Model: Fix 252436 (#2367) * Content Model: Fix 252436 * add test --------- Co-authored-by: Bryan Valverde U * Code cleanup: Remove parameter onNodeCreated (#2374) * Code cleanup: Remove isContentModelEditor (#2371) * Code cleanup: Remove isContentModelEditor * add buttons * Add `isReverted` to DOM Selection (#2368) * add Changes * add Tests * fix dependencies * address comments * address comment * fix after merge * fix test --------- Co-authored-by: Jiuqing Song * Code cleanup: Remove NormalizeTablePlugin (#2376) * add normalize content model * fix build * Code cleanup: Replace createContentModel with getContentModelCopy (#2372) * Code cleanup: Remove isContentModelEditor * add buttons * Code cleanup: Replace createContentModel with getContentModelCopy * Code cleanup: Remove get/setZoomScale (#2378) * Code cleanup: Remove get/setZoomScale * Add calculateZoomScale function * improve * Code cleanup: Move isNodeInEditor into DOMHelper (#2379) * Code cleanup: Remove TODO (#2384) * Add write permission to deploy action (#2383) * Revert "Add support to cursor around Block entities. (#2350)" (#2390) This reverts commit 7998d03a7f225e270d5e061926587bf978a27e98. * Content Model: Fix #2202 (#2389) Co-authored-by: Bryan Valverde U * bump --------- Co-authored-by: JĂșlia Roldi Co-authored-by: Julia Roldi <87443959+juliaroldi@users.noreply.github.com> Co-authored-by: Gani <107857762+gm-al@users.noreply.github.com> Co-authored-by: Ghanem10 <107857762+Ghanem10@users.noreply.github.com> Co-authored-by: Jiuqing Song Co-authored-by: Bryan Valverde U --- .github/workflows/build-and-deploy.yml | 2 + .../controls/ContentModelEditorMainPane.tsx | 153 ++- .../controls/StandaloneEditorMainPane.tsx | 150 ++- .../editor/ContentModelRooster.tsx | 8 +- .../contentModel/ContentModelRibbon.tsx | 147 +-- .../ribbonButtons/contentModel/zoom.ts | 4 +- .../contentModel/ContentModelPane.tsx | 13 +- .../contentModel/ContentModelPanePlugin.ts | 26 +- .../contentModel/buttons/exportButton.ts | 19 +- .../contentModel/buttons/refreshButton.ts | 8 +- .../sidePane/contentModel/currentModel.ts | 22 +- .../roosterjs-content-model-api/lib/index.ts | 1 + .../lib/modelApi/block/setModelDirection.ts | 1 - .../modelApi/block/toggleModelBlockQuote.ts | 9 +- .../common/retrieveModelFormatState.ts | 4 - .../lib/modelApi/common/wrapBlock.ts | 21 +- .../lib/modelApi/link/matchLink.ts | 114 ++ .../lib/modelApi/list/setListType.ts | 16 +- .../lib/publicApi/block/toggleBlockQuote.ts | 25 +- .../lib/publicApi/entity/insertEntity.ts | 18 +- .../lib/publicApi/format/getFormatState.ts | 107 +- .../lib/publicApi/link/insertLink.ts | 15 +- .../publicApi/segment/changeCapitalization.ts | 1 - .../roosterjs-content-model-api/package.json | 2 - .../block/toggleModelBlockQuoteTest.ts | 117 ++- .../test/modelApi/common/wrapBlockTest.ts | 6 +- .../test/modelApi/link/matchLinkTest.ts | 260 +++++ .../test/modelApi/list/setListTypeTest.ts | 64 +- .../test/publicApi/block/setAlignmentTest.ts | 12 - .../publicApi/block/toggleBlockQuoteTest.ts | 62 +- .../publicApi/format/getFormatStateTest.ts | 343 +----- .../publicApi/link/adjustLinkSelectionTest.ts | 13 +- .../test/publicApi/link/insertLinkTest.ts | 43 + .../publicApi/segment/changeFontSizeTest.ts | 3 +- .../publicApi/segment/segmentTestCommon.ts | 2 - .../lib/coreApi/addUndoSnapshot.ts | 10 +- .../lib/coreApi/createContentModel.ts | 43 +- .../lib/coreApi/createEditorContext.ts | 17 +- .../lib/coreApi/formatContentModel.ts | 1 - .../lib/coreApi/getDOMSelection.ts | 28 +- .../lib/coreApi/paste.ts | 65 +- .../lib/coreApi/setContentModel.ts | 11 +- .../lib/coreApi/setDOMSelection.ts | 2 +- .../lib/coreApi/switchShadowEdit.ts | 4 - .../lib/corePlugin/ContentModelCachePlugin.ts | 95 +- .../corePlugin/ContentModelCopyPastePlugin.ts | 36 +- .../corePlugin/ContentModelFormatPlugin.ts | 1 - .../lib/corePlugin/ContextMenuPlugin.ts | 127 +++ .../lib/corePlugin/EntityPlugin.ts | 7 +- .../lib/corePlugin/LifecyclePlugin.ts | 30 +- .../lib/corePlugin/SelectionPlugin.ts | 48 +- .../createStandaloneEditorCorePlugins.ts | 4 +- .../corePlugin/utils/addRangeToSelection.ts | 14 +- .../corePlugin/utils/applyDefaultFormat.ts | 2 +- .../corePlugin/utils/textMutationObserver.ts | 53 + .../lib/editor/DOMHelperImpl.ts | 30 + .../lib/editor/StandaloneEditor.ts | 136 ++- .../lib/editor/createStandaloneEditorCore.ts | 5 +- .../override/reducedModelChildProcessor.ts | 93 ++ .../lib/publicApi/domUtils/borderValues.ts | 2 +- .../lib/publicApi/selection/deleteBlock.ts | 3 + .../lib/publicApi/selection/deleteSegment.ts | 5 +- .../publicApi/selection/iterateSelections.ts | 2 - .../lib/utils/createSnapshotSelection.ts | 104 +- .../lib/utils/paste/mergePasteContent.ts | 95 +- .../lib/utils/restoreSnapshotSelection.ts | 1 + .../test/coreApi/addUndoSnapshotTest.ts | 59 +- .../test/coreApi/createContentModelTest.ts | 22 +- .../test/coreApi/createEditorContextTest.ts | 102 +- .../test/coreApi/getDOMSelectionTest.ts | 200 +++- .../test/coreApi/pasteTest.ts | 139 +-- .../test/coreApi/setContentModelTest.ts | 114 +- .../test/coreApi/setDOMSelectionTest.ts | 200 ++-- .../corePlugin/ContentModelCachePluginTest.ts | 220 +--- .../ContentModelCopyPastePluginTest.ts | 89 +- .../ContentModelFormatPluginTest.ts | 4 +- .../test/corePlugin/ContextMenuPluginTest.ts | 169 +++ .../test/corePlugin/EntityPluginTest.ts | 34 +- .../test/corePlugin/LifecyclePluginTest.ts | 69 -- .../test/corePlugin/SelectionPluginTest.ts | 237 +++++ .../utils/applyDefaultFormatTest.ts | 4 +- .../corePlugin/utils/areSameRangeExTest.ts | 12 + .../utils/contentModelDomIndexerTest.ts | 12 +- .../utils/textMutationObserverTest.ts | 134 +++ .../test/editor/DOMHelperImplTest.ts | 63 ++ .../test/editor/StandaloneEditorTest.ts | 209 ++-- .../editor/createStandaloneEditorCoreTest.ts | 52 +- .../handleListItemWithMetadataTest.ts | 2 +- .../metadata/handleListWithMetadataTest.ts | 15 +- .../reducedModelChildProcessorTest.ts | 322 ++++++ .../test/overrides/tablePreProcessorTest.ts | 1 + .../selection/getSelectionRootNodeTest.ts | 1 + .../test/utils/createSnapshotSelectionTest.ts | 499 ++++++++- .../utils/paste/createPasteFragmentTest.ts | 2 +- .../test/utils/paste/mergePasteContentTest.ts | 143 ++- .../test/utils/paste/retrieveHtmlInfoTest.ts | 2 +- .../utils/restoreSnapshotSelectionTest.ts | 10 + .../domToModel/processors/entityProcessor.ts | 1 - .../block/marginFormatHandler.ts | 20 - .../utils/parseValueWithUnit.ts | 7 +- .../roosterjs-content-model-dom/lib/index.ts | 1 + .../lib/modelApi/creators/createEmptyModel.ts | 22 + .../lib/modelToDom/contentModelToDom.ts | 8 +- .../processors/childProcessorTest.ts | 8 + .../processors/delimiterProcessorTest.ts | 4 +- .../processors/generalProcessorTest.ts | 2 + .../processors/textProcessorTest.ts | 8 +- .../test/endToEndTest.ts | 2 +- .../block/marginFormatHandlerTest.ts | 54 +- .../backgroundColorFormatHandlerTest.ts | 2 +- .../common/borderFormatHandlerTest.ts | 15 +- .../segment/textColorFormatHandlerTest.ts | 2 +- .../utils/parseValueWithUnitTest.ts | 20 +- .../modelApi/creators/createEmptyModelTest.ts | 59 ++ .../modelToDom/handlers/handleImageTest.ts | 25 +- .../modelToDom/handlers/handleListTest.ts | 2 +- .../handlers/handleSegmentDecoratorTest.ts | 2 +- .../test/testUtils.ts | 31 + .../lib/coreApi/ensureTypeInContainer.ts | 1 + .../lib/coreApi/insertNode.ts | 1 + .../lib/coreApi/setContent.ts | 1 + .../lib/corePlugins/BridgePlugin.ts | 44 +- .../lib/corePlugins/ContextMenuPlugin.ts | 111 -- .../lib/corePlugins}/EntityDelimiterPlugin.ts | 13 +- .../lib/corePlugins/NormalizeTablePlugin.ts | 187 ---- .../lib/editor/ContentModelEditor.ts | 36 +- .../lib/editor/isContentModelEditor.ts | 13 - .../lib/editor/utils/eventConverter.ts | 4 +- .../lib/editor/utils/selectionConverter.ts | 1 + .../lib/index.ts | 2 - .../publicTypes/ContentModelCorePlugins.ts | 7 +- .../test/corePlugins/BridgePluginTest.ts | 92 +- .../test/corePlugins/ContextMenuPluginTest.ts | 93 -- .../test/editor/ContentModelEditorTest.ts | 86 +- .../test/editor/createEditorCoreTest.ts | 10 +- .../test/editor/isContentModelEditorTest.ts | 21 - .../test/editor/utils/eventConverterTest.ts | 26 +- .../editor/utils/selectionConverterTest.ts | 2 + .../ContentModelAutoFormatPlugin.ts | 101 ++ .../lib/autoFormat/keyboardListTrigger.ts | 60 ++ .../utils/convertAlphaToDecimals.ts | 19 + .../lib/autoFormat/utils/getIndex.ts | 10 + .../lib/autoFormat/utils/getListTypeStyle.ts | 106 ++ .../autoFormat/utils/getNumberingListStyle.ts | 172 +++ .../lib/edit/ContentModelEditPlugin.ts | 1 - .../lib/edit/keyboardInput.ts | 2 +- .../lib/index.ts | 5 +- .../lib/paste/ContentModelPastePlugin.ts | 1 - .../package.json | 6 +- .../ContentModelAutoFormatPluginTest.ts | 115 ++ .../autoFormat/keyboardListTriggerTest.ts | 460 ++++++++ .../utils/convertAlphaToDecimalsTest.ts | 24 + .../test/autoFormat/utils/getIndexTest.ts | 32 + .../autoFormat/utils/getListTypeStyleTest.ts | 987 ++++++++++++++++++ .../utils/getNumberingListStyleTest.ts | 114 ++ .../test/edit/deleteSteps/deleteListTest.ts | 4 +- .../test/edit/keyboardDeleteTest.ts | 4 + .../paste/e2e/cmPasteFromExcelOnlineTest.ts | 16 +- .../test/paste/e2e/cmPasteFromExcelTest.ts | 21 +- .../test/paste/e2e/cmPasteFromWacTest.ts | 15 +- .../test/paste/e2e/cmPasteFromWordTest.ts | 24 +- .../test/paste/e2e/cmPasteTest.ts | 11 +- .../getPasteSourceTest.ts | 16 +- .../processPastedContentFromExcelTest.ts | 7 +- .../paste/processPastedContentFromWacTest.ts | 11 +- ...processPastedContentFromWordDesktopTest.ts | 38 +- .../lib/context/TextMutationObserver.ts | 19 + .../lib/editor/IStandaloneEditor.ts | 61 +- .../lib/editor/StandaloneEditorCore.ts | 18 +- .../lib/editor/StandaloneEditorCorePlugins.ts | 6 + .../lib/editor/StandaloneEditorOptions.ts | 8 - .../lib/event/ZoomChangedEvent.ts | 5 - .../lib/format/formatParts/MarginFormat.ts | 10 - .../lib/index.ts | 3 + .../lib/parameter/ContentModelFormatState.ts | 5 - .../lib/parameter/DOMHelper.ts | 33 + .../lib/parameter/Snapshot.ts | 6 + .../ContentModelCachePluginState.ts | 6 + .../pluginState}/ContextMenuPluginState.ts | 2 +- .../lib/selection/DOMSelection.ts | 6 + ...eContentModelEditor.ts => createEditor.ts} | 31 +- .../roosterjs-content-model/lib/index.ts | 3 +- .../roosterjs-content-model/package.json | 1 - .../roosterjs-react/lib/common/index.ts | 1 + .../lib/common/type/RibbonPluginOptions.ts | 10 + .../lib/ribbon/plugin/createRibbonPlugin.ts | 46 +- .../lib/coreApi/focus.ts | 6 +- .../lib/corePlugins/DOMEventPlugin.ts | 32 +- .../roosterjs-editor-dom/lib/list/VList.ts | 4 +- .../lib/list/VListItem.ts | 9 +- .../test/list/VListTest.ts | 25 +- .../plugins/ImageEdit/imageEditors/Rotator.ts | 2 +- .../TableCellSelection/TableCellSelection.ts | 32 +- tools/buildTools/buildDemo.js | 2 +- tools/buildTools/pack.js | 23 +- tools/tsconfig.doc.json | 3 + versions.json | 19 +- 197 files changed, 6944 insertions(+), 2947 deletions(-) create mode 100644 packages-content-model/roosterjs-content-model-api/lib/modelApi/link/matchLink.ts create mode 100644 packages-content-model/roosterjs-content-model-api/test/modelApi/link/matchLinkTest.ts create mode 100644 packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContextMenuPlugin.ts create mode 100644 packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/textMutationObserver.ts create mode 100644 packages-content-model/roosterjs-content-model-core/lib/editor/DOMHelperImpl.ts create mode 100644 packages-content-model/roosterjs-content-model-core/lib/override/reducedModelChildProcessor.ts create mode 100644 packages-content-model/roosterjs-content-model-core/test/corePlugin/ContextMenuPluginTest.ts create mode 100644 packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/textMutationObserverTest.ts create mode 100644 packages-content-model/roosterjs-content-model-core/test/editor/DOMHelperImplTest.ts create mode 100644 packages-content-model/roosterjs-content-model-core/test/overrides/reducedModelChildProcessorTest.ts create mode 100644 packages-content-model/roosterjs-content-model-dom/lib/modelApi/creators/createEmptyModel.ts create mode 100644 packages-content-model/roosterjs-content-model-dom/test/modelApi/creators/createEmptyModelTest.ts create mode 100644 packages-content-model/roosterjs-content-model-dom/test/testUtils.ts delete mode 100644 packages-content-model/roosterjs-content-model-editor/lib/corePlugins/ContextMenuPlugin.ts rename packages-content-model/{roosterjs-content-model-plugins/lib/entityDelimiter => roosterjs-content-model-editor/lib/corePlugins}/EntityDelimiterPlugin.ts (97%) delete mode 100644 packages-content-model/roosterjs-content-model-editor/lib/corePlugins/NormalizeTablePlugin.ts delete mode 100644 packages-content-model/roosterjs-content-model-editor/lib/editor/isContentModelEditor.ts delete mode 100644 packages-content-model/roosterjs-content-model-editor/test/corePlugins/ContextMenuPluginTest.ts delete mode 100644 packages-content-model/roosterjs-content-model-editor/test/editor/isContentModelEditorTest.ts create mode 100644 packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts create mode 100644 packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts create mode 100644 packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/convertAlphaToDecimals.ts create mode 100644 packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getIndex.ts create mode 100644 packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getListTypeStyle.ts create mode 100644 packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getNumberingListStyle.ts create mode 100644 packages-content-model/roosterjs-content-model-plugins/test/autoFormat/ContentModelAutoFormatPluginTest.ts create mode 100644 packages-content-model/roosterjs-content-model-plugins/test/autoFormat/keyboardListTriggerTest.ts create mode 100644 packages-content-model/roosterjs-content-model-plugins/test/autoFormat/utils/convertAlphaToDecimalsTest.ts create mode 100644 packages-content-model/roosterjs-content-model-plugins/test/autoFormat/utils/getIndexTest.ts create mode 100644 packages-content-model/roosterjs-content-model-plugins/test/autoFormat/utils/getListTypeStyleTest.ts create mode 100644 packages-content-model/roosterjs-content-model-plugins/test/autoFormat/utils/getNumberingListStyleTest.ts create mode 100644 packages-content-model/roosterjs-content-model-types/lib/context/TextMutationObserver.ts create mode 100644 packages-content-model/roosterjs-content-model-types/lib/parameter/DOMHelper.ts rename packages-content-model/{roosterjs-content-model-editor/lib/publicTypes => roosterjs-content-model-types/lib/pluginState}/ContextMenuPluginState.ts (75%) rename packages-content-model/roosterjs-content-model/lib/{createContentModelEditor.ts => createEditor.ts} (57%) create mode 100644 packages-ui/roosterjs-react/lib/common/type/RibbonPluginOptions.ts diff --git a/.github/workflows/build-and-deploy.yml b/.github/workflows/build-and-deploy.yml index 1bdf7c1b622..7b80d8b6250 100644 --- a/.github/workflows/build-and-deploy.yml +++ b/.github/workflows/build-and-deploy.yml @@ -6,6 +6,8 @@ on: jobs: build-and-deploy: runs-on: ubuntu-latest + permissions: + contents: write steps: - name: Checkout uses: actions/checkout@v2.3.1 diff --git a/demo/scripts/controls/ContentModelEditorMainPane.tsx b/demo/scripts/controls/ContentModelEditorMainPane.tsx index 7ddadb797fc..00bbcdf204a 100644 --- a/demo/scripts/controls/ContentModelEditorMainPane.tsx +++ b/demo/scripts/controls/ContentModelEditorMainPane.tsx @@ -6,7 +6,7 @@ import ContentModelEventViewPlugin from './sidePane/eventViewer/ContentModelEven import ContentModelFormatPainterPlugin from './contentModel/plugins/ContentModelFormatPainterPlugin'; import ContentModelFormatStatePlugin from './sidePane/formatState/ContentModelFormatStatePlugin'; import ContentModelPanePlugin from './sidePane/contentModel/ContentModelPanePlugin'; -import ContentModelRibbon from './ribbonButtons/contentModel/ContentModelRibbon'; +import ContentModelRibbonButton from './ribbonButtons/contentModel/ContentModelRibbonButton'; import ContentModelRooster from './contentModel/editor/ContentModelRooster'; import ContentModelSnapshotPlugin from './sidePane/snapshot/ContentModelSnapshotPlugin'; import getToggleablePlugins from './getToggleablePlugins'; @@ -15,18 +15,86 @@ import RibbonPlugin from './ribbonButtons/contentModel/RibbonPlugin'; import SampleEntityPlugin from './sampleEntity/SampleEntityPlugin'; import SidePane from './sidePane/SidePane'; import TitleBar from './titleBar/TitleBar'; +import { alignCenterButton } from './ribbonButtons/contentModel/alignCenterButton'; +import { alignJustifyButton } from './ribbonButtons/contentModel/alignJustifyButton'; +import { alignLeftButton } from './ribbonButtons/contentModel/alignLeftButton'; +import { alignRightButton } from './ribbonButtons/contentModel/alignRightButton'; import { arrayPush } from 'roosterjs-editor-dom'; +import { backgroundColorButton } from './ribbonButtons/contentModel/backgroundColorButton'; +import { blockQuoteButton } from './ribbonButtons/contentModel/blockQuoteButton'; +import { boldButton } from './ribbonButtons/contentModel/boldButton'; +import { bulletedListButton } from './ribbonButtons/contentModel/bulletedListButton'; +import { changeImageButton } from './ribbonButtons/contentModel/changeImageButton'; +import { clearFormatButton } from './ribbonButtons/contentModel/clearFormatButton'; +import { codeButton } from './ribbonButtons/contentModel/codeButton'; +import { ContentModelRibbon } from './ribbonButtons/contentModel/ContentModelRibbon'; import { ContentModelRibbonPlugin } from './ribbonButtons/contentModel/ContentModelRibbonPlugin'; import { ContentModelSegmentFormat, Snapshots } from 'roosterjs-content-model-types'; import { createEmojiPlugin, createPasteOptionPlugin } from 'roosterjs-react'; +import { darkMode } from './ribbonButtons/contentModel/darkMode'; +import { decreaseFontSizeButton } from './ribbonButtons/contentModel/decreaseFontSizeButton'; +import { decreaseIndentButton } from './ribbonButtons/contentModel/decreaseIndentButton'; import { EditorPlugin } from 'roosterjs-editor-types'; +import { exportContent } from './ribbonButtons/contentModel/export'; +import { fontButton } from './ribbonButtons/contentModel/fontButton'; +import { fontSizeButton } from './ribbonButtons/contentModel/fontSizeButton'; +import { formatPainterButton } from './ribbonButtons/contentModel/formatPainterButton'; +import { formatTableButton } from './ribbonButtons/contentModel/formatTableButton'; import { getDarkColor } from 'roosterjs-color-utils'; +import { imageBorderColorButton } from './ribbonButtons/contentModel/imageBorderColorButton'; +import { imageBorderRemoveButton } from './ribbonButtons/contentModel/imageBorderRemoveButton'; +import { imageBorderStyleButton } from './ribbonButtons/contentModel/imageBorderStyleButton'; +import { imageBorderWidthButton } from './ribbonButtons/contentModel/imageBorderWidthButton'; +import { imageBoxShadowButton } from './ribbonButtons/contentModel/imageBoxShadowButton'; +import { increaseFontSizeButton } from './ribbonButtons/contentModel/increaseFontSizeButton'; +import { increaseIndentButton } from './ribbonButtons/contentModel/increaseIndentButton'; +import { insertImageButton } from './ribbonButtons/contentModel/insertImageButton'; +import { insertLinkButton } from './ribbonButtons/contentModel/insertLinkButton'; +import { insertTableButton } from './ribbonButtons/contentModel/insertTableButton'; +import { italicButton } from './ribbonButtons/contentModel/italicButton'; +import { listStartNumberButton } from './ribbonButtons/contentModel/listStartNumberButton'; +import { ltrButton } from './ribbonButtons/contentModel/ltrButton'; +import { numberedListButton } from './ribbonButtons/contentModel/numberedListButton'; import { PartialTheme } from '@fluentui/react/lib/Theme'; +import { pasteButton } from './ribbonButtons/contentModel/pasteButton'; +import { popout } from './ribbonButtons/contentModel/popout'; +import { redoButton } from './ribbonButtons/contentModel/redoButton'; +import { removeLinkButton } from './ribbonButtons/contentModel/removeLinkButton'; +import { rtlButton } from './ribbonButtons/contentModel/rtlButton'; +import { setBulletedListStyleButton } from './ribbonButtons/contentModel/setBulletedListStyleButton'; +import { setHeadingLevelButton } from './ribbonButtons/contentModel/setHeadingLevelButton'; +import { setNumberedListStyleButton } from './ribbonButtons/contentModel/setNumberedListStyleButton'; +import { setTableCellShadeButton } from './ribbonButtons/contentModel/setTableCellShadeButton'; +import { setTableHeaderButton } from './ribbonButtons/contentModel/setTableHeaderButton'; +import { spacingButton } from './ribbonButtons/contentModel/spacingButton'; +import { strikethroughButton } from './ribbonButtons/contentModel/strikethroughButton'; +import { subscriptButton } from './ribbonButtons/contentModel/subscriptButton'; +import { superscriptButton } from './ribbonButtons/contentModel/superscriptButton'; +import { tableBorderApplyButton } from './ribbonButtons/contentModel/tableBorderApplyButton'; +import { tableBorderColorButton } from './ribbonButtons/contentModel/tableBorderColorButton'; +import { tableBorderStyleButton } from './ribbonButtons/contentModel/tableBorderStyleButton'; +import { tableBorderWidthButton } from './ribbonButtons/contentModel/tableBorderWidthButton'; +import { textColorButton } from './ribbonButtons/contentModel/textColorButton'; import { trustedHTMLHandler } from '../utils/trustedHTMLHandler'; +import { underlineButton } from './ribbonButtons/contentModel/underlineButton'; +import { undoButton } from './ribbonButtons/contentModel/undoButton'; +import { zoom } from './ribbonButtons/contentModel/zoom'; import { + spaceAfterButton, + spaceBeforeButton, +} from './ribbonButtons/contentModel/spaceBeforeAfterButtons'; +import { + tableAlignCellButton, + tableAlignTableButton, + tableDeleteButton, + tableInsertButton, + tableMergeButton, + tableSplitButton, +} from './ribbonButtons/contentModel/tableEditButtons'; +import { + ContentModelAutoFormatPlugin, ContentModelEditPlugin, ContentModelPastePlugin, - EntityDelimiterPlugin, } from 'roosterjs-content-model-plugins'; import { ContentModelEditor, @@ -101,16 +169,80 @@ class ContentModelEditorMainPane extends MainPaneBase private apiPlaygroundPlugin: ApiPlaygroundPlugin; private contentModelPanePlugin: ContentModelPanePlugin; private contentModelEditPlugin: ContentModelEditPlugin; + private contentModelAutoFormatPlugin: ContentModelAutoFormatPlugin; private contentModelRibbonPlugin: RibbonPlugin; private pasteOptionPlugin: EditorPlugin; private emojiPlugin: EditorPlugin; private snapshotPlugin: ContentModelSnapshotPlugin; - private entityDelimiterPlugin: EntityDelimiterPlugin; private toggleablePlugins: EditorPlugin[] | null = null; private formatPainterPlugin: ContentModelFormatPainterPlugin; private pastePlugin: ContentModelPastePlugin; private sampleEntityPlugin: SampleEntityPlugin; private snapshots: Snapshots; + private buttons: ContentModelRibbonButton[] = [ + formatPainterButton, + boldButton, + italicButton, + underlineButton, + fontButton, + fontSizeButton, + increaseFontSizeButton, + decreaseFontSizeButton, + textColorButton, + backgroundColorButton, + bulletedListButton, + numberedListButton, + decreaseIndentButton, + increaseIndentButton, + blockQuoteButton, + alignLeftButton, + alignCenterButton, + alignRightButton, + alignJustifyButton, + insertLinkButton, + removeLinkButton, + insertTableButton, + insertImageButton, + superscriptButton, + subscriptButton, + strikethroughButton, + setHeadingLevelButton, + codeButton, + ltrButton, + rtlButton, + undoButton, + redoButton, + clearFormatButton, + setBulletedListStyleButton, + setNumberedListStyleButton, + listStartNumberButton, + formatTableButton, + setTableCellShadeButton, + setTableHeaderButton, + tableInsertButton, + tableDeleteButton, + tableMergeButton, + tableSplitButton, + tableAlignCellButton, + tableAlignTableButton, + tableBorderApplyButton, + tableBorderColorButton, + tableBorderWidthButton, + tableBorderStyleButton, + imageBorderColorButton, + imageBorderWidthButton, + imageBorderStyleButton, + imageBorderRemoveButton, + changeImageButton, + imageBoxShadowButton, + spacingButton, + spaceBeforeButton, + spaceAfterButton, + pasteButton, + darkMode, + zoom, + exportContent, + ]; constructor(props: {}) { super(props); @@ -130,10 +262,10 @@ class ContentModelEditorMainPane extends MainPaneBase this.snapshotPlugin = new ContentModelSnapshotPlugin(this.snapshots); this.contentModelPanePlugin = new ContentModelPanePlugin(); this.contentModelEditPlugin = new ContentModelEditPlugin(); + this.contentModelAutoFormatPlugin = new ContentModelAutoFormatPlugin(); this.contentModelRibbonPlugin = new ContentModelRibbonPlugin(); this.pasteOptionPlugin = createPasteOptionPlugin(); this.emojiPlugin = createEmojiPlugin(); - this.entityDelimiterPlugin = new EntityDelimiterPlugin(); this.formatPainterPlugin = new ContentModelFormatPainterPlugin(); this.pastePlugin = new ContentModelPastePlugin(); this.sampleEntityPlugin = new SampleEntityPlugin(); @@ -162,11 +294,13 @@ class ContentModelEditorMainPane extends MainPaneBase } renderRibbon(isPopout: boolean) { + const buttons = isPopout ? this.buttons : this.buttons.concat([popout]); + return ( ); } @@ -192,10 +326,8 @@ class ContentModelEditorMainPane extends MainPaneBase const plugins = [ ...this.toggleablePlugins, - this.contentModelPanePlugin.getInnerRibbonPlugin(), this.pasteOptionPlugin, this.emojiPlugin, - this.entityDelimiterPlugin, this.sampleEntityPlugin, ]; @@ -254,7 +386,9 @@ class ContentModelEditorMainPane extends MainPaneBase this.contentModelRibbonPlugin, this.formatPainterPlugin, this.pastePlugin, + this.contentModelAutoFormatPlugin, this.contentModelEditPlugin, + this.contentModelPanePlugin.getInnerRibbonPlugin(), ]} defaultSegmentFormat={defaultFormat} inDarkMode={this.state.isDarkMode} @@ -262,7 +396,6 @@ class ContentModelEditorMainPane extends MainPaneBase experimentalFeatures={this.state.initState.experimentalFeatures} snapshots={this.snapshotPlugin.getSnapshots()} trustedHTMLHandler={trustedHTMLHandler} - zoomScale={this.state.scale} initialContent={this.content} editorCreator={this.state.editorCreator} dir={this.state.isRtl ? 'rtl' : 'ltr'} diff --git a/demo/scripts/controls/StandaloneEditorMainPane.tsx b/demo/scripts/controls/StandaloneEditorMainPane.tsx index 687308db6cb..8cae61e11d4 100644 --- a/demo/scripts/controls/StandaloneEditorMainPane.tsx +++ b/demo/scripts/controls/StandaloneEditorMainPane.tsx @@ -6,26 +6,97 @@ import ContentModelEventViewPlugin from './sidePane/eventViewer/ContentModelEven import ContentModelFormatPainterPlugin from './contentModel/plugins/ContentModelFormatPainterPlugin'; import ContentModelFormatStatePlugin from './sidePane/formatState/ContentModelFormatStatePlugin'; import ContentModelPanePlugin from './sidePane/contentModel/ContentModelPanePlugin'; -import ContentModelRibbon from './ribbonButtons/contentModel/ContentModelRibbon'; +import ContentModelRibbonButton from './ribbonButtons/contentModel/ContentModelRibbonButton'; import ContentModelRooster from './contentModel/editor/ContentModelRooster'; import ContentModelSnapshotPlugin from './sidePane/snapshot/ContentModelSnapshotPlugin'; import MainPaneBase, { MainPaneBaseState } from './MainPaneBase'; import RibbonPlugin from './ribbonButtons/contentModel/RibbonPlugin'; import SidePane from './sidePane/SidePane'; import TitleBar from './titleBar/TitleBar'; -import { ContentModelEditPlugin } from 'roosterjs-content-model-plugins'; +import { alignCenterButton } from './ribbonButtons/contentModel/alignCenterButton'; +import { alignJustifyButton } from './ribbonButtons/contentModel/alignJustifyButton'; +import { alignLeftButton } from './ribbonButtons/contentModel/alignLeftButton'; +import { alignRightButton } from './ribbonButtons/contentModel/alignRightButton'; +import { backgroundColorButton } from './ribbonButtons/contentModel/backgroundColorButton'; +import { blockQuoteButton } from './ribbonButtons/contentModel/blockQuoteButton'; +import { boldButton } from './ribbonButtons/contentModel/boldButton'; +import { bulletedListButton } from './ribbonButtons/contentModel/bulletedListButton'; +import { changeImageButton } from './ribbonButtons/contentModel/changeImageButton'; +import { clearFormatButton } from './ribbonButtons/contentModel/clearFormatButton'; +import { codeButton } from './ribbonButtons/contentModel/codeButton'; +import { ContentModelRibbon } from './ribbonButtons/contentModel/ContentModelRibbon'; import { ContentModelRibbonPlugin } from './ribbonButtons/contentModel/ContentModelRibbonPlugin'; +import { darkMode } from './ribbonButtons/contentModel/darkMode'; +import { decreaseFontSizeButton } from './ribbonButtons/contentModel/decreaseFontSizeButton'; +import { decreaseIndentButton } from './ribbonButtons/contentModel/decreaseIndentButton'; +import { exportContent } from './ribbonButtons/contentModel/export'; +import { fontButton } from './ribbonButtons/contentModel/fontButton'; +import { fontSizeButton } from './ribbonButtons/contentModel/fontSizeButton'; +import { formatPainterButton } from './ribbonButtons/contentModel/formatPainterButton'; +import { formatTableButton } from './ribbonButtons/contentModel/formatTableButton'; import { getDarkColor } from 'roosterjs-color-utils'; +import { imageBorderColorButton } from './ribbonButtons/contentModel/imageBorderColorButton'; +import { imageBorderRemoveButton } from './ribbonButtons/contentModel/imageBorderRemoveButton'; +import { imageBorderStyleButton } from './ribbonButtons/contentModel/imageBorderStyleButton'; +import { imageBorderWidthButton } from './ribbonButtons/contentModel/imageBorderWidthButton'; +import { imageBoxShadowButton } from './ribbonButtons/contentModel/imageBoxShadowButton'; +import { increaseFontSizeButton } from './ribbonButtons/contentModel/increaseFontSizeButton'; +import { increaseIndentButton } from './ribbonButtons/contentModel/increaseIndentButton'; +import { insertImageButton } from './ribbonButtons/contentModel/insertImageButton'; +import { insertLinkButton } from './ribbonButtons/contentModel/insertLinkButton'; +import { insertTableButton } from './ribbonButtons/contentModel/insertTableButton'; +import { italicButton } from './ribbonButtons/contentModel/italicButton'; +import { listStartNumberButton } from './ribbonButtons/contentModel/listStartNumberButton'; +import { ltrButton } from './ribbonButtons/contentModel/ltrButton'; +import { numberedListButton } from './ribbonButtons/contentModel/numberedListButton'; import { PartialTheme } from '@fluentui/react/lib/Theme'; +import { pasteButton } from './ribbonButtons/contentModel/pasteButton'; +import { popout } from './ribbonButtons/contentModel/popout'; +import { redoButton } from './ribbonButtons/contentModel/redoButton'; +import { removeLinkButton } from './ribbonButtons/contentModel/removeLinkButton'; +import { rtlButton } from './ribbonButtons/contentModel/rtlButton'; +import { setBulletedListStyleButton } from './ribbonButtons/contentModel/setBulletedListStyleButton'; +import { setHeadingLevelButton } from './ribbonButtons/contentModel/setHeadingLevelButton'; +import { setNumberedListStyleButton } from './ribbonButtons/contentModel/setNumberedListStyleButton'; +import { setTableCellShadeButton } from './ribbonButtons/contentModel/setTableCellShadeButton'; +import { setTableHeaderButton } from './ribbonButtons/contentModel/setTableHeaderButton'; import { Snapshots } from 'roosterjs-editor-types'; +import { spacingButton } from './ribbonButtons/contentModel/spacingButton'; import { StandaloneEditor } from 'roosterjs-content-model-core'; +import { strikethroughButton } from './ribbonButtons/contentModel/strikethroughButton'; +import { subscriptButton } from './ribbonButtons/contentModel/subscriptButton'; +import { superscriptButton } from './ribbonButtons/contentModel/superscriptButton'; +import { tableBorderApplyButton } from './ribbonButtons/contentModel/tableBorderApplyButton'; +import { tableBorderColorButton } from './ribbonButtons/contentModel/tableBorderColorButton'; +import { tableBorderStyleButton } from './ribbonButtons/contentModel/tableBorderStyleButton'; +import { tableBorderWidthButton } from './ribbonButtons/contentModel/tableBorderWidthButton'; +import { textColorButton } from './ribbonButtons/contentModel/textColorButton'; import { trustedHTMLHandler } from '../utils/trustedHTMLHandler'; +import { underlineButton } from './ribbonButtons/contentModel/underlineButton'; +import { undoButton } from './ribbonButtons/contentModel/undoButton'; +import { zoom } from './ribbonButtons/contentModel/zoom'; import { ContentModelSegmentFormat, IStandaloneEditor, Snapshot, StandaloneEditorOptions, } from 'roosterjs-content-model-types'; +import { + spaceAfterButton, + spaceBeforeButton, +} from './ribbonButtons/contentModel/spaceBeforeAfterButtons'; +import { + tableAlignCellButton, + tableAlignTableButton, + tableDeleteButton, + tableInsertButton, + tableMergeButton, + tableSplitButton, +} from './ribbonButtons/contentModel/tableEditButtons'; +import { + ContentModelAutoFormatPlugin, + ContentModelEditPlugin, +} from 'roosterjs-content-model-plugins'; const styles = require('./StandaloneEditorMainPane.scss'); @@ -95,9 +166,74 @@ class ContentModelEditorMainPane extends MainPaneBase private contentModelPanePlugin: ContentModelPanePlugin; private contentModelEditPlugin: ContentModelEditPlugin; private contentModelRibbonPlugin: RibbonPlugin; + private contentAutoFormatPlugin: ContentModelAutoFormatPlugin; private snapshotPlugin: ContentModelSnapshotPlugin; private formatPainterPlugin: ContentModelFormatPainterPlugin; private snapshots: Snapshots; + private buttons: ContentModelRibbonButton[] = [ + formatPainterButton, + boldButton, + italicButton, + underlineButton, + fontButton, + fontSizeButton, + increaseFontSizeButton, + decreaseFontSizeButton, + textColorButton, + backgroundColorButton, + bulletedListButton, + numberedListButton, + decreaseIndentButton, + increaseIndentButton, + blockQuoteButton, + alignLeftButton, + alignCenterButton, + alignRightButton, + alignJustifyButton, + insertLinkButton, + removeLinkButton, + insertTableButton, + insertImageButton, + superscriptButton, + subscriptButton, + strikethroughButton, + setHeadingLevelButton, + codeButton, + ltrButton, + rtlButton, + undoButton, + redoButton, + clearFormatButton, + setBulletedListStyleButton, + setNumberedListStyleButton, + listStartNumberButton, + formatTableButton, + setTableCellShadeButton, + setTableHeaderButton, + tableInsertButton, + tableDeleteButton, + tableMergeButton, + tableSplitButton, + tableAlignCellButton, + tableAlignTableButton, + tableBorderApplyButton, + tableBorderColorButton, + tableBorderWidthButton, + tableBorderStyleButton, + imageBorderColorButton, + imageBorderWidthButton, + imageBorderStyleButton, + imageBorderRemoveButton, + changeImageButton, + imageBoxShadowButton, + spacingButton, + spaceBeforeButton, + spaceAfterButton, + pasteButton, + darkMode, + zoom, + exportContent, + ]; constructor(props: {}) { super(props); @@ -117,6 +253,7 @@ class ContentModelEditorMainPane extends MainPaneBase this.snapshotPlugin = new ContentModelSnapshotPlugin(this.snapshots); this.contentModelPanePlugin = new ContentModelPanePlugin(); this.contentModelEditPlugin = new ContentModelEditPlugin(); + this.contentAutoFormatPlugin = new ContentModelAutoFormatPlugin(); this.contentModelRibbonPlugin = new ContentModelRibbonPlugin(); this.formatPainterPlugin = new ContentModelFormatPainterPlugin(); this.state = { @@ -144,11 +281,12 @@ class ContentModelEditorMainPane extends MainPaneBase } renderRibbon(isPopout: boolean) { + const buttons = isPopout ? this.buttons : this.buttons.concat([popout]); return ( ); } @@ -211,6 +349,7 @@ class ContentModelEditorMainPane extends MainPaneBase this.contentModelRibbonPlugin, this.formatPainterPlugin, this.contentModelEditPlugin, + this.contentAutoFormatPlugin, ]} defaultSegmentFormat={defaultFormat} inDarkMode={this.state.isDarkMode} @@ -218,7 +357,6 @@ class ContentModelEditorMainPane extends MainPaneBase experimentalFeatures={this.state.initState.experimentalFeatures} snapshots={this.snapshotPlugin.getSnapshots()} trustedHTMLHandler={trustedHTMLHandler} - zoomScale={this.state.scale} initialContent={this.content} editorCreator={this.state.editorCreator} dir={this.state.isRtl ? 'rtl' : 'ltr'} diff --git a/demo/scripts/controls/contentModel/editor/ContentModelRooster.tsx b/demo/scripts/controls/contentModel/editor/ContentModelRooster.tsx index d31481a5692..717ec81bcfa 100644 --- a/demo/scripts/controls/contentModel/editor/ContentModelRooster.tsx +++ b/demo/scripts/controls/contentModel/editor/ContentModelRooster.tsx @@ -39,7 +39,7 @@ export default function ContentModelRooster(props: ContentModelRoosterProps) { const editor = React.useRef(null); const theme = useTheme(); - const { focusOnInit, editorCreator, zoomScale, inDarkMode, plugins, legacyPlugins } = props; + const { focusOnInit, editorCreator, inDarkMode, plugins, legacyPlugins } = props; React.useEffect(() => { if (editorDiv.current) { @@ -71,12 +71,6 @@ export default function ContentModelRooster(props: ContentModelRoosterProps) { editor.current?.setDarkModeState(!!inDarkMode); }, [inDarkMode]); - React.useEffect(() => { - if (zoomScale) { - editor.current?.setZoomScale(zoomScale); - } - }, [zoomScale]); - const divProps = getNativeProps>(props, divProperties); return
; } diff --git a/demo/scripts/controls/ribbonButtons/contentModel/ContentModelRibbon.tsx b/demo/scripts/controls/ribbonButtons/contentModel/ContentModelRibbon.tsx index f083a761cc5..c061beb4930 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/ContentModelRibbon.tsx +++ b/demo/scripts/controls/ribbonButtons/contentModel/ContentModelRibbon.tsx @@ -1,141 +1,15 @@ import * as React from 'react'; import ContentModelRibbonButton from './ContentModelRibbonButton'; import RibbonPlugin from './RibbonPlugin'; -import { alignCenterButton } from './alignCenterButton'; -import { alignJustifyButton } from './alignJustifyButton'; -import { alignLeftButton } from './alignLeftButton'; -import { alignRightButton } from './alignRightButton'; -import { backgroundColorButton } from './backgroundColorButton'; -import { blockQuoteButton } from './blockQuoteButton'; -import { boldButton } from './boldButton'; -import { bulletedListButton } from './bulletedListButton'; -import { changeImageButton } from './changeImageButton'; -import { clearFormatButton } from './clearFormatButton'; -import { codeButton } from './codeButton'; import { CommandBar, ICommandBarItemProps, ICommandBarProps } from '@fluentui/react/lib/CommandBar'; -import { darkMode } from './darkMode'; -import { decreaseFontSizeButton } from './decreaseFontSizeButton'; -import { decreaseIndentButton } from './decreaseIndentButton'; -import { exportContent } from './export'; import { FocusZoneDirection } from '@fluentui/react/lib/FocusZone'; -import { fontButton } from './fontButton'; -import { fontSizeButton } from './fontSizeButton'; -import { formatPainterButton } from './formatPainterButton'; import { FormatState } from 'roosterjs-editor-types'; -import { formatTableButton } from './formatTableButton'; import { getLocalizedString, LocalizedStrings } from 'roosterjs-react'; import { getObjectKeys } from 'roosterjs-content-model-dom'; import { IContextualMenuItem, IContextualMenuItemProps } from '@fluentui/react/lib/ContextualMenu'; -import { imageBorderColorButton } from './imageBorderColorButton'; -import { imageBorderRemoveButton } from './imageBorderRemoveButton'; -import { imageBorderStyleButton } from './imageBorderStyleButton'; -import { imageBorderWidthButton } from './imageBorderWidthButton'; -import { imageBoxShadowButton } from './imageBoxShadowButton'; -import { increaseFontSizeButton } from './increaseFontSizeButton'; -import { increaseIndentButton } from './increaseIndentButton'; -import { insertImageButton } from './insertImageButton'; -import { insertLinkButton } from './insertLinkButton'; -import { insertTableButton } from './insertTableButton'; import { IRenderFunction } from '@fluentui/react/lib/Utilities'; -import { italicButton } from './italicButton'; -import { listStartNumberButton } from './listStartNumberButton'; -import { ltrButton } from './ltrButton'; import { mergeStyles } from '@fluentui/react/lib/Styling'; import { moreCommands } from './moreCommands'; -import { numberedListButton } from './numberedListButton'; -import { pasteButton } from './pasteButton'; -import { popout } from './popout'; -import { redoButton } from './redoButton'; -import { removeLinkButton } from './removeLinkButton'; -import { rtlButton } from './rtlButton'; -import { setBulletedListStyleButton } from './setBulletedListStyleButton'; -import { setHeadingLevelButton } from './setHeadingLevelButton'; -import { setNumberedListStyleButton } from './setNumberedListStyleButton'; -import { setTableCellShadeButton } from './setTableCellShadeButton'; -import { setTableHeaderButton } from './setTableHeaderButton'; -import { spaceAfterButton, spaceBeforeButton } from './spaceBeforeAfterButtons'; -import { spacingButton } from './spacingButton'; -import { strikethroughButton } from './strikethroughButton'; -import { subscriptButton } from './subscriptButton'; -import { superscriptButton } from './superscriptButton'; -import { tableBorderApplyButton } from './tableBorderApplyButton'; -import { tableBorderColorButton } from './tableBorderColorButton'; -import { tableBorderStyleButton } from './tableBorderStyleButton'; -import { tableBorderWidthButton } from './tableBorderWidthButton'; -import { textColorButton } from './textColorButton'; -import { underlineButton } from './underlineButton'; -import { undoButton } from './undoButton'; -import { zoom } from './zoom'; -import { - tableAlignCellButton, - tableAlignTableButton, - tableDeleteButton, - tableInsertButton, - tableMergeButton, - tableSplitButton, -} from './tableEditButtons'; - -const buttons: ContentModelRibbonButton[] = [ - formatPainterButton, - boldButton, - italicButton, - underlineButton, - fontButton, - fontSizeButton, - increaseFontSizeButton, - decreaseFontSizeButton, - textColorButton, - backgroundColorButton, - bulletedListButton, - numberedListButton, - decreaseIndentButton, - increaseIndentButton, - blockQuoteButton, - alignLeftButton, - alignCenterButton, - alignRightButton, - alignJustifyButton, - insertLinkButton, - removeLinkButton, - insertTableButton, - insertImageButton, - superscriptButton, - subscriptButton, - strikethroughButton, - setHeadingLevelButton, - codeButton, - ltrButton, - rtlButton, - undoButton, - redoButton, - clearFormatButton, - setBulletedListStyleButton, - setNumberedListStyleButton, - listStartNumberButton, - formatTableButton, - setTableCellShadeButton, - setTableHeaderButton, - tableInsertButton, - tableDeleteButton, - tableMergeButton, - tableSplitButton, - tableAlignCellButton, - tableAlignTableButton, - tableBorderApplyButton, - tableBorderColorButton, - tableBorderWidthButton, - tableBorderStyleButton, - imageBorderColorButton, - imageBorderWidthButton, - imageBorderStyleButton, - imageBorderRemoveButton, - changeImageButton, - imageBoxShadowButton, - spacingButton, - spaceBeforeButton, - spaceAfterButton, - pasteButton, -]; const ribbonClassName = mergeStyles({ '& .ms-CommandBar': { @@ -170,7 +44,7 @@ interface RibbonProps extends Partial { * @param props Properties of format ribbon component * @returns The format ribbon component */ -function Ribbon(props: RibbonProps) { +export function ContentModelRibbon(props: RibbonProps) { const { plugin, buttons, strings, dir } = props; const [formatState, setFormatState] = React.useState(null); const isRtl = dir == 'rtl'; @@ -302,22 +176,3 @@ function Ribbon(props: RibbonProps) { /> ); } - -export default function ContentModelRibbon(props: { - ribbonPlugin: RibbonPlugin; - isRtl: boolean; - isInPopout: boolean; -}) { - const { ribbonPlugin, isRtl, isInPopout } = props; - const ribbonButtons = React.useMemo(() => { - const result: ContentModelRibbonButton[] = [...buttons, darkMode, zoom, exportContent]; - - if (!isInPopout) { - result.push(popout); - } - - return result; - }, [isInPopout]); - - return ; -} diff --git a/demo/scripts/controls/ribbonButtons/contentModel/zoom.ts b/demo/scripts/controls/ribbonButtons/contentModel/zoom.ts index a4ee19075a7..b102d9c1c31 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/zoom.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/zoom.ts @@ -39,11 +39,13 @@ export const zoom: ContentModelRibbonButton = { }, onClick: (editor, key) => { const zoomScale = DropDownValues[key as keyof typeof DropDownItems]; - editor.setZoomScale(zoomScale); editor.focus(); // Let main pane know this state change so that it can be persisted when pop out/pop in MainPaneBase.getInstance().setScale(zoomScale); + + editor.triggerEvent('zoomChanged', { newZoomScale: zoomScale }); + return true; }, }; diff --git a/demo/scripts/controls/sidePane/contentModel/ContentModelPane.tsx b/demo/scripts/controls/sidePane/contentModel/ContentModelPane.tsx index 129a44b6492..8f2b2e8db99 100644 --- a/demo/scripts/controls/sidePane/contentModel/ContentModelPane.tsx +++ b/demo/scripts/controls/sidePane/contentModel/ContentModelPane.tsx @@ -1,9 +1,11 @@ import * as React from 'react'; +import ContentModelRibbonButton from '../../ribbonButtons/contentModel/ContentModelRibbonButton'; import { ContentModelDocument } from 'roosterjs-content-model-types'; import { ContentModelDocumentView } from '../../contentModel/components/model/ContentModelDocumentView'; +import { ContentModelRibbon } from '../../ribbonButtons/contentModel/ContentModelRibbon'; +import { ContentModelRibbonPlugin } from '../../ribbonButtons/contentModel/ContentModelRibbonPlugin'; import { exportButton } from './buttons/exportButton'; import { refreshButton } from './buttons/refreshButton'; -import { Ribbon, RibbonButton, RibbonPlugin } from 'roosterjs-react'; import { SidePaneElementProps } from '../SidePaneElement'; const styles = require('./ContentModelPane.scss'); @@ -13,14 +15,14 @@ export interface ContentModelPaneState { } export interface ContentModelPaneProps extends ContentModelPaneState, SidePaneElementProps { - ribbonPlugin: RibbonPlugin; + ribbonPlugin: ContentModelRibbonPlugin; } export default class ContentModelPane extends React.Component< ContentModelPaneProps, ContentModelPaneState > { - private contentModelButtons: RibbonButton[]; + private contentModelButtons: ContentModelRibbonButton[]; constructor(props: ContentModelPaneProps) { super(props); @@ -41,7 +43,10 @@ export default class ContentModelPane extends React.Component< render() { return ( <> - +
{this.state.model ? : null}
diff --git a/demo/scripts/controls/sidePane/contentModel/ContentModelPanePlugin.ts b/demo/scripts/controls/sidePane/contentModel/ContentModelPanePlugin.ts index 405d0372e54..7fb8c1ad82b 100644 --- a/demo/scripts/controls/sidePane/contentModel/ContentModelPanePlugin.ts +++ b/demo/scripts/controls/sidePane/contentModel/ContentModelPanePlugin.ts @@ -1,8 +1,8 @@ import ContentModelPane, { ContentModelPaneProps } from './ContentModelPane'; import SidePanePluginImpl from '../SidePanePluginImpl'; -import { createRibbonPlugin, RibbonPlugin } from 'roosterjs-react'; +import { ContentModelRibbonPlugin } from '../../ribbonButtons/contentModel/ContentModelRibbonPlugin'; +import { IContentModelEditor } from 'roosterjs-content-model-editor'; import { IEditor, PluginEvent, PluginEventType } from 'roosterjs-editor-types'; -import { isContentModelEditor } from 'roosterjs-content-model-editor'; import { setCurrentContentModel } from './currentModel'; import { SidePaneElementProps } from '../SidePaneElement'; @@ -10,17 +10,17 @@ export default class ContentModelPanePlugin extends SidePanePluginImpl< ContentModelPane, ContentModelPaneProps > { - private contentModelRibbon: RibbonPlugin; + private contentModelRibbon: ContentModelRibbonPlugin; constructor() { super(ContentModelPane, 'contentModel', 'Content Model (Under development)'); - this.contentModelRibbon = createRibbonPlugin(); + this.contentModelRibbon = new ContentModelRibbonPlugin(); } initialize(editor: IEditor): void { super.initialize(editor); - this.contentModelRibbon.initialize(editor); + this.contentModelRibbon.initialize(editor as IContentModelEditor); // Temporarily use IContentModelEditor here. TODO: Port side pane to use IStandaloneEditor editor.getDocument().addEventListener('selectionchange', this.onModelChangeFromSelection); } @@ -36,11 +36,10 @@ export default class ContentModelPanePlugin extends SidePanePluginImpl< onPluginEvent(e: PluginEvent) { if (e.eventType == PluginEventType.ContentChanged && e.source == 'RefreshModel') { this.getComponent(component => { - const model = isContentModelEditor(this.editor) - ? this.editor.createContentModel() - : null; + // TODO: Port to use IStandaloneEditor and remove type cast here + const model = (this.editor as IContentModelEditor).getContentModelCopy('connected'); component.setContentModel(model); - setCurrentContentModel(this.editor, model); + setCurrentContentModel(model); }); } else if ( e.eventType == PluginEventType.Input || @@ -49,7 +48,7 @@ export default class ContentModelPanePlugin extends SidePanePluginImpl< this.onModelChange(); } - this.contentModelRibbon.onPluginEvent(e); + // this.contentModelRibbon.onPluginEvent(e); } getInnerRibbonPlugin() { @@ -72,11 +71,10 @@ export default class ContentModelPanePlugin extends SidePanePluginImpl< private onModelChange = () => { this.getComponent(component => { - const model = isContentModelEditor(this.editor) - ? this.editor.createContentModel() - : null; + // TODO: Port to use IStandaloneEditor and remove type cast here + const model = (this.editor as IContentModelEditor).getContentModelCopy('connected'); component.setContentModel(model); - setCurrentContentModel(this.editor, model); + setCurrentContentModel(model); }); }; } diff --git a/demo/scripts/controls/sidePane/contentModel/buttons/exportButton.ts b/demo/scripts/controls/sidePane/contentModel/buttons/exportButton.ts index 8642941f4af..8d1b43fd834 100644 --- a/demo/scripts/controls/sidePane/contentModel/buttons/exportButton.ts +++ b/demo/scripts/controls/sidePane/contentModel/buttons/exportButton.ts @@ -1,20 +1,19 @@ -import { ChangeSource } from 'roosterjs-editor-types'; +import ContentModelRibbonButton from '../../../ribbonButtons/contentModel/ContentModelRibbonButton'; import { getCurrentContentModel } from '../currentModel'; -import { isContentModelEditor } from 'roosterjs-content-model-editor'; -import { RibbonButton } from 'roosterjs-react'; -export const exportButton: RibbonButton<'buttonNameExport'> = { +export const exportButton: ContentModelRibbonButton<'buttonNameExport'> = { key: 'buttonNameExport', unlocalizedText: 'Create DOM tree', iconName: 'DOM', onClick: editor => { - const model = getCurrentContentModel(editor); + const model = getCurrentContentModel(); - if (model && isContentModelEditor(editor)) { - editor.addUndoSnapshot(() => { - editor.focus(); - editor.setContentModel(model); - }, ChangeSource.SetContent); + if (model) { + editor.formatContentModel(currentModel => { + currentModel.blocks = model.blocks; + + return true; + }); } }, }; diff --git a/demo/scripts/controls/sidePane/contentModel/buttons/refreshButton.ts b/demo/scripts/controls/sidePane/contentModel/buttons/refreshButton.ts index 9e9cc0ecc7e..d427036034f 100644 --- a/demo/scripts/controls/sidePane/contentModel/buttons/refreshButton.ts +++ b/demo/scripts/controls/sidePane/contentModel/buttons/refreshButton.ts @@ -1,10 +1,12 @@ -import { RibbonButton } from 'roosterjs-react'; +import ContentModelRibbonButton from '../../../ribbonButtons/contentModel/ContentModelRibbonButton'; -export const refreshButton: RibbonButton<'buttonNameRefresh'> = { +export const refreshButton: ContentModelRibbonButton<'buttonNameRefresh'> = { key: 'buttonNameRefresh', unlocalizedText: 'Refresh', iconName: 'Refresh', onClick: editor => { - editor.triggerContentChangedEvent('RefreshModel'); + editor.triggerEvent('contentChanged', { + source: 'RefreshModel', + }); }, }; diff --git a/demo/scripts/controls/sidePane/contentModel/currentModel.ts b/demo/scripts/controls/sidePane/contentModel/currentModel.ts index 3e942718feb..1431f568510 100644 --- a/demo/scripts/controls/sidePane/contentModel/currentModel.ts +++ b/demo/scripts/controls/sidePane/contentModel/currentModel.ts @@ -1,23 +1,11 @@ import { ContentModelDocument } from 'roosterjs-content-model-types'; -import { IEditor } from 'roosterjs-editor-types'; -const CurrentContentModelHolderKey = '_CurrentContentModelHolder'; +let currentModel: ContentModelDocument | null = null; -interface CurrentContentModelHolder { - model: ContentModelDocument | null; +export function getCurrentContentModel(): ContentModelDocument | null { + return currentModel; } -function getCurrentModelHolder(editor: IEditor) { - return editor.getCustomData( - CurrentContentModelHolderKey, - () => { model: null } - ); -} - -export function getCurrentContentModel(editor: IEditor): ContentModelDocument | null { - return getCurrentModelHolder(editor).model; -} - -export function setCurrentContentModel(editor: IEditor, model: ContentModelDocument | null) { - getCurrentModelHolder(editor).model = model; +export function setCurrentContentModel(model: ContentModelDocument | null) { + currentModel = model; } diff --git a/packages-content-model/roosterjs-content-model-api/lib/index.ts b/packages-content-model/roosterjs-content-model-api/lib/index.ts index 8bef29554bd..b5bd38fc378 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/index.ts @@ -40,3 +40,4 @@ export { default as adjustImageSelection } from './publicApi/image/adjustImageSe export { default as setParagraphMargin } from './publicApi/block/setParagraphMargin'; export { default as toggleCode } from './publicApi/segment/toggleCode'; export { default as insertEntity } from './publicApi/entity/insertEntity'; +export { setListType } from './modelApi/list/setListType'; 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 c2170883717..22493f5d061 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 @@ -56,7 +56,6 @@ function internalSetDirection( format.direction = direction; // 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 const marginLeft = format.marginLeft; const paddingLeft = format.paddingLeft; diff --git a/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/toggleModelBlockQuote.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/toggleModelBlockQuote.ts index f3f25a977f4..6272112a10c 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/toggleModelBlockQuote.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/toggleModelBlockQuote.ts @@ -17,7 +17,8 @@ import type { */ export function toggleModelBlockQuote( model: ContentModelDocument, - format: ContentModelFormatContainerFormat + formatLtr: ContentModelFormatContainerFormat, + formatRtl: ContentModelFormatContainerFormat ): boolean { const paragraphOfQuote = getOperationalBlocks< ContentModelFormatContainer | ContentModelListItem @@ -30,12 +31,14 @@ export function toggleModelBlockQuote( }); } else { const step1Results: WrapBlockStep1Result[] = []; - const creator = () => createFormatContainer('blockquote', format); + const creator = (isRtl: boolean) => + createFormatContainer('blockquote', isRtl ? formatRtl : formatLtr); const canMerge = ( + isRtl: boolean, target: ContentModelBlock, current?: ContentModelFormatContainer ): target is ContentModelFormatContainer => - canMergeQuote(target, current?.format || format); + canMergeQuote(target, current?.format || (isRtl ? formatRtl : formatLtr)); paragraphOfQuote.forEach(({ block, parent }) => { if (isQuote(block)) { 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 55e499ce428..d04ba929bcf 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 @@ -122,8 +122,6 @@ export function retrieveModelFormatState( firstTableContext = tableContext; } } - - // TODO: Support Code block in format state for Content Model }, { includeListFormatHolder: 'never', @@ -161,8 +159,6 @@ function retrieveSegmentFormat( mergeValue(result, 'backgroundColor', mergedFormat.backgroundColor, isFirst); mergeValue(result, 'textColor', mergedFormat.textColor, isFirst); mergeValue(result, 'fontWeight', mergedFormat.fontWeight, isFirst); - - //TODO: handle block owning segments with different line-heights mergeValue(result, 'lineHeight', mergedFormat.lineHeight, isFirst); } diff --git a/packages-content-model/roosterjs-content-model-api/lib/modelApi/common/wrapBlock.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/common/wrapBlock.ts index 6acaa412e3f..23d2aa89756 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/modelApi/common/wrapBlock.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/modelApi/common/wrapBlock.ts @@ -16,16 +16,19 @@ export function wrapBlockStep1[], parent: ContentModelBlockGroup | null, blockToWrap: ContentModelBlock, - creator: () => T, - canMerge: (target: ContentModelBlock) => target is T + creator: (isRtl: boolean) => T, + canMerge: (isRtl: boolean, target: ContentModelBlock) => target is T ) { const index = parent?.blocks.indexOf(blockToWrap) ?? -1; if (parent && index >= 0) { parent.blocks.splice(index, 1); - const prevBlock = parent.blocks[index - 1]; - const wrapper = canMerge(prevBlock) ? prevBlock : createAndAdd(parent, index, creator); + const prevBlock: ContentModelBlock = parent.blocks[index - 1]; + const isRtl = blockToWrap.format.direction == 'rtl'; + const wrapper = canMerge(isRtl, prevBlock) + ? prevBlock + : createAndAdd(parent, index, creator, isRtl); setParagraphNotImplicit(blockToWrap); addBlock(wrapper, blockToWrap); @@ -40,13 +43,14 @@ export function wrapBlockStep1( step1Result: WrapBlockStep1Result[], - canMerge: (target: ContentModelBlock, current: T) => target is T + canMerge: (isRtl: boolean, target: ContentModelBlock, current: T) => target is T ) { step1Result.forEach(({ parent, wrapper }) => { const index = parent.blocks.indexOf(wrapper); const nextBlock = parent.blocks[index + 1]; + const isRtl = wrapper.format.direction == 'rtl'; - if (index >= 0 && canMerge(nextBlock, wrapper)) { + if (index >= 0 && canMerge(isRtl, nextBlock, wrapper)) { wrapper.blocks.forEach(setParagraphNotImplicit); wrapper.blocks.push(...nextBlock.blocks); parent.blocks.splice(index + 1, 1); @@ -57,9 +61,10 @@ export function wrapBlockStep2( parent: ContentModelBlockGroup, index: number, - creator: () => T + creator: (isRtl: boolean) => T, + isRtl: boolean ): T { - const block = creator(); + const block = creator(isRtl); parent.blocks.splice(index, 0, block); return block; diff --git a/packages-content-model/roosterjs-content-model-api/lib/modelApi/link/matchLink.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/link/matchLink.ts new file mode 100644 index 00000000000..b2238fe08e9 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-api/lib/modelApi/link/matchLink.ts @@ -0,0 +1,114 @@ +import { getObjectKeys } from 'roosterjs-content-model-dom'; + +/** + * @internal + */ +export interface LinkData { + /** + * Schema of a hyperlink + */ + scheme: string; + + /** + * Original url of a hyperlink + */ + originalUrl: string; + + /** + * Normalized url of a hyperlink + */ + normalizedUrl: string; +} + +interface LinkMatchRule { + match: RegExp; + except?: RegExp; + normalizeUrl?: (url: string) => string; +} + +// http exclude matching regex +// invalid URL example (in particular on IE and Edge): +// - http://www.bing.com%00, %00 before ? (question mark) is considered invalid. IE/Edge throws invalid argument exception +// - http://www.bing.com%1, %1 is invalid +// - http://www.bing.com%g, %g is invalid (IE and Edge expects a two hex value after a %) +// - http://www.bing.com%, % as ending is invalid (IE and Edge expects a two hex value after a %) +// All above % cases if they're after ? (question mark) is then considered valid again +// Similar for @, it needs to be after / (forward slash), or ? (question mark). Otherwise IE/Edge will throw security exception +// - http://www.bing.com@name, @name before ? (question mark) is considered invalid +// - http://www.bing.com/@name, is valid sine it is after / (forward slash) +// - http://www.bing.com?@name, is also valid since it is after ? (question mark) +// The regex below is essentially a break down of: +// ^[^?]+%[^0-9a-f]+ => to exclude URL like www.bing.com%% +// ^[^?]+%[0-9a-f][^0-9a-f]+ => to exclude URL like www.bing.com%1 +// ^[^?]+%00 => to exclude URL like www.bing.com%00 +// ^[^?]+%$ => to exclude URL like www.bing.com% +// ^https?:\/\/[^?\/]+@ => to exclude URL like http://www.bing.com@name +// ^www\.[^?\/]+@ => to exclude URL like www.bing.com@name +// , => to exclude url like www.bing,,com +const httpExcludeRegEx = /^[^?]+%[^0-9a-f]+|^[^?]+%[0-9a-f][^0-9a-f]+|^[^?]+%00|^[^?]+%$|^https?:\/\/[^?\/]+@|^www\.[^?\/]+@/i; + +// via https://tools.ietf.org/html/rfc1035 Page 7 +const labelRegEx = '[a-z0-9](?:[a-z0-9-]*[a-z0-9])?'; // We're using case insensitive regexps below so don't bother including A-Z +const domainNameRegEx = `(?:${labelRegEx}\\.)*${labelRegEx}`; +const domainPortRegEx = `${domainNameRegEx}(?:\\:[0-9]+)?`; +const domainPortWithUrlRegEx = `${domainPortRegEx}(?:[\\/\\?]\\S*)?`; + +const linkMatchRules: Record = { + http: { + match: new RegExp( + `^(?:microsoft-edge:)?http:\\/\\/${domainPortWithUrlRegEx}|www\\.${domainPortWithUrlRegEx}`, + 'i' + ), + except: httpExcludeRegEx, + normalizeUrl: url => + new RegExp('^(?:microsoft-edge:)?http:\\/\\/', 'i').test(url) ? url : 'http://' + url, + }, + https: { + match: new RegExp(`^(?:microsoft-edge:)?https:\\/\\/${domainPortWithUrlRegEx}`, 'i'), + except: httpExcludeRegEx, + }, + mailto: { match: new RegExp('^mailto:\\S+@\\S+\\.\\S+', 'i') }, + notes: { match: new RegExp('^notes:\\/\\/\\S+', 'i') }, + file: { match: new RegExp('^file:\\/\\/\\/?\\S+', 'i') }, + unc: { match: new RegExp('^\\\\\\\\\\S+', 'i') }, + ftp: { + match: new RegExp( + `^ftp:\\/\\/${domainPortWithUrlRegEx}|ftp\\.${domainPortWithUrlRegEx}`, + 'i' + ), + normalizeUrl: url => (new RegExp('^ftp:\\/\\/', 'i').test(url) ? url : 'ftp://' + url), + }, + news: { match: new RegExp(`^news:(\\/\\/)?${domainPortWithUrlRegEx}`, 'i') }, + telnet: { match: new RegExp(`^telnet:(\\/\\/)?${domainPortWithUrlRegEx}`, 'i') }, + gopher: { match: new RegExp(`^gopher:\\/\\/${domainPortWithUrlRegEx}`, 'i') }, + wais: { match: new RegExp(`^wais:(\\/\\/)?${domainPortWithUrlRegEx}`, 'i') }, +}; + +/** + * @internal + * Try to match a given string with link match rules, return matched link + * @param url Input url to match + * @param option Link match option, exact or partial. If it is exact match, we need + * to check the length of matched link and url + * @param rules Optional link match rules, if not passed, only the default link match + * rules will be applied + * @returns The matched link data, or null if no match found. + * The link data includes an original url and a normalized url + */ +export function matchLink(url: string): LinkData | null { + if (url) { + for (const schema of getObjectKeys(linkMatchRules)) { + const rule = linkMatchRules[schema]; + const matches = url.match(rule.match); + if (matches && matches[0] == url && (!rule.except || !rule.except.test(url))) { + return { + scheme: schema, + originalUrl: url, + normalizedUrl: rule.normalizeUrl ? rule.normalizeUrl(url) : url, + }; + } + } + } + + return null; +} diff --git a/packages-content-model/roosterjs-content-model-api/lib/modelApi/list/setListType.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/list/setListType.ts index bac375bd0cb..4ffacd55d76 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/modelApi/list/setListType.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/modelApi/list/setListType.ts @@ -12,7 +12,9 @@ import type { } from 'roosterjs-content-model-types'; /** - * @internal + * Set a list type to content model + * @param model the model document + * @param listType the list type OL | UL */ export function setListType(model: ContentModelDocument, listType: 'OL' | 'UL') { const paragraphOrListItems = getOperationalBlocks( @@ -26,7 +28,6 @@ export function setListType(model: ContentModelDocument, listType: 'OL' | 'UL') : shouldIgnoreBlock(block) ); let existingListItems: ContentModelListItem[] = []; - let hasIgnoredParagraphBefore = false; paragraphOrListItems.forEach(({ block, parent }, itemIndex) => { if (isBlockGroupOfType(block, 'ListItem')) { @@ -75,9 +76,8 @@ export function setListType(model: ContentModelDocument, listType: 'OL' | 'UL') : 1, direction: block.format.direction, textAlign: block.format.textAlign, - marginTop: hasIgnoredParagraphBefore ? '0' : undefined, - marginBlockEnd: '0px', - marginBlockStart: '0px', + marginTop: '0px', + marginBottom: '0px', }), ], // For list bullet, we only want to carry over these formats from segments: @@ -88,8 +88,6 @@ export function setListType(model: ContentModelDocument, listType: 'OL' | 'UL') } ); - // Since there is only one paragraph under the list item, no need to keep its paragraph element (DIV). - // TODO: Do we need to keep the CSS styles applied to original DIV? if (block.blockType == 'Paragraph') { block.isImplicit = true; } @@ -112,9 +110,7 @@ export function setListType(model: ContentModelDocument, listType: 'OL' | 'UL') parent.blocks.splice(index, 1, newListItem); existingListItems.push(newListItem); } else { - hasIgnoredParagraphBefore = true; - - existingListItems.forEach(x => (x.levels[0].format.marginBottom = '0')); + existingListItems.forEach(x => (x.levels[0].format.marginBottom = '0px')); existingListItems = []; } } diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/block/toggleBlockQuote.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/block/toggleBlockQuote.ts index ec3ac86d7a5..87fa8055e0b 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/block/toggleBlockQuote.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/block/toggleBlockQuote.ts @@ -4,8 +4,12 @@ import type { IStandaloneEditor, } from 'roosterjs-content-model-types'; -const DefaultQuoteFormat: ContentModelFormatContainerFormat = { - borderLeft: '3px solid rgb(200, 200, 200)', // TODO: Support RTL +const DefaultQuoteFormatLtr: ContentModelFormatContainerFormat = { + borderLeft: '3px solid rgb(200, 200, 200)', + textColor: 'rgb(102, 102, 102)', +}; +const DefaultQuoteFormatRtl: ContentModelFormatContainerFormat = { + borderRight: '3px solid rgb(200, 200, 200)', textColor: 'rgb(102, 102, 102)', }; const BuildInQuoteFormat: ContentModelFormatContainerFormat = { @@ -13,7 +17,6 @@ const BuildInQuoteFormat: ContentModelFormatContainerFormat = { marginBottom: '1em', marginLeft: '40px', marginRight: '40px', - paddingLeft: '10px', }; /** @@ -25,11 +28,19 @@ const BuildInQuoteFormat: ContentModelFormatContainerFormat = { */ export default function toggleBlockQuote( editor: IStandaloneEditor, - quoteFormat: ContentModelFormatContainerFormat = DefaultQuoteFormat + quoteFormat?: ContentModelFormatContainerFormat, + quoteFormatRtl?: ContentModelFormatContainerFormat ) { - const fullQuoteFormat = { + const fullQuoteFormatLtr: ContentModelFormatContainerFormat = { + ...BuildInQuoteFormat, + paddingLeft: '10px', + ...(quoteFormat ?? DefaultQuoteFormatLtr), + }; + const fullQuoteFormatRtl: ContentModelFormatContainerFormat = { ...BuildInQuoteFormat, - ...quoteFormat, + paddingRight: '10px', + direction: 'rtl', + ...(quoteFormatRtl ?? quoteFormat ?? DefaultQuoteFormatRtl), }; editor.focus(); @@ -38,7 +49,7 @@ export default function toggleBlockQuote( (model, context) => { context.newPendingFormat = 'preserve'; - return toggleModelBlockQuote(model, fullQuoteFormat); + return toggleModelBlockQuote(model, fullQuoteFormatLtr, fullQuoteFormatRtl); }, { apiName: 'toggleBlockQuote', diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/entity/insertEntity.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/entity/insertEntity.ts index 7b7b4701d5d..2cefb62eb9b 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/entity/insertEntity.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/entity/insertEntity.ts @@ -8,7 +8,6 @@ import type { InsertEntityOptions, IStandaloneEditor, } from 'roosterjs-content-model-types'; -import type { Entity } from 'roosterjs-editor-types'; const BlockEntityTag = 'div'; const InlineEntityTag = 'span'; @@ -91,17 +90,12 @@ export default function insertEntity( { selectionOverride: typeof position === 'object' ? position : undefined, changeSource: ChangeSource.InsertEntity, - getChangeData: () => { - // TODO: Remove this entity when we have standalone editor - const entity: Entity = { - wrapper, - type, - id: '', - isReadonly: true, - }; - - return entity; - }, + getChangeData: () => ({ + wrapper, + type, + id: '', + isReadonly: true, + }), apiName: 'insertEntity', } ); diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/format/getFormatState.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/format/getFormatState.ts index 42aad59d7c1..d735115e945 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/format/getFormatState.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/format/getFormatState.ts @@ -1,18 +1,5 @@ -import { getSelectionRootNode } from 'roosterjs-content-model-core'; import { retrieveModelFormatState } from '../../modelApi/common/retrieveModelFormatState'; -import type { - IStandaloneEditor, - ContentModelBlockGroup, - ContentModelFormatState, - DomToModelContext, -} from 'roosterjs-content-model-types'; - -import { - getRegularSelectionOffsets, - handleRegularSelection, - isNodeOfType, - processChildNode, -} from 'roosterjs-content-model-dom'; +import type { IStandaloneEditor, ContentModelFormatState } from 'roosterjs-content-model-types'; /** * Get current format state @@ -20,105 +7,15 @@ import { */ export default function getFormatState(editor: IStandaloneEditor): ContentModelFormatState { const pendingFormat = editor.getPendingFormat(); - const model = editor.createContentModel({ - processorOverride: { - child: reducedModelChildProcessor, - }, - }); + const model = editor.getContentModelCopy('reduced'); const manager = editor.getSnapshotsManager(); const result: ContentModelFormatState = { canUndo: manager.hasNewContent || manager.canMove(-1), canRedo: manager.canMove(1), isDarkMode: editor.isDarkMode(), - zoomScale: editor.getZoomScale(), }; retrieveModelFormatState(model, pendingFormat, result); return result; } - -/** - * @internal - */ -interface FormatStateContext extends DomToModelContext { - /** - * An optional stack of parent elements to process. When provided, the child nodes of current parent element will be ignored, - * but use the top element in this stack instead in childProcessor. - */ - nodeStack?: Node[]; -} - -/** - * @internal - * Export for test only - * In order to get format, we can still use the regular child processor. However, to improve performance, we don't need to create - * content model for the whole doc, instead we only need to traverse the tree path that can arrive current selected node. - * This "reduced" child processor will first create a node stack that stores DOM node from root to current common ancestor node of selection, - * then use this stack as a faked DOM tree to create a reduced content model which we can use to retrieve format state - */ -export function reducedModelChildProcessor( - group: ContentModelBlockGroup, - parent: ParentNode, - context: FormatStateContext -) { - if (!context.nodeStack) { - const selectionRootNode = getSelectionRootNode(context.selection); - context.nodeStack = selectionRootNode ? createNodeStack(parent, selectionRootNode) : []; - } - - const stackChild = context.nodeStack.pop(); - - if (stackChild) { - const [nodeStartOffset, nodeEndOffset] = getRegularSelectionOffsets(context, parent); - - // If selection is not on this node, skip getting node index to save some time since we don't need it here - const index = - nodeStartOffset >= 0 || nodeEndOffset >= 0 ? getChildIndex(parent, stackChild) : -1; - - if (index >= 0) { - handleRegularSelection(index, context, group, nodeStartOffset, nodeEndOffset); - } - - processChildNode(group, stackChild, context); - - if (index >= 0) { - handleRegularSelection(index + 1, context, group, nodeStartOffset, nodeEndOffset); - } - } else { - // No child node from node stack, that means we have reached the deepest node of selection. - // Now we can use default child processor to perform full sub tree scanning for content model, - // So that all selected node will be included. - context.defaultElementProcessors.child(group, parent, context); - } -} - -function createNodeStack(root: Node, startNode: Node): Node[] { - const result: Node[] = []; - let node: Node | null = startNode; - - while (node && root != node && root.contains(node)) { - if (isNodeOfType(node, 'ELEMENT_NODE') && node.tagName == 'TABLE') { - // For table, we can't do a reduced model creation since we need to handle their cells and indexes, - // so clean up whatever we already have, and just put table into the stack - result.splice(0, result.length, node); - } else { - result.push(node); - } - - node = node.parentNode; - } - - return result; -} - -function getChildIndex(parent: ParentNode, stackChild: Node) { - let index = 0; - let child = parent.firstChild; - - while (child && child != stackChild) { - index++; - child = child.nextSibling; - } - return index; -} diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/link/insertLink.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/link/insertLink.ts index 64b68247a7b..90b16943afe 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,6 +1,6 @@ import { adjustTrailingSpaceSelection } from '../../modelApi/selection/adjustTrailingSpaceSelection'; import { ChangeSource, getSelectedSegments, mergeModel } from 'roosterjs-content-model-core'; -import { HtmlSanitizer, matchLink } from 'roosterjs-editor-dom'; +import { matchLink } from '../../modelApi/link/matchLink'; import type { ContentModelLink, IStandaloneEditor } from 'roosterjs-content-model-types'; import { addLink, @@ -126,7 +126,6 @@ const createLink = ( }; }; -// TODO: This is copied from original code. We may need to integrate this logic into matchLink() later. function applyLinkPrefix(url: string): string { if (!url) { return url; @@ -152,16 +151,6 @@ function applyLinkPrefix(url: string): string { return prefix + url; } -// TODO: This is copied from original code. However, ContentModel should be able to filter out malicious -// attributes later, so no need to use HtmlSanitizer here function checkXss(link: string): string { - const sanitizer = new HtmlSanitizer(); - const a = document.createElement('a'); - - a.href = link || ''; - - sanitizer.sanitize(a); - // We use getAttribute because some browsers will try to make the href property a valid link. - // This has unintended side effects when the link lacks a protocol. - return a.getAttribute('href') || ''; + return link.match(/s\n*c\n*r\n*i\n*p\n*t\n*:/i) ? '' : link; } diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/changeCapitalization.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/changeCapitalization.ts index 207efe20aae..e5e3e30f96e 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/changeCapitalization.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/changeCapitalization.ts @@ -40,7 +40,6 @@ export default function changeCapitalization( break; case 'sentence': - // TODO: Add rules on punctuation for internationalization - TASK 104769 const punctuationMarks = '[\\.\\!\\?]'; // Find a match of a word character either: // - At the beginning of a string with or without preceding whitespace, for diff --git a/packages-content-model/roosterjs-content-model-api/package.json b/packages-content-model/roosterjs-content-model-api/package.json index d4f7558e6ae..9981d13b011 100644 --- a/packages-content-model/roosterjs-content-model-api/package.json +++ b/packages-content-model/roosterjs-content-model-api/package.json @@ -3,8 +3,6 @@ "description": "Content Model for roosterjs (Under development)", "dependencies": { "tslib": "^2.3.1", - "roosterjs-editor-types": "", - "roosterjs-editor-dom": "", "roosterjs-content-model-core": "", "roosterjs-content-model-dom": "", "roosterjs-content-model-types": "" diff --git a/packages-content-model/roosterjs-content-model-api/test/modelApi/block/toggleModelBlockQuoteTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/block/toggleModelBlockQuoteTest.ts index 3ba27782d5f..2969ec2145f 100644 --- a/packages-content-model/roosterjs-content-model-api/test/modelApi/block/toggleModelBlockQuoteTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/modelApi/block/toggleModelBlockQuoteTest.ts @@ -11,7 +11,7 @@ describe('toggleModelBlockQuote', () => { it('empty model', () => { const doc = createContentModelDocument(); - toggleModelBlockQuote(doc, {}); + toggleModelBlockQuote(doc, {}, {}); expect(doc).toEqual({ blockGroupType: 'Document', @@ -27,7 +27,7 @@ describe('toggleModelBlockQuote', () => { para.segments.push(text); doc.blocks.push(para); - toggleModelBlockQuote(doc, {}); + toggleModelBlockQuote(doc, {}, {}); expect(doc).toEqual({ blockGroupType: 'Document', @@ -56,7 +56,7 @@ describe('toggleModelBlockQuote', () => { doc.blocks.push(para); text.isSelected = true; - toggleModelBlockQuote(doc, {}); + toggleModelBlockQuote(doc, {}, {}); expect(doc).toEqual({ blockGroupType: 'Document', @@ -99,7 +99,7 @@ describe('toggleModelBlockQuote', () => { text1.isSelected = true; text2.isSelected = true; - toggleModelBlockQuote(doc, {}); + toggleModelBlockQuote(doc, {}, {}); expect(doc).toEqual({ blockGroupType: 'Document', @@ -140,6 +140,99 @@ describe('toggleModelBlockQuote', () => { }); }); + it('Has multiple selection, with RTL, do not merge', () => { + const doc = createContentModelDocument(); + const para1 = createParagraph(); + const text1 = createText('test1'); + const para2 = createParagraph(); + const text2 = createText('test2'); + const para3 = createParagraph(); + const text3 = createText('test3'); + + para1.segments.push(text1); + para2.segments.push(text2); + para3.segments.push(text3); + + para2.format.direction = 'rtl'; + + doc.blocks.push(para1, para2, para3); + text1.isSelected = true; + text2.isSelected = true; + text3.isSelected = true; + + toggleModelBlockQuote(doc, {}, { direction: 'rtl' }); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'FormatContainer', + tagName: 'blockquote', + format: {}, + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'test1', + format: {}, + isSelected: true, + }, + ], + }, + ], + }, + { + blockType: 'BlockGroup', + blockGroupType: 'FormatContainer', + tagName: 'blockquote', + format: { + direction: 'rtl', + }, + blocks: [ + { + blockType: 'Paragraph', + format: { + direction: 'rtl', + }, + segments: [ + { + segmentType: 'Text', + text: 'test2', + format: {}, + isSelected: true, + }, + ], + }, + ], + }, + { + blockType: 'BlockGroup', + blockGroupType: 'FormatContainer', + tagName: 'blockquote', + format: {}, + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'test3', + format: {}, + isSelected: true, + }, + ], + }, + ], + }, + ], + }); + }); + it('Has single selection, merge before', () => { const doc = createContentModelDocument(); const para1 = createParagraph(); @@ -155,7 +248,7 @@ describe('toggleModelBlockQuote', () => { doc.blocks.push(para2); text2.isSelected = true; - toggleModelBlockQuote(doc, {}); + toggleModelBlockQuote(doc, {}, {}); expect(doc).toEqual({ blockGroupType: 'Document', @@ -210,7 +303,7 @@ describe('toggleModelBlockQuote', () => { doc.blocks.push(quote); text1.isSelected = true; - toggleModelBlockQuote(doc, {}); + toggleModelBlockQuote(doc, {}, {}); expect(doc).toEqual({ blockGroupType: 'Document', @@ -271,7 +364,7 @@ describe('toggleModelBlockQuote', () => { doc.blocks.push(quote3); text2.isSelected = true; - toggleModelBlockQuote(doc, {}); + toggleModelBlockQuote(doc, {}, {}); expect(doc).toEqual({ blockGroupType: 'Document', @@ -343,7 +436,7 @@ describe('toggleModelBlockQuote', () => { doc.blocks.push(quote3); text2.isSelected = true; - toggleModelBlockQuote(doc, {}); + toggleModelBlockQuote(doc, {}, {}); expect(doc).toEqual({ blockGroupType: 'Document', @@ -436,7 +529,7 @@ describe('toggleModelBlockQuote', () => { text2.isSelected = true; text3.isSelected = true; - toggleModelBlockQuote(doc, {}); + toggleModelBlockQuote(doc, {}, {}); expect(doc).toEqual({ blockGroupType: 'Document', @@ -512,7 +605,7 @@ describe('toggleModelBlockQuote', () => { text1.isSelected = true; - toggleModelBlockQuote(doc, {}); + toggleModelBlockQuote(doc, {}, {}); expect(doc).toEqual({ blockGroupType: 'Document', @@ -575,7 +668,7 @@ describe('toggleModelBlockQuote', () => { text1.isSelected = true; text2.isSelected = true; - toggleModelBlockQuote(doc, {}); + toggleModelBlockQuote(doc, {}, {}); expect(doc).toEqual({ blockGroupType: 'Document', @@ -637,7 +730,7 @@ describe('toggleModelBlockQuote', () => { text2.isSelected = true; - toggleModelBlockQuote(doc, {}); + toggleModelBlockQuote(doc, {}, {}); expect(doc).toEqual({ blockGroupType: 'Document', diff --git a/packages-content-model/roosterjs-content-model-api/test/modelApi/common/wrapBlockTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/common/wrapBlockTest.ts index c7765727613..604c9ef6292 100644 --- a/packages-content-model/roosterjs-content-model-api/test/modelApi/common/wrapBlockTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/modelApi/common/wrapBlockTest.ts @@ -225,7 +225,7 @@ describe('wrapBlockStep2', () => { ], }); expect(canMerge).toHaveBeenCalledTimes(1); - expect(canMerge).toHaveBeenCalledWith(undefined, quote); + expect(canMerge).toHaveBeenCalledWith(false, undefined, quote); }); it('Has results, can merge', () => { @@ -270,8 +270,8 @@ describe('wrapBlockStep2', () => { wrapBlockStep2(result, canMerge as any); expect(canMerge).toHaveBeenCalledTimes(2); - expect(canMerge).toHaveBeenCalledWith(quote4, quote3); - expect(canMerge).toHaveBeenCalledWith(quote2, quote1); + expect(canMerge).toHaveBeenCalledWith(false, quote4, quote3); + expect(canMerge).toHaveBeenCalledWith(false, quote2, quote1); expect(doc).toEqual({ blockGroupType: 'Document', blocks: [ diff --git a/packages-content-model/roosterjs-content-model-api/test/modelApi/link/matchLinkTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/link/matchLinkTest.ts new file mode 100644 index 00000000000..ad25b487f07 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-api/test/modelApi/link/matchLinkTest.ts @@ -0,0 +1,260 @@ +import { LinkData, matchLink } from '../../../lib/modelApi/link/matchLink'; + +function runMatchTestWithValidLink(link: string, expected: LinkData): void { + let resultData = matchLink(link); + expect(resultData).not.toBe(null); + expect(resultData.scheme).toBe(expected.scheme); + expect(resultData.originalUrl).toBe(expected.originalUrl); + expect(resultData.normalizedUrl).toBe(expected.normalizedUrl); +} + +function runMatchTestWithBadLink(link: string): void { + let linkData = matchLink(link); + expect(linkData).toBeNull(); +} + +describe('defaultLinkMatchRules regular http links with extact match', () => { + it('http://www.bing.com', () => { + let link = 'http://www.bing.com'; + runMatchTestWithValidLink(link, { scheme: 'http', originalUrl: link, normalizedUrl: link }); + }); + + it('http://www.bing.com/', () => { + let link = 'http://www.bing.com/'; + runMatchTestWithValidLink(link, { scheme: 'http', originalUrl: link, normalizedUrl: link }); + }); + + it('http://1drv.com/test', () => { + let link = 'http://1drv.com/test'; + runMatchTestWithValidLink(link, { scheme: 'http', originalUrl: link, normalizedUrl: link }); + }); + + it('www.1234.com/test', () => { + let link = 'www.1234.com/test'; + runMatchTestWithValidLink(link, { + scheme: 'http', + originalUrl: link, + normalizedUrl: 'http://' + link, + }); + }); + + it('http://www.lifewire.com/how-torrent-downloading-works-2483513', () => { + let link = 'http://www.lifewire.com/how-torrent-downloading-works-2483513'; + runMatchTestWithValidLink(link, { scheme: 'http', originalUrl: link, normalizedUrl: link }); + }); +}); + +describe('defaultLinkMatchRules regular www links with extact match', () => { + it('www.eartheasy.com/grow_compost.html', () => { + let link = 'www.eartheasy.com/grow_compost.html'; + runMatchTestWithValidLink(link, { + scheme: 'http', + originalUrl: link, + normalizedUrl: 'http://' + link, + }); + }); +}); + +describe('defaultLinkMatchRules regular https links with extact match', () => { + it('https://en.wikipedia.org/wiki/Compost', () => { + let link = 'https://en.wikipedia.org/wiki/Compost'; + runMatchTestWithValidLink(link, { + scheme: 'https', + originalUrl: link, + normalizedUrl: link, + }); + }); + + it('https://www.youtube.com/watch?v=e3Nl_TCQXuw', () => { + let link = 'https://www.youtube.com/watch?v=e3Nl_TCQXuw'; + runMatchTestWithValidLink(link, { + scheme: 'https', + originalUrl: link, + normalizedUrl: link, + }); + }); + + it('https://www.bing.com/news/search?q=MSFT&qpvt=msft&FORM=EWRE', () => { + let link = 'https://www.bing.com/news/search?q=MSFT&qpvt=msft&FORM=EWRE'; + runMatchTestWithValidLink(link, { + scheme: 'https', + originalUrl: link, + normalizedUrl: link, + }); + }); + + it('https://microsoft.sharepoint.com/teams/peopleposse/Shared%20Documents/Feedback%20Plan.pptx?web=1', () => { + let link = + 'https://microsoft.sharepoint.com/teams/peopleposse/Shared%20Documents/Feedback%20Plan.pptx?web=1'; + runMatchTestWithValidLink(link, { + scheme: 'https', + originalUrl: link, + normalizedUrl: link, + }); + }); +}); + +describe('defaultLinkMatchRules special http links that has % and @, but is valid', () => { + it('www.test.com/?test=test%00it', () => { + // URL: www.test.com/?test=test%00it %00 => %00 is invalid percent encoding but URL is valid since it is after ? + let link = 'www.test.com/?test=test%00it'; + runMatchTestWithValidLink(link, { + scheme: 'http', + originalUrl: link, + normalizedUrl: 'http://' + link, + }); + }); + + it('http://www.test.com/?test=test%hhit', () => { + // URL: http://www.test.com/?test=test%hhit => %h is invalid encoding, but URL is valid since it is after ? + let link = 'http://www.test.com/?test=test%hhit'; + runMatchTestWithValidLink(link, { scheme: 'http', originalUrl: link, normalizedUrl: link }); + }); + + it('www.test.com/kitty@supercute.com', () => { + // URL: www.test.com/kitty@supercute.com => @ is valid when it is after / + let link = 'www.test.com/kitty@supercute.com'; + runMatchTestWithValidLink(link, { + scheme: 'http', + originalUrl: link, + normalizedUrl: 'http://' + link, + }); + }); + + it('www.test.com?kitty@supercute.com', () => { + // URL: www.test.com?kitty@supercute.com => @ is valid when it is after ? + let link = 'www.test.com?kitty@supercute.com'; + runMatchTestWithValidLink(link, { + scheme: 'http', + originalUrl: link, + normalizedUrl: 'http://' + link, + }); + }); + + it('http://www.test.com/kitty@supercute.com', () => { + // URL: http://www.test.com/kitty@supercute.com @ is valid when it is after / for URL that has http:// prefix + let link = 'http://www.test.com/kitty@supercute.com'; + runMatchTestWithValidLink(link, { scheme: 'http', originalUrl: link, normalizedUrl: link }); + }); +}); + +describe('defaultLinkMatchRules invalid http links with % and @ in URL', () => { + it('http://www.test%00it.com/', () => { + // %00 is invalid percent encoding + runMatchTestWithBadLink('http://www.test%00it.com/'); + }); + + it('http://www.test%hhit.com/', () => { + // %h is invalid percent encoding + runMatchTestWithBadLink('http://www.test%hhit.com/'); + }); + + it('www.test%0hit.com/', () => { + // %0 is invalid percent encoding + runMatchTestWithBadLink('www.test%0hit.com/'); + }); + + it('www.kitty@supercute.com.com/test', () => { + // @ is invalid when it apperas before / + runMatchTestWithBadLink('www.kitty@supercute.com.com/test'); + }); + + it('www.kitty@supercute.com.com?test', () => { + // @ is invalid when it apperas before ? + runMatchTestWithBadLink('www.kitty@supercute.com.com?test'); + }); + + it('https' + '://www.kitty@supercute.com.com/test', () => { + // @ is invalid when it apperas before /. Note we're testing @ after http:// and before first / + runMatchTestWithBadLink('https' + '://www.kitty@supercute.com.com/test'); + }); +}); + +describe('defaultLinkMatchRules exact match with extra space and text', () => { + it('www.bing.com more', () => { + // exact match should not match since there is some space and extra text after the url + runMatchTestWithBadLink('www.bing.com more'); + }); +}); + +describe('defaultLinkmatchRules does not match invalid urls', () => { + it('www.bing,com', () => { + runMatchTestWithBadLink('www.bing,com'); + }); + + it('www.b,,au', () => { + runMatchTestWithBadLink('www.b,,au'); + }); +}); + +describe('defaultLinkMatchRules other protocols, mailto, notes, file etc.', () => { + it('mailto:someone@example.com', () => { + let link = 'mailto:someone@example.com'; + runMatchTestWithValidLink(link, { + scheme: 'mailto', + originalUrl: link, + normalizedUrl: link, + }); + }); + + it('notes://Garth/86256EDF005310E2/A4D87D90E1B19842852564FF006DED4E/', () => { + let link = 'notes://Garth/86256EDF005310E2/A4D87D90E1B19842852564FF006DED4E/'; + runMatchTestWithValidLink(link, { + scheme: 'notes', + originalUrl: link, + normalizedUrl: link, + }); + }); + + it('file://hostname/path/to/the%20file.txt', () => { + let link = 'file://hostname/path/to/the%20file.txt'; + runMatchTestWithValidLink(link, { scheme: 'file', originalUrl: link, normalizedUrl: link }); + }); + + it('\\\\fileserver\\SharedFolder\\Resource', () => { + let link = '\\\\fileserver\\SharedFolder\\Resource'; + runMatchTestWithValidLink(link, { scheme: 'unc', originalUrl: link, normalizedUrl: link }); + }); + + it('ftp://test.com/share', () => { + let link = 'ftp://test.com/share'; + runMatchTestWithValidLink(link, { scheme: 'ftp', originalUrl: link, normalizedUrl: link }); + }); + + it('ftp.test.com/share', () => { + let link = 'ftp.test.com/share'; + runMatchTestWithValidLink(link, { + scheme: 'ftp', + originalUrl: link, + normalizedUrl: 'ftp://' + link, + }); + }); + + it('news://news.server.example/example', () => { + let link = 'news://news.server.example/example'; + runMatchTestWithValidLink(link, { scheme: 'news', originalUrl: link, normalizedUrl: link }); + }); + + it('telnet://test.com:25', () => { + let link = 'telnet://test.com:25'; + runMatchTestWithValidLink(link, { + scheme: 'telnet', + originalUrl: link, + normalizedUrl: link, + }); + }); + + it('gopher://test.com/share', () => { + let link = 'gopher://test.com/share'; + runMatchTestWithValidLink(link, { + scheme: 'gopher', + originalUrl: link, + normalizedUrl: link, + }); + }); + + it('wais://testserver:2000/DB1', () => { + let link = 'wais://testserver:2000/DB1'; + runMatchTestWithValidLink(link, { scheme: 'wais', originalUrl: link, normalizedUrl: link }); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-api/test/modelApi/list/setListTypeTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/list/setListTypeTest.ts index 609b2ac2ee1..12ab4e5aab5 100644 --- a/packages-content-model/roosterjs-content-model-api/test/modelApi/list/setListTypeTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/modelApi/list/setListTypeTest.ts @@ -75,9 +75,8 @@ describe('indent', () => { startNumberOverride: 1, direction: undefined, textAlign: undefined, - marginTop: undefined, - marginBlockEnd: '0px', - marginBlockStart: '0px', + marginTop: '0px', + marginBottom: '0px', }, dataset: {}, }, @@ -304,9 +303,8 @@ describe('indent', () => { startNumberOverride: undefined, direction: undefined, textAlign: undefined, - marginTop: undefined, - marginBlockEnd: '0px', - marginBlockStart: '0px', + marginTop: '0px', + marginBottom: '0px', }, dataset: {}, }, @@ -369,9 +367,8 @@ describe('indent', () => { startNumberOverride: 1, direction: 'rtl', textAlign: 'start', - marginTop: undefined, - marginBlockEnd: '0px', - marginBlockStart: '0px', + marginTop: '0px', + marginBottom: '0px', }, }, ], @@ -451,10 +448,8 @@ describe('indent', () => { startNumberOverride: 1, direction: undefined, textAlign: undefined, - marginTop: undefined, - marginBottom: '0', - marginBlockEnd: '0px', - marginBlockStart: '0px', + marginBottom: '0px', + marginTop: '0px', }, }, ], @@ -482,9 +477,8 @@ describe('indent', () => { direction: undefined, textAlign: undefined, startNumberOverride: undefined, - marginTop: '0', - marginBlockEnd: '0px', - marginBlockStart: '0px', + marginTop: '0px', + marginBottom: '0px', }, }, ], @@ -538,9 +532,8 @@ describe('indent', () => { startNumberOverride: 1, direction: undefined, textAlign: undefined, - marginTop: undefined, - marginBlockEnd: '0px', - marginBlockStart: '0px', + marginTop: '0px', + marginBottom: '0px', }, }, ], @@ -596,9 +589,8 @@ describe('indent', () => { startNumberOverride: 1, direction: undefined, textAlign: undefined, - marginTop: undefined, - marginBlockEnd: '0px', - marginBlockStart: '0px', + marginTop: '0px', + marginBottom: '0px', }, }, ], @@ -625,9 +617,8 @@ describe('indent', () => { startNumberOverride: undefined, direction: undefined, textAlign: undefined, - marginTop: undefined, - marginBlockEnd: '0px', - marginBlockStart: '0px', + marginTop: '0px', + marginBottom: '0px', }, }, ], @@ -651,12 +642,11 @@ describe('indent', () => { listType: 'OL', dataset: {}, format: { - marginTop: undefined, direction: undefined, textAlign: undefined, startNumberOverride: undefined, - marginBlockEnd: '0px', - marginBlockStart: '0px', + marginTop: '0px', + marginBottom: '0px', }, }, ], @@ -729,9 +719,8 @@ describe('indent', () => { startNumberOverride: 1, direction: undefined, textAlign: undefined, - marginTop: undefined, - marginBlockEnd: '0px', - marginBlockStart: '0px', + marginTop: '0px', + marginBottom: '0px', }, dataset: {}, }, @@ -760,9 +749,8 @@ describe('indent', () => { startNumberOverride: undefined, direction: undefined, textAlign: undefined, - marginTop: undefined, - marginBlockEnd: '0px', - marginBlockStart: '0px', + marginTop: '0px', + marginBottom: '0px', }, dataset: {}, }, @@ -814,8 +802,8 @@ describe('indent', () => { { listType: 'UL', format: { - marginBlockStart: '0px', - marginBlockEnd: '0px', + marginTop: '0px', + marginBottom: '0px', }, dataset: {}, }, @@ -855,8 +843,8 @@ describe('indent', () => { { listType: 'UL', format: { - marginBlockStart: '0px', - marginBlockEnd: '0px', + marginTop: '0px', + marginBottom: '0px', }, dataset: {}, }, 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 27e65109885..bb8a056b683 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 @@ -416,12 +416,10 @@ describe('setAlignment', () => { describe('setAlignment in table', () => { let editor: IStandaloneEditor; - let createContentModel: jasmine.Spy; let triggerEvent: jasmine.Spy; let getVisibleViewport: jasmine.Spy; beforeEach(() => { - createContentModel = jasmine.createSpy('createContentModel'); triggerEvent = jasmine.createSpy('triggerEvent'); getVisibleViewport = jasmine.createSpy('getVisibleViewport'); @@ -430,7 +428,6 @@ describe('setAlignment in table', () => { editor = ({ focus: () => {}, addUndoSnapshot: (callback: Function) => callback(), - createContentModel, isDarkMode: () => false, triggerEvent, getVisibleViewport, @@ -445,8 +442,6 @@ describe('setAlignment in table', () => { const model = createContentModelDocument(); model.blocks.push(table); - createContentModel.and.returnValue(model); - editor.formatContentModel = jasmine .createSpy('formatContentModel') .and.callFake( @@ -821,22 +816,16 @@ describe('setAlignment in table', () => { describe('setAlignment in list', () => { let editor: IStandaloneEditor; - let setContentModel: jasmine.Spy; - let createContentModel: jasmine.Spy; let triggerEvent: jasmine.Spy; let getVisibleViewport: jasmine.Spy; beforeEach(() => { - setContentModel = jasmine.createSpy('setContentModel'); - createContentModel = jasmine.createSpy('createContentModel'); triggerEvent = jasmine.createSpy('triggerEvent'); getVisibleViewport = jasmine.createSpy('getVisibleViewport'); editor = ({ focus: () => {}, addUndoSnapshot: (callback: Function) => callback(), - setContentModel, - createContentModel, isDarkMode: () => false, triggerEvent, getVisibleViewport, @@ -851,7 +840,6 @@ describe('setAlignment in list', () => { const model = createContentModelDocument(); model.blocks.push(list); - createContentModel.and.returnValue(model); let result: boolean | undefined; editor.formatContentModel = jasmine diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/block/toggleBlockQuoteTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/block/toggleBlockQuoteTest.ts index 6d897dd1b17..a90649ddaf8 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/block/toggleBlockQuoteTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/block/toggleBlockQuoteTest.ts @@ -39,15 +39,28 @@ describe('toggleBlockQuote', () => { expect(formatContentModelSpy).toHaveBeenCalledTimes(1); expect(toggleModelBlockQuote.toggleModelBlockQuote).toHaveBeenCalledTimes(1); - expect(toggleModelBlockQuote.toggleModelBlockQuote).toHaveBeenCalledWith(fakeModel, { - marginTop: '1em', - marginBottom: '1em', - marginLeft: '40px', - marginRight: '40px', - paddingLeft: '10px', - a: 'b', - c: 'd', - } as any); + expect(toggleModelBlockQuote.toggleModelBlockQuote).toHaveBeenCalledWith( + fakeModel, + { + marginTop: '1em', + marginBottom: '1em', + marginLeft: '40px', + marginRight: '40px', + paddingLeft: '10px', + a: 'b', + c: 'd', + } as any, + { + marginTop: '1em', + marginBottom: '1em', + marginLeft: '40px', + marginRight: '40px', + paddingRight: '10px', + direction: 'rtl', + a: 'b', + c: 'd', + } as any + ); expect(context).toEqual({ newEntities: [], newImages: [], @@ -63,15 +76,28 @@ describe('toggleBlockQuote', () => { expect(formatContentModelSpy).toHaveBeenCalledTimes(1); expect(toggleModelBlockQuote.toggleModelBlockQuote).toHaveBeenCalledTimes(1); - expect(toggleModelBlockQuote.toggleModelBlockQuote).toHaveBeenCalledWith(fakeModel, { - marginTop: '1em', - marginBottom: '1em', - marginLeft: '40px', - marginRight: '40px', - paddingLeft: '10px', - lineHeight: '2', - textColor: 'red', - } as any); + expect(toggleModelBlockQuote.toggleModelBlockQuote).toHaveBeenCalledWith( + fakeModel, + { + marginTop: '1em', + marginBottom: '1em', + marginLeft: '40px', + marginRight: '40px', + paddingLeft: '10px', + lineHeight: '2', + textColor: 'red', + } as any, + { + marginTop: '1em', + marginBottom: '1em', + marginLeft: '40px', + marginRight: '40px', + paddingRight: '10px', + lineHeight: '2', + textColor: 'red', + direction: 'rtl', + } + ); expect(context).toEqual({ newEntities: [], newImages: [], diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/format/getFormatStateTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/format/getFormatStateTest.ts index 0a58e470da6..cd065e35e3b 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/format/getFormatStateTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/format/getFormatStateTest.ts @@ -1,20 +1,14 @@ -import * as getSelectionRootNode from 'roosterjs-content-model-core/lib/publicApi/selection/getSelectionRootNode'; import * as retrieveModelFormatState from '../../../lib/modelApi/common/retrieveModelFormatState'; -import { ContentModelFormatState, DomToModelContext } from 'roosterjs-content-model-types'; +import getFormatState from '../../../lib/publicApi/format/getFormatState'; +import { ContentModelDocument, ContentModelSegmentFormat } from 'roosterjs-content-model-types'; +import { ContentModelFormatState } from 'roosterjs-content-model-types'; import { IStandaloneEditor } from 'roosterjs-content-model-types'; -import getFormatState, { - reducedModelChildProcessor, -} from '../../../lib/publicApi/format/getFormatState'; +import { reducedModelChildProcessor } from 'roosterjs-content-model-core/lib/override/reducedModelChildProcessor'; import { createContentModelDocument, createDomToModelContext, normalizeContentModel, } from 'roosterjs-content-model-dom'; -import { - ContentModelDocument, - ContentModelSegmentFormat, - DomToModelOption, -} from 'roosterjs-content-model-types'; const selectedNodeId = 'Selected'; @@ -37,7 +31,7 @@ describe('getFormatState', () => { isDarkMode: () => false, getZoomScale: () => 1, getPendingFormat: () => pendingFormat, - createContentModel: (options: DomToModelOption) => { + getContentModelCopy: () => { const model = createContentModelDocument(); const editorDiv = document.createElement('div'); @@ -45,7 +39,9 @@ describe('getFormatState', () => { const selectedNode = editorDiv.querySelector('#' + selectedNodeId); const context = createDomToModelContext(undefined, { - ...(options || {}), + processorOverride: { + child: reducedModelChildProcessor, + }, }); if (selectedNode) { @@ -54,6 +50,7 @@ describe('getFormatState', () => { range: { commonAncestorContainer: selectedNode, } as any, + isReverted: false, }; } @@ -75,7 +72,6 @@ describe('getFormatState', () => { canUndo: false, canRedo: false, isDarkMode: false, - zoomScale: 1, } ); expect(result).toEqual(expectedFormat); @@ -89,7 +85,7 @@ describe('getFormatState', () => { blockGroupType: 'Document', blocks: [], }, - { canUndo: false, canRedo: false, isDarkMode: false, zoomScale: 1 } + { canUndo: false, canRedo: false, isDarkMode: false } ); }); @@ -114,7 +110,7 @@ describe('getFormatState', () => { }, ], }, - { canUndo: false, canRedo: false, isDarkMode: false, zoomScale: 1 } + { canUndo: false, canRedo: false, isDarkMode: false } ); }); @@ -139,7 +135,7 @@ describe('getFormatState', () => { }, ], }, - { canUndo: false, canRedo: false, isDarkMode: false, zoomScale: 1 } + { canUndo: false, canRedo: false, isDarkMode: false } ); }); @@ -174,320 +170,7 @@ describe('getFormatState', () => { }, ], }, - { canUndo: false, canRedo: false, isDarkMode: false, zoomScale: 1 } + { canUndo: false, canRedo: false, isDarkMode: false } ); }); }); - -describe('reducedModelChildProcessor', () => { - let context: DomToModelContext; - let getSelectionRootNodeSpy: jasmine.Spy; - - beforeEach(() => { - context = createDomToModelContext(undefined, { - processorOverride: { - child: reducedModelChildProcessor, - }, - }); - - getSelectionRootNodeSpy = spyOn( - getSelectionRootNode, - 'getSelectionRootNode' - ).and.callThrough(); - }); - - it('Empty DOM', () => { - const doc = createContentModelDocument(); - const div = document.createElement('div'); - - reducedModelChildProcessor(doc, div, context); - - expect(doc).toEqual({ - blockGroupType: 'Document', - blocks: [], - }); - expect(getSelectionRootNodeSpy).toHaveBeenCalledTimes(1); - }); - - it('Single child node, with selected Node in context', () => { - const doc = createContentModelDocument(); - const div = document.createElement('div'); - const span = document.createElement('span'); - - div.appendChild(span); - span.textContent = 'test'; - context.selection = { - type: 'range', - range: { - commonAncestorContainer: span, - } as any, - }; - - reducedModelChildProcessor(doc, div, context); - - expect(doc).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - isImplicit: true, - segments: [ - { - segmentType: 'Text', - text: 'test', - format: {}, - }, - ], - }, - ], - }); - expect(getSelectionRootNodeSpy).toHaveBeenCalledTimes(1); - }); - - it('Multiple child nodes, with selected Node in context', () => { - const doc = createContentModelDocument(); - const div = document.createElement('div'); - const span1 = document.createElement('span'); - const span2 = document.createElement('span'); - const span3 = document.createElement('span'); - - div.appendChild(span1); - div.appendChild(span2); - div.appendChild(span3); - span1.textContent = 'test1'; - span2.textContent = 'test2'; - span3.textContent = 'test3'; - context.selection = { - type: 'range', - range: { - commonAncestorContainer: span2, - } as any, - }; - - reducedModelChildProcessor(doc, div, context); - - expect(doc).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - isImplicit: true, - segments: [ - { - segmentType: 'Text', - text: 'test2', - format: {}, - }, - ], - }, - ], - }); - expect(getSelectionRootNodeSpy).toHaveBeenCalledTimes(1); - }); - - it('Multiple child nodes, with selected Node in context, with more child nodes under selected node', () => { - const doc = createContentModelDocument(); - const div = document.createElement('div'); - const span1 = document.createElement('span'); - const span2 = document.createElement('span'); - const span3 = document.createElement('span'); - - div.appendChild(span1); - div.appendChild(span2); - div.appendChild(span3); - span1.textContent = 'test1'; - span2.innerHTML = '
line1
line2
'; - span3.textContent = 'test3'; - context.selection = { - type: 'range', - range: { - commonAncestorContainer: span2, - } as any, - }; - - reducedModelChildProcessor(doc, div, context); - - expect(doc).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: 'line1', - format: {}, - }, - ], - format: {}, - }, - { - blockType: 'Paragraph', - segments: [], - format: {}, - isImplicit: true, - }, - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: 'line2', - format: {}, - }, - ], - format: {}, - }, - { - blockType: 'Paragraph', - segments: [], - format: {}, - isImplicit: true, - }, - ], - }); - expect(getSelectionRootNodeSpy).toHaveBeenCalledTimes(1); - }); - - it('Multiple layer with multiple child nodes, with selected Node in context, with more child nodes under selected node', () => { - const doc = createContentModelDocument(); - const div1 = document.createElement('div'); - const div2 = document.createElement('div'); - const div3 = document.createElement('div'); - const span1 = document.createElement('span'); - const span2 = document.createElement('span'); - const span3 = document.createElement('span'); - - div3.appendChild(span1); - div3.appendChild(span2); - div3.appendChild(span3); - div1.appendChild(div2); - div2.appendChild(div3); - span1.textContent = 'test1'; - span2.innerHTML = '
line1
line2
'; - span3.textContent = 'test3'; - - context.selection = { - type: 'range', - range: { - commonAncestorContainer: span2, - } as any, - }; - - reducedModelChildProcessor(doc, div1, context); - - expect(doc).toEqual({ - blockGroupType: 'Document', - blocks: [ - { blockType: 'Paragraph', segments: [], format: {} }, - { - blockType: 'Paragraph', - segments: [], - format: {}, - }, - { - blockType: 'Paragraph', - segments: [{ segmentType: 'Text', text: 'line1', format: {} }], - format: {}, - }, - { blockType: 'Paragraph', segments: [], format: {}, isImplicit: true }, - { - blockType: 'Paragraph', - segments: [{ segmentType: 'Text', text: 'line2', format: {} }], - format: {}, - }, - { - blockType: 'Paragraph', - segments: [], - format: {}, - isImplicit: true, - }, - { blockType: 'Paragraph', segments: [], format: {}, isImplicit: true }, - { blockType: 'Paragraph', segments: [], format: {}, isImplicit: true }, - ], - }); - expect(getSelectionRootNodeSpy).toHaveBeenCalledTimes(1); - }); - - it('With table, need to do format for all table cells', () => { - const doc = createContentModelDocument(); - const div = document.createElement('div'); - div.innerHTML = - 'aa
test1test2
bb'; - context.selection = { - type: 'range', - range: { - commonAncestorContainer: div.querySelector('#selection') as HTMLElement, - } as any, - }; - - reducedModelChildProcessor(doc, div, context); - - expect(doc).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Table', - rows: [ - { - format: {}, - height: 0, - cells: [ - { - blockGroupType: 'TableCell', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: 'test1', - format: {}, - }, - ], - format: {}, - isImplicit: true, - }, - ], - format: {}, - spanLeft: false, - spanAbove: false, - isHeader: false, - dataset: {}, - }, - { - blockGroupType: 'TableCell', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: 'test2', - format: {}, - }, - ], - format: {}, - isImplicit: true, - }, - ], - format: {}, - spanLeft: false, - spanAbove: false, - isHeader: false, - dataset: {}, - }, - ], - }, - ], - format: {}, - widths: [], - dataset: {}, - }, - ], - }); - expect(getSelectionRootNodeSpy).toHaveBeenCalledTimes(1); - }); -}); diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/link/adjustLinkSelectionTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/link/adjustLinkSelectionTest.ts index bd4fe07ee36..496055259fb 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/link/adjustLinkSelectionTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/link/adjustLinkSelectionTest.ts @@ -18,24 +18,19 @@ import { describe('adjustLinkSelection', () => { let editor: IStandaloneEditor; - let createContentModel: jasmine.Spy; let formatContentModel: jasmine.Spy; let formatResult: boolean | undefined; - let model: ContentModelDocument | undefined; + let mockedModel: ContentModelDocument; beforeEach(() => { - createContentModel = jasmine.createSpy('createContentModel'); - - model = undefined; + mockedModel = undefined; formatResult = undefined; formatContentModel = jasmine .createSpy('formatContentModel') .and.callFake( (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - model = createContentModel(); - - formatResult = callback(model, { + formatResult = callback(mockedModel, { newEntities: [], deletedEntities: [], newImages: [], @@ -54,7 +49,7 @@ describe('adjustLinkSelection', () => { expectedText: string, expectedUrl: string | null ) { - createContentModel.and.returnValue(model); + mockedModel = model; const [text, url] = adjustLinkSelection(editor); 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 176da2eee1d..96e9ed0ac03 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 @@ -403,4 +403,47 @@ describe('insertLink', () => { ], }); }); + + it('Invalid url', () => { + const doc = createContentModelDocument(); + addSegment(doc, createSelectionMarker()); + + const url = 'javasc\nript:onC\nlick()'; + let formatResult: boolean | undefined; + const formatContentModel = jasmine + .createSpy('formatContentModel') + .and.callFake( + (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { + formatResult = callback(doc, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); + } + ); + + editor.formatContentModel = formatContentModel; + + insertLink(editor, url); + + expect(formatContentModel).toHaveBeenCalledTimes(0); + expect(formatResult).toBeFalsy(); + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + isImplicit: true, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + }); + }); }); diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/changeFontSizeTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/changeFontSizeTest.ts index 69de2270a9b..b0ce78a6754 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/changeFontSizeTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/changeFontSizeTest.ts @@ -1,6 +1,6 @@ import changeFontSize from '../../../lib/publicApi/segment/changeFontSize'; import { createDomToModelContext, domToContentModel } from 'roosterjs-content-model-dom'; -import { createRange } from 'roosterjs-editor-dom'; +import { createRange } from 'roosterjs-content-model-dom/test/testUtils'; import { IStandaloneEditor } from 'roosterjs-content-model-types'; import { segmentTestCommon } from './segmentTestCommon'; import { @@ -342,6 +342,7 @@ describe('changeFontSize', () => { const model = domToContentModel(div, createDomToModelContext(undefined), { type: 'range', range: createRange(sub), + isReverted: false, }); let formatResult: boolean | undefined; diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/segmentTestCommon.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/segmentTestCommon.ts index 4ed22a5ea0c..99205b8e38b 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/segmentTestCommon.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/segmentTestCommon.ts @@ -1,5 +1,4 @@ import { IStandaloneEditor } from 'roosterjs-content-model-types'; -import { NodePosition } from 'roosterjs-editor-types'; import { ContentModelDocument, ContentModelFormatter, @@ -26,7 +25,6 @@ export function segmentTestCommon( }); const editor = ({ focus: jasmine.createSpy(), - getFocusedPosition: () => null as NodePosition, getPendingFormat: () => null as any, formatContentModel, } as any) as IStandaloneEditor; diff --git a/packages-content-model/roosterjs-content-model-core/lib/coreApi/addUndoSnapshot.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/addUndoSnapshot.ts index dccba9db403..c2bcdcf9eb2 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/coreApi/addUndoSnapshot.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/addUndoSnapshot.ts @@ -11,17 +11,19 @@ import type { AddUndoSnapshot, Snapshot } from 'roosterjs-content-model-types'; * when undo/redo to this snapshot */ export const addUndoSnapshot: AddUndoSnapshot = (core, canUndoByBackspace, entityStates) => { - const { lifecycle, api, contentDiv, undo } = core; + const { lifecycle, contentDiv, undo } = core; let snapshot: Snapshot | null = null; if (!lifecycle.shadowEditFragment) { - const selection = api.getDOMSelection(core); + // Need to create snapshot selection before retrieve innerHTML since HTML can be changed during creating selection when normalize table + const selection = createSnapshotSelection(core); + const html = contentDiv.innerHTML; snapshot = { - html: contentDiv.innerHTML, + html, entityStates, isDarkMode: !!lifecycle.isDarkMode, - selection: createSnapshotSelection(contentDiv, selection), + selection, }; undo.snapshotsManager.addSnapshot(snapshot, !!canUndoByBackspace); 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 3d01c9aeaa5..42f09475ec6 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 @@ -4,12 +4,7 @@ import { createDomToModelContextWithConfig, domToContentModel, } from 'roosterjs-content-model-dom'; -import type { - DOMSelection, - DomToModelOption, - CreateContentModel, - StandaloneEditorCore, -} from 'roosterjs-content-model-types'; +import type { CreateContentModel } from 'roosterjs-content-model-types'; /** * @internal @@ -19,6 +14,9 @@ import type { * @param selectionOverride When passed, use this selection range instead of current selection in editor */ export const createContentModel: CreateContentModel = (core, option, selectionOverride) => { + // Flush all mutations if any, so that we can get an up-to-date Content Model + core.cache.textMutationObserver?.flushMutations(); + let cachedModel = selectionOverride ? null : core.cache.cachedModel; if (cachedModel && core.lifecycle.shadowEditFragment) { @@ -30,9 +28,20 @@ export const createContentModel: CreateContentModel = (core, option, selectionOv return cachedModel; } else { const selection = selectionOverride || core.api.getDOMSelection(core) || undefined; - const model = internalCreateContentModel(core, selection, option); + const saveIndex = !option && !selectionOverride; + const editorContext = core.api.createEditorContext(core, saveIndex); + const domToModelContext = option + ? createDomToModelContext( + editorContext, + core.domToModelSettings.builtIn, + core.domToModelSettings.customized, + option + ) + : createDomToModelContextWithConfig(core.domToModelSettings.calculated, editorContext); + + const model = domToContentModel(core.contentDiv, domToModelContext, selection); - if (!option && !selectionOverride) { + if (saveIndex) { core.cache.cachedModel = model; core.cache.cachedSelection = selection; } @@ -40,21 +49,3 @@ export const createContentModel: CreateContentModel = (core, option, selectionOv return model; } }; - -function internalCreateContentModel( - core: StandaloneEditorCore, - selection?: DOMSelection, - option?: DomToModelOption -) { - const editorContext = core.api.createEditorContext(core); - const domToModelContext = option - ? 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/createEditorContext.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/createEditorContext.ts index f9a1329f461..2073a4403f5 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/coreApi/createEditorContext.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/createEditorContext.ts @@ -4,8 +4,8 @@ import type { EditorContext, CreateEditorContext } from 'roosterjs-content-model * @internal * Create a EditorContext object used by ContentModel API */ -export const createEditorContext: CreateEditorContext = core => { - const { lifecycle, format, darkColorHandler, contentDiv, cache } = core; +export const createEditorContext: CreateEditorContext = (core, saveIndex) => { + const { lifecycle, format, darkColorHandler, contentDiv, cache, domHelper } = core; const context: EditorContext = { isDarkMode: lifecycle.isDarkMode, @@ -14,24 +14,15 @@ export const createEditorContext: CreateEditorContext = core => { darkColorHandler: darkColorHandler, addDelimiterForEntity: true, allowCacheElement: true, - domIndexer: cache.domIndexer, + domIndexer: saveIndex ? cache.domIndexer : undefined, + zoomScale: domHelper.calculateZoomScale(), }; checkRootRtl(contentDiv, context); - checkZoomScale(contentDiv, context); return context; }; -function checkZoomScale(element: HTMLElement, context: EditorContext) { - const originalWidth = element?.getBoundingClientRect()?.width || 0; - const visualWidth = element.offsetWidth; - - if (visualWidth > 0 && originalWidth > 0) { - context.zoomScale = Math.round((originalWidth / visualWidth) * 100) / 100; - } -} - function checkRootRtl(element: HTMLElement, context: EditorContext) { const style = element?.ownerDocument.defaultView?.getComputedStyle(element); diff --git a/packages-content-model/roosterjs-content-model-core/lib/coreApi/formatContentModel.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/formatContentModel.ts index 4f3c8bed1ec..dd11ef571ac 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/coreApi/formatContentModel.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/formatContentModel.ts @@ -21,7 +21,6 @@ import type { export const formatContentModel: FormatContentModel = (core, formatter, options) => { const { apiName, onNodeCreated, getChangeData, changeSource, rawEvent, selectionOverride } = options || {}; - const model = core.api.createContentModel(core, undefined /*option*/, selectionOverride); const context: FormatWithContentModelContext = { newEntities: [], diff --git a/packages-content-model/roosterjs-content-model-core/lib/coreApi/getDOMSelection.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/getDOMSelection.ts index a693df4c426..ee4c7f8e26a 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/coreApi/getDOMSelection.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/getDOMSelection.ts @@ -8,9 +8,15 @@ import type { * @internal */ export const getDOMSelection: GetDOMSelection = core => { - return core.lifecycle.shadowEditFragment - ? null - : core.selection.selection ?? getNewSelection(core); + if (core.lifecycle.shadowEditFragment) { + return null; + } else { + const selection = core.selection.selection; + + return selection && (selection.type != 'range' || !core.api.hasFocus(core)) + ? selection + : getNewSelection(core); + } }; function getNewSelection(core: StandaloneEditorCore): DOMSelection | null { @@ -20,7 +26,21 @@ function getNewSelection(core: StandaloneEditorCore): DOMSelection | null { return range && core.contentDiv.contains(range.commonAncestorContainer) ? { type: 'range', - range: range, + range, + isReverted: isSelectionReverted(selection), } : null; } + +function isSelectionReverted(selection: Selection | null | undefined): boolean { + if (selection && selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + return ( + !range.collapsed && + selection.focusNode != range.endContainer && + selection.focusOffset != range.endOffset + ); + } + + return false; +} 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 index 1e2849b50bd..aee4fbf2dc3 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/coreApi/paste.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/paste.ts @@ -1,4 +1,3 @@ -import { ChangeSource } from '../constants/ChangeSource'; import { cloneModel } from '../publicApi/model/cloneModel'; import { convertInlineCss } from '../utils/paste/convertInlineCss'; import { createPasteFragment } from '../utils/paste/createPasteFragment'; @@ -38,49 +37,37 @@ export const paste: Paste = ( 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); + // 1. Prepare variables + const doc = createDOMFromHtml(clipboardData.rawHtml, core.trustedHTMLHandler); - // 3. Create target fragment - const sourceFragment = createPasteFragment( - core.contentDiv.ownerDocument, - clipboardData, - pasteType, - (clipboardData.rawHtml == clipboardData.html - ? doc - : createDOMFromHtml(clipboardData.html, core.trustedHTMLHandler) - )?.body - ); + // 2. Handle HTML from clipboard + const htmlFromClipboard = retrieveHtmlInfo(doc, clipboardData); - // 4. Trigger BeforePaste event to allow plugins modify the fragment - const eventResult = generatePasteOptionFromPlugins( - core, - clipboardData, - sourceFragment, - htmlFromClipboard, - pasteType - ); + // 3. Create target fragment + const sourceFragment = createPasteFragment( + core.contentDiv.ownerDocument, + clipboardData, + pasteType, + (clipboardData.rawHtml == clipboardData.html + ? doc + : createDOMFromHtml(clipboardData.html, core.trustedHTMLHandler) + )?.body + ); - // 5. Convert global CSS to inline CSS - convertInlineCss(eventResult.fragment, htmlFromClipboard.globalCssRules); + // 4. Trigger BeforePaste event to allow plugins modify the fragment + const eventResult = generatePasteOptionFromPlugins( + core, + clipboardData, + sourceFragment, + htmlFromClipboard, + pasteType + ); - // 6. Merge pasted content into main Content Model - mergePasteContent(model, context, eventResult, core.domToModelSettings.customized); + // 5. Convert global CSS to inline CSS + convertInlineCss(eventResult.fragment, htmlFromClipboard.globalCssRules); - return true; - }, - { - changeSource: ChangeSource.Paste, - getChangeData: () => clipboardData, - apiName: 'paste', - } - ); + // 6. Merge pasted content into main Content Model + mergePasteContent(core, eventResult, clipboardData); }; function createDOMFromHtml( 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 ef396794bfc..bf776ab6b70 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 @@ -13,7 +13,7 @@ import type { SetContentModel } from 'roosterjs-content-model-types'; * @param option Additional options to customize the behavior of Content Model to DOM conversion */ export const setContentModel: SetContentModel = (core, model, option, onNodeCreated) => { - const editorContext = core.api.createEditorContext(core); + const editorContext = core.api.createEditorContext(core, true /*saveIndex*/); const modelToDomContext = option ? createModelToDomContext( editorContext, @@ -23,12 +23,13 @@ export const setContentModel: SetContentModel = (core, model, option, onNodeCrea ) : createModelToDomContextWithConfig(core.modelToDomSettings.calculated, editorContext); + modelToDomContext.onNodeCreated = onNodeCreated; + const selection = contentModelToDom( core.contentDiv.ownerDocument, core.contentDiv, model, - modelToDomContext, - onNodeCreated + modelToDomContext ); if (!core.lifecycle.shadowEditFragment) { @@ -36,10 +37,12 @@ export const setContentModel: SetContentModel = (core, model, option, onNodeCrea if (!option?.ignoreSelection && selection) { core.api.setDOMSelection(core, selection); - } else if (!selection || selection.type !== 'range') { + } else { core.selection.selection = selection; } + // Clear pending mutations since we will use our latest model object to replace existing cache + core.cache.textMutationObserver?.flushMutations(); core.cache.cachedModel = model; } 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 55432d099d0..9f40ab68127 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 @@ -58,7 +58,7 @@ export const setDOMSelection: SetDOMSelection = (core, selection, skipSelectionC setRangeSelection(doc, table.rows[firstRow]?.cells[firstColumn]); break; case 'range': - addRangeToSelection(doc, selection.range); + addRangeToSelection(doc, selection.range, selection.isReverted); core.selection.selection = core.api.hasFocus(core) ? null : selection; break; diff --git a/packages-content-model/roosterjs-content-model-core/lib/coreApi/switchShadowEdit.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/switchShadowEdit.ts index 2829c58c888..c2053bd4351 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/coreApi/switchShadowEdit.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/switchShadowEdit.ts @@ -9,15 +9,11 @@ import type { SwitchShadowEdit } from 'roosterjs-content-model-types'; * @param isOn True to switch On, False to switch Off */ export const switchShadowEdit: SwitchShadowEdit = (editorCore, isOn): void => { - // TODO: Use strong-typed editor core object const core = editorCore; if (isOn != !!core.lifecycle.shadowEditFragment) { if (isOn) { const model = !core.cache.cachedModel ? core.api.createContentModel(core) : null; - - // Fake object, not used in Content Model Editor, just to satisfy original editor code - // TODO: we can remove them once we have standalone Content Model Editor const fragment = core.contentDiv.ownerDocument.createDocumentFragment(); const clonedRoot = core.contentDiv.cloneNode(true /*deep*/); 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 892a85cb1e5..5d42d070a08 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 @@ -1,10 +1,9 @@ import { areSameSelection } from './utils/areSameSelection'; import { contentModelDomIndexer } from './utils/contentModelDomIndexer'; -import { isCharacterValue } from '../publicApi/domUtils/eventUtils'; +import { createTextMutationObserver } from './utils/textMutationObserver'; import type { ContentModelCachePluginState, IStandaloneEditor, - KeyDownEvent, PluginEvent, PluginWithState, StandaloneEditorOptions, @@ -20,11 +19,15 @@ class ContentModelCachePlugin implements PluginWithState { + if (this.editor) { + if (isTextChangeOnly) { + this.updateCachedModel(this.editor, true /*forceUpdate*/); + } else { + this.invalidateCache(); + } + } + }; + private onNativeSelectionChange = () => { if (this.editor?.hasFocus()) { this.updateCachedModel(this.editor); @@ -150,48 +160,17 @@ class ContentModelCachePlugin implements PluginWithState { - return new ContentModelCachePlugin(option); + return new ContentModelCachePlugin(option, contentDiv); } 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 b727f9b8d71..0b79d4cb758 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 @@ -1,12 +1,11 @@ import { addRangeToSelection } from './utils/addRangeToSelection'; import { ChangeSource } from '../constants/ChangeSource'; -import { cloneModel } from '../publicApi/model/cloneModel'; import { deleteEmptyList } from './utils/deleteEmptyList'; import { deleteSelection } from '../publicApi/selection/deleteSelection'; import { extractClipboardItems } from '../utils/extractClipboardItems'; import { getSelectedCells } from '../publicApi/table/getSelectedCells'; import { iterateSelections } from '../publicApi/selection/iterateSelections'; -import { transformColor } from '../publicApi/color/transformColor'; + import { contentModelToDom, createModelToDomContext, @@ -109,12 +108,7 @@ class ContentModelCopyPastePlugin implements PluginWithState { @@ -130,12 +124,15 @@ class ContentModelCopyPastePlugin implements PluginWithState { - if (type == 'cache' || !this.editor) { - return undefined; - } - - const result = node.cloneNode(true /*deep*/) as HTMLElement; - const colorHandler = this.editor.getColorManager(); - - transformColor(result, true /*includeSelf*/, 'darkToLight', colorHandler); - - result.style.color = result.style.color || 'inherit'; - result.style.backgroundColor = result.style.backgroundColor || 'inherit'; - - return result; - }; } /** 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 9a095d341c6..4219a3728c4 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 @@ -48,7 +48,6 @@ class ContentModelFormatPlugin implements PluginWithState { + private editor: IStandaloneEditor | null = null; + private state: ContextMenuPluginState; + private disposer: (() => void) | null = null; + + /** + * Construct a new instance of EditPlugin + * @param options The editor options + */ + constructor(options: StandaloneEditorOptions) { + this.state = { + contextMenuProviders: + options.plugins?.filter>(isContextMenuProvider) || [], + }; + } + + /** + * Get a friendly name of this plugin + */ + getName() { + return 'ContextMenu'; + } + + /** + * Initialize this plugin. This should only be called from Editor + * @param editor Editor instance + */ + initialize(editor: IStandaloneEditor) { + this.editor = editor; + this.disposer = this.editor.attachDomEvent({ + contextmenu: { + beforeDispatch: this.onContextMenuEvent, + }, + }); + } + + /** + * Dispose this plugin + */ + dispose() { + this.disposer?.(); + this.disposer = null; + this.editor = null; + } + + /** + * Get plugin state object + */ + getState() { + return this.state; + } + + private onContextMenuEvent = (e: Event) => { + if (this.editor) { + const allItems: any[] = []; + const mouseEvent = e as MouseEvent; + + // ContextMenu event can be triggered from mouse right click or keyboard (e.g. Shift+F10 on Windows) + // Need to check if this is from keyboard, we need to get target node from selection because in that case + // event.target is always the element that attached context menu event, here it will be editor content div. + const targetNode = + mouseEvent.button == ContextMenuButton + ? (mouseEvent.target as Node) + : this.getFocusedNode(this.editor); + + if (targetNode) { + this.state.contextMenuProviders.forEach(provider => { + const items = provider.getContextMenuItems(targetNode) ?? []; + if (items?.length > 0) { + if (allItems.length > 0) { + allItems.push(null); + } + + allItems.push(...items); + } + }); + } + + this.editor?.triggerEvent('contextMenu', { + rawEvent: mouseEvent, + items: allItems, + }); + } + }; + + private getFocusedNode(editor: IStandaloneEditor) { + const selection = editor.getDOMSelection(); + + if (selection) { + if (selection.type == 'range') { + selection.range.collapse(true /*toStart*/); + } + + return getSelectionRootNode(selection) || null; + } else { + return null; + } + } +} + +function isContextMenuProvider(source: unknown): source is ContextMenuProvider { + return !!(>source)?.getContextMenuItems; +} + +/** + * @internal + * Create a new instance of EditPlugin. + */ +export function createContextMenuPlugin( + options: StandaloneEditorOptions +): PluginWithState { + return new ContextMenuPlugin(options); +} 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 62319fc47bc..cdb1545b433 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 @@ -97,7 +97,7 @@ class EntityPlugin implements PluginWithState { let node: Node | null = rawEvent.target as Node; if (isClicking && this.editor) { - while (node && this.editor.isNodeInEditor(node)) { + while (node && this.editor.getDOMHelper().isNodeInEditor(node)) { if (isEntityElement(node)) { this.triggerEvent(editor, node as HTMLElement, 'click', rawEvent); break; @@ -175,7 +175,10 @@ class EntityPlugin implements PluginWithState { private getChangedEntities(editor: IStandaloneEditor): ChangedEntity[] { const result: ChangedEntity[] = []; - findAllEntities(editor.createContentModel(), result); + editor.formatContentModel(model => { + findAllEntities(model, result); + return false; + }); getObjectKeys(this.state.entityMap).forEach(id => { const entry = this.state.entityMap[id]; 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 9f393bf3767..77875155d38 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 @@ -1,14 +1,6 @@ import { ChangeSource } from '../constants/ChangeSource'; -import { - createBr, - createContentModelDocument, - createParagraph, - createSelectionMarker, - setColor, -} from 'roosterjs-content-model-dom'; +import { setColor } from 'roosterjs-content-model-dom'; import type { - ContentModelDocument, - ContentModelSegmentFormat, IStandaloneEditor, LifecyclePluginState, PluginEvent, @@ -26,7 +18,6 @@ const DefaultBackColor = '#ffffff'; class LifecyclePlugin implements PluginWithState { private editor: IStandaloneEditor | null = null; private state: LifecyclePluginState; - private initialModel: ContentModelDocument; private initializer: (() => void) | null = null; private disposer: (() => void) | null = null; private adjustColor: () => void; @@ -37,9 +28,6 @@ class LifecyclePlugin implements PluginWithState { * @param contentDiv The editor content DIV */ constructor(options: StandaloneEditorOptions, contentDiv: HTMLDivElement) { - this.initialModel = - options.initialModel ?? this.createInitModel(options.defaultSegmentFormat); - // Make the container editable and set its selection styles if (contentDiv.getAttribute(ContentEditableAttributeName) === null) { this.initializer = () => { @@ -77,12 +65,6 @@ class LifecyclePlugin implements PluginWithState { initialize(editor: IStandaloneEditor) { this.editor = editor; - this.editor.setContentModel(this.initialModel, { ignoreSelection: true }); - - // Initial model is only used once. After that we can just clean it up to make sure we don't cache anything useless - // including the cached DOM element inside the model. - this.initialModel = createContentModelDocument(); - // Set content DIV to be editable this.initializer?.(); @@ -150,16 +132,6 @@ class LifecyclePlugin implements PluginWithState { ); } } - - private createInitModel(format?: ContentModelSegmentFormat) { - const model = createContentModelDocument(format); - const paragraph = createParagraph(false /*isImplicit*/, undefined /*blockFormat*/, format); - - paragraph.segments.push(createSelectionMarker(format), createBr(format)); - model.blocks.push(paragraph); - - return model; - } } /** 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 f06475293d9..87a0efb2370 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 @@ -16,12 +16,13 @@ class SelectionPlugin implements PluginWithState { private editor: IStandaloneEditor | null = null; private state: SelectionPluginState; private disposer: (() => void) | null = null; + private isSafari = false; constructor(options: StandaloneEditorOptions) { this.state = { selection: null, selectionStyleNode: null, - imageSelectionBorderColor: options.imageSelectionBorderColor, // TODO: Move to Selection core plugin + imageSelectionBorderColor: options.imageSelectionBorderColor, }; } @@ -41,10 +42,10 @@ class SelectionPlugin implements PluginWithState { const env = this.editor.getEnvironment(); const document = this.editor.getDocument(); - if (env.isSafari) { - document.addEventListener('mousedown', this.onMouseDownDocument, true /*useCapture*/); - document.addEventListener('keydown', this.onKeyDownDocument); - document.defaultView?.addEventListener('blur', this.onBlur); + this.isSafari = !!env.isSafari; + + if (this.isSafari) { + document.addEventListener('selectionchange', this.onSelectionChangeSafari); this.disposer = this.editor.attachDomEvent({ focus: { beforeDispatch: this.onFocus } }); } else { this.disposer = this.editor.attachDomEvent({ @@ -55,6 +56,10 @@ class SelectionPlugin implements PluginWithState { } dispose() { + this.editor + ?.getDocument() + .removeEventListener('selectionchange', this.onSelectionChangeSafari); + if (this.state.selectionStyleNode) { this.state.selectionStyleNode.parentNode?.removeChild(this.state.selectionStyleNode); this.state.selectionStyleNode = null; @@ -65,19 +70,7 @@ class SelectionPlugin implements PluginWithState { this.disposer = null; } - if (this.editor) { - const document = this.editor.getDocument(); - - document.removeEventListener( - 'mousedown', - this.onMouseDownDocument, - true /*useCapture*/ - ); - document.removeEventListener('keydown', this.onKeyDownDocument); - document.defaultView?.removeEventListener('blur', this.onBlur); - - this.editor = null; - } + this.editor = null; } getState(): SelectionPluginState { @@ -162,6 +155,7 @@ class SelectionPlugin implements PluginWithState { editor.setDOMSelection({ type: 'range', range: range, + isReverted: false, }); } } @@ -179,7 +173,7 @@ class SelectionPlugin implements PluginWithState { this.editor?.setDOMSelection(this.state.selection); } - if (this.state.selection?.type == 'range') { + if (this.state.selection?.type == 'range' && !this.isSafari) { // Editor is focused, now we can get live selection. So no need to keep a selection if the selection type is range. this.state.selection = null; } @@ -191,15 +185,15 @@ class SelectionPlugin implements PluginWithState { } }; - private onKeyDownDocument = (event: KeyboardEvent) => { - if (event.key == 'Tab' && !event.defaultPrevented) { - this.onBlur(); - } - }; + private onSelectionChangeSafari = () => { + if (this.editor?.hasFocus() && !this.editor.isInShadowEdit()) { + // Safari has problem to handle onBlur event. When blur, we cannot get the original selection from editor. + // So we always save a selection whenever editor has focus. Then after blur, we can still use this cached selection. + const newSelection = this.editor.getDOMSelection(); - private onMouseDownDocument = (event: MouseEvent) => { - if (this.editor && !this.editor.isNodeInEditor(event.target as Node)) { - this.onBlur(); + if (newSelection?.type == 'range') { + this.state.selection = newSelection; + } } }; } diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/createStandaloneEditorCorePlugins.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/createStandaloneEditorCorePlugins.ts index 9ecbed73c79..c329d7a33d1 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/createStandaloneEditorCorePlugins.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/createStandaloneEditorCorePlugins.ts @@ -1,6 +1,7 @@ import { createContentModelCachePlugin } from './ContentModelCachePlugin'; import { createContentModelCopyPastePlugin } from './ContentModelCopyPastePlugin'; import { createContentModelFormatPlugin } from './ContentModelFormatPlugin'; +import { createContextMenuPlugin } from './ContextMenuPlugin'; import { createDOMEventPlugin } from './DOMEventPlugin'; import { createEntityPlugin } from './EntityPlugin'; import { createLifecyclePlugin } from './LifecyclePlugin'; @@ -21,13 +22,14 @@ export function createStandaloneEditorCorePlugins( contentDiv: HTMLDivElement ): StandaloneEditorCorePlugins { return { - cache: createContentModelCachePlugin(options), + cache: createContentModelCachePlugin(options, contentDiv), format: createContentModelFormatPlugin(options), copyPaste: createContentModelCopyPastePlugin(options), domEvent: createDOMEventPlugin(options, contentDiv), lifecycle: createLifecyclePlugin(options, contentDiv), entity: createEntityPlugin(), selection: createSelectionPlugin(options), + contextMenu: createContextMenuPlugin(options), undo: createUndoPlugin(options), }; } diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/addRangeToSelection.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/addRangeToSelection.ts index e7a53dbe53e..bb128dd0a46 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/addRangeToSelection.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/addRangeToSelection.ts @@ -1,11 +1,21 @@ /** * @internal */ -export function addRangeToSelection(doc: Document, range: Range) { +export function addRangeToSelection(doc: Document, range: Range, isReverted: boolean = false) { const selection = doc.defaultView?.getSelection(); if (selection) { selection.removeAllRanges(); - selection.addRange(range); + + if (!isReverted) { + selection.addRange(range); + } else { + selection.setBaseAndExtent( + range.endContainer, + range.endOffset, + range.startContainer, + range.startOffset + ); + } } } 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 56defc297df..3ddd87d15cb 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 @@ -20,7 +20,7 @@ export function applyDefaultFormat( if (posContainer) { let node: Node | null = posContainer; - while (node && editor.isNodeInEditor(node)) { + while (node && editor.getDOMHelper().isNodeInEditor(node)) { if (isNodeOfType(node, 'ELEMENT_NODE')) { if (node.getAttribute?.('style')) { return; diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/textMutationObserver.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/textMutationObserver.ts new file mode 100644 index 00000000000..42790400a69 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/textMutationObserver.ts @@ -0,0 +1,53 @@ +import type { TextMutationObserver } from 'roosterjs-content-model-types'; + +class TextMutationObserverImpl implements TextMutationObserver { + private observer: MutationObserver; + + constructor( + private contentDiv: HTMLDivElement, + private onMutation: (isTextChangeOnly: boolean) => void + ) { + this.observer = new MutationObserver(this.onMutationInternal); + } + + startObserving() { + this.observer.observe(this.contentDiv, { + subtree: true, + childList: true, + attributes: true, + characterData: true, + }); + } + + stopObserving() { + this.observer.disconnect(); + } + + flushMutations() { + const mutations = this.observer.takeRecords(); + + this.onMutationInternal(mutations); + } + + private onMutationInternal = (mutations: MutationRecord[]) => { + const firstTarget = mutations[0]?.target; + + if (firstTarget) { + const isTextChangeOnly = mutations.every( + mutation => mutation.type == 'characterData' && mutation.target == firstTarget + ); + + this.onMutation(isTextChangeOnly); + } + }; +} + +/** + * @internal + */ +export function createTextMutationObserver( + contentDiv: HTMLDivElement, + onMutation: (isTextChangeOnly: boolean) => void +): TextMutationObserver { + return new TextMutationObserverImpl(contentDiv, onMutation); +} diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/DOMHelperImpl.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/DOMHelperImpl.ts new file mode 100644 index 00000000000..7a49f6bbcce --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/DOMHelperImpl.ts @@ -0,0 +1,30 @@ +import { toArray } from 'roosterjs-content-model-dom'; +import type { DOMHelper } from 'roosterjs-content-model-types'; + +class DOMHelperImpl implements DOMHelper { + constructor(private contentDiv: HTMLElement) {} + + queryElements(selector: string): HTMLElement[] { + return toArray(this.contentDiv.querySelectorAll(selector)) as HTMLElement[]; + } + + isNodeInEditor(node: Node): boolean { + return this.contentDiv.contains(node); + } + + calculateZoomScale(): number { + const originalWidth = this.contentDiv.getBoundingClientRect()?.width || 0; + const visualWidth = this.contentDiv.offsetWidth; + + return visualWidth > 0 && originalWidth > 0 + ? Math.round((originalWidth / visualWidth) * 100) / 100 + : 1; + } +} + +/** + * @internal Create new instance of DOMHelper + */ +export function createDOMHelper(contentDiv: HTMLElement): DOMHelper { + return new DOMHelperImpl(contentDiv); +} 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 index 00e76f285a0..43095b2d18a 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/editor/StandaloneEditor.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/StandaloneEditor.ts @@ -1,6 +1,10 @@ import { ChangeSource } from '../constants/ChangeSource'; +import { cloneModel } from '../publicApi/model/cloneModel'; +import { createEmptyModel, tableProcessor } from 'roosterjs-content-model-dom'; import { createStandaloneEditorCore } from './createStandaloneEditorCore'; +import { reducedModelChildProcessor } from '../override/reducedModelChildProcessor'; import { transformColor } from '../publicApi/color/transformColor'; +import type { CachedElementHandler } from '../publicApi/model/cloneModel'; import type { ClipboardData, ContentModelDocument, @@ -8,13 +12,11 @@ import type { ContentModelSegmentFormat, DarkColorHandler, DOMEventRecord, + DOMHelper, DOMSelection, - DomToModelOption, EditorEnvironment, FormatWithContentModelOptions, IStandaloneEditor, - ModelToDomOption, - OnNodeCreated, PasteType, PluginEventData, PluginEventFromType, @@ -46,7 +48,10 @@ export class StandaloneEditor implements IStandaloneEditor { onBeforeInitializePlugins?.(); - this.getCore().plugins.forEach(plugin => plugin.initialize(this)); + const initialModel = options.initialModel ?? createEmptyModel(options.defaultSegmentFormat); + + this.core.api.setContentModel(this.core, initialModel, { ignoreSelection: true }); + this.core.plugins.forEach(plugin => plugin.initialize(this)); } /** @@ -81,31 +86,38 @@ export class StandaloneEditor implements IStandaloneEditor { /** * Create Content Model from DOM tree in this editor - * @param option The option to customize the behavior of DOM to Content Model conversion + * @param mode What kind of Content Model we want. Currently we support the following values: + * - connected: Returns a connect Content Model object. "Connected" means if there is any entity inside editor, the returned Content Model will + * contain the same wrapper element for entity. This option should only be used in some special cases. In most cases we should use "disconnected" + * to get a fully disconnected Content Model so that any change to the model will not impact editor content. + * - disconnected: Returns a disconnected clone of Content Model from editor which you can do any change on it and it won't impact the editor content. + * If there is any entity in editor, the returned object will contain cloned copy of entity wrapper element. + * If editor is in dark mode, the cloned entity will be converted back to light mode. + * - reduced: Returns a reduced Content Model that only contains the model of current selection. If there is already a up-to-date cached model, use it + * instead to improve performance. This is mostly used for retrieve current format state. */ - createContentModel( - option?: DomToModelOption, - selectionOverride?: DOMSelection - ): ContentModelDocument { + getContentModelCopy(mode: 'connected' | 'disconnected' | 'reduced'): 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); + switch (mode) { + case 'connected': + return core.api.createContentModel(core, { + processorOverride: { + table: tableProcessor, // Use the original table processor to create Content Model with real table content but not just an entity + }, + }); + + case 'disconnected': + return cloneModel(core.api.createContentModel(core), { + includeCachedElement: this.cloneOptionCallback, + }); + case 'reduced': + return core.api.createContentModel(core, { + processorOverride: { + child: reducedModelChildProcessor, + }, + }); + } } /** @@ -158,6 +170,13 @@ export class StandaloneEditor implements IStandaloneEditor { return this.getCore().format.pendingFormat?.format ?? null; } + /** + * Get a DOM Helper object to help access DOM tree in editor + */ + getDOMHelper(): DOMHelper { + return this.getCore().domHelper; + } + /** * Add a single undo snapshot to undo stack */ @@ -336,52 +355,6 @@ export class StandaloneEditor implements IStandaloneEditor { 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; - } - - /** - * Set 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 - * @param scale The new scale number to set. It should be positive number and no greater than 10, otherwise it will be ignored. - */ - setZoomScale(scale: number): void { - const core = this.getCore(); - - if (scale > 0 && scale <= 10) { - const oldValue = core.zoomScale; - core.zoomScale = scale; - - if (oldValue != scale) { - this.triggerEvent( - 'zoomChanged', - { - oldZoomScale: oldValue, - newZoomScale: scale, - }, - true /*broadcast*/ - ); - } - } - } - /** * Get a function to convert HTML string to trusted HTML string. * By default it will just return the input HTML directly. To override this behavior, @@ -402,4 +375,23 @@ export class StandaloneEditor implements IStandaloneEditor { } return this.core; } + + private cloneOptionCallback: CachedElementHandler = (node, type) => { + if (type == 'cache') { + return undefined; + } + + const result = node.cloneNode(true /*deep*/) as HTMLElement; + + if (this.isDarkMode()) { + const colorHandler = this.getColorManager(); + + transformColor(result, true /*includeSelf*/, 'darkToLight', colorHandler); + + result.style.color = result.style.color || 'inherit'; + result.style.backgroundColor = result.style.backgroundColor || 'inherit'; + } + + return result; + }; } 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 d208bc568b4..b4680abdb9e 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,4 +1,5 @@ import { createDarkColorHandler } from './DarkColorHandlerImpl'; +import { createDOMHelper } from './DOMHelperImpl'; import { createStandaloneEditorCorePlugins } from '../corePlugin/createStandaloneEditorCorePlugins'; import { standaloneCoreApiMap } from './standaloneCoreApiMap'; import { @@ -38,6 +39,7 @@ export function createStandaloneEditorCore( corePlugins.entity, ...(options.plugins ?? []).filter(x => !!x), corePlugins.undo, + corePlugins.contextMenu, corePlugins.lifecycle, ], environment: createEditorEnvironment(contentDiv), @@ -49,9 +51,9 @@ export function createStandaloneEditorCore( trustedHTMLHandler: options.trustedHTMLHandler || defaultTrustHtmlHandler, domToModelSettings: createDomToModelSettings(options), modelToDomSettings: createModelToDomSettings(options), + domHelper: createDOMHelper(contentDiv), ...getPluginState(corePlugins), disposeErrorHandler: options.disposeErrorHandler, - zoomScale: (options.zoomScale ?? -1) > 0 ? options.zoomScale! : 1, }; } @@ -86,6 +88,7 @@ function getPluginState(corePlugins: StandaloneEditorCorePlugins): PluginState { lifecycle: corePlugins.lifecycle.getState(), entity: corePlugins.entity.getState(), selection: corePlugins.selection.getState(), + contextMenu: corePlugins.contextMenu.getState(), undo: corePlugins.undo.getState(), }; } diff --git a/packages-content-model/roosterjs-content-model-core/lib/override/reducedModelChildProcessor.ts b/packages-content-model/roosterjs-content-model-core/lib/override/reducedModelChildProcessor.ts new file mode 100644 index 00000000000..f49536f187e --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/override/reducedModelChildProcessor.ts @@ -0,0 +1,93 @@ +import { getSelectionRootNode } from '../publicApi/selection/getSelectionRootNode'; +import { + getRegularSelectionOffsets, + handleRegularSelection, + isNodeOfType, + processChildNode, +} from 'roosterjs-content-model-dom'; +import type { ContentModelBlockGroup, DomToModelContext } from 'roosterjs-content-model-types'; + +/** + * @internal + */ +interface FormatStateContext extends DomToModelContext { + /** + * An optional stack of parent elements to process. When provided, the child nodes of current parent element will be ignored, + * but use the top element in this stack instead in childProcessor. + */ + nodeStack?: Node[]; +} + +/** + * @internal + * Export for test only + * In order to get format, we can still use the regular child processor. However, to improve performance, we don't need to create + * content model for the whole doc, instead we only need to traverse the tree path that can arrive current selected node. + * This "reduced" child processor will first create a node stack that stores DOM node from root to current common ancestor node of selection, + * then use this stack as a faked DOM tree to create a reduced content model which we can use to retrieve format state + */ +export function reducedModelChildProcessor( + group: ContentModelBlockGroup, + parent: ParentNode, + context: FormatStateContext +) { + if (!context.nodeStack) { + const selectionRootNode = getSelectionRootNode(context.selection); + context.nodeStack = selectionRootNode ? createNodeStack(parent, selectionRootNode) : []; + } + + const stackChild = context.nodeStack.pop(); + + if (stackChild) { + const [nodeStartOffset, nodeEndOffset] = getRegularSelectionOffsets(context, parent); + + // If selection is not on this node, skip getting node index to save some time since we don't need it here + const index = + nodeStartOffset >= 0 || nodeEndOffset >= 0 ? getChildIndex(parent, stackChild) : -1; + + if (index >= 0) { + handleRegularSelection(index, context, group, nodeStartOffset, nodeEndOffset); + } + + processChildNode(group, stackChild, context); + + if (index >= 0) { + handleRegularSelection(index + 1, context, group, nodeStartOffset, nodeEndOffset); + } + } else { + // No child node from node stack, that means we have reached the deepest node of selection. + // Now we can use default child processor to perform full sub tree scanning for content model, + // So that all selected node will be included. + context.defaultElementProcessors.child(group, parent, context); + } +} + +function createNodeStack(root: Node, startNode: Node): Node[] { + const result: Node[] = []; + let node: Node | null = startNode; + + while (node && root != node && root.contains(node)) { + if (isNodeOfType(node, 'ELEMENT_NODE') && node.tagName == 'TABLE') { + // For table, we can't do a reduced model creation since we need to handle their cells and indexes, + // so clean up whatever we already have, and just put table into the stack + result.splice(0, result.length, node); + } else { + result.push(node); + } + + node = node.parentNode; + } + + return result; +} + +function getChildIndex(parent: ParentNode, stackChild: Node) { + let index = 0; + let child = parent.firstChild; + + while (child && child != stackChild) { + index++; + child = child.nextSibling; + } + return index; +} diff --git a/packages-content-model/roosterjs-content-model-core/lib/publicApi/domUtils/borderValues.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/domUtils/borderValues.ts index 11be7401d45..17fa4c1bd9a 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/publicApi/domUtils/borderValues.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/domUtils/borderValues.ts @@ -29,7 +29,7 @@ export function extractBorderValues(combinedBorder?: string): Border { } else if (BorderSizeRegex.test(v) && !result.width) { result.width = v; } else if (v && !result.color) { - result.color = v; // TODO: Do we need to use a regex to match all possible colors? + result.color = v; } }); diff --git a/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteBlock.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteBlock.ts index f85b93cf66a..df023246d3f 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteBlock.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteBlock.ts @@ -38,6 +38,9 @@ export function deleteBlock( : undefined; if (operation !== undefined) { + const wrapper = blockToDelete.wrapper; + + wrapper.parentNode?.removeChild(wrapper); replacement ? blocks.splice(index, 1, replacement) : blocks.splice(index, 1); context?.deletedEntities.push({ entity: blockToDelete, 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 87ffca8a863..de146b5d777 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 @@ -48,6 +48,9 @@ export function deleteSegment( ? 'removeFromEnd' : undefined; if (operation !== undefined) { + const wrapper = segmentToDelete.wrapper; + + wrapper.parentNode?.removeChild(wrapper); segments.splice(index, 1); context?.deletedEntities.push({ entity: segmentToDelete, @@ -83,8 +86,6 @@ export function deleteSegment( segments.splice(index, 1); return true; } else { - // No op if a general segment is not selected, let browser handle general segment - // TODO: Need to revisit this return false; } } diff --git a/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/iterateSelections.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/iterateSelections.ts index 33f39d2a987..2e1f45d84b9 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/iterateSelections.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/iterateSelections.ts @@ -68,8 +68,6 @@ export function iterateSelections( ): void { const internalCallback: IterateSelectionsCallback = (path, tableContext, block, segments) => { if (!!(block as ContentModelBlockWithCache)?.cachedElement) { - // TODO: This is a temporary solution. A better solution would be making all results from iterationSelection() to be readonly, - // use a util function to change it to be editable before edit them where we clear its cached element delete (block as ContentModelBlockWithCache).cachedElement; } diff --git a/packages-content-model/roosterjs-content-model-core/lib/utils/createSnapshotSelection.ts b/packages-content-model/roosterjs-content-model-core/lib/utils/createSnapshotSelection.ts index f01e75929d0..b15da47dd43 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/utils/createSnapshotSelection.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/utils/createSnapshotSelection.ts @@ -1,13 +1,39 @@ -import { isNodeOfType } from 'roosterjs-content-model-dom'; -import type { DOMSelection, SnapshotSelection } from 'roosterjs-content-model-types'; +import { isElementOfType, isNodeOfType, moveChildNodes } from 'roosterjs-content-model-dom'; +import type { SnapshotSelection, StandaloneEditorCore } from 'roosterjs-content-model-types'; /** * @internal */ -export function createSnapshotSelection( - contentDiv: HTMLElement, - selection: DOMSelection | null -): SnapshotSelection { +export function createSnapshotSelection(core: StandaloneEditorCore): SnapshotSelection { + const { contentDiv, api } = core; + const selection = api.getDOMSelection(core); + + // Normalize tables to ensure they have TBODY element between TABLE and TR so that the selection path will include correct values + if (selection?.type == 'range') { + const { startContainer, startOffset, endContainer, endOffset } = selection.range; + let isDOMChanged = normalizeTableTree(startContainer, contentDiv); + + if (endContainer != startContainer) { + isDOMChanged = normalizeTableTree(endContainer, contentDiv) || isDOMChanged; + } + + if (isDOMChanged) { + const newRange = contentDiv.ownerDocument.createRange(); + + newRange.setStart(startContainer, startOffset); + newRange.setEnd(endContainer, endOffset); + api.setDOMSelection( + core, + { + type: 'range', + range: newRange, + isReverted: !!selection.isReverted, + }, + true /*skipSelectionChangedEvent*/ + ); + } + } + switch (selection?.type) { case 'image': return { @@ -32,6 +58,7 @@ export function createSnapshotSelection( type: 'range', start: getPath(range.startContainer, range.startOffset, contentDiv), end: getPath(range.endContainer, range.endOffset, contentDiv), + isReverted: !!selection.isReverted, }; default: @@ -39,6 +66,7 @@ export function createSnapshotSelection( type: 'range', start: [], end: [], + isReverted: false, }; } } @@ -102,3 +130,67 @@ function getPath(node: Node | null, offset: number, rootNode: Node): number[] { return result; } + +function normalizeTableTree(startNode: Node, root: Node) { + let node: Node | null = startNode; + let isDOMChanged = false; + + while (node && root.contains(node)) { + if (isNodeOfType(node, 'ELEMENT_NODE') && isElementOfType(node, 'table')) { + isDOMChanged = normalizeTable(node) || isDOMChanged; + } + + node = node.parentNode; + } + + return isDOMChanged; +} + +function normalizeTable(table: HTMLTableElement): boolean { + let isDOMChanged = false; + let tbody: HTMLTableSectionElement | null = null; + + for (let child = table.firstChild; child; child = child.nextSibling) { + const tag = isNodeOfType(child, 'ELEMENT_NODE') ? child.tagName : null; + + switch (tag) { + case 'TR': + if (!tbody) { + tbody = table.ownerDocument.createElement('tbody'); + table.insertBefore(tbody, child); + } + + tbody.appendChild(child); + child = tbody; + isDOMChanged = true; + + break; + case 'TBODY': + if (tbody) { + moveChildNodes(tbody, child, true /*keepExistingChildren*/); + child.parentNode?.removeChild(child); + child = tbody; + isDOMChanged = true; + } else { + tbody = child as HTMLTableSectionElement; + } + break; + default: + tbody = null; + break; + } + } + + const colgroups = table.querySelectorAll('colgroup'); + const thead = table.querySelector('thead'); + + if (thead) { + colgroups.forEach(colgroup => { + if (!thead.contains(colgroup)) { + thead.appendChild(colgroup); + } + }); + } + + return isDOMChanged; +} 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 index 55240901c11..57796bbb053 100644 --- 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 @@ -1,3 +1,4 @@ +import { ChangeSource } from '../../constants/ChangeSource'; import { containerSizeFormatParser } from '../../override/containerSizeFormatParser'; import { createDomToModelContext, domToContentModel } from 'roosterjs-content-model-dom'; import { createPasteEntityProcessor } from '../../override/pasteEntityProcessor'; @@ -10,10 +11,10 @@ import { pasteTextProcessor } from '../../override/pasteTextProcessor'; import type { MergeModelOption } from '../../publicApi/model/mergeModel'; import type { BeforePasteEvent, + ClipboardData, ContentModelDocument, ContentModelSegmentFormat, - DomToModelOption, - FormatWithContentModelContext, + StandaloneEditorCore, } from 'roosterjs-content-model-types'; const EmptySegmentFormat: Required = { @@ -34,51 +35,65 @@ const EmptySegmentFormat: Required = { * @internal */ export function mergePasteContent( - model: ContentModelDocument, - context: FormatWithContentModelContext, + core: StandaloneEditorCore, eventResult: BeforePasteEvent, - defaultDomToModelOptions: DomToModelOption + clipboardData: ClipboardData ) { 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: [containerSizeFormatParser], - }, - }, - domToModelOption - ); - domToModelContext.segmentFormat = selectedSegment ? getSegmentTextFormat(selectedSegment) : {}; + core.api.formatContentModel( + core, + (model, context) => { + const selectedSegment = getSelectedSegments(model, true /*includeFormatHolder*/)[0]; + const domToModelContext = createDomToModelContext( + undefined /*editorContext*/, + core.domToModelSettings.customized, + { + processorOverride: { + '#text': pasteTextProcessor, + entity: createPasteEntityProcessor(domToModelOption), + '*': createPasteGeneralProcessor(domToModelOption), + }, + formatParserOverride: { + display: pasteDisplayFormatParser, + }, + additionalFormatParsers: { + container: [containerSizeFormatParser], + }, + }, + domToModelOption + ); - const pasteModel = domToContentModel(fragment, domToModelContext); - const mergeOption: MergeModelOption = { - mergeFormat: pasteType == 'mergeFormat' ? 'keepSourceEmphasisFormat' : 'none', - mergeTable: shouldMergeTable(pasteModel), - }; + domToModelContext.segmentFormat = selectedSegment + ? getSegmentTextFormat(selectedSegment) + : {}; - const insertPoint = customizedMerge - ? customizedMerge(model, pasteModel) - : mergeModel(model, pasteModel, context, mergeOption); + const pasteModel = domToContentModel(fragment, domToModelContext); + const mergeOption: MergeModelOption = { + mergeFormat: pasteType == 'mergeFormat' ? 'keepSourceEmphasisFormat' : 'none', + mergeTable: shouldMergeTable(pasteModel), + }; - if (insertPoint) { - context.newPendingFormat = { - ...EmptySegmentFormat, - ...model.format, - ...insertPoint.marker.format, - }; - } + const insertPoint = customizedMerge + ? customizedMerge(model, pasteModel) + : mergeModel(model, pasteModel, context, mergeOption); + + if (insertPoint) { + context.newPendingFormat = { + ...EmptySegmentFormat, + ...model.format, + ...insertPoint.marker.format, + }; + } + + return true; + }, + { + changeSource: ChangeSource.Paste, + getChangeData: () => clipboardData, + apiName: 'paste', + } + ); } function shouldMergeTable(pasteModel: ContentModelDocument): boolean | undefined { diff --git a/packages-content-model/roosterjs-content-model-core/lib/utils/restoreSnapshotSelection.ts b/packages-content-model/roosterjs-content-model-core/lib/utils/restoreSnapshotSelection.ts index f415aff8b99..79e2e4e2b70 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/utils/restoreSnapshotSelection.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/utils/restoreSnapshotSelection.ts @@ -22,6 +22,7 @@ export function restoreSnapshotSelection(core: StandaloneEditorCore, snapshot: S domSelection = { type: 'range', range, + isReverted: snapshotSelection.isReverted, }; break; case 'table': diff --git a/packages-content-model/roosterjs-content-model-core/test/coreApi/addUndoSnapshotTest.ts b/packages-content-model/roosterjs-content-model-core/test/coreApi/addUndoSnapshotTest.ts index c72c9a54e84..f1a579518e5 100644 --- a/packages-content-model/roosterjs-content-model-core/test/coreApi/addUndoSnapshotTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/addUndoSnapshotTest.ts @@ -4,7 +4,6 @@ import { SnapshotsManager, StandaloneEditorCore } from 'roosterjs-content-model- describe('addUndoSnapshot', () => { let core: StandaloneEditorCore; - let getDOMSelectionSpy: jasmine.Spy; let contentDiv: HTMLDivElement; let addSnapshotSpy: jasmine.Spy; let getKnownColorsCopySpy: jasmine.Spy; @@ -12,7 +11,6 @@ describe('addUndoSnapshot', () => { let snapshotsManager: SnapshotsManager; beforeEach(() => { - getDOMSelectionSpy = jasmine.createSpy('getDOMSelection'); addSnapshotSpy = jasmine.createSpy('addSnapshot'); getKnownColorsCopySpy = jasmine.createSpy('getKnownColorsCopy'); createSnapshotSelectionSpy = spyOn(createSnapshotSelection, 'createSnapshotSelection'); @@ -28,9 +26,6 @@ describe('addUndoSnapshot', () => { getKnownColorsCopy: getKnownColorsCopySpy, }, lifecycle: {}, - api: { - getDOMSelection: getDOMSelectionSpy, - }, undo: { snapshotsManager, }, @@ -42,21 +37,18 @@ describe('addUndoSnapshot', () => { const result = addUndoSnapshot(core, false); - expect(getDOMSelectionSpy).not.toHaveBeenCalled(); expect(createSnapshotSelectionSpy).not.toHaveBeenCalled(); expect(addSnapshotSpy).not.toHaveBeenCalled(); expect(result).toEqual(null); }); it('Has a selection', () => { - const mockedSelection = 'SELECTION' as any; const mockedColors = 'COLORS' as any; const mockedHTML = 'HTML' as any; const mockedSnapshotSelection = 'SNAPSHOTSELECTION' as any; contentDiv.innerHTML = mockedHTML; - getDOMSelectionSpy.and.returnValue(mockedSelection); getKnownColorsCopySpy.and.returnValue(mockedColors); createSnapshotSelectionSpy.and.returnValue(mockedSnapshotSelection); @@ -66,8 +58,7 @@ describe('addUndoSnapshot', () => { snapshotsManager: snapshotsManager, } as any); expect(snapshotsManager.hasNewContent).toBeFalse(); - expect(getDOMSelectionSpy).toHaveBeenCalledWith(core); - expect(createSnapshotSelectionSpy).toHaveBeenCalledWith(contentDiv, mockedSelection); + expect(createSnapshotSelectionSpy).toHaveBeenCalledWith(core); expect(addSnapshotSpy).toHaveBeenCalledWith( { html: mockedHTML, @@ -86,14 +77,12 @@ describe('addUndoSnapshot', () => { }); it('Has a selection, canUndoByBackspace', () => { - const mockedSelection = 'SELECTION' as any; const mockedColors = 'COLORS' as any; const mockedHTML = 'HTML' as any; const mockedSnapshotSelection = 'SNAPSHOTSELECTION' as any; contentDiv.innerHTML = mockedHTML; - getDOMSelectionSpy.and.returnValue(mockedSelection); getKnownColorsCopySpy.and.returnValue(mockedColors); createSnapshotSelectionSpy.and.returnValue(mockedSnapshotSelection); @@ -103,8 +92,7 @@ describe('addUndoSnapshot', () => { snapshotsManager: snapshotsManager, } as any); expect(snapshotsManager.hasNewContent).toBeFalse(); - expect(getDOMSelectionSpy).toHaveBeenCalledWith(core); - expect(createSnapshotSelectionSpy).toHaveBeenCalledWith(contentDiv, mockedSelection); + expect(createSnapshotSelectionSpy).toHaveBeenCalledWith(core); expect(addSnapshotSpy).toHaveBeenCalledWith( { html: mockedHTML, @@ -123,7 +111,6 @@ describe('addUndoSnapshot', () => { }); it('Has entityStates', () => { - const mockedSelection = 'SELECTION' as any; const mockedColors = 'COLORS' as any; const mockedHTML = 'HTML' as any; const mockedSnapshotSelection = 'SNAPSHOTSELECTION' as any; @@ -131,7 +118,6 @@ describe('addUndoSnapshot', () => { contentDiv.innerHTML = mockedHTML; - getDOMSelectionSpy.and.returnValue(mockedSelection); getKnownColorsCopySpy.and.returnValue(mockedColors); createSnapshotSelectionSpy.and.returnValue(mockedSnapshotSelection); @@ -141,8 +127,7 @@ describe('addUndoSnapshot', () => { snapshotsManager: snapshotsManager, } as any); expect(snapshotsManager.hasNewContent).toBeFalse(); - expect(getDOMSelectionSpy).toHaveBeenCalledWith(core); - expect(createSnapshotSelectionSpy).toHaveBeenCalledWith(contentDiv, mockedSelection); + expect(createSnapshotSelectionSpy).toHaveBeenCalledWith(core); expect(addSnapshotSpy).toHaveBeenCalledWith( { html: mockedHTML, @@ -159,4 +144,42 @@ describe('addUndoSnapshot', () => { selection: mockedSnapshotSelection, }); }); + + it('Verify get html after create selection', () => { + const mockedColors = 'COLORS' as any; + const mockedHTML1 = 'HTML1' as any; + const mockedHTML2 = 'HTML2' as any; + const mockedSnapshotSelection = 'SNAPSHOTSELECTION' as any; + + contentDiv.innerHTML = mockedHTML1; + + getKnownColorsCopySpy.and.returnValue(mockedColors); + createSnapshotSelectionSpy.and.callFake(() => { + contentDiv.innerHTML = mockedHTML2; + return mockedSnapshotSelection; + }); + + const result = addUndoSnapshot(core, false); + + expect(core.undo).toEqual({ + snapshotsManager: snapshotsManager, + } as any); + expect(snapshotsManager.hasNewContent).toBeFalse(); + expect(createSnapshotSelectionSpy).toHaveBeenCalledWith(core); + expect(addSnapshotSpy).toHaveBeenCalledWith( + { + html: mockedHTML2, + entityStates: undefined, + isDarkMode: false, + selection: mockedSnapshotSelection, + }, + false + ); + expect(result).toEqual({ + html: mockedHTML2, + entityStates: undefined, + isDarkMode: false, + selection: mockedSnapshotSelection, + }); + }); }); 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 49f99ac25cd..526e3991776 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 @@ -52,7 +52,7 @@ describe('createContentModel', () => { const model = createContentModel(core); - expect(createEditorContext).toHaveBeenCalledWith(core); + expect(createEditorContext).toHaveBeenCalledWith(core, true); expect(getDOMSelection).toHaveBeenCalledWith(core); expect(domToContentModelSpy).toHaveBeenCalledWith(mockedDiv, mockedContext, undefined); expect(model).toBe(mockedModel); @@ -201,4 +201,24 @@ describe('createContentModel with selection', () => { type: 'table', } as any); }); + + it('Flush mutation before create model', () => { + const cachedModel = 'MODEL1' as any; + const updatedModel = 'MODEL2' as any; + const flushMutationsSpy = jasmine.createSpy('flushMutations').and.callFake(() => { + core.cache.cachedModel = updatedModel; + }); + + core.cache.cachedModel = cachedModel; + core.lifecycle = {}; + + core.cache.textMutationObserver = { + flushMutations: flushMutationsSpy, + } as any; + + const model = createContentModel(core); + + expect(model).toBe(updatedModel); + expect(flushMutationsSpy).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages-content-model/roosterjs-content-model-core/test/coreApi/createEditorContextTest.ts b/packages-content-model/roosterjs-content-model-core/test/coreApi/createEditorContextTest.ts index a7251eac6cd..7efe27aa876 100644 --- a/packages-content-model/roosterjs-content-model-core/test/coreApi/createEditorContextTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/createEditorContextTest.ts @@ -7,7 +7,8 @@ describe('createEditorContext', () => { const defaultFormat = 'DEFAULTFORMAT' as any; const darkColorHandler = 'DARKHANDLER' as any; const getComputedStyleSpy = jasmine.createSpy('getComputedStyleSpy'); - const getBoundingClientRectSpy = jasmine.createSpy('getBoundingClientRect'); + const calculateZoomScaleSpy = jasmine.createSpy('calculateZoomScale').and.returnValue(1); + const domIndexer = 'DOMINDEXER' as any; const div = { ownerDocument: { @@ -15,7 +16,6 @@ describe('createEditorContext', () => { getComputedStyle: getComputedStyleSpy, }, }, - getBoundingClientRect: getBoundingClientRectSpy, }; const core = ({ @@ -27,10 +27,15 @@ describe('createEditorContext', () => { defaultFormat, }, darkColorHandler, - cache: {}, + cache: { + domIndexer: domIndexer, + }, + domHelper: { + calculateZoomScale: calculateZoomScaleSpy, + }, } as any) as StandaloneEditorCore; - const context = createEditorContext(core); + const context = createEditorContext(core, false); expect(context).toEqual({ isDarkMode, @@ -40,6 +45,7 @@ describe('createEditorContext', () => { allowCacheElement: true, domIndexer: undefined, pendingFormat: undefined, + zoomScale: 1, }); }); @@ -48,7 +54,7 @@ describe('createEditorContext', () => { const defaultFormat = 'DEFAULTFORMAT' as any; const darkColorHandler = 'DARKHANDLER' as any; const getComputedStyleSpy = jasmine.createSpy('getComputedStyleSpy'); - const getBoundingClientRectSpy = jasmine.createSpy('getBoundingClientRect'); + const calculateZoomScaleSpy = jasmine.createSpy('calculateZoomScale').and.returnValue(1); const domIndexer = 'DOMINDEXER' as any; const div = { @@ -57,7 +63,6 @@ describe('createEditorContext', () => { getComputedStyle: getComputedStyleSpy, }, }, - getBoundingClientRect: getBoundingClientRectSpy, }; const core = ({ @@ -72,9 +77,12 @@ describe('createEditorContext', () => { cache: { domIndexer, }, + domHelper: { + calculateZoomScale: calculateZoomScaleSpy, + }, } as any) as StandaloneEditorCore; - const context = createEditorContext(core); + const context = createEditorContext(core, true); expect(context).toEqual({ isDarkMode, @@ -84,6 +92,7 @@ describe('createEditorContext', () => { allowCacheElement: true, domIndexer, pendingFormat: undefined, + zoomScale: 1, }); }); @@ -93,7 +102,7 @@ describe('createEditorContext', () => { const darkColorHandler = 'DARKHANDLER' as any; const mockedPendingFormat = 'PENDINGFORMAT' as any; const getComputedStyleSpy = jasmine.createSpy('getComputedStyleSpy'); - const getBoundingClientRectSpy = jasmine.createSpy('getBoundingClientRect'); + const calculateZoomScaleSpy = jasmine.createSpy('calculateZoomScale').and.returnValue(1); const div = { ownerDocument: { @@ -101,7 +110,6 @@ describe('createEditorContext', () => { getComputedStyle: getComputedStyleSpy, }, }, - getBoundingClientRect: getBoundingClientRectSpy, }; const core = ({ @@ -115,9 +123,12 @@ describe('createEditorContext', () => { }, darkColorHandler, cache: {}, + domHelper: { + calculateZoomScale: calculateZoomScaleSpy, + }, } as any) as StandaloneEditorCore; - const context = createEditorContext(core); + const context = createEditorContext(core, false); expect(context).toEqual({ isDarkMode, @@ -127,6 +138,7 @@ describe('createEditorContext', () => { allowCacheElement: true, domIndexer: undefined, pendingFormat: mockedPendingFormat, + zoomScale: 1, }); }); }); @@ -135,14 +147,14 @@ describe('createEditorContext - checkZoomScale', () => { let core: StandaloneEditorCore; let div: any; let getComputedStyleSpy: jasmine.Spy; - let getBoundingClientRectSpy: jasmine.Spy; + let calculateZoomScaleSpy: jasmine.Spy; const isDarkMode = 'DARKMODE' as any; const defaultFormat = 'DEFAULTFORMAT' as any; const darkColorHandler = 'DARKHANDLER' as any; beforeEach(() => { getComputedStyleSpy = jasmine.createSpy('getComputedStyleSpy'); - getBoundingClientRectSpy = jasmine.createSpy('getBoundingClientRect'); + calculateZoomScaleSpy = jasmine.createSpy('calculateZoomScale'); div = { ownerDocument: { @@ -150,7 +162,6 @@ describe('createEditorContext - checkZoomScale', () => { getComputedStyle: getComputedStyleSpy, }, }, - getBoundingClientRect: getBoundingClientRectSpy, }; core = ({ contentDiv: div, @@ -162,36 +173,16 @@ describe('createEditorContext - checkZoomScale', () => { }, darkColorHandler, cache: {}, + domHelper: { + calculateZoomScale: calculateZoomScaleSpy, + }, } as any) as StandaloneEditorCore; }); - it('Zoom scale = 1', () => { - div.offsetWidth = 100; - getBoundingClientRectSpy.and.returnValue({ - width: 100, - }); - - const context = createEditorContext(core); - - expect(context).toEqual({ - isDarkMode, - defaultFormat, - darkColorHandler, - addDelimiterForEntity: true, - zoomScale: 1, - allowCacheElement: true, - domIndexer: undefined, - pendingFormat: undefined, - }); - }); - it('Zoom scale = 2', () => { - div.offsetWidth = 50; - getBoundingClientRectSpy.and.returnValue({ - width: 100, - }); + calculateZoomScaleSpy.and.returnValue(2); - const context = createEditorContext(core); + const context = createEditorContext(core, false); expect(context).toEqual({ isDarkMode, @@ -204,48 +195,26 @@ describe('createEditorContext - checkZoomScale', () => { pendingFormat: undefined, }); }); - - it('Zoom scale = 0.5', () => { - div.offsetWidth = 200; - getBoundingClientRectSpy.and.returnValue({ - width: 100, - }); - - const context = createEditorContext(core); - - expect(context).toEqual({ - isDarkMode, - defaultFormat, - darkColorHandler, - addDelimiterForEntity: true, - zoomScale: 0.5, - allowCacheElement: true, - domIndexer: undefined, - pendingFormat: undefined, - }); - }); }); describe('createEditorContext - checkRootDir', () => { let core: StandaloneEditorCore; let div: any; let getComputedStyleSpy: jasmine.Spy; - let getBoundingClientRectSpy: jasmine.Spy; + let calculateZoomScaleSpy: jasmine.Spy; const isDarkMode = 'DARKMODE' as any; const defaultFormat = 'DEFAULTFORMAT' as any; const darkColorHandler = 'DARKHANDLER' as any; beforeEach(() => { getComputedStyleSpy = jasmine.createSpy('getComputedStyleSpy'); - getBoundingClientRectSpy = jasmine.createSpy('getBoundingClientRect'); - + calculateZoomScaleSpy = jasmine.createSpy('calculateZoomScale').and.returnValue(1); div = { ownerDocument: { defaultView: { getComputedStyle: getComputedStyleSpy, }, }, - getBoundingClientRect: getBoundingClientRectSpy, }; core = ({ contentDiv: div, @@ -257,6 +226,9 @@ describe('createEditorContext - checkRootDir', () => { }, darkColorHandler, cache: {}, + domHelper: { + calculateZoomScale: calculateZoomScaleSpy, + }, } as any) as StandaloneEditorCore; }); @@ -265,7 +237,7 @@ describe('createEditorContext - checkRootDir', () => { direction: 'ltr', }); - const context = createEditorContext(core); + const context = createEditorContext(core, false); expect(context).toEqual({ isDarkMode, @@ -275,6 +247,7 @@ describe('createEditorContext - checkRootDir', () => { allowCacheElement: true, domIndexer: undefined, pendingFormat: undefined, + zoomScale: 1, }); }); @@ -283,7 +256,7 @@ describe('createEditorContext - checkRootDir', () => { direction: 'rtl', }); - const context = createEditorContext(core); + const context = createEditorContext(core, false); expect(context).toEqual({ isDarkMode, @@ -294,6 +267,7 @@ describe('createEditorContext - checkRootDir', () => { allowCacheElement: true, domIndexer: undefined, pendingFormat: undefined, + zoomScale: 1, }); }); }); diff --git a/packages-content-model/roosterjs-content-model-core/test/coreApi/getDOMSelectionTest.ts b/packages-content-model/roosterjs-content-model-core/test/coreApi/getDOMSelectionTest.ts index d689ff28b48..952a2af9fa2 100644 --- a/packages-content-model/roosterjs-content-model-core/test/coreApi/getDOMSelectionTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/getDOMSelectionTest.ts @@ -4,11 +4,13 @@ import { StandaloneEditorCore } from 'roosterjs-content-model-types'; describe('getDOMSelection', () => { let core: StandaloneEditorCore; let getSelectionSpy: jasmine.Spy; + let hasFocusSpy: jasmine.Spy; let containsSpy: jasmine.Spy; beforeEach(() => { getSelectionSpy = jasmine.createSpy('getSelection'); containsSpy = jasmine.createSpy('contains'); + hasFocusSpy = jasmine.createSpy('hasFocus'); core = { lifecycle: {}, @@ -21,6 +23,9 @@ describe('getDOMSelection', () => { }, contains: containsSpy, }, + api: { + hasFocus: hasFocusSpy, + }, } as any; }); @@ -30,6 +35,7 @@ describe('getDOMSelection', () => { }; getSelectionSpy.and.returnValue(mockedSelection); + hasFocusSpy.and.returnValue(true); const result = getDOMSelection(core); @@ -47,6 +53,7 @@ describe('getDOMSelection', () => { getSelectionSpy.and.returnValue(mockedSelection); containsSpy.and.returnValue(false); + hasFocusSpy.and.returnValue(true); const result = getDOMSelection(core); @@ -55,33 +62,224 @@ describe('getDOMSelection', () => { it('no cached selection, range selection is in editor', () => { const mockedElement = 'ELEMENT' as any; + const mockedElementOffset = 'MOCKED_ELEMENT_OFFSET' as any; + const secondaryMockedElement = 'ELEMENT_2' as any; + const secondaryMockedElementOffset = 'MOCKED_ELEMENT_OFFSET_2' as any; + const mockedRange = { + commonAncestorContainer: mockedElement, + startContainer: mockedElement, + startOffset: mockedElementOffset, + endContainer: secondaryMockedElement, + endOffset: secondaryMockedElementOffset, + } as any; + const setBaseAndExtendSpy = jasmine.createSpy('setBaseAndExtendSpy'); + const mockedSelection = { + rangeCount: 1, + getRangeAt: () => mockedRange, + setBaseAndExtend: setBaseAndExtendSpy, + focusNode: secondaryMockedElement, + focusOffset: secondaryMockedElementOffset, + }; + + getSelectionSpy.and.returnValue(mockedSelection); + containsSpy.and.returnValue(true); + hasFocusSpy.and.returnValue(true); + + const result = getDOMSelection(core); + + expect(result).toEqual({ + type: 'range', + range: mockedRange, + isReverted: false, + }); + }); + + it('no cached selection, range selection is in editor, isReverted', () => { + const mockedElement = 'ELEMENT' as any; + const mockedElementOffset = 'MOCKED_ELEMENT_OFFSET' as any; + const secondaryMockedElement = 'ELEMENT_2' as any; + const secondaryMockedElementOffset = 'MOCKED_ELEMENT_OFFSET_2' as any; const mockedRange = { commonAncestorContainer: mockedElement, + startContainer: mockedElement, + startOffset: mockedElementOffset, + endContainer: secondaryMockedElement, + endOffset: secondaryMockedElementOffset, + collapsed: false, } as any; + const setBaseAndExtendSpy = jasmine.createSpy('setBaseAndExtendSpy'); const mockedSelection = { rangeCount: 1, getRangeAt: () => mockedRange, + setBaseAndExtend: setBaseAndExtendSpy, + focusNode: mockedElement, + focusOffset: mockedElementOffset, }; getSelectionSpy.and.returnValue(mockedSelection); containsSpy.and.returnValue(true); + hasFocusSpy.and.returnValue(true); const result = getDOMSelection(core); expect(result).toEqual({ type: 'range', range: mockedRange, + isReverted: true, }); }); - it('has cached selection', () => { + it('has cached selection, editor is in shadowEdit', () => { const mockedSelection = 'SELECTION' as any; core.selection.selection = mockedSelection; + core.lifecycle.shadowEditFragment = true as any; + containsSpy.and.returnValue(true); + hasFocusSpy.and.returnValue(true); + + const result = getDOMSelection(core); + + expect(result).toBe(null); + }); + + it('has cached table selection, editor has focus', () => { + const mockedSelection = { + type: 'table', + } as any; + core.selection.selection = mockedSelection; + + hasFocusSpy.and.returnValue(true); + containsSpy.and.returnValue(true); + + const result = getDOMSelection(core); + + expect(result).toBe(mockedSelection); + }); + + it('has cached image selection, editor has focus', () => { + const mockedSelection = { + type: 'image', + } as any; + core.selection.selection = mockedSelection; + + hasFocusSpy.and.returnValue(true); + containsSpy.and.returnValue(true); + + const result = getDOMSelection(core); + + expect(result).toBe(mockedSelection); + }); + + it('has cached range selection, editor has focus', () => { + const mockedElement = 'ELEMENT' as any; + const mockedElementOffset = 'MOCKED_ELEMENT_OFFSET' as any; + const secondaryMockedElement = 'ELEMENT_2' as any; + const secondaryMockedElementOffset = 'MOCKED_ELEMENT_OFFSET_2' as any; + const mockedRange = { + commonAncestorContainer: mockedElement, + startContainer: mockedElement, + startOffset: mockedElementOffset, + endContainer: secondaryMockedElement, + endOffset: secondaryMockedElementOffset, + } as any; + const setBaseAndExtendSpy = jasmine.createSpy('setBaseAndExtendSpy'); + const mockedSelectionObj = { + rangeCount: 1, + getRangeAt: () => mockedRange, + setBaseAndExtend: setBaseAndExtendSpy, + focusNode: secondaryMockedElement, + focusOffset: secondaryMockedElementOffset, + }; + const mockedSelection = { + type: 'range', + } as any; + + core.selection.selection = mockedSelection; + getSelectionSpy.and.returnValue(mockedSelectionObj); + + hasFocusSpy.and.returnValue(true); + containsSpy.and.returnValue(true); + + const result = getDOMSelection(core); + + expect(result).toEqual({ + type: 'range', + range: mockedRange, + isReverted: false, + }); + }); + + it('has cached range selection, editor has focus, reverted', () => { + const mockedElement = 'ELEMENT' as any; + const mockedElementOffset = 'MOCKED_ELEMENT_OFFSET' as any; + const secondaryMockedElement = 'ELEMENT_2' as any; + const secondaryMockedElementOffset = 'MOCKED_ELEMENT_OFFSET_2' as any; + const mockedRange = { + commonAncestorContainer: mockedElement, + startContainer: mockedElement, + startOffset: mockedElementOffset, + endContainer: secondaryMockedElement, + endOffset: secondaryMockedElementOffset, + collapsed: false, + } as any; + const setBaseAndExtendSpy = jasmine.createSpy('setBaseAndExtendSpy'); + const mockedSelectionObj = { + rangeCount: 1, + getRangeAt: () => mockedRange, + setBaseAndExtend: setBaseAndExtendSpy, + focusNode: mockedElement, + focusOffset: mockedElementOffset, + }; + const mockedSelection = { + type: 'range', + } as any; + + core.selection.selection = mockedSelection; + getSelectionSpy.and.returnValue(mockedSelectionObj); + + hasFocusSpy.and.returnValue(true); + containsSpy.and.returnValue(true); + + const result = getDOMSelection(core); + + expect(result).toEqual({ + type: 'range', + range: mockedRange, + isReverted: true, + }); + }); + + it('has cached range selection, editor does not have focus', () => { + const mockedSelection = { + type: 'image', + } as any; + core.selection.selection = mockedSelection; + + hasFocusSpy.and.returnValue(false); containsSpy.and.returnValue(true); const result = getDOMSelection(core); expect(result).toBe(mockedSelection); }); + + it('no cached selection, editor does not have focus', () => { + const mockedNewSelection = 'NEWSELECTION' as any; + + hasFocusSpy.and.returnValue(false); + containsSpy.and.returnValue(true); + + getSelectionSpy.and.returnValue({ + rangeCount: 1, + getRangeAt: () => mockedNewSelection, + }); + + const result = getDOMSelection(core); + + expect(result).toEqual({ + type: 'range', + range: mockedNewSelection, + isReverted: false, + }); + }); }); diff --git a/packages-content-model/roosterjs-content-model-core/test/coreApi/pasteTest.ts b/packages-content-model/roosterjs-content-model-core/test/coreApi/pasteTest.ts index 6ab45b0ca22..491ace4082f 100644 --- a/packages-content-model/roosterjs-content-model-core/test/coreApi/pasteTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/pasteTest.ts @@ -9,17 +9,12 @@ import * as PPT from 'roosterjs-content-model-plugins/lib/paste/PowerPoint/proce 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 { ContentModelEditor } from 'roosterjs-content-model-editor'; 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 { StandaloneEditor } from '../../lib/editor/StandaloneEditor'; import { ClipboardData, ContentModelDocument, - DomToModelOption, - ContentModelFormatter, - FormatWithContentModelContext, - FormatWithContentModelOptions, IStandaloneEditor, BeforePasteEvent, PluginEvent, @@ -35,14 +30,8 @@ describe('Paste ', () => { let focus: jasmine.Spy; let mockedModel: ContentModelDocument; let mockedMergeModel: ContentModelDocument; - let getFocusedPosition: jasmine.Spy; - let getContent: 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; @@ -69,10 +58,8 @@ describe('Paste ', () => { createContentModel = jasmine.createSpy('createContentModel').and.returnValue(mockedModel); focus = jasmine.createSpy('focus'); - getFocusedPosition = jasmine.createSpy('getFocusedPosition').and.returnValue(mockedPos); - getContent = jasmine.createSpy('getContent'); getVisibleViewport = jasmine.createSpy('getVisibleViewport'); - mergeModelSpy = spyOn(mergeModelFile, 'mergeModel').and.callFake(() => { + spyOn(mergeModelFile, 'mergeModel').and.callFake(() => { mockedModel = mockedMergeModel; return null; }); @@ -84,36 +71,14 @@ describe('Paste ', () => { }, } as any, ]); - const formatContentModel = jasmine - .createSpy('formatContentModel') - .and.callFake( - ( - core: any, - callback: ContentModelFormatter, - options: FormatWithContentModelOptions - ) => { - context = { - newEntities: [], - deletedEntities: [], - newImages: [], - }; - formatResult = callback(mockedModel, context); - } - ); - - formatResult = undefined; - context = undefined; - - editor = new ContentModelEditor(div, { + + editor = new StandaloneEditor(div, { plugins: [new ContentModelPastePlugin()], coreApiOverride: { focus, createContentModel, getVisibleViewport, - formatContentModel, - }, - legacyCoreApiOverride: { - getContent, + // formatContentModel, }, }); @@ -129,72 +94,24 @@ describe('Paste ', () => { it('Execute', () => { editor.pasteFromClipboard(clipboardData); - expect(formatResult).toBeTrue(); expect(mockedModel).toEqual(mockedMergeModel); }); it('Execute | As plain text', () => { editor.pasteFromClipboard(clipboardData, 'asPlainText'); - expect(formatResult).toBeTrue(); expect(mockedModel).toEqual(mockedMergeModel); }); - - it('Preserve segment format after paste', () => { - const mockedNode = 'Node' as any; - const mockedOffset = 'Offset' as any; - const mockedFormat = { - fontFamily: 'Arial', - }; - clipboardData.rawHtml = - 'test'; - getFocusedPosition.and.returnValue({ - node: mockedNode, - offset: mockedOffset, - }); - mergeModelSpy.and.returnValue({ - marker: { - format: mockedFormat, - }, - }); - - editor.pasteFromClipboard(clipboardData); - - editor.createContentModel({ - processorOverride: { - table: tableProcessor, - }, - }); - - expect(context).toEqual({ - newEntities: [], - newImages: [], - deletedEntities: [], - newPendingFormat: { - backgroundColor: '', - fontFamily: 'Arial', - fontSize: '', - fontWeight: '', - italic: false, - letterSpacing: '', - lineHeight: '', - strikethrough: false, - superOrSubScriptSequence: '', - textColor: '', - underline: false, - }, - }); - }); }); describe('paste with content model & paste plugin', () => { - let editor: ContentModelEditor | undefined; + let editor: StandaloneEditor | undefined; let div: HTMLDivElement | undefined; beforeEach(() => { div = document.createElement('div'); document.body.appendChild(div); - editor = new ContentModelEditor(div, { + editor = new StandaloneEditor(div, { plugins: [new ContentModelPastePlugin()], }); spyOn(addParserF, 'default').and.callThrough(); @@ -220,7 +137,7 @@ describe('paste with content model & paste plugin', () => { spyOn(getPasteSourceF, 'getPasteSource').and.returnValue('wordDesktop'); spyOn(WordDesktopFile, 'processPastedContentFromWordDesktop').and.callThrough(); - editor?.paste(clipboardData); + editor?.pasteFromClipboard(clipboardData); expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(1); expect(addParserF.default).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED + 5); @@ -231,7 +148,7 @@ describe('paste with content model & paste plugin', () => { spyOn(getPasteSourceF, 'getPasteSource').and.returnValue('wacComponents'); spyOn(WacComponents, 'processPastedContentWacComponents').and.callThrough(); - editor?.paste(clipboardData); + editor?.pasteFromClipboard(clipboardData); expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(2); expect(addParserF.default).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED + 6); @@ -242,7 +159,7 @@ describe('paste with content model & paste plugin', () => { spyOn(getPasteSourceF, 'getPasteSource').and.returnValue('excelOnline'); spyOn(ExcelF, 'processPastedContentFromExcel').and.callThrough(); - editor?.paste(clipboardData); + editor?.pasteFromClipboard(clipboardData); expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(1); expect(addParserF.default).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED + 1); @@ -253,7 +170,7 @@ describe('paste with content model & paste plugin', () => { spyOn(getPasteSourceF, 'getPasteSource').and.returnValue('excelDesktop'); spyOn(ExcelF, 'processPastedContentFromExcel').and.callThrough(); - editor?.paste(clipboardData); + editor?.pasteFromClipboard(clipboardData); expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(1); expect(addParserF.default).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED + 1); @@ -264,7 +181,7 @@ describe('paste with content model & paste plugin', () => { spyOn(getPasteSourceF, 'getPasteSource').and.returnValue('powerPointDesktop'); spyOn(PPT, 'processPastedContentFromPowerPoint').and.callThrough(); - editor?.paste(clipboardData); + editor?.pasteFromClipboard(clipboardData); expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(0); expect(addParserF.default).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED); @@ -276,7 +193,7 @@ describe('paste with content model & paste plugin', () => { spyOn(getPasteSourceF, 'getPasteSource').and.returnValue('wordDesktop'); spyOn(WordDesktopFile, 'processPastedContentFromWordDesktop').and.callThrough(); - editor?.paste(clipboardData, true /* pasteAsText */); + editor?.pasteFromClipboard(clipboardData, 'asPlainText'); expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(0); expect(addParserF.default).toHaveBeenCalledTimes(0); @@ -287,7 +204,7 @@ describe('paste with content model & paste plugin', () => { spyOn(getPasteSourceF, 'getPasteSource').and.returnValue('wacComponents'); spyOn(WacComponents, 'processPastedContentWacComponents').and.callThrough(); - editor?.paste(clipboardData, true /* pasteAsText */); + editor?.pasteFromClipboard(clipboardData, 'asPlainText'); expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(0); expect(addParserF.default).toHaveBeenCalledTimes(0); @@ -298,7 +215,7 @@ describe('paste with content model & paste plugin', () => { spyOn(getPasteSourceF, 'getPasteSource').and.returnValue('excelOnline'); spyOn(ExcelF, 'processPastedContentFromExcel').and.callThrough(); - editor?.paste(clipboardData, true /* pasteAsText */); + editor?.pasteFromClipboard(clipboardData, 'asPlainText'); expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(0); expect(addParserF.default).toHaveBeenCalledTimes(0); @@ -309,7 +226,7 @@ describe('paste with content model & paste plugin', () => { spyOn(getPasteSourceF, 'getPasteSource').and.returnValue('excelDesktop'); spyOn(ExcelF, 'processPastedContentFromExcel').and.callThrough(); - editor?.paste(clipboardData, true /* pasteAsText */); + editor?.pasteFromClipboard(clipboardData, 'asPlainText'); expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(0); expect(addParserF.default).toHaveBeenCalledTimes(0); @@ -320,7 +237,7 @@ describe('paste with content model & paste plugin', () => { spyOn(getPasteSourceF, 'getPasteSource').and.returnValue('powerPointDesktop'); spyOn(PPT, 'processPastedContentFromPowerPoint').and.callThrough(); - editor?.paste(clipboardData, true /* pasteAsText */); + editor?.pasteFromClipboard(clipboardData, 'asPlainText'); expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(0); expect(addParserF.default).toHaveBeenCalledTimes(0); @@ -341,7 +258,7 @@ describe('paste with content model & paste plugin', () => { }; let eventChecker: BeforePasteEvent = {}; - editor = new ContentModelEditor(div!, { + editor = new StandaloneEditor(div!, { plugins: [ { initialize: () => {}, @@ -356,7 +273,7 @@ describe('paste with content model & paste plugin', () => { ], }); - editor?.paste(clipboardData); + editor?.pasteFromClipboard(clipboardData); expect(eventChecker?.clipboardData).toEqual(clipboardData); expect(eventChecker?.htmlBefore).toBeTruthy(); @@ -394,11 +311,7 @@ describe('Paste with clipboardData', () => { editor.pasteFromClipboard(clipboardData); - const model = editor.createContentModel({ - processorOverride: { - table: tableProcessor, - }, - }); + const model = editor.getContentModelCopy('connected'); expectEqual(model, { blockGroupType: 'Document', @@ -451,11 +364,7 @@ describe('Paste with clipboardData', () => { editor.pasteFromClipboard(clipboardData); - const model = editor.createContentModel({ - processorOverride: { - table: tableProcessor, - }, - }); + const model = editor.getContentModelCopy('connected'); expectEqual(model, { blockGroupType: 'Document', @@ -495,11 +404,7 @@ describe('Paste with clipboardData', () => { editor.pasteFromClipboard(clipboardData); - const model = editor.createContentModel({ - processorOverride: { - table: tableProcessor, - }, - }); + const model = editor.getContentModelCopy('connected'); expectEqual(model, { blockGroupType: 'Document', 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 bfacc023283..31a0cefe234 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 @@ -3,13 +3,10 @@ import * as createModelToDomContext from 'roosterjs-content-model-dom/lib/modelT import { setContentModel } from '../../lib/coreApi/setContentModel'; import { StandaloneEditorCore } from 'roosterjs-content-model-types'; -const mockedRange = { - type: 'image', -} as any; const mockedDoc = 'DOCUMENT' as any; const mockedModel = 'MODEL' as any; const mockedEditorContext = 'EDITORCONTEXT' as any; -const mockedContext = 'CONTEXT' as any; +const mockedContext = { name: 'CONTEXT' } as any; const mockedDiv = { ownerDocument: mockedDoc } as any; const mockedConfig = 'CONFIG' as any; @@ -23,9 +20,7 @@ describe('setContentModel', () => { let getDOMSelectionSpy: jasmine.Spy; beforeEach(() => { - contentModelToDomSpy = spyOn(contentModelToDom, 'contentModelToDom').and.returnValue( - mockedRange - ); + contentModelToDomSpy = spyOn(contentModelToDom, 'contentModelToDom'); createEditorContext = jasmine .createSpy('createEditorContext') .and.returnValue(mockedEditorContext); @@ -56,6 +51,12 @@ describe('setContentModel', () => { }); it('no default option, no shadow edit', () => { + const mockedRange = { + type: 'image', + } as any; + + contentModelToDomSpy.and.returnValue(mockedRange); + setContentModel(core, mockedModel); expect(createModelToDomContextSpy).not.toHaveBeenCalled(); @@ -67,8 +68,7 @@ describe('setContentModel', () => { mockedDoc, mockedDiv, mockedModel, - mockedContext, - undefined + mockedContext ); expect(setDOMSelectionSpy).toHaveBeenCalledWith(core, mockedRange); expect(core.cache.cachedSelection).toBe(mockedRange); @@ -76,6 +76,12 @@ describe('setContentModel', () => { }); it('with default option, no shadow edit', () => { + const mockedRange = { + type: 'image', + } as any; + + contentModelToDomSpy.and.returnValue(mockedRange); + setContentModel(core, mockedModel); expect(createModelToDomContextWithConfigSpy).toHaveBeenCalledWith( @@ -86,8 +92,7 @@ describe('setContentModel', () => { mockedDoc, mockedDiv, mockedModel, - mockedContext, - undefined + mockedContext ); expect(setDOMSelectionSpy).toHaveBeenCalledWith(core, mockedRange); }); @@ -95,6 +100,11 @@ describe('setContentModel', () => { it('with default option, no shadow edit, with additional option', () => { const defaultOption = { o: 'OPTION' } as any; const additionalOption = { o: 'OPTION1', o2: 'OPTION2' } as any; + const mockedRange = { + type: 'image', + } as any; + + contentModelToDomSpy.and.returnValue(mockedRange); core.modelToDomSettings.builtIn = defaultOption; setContentModel(core, mockedModel, additionalOption); @@ -109,14 +119,18 @@ describe('setContentModel', () => { mockedDoc, mockedDiv, mockedModel, - mockedContext, - undefined + mockedContext ); expect(setDOMSelectionSpy).toHaveBeenCalledWith(core, mockedRange); }); it('no default option, with shadow edit', () => { core.lifecycle.shadowEditFragment = {} as any; + const mockedRange = { + type: 'image', + } as any; + + contentModelToDomSpy.and.returnValue(mockedRange); setContentModel(core, mockedModel); @@ -128,13 +142,18 @@ describe('setContentModel', () => { mockedDoc, mockedDiv, mockedModel, - mockedContext, - undefined + mockedContext ); expect(setDOMSelectionSpy).not.toHaveBeenCalled(); }); it('restore selection ', () => { + const mockedRange = { + type: 'image', + } as any; + + contentModelToDomSpy.and.returnValue(mockedRange); + core.selection = { selection: null, selectionStyleNode: null, @@ -155,10 +174,71 @@ describe('setContentModel', () => { mockedDoc, mockedDiv, mockedModel, - mockedContext, - undefined + mockedContext ); expect(setDOMSelectionSpy).not.toHaveBeenCalled(); expect(core.selection.selection).toBe(mockedRange); }); + + it('restore range selection ', () => { + const mockedRange = { + type: 'range', + } as any; + + contentModelToDomSpy.and.returnValue(mockedRange); + + core.selection = { + selection: null, + selectionStyleNode: null, + }; + setContentModel(core, mockedModel, { + ignoreSelection: true, + }); + + expect(createModelToDomContextSpy).toHaveBeenCalledWith( + mockedEditorContext, + undefined, + undefined, + { + ignoreSelection: true, + } + ); + expect(contentModelToDomSpy).toHaveBeenCalledWith( + mockedDoc, + mockedDiv, + mockedModel, + mockedContext + ); + expect(setDOMSelectionSpy).not.toHaveBeenCalled(); + expect(core.selection.selection).toBe(mockedRange); + }); + + it('restore null selection ', () => { + contentModelToDomSpy.and.returnValue(null); + + core.selection = { + selection: null, + selectionStyleNode: null, + }; + setContentModel(core, mockedModel, { + ignoreSelection: true, + }); + + expect(createModelToDomContextSpy).toHaveBeenCalledWith( + mockedEditorContext, + undefined, + undefined, + { + ignoreSelection: true, + } + ); + expect(contentModelToDomSpy).toHaveBeenCalledWith( + mockedDoc, + mockedDiv, + mockedModel, + mockedContext + ); + expect(setDOMSelectionSpy).not.toHaveBeenCalled(); + expect(core.selection.selection).toBe(null); + }); }); 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 239c1f38dc3..d97267cd9a6 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 @@ -1,6 +1,4 @@ import * as addRangeToSelection from '../../lib/corePlugin/utils/addRangeToSelection'; -import { createElement } from 'roosterjs-editor-dom'; -import { CreateElementData } from 'roosterjs-editor-types'; import { DOMSelection, StandaloneEditorCore } from 'roosterjs-content-model-types'; import { setDOMSelection } from '../../lib/coreApi/setDOMSelection'; @@ -99,6 +97,7 @@ describe('setDOMSelection', () => { runTest({ type: 'range', range: {} as any, + isReverted: false, }); }); @@ -126,6 +125,7 @@ describe('setDOMSelection', () => { const mockedSelection = { type: 'range', range: mockedRange, + isReverted: false, } as any; (core.selection.selectionStyleNode!.sheet!.cssRules as any) = ['Rule1', 'Rule2']; @@ -148,7 +148,11 @@ describe('setDOMSelection', () => { }, true ); - expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange); + expect(addRangeToSelectionSpy).toHaveBeenCalledWith( + doc, + mockedRange, + false /* isReverted */ + ); expect(contentDiv.id).toBe('contentDiv_0'); expect(deleteRuleSpy).toHaveBeenCalledTimes(2); expect(deleteRuleSpy).toHaveBeenCalledWith(1); @@ -180,7 +184,7 @@ describe('setDOMSelection', () => { }, true ); - expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange); + expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange, undefined); expect(contentDiv.id).toBe('contentDiv_0'); expect(deleteRuleSpy).not.toHaveBeenCalled(); expect(insertRuleSpy).not.toHaveBeenCalled(); @@ -190,6 +194,7 @@ describe('setDOMSelection', () => { const mockedSelection = { type: 'range', range: mockedRange, + isReverted: false, } as any; querySelectorAllSpy.and.returnValue([]); @@ -203,7 +208,7 @@ describe('setDOMSelection', () => { selectionStyleNode: mockedStyleNode, } as any); expect(triggerEventSpy).not.toHaveBeenCalled(); - expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange); + expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange, false); expect(contentDiv.id).toBe('contentDiv_0'); expect(deleteRuleSpy).not.toHaveBeenCalled(); expect(insertRuleSpy).not.toHaveBeenCalled(); @@ -213,6 +218,7 @@ describe('setDOMSelection', () => { const mockedSelection = { type: 'range', range: mockedRange, + isReverted: false, } as any; querySelectorAllSpy.and.returnValue([]); @@ -233,7 +239,7 @@ describe('setDOMSelection', () => { }, true ); - expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange); + expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange, false); expect(contentDiv.id).toBe('contentDiv_0'); expect(deleteRuleSpy).not.toHaveBeenCalled(); expect(insertRuleSpy).not.toHaveBeenCalled(); @@ -243,6 +249,7 @@ describe('setDOMSelection', () => { const mockedSelection = { type: 'range', range: mockedRange, + isReverted: false, } as any; contentDiv.id = 'testId'; @@ -264,7 +271,7 @@ describe('setDOMSelection', () => { }, true ); - expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange); + expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange, false); expect(contentDiv.id).toBe('testId'); expect(deleteRuleSpy).not.toHaveBeenCalled(); expect(insertRuleSpy).not.toHaveBeenCalled(); @@ -274,6 +281,7 @@ describe('setDOMSelection', () => { const mockedSelection = { type: 'range', range: mockedRange, + isReverted: false, } as any; contentDiv.id = 'testId'; @@ -297,7 +305,7 @@ describe('setDOMSelection', () => { }, true ); - expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange); + expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange, false); expect(contentDiv.id).toBe('testId_0'); expect(deleteRuleSpy).not.toHaveBeenCalled(); expect(insertRuleSpy).not.toHaveBeenCalled(); @@ -307,6 +315,7 @@ describe('setDOMSelection', () => { const mockedSelection = { type: 'range', range: mockedRange, + isReverted: false, } as any; contentDiv.id = 'testId'; @@ -330,7 +339,7 @@ describe('setDOMSelection', () => { }, true ); - expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange); + expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange, false); expect(contentDiv.id).toBe('testId_1'); expect(deleteRuleSpy).not.toHaveBeenCalled(); expect(insertRuleSpy).not.toHaveBeenCalled(); @@ -666,41 +675,29 @@ describe('setDOMSelection', () => { }); it('Select TH and TR in the same row', () => { + const table = document.createElement('table'); + const tr1 = document.createElement('tr'); + const th1 = document.createElement('th'); + const td1 = document.createElement('td'); + const tr2 = document.createElement('tr'); + const th2 = document.createElement('th'); + const td2 = document.createElement('td'); + + th1.appendChild(document.createTextNode('test')); + td1.appendChild(document.createTextNode('test')); + tr1.appendChild(th1); + tr1.appendChild(td1); + + th2.appendChild(document.createTextNode('test')); + td2.appendChild(document.createTextNode('test')); + tr2.appendChild(th2); + tr2.appendChild(td2); + + table.appendChild(tr1); + table.appendChild(tr2); + runTest( - createElement( - { - tag: 'table', - children: [ - { - tag: 'TR', - children: [ - { - tag: 'TH', - children: ['test'], - }, - { - tag: 'TD', - children: ['test'], - }, - ], - }, - { - tag: 'TR', - children: [ - { - tag: 'TH', - children: ['test'], - }, - { - tag: 'TD', - children: ['test'], - }, - ], - }, - ], - }, - document - ) as HTMLTableElement, + table, 0, 0, 0, @@ -773,41 +770,32 @@ describe('setDOMSelection', () => { }); function buildTable(tbody: boolean, thead: boolean = false, tfoot: boolean = false) { - const getElement = (tag: string): CreateElementData => { - return { - tag, - children: [ - { - tag: 'TR', - children: [ - { - tag: 'TD', - children: ['test'], - }, - { - tag: 'TD', - children: ['test'], - }, - ], - }, - { - tag: 'TR', - children: [ - { - tag: 'TD', - children: ['test'], - }, - { - tag: 'TD', - children: ['test'], - }, - ], - }, - ], - }; + const getElement = (tag: string) => { + const container = document.createElement(tag); + const tr1 = document.createElement('tr'); + const td1 = document.createElement('td'); + const td2 = document.createElement('td'); + const tr2 = document.createElement('tr'); + const td3 = document.createElement('td'); + const td4 = document.createElement('td'); + + td1.appendChild(document.createTextNode('test')); + td2.appendChild(document.createTextNode('test')); + tr1.appendChild(td1); + tr1.appendChild(td2); + + td3.appendChild(document.createTextNode('test')); + td4.appendChild(document.createTextNode('test')); + tr2.appendChild(td3); + tr2.appendChild(td4); + + container.appendChild(tr1); + container.appendChild(tr2); + + return container; }; - const children: (string | CreateElementData)[] = []; + const children: HTMLElement[] = []; if (thead) { children.push(getElement('thead')); } @@ -818,41 +806,31 @@ function buildTable(tbody: boolean, thead: boolean = false, tfoot: boolean = fal children.push(getElement('tfoot')); } if (children.length === 0) { - children.push( - { - tag: 'TR', - children: [ - { - tag: 'TD', - children: ['test'], - }, - { - tag: 'TD', - children: ['test'], - }, - ], - }, - { - tag: 'TR', - children: [ - { - tag: 'TD', - children: ['test'], - }, - { - tag: 'TD', - children: ['test'], - }, - ], - } - ); + const tr1 = document.createElement('tr'); + const td1 = document.createElement('td'); + const td2 = document.createElement('td'); + const tr2 = document.createElement('tr'); + const td3 = document.createElement('td'); + const td4 = document.createElement('td'); + + td1.appendChild(document.createTextNode('test')); + td2.appendChild(document.createTextNode('test')); + tr1.appendChild(td1); + tr1.appendChild(td2); + + td3.appendChild(document.createTextNode('test')); + td4.appendChild(document.createTextNode('test')); + tr2.appendChild(td3); + tr2.appendChild(td4); + + children.push(tr1, tr2); } - return createElement( - { - tag: 'table', - children, - }, - document - ) as HTMLTableElement; + const table = document.createElement('table'); + + children.forEach(node => { + table.appendChild(node); + }); + + return table; } diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCachePluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCachePluginTest.ts index ce777586b4e..d75dc60e4e4 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCachePluginTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCachePluginTest.ts @@ -1,9 +1,12 @@ +import * as textMutationObserver from '../../lib/corePlugin/utils/textMutationObserver'; +import { contentModelDomIndexer } from '../../lib/corePlugin/utils/contentModelDomIndexer'; import { createContentModelCachePlugin } from '../../lib/corePlugin/ContentModelCachePlugin'; import { ContentModelCachePluginState, ContentModelDomIndexer, IStandaloneEditor, PluginWithState, + StandaloneEditorOptions, } from 'roosterjs-content-model-types'; describe('ContentModelCachePlugin', () => { @@ -16,8 +19,9 @@ describe('ContentModelCachePlugin', () => { let reconcileSelectionSpy: jasmine.Spy; let isInShadowEditSpy: jasmine.Spy; let domIndexer: ContentModelDomIndexer; + let contentDiv: HTMLDivElement; - function init() { + function init(option: StandaloneEditorOptions) { addEventListenerSpy = jasmine.createSpy('addEventListenerSpy'); removeEventListenerSpy = jasmine.createSpy('removeEventListener'); getDOMSelectionSpy = jasmine.createSpy('getDOMSelection'); @@ -28,6 +32,8 @@ describe('ContentModelCachePlugin', () => { reconcileSelection: reconcileSelectionSpy, } as any; + contentDiv = document.createElement('div'); + editor = ({ getDOMSelection: getDOMSelectionSpy, isInShadowEdit: isInShadowEditSpy, @@ -39,23 +45,47 @@ describe('ContentModelCachePlugin', () => { }, } as any) as IStandaloneEditor; - plugin = createContentModelCachePlugin({}); + plugin = createContentModelCachePlugin(option, contentDiv); plugin.initialize(editor); } describe('initialize', () => { - beforeEach(init); afterEach(() => { plugin.dispose(); }); it('initialize', () => { + init({}); + expect(addEventListenerSpy).toHaveBeenCalledWith('selectionchange', jasmine.anything()); + expect(plugin.getState()).toEqual({}); + }); + + it('initialize with cache', () => { + const startObservingSpy = jasmine.createSpy('startObserving'); + const stopObservingSpy = jasmine.createSpy('stopObserving'); + const mockedObserver = { + startObserving: startObservingSpy, + stopObserving: stopObservingSpy, + } as any; + spyOn(textMutationObserver, 'createTextMutationObserver').and.returnValue( + mockedObserver + ); + init({ + cacheModel: true, + }); expect(addEventListenerSpy).toHaveBeenCalledWith('selectionchange', jasmine.anything()); + expect(plugin.getState()).toEqual({ + domIndexer: contentModelDomIndexer, + textMutationObserver: mockedObserver, + }); + expect(startObservingSpy).toHaveBeenCalledTimes(1); }); }); describe('KeyDown event', () => { - beforeEach(init); + beforeEach(() => { + init({}); + }); afterEach(() => { plugin.dispose(); }); @@ -71,7 +101,6 @@ describe('ContentModelCachePlugin', () => { expect(plugin.getState()).toEqual({ cachedModel: undefined, cachedSelection: undefined, - domIndexer: undefined, }); }); @@ -86,7 +115,6 @@ describe('ContentModelCachePlugin', () => { expect(plugin.getState()).toEqual({ cachedModel: undefined, cachedSelection: undefined, - domIndexer: undefined, }); }); @@ -95,26 +123,7 @@ describe('ContentModelCachePlugin', () => { state.cachedSelection = { type: 'range', range: { collapsed: true } as any, - }; - - plugin.onPluginEvent({ - eventType: 'keyDown', - rawEvent: { - key: 'B', - } as any, - }); - - expect(state).toEqual({ - cachedSelection: { type: 'range', range: { collapsed: true } as any }, - domIndexer: undefined, - }); - }); - - it('Expanded selection with text input', () => { - const state = plugin.getState(); - state.cachedSelection = { - type: 'range', - range: { collapsed: false } as any, + isReverted: false, }; plugin.onPluginEvent({ @@ -127,7 +136,6 @@ describe('ContentModelCachePlugin', () => { expect(state).toEqual({ cachedModel: undefined, cachedSelection: undefined, - domIndexer: undefined, }); }); @@ -136,6 +144,7 @@ describe('ContentModelCachePlugin', () => { state.cachedSelection = { type: 'range', range: { collapsed: false } as any, + isReverted: false, }; plugin.onPluginEvent({ @@ -145,52 +154,9 @@ describe('ContentModelCachePlugin', () => { } as any, }); - expect(state).toEqual({ - cachedSelection: { - type: 'range', - range: { collapsed: false } as any, - }, - domIndexer: undefined, - }); - }); - - it('Table selection', () => { - const state = plugin.getState(); - state.cachedSelection = { - type: 'table', - } as any; - - plugin.onPluginEvent({ - eventType: 'keyDown', - rawEvent: { - key: 'B', - } as any, - }); - expect(state).toEqual({ cachedModel: undefined, cachedSelection: undefined, - domIndexer: undefined, - }); - }); - - it('Image selection', () => { - const state = plugin.getState(); - state.cachedSelection = { - type: 'image', - } as any; - - plugin.onPluginEvent({ - eventType: 'keyDown', - rawEvent: { - key: 'B', - } as any, - }); - - expect(state).toEqual({ - cachedModel: undefined, - cachedSelection: undefined, - domIndexer: undefined, }); }); @@ -205,93 +171,23 @@ describe('ContentModelCachePlugin', () => { } as any, }); - expect(state).toEqual({ - domIndexer: undefined, - }); + expect(state).toEqual({}); }); }); describe('Input event', () => { - beforeEach(init); + beforeEach(() => { + init({}); + }); afterEach(() => { plugin.dispose(); }); it('No cached range, no cached model', () => { const state = plugin.getState(); - state.cachedModel = undefined; - state.cachedSelection = undefined; - - const selection = 'MockedRange' as any; - getDOMSelectionSpy.and.returnValue(selection); - - plugin.onPluginEvent({ - eventType: 'input', - rawEvent: null!, - }); - - expect(state).toEqual({ - cachedModel: undefined, - cachedSelection: undefined, - domIndexer: undefined, - }); - }); - - it('No cached range, has cached model', () => { - const selection = 'MockedRange' as any; - const model = 'MockedModel' as any; - const state = plugin.getState(); - - state.cachedModel = model; - state.cachedSelection = undefined; - - getDOMSelectionSpy.and.returnValue(selection); - - plugin.onPluginEvent({ - eventType: 'input', - rawEvent: null!, - }); - - expect(state).toEqual({ - cachedModel: undefined, - cachedSelection: undefined, - domIndexer: undefined, - }); - }); - it('No cached range, has cached model, reconcile succeed', () => { const selection = 'MockedRange' as any; - const model = 'MockedModel' as any; - const state = plugin.getState(); - - state.cachedModel = model; - state.cachedSelection = undefined; - state.domIndexer = domIndexer; - getDOMSelectionSpy.and.returnValue(selection); - reconcileSelectionSpy.and.returnValue(true); - - plugin.onPluginEvent({ - eventType: 'input', - rawEvent: null!, - }); - - expect(state).toEqual({ - cachedModel: model, - cachedSelection: selection, - domIndexer: domIndexer, - }); - }); - - it('has cached range, has cached model', () => { - const oldRangeEx = 'MockedRangeOld' as any; - const newRangeEx = 'MockedRangeNew' as any; - const model = 'MockedModel' as any; - const state = plugin.getState(); - - state.cachedModel = model; - state.cachedSelection = oldRangeEx; - getDOMSelectionSpy.and.returnValue(newRangeEx); plugin.onPluginEvent({ eventType: 'input', @@ -301,38 +197,14 @@ describe('ContentModelCachePlugin', () => { expect(state).toEqual({ cachedModel: undefined, cachedSelection: undefined, - domIndexer: undefined, - }); - }); - - it('has cached range, has cached model, has domIndexer', () => { - const oldRangeEx = 'MockedRangeOld' as any; - const newRangeEx = 'MockedRangeNew' as any; - const model = 'MockedModel' as any; - const state = plugin.getState(); - - state.cachedModel = model; - state.cachedSelection = oldRangeEx; - state.domIndexer = domIndexer; - - getDOMSelectionSpy.and.returnValue(newRangeEx); - reconcileSelectionSpy.and.returnValue(true); - - plugin.onPluginEvent({ - eventType: 'input', - rawEvent: null!, - }); - - expect(state).toEqual({ - cachedModel: model, - cachedSelection: newRangeEx, - domIndexer, }); }); }); describe('SelectionChanged', () => { - beforeEach(init); + beforeEach(() => { + init({}); + }); afterEach(() => { plugin.dispose(); }); @@ -416,7 +288,9 @@ describe('ContentModelCachePlugin', () => { }); describe('ContentChanged', () => { - beforeEach(init); + beforeEach(() => { + init({}); + }); afterEach(() => { plugin.dispose(); }); @@ -436,7 +310,6 @@ describe('ContentModelCachePlugin', () => { expect(state).toEqual({ cachedModel: undefined, cachedSelection: undefined, - domIndexer: undefined, }); expect(reconcileSelectionSpy).not.toHaveBeenCalled(); }); @@ -462,7 +335,6 @@ describe('ContentModelCachePlugin', () => { expect(state).toEqual({ cachedModel: undefined, cachedSelection: undefined, - domIndexer: undefined, }); expect(reconcileSelectionSpy).not.toHaveBeenCalled(); }); 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 9795fa2a2bc..8c9552d8519 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 @@ -1,13 +1,11 @@ import * as addRangeToSelection from '../../lib/corePlugin/utils/addRangeToSelection'; -import * as cloneModelFile from '../../lib/publicApi/model/cloneModel'; import * as contentModelToDomFile from 'roosterjs-content-model-dom/lib/modelToDom/contentModelToDom'; import * as deleteSelectionsFile from '../../lib/publicApi/selection/deleteSelection'; 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 transformColor from '../../lib/publicApi/color/transformColor'; import { createModelToDomContext, createTable, createTableCell } from 'roosterjs-content-model-dom'; -import { createRange } from 'roosterjs-editor-dom'; +import { createRange } from 'roosterjs-content-model-dom/test/testUtils'; import { setEntityElementClasses } from 'roosterjs-content-model-dom/test/domUtils/entityUtilTest'; import { ContentModelDocument, @@ -67,7 +65,7 @@ describe('ContentModelCopyPastePlugin |', () => { let selectionValue: DOMSelection; let getDOMSelectionSpy: jasmine.Spy; - let createContentModelSpy: jasmine.Spy; + let getContentModelCopySpy: jasmine.Spy; let triggerPluginEventSpy: jasmine.Spy; let focusSpy: jasmine.Spy; let formatContentModelSpy: jasmine.Spy; @@ -75,8 +73,6 @@ describe('ContentModelCopyPastePlugin |', () => { let isDisposed: jasmine.Spy; let pasteSpy: jasmine.Spy; - let cloneModelSpy: jasmine.Spy; - let transformColorSpy: jasmine.Spy; let getVisibleViewportSpy: jasmine.Spy; let formatResult: boolean | undefined; let modelResult: ContentModelDocument | undefined; @@ -90,9 +86,9 @@ describe('ContentModelCopyPastePlugin |', () => { getDOMSelectionSpy = jasmine .createSpy('getDOMSelection') .and.callFake(() => selectionValue); - createContentModelSpy = jasmine - .createSpy('createContentModelSpy') - .and.returnValue(modelValue); + getContentModelCopySpy = jasmine + .createSpy('getContentModelCopy') + .and.returnValue(pasteModelValue); triggerPluginEventSpy = jasmine.createSpy('triggerPluginEventSpy'); focusSpy = jasmine.createSpy('focusSpy'); setDOMSelectionSpy = jasmine.createSpy('setDOMSelection'); @@ -100,16 +96,12 @@ describe('ContentModelCopyPastePlugin |', () => { isDisposed = jasmine.createSpy('isDisposed'); getVisibleViewportSpy = jasmine.createSpy('getVisibleViewport'); - cloneModelSpy = spyOn(cloneModelFile, 'cloneModel').and.callFake( - (model: any) => pasteModelValue - ); - transformColorSpy = spyOn(transformColor, 'transformColor'); mockedDarkColorHandler = 'DARKCOLORHANDLER' as any; formatContentModelSpy = jasmine .createSpy('formatContentModel') .and.callFake( (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - modelResult = createContentModelSpy(); + modelResult = modelValue; formatResult = callback(modelResult!, { newEntities: [], deletedEntities: [], @@ -128,7 +120,7 @@ describe('ContentModelCopyPastePlugin |', () => { attachDomEvent: (eventMap: Record) => { domEvents = eventMap; }, - createContentModel: (options: any) => createContentModelSpy(options), + getContentModelCopy: (options: any) => getContentModelCopySpy(options), triggerEvent(eventType: any, data: any, broadcast: any) { triggerPluginEventSpy(eventType, data, broadcast); return data; @@ -173,7 +165,7 @@ describe('ContentModelCopyPastePlugin |', () => { range: { collapsed: true } as any, }; - createContentModelSpy.and.callThrough(); + getContentModelCopySpy.and.callThrough(); triggerPluginEventSpy.and.callThrough(); focusSpy.and.callThrough(); setDOMSelectionSpy.and.callThrough(); @@ -181,7 +173,7 @@ describe('ContentModelCopyPastePlugin |', () => { domEvents.copy.beforeDispatch?.({}); expect(getDOMSelectionSpy).toHaveBeenCalled(); - expect(createContentModelSpy).not.toHaveBeenCalled(); + expect(getContentModelCopySpy).not.toHaveBeenCalled(); expect(triggerPluginEventSpy).not.toHaveBeenCalled(); expect(focusSpy).not.toHaveBeenCalled(); expect(setDOMSelectionSpy).not.toHaveBeenCalled(); @@ -213,10 +205,9 @@ describe('ContentModelCopyPastePlugin |', () => { document, div, pasteModelValue, - createModelToDomContext(), - onNodeCreated + { ...createModelToDomContext(), onNodeCreated } ); - expect(createContentModelSpy).toHaveBeenCalled(); + expect(getContentModelCopySpy).toHaveBeenCalled(); expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); expect(iterateSelectionsFile.iterateSelections).toHaveBeenCalled(); expect(focusSpy).toHaveBeenCalled(); @@ -261,10 +252,9 @@ describe('ContentModelCopyPastePlugin |', () => { document, div, pasteModelValue, - createModelToDomContext(), - onNodeCreated + { ...createModelToDomContext(), onNodeCreated } ); - expect(createContentModelSpy).toHaveBeenCalled(); + expect(getContentModelCopySpy).toHaveBeenCalled(); expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); expect(iterateSelectionsFile.iterateSelections).toHaveBeenCalled(); expect(focusSpy).toHaveBeenCalled(); @@ -305,10 +295,9 @@ describe('ContentModelCopyPastePlugin |', () => { document, div, pasteModelValue, - createModelToDomContext(), - onNodeCreated + { ...createModelToDomContext(), onNodeCreated } ); - expect(createContentModelSpy).toHaveBeenCalled(); + expect(getContentModelCopySpy).toHaveBeenCalled(); expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); expect(focusSpy).toHaveBeenCalled(); expect(setDOMSelectionSpy).toHaveBeenCalledWith(selectionValue); @@ -344,29 +333,6 @@ describe('ContentModelCopyPastePlugin |', () => { editor.isDarkMode = () => true; - cloneModelSpy.and.callFake((model, options) => { - expect(model).toEqual(modelValue); - expect(typeof options.includeCachedElement).toBe('function'); - - const cloneCache = options.includeCachedElement(wrapper, 'cache'); - const cloneEntity = options.includeCachedElement(wrapper, 'entity'); - - expect(cloneCache).toBeUndefined(); - expect(cloneEntity.outerHTML).toBe( - '' - ); - expect(cloneEntity).not.toBe(wrapper); - expect(transformColorSpy).toHaveBeenCalledTimes(1); - expect(transformColorSpy).toHaveBeenCalledWith( - cloneEntity, - true, - 'darkToLight', - mockedDarkColorHandler - ); - - return pasteModelValue; - }); - // Act domEvents.copy.beforeDispatch?.({}); @@ -377,14 +343,12 @@ describe('ContentModelCopyPastePlugin |', () => { document, div, pasteModelValue, - createModelToDomContext(), - onNodeCreated + { ...createModelToDomContext(), onNodeCreated } ); - expect(createContentModelSpy).toHaveBeenCalled(); + expect(getContentModelCopySpy).toHaveBeenCalledWith('disconnected'); expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); expect(focusSpy).toHaveBeenCalled(); expect(setDOMSelectionSpy).toHaveBeenCalledWith(selectionValue); - expect(cloneModelSpy).toHaveBeenCalledTimes(1); // On Cut Spy expect(formatContentModelSpy).not.toHaveBeenCalled(); @@ -401,7 +365,7 @@ describe('ContentModelCopyPastePlugin |', () => { range: { collapsed: true } as any, }; - createContentModelSpy.and.callThrough(); + getContentModelCopySpy.and.callThrough(); triggerPluginEventSpy.and.callThrough(); focusSpy.and.callThrough(); setDOMSelectionSpy.and.callThrough(); @@ -411,7 +375,7 @@ describe('ContentModelCopyPastePlugin |', () => { // Assert expect(getDOMSelectionSpy).toHaveBeenCalled(); - expect(createContentModelSpy).not.toHaveBeenCalled(); + expect(getContentModelCopySpy).not.toHaveBeenCalled(); expect(triggerPluginEventSpy).not.toHaveBeenCalled(); expect(focusSpy).not.toHaveBeenCalled(); expect(formatContentModelSpy).not.toHaveBeenCalled(); @@ -452,10 +416,9 @@ describe('ContentModelCopyPastePlugin |', () => { document, div, pasteModelValue, - createModelToDomContext(), - onNodeCreated + { ...createModelToDomContext(), onNodeCreated } ); - expect(createContentModelSpy).toHaveBeenCalled(); + expect(getContentModelCopySpy).toHaveBeenCalled(); expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); expect(focusSpy).toHaveBeenCalled(); expect(setDOMSelectionSpy).toHaveBeenCalledWith(selectionValue); @@ -502,10 +465,9 @@ describe('ContentModelCopyPastePlugin |', () => { document, div, pasteModelValue, - createModelToDomContext(), - onNodeCreated + { ...createModelToDomContext(), onNodeCreated } ); - expect(createContentModelSpy).toHaveBeenCalled(); + expect(getContentModelCopySpy).toHaveBeenCalled(); expect(iterateSelectionsFile.iterateSelections).toHaveBeenCalled(); expect(focusSpy).toHaveBeenCalled(); expect(setDOMSelectionSpy).toHaveBeenCalledWith(selectionValue); @@ -551,10 +513,9 @@ describe('ContentModelCopyPastePlugin |', () => { document, div, pasteModelValue, - createModelToDomContext(), - onNodeCreated + { ...createModelToDomContext(), onNodeCreated } ); - expect(createContentModelSpy).toHaveBeenCalled(); + expect(getContentModelCopySpy).toHaveBeenCalled(); expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); expect(focusSpy).toHaveBeenCalled(); expect(setDOMSelectionSpy).toHaveBeenCalledWith(selectionValue); 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 a773e8df4fc..bff9bb60866 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 @@ -277,7 +277,9 @@ describe('ContentModelFormatPlugin for default format', () => { contentDiv = document.createElement('div'); editor = ({ - isNodeInEditor: (e: Node) => contentDiv != e && contentDiv.contains(e), + getDOMHelper: () => ({ + 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/ContextMenuPluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContextMenuPluginTest.ts new file mode 100644 index 00000000000..92fac47a729 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContextMenuPluginTest.ts @@ -0,0 +1,169 @@ +import * as getSelectionRootNode from '../../lib/publicApi/selection/getSelectionRootNode'; +import { createContextMenuPlugin } from '../../lib/corePlugin/ContextMenuPlugin'; +import { + ContextMenuPluginState, + DOMEventRecord, + IStandaloneEditor, + PluginWithState, +} from 'roosterjs-content-model-types'; + +describe('ContextMenu handle other event', () => { + let plugin: PluginWithState; + let eventMap: Record; + let triggerEventSpy: jasmine.Spy; + let getDOMSelectionSpy: jasmine.Spy; + let attachDOMEventSpy: jasmine.Spy; + let getSelectionRootNodeSpy: jasmine.Spy; + let editor: IStandaloneEditor; + + beforeEach(() => { + triggerEventSpy = jasmine.createSpy('triggerEvent'); + getDOMSelectionSpy = jasmine.createSpy('getDOMSelection'); + getSelectionRootNodeSpy = spyOn(getSelectionRootNode, 'getSelectionRootNode'); + attachDOMEventSpy = jasmine + .createSpy('attachDOMEvent') + .and.callFake((handlers: Record) => { + eventMap = handlers; + }); + + editor = ({ + getDOMSelection: getDOMSelectionSpy, + attachDomEvent: attachDOMEventSpy, + triggerEvent: triggerEventSpy, + }); + }); + + afterEach(() => { + plugin.dispose(); + }); + + it('Ctor with parameter', () => { + const mockedPlugin1 = {} as any; + const mockedPlugin2 = { + getContextMenuItems: () => {}, + } as any; + + plugin = createContextMenuPlugin({ + plugins: [mockedPlugin1, mockedPlugin2], + }); + + plugin.initialize(editor); + + expect(attachDOMEventSpy).toHaveBeenCalledTimes(1); + + const state = plugin.getState(); + + expect(state).toEqual({ + contextMenuProviders: [mockedPlugin2], + }); + }); + + it('Trigger contextmenu event, skip reselect', () => { + plugin = createContextMenuPlugin({}); + plugin.initialize(editor); + + const state = plugin.getState(); + const mockedItems1 = ['Item1', 'Item2']; + const mockedItems2 = ['Item3', 'Item4']; + + const getContextMenuItemSpy1 = jasmine + .createSpy('getContextMenu 1') + .and.returnValue(mockedItems1); + const getContextMenuItemSpy2 = jasmine + .createSpy('getContextMenu 2') + .and.returnValue(mockedItems2); + + state.contextMenuProviders = [ + { + getContextMenuItems: getContextMenuItemSpy1, + } as any, + { + getContextMenuItems: getContextMenuItemSpy2, + } as any, + ]; + + const mockedTarget = 'TARGET' as any; + const mockedEvent = { + button: 2, + target: mockedTarget, + }; + const collapseSpy = jasmine.createSpy('collapse'); + const mockedRange = { + collapse: collapseSpy, + }; + const mockedSelection = { + type: 'range', + range: mockedRange, + }; + const mockedNode = 'NODE'; + + getDOMSelectionSpy.and.returnValue(mockedSelection); + getSelectionRootNodeSpy.and.returnValue(mockedNode); + + eventMap.contextmenu.beforeDispatch(mockedEvent); + + expect(collapseSpy).not.toHaveBeenCalled(); + expect(getDOMSelectionSpy).not.toHaveBeenCalled(); + expect(getSelectionRootNodeSpy).not.toHaveBeenCalled(); + expect(getContextMenuItemSpy1).toHaveBeenCalledWith(mockedTarget); + expect(getContextMenuItemSpy2).toHaveBeenCalledWith(mockedTarget); + expect(triggerEventSpy).toHaveBeenCalledWith('contextMenu', { + rawEvent: mockedEvent, + items: ['Item1', 'Item2', null, 'Item3', 'Item4'], + }); + }); + + it('Trigger contextmenu event using keyboard', () => { + plugin = createContextMenuPlugin({}); + plugin.initialize(editor); + + const state = plugin.getState(); + const mockedItems1 = ['Item1', 'Item2']; + const mockedItems2 = ['Item3', 'Item4']; + const getContextMenuItemSpy1 = jasmine + .createSpy('getContextMenu 1') + .and.returnValue(mockedItems1); + const getContextMenuItemSpy2 = jasmine + .createSpy('getContextMenu 2') + .and.returnValue(mockedItems2); + + state.contextMenuProviders = [ + { + getContextMenuItems: getContextMenuItemSpy1, + } as any, + { + getContextMenuItems: getContextMenuItemSpy2, + } as any, + ]; + + const mockedTarget = 'TARGET' as any; + const mockedEvent = { + button: -1, + target: mockedTarget, + }; + const collapseSpy = jasmine.createSpy('collapse'); + const mockedRange = { + collapse: collapseSpy, + }; + const mockedSelection = { + type: 'range', + range: mockedRange, + }; + const mockedNode = 'NODE'; + + getDOMSelectionSpy.and.returnValue(mockedSelection); + getSelectionRootNodeSpy.and.returnValue(mockedNode); + + eventMap.contextmenu.beforeDispatch(mockedEvent); + + expect(collapseSpy).toHaveBeenCalledWith(true); + expect(getDOMSelectionSpy).toHaveBeenCalledWith(); + expect(getSelectionRootNodeSpy).toHaveBeenCalledWith(mockedSelection); + expect(getContextMenuItemSpy1).toHaveBeenCalledWith(mockedNode); + expect(getContextMenuItemSpy2).toHaveBeenCalledWith(mockedNode); + expect(triggerEventSpy).toHaveBeenCalledWith('contextMenu', { + rawEvent: mockedEvent, + items: ['Item1', 'Item2', null, 'Item3', 'Item4'], + }); + }); +}); 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 60df830cfb9..2f3b43156df 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 @@ -3,6 +3,7 @@ import * as transformColor from '../../lib/publicApi/color/transformColor'; import { createContentModelDocument, createEntity } from '../../../roosterjs-content-model-dom/lib'; import { createEntityPlugin } from '../../lib/corePlugin/EntityPlugin'; import { + ContentModelDocument, DarkColorHandler, EntityPluginState, IStandaloneEditor, @@ -12,15 +13,20 @@ import { describe('EntityPlugin', () => { let editor: IStandaloneEditor; let plugin: PluginWithState; - let createContentModelSpy: jasmine.Spy; + let formatContentModelSpy: jasmine.Spy; let triggerPluginEventSpy: jasmine.Spy; let isDarkModeSpy: jasmine.Spy; let isNodeInEditorSpy: jasmine.Spy; let transformColorSpy: jasmine.Spy; let mockedDarkColorHandler: DarkColorHandler; + let mockedModel: ContentModelDocument; beforeEach(() => { - createContentModelSpy = jasmine.createSpy('createContentModel'); + formatContentModelSpy = jasmine + .createSpy('formatContentModel') + .and.callFake((callback: Function) => { + callback(mockedModel); + }); triggerPluginEventSpy = jasmine.createSpy('triggerEvent'); isDarkModeSpy = jasmine.createSpy('isDarkMode'); isNodeInEditorSpy = jasmine.createSpy('isNodeInEditor'); @@ -28,10 +34,12 @@ describe('EntityPlugin', () => { mockedDarkColorHandler = 'COLOR' as any; editor = { - createContentModel: createContentModelSpy, + formatContentModel: formatContentModelSpy, triggerEvent: triggerPluginEventSpy, isDarkMode: isDarkModeSpy, - isNodeInEditor: isNodeInEditorSpy, + getDOMHelper: () => ({ + isNodeInEditor: isNodeInEditorSpy, + }), getColorManager: () => mockedDarkColorHandler, } as any; plugin = createEntityPlugin(); @@ -48,7 +56,7 @@ describe('EntityPlugin', () => { describe('EditorReady event', () => { it('empty doc', () => { - createContentModelSpy.and.returnValue(createContentModelDocument()); + mockedModel = createContentModelDocument(); plugin.onPluginEvent({ eventType: 'editorReady', @@ -68,7 +76,7 @@ describe('EntityPlugin', () => { doc.blocks.push(entity); - createContentModelSpy.and.returnValue(doc); + mockedModel = doc; plugin.onPluginEvent({ eventType: 'editorReady', @@ -108,7 +116,7 @@ describe('EntityPlugin', () => { doc.blocks.push(entity); - createContentModelSpy.and.returnValue(doc); + mockedModel = doc; triggerPluginEventSpy.and.returnValue({ shouldPersist: true, }); @@ -153,7 +161,7 @@ describe('EntityPlugin', () => { doc.blocks.push(entity); - createContentModelSpy.and.returnValue(doc); + mockedModel = doc; plugin.onPluginEvent({ eventType: 'contentChanged', @@ -193,7 +201,7 @@ describe('EntityPlugin', () => { doc.blocks.push(entity); - createContentModelSpy.and.returnValue(doc); + mockedModel = doc; isDarkModeSpy.and.returnValue(true); plugin.onPluginEvent({ @@ -240,7 +248,7 @@ describe('EntityPlugin', () => { doc.blocks.push(entity); - createContentModelSpy.and.returnValue(doc); + mockedModel = doc; const state = plugin.getState(); const wrapper2 = document.createElement('div'); @@ -298,7 +306,7 @@ describe('EntityPlugin', () => { it('Do not trigger event for already deleted entity', () => { const doc = createContentModelDocument(); - createContentModelSpy.and.returnValue(doc); + mockedModel = doc; const state = plugin.getState(); const wrapper2 = document.createElement('div'); @@ -332,7 +340,7 @@ describe('EntityPlugin', () => { doc.blocks.push(entity); - createContentModelSpy.and.returnValue(doc); + mockedModel = doc; const state = plugin.getState(); state.entityMap['Entity1'] = { @@ -519,7 +527,7 @@ describe('EntityPlugin', () => { isReadonly: true, }); doc.blocks.push(entity); - createContentModelSpy.and.returnValue(doc); + mockedModel = doc; state.entityMap[id] = { element: wrapper, diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/LifecyclePluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/LifecyclePluginTest.ts index 9c434af5ccc..d4a3e3bb258 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/LifecyclePluginTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/LifecyclePluginTest.ts @@ -9,14 +9,12 @@ describe('LifecyclePlugin', () => { const plugin = createLifecyclePlugin({}, div); const triggerEvent = jasmine.createSpy('triggerEvent'); const state = plugin.getState(); - const setContentModelSpy = jasmine.createSpy('setContentModel'); plugin.initialize(({ triggerEvent, getFocusedPosition: () => null, getColorManager: () => null, isDarkMode: () => false, - setContentModel: setContentModelSpy, })); expect(state).toEqual({ @@ -29,23 +27,6 @@ describe('LifecyclePlugin', () => { expect(div.innerHTML).toBe(''); expect(triggerEvent).toHaveBeenCalledTimes(1); expect(triggerEvent.calls.argsFor(0)[0]).toBe('editorReady'); - expect(setContentModelSpy).toHaveBeenCalledTimes(1); - expect(setContentModelSpy).toHaveBeenCalledWith( - { - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { segmentType: 'SelectionMarker', isSelected: true, format: {} }, - { segmentType: 'Br', format: {} }, - ], - format: {}, - }, - ], - }, - { ignoreSelection: true } - ); plugin.dispose(); expect(div.isContentEditable).toBeFalse(); @@ -65,14 +46,12 @@ describe('LifecyclePlugin', () => { ); const triggerEvent = jasmine.createSpy('triggerEvent'); const state = plugin.getState(); - const setContentModelSpy = jasmine.createSpy('setContentModel'); plugin.initialize(({ triggerEvent, getFocusedPosition: () => null, getColorManager: () => null, isDarkMode: () => false, - setContentModel: setContentModelSpy, })); expect(state).toEqual({ @@ -80,9 +59,6 @@ describe('LifecyclePlugin', () => { shadowEditFragment: null, }); - expect(setContentModelSpy).toHaveBeenCalledTimes(1); - expect(setContentModelSpy).toHaveBeenCalledWith(mockedModel, { ignoreSelection: true }); - expect(div.isContentEditable).toBeTrue(); expect(div.style.userSelect).toBe('text'); expect(triggerEvent).toHaveBeenCalledTimes(1); @@ -97,14 +73,12 @@ describe('LifecyclePlugin', () => { div.contentEditable = 'true'; const plugin = createLifecyclePlugin({}, div); const triggerEvent = jasmine.createSpy('triggerEvent'); - const setContentModelSpy = jasmine.createSpy('setContentModel'); plugin.initialize(({ triggerEvent, getFocusedPosition: () => null, getColorManager: () => null, isDarkMode: () => false, - setContentModel: setContentModelSpy, })); expect(div.isContentEditable).toBeTrue(); @@ -112,24 +86,6 @@ describe('LifecyclePlugin', () => { expect(triggerEvent).toHaveBeenCalledTimes(1); expect(triggerEvent.calls.argsFor(0)[0]).toBe('editorReady'); - expect(setContentModelSpy).toHaveBeenCalledTimes(1); - expect(setContentModelSpy).toHaveBeenCalledWith( - { - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { segmentType: 'SelectionMarker', isSelected: true, format: {} }, - { segmentType: 'Br', format: {} }, - ], - format: {}, - }, - ], - }, - { ignoreSelection: true } - ); - plugin.dispose(); expect(div.isContentEditable).toBeTrue(); }); @@ -139,33 +95,14 @@ describe('LifecyclePlugin', () => { div.contentEditable = 'false'; const plugin = createLifecyclePlugin({}, div); const triggerEvent = jasmine.createSpy('triggerEvent'); - const setContentModelSpy = jasmine.createSpy('setContentModel'); plugin.initialize(({ triggerEvent, getFocusedPosition: () => null, getColorManager: () => null, isDarkMode: () => false, - setContentModel: setContentModelSpy, })); - expect(setContentModelSpy).toHaveBeenCalledTimes(1); - expect(setContentModelSpy).toHaveBeenCalledWith( - { - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { segmentType: 'SelectionMarker', isSelected: true, format: {} }, - { segmentType: 'Br', format: {} }, - ], - format: {}, - }, - ], - }, - { ignoreSelection: true } - ); expect(div.isContentEditable).toBeFalse(); expect(div.style.userSelect).toBe(''); expect(triggerEvent).toHaveBeenCalledTimes(1); @@ -180,7 +117,6 @@ describe('LifecyclePlugin', () => { const plugin = createLifecyclePlugin({}, div); const triggerEvent = jasmine.createSpy('triggerEvent'); const state = plugin.getState(); - const setContentModelSpy = jasmine.createSpy('setContentModel'); const mockedDarkColorHandler = 'HANDLER' as any; const setColorSpy = spyOn(color, 'setColor'); @@ -188,7 +124,6 @@ describe('LifecyclePlugin', () => { plugin.initialize(({ triggerEvent, getColorManager: () => mockedDarkColorHandler, - setContentModel: setContentModelSpy, })); expect(setColorSpy).toHaveBeenCalledTimes(2); @@ -212,7 +147,6 @@ describe('LifecyclePlugin', () => { const plugin = createLifecyclePlugin({}, div); const triggerEvent = jasmine.createSpy('triggerEvent'); const state = plugin.getState(); - const setContentModelSpy = jasmine.createSpy('setContentModel'); const mockedDarkColorHandler = 'HANDLER' as any; const setColorSpy = spyOn(color, 'setColor'); @@ -220,7 +154,6 @@ describe('LifecyclePlugin', () => { plugin.initialize(({ triggerEvent, getColorManager: () => mockedDarkColorHandler, - setContentModel: setContentModelSpy, })); expect(setColorSpy).toHaveBeenCalledTimes(2); @@ -266,7 +199,6 @@ describe('LifecyclePlugin', () => { ); const triggerEvent = jasmine.createSpy('triggerEvent'); const state = plugin.getState(); - const setContentModelSpy = jasmine.createSpy('setContentModel'); const mockedDarkColorHandler = 'HANDLER' as any; const setColorSpy = spyOn(color, 'setColor'); @@ -274,7 +206,6 @@ describe('LifecyclePlugin', () => { plugin.initialize(({ triggerEvent, getDarkColorHandler: () => mockedDarkColorHandler, - setContentModel: setContentModelSpy, })); expect(setColorSpy).toHaveBeenCalledTimes(0); 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 a8920c267ae..e73cac4e98b 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 @@ -248,6 +248,7 @@ describe('SelectionPlugin handle image selection', () => { expect(setDOMSelectionSpy).toHaveBeenCalledWith({ type: 'range', range: mockedRange, + isReverted: false, }); }); @@ -463,6 +464,7 @@ describe('SelectionPlugin handle image selection', () => { expect(setDOMSelectionSpy).toHaveBeenCalledWith({ type: 'range', range: mockedRange, + isReverted: false, }); }); @@ -500,6 +502,7 @@ describe('SelectionPlugin handle image selection', () => { expect(setDOMSelectionSpy).toHaveBeenCalledWith({ type: 'range', range: mockedRange, + isReverted: false, }); }); @@ -569,3 +572,237 @@ describe('SelectionPlugin handle image selection', () => { expect(setDOMSelectionSpy).not.toHaveBeenCalled(); }); }); + +describe('SelectionPlugin on Safari', () => { + let disposer: jasmine.Spy; + let createElementSpy: jasmine.Spy; + let appendChildSpy: jasmine.Spy; + let attachDomEvent: jasmine.Spy; + let removeEventListenerSpy: jasmine.Spy; + let addEventListenerSpy: jasmine.Spy; + let getDocumentSpy: jasmine.Spy; + let hasFocusSpy: jasmine.Spy; + let isInShadowEditSpy: jasmine.Spy; + let getDOMSelectionSpy: jasmine.Spy; + let editor: IStandaloneEditor; + + beforeEach(() => { + disposer = jasmine.createSpy('disposer'); + createElementSpy = jasmine.createSpy('createElement').and.returnValue(MockedStyleNode); + appendChildSpy = jasmine.createSpy('appendChild'); + attachDomEvent = jasmine.createSpy('attachDomEvent').and.returnValue(disposer); + removeEventListenerSpy = jasmine.createSpy('removeEventListener'); + addEventListenerSpy = jasmine.createSpy('addEventListener'); + getDocumentSpy = jasmine.createSpy('getDocument').and.returnValue({ + createElement: createElementSpy, + head: { + appendChild: appendChildSpy, + }, + addEventListener: addEventListenerSpy, + removeEventListener: removeEventListenerSpy, + }); + hasFocusSpy = jasmine.createSpy('hasFocus'); + isInShadowEditSpy = jasmine.createSpy('isInShadowEdit'); + getDOMSelectionSpy = jasmine.createSpy('getDOMSelection'); + + editor = ({ + getDocument: getDocumentSpy, + attachDomEvent, + getEnvironment: () => ({ + isSafari: true, + }), + hasFocus: hasFocusSpy, + isInShadowEdit: isInShadowEditSpy, + getDOMSelection: getDOMSelectionSpy, + } as any) as IStandaloneEditor; + }); + + it('init and dispose', () => { + const plugin = createSelectionPlugin({}); + const state = plugin.getState(); + + plugin.initialize(editor); + + expect(state).toEqual({ + selection: null, + selectionStyleNode: MockedStyleNode, + imageSelectionBorderColor: undefined, + }); + expect(attachDomEvent).toHaveBeenCalled(); + expect(addEventListenerSpy).toHaveBeenCalledWith('selectionchange', jasmine.anything()); + expect(removeEventListenerSpy).not.toHaveBeenCalled(); + expect(disposer).not.toHaveBeenCalled(); + + const onSelectionChange = addEventListenerSpy.calls.argsFor(0)[1] as Function; + + plugin.dispose(); + + expect(removeEventListenerSpy).toHaveBeenCalledWith('selectionchange', onSelectionChange); + expect(disposer).toHaveBeenCalled(); + }); + + it('onSelectionChange when editor has focus, no selection, not in shadow edit', () => { + const plugin = createSelectionPlugin({}); + const state = plugin.getState(); + const mockedOldSelection = 'OLDSELECTION' as any; + + state.selection = mockedOldSelection; + + plugin.initialize(editor); + + const onSelectionChange = addEventListenerSpy.calls.argsFor(0)[1] as Function; + + hasFocusSpy.and.returnValue(true); + isInShadowEditSpy.and.returnValue(false); + getDOMSelectionSpy.and.returnValue(null); + + onSelectionChange(); + + expect(state).toEqual({ + selection: mockedOldSelection, + selectionStyleNode: MockedStyleNode, + imageSelectionBorderColor: undefined, + }); + expect(getDOMSelectionSpy).toHaveBeenCalledTimes(1); + }); + + it('onSelectionChange when editor has focus, range selection, not in shadow edit', () => { + const plugin = createSelectionPlugin({}); + const state = plugin.getState(); + const mockedOldSelection = 'OLDSELECTION' as any; + + state.selection = mockedOldSelection; + + plugin.initialize(editor); + + const onSelectionChange = addEventListenerSpy.calls.argsFor(0)[1] as Function; + const mockedNewSelection = { + type: 'range', + } as any; + + hasFocusSpy.and.returnValue(true); + isInShadowEditSpy.and.returnValue(false); + getDOMSelectionSpy.and.returnValue(mockedNewSelection); + + onSelectionChange(); + + expect(state).toEqual({ + selection: mockedNewSelection, + selectionStyleNode: MockedStyleNode, + imageSelectionBorderColor: undefined, + }); + expect(getDOMSelectionSpy).toHaveBeenCalledTimes(1); + }); + + it('onSelectionChange when editor has focus, table selection, not in shadow edit', () => { + const plugin = createSelectionPlugin({}); + const state = plugin.getState(); + const mockedOldSelection = 'OLDSELECTION' as any; + + state.selection = mockedOldSelection; + + plugin.initialize(editor); + + const onSelectionChange = addEventListenerSpy.calls.argsFor(0)[1] as Function; + const mockedNewSelection = { + type: 'table', + } as any; + + hasFocusSpy.and.returnValue(true); + isInShadowEditSpy.and.returnValue(false); + getDOMSelectionSpy.and.returnValue(mockedNewSelection); + + onSelectionChange(); + + expect(state).toEqual({ + selection: mockedOldSelection, + selectionStyleNode: MockedStyleNode, + imageSelectionBorderColor: undefined, + }); + expect(getDOMSelectionSpy).toHaveBeenCalledTimes(1); + }); + + it('onSelectionChange when editor has focus, image selection, not in shadow edit', () => { + const plugin = createSelectionPlugin({}); + const state = plugin.getState(); + const mockedOldSelection = 'OLDSELECTION' as any; + + state.selection = mockedOldSelection; + + plugin.initialize(editor); + + const onSelectionChange = addEventListenerSpy.calls.argsFor(0)[1] as Function; + const mockedNewSelection = { + type: 'image', + } as any; + + hasFocusSpy.and.returnValue(true); + isInShadowEditSpy.and.returnValue(false); + getDOMSelectionSpy.and.returnValue(mockedNewSelection); + + onSelectionChange(); + + expect(state).toEqual({ + selection: mockedOldSelection, + selectionStyleNode: MockedStyleNode, + imageSelectionBorderColor: undefined, + }); + expect(getDOMSelectionSpy).toHaveBeenCalledTimes(1); + }); + + it('onSelectionChange when editor has focus, is in shadow edit', () => { + const plugin = createSelectionPlugin({}); + const state = plugin.getState(); + const mockedOldSelection = 'OLDSELECTION' as any; + + state.selection = mockedOldSelection; + + plugin.initialize(editor); + + const onSelectionChange = addEventListenerSpy.calls.argsFor(0)[1] as Function; + const mockedNewSelection = { + type: 'range', + } as any; + + hasFocusSpy.and.returnValue(true); + isInShadowEditSpy.and.returnValue(true); + getDOMSelectionSpy.and.returnValue(mockedNewSelection); + + onSelectionChange(); + + expect(state).toEqual({ + selection: mockedOldSelection, + selectionStyleNode: MockedStyleNode, + imageSelectionBorderColor: undefined, + }); + expect(getDOMSelectionSpy).toHaveBeenCalledTimes(0); + }); + + it('onSelectionChange when editor does not have focus', () => { + const plugin = createSelectionPlugin({}); + const state = plugin.getState(); + const mockedOldSelection = 'OLDSELECTION' as any; + + state.selection = mockedOldSelection; + + plugin.initialize(editor); + + const onSelectionChange = addEventListenerSpy.calls.argsFor(0)[1] as Function; + const mockedNewSelection = { + type: 'range', + } as any; + + hasFocusSpy.and.returnValue(false); + isInShadowEditSpy.and.returnValue(false); + getDOMSelectionSpy.and.returnValue(mockedNewSelection); + + onSelectionChange(); + + expect(state).toEqual({ + selection: mockedOldSelection, + selectionStyleNode: MockedStyleNode, + imageSelectionBorderColor: undefined, + }); + expect(getDOMSelectionSpy).toHaveBeenCalledTimes(0); + }); +}); 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 3c0b2050ddd..47abee4f5f7 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 @@ -64,7 +64,9 @@ describe('applyDefaultFormat', () => { ); editor = { - isNodeInEditor: () => true, + getDOMHelper: () => ({ + isNodeInEditor: () => true, + }), getDOMSelection: getDOMSelectionSpy, formatContentModel: formatContentModelSpy, takeSnapshot: takeSnapshotSpy, diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/areSameRangeExTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/areSameRangeExTest.ts index a97c0de20db..a8fd0ffbb0f 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/areSameRangeExTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/areSameRangeExTest.ts @@ -28,6 +28,7 @@ describe('areSameSelection', () => { startOffset, endOffset, } as any, + isReverted: false, }, { type: 'range', @@ -37,6 +38,7 @@ describe('areSameSelection', () => { startOffset, endOffset, } as any, + isReverted: false, }, true ); @@ -88,6 +90,7 @@ describe('areSameSelection', () => { startOffset, endOffset, } as any, + isReverted: false, }, { type: 'table', @@ -111,6 +114,7 @@ describe('areSameSelection', () => { startOffset, endOffset, } as any, + isReverted: false, }, { type: 'image', @@ -148,6 +152,7 @@ describe('areSameSelection', () => { startOffset, endOffset, } as any, + isReverted: false, }, { type: 'range', @@ -157,6 +162,7 @@ describe('areSameSelection', () => { startOffset, endOffset, } as any, + isReverted: false, }, false ); @@ -172,6 +178,7 @@ describe('areSameSelection', () => { startOffset, endOffset, } as any, + isReverted: false, }, { type: 'range', @@ -181,6 +188,7 @@ describe('areSameSelection', () => { startOffset, endOffset, } as any, + isReverted: false, }, false ); @@ -196,6 +204,7 @@ describe('areSameSelection', () => { startOffset, endOffset, } as any, + isReverted: false, }, { type: 'range', @@ -205,6 +214,7 @@ describe('areSameSelection', () => { startOffset: 3, endOffset, } as any, + isReverted: false, }, false ); @@ -220,6 +230,7 @@ describe('areSameSelection', () => { startOffset, endOffset, } as any, + isReverted: false, }, { type: 'range', @@ -229,6 +240,7 @@ describe('areSameSelection', () => { startOffset, endOffset: 4, } as any, + isReverted: false, }, false ); diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/contentModelDomIndexerTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/contentModelDomIndexerTest.ts index a6d1c33c4a2..447252d85d0 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/contentModelDomIndexerTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/contentModelDomIndexerTest.ts @@ -1,6 +1,6 @@ import * as setSelection from '../../../lib/publicApi/selection/setSelection'; import { contentModelDomIndexer } from '../../../lib/corePlugin/utils/contentModelDomIndexer'; -import { createRange } from 'roosterjs-editor-dom'; +import { createRange } from 'roosterjs-content-model-dom/test/testUtils'; import { ContentModelDocument, ContentModelSegment, @@ -200,6 +200,7 @@ describe('contentModelDomIndexer.reconcileSelection', () => { const newRangeEx: DOMSelection = { type: 'range', range: createRange(node, 2), + isReverted: false, }; const result = contentModelDomIndexer.reconcileSelection(model, newRangeEx); @@ -213,6 +214,7 @@ describe('contentModelDomIndexer.reconcileSelection', () => { const newRangeEx: DOMSelection = { type: 'range', range: createRange(node, 2), + isReverted: false, }; const paragraph = createParagraph(); const segment = createText(''); @@ -259,6 +261,7 @@ describe('contentModelDomIndexer.reconcileSelection', () => { const newRangeEx: DOMSelection = { type: 'range', range: createRange(node, 1, node, 3), + isReverted: false, }; const paragraph = createParagraph(); const segment = createText(''); @@ -309,6 +312,7 @@ describe('contentModelDomIndexer.reconcileSelection', () => { const newRangeEx: DOMSelection = { type: 'range', range: createRange(node1, 2, node2, 3), + isReverted: false, }; const paragraph = createParagraph(); const oldSegment1 = createText(''); @@ -378,6 +382,7 @@ describe('contentModelDomIndexer.reconcileSelection', () => { const newRangeEx: DOMSelection = { type: 'range', range: createRange(node1, 2, parent, 2), + isReverted: false, }; const paragraph = createParagraph(); const oldSegment1 = createText(''); @@ -521,6 +526,7 @@ describe('contentModelDomIndexer.reconcileSelection', () => { const newRangeEx: DOMSelection = { type: 'range', range: createRange(parent, 1), + isReverted: false, }; const paragraph = createParagraph(); const segment = createBr({ fontFamily: 'Arial' }); @@ -548,10 +554,12 @@ describe('contentModelDomIndexer.reconcileSelection', () => { const oldRangeEx: DOMSelection = { type: 'range', range: createRange(node, 2), + isReverted: false, }; const newRangeEx: DOMSelection = { type: 'range', range: createRange(node, 1, node, 3), + isReverted: false, }; const paragraph = createParagraph(); const oldSegment1 = createText('te'); @@ -597,10 +605,12 @@ describe('contentModelDomIndexer.reconcileSelection', () => { const oldRangeEx: DOMSelection = { type: 'range', range: createRange(node, 1, node, 3), + isReverted: false, }; const newRangeEx: DOMSelection = { type: 'range', range: createRange(node, 2), + isReverted: false, }; const paragraph = createParagraph(); const oldSegment1: ContentModelSegment = { diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/textMutationObserverTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/textMutationObserverTest.ts new file mode 100644 index 00000000000..7ba5a6df1b3 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/textMutationObserverTest.ts @@ -0,0 +1,134 @@ +import * as TextMutationObserver from '../../../lib/corePlugin/utils/textMutationObserver'; + +describe('TextMutationObserverImpl', () => { + it('init', () => { + const div = document.createElement('div'); + const onMutation = jasmine.createSpy('onMutation'); + TextMutationObserver.createTextMutationObserver(div, onMutation); + + expect(onMutation).not.toHaveBeenCalled(); + }); + + it('not text change', async () => { + const div = document.createElement('div'); + const onMutation = jasmine.createSpy('onMutation'); + const observer = TextMutationObserver.createTextMutationObserver(div, onMutation); + + observer.startObserving(); + + div.appendChild(document.createElement('br')); + + await new Promise(resolve => { + window.setTimeout(resolve, 10); + }); + + expect(onMutation).toHaveBeenCalledTimes(1); + expect(onMutation).toHaveBeenCalledWith(false); + }); + + it('text change', async () => { + const div = document.createElement('div'); + const text = document.createTextNode('test'); + + div.appendChild(text); + + const onMutation = jasmine.createSpy('onMutation'); + const observer = TextMutationObserver.createTextMutationObserver(div, onMutation); + + observer.startObserving(); + + text.nodeValue = '1'; + + await new Promise(resolve => { + window.setTimeout(resolve, 10); + }); + + expect(onMutation).toHaveBeenCalledTimes(1); + expect(onMutation).toHaveBeenCalledWith(true); + }); + + it('text change in deeper node', async () => { + const div = document.createElement('div'); + const span = document.createElement('span'); + const text = document.createTextNode('test'); + + span.appendChild(text); + div.appendChild(span); + + const onMutation = jasmine.createSpy('onMutation'); + const observer = TextMutationObserver.createTextMutationObserver(div, onMutation); + + observer.startObserving(); + + text.nodeValue = '1'; + + await new Promise(resolve => { + window.setTimeout(resolve, 10); + }); + + expect(onMutation).toHaveBeenCalledTimes(1); + expect(onMutation).toHaveBeenCalledWith(true); + }); + + it('text and non-text change', async () => { + const div = document.createElement('div'); + const text = document.createTextNode('test'); + + div.appendChild(text); + + const onMutation = jasmine.createSpy('onMutation'); + const observer = TextMutationObserver.createTextMutationObserver(div, onMutation); + + observer.startObserving(); + + text.nodeValue = '1'; + div.appendChild(document.createElement('br')); + + await new Promise(resolve => { + window.setTimeout(resolve, 10); + }); + + expect(onMutation).toHaveBeenCalledTimes(1); + expect(onMutation).toHaveBeenCalledWith(false); + }); + + it('flush mutation', async () => { + const div = document.createElement('div'); + const text = document.createTextNode('test'); + + div.appendChild(text); + + const onMutation = jasmine.createSpy('onMutation'); + const observer = TextMutationObserver.createTextMutationObserver(div, onMutation); + + observer.startObserving(); + + text.nodeValue = '1'; + observer.flushMutations(); + + await new Promise(resolve => { + window.setTimeout(resolve, 10); + }); + + expect(onMutation).toHaveBeenCalledWith(true); + }); + + it('flush mutation without change', async () => { + const div = document.createElement('div'); + const text = document.createTextNode('test'); + + div.appendChild(text); + + const onMutation = jasmine.createSpy('onMutation'); + const observer = TextMutationObserver.createTextMutationObserver(div, onMutation); + + observer.startObserving(); + observer.flushMutations(); + + await new Promise(resolve => { + window.setTimeout(resolve, 10); + }); + + expect(onMutation).not.toHaveBeenCalled(); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-core/test/editor/DOMHelperImplTest.ts b/packages-content-model/roosterjs-content-model-core/test/editor/DOMHelperImplTest.ts new file mode 100644 index 00000000000..a3913ec6610 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/test/editor/DOMHelperImplTest.ts @@ -0,0 +1,63 @@ +import { createDOMHelper } from '../../lib/editor/DOMHelperImpl'; + +describe('DOMHelperImpl', () => { + it('isNodeInEditor', () => { + const mockedResult = 'RESULT' as any; + const containsSpy = jasmine.createSpy('contains').and.returnValue(mockedResult); + const mockedDiv = { + contains: containsSpy, + } as any; + const domHelper = createDOMHelper(mockedDiv); + const mockedNode = 'NODE' as any; + + const result = domHelper.isNodeInEditor(mockedNode); + + expect(result).toBe(mockedResult); + expect(containsSpy).toHaveBeenCalledWith(mockedNode); + }); + + it('queryElements', () => { + const mockedResult = ['RESULT'] as any; + const querySelectorAllSpy = jasmine + .createSpy('querySelectorAll') + .and.returnValue(mockedResult); + const mockedDiv: HTMLElement = { + querySelectorAll: querySelectorAllSpy, + } as any; + const mockedSelector = 'SELECTOR'; + const domHelper = createDOMHelper(mockedDiv); + + const result = domHelper.queryElements(mockedSelector); + + expect(result).toEqual(mockedResult); + expect(querySelectorAllSpy).toHaveBeenCalledWith(mockedSelector); + }); + + it('calculateZoomScale 1', () => { + const mockedDiv = { + getBoundingClientRect: () => ({ + width: 1, + }), + offsetWidth: 2, + } as any; + const domHelper = createDOMHelper(mockedDiv); + + const zoomScale = domHelper.calculateZoomScale(); + + expect(zoomScale).toBe(0.5); + }); + + it('calculateZoomScale 2', () => { + const mockedDiv = { + getBoundingClientRect: () => ({ + width: 1, + }), + offsetWidth: 0, // Wrong number, should return 1 as fallback + } as any; + const domHelper = createDOMHelper(mockedDiv); + + const zoomScale = domHelper.calculateZoomScale(); + + expect(zoomScale).toBe(1); + }); +}); 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 index 7cc680eee62..304b3834b68 100644 --- a/packages-content-model/roosterjs-content-model-core/test/editor/StandaloneEditorTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/editor/StandaloneEditorTest.ts @@ -1,11 +1,17 @@ +import * as cloneModel from '../../lib/publicApi/model/cloneModel'; +import * as createEmptyModel from 'roosterjs-content-model-dom/lib/modelApi/creators/createEmptyModel'; import * as createStandaloneEditorCore from '../../lib/editor/createStandaloneEditorCore'; import * as transformColor from '../../lib/publicApi/color/transformColor'; import { ChangeSource } from '../../lib/constants/ChangeSource'; +import { reducedModelChildProcessor } from '../../lib/override/reducedModelChildProcessor'; import { StandaloneEditor } from '../../lib/editor/StandaloneEditor'; +import { tableProcessor } from 'roosterjs-content-model-dom'; describe('StandaloneEditor', () => { let createEditorCoreSpy: jasmine.Spy; let updateKnownColorSpy: jasmine.Spy; + let setContentModelSpy: jasmine.Spy; + let createEmptyModelSpy: jasmine.Spy; beforeEach(() => { updateKnownColorSpy = jasmine.createSpy('updateKnownColor'); @@ -13,10 +19,15 @@ describe('StandaloneEditor', () => { createStandaloneEditorCore, 'createStandaloneEditorCore' ).and.callThrough(); + setContentModelSpy = jasmine.createSpy('setContentModel'); + createEmptyModelSpy = spyOn(createEmptyModel, 'createEmptyModel'); }); it('ctor and dispose, no options', () => { const div = document.createElement('div'); + + createEmptyModelSpy.and.callThrough(); + const editor = new StandaloneEditor(div); expect(createEditorCoreSpy).toHaveBeenCalledWith(div, {}); @@ -25,6 +36,7 @@ describe('StandaloneEditor', () => { expect(editor.isDarkMode()).toBeFalse(); expect(editor.isInIME()).toBeFalse(); expect(editor.isInShadowEdit()).toBeFalse(); + expect(createEmptyModelSpy).toHaveBeenCalledWith(undefined); editor.dispose(); @@ -48,14 +60,21 @@ describe('StandaloneEditor', () => { initialize: initSpy2, dispose: disposeSpy2, } as any; - + const setContentModelSpy = jasmine.createSpy('setContentModel'); const disposeErrorHandlerSpy = jasmine.createSpy('disposeErrorHandler'); + const mockedInitialModel = 'INITMODEL' as any; const options = { plugins: [mockedPlugin1, mockedPlugin2], disposeErrorHandler: disposeErrorHandlerSpy, inDarkMode: true, + initialModel: mockedInitialModel, + coreApiOverride: { + setContentModel: setContentModelSpy, + }, }; + createEmptyModelSpy.and.callThrough(); + const editor = new StandaloneEditor(div, options); expect(createEditorCoreSpy).toHaveBeenCalledWith(div, options); @@ -64,6 +83,12 @@ describe('StandaloneEditor', () => { expect(editor.isDarkMode()).toBeTrue(); expect(editor.isInIME()).toBeFalse(); expect(editor.isInShadowEdit()).toBeFalse(); + expect(createEmptyModelSpy).not.toHaveBeenCalled(); + expect(setContentModelSpy).toHaveBeenCalledWith( + jasmine.anything() /*core*/, + mockedInitialModel, + { ignoreSelection: true } + ); expect(initSpy1).toHaveBeenCalledWith(editor); expect(initSpy2).toHaveBeenCalledWith(editor); @@ -84,7 +109,7 @@ describe('StandaloneEditor', () => { expect(disposeErrorHandlerSpy).toHaveBeenCalledWith(mockedPlugin2, new Error('test')); }); - it('createContentModel', () => { + it('getContentModelCopy', () => { const div = document.createElement('div'); const mockedModel = 'MODEL' as any; const createContentModelSpy = jasmine @@ -99,6 +124,7 @@ describe('StandaloneEditor', () => { }, api: { createContentModel: createContentModelSpy, + setContentModel: setContentModelSpy, }, } as any; @@ -106,31 +132,37 @@ describe('StandaloneEditor', () => { const editor = new StandaloneEditor(div); - const model1 = editor.createContentModel(); + const model1 = editor.getContentModelCopy('connected'); expect(model1).toBe(mockedModel); - expect(createContentModelSpy).toHaveBeenCalledWith(mockedCore, undefined, undefined); - - const mockedOptions = 'OPTIONS' as any; - const selectionOverride = 'SELECTION' as any; + expect(createContentModelSpy).toHaveBeenCalledWith(mockedCore, { + processorOverride: { + table: tableProcessor, // Use the original table processor to create Content Model with real table content but not just an entity + }, + }); - const model2 = editor.createContentModel(mockedOptions, selectionOverride); + const model2 = editor.getContentModelCopy('reduced'); expect(model2).toBe(mockedModel); - expect(createContentModelSpy).toHaveBeenCalledWith( - mockedCore, - mockedOptions, - selectionOverride - ); + expect(createContentModelSpy).toHaveBeenCalledWith(mockedCore, { + processorOverride: { + child: reducedModelChildProcessor, + }, + }); editor.dispose(); - expect(() => editor.createContentModel()).toThrow(); + expect(() => editor.getContentModelCopy('connected')).toThrow(); + expect(() => editor.getContentModelCopy('reduced')).toThrow(); expect(resetSpy).toHaveBeenCalledWith(); }); - it('setContentModel', () => { + it('getContentModelCopy to return disconnected model', () => { const div = document.createElement('div'); - const setContentModelSpy = jasmine.createSpy('setContentModel'); + const mockedModel = 'MODEL' as any; + const mockedClonedModel = 'MODEL2' as any; + const createContentModelSpy = jasmine + .createSpy('createContentModel') + .and.returnValue(mockedModel); const resetSpy = jasmine.createSpy('reset'); const mockedCore = { plugins: [], @@ -138,40 +170,75 @@ describe('StandaloneEditor', () => { updateKnownColor: updateKnownColorSpy, reset: resetSpy, }, + lifecycle: { + isDarkMode: false, + }, api: { + createContentModel: createContentModelSpy, setContentModel: setContentModelSpy, }, } as any; createEditorCoreSpy.and.returnValue(mockedCore); - const mockedModel = 'MODEL' as any; const editor = new StandaloneEditor(div); - editor.setContentModel(mockedModel); + const cloneModelSpy = spyOn(cloneModel, 'cloneModel').and.returnValue(mockedClonedModel); - expect(setContentModelSpy).toHaveBeenCalledWith( - mockedCore, - mockedModel, - undefined, - undefined - ); + const model = editor.getContentModelCopy('disconnected'); - const mockedOptions = 'OPTIONS' as any; - const mockedOnNodeCreated = 'ONNODECREATED' as any; + expect(cloneModelSpy).toHaveBeenCalledWith(mockedModel, { + includeCachedElement: jasmine.anything() as any, + }); + + const transformColorSpy = spyOn(transformColor, 'transformColor'); + const onClone = cloneModelSpy.calls.argsFor(0)[1]! + .includeCachedElement as cloneModel.CachedElementHandler; - editor.setContentModel(mockedModel, mockedOptions, mockedOnNodeCreated); + const clonedNode = { + style: { + backgroundColor: 'red', + }, + } as any; + const cloneNodeSpy = jasmine.createSpy('cloneNode').and.returnValue(clonedNode); + const mockedNode = { + cloneNode: cloneNodeSpy, + } as any; - expect(setContentModelSpy).toHaveBeenCalledWith( - mockedCore, - mockedModel, - mockedOptions, - mockedOnNodeCreated + expect(onClone(mockedNode, 'cache')).toBeUndefined(); + expect(cloneNodeSpy).not.toHaveBeenCalled(); + + // clone entity in light mode + expect(onClone(mockedNode, 'entity')).toBe(clonedNode); + expect(cloneNodeSpy).toHaveBeenCalledWith(true); + + expect(model).toBe(mockedClonedModel); + expect(createContentModelSpy).toHaveBeenCalledWith(mockedCore); + expect(transformColorSpy).not.toHaveBeenCalled(); + + // Clone in dark mode + mockedCore.lifecycle.isDarkMode = true; + expect(onClone(mockedNode, 'entity')).toBe(clonedNode); + expect(cloneNodeSpy).toHaveBeenCalledWith(true); + + expect(model).toBe(mockedClonedModel); + expect(createContentModelSpy).toHaveBeenCalledWith(mockedCore); + expect(transformColorSpy).toHaveBeenCalledWith( + clonedNode, + true, + 'darkToLight', + mockedCore.darkColorHandler ); + expect(clonedNode).toEqual({ + style: { + color: 'inherit', + backgroundColor: 'red', + }, + }); editor.dispose(); + expect(() => editor.getContentModelCopy('disconnected')).toThrow(); expect(resetSpy).toHaveBeenCalledWith(); - expect(() => editor.setContentModel(mockedModel)).toThrow(); }); it('getEnvironment', () => { @@ -185,6 +252,7 @@ describe('StandaloneEditor', () => { reset: resetSpy, }, environment: mockedEnvironment, + api: { setContentModel: setContentModelSpy }, } as any; createEditorCoreSpy.and.returnValue(mockedCore); @@ -215,6 +283,7 @@ describe('StandaloneEditor', () => { }, api: { getDOMSelection: getDOMSelectionSpy, + setContentModel: setContentModelSpy, }, } as any; @@ -245,6 +314,7 @@ describe('StandaloneEditor', () => { }, api: { setDOMSelection: setDOMSelectionSpy, + setContentModel: setContentModelSpy, }, } as any; @@ -279,6 +349,7 @@ describe('StandaloneEditor', () => { }, api: { formatContentModel: formatContentModelSpy, + setContentModel: setContentModelSpy, }, } as any; @@ -314,6 +385,9 @@ describe('StandaloneEditor', () => { reset: resetSpy, }, format: {}, + api: { + setContentModel: setContentModelSpy, + }, } as any; createEditorCoreSpy.and.returnValue(mockedCore); @@ -351,6 +425,7 @@ describe('StandaloneEditor', () => { }, api: { addUndoSnapshot: addUndoSnapshotSpy, + setContentModel: setContentModelSpy, }, } as any; @@ -368,6 +443,32 @@ describe('StandaloneEditor', () => { expect(() => editor.takeSnapshot()).toThrow(); }); + it('getDOMHelper', () => { + const div = document.createElement('div'); + const mockedDOMHelper = 'DOMHELPER' as any; + const resetSpy = jasmine.createSpy('reset'); + const mockedCore = { + plugins: [], + domHelper: mockedDOMHelper, + darkColorHandler: { + updateKnownColor: updateKnownColorSpy, + reset: resetSpy, + }, + api: { setContentModel: setContentModelSpy }, + } as any; + + createEditorCoreSpy.and.returnValue(mockedCore); + + const editor = new StandaloneEditor(div); + const domHelper = editor.getDOMHelper(); + + expect(domHelper).toBe(mockedDOMHelper); + + editor.dispose(); + expect(resetSpy).toHaveBeenCalledWith(); + expect(() => editor.takeSnapshot()).toThrow(); + }); + it('restoreSnapshot', () => { const div = document.createElement('div'); const mockedSnapshot = 'SNAPSHOT' as any; @@ -381,6 +482,7 @@ describe('StandaloneEditor', () => { }, api: { restoreUndoSnapshot: restoreUndoSnapshotSpy, + setContentModel: setContentModelSpy, }, } as any; @@ -409,6 +511,7 @@ describe('StandaloneEditor', () => { }, api: { focus: focusSpy, + setContentModel: setContentModelSpy, }, } as any; @@ -437,6 +540,7 @@ describe('StandaloneEditor', () => { }, api: { hasFocus: hasFocusSpy, + setContentModel: setContentModelSpy, }, } as any; @@ -471,6 +575,7 @@ describe('StandaloneEditor', () => { }, api: { triggerEvent: triggerEventSpy, + setContentModel: setContentModelSpy, }, } as any; @@ -516,6 +621,7 @@ describe('StandaloneEditor', () => { }, api: { attachDomEvent: attachDomEventSpy, + setContentModel: setContentModelSpy, }, } as any; @@ -546,7 +652,9 @@ describe('StandaloneEditor', () => { }, undo: { snapshotsManager: mockedSnapshotManager, + setContentModel: setContentModelSpy, }, + api: { setContentModel: setContentModelSpy }, } as any; createEditorCoreSpy.and.returnValue(mockedCore); @@ -579,6 +687,7 @@ describe('StandaloneEditor', () => { lifecycle: {}, api: { switchShadowEdit: switchShadowEditSpy, + setContentModel: setContentModelSpy, }, } as any; @@ -619,6 +728,7 @@ describe('StandaloneEditor', () => { }, api: { paste: pasteSpy, + setContentModel: setContentModelSpy, }, } as any; @@ -652,6 +762,7 @@ describe('StandaloneEditor', () => { const mockedCore = { plugins: [], darkColorHandler: mockedColorHandler, + api: { setContentModel: setContentModelSpy }, } as any; createEditorCoreSpy.and.returnValue(mockedCore); @@ -669,37 +780,6 @@ describe('StandaloneEditor', () => { expect(() => editor.getColorManager()).toThrow(); }); - it('isNodeInEditor', () => { - const mockedResult = 'RESULT' as any; - const containsSpy = jasmine.createSpy('contains').and.returnValue(mockedResult); - const resetSpy = jasmine.createSpy('reset'); - const div = { - contains: containsSpy, - } as any; - const mockedCore = { - plugins: [], - darkColorHandler: { - updateKnownColor: updateKnownColorSpy, - reset: resetSpy, - }, - 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(resetSpy).toHaveBeenCalledWith(); - expect(() => editor.isNodeInEditor(mockedNode)).toThrow(); - }); - it('dark mode', () => { const transformColorSpy = spyOn(transformColor, 'transformColor'); const triggerEventSpy = jasmine.createSpy('triggerEvent').and.callFake((core, event) => { @@ -720,6 +800,7 @@ describe('StandaloneEditor', () => { }, api: { triggerEvent: triggerEventSpy, + setContentModel: setContentModelSpy, }, } as any; 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 index cd6e4dfa861..a7e4e383390 100644 --- a/packages-content-model/roosterjs-content-model-core/test/editor/createStandaloneEditorCoreTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/editor/createStandaloneEditorCoreTest.ts @@ -1,6 +1,7 @@ import * as createDefaultSettings from '../../lib/editor/createStandaloneEditorDefaultSettings'; import * as createStandaloneEditorCorePlugins from '../../lib/corePlugin/createStandaloneEditorCorePlugins'; import * as DarkColorHandlerImpl from '../../lib/editor/DarkColorHandlerImpl'; +import * as DOMHelperImpl from '../../lib/editor/DOMHelperImpl'; import { standaloneCoreApiMap } from '../../lib/editor/standaloneCoreApiMap'; import { StandaloneEditorCore, StandaloneEditorOptions } from 'roosterjs-content-model-types'; import { @@ -24,6 +25,7 @@ describe('createEditorCore', () => { const mockedEntityPlugin = createMockedPlugin('entity'); const mockedSelectionPlugin = createMockedPlugin('selection'); const mockedUndoPlugin = createMockedPlugin('undo'); + const mockedContextMenuPlugin = createMockedPlugin('contextMenu'); const mockedPlugins = { cache: mockedCachePlugin, format: mockedFormatPlugin, @@ -33,10 +35,12 @@ describe('createEditorCore', () => { entity: mockedEntityPlugin, selection: mockedSelectionPlugin, undo: mockedUndoPlugin, + contextMenu: mockedContextMenuPlugin, }; const mockedDarkColorHandler = 'DARKCOLOR' as any; const mockedDomToModelSettings = 'DOMTOMODEL' as any; const mockedModelToDomSettings = 'MODELTODOM' as any; + const mockedDOMHelper = 'DOMHELPER' as any; beforeEach(() => { spyOn( @@ -52,6 +56,7 @@ describe('createEditorCore', () => { spyOn(createDefaultSettings, 'createModelToDomSettings').and.returnValue( mockedModelToDomSettings ); + spyOn(DOMHelperImpl, 'createDOMHelper').and.returnValue(mockedDOMHelper); }); function runTest( @@ -73,6 +78,7 @@ describe('createEditorCore', () => { mockedSelectionPlugin, mockedEntityPlugin, mockedUndoPlugin, + mockedContextMenuPlugin, mockedLifeCyclePlugin, ], environment: { @@ -92,8 +98,9 @@ describe('createEditorCore', () => { entity: 'entity' as any, selection: 'selection' as any, undo: 'undo' as any, + contextMenu: 'contextMenu' as any, + domHelper: mockedDOMHelper, disposeErrorHandler: undefined, - zoomScale: 1, ...additionalResult, }); @@ -146,7 +153,6 @@ describe('createEditorCore', () => { getDarkColor: mockedGetDarkColor, trustedHTMLHandler: mockedTrustHtmlHandler, disposeErrorHandler: mockedDisposeErrorHandler, - zoomScale: 2, } as any; runTest(mockedDiv, mockedOptions, { @@ -162,12 +168,12 @@ describe('createEditorCore', () => { mockedPlugin1, mockedPlugin2, mockedUndoPlugin, + mockedContextMenuPlugin, mockedLifeCyclePlugin, ], darkColorHandler: mockedDarkColorHandler, trustedHTMLHandler: mockedTrustHtmlHandler, disposeErrorHandler: mockedDisposeErrorHandler, - zoomScale: 2, }); expect(DarkColorHandlerImpl.createDarkColorHandler).toHaveBeenCalledWith( @@ -177,26 +183,6 @@ describe('createEditorCore', () => { ); }); - 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, - undefined - ); - }); - it('Android', () => { const mockedDiv = { ownerDocument: { @@ -210,9 +196,7 @@ describe('createEditorCore', () => { a: 'b', }, } as any; - const mockedOptions = { - zoomScale: -1, - } as any; + const mockedOptions = {} as any; runTest(mockedDiv, mockedOptions, { environment: { @@ -242,9 +226,7 @@ describe('createEditorCore', () => { a: 'b', }, } as any; - const mockedOptions = { - zoomScale: -1, - } as any; + const mockedOptions = {} as any; runTest(mockedDiv, mockedOptions, { environment: { @@ -274,9 +256,7 @@ describe('createEditorCore', () => { a: 'b', }, } as any; - const mockedOptions = { - zoomScale: -1, - } as any; + const mockedOptions = {} as any; runTest(mockedDiv, mockedOptions, { environment: { @@ -306,9 +286,7 @@ describe('createEditorCore', () => { a: 'b', }, } as any; - const mockedOptions = { - zoomScale: -1, - } as any; + const mockedOptions = {} as any; runTest(mockedDiv, mockedOptions, { environment: { @@ -338,9 +316,7 @@ describe('createEditorCore', () => { a: 'b', }, } as any; - const mockedOptions = { - zoomScale: -1, - } as any; + const mockedOptions = {} as any; runTest(mockedDiv, mockedOptions, { environment: { diff --git a/packages-content-model/roosterjs-content-model-core/test/metadata/handleListItemWithMetadataTest.ts b/packages-content-model/roosterjs-content-model-core/test/metadata/handleListItemWithMetadataTest.ts index 3069b2e9100..3ec21ecffb4 100644 --- a/packages-content-model/roosterjs-content-model-core/test/metadata/handleListItemWithMetadataTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/metadata/handleListItemWithMetadataTest.ts @@ -1,5 +1,5 @@ import * as applyFormat from 'roosterjs-content-model-dom/lib/modelToDom/utils/applyFormat'; -import { expectHtml } from 'roosterjs-editor-dom/test/DomTestHelper'; +import { expectHtml } from 'roosterjs-content-model-dom/test/testUtils'; import { handleList as originalHandleList } from 'roosterjs-content-model-dom/lib/modelToDom/handlers/handleList'; import { handleListItem } from 'roosterjs-content-model-dom/lib/modelToDom/handlers/handleListItem'; import { diff --git a/packages-content-model/roosterjs-content-model-core/test/metadata/handleListWithMetadataTest.ts b/packages-content-model/roosterjs-content-model-core/test/metadata/handleListWithMetadataTest.ts index cce69e71cfb..2f713dab8d1 100644 --- a/packages-content-model/roosterjs-content-model-core/test/metadata/handleListWithMetadataTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/metadata/handleListWithMetadataTest.ts @@ -1,4 +1,4 @@ -import { expectHtml, itChromeOnly } from 'roosterjs-editor-dom/test/DomTestHelper'; +import { expectHtml } from 'roosterjs-content-model-dom/test/testUtils'; import { handleList } from 'roosterjs-content-model-dom/lib/modelToDom/handlers/handleList'; import { ModelToDomContext } from 'roosterjs-content-model-types'; import { @@ -118,7 +118,7 @@ describe('handleList with metadata', () => { }); }); - itChromeOnly('Context has OL, single OL list item, do not reuse existing OL element', () => { + it('Context has OL, single OL list item, do not reuse existing OL element', () => { const existingOL = document.createElement('ol'); const listItem = createListItem([ createListLevel('OL', {}, { editingInfo: JSON.stringify({ orderedStyleType: 2 }) }), @@ -150,7 +150,7 @@ describe('handleList with metadata', () => { }); }); - itChromeOnly('Context has OL, 2 level OL list item, reuse existing OL element', () => { + it('Context has OL, 2 level OL list item, reuse existing OL element', () => { const existingOL = document.createElement('ol'); const listItem = createListItem([ createListLevel('OL'), @@ -187,7 +187,7 @@ describe('handleList with metadata', () => { }); }); - itChromeOnly('Context has OL, 2 level OL list item, do not reuse existing OL element', () => { + it('Context has OL, 2 level OL list item, do not reuse existing OL element', () => { const existingOL = document.createElement('ol'); const listItem = createListItem([ createListLevel( @@ -209,9 +209,10 @@ describe('handleList with metadata', () => { handleList(document, parent, listItem, context, null); - expect(parent.outerHTML).toBe( - '
      ' - ); + expectHtml(parent.outerHTML, [ + '
          ', + '
              ', + ]); expect(context.listFormat).toEqual({ threadItemCounts: [1, 0], nodeStack: [ diff --git a/packages-content-model/roosterjs-content-model-core/test/overrides/reducedModelChildProcessorTest.ts b/packages-content-model/roosterjs-content-model-core/test/overrides/reducedModelChildProcessorTest.ts new file mode 100644 index 00000000000..bc4f540912e --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/test/overrides/reducedModelChildProcessorTest.ts @@ -0,0 +1,322 @@ +import * as getSelectionRootNode from '../../lib/publicApi/selection/getSelectionRootNode'; +import { createContentModelDocument, createDomToModelContext } from 'roosterjs-content-model-dom'; +import { DomToModelContext } from 'roosterjs-content-model-types'; +import { reducedModelChildProcessor } from '../../lib/override/reducedModelChildProcessor'; + +describe('reducedModelChildProcessor', () => { + let context: DomToModelContext; + let getSelectionRootNodeSpy: jasmine.Spy; + + beforeEach(() => { + context = createDomToModelContext(undefined, { + processorOverride: { + child: reducedModelChildProcessor, + }, + }); + + getSelectionRootNodeSpy = spyOn( + getSelectionRootNode, + 'getSelectionRootNode' + ).and.callThrough(); + }); + + it('Empty DOM', () => { + const doc = createContentModelDocument(); + const div = document.createElement('div'); + + reducedModelChildProcessor(doc, div, context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [], + }); + expect(getSelectionRootNodeSpy).toHaveBeenCalledTimes(1); + }); + + it('Single child node, with selected Node in context', () => { + const doc = createContentModelDocument(); + const div = document.createElement('div'); + const span = document.createElement('span'); + + div.appendChild(span); + span.textContent = 'test'; + context.selection = { + type: 'range', + range: { + commonAncestorContainer: span, + } as any, + isReverted: false, + }; + + reducedModelChildProcessor(doc, div, context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + isImplicit: true, + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + }, + ], + }); + expect(getSelectionRootNodeSpy).toHaveBeenCalledTimes(1); + }); + + it('Multiple child nodes, with selected Node in context', () => { + const doc = createContentModelDocument(); + const div = document.createElement('div'); + const span1 = document.createElement('span'); + const span2 = document.createElement('span'); + const span3 = document.createElement('span'); + + div.appendChild(span1); + div.appendChild(span2); + div.appendChild(span3); + span1.textContent = 'test1'; + span2.textContent = 'test2'; + span3.textContent = 'test3'; + context.selection = { + type: 'range', + range: { + commonAncestorContainer: span2, + } as any, + isReverted: false, + }; + + reducedModelChildProcessor(doc, div, context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + isImplicit: true, + segments: [ + { + segmentType: 'Text', + text: 'test2', + format: {}, + }, + ], + }, + ], + }); + expect(getSelectionRootNodeSpy).toHaveBeenCalledTimes(1); + }); + + it('Multiple child nodes, with selected Node in context, with more child nodes under selected node', () => { + const doc = createContentModelDocument(); + const div = document.createElement('div'); + const span1 = document.createElement('span'); + const span2 = document.createElement('span'); + const span3 = document.createElement('span'); + + div.appendChild(span1); + div.appendChild(span2); + div.appendChild(span3); + span1.textContent = 'test1'; + span2.innerHTML = '
              line1
              line2
              '; + span3.textContent = 'test3'; + context.selection = { + type: 'range', + range: { + commonAncestorContainer: span2, + } as any, + isReverted: false, + }; + + reducedModelChildProcessor(doc, div, context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'line1', + format: {}, + }, + ], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [], + format: {}, + isImplicit: true, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'line2', + format: {}, + }, + ], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [], + format: {}, + isImplicit: true, + }, + ], + }); + expect(getSelectionRootNodeSpy).toHaveBeenCalledTimes(1); + }); + + it('Multiple layer with multiple child nodes, with selected Node in context, with more child nodes under selected node', () => { + const doc = createContentModelDocument(); + const div1 = document.createElement('div'); + const div2 = document.createElement('div'); + const div3 = document.createElement('div'); + const span1 = document.createElement('span'); + const span2 = document.createElement('span'); + const span3 = document.createElement('span'); + + div3.appendChild(span1); + div3.appendChild(span2); + div3.appendChild(span3); + div1.appendChild(div2); + div2.appendChild(div3); + span1.textContent = 'test1'; + span2.innerHTML = '
              line1
              line2
              '; + span3.textContent = 'test3'; + + context.selection = { + type: 'range', + range: { + commonAncestorContainer: span2, + } as any, + isReverted: false, + }; + + reducedModelChildProcessor(doc, div1, context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { blockType: 'Paragraph', segments: [], format: {} }, + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [{ segmentType: 'Text', text: 'line1', format: {} }], + format: {}, + }, + { blockType: 'Paragraph', segments: [], format: {}, isImplicit: true }, + { + blockType: 'Paragraph', + segments: [{ segmentType: 'Text', text: 'line2', format: {} }], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [], + format: {}, + isImplicit: true, + }, + { blockType: 'Paragraph', segments: [], format: {}, isImplicit: true }, + { blockType: 'Paragraph', segments: [], format: {}, isImplicit: true }, + ], + }); + expect(getSelectionRootNodeSpy).toHaveBeenCalledTimes(1); + }); + + it('With table, need to do format for all table cells', () => { + const doc = createContentModelDocument(); + const div = document.createElement('div'); + div.innerHTML = + 'aa
              test1test2
              bb'; + context.selection = { + type: 'range', + range: { + commonAncestorContainer: div.querySelector('#selection') as HTMLElement, + } as any, + isReverted: false, + }; + + reducedModelChildProcessor(doc, div, context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + rows: [ + { + format: {}, + height: 0, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test1', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test2', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: {}, + widths: [], + dataset: {}, + }, + ], + }); + expect(getSelectionRootNodeSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-core/test/overrides/tablePreProcessorTest.ts b/packages-content-model/roosterjs-content-model-core/test/overrides/tablePreProcessorTest.ts index e759c7b4a19..55089ec522a 100644 --- a/packages-content-model/roosterjs-content-model-core/test/overrides/tablePreProcessorTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/overrides/tablePreProcessorTest.ts @@ -64,6 +64,7 @@ describe('tablePreProcessor', () => { range: { commonAncestorContainer: txt, } as any, + isReverted: false, }; tablePreProcessor(group, table, context); diff --git a/packages-content-model/roosterjs-content-model-core/test/publicApi/selection/getSelectionRootNodeTest.ts b/packages-content-model/roosterjs-content-model-core/test/publicApi/selection/getSelectionRootNodeTest.ts index cbb1d37affa..25023764cfd 100644 --- a/packages-content-model/roosterjs-content-model-core/test/publicApi/selection/getSelectionRootNodeTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/publicApi/selection/getSelectionRootNodeTest.ts @@ -14,6 +14,7 @@ describe('getSelectionRootNode', () => { range: { commonAncestorContainer: mockedRoot, } as any, + isReverted: false, }); expect(root).toBe(mockedRoot); diff --git a/packages-content-model/roosterjs-content-model-core/test/utils/createSnapshotSelectionTest.ts b/packages-content-model/roosterjs-content-model-core/test/utils/createSnapshotSelectionTest.ts index 6448918105e..d6261cf5be8 100644 --- a/packages-content-model/roosterjs-content-model-core/test/utils/createSnapshotSelectionTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/utils/createSnapshotSelectionTest.ts @@ -1,11 +1,20 @@ import { createSnapshotSelection } from '../../lib/utils/createSnapshotSelection'; -import { DOMSelection } from 'roosterjs-content-model-types'; +import { DOMSelection, StandaloneEditorCore } from 'roosterjs-content-model-types'; describe('createSnapshotSelection', () => { let div: HTMLDivElement; + let core: StandaloneEditorCore; + let getDOMSelectionSpy: jasmine.Spy; beforeEach(() => { div = document.createElement('div'); + getDOMSelectionSpy = jasmine.createSpy('getDOMSelection'); + core = { + contentDiv: div, + api: { + getDOMSelection: getDOMSelectionSpy, + }, + } as any; }); it('Image selection', () => { @@ -17,7 +26,9 @@ describe('createSnapshotSelection', () => { image.id = 'id1'; - const result = createSnapshotSelection(div, selection); + getDOMSelectionSpy.and.returnValue(selection); + + const result = createSnapshotSelection(core); expect(result).toEqual({ type: 'image', @@ -38,7 +49,9 @@ describe('createSnapshotSelection', () => { table.id = 'id1'; - const result = createSnapshotSelection(div, selection); + getDOMSelectionSpy.and.returnValue(selection); + + const result = createSnapshotSelection(core); expect(result).toEqual({ type: 'table', @@ -53,18 +66,30 @@ describe('createSnapshotSelection', () => { describe('createSnapshotSelection - Range selection', () => { let div: HTMLDivElement; + let core: StandaloneEditorCore; + let getDOMSelectionSpy: jasmine.Spy; beforeEach(() => { div = document.createElement('div'); + getDOMSelectionSpy = jasmine.createSpy('getDOMSelection'); + core = { + contentDiv: div, + api: { + getDOMSelection: getDOMSelectionSpy, + }, + } as any; }); it('Null selection', () => { - const result = createSnapshotSelection(div, null); + getDOMSelectionSpy.and.returnValue(null); + + const result = createSnapshotSelection(core); expect(result).toEqual({ type: 'range', start: [], end: [], + isReverted: false, }); }); @@ -75,15 +100,19 @@ describe('createSnapshotSelection - Range selection', () => { range.setStart(div.childNodes[0], 2); range.setEnd(div.childNodes[0], 4); - const result = createSnapshotSelection(div, { + getDOMSelectionSpy.and.returnValue({ type: 'range', range: range, + isReverted: false, }); + const result = createSnapshotSelection(core); + expect(result).toEqual({ type: 'range', start: [0, 2], end: [0, 4], + isReverted: false, }); }); @@ -94,15 +123,19 @@ describe('createSnapshotSelection - Range selection', () => { range.setStart(div.childNodes[0].firstChild!, 2); range.setEnd(div.childNodes[0].firstChild!, 4); - const result = createSnapshotSelection(div, { + getDOMSelectionSpy.and.returnValue({ type: 'range', range: range, + isReverted: false, }); + const result = createSnapshotSelection(core); + expect(result).toEqual({ type: 'range', start: [0, 0, 2], end: [0, 0, 4], + isReverted: false, }); }); @@ -113,15 +146,19 @@ describe('createSnapshotSelection - Range selection', () => { range.setStart(div.childNodes[0].childNodes[0], 1); range.setEnd(div.childNodes[1].childNodes[0], 0); - const result = createSnapshotSelection(div, { + getDOMSelectionSpy.and.returnValue({ type: 'range', range: range, + isReverted: false, }); + const result = createSnapshotSelection(core); + expect(result).toEqual({ type: 'range', start: [0, 0, 1], end: [1, 0, 0], + isReverted: false, }); }); @@ -135,15 +172,19 @@ describe('createSnapshotSelection - Range selection', () => { range.setStart(div.childNodes[1], 2); range.setEnd(div.childNodes[2], 2); - const result = createSnapshotSelection(div, { + getDOMSelectionSpy.and.returnValue({ type: 'range', range: range, + isReverted: false, }); + const result = createSnapshotSelection(core); + expect(result).toEqual({ type: 'range', start: [0, 7], end: [0, 12], + isReverted: false, }); }); @@ -165,15 +206,455 @@ describe('createSnapshotSelection - Range selection', () => { range.setStart(div.childNodes[2].childNodes[1], 2); range.setEnd(div.childNodes[4], 2); - const result = createSnapshotSelection(div, { + getDOMSelectionSpy.and.returnValue({ type: 'range', range: range, + isReverted: false, }); + const result = createSnapshotSelection(core); + expect(result).toEqual({ type: 'range', start: [1, 0, 7], end: [2, 7], + isReverted: false, }); }); }); + +describe('createSnapshotSelection - Normalize Table', () => { + const TABLE_ID1 = 't1'; + const TABLE_ID2 = 't2'; + let div: HTMLDivElement; + let core: StandaloneEditorCore; + let getDOMSelectionSpy: jasmine.Spy; + let setDOMSelectionSpy: jasmine.Spy; + + beforeEach(() => { + div = document.createElement('div'); + getDOMSelectionSpy = jasmine.createSpy('getDOMSelection'); + setDOMSelectionSpy = jasmine.createSpy('setDOMSelection'); + core = { + contentDiv: div, + api: { + getDOMSelection: getDOMSelectionSpy, + setDOMSelection: setDOMSelectionSpy, + }, + } as any; + }); + + function runTest( + input: CreateElementData, + output: string, + startPath: number[], + endPath: number[], + setDOMSelection: boolean + ) { + div.appendChild(createElement(input)); + + const node1 = div.querySelector('#' + TABLE_ID1); + const node2 = div.querySelector('#' + TABLE_ID2) || node1; + const mockedRange = { + startContainer: node1, + startOffset: 0, + endContainer: node2, + endOffset: 1, + } as any; + + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: mockedRange, + }); + + const result = createSnapshotSelection(core); + + expect(result).toEqual({ + type: 'range', + start: startPath, + end: endPath, + isReverted: false, + }); + expect(div.innerHTML).toBe(output); + + if (setDOMSelection) { + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + + const selection = setDOMSelectionSpy.calls.argsFor(0)[1]; + + expect(selection.type).toBe('range'); + expect(selection.range.startContainer).toBe(node1); + expect(selection.range.endContainer).toBe(node2); + expect(selection.range.startOffset).toBe(0); + expect(selection.range.endOffset).toBe(1); + } else { + expect(setDOMSelectionSpy).not.toHaveBeenCalled(); + } + } + + function createTd(text: string, id?: string, tag: string = 'td'): CreateElementData { + return { + tag: tag, + id: id, + children: [text], + }; + } + + function createTr(...tds: CreateElementData[]): CreateElementData { + return { + tag: 'tr', + children: [...tds], + }; + } + + function createTableSection(tag: string, ...trs: CreateElementData[]): CreateElementData { + return { + tag: tag, + children: [...trs], + }; + } + + function createTable(...children: CreateElementData[]): CreateElementData { + return { + tag: 'table', + children: [...children], + }; + } + + function createColGroup(): CreateElementData { + return { + tag: 'colgroup', + children: [ + { + tag: 'col', + }, + { + tag: 'col', + }, + ], + }; + } + + it('Table already has THEAD/TBODY/TFOOT', () => { + const input = createTable( + createTableSection('thead', createTr(createTd('test1'))), + createTableSection( + 'tbody', + createTr(createTd('test2', TABLE_ID1)), + createTr(createTd('test3')) + ), + createTableSection('tfoot', createTr(createTd('test4'))) + ); + runTest( + input, + '
              test1
              test2
              test3
              test4
              ', + [0, 1, 0, 0, 0], + [0, 1, 0, 0, 1], + false + ); + }); + + it('Table only has TR', () => { + const input = createTable( + createTr(createTd('test1')), + createTr(createTd('test2', TABLE_ID1)) + ); + runTest( + input, + '
              test1
              test2
              ', + [0, 0, 1, 0, 0], + [0, 0, 1, 0, 1], + true + ); + }); + + it('Table has TR and TBODY 1', () => { + runTest( + createTable( + createTr(createTd('test1')), + createTableSection('tbody', createTr(createTd('test2', TABLE_ID1))) + ), + '
              test1
              test2
              ', + [0, 0, 1, 0, 0], + [0, 0, 1, 0, 1], + true + ); + }); + + it('Table has TR and TBODY 2', () => { + runTest( + createTable( + createTableSection('tbody', createTr(createTd('test1'))), + createTr(createTd('test2', TABLE_ID1)) + ), + '
              test1
              test2
              ', + [0, 0, 1, 0, 0], + [0, 0, 1, 0, 1], + true + ); + }); + + it('Table has TR and TBODY and TR', () => { + runTest( + createTable( + createTr(createTd('test1')), + createTableSection('tbody', createTr(createTd('test2', TABLE_ID1))), + createTr(createTd('test3', TABLE_ID2)) + ), + '
              test1
              test2
              test3
              ', + [0, 0, 1, 0, 0], + [0, 0, 2, 0, 1], + true + ); + }); + + it('Table has THEAD and TR and TFOOT', () => { + runTest( + createTable( + createTableSection('thead', createTr(createTd('test1'))), + createTr(createTd('test2', TABLE_ID1)), + createTr(createTd('test3', TABLE_ID2)), + createTableSection('tfoot', createTr(createTd('test4'))) + ), + '
              test1
              test2
              test3
              test4
              ', + [0, 1, 0, 0, 0], + [0, 1, 1, 0, 1], + true + ); + }); + + it('Table has THEAD and TR and TBODY and TR and TFOOT', () => { + runTest( + createTable( + createTableSection('thead', createTr(createTd('test1'))), + createTr(createTd('test2', TABLE_ID1)), + createTableSection('tbody', createTr(createTd('test3'))), + createTr(createTd('test4', TABLE_ID2)), + createTableSection('tfoot', createTr(createTd('test5'))) + ), + '' + + '' + + '' + + '' + + '
              test1
              test2
              test3
              test4
              test5
              ', + [0, 1, 0, 0, 0], + [0, 1, 2, 0, 1], + true + ); + }); + + it('Table has THEAD and TBODY and TR and TBODY and TFOOT', () => { + runTest( + createTable( + createTableSection('thead', createTr(createTd('test1'))), + createTableSection('tbody', createTr(createTd('test2'))), + createTr(createTd('test3', TABLE_ID1)), + createTableSection('tbody', createTr(createTd('test4'))), + createTableSection('tfoot', createTr(createTd('test5'))) + ), + '' + + '' + + '' + + '' + + '
              test1
              test2
              test3
              test4
              test5
              ', + [0, 1, 1, 0, 0], + [0, 1, 1, 0, 1], + true + ); + }); + + it('Normalize table with THEAD With colgroup, Tbody, Tfoot', () => { + runTest( + createTable( + createTableSection('thead', createColGroup(), createTr(createTd('test1'))), + createTableSection('tbody', createTr(createTd('test2'))), + createTr(createTd('test3', TABLE_ID1)), + createTableSection('tbody', createTr(createTd('test4'))), + createTableSection('tfoot', createTr(createTd('test5'))) + ), + '' + + '' + + '' + + '' + + '
              test1
              test2
              test3
              test4
              test5
              ', + [0, 1, 1, 0, 0], + [0, 1, 1, 0, 1], + true + ); + }); + + it('Table already has THEAD With colgroup/TBODY/TFOOT', () => { + runTest( + createTable( + createTableSection('thead', createTr(createTd('test1')), createColGroup()), + createTableSection( + 'tbody', + createTr(createTd('test2')), + createTr(createTd('test3')) + ), + createTableSection('tfoot', createTr(createTd('test4', TABLE_ID1))) + ), + '' + + '' + + '' + + '
              test1
              test2
              test3
              test4
              ', + [0, 2, 0, 0, 0], + [0, 2, 0, 0, 1], + false + ); + }); + + it('Table has THEAD With colgroup and TR and TFOOT', () => { + runTest( + createTable( + createTableSection('thead', createTr(createTd('test1')), createColGroup()), + createTr(createTd('test2')), + createTr(createTd('test3', TABLE_ID1)), + createTableSection('tfoot', createTr(createTd('test4'))) + ), + '' + + '' + + '' + + '
              test1
              test2
              test3
              test4
              ', + [0, 1, 1, 0, 0], + [0, 1, 1, 0, 1], + true + ); + }); + + it('Table has THEAD With colgroup and TR and TBODY and TR and TFOOT', () => { + runTest( + createTable( + createTableSection('thead', createTr(createTd('test1')), createColGroup()), + createTr(createTd('test2', TABLE_ID1)), + createTableSection('tbody', createTr(createTd('test3'))), + createTr(createTd('test4')), + createTableSection('tfoot', createTr(createTd('test5'))) + ), + '' + + '' + + '' + + '' + + '
              test1
              test2
              test3
              test4
              test5
              ', + [0, 1, 0, 0, 0], + [0, 1, 0, 0, 1], + true + ); + }); + + it('Table has THEAD With colgroup and TR and TBODY and TR and TFOOT 2', () => { + runTest( + createTable( + createTableSection('thead', createTr(createTd('test1'))), + createColGroup(), + createTr(createTd('test2', TABLE_ID1)), + createTableSection('tbody', createTr(createTd('test3'))), + createTr(createTd('test4')), + createTableSection('tfoot', createTr(createTd('test5'))) + ), + '' + + '' + + '' + + '' + + '
              test1
              test2
              test3
              test4
              test5
              ', + [0, 1, 0, 0, 0], + [0, 1, 0, 0, 1], + true + ); + }); + + it('Table has THEAD With colgroup and TBODY and TR and TBODY and TFOOT', () => { + runTest( + createTable( + createTableSection('thead', createTr(createTd('test1')), createColGroup()), + createTableSection('tbody', createTr(createTd('test2'))), + createTr(createTd('test3')), + createTableSection('tbody', createTr(createTd('test4'))), + createTableSection('tfoot', createTr(createTd('test5', TABLE_ID1))) + ), + '' + + '' + + '' + + '' + + '
              test1
              test2
              test3
              test4
              test5
              ', + [0, 2, 0, 0, 0], + [0, 2, 0, 0, 1], + true + ); + }); + + it('Table has TR and TBODY and a orphaned colgroup 1', () => { + runTest( + createTable( + createColGroup(), + createTr(createTd('test1', TABLE_ID1)), + createColGroup(), + createTableSection('tbody', createTr(createTd('test2'))), + createColGroup() + ), + '' + + '' + + '' + + '
              test1
              test2
              ', + [0, 1, 0, 0, 0], + [0, 1, 0, 0, 1], + true + ); + }); + + it('Table has TR and TBODY and a orphaned colgroup 2', () => { + runTest( + createTable( + createTableSection('tbody', createTr(createTd('test1', TABLE_ID1))), + createTr(createTd('test2')), + createColGroup() + ), + '' + + '' + + '
              test1
              test2
              ', + [0, 0, 0, 0, 0], + [0, 0, 0, 0, 1], + true + ); + }); + + it('Nested table', () => { + runTest( + createTable( + createTr({ + tag: 'td', + children: [createTable(createTr(createTd('test1', TABLE_ID1)))], + }) + ), + '
              test1
              ', + [0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 1], + true + ); + }); +}); + +interface CreateElementData { + tag: string; + id?: string; + children?: (CreateElementData | string)[]; +} + +function createElement(elementData: CreateElementData): Element { + const { tag, id, children } = elementData; + const result = document.createElement(tag); + + if (id) { + result.id = id; + } + + if (children) { + children.forEach(child => { + result.appendChild( + typeof child == 'string' ? document.createTextNode(child) : createElement(child) + ); + }); + } + + return result; +} 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 index 0d66cd3ffa7..93cd2da2f48 100644 --- 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 @@ -1,7 +1,7 @@ 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'; +import { expectHtml } from 'roosterjs-content-model-dom/test/testUtils'; describe('createPasteFragment', () => { let moveChildNodesSpy: jasmine.Spy; 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 index 373cc5bc8f8..c0e8c7df151 100644 --- 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 @@ -10,12 +10,55 @@ import { pasteDisplayFormatParser } from '../../../lib/override/pasteDisplayForm import { pasteTextProcessor } from '../../../lib/override/pasteTextProcessor'; import { ContentModelDocument, + ContentModelFormatter, ContentModelSegmentFormat, FormatWithContentModelContext, + FormatWithContentModelOptions, InsertPoint, + StandaloneEditorCore, } from 'roosterjs-content-model-types'; describe('mergePasteContent', () => { + let formatResult: boolean | undefined; + let context: FormatWithContentModelContext | undefined; + let formatContentModel: jasmine.Spy; + let sourceModel: ContentModelDocument; + let core: StandaloneEditorCore; + const mockedClipboard = 'CLIPBOARD' as any; + + beforeEach(() => { + formatResult = undefined; + context = undefined; + + formatContentModel = jasmine + .createSpy('formatContentModel') + .and.callFake( + ( + core: any, + callback: ContentModelFormatter, + options: FormatWithContentModelOptions + ) => { + context = { + newEntities: [], + deletedEntities: [], + newImages: [], + }; + formatResult = callback(sourceModel, context); + + const changedData = options.getChangeData!(); + + expect(changedData).toBe(mockedClipboard); + } + ); + + core = { + api: { + formatContentModel, + }, + domToModelSettings: {}, + } as any; + }); + it('merge table', () => { // A doc with only one table in content const pasteModel: ContentModelDocument = { @@ -63,7 +106,7 @@ describe('mergePasteContent', () => { }; // A doc with a table, and selection marker inside of it. - const sourceModel: ContentModelDocument = { + sourceModel = { blockGroupType: 'Document', blocks: [ { @@ -122,14 +165,10 @@ describe('mergePasteContent', () => { domToModelOption: { additionalAllowedTags: [] }, } as any; - const context: FormatWithContentModelContext = { - newEntities: [], - deletedEntities: [], - newImages: [], - }; - - mergePasteContent(sourceModel, context, eventResult, {}); + mergePasteContent(core, eventResult, mockedClipboard); + expect(formatContentModel).toHaveBeenCalledTimes(1); + expect(formatResult).toBeTrue(); expect(mergeModelFile.mergeModel).toHaveBeenCalledWith(sourceModel, pasteModel, context, { mergeFormat: 'none', mergeTable: true, @@ -217,7 +256,8 @@ describe('mergePasteContent', () => { it('customized merge', () => { const pasteModel: ContentModelDocument = createContentModelDocument(); - const sourceModel: ContentModelDocument = createContentModelDocument(); + + sourceModel = createContentModelDocument(); const customizedMerge = jasmine.createSpy('customizedMerge'); @@ -230,20 +270,18 @@ describe('mergePasteContent', () => { customizedMerge, } as any; - mergePasteContent( - sourceModel, - { newEntities: [], deletedEntities: [], newImages: [] }, - eventResult, - {} - ); + mergePasteContent(core, eventResult, mockedClipboard); + expect(formatContentModel).toHaveBeenCalledTimes(1); + expect(formatResult).toBeTrue(); expect(mergeModelFile.mergeModel).not.toHaveBeenCalled(); expect(customizedMerge).toHaveBeenCalledWith(sourceModel, pasteModel); }); it('Apply current format', () => { const pasteModel: ContentModelDocument = createContentModelDocument(); - const sourceModel: ContentModelDocument = createContentModelDocument(); + + sourceModel = createContentModelDocument(); spyOn(mergeModelFile, 'mergeModel').and.callThrough(); spyOn(domToContentModel, 'domToContentModel').and.returnValue(pasteModel); @@ -253,13 +291,10 @@ describe('mergePasteContent', () => { domToModelOption: { additionalAllowedTags: [] }, } as any; - mergePasteContent( - sourceModel, - { newEntities: [], deletedEntities: [], newImages: [] }, - eventResult, - {} - ); + mergePasteContent(core, eventResult, mockedClipboard); + expect(formatContentModel).toHaveBeenCalledTimes(1); + expect(formatResult).toBeTrue(); expect(mergeModelFile.mergeModel).toHaveBeenCalledWith( sourceModel, pasteModel, @@ -276,7 +311,8 @@ describe('mergePasteContent', () => { blockGroupType: 'Document', blocks: [], }; - const targetModel: ContentModelDocument = { + + sourceModel = { blockGroupType: 'Document', blocks: [ { @@ -335,25 +371,25 @@ describe('mergePasteContent', () => { '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; + (core as any).domToModelSettings = { + customized: mockedDomToModelOptions, + }; + mergePasteContent( - targetModel, - context, + core, { fragment: mockedFragment, domToModelOption: mockedDefaultDomToModelOptions, } as any, - mockedDomToModelOptions + mockedClipboard ); + expect(formatContentModel).toHaveBeenCalledTimes(1); + expect(formatResult).toBeTrue(); expect(context).toEqual({ deletedEntities: [], newEntities: [], @@ -373,7 +409,7 @@ describe('mergePasteContent', () => { }, }); expect(domToContentModelSpy).toHaveBeenCalledWith(mockedFragment, mockedDomToModelContext); - expect(mergeModelSpy).toHaveBeenCalledWith(targetModel, pasteModel, context, { + expect(mergeModelSpy).toHaveBeenCalledWith(sourceModel, pasteModel, context, { mergeFormat: 'none', mergeTable: false, }); @@ -399,4 +435,47 @@ describe('mergePasteContent', () => { ); expect(mockedDomToModelContext.segmentFormat).toEqual({ lineHeight: '1pt' }); }); + + it('Preserve segment format after paste', () => { + const pasteModel: ContentModelDocument = createContentModelDocument(); + const mockedFormat = { + fontFamily: 'Arial', + }; + sourceModel = createContentModelDocument(); + + spyOn(mergeModelFile, 'mergeModel').and.returnValue({ + marker: { + format: mockedFormat, + }, + } as any); + spyOn(domToContentModel, 'domToContentModel').and.returnValue(pasteModel); + + const eventResult = { + pasteType: 'normal', + domToModelOption: { additionalAllowedTags: [] }, + } as any; + + mergePasteContent(core, eventResult, mockedClipboard); + + expect(formatContentModel).toHaveBeenCalledTimes(1); + expect(formatResult).toBeTrue(); + expect(context).toEqual({ + newEntities: [], + newImages: [], + deletedEntities: [], + newPendingFormat: { + backgroundColor: '', + fontFamily: 'Arial', + fontSize: '', + fontWeight: '', + italic: false, + letterSpacing: '', + lineHeight: '', + strikethrough: false, + superOrSubScriptSequence: '', + textColor: '', + underline: false, + }, + }); + }); }); 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 index dafa01821e1..9e705f56b2b 100644 --- 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 @@ -1,6 +1,6 @@ import { ClipboardData } from 'roosterjs-content-model-types'; import { HtmlFromClipboard, retrieveHtmlInfo } from '../../../lib/utils/paste/retrieveHtmlInfo'; -import { itChromeOnly } from 'roosterjs-editor-dom/test/DomTestHelper'; +import { itChromeOnly } from 'roosterjs-content-model-dom/test/testUtils'; describe('retrieveHtmlInfo', () => { function runTest( diff --git a/packages-content-model/roosterjs-content-model-core/test/utils/restoreSnapshotSelectionTest.ts b/packages-content-model/roosterjs-content-model-core/test/utils/restoreSnapshotSelectionTest.ts index f2fa0cfde1c..db6196e2e1c 100644 --- a/packages-content-model/roosterjs-content-model-core/test/utils/restoreSnapshotSelectionTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/utils/restoreSnapshotSelectionTest.ts @@ -138,6 +138,7 @@ describe('restoreSnapshotSelection', () => { type: 'range', start: [], end: [], + isReverted: false, }; const snapshot: Snapshot = { selection: snapshotSelection, @@ -148,6 +149,7 @@ describe('restoreSnapshotSelection', () => { expect(setDOMSelectionSpy).toHaveBeenCalledWith(core, { type: 'range', range: mockedRange, + isReverted: false, }); expect(setStartSpy).toHaveBeenCalledTimes(1); expect(setEndSpy).toHaveBeenCalledTimes(1); @@ -163,6 +165,7 @@ describe('restoreSnapshotSelection', () => { type: 'range', start: [1, 0], end: [1, 1], + isReverted: false, }; const snapshot: Snapshot = { selection: snapshotSelection, @@ -174,6 +177,7 @@ describe('restoreSnapshotSelection', () => { expect(setDOMSelectionSpy).toHaveBeenCalledWith(core, { type: 'range', range: mockedRange, + isReverted: false, }); expect(setStartSpy).toHaveBeenCalledTimes(1); expect(setEndSpy).toHaveBeenCalledTimes(1); @@ -189,6 +193,7 @@ describe('restoreSnapshotSelection', () => { type: 'range', start: [1, 0, 0], end: [1, 1, 0], + isReverted: false, }; const snapshot: Snapshot = { selection: snapshotSelection, @@ -201,6 +206,7 @@ describe('restoreSnapshotSelection', () => { expect(setDOMSelectionSpy).toHaveBeenCalledWith(core, { type: 'range', range: mockedRange, + isReverted: false, }); expect(setStartSpy).toHaveBeenCalledTimes(1); expect(setEndSpy).toHaveBeenCalledTimes(1); @@ -216,6 +222,7 @@ describe('restoreSnapshotSelection', () => { type: 'range', start: [1, 0, 0, 1], end: [1, 0, 2, 3], + isReverted: false, }; const snapshot: Snapshot = { selection: snapshotSelection, @@ -227,6 +234,7 @@ describe('restoreSnapshotSelection', () => { expect(setDOMSelectionSpy).toHaveBeenCalledWith(core, { type: 'range', range: mockedRange, + isReverted: false, }); expect(setStartSpy).toHaveBeenCalledTimes(1); expect(setEndSpy).toHaveBeenCalledTimes(1); @@ -241,6 +249,7 @@ describe('restoreSnapshotSelection', () => { type: 'range', start: [0, 0, 0, 1], end: [0, 2, 2, 3], + isReverted: false, }; const snapshot: Snapshot = { selection: snapshotSelection, @@ -253,6 +262,7 @@ describe('restoreSnapshotSelection', () => { expect(setDOMSelectionSpy).toHaveBeenCalledWith(core, { type: 'range', range: mockedRange, + isReverted: false, }); expect(setStartSpy).toHaveBeenCalledTimes(1); expect(setEndSpy).toHaveBeenCalledTimes(1); diff --git a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/entityProcessor.ts b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/entityProcessor.ts index cfdf01b62e4..feff4584d73 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/entityProcessor.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/entityProcessor.ts @@ -23,7 +23,6 @@ export const entityProcessor: ElementProcessor = (group, element, c parseFormat(element, context.formatParsers.entity, entityModel.entityFormat, context); - // TODO: Need to handle selection for editable entity if (context.isInSelection) { entityModel.isSelected = true; } diff --git a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/block/marginFormatHandler.ts b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/block/marginFormatHandler.ts index 28d2b576ea7..823735279b9 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/block/marginFormatHandler.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/block/marginFormatHandler.ts @@ -35,18 +35,6 @@ export const marginFormatHandler: FormatHandler = { } } }); - - const marginBlockStart = element.style.marginBlockStart || defaultStyle.marginBlockStart; - const marginTop = element.style.marginTop || defaultStyle.marginTop; - if (marginBlockStart && !marginTop) { - format.marginBlockStart = parseValueWithUnit(marginBlockStart) + 'px'; - } - - const marginBlockEnd = element.style.marginBlockEnd || defaultStyle.marginBlockEnd; - const marginBottom = element.style.marginBottom || defaultStyle.marginBottom; - if (marginBlockEnd && !marginBottom) { - format.marginBlockEnd = parseValueWithUnit(marginBlockEnd) + 'px'; - } }, apply: (format, element, context) => { MarginKeys.forEach(key => { @@ -56,13 +44,5 @@ export const marginFormatHandler: FormatHandler = { element.style[key] = value || '0'; } }); - - if (format.marginBlockStart && !format.marginTop) { - element.style.marginBlockStart = format.marginBlockStart; - } - - if (format.marginBlockEnd && !format.marginBottom) { - element.style.marginBlockEnd = format.marginBlockEnd; - } }, }; 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 f5a5a7adea4..f78aaad8ed9 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 @@ -1,5 +1,8 @@ const MarginValueRegex = /(-?\d+(\.\d+)?)([a-z]+|%)/; +// According to https://developer.mozilla.org/en-US/docs/Glossary/CSS_pixel, 1in = 96px +const PixelPerInch = 96; + /** * Parse unit value with its unit * @param value The source value to parse @@ -35,8 +38,8 @@ export function parseValueWithUnit( case '%': result = (getFontSize(currentSizePxOrElement) * num) / 100; break; - default: - // TODO: Support more unit if need + case 'in': + result = num * PixelPerInch; break; } } 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 54159c679ac..551ce399585 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/index.ts @@ -46,6 +46,7 @@ export { createGeneralBlock } from './modelApi/creators/createGeneralBlock'; export { createEntity } from './modelApi/creators/createEntity'; export { createDivider } from './modelApi/creators/createDivider'; export { createListLevel } from './modelApi/creators/createListLevel'; +export { createEmptyModel } from './modelApi/creators/createEmptyModel'; export { addBlock } from './modelApi/common/addBlock'; export { addCode } from './modelApi/common/addDecorators'; diff --git a/packages-content-model/roosterjs-content-model-dom/lib/modelApi/creators/createEmptyModel.ts b/packages-content-model/roosterjs-content-model-dom/lib/modelApi/creators/createEmptyModel.ts new file mode 100644 index 00000000000..23c3e2c439a --- /dev/null +++ b/packages-content-model/roosterjs-content-model-dom/lib/modelApi/creators/createEmptyModel.ts @@ -0,0 +1,22 @@ +import { createBr } from './createBr'; +import { createContentModelDocument } from './createContentModelDocument'; +import { createParagraph } from './createParagraph'; +import { createSelectionMarker } from './createSelectionMarker'; +import type { + ContentModelDocument, + ContentModelSegmentFormat, +} from 'roosterjs-content-model-types'; + +/** + * Create an empty Content Model Document with initial empty line and insert point with default format + * @param format @optional The default format to be applied to this Content Model + */ +export function createEmptyModel(format?: ContentModelSegmentFormat): ContentModelDocument { + const model = createContentModelDocument(format); + const paragraph = createParagraph(false /*isImplicit*/, undefined /*blockFormat*/, format); + + paragraph.segments.push(createSelectionMarker(format), createBr(format)); + model.blocks.push(paragraph); + + return model; +} diff --git a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/contentModelToDom.ts b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/contentModelToDom.ts index 876518c0fe1..7b90a3654d6 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/contentModelToDom.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/contentModelToDom.ts @@ -5,7 +5,6 @@ import type { DOMSelection, ModelToDomBlockAndSegmentNode, ModelToDomContext, - OnNodeCreated, } from 'roosterjs-content-model-types'; /** @@ -16,18 +15,14 @@ import type { * won't be touched. * @param model The content model document to generate DOM tree from * @param context The context object for Content Model to DOM conversion - * @param onNodeCreated Callback invoked when a DOM node is created * @returns The selection range created in DOM tree from this model, or null when there is no selection */ export function contentModelToDom( doc: Document, root: Node, model: ContentModelDocument, - context: ModelToDomContext, - onNodeCreated?: OnNodeCreated + context: ModelToDomContext ): DOMSelection | null { - context.onNodeCreated = onNodeCreated; - context.modelHandlers.blockGroupChildren(doc, root, model, context); const range = extractSelectionRange(doc, context); @@ -61,6 +56,7 @@ function extractSelectionRange(doc: Document, context: ModelToDomContext): DOMSe return { type: 'range', range, + isReverted: false, }; } else if (tableSelection) { return tableSelection; diff --git a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/childProcessorTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/childProcessorTest.ts index 69d7e6b96be..f34c80383cc 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/childProcessorTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/childProcessorTest.ts @@ -121,6 +121,7 @@ describe('childProcessor', () => { endOffset: 0, collapsed: false, } as any, + isReverted: false, }; context.pendingFormat = { format: { @@ -166,6 +167,7 @@ describe('childProcessor', () => { endOffset: 0, collapsed: false, } as any, + isReverted: false, }; context.pendingFormat = { format: { @@ -218,6 +220,7 @@ describe('childProcessor', () => { endOffset: 2, collapsed: false, } as any, + isReverted: false, }; childProcessor(doc, div, context); @@ -253,6 +256,7 @@ describe('childProcessor', () => { endOffset: 1, collapsed: true, } as any, + isReverted: false, }; childProcessor(doc, div, context); @@ -288,6 +292,7 @@ describe('childProcessor', () => { endOffset: 10, collapsed: false, } as any, + isReverted: false, }; childProcessor(doc, div, context); @@ -323,6 +328,7 @@ describe('childProcessor', () => { endOffset: 5, collapsed: true, } as any, + isReverted: false, }; childProcessor(doc, div, context); @@ -357,6 +363,7 @@ describe('childProcessor', () => { endOffset: 5, collapsed: false, } as any, + isReverted: false, }; childProcessor(doc, div, context); @@ -403,6 +410,7 @@ describe('childProcessor', () => { endOffset: 0, collapsed: true, } as any, + isReverted: false, }; childProcessor(doc, div, context); diff --git a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/delimiterProcessorTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/delimiterProcessorTest.ts index e2312476e6f..c4fd0166aa4 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/delimiterProcessorTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/delimiterProcessorTest.ts @@ -1,7 +1,7 @@ import * as delimiterProcessorFile from '../../../lib/domToModel/processors/childProcessor'; import { createContentModelDocument } from '../../../lib/modelApi/creators/createContentModelDocument'; import { createDomToModelContext } from '../../../lib/domToModel/context/createDomToModelContext'; -import { createRange } from 'roosterjs-editor-dom'; +import { createRange } from '../../testUtils'; import { delimiterProcessor } from '../../../lib/domToModel/processors/delimiterProcessor'; import { DomToModelContext } from 'roosterjs-content-model-types'; @@ -41,6 +41,7 @@ describe('delimiterProcessor', () => { context.selection = { type: 'range', range: createRange(text, 0, span2, 0), + isReverted: false, }; delimiterProcessor(doc, span, context); @@ -82,6 +83,7 @@ describe('delimiterProcessor', () => { context.selection = { type: 'range', range: createRange(text1, 2, text2, 3), + isReverted: false, }; delimiterProcessor(doc, span, context); diff --git a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/generalProcessorTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/generalProcessorTest.ts index bb215908c18..c52c730bfe2 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/generalProcessorTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/generalProcessorTest.ts @@ -160,6 +160,7 @@ describe('generalProcessor', () => { endOffset: 3, collapsed: false, } as any, + isReverted: false, }; childProcessor.and.callFake(originalChildProcessor); @@ -227,6 +228,7 @@ describe('generalProcessor', () => { endOffset: 3, collapsed: false, } as any, + isReverted: false, }; context.isInSelection = true; diff --git a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/textProcessorTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/textProcessorTest.ts index 0d05deee228..2bfb3db8ad0 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/textProcessorTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/textProcessorTest.ts @@ -4,7 +4,7 @@ import { addSegment } from '../../../lib/modelApi/common/addSegment'; import { createContentModelDocument } from '../../../lib/modelApi/creators/createContentModelDocument'; import { createDomToModelContext } from '../../../lib/domToModel/context/createDomToModelContext'; import { createParagraph } from '../../../lib/modelApi/creators/createParagraph'; -import { createRange } from 'roosterjs-editor-dom'; +import { createRange } from '../../testUtils'; import { createSelectionMarker } from '../../../lib/modelApi/creators/createSelectionMarker'; import { createText } from '../../../lib/modelApi/creators/createText'; import { textProcessor } from '../../../lib/domToModel/processors/textProcessor'; @@ -384,6 +384,7 @@ describe('textProcessor', () => { endOffset: 2, collapsed: true, } as any, + isReverted: false, }; textProcessor(doc, text, context); @@ -616,6 +617,7 @@ describe('textProcessor', () => { context.selection = { type: 'range', range: createRange(text, 2), + isReverted: false, }; textProcessor(doc, text, context); @@ -660,6 +662,7 @@ describe('textProcessor', () => { context.selection = { type: 'range', range: createRange(text, 1, text, 3), + isReverted: false, }; textProcessor(doc, text, context); @@ -745,6 +748,7 @@ describe('textProcessor', () => { startOffset: 2, endOffset: 2, } as any, + isReverted: false, }; context.pendingFormat = { format: { @@ -803,6 +807,7 @@ describe('textProcessor', () => { startOffset: 1, endOffset: 3, } as any, + isReverted: false, }; context.pendingFormat = { format: { @@ -863,6 +868,7 @@ describe('textProcessor', () => { startOffset: 2, endOffset: 2, } as any, + isReverted: false, }; context.pendingFormat = { format: { diff --git a/packages-content-model/roosterjs-content-model-dom/test/endToEndTest.ts b/packages-content-model/roosterjs-content-model-dom/test/endToEndTest.ts index b58a881d99e..b86beaf0ddc 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/endToEndTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/endToEndTest.ts @@ -2,7 +2,7 @@ import * as createGeneralBlock from '../lib/modelApi/creators/createGeneralBlock import { contentModelToDom } from '../lib/modelToDom/contentModelToDom'; import { createDomToModelContext, createModelToDomContext } from '../lib'; import { domToContentModel } from '../lib/domToModel/domToContentModel'; -import { expectHtml } from 'roosterjs-editor-api/test/TestHelper'; +import { expectHtml } from './testUtils'; import { ContentModelBlockFormat, ContentModelDocument, diff --git a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/block/marginFormatHandlerTest.ts b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/block/marginFormatHandlerTest.ts index da4aa15fd5a..d1f6918e3c1 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/block/marginFormatHandlerTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/block/marginFormatHandlerTest.ts @@ -50,33 +50,12 @@ describe('marginFormatHandler.parse', () => { }); }); - it('Has margin block in CSS', () => { - div.style.marginBlockEnd = '1px'; - div.style.marginBlockStart = '1px'; - marginFormatHandler.parse(format, div, context, {}); - expect(format).toEqual({ - marginBlockEnd: '1px', - marginBlockStart: '1px', - }); - }); - - it('Has margin block in default style', () => { - marginFormatHandler.parse(format, div, context, { - marginBlockEnd: '1em', - marginBlockStart: '1em', - }); - expect(format).toEqual({ - marginBlockEnd: '0px', - marginBlockStart: '0px', - }); - }); - it('Merge margin values', () => { - div.style.marginBlockStart = '15pt'; - format.marginBlockStart = '30px'; + div.style.marginTop = '15pt'; + format.marginTop = '30px'; marginFormatHandler.parse(format, div, context, {}); expect(format).toEqual({ - marginBlockStart: '20px', + marginTop: '15pt', }); }); @@ -153,31 +132,4 @@ describe('marginFormatHandler.apply', () => { marginFormatHandler.apply(format, div, context); expect(div.outerHTML).toBe('
              '); }); - - it('No margin block', () => { - marginFormatHandler.apply(format, div, context); - expect(div.outerHTML).toBe('
              '); - }); - - it('Has margin block', () => { - format.marginBlockEnd = '1px'; - format.marginBlockStart = '2px'; - - marginFormatHandler.apply(format, div, context); - - expect(div.outerHTML).toBe('
              '); - }); - - it('Do not overlay margin values with margin block values', () => { - format.marginTop = '1px'; - format.marginRight = '2px'; - format.marginBottom = '3px'; - format.marginLeft = '4px'; - format.marginBlockEnd = '5px'; - format.marginBlockStart = '6px'; - - marginFormatHandler.apply(format, div, context); - - expect(div.outerHTML).toBe('
              '); - }); }); 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 c54a70e1945..8b2fff7bd78 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/common/backgroundColorFormatHandlerTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/common/backgroundColorFormatHandlerTest.ts @@ -2,7 +2,7 @@ import { backgroundColorFormatHandler } from '../../../lib/formatHandlers/common import { createDomToModelContext } from '../../../lib/domToModel/context/createDomToModelContext'; import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext'; import { DeprecatedColors } from '../../../lib/formatHandlers/utils/color'; -import { expectHtml } from 'roosterjs-editor-dom/test/DomTestHelper'; +import { expectHtml } from '../../testUtils'; import { BackgroundColorFormat, DomToModelContext, 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 fbf9e8aae58..d989ab9ff5f 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 @@ -2,7 +2,6 @@ import { BorderFormat, DomToModelContext, ModelToDomContext } from 'roosterjs-co import { borderFormatHandler } from '../../../lib/formatHandlers/common/borderFormatHandler'; import { createDomToModelContext } from '../../../lib/domToModel/context/createDomToModelContext'; import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext'; -import { itChromeOnly } from 'roosterjs-editor-dom/test/DomTestHelper'; describe('borderFormatHandler.parse', () => { let div: HTMLElement; @@ -51,7 +50,7 @@ describe('borderFormatHandler.parse', () => { }); }); - itChromeOnly('Has border width none value', () => { + it('Has border width none value', () => { div.style.borderWidth = '1px 2px 3px 4px'; div.style.borderStyle = 'none'; div.style.borderColor = 'red'; @@ -59,10 +58,10 @@ describe('borderFormatHandler.parse', () => { borderFormatHandler.parse(format, div, context, {}); expect(format).toEqual({ - borderTop: '1px none red', - borderRight: '2px none red', - borderBottom: '3px none red', - borderLeft: '4px none red', + borderTop: jasmine.stringMatching(/1px (none )?red/), + borderRight: jasmine.stringMatching(/2px (none )?red/), + borderBottom: jasmine.stringMatching(/3px (none )?red/), + borderLeft: jasmine.stringMatching(/4px (none )?red/), }); }); @@ -171,7 +170,7 @@ describe('borderFormatHandler.apply', () => { expect(div.outerHTML).toEqual('
              '); }); - itChromeOnly('Has border color - empty values', () => { + it('Has border color - empty values', () => { format.borderTop = 'red'; borderFormatHandler.apply(format, div, context); @@ -179,7 +178,7 @@ describe('borderFormatHandler.apply', () => { expect(div.outerHTML).toEqual('
              '); }); - itChromeOnly('Use independant border radius 1', () => { + it('Use independant border radius 1', () => { format.borderBottomLeftRadius = '2px'; format.borderBottomRightRadius = '3px'; format.borderTopRightRadius = '3px'; diff --git a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/segment/textColorFormatHandlerTest.ts b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/segment/textColorFormatHandlerTest.ts index 4f57a69d524..379dab76eab 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/segment/textColorFormatHandlerTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/segment/textColorFormatHandlerTest.ts @@ -2,7 +2,7 @@ import { createDomToModelContext } from '../../../lib/domToModel/context/createD import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext'; import { defaultHTMLStyleMap } from '../../../lib/config/defaultHTMLStyleMap'; import { DeprecatedColors } from '../../../lib'; -import { expectHtml } from 'roosterjs-editor-dom/test/DomTestHelper'; +import { expectHtml } from '../../testUtils'; import { textColorFormatHandler } from '../../../lib/formatHandlers/segment/textColorFormatHandler'; import { DomToModelContext, 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 989766c9f4c..c2e25db3bad 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 @@ -17,7 +17,11 @@ describe('parseValueWithUnit with element', () => { const input = value + unit; const result = parseValueWithUnit(input, mockedElement); - expect(result).toBe(results[i], input); + if (Number.isNaN(results[i])) { + expect(result).toBeNaN(); + } else { + expect(Math.abs(result - results[i])).toBeLessThan(1e-3, input); + } }); } @@ -70,6 +74,10 @@ describe('parseValueWithUnit with element', () => { expect(result).toBe(16); }); + + it('in to px', () => { + runTest('in', [0, 96, 105.6, -105.6]); + }); }); describe('parseValueWithUnit with number', () => { @@ -78,7 +86,11 @@ describe('parseValueWithUnit with number', () => { const input = value + unit; const result = parseValueWithUnit(input, 20); - expect(result).toBe(results[i], input); + if (Number.isNaN(results[i])) { + expect(result).toBeNaN(); + } else { + expect(Math.abs(result - results[i])).toBeLessThan(1e-3, input); + } }); } @@ -127,4 +139,8 @@ describe('parseValueWithUnit with number', () => { expect(result).toBe(16); }); + + it('in to px', () => { + runTest('in', [0, 96, 105.6, -105.6]); + }); }); diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelApi/creators/createEmptyModelTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelApi/creators/createEmptyModelTest.ts new file mode 100644 index 00000000000..c4fc77a8773 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-dom/test/modelApi/creators/createEmptyModelTest.ts @@ -0,0 +1,59 @@ +import { ContentModelSegmentFormat } from 'roosterjs-content-model-types'; +import { createEmptyModel } from '../../../lib/modelApi/creators/createEmptyModel'; + +describe('createEmptyModel', () => { + it('no param', () => { + const result = createEmptyModel(); + + expect(result).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + }, + ], + }); + }); + + it('with param', () => { + const mockedFormat: ContentModelSegmentFormat = { + fontFamily: 'Arial', + }; + const result = createEmptyModel(mockedFormat); + + expect(result).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: mockedFormat, + }, + { + segmentType: 'Br', + format: mockedFormat, + }, + ], + segmentFormat: mockedFormat, + }, + ], + format: mockedFormat, + }); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleImageTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleImageTest.ts index f9c0a109424..4cb684d0b80 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleImageTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleImageTest.ts @@ -1,7 +1,7 @@ import * as stackFormat from '../../../lib/modelToDom/utils/stackFormat'; import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext'; +import { expectHtml } from '../../testUtils'; import { handleImage } from '../../../lib/modelToDom/handlers/handleImage'; -import { itChromeOnly } from 'roosterjs-editor-dom/test/DomTestHelper'; import { ContentModelBlock, ContentModelBlockHandler, @@ -25,14 +25,14 @@ describe('handleSegment', () => { function runTest( segment: ContentModelImage, - expectedInnerHTML: string, + expectedInnerHTML: string[], expectedCreateBlockFromContentModelCalledTimes: number ) { parent = document.createElement('div'); handleImage(document, parent, segment, context, []); - expect(parent.innerHTML).toBe(expectedInnerHTML); + expectHtml(parent.innerHTML, expectedInnerHTML); expect(handleBlock).toHaveBeenCalledTimes(expectedCreateBlockFromContentModelCalledTimes); } @@ -44,7 +44,7 @@ describe('handleSegment', () => { dataset: {}, }; - runTest(segment, '', 0); + runTest(segment, [''], 0); expect(context.imageSelection).toBeUndefined(); }); @@ -58,7 +58,7 @@ describe('handleSegment', () => { dataset: {}, }; - runTest(segment, '', 0); + runTest(segment, [''], 0); expect(context.imageSelection!.image.src).toBe('http://test.com/test'); }); @@ -73,7 +73,7 @@ describe('handleSegment', () => { dataset: {}, }; - runTest(segment, 'a', 0); + runTest(segment, ['a'], 0); }); it('image segment with link', () => { @@ -85,10 +85,10 @@ describe('handleSegment', () => { dataset: {}, }; - runTest(segment, '', 0); + runTest(segment, [''], 0); }); - itChromeOnly('image segment with size', () => { + it('image segment with size', () => { const segment: ContentModelImage = { segmentType: 'Image', src: 'http://test.com/test', @@ -99,7 +99,10 @@ describe('handleSegment', () => { runTest( segment, - '', + [ + '', + '', + ], 0 ); }); @@ -117,7 +120,7 @@ describe('handleSegment', () => { runTest( segment, - '', + [''], 0 ); }); @@ -133,7 +136,7 @@ describe('handleSegment', () => { spyOn(stackFormat, 'stackFormat').and.callThrough(); - runTest(segment, '', 0); + runTest(segment, [''], 0); expect(stackFormat.stackFormat).toHaveBeenCalledTimes(1); expect((stackFormat.stackFormat).calls.argsFor(0)[1]).toBe('a'); diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleListTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleListTest.ts index 19de98dc6f7..99ef6fecd6a 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleListTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleListTest.ts @@ -3,7 +3,7 @@ import { ContentModelListItem, ModelToDomContext } from 'roosterjs-content-model import { createListItem } from '../../../lib/modelApi/creators/createListItem'; import { createListLevel } from '../../../lib/modelApi/creators/createListLevel'; import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext'; -import { expectHtml } from 'roosterjs-editor-dom/test/DomTestHelper'; +import { expectHtml } from '../../testUtils'; import { handleList } from '../../../lib/modelToDom/handlers/handleList'; import { NumberingListType } from 'roosterjs-content-model-core/lib/constants/NumberingListType'; diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleSegmentDecoratorTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleSegmentDecoratorTest.ts index 3cd180aae63..022733f2116 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleSegmentDecoratorTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleSegmentDecoratorTest.ts @@ -1,5 +1,5 @@ import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext'; -import { expectHtml } from 'roosterjs-editor-dom/test/DomTestHelper'; +import { expectHtml } from '../../testUtils'; import { handleSegmentDecorator } from '../../../lib/modelToDom/handlers/handleSegmentDecorator'; import { ContentModelCode, diff --git a/packages-content-model/roosterjs-content-model-dom/test/testUtils.ts b/packages-content-model/roosterjs-content-model-dom/test/testUtils.ts new file mode 100644 index 00000000000..ce9da85fe1d --- /dev/null +++ b/packages-content-model/roosterjs-content-model-dom/test/testUtils.ts @@ -0,0 +1,31 @@ +export function expectHtml(actualHtml: string, expectedHtml: string | string[]) { + expectedHtml = Array.isArray(expectedHtml) ? expectedHtml : [expectedHtml]; + expect(expectedHtml.indexOf(actualHtml)).toBeGreaterThanOrEqual(0, actualHtml); +} + +export function createRange(node1: Node, offset1?: number, node2?: Node, offset2?: number): Range { + const range = document.createRange(); + + if (typeof offset1 == 'number') { + range.setStart(node1, offset1); + } else { + range.selectNode(node1); + } + + if (node2 && typeof offset2 == 'number') { + range.setEnd(node2, offset2); + } + + return range; +} + +declare var __karma__: any; + +export function itChromeOnly( + expectation: string, + assertion?: jasmine.ImplementationCallback, + timeout?: number +) { + const func = __karma__.config.browser == 'Chrome' ? it : xit; + return func(expectation, assertion, timeout); +} 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 0a92376edc1..c00066f23ab 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 @@ -70,6 +70,7 @@ export const ensureTypeInContainer: EnsureTypeInContainer = ( api.setDOMSelection(innerCore, { type: 'range', range: createRange(new Position(position)), + isReverted: false, }); } }; 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 937eda1bcd0..c1f40618293 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 @@ -172,6 +172,7 @@ export const insertNode: InsertNode = (core, innerCore, node, option) => { api.setDOMSelection(innerCore, { type: 'range', range: rangeToRestore, + isReverted: false, }); } 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 8f7892d0b81..2ea1d68e87a 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 @@ -99,6 +99,7 @@ function convertMetadataToDOMSelection( return { type: 'range', range: createRange(contentDiv, metadata.start, metadata.end), + isReverted: false, }; case SelectionRangeTypes.TableSelection: const table = queryElements(contentDiv, '#' + metadata.tableId)[0] as HTMLTableElement; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/BridgePlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/BridgePlugin.ts index 9f1d76ae781..7d38348dba5 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/BridgePlugin.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/BridgePlugin.ts @@ -1,6 +1,5 @@ -import { createContextMenuPlugin } from './ContextMenuPlugin'; import { createEditPlugin } from './EditPlugin'; -import { createNormalizeTablePlugin } from './NormalizeTablePlugin'; +import { createEntityDelimiterPlugin } from './EntityDelimiterPlugin'; import { newEventToOldEvent, oldEventToNewEvent } from '../editor/utils/eventConverter'; import { PluginEventType } from 'roosterjs-editor-types'; import type { ContentModelCorePluginState } from '../publicTypes/ContentModelCorePlugins'; @@ -11,8 +10,9 @@ import type { import type { EditorPlugin as LegacyEditorPlugin, PluginEvent as LegacyPluginEvent, + ContextMenuProvider as LegacyContextMenuProvider, } from 'roosterjs-editor-types'; -import type { EditorPlugin, PluginEvent } from 'roosterjs-content-model-types'; +import type { ContextMenuProvider, PluginEvent } from 'roosterjs-content-model-types'; const ExclusivelyHandleEventPluginKey = '__ExclusivelyHandleEventPlugin'; @@ -20,7 +20,7 @@ const ExclusivelyHandleEventPluginKey = '__ExclusivelyHandleEventPlugin'; * @internal * Act as a bridge between Standalone editor and Content Model editor, translate Standalone editor event type to legacy event type */ -export class BridgePlugin implements EditorPlugin { +export class BridgePlugin implements ContextMenuProvider { private legacyPlugins: LegacyEditorPlugin[]; private corePluginState: ContentModelCorePluginState; private outerEditor: IContentModelEditor | null = null; @@ -28,18 +28,16 @@ export class BridgePlugin implements EditorPlugin { constructor(options: ContentModelEditorOptions) { const editPlugin = createEditPlugin(); - const contextMenuPlugin = createContextMenuPlugin(options); - const normalizeTablePlugin = createNormalizeTablePlugin(); + const entityDelimiterPlugin = createEntityDelimiterPlugin(); this.legacyPlugins = [ editPlugin, ...(options.legacyPlugins ?? []).filter(x => !!x), - contextMenuPlugin, - normalizeTablePlugin, + entityDelimiterPlugin, ]; this.corePluginState = { edit: editPlugin.getState(), - contextMenu: contextMenuPlugin.getState(), + contextMenuProviders: this.legacyPlugins.filter(isContextMenuProvider), }; this.checkExclusivelyHandling = this.legacyPlugins.some( plugin => plugin.willHandleEventExclusively @@ -134,4 +132,32 @@ export class BridgePlugin implements EditorPlugin { Object.assign(event, oldEventToNewEvent(oldEvent, event)); } } + + /** + * A callback to return context menu items + * @param target Target node that triggered a ContextMenu event + * @returns An array of context menu items, or null means no items needed + */ + getContextMenuItems(target: Node): any[] { + const allItems: any[] = []; + + this.corePluginState.contextMenuProviders.forEach(provider => { + const items = provider.getContextMenuItems(target) ?? []; + if (items?.length > 0) { + if (allItems.length > 0) { + allItems.push(null); + } + + allItems.push(...items); + } + }); + + return allItems; + } +} + +function isContextMenuProvider( + source: LegacyEditorPlugin +): source is LegacyContextMenuProvider { + return !!(>source)?.getContextMenuItems; } 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 deleted file mode 100644 index 850101d5447..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/ContextMenuPlugin.ts +++ /dev/null @@ -1,111 +0,0 @@ -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.legacyPlugins?.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-plugins/lib/entityDelimiter/EntityDelimiterPlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/EntityDelimiterPlugin.ts similarity index 97% rename from packages-content-model/roosterjs-content-model-plugins/lib/entityDelimiter/EntityDelimiterPlugin.ts rename to packages-content-model/roosterjs-content-model-editor/lib/corePlugins/EntityDelimiterPlugin.ts index 6d0bb852b17..95e8b9c6ffe 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/entityDelimiter/EntityDelimiterPlugin.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/EntityDelimiterPlugin.ts @@ -1,4 +1,5 @@ import { isCharacterValue } from 'roosterjs-content-model-core'; +import type { IContentModelEditor } from '../publicTypes/IContentModelEditor'; import { addDelimiters, isBlockElement, @@ -28,7 +29,6 @@ import type { PluginEvent, PluginKeyDownEvent, } from 'roosterjs-editor-types'; -import type { IContentModelEditor } from 'roosterjs-content-model-editor'; const DELIMITER_SELECTOR = '.' + DelimiterClasses.DELIMITER_AFTER + ',.' + DelimiterClasses.DELIMITER_BEFORE; @@ -36,9 +36,10 @@ const ZERO_WIDTH_SPACE = '\u200B'; const INLINE_ENTITY_SELECTOR = 'span' + getEntitySelector(); /** + * @internal * Entity delimiter plugin helps maintain delimiter elements around an entity so that user can put focus before/after an entity */ -export class EntityDelimiterPlugin implements EditorPlugin { +class EntityDelimiterPlugin implements EditorPlugin { private editor: IContentModelEditor | null = null; /** @@ -314,3 +315,11 @@ function handleKeyDownEvent(editor: IEditor, event: PluginKeyDownEvent) { handleSelectionNotCollapsed(editor, currentRange, rawEvent); } } + +/** + * @internal + * Create a new instance of EntityDelimiterPlugin. + */ +export function createEntityDelimiterPlugin(): EditorPlugin { + return new EntityDelimiterPlugin(); +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/NormalizeTablePlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/NormalizeTablePlugin.ts deleted file mode 100644 index f0f3cecda33..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/NormalizeTablePlugin.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { PluginEventType, SelectionRangeTypes } from 'roosterjs-editor-types'; -import { - changeElementTag, - getTagOfNode, - moveChildNodes, - safeInstanceOf, - toArray, -} from 'roosterjs-editor-dom'; -import type { EditorPlugin, IEditor, PluginEvent } from 'roosterjs-editor-types'; - -/** - * TODO: Rename this plugin since it is not only for table now - * - * NormalizeTable plugin makes sure each table in editor has TBODY/THEAD/TFOOT tag around TR tags - * - * When we retrieve HTML content using innerHTML, browser will always add TBODY around TR nodes if there is not. - * This causes some issue when we restore the HTML content with selection path since the selection path is - * deeply coupled with DOM structure. So we need to always make sure there is already TBODY tag whenever - * new table is inserted, to make sure the selection path we created is correct. - */ -class NormalizeTablePlugin implements EditorPlugin { - private editor: IEditor | null = null; - - /** - * Get a friendly name of this plugin - */ - getName() { - return 'NormalizeTable'; - } - - /** - * The first method that editor will call to a plugin when editor is initializing. - * It will pass in the editor instance, plugin should take this chance to save the - * editor reference so that it can call to any editor method or format API later. - * @param editor The editor object - */ - initialize(editor: IEditor) { - this.editor = editor; - } - - /** - * The last method that editor will call to a plugin before it is disposed. - * Plugin can take this chance to clear the reference to editor. After this method is - * called, plugin should not call to any editor method since it will result in error. - */ - dispose() { - this.editor = null; - } - - /** - * Core method for a plugin. Once an event happens in editor, editor will call this - * method of each plugin to handle the event as long as the event is not handled - * exclusively by another plugin. - * @param event The event to handle: - */ - onPluginEvent(event: PluginEvent) { - switch (event.eventType) { - case PluginEventType.EditorReady: - case PluginEventType.ContentChanged: - if (this.editor) { - this.normalizeTables(this.editor.queryElements('table')); - } - break; - - case PluginEventType.BeforePaste: - this.normalizeTables(toArray(event.fragment.querySelectorAll('table'))); - break; - - case PluginEventType.MouseDown: - this.normalizeTableFromEvent(event.rawEvent); - break; - - case PluginEventType.KeyDown: - if (event.rawEvent.shiftKey) { - this.normalizeTableFromEvent(event.rawEvent); - } - break; - - case PluginEventType.ExtractContentWithDom: - normalizeListsForExport(event.clonedRoot); - break; - } - } - - private normalizeTableFromEvent(event: KeyboardEvent | MouseEvent) { - const table = this.editor?.getElementAtCursor('table', event.target as Node); - - if (table) { - this.normalizeTables([table]); - } - } - - private normalizeTables(tables: HTMLTableElement[]) { - if (this.editor && tables.length > 0) { - const rangeEx = this.editor.getSelectionRangeEx(); - const { startContainer, endContainer, startOffset, endOffset } = - (rangeEx?.type == SelectionRangeTypes.Normal && rangeEx.ranges[0]) || {}; - - const isChanged = normalizeTables(tables); - - if (isChanged) { - if ( - startContainer && - endContainer && - typeof startOffset === 'number' && - typeof endOffset === 'number' - ) { - this.editor.select(startContainer, startOffset, endContainer, endOffset); - } else if ( - rangeEx?.type == SelectionRangeTypes.TableSelection && - rangeEx.coordinates - ) { - this.editor.select(rangeEx.table, rangeEx.coordinates); - } - } - } - } -} - -function normalizeTables(tables: HTMLTableElement[]) { - let isDOMChanged = false; - tables.forEach(table => { - let tbody: HTMLTableSectionElement | null = null; - - for (let child = table.firstChild; child; child = child.nextSibling) { - const tag = getTagOfNode(child); - switch (tag) { - case 'TR': - if (!tbody) { - tbody = table.ownerDocument.createElement('tbody'); - table.insertBefore(tbody, child); - } - - tbody.appendChild(child); - child = tbody; - isDOMChanged = true; - - break; - case 'TBODY': - if (tbody) { - moveChildNodes(tbody, child, true /*keepExistingChildren*/); - child.parentNode?.removeChild(child); - child = tbody; - isDOMChanged = true; - } else { - tbody = child as HTMLTableSectionElement; - } - break; - default: - tbody = null; - break; - } - } - - const colgroups = table.querySelectorAll('colgroup'); - const thead = table.querySelector('thead'); - if (thead) { - colgroups.forEach(colgroup => { - if (!thead.contains(colgroup)) { - thead.appendChild(colgroup); - } - }); - } - }); - - return isDOMChanged; -} - -function normalizeListsForExport(root: ParentNode) { - toArray(root.querySelectorAll('li')).forEach(li => { - const prevElement = li.previousSibling; - - if (li.style.display == 'block' && safeInstanceOf(prevElement, 'HTMLLIElement')) { - li.style.removeProperty('display'); - - prevElement.appendChild(changeElementTag(li, 'div')); - } - }); -} - -/** - * @internal - * Create a new instance of NormalizeTablePlugin. - */ -export function createNormalizeTablePlugin(): EditorPlugin { - return new NormalizeTablePlugin(); -} 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 4fd612d08bc..7e6d166d06a 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 @@ -126,13 +126,15 @@ export class ContentModelEditor extends StandaloneEditor implements IContentMode super(contentDiv, standaloneEditorOptions, () => { const core = this.getCore(); + const sizeTransformer: SizeTransformer = size => + size / this.getDOMHelper().calculateZoomScale(); // Need to create Content Model Editor Core before initialize plugins since some plugins need this object this.contentModelEditorCore = createEditorCore( options, corePluginState, core.darkColorHandler, - size => size / this.getCore().zoomScale + sizeTransformer ); bridgePlugin.setOuterEditor(this); @@ -977,6 +979,38 @@ export class ContentModelEditor extends StandaloneEditor implements IContentMode ); } + /** + * 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.getDOMHelper().calculateZoomScale(); + } + + /** + * Set 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 + * @param scale The new scale number to set. It should be positive number and no greater than 10, otherwise it will be ignored. + */ + setZoomScale(scale: number): void { + if (scale > 0 && scale <= 10) { + const oldValue = this.getZoomScale(); + + if (oldValue != scale) { + this.triggerEvent( + 'zoomChanged', + { + newZoomScale: scale, + }, + true /*broadcast*/ + ); + } + } + } + /** * @deprecated Use getZoomScale() instead */ diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/isContentModelEditor.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/isContentModelEditor.ts deleted file mode 100644 index c2436986be0..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/isContentModelEditor.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { IContentModelEditor } from '../publicTypes/IContentModelEditor'; -import type { IEditor } from 'roosterjs-editor-types'; - -/** - * Check if the given editor object is Content Model editor - * @param editor The editor to check - * @returns True if the given editor is Content Model editor, otherwise false - */ -export function isContentModelEditor(editor: IEditor): editor is IContentModelEditor { - const contentModelEditor = editor as IContentModelEditor; - - return !!contentModelEditor.createContentModel; -} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/eventConverter.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/eventConverter.ts index c973fc8c0c2..3fd1b936649 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/eventConverter.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/eventConverter.ts @@ -313,7 +313,6 @@ export function oldEventToNewEvent( eventType: 'zoomChanged', eventDataCache: input.eventDataCache, newZoomScale: input.newZoomScale, - oldZoomScale: input.oldZoomScale, }; default: @@ -524,7 +523,8 @@ export function newEventToOldEvent(input: NewEvent, refEvent?: OldEvent): OldEve eventType: PluginEventType.ZoomChanged, eventDataCache: input.eventDataCache, newZoomScale: input.newZoomScale, - oldZoomScale: input.oldZoomScale, + oldZoomScale: + refEvent?.eventType == PluginEventType.ZoomChanged ? refEvent.oldZoomScale : 1, // In new ZoomChangedEvent we don't really have oldZoomScale. So if we can't get it, just use 1 instead }; default: diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/selectionConverter.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/selectionConverter.ts index 287c48e7d8b..6279645eee9 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/selectionConverter.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/selectionConverter.ts @@ -24,6 +24,7 @@ export function convertRangeExToDomSelection( ? { type: 'range', range: rangeEx.ranges[0], + isReverted: false, } : null; 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 1aecfd69e2c..d0a490ed6e3 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/index.ts @@ -8,9 +8,7 @@ export { EnsureTypeInContainer, } from './publicTypes/ContentModelEditorCore'; export { IContentModelEditor, ContentModelEditorOptions } from './publicTypes/IContentModelEditor'; -export { ContextMenuPluginState } from './publicTypes/ContextMenuPluginState'; export { ContentModelCorePluginState } from './publicTypes/ContentModelCorePlugins'; export { ContentModelBeforePasteEvent } from './publicTypes/ContentModelBeforePasteEvent'; export { ContentModelEditor } from './editor/ContentModelEditor'; -export { isContentModelEditor } from './editor/isContentModelEditor'; 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 851b170b720..450b843c1e1 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,5 +1,4 @@ -import type { ContextMenuPluginState } from './ContextMenuPluginState'; -import type { EditPluginState } from 'roosterjs-editor-types'; +import type { ContextMenuProvider, EditPluginState } from 'roosterjs-editor-types'; /** * Core plugin state for Content Model Editor @@ -11,7 +10,7 @@ export interface ContentModelCorePluginState { readonly edit: EditPluginState; /** - * Plugin state of ContextMenuPlugin + * Context Menu providers */ - readonly contextMenu: ContextMenuPluginState; + readonly contextMenuProviders: ContextMenuProvider[]; } diff --git a/packages-content-model/roosterjs-content-model-editor/test/corePlugins/BridgePluginTest.ts b/packages-content-model/roosterjs-content-model-editor/test/corePlugins/BridgePluginTest.ts index 6c42aeef97a..26484b98713 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/corePlugins/BridgePluginTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/corePlugins/BridgePluginTest.ts @@ -1,7 +1,5 @@ -import * as ContextMenuPlugin from '../../lib/corePlugins/ContextMenuPlugin'; import * as EditPlugin from '../../lib/corePlugins/EditPlugin'; import * as eventConverter from '../../lib/editor/utils/eventConverter'; -import * as NormalizeTablePlugin from '../../lib/corePlugins/NormalizeTablePlugin'; import { BridgePlugin } from '../../lib/corePlugins/BridgePlugin'; import { PluginEventType } from 'roosterjs-editor-types'; @@ -14,13 +12,7 @@ describe('BridgePlugin', () => { } as any; } beforeEach(() => { - spyOn(ContextMenuPlugin, 'createContextMenuPlugin').and.returnValue( - createMockedPlugin('contextMenu') - ); spyOn(EditPlugin, 'createEditPlugin').and.returnValue(createMockedPlugin('edit')); - spyOn(NormalizeTablePlugin, 'createNormalizeTablePlugin').and.returnValue( - createMockedPlugin('normalizeTable') - ); }); it('Ctor and init', () => { @@ -28,6 +20,7 @@ describe('BridgePlugin', () => { const onPluginEventSpy1 = jasmine.createSpy('onPluginEvent1'); const onPluginEventSpy2 = jasmine.createSpy('onPluginEvent2'); const disposeSpy = jasmine.createSpy('dispose'); + const queryElementsSpy = jasmine.createSpy('queryElement').and.returnValue([]); const mockedPlugin1 = { initialize: initializeSpy, @@ -39,7 +32,9 @@ describe('BridgePlugin', () => { onPluginEvent: onPluginEventSpy2, dispose: disposeSpy, } as any; - const mockedEditor = 'EDITOR' as any; + const mockedEditor = { + queryElements: queryElementsSpy, + } as any; const plugin = new BridgePlugin({ legacyPlugins: [mockedPlugin1, mockedPlugin2], @@ -51,7 +46,7 @@ describe('BridgePlugin', () => { expect(plugin.getCorePluginState()).toEqual({ edit: 'edit', - contextMenu: 'contextMenu', + contextMenuProviders: [], } as any); plugin.setOuterEditor(mockedEditor); @@ -258,4 +253,81 @@ describe('BridgePlugin', () => { plugin.dispose(); }); + + it('Context Menu provider', () => { + const initializeSpy = jasmine.createSpy('initialize'); + const onPluginEventSpy1 = jasmine.createSpy('onPluginEvent1'); + const onPluginEventSpy2 = jasmine.createSpy('onPluginEvent2'); + const disposeSpy = jasmine.createSpy('dispose'); + const queryElementsSpy = jasmine.createSpy('queryElement').and.returnValue([]); + const getContextMenuItemsSpy1 = jasmine + .createSpy('getContextMenuItems 1') + .and.returnValue(['item1', 'item2']); + const getContextMenuItemsSpy2 = jasmine + .createSpy('getContextMenuItems 2') + .and.returnValue(['item3', 'item4']); + + const mockedPlugin1 = { + initialize: initializeSpy, + onPluginEvent: onPluginEventSpy1, + dispose: disposeSpy, + getContextMenuItems: getContextMenuItemsSpy1, + } as any; + const mockedPlugin2 = { + initialize: initializeSpy, + onPluginEvent: onPluginEventSpy2, + dispose: disposeSpy, + getContextMenuItems: getContextMenuItemsSpy2, + } as any; + const mockedEditor = { + queryElements: queryElementsSpy, + } as any; + + const plugin = new BridgePlugin({ + legacyPlugins: [mockedPlugin1, mockedPlugin2], + }); + expect(initializeSpy).not.toHaveBeenCalled(); + expect(onPluginEventSpy1).not.toHaveBeenCalled(); + expect(onPluginEventSpy2).not.toHaveBeenCalled(); + expect(disposeSpy).not.toHaveBeenCalled(); + + expect(plugin.getCorePluginState()).toEqual({ + edit: 'edit', + contextMenuProviders: [mockedPlugin1, mockedPlugin2], + } as any); + + plugin.setOuterEditor(mockedEditor); + + expect(initializeSpy).toHaveBeenCalledTimes(0); + expect(onPluginEventSpy1).toHaveBeenCalledTimes(0); + expect(onPluginEventSpy2).toHaveBeenCalledTimes(0); + expect(disposeSpy).not.toHaveBeenCalled(); + + plugin.initialize(); + + expect(initializeSpy).toHaveBeenCalledTimes(2); + expect(onPluginEventSpy1).toHaveBeenCalledTimes(1); + expect(onPluginEventSpy2).toHaveBeenCalledTimes(1); + expect(disposeSpy).not.toHaveBeenCalled(); + + expect(initializeSpy).toHaveBeenCalledWith(mockedEditor); + expect(onPluginEventSpy1).toHaveBeenCalledWith({ + eventType: PluginEventType.EditorReady, + }); + expect(onPluginEventSpy2).toHaveBeenCalledWith({ + eventType: PluginEventType.EditorReady, + }); + + const mockedNode = 'NODE' as any; + + const items = plugin.getContextMenuItems(mockedNode); + + expect(items).toEqual(['item1', 'item2', null, 'item3', 'item4']); + expect(getContextMenuItemsSpy1).toHaveBeenCalledWith(mockedNode); + expect(getContextMenuItemsSpy2).toHaveBeenCalledWith(mockedNode); + + plugin.dispose(); + + expect(disposeSpy).toHaveBeenCalledTimes(2); + }); }); 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 deleted file mode 100644 index 763cda3b4c0..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/test/corePlugins/ContextMenuPluginTest.ts +++ /dev/null @@ -1,93 +0,0 @@ -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({ - legacyPlugins: [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 15c28837049..55b31bf529c 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts @@ -1,6 +1,4 @@ -import * as contentModelToDom from 'roosterjs-content-model-dom/lib/modelToDom/contentModelToDom'; import * as createDomToModelContext from 'roosterjs-content-model-dom/lib/domToModel/context/createDomToModelContext'; -import * as createModelToDomContext from 'roosterjs-content-model-dom/lib/modelToDom/context/createModelToDomContext'; import * as domToContentModel from 'roosterjs-content-model-dom/lib/domToModel/domToContentModel'; import * as findAllEntities from 'roosterjs-content-model-core/lib/corePlugin/utils/findAllEntities'; import { ContentModelEditor } from '../../lib/editor/ContentModelEditor'; @@ -38,7 +36,7 @@ describe('ContentModelEditor', () => { }, }); - const model = editor.createContentModel(); + const model = editor.getContentModelCopy('connected'); expect(model).toBe(mockedResult); expect(domToContentModel.domToContentModel).toHaveBeenCalledTimes(1); @@ -75,7 +73,7 @@ describe('ContentModelEditor', () => { }, }); - const model = editor.createContentModel(); + const model = editor.getContentModelCopy('connected'); expect(model).toBe(mockedResult); expect(domToContentModel.domToContentModel).toHaveBeenCalledTimes(1); @@ -90,80 +88,6 @@ describe('ContentModelEditor', () => { ); }); - it('setContentModel with normal selection', () => { - const mockedRange = { - type: 'range', - range: document.createRange(), - } as any; - const mockedModel = 'MockedModel' as any; - const mockedContext = 'MockedContext' as any; - const mockedConfig = 'MockedConfig' as any; - - spyOn(contentModelToDom, 'contentModelToDom').and.returnValue(mockedRange); - spyOn(createModelToDomContext, 'createModelToDomContextWithConfig').and.returnValue( - mockedContext - ); - spyOn(createModelToDomContext, 'createModelToDomConfig').and.returnValue(mockedConfig); - - const div = document.createElement('div'); - const editor = new ContentModelEditor(div); - - spyOn((editor as any).core.api, 'createEditorContext').and.returnValue(editorContext); - - const selection = editor.setContentModel(mockedModel); - - expect(contentModelToDom.contentModelToDom).toHaveBeenCalledTimes(2); - expect(contentModelToDom.contentModelToDom).toHaveBeenCalledWith( - document, - div, - mockedModel, - mockedContext, - undefined - ); - expect(createModelToDomContext.createModelToDomContextWithConfig).toHaveBeenCalledWith( - mockedConfig, - editorContext - ); - expect(selection).toBe(mockedRange); - }); - - it('setContentModel', () => { - const mockedRange = { - type: 'range', - range: document.createRange(), - } as any; - const mockedModel = 'MockedModel' as any; - const mockedContext = 'MockedContext' as any; - const mockedConfig = 'MockedConfig' as any; - - spyOn(contentModelToDom, 'contentModelToDom').and.returnValue(mockedRange); - spyOn(createModelToDomContext, 'createModelToDomContextWithConfig').and.returnValue( - mockedContext - ); - spyOn(createModelToDomContext, 'createModelToDomConfig').and.returnValue(mockedConfig); - - const div = document.createElement('div'); - const editor = new ContentModelEditor(div); - - spyOn((editor as any).core.api, 'createEditorContext').and.returnValue(editorContext); - - const selection = editor.setContentModel(mockedModel); - - expect(contentModelToDom.contentModelToDom).toHaveBeenCalledTimes(2); - expect(contentModelToDom.contentModelToDom).toHaveBeenCalledWith( - document, - div, - mockedModel, - mockedContext, - undefined - ); - expect(createModelToDomContext.createModelToDomContextWithConfig).toHaveBeenCalledWith( - mockedConfig, - editorContext - ); - expect(selection).toBe(mockedRange); - }); - it('createContentModel in EditorReady event', () => { let model: ContentModelDocument | undefined; let pluginEditor: any; @@ -179,7 +103,7 @@ describe('ContentModelEditor', () => { }, onPluginEvent: event => { if (event.eventType == PluginEventType.EditorReady) { - model = pluginEditor.createContentModel(); + model = pluginEditor.getContentModelCopy('connected'); } }, }; @@ -220,7 +144,7 @@ describe('ContentModelEditor', () => { spyOn(domToContentModel, 'domToContentModel'); - const model = editor.createContentModel(); + const model = editor.getContentModelCopy('connected'); expect(model).toBe(cachedModel); expect(domToContentModel.domToContentModel).not.toHaveBeenCalled(); @@ -253,7 +177,7 @@ describe('ContentModelEditor', () => { }, }); - const model = editor.createContentModel(); + const model = editor.getContentModelCopy('connected'); expect(model.format).toEqual({ fontWeight: 'bold', 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 4a382a51853..7b92620902b 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 @@ -5,7 +5,6 @@ import { createEditorCore } from '../../lib/editor/createEditorCore'; describe('createEditorCore', () => { const mockedSizeTransformer = 'TRANSFORMER' as any; const mockedEditPluginState = 'EDITSTATE' as any; - const mockedContextMenuPluginState = 'CONTEXTMENUSTATE' as any; const mockedInnerHandler = 'INNER' as any; const mockedDarkHandler = 'DARK' as any; @@ -18,7 +17,7 @@ describe('createEditorCore', () => { {}, { edit: mockedEditPluginState, - contextMenu: mockedContextMenuPluginState, + contextMenuProviders: [], }, mockedInnerHandler, mockedSizeTransformer @@ -30,7 +29,7 @@ describe('createEditorCore', () => { customData: {}, experimentalFeatures: [], edit: mockedEditPluginState, - contextMenu: mockedContextMenuPluginState, + contextMenuProviders: [], sizeTransformer: mockedSizeTransformer, darkColorHandler: mockedDarkHandler, }); @@ -40,6 +39,7 @@ describe('createEditorCore', () => { it('With additional plugins', () => { const mockedPlugin1 = 'P1' as any; const mockedPlugin2 = 'P2' as any; + const mockedPlugin3 = 'P3' as any; const mockedFeatures = 'FEATURES' as any; const mockedCoreApi = { a: 'b', @@ -53,7 +53,7 @@ describe('createEditorCore', () => { }, { edit: mockedEditPluginState, - contextMenu: mockedContextMenuPluginState, + contextMenuProviders: [mockedPlugin3], }, mockedInnerHandler, mockedSizeTransformer @@ -63,9 +63,9 @@ describe('createEditorCore', () => { api: { ...coreApiMap, a: 'b' } as any, originalApi: { ...coreApiMap }, customData: {}, + contextMenuProviders: [mockedPlugin3], experimentalFeatures: mockedFeatures, edit: mockedEditPluginState, - contextMenu: mockedContextMenuPluginState, sizeTransformer: mockedSizeTransformer, darkColorHandler: mockedDarkHandler, }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/isContentModelEditorTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/isContentModelEditorTest.ts deleted file mode 100644 index 19020bb14da..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/isContentModelEditorTest.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { ContentModelEditor } from '../../lib/editor/ContentModelEditor'; -import { Editor } from 'roosterjs-editor-core'; -import { isContentModelEditor } from '../../lib/editor/isContentModelEditor'; - -describe('isContentModelEditor', () => { - it('Legacy editor', () => { - const div = document.createElement('div'); - const editor = new Editor(div); - const result = isContentModelEditor(editor); - - expect(result).toBeFalse(); - }); - - it('Content Model editor', () => { - const div = document.createElement('div'); - const editor = new ContentModelEditor(div); - const result = isContentModelEditor(editor); - - expect(result).toBeTrue(); - }); -}); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/utils/eventConverterTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/utils/eventConverterTest.ts index 3adf797f5ec..581971e0e6c 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/utils/eventConverterTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/utils/eventConverterTest.ts @@ -623,7 +623,6 @@ describe('oldEventToNewEvent', () => { eventType: 'zoomChanged', eventDataCache: mockedDataCache, newZoomScale: mockedNewZoomScale, - oldZoomScale: mockedOldZoomScale, } ); }); @@ -1212,6 +1211,25 @@ describe('newEventToOldEvent', () => { it('ZoomChanged', () => { const mockedNewZoomScale = 'NEWSCALE' as any; + + runTest( + { + eventType: 'zoomChanged', + eventDataCache: mockedDataCache, + newZoomScale: mockedNewZoomScale, + }, + undefined, + { + eventType: PluginEventType.ZoomChanged, + eventDataCache: mockedDataCache, + newZoomScale: mockedNewZoomScale, + oldZoomScale: 1, + } + ); + }); + + it('ZoomChanged with ref', () => { + const mockedNewZoomScale = 'NEWSCALE' as any; const mockedOldZoomScale = 'OLDSCALE' as any; runTest( @@ -1219,9 +1237,13 @@ describe('newEventToOldEvent', () => { eventType: 'zoomChanged', eventDataCache: mockedDataCache, newZoomScale: mockedNewZoomScale, + }, + { + eventType: PluginEventType.ZoomChanged, + eventDataCache: mockedDataCache, + newZoomScale: mockedNewZoomScale, oldZoomScale: mockedOldZoomScale, }, - undefined, { eventType: PluginEventType.ZoomChanged, eventDataCache: mockedDataCache, diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/utils/selectionConverterTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/utils/selectionConverterTest.ts index df95ac3dd7b..edbcf1a1a87 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/utils/selectionConverterTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/utils/selectionConverterTest.ts @@ -25,6 +25,7 @@ describe('convertRangeExToDomSelection', () => { expect(result).toEqual({ type: 'range', range: mockedRange, + isReverted: false, }); }); @@ -120,6 +121,7 @@ describe('convertDomSelectionToRangeEx', () => { const result = convertDomSelectionToRangeEx({ type: 'range', range: mockedRange, + isReverted: false, }); expect(result).toEqual({ diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts new file mode 100644 index 00000000000..63589ea9e99 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts @@ -0,0 +1,101 @@ +import { keyboardListTrigger } from './keyboardListTrigger'; +import type { + EditorPlugin, + IStandaloneEditor, + KeyDownEvent, + PluginEvent, +} from 'roosterjs-content-model-types'; + +/** + * Options to customize the Content Model Auto Format Plugin + */ +export type AutoFormatOptions = { + /** + * When true, after type *, ->, -, --, => , —, > and space key a type of bullet list will be triggered. @default true + */ + autoBullet: boolean; + + /** + * When true, after type 1, A, a, i, I followed by ., ), - or between () and space key a type of numbering list will be triggered. @default true + */ + autoNumbering: boolean; +}; + +/** + * @internal + */ +const DefaultOptions: Required = { + autoBullet: true, + autoNumbering: true, +}; + +/** + * Auto Format plugin handles auto formatting, such as transforming * characters into a bullet list. + * It can be customized with options to enable or disable auto list features. + */ +export class ContentModelAutoFormatPlugin implements EditorPlugin { + private editor: IStandaloneEditor | null = null; + + /** + * @param options An optional parameter that takes in an object of type AutoFormatOptions, which includes the following properties: + * - autoBullet: A boolean that enables or disables automatic bullet list formatting. Defaults to true. + * - autoNumbering: A boolean that enables or disables automatic numbering formatting. Defaults to true. + */ + constructor(private options: AutoFormatOptions = DefaultOptions) {} + + /** + * Get name of this plugin + */ + getName() { + return 'ContentModelAutoFormat'; + } + + /** + * The first method that editor will call to a plugin when editor is initializing. + * It will pass in the editor instance, plugin should take this chance to save the + * editor reference so that it can call to any editor method or format API later. + * @param editor The editor object + */ + initialize(editor: IStandaloneEditor) { + this.editor = editor; + } + + /** + * The last method that editor will call to a plugin before it is disposed. + * Plugin can take this chance to clear the reference to editor. After this method is + * called, plugin should not call to any editor method since it will result in error. + */ + dispose() { + this.editor = null; + } + + /** + * Core method for a plugin. Once an event happens in editor, editor will call this + * method of each plugin to handle the event as long as the event is not handled + * exclusively by another plugin. + * @param event The event to handle: + */ + onPluginEvent(event: PluginEvent) { + if (this.editor) { + switch (event.eventType) { + case 'keyDown': + this.handleKeyDownEvent(this.editor, event); + break; + } + } + } + + private handleKeyDownEvent(editor: IStandaloneEditor, event: KeyDownEvent) { + const rawEvent = event.rawEvent; + if (!rawEvent.defaultPrevented && !event.handledByEditFeature) { + switch (rawEvent.key) { + case ' ': + const { autoBullet, autoNumbering } = this.options; + if (autoBullet || autoNumbering) { + keyboardListTrigger(editor, rawEvent, autoBullet, autoNumbering); + } + break; + } + } + } +} diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts new file mode 100644 index 00000000000..27398185a5f --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts @@ -0,0 +1,60 @@ +import { getListTypeStyle } from './utils/getListTypeStyle'; +import { getSelectedSegmentsAndParagraphs } from 'roosterjs-content-model-core'; +import { normalizeContentModel } from 'roosterjs-content-model-dom'; +import { setListStartNumber, setListStyle, setListType } from 'roosterjs-content-model-api'; +import type { ContentModelDocument, IStandaloneEditor } from 'roosterjs-content-model-types'; + +/** + * @internal + */ +export function keyboardListTrigger( + editor: IStandaloneEditor, + rawEvent: KeyboardEvent, + shouldSearchForBullet: boolean = true, + shouldSearchForNumbering: boolean = true +) { + editor.formatContentModel((model, _context) => { + const listStyleType = getListTypeStyle( + model, + shouldSearchForBullet, + shouldSearchForNumbering + ); + if (listStyleType) { + const segmentsAndParagraphs = getSelectedSegmentsAndParagraphs(model, false); + if (segmentsAndParagraphs[0] && segmentsAndParagraphs[0][1]) { + segmentsAndParagraphs[0][1].segments.splice(0, 1); + } + const { listType, styleType, index } = listStyleType; + triggerList(editor, model, listType, styleType, index); + rawEvent.preventDefault(); + normalizeContentModel(model); + return true; + } + return false; + }); +} + +const triggerList = ( + editor: IStandaloneEditor, + model: ContentModelDocument, + listType: 'OL' | 'UL', + styleType: number, + index?: number +) => { + setListType(model, listType); + const isOrderedList = listType == 'OL'; + // If the index < 1, it is a new list, so it will be starting by 1, then no need to set startNumber + if (index && index > 1 && isOrderedList) { + setListStartNumber(editor, index); + } + setListStyle( + editor, + isOrderedList + ? { + orderedStyleType: styleType, + } + : { + unorderedStyleType: styleType, + } + ); +}; diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/convertAlphaToDecimals.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/convertAlphaToDecimals.ts new file mode 100644 index 00000000000..f439feeab7e --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/convertAlphaToDecimals.ts @@ -0,0 +1,19 @@ +/** + * @internal + * Convert english alphabet numbers into decimal numbers + * @param letter The letter that needs to be converted + * @returns + */ +export function convertAlphaToDecimals(letter: string): number | undefined { + const alpha = letter.toUpperCase(); + if (alpha) { + let result = 0; + for (let i = 0; i < alpha.length; i++) { + const charCode = alpha.charCodeAt(i) - 65 + 1; + result = result * 26 + charCode; + } + + return result; + } + return undefined; +} diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getIndex.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getIndex.ts new file mode 100644 index 00000000000..7997045550a --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getIndex.ts @@ -0,0 +1,10 @@ +import { convertAlphaToDecimals } from './convertAlphaToDecimals'; + +/** + * @internal + */ +export function getIndex(listIndex: string) { + const index = listIndex.replace(/[^a-zA-Z0-9 ]/g, ''); + const indexNumber = parseInt(index); + return !isNaN(indexNumber) ? indexNumber : convertAlphaToDecimals(index); +} diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getListTypeStyle.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getListTypeStyle.ts new file mode 100644 index 00000000000..86b8571ae24 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getListTypeStyle.ts @@ -0,0 +1,106 @@ +import { getIndex } from './getIndex'; +import { getNumberingListStyle } from './getNumberingListStyle'; + +import type { + ContentModelDocument, + ContentModelListItem, + ContentModelParagraph, +} from 'roosterjs-content-model-types'; +import { + BulletListType, + isBlockGroupOfType, + updateListMetadata, + getOperationalBlocks, + getSelectedSegmentsAndParagraphs, +} from 'roosterjs-content-model-core'; + +/** + * @internal + */ +interface ListTypeStyle { + listType: 'UL' | 'OL'; + styleType: number; + index?: number; +} + +/** + * @internal + */ +export function getListTypeStyle( + model: ContentModelDocument, + shouldSearchForBullet: boolean = true, + shouldSearchForNumbering: boolean = true +): ListTypeStyle | undefined { + const selectedSegmentsAndParagraphs = getSelectedSegmentsAndParagraphs(model, true); + const marker = selectedSegmentsAndParagraphs[0][0]; + const paragraph = selectedSegmentsAndParagraphs[0][1]; + const listMarkerSegment = paragraph?.segments[0]; + + if ( + marker && + marker.segmentType == 'SelectionMarker' && + listMarkerSegment && + listMarkerSegment.segmentType == 'Text' + ) { + const listMarker = listMarkerSegment.text; + const bulletType = bulletListType[listMarker]; + + if (bulletType && shouldSearchForBullet) { + return { listType: 'UL', styleType: bulletType }; + } else if (shouldSearchForNumbering) { + const previousList = getPreviousListLevel(model, paragraph); + const previousListStyle = getPreviousListStyle(previousList); + const numberingType = getNumberingListStyle( + listMarker, + previousList?.format?.listStyleType + ? getIndex(previousList.format.listStyleType) + : undefined, + previousListStyle + ); + if (numberingType) { + return { + listType: 'OL', + styleType: numberingType, + index: previousList?.format?.listStyleType ? getIndex(listMarker) : undefined, + }; + } + } + } + return undefined; +} + +const getPreviousListLevel = (model: ContentModelDocument, paragraph: ContentModelParagraph) => { + const blocks = getOperationalBlocks(model, ['ListItem'], ['TableCell']); + let listItem: ContentModelListItem | undefined = undefined; + const listBlock = blocks.filter(({ block, parent }) => { + return parent.blocks.indexOf(paragraph) > -1; + })[0]; + if (listBlock) { + const length = listBlock.parent.blocks.length; + for (let i = length - 1; i > -1; i--) { + const item = listBlock.parent.blocks[i]; + if (isBlockGroupOfType(item, 'ListItem')) { + listItem = item; + break; + } + } + } + return listItem; +}; + +const getPreviousListStyle = (list?: ContentModelListItem) => { + if (list?.levels[0].dataset) { + return updateListMetadata(list.levels[0])?.orderedStyleType; + } +}; + +const bulletListType: Record = { + '*': BulletListType.Disc, + '-': BulletListType.Dash, + '--': BulletListType.Square, + '->': BulletListType.LongArrow, + '-->': BulletListType.DoubleLongArrow, + '=>': BulletListType.UnfilledArrow, + '>': BulletListType.ShortArrow, + '—': BulletListType.Hyphen, +}; diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getNumberingListStyle.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getNumberingListStyle.ts new file mode 100644 index 00000000000..bc0de54d63f --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getNumberingListStyle.ts @@ -0,0 +1,172 @@ +import { getIndex } from './getIndex'; +import { NumberingListType } from 'roosterjs-content-model-core'; + +const enum NumberingTypes { + Decimal = 1, + LowerAlpha = 2, + UpperAlpha = 3, + LowerRoman = 4, + UpperRoman = 5, +} + +const enum Character { + Dot = 1, + Dash = 2, + Parenthesis = 3, + DoubleParenthesis = 4, +} + +const characters: Record = { + '.': Character.Dot, + '-': Character.Dash, + ')': Character.Parenthesis, +}; + +const lowerRomanTypes = [ + NumberingListType.LowerRoman, + NumberingListType.LowerRomanDash, + NumberingListType.LowerRomanDoubleParenthesis, + NumberingListType.LowerRomanParenthesis, +]; +const upperRomanTypes = [ + NumberingListType.UpperRoman, + NumberingListType.UpperRomanDash, + NumberingListType.UpperRomanDoubleParenthesis, + NumberingListType.UpperRomanParenthesis, +]; +const numberingTriggers = ['1', 'a', 'A', 'I', 'i']; +const lowerRomanNumbers = ['i', 'v', 'x', 'l', 'c', 'd', 'm']; +const upperRomanNumbers = ['I', 'V', 'X', 'L', 'C', 'D', 'M']; + +const identifyNumberingType = (text: string, previousListStyle?: number) => { + if (!isNaN(parseInt(text))) { + return NumberingTypes.Decimal; + } else if (/[a-z]+/g.test(text)) { + if ( + (previousListStyle != undefined && + lowerRomanTypes.indexOf(previousListStyle) > -1 && + lowerRomanNumbers.indexOf(text[0]) > -1) || + (!previousListStyle && text === 'i') + ) { + return NumberingTypes.LowerRoman; + } else if (previousListStyle || (!previousListStyle && text === 'a')) { + return NumberingTypes.LowerAlpha; + } + } else if (/[A-Z]+/g.test(text)) { + if ( + (previousListStyle != undefined && + upperRomanTypes.indexOf(previousListStyle) > -1 && + upperRomanNumbers.indexOf(text[0]) > -1) || + (!previousListStyle && text === 'I') + ) { + return NumberingTypes.UpperRoman; + } else if (previousListStyle || (!previousListStyle && text === 'A')) { + return NumberingTypes.UpperAlpha; + } + } +}; + +const numberingListTypes: Record number | undefined> = { + [NumberingTypes.Decimal]: char => DecimalsTypes[char] || undefined, + [NumberingTypes.LowerAlpha]: char => LowerAlphaTypes[char] || undefined, + [NumberingTypes.UpperAlpha]: char => UpperAlphaTypes[char] || undefined, + [NumberingTypes.LowerRoman]: char => LowerRomanTypes[char] || undefined, + [NumberingTypes.UpperRoman]: char => UpperRomanTypes[char] || undefined, +}; + +const UpperRomanTypes: Record = { + [Character.Dot]: NumberingListType.UpperRoman, + [Character.Dash]: NumberingListType.UpperRomanDash, + [Character.Parenthesis]: NumberingListType.UpperRomanParenthesis, + [Character.DoubleParenthesis]: NumberingListType.UpperRomanDoubleParenthesis, +}; + +const LowerRomanTypes: Record = { + [Character.Dot]: NumberingListType.LowerRoman, + [Character.Dash]: NumberingListType.LowerRomanDash, + [Character.Parenthesis]: NumberingListType.LowerRomanParenthesis, + [Character.DoubleParenthesis]: NumberingListType.LowerRomanDoubleParenthesis, +}; + +const UpperAlphaTypes: Record = { + [Character.Dot]: NumberingListType.UpperAlpha, + [Character.Dash]: NumberingListType.UpperAlphaDash, + [Character.Parenthesis]: NumberingListType.UpperAlphaParenthesis, + [Character.DoubleParenthesis]: NumberingListType.UpperAlphaDoubleParenthesis, +}; + +const LowerAlphaTypes: Record = { + [Character.Dot]: NumberingListType.LowerAlpha, + [Character.Dash]: NumberingListType.LowerAlphaDash, + [Character.Parenthesis]: NumberingListType.LowerAlphaParenthesis, + [Character.DoubleParenthesis]: NumberingListType.LowerAlphaDoubleParenthesis, +}; + +const DecimalsTypes: Record = { + [Character.Dot]: NumberingListType.Decimal, + [Character.Dash]: NumberingListType.DecimalDash, + [Character.Parenthesis]: NumberingListType.DecimalParenthesis, + [Character.DoubleParenthesis]: NumberingListType.DecimalDoubleParenthesis, +}; + +const identifyNumberingListType = ( + numbering: string, + isDoubleParenthesis: boolean, + previousListStyle?: number +): number | undefined => { + const separatorCharacter = isDoubleParenthesis + ? Character.DoubleParenthesis + : characters[numbering[numbering.length - 1]]; + // if separator is not valid, no need to check if the number is valid. + if (separatorCharacter) { + const number = isDoubleParenthesis ? numbering.slice(1, -1) : numbering.slice(0, -1); + const numberingType = identifyNumberingType(number, previousListStyle); + return numberingType ? numberingListTypes[numberingType](separatorCharacter) : undefined; + } + return undefined; +}; + +/** + * @internal + */ +export function getNumberingListStyle( + textBeforeCursor: string, + previousListIndex?: number, + previousListStyle?: number +): number | undefined { + const trigger = textBeforeCursor.trim(); + const isDoubleParenthesis = trigger[0] === '(' && trigger[trigger.length - 1] === ')'; + //Only the staring items ['1', 'a', 'A', 'I', 'i'] must trigger a new list. All the other triggers is used to keep the list chain. + //The index is always the characters before the last character + const listIndex = isDoubleParenthesis ? trigger.slice(1, -1) : trigger.slice(0, -1); + const index = getIndex(listIndex); + + if ( + !index || + index < 1 || + (!previousListIndex && numberingTriggers.indexOf(listIndex) < 0) || + (previousListIndex && + numberingTriggers.indexOf(listIndex) < 0 && + !canAppendList(index, previousListIndex)) + ) { + return undefined; + } + + const numberingType = isValidNumbering(listIndex) + ? identifyNumberingListType(trigger, isDoubleParenthesis, previousListStyle) + : undefined; + return numberingType; +} + +/** + * Check if index has only numbers or only letters to avoid sequence of character such 1:1. trigger a list. + * @param index + * @returns + */ +function isValidNumbering(index: string) { + return Number(index) || /^[A-Za-z\s]*$/.test(index); +} + +function canAppendList(index?: number, previousListIndex?: number) { + return previousListIndex && index && previousListIndex + 1 === index; +} diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/ContentModelEditPlugin.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/ContentModelEditPlugin.ts index 0653fac82f3..a03c145dfc5 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/ContentModelEditPlugin.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/ContentModelEditPlugin.ts @@ -30,7 +30,6 @@ export class ContentModelEditPlugin implements EditorPlugin { * @param editor The editor object */ initialize(editor: IStandaloneEditor) { - // TODO: Later we may need a different interface for Content Model editor plugin this.editor = editor; } diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts index 70ed141d425..d72a251e179 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts @@ -57,7 +57,7 @@ function shouldInputWithContentModel( return ( selection.type != 'range' || (!selection.range.collapsed && !rawEvent.isComposing && !isInIME) - ); // TODO: Also handle Enter key even selection is collapsed + ); } else { return false; } diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/index.ts b/packages-content-model/roosterjs-content-model-plugins/lib/index.ts index d479dec6ddf..1128be25a61 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/index.ts @@ -1,3 +1,6 @@ export { ContentModelPastePlugin } from './paste/ContentModelPastePlugin'; export { ContentModelEditPlugin } from './edit/ContentModelEditPlugin'; -export { EntityDelimiterPlugin } from './entityDelimiter/EntityDelimiterPlugin'; +export { + ContentModelAutoFormatPlugin, + AutoFormatOptions, +} from './autoFormat/ContentModelAutoFormatPlugin'; 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 4357e1c5ed4..68aa979ea7e 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 @@ -50,7 +50,6 @@ export class ContentModelPastePlugin implements EditorPlugin { * @param editor The editor object */ initialize(editor: IStandaloneEditor) { - // TODO: Later we may need a different interface for Content Model editor plugin this.editor = editor; } diff --git a/packages-content-model/roosterjs-content-model-plugins/package.json b/packages-content-model/roosterjs-content-model-plugins/package.json index e33c18cf24d..9472ec52eb4 100644 --- a/packages-content-model/roosterjs-content-model-plugins/package.json +++ b/packages-content-model/roosterjs-content-model-plugins/package.json @@ -3,12 +3,10 @@ "description": "Content Model for roosterjs (Under development)", "dependencies": { "tslib": "^2.3.1", - "roosterjs-editor-types": "", - "roosterjs-editor-dom": "", "roosterjs-content-model-core": "", - "roosterjs-content-model-editor": "", "roosterjs-content-model-dom": "", - "roosterjs-content-model-types": "" + "roosterjs-content-model-types": "", + "roosterjs-content-model-api": "" }, "version": "0.0.0", "main": "./lib/index.ts" diff --git a/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/ContentModelAutoFormatPluginTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/ContentModelAutoFormatPluginTest.ts new file mode 100644 index 00000000000..3060811a4b3 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/ContentModelAutoFormatPluginTest.ts @@ -0,0 +1,115 @@ +import * as keyboardTrigger from '../../lib/autoFormat/keyboardListTrigger'; +import { ContentModelAutoFormatPlugin } from '../../lib/autoFormat/ContentModelAutoFormatPlugin'; +import { IContentModelEditor } from 'roosterjs-content-model-editor'; +import { KeyDownEvent } from 'roosterjs-content-model-types'; + +describe('Content Model Auto Format Plugin Test', () => { + let editor: IContentModelEditor; + + beforeEach(() => { + editor = ({ + focus: () => {}, + getDOMSelection: () => + ({ + type: -1, + } as any), // Force return invalid range to go through content model code + } as any) as IContentModelEditor; + }); + + describe('onPluginEvent', () => { + let keyboardListTriggerSpy: jasmine.Spy; + + beforeEach(() => { + keyboardListTriggerSpy = spyOn(keyboardTrigger, 'keyboardListTrigger'); + }); + + function runTest( + event: KeyDownEvent, + shouldCallTrigger: boolean, + options?: { autoBullet: boolean; autoNumbering: boolean } + ) { + const plugin = new ContentModelAutoFormatPlugin(options); + plugin.initialize(editor); + + plugin.onPluginEvent(event); + + if (shouldCallTrigger) { + expect(keyboardListTriggerSpy).toHaveBeenCalledWith( + editor, + event.rawEvent, + options?.autoBullet ?? true, + options?.autoNumbering ?? true + ); + } else { + expect(keyboardListTriggerSpy).not.toHaveBeenCalled(); + } + } + + it('should trigger keyboardListTrigger', () => { + const event: KeyDownEvent = { + eventType: 'keyDown', + rawEvent: { key: ' ', defaultPrevented: false, preventDefault: () => {} } as any, + handledByEditFeature: false, + }; + runTest(event, true); + }); + + it('should not trigger keyboardListTrigger', () => { + const event: KeyDownEvent = { + eventType: 'keyDown', + rawEvent: { key: '*', defaultPrevented: false, preventDefault: () => {} } as any, + handledByEditFeature: false, + }; + + runTest(event, false); + }); + + it('should not trigger keyboardListTrigger', () => { + const event: KeyDownEvent = { + eventType: 'keyDown', + rawEvent: { key: ' ', defaultPrevented: false, preventDefault: () => {} } as any, + handledByEditFeature: false, + }; + + runTest(event, false, { autoBullet: false, autoNumbering: false }); + }); + + it('should trigger keyboardListTrigger with auto bullet only', () => { + const event: KeyDownEvent = { + eventType: 'keyDown', + rawEvent: { key: ' ', defaultPrevented: false, preventDefault: () => {} } as any, + handledByEditFeature: false, + }; + runTest(event, true, { autoBullet: true, autoNumbering: false }); + }); + + it('should trigger keyboardListTrigger with auto numbering only', () => { + const event: KeyDownEvent = { + eventType: 'keyDown', + rawEvent: { key: ' ', defaultPrevented: false, preventDefault: () => {} } as any, + handledByEditFeature: false, + }; + runTest(event, true, { autoBullet: false, autoNumbering: true }); + }); + + it('should not trigger keyboardListTrigger, because handledByEditFeature', () => { + const event: KeyDownEvent = { + eventType: 'keyDown', + rawEvent: { key: ' ', defaultPrevented: false, preventDefault: () => {} } as any, + handledByEditFeature: true, + }; + + runTest(event, false); + }); + + it('should not trigger keyboardListTrigger, because defaultPrevented', () => { + const event: KeyDownEvent = { + eventType: 'keyDown', + rawEvent: { key: ' ', defaultPrevented: true } as any, + handledByEditFeature: false, + }; + + runTest(event, false); + }); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/keyboardListTriggerTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/keyboardListTriggerTest.ts new file mode 100644 index 00000000000..abdbb18ccdc --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/keyboardListTriggerTest.ts @@ -0,0 +1,460 @@ +import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; +import { ContentModelDocument } from 'roosterjs-content-model-types'; +import { keyboardListTrigger } from '../../lib/autoFormat/keyboardListTrigger'; + +describe('keyboardListTrigger', () => { + let normalizeContentModelSpy: jasmine.Spy; + beforeEach(() => { + normalizeContentModelSpy = spyOn(normalizeContentModel, 'normalizeContentModel'); + }); + + function runTest( + input: ContentModelDocument, + expectedModel: ContentModelDocument, + expectedResult: boolean, + shouldSearchForBullet: boolean = true, + shouldSearchForNumbering: boolean = true + ) { + const formatWithContentModelSpy = jasmine + .createSpy('formatWithContentModel') + .and.callFake((callback, options) => { + const result = callback(input, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); + expect(result).toBe(expectedResult); + }); + + keyboardListTrigger( + { + focus: () => {}, + formatContentModel: formatWithContentModelSpy, + } as any, + { + preventDefault: () => {}, + } as KeyboardEvent, + shouldSearchForBullet, + shouldSearchForNumbering + ); + + if (expectedResult) { + expect(normalizeContentModelSpy).toHaveBeenCalled(); + } else { + expect(normalizeContentModelSpy).not.toHaveBeenCalled(); + } + + expect(formatWithContentModelSpy).toHaveBeenCalled(); + expect(input).toEqual(expectedModel); + } + + it('trigger numbering list', () => { + runTest( + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '1)', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + startNumberOverride: 1, + direction: undefined, + textAlign: undefined, + }, + dataset: { + editingInfo: '{"orderedStyleType":3}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: { + fontFamily: undefined, + fontSize: undefined, + textColor: undefined, + }, + }, + format: {}, + }, + ], + format: {}, + }, + true + ); + }); + + it('trigger continued numbering list', () => { + runTest( + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: ' test', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + }, + dataset: { + editingInfo: '{"orderedStyleType":3,"unorderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"1) "', + }, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '2)', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: ' test', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + }, + dataset: { + editingInfo: '{"orderedStyleType":3,"unorderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"1) "', + }, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + startNumberOverride: 2, + direction: undefined, + textAlign: undefined, + marginBottom: '0px', + marginTop: '0px', + }, + dataset: { + editingInfo: '{"orderedStyleType":3}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: { + fontFamily: undefined, + fontSize: undefined, + textColor: undefined, + }, + }, + format: {}, + }, + ], + format: {}, + }, + true + ); + }); + + it('should not trigger numbering list', () => { + runTest( + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '1)', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '1)', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }, + false, + undefined, + false + ); + }); + + it('should trigger bullet list', () => { + runTest( + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '*', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'UL', + format: { + marginTop: '0px', + marginBottom: '0px', + startNumberOverride: 1, + direction: undefined, + textAlign: undefined, + }, + dataset: { + editingInfo: '{"unorderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: { + fontFamily: undefined, + fontSize: undefined, + textColor: undefined, + }, + }, + format: {}, + }, + ], + format: {}, + }, + true + ); + }); + + it('should not trigger bullet list', () => { + runTest( + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '*', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '*', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }, + false, + false + ); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/utils/convertAlphaToDecimalsTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/utils/convertAlphaToDecimalsTest.ts new file mode 100644 index 00000000000..223e7349689 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/utils/convertAlphaToDecimalsTest.ts @@ -0,0 +1,24 @@ +import { convertAlphaToDecimals } from '../../../lib/autoFormat/utils/convertAlphaToDecimals'; + +describe('convertAlphaToDecimals', () => { + function runTest(alpha: string, expectedResult: number) { + const decimal = convertAlphaToDecimals(alpha); + expect(decimal).toBe(expectedResult); + } + + it('should convert a to 1', () => { + runTest('a', 1); + }); + + it('should convert G to 6', () => { + runTest('G', 7); + }); + + it('should convert AA to 27', () => { + runTest('AA', 27); + }); + + it('should convert ba to 52', () => { + runTest('ba', 53); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/utils/getIndexTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/utils/getIndexTest.ts new file mode 100644 index 00000000000..9a5e006100d --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/utils/getIndexTest.ts @@ -0,0 +1,32 @@ +import { getIndex } from '../../../lib/autoFormat/utils/getIndex'; + +describe('getIndex', () => { + function runTest(listMarker: string, expectedResult: number) { + const index = getIndex(listMarker); + expect(index).toBe(expectedResult); + } + + it('should convert a. to 1', () => { + runTest('a.', 1); + }); + + it('should convert 4. to 4', () => { + runTest('4.', 4); + }); + + it('should convert (5) to 5', () => { + runTest('(5)', 5); + }); + + it('should convert (B) to 2', () => { + runTest('(B)', 2); + }); + + it('should convert g) to 7', () => { + runTest('g)', 7); + }); + + it('should convert C) to 3', () => { + runTest('C)', 3); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/utils/getListTypeStyleTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/utils/getListTypeStyleTest.ts new file mode 100644 index 00000000000..63c1d036247 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/utils/getListTypeStyleTest.ts @@ -0,0 +1,987 @@ +import { BulletListType, NumberingListType } from 'roosterjs-content-model-core'; +import { ContentModelDocument } from 'roosterjs-content-model-types'; +import { getListTypeStyle } from '../../../lib/autoFormat/utils/getListTypeStyle'; + +describe('getListTypeStyle', () => { + function runTest( + model: ContentModelDocument, + expectedResult: + | { + listType: 'UL' | 'OL'; + styleType: number; + index?: number; + } + | undefined, + shouldSearchForBullet?: boolean, + shouldSearchForNumbering?: boolean + ) { + const listTypeStyle = getListTypeStyle( + model, + shouldSearchForBullet, + shouldSearchForNumbering + ); + expect(listTypeStyle).toEqual(expectedResult); + } + + it('should identify Decimal Parenthesis', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '1)', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, { + listType: 'OL', + styleType: NumberingListType.DecimalParenthesis, + index: undefined, + }); + }); + + it('should not identify Decimal Parenthesis', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '1)', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, undefined, true, false); + }); + + it('should identify Disc', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '*', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, { + listType: 'UL', + styleType: BulletListType.Disc, + }); + }); + + it('should not identify Disc', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '*', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, undefined, false); + }); + + it('should identify Decimal', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '1.', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, { + listType: 'OL', + styleType: NumberingListType.Decimal, + index: undefined, + }); + }); + + it('should identify DecimalDash', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '1-', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, { + listType: 'OL', + styleType: NumberingListType.DecimalDash, + index: undefined, + }); + }); + + it('should identify DecimalDoubleParenthesis', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '(1)', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, { + listType: 'OL', + styleType: NumberingListType.DecimalDoubleParenthesis, + index: undefined, + }); + }); + + it('should identify LowerAlpha', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'a.', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, { + listType: 'OL', + styleType: NumberingListType.LowerAlpha, + index: undefined, + }); + }); + + it('should identify LowerAlphaParenthesis', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'a)', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, { + listType: 'OL', + styleType: NumberingListType.LowerAlphaParenthesis, + index: undefined, + }); + }); + + it('should identify LowerAlphaDoubleParenthesis', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '(a)', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, { + listType: 'OL', + styleType: NumberingListType.LowerAlphaDoubleParenthesis, + index: undefined, + }); + }); + + it('should identify LowerAlphaDash', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'a-', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, { + listType: 'OL', + styleType: NumberingListType.LowerAlphaDash, + index: undefined, + }); + }); + + it('should identify UpperAlpha', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'A.', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, { + listType: 'OL', + styleType: NumberingListType.UpperAlpha, + index: undefined, + }); + }); + + it('should identify UpperAlphaParenthesis', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'A)', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, { + listType: 'OL', + styleType: NumberingListType.UpperAlphaParenthesis, + index: undefined, + }); + }); + + it('should identify UpperAlphaDoubleParenthesis', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '(A)', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, { + listType: 'OL', + styleType: NumberingListType.UpperAlphaDoubleParenthesis, + index: undefined, + }); + }); + + it('should identify UpperAlphaDash', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'A-', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, { + listType: 'OL', + styleType: NumberingListType.UpperAlphaDash, + index: undefined, + }); + }); + + it('should identify LowerRoman', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'i.', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, { + listType: 'OL', + styleType: NumberingListType.LowerRoman, + index: undefined, + }); + }); + + it('should identify LowerRomanParenthesis', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'i)', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, { + listType: 'OL', + styleType: NumberingListType.LowerRomanParenthesis, + index: undefined, + }); + }); + + it('should identify LowerRomanDoubleParenthesis', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '(i)', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, { + listType: 'OL', + styleType: NumberingListType.LowerRomanDoubleParenthesis, + index: undefined, + }); + }); + + it('should identify LowerRomanDash', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'i-', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, { + listType: 'OL', + styleType: NumberingListType.LowerRomanDash, + index: undefined, + }); + }); + + it('should identify UpperRoman', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'I.', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, { + listType: 'OL', + styleType: NumberingListType.UpperRoman, + index: undefined, + }); + }); + + it('should identify UpperRomanParenthesis', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'I)', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, { + listType: 'OL', + styleType: NumberingListType.UpperRomanParenthesis, + index: undefined, + }); + }); + + it('should identify UpperRomanDoubleParenthesis', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '(I)', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, { + listType: 'OL', + styleType: NumberingListType.UpperRomanDoubleParenthesis, + index: undefined, + }); + }); + + it('should identify UpperRomanDash', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'I-', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, { + listType: 'OL', + styleType: NumberingListType.UpperRomanDash, + index: undefined, + }); + }); + + it('should identify Dash', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '-', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, { + listType: 'UL', + styleType: BulletListType.Dash, + }); + }); + + it('should identify Square', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '--', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, { + listType: 'UL', + styleType: BulletListType.Square, + }); + }); + + it('should identify ShortArrow', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '>', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, { + listType: 'UL', + styleType: BulletListType.ShortArrow, + }); + }); + + it('should identify LongArrow', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '->', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, { + listType: 'UL', + styleType: BulletListType.LongArrow, + }); + }); + + it('should identify UnfilledArrow', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '=>', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, { + listType: 'UL', + styleType: BulletListType.UnfilledArrow, + }); + }); + + it('should identify Hyphen', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '—', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, { + listType: 'UL', + styleType: BulletListType.Hyphen, + }); + }); + + it('should identify DoubleLongArrow', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '-->', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, { + listType: 'UL', + styleType: BulletListType.DoubleLongArrow, + }); + }); + + it('should not identify invalid character', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '1:', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, undefined); + }); + + it('should not identify invalid character - 2', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'a', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, undefined); + }); + + it('should not identify invalid character - 3', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '>)', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, undefined); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/utils/getNumberingListStyleTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/utils/getNumberingListStyleTest.ts new file mode 100644 index 00000000000..5877b37a805 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/utils/getNumberingListStyleTest.ts @@ -0,0 +1,114 @@ +import { getNumberingListStyle } from '../../../lib/autoFormat/utils/getNumberingListStyle'; +import { NumberingListType } from 'roosterjs-content-model-core'; + +describe('getNumberingListStyle', () => { + function runTest( + listMarker: string, + expectedResult?: number, + previousListIndex?: number | undefined, + previousListStyle?: number | undefined + ) { + const index = getNumberingListStyle(listMarker, previousListIndex, previousListStyle); + expect(index).toBe(expectedResult); + } + + it('1. ', () => { + runTest('1.', NumberingListType.Decimal); + }); + + it('1- ', () => { + runTest('1- ', NumberingListType.DecimalDash); + }); + + it('1) ', () => { + runTest('1) ', NumberingListType.DecimalParenthesis); + }); + + it('(1) ', () => { + runTest('(1) ', NumberingListType.DecimalDoubleParenthesis); + }); + + it('A.', () => { + runTest('A. ', NumberingListType.UpperAlpha); + }); + + it('A- ', () => { + runTest('A- ', NumberingListType.UpperAlphaDash); + }); + + it('A) ', () => { + runTest('A) ', NumberingListType.UpperAlphaParenthesis); + }); + + it('(A) ', () => { + runTest('(A) ', NumberingListType.UpperAlphaDoubleParenthesis); + }); + + it('a. ', () => { + runTest('a. ', NumberingListType.LowerAlpha); + }); + + it('a- ', () => { + runTest('a- ', NumberingListType.LowerAlphaDash); + }); + + it('a) ', () => { + runTest('a) ', NumberingListType.LowerAlphaParenthesis); + }); + + it('(a) ', () => { + runTest('(a) ', NumberingListType.LowerAlphaDoubleParenthesis); + }); + + it('i. ', () => { + runTest('i. ', NumberingListType.LowerRoman); + }); + + it('i- ', () => { + runTest('i- ', NumberingListType.LowerRomanDash); + }); + + it('i) ', () => { + runTest('i) ', NumberingListType.LowerRomanParenthesis); + }); + + it('(i) ', () => { + runTest('(i) ', NumberingListType.LowerRomanDoubleParenthesis); + }); + + it('I. ', () => { + runTest('I. ', NumberingListType.UpperRoman); + }); + + it('I- ', () => { + runTest('I- ', NumberingListType.UpperRomanDash); + }); + + it('I) ', () => { + runTest('I) ', NumberingListType.UpperRomanParenthesis); + }); + + it('(I) ', () => { + runTest('(I) ', NumberingListType.UpperRomanDoubleParenthesis); + }); + + it('4) ', () => { + runTest('4) ', 3, NumberingListType.DecimalParenthesis); + }); + + it('1:1. ', () => { + runTest('1:1. ', undefined); + }); + + it('30%). ', () => { + runTest('30%). ', undefined); + }); + + it('4th. ', () => { + runTest('4th. ', undefined); + }); + + it('30%) ', () => { + runTest('30%) ', undefined); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/deleteSteps/deleteListTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/deleteSteps/deleteListTest.ts index 8d7cd3c356e..820c410160b 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/edit/deleteSteps/deleteListTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/deleteSteps/deleteListTest.ts @@ -363,8 +363,8 @@ describe('deleteList', () => { { listType: 'UL', format: { - marginBlockStart: '0px', - marginBlockEnd: '0px', + marginTop: '0px', + marginBottom: '0px', listStyleType: 'disc', }, dataset: {}, diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardDeleteTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardDeleteTest.ts index 4049f8ac232..06f1edd5e85 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardDeleteTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardDeleteTest.ts @@ -537,6 +537,7 @@ describe('keyboardDelete', () => { startContainer: document.createTextNode('test'), startOffset: 2, } as any) as Range, + isReverted: false, }; const editor = { formatContentModel: formatWithContentModelSpy, @@ -558,6 +559,7 @@ describe('keyboardDelete', () => { startContainer: document.createTextNode('test'), startOffset: 2, } as any) as Range, + isReverted: false, }; const editor = { formatContentModel: formatWithContentModelSpy, @@ -579,6 +581,7 @@ describe('keyboardDelete', () => { startContainer: document.createTextNode('test'), startOffset: 0, } as any) as Range, + isReverted: false, }; const editor = { @@ -602,6 +605,7 @@ describe('keyboardDelete', () => { startContainer: document.createTextNode('test'), startOffset: 4, } as any) as Range, + isReverted: false, }; const editor = { 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 3550d9d49c3..9bb91be927e 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 @@ -1,7 +1,5 @@ import * as processPastedContentFromExcel from '../../../lib/paste/Excel/processPastedContentFromExcel'; import { expectEqual, initEditor } from './testUtils'; -import { itChromeOnly } from 'roosterjs-editor-dom/test/DomTestHelper'; -import { tableProcessor } from 'roosterjs-content-model-dom'; import type { ClipboardData, IStandaloneEditor } from 'roosterjs-content-model-types'; const ID = 'CM_Paste_From_ExcelOnline_E2E'; @@ -34,16 +32,12 @@ describe(ID, () => { spyOn(processPastedContentFromExcel, 'processPastedContentFromExcel').and.callThrough(); editor.pasteFromClipboard(clipboardData); - editor.createContentModel({ - processorOverride: { - table: tableProcessor, - }, - }); + editor.getContentModelCopy('connected'); expect(processPastedContentFromExcel.processPastedContentFromExcel).toHaveBeenCalled(); }); - itChromeOnly('E2E Table with table cells with text color', () => { + it('E2E Table with table cells with text color', () => { const CD = ({ types: ['text/plain', 'text/html'], text: @@ -59,11 +53,7 @@ describe(ID, () => { editor.pasteFromClipboard(CD); - const model = editor.createContentModel({ - processorOverride: { - table: tableProcessor, - }, - }); + const model = editor.getContentModelCopy('connected'); expectEqual(model, { blockGroupType: 'Document', 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 be1a41baf9d..31c4ca31710 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 @@ -1,7 +1,6 @@ import * as processPastedContentFromExcel from '../../../lib/paste/Excel/processPastedContentFromExcel'; import { expectEqual, initEditor } from './testUtils'; -import { itChromeOnly } from 'roosterjs-editor-dom/test/DomTestHelper'; -import { tableProcessor } from 'roosterjs-content-model-dom'; +import { itChromeOnly } from 'roosterjs-content-model-dom/test/testUtils'; import type { ClipboardData, IStandaloneEditor } from 'roosterjs-content-model-types'; const ID = 'CM_Paste_From_Excel_E2E'; @@ -30,25 +29,21 @@ describe(ID, () => { document.getElementById(ID)?.remove(); }); - itChromeOnly('E2E', () => { + it('E2E', () => { spyOn(processPastedContentFromExcel, 'processPastedContentFromExcel').and.callThrough(); editor.pasteFromClipboard(clipboardData); - editor.createContentModel({}); + editor.getContentModelCopy('connected'); expect(processPastedContentFromExcel.processPastedContentFromExcel).toHaveBeenCalled(); }); - itChromeOnly('E2E paste as image', () => { + it('E2E paste as image', () => { spyOn(processPastedContentFromExcel, 'processPastedContentFromExcel').and.callThrough(); editor.pasteFromClipboard(clipboardData, 'asImage'); - const model = editor.createContentModel({ - processorOverride: { - table: tableProcessor, - }, - }); + const model = editor.getContentModelCopy('connected'); expect(model).toEqual({ blockGroupType: 'Document', @@ -107,11 +102,7 @@ describe(ID, () => { editor.pasteFromClipboard(CD); - const model = editor.createContentModel({ - processorOverride: { - table: tableProcessor, - }, - }); + const model = editor.getContentModelCopy('connected'); expectEqual(model, { blockGroupType: 'Document', 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 7b0e240a179..19986e9eb1a 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 @@ -1,7 +1,6 @@ import * as processPastedContentWacComponents from '../../../lib/paste/WacComponents/processPastedContentWacComponents'; -import { ClipboardData, DomToModelOption, IStandaloneEditor } from 'roosterjs-content-model-types'; +import { ClipboardData, IStandaloneEditor } from 'roosterjs-content-model-types'; import { expectEqual, initEditor } from './testUtils'; -import { tableProcessor } from 'roosterjs-content-model-dom'; const ID = 'CM_Paste_From_WORD_Online_E2E'; @@ -38,11 +37,7 @@ describe(ID, () => { ).and.callThrough(); editor.pasteFromClipboard(clipboardData); - editor.createContentModel({ - processorOverride: { - table: tableProcessor, - }, - }); + editor.getContentModelCopy('connected'); expect( processPastedContentWacComponents.processPastedContentWacComponents @@ -55,11 +50,7 @@ describe(ID, () => { editor.pasteFromClipboard(clipboardData); - const model = editor.createContentModel({ - processorOverride: { - table: tableProcessor, - }, - }); + const model = editor.getContentModelCopy('connected'); expectEqual(model, { blockGroupType: 'Document', 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 a4aa5f48082..00865771548 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,9 +1,8 @@ import * as wordFile from '../../../lib/paste/WordDesktop/processPastedContentFromWordDesktop'; -import { ClipboardData, DomToModelOption, IStandaloneEditor } from 'roosterjs-content-model-types'; +import { ClipboardData, IStandaloneEditor } from 'roosterjs-content-model-types'; import { cloneModel } from 'roosterjs-content-model-core'; import { expectEqual, initEditor } from './testUtils'; -import { itChromeOnly } from 'roosterjs-editor-dom/test/DomTestHelper'; -import { tableProcessor } from 'roosterjs-content-model-dom'; +import { itChromeOnly } from 'roosterjs-content-model-dom/test/testUtils'; const ID = 'CM_Paste_From_WORD_E2E'; const clipboardData = ({ @@ -36,16 +35,9 @@ describe(ID, () => { '\r\n\r\n\r\n\r\n\r\n\r\n\r\n\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'; editor.pasteFromClipboard(clipboardData); - const model = cloneModel( - editor.createContentModel({ - processorOverride: { - table: tableProcessor, - }, - }), - { - includeCachedElement: false, - } - ); + const model = cloneModel(editor.getContentModelCopy('connected'), { + includeCachedElement: false, + }); expect(wordFile.processPastedContentFromWordDesktop).toHaveBeenCalled(); expect(model).toEqual({ @@ -116,11 +108,7 @@ describe(ID, () => { editor.pasteFromClipboard(clipboardData); - const model = editor.createContentModel({ - processorOverride: { - table: tableProcessor, - }, - }); + const model = editor.getContentModelCopy('connected'); expect(wordFile.processPastedContentFromWordDesktop).toHaveBeenCalled(); expectEqual(model, { 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 66e3f228515..6fb885a9889 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 @@ -1,8 +1,7 @@ import * as wordFile from '../../../lib/paste/WordDesktop/processPastedContentFromWordDesktop'; -import { ClipboardData, DomToModelOption, IStandaloneEditor } from 'roosterjs-content-model-types'; +import { ClipboardData, IStandaloneEditor } from 'roosterjs-content-model-types'; import { expectEqual, initEditor } from './testUtils'; -import { itChromeOnly } from 'roosterjs-editor-dom/test/DomTestHelper'; -import { tableProcessor } from 'roosterjs-content-model-dom'; +import { itChromeOnly } from 'roosterjs-content-model-dom/test/testUtils'; const ID = 'CM_Paste_E2E'; @@ -36,11 +35,7 @@ describe(ID, () => { editor.pasteFromClipboard(clipboardData); - const model = editor.createContentModel({ - processorOverride: { - table: tableProcessor, - }, - }); + const model = editor.getContentModelCopy('connected'); expectEqual(model, { blockGroupType: 'Document', diff --git a/packages-content-model/roosterjs-content-model-plugins/test/paste/pasteSourceValidations/getPasteSourceTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/pasteSourceValidations/getPasteSourceTest.ts index e00e653cdaf..136f4f36eb3 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/paste/pasteSourceValidations/getPasteSourceTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/pasteSourceValidations/getPasteSourceTest.ts @@ -1,12 +1,16 @@ import { BeforePasteEvent, ClipboardData } from 'roosterjs-content-model-types'; import { getPasteSource } from '../../../lib/paste/pasteSourceValidations/getPasteSource'; import { PastePropertyNames } from '../../../lib/paste/pasteSourceValidations/constants'; -import { - EXCEL_ATTRIBUTE_VALUE, - getWacElement, - POWERPOINT_ATTRIBUTE_VALUE, - WORD_ATTRIBUTE_VALUE, -} from 'roosterjs-editor-plugins/test/paste/pasteTestUtils'; + +const EXCEL_ATTRIBUTE_VALUE = 'urn:schemas-microsoft-com:office:excel'; +const POWERPOINT_ATTRIBUTE_VALUE = 'PowerPoint.Slide'; +const WORD_ATTRIBUTE_VALUE = 'urn:schemas-microsoft-com:office:word'; + +const getWacElement = (): HTMLElement => { + const element = document.createElement('span'); + element.classList.add('WACImageContainer'); + return element; +}; describe('getPasteSourceTest | ', () => { it('Is Word', () => { diff --git a/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromExcelTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromExcelTest.ts index ecd95a2316d..fbe214f5316 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromExcelTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromExcelTest.ts @@ -1,5 +1,4 @@ import * as PastePluginFile from '../../lib/paste/Excel/processPastedContentFromExcel'; -import { Browser } from 'roosterjs-editor-dom'; import { ContentModelDocument } from 'roosterjs-content-model-types'; import { createBeforePasteEventMock } from './processPastedContentFromWordDesktopTest'; import { processPastedContentFromExcel } from '../../lib/paste/Excel/processPastedContentFromExcel'; @@ -46,7 +45,7 @@ describe('processPastedContentFromExcelTest', () => { ); //Assert - if (expected && Browser.isChrome) { + if (expected) { expect(div.innerHTML.replace(' ', '')).toBe(expected.replace(' ', '')); } @@ -357,9 +356,7 @@ describe('Do not run scenarios', () => { } moveChildNodes(div, fragment1); - if (Browser.isChrome) { - expect(div.innerHTML).toEqual(result); - } + expect(div.innerHTML).toEqual(result); } it('excel is modified', () => { 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 3fe1f3189cf..e6832379ba0 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 @@ -1,8 +1,7 @@ -import { Browser } from 'roosterjs-editor-dom'; import { ContentModelDocument } from 'roosterjs-content-model-types'; import { createBeforePasteEventMock } from './processPastedContentFromWordDesktopTest'; import { expectEqual } from './e2e/testUtils'; -import { itChromeOnly } from 'roosterjs-editor-dom/test/DomTestHelper'; +import { itChromeOnly } from 'roosterjs-content-model-dom/test/testUtils'; import { pasteDisplayFormatParser } from 'roosterjs-content-model-core/lib/override/pasteDisplayFormatParser'; import { processPastedContentWacComponents } from '../../lib/paste/WacComponents/processPastedContentWacComponents'; import { @@ -1400,10 +1399,10 @@ describe('wordOnlineHandler', () => { marginRight: '0px', marginBottom: '0px', marginLeft: '0px', - borderTop: Browser.isFirefox ? 'medium none' : '', - borderRight: Browser.isFirefox ? 'medium none' : '', - borderBottom: Browser.isFirefox ? 'medium none' : '', - borderLeft: Browser.isFirefox ? 'medium none' : '', + borderTop: '', + borderRight: '', + borderBottom: '', + borderLeft: '', verticalAlign: 'top', }, 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 47fd3f62e40..e818e12b1ae 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,9 +1,8 @@ import * as getStyleMetadata from '../../lib/paste/WordDesktop/getStyleMetadata'; +import { BeforePasteEvent, ClipboardData } from 'roosterjs-content-model-types'; import { expectEqual } from './e2e/testUtils'; -import { PluginEventType } from 'roosterjs-editor-types'; import { processPastedContentFromWordDesktop } from '../../lib/paste/WordDesktop/processPastedContentFromWordDesktop'; import { WordMetadata } from '../../lib/paste/WordDesktop/WordMetadata'; -import { BeforePasteEvent, ClipboardData } from 'roosterjs-content-model-types'; import { createDomToModelContext, domToContentModel, @@ -5127,29 +5126,28 @@ describe('processPastedContentFromWordDesktopTest', () => { }); }); -export function createBeforePasteEventMock(fragment: DocumentFragment, htmlBefore: string = '') { - return ({ - eventType: PluginEventType.BeforePaste, +export function createBeforePasteEventMock( + fragment: DocumentFragment, + htmlBefore: string = '' +): BeforePasteEvent { + return { + eventType: 'beforePaste', clipboardData: {}, fragment: fragment, - sanitizingOption: { - elementCallbacks: {}, - attributeCallbacks: {}, - cssStyleCallbacks: {}, - additionalTagReplacements: {}, - additionalAllowedAttributes: [], - additionalAllowedCssClasses: [], - additionalDefaultStyleValues: {}, - additionalGlobalStyleNodes: [], - additionalPredefinedCssForElement: {}, - preserveHtmlComments: false, - unknownTagReplacement: null, - }, htmlBefore, htmlAfter: '', htmlAttributes: {}, - domToModelOption: { additionalAllowedTags: [], additionalDisallowedTags: [] }, - } as any) as BeforePasteEvent; + pasteType: 'normal', + domToModelOption: { + additionalAllowedTags: [], + additionalDisallowedTags: [], + additionalFormatParsers: {}, + attributeSanitizers: {}, + formatParserOverride: {}, + processorOverride: {}, + styleSanitizers: {}, + }, + }; } function createListElementFromWord( diff --git a/packages-content-model/roosterjs-content-model-types/lib/context/TextMutationObserver.ts b/packages-content-model/roosterjs-content-model-types/lib/context/TextMutationObserver.ts new file mode 100644 index 00000000000..96fc813ada0 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-types/lib/context/TextMutationObserver.ts @@ -0,0 +1,19 @@ +/** + * A wrapper of MutationObserver to observe text change from editor + */ +export interface TextMutationObserver { + /** + * Start observing mutations from editor + */ + startObserving(): void; + + /** + * Stop observing mutations from editor + */ + stopObserving(): void; + + /** + * Flush all pending mutations that have not be handled in order to ignore them + */ + flushMutations(): void; +} 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 bee62884242..6810a474c31 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,4 @@ +import type { DOMHelper } from '../parameter/DOMHelper'; import type { PluginEventData, PluginEventFromType } from '../event/PluginEventData'; import type { PluginEventType } from '../event/PluginEventType'; import type { PasteType } from '../enum/PasteType'; @@ -8,10 +9,7 @@ import type { Snapshot } from '../parameter/Snapshot'; import type { ContentModelDocument } from '../group/ContentModelDocument'; import type { ContentModelSegmentFormat } from '../format/ContentModelSegmentFormat'; import type { DOMSelection } from '../selection/DOMSelection'; -import type { DomToModelOption } from '../context/DomToModelOption'; import type { EditorEnvironment } from '../parameter/EditorEnvironment'; -import type { ModelToDomOption } from '../context/ModelToDomOption'; -import type { OnNodeCreated } from '../context/ModelToDomSettings'; import type { ContentModelFormatter, FormatWithContentModelOptions, @@ -26,27 +24,17 @@ import type { TrustedHTMLHandler } from '../parameter/TrustedHTMLHandler'; export interface IStandaloneEditor { /** * Create Content Model from DOM tree in this editor - * @param rootNode Optional start node. If provided, Content Model will be created from this node (including itself), - * otherwise it will create Content Model for the whole content in editor. - * @param option The options to customize the behavior of DOM to Content Model conversion - * @param selectionOverride When specified, use this selection to override existing selection inside editor + * @param mode What kind of Content Model we want. Currently we support the following values: + * - connected: Returns a connect Content Model object. "Connected" means if there is any entity inside editor, the returned Content Model will + * contain the same wrapper element for entity. This option should only be used in some special cases. In most cases we should use "disconnected" + * to get a fully disconnected Content Model so that any change to the model will not impact editor content. + * - disconnected: Returns a disconnected clone of Content Model from editor which you can do any change on it and it won't impact the editor content. + * If there is any entity in editor, the returned object will contain cloned copy of entity wrapper element. + * If editor is in dark mode, the cloned entity will be converted back to light mode. + * - reduced: Returns a reduced Content Model that only contains the model of current selection. If there is already a up-to-date cached model, use it + * instead to improve performance. This is mostly used for retrieve current format state. */ - createContentModel( - option?: DomToModelOption, - selectionOverride?: DOMSelection - ): ContentModelDocument; - - /** - * 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; + getContentModelCopy(mode: 'connected' | 'disconnected' | 'reduced'): ContentModelDocument; /** * Get current running environment, such as if editor is running on Mac @@ -90,6 +78,11 @@ export interface IStandaloneEditor { */ isDisposed(): boolean; + /** + * Get a DOM Helper object to help access DOM tree in editor + */ + getDOMHelper(): DOMHelper; + /** * Get document which contains this editor * @returns The HTML document which contains this editor @@ -133,21 +126,6 @@ export interface IStandaloneEditor { */ 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 - * to let editor behave correctly especially for those mouse drag/drop behaviors - * @returns current zoom scale number - */ - getZoomScale(): number; - - /** - * Set 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 - */ - setZoomScale(scale: number): void; - /** * Add a single undo snapshot to undo stack */ @@ -191,12 +169,6 @@ export interface IStandaloneEditor { */ 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 @@ -213,6 +185,7 @@ export interface IStandaloneEditor { * 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 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 776a2fa11df..6cdbe0a3115 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,3 +1,4 @@ +import type { DOMHelper } from '../parameter/DOMHelper'; import type { PluginEvent } from '../event/PluginEvent'; import type { PluginState } from '../pluginState/PluginState'; import type { EditorPlugin } from './EditorPlugin'; @@ -25,8 +26,9 @@ import type { /** * Create a EditorContext object used by ContentModel API * @param core The StandaloneEditorCore object + * @param saveIndex True to allow saving index info into node using domIndexer, otherwise false */ -export type CreateEditorContext = (core: StandaloneEditorCore) => EditorContext; +export type CreateEditorContext = (core: StandaloneEditorCore, saveIndex: boolean) => EditorContext; /** * Create Content Model from DOM tree in this editor @@ -176,6 +178,7 @@ export interface StandaloneCoreApiMap { /** * Create a EditorContext object used by ContentModel API * @param core The StandaloneEditorCore object + * @param saveIndex True to allow saving index info into node using domIndexer, otherwise false */ createEditorContext: CreateEditorContext; @@ -338,20 +341,17 @@ export interface StandaloneEditorCore extends PluginState { */ readonly trustedHTMLHandler: TrustedHTMLHandler; + /** + * A helper class to provide DOM access APIs + */ + readonly domHelper: DOMHelper; + /** * 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; } /** diff --git a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCorePlugins.ts b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCorePlugins.ts index 007a3cb6da7..3ef59057fd2 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCorePlugins.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCorePlugins.ts @@ -1,3 +1,4 @@ +import type { ContextMenuPluginState } from '../pluginState/ContextMenuPluginState'; import type { PluginWithState } from './PluginWithState'; import type { CopyPastePluginState } from '../pluginState/CopyPastePluginState'; import type { UndoPluginState } from '../pluginState/UndoPluginState'; @@ -47,6 +48,11 @@ export interface StandaloneEditorCorePlugins { */ readonly undo: PluginWithState; + /** + * Undo plugin provides the ability get context menu items and trigger ContextMenu event + */ + readonly contextMenu: PluginWithState; + /** * Lifecycle plugin handles editor initialization and disposing */ 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 005c3e8a83f..d3a75d37053 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 @@ -109,12 +109,4 @@ export interface StandaloneEditorOptions { * @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/ZoomChangedEvent.ts b/packages-content-model/roosterjs-content-model-types/lib/event/ZoomChangedEvent.ts index 9cc66f7e171..4f5af65db26 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/event/ZoomChangedEvent.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/event/ZoomChangedEvent.ts @@ -6,11 +6,6 @@ import type { BasePluginEvent } from './BasePluginEvent'; * */ export interface ZoomChangedEvent extends BasePluginEvent<'zoomChanged'> { - /** - * Zoom scale value before this change - */ - oldZoomScale: number; - /** * Zoom scale value after this change */ diff --git a/packages-content-model/roosterjs-content-model-types/lib/format/formatParts/MarginFormat.ts b/packages-content-model/roosterjs-content-model-types/lib/format/formatParts/MarginFormat.ts index 36deed9dcbc..7377b7f3588 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/format/formatParts/MarginFormat.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/format/formatParts/MarginFormat.ts @@ -21,14 +21,4 @@ export type MarginFormat = { * Margin left value */ marginLeft?: string; - - /** - * Margin-block start value - */ - marginBlockStart?: string; - - /** - * Margin-block end value - */ - marginBlockEnd?: string; }; diff --git a/packages-content-model/roosterjs-content-model-types/lib/index.ts b/packages-content-model/roosterjs-content-model-types/lib/index.ts index e4b65bf7679..48047b40c35 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/index.ts @@ -184,6 +184,7 @@ export { export { DomToModelOption } from './context/DomToModelOption'; export { ModelToDomOption } from './context/ModelToDomOption'; export { ContentModelDomIndexer } from './context/ContentModelDomIndexer'; +export { TextMutationObserver } from './context/TextMutationObserver'; export { DefinitionType } from './metadata/DefinitionType'; export { @@ -245,6 +246,7 @@ export { GenericPluginState, PluginState, } from './pluginState/PluginState'; +export { ContextMenuPluginState } from './pluginState/ContextMenuPluginState'; export { EditorEnvironment } from './parameter/EditorEnvironment'; export { @@ -283,6 +285,7 @@ export { AnnounceData, KnownAnnounceStrings } from './parameter/AnnounceData'; export { TrustedHTMLHandler } from './parameter/TrustedHTMLHandler'; export { Rect } from './parameter/Rect'; export { ValueSanitizer } from './parameter/ValueSanitizer'; +export { DOMHelper } from './parameter/DOMHelper'; export { BasePluginEvent, BasePluginDomEvent } from './event/BasePluginEvent'; export { BeforeCutCopyEvent } from './event/BeforeCutCopyEvent'; diff --git a/packages-content-model/roosterjs-content-model-types/lib/parameter/ContentModelFormatState.ts b/packages-content-model/roosterjs-content-model-types/lib/parameter/ContentModelFormatState.ts index e3ae48f49ef..f1fddbd08b6 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/parameter/ContentModelFormatState.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/parameter/ContentModelFormatState.ts @@ -174,9 +174,4 @@ export interface ContentModelFormatState { * Whether editor is in dark mode */ isDarkMode?: boolean; - - /** - * Current zoom scale of editor - */ - zoomScale?: number; } diff --git a/packages-content-model/roosterjs-content-model-types/lib/parameter/DOMHelper.ts b/packages-content-model/roosterjs-content-model-types/lib/parameter/DOMHelper.ts new file mode 100644 index 00000000000..2e8e1e4e2a1 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-types/lib/parameter/DOMHelper.ts @@ -0,0 +1,33 @@ +/** + * A helper class to provide DOM access APIs + */ +export interface DOMHelper { + /** + * Check if the given DOM node is in editor + * @param node The node to check + */ + isNodeInEditor(node: Node): boolean; + + /** + * Query HTML elements in editor by tag name. + * Be careful of this function since it will also return element under entity. + * @param tag Tag name of the element to query + * @returns HTML Element array of the query result + */ + queryElements( + tag: TTag + ): HTMLElementTagNameMap[TTag][]; + + /** + * Query HTML elements in editor by a selector string + * Be careful of this function since it will also return element under entity. + * @param selector Selector string to query + * @returns HTML Element array of the query result + */ + queryElements(selector: string): HTMLElement[]; + + /** + * Calculate current zoom scale of editor + */ + calculateZoomScale(): number; +} diff --git a/packages-content-model/roosterjs-content-model-types/lib/parameter/Snapshot.ts b/packages-content-model/roosterjs-content-model-types/lib/parameter/Snapshot.ts index fc28c19f77c..0c4d3460060 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/parameter/Snapshot.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/parameter/Snapshot.ts @@ -25,6 +25,12 @@ export interface RangeSnapshotSelection extends SnapshotSelectionBase<'range'> { * End path of selection */ end: number[]; + + /** + * Whether the selection was from left to right (in document order) or + * right to left (reverse of document order) + */ + isReverted: boolean; } /** diff --git a/packages-content-model/roosterjs-content-model-types/lib/pluginState/ContentModelCachePluginState.ts b/packages-content-model/roosterjs-content-model-types/lib/pluginState/ContentModelCachePluginState.ts index b9fbc4befcb..82353224c75 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/pluginState/ContentModelCachePluginState.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/pluginState/ContentModelCachePluginState.ts @@ -1,3 +1,4 @@ +import type { TextMutationObserver } from '../context/TextMutationObserver'; import type { ContentModelDocument } from '../group/ContentModelDocument'; import type { ContentModelDomIndexer } from '../context/ContentModelDomIndexer'; import type { DOMSelection } from '../selection/DOMSelection'; @@ -20,4 +21,9 @@ export interface ContentModelCachePluginState { * @optional Indexer for content model, to help build backward relationship from DOM node to Content Model */ domIndexer?: ContentModelDomIndexer; + + /** + * @optional A wrapper of MutationObserver to help detect text changes in editor + */ + textMutationObserver?: TextMutationObserver; } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContextMenuPluginState.ts b/packages-content-model/roosterjs-content-model-types/lib/pluginState/ContextMenuPluginState.ts similarity index 75% rename from packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContextMenuPluginState.ts rename to packages-content-model/roosterjs-content-model-types/lib/pluginState/ContextMenuPluginState.ts index 7b2f58a8d66..33aab4d8753 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContextMenuPluginState.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/pluginState/ContextMenuPluginState.ts @@ -1,4 +1,4 @@ -import type { ContextMenuProvider } from 'roosterjs-editor-types'; +import type { ContextMenuProvider } from '../editor/ContextMenuProvider'; /** * The state object for DOMEventPlugin diff --git a/packages-content-model/roosterjs-content-model-types/lib/selection/DOMSelection.ts b/packages-content-model/roosterjs-content-model-types/lib/selection/DOMSelection.ts index 13f7939fb46..85c830efb37 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/selection/DOMSelection.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/selection/DOMSelection.ts @@ -28,6 +28,12 @@ export interface RangeSelection extends SelectionBase<'range'> { * The DOM Range of this selection */ range: Range; + + /** + * Whether the selection was from left to right (in document order) or + * right to left (reverse of document order) + */ + isReverted: boolean; } /** diff --git a/packages-content-model/roosterjs-content-model/lib/createContentModelEditor.ts b/packages-content-model/roosterjs-content-model/lib/createEditor.ts similarity index 57% rename from packages-content-model/roosterjs-content-model/lib/createContentModelEditor.ts rename to packages-content-model/roosterjs-content-model/lib/createEditor.ts index ffad56e762c..dc7c2e1b807 100644 --- a/packages-content-model/roosterjs-content-model/lib/createContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model/lib/createEditor.ts @@ -1,14 +1,11 @@ -import { ContentModelEditor } from 'roosterjs-content-model-editor'; -import { - ContentModelEditPlugin, - ContentModelPastePlugin, - EntityDelimiterPlugin, -} from 'roosterjs-content-model-plugins'; +import { ContentModelEditPlugin, ContentModelPastePlugin } from 'roosterjs-content-model-plugins'; +import { StandaloneEditor } from 'roosterjs-content-model-core'; import type { - ContentModelEditorOptions, - IContentModelEditor, -} from 'roosterjs-content-model-editor'; -import type { EditorPlugin } from 'roosterjs-content-model-types'; + ContentModelDocument, + EditorPlugin, + IStandaloneEditor, + StandaloneEditorOptions, +} from 'roosterjs-content-model-types'; /** * Create a Content Model Editor using the given options @@ -18,27 +15,25 @@ import type { EditorPlugin } from 'roosterjs-content-model-types'; * @param initialContent The initial content to show in editor. It can't be removed by undo, user need to manually remove it if needed. * @returns The ContentModelEditor instance */ -export function createContentModelEditor( +export function createEditor( contentDiv: HTMLDivElement, additionalPlugins?: EditorPlugin[], - initialContent?: string -): IContentModelEditor { - const legacyPlugins = [new EntityDelimiterPlugin()]; + initialModel?: ContentModelDocument +): IStandaloneEditor { const plugins = [ new ContentModelPastePlugin(), new ContentModelEditPlugin(), ...(additionalPlugins ?? []), ]; - const options: ContentModelEditorOptions = { - legacyPlugins: legacyPlugins, + const options: StandaloneEditorOptions = { plugins: plugins, - initialContent: initialContent, + initialModel, defaultSegmentFormat: { fontFamily: 'Calibri,Arial,Helvetica,sans-serif', fontSize: '11pt', textColor: '#000000', }, }; - return new ContentModelEditor(contentDiv, options); + return new StandaloneEditor(contentDiv, options); } diff --git a/packages-content-model/roosterjs-content-model/lib/index.ts b/packages-content-model/roosterjs-content-model/lib/index.ts index ef7a866c977..ddfbf48efca 100644 --- a/packages-content-model/roosterjs-content-model/lib/index.ts +++ b/packages-content-model/roosterjs-content-model/lib/index.ts @@ -1,7 +1,6 @@ -export { createContentModelEditor } from './createContentModelEditor'; +export { createEditor } from './createEditor'; export * from 'roosterjs-content-model-types'; export * from 'roosterjs-content-model-dom'; export * from 'roosterjs-content-model-core'; export * from 'roosterjs-content-model-api'; -export * from 'roosterjs-content-model-editor'; export * from 'roosterjs-content-model-plugins'; diff --git a/packages-content-model/roosterjs-content-model/package.json b/packages-content-model/roosterjs-content-model/package.json index f821aa513a7..49ae50dea08 100644 --- a/packages-content-model/roosterjs-content-model/package.json +++ b/packages-content-model/roosterjs-content-model/package.json @@ -7,7 +7,6 @@ "roosterjs-content-model-dom": "", "roosterjs-content-model-core": "", "roosterjs-content-model-api": "", - "roosterjs-content-model-editor": "", "roosterjs-content-model-plugins": "" }, "version": "0.0.0", diff --git a/packages-ui/roosterjs-react/lib/common/index.ts b/packages-ui/roosterjs-react/lib/common/index.ts index cb2e3997321..b1b9e505a13 100644 --- a/packages-ui/roosterjs-react/lib/common/index.ts +++ b/packages-ui/roosterjs-react/lib/common/index.ts @@ -8,3 +8,4 @@ export { default as UIUtilities } from './type/UIUtilities'; export { default as ReactEditorPlugin } from './type/ReactEditorPlugin'; export { default as createUIUtilities } from './utils/createUIUtilities'; export { default as getLocalizedString } from './utils/getLocalizedString'; +export { default as RibbonPluginOptions } from './type/RibbonPluginOptions'; diff --git a/packages-ui/roosterjs-react/lib/common/type/RibbonPluginOptions.ts b/packages-ui/roosterjs-react/lib/common/type/RibbonPluginOptions.ts new file mode 100644 index 00000000000..5c8578a5b1e --- /dev/null +++ b/packages-ui/roosterjs-react/lib/common/type/RibbonPluginOptions.ts @@ -0,0 +1,10 @@ +/** + * Interface to allow insert link on hot key press in ribbon plugin. + */ +export default interface RibbonPluginOptions { + /** + * Set the allowInsertLinkHotKey property to false when the user doesn't want to use this feature + * @default true + */ + allowInsertLinkHotKey?: Boolean; +} diff --git a/packages-ui/roosterjs-react/lib/ribbon/plugin/createRibbonPlugin.ts b/packages-ui/roosterjs-react/lib/ribbon/plugin/createRibbonPlugin.ts index b1835f59305..a372864b216 100644 --- a/packages-ui/roosterjs-react/lib/ribbon/plugin/createRibbonPlugin.ts +++ b/packages-ui/roosterjs-react/lib/ribbon/plugin/createRibbonPlugin.ts @@ -1,10 +1,14 @@ import { getFormatState } from 'roosterjs-editor-api'; -import { getObjectKeys } from 'roosterjs-editor-dom'; import { PluginEventType } from 'roosterjs-editor-types'; +import { insertLink } from '../component/buttons/insertLink'; + +import { getObjectKeys, isCtrlOrMetaPressed } from 'roosterjs-editor-dom'; + import type RibbonButton from '../type/RibbonButton'; import type RibbonPlugin from '../type/RibbonPlugin'; + import type { FormatState, IEditor, PluginEvent } from 'roosterjs-editor-types'; -import type { LocalizedStrings, UIUtilities } from '../../common/index'; +import type { LocalizedStrings, UIUtilities, RibbonPluginOptions } from '../../common/index'; /** * A plugin to connect format ribbon component and the editor @@ -15,12 +19,16 @@ class RibbonPluginImpl implements RibbonPlugin { private timer = 0; private formatState: FormatState | null = null; private uiUtilities: UIUtilities | null = null; + private options: RibbonPluginOptions | undefined; /** * Construct a new instance of RibbonPlugin object * @param delayUpdateTime The time to wait before refresh the button when user do some editing operation in editor + * @param options The options for ribbon plugin to allow insert link on hot key press. */ - constructor(private delayUpdateTime: number = 200) {} + constructor(private delayUpdateTime: number = 200, options?: RibbonPluginOptions) { + this.options = options; + } /** * Get a friendly name of this plugin @@ -57,6 +65,16 @@ class RibbonPluginImpl implements RibbonPlugin { break; case PluginEventType.KeyDown: + if ( + event.rawEvent.key == 'k' && + isCtrlOrMetaPressed(event.rawEvent) && + !event.rawEvent.altKey && + this.options?.allowInsertLinkHotKey + ) { + this.handleButtonClick(insertLink, 'insertLinkTitle', undefined); + event.rawEvent.preventDefault(); + } + break; case PluginEventType.MouseUp: this.delayUpdate(); break; @@ -92,6 +110,20 @@ class RibbonPluginImpl implements RibbonPlugin { button: RibbonButton, key: T, strings?: LocalizedStrings + ) { + this.handleButtonClick(button, key, strings); + } + + /** + * Common method to handle button clicks + * @param button The button that is clicked + * @param key Key of child menu item that is clicked if any + * @param strings The localized string map for this button + */ + private handleButtonClick( + button: RibbonButton, + key: T, + strings?: LocalizedStrings ) { if (this.editor && this.uiUtilities) { this.editor.stopShadowEdit(); @@ -173,7 +205,11 @@ class RibbonPluginImpl implements RibbonPlugin { /** * Create a new instance of RibbonPlugin object * @param delayUpdateTime The time to wait before refresh the button when user do some editing operation in editor + * @param options The options for ribbon plugin to allow insert link on hot key press. */ -export default function createRibbonPlugin(delayUpdateTime?: number): RibbonPlugin { - return new RibbonPluginImpl(delayUpdateTime); +export default function createRibbonPlugin( + delayUpdateTime?: number, + options?: RibbonPluginOptions +): RibbonPlugin { + return new RibbonPluginImpl(delayUpdateTime, options); } diff --git a/packages/roosterjs-editor-core/lib/coreApi/focus.ts b/packages/roosterjs-editor-core/lib/coreApi/focus.ts index 4490f5a24a0..5b86767a890 100644 --- a/packages/roosterjs-editor-core/lib/coreApi/focus.ts +++ b/packages/roosterjs-editor-core/lib/coreApi/focus.ts @@ -1,4 +1,4 @@ -import { createRange, getFirstLeafNode } from 'roosterjs-editor-dom'; +import { Browser, createRange, getFirstLeafNode } from 'roosterjs-editor-dom'; import { PositionType } from 'roosterjs-editor-types'; import type { EditorCore, Focus } from 'roosterjs-editor-types'; @@ -34,7 +34,9 @@ export const focus: Focus = (core: EditorCore) => { } // remember to clear cached selection range - core.domEvent.selectionRange = null; + if (!Browser.isSafari) { + core.domEvent.selectionRange = null; + } // This is more a fallback to ensure editor gets focus if it didn't manage to move focus to editor if (!core.api.hasFocus(core)) { diff --git a/packages/roosterjs-editor-core/lib/corePlugins/DOMEventPlugin.ts b/packages/roosterjs-editor-core/lib/corePlugins/DOMEventPlugin.ts index 206ab44193e..3a924751ec5 100644 --- a/packages/roosterjs-editor-core/lib/corePlugins/DOMEventPlugin.ts +++ b/packages/roosterjs-editor-core/lib/corePlugins/DOMEventPlugin.ts @@ -95,9 +95,7 @@ export default class DOMEventPlugin implements PluginWithState; @@ -121,13 +119,7 @@ export default class DOMEventPlugin implements PluginWithState { - if (event.which == Keys.TAB && !event.defaultPrevented) { - this.cacheSelection(); + if (!Browser.isSafari) { + this.state.selectionRange = null; } }; - private onMouseDownDocument = (event: MouseEvent) => { - if ( - this.editor && - !this.state.selectionRange && - !this.editor.contains(event.target as Node) - ) { - this.cacheSelection(); + private onSelectionChangeSafari = () => { + // Safari has problem to handle onBlur event. When blur, we cannot get the original selection from editor. + // So we always save a selection whenever editor has focus. Then after blur, we can still use this cached selection. + if (this.editor?.hasFocus() && !this.editor.isInShadowEdit()) { + this.state.selectionRange = this.editor.getSelectionRange(false /*tryGetFromCache*/); } }; @@ -196,6 +183,7 @@ export default class DOMEventPlugin implements PluginWithState { this.editor?.triggerPluginEvent(PluginEventType.Scroll, { rawEvent: e, diff --git a/packages/roosterjs-editor-dom/lib/list/VList.ts b/packages/roosterjs-editor-dom/lib/list/VList.ts index a8022ba1662..9ce4a0a16a7 100644 --- a/packages/roosterjs-editor-dom/lib/list/VList.ts +++ b/packages/roosterjs-editor-dom/lib/list/VList.ts @@ -332,8 +332,8 @@ export default class VList { */ removeMargins() { if (!this.rootList.style.marginTop && !this.rootList.style.marginBottom) { - this.rootList.style.marginBlockStart = '0px'; - this.rootList.style.marginBlockEnd = '0px'; + this.rootList.style.marginTop = '0px'; + this.rootList.style.marginBottom = '0px'; } } diff --git a/packages/roosterjs-editor-dom/lib/list/VListItem.ts b/packages/roosterjs-editor-dom/lib/list/VListItem.ts index cb165d0a060..dad80b6b83b 100644 --- a/packages/roosterjs-editor-dom/lib/list/VListItem.ts +++ b/packages/roosterjs-editor-dom/lib/list/VListItem.ts @@ -473,12 +473,9 @@ function createListElement( result = doc.createElement(listType == ListType.Ordered ? 'ol' : 'ul'); } - if ( - originalRoot?.style.marginBlockStart == '0px' && - originalRoot?.style.marginBlockEnd == '0px' - ) { - result.style.marginBlockStart = '0px'; - result.style.marginBlockEnd = '0px'; + if (originalRoot?.style.marginTop == '0px' && originalRoot?.style.marginBottom == '0px') { + result.style.marginTop = '0px'; + result.style.marginBottom = '0px'; } // Always maintain the metadata saved in the list diff --git a/packages/roosterjs-editor-dom/test/list/VListTest.ts b/packages/roosterjs-editor-dom/test/list/VListTest.ts index 0090990035e..7bc729d2ee0 100644 --- a/packages/roosterjs-editor-dom/test/list/VListTest.ts +++ b/packages/roosterjs-editor-dom/test/list/VListTest.ts @@ -1286,10 +1286,10 @@ describe('VList.split', () => { ); }); - it('split List 4 with margin-block', () => { + it('split List 4 with margin-top and bottom', () => { runTest( - `
              1. 1
                1. 1
                2. 2
                3. 3
              2. 3
              3. 4
              `, - '
              1. 1
                1. 1
                2. 2
                3. 3
              2. 3
              3. 4
              ', + `
              1. 1
                1. 1
                2. 2
                3. 3
              2. 3
              3. 4
              `, + '
              1. 1
                1. 1
                2. 2
                3. 3
              2. 3
              3. 4
              ', 9 ); }); @@ -1531,7 +1531,7 @@ describe('VList.removeMargins', () => { DomTestHelper.removeElement(testId); }); - function runTest(source: string, shouldNotRemoveMargin: boolean = false) { + function runTest(source: string, expectedMarginTop: string, expectedMarginBottom: string) { DomTestHelper.createElementFromContent(testId, source); const list = document.getElementById(ListRoot) as HTMLOListElement; @@ -1542,27 +1542,28 @@ describe('VList.removeMargins', () => { // Act vList.removeMargins(); - if (shouldNotRemoveMargin) { - expect(list.style.marginBlock).toEqual(''); - } else { - expect(list.style.marginBlock).toEqual('0px'); - } + + expect(list.style.marginTop).toBe(expectedMarginTop); + expect(list.style.marginBottom).toBe(expectedMarginBottom); DomTestHelper.removeElement(testId); } it('remove list margins OL list', () => { const list = `
                `; - runTest(list); + + runTest(list, '0px', '0px'); }); it('remove list margins UL list', () => { const list = `
                  `; - runTest(list); + + runTest(list, '0px', '0px'); }); it('do not remove list margins UL list', () => { const list = `
                  • test
                  `; - runTest(list, true /** shouldNotRemoveMargin */); + + runTest(list, '1px', ''); }); }); diff --git a/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/imageEditors/Rotator.ts b/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/imageEditors/Rotator.ts index c3970238edb..4fdbf7451c2 100644 --- a/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/imageEditors/Rotator.ts +++ b/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/imageEditors/Rotator.ts @@ -124,7 +124,7 @@ export function getRotateHTML({ className: ImageEditElementClass.RotateHandle, style: `position:absolute;background-color:${rotateHandleBackColor};border:solid 1px ${borderColor};border-radius:50%;width:${ROTATE_SIZE}px;height:${ROTATE_SIZE}px;left:-${ handleLeft + ROTATE_WIDTH - }px;cursor:move;top:${-ROTATE_SIZE}px;`, + }px;cursor:move;top:${-ROTATE_SIZE}px;line-height: 0px;`, children: [getRotateIconHTML(borderColor)], }, ], diff --git a/packages/roosterjs-editor-plugins/lib/plugins/TableCellSelection/TableCellSelection.ts b/packages/roosterjs-editor-plugins/lib/plugins/TableCellSelection/TableCellSelection.ts index c634efc8aa3..55def37f29b 100644 --- a/packages/roosterjs-editor-plugins/lib/plugins/TableCellSelection/TableCellSelection.ts +++ b/packages/roosterjs-editor-plugins/lib/plugins/TableCellSelection/TableCellSelection.ts @@ -4,9 +4,9 @@ import { handleKeyDownEvent } from './keyUtils/handleKeyDownEvent'; import { handleKeyUpEvent } from './keyUtils/handleKeyUpEvent'; import { handleMouseDownEvent } from './mouseUtils/handleMouseDownEvent'; import { handleScrollEvent } from './mouseUtils/handleScrollEvent'; -import { PluginEventType, SelectionRangeTypes } from 'roosterjs-editor-types'; +import { PluginEventType } from 'roosterjs-editor-types'; import type { TableCellSelectionState } from './TableCellSelectionState'; -import type { EditorPlugin, IEditor, PluginEvent, TableSelection } from 'roosterjs-editor-types'; +import type { EditorPlugin, IEditor, PluginEvent } from 'roosterjs-editor-types'; /** * TableCellSelectionPlugin help highlight table cells @@ -14,7 +14,6 @@ import type { EditorPlugin, IEditor, PluginEvent, TableSelection } from 'rooster export default class TableCellSelection implements EditorPlugin { private editor: IEditor | null = null; private state: TableCellSelectionState | null; - private shadowEditCoordinatesBackup: TableSelection | null = null; constructor() { this.state = { @@ -62,12 +61,6 @@ export default class TableCellSelection implements EditorPlugin { onPluginEvent(event: PluginEvent) { if (this.editor && this.state) { switch (event.eventType) { - case PluginEventType.EnteredShadowEdit: - this.handleEnteredShadowEdit(this.state, this.editor); - break; - case PluginEventType.LeavingShadowEdit: - this.handleLeavingShadowEdit(this.state, this.editor); - break; case PluginEventType.MouseDown: if (!this.state.startedSelection) { handleMouseDownEvent(event, this.state, this.editor); @@ -100,25 +93,4 @@ export default class TableCellSelection implements EditorPlugin { } } } - - private handleLeavingShadowEdit(state: TableCellSelectionState, editor: IEditor) { - if (state.firstTable && state.tableSelection && state.firstTable) { - const table = editor.queryElements('#' + state.firstTable.id); - if (table.length == 1) { - state.firstTable = table[0] as HTMLTableElement; - editor.select(state.firstTable, this.shadowEditCoordinatesBackup); - this.shadowEditCoordinatesBackup = null; - } - } - } - - private handleEnteredShadowEdit(state: TableCellSelectionState, editor: IEditor) { - const selection = editor.getSelectionRangeEx(); - if (selection.type == SelectionRangeTypes.TableSelection) { - this.shadowEditCoordinatesBackup = selection.coordinates ?? null; - state.firstTable = selection.table; - state.tableSelection = true; - editor.select(selection.table, null); - } - } } diff --git a/tools/buildTools/buildDemo.js b/tools/buildTools/buildDemo.js index 3d8ea2de4de..445fb90b28f 100644 --- a/tools/buildTools/buildDemo.js +++ b/tools/buildTools/buildDemo.js @@ -77,7 +77,7 @@ async function buildDemoSite() { [/^roosterjs-editor-plugins\/.*$/, 'roosterjs'], [/^roosterjs-react\/.*$/, 'roosterjsReact'], [/^roosterjs-react$/, 'roosterjsReact'], - [/^roosterjs-content-model.*$/, 'roosterjsContentModel'], + [/^roosterjs-content-model((?!-editor).)*\/.*$/, 'roosterjsContentModel'], ], [] ), diff --git a/tools/buildTools/pack.js b/tools/buildTools/pack.js index f42f4dc01ba..8715cd34654 100644 --- a/tools/buildTools/pack.js +++ b/tools/buildTools/pack.js @@ -53,24 +53,36 @@ async function pack(isProduction, isAmd, target, filename) { await runWebPack(webpackConfig); } -function createStep(isProduction, isAmd, target) { +function createStep(isProduction, isAmd, target, enableForDemoSite) { const fileName = `${buildConfig[target].jsFileBaseName}${isAmd ? '-amd' : ''}${ isProduction ? '-min' : '' }.js`; return { message: `Packing ${fileName}...`, callback: async () => pack(isProduction, isAmd, target, fileName), - enabled: options => (isProduction ? options.packprod : options.pack), + enabled: options => + (enableForDemoSite && options.builddemo) || + (isProduction ? options.packprod : options.pack), }; } module.exports = { commonJsDebug: createStep(false /*isProduction*/, false /*isAmd*/, 'packages'), - commonJsProduction: createStep(true /*isProduction*/, false /*isAmd*/, 'packages'), + commonJsProduction: createStep( + true /*isProduction*/, + false /*isAmd*/, + 'packages', + true /*enableForDemoSite*/ + ), amdDebug: createStep(false /*isProduction*/, true /*isAmd*/, 'packages'), amdProduction: createStep(true /*isProduction*/, true /*isAmd*/, 'packages'), commonJsDebugUi: createStep(false /*isProduction*/, false /*isAmd*/, 'packages-ui'), - commonJsProductionUi: createStep(true /*isProduction*/, false /*isAmd*/, 'packages-ui'), + commonJsProductionUi: createStep( + true /*isProduction*/, + false /*isAmd*/, + 'packages-ui', + true /*enableForDemoSite*/ + ), amdDebugUi: createStep(false /*isProduction*/, true /*isAmd*/, 'packages-ui'), amdProductionUi: createStep(true /*isProduction*/, true /*isAmd*/, 'packages-ui'), commonJsDebugContentModel: createStep( @@ -81,7 +93,8 @@ module.exports = { commonJsProductionContentModel: createStep( true /*isProduction*/, false /*isAmd*/, - 'packages-content-model' + 'packages-content-model', + true /*enableForDemoSite*/ ), amdDebugContentModel: createStep( false /*isProduction*/, diff --git a/tools/tsconfig.doc.json b/tools/tsconfig.doc.json index 63b74d2dfb3..6c968646613 100644 --- a/tools/tsconfig.doc.json +++ b/tools/tsconfig.doc.json @@ -33,6 +33,9 @@ "../packages/roosterjs/lib/index.ts", "../packages-content-model/roosterjs-content-model-types/lib/index.ts", "../packages-content-model/roosterjs-content-model-dom/lib/index.ts", + "../packages-content-model/roosterjs-content-model-core/lib/index.ts", + "../packages-content-model/roosterjs-content-model-api/lib/index.ts", + "../packages-content-model/roosterjs-content-model-plugins/lib/index.ts", "../packages-content-model/roosterjs-content-model-editor/lib/index.ts", "../packages-content-model/roosterjs-content-model/lib/index.ts" ], diff --git a/versions.json b/versions.json index 26a8696ed0a..25df4210707 100644 --- a/versions.json +++ b/versions.json @@ -1,10 +1,9 @@ -{ - "packages": "8.59.0", - "packages-ui": "8.54.0", - "packages-content-model": "0.24.0", - "overrides": { - "roosterjs-editor-core": "8.59.1", - "roosterjs-editor-plugins": "8.60.0", - "roosterjs-content-model-editor": "0.24.2" - } -} +{ + "packages": "8.59.0", + "packages-ui": "8.55.0", + "packages-content-model": "0.25.0", + "overrides": { + "roosterjs-editor-core": "8.59.1", + "roosterjs-editor-plugins": "8.60.1" + } +} \ No newline at end of file