From 10c1d9ddbb96fb9e0b05c0900ebd01677fcf38f2 Mon Sep 17 00:00:00 2001 From: Andres-CT98 <107568016+Andres-CT98@users.noreply.github.com> Date: Fri, 24 May 2024 16:02:16 -0600 Subject: [PATCH] Bump version 9.3.1 -> 9.4 (#2658) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * code suport * markdown * adjust * demo site * WIP * fix code pen * fix demo site * Handle Tab key on whole table selection or last cell on Edit Plugin (#2536) * Implement indent whole table * implement tab on last cell new row * add Table selection and single cell handling * export clearSelectedCells * undefined check, simplify * add tests * add test for list in table edge case * Fix seelection on void element (#2551) * port hyphen * Implement movement with Tab key inside Table (#2529) * implement tab movement, tests * remove unused variable * fix name, normalisation, add test * fix wrong parethesis * restore normalizePos * fix tests * add formatTextSegmentBeforeSelectionMarker * Improve backspace on list (#2555) * fix selection with ctrl+a * refactor code using formatTextSegmentBeforeSelectionMarkerTest * remove getLinkSegment * Set default format in demo site (#2559) * clean demo site * clean * Enable selecting image when the only element in the range is an Image (#2554) * init * Address comment * Reuse isReverted from Range Selection * Fix build * Fix build * Unselect image when Up or Down, or it remains selected * remove unneeded changes and improve name of tests * Update --------- Co-authored-by: Julia Roldi <87443959+juliaroldi@users.noreply.github.com> * fix markdown plugin * Port Hyperlink plugin (#2560) * Port Hyperlink plugin * improve --------- Co-authored-by: Bryan Valverde U * export formatTextSegmentBeforeSelectionMarker * fix build * Prevent ScrollTop to be lost when the focus is done to the editor (#2564) * init * init --------- Co-authored-by: Julia Roldi <87443959+juliaroldi@users.noreply.github.com> * Fix 265959: extra empty line generated when get plain text (#2566) Co-authored-by: Bryan Valverde U * Fix #2500 Hyperlink misses color (#2570) * Fix #2500 * fix test * mac shortcuts * fix test * restore selection (#2577) * fix: on webkit-based applications, when the selection is empty, focus will cause the window to scroll to the top (#2571) Co-authored-by: Julia Roldi <87443959+juliaroldi@users.noreply.github.com> Co-authored-by: Jiuqing Song * Port PickerPlugin (#2569) * Port PickerPlugin * fix buid * Improve * fix build * Improve * Improve * add test * Improve * adjust image * fix test * fixes * remove * Preserve reverted selection info in Content Model (#2580) * Preserve reverted selection info in Content Model * improve * fix empty text * trigger Events * Allow Shift+Delete to Cut (#2585) * Allow Shift+Delete to Cut * improve * change source * fix build * Fix #2584: Safari context menu event causes selection to be expanded (#2588) * Fix #2584: Safari context menu event causes selection to be expanded * fix test * Port AnnouncePlugin step 1: refactor list number code (#2589) * test * refactor * fix demo site * Select image after inserting it (#2593) * Focus image after insertion * Revert "Focus image after insertion" This reverts commit 887c9e57c689efae570ce3b49e97ae9a668f935b. * Use setSelection * Port AnnouncePlugin Step 2: Add announce core API (#2591) * Port AnnouncePlugin step 1: refactor list number code * Port AnnouncePlugin step 2 --------- Co-authored-by: Bryan Valverde U * WIP * custom replace * Fix table mover selector (#2596) * fix edge case tables and trigger contentchanged * revert event * trigger autolink * wip * Port AnnouncePlugin step 3: Add announce features for list and table (#2592) * Port AnnouncePlugin step 1: refactor list number code * Port AnnouncePlugin step 2 * Port AnnouncePlugin ste 3 * add test * Fix #2575 Entity delimiter cursor moving (#2581) * Preserve reverted selection info in Content Model * Entity delimiter cursor moving (#2575) * improve * Add test * add test * Allow customizability for table editors (#2603) * init * Simplify type callback * Also allow using Ctrl-Shift-Z on Windows (#2607) * Also allow using Ctrl-Shift-Z on Windows * Fix failing test * Fix broken test * Fix failing test * Keep and deprecate old ShortcutRedoMacOS * Remove test not needed anymore * Wip * add span * fix test * changes * remove code * fixes * fixes * Hide watermark when input with IME (#2611) * Updated watermark property access modifier to protected (#2614) * Move Content Model type files into contentModel folder (#2602) * Fix 262779 (#2600) * Add undo snapshot when mouse down if there is new content after last undo snapshot (#2604) * Fix #2601 allow customization when convert from content model to plain text (#2605) * Fix #2601 allow customization when convert from content model to plain text * Improve * fix test * fix build * Scroll caret into view when call formatContentModel (#2617) * Perf step 0: Do not allow getting connected model (#2615) * Perf: Do not allow getting connected model * fix build * fix build * debug firefox options (#2620) * Updated isModelEmptyFast to consider changed indentation as not empty string (#2625) * Updated isModelEmptyFast * Tests fix * Add parameter to Disable table edit features (#2624) * disableFeatures, renaming * Export Table Feature Ids * Modify and create tests * move Id to respective feature, add tests, cleanup * export feature names * TableEditFeatureName * Implement Table Movement (#2599) * isNodeEditor * domHelper test * implementation and test fix * fix undefined * isNodeEditor * implement live focus, restore initial selection on cancel * broswer drag and drop attempt * use getNodePositionFromEvent, add move cursor while dragging * restore class * fix import * restore DOMHelper * remove unneeded snapshots and checks, add copy feature * cleanup * fix merge * cursor change on copy, remove onEditorCreated * fix disposal issue * removed unused forEach, * export formatInsertPointWithContentModel * use formatInsertPointWithContentModel for table movement * reorder function * Add tests for table movement * optimisations and exports for testing * restore final table selection after move * added test * fix and add more tests * comments, remove copy feature, fix tests * fix test * fix test * implement disable movement * Do not apply Table selection if table is not editable (#2628) * Fix #2633 `scrollCaretIntoView` causes unexpected scroll (#2634) * Fix #2633 * add comment * Added tableCellSelectionBackgroundColor option (#2640) * Added tableCellSelectionBackgroundColor option * Fixed a test * Tests fix * Tests fix * Add dark color handling for table and image selection (#2647) * Added tableCellSelectionBackgroundColor option * Fixed a test * Tests fix * Tests fix * Add dark color check * Add more tests * Updated login and tests * Functionality update * Update checks * Added transparent options test * Code review fixes * Fix cursor jump issue when applying Gboard suggestions (#2638) * fix cursor issue * resolve comment --------- Co-authored-by: Jiuqing Song * Content Model Cache improvement - Step 1: Introduce Readonly types and mutate utility (#2629) * Readonly types (3rd try * Improve * fix build * Improve * improve * Improve * Add shallow mutable type * improve * Improve * improve * improve * add test * improve * improve * improve * test * test * link preview * remove auto format plugin code * Content Model Cache improvement - Step 2: Prepare utility functions (#2641) * Readonly types (3rd try * Improve * fix build * Improve * improve * Improve * Add shallow mutable type * improve * Improve * improve * improve * add test * Readonly types step 2 * add test * Improve * improve * improve * improve * Improve * fix test * improve * Content Model Cache improvement - Step 3: Let creators accept readonly types (#2642) * Readonly types (3rd try * Improve * fix build * Improve * improve * Improve * Add shallow mutable type * improve * Improve * improve * improve * add test * Readonly types step 3 * improve * improve * improve * Content Model Cache improvement - Step 4: Port "readonly" functions (#2643) * Readonly types (3rd try * Improve * fix build * Improve * improve * Improve * Add shallow mutable type * improve * Improve * improve * improve * add test * Readonly types step 2 * Readonly types step 3 * Readonly type step 4 * add test * Improve * improve * improve * improve * improve * Improve * improve * fix test * improve * Fix Table first column (#2652) * create Table Options button for demo * fix setFirstColumnFormat * fix dependency * restore background color check for first column * remove logs, move TableOptionsMenuItemStringKey, isHeader * remove import * Content Model Cache improvement - Step 5: Port roosterjs-content-model-dom package (#2648) * Readonly types (3rd try * Improve * fix build * Improve * improve * Improve * Add shallow mutable type * improve * Improve * improve * improve * add test * Readonly types step 2 * Readonly types step 3 * Readonly type step 4 * add test * Improve * improve * improve * Readonly types step 5: dom package * add change * improve * improve * Improve * improve * fix test * Improve * fix build * improve * Optimise content model table fetching for Table Edit Plugins (#2656) * optimise cmTable fetching * create getCMTableFromTable * Fix normalisation and First Column issues (#2657) * optimise cmTable fetching * First Column not apply to very first cell * fix normalisation * fix first cell color for First Column * fix and add test * fix test * fix tests * fix test and hasHeaderRow case * reorder rows * bump * bump --------- Co-authored-by: Júlia Roldi Co-authored-by: Julia Roldi <87443959+juliaroldi@users.noreply.github.com> Co-authored-by: Jiuqing Song Co-authored-by: Bryan Valverde U Co-authored-by: Rain-Zheng <67583056+Rain-Zheng@users.noreply.github.com> Co-authored-by: 庄黛淳华 Co-authored-by: florian-msft <87671048+florian-msft@users.noreply.github.com> Co-authored-by: vhuseinova-msft <98852890+vhuseinova-msft@users.noreply.github.com> --- .../demoButtons/setTableHeaderButton.ts | 13 - .../demoButtons/tableOptionsButton.ts | 53 ++++ .../insertEntity/InsertEntityPane.tsx | 4 +- .../contentModel/ContentModelPanePlugin.ts | 6 +- .../components/format/MetadataView.tsx | 10 +- demo/scripts/controlsV2/tabs/ribbonButtons.ts | 6 +- .../lib/modelApi/block/setModelIndentation.ts | 26 +- .../lib/modelApi/entity/insertEntityModel.ts | 21 +- .../list/findListItemsInSameThread.ts | 43 ++- .../lib/modelApi/list/getListAnnounceData.ts | 14 +- .../lib/modelApi/table/canMergeCells.ts | 16 +- .../lib/publicApi/image/changeImage.ts | 4 +- .../publicApi/table/applyTableBorderFormat.ts | 9 + .../lib/publicApi/table/setTableCellShade.ts | 3 - .../image/adjustImageSelectionTest.ts | 1 - .../publicApi/link/adjustLinkSelectionTest.ts | 2 - .../test/publicApi/link/removeLinkTest.ts | 1 - .../table/applyTableBorderFormatTest.ts | 2 +- .../lib/command/paste/mergePasteContent.ts | 3 +- .../setDOMSelection/setDOMSelection.ts | 18 +- .../corePlugin/copyPaste/deleteEmptyList.ts | 7 +- .../lib/corePlugin/entity/findAllEntities.ts | 4 +- .../corePlugin/selection/SelectionPlugin.ts | 41 ++- .../createContentModelTest.ts | 5 +- .../setDOMSelection/setDOMSelectionTest.ts | 147 ++++++++- .../selection/SelectionPluginTest.ts | 282 ++++++++++++++++-- .../test/editor/EditorTest.ts | 4 +- .../roosterjs-content-model-dom/lib/index.ts | 21 +- .../modelApi/block/setParagraphNotImplicit.ts | 7 +- .../lib/modelApi/common/addBlock.ts | 10 +- .../lib/modelApi/common/addDecorators.ts | 21 +- .../lib/modelApi/common/addSegment.ts | 25 +- .../lib/modelApi/common/ensureParagraph.ts | 25 +- .../lib/modelApi/common/isEmpty.ts | 32 +- .../lib/modelApi/common/mutate.ts | 107 +++++++ .../modelApi/common/normalizeContentModel.ts | 7 +- .../lib/modelApi/common/normalizeParagraph.ts | 28 +- .../lib/modelApi/common/normalizeSegment.ts | 74 +++-- .../lib/modelApi/common/unwrapBlock.ts | 14 +- .../lib/modelApi/creators/createBr.ts | 4 +- .../creators/createContentModelDocument.ts | 2 +- .../lib/modelApi/creators/createDivider.ts | 4 +- .../lib/modelApi/creators/createEmptyModel.ts | 4 +- .../lib/modelApi/creators/createEntity.ts | 2 +- .../creators/createFormatContainer.ts | 4 +- .../modelApi/creators/createGeneralSegment.ts | 4 +- .../lib/modelApi/creators/createImage.ts | 7 +- .../lib/modelApi/creators/createListItem.ts | 6 +- .../lib/modelApi/creators/createListLevel.ts | 6 +- .../lib/modelApi/creators/createParagraph.ts | 10 +- .../creators/createParagraphDecorator.ts | 4 +- .../creators/createSelectionMarker.ts | 4 +- .../lib/modelApi/creators/createTable.ts | 14 +- .../lib/modelApi/creators/createTableCell.ts | 8 +- .../lib/modelApi/creators/createTableRow.ts | 16 + .../lib/modelApi/creators/createText.ts | 12 +- .../lib/modelApi/editing/applyTableFormat.ts | 139 +++++---- .../lib/modelApi/editing/cloneModel.ts | 73 +++-- .../lib/modelApi/editing/deleteBlock.ts | 8 +- .../editing/deleteExpandedSelection.ts | 42 +-- .../lib/modelApi/editing/deleteSegment.ts | 33 +- .../lib/modelApi/editing/deleteSelection.ts | 11 +- .../getClosestAncestorBlockGroupIndex.ts | 3 +- .../modelApi/editing/getSegmentTextFormat.ts | 12 +- .../lib/modelApi/editing/mergeModel.ts | 43 +-- .../lib/modelApi/editing/normalizeTable.ts | 32 +- .../editing/retrieveModelFormatState.ts | 50 ++-- .../editing/setTableCellBackgroundColor.ts | 21 +- .../modelApi/metadata/updateImageMetadata.ts | 16 +- .../modelApi/metadata/updateListMetadata.ts | 17 +- .../lib/modelApi/metadata/updateMetadata.ts | 41 ++- .../metadata/updateTableCellMetadata.ts | 20 +- .../modelApi/metadata/updateTableMetadata.ts | 18 +- .../modelApi/selection/collectSelections.ts | 272 +++++++++++++++-- .../modelApi/selection/getSelectedCells.ts | 9 +- .../modelApi/selection/hasSelectionInBlock.ts | 4 +- .../selection/hasSelectionInBlockGroup.ts | 4 +- .../selection/hasSelectionInSegment.ts | 4 +- .../modelApi/selection/iterateSelections.ts | 43 ++- .../lib/modelApi/selection/setSelection.ts | 124 +++++--- .../modelApi/typeCheck/isBlockGroupOfType.ts | 25 ++ .../modelApi/typeCheck/isGeneralSegment.ts | 24 ++ .../block/setParagraphNotImplicitTest.ts | 16 + .../test/modelApi/common/addSegmentTest.ts | 30 +- .../modelApi/common/ensureParagraphTest.ts | 17 +- .../test/modelApi/common/mutateTest.ts | 235 +++++++++++++++ .../common/normalizeContentModelTest.ts | 17 +- .../modelApi/common/normalizeParagraphTest.ts | 11 +- .../modelApi/common/normalizeSegmentTest.ts | 42 ++- .../test/modelApi/common/unwrapBlockTest.ts | 16 +- .../test/modelApi/creators/creatorsTest.ts | 21 ++ .../modelApi/editing/applyTableFormatTest.ts | 27 +- .../modelApi/editing/normalizeTableTest.ts | 201 ++++++++++--- .../editing/retrieveModelFormatStateTest.ts | 44 +-- .../setTableCellBackgroundColorTest.ts | 7 +- .../metadata/updateImageMetadataTest.ts | 72 ++++- .../metadata/updateListMetadataTest.ts | 45 ++- .../modelApi/metadata/updateMetadataTest.ts | 95 +++++- .../metadata/updateTableCellMetadataTest.ts | 58 +++- .../metadata/updateTableMetadataTest.ts | 77 ++++- .../selection/collectSelectionsTest.ts | 11 +- .../selection/getSelectedSegmentsTest.ts | 36 ++- .../selection/iterateSelectionsTest.ts | 8 +- .../modelApi/selection/setSelectionTest.ts | 1 - .../lib/autoFormat/link/createLink.ts | 11 + .../lib/autoFormat/list/getListTypeStyle.ts | 16 +- .../lib/edit/EditPlugin.ts | 18 +- .../deleteSteps/deleteCollapsedSelection.ts | 36 +-- .../lib/edit/deleteSteps/deleteEmptyQuote.ts | 7 +- .../edit/deleteSteps/deleteWordSelection.ts | 4 +- .../lib/edit/inputSteps/handleEnterOnList.ts | 27 +- .../lib/edit/utils/getLeafSiblingBlock.ts | 41 ++- .../tableEdit/editors/features/CellResizer.ts | 22 +- .../tableEdit/editors/features/TableMover.ts | 20 +- .../editors/features/TableResizer.ts | 28 +- .../editors/utils/getTableFromContentModel.ts | 29 ++ .../lib/watermark/isModelEmptyFast.ts | 4 +- .../test/autoFormat/link/createLinkTest.ts | 2 +- .../test/tableEdit/tableInserterTest.ts | 4 +- .../contentModel/block/ContentModelBlock.ts | 56 +++- .../block/ContentModelBlockBase.ts | 55 +++- .../contentModel/block/ContentModelDivider.ts | 28 +- .../block/ContentModelParagraph.ts | 71 ++++- .../contentModel/block/ContentModelTable.ts | 58 +++- .../block/ContentModelTableRow.ts | 54 +++- .../blockGroup/ContentModelBlockGroup.ts | 50 +++- .../blockGroup/ContentModelBlockGroupBase.ts | 47 ++- .../blockGroup/ContentModelDocument.ts | 41 ++- .../blockGroup/ContentModelFormatContainer.ts | 52 +++- .../blockGroup/ContentModelGeneralBlock.ts | 58 +++- .../blockGroup/ContentModelListItem.ts | 64 +++- .../blockGroup/ContentModelTableCell.ts | 63 +++- .../lib/contentModel/common/MutableMark.ts | 73 +++++ .../lib/contentModel/common/MutableType.ts | 101 +++++++ .../lib/contentModel/common/Selectable.ts | 24 +- .../decorator/ContentModelCode.ts | 19 +- .../decorator/ContentModelDecorator.ts | 14 +- .../decorator/ContentModelLink.ts | 24 +- .../decorator/ContentModelListLevel.ts | 38 ++- .../ContentModelParagraphDecorator.ts | 33 +- .../format/ContentModelWithDataset.ts | 27 +- .../format/ContentModelWithFormat.ts | 10 + .../format/metadata/DatasetFormat.ts | 5 + .../format/metadata/TableMetadataFormat.ts | 1 + .../contentModel/segment/ContentModelBr.ts | 10 +- .../segment/ContentModelGeneralSegment.ts | 32 +- .../contentModel/segment/ContentModelImage.ts | 32 +- .../segment/ContentModelSegment.ts | 39 ++- .../segment/ContentModelSegmentBase.ts | 75 ++++- .../segment/ContentModelSelectionMarker.ts | 11 +- .../contentModel/segment/ContentModelText.ts | 21 +- .../lib/editor/EditorOptions.ts | 5 + .../lib/index.ts | 181 +++++++++-- .../lib/parameter/DeleteSelectionStep.ts | 8 +- .../lib/parameter/IterateSelectionsOption.ts | 35 ++- .../lib/parameter/OperationalBlocks.ts | 30 +- .../lib/parameter/TypeOfBlockGroup.ts | 16 +- .../lib/pluginState/SelectionPluginState.ts | 15 + .../lib/selection/InsertPoint.ts | 12 +- .../lib/selection/TableSelectionContext.ts | 30 +- versions.json | 13 +- 161 files changed, 4315 insertions(+), 987 deletions(-) delete mode 100644 demo/scripts/controlsV2/demoButtons/setTableHeaderButton.ts create mode 100644 demo/scripts/controlsV2/demoButtons/tableOptionsButton.ts create mode 100644 packages/roosterjs-content-model-dom/lib/modelApi/common/mutate.ts create mode 100644 packages/roosterjs-content-model-dom/lib/modelApi/creators/createTableRow.ts create mode 100644 packages/roosterjs-content-model-dom/test/modelApi/common/mutateTest.ts create mode 100644 packages/roosterjs-content-model-plugins/lib/tableEdit/editors/utils/getTableFromContentModel.ts create mode 100644 packages/roosterjs-content-model-types/lib/contentModel/common/MutableMark.ts create mode 100644 packages/roosterjs-content-model-types/lib/contentModel/common/MutableType.ts diff --git a/demo/scripts/controlsV2/demoButtons/setTableHeaderButton.ts b/demo/scripts/controlsV2/demoButtons/setTableHeaderButton.ts deleted file mode 100644 index c6ab1b058df..00000000000 --- a/demo/scripts/controlsV2/demoButtons/setTableHeaderButton.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { formatTable, getFormatState } from 'roosterjs-content-model-api'; -import type { RibbonButton } from '../roosterjsReact/ribbon'; - -export const setTableHeaderButton: RibbonButton<'ribbonButtonSetTableHeader'> = { - key: 'ribbonButtonSetTableHeader', - unlocalizedText: 'Toggle table header', - iconName: 'Header', - isDisabled: formatState => !formatState.isInTable, - onClick: editor => { - const format = getFormatState(editor); - formatTable(editor, { hasHeaderRow: !format.tableHasHeader }, true /*keepCellShade*/); - }, -}; diff --git a/demo/scripts/controlsV2/demoButtons/tableOptionsButton.ts b/demo/scripts/controlsV2/demoButtons/tableOptionsButton.ts new file mode 100644 index 00000000000..d5be61d9517 --- /dev/null +++ b/demo/scripts/controlsV2/demoButtons/tableOptionsButton.ts @@ -0,0 +1,53 @@ +import { formatTable, getFormatState } from 'roosterjs-content-model-api'; +import { TableMetadataFormat } from 'roosterjs-content-model-types'; +import type { RibbonButton } from '../roosterjsReact/ribbon'; + +const TableEditOperationMap: Partial> = { + menuNameTableSetHeaderRow: 'hasHeaderRow', + menuNameTableSetFirstColumn: 'hasFirstColumn', + menuNameTableSetBandedColumns: 'hasBandedColumns', + menuNameTableSetBandedRows: 'hasBandedRows', +}; + +/** + * Key of localized strings of Table Options menu items + */ +type TableOptionsMenuItemStringKey = + | 'menuNameTableSetHeaderRow' + | 'menuNameTableSetFirstColumn' + | 'menuNameTableSetBandedColumns' + | 'menuNameTableSetBandedRows'; + +export const tableOptionsButton: RibbonButton< + 'ribbonButtonTableOptions' | TableOptionsMenuItemStringKey +> = { + key: 'ribbonButtonTableOptions', + iconName: '', + unlocalizedText: 'Options', + isDisabled: formatState => !formatState.isInTable, + dropDownMenu: { + items: { + menuNameTableSetHeaderRow: 'Header Row', + menuNameTableSetFirstColumn: 'First Column', + menuNameTableSetBandedColumns: 'Banded Columns', + menuNameTableSetBandedRows: 'Banded Rows', + }, + }, + onClick: (editor, key) => { + if (key != 'ribbonButtonTableOptions') { + const format = getFormatState(editor); + const tableFormatProperty = TableEditOperationMap[key]; + formatTable( + editor, + { [tableFormatProperty]: !format.tableFormat[tableFormatProperty] }, + true /*keepCellShade*/ + ); + } + }, + commandBarProperties: { + iconOnly: false, + }, +}; diff --git a/demo/scripts/controlsV2/sidePane/apiPlayground/insertEntity/InsertEntityPane.tsx b/demo/scripts/controlsV2/sidePane/apiPlayground/insertEntity/InsertEntityPane.tsx index f03ddcd9e7f..e304a6b79aa 100644 --- a/demo/scripts/controlsV2/sidePane/apiPlayground/insertEntity/InsertEntityPane.tsx +++ b/demo/scripts/controlsV2/sidePane/apiPlayground/insertEntity/InsertEntityPane.tsx @@ -3,9 +3,9 @@ import { ApiPaneProps } from '../ApiPaneProps'; import { insertEntity } from 'roosterjs-content-model-api'; import { trustedHTMLHandler } from '../../../../utils/trustedHTMLHandler'; import { - ContentModelBlockGroup, ContentModelEntity, InsertEntityOptions, + ReadonlyContentModelBlockGroup, } from 'roosterjs-content-model-types'; const styles = require('./InsertEntityPane.scss'); @@ -155,7 +155,7 @@ export default class InsertEntityPane extends React.Component { switch (block.blockType) { case 'BlockGroup': diff --git a/demo/scripts/controlsV2/sidePane/contentModel/ContentModelPanePlugin.ts b/demo/scripts/controlsV2/sidePane/contentModel/ContentModelPanePlugin.ts index 4bb8825a43a..1f2651ae146 100644 --- a/demo/scripts/controlsV2/sidePane/contentModel/ContentModelPanePlugin.ts +++ b/demo/scripts/controlsV2/sidePane/contentModel/ContentModelPanePlugin.ts @@ -1,3 +1,4 @@ +import { cloneModel } from 'roosterjs-content-model-dom'; import { ContentModelPane, ContentModelPaneProps } from './ContentModelPane'; import { createRibbonPlugin, RibbonButton, RibbonPlugin } from '../../roosterjsReact/ribbon'; import { getRefreshButton } from './buttons/refreshButton'; @@ -70,8 +71,9 @@ export class ContentModelPanePlugin extends SidePanePluginImpl< this.getComponent(component => { this.editor.formatContentModel( model => { - component.setContentModel(model); - setCurrentContentModel(model); + const clonedModel = cloneModel(model); + component.setContentModel(clonedModel); + setCurrentContentModel(clonedModel); return false; }, diff --git a/demo/scripts/controlsV2/sidePane/contentModel/components/format/MetadataView.tsx b/demo/scripts/controlsV2/sidePane/contentModel/components/format/MetadataView.tsx index 40618e31f1c..493f20902f9 100644 --- a/demo/scripts/controlsV2/sidePane/contentModel/components/format/MetadataView.tsx +++ b/demo/scripts/controlsV2/sidePane/contentModel/components/format/MetadataView.tsx @@ -1,13 +1,19 @@ import * as React from 'react'; -import { ContentModelWithDataset } from 'roosterjs-content-model-types'; import { FormatRenderer } from './utils/FormatRenderer'; +import { + ContentModelWithDataset, + ShallowMutableContentModelWithDataset, +} from 'roosterjs-content-model-types'; const styles = require('./FormatView.scss'); export function MetadataView(props: { model: ContentModelWithDataset; renderers: FormatRenderer[]; - updater: (model: ContentModelWithDataset, callback: (format: T | null) => T | null) => void; + updater: ( + model: ShallowMutableContentModelWithDataset, + callback: (format: T | null) => T | null + ) => void; }) { const { model, renderers, updater } = props; const metadata = React.useRef(null); diff --git a/demo/scripts/controlsV2/tabs/ribbonButtons.ts b/demo/scripts/controlsV2/tabs/ribbonButtons.ts index 1a3a504156b..c2879edc086 100644 --- a/demo/scripts/controlsV2/tabs/ribbonButtons.ts +++ b/demo/scripts/controlsV2/tabs/ribbonButtons.ts @@ -37,7 +37,6 @@ import { setBulletedListStyleButton } from '../demoButtons/setBulletedListStyleB import { setHeadingLevelButton } from '../roosterjsReact/ribbon/buttons/setHeadingLevelButton'; import { setNumberedListStyleButton } from '../demoButtons/setNumberedListStyleButton'; import { setTableCellShadeButton } from '../demoButtons/setTableCellShadeButton'; -import { setTableHeaderButton } from '../demoButtons/setTableHeaderButton'; import { spaceAfterButton, spaceBeforeButton } from '../demoButtons/spaceBeforeAfterButtons'; import { spacingButton } from '../demoButtons/spacingButton'; import { strikethroughButton } from '../roosterjsReact/ribbon/buttons/strikethroughButton'; @@ -47,6 +46,7 @@ import { tableBorderApplyButton } from '../demoButtons/tableBorderApplyButton'; import { tableBorderColorButton } from '../demoButtons/tableBorderColorButton'; import { tableBorderStyleButton } from '../demoButtons/tableBorderStyleButton'; import { tableBorderWidthButton } from '../demoButtons/tableBorderWidthButton'; +import { tableOptionsButton } from '../demoButtons/tableOptionsButton'; import { tabNames } from './getTabs'; import { textColorButton } from '../roosterjsReact/ribbon/buttons/textColorButton'; import { underlineButton } from '../roosterjsReact/ribbon/buttons/underlineButton'; @@ -79,7 +79,7 @@ const tableButtons: RibbonButton[] = [ insertTableButton, formatTableButton, setTableCellShadeButton, - setTableHeaderButton, + tableOptionsButton, tableInsertButton, tableDeleteButton, tableBorderApplyButton, @@ -169,7 +169,7 @@ const allButtons: RibbonButton[] = [ listStartNumberButton, formatTableButton, setTableCellShadeButton, - setTableHeaderButton, + tableOptionsButton, tableInsertButton, tableDeleteButton, tableMergeButton, diff --git a/packages/roosterjs-content-model-api/lib/modelApi/block/setModelIndentation.ts b/packages/roosterjs-content-model-api/lib/modelApi/block/setModelIndentation.ts index 828635044a0..7abcf8e17f8 100644 --- a/packages/roosterjs-content-model-api/lib/modelApi/block/setModelIndentation.ts +++ b/packages/roosterjs-content-model-api/lib/modelApi/block/setModelIndentation.ts @@ -4,17 +4,19 @@ import { createListLevel, getOperationalBlocks, isBlockGroupOfType, + mutateBlock, parseValueWithUnit, updateListMetadata, } from 'roosterjs-content-model-dom'; import type { - ContentModelBlock, ContentModelBlockFormat, - ContentModelBlockGroup, - ContentModelDocument, ContentModelListItem, ContentModelListLevel, FormatContentModelContext, + ReadonlyContentModelBlock, + ReadonlyContentModelBlockGroup, + ReadonlyContentModelDocument, + ReadonlyContentModelListItem, } from 'roosterjs-content-model-types'; const IndentStepInPixel = 40; @@ -26,7 +28,7 @@ const IndentStepInPixel = 40; * Set indentation for selected list items or paragraphs */ export function setModelIndentation( - model: ContentModelDocument, + model: ReadonlyContentModelDocument, indentation: 'indent' | 'outdent', length: number = IndentStepInPixel, context?: FormatContentModelContext @@ -37,7 +39,7 @@ export function setModelIndentation( ['TableCell'] ); const isIndent = indentation == 'indent'; - const modifiedBlocks: ContentModelBlock[] = []; + const modifiedBlocks: ReadonlyContentModelBlock[] = []; paragraphOrListItem.forEach(({ block, parent, path }) => { if (isBlockGroupOfType(block, 'ListItem')) { @@ -89,12 +91,12 @@ export function setModelIndentation( } } } else if (block) { - let currentBlock: ContentModelBlock = block; - let currentParent: ContentModelBlockGroup = parent; + let currentBlock: ReadonlyContentModelBlock = block; + let currentParent: ReadonlyContentModelBlockGroup = parent; while (currentParent && modifiedBlocks.indexOf(currentBlock) < 0) { const index = path.indexOf(currentParent); - const { format } = currentBlock; + const { format } = mutateBlock(currentBlock); const newValue = calculateMarginValue(format, isIndent, length); if (newValue !== null) { @@ -124,7 +126,7 @@ export function setModelIndentation( return paragraphOrListItem.length > 0; } -function isSelected(listItem: ContentModelListItem) { +function isSelected(listItem: ReadonlyContentModelListItem) { return listItem.blocks.some(block => { if (block.blockType == 'Paragraph') { return block.segments.some(segment => segment.isSelected); @@ -137,9 +139,9 @@ function isSelected(listItem: ContentModelListItem) { * Otherwise, the margin of the first item will be changed, and the sub list will be created, creating a unintentional margin difference between the list items. */ function isMultilevelSelection( - model: ContentModelDocument, - listItem: ContentModelListItem, - parent: ContentModelBlockGroup + model: ReadonlyContentModelDocument, + listItem: ReadonlyContentModelListItem, + parent: ReadonlyContentModelBlockGroup ) { const listIndex = parent.blocks.indexOf(listItem); for (let i = listIndex - 1; i >= 0; i--) { diff --git a/packages/roosterjs-content-model-api/lib/modelApi/entity/insertEntityModel.ts b/packages/roosterjs-content-model-api/lib/modelApi/entity/insertEntityModel.ts index faa023c42a8..d7db2f0a7c2 100644 --- a/packages/roosterjs-content-model-api/lib/modelApi/entity/insertEntityModel.ts +++ b/packages/roosterjs-content-model-api/lib/modelApi/entity/insertEntityModel.ts @@ -6,16 +6,18 @@ import { deleteSelection, getClosestAncestorBlockGroupIndex, setSelection, + mutateBlock, } from 'roosterjs-content-model-dom'; import type { - ContentModelBlock, - ContentModelBlockGroup, ContentModelDocument, ContentModelEntity, - ContentModelParagraph, FormatContentModelContext, InsertEntityPosition, InsertPoint, + ReadonlyContentModelBlock, + ShallowMutableContentModelBlock, + ShallowMutableContentModelBlockGroup, + ShallowMutableContentModelParagraph, } from 'roosterjs-content-model-types'; /** @@ -30,7 +32,7 @@ export function insertEntityModel( context?: FormatContentModelContext, insertPointOverride?: InsertPoint ) { - let blockParent: ContentModelBlockGroup | undefined; + let blockParent: ShallowMutableContentModelBlockGroup | undefined; let blockIndex = -1; let insertPoint: InsertPoint | null; @@ -57,9 +59,10 @@ export function insertEntityModel( position == 'root' ? getClosestAncestorBlockGroupIndex(path, ['TableCell', 'Document']) : 0; - blockParent = path[pathIndex]; + blockParent = mutateBlock(path[pathIndex]); + const child = path[pathIndex - 1]; - const directChild: ContentModelBlock = + const directChild: ReadonlyContentModelBlock = child?.blockGroupType == 'FormatContainer' || child?.blockGroupType == 'General' || child?.blockGroupType == 'ListItem' @@ -71,8 +74,8 @@ export function insertEntityModel( } if (blockIndex >= 0 && blockParent) { - const blocksToInsert: ContentModelBlock[] = []; - let nextParagraph: ContentModelParagraph | undefined; + const blocksToInsert: ShallowMutableContentModelBlock[] = []; + let nextParagraph: ShallowMutableContentModelParagraph | undefined; if (isBlock) { const nextBlock = blockParent.blocks[blockIndex]; @@ -80,7 +83,7 @@ export function insertEntityModel( blocksToInsert.push(entityModel); if (nextBlock?.blockType == 'Paragraph') { - nextParagraph = nextBlock; + nextParagraph = mutateBlock(nextBlock); } else if (!nextBlock || nextBlock.blockType == 'Entity' || focusAfterEntity) { nextParagraph = createParagraph(false /*isImplicit*/, {}, model.format); nextParagraph.segments.push(createBr(model.format)); diff --git a/packages/roosterjs-content-model-api/lib/modelApi/list/findListItemsInSameThread.ts b/packages/roosterjs-content-model-api/lib/modelApi/list/findListItemsInSameThread.ts index 3ad7824f584..14b9f4ff99f 100644 --- a/packages/roosterjs-content-model-api/lib/modelApi/list/findListItemsInSameThread.ts +++ b/packages/roosterjs-content-model-api/lib/modelApi/list/findListItemsInSameThread.ts @@ -1,14 +1,34 @@ -import type { ContentModelBlockGroup, ContentModelListItem } from 'roosterjs-content-model-types'; +import type { + ContentModelBlockGroup, + ContentModelListItem, + ReadonlyContentModelBlockGroup, + ReadonlyContentModelListItem, +} from 'roosterjs-content-model-types'; /** + * Search for all list items in the same thread as the current list item * @param model The content model * @param currentItem The current list item - * Search for all list items in the same thread as the current list item */ export function findListItemsInSameThread( group: ContentModelBlockGroup, currentItem: ContentModelListItem -): ContentModelListItem[] { +): ContentModelListItem[]; + +/** + * Search for all list items in the same thread as the current list item (Readonly) + * @param model The content model + * @param currentItem The current list item + */ +export function findListItemsInSameThread( + group: ReadonlyContentModelBlockGroup, + currentItem: ReadonlyContentModelListItem +): ReadonlyContentModelListItem[]; + +export function findListItemsInSameThread( + group: ReadonlyContentModelBlockGroup, + currentItem: ReadonlyContentModelListItem +): ReadonlyContentModelListItem[] { const items: (ContentModelListItem | null)[] = []; findListItems(group, items); @@ -16,7 +36,10 @@ export function findListItemsInSameThread( return filterListItems(items, currentItem); } -function findListItems(group: ContentModelBlockGroup, result: (ContentModelListItem | null)[]) { +function findListItems( + group: ReadonlyContentModelBlockGroup, + result: (ReadonlyContentModelListItem | null)[] +) { group.blocks.forEach(block => { switch (block.blockType) { case 'BlockGroup': @@ -56,7 +79,7 @@ function findListItems(group: ContentModelBlockGroup, result: (ContentModelListI }); } -function pushNullIfNecessary(result: (ContentModelListItem | null)[]) { +function pushNullIfNecessary(result: (ReadonlyContentModelListItem | null)[]) { const last = result[result.length - 1]; if (!last || last !== null) { @@ -65,10 +88,10 @@ function pushNullIfNecessary(result: (ContentModelListItem | null)[]) { } function filterListItems( - items: (ContentModelListItem | null)[], - currentItem: ContentModelListItem + items: (ReadonlyContentModelListItem | null)[], + currentItem: ReadonlyContentModelListItem ) { - const result: ContentModelListItem[] = []; + const result: ReadonlyContentModelListItem[] = []; const currentIndex = items.indexOf(currentItem); const levelLength = currentItem.levels.length; const isOrderedList = currentItem.levels[levelLength - 1]?.listType == 'OL'; @@ -131,7 +154,7 @@ function filterListItems( } function areListTypesCompatible( - listItems: (ContentModelListItem | null)[], + listItems: (ReadonlyContentModelListItem | null)[], currentIndex: number, compareToIndex: number ): boolean { @@ -146,7 +169,7 @@ function areListTypesCompatible( ); } -function hasStartNumberOverride(item: ContentModelListItem, levelLength: number): boolean { +function hasStartNumberOverride(item: ReadonlyContentModelListItem, levelLength: number): boolean { return item.levels .slice(0, levelLength) .some(level => level.format.startNumberOverride !== undefined); diff --git a/packages/roosterjs-content-model-api/lib/modelApi/list/getListAnnounceData.ts b/packages/roosterjs-content-model-api/lib/modelApi/list/getListAnnounceData.ts index 20fc55e3ee1..16fa2e84df1 100644 --- a/packages/roosterjs-content-model-api/lib/modelApi/list/getListAnnounceData.ts +++ b/packages/roosterjs-content-model-api/lib/modelApi/list/getListAnnounceData.ts @@ -2,13 +2,14 @@ import { findListItemsInSameThread } from './findListItemsInSameThread'; import { getAutoListStyleType, getClosestAncestorBlockGroupIndex, + getListMetadata, getOrderedListNumberStr, - updateListMetadata, } from 'roosterjs-content-model-dom'; import type { AnnounceData, - ContentModelBlockGroup, ContentModelListItem, + ReadonlyContentModelBlockGroup, + ReadonlyContentModelListItem, } from 'roosterjs-content-model-types'; /** @@ -16,7 +17,7 @@ import type { * @param path Content model path that include the list item * @returns Announce data of current list item if any, or null */ -export function getListAnnounceData(path: ContentModelBlockGroup[]): AnnounceData | null { +export function getListAnnounceData(path: ReadonlyContentModelBlockGroup[]): AnnounceData | null { const index = getClosestAncestorBlockGroupIndex(path, ['ListItem'], ['TableCell']); if (index >= 0) { @@ -27,7 +28,7 @@ export function getListAnnounceData(path: ContentModelBlockGroup[]): AnnounceDat return null; } else if (level.listType == 'OL') { const listNumber = getListNumber(path, listItem); - const metadata = updateListMetadata(level); + const metadata = getListMetadata(level); const listStyle = getAutoListStyleType( 'OL', metadata ?? {}, @@ -51,7 +52,10 @@ export function getListAnnounceData(path: ContentModelBlockGroup[]): AnnounceDat } } -function getListNumber(path: ContentModelBlockGroup[], listItem: ContentModelListItem) { +function getListNumber( + path: ReadonlyContentModelBlockGroup[], + listItem: ReadonlyContentModelListItem +) { const items = findListItemsInSameThread(path[path.length - 1], listItem); let listNumber = 0; diff --git a/packages/roosterjs-content-model-api/lib/modelApi/table/canMergeCells.ts b/packages/roosterjs-content-model-api/lib/modelApi/table/canMergeCells.ts index 622ad9c8324..1c14ab605a7 100644 --- a/packages/roosterjs-content-model-api/lib/modelApi/table/canMergeCells.ts +++ b/packages/roosterjs-content-model-api/lib/modelApi/table/canMergeCells.ts @@ -1,10 +1,10 @@ -import type { ContentModelTableRow } from 'roosterjs-content-model-types'; +import type { ReadonlyContentModelTableRow } from 'roosterjs-content-model-types'; /** * @internal */ export function canMergeCells( - rows: ContentModelTableRow[], + rows: ReadonlyContentModelTableRow[], firstRow: number, firstCol: number, lastRow: number, @@ -40,7 +40,11 @@ export function canMergeCells( return noSpanAbove && noSpanLeft && noDifferentBelowSpan && noDifferentRightSpan; } -function getBelowSpanCount(rows: ContentModelTableRow[], rowIndex: number, colIndex: number) { +function getBelowSpanCount( + rows: ReadonlyContentModelTableRow[], + rowIndex: number, + colIndex: number +) { let spanCount = 0; for (let row = rowIndex + 1; row < rows.length; row++) { @@ -54,7 +58,11 @@ function getBelowSpanCount(rows: ContentModelTableRow[], rowIndex: number, colIn return spanCount; } -function getRightSpanCount(rows: ContentModelTableRow[], rowIndex: number, colIndex: number) { +function getRightSpanCount( + rows: ReadonlyContentModelTableRow[], + rowIndex: number, + colIndex: number +) { let spanCount = 0; for (let col = colIndex + 1; col < rows[rowIndex]?.cells.length; col++) { diff --git a/packages/roosterjs-content-model-api/lib/publicApi/image/changeImage.ts b/packages/roosterjs-content-model-api/lib/publicApi/image/changeImage.ts index 7067c9fae8e..a14a91db4a2 100644 --- a/packages/roosterjs-content-model-api/lib/publicApi/image/changeImage.ts +++ b/packages/roosterjs-content-model-api/lib/publicApi/image/changeImage.ts @@ -1,5 +1,5 @@ import { formatImageWithContentModel } from '../utils/formatImageWithContentModel'; -import { readFile, updateImageMetadata } from 'roosterjs-content-model-dom'; +import { getImageMetadata, readFile } from 'roosterjs-content-model-dom'; import type { ContentModelImage, IEditor } from 'roosterjs-content-model-types'; /** @@ -14,7 +14,7 @@ export function changeImage(editor: IEditor, file: File) { readFile(file, dataUrl => { if (dataUrl && !editor.isDisposed() && selection?.type === 'image') { formatImageWithContentModel(editor, 'changeImage', (image: ContentModelImage) => { - const originalSrc = updateImageMetadata(image)?.src ?? ''; + const originalSrc = getImageMetadata(image)?.src ?? ''; const previousSrc = image.src; image.src = dataUrl; diff --git a/packages/roosterjs-content-model-api/lib/publicApi/table/applyTableBorderFormat.ts b/packages/roosterjs-content-model-api/lib/publicApi/table/applyTableBorderFormat.ts index 83a98fd84e7..55262c39c5e 100644 --- a/packages/roosterjs-content-model-api/lib/publicApi/table/applyTableBorderFormat.ts +++ b/packages/roosterjs-content-model-api/lib/publicApi/table/applyTableBorderFormat.ts @@ -2,7 +2,10 @@ import { extractBorderValues, getFirstSelectedTable, getSelectedCells, + getTableMetadata, + hasMetadata, parseValueWithUnit, + setFirstColumnFormatBorders, updateTableCellMetadata, } from 'roosterjs-content-model-dom'; import type { @@ -366,6 +369,12 @@ export function applyTableBorderFormat( modifyPerimeter(tableModel, sel, borderFormat, perimeter, isRtl); } + const tableMeta = hasMetadata(tableModel) ? getTableMetadata(tableModel) : {}; + if (tableMeta) { + // Enforce first column format if necessary + setFirstColumnFormatBorders(tableModel.rows, tableMeta); + } + return true; } else { return false; diff --git a/packages/roosterjs-content-model-api/lib/publicApi/table/setTableCellShade.ts b/packages/roosterjs-content-model-api/lib/publicApi/table/setTableCellShade.ts index fc00545ece3..12f52e26381 100644 --- a/packages/roosterjs-content-model-api/lib/publicApi/table/setTableCellShade.ts +++ b/packages/roosterjs-content-model-api/lib/publicApi/table/setTableCellShade.ts @@ -1,7 +1,6 @@ import { hasSelectionInBlockGroup, getFirstSelectedTable, - normalizeTable, setTableCellBackgroundColor, } from 'roosterjs-content-model-dom'; import type { IEditor } from 'roosterjs-content-model-types'; @@ -19,8 +18,6 @@ export function setTableCellShade(editor: IEditor, color: string | null) { const [table] = getFirstSelectedTable(model); if (table) { - normalizeTable(table); - table.rows.forEach(row => row.cells.forEach(cell => { if (hasSelectionInBlockGroup(cell)) { diff --git a/packages/roosterjs-content-model-api/test/publicApi/image/adjustImageSelectionTest.ts b/packages/roosterjs-content-model-api/test/publicApi/image/adjustImageSelectionTest.ts index 5e60019d87a..a0e6b54b11f 100644 --- a/packages/roosterjs-content-model-api/test/publicApi/image/adjustImageSelectionTest.ts +++ b/packages/roosterjs-content-model-api/test/publicApi/image/adjustImageSelectionTest.ts @@ -189,7 +189,6 @@ describe('adjustImageSelection', () => { format: {}, src: 'img2', dataset: {}, - isSelectedAsImageSelection: false, }, { segmentType: 'Text', diff --git a/packages/roosterjs-content-model-api/test/publicApi/link/adjustLinkSelectionTest.ts b/packages/roosterjs-content-model-api/test/publicApi/link/adjustLinkSelectionTest.ts index fdac2d121dc..c53b8428ac0 100644 --- a/packages/roosterjs-content-model-api/test/publicApi/link/adjustLinkSelectionTest.ts +++ b/packages/roosterjs-content-model-api/test/publicApi/link/adjustLinkSelectionTest.ts @@ -154,7 +154,6 @@ describe('adjustLinkSelection', () => { link: link, dataset: {}, isSelected: true, - isSelectedAsImageSelection: false, }, { segmentType: 'Text', @@ -228,7 +227,6 @@ describe('adjustLinkSelection', () => { link: link, dataset: {}, isSelected: true, - isSelectedAsImageSelection: false, }, { segmentType: 'Text', diff --git a/packages/roosterjs-content-model-api/test/publicApi/link/removeLinkTest.ts b/packages/roosterjs-content-model-api/test/publicApi/link/removeLinkTest.ts index 0d0f1f081bf..ac563a97d05 100644 --- a/packages/roosterjs-content-model-api/test/publicApi/link/removeLinkTest.ts +++ b/packages/roosterjs-content-model-api/test/publicApi/link/removeLinkTest.ts @@ -150,7 +150,6 @@ describe('removeLink', () => { dataset: {}, format: {}, isSelected: true, - isSelectedAsImageSelection: false, }, ], }, diff --git a/packages/roosterjs-content-model-api/test/publicApi/table/applyTableBorderFormatTest.ts b/packages/roosterjs-content-model-api/test/publicApi/table/applyTableBorderFormatTest.ts index a2e23423c36..20b9aba5603 100644 --- a/packages/roosterjs-content-model-api/test/publicApi/table/applyTableBorderFormatTest.ts +++ b/packages/roosterjs-content-model-api/test/publicApi/table/applyTableBorderFormatTest.ts @@ -26,7 +26,7 @@ describe('applyTableBorderFormat', () => { format?: ContentModelTableCell['format'] ) { // Create a table with all cells selected except the first and last row and column - const table = createTable(rows); + const table: ContentModelTable = createTable(rows); for (let i = 0; i < rows; i++) { const row = table.rows[i]; for (let j = 0; j < columns; j++) { diff --git a/packages/roosterjs-content-model-core/lib/command/paste/mergePasteContent.ts b/packages/roosterjs-content-model-core/lib/command/paste/mergePasteContent.ts index 55f2374297a..af149946960 100644 --- a/packages/roosterjs-content-model-core/lib/command/paste/mergePasteContent.ts +++ b/packages/roosterjs-content-model-core/lib/command/paste/mergePasteContent.ts @@ -15,6 +15,7 @@ import type { ContentModelSegmentFormat, IEditor, MergeModelOption, + ReadonlyContentModelDocument, } from 'roosterjs-content-model-types'; const EmptySegmentFormat: Required = { @@ -38,7 +39,7 @@ const CloneOption: CloneModelOptions = { /** * @internal */ -export function cloneModelForPaste(model: ContentModelDocument) { +export function cloneModelForPaste(model: ReadonlyContentModelDocument) { return cloneModel(model, CloneOption); } diff --git a/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/setDOMSelection.ts b/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/setDOMSelection.ts index a89fee21134..44411024aa2 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/setDOMSelection.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/setDOMSelection.ts @@ -21,8 +21,6 @@ const HIDE_CURSOR_CSS_KEY = '_DOMSelectionHideCursor'; const HIDE_SELECTION_CSS_KEY = '_DOMSelectionHideSelection'; const IMAGE_ID = 'image'; const TABLE_ID = 'table'; -const DEFAULT_SELECTION_BORDER_COLOR = '#DB626C'; -const TABLE_CSS_RULE = 'background-color:#C6C6C6!important;'; const CARET_CSS_RULE = 'caret-color: transparent'; const TRANSPARENT_SELECTION_CSS_RULE = 'background-color: transparent !important;'; const SELECTION_SELECTOR = '*::selection'; @@ -36,7 +34,7 @@ export const setDOMSelection: SetDOMSelection = (core, selection, skipSelectionC const skipReselectOnFocus = core.selection.skipReselectOnFocus; const doc = core.physicalRoot.ownerDocument; - + const isDarkMode = core.lifecycle.isDarkMode; core.selection.skipReselectOnFocus = true; core.api.setEditorStyle(core, DOM_SELECTION_CSS_KEY, null /*cssRule*/); core.api.setEditorStyle(core, HIDE_CURSOR_CSS_KEY, null /*cssRule*/); @@ -51,12 +49,14 @@ export const setDOMSelection: SetDOMSelection = (core, selection, skipSelectionC type: 'image', image, }; + const imageSelectionColor = isDarkMode + ? core.selection.imageSelectionBorderColorDark + : core.selection.imageSelectionBorderColor; + core.api.setEditorStyle( core, DOM_SELECTION_CSS_KEY, - `outline-style:auto!important; outline-color:${ - core.selection.imageSelectionBorderColor || DEFAULT_SELECTION_BORDER_COLOR - }!important;`, + `outline-style:auto!important; outline-color:${imageSelectionColor}!important;`, [`span:has(>img#${ensureUniqueId(image, IMAGE_ID)})`] ); core.api.setEditorStyle( @@ -112,10 +112,14 @@ export const setDOMSelection: SetDOMSelection = (core, selection, skipSelectionC : handleTableSelected(parsedTable, tableId, table, firstCell, lastCell); core.selection.selection = selection; + + const tableSelectionColor = isDarkMode + ? core.selection.tableCellSelectionBackgroundColorDark + : core.selection.tableCellSelectionBackgroundColor; core.api.setEditorStyle( core, DOM_SELECTION_CSS_KEY, - TABLE_CSS_RULE, + `background-color:${tableSelectionColor}!important;`, tableSelectors ); core.api.setEditorStyle(core, HIDE_CURSOR_CSS_KEY, CARET_CSS_RULE); diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/copyPaste/deleteEmptyList.ts b/packages/roosterjs-content-model-core/lib/corePlugin/copyPaste/deleteEmptyList.ts index 0031bb195a0..7aae91ee484 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/copyPaste/deleteEmptyList.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/copyPaste/deleteEmptyList.ts @@ -2,14 +2,15 @@ import { getClosestAncestorBlockGroupIndex, hasSelectionInBlock, hasSelectionInBlockGroup, + mutateBlock, } from 'roosterjs-content-model-dom'; import type { - ContentModelBlock, DeleteSelectionContext, DeleteSelectionStep, + ReadonlyContentModelBlock, } from 'roosterjs-content-model-types'; -function isEmptyBlock(block: ContentModelBlock | undefined): boolean { +function isEmptyBlock(block: ReadonlyContentModelBlock | undefined): boolean { if (block && block.blockType == 'Paragraph') { return block.segments.every( segment => segment.segmentType !== 'SelectionMarker' && segment.segmentType == 'Br' @@ -53,7 +54,7 @@ export const deleteEmptyList: DeleteSelectionStep = (context: DeleteSelectionCon nextBlock && isEmptyBlock(nextBlock) ) { - item.levels = []; + mutateBlock(item).levels = []; } } } diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/entity/findAllEntities.ts b/packages/roosterjs-content-model-core/lib/corePlugin/entity/findAllEntities.ts index 0f0b466eb92..b615cf49d8a 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/entity/findAllEntities.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/entity/findAllEntities.ts @@ -1,9 +1,9 @@ -import type { ChangedEntity, ContentModelBlockGroup } from 'roosterjs-content-model-types'; +import type { ChangedEntity, ReadonlyContentModelBlockGroup } from 'roosterjs-content-model-types'; /** * @internal */ -export function findAllEntities(group: ContentModelBlockGroup, entities: ChangedEntity[]) { +export function findAllEntities(group: ReadonlyContentModelBlockGroup, entities: ChangedEntity[]) { group.blocks.forEach(block => { switch (block.blockType) { case 'BlockGroup': diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts b/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts index 9dfef270c5c..3c3c040c157 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts @@ -34,6 +34,15 @@ const Left = 'ArrowLeft'; const Right = 'ArrowRight'; const Tab = 'Tab'; +/** + * @internal + */ +export const DEFAULT_SELECTION_BORDER_COLOR = '#DB626C'; +/** + * @internal + */ +export const DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR = '#C6C6C6'; + class SelectionPlugin implements PluginWithState { private editor: IEditor | null = null; private state: SelectionPluginState; @@ -46,7 +55,17 @@ class SelectionPlugin implements PluginWithState { this.state = { selection: null, tableSelection: null, - imageSelectionBorderColor: options.imageSelectionBorderColor, + imageSelectionBorderColor: + options.imageSelectionBorderColor ?? DEFAULT_SELECTION_BORDER_COLOR, + imageSelectionBorderColorDark: options.imageSelectionBorderColor + ? undefined + : DEFAULT_SELECTION_BORDER_COLOR, + tableCellSelectionBackgroundColor: + options.tableCellSelectionBackgroundColor ?? + DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, + tableCellSelectionBackgroundColorDark: options.tableCellSelectionBackgroundColor + ? undefined + : DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, }; } @@ -57,6 +76,25 @@ class SelectionPlugin implements PluginWithState { initialize(editor: IEditor) { this.editor = editor; + if (!this.state.imageSelectionBorderColorDark && this.state.imageSelectionBorderColor) { + this.state.imageSelectionBorderColorDark = editor + .getColorManager() + .getDarkColor(this.state.imageSelectionBorderColor, undefined, 'border'); + } + + if ( + !this.state.tableCellSelectionBackgroundColorDark && + this.state.tableCellSelectionBackgroundColor + ) { + this.state.tableCellSelectionBackgroundColorDark = editor + .getColorManager() + .getDarkColor( + this.state.tableCellSelectionBackgroundColor, + undefined, + 'background' + ); + } + const env = this.editor.getEnvironment(); const document = this.editor.getDocument(); @@ -601,6 +639,7 @@ class SelectionPlugin implements PluginWithState { if ( (table = domHelper.findClosestElementAncestor(tableStart, 'table')) && + table.isContentEditable && (parsedTable = parseTableCells(table)) && (firstCo = findCoordinate(parsedTable, tdStart, domHelper)) ) { diff --git a/packages/roosterjs-content-model-core/test/coreApi/createContentModel/createContentModelTest.ts b/packages/roosterjs-content-model-core/test/coreApi/createContentModel/createContentModelTest.ts index 0b0ec01a507..bb223138b34 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/createContentModel/createContentModelTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/createContentModel/createContentModelTest.ts @@ -4,6 +4,7 @@ import * as domToContentModel from 'roosterjs-content-model-dom/lib/domToModel/d import * as updateCachedSelection from '../../../lib/corePlugin/cache/updateCachedSelection'; import { createContentModel } from '../../../lib/coreApi/createContentModel/createContentModel'; import { + ContentModelDocument, DomToModelContext, DomToModelOptionForCreateModel, EditorCore, @@ -362,7 +363,9 @@ describe('createContentModel and cache management', () => { }, } as any; - cloneModelSpy = spyOn(cloneModel, 'cloneModel').and.callFake(x => x); + cloneModelSpy = spyOn(cloneModel, 'cloneModel').and.callFake( + x => x as ContentModelDocument + ); spyOn(domToContentModel, 'domToContentModel').and.returnValue(mockedNewModel); diff --git a/packages/roosterjs-content-model-core/test/coreApi/setDOMSelection/setDOMSelectionTest.ts b/packages/roosterjs-content-model-core/test/coreApi/setDOMSelection/setDOMSelectionTest.ts index f1db9866d87..644455473f4 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/setDOMSelection/setDOMSelectionTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/setDOMSelection/setDOMSelectionTest.ts @@ -1,7 +1,12 @@ import * as addRangeToSelection from '../../../lib/coreApi/setDOMSelection/addRangeToSelection'; import { DOMSelection, EditorCore } from 'roosterjs-content-model-types'; import { setDOMSelection } from '../../../lib/coreApi/setDOMSelection/setDOMSelection'; +import { + DEFAULT_SELECTION_BORDER_COLOR, + DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, +} from '../../../lib/corePlugin/selection/SelectionPlugin'; +const DEFAULT_DARK_COLOR_SUFFIX_COLOR = 'DarkColorMock-'; describe('setDOMSelection', () => { let core: EditorCore; let querySelectorAllSpy: jasmine.Spy; @@ -47,7 +52,10 @@ describe('setDOMSelection', () => { core = { physicalRoot: contentDiv, logicalRoot: contentDiv, - selection: {}, + selection: { + imageSelectionBorderColor: DEFAULT_SELECTION_BORDER_COLOR, + tableCellSelectionBackgroundColor: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, + }, api: { triggerEvent: triggerEventSpy, setEditorStyle: setEditorStyleSpy, @@ -55,6 +63,9 @@ describe('setDOMSelection', () => { domHelper: { hasFocus: hasFocusSpy, }, + lifecycle: { + isDarkMode: false, + }, } as any; }); @@ -71,6 +82,8 @@ describe('setDOMSelection', () => { expect(core.selection).toEqual({ skipReselectOnFocus: undefined, selection: null, + imageSelectionBorderColor: DEFAULT_SELECTION_BORDER_COLOR, + tableCellSelectionBackgroundColor: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, } as any); expect(triggerEventSpy).toHaveBeenCalledWith( core, @@ -138,6 +151,8 @@ describe('setDOMSelection', () => { expect(core.selection).toEqual({ skipReselectOnFocus: undefined, selection: null, + imageSelectionBorderColor: DEFAULT_SELECTION_BORDER_COLOR, + tableCellSelectionBackgroundColor: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, } as any); expect(setEditorStyleSpy).toHaveBeenCalledTimes(3); expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelection', null); @@ -177,6 +192,8 @@ describe('setDOMSelection', () => { expect(core.selection).toEqual({ skipReselectOnFocus: undefined, selection: null, + imageSelectionBorderColor: DEFAULT_SELECTION_BORDER_COLOR, + tableCellSelectionBackgroundColor: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, } as any); expect(triggerEventSpy).not.toHaveBeenCalled(); expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange, false); @@ -205,6 +222,8 @@ describe('setDOMSelection', () => { expect(core.selection).toEqual({ skipReselectOnFocus: undefined, selection: mockedSelection, + imageSelectionBorderColor: DEFAULT_SELECTION_BORDER_COLOR, + tableCellSelectionBackgroundColor: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, } as any); expect(triggerEventSpy).toHaveBeenCalledWith( core, @@ -262,6 +281,8 @@ describe('setDOMSelection', () => { expect(core.selection).toEqual({ skipReselectOnFocus: undefined, selection: mockedSelection, + imageSelectionBorderColor: DEFAULT_SELECTION_BORDER_COLOR, + tableCellSelectionBackgroundColor: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, } as any); expect(triggerEventSpy).toHaveBeenCalledWith( core, @@ -322,6 +343,7 @@ describe('setDOMSelection', () => { skipReselectOnFocus: undefined, selection: mockedSelection, imageSelectionBorderColor: 'red', + tableCellSelectionBackgroundColor: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, } as any); expect(triggerEventSpy).toHaveBeenCalledWith( core, @@ -356,6 +378,73 @@ describe('setDOMSelection', () => { ); }); + it('image selection with customized selection border color and dark mode', () => { + const mockedSelection = { + type: 'image', + image: mockedImage, + } as any; + const selectNodeSpy = jasmine.createSpy('selectNode'); + const collapseSpy = jasmine.createSpy('collapse'); + const mockedRange = { + selectNode: selectNodeSpy, + collapse: collapseSpy, + }; + const coreValue = { ...core, lifecycle: { isDarkMode: true } as any }; + + coreValue.selection.imageSelectionBorderColor = 'red'; + coreValue.selection.imageSelectionBorderColorDark = `${DEFAULT_DARK_COLOR_SUFFIX_COLOR}red`; + + createRangeSpy.and.returnValue(mockedRange); + + querySelectorAllSpy.and.returnValue([]); + hasFocusSpy.and.returnValue(false); + + setDOMSelection(coreValue, mockedSelection); + + expect(coreValue.selection).toEqual({ + skipReselectOnFocus: undefined, + selection: mockedSelection, + imageSelectionBorderColor: 'red', + imageSelectionBorderColorDark: `${DEFAULT_DARK_COLOR_SUFFIX_COLOR}red`, + tableCellSelectionBackgroundColor: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, + } as any); + expect(triggerEventSpy).toHaveBeenCalledWith( + coreValue, + { + eventType: 'selectionChanged', + newSelection: mockedSelection, + }, + true + ); + expect(selectNodeSpy).toHaveBeenCalledWith(mockedImage); + expect(collapseSpy).not.toHaveBeenCalledWith(); + expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange, undefined); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(5); + expect(setEditorStyleSpy).toHaveBeenCalledWith(coreValue, '_DOMSelection', null); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + coreValue, + '_DOMSelectionHideCursor', + null + ); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + coreValue, + '_DOMSelectionHideSelection', + null + ); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + coreValue, + '_DOMSelection', + 'outline-style:auto!important; outline-color:DarkColorMock-red!important;', + ['span:has(>img#image_0)'] + ); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + coreValue, + '_DOMSelectionHideSelection', + 'background-color: transparent !important;', + ['*::selection'] + ); + }); + it('do not select if node is out of document', () => { const mockedSelection = { type: 'image', @@ -380,6 +469,8 @@ describe('setDOMSelection', () => { expect(core.selection).toEqual({ skipReselectOnFocus: undefined, selection: mockedSelection, + imageSelectionBorderColor: DEFAULT_SELECTION_BORDER_COLOR, + tableCellSelectionBackgroundColor: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, } as any); expect(triggerEventSpy).toHaveBeenCalledWith( core, @@ -440,6 +531,8 @@ describe('setDOMSelection', () => { expect(core.selection).toEqual({ skipReselectOnFocus: undefined, selection: mockedSelection, + imageSelectionBorderColor: DEFAULT_SELECTION_BORDER_COLOR, + tableCellSelectionBackgroundColor: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, } as any); expect(triggerEventSpy).toHaveBeenCalledWith( core, @@ -507,6 +600,8 @@ describe('setDOMSelection', () => { expect(core.selection).toEqual({ skipReselectOnFocus: undefined, + imageSelectionBorderColor: DEFAULT_SELECTION_BORDER_COLOR, + tableCellSelectionBackgroundColor: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, } as any); expect(triggerEventSpy).not.toHaveBeenCalled(); expect(selectNodeSpy).not.toHaveBeenCalled(); @@ -530,7 +625,9 @@ describe('setDOMSelection', () => { firstRow: number, lastColumn: number, lastRow: number, - result: string[] + result: string[], + selectionColor?: string, + expectedDarkSelectionColor?: string ) { const mockedSelection = { type: 'table', @@ -557,6 +654,12 @@ describe('setDOMSelection', () => { expect(core.selection).toEqual({ skipReselectOnFocus: undefined, selection: mockedSelection, + imageSelectionBorderColor: DEFAULT_SELECTION_BORDER_COLOR, + tableCellSelectionBackgroundColor: + selectionColor ?? DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, + ...(expectedDarkSelectionColor + ? { tableCellSelectionBackgroundColorDark: expectedDarkSelectionColor } + : {}), } as any); expect(triggerEventSpy).toHaveBeenCalledWith( core, @@ -578,7 +681,11 @@ describe('setDOMSelection', () => { expect(setEditorStyleSpy).toHaveBeenCalledWith( core, '_DOMSelection', - 'background-color:#C6C6C6!important;', + `background-color:${ + expectedDarkSelectionColor ?? + selectionColor ?? + DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR + }!important;`, result ); expect(setEditorStyleSpy).toHaveBeenCalledWith( @@ -697,6 +804,8 @@ describe('setDOMSelection', () => { expect(core.selection).toEqual({ skipReselectOnFocus: undefined, selection: resultSelection, + imageSelectionBorderColor: DEFAULT_SELECTION_BORDER_COLOR, + tableCellSelectionBackgroundColor: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, } as any); expect(triggerEventSpy).toHaveBeenCalledWith( core, @@ -774,6 +883,38 @@ describe('setDOMSelection', () => { '#table_0 *', ]); }); + + it('Select All with custom selection color', () => { + const selectionColor = 'red'; + core.selection.tableCellSelectionBackgroundColor = selectionColor; + runTest( + buildTable(true /* tbody */, false, false), + 0, + 0, + 1, + 1, + ['#table_0', '#table_0 *'], + selectionColor + ); + }); + + it('Select All with custom selection color and dark mode', () => { + const selectionColor = 'red'; + const selectionColorDark = `${DEFAULT_DARK_COLOR_SUFFIX_COLOR}red`; + core.selection.tableCellSelectionBackgroundColor = selectionColor; + core.selection.tableCellSelectionBackgroundColorDark = selectionColorDark; + core.lifecycle.isDarkMode = true; + runTest( + buildTable(true /* tbody */, false, false), + 0, + 0, + 1, + 1, + ['#table_0', '#table_0 *'], + selectionColor, + selectionColorDark + ); + }); }); }); diff --git a/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts index cc1adf1e727..daab8e16703 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts @@ -1,6 +1,10 @@ import * as isSingleImageInSelection from '../../../lib/corePlugin/selection/isSingleImageInSelection'; import { createDOMHelper } from '../../../lib/editor/core/DOMHelperImpl'; -import { createSelectionPlugin } from '../../../lib/corePlugin/selection/SelectionPlugin'; +import { + createSelectionPlugin, + DEFAULT_SELECTION_BORDER_COLOR, + DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, +} from '../../../lib/corePlugin/selection/SelectionPlugin'; import { DOMEventRecord, DOMSelection, @@ -10,6 +14,8 @@ import { SelectionPluginState, } from 'roosterjs-content-model-types'; +const DEFAULT_DARK_COLOR_SUFFIX_COLOR = 'DarkColorMock-'; + describe('SelectionPlugin', () => { it('init and dispose', () => { const plugin = createSelectionPlugin({}); @@ -26,13 +32,19 @@ describe('SelectionPlugin', () => { getDocument: getDocumentSpy, attachDomEvent, getEnvironment: () => ({}), + getColorManager: () => ({ + getDarkColor: (color: string) => `${DEFAULT_DARK_COLOR_SUFFIX_COLOR}${color}`, + }), } as any) as IEditor; plugin.initialize(editor); expect(state).toEqual({ selection: null, - imageSelectionBorderColor: undefined, + imageSelectionBorderColor: DEFAULT_SELECTION_BORDER_COLOR, + imageSelectionBorderColorDark: DEFAULT_SELECTION_BORDER_COLOR, + tableCellSelectionBackgroundColor: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, + tableCellSelectionBackgroundColorDark: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, tableSelection: null, }); expect(attachDomEvent).toHaveBeenCalled(); @@ -48,6 +60,7 @@ describe('SelectionPlugin', () => { it('init with different options', () => { const plugin = createSelectionPlugin({ imageSelectionBorderColor: 'red', + tableCellSelectionBackgroundColor: 'blue', }); const state = plugin.getState(); const addEventListenerSpy = jasmine.createSpy('addEventListener'); @@ -59,16 +72,59 @@ describe('SelectionPlugin', () => { removeEventListener: removeEventListenerSpy, addEventListener: addEventListenerSpy, }); - plugin.initialize(({ getDocument: getDocumentSpy, attachDomEvent, getEnvironment: () => ({}), + getColorManager: () => ({ + getDarkColor: (color: string) => `${DEFAULT_DARK_COLOR_SUFFIX_COLOR}${color}`, + }), })); expect(state).toEqual({ selection: null, imageSelectionBorderColor: 'red', + tableCellSelectionBackgroundColor: 'blue', + imageSelectionBorderColorDark: `${DEFAULT_DARK_COLOR_SUFFIX_COLOR}red`, + tableCellSelectionBackgroundColorDark: `${DEFAULT_DARK_COLOR_SUFFIX_COLOR}blue`, + tableSelection: null, + }); + + expect(attachDomEvent).toHaveBeenCalled(); + + plugin.dispose(); + }); + + it('init with different options - transparent colors', () => { + const plugin = createSelectionPlugin({ + imageSelectionBorderColor: 'transparent', + tableCellSelectionBackgroundColor: 'transparent', + }); + const state = plugin.getState(); + const addEventListenerSpy = jasmine.createSpy('addEventListener'); + const attachDomEvent = jasmine + .createSpy('attachDomEvent') + .and.returnValue(jasmine.createSpy('disposer')); + const removeEventListenerSpy = jasmine.createSpy('removeEventListener'); + const getDocumentSpy = jasmine.createSpy('getDocument').and.returnValue({ + removeEventListener: removeEventListenerSpy, + addEventListener: addEventListenerSpy, + }); + plugin.initialize(({ + getDocument: getDocumentSpy, + attachDomEvent, + getEnvironment: () => ({}), + getColorManager: () => ({ + getDarkColor: (color: string) => `${DEFAULT_DARK_COLOR_SUFFIX_COLOR}${color}`, + }), + })); + + expect(state).toEqual({ + selection: null, + imageSelectionBorderColor: 'transparent', + tableCellSelectionBackgroundColor: 'transparent', + imageSelectionBorderColorDark: `${DEFAULT_DARK_COLOR_SUFFIX_COLOR}transparent`, + tableCellSelectionBackgroundColorDark: `${DEFAULT_DARK_COLOR_SUFFIX_COLOR}transparent`, tableSelection: null, }); @@ -116,6 +172,9 @@ describe('SelectionPlugin handle onFocus and onBlur event', () => { getElementAtCursor: getElementAtCursorSpy, setDOMSelection: setDOMSelectionSpy, getScrollContainer: getScrollContainerSpy, + getColorManager: () => ({ + getDarkColor: (color: string) => `${DEFAULT_DARK_COLOR_SUFFIX_COLOR}${color}`, + }), }); plugin.initialize(editor); }); @@ -134,7 +193,10 @@ describe('SelectionPlugin handle onFocus and onBlur event', () => { eventMap.focus.beforeDispatch(); expect(plugin.getState()).toEqual({ selection: mockedRange, - imageSelectionBorderColor: undefined, + imageSelectionBorderColor: DEFAULT_SELECTION_BORDER_COLOR, + imageSelectionBorderColorDark: DEFAULT_SELECTION_BORDER_COLOR, + tableCellSelectionBackgroundColor: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, + tableCellSelectionBackgroundColorDark: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, skipReselectOnFocus: false, tableSelection: null, }); @@ -150,7 +212,10 @@ describe('SelectionPlugin handle onFocus and onBlur event', () => { eventMap.focus.beforeDispatch(); expect(plugin.getState()).toEqual({ selection: mockedRange, - imageSelectionBorderColor: undefined, + imageSelectionBorderColor: DEFAULT_SELECTION_BORDER_COLOR, + imageSelectionBorderColorDark: DEFAULT_SELECTION_BORDER_COLOR, + tableCellSelectionBackgroundColor: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, + tableCellSelectionBackgroundColorDark: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, skipReselectOnFocus: true, tableSelection: null, }); @@ -219,6 +284,9 @@ describe('SelectionPlugin scroll event ', () => { setDOMSelection: setDOMSelectionSpy, getScrollContainer: getScrollContainerSpy, hasFocus: hasFocusSpy, + getColorManager: () => ({ + getDarkColor: (color: string) => `${DEFAULT_DARK_COLOR_SUFFIX_COLOR}${color}`, + }), }); plugin.initialize(editor); }); @@ -295,6 +363,9 @@ describe('SelectionPlugin handle image selection', () => { attachDomEvent: (map: Record) => { return jasmine.createSpy('disposer'); }, + getColorManager: () => ({ + getDarkColor: (color: string) => `${DEFAULT_DARK_COLOR_SUFFIX_COLOR}${color}`, + }), } as any; plugin = createSelectionPlugin({}); plugin.initialize(editor); @@ -726,6 +797,9 @@ describe('SelectionPlugin handle table selection', () => { getDOMSelection: getDOMSelectionSpy, setDOMSelection: setDOMSelectionSpy, getDocument: getDocumentSpy, + getColorManager: () => ({ + getDarkColor: (color: string) => `${DEFAULT_DARK_COLOR_SUFFIX_COLOR}${color}`, + }), getEnvironment: () => ({}), getDOMHelper: () => domHelper, attachDomEvent: (map: Record>) => { @@ -767,7 +841,10 @@ describe('SelectionPlugin handle table selection', () => { expect(state).toEqual({ selection: null, tableSelection: mockedTableSelection, - imageSelectionBorderColor: undefined, + imageSelectionBorderColor: DEFAULT_SELECTION_BORDER_COLOR, + imageSelectionBorderColorDark: DEFAULT_SELECTION_BORDER_COLOR, + tableCellSelectionBackgroundColor: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, + tableCellSelectionBackgroundColorDark: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, }); plugin.onPluginEvent!({ @@ -776,11 +853,13 @@ describe('SelectionPlugin handle table selection', () => { button: 0, } as any, }); - expect(state).toEqual({ selection: null, tableSelection: null, - imageSelectionBorderColor: undefined, + imageSelectionBorderColor: DEFAULT_SELECTION_BORDER_COLOR, + imageSelectionBorderColorDark: DEFAULT_SELECTION_BORDER_COLOR, + tableCellSelectionBackgroundColor: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, + tableCellSelectionBackgroundColorDark: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, }); expect(mouseDispatcher).toBeUndefined(); }); @@ -788,6 +867,7 @@ describe('SelectionPlugin handle table selection', () => { it('MouseDown - save a table selection when left click', () => { const state = plugin.getState(); const table = document.createElement('table'); + table.setAttribute('contenteditable', 'true'); const tr = document.createElement('tr'); const td = document.createElement('td'); const div = document.createElement('div'); @@ -812,7 +892,10 @@ describe('SelectionPlugin handle table selection', () => { expect(state).toEqual({ selection: null, tableSelection: null, - imageSelectionBorderColor: undefined, + imageSelectionBorderColor: DEFAULT_SELECTION_BORDER_COLOR, + imageSelectionBorderColorDark: DEFAULT_SELECTION_BORDER_COLOR, + tableCellSelectionBackgroundColor: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, + tableCellSelectionBackgroundColorDark: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, }); plugin.onPluginEvent!({ @@ -832,14 +915,71 @@ describe('SelectionPlugin handle table selection', () => { startNode: td, }, mouseDisposer: mouseMoveDisposer, - imageSelectionBorderColor: undefined, + imageSelectionBorderColor: DEFAULT_SELECTION_BORDER_COLOR, + imageSelectionBorderColorDark: DEFAULT_SELECTION_BORDER_COLOR, + tableCellSelectionBackgroundColor: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, + tableCellSelectionBackgroundColorDark: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, }); expect(mouseDispatcher).toBeDefined(); }); + it('MouseDown - do not save a table selection when left click, table is not editable', () => { + const state = plugin.getState(); + const table = document.createElement('table'); + table.setAttribute('contenteditable', 'false'); + const tr = document.createElement('tr'); + const td = document.createElement('td'); + const div = document.createElement('div'); + + tr.appendChild(td); + table.appendChild(tr); + contentDiv.appendChild(table); + contentDiv.appendChild(div); + + getDOMSelectionSpy.and.returnValue({ + type: 'table', + }); + + plugin.onPluginEvent!({ + eventType: 'mouseDown', + rawEvent: { + button: 0, + target: div, + } as any, + }); + + expect(state).toEqual({ + selection: null, + tableSelection: null, + imageSelectionBorderColor: DEFAULT_SELECTION_BORDER_COLOR, + imageSelectionBorderColorDark: DEFAULT_SELECTION_BORDER_COLOR, + tableCellSelectionBackgroundColor: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, + tableCellSelectionBackgroundColorDark: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, + }); + + plugin.onPluginEvent!({ + eventType: 'mouseDown', + rawEvent: { + button: 0, + target: td, + } as any, + }); + + expect(state).toEqual({ + selection: null, + tableSelection: null, + imageSelectionBorderColor: DEFAULT_SELECTION_BORDER_COLOR, + imageSelectionBorderColorDark: DEFAULT_SELECTION_BORDER_COLOR, + tableCellSelectionBackgroundColor: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, + tableCellSelectionBackgroundColorDark: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, + }); + expect(mouseDispatcher).toBeUndefined(); + }); + it('MouseDown - triple click', () => { const state = plugin.getState(); const table = document.createElement('table'); + table.setAttribute('contenteditable', 'true'); const tr = document.createElement('tr'); const td = document.createElement('td'); @@ -869,7 +1009,10 @@ describe('SelectionPlugin handle table selection', () => { startNode: td, }, mouseDisposer: mouseMoveDisposer, - imageSelectionBorderColor: undefined, + imageSelectionBorderColor: DEFAULT_SELECTION_BORDER_COLOR, + imageSelectionBorderColorDark: DEFAULT_SELECTION_BORDER_COLOR, + tableCellSelectionBackgroundColor: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, + tableCellSelectionBackgroundColorDark: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, }); expect(mouseDispatcher).toBeDefined(); expect(preventDefaultSpy).toHaveBeenCalled(); @@ -887,6 +1030,7 @@ describe('SelectionPlugin handle table selection', () => { it('MouseMove - in same table', () => { const state = plugin.getState(); const table = document.createElement('table'); + table.setAttribute('contenteditable', 'true'); const tr = document.createElement('tr'); const td1 = document.createElement('td'); const td2 = document.createElement('td'); @@ -999,6 +1143,8 @@ describe('SelectionPlugin handle table selection', () => { const state = plugin.getState(); const table1 = document.createElement('table'); const table2 = document.createElement('table'); + table1.setAttribute('contenteditable', 'true'); + table2.setAttribute('contenteditable', 'true'); const tr1 = document.createElement('tr'); const tr2 = document.createElement('tr'); const td1 = document.createElement('td'); @@ -1164,6 +1310,7 @@ describe('SelectionPlugin handle table selection', () => { beforeEach(() => { table = document.createElement('table'); + table.setAttribute('contenteditable', 'true'); tr1 = document.createElement('tr'); tr2 = document.createElement('tr'); td1 = document.createElement('td'); @@ -1205,7 +1352,10 @@ describe('SelectionPlugin handle table selection', () => { expect(plugin.getState()).toEqual({ selection: null, tableSelection: null, - imageSelectionBorderColor: undefined, + imageSelectionBorderColor: DEFAULT_SELECTION_BORDER_COLOR, + imageSelectionBorderColorDark: DEFAULT_SELECTION_BORDER_COLOR, + tableCellSelectionBackgroundColor: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, + tableCellSelectionBackgroundColorDark: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, }); expect(setDOMSelectionSpy).not.toHaveBeenCalled(); expect(announceSpy).not.toHaveBeenCalled(); @@ -1246,7 +1396,10 @@ describe('SelectionPlugin handle table selection', () => { expect(plugin.getState()).toEqual({ selection: null, tableSelection: null, - imageSelectionBorderColor: undefined, + imageSelectionBorderColor: DEFAULT_SELECTION_BORDER_COLOR, + imageSelectionBorderColorDark: DEFAULT_SELECTION_BORDER_COLOR, + tableCellSelectionBackgroundColor: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, + tableCellSelectionBackgroundColorDark: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, }); expect(setDOMSelectionSpy).toHaveBeenCalledTimes(0); expect(announceSpy).not.toHaveBeenCalled(); @@ -1302,7 +1455,10 @@ describe('SelectionPlugin handle table selection', () => { expect(plugin.getState()).toEqual({ selection: null, tableSelection: null, - imageSelectionBorderColor: undefined, + imageSelectionBorderColor: DEFAULT_SELECTION_BORDER_COLOR, + imageSelectionBorderColorDark: DEFAULT_SELECTION_BORDER_COLOR, + tableCellSelectionBackgroundColor: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, + tableCellSelectionBackgroundColorDark: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, }); expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); expect(setDOMSelectionSpy).toHaveBeenCalledWith({ @@ -1365,7 +1521,10 @@ describe('SelectionPlugin handle table selection', () => { expect(plugin.getState()).toEqual({ selection: null, tableSelection: null, - imageSelectionBorderColor: undefined, + imageSelectionBorderColor: DEFAULT_SELECTION_BORDER_COLOR, + imageSelectionBorderColorDark: DEFAULT_SELECTION_BORDER_COLOR, + tableCellSelectionBackgroundColor: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, + tableCellSelectionBackgroundColorDark: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, }); expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); expect(setDOMSelectionSpy).toHaveBeenCalledWith({ @@ -1427,7 +1586,10 @@ describe('SelectionPlugin handle table selection', () => { expect(plugin.getState()).toEqual({ selection: null, tableSelection: null, - imageSelectionBorderColor: undefined, + imageSelectionBorderColor: DEFAULT_SELECTION_BORDER_COLOR, + imageSelectionBorderColorDark: DEFAULT_SELECTION_BORDER_COLOR, + tableCellSelectionBackgroundColor: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, + tableCellSelectionBackgroundColorDark: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, }); expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); expect(setDOMSelectionSpy).toHaveBeenCalledWith({ @@ -1490,7 +1652,10 @@ describe('SelectionPlugin handle table selection', () => { expect(plugin.getState()).toEqual({ selection: null, tableSelection: null, - imageSelectionBorderColor: undefined, + imageSelectionBorderColor: DEFAULT_SELECTION_BORDER_COLOR, + imageSelectionBorderColorDark: DEFAULT_SELECTION_BORDER_COLOR, + tableCellSelectionBackgroundColor: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, + tableCellSelectionBackgroundColorDark: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, }); expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); expect(setDOMSelectionSpy).toHaveBeenCalledWith({ @@ -1552,7 +1717,10 @@ describe('SelectionPlugin handle table selection', () => { expect(plugin.getState()).toEqual({ selection: null, tableSelection: null, - imageSelectionBorderColor: undefined, + imageSelectionBorderColor: DEFAULT_SELECTION_BORDER_COLOR, + imageSelectionBorderColorDark: DEFAULT_SELECTION_BORDER_COLOR, + tableCellSelectionBackgroundColor: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, + tableCellSelectionBackgroundColorDark: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, }); expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); expect(setDOMSelectionSpy).toHaveBeenCalledWith({ @@ -1614,7 +1782,10 @@ describe('SelectionPlugin handle table selection', () => { expect(plugin.getState()).toEqual({ selection: null, tableSelection: null, - imageSelectionBorderColor: undefined, + imageSelectionBorderColor: DEFAULT_SELECTION_BORDER_COLOR, + imageSelectionBorderColorDark: DEFAULT_SELECTION_BORDER_COLOR, + tableCellSelectionBackgroundColor: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, + tableCellSelectionBackgroundColorDark: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, }); expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); expect(setDOMSelectionSpy).toHaveBeenCalledWith({ @@ -1679,7 +1850,10 @@ describe('SelectionPlugin handle table selection', () => { lastCo: { row: 0, col: 1 }, startNode: td4, }, - imageSelectionBorderColor: undefined, + imageSelectionBorderColor: DEFAULT_SELECTION_BORDER_COLOR, + imageSelectionBorderColorDark: DEFAULT_SELECTION_BORDER_COLOR, + tableCellSelectionBackgroundColor: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, + tableCellSelectionBackgroundColorDark: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, }); expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); expect(setDOMSelectionSpy).toHaveBeenCalledWith({ @@ -1744,7 +1918,10 @@ describe('SelectionPlugin handle table selection', () => { lastCo: { row: 1, col: 1 }, startNode: td2, }, - imageSelectionBorderColor: undefined, + imageSelectionBorderColor: DEFAULT_SELECTION_BORDER_COLOR, + imageSelectionBorderColorDark: DEFAULT_SELECTION_BORDER_COLOR, + tableCellSelectionBackgroundColor: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, + tableCellSelectionBackgroundColorDark: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, }); expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); expect(setDOMSelectionSpy).toHaveBeenCalledWith({ @@ -1808,7 +1985,10 @@ describe('SelectionPlugin handle table selection', () => { firstCo: { row: 0, col: 1 }, startNode: td2, }, - imageSelectionBorderColor: undefined, + imageSelectionBorderColor: DEFAULT_SELECTION_BORDER_COLOR, + imageSelectionBorderColorDark: DEFAULT_SELECTION_BORDER_COLOR, + tableCellSelectionBackgroundColor: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, + tableCellSelectionBackgroundColorDark: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, }); expect(setDOMSelectionSpy).toHaveBeenCalledTimes(0); expect(announceSpy).not.toHaveBeenCalled(); @@ -1852,7 +2032,10 @@ describe('SelectionPlugin handle table selection', () => { lastCo: { row: 1, col: 1 }, startNode: td2, }, - imageSelectionBorderColor: undefined, + imageSelectionBorderColor: DEFAULT_SELECTION_BORDER_COLOR, + imageSelectionBorderColorDark: DEFAULT_SELECTION_BORDER_COLOR, + tableCellSelectionBackgroundColor: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, + tableCellSelectionBackgroundColorDark: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, }); expect(setDOMSelectionSpy).not.toHaveBeenCalled(); expect(preventDefaultSpy).not.toHaveBeenCalled(); @@ -1905,7 +2088,10 @@ describe('SelectionPlugin handle table selection', () => { expect(plugin.getState()).toEqual({ selection: null, tableSelection: null, - imageSelectionBorderColor: undefined, + imageSelectionBorderColor: DEFAULT_SELECTION_BORDER_COLOR, + imageSelectionBorderColorDark: DEFAULT_SELECTION_BORDER_COLOR, + tableCellSelectionBackgroundColor: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, + tableCellSelectionBackgroundColorDark: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, }); expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); expect(setDOMSelectionSpy).toHaveBeenCalledWith(null); @@ -1954,7 +2140,10 @@ describe('SelectionPlugin handle table selection', () => { lastCo: { row: 1, col: 0 }, startNode: td2, }, - imageSelectionBorderColor: undefined, + imageSelectionBorderColor: DEFAULT_SELECTION_BORDER_COLOR, + imageSelectionBorderColorDark: DEFAULT_SELECTION_BORDER_COLOR, + tableCellSelectionBackgroundColor: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, + tableCellSelectionBackgroundColorDark: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, }); expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); expect(setDOMSelectionSpy).toHaveBeenCalledWith({ @@ -2010,7 +2199,10 @@ describe('SelectionPlugin handle table selection', () => { lastCo: { row: 0, col: 1 }, startNode: td3, }, - imageSelectionBorderColor: undefined, + imageSelectionBorderColor: DEFAULT_SELECTION_BORDER_COLOR, + imageSelectionBorderColorDark: DEFAULT_SELECTION_BORDER_COLOR, + tableCellSelectionBackgroundColor: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, + tableCellSelectionBackgroundColorDark: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, }); expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); expect(setDOMSelectionSpy).toHaveBeenCalledWith({ @@ -2068,6 +2260,9 @@ describe('SelectionPlugin on Safari', () => { hasFocus: hasFocusSpy, isInShadowEdit: isInShadowEditSpy, getDOMSelection: getDOMSelectionSpy, + getColorManager: () => ({ + getDarkColor: (color: string) => `${DEFAULT_DARK_COLOR_SUFFIX_COLOR}${color}`, + }), } as any) as IEditor; }); @@ -2079,7 +2274,10 @@ describe('SelectionPlugin on Safari', () => { expect(state).toEqual({ selection: null, - imageSelectionBorderColor: undefined, + imageSelectionBorderColor: DEFAULT_SELECTION_BORDER_COLOR, + imageSelectionBorderColorDark: DEFAULT_SELECTION_BORDER_COLOR, + tableCellSelectionBackgroundColor: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, + tableCellSelectionBackgroundColorDark: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, tableSelection: null, }); expect(attachDomEvent).toHaveBeenCalled(); @@ -2114,7 +2312,10 @@ describe('SelectionPlugin on Safari', () => { expect(state).toEqual({ selection: mockedOldSelection, - imageSelectionBorderColor: undefined, + imageSelectionBorderColor: DEFAULT_SELECTION_BORDER_COLOR, + imageSelectionBorderColorDark: DEFAULT_SELECTION_BORDER_COLOR, + tableCellSelectionBackgroundColor: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, + tableCellSelectionBackgroundColorDark: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, tableSelection: null, }); expect(getDOMSelectionSpy).toHaveBeenCalledTimes(1); @@ -2145,7 +2346,10 @@ describe('SelectionPlugin on Safari', () => { expect(state).toEqual({ selection: mockedNewSelection, - imageSelectionBorderColor: undefined, + imageSelectionBorderColor: DEFAULT_SELECTION_BORDER_COLOR, + imageSelectionBorderColorDark: DEFAULT_SELECTION_BORDER_COLOR, + tableCellSelectionBackgroundColor: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, + tableCellSelectionBackgroundColorDark: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, tableSelection: null, }); expect(getDOMSelectionSpy).toHaveBeenCalledTimes(1); @@ -2173,7 +2377,10 @@ describe('SelectionPlugin on Safari', () => { expect(state).toEqual({ selection: mockedOldSelection, - imageSelectionBorderColor: undefined, + imageSelectionBorderColor: DEFAULT_SELECTION_BORDER_COLOR, + imageSelectionBorderColorDark: DEFAULT_SELECTION_BORDER_COLOR, + tableCellSelectionBackgroundColor: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, + tableCellSelectionBackgroundColorDark: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, tableSelection: null, }); expect(getDOMSelectionSpy).toHaveBeenCalledTimes(1); @@ -2201,7 +2408,10 @@ describe('SelectionPlugin on Safari', () => { expect(state).toEqual({ selection: mockedOldSelection, - imageSelectionBorderColor: undefined, + imageSelectionBorderColor: DEFAULT_SELECTION_BORDER_COLOR, + imageSelectionBorderColorDark: DEFAULT_SELECTION_BORDER_COLOR, + tableCellSelectionBackgroundColor: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, + tableCellSelectionBackgroundColorDark: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, tableSelection: null, }); expect(getDOMSelectionSpy).toHaveBeenCalledTimes(1); @@ -2229,7 +2439,10 @@ describe('SelectionPlugin on Safari', () => { expect(state).toEqual({ selection: mockedOldSelection, - imageSelectionBorderColor: undefined, + imageSelectionBorderColor: DEFAULT_SELECTION_BORDER_COLOR, + imageSelectionBorderColorDark: DEFAULT_SELECTION_BORDER_COLOR, + tableCellSelectionBackgroundColor: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, + tableCellSelectionBackgroundColorDark: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, tableSelection: null, }); expect(getDOMSelectionSpy).toHaveBeenCalledTimes(0); @@ -2257,7 +2470,10 @@ describe('SelectionPlugin on Safari', () => { expect(state).toEqual({ selection: mockedOldSelection, - imageSelectionBorderColor: undefined, + imageSelectionBorderColor: DEFAULT_SELECTION_BORDER_COLOR, + imageSelectionBorderColorDark: DEFAULT_SELECTION_BORDER_COLOR, + tableCellSelectionBackgroundColor: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, + tableCellSelectionBackgroundColorDark: DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, tableSelection: null, }); expect(getDOMSelectionSpy).toHaveBeenCalledTimes(0); diff --git a/packages/roosterjs-content-model-core/test/editor/EditorTest.ts b/packages/roosterjs-content-model-core/test/editor/EditorTest.ts index 042f2d0c9db..cb415755e7c 100644 --- a/packages/roosterjs-content-model-core/test/editor/EditorTest.ts +++ b/packages/roosterjs-content-model-core/test/editor/EditorTest.ts @@ -278,7 +278,9 @@ describe('Editor', () => { mockedModel ); - const cloneModelSpy = spyOn(cloneModel, 'cloneModel').and.callFake(x => x); + const cloneModelSpy = spyOn(cloneModel, 'cloneModel').and.callFake( + x => x as ContentModelDocument + ); const model = editor.getContentModelCopy('clean'); expect(model).toBe(mockedModel); diff --git a/packages/roosterjs-content-model-dom/lib/index.ts b/packages/roosterjs-content-model-dom/lib/index.ts index d2f8789135a..00eaa77bd95 100644 --- a/packages/roosterjs-content-model-dom/lib/index.ts +++ b/packages/roosterjs-content-model-dom/lib/index.ts @@ -15,7 +15,7 @@ export { areSameFormats } from './domToModel/utils/areSameFormats'; export { isBlockElement } from './domToModel/utils/isBlockElement'; export { buildSelectionMarker } from './domToModel/utils/buildSelectionMarker'; -export { updateMetadata, hasMetadata } from './modelApi/metadata/updateMetadata'; +export { updateMetadata, getMetadata, hasMetadata } from './modelApi/metadata/updateMetadata'; export { isNodeOfType } from './domUtils/isNodeOfType'; export { isElementOfType } from './domUtils/isElementOfType'; export { getObjectKeys } from './domUtils/getObjectKeys'; @@ -54,7 +54,9 @@ 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 { createTableRow } from './modelApi/creators/createTableRow'; +export { mutateBlock, mutateSegments, mutateSegment } from './modelApi/common/mutate'; export { addBlock } from './modelApi/common/addBlock'; export { addCode } from './modelApi/common/addDecorators'; export { addLink } from './modelApi/common/addDecorators'; @@ -122,17 +124,24 @@ export { mergeModel } from './modelApi/editing/mergeModel'; export { deleteSelection } from './modelApi/editing/deleteSelection'; export { deleteSegment } from './modelApi/editing/deleteSegment'; export { deleteBlock } from './modelApi/editing/deleteBlock'; -export { applyTableFormat } from './modelApi/editing/applyTableFormat'; +export { applyTableFormat, setFirstColumnFormatBorders } from './modelApi/editing/applyTableFormat'; export { normalizeTable, MIN_ALLOWED_TABLE_CELL_WIDTH } from './modelApi/editing/normalizeTable'; export { setTableCellBackgroundColor } from './modelApi/editing/setTableCellBackgroundColor'; export { retrieveModelFormatState } from './modelApi/editing/retrieveModelFormatState'; export { getListStyleTypeFromString } from './modelApi/editing/getListStyleTypeFromString'; export { getSegmentTextFormat } from './modelApi/editing/getSegmentTextFormat'; -export { updateImageMetadata } from './modelApi/metadata/updateImageMetadata'; -export { updateTableCellMetadata } from './modelApi/metadata/updateTableCellMetadata'; -export { updateTableMetadata } from './modelApi/metadata/updateTableMetadata'; -export { updateListMetadata, ListMetadataDefinition } from './modelApi/metadata/updateListMetadata'; +export { updateImageMetadata, getImageMetadata } from './modelApi/metadata/updateImageMetadata'; +export { + updateTableCellMetadata, + getTableCellMetadata, +} from './modelApi/metadata/updateTableCellMetadata'; +export { updateTableMetadata, getTableMetadata } from './modelApi/metadata/updateTableMetadata'; +export { + updateListMetadata, + getListMetadata, + ListMetadataDefinition, +} from './modelApi/metadata/updateListMetadata'; export { ChangeSource } from './constants/ChangeSource'; export { BulletListType } from './constants/BulletListType'; diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/block/setParagraphNotImplicit.ts b/packages/roosterjs-content-model-dom/lib/modelApi/block/setParagraphNotImplicit.ts index 949e3cb3b50..734c9999a0c 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/block/setParagraphNotImplicit.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/block/setParagraphNotImplicit.ts @@ -1,11 +1,12 @@ -import type { ContentModelBlock } from 'roosterjs-content-model-types'; +import { mutateBlock } from '../common/mutate'; +import type { ReadonlyContentModelBlock } from 'roosterjs-content-model-types'; /** * For a given block, if it is a paragraph, set it to be not-implicit * @param block The block to check */ -export function setParagraphNotImplicit(block: ContentModelBlock) { +export function setParagraphNotImplicit(block: ReadonlyContentModelBlock) { if (block.blockType == 'Paragraph' && block.isImplicit) { - block.isImplicit = false; + mutateBlock(block).isImplicit = false; } } diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/common/addBlock.ts b/packages/roosterjs-content-model-dom/lib/modelApi/common/addBlock.ts index 3461d6baccb..57a51f84640 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/common/addBlock.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/common/addBlock.ts @@ -1,10 +1,16 @@ -import type { ContentModelBlock, ContentModelBlockGroup } from 'roosterjs-content-model-types'; +import type { + ShallowMutableContentModelBlock, + ShallowMutableContentModelBlockGroup, +} from 'roosterjs-content-model-types'; /** * Add a given block to block group * @param group The block group to add block into * @param block The block to add */ -export function addBlock(group: ContentModelBlockGroup, block: ContentModelBlock) { +export function addBlock( + group: ShallowMutableContentModelBlockGroup, + block: ShallowMutableContentModelBlock +) { group.blocks.push(block); } diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/common/addDecorators.ts b/packages/roosterjs-content-model-dom/lib/modelApi/common/addDecorators.ts index 25ba00b0703..6abccd578c9 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/common/addDecorators.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/common/addDecorators.ts @@ -1,14 +1,17 @@ import type { - ContentModelCode, - ContentModelLink, - ContentModelSegment, DomToModelDecoratorContext, + ReadonlyContentModelCode, + ReadonlyContentModelLink, + ShallowMutableContentModelSegment, } from 'roosterjs-content-model-types'; /** * @internal */ -export function addLink(segment: ContentModelSegment, link: ContentModelLink) { +export function addLink( + segment: ShallowMutableContentModelSegment, + link: ReadonlyContentModelLink +) { if (link.format.href) { segment.link = { format: { ...link.format }, @@ -22,7 +25,10 @@ export function addLink(segment: ContentModelSegment, link: ContentModelLink) { * @param segment The segment to add decorator to * @param code The code decorator to add */ -export function addCode(segment: ContentModelSegment, code: ContentModelCode) { +export function addCode( + segment: ShallowMutableContentModelSegment, + code: ReadonlyContentModelCode +) { if (code.format.fontFamily) { segment.code = { format: { ...code.format }, @@ -33,7 +39,10 @@ export function addCode(segment: ContentModelSegment, code: ContentModelCode) { /** * @internal */ -export function addDecorators(segment: ContentModelSegment, context: DomToModelDecoratorContext) { +export function addDecorators( + segment: ShallowMutableContentModelSegment, + context: DomToModelDecoratorContext +) { addLink(segment, context.link); addCode(segment, context.code); } diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/common/addSegment.ts b/packages/roosterjs-content-model-dom/lib/modelApi/common/addSegment.ts index d564538490a..08535230a17 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/common/addSegment.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/common/addSegment.ts @@ -5,6 +5,8 @@ import type { ContentModelParagraph, ContentModelSegment, ContentModelSegmentFormat, + ShallowMutableContentModelBlockGroup, + ShallowMutableContentModelParagraph, } from 'roosterjs-content-model-types'; /** @@ -19,7 +21,28 @@ export function addSegment( newSegment: ContentModelSegment, blockFormat?: ContentModelBlockFormat, segmentFormat?: ContentModelSegmentFormat -): ContentModelParagraph { +): ContentModelParagraph; + +/** + * Add a given segment into a paragraph from its parent group. If the last block of the given group is not paragraph, create a new paragraph. (Shallow mutable) + * @param group The parent block group of the paragraph to add segment into + * @param newSegment The segment to add + * @param blockFormat The block format used for creating a new paragraph when need + * @returns The parent paragraph where the segment is added to + */ +export function addSegment( + group: ShallowMutableContentModelBlockGroup, + newSegment: ContentModelSegment, + blockFormat?: ContentModelBlockFormat, + segmentFormat?: ContentModelSegmentFormat +): ShallowMutableContentModelParagraph; + +export function addSegment( + group: ShallowMutableContentModelBlockGroup, + newSegment: ContentModelSegment, + blockFormat?: ContentModelBlockFormat, + segmentFormat?: ContentModelSegmentFormat +): ShallowMutableContentModelParagraph { const paragraph = ensureParagraph(group, blockFormat, segmentFormat); const lastSegment = paragraph.segments[paragraph.segments.length - 1]; diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/common/ensureParagraph.ts b/packages/roosterjs-content-model-dom/lib/modelApi/common/ensureParagraph.ts index e638f1920ce..0714d6c8e0e 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/common/ensureParagraph.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/common/ensureParagraph.ts @@ -1,10 +1,13 @@ import { addBlock } from './addBlock'; import { createParagraph } from '../creators/createParagraph'; +import { mutateBlock } from './mutate'; import type { ContentModelBlockFormat, ContentModelBlockGroup, ContentModelParagraph, ContentModelSegmentFormat, + ShallowMutableContentModelBlockGroup, + ShallowMutableContentModelParagraph, } from 'roosterjs-content-model-types'; /** @@ -17,11 +20,29 @@ export function ensureParagraph( group: ContentModelBlockGroup, blockFormat?: ContentModelBlockFormat, segmentFormat?: ContentModelSegmentFormat -): ContentModelParagraph { +): ContentModelParagraph; + +/** + * @internal + * Ensure there is a Paragraph that can insert segments in a Content Model Block Group (Shallow mutable) + * @param group The parent block group of the target paragraph + * @param blockFormat Format of the paragraph. This is only used if we need to create a new paragraph + */ +export function ensureParagraph( + group: ShallowMutableContentModelBlockGroup, + blockFormat?: ContentModelBlockFormat, + segmentFormat?: ContentModelSegmentFormat +): ShallowMutableContentModelParagraph; + +export function ensureParagraph( + group: ShallowMutableContentModelBlockGroup, + blockFormat?: ContentModelBlockFormat, + segmentFormat?: ContentModelSegmentFormat +): ShallowMutableContentModelParagraph { const lastBlock = group.blocks[group.blocks.length - 1]; if (lastBlock?.blockType == 'Paragraph') { - return lastBlock; + return mutateBlock(lastBlock); } else { const paragraph = createParagraph(true, blockFormat, segmentFormat); addBlock(group, paragraph); diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/common/isEmpty.ts b/packages/roosterjs-content-model-dom/lib/modelApi/common/isEmpty.ts index 64a1944437b..ae399702e2d 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/common/isEmpty.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/common/isEmpty.ts @@ -1,13 +1,13 @@ import type { - ContentModelBlock, - ContentModelBlockGroup, - ContentModelSegment, + ReadonlyContentModelBlock, + ReadonlyContentModelBlockGroup, + ReadonlyContentModelSegment, } from 'roosterjs-content-model-types'; /** * @internal */ -export function isBlockEmpty(block: ContentModelBlock): boolean { +export function isBlockEmpty(block: ReadonlyContentModelBlock): boolean { switch (block.blockType) { case 'Paragraph': return block.segments.length == 0; @@ -29,7 +29,7 @@ export function isBlockEmpty(block: ContentModelBlock): boolean { /** * @internal */ -export function isBlockGroupEmpty(group: ContentModelBlockGroup): boolean { +export function isBlockGroupEmpty(group: ReadonlyContentModelBlockGroup): boolean { switch (group.blockGroupType) { case 'FormatContainer': // Format Container of DIV is a container for style, so we always treat it as not empty @@ -51,7 +51,7 @@ export function isBlockGroupEmpty(group: ContentModelBlockGroup): boolean { /** * @internal */ -export function isSegmentEmpty(segment: ContentModelSegment): boolean { +export function isSegmentEmpty(segment: ReadonlyContentModelSegment): boolean { switch (segment.segmentType) { case 'Text': return !segment.text; @@ -69,7 +69,7 @@ export function isSegmentEmpty(segment: ContentModelSegment): boolean { * @returns true if the model is empty. */ export function isEmpty( - model: ContentModelBlock | ContentModelBlockGroup | ContentModelSegment + model: ReadonlyContentModelBlock | ReadonlyContentModelBlockGroup | ReadonlyContentModelSegment ): boolean { if (isBlockGroup(model)) { return isBlockGroupEmpty(model); @@ -83,19 +83,19 @@ export function isEmpty( } function isSegment( - model: ContentModelBlock | ContentModelBlockGroup | ContentModelSegment -): model is ContentModelSegment { - return typeof (model).segmentType === 'string'; + model: ReadonlyContentModelBlock | ReadonlyContentModelBlockGroup | ReadonlyContentModelSegment +): model is ReadonlyContentModelSegment { + return typeof (model).segmentType === 'string'; } function isBlock( - model: ContentModelBlock | ContentModelBlockGroup | ContentModelSegment -): model is ContentModelBlock { - return typeof (model).blockType === 'string'; + model: ReadonlyContentModelBlock | ReadonlyContentModelBlockGroup | ReadonlyContentModelSegment +): model is ReadonlyContentModelBlock { + return typeof (model).blockType === 'string'; } function isBlockGroup( - model: ContentModelBlock | ContentModelBlockGroup | ContentModelSegment -): model is ContentModelBlockGroup { - return typeof (model).blockGroupType === 'string'; + model: ReadonlyContentModelBlock | ReadonlyContentModelBlockGroup | ReadonlyContentModelSegment +): model is ReadonlyContentModelBlockGroup { + return typeof (model).blockGroupType === 'string'; } diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/common/mutate.ts b/packages/roosterjs-content-model-dom/lib/modelApi/common/mutate.ts new file mode 100644 index 00000000000..e2ce4bc59f3 --- /dev/null +++ b/packages/roosterjs-content-model-dom/lib/modelApi/common/mutate.ts @@ -0,0 +1,107 @@ +import type { + MutableType, + ReadonlyContentModelBlock, + ReadonlyContentModelBlockGroup, + ReadonlyContentModelListItem, + ReadonlyContentModelParagraph, + ReadonlyContentModelSegment, + ReadonlyContentModelTable, + ShallowMutableContentModelParagraph, + ShallowMutableContentModelSegment, +} from 'roosterjs-content-model-types'; + +/** + * Convert a readonly block to mutable block, clear cached element if exist + * @param block The block to convert from + * @returns The same block object of its related mutable type + */ +export function mutateBlock( + block: T +): MutableType { + if (block.cachedElement) { + delete block.cachedElement; + } + + if (isTable(block)) { + block.rows.forEach(row => { + delete row.cachedElement; + }); + } else if (isListItem(block)) { + block.levels.forEach(level => delete level.cachedElement); + } + + const result = (block as unknown) as MutableType; + + return result; +} + +/** + * Convert segments of a readonly paragraph to be mutable. + * Segments that are not belong to the given paragraph will be skipped + * @param paragraph The readonly paragraph to convert from + * @param segments The segments to convert from + */ +export function mutateSegments( + paragraph: ReadonlyContentModelParagraph, + segments: ReadonlyContentModelSegment[] +): [ShallowMutableContentModelParagraph, ShallowMutableContentModelSegment[], number[]] { + const mutablePara = mutateBlock(paragraph); + const result: [ + ShallowMutableContentModelParagraph, + ShallowMutableContentModelSegment[], + number[] + ] = [mutablePara, [], []]; + + if (segments) { + segments.forEach(segment => { + const index = paragraph.segments.indexOf(segment); + + if (index >= 0) { + result[1].push(mutablePara.segments[index]); + result[2].push(index); + } + }); + } + + return result; +} + +/** + * Convert a readonly segment to be mutable, together with its owner paragraph + * If the segment does not belong to the given paragraph, return null for the segment + * @param paragraph The readonly paragraph to convert from + * @param segment The segment to convert from + */ +export function mutateSegment( + paragraph: ReadonlyContentModelParagraph, + segment: T, + callback?: ( + segment: MutableType, + paragraph: ShallowMutableContentModelParagraph, + index: number + ) => void +): [ShallowMutableContentModelParagraph, MutableType | null, number] { + const [mutablePara, mutableSegments, indexes] = mutateSegments(paragraph, [segment]); + const mutableSegment = + (mutableSegments[0] as ReadonlyContentModelSegment) == segment + ? (mutableSegments[0] as MutableType) + : null; + + if (callback && mutableSegment) { + callback(mutableSegments[0] as MutableType, mutablePara, indexes[0]); + } + + return [mutablePara, mutableSegment, indexes[0] ?? -1]; +} + +function isTable( + obj: ReadonlyContentModelBlockGroup | ReadonlyContentModelBlock +): obj is ReadonlyContentModelTable { + return (obj as ReadonlyContentModelTable).blockType == 'Table'; +} + +function isListItem( + obj: ReadonlyContentModelBlockGroup | ReadonlyContentModelBlock +): obj is ReadonlyContentModelListItem { + return (obj as ReadonlyContentModelListItem).blockGroupType == 'ListItem'; +} diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel.ts b/packages/roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel.ts index a808e975a4c..46f648c85a1 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel.ts @@ -1,7 +1,8 @@ import { isBlockEmpty } from './isEmpty'; +import { mutateBlock } from './mutate'; import { normalizeParagraph } from './normalizeParagraph'; import { unwrapBlock } from './unwrapBlock'; -import type { ContentModelBlockGroup } from 'roosterjs-content-model-types'; +import type { ReadonlyContentModelBlockGroup } from 'roosterjs-content-model-types'; /** * For a given content model, normalize it to make the model be consistent. @@ -12,7 +13,7 @@ import type { ContentModelBlockGroup } from 'roosterjs-content-model-types'; * - For an empty block, remove it * @param group The root level block group of content model to normalize */ -export function normalizeContentModel(group: ContentModelBlockGroup) { +export function normalizeContentModel(group: ReadonlyContentModelBlockGroup) { for (let i = group.blocks.length - 1; i >= 0; i--) { const block = group.blocks[i]; @@ -40,7 +41,7 @@ export function normalizeContentModel(group: ContentModelBlockGroup) { } if (isBlockEmpty(block)) { - group.blocks.splice(i, 1); + mutateBlock(group).blocks.splice(i, 1); } } } diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/common/normalizeParagraph.ts b/packages/roosterjs-content-model-dom/lib/modelApi/common/normalizeParagraph.ts index 14616124d47..b9c120c5a83 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/common/normalizeParagraph.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/common/normalizeParagraph.ts @@ -2,18 +2,19 @@ import { areSameFormats } from '../../domToModel/utils/areSameFormats'; import { createBr } from '../creators/createBr'; import { isSegmentEmpty } from './isEmpty'; import { isWhiteSpacePreserved } from '../../domUtils/isWhiteSpacePreserved'; +import { mutateBlock, mutateSegment } from './mutate'; import { normalizeAllSegments } from './normalizeSegment'; import type { - ContentModelParagraph, - ContentModelSegment, ContentModelSegmentFormat, + ReadonlyContentModelParagraph, + ReadonlyContentModelSegment, } from 'roosterjs-content-model-types'; /** * @param paragraph The paragraph to normalize * Normalize a paragraph. If it is empty, add a BR segment to make sure it can insert content */ -export function normalizeParagraph(paragraph: ContentModelParagraph) { +export function normalizeParagraph(paragraph: ReadonlyContentModelParagraph) { const segments = paragraph.segments; if (!paragraph.isImplicit && segments.length > 0) { @@ -24,7 +25,7 @@ export function normalizeParagraph(paragraph: ContentModelParagraph) { last.segmentType == 'SelectionMarker' && (!secondLast || secondLast.segmentType == 'Br') ) { - segments.push(createBr(last.format)); + mutateBlock(paragraph).segments.push(createBr(last.format)); } else if (segments.length > 1 && segments[segments.length - 1].segmentType == 'Br') { const noMarkerSegments = segments.filter(x => x.segmentType != 'SelectionMarker'); @@ -34,7 +35,7 @@ export function normalizeParagraph(paragraph: ContentModelParagraph) { noMarkerSegments.length > 1 && noMarkerSegments[noMarkerSegments.length - 2].segmentType != 'Br' ) { - segments.pop(); + mutateBlock(paragraph).segments.pop(); } } } @@ -50,20 +51,21 @@ export function normalizeParagraph(paragraph: ContentModelParagraph) { moveUpSegmentFormat(paragraph); } -function removeEmptySegments(block: ContentModelParagraph) { +function removeEmptySegments(block: ReadonlyContentModelParagraph) { for (let j = block.segments.length - 1; j >= 0; j--) { if (isSegmentEmpty(block.segments[j])) { - block.segments.splice(j, 1); + mutateBlock(block).segments.splice(j, 1); } } } -function removeEmptyLinks(paragraph: ContentModelParagraph) { +function removeEmptyLinks(paragraph: ReadonlyContentModelParagraph) { const marker = paragraph.segments.find(x => x.segmentType == 'SelectionMarker'); if (marker) { const markerIndex = paragraph.segments.indexOf(marker); const prev = paragraph.segments[markerIndex - 1]; const next = paragraph.segments[markerIndex + 1]; + if ( (prev && !prev.link && @@ -76,7 +78,9 @@ function removeEmptyLinks(paragraph: ContentModelParagraph) { !next.link && areSameFormats(next.format, marker.format)) ) { - delete marker.link; + mutateSegment(paragraph, marker, mutableMarker => { + delete mutableMarker.link; + }); } } } @@ -85,7 +89,7 @@ type FormatsToMoveUp = 'fontFamily' | 'fontSize' | 'textColor'; const formatsToMoveUp: FormatsToMoveUp[] = ['fontFamily', 'fontSize', 'textColor']; // When all segments are sharing the same segment format (font name, size and color), we can move its format to paragraph -function moveUpSegmentFormat(paragraph: ContentModelParagraph) { +function moveUpSegmentFormat(paragraph: ReadonlyContentModelParagraph) { if (!paragraph.decorator) { const segments = paragraph.segments.filter(x => x.segmentType != 'SelectionMarker'); const target = paragraph.segmentFormat || {}; @@ -96,13 +100,13 @@ function moveUpSegmentFormat(paragraph: ContentModelParagraph) { }); if (changed) { - paragraph.segmentFormat = target; + mutateBlock(paragraph).segmentFormat = target; } } } function internalMoveUpSegmentFormat( - segments: ContentModelSegment[], + segments: ReadonlyContentModelSegment[], target: ContentModelSegmentFormat, formatKey: FormatsToMoveUp ): boolean { diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/common/normalizeSegment.ts b/packages/roosterjs-content-model-dom/lib/modelApi/common/normalizeSegment.ts index 59bb9be59e8..d191f4ab61c 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/common/normalizeSegment.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/common/normalizeSegment.ts @@ -1,8 +1,9 @@ import { hasSpacesOnly } from './hasSpacesOnly'; +import { mutateSegment } from './mutate'; import type { - ContentModelParagraph, - ContentModelSegment, - ContentModelText, + ReadonlyContentModelParagraph, + ReadonlyContentModelSegment, + ReadonlyContentModelText, } from 'roosterjs-content-model-types'; const SPACE = '\u0020'; @@ -13,15 +14,15 @@ const TRAILING_SPACE_REGEX = /\u0020+$/; /** * @internal */ -export function normalizeAllSegments(paragraph: ContentModelParagraph) { +export function normalizeAllSegments(paragraph: ReadonlyContentModelParagraph) { const context = resetNormalizeSegmentContext(); paragraph.segments.forEach(segment => { - normalizeSegment(segment, context); + normalizeSegment(paragraph, segment, context); }); - normalizeTextSegments(context.textSegments, context.lastInlineSegment); - normalizeLastTextSegment(context.lastTextSegment, context.lastInlineSegment); + normalizeTextSegments(paragraph, context.textSegments, context.lastInlineSegment); + normalizeLastTextSegment(paragraph, context.lastTextSegment, context.lastInlineSegment); } /** @@ -30,24 +31,25 @@ export function normalizeAllSegments(paragraph: ContentModelParagraph) { * @param ignoreTrailingSpaces Whether we should ignore the trailing space of the text segment @default false */ export function normalizeSingleSegment( - segment: ContentModelSegment, + paragraph: ReadonlyContentModelParagraph, + segment: ReadonlyContentModelSegment, ignoreTrailingSpaces: boolean = false ) { const context = resetNormalizeSegmentContext(); context.ignoreTrailingSpaces = ignoreTrailingSpaces; - normalizeSegment(segment, context); + normalizeSegment(paragraph, segment, context); } /** * @internal Export for test only */ export interface NormalizeSegmentContext { - textSegments: ContentModelText[]; + textSegments: ReadonlyContentModelText[]; ignoreLeadingSpaces: boolean; ignoreTrailingSpaces: boolean; - lastTextSegment: ContentModelText | undefined; - lastInlineSegment: ContentModelSegment | undefined; + lastTextSegment: ReadonlyContentModelText | undefined; + lastInlineSegment: ReadonlyContentModelSegment | undefined; } /** @@ -72,11 +74,15 @@ function resetNormalizeSegmentContext( /** * @internal Export for test only */ -export function normalizeSegment(segment: ContentModelSegment, context: NormalizeSegmentContext) { +export function normalizeSegment( + paragraph: ReadonlyContentModelParagraph, + segment: ReadonlyContentModelSegment, + context: NormalizeSegmentContext +) { switch (segment.segmentType) { case 'Br': - normalizeTextSegments(context.textSegments, context.lastInlineSegment); - normalizeLastTextSegment(context.lastTextSegment, context.lastInlineSegment); + normalizeTextSegments(paragraph, context.textSegments, context.lastInlineSegment); + normalizeLastTextSegment(paragraph, context.lastTextSegment, context.lastInlineSegment); // Line ends, reset all states resetNormalizeSegmentContext(context); @@ -103,18 +109,22 @@ export function normalizeSegment(segment: ContentModelSegment, context: Normaliz if (!hasSpacesOnly(segment.text)) { if (first == SPACE) { // 1. Multiple leading space => single   or empty (depends on if previous segment ends with space) - segment.text = segment.text.replace( - LEADING_SPACE_REGEX, - context.ignoreLeadingSpaces ? '' : NONE_BREAK_SPACE - ); + mutateSegment(paragraph, segment, textSegment => { + textSegment.text = textSegment.text.replace( + LEADING_SPACE_REGEX, + context.ignoreLeadingSpaces ? '' : NONE_BREAK_SPACE + ); + }); } if (last == SPACE) { // 2. Multiple trailing space => single space - segment.text = segment.text.replace( - TRAILING_SPACE_REGEX, - context.ignoreTrailingSpaces ? SPACE : NONE_BREAK_SPACE - ); + mutateSegment(paragraph, segment, textSegment => { + textSegment.text = textSegment.text.replace( + TRAILING_SPACE_REGEX, + context.ignoreTrailingSpaces ? SPACE : NONE_BREAK_SPACE + ); + }); } } @@ -125,8 +135,9 @@ export function normalizeSegment(segment: ContentModelSegment, context: Normaliz } function normalizeTextSegments( - segments: ContentModelText[], - lastInlineSegment: ContentModelSegment | undefined + paragraph: ReadonlyContentModelParagraph, + segments: ReadonlyContentModelText[], + lastInlineSegment: ReadonlyContentModelSegment | undefined ) { segments.forEach(segment => { // 3. Segment ends with   replace it with space if the previous char is not space so that next segment can wrap @@ -139,18 +150,23 @@ function normalizeTextSegments( text.length > 1 && text.substr(-2, 1) != SPACE ) { - segment.text = text.substring(0, text.length - 1) + SPACE; + mutateSegment(paragraph, segment, textSegment => { + textSegment.text = text.substring(0, text.length - 1) + SPACE; + }); } } }); } function normalizeLastTextSegment( - segment: ContentModelText | undefined, - lastInlineSegment: ContentModelSegment | undefined + paragraph: ReadonlyContentModelParagraph, + segment: ReadonlyContentModelText | undefined, + lastInlineSegment: ReadonlyContentModelSegment | undefined ) { if (segment && segment == lastInlineSegment && segment?.text.substr(-1) == SPACE) { // 4. last text segment of the paragraph, remove trailing space - segment.text = segment.text.replace(TRAILING_SPACE_REGEX, ''); + mutateSegment(paragraph, segment, textSegment => { + textSegment.text = textSegment.text.replace(TRAILING_SPACE_REGEX, ''); + }); } } diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/common/unwrapBlock.ts b/packages/roosterjs-content-model-dom/lib/modelApi/common/unwrapBlock.ts index 312f007bfab..375f0a22117 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/common/unwrapBlock.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/common/unwrapBlock.ts @@ -1,5 +1,9 @@ +import { mutateBlock } from './mutate'; import { setParagraphNotImplicit } from '../block/setParagraphNotImplicit'; -import type { ContentModelBlock, ContentModelBlockGroup } from 'roosterjs-content-model-types'; +import type { + ReadonlyContentModelBlock, + ReadonlyContentModelBlockGroup, +} from 'roosterjs-content-model-types'; /** * Unwrap a given block group, move its child blocks to be under its parent group @@ -7,14 +11,16 @@ import type { ContentModelBlock, ContentModelBlockGroup } from 'roosterjs-conten * @param groupToUnwrap The block group to unwrap */ export function unwrapBlock( - parent: ContentModelBlockGroup | null, - groupToUnwrap: ContentModelBlockGroup & ContentModelBlock + parent: ReadonlyContentModelBlockGroup | null, + groupToUnwrap: ReadonlyContentModelBlockGroup & ReadonlyContentModelBlock ) { const index = parent?.blocks.indexOf(groupToUnwrap) ?? -1; if (index >= 0) { groupToUnwrap.blocks.forEach(setParagraphNotImplicit); - parent?.blocks.splice(index, 1, ...groupToUnwrap.blocks); + if (parent) { + mutateBlock(parent)?.blocks.splice(index, 1, ...groupToUnwrap.blocks.map(mutateBlock)); + } } } diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/creators/createBr.ts b/packages/roosterjs-content-model-dom/lib/modelApi/creators/createBr.ts index f5c907211b7..55e53d373ce 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/creators/createBr.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/creators/createBr.ts @@ -4,9 +4,9 @@ import type { ContentModelBr, ContentModelSegmentFormat } from 'roosterjs-conten * Create a ContentModelBr model * @param format @optional The format of this model */ -export function createBr(format?: ContentModelSegmentFormat): ContentModelBr { +export function createBr(format?: Readonly): ContentModelBr { return { segmentType: 'Br', - format: format ? { ...format } : {}, + format: { ...format }, }; } diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/creators/createContentModelDocument.ts b/packages/roosterjs-content-model-dom/lib/modelApi/creators/createContentModelDocument.ts index d4ef4a4d539..ea30b78b8ba 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/creators/createContentModelDocument.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/creators/createContentModelDocument.ts @@ -8,7 +8,7 @@ import type { * @param defaultFormat @optional Default format of this model */ export function createContentModelDocument( - defaultFormat?: ContentModelSegmentFormat + defaultFormat?: Readonly ): ContentModelDocument { const result: ContentModelDocument = { blockGroupType: 'Document', diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/creators/createDivider.ts b/packages/roosterjs-content-model-dom/lib/modelApi/creators/createDivider.ts index f6d058dd4ea..bd0a27d6342 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/creators/createDivider.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/creators/createDivider.ts @@ -7,11 +7,11 @@ import type { ContentModelBlockFormat, ContentModelDivider } from 'roosterjs-con */ export function createDivider( tagName: 'hr' | 'div', - format?: ContentModelBlockFormat + format?: Readonly ): ContentModelDivider { return { blockType: 'Divider', tagName, - format: format ? { ...format } : {}, + format: { ...format }, }; } diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/creators/createEmptyModel.ts b/packages/roosterjs-content-model-dom/lib/modelApi/creators/createEmptyModel.ts index 23c3e2c439a..d255f33d1c5 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/creators/createEmptyModel.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/creators/createEmptyModel.ts @@ -11,7 +11,9 @@ import type { * 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 { +export function createEmptyModel( + format?: Readonly +): ContentModelDocument { const model = createContentModelDocument(format); const paragraph = createParagraph(false /*isImplicit*/, undefined /*blockFormat*/, format); diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/creators/createEntity.ts b/packages/roosterjs-content-model-dom/lib/modelApi/creators/createEntity.ts index da66859a67b..b323e5a4f97 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/creators/createEntity.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/creators/createEntity.ts @@ -11,7 +11,7 @@ import type { ContentModelEntity, ContentModelSegmentFormat } from 'roosterjs-co export function createEntity( wrapper: HTMLElement, isReadonly: boolean = true, - segmentFormat?: ContentModelSegmentFormat, + segmentFormat?: Readonly, type?: string, id?: string ): ContentModelEntity { diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/creators/createFormatContainer.ts b/packages/roosterjs-content-model-dom/lib/modelApi/creators/createFormatContainer.ts index 9bf73a25b2c..bc4d7962ea3 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/creators/createFormatContainer.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/creators/createFormatContainer.ts @@ -10,13 +10,13 @@ import type { */ export function createFormatContainer( tag: Lowercase, - format?: ContentModelFormatContainerFormat + format?: Readonly ): ContentModelFormatContainer { return { blockType: 'BlockGroup', blockGroupType: 'FormatContainer', tagName: tag, blocks: [], - format: { ...(format || {}) }, + format: { ...format }, }; } diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/creators/createGeneralSegment.ts b/packages/roosterjs-content-model-dom/lib/modelApi/creators/createGeneralSegment.ts index e92400efd87..b21c253ab96 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/creators/createGeneralSegment.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/creators/createGeneralSegment.ts @@ -10,13 +10,13 @@ import type { */ export function createGeneralSegment( element: HTMLElement, - format?: ContentModelSegmentFormat + format?: Readonly ): ContentModelGeneralSegment { return { blockType: 'BlockGroup', blockGroupType: 'General', segmentType: 'General', - format: format ? { ...format } : {}, + format: { ...format }, blocks: [], element: element, }; diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/creators/createImage.ts b/packages/roosterjs-content-model-dom/lib/modelApi/creators/createImage.ts index 44c3d744d6d..1114ce41b67 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/creators/createImage.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/creators/createImage.ts @@ -5,11 +5,14 @@ import type { ContentModelImage, ContentModelImageFormat } from 'roosterjs-conte * @param src Image source * @param format @optional The format of this model */ -export function createImage(src: string, format?: ContentModelImageFormat): ContentModelImage { +export function createImage( + src: string, + format?: Readonly +): ContentModelImage { return { segmentType: 'Image', src: src, - format: format ? { ...format } : {}, + format: { ...format }, dataset: {}, }; } diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/creators/createListItem.ts b/packages/roosterjs-content-model-dom/lib/modelApi/creators/createListItem.ts index fbad6b221de..bbfec49ca13 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/creators/createListItem.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/creators/createListItem.ts @@ -2,8 +2,8 @@ import { createListLevel } from './createListLevel'; import { createSelectionMarker } from './createSelectionMarker'; import type { ContentModelListItem, - ContentModelListLevel, ContentModelSegmentFormat, + ReadonlyContentModelListLevel, } from 'roosterjs-content-model-types'; /** @@ -12,8 +12,8 @@ import type { * @param format @optional The format of this model */ export function createListItem( - levels: ContentModelListLevel[], - format?: ContentModelSegmentFormat + levels: ReadonlyArray, + format?: Readonly ): ContentModelListItem { const formatHolder = createSelectionMarker(format); diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/creators/createListLevel.ts b/packages/roosterjs-content-model-dom/lib/modelApi/creators/createListLevel.ts index 4355b02171c..25b8eb3946e 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/creators/createListLevel.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/creators/createListLevel.ts @@ -1,7 +1,7 @@ import type { ContentModelListItemLevelFormat, ContentModelListLevel, - DatasetFormat, + ReadonlyDatasetFormat, } from 'roosterjs-content-model-types'; /** @@ -12,8 +12,8 @@ import type { */ export function createListLevel( listType: 'OL' | 'UL', - format?: ContentModelListItemLevelFormat, - dataset?: DatasetFormat + format?: Readonly, + dataset?: ReadonlyDatasetFormat ): ContentModelListLevel { return { listType, diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/creators/createParagraph.ts b/packages/roosterjs-content-model-dom/lib/modelApi/creators/createParagraph.ts index 06e3226eecd..f987bc2d66c 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/creators/createParagraph.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/creators/createParagraph.ts @@ -1,8 +1,8 @@ import type { ContentModelBlockFormat, ContentModelParagraph, - ContentModelParagraphDecorator, ContentModelSegmentFormat, + ReadonlyContentModelParagraphDecorator, } from 'roosterjs-content-model-types'; /** @@ -14,14 +14,14 @@ import type { */ export function createParagraph( isImplicit?: boolean, - blockFormat?: ContentModelBlockFormat, - segmentFormat?: ContentModelSegmentFormat, - decorator?: ContentModelParagraphDecorator + blockFormat?: Readonly, + segmentFormat?: Readonly, + decorator?: ReadonlyContentModelParagraphDecorator ): ContentModelParagraph { const result: ContentModelParagraph = { blockType: 'Paragraph', segments: [], - format: blockFormat ? { ...blockFormat } : {}, + format: { ...blockFormat }, }; if (segmentFormat && Object.keys(segmentFormat).length > 0) { diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/creators/createParagraphDecorator.ts b/packages/roosterjs-content-model-dom/lib/modelApi/creators/createParagraphDecorator.ts index fb5e5835ed6..cb924ee780d 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/creators/createParagraphDecorator.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/creators/createParagraphDecorator.ts @@ -10,10 +10,10 @@ import type { */ export function createParagraphDecorator( tagName: string, - format?: ContentModelSegmentFormat + format?: Readonly ): ContentModelParagraphDecorator { return { tagName: tagName.toLocaleLowerCase(), - format: { ...(format || {}) }, + format: { ...format }, }; } diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/creators/createSelectionMarker.ts b/packages/roosterjs-content-model-dom/lib/modelApi/creators/createSelectionMarker.ts index 88f5ac2e86a..e8ec164d896 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/creators/createSelectionMarker.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/creators/createSelectionMarker.ts @@ -8,11 +8,11 @@ import type { * @param format @optional The format of this model */ export function createSelectionMarker( - format?: ContentModelSegmentFormat + format?: Readonly ): ContentModelSelectionMarker { return { segmentType: 'SelectionMarker', isSelected: true, - format: format ? { ...format } : {}, + format: { ...format }, }; } diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/creators/createTable.ts b/packages/roosterjs-content-model-dom/lib/modelApi/creators/createTable.ts index 7a549dac31f..2d62d7cfb09 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/creators/createTable.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/creators/createTable.ts @@ -1,3 +1,4 @@ +import { createTableRow } from './createTableRow'; import type { ContentModelTable, ContentModelTableFormat, @@ -9,21 +10,20 @@ import type { * @param rowCount Count of rows of this table * @param format @optional The format of this model */ -export function createTable(rowCount: number, format?: ContentModelTableFormat): ContentModelTable { +export function createTable( + rowCount: number, + format?: Readonly +): ContentModelTable { const rows: ContentModelTableRow[] = []; for (let i = 0; i < rowCount; i++) { - rows.push({ - height: 0, - format: {}, - cells: [], - }); + rows.push(createTableRow()); } return { blockType: 'Table', rows, - format: { ...(format || {}) }, + format: { ...format }, widths: [], dataset: {}, }; diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/creators/createTableCell.ts b/packages/roosterjs-content-model-dom/lib/modelApi/creators/createTableCell.ts index 79d67f76a16..cfe036d6398 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/creators/createTableCell.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/creators/createTableCell.ts @@ -1,7 +1,7 @@ import type { ContentModelTableCell, ContentModelTableCellFormat, - DatasetFormat, + ReadonlyDatasetFormat, } from 'roosterjs-content-model-types'; /** @@ -15,8 +15,8 @@ export function createTableCell( spanLeftOrColSpan?: boolean | number, spanAboveOrRowSpan?: boolean | number, isHeader?: boolean, - format?: ContentModelTableCellFormat, - dataset?: DatasetFormat + format?: Readonly, + dataset?: ReadonlyDatasetFormat ): ContentModelTableCell { const spanLeft = typeof spanLeftOrColSpan === 'number' ? spanLeftOrColSpan > 1 : !!spanLeftOrColSpan; @@ -25,7 +25,7 @@ export function createTableCell( return { blockGroupType: 'TableCell', blocks: [], - format: format ? { ...format } : {}, + format: { ...format }, spanLeft, spanAbove, isHeader: !!isHeader, diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/creators/createTableRow.ts b/packages/roosterjs-content-model-dom/lib/modelApi/creators/createTableRow.ts new file mode 100644 index 00000000000..3e9608b6929 --- /dev/null +++ b/packages/roosterjs-content-model-dom/lib/modelApi/creators/createTableRow.ts @@ -0,0 +1,16 @@ +import type { ContentModelBlockFormat, ContentModelTableRow } from 'roosterjs-content-model-types'; + +/** + * Create a ContentModelTableRow model + * @param format @optional The format of this model + */ +export function createTableRow( + format?: Readonly, + height: number = 0 +): ContentModelTableRow { + return { + height: height, + format: { ...format }, + cells: [], + }; +} diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/creators/createText.ts b/packages/roosterjs-content-model-dom/lib/modelApi/creators/createText.ts index 6f73d09c662..c837d11f5c9 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/creators/createText.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/creators/createText.ts @@ -1,9 +1,9 @@ import { addCode, addLink } from '../common/addDecorators'; import type { - ContentModelCode, - ContentModelLink, ContentModelSegmentFormat, ContentModelText, + ReadonlyContentModelCode, + ReadonlyContentModelLink, } from 'roosterjs-content-model-types'; /** @@ -15,14 +15,14 @@ import type { */ export function createText( text: string, - format?: ContentModelSegmentFormat, - link?: ContentModelLink, - code?: ContentModelCode + format?: Readonly, + link?: ReadonlyContentModelLink, + code?: ReadonlyContentModelCode ): ContentModelText { const result: ContentModelText = { segmentType: 'Text', text: text, - format: format ? { ...format } : {}, + format: { ...format }, }; if (link) { diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/editing/applyTableFormat.ts b/packages/roosterjs-content-model-dom/lib/modelApi/editing/applyTableFormat.ts index 7ef33b0ce18..942a638fe91 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/editing/applyTableFormat.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/editing/applyTableFormat.ts @@ -1,13 +1,14 @@ import { BorderKeys } from '../../formatHandlers/common/borderFormatHandler'; import { combineBorderValue, extractBorderValues } from '../../domUtils/style/borderValues'; +import { mutateBlock } from '../common/mutate'; import { setTableCellBackgroundColor } from './setTableCellBackgroundColor'; import { TableBorderFormat } from '../../constants/TableBorderFormat'; import { updateTableCellMetadata } from '../metadata/updateTableCellMetadata'; import { updateTableMetadata } from '../metadata/updateTableMetadata'; import type { BorderFormat, - ContentModelTable, - ContentModelTableRow, + ReadonlyContentModelTable, + ShallowMutableContentModelTableRow, TableMetadataFormat, } from 'roosterjs-content-model-types'; @@ -39,42 +40,34 @@ type MetaOverrides = { * @param keepCellShade @optional When pass true, table cells with customized shade color will not be overwritten. @default false */ export function applyTableFormat( - table: ContentModelTable, + table: ReadonlyContentModelTable, newFormat?: TableMetadataFormat, keepCellShade?: boolean ) { - const { rows } = table; + const mutableTable = mutateBlock(table); + const { rows } = mutableTable; - updateTableMetadata(table, format => { + updateTableMetadata(mutableTable, format => { const effectiveMetadata = { ...DEFAULT_FORMAT, ...format, - ...(newFormat || {}), + ...newFormat, }; const metaOverrides: MetaOverrides = updateOverrides(rows, !keepCellShade); - delete table.cachedElement; - - clearCache(rows); formatCells(rows, effectiveMetadata, metaOverrides); - setFirstColumnFormat(rows, effectiveMetadata, metaOverrides); + setFirstColumnFormatBorders(rows, effectiveMetadata); setHeaderRowFormat(rows, effectiveMetadata, metaOverrides); - return effectiveMetadata; - }); -} -function clearCache(rows: ContentModelTableRow[]) { - rows.forEach(row => { - row.cells.forEach(cell => { - delete cell.cachedElement; - }); - - delete row.cachedElement; + return effectiveMetadata; }); } -function updateOverrides(rows: ContentModelTableRow[], removeCellShade: boolean): MetaOverrides { +function updateOverrides( + rows: ShallowMutableContentModelTableRow[], + removeCellShade: boolean +): MetaOverrides { const overrides: MetaOverrides = { bgColorOverrides: [], vAlignOverrides: [], @@ -91,7 +84,7 @@ function updateOverrides(rows: ContentModelTableRow[], removeCellShade: boolean) overrides.borderOverrides.push(borderOverrides); row.cells.forEach(cell => { - updateTableCellMetadata(cell, metadata => { + updateTableCellMetadata(mutateBlock(cell), metadata => { if (metadata && removeCellShade) { bgColorOverrides.push(false); delete metadata.bgColorOverride; @@ -172,14 +165,16 @@ const BorderFormatters: Record = * Apply vertical align, borders, and background color to all cells in the table */ function formatCells( - rows: ContentModelTableRow[], + rows: ShallowMutableContentModelTableRow[], format: TableMetadataFormat, metaOverrides: MetaOverrides ) { - const { hasBandedRows, hasBandedColumns, bgColorOdd, bgColorEven } = format; + const { hasBandedRows, hasBandedColumns, bgColorOdd, bgColorEven, hasFirstColumn } = format; rows.forEach((row, rowIndex) => { - row.cells.forEach((cell, colIndex) => { + row.cells.forEach((readonlyCell, colIndex) => { + const cell = mutateBlock(readonlyCell); + // Format Borders if ( !metaOverrides.borderOverrides[rowIndex][colIndex] && @@ -212,14 +207,18 @@ function formatCells( // Format Background Color if (!metaOverrides.bgColorOverrides[rowIndex][colIndex]) { - const color = - hasBandedRows || hasBandedColumns - ? (hasBandedColumns && colIndex % 2 != 0) || - (hasBandedRows && rowIndex % 2 != 0) - ? bgColorOdd - : bgColorEven - : bgColorEven; /* bgColorEven is the default color */ - + let color: string | null | undefined; + if (hasFirstColumn && colIndex == 0 && rowIndex > 0) { + color = null; + } else { + color = + hasBandedRows || hasBandedColumns + ? (hasBandedColumns && colIndex % 2 != 0) || + (hasBandedRows && rowIndex % 2 != 0) + ? bgColorOdd + : bgColorEven + : bgColorEven; /* bgColorEven is the default color */ + } setTableCellBackgroundColor( cell, color, @@ -232,51 +231,72 @@ function formatCells( if (format.verticalAlign && !metaOverrides.vAlignOverrides[rowIndex][colIndex]) { cell.format.verticalAlign = format.verticalAlign; } + + // Format Header + cell.isHeader = false; }); }); } -function setFirstColumnFormat( - rows: ContentModelTableRow[], - format: Partial, - metaOverrides: MetaOverrides +/** + * Set the first column format borders for the table as well as header property + * @param rows The rows of the table + * @param format The table metadata format + */ +export function setFirstColumnFormatBorders( + rows: ShallowMutableContentModelTableRow[], + format: Partial ) { + // Exit early hasFirstColumn is not set + if (!format.hasFirstColumn) { + return; + } + rows.forEach((row, rowIndex) => { - row.cells.forEach((cell, cellIndex) => { - if (format.hasFirstColumn && cellIndex === 0) { - cell.isHeader = true; + row.cells.forEach((readonlyCell, cellIndex) => { + const cell = mutateBlock(readonlyCell); - if (rowIndex !== 0 && !metaOverrides.bgColorOverrides[rowIndex][cellIndex]) { - setBorderColor(cell.format, 'borderTop'); - setTableCellBackgroundColor( - cell, - null /*color*/, - false /*isColorOverride*/, - true /*applyToSegments*/ - ); - } + if (cellIndex === 0) { + cell.isHeader = true; - if (rowIndex !== rows.length - 1 && rowIndex !== 0) { - setBorderColor(cell.format, 'borderBottom'); + switch (rowIndex) { + case 0: + cell.isHeader = !!format.hasHeaderRow; + break; + case rows.length - 1: + setBorderColor(cell.format, 'borderTop'); + break; + case 1: + setBorderColor(cell.format, 'borderBottom'); + break; + default: + setBorderColor(cell.format, 'borderTop'); + setBorderColor(cell.format, 'borderBottom'); + break; } - } else { - cell.isHeader = false; } }); }); } function setHeaderRowFormat( - rows: ContentModelTableRow[], + rows: ShallowMutableContentModelTableRow[], format: TableMetadataFormat, metaOverrides: MetaOverrides ) { + // Exit early if hasHeaderRow is not set + if (!format.hasHeaderRow) { + return; + } + const rowIndex = 0; - rows[rowIndex]?.cells.forEach((cell, cellIndex) => { - cell.isHeader = format.hasHeaderRow; + rows[rowIndex]?.cells.forEach((readonlyCell, cellIndex) => { + const cell = mutateBlock(readonlyCell); - if (format.hasHeaderRow && format.headerRowColor) { + cell.isHeader = true; + + if (format.headerRowColor) { if (!metaOverrides.bgColorOverrides[rowIndex][cellIndex]) { setTableCellBackgroundColor( cell, @@ -293,6 +313,11 @@ function setHeaderRowFormat( }); } +/** + * @param format The cell format to set the border color + * @param key The border key to set the color + * @param value The color to set. If not given, it removes the color and sets the style to transparent + */ function setBorderColor(format: BorderFormat, key: keyof BorderFormat, value?: string) { const border = extractBorderValues(format[key]); border.color = value || ''; diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/editing/cloneModel.ts b/packages/roosterjs-content-model-dom/lib/modelApi/editing/cloneModel.ts index 2bf5dc15344..b874b04a973 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/editing/cloneModel.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/editing/cloneModel.ts @@ -26,6 +26,26 @@ import type { ContentModelTableRow, ContentModelListLevel, CloneModelOptions, + ReadonlyContentModelDocument, + ReadonlyContentModelBlockGroupBase, + ReadonlyContentModelBlock, + ReadonlyContentModelFormatContainer, + ReadonlyContentModelBlockBase, + ReadonlyContentModelGeneralBlock, + ReadonlyContentModelListItem, + ReadonlyContentModelSelectionMarker, + ReadonlyContentModelSegmentBase, + ReadonlyContentModelWithDataset, + ReadonlyContentModelDivider, + ReadonlyContentModelListLevel, + ReadonlyContentModelParagraph, + ReadonlyContentModelSegment, + ReadonlyContentModelTable, + ReadonlyContentModelTableRow, + ReadonlyContentModelTableCell, + ReadonlyContentModelGeneralSegment, + ReadonlyContentModelImage, + ReadonlyContentModelText, } from 'roosterjs-content-model-types'; /** @@ -34,7 +54,7 @@ import type { * @param options @optional Options to specify customize the clone behavior */ export function cloneModel( - model: ContentModelDocument, + model: ReadonlyContentModelDocument, options?: CloneModelOptions ): ContentModelDocument { const newModel: ContentModelDocument = cloneBlockGroupBase(model, options || {}); @@ -46,7 +66,10 @@ export function cloneModel( return newModel; } -function cloneBlock(block: ContentModelBlock, options: CloneModelOptions): ContentModelBlock { +function cloneBlock( + block: ReadonlyContentModelBlock, + options: CloneModelOptions +): ContentModelBlock { switch (block.blockType) { case 'BlockGroup': switch (block.blockGroupType) { @@ -70,7 +93,7 @@ function cloneBlock(block: ContentModelBlock, options: CloneModelOptions): Conte } function cloneSegment( - segment: ContentModelSegment, + segment: ReadonlyContentModelSegment, options: CloneModelOptions ): ContentModelSegment { switch (segment.segmentType) { @@ -97,14 +120,16 @@ function cloneModelWithFormat( }; } -function cloneModelWithDataset(model: ContentModelWithDataset): ContentModelWithDataset { +function cloneModelWithDataset( + model: ReadonlyContentModelWithDataset +): ContentModelWithDataset { return { dataset: Object.assign({}, model.dataset), }; } function cloneBlockBase( - block: ContentModelBlockBase + block: ReadonlyContentModelBlockBase ): ContentModelBlockBase { const { blockType } = block; @@ -117,7 +142,7 @@ function cloneBlockBase( } function cloneBlockGroupBase( - group: ContentModelBlockGroupBase, + group: ReadonlyContentModelBlockGroupBase, options: CloneModelOptions ): ContentModelBlockGroupBase { const { blockGroupType, blocks } = group; @@ -129,7 +154,7 @@ function cloneBlockGroupBase( } function cloneSegmentBase( - segment: ContentModelSegmentBase + segment: ReadonlyContentModelSegmentBase ): ContentModelSegmentBase { const { segmentType, isSelected, code, link } = segment; @@ -165,7 +190,7 @@ function cloneEntity(entity: ContentModelEntity, options: CloneModelOptions): Co } function cloneParagraph( - paragraph: ContentModelParagraph, + paragraph: ReadonlyContentModelParagraph, options: CloneModelOptions ): ContentModelParagraph { const { cachedElement, segments, isImplicit, decorator, segmentFormat } = paragraph; @@ -193,7 +218,10 @@ function cloneParagraph( return newParagraph; } -function cloneTable(table: ContentModelTable, options: CloneModelOptions): ContentModelTable { +function cloneTable( + table: ReadonlyContentModelTable, + options: CloneModelOptions +): ContentModelTable { const { cachedElement, widths, rows } = table; return Object.assign( @@ -208,7 +236,7 @@ function cloneTable(table: ContentModelTable, options: CloneModelOptions): Conte } function cloneTableRow( - row: ContentModelTableRow, + row: ReadonlyContentModelTableRow, options: CloneModelOptions ): ContentModelTableRow { const { height, cells, cachedElement } = row; @@ -224,7 +252,7 @@ function cloneTableRow( } function cloneTableCell( - cell: ContentModelTableCell, + cell: ReadonlyContentModelTableCell, options: CloneModelOptions ): ContentModelTableCell { const { cachedElement, isSelected, spanAbove, spanLeft, isHeader } = cell; @@ -244,7 +272,7 @@ function cloneTableCell( } function cloneFormatContainer( - container: ContentModelFormatContainer, + container: ReadonlyContentModelFormatContainer, options: CloneModelOptions ): ContentModelFormatContainer { const { tagName, cachedElement } = container; @@ -262,28 +290,29 @@ function cloneFormatContainer( } function cloneListItem( - item: ContentModelListItem, + item: ReadonlyContentModelListItem, options: CloneModelOptions ): ContentModelListItem { - const { formatHolder, levels } = item; + const { formatHolder, levels, cachedElement } = item; return Object.assign( { formatHolder: cloneSelectionMarker(formatHolder), levels: levels.map(cloneListLevel), + cachedElement: handleCachedElement(cachedElement, 'cache', options), }, cloneBlockBase(item), cloneBlockGroupBase(item, options) ); } -function cloneListLevel(level: ContentModelListLevel): ContentModelListLevel { +function cloneListLevel(level: ReadonlyContentModelListLevel): ContentModelListLevel { const { listType } = level; return Object.assign({ listType }, cloneModelWithFormat(level), cloneModelWithDataset(level)); } function cloneDivider( - divider: ContentModelDivider, + divider: ReadonlyContentModelDivider, options: CloneModelOptions ): ContentModelDivider { const { tagName, isSelected, cachedElement } = divider; @@ -299,7 +328,7 @@ function cloneDivider( } function cloneGeneralBlock( - general: ContentModelGeneralBlock, + general: ReadonlyContentModelGeneralBlock, options: CloneModelOptions ): ContentModelGeneralBlock { const { element } = general; @@ -313,11 +342,13 @@ function cloneGeneralBlock( ); } -function cloneSelectionMarker(marker: ContentModelSelectionMarker): ContentModelSelectionMarker { +function cloneSelectionMarker( + marker: ReadonlyContentModelSelectionMarker +): ContentModelSelectionMarker { return Object.assign({ isSelected: marker.isSelected }, cloneSegmentBase(marker)); } -function cloneImage(image: ContentModelImage): ContentModelImage { +function cloneImage(image: ReadonlyContentModelImage): ContentModelImage { const { src, alt, title, isSelectedAsImageSelection } = image; return Object.assign( @@ -328,13 +359,13 @@ function cloneImage(image: ContentModelImage): ContentModelImage { } function cloneGeneralSegment( - general: ContentModelGeneralSegment, + general: ReadonlyContentModelGeneralSegment, options: CloneModelOptions ): ContentModelGeneralSegment { return Object.assign(cloneGeneralBlock(general, options), cloneSegmentBase(general)); } -function cloneText(textSegment: ContentModelText): ContentModelText { +function cloneText(textSegment: ReadonlyContentModelText): ContentModelText { const { text } = textSegment; return Object.assign({ text }, cloneSegmentBase(textSegment)); } diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/editing/deleteBlock.ts b/packages/roosterjs-content-model-dom/lib/modelApi/editing/deleteBlock.ts index 6b58dc828e9..a7acdd276a8 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/editing/deleteBlock.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/editing/deleteBlock.ts @@ -1,7 +1,7 @@ import type { - ContentModelBlock, EntityRemovalOperation, FormatContentModelContext, + ReadonlyContentModelBlock, } from 'roosterjs-content-model-types'; /** @@ -14,9 +14,9 @@ import type { * If not specified, only selected entity will be deleted */ export function deleteBlock( - blocks: ContentModelBlock[], - blockToDelete: ContentModelBlock, - replacement?: ContentModelBlock, + blocks: ReadonlyContentModelBlock[], + blockToDelete: ReadonlyContentModelBlock, + replacement?: ReadonlyContentModelBlock, context?: FormatContentModelContext, direction?: 'forward' | 'backward' ): boolean { diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/editing/deleteExpandedSelection.ts b/packages/roosterjs-content-model-dom/lib/modelApi/editing/deleteExpandedSelection.ts index f2131c384a6..33ebafe00ca 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/editing/deleteExpandedSelection.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/editing/deleteExpandedSelection.ts @@ -5,17 +5,18 @@ import { deleteBlock } from './deleteBlock'; import { deleteSegment } from './deleteSegment'; import { getSegmentTextFormat } from './getSegmentTextFormat'; import { iterateSelections } from '../selection/iterateSelections'; +import { mutateBlock, mutateSegments } from '../common/mutate'; import { setParagraphNotImplicit } from '../block/setParagraphNotImplicit'; import type { - ContentModelBlockGroup, - ContentModelDocument, - ContentModelParagraph, ContentModelSelectionMarker, DeleteSelectionContext, FormatContentModelContext, InsertPoint, IterateSelectionsOption, - TableSelectionContext, + ReadonlyContentModelBlockGroup, + ReadonlyContentModelDocument, + ReadonlyTableSelectionContext, + ShallowMutableContentModelParagraph, } from 'roosterjs-content-model-types'; const DeleteSelectionIteratingOptions: IterateSelectionsOption = { @@ -30,7 +31,7 @@ const DeleteSelectionIteratingOptions: IterateSelectionsOption = { * at the first deleted position so that we know where to put cursor to after delete */ export function deleteExpandedSelection( - model: ContentModelDocument, + model: ReadonlyContentModelDocument, formatContext?: FormatContentModelContext ): DeleteSelectionContext { const context: DeleteSelectionContext = { @@ -41,10 +42,10 @@ export function deleteExpandedSelection( iterateSelections( model, - (path, tableContext, block, segments) => { + (path, tableContext, readonlyBlock, readonlySegments) => { // Set paragraph, format and index for default position where we will put cursor to. // Later we can overwrite these info when process the selections - let paragraph = createParagraph( + let paragraph: ShallowMutableContentModelParagraph = createParagraph( true /*implicit*/, undefined /*blockFormat*/, model.format @@ -52,13 +53,15 @@ export function deleteExpandedSelection( let markerFormat = model.format; let insertMarkerIndex = 0; - if (segments) { + if (readonlySegments && readonlyBlock?.blockType == 'Paragraph') { + const [block, segments, indexes] = mutateSegments(readonlyBlock, readonlySegments); + // Delete segments inside a paragraph - if (segments[0] && block?.blockType == 'Paragraph') { + if (segments[0]) { // Now that we have found a paragraph with selections, we can overwrite the default paragraph with this one // so we can put cursor here after delete paragraph = block; - insertMarkerIndex = paragraph.segments.indexOf(segments[0]); + insertMarkerIndex = indexes[0]; markerFormat = getSegmentTextFormat(segments[0]); context.lastParagraph = paragraph; @@ -90,25 +93,24 @@ export function deleteExpandedSelection( setParagraphNotImplicit(block); } } - } else if (block) { + } else if (readonlyBlock) { // Delete a whole block (divider, table, ...) - const blocks = path[0].blocks; + const blocks = mutateBlock(path[0]).blocks; - if (deleteBlock(blocks, block, paragraph, context.formatContext)) { + if (deleteBlock(blocks, readonlyBlock, paragraph, context.formatContext)) { context.deleteResult = 'range'; } } else if (tableContext) { // Delete a whole table cell const { table, colIndex, rowIndex } = tableContext; - const row = table.rows[rowIndex]; - const cell = row.cells[colIndex]; + const mutableTable = mutateBlock(table); + const row = mutableTable.rows[rowIndex]; + const cell = mutateBlock(row.cells[colIndex]); path = [cell, ...path]; paragraph.segments.push(createBr(model.format)); cell.blocks = [paragraph]; - delete cell.cachedElement; - delete row.cachedElement; context.deleteResult = 'range'; } @@ -129,9 +131,9 @@ export function deleteExpandedSelection( function createInsertPoint( marker: ContentModelSelectionMarker, - paragraph: ContentModelParagraph, - path: ContentModelBlockGroup[], - tableContext: TableSelectionContext | undefined + paragraph: ShallowMutableContentModelParagraph, + path: ReadonlyContentModelBlockGroup[], + tableContext: ReadonlyTableSelectionContext | undefined ): InsertPoint { return { marker, diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/editing/deleteSegment.ts b/packages/roosterjs-content-model-dom/lib/modelApi/editing/deleteSegment.ts index ee809726160..d1018ec16dc 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/editing/deleteSegment.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/editing/deleteSegment.ts @@ -1,39 +1,43 @@ import { deleteSingleChar } from './deleteSingleChar'; import { isWhiteSpacePreserved } from '../../domUtils/isWhiteSpacePreserved'; +import { mutateSegment } from '../common/mutate'; import { normalizeSingleSegment } from '../common/normalizeSegment'; import { normalizeText } from '../../domUtils/stringUtil'; import type { - ContentModelParagraph, - ContentModelSegment, EntityRemovalOperation, FormatContentModelContext, + ReadonlyContentModelParagraph, + ReadonlyContentModelSegment, } from 'roosterjs-content-model-types'; /** * Delete a content model segment from current selection - * @param paragraph Parent paragraph of the segment to delete - * @param segmentToDelete The segment to delete + * @param readonlyParagraph Parent paragraph of the segment to delete + * @param readonlySegmentToDelete The segment to delete * @param context @optional Context object provided by formatContentModel API * @param direction @optional Whether this is deleting forward or backward. This is only used for deleting entity. * If not specified, only selected entity will be deleted */ export function deleteSegment( - paragraph: ContentModelParagraph, - segmentToDelete: ContentModelSegment, + readonlyParagraph: ReadonlyContentModelParagraph, + readonlySegmentToDelete: ReadonlyContentModelSegment, context?: FormatContentModelContext, direction?: 'forward' | 'backward' ): boolean { + const [paragraph, segmentToDelete, index] = mutateSegment( + readonlyParagraph, + readonlySegmentToDelete + ); const segments = paragraph.segments; - const index = segments.indexOf(segmentToDelete); const preserveWhiteSpace = isWhiteSpacePreserved(paragraph.format.whiteSpace); const isForward = direction == 'forward'; const isBackward = direction == 'backward'; if (!preserveWhiteSpace) { - normalizePreviousSegment(segments, index); + normalizePreviousSegment(paragraph, segments, index); } - switch (segmentToDelete.segmentType) { + switch (segmentToDelete?.segmentType) { case 'Br': case 'Image': case 'SelectionMarker': @@ -86,10 +90,17 @@ export function deleteSegment( } else { return false; } + + default: + return false; } } -function normalizePreviousSegment(segments: ContentModelSegment[], currentIndex: number) { +function normalizePreviousSegment( + paragraph: ReadonlyContentModelParagraph, + segments: ReadonlyArray, + currentIndex: number +) { let index = currentIndex - 1; while (segments[index]?.segmentType == 'SelectionMarker') { @@ -99,6 +110,6 @@ function normalizePreviousSegment(segments: ContentModelSegment[], currentIndex: const segment = segments[index]; if (segment) { - normalizeSingleSegment(segment); + normalizeSingleSegment(paragraph, segment); } } diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/editing/deleteSelection.ts b/packages/roosterjs-content-model-dom/lib/modelApi/editing/deleteSelection.ts index 20ff0629c65..adcd718f964 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/editing/deleteSelection.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/editing/deleteSelection.ts @@ -1,10 +1,11 @@ import { deleteExpandedSelection } from './deleteExpandedSelection'; +import { mutateBlock } from '../common/mutate'; import type { - ContentModelDocument, DeleteSelectionContext, DeleteSelectionResult, DeleteSelectionStep, FormatContentModelContext, + ReadonlyContentModelDocument, ValidDeleteSelectionContext, } from 'roosterjs-content-model-types'; @@ -16,7 +17,7 @@ import type { * @returns A DeleteSelectionResult object to specify the deletion result */ export function deleteSelection( - model: ContentModelDocument, + model: ReadonlyContentModelDocument, additionalSteps: (DeleteSelectionStep | null)[] = [], formatContext?: FormatContentModelContext ): DeleteSelectionResult { @@ -50,7 +51,9 @@ function mergeParagraphAfterDelete(context: DeleteSelectionContext) { lastParagraph != insertPoint.paragraph && lastTableContext == insertPoint.tableContext ) { - insertPoint.paragraph.segments.push(...lastParagraph.segments); - lastParagraph.segments = []; + const mutableLastParagraph = mutateBlock(lastParagraph); + + mutateBlock(insertPoint.paragraph).segments.push(...mutableLastParagraph.segments); + mutableLastParagraph.segments = []; } } diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/editing/getClosestAncestorBlockGroupIndex.ts b/packages/roosterjs-content-model-dom/lib/modelApi/editing/getClosestAncestorBlockGroupIndex.ts index 27a95cef4c5..b680494215a 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/editing/getClosestAncestorBlockGroupIndex.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/editing/getClosestAncestorBlockGroupIndex.ts @@ -1,6 +1,7 @@ import type { ContentModelBlockGroup, ContentModelBlockGroupType, + ReadonlyContentModelBlockGroup, TypeOfBlockGroup, } from 'roosterjs-content-model-types'; @@ -11,7 +12,7 @@ import type { * @param stopTypes @optional Block group types that will cause stop searching */ export function getClosestAncestorBlockGroupIndex( - path: ContentModelBlockGroup[], + path: ReadonlyContentModelBlockGroup[], blockGroupTypes: TypeOfBlockGroup[], stopTypes: ContentModelBlockGroupType[] = [] ): number { diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/editing/getSegmentTextFormat.ts b/packages/roosterjs-content-model-dom/lib/modelApi/editing/getSegmentTextFormat.ts index c8928b96cf9..b8962f3205a 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/editing/getSegmentTextFormat.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/editing/getSegmentTextFormat.ts @@ -1,11 +1,16 @@ -import type { ContentModelSegment, ContentModelSegmentFormat } from 'roosterjs-content-model-types'; +import type { + ContentModelSegmentFormat, + ReadonlyContentModelSegment, +} from 'roosterjs-content-model-types'; /** * Get the text format of a segment, this function will return only format that is applicable to text * @param segment The segment to get format from * @returns */ -export function getSegmentTextFormat(segment: ContentModelSegment): ContentModelSegmentFormat { +export function getSegmentTextFormat( + segment: ReadonlyContentModelSegment +): ContentModelSegmentFormat { const { fontFamily, fontSize, textColor, backgroundColor, letterSpacing, lineHeight } = segment?.format ?? {}; @@ -22,9 +27,10 @@ export function getSegmentTextFormat(segment: ContentModelSegment): ContentModel } const removeUndefinedValues = (format: ContentModelSegmentFormat): ContentModelSegmentFormat => { - const textFormat: Record = {}; + const textFormat: Record = {}; Object.keys(format).filter(key => { const value = format[key as keyof ContentModelSegmentFormat]; + if (value !== undefined) { textFormat[key] = value; } diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/editing/mergeModel.ts b/packages/roosterjs-content-model-dom/lib/modelApi/editing/mergeModel.ts index ed46c5c1d09..b440afd1f69 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/editing/mergeModel.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/editing/mergeModel.ts @@ -7,12 +7,12 @@ import { createTableCell } from '../creators/createTableCell'; import { deleteSelection } from './deleteSelection'; import { getClosestAncestorBlockGroupIndex } from './getClosestAncestorBlockGroupIndex'; import { getObjectKeys } from '../..//domUtils/getObjectKeys'; +import { mutateBlock } from '../common/mutate'; import { normalizeContentModel } from '../common/normalizeContentModel'; import { normalizeTable } from './normalizeTable'; import type { ContentModelBlock, ContentModelBlockFormat, - ContentModelBlockGroup, ContentModelDocument, ContentModelListItem, ContentModelParagraph, @@ -21,6 +21,10 @@ import type { FormatContentModelContext, InsertPoint, MergeModelOption, + ReadonlyContentModelBlock, + ReadonlyContentModelBlockGroup, + ReadonlyContentModelDocument, + ShallowMutableContentModelParagraph, } from 'roosterjs-content-model-types'; const HeadingTags = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']; @@ -34,7 +38,7 @@ const HeadingTags = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']; * @returns Insert point after merge, or null if there is no insert point */ export function mergeModel( - target: ContentModelDocument, + target: ReadonlyContentModelDocument, source: ContentModelDocument, context?: FormatContentModelContext, options?: MergeModelOption @@ -170,7 +174,9 @@ function mergeTable( const { tableContext, marker } = markerPosition; if (tableContext && source.blocks.length == 1 && source.blocks[0] == newTable) { - const { table, colIndex, rowIndex } = tableContext; + const { table: readonlyTable, colIndex, rowIndex } = tableContext; + const table = mutateBlock(readonlyTable); + for (let i = 0; i < newTable.rows.length; i++) { for (let j = 0; j < newTable.rows[i].cells.length; j++) { const newCell = newTable.rows[i].cells[j]; @@ -242,7 +248,7 @@ function mergeList(markerPosition: InsertPoint, newList: ContentModelListItem) { const blockIndex = listParent.blocks.indexOf(listItem || paragraph); if (blockIndex >= 0) { - listParent.blocks.splice(blockIndex, 0, newList); + mutateBlock(listParent).blocks.splice(blockIndex, 0, newList); } if (listItem) { @@ -256,7 +262,7 @@ function splitParagraph(markerPosition: InsertPoint, newParaFormat: ContentModel const { paragraph, marker, path } = markerPosition; const segmentIndex = paragraph.segments.indexOf(marker); const paraIndex = path[0].blocks.indexOf(paragraph); - const newParagraph = createParagraph( + const newParagraph: ShallowMutableContentModelParagraph = createParagraph( false /*isImplicit*/, { ...paragraph.format, ...newParaFormat }, paragraph.segmentFormat @@ -267,7 +273,7 @@ function splitParagraph(markerPosition: InsertPoint, newParaFormat: ContentModel } if (paraIndex >= 0) { - path[0].blocks.splice(paraIndex + 1, 0, newParagraph); + mutateBlock(path[0]).blocks.splice(paraIndex + 1, 0, newParagraph); } const listItemIndex = getClosestAncestorBlockGroupIndex( @@ -289,7 +295,7 @@ function splitParagraph(markerPosition: InsertPoint, newParaFormat: ContentModel } if (blockIndex >= 0) { - listParent.blocks.splice(blockIndex + 1, 0, newListItem); + mutateBlock(listParent).blocks.splice(blockIndex + 1, 0, newListItem); } path[listItemIndex] = newListItem; @@ -308,21 +314,22 @@ function insertBlock(markerPosition: InsertPoint, block: ContentModelBlock) { const blockIndex = path[0].blocks.indexOf(newPara); if (blockIndex >= 0) { - path[0].blocks.splice(blockIndex, 0, block); + mutateBlock(path[0]).blocks.splice(blockIndex, 0, block); } } function applyDefaultFormat( - group: ContentModelBlockGroup, + group: ReadonlyContentModelBlockGroup, format: ContentModelSegmentFormat, applyDefaultFormatOption: 'mergeAll' | 'keepSourceEmphasisFormat' ) { group.blocks.forEach(block => { mergeBlockFormat(applyDefaultFormatOption, block); + switch (block.blockType) { case 'BlockGroup': if (block.blockGroupType == 'ListItem') { - block.formatHolder.format = mergeSegmentFormat( + mutateBlock(block).formatHolder.format = mergeSegmentFormat( applyDefaultFormatOption, format, block.formatHolder.format @@ -341,7 +348,9 @@ function applyDefaultFormat( case 'Paragraph': const paragraphFormat = block.decorator?.format || {}; - block.segments.forEach(segment => { + const paragraph = mutateBlock(block); + + paragraph.segments.forEach(segment => { if (segment.segmentType == 'General') { applyDefaultFormat(segment, format, applyDefaultFormatOption); } @@ -353,28 +362,28 @@ function applyDefaultFormat( }); if (applyDefaultFormatOption === 'keepSourceEmphasisFormat') { - delete block.decorator; + delete paragraph.decorator; } break; } }); } -function mergeBlockFormat(applyDefaultFormatOption: string, block: ContentModelBlock) { +function mergeBlockFormat(applyDefaultFormatOption: string, block: ReadonlyContentModelBlock) { if (applyDefaultFormatOption == 'keepSourceEmphasisFormat' && block.format.backgroundColor) { - delete block.format.backgroundColor; + delete mutateBlock(block).format.backgroundColor; } } function mergeSegmentFormat( applyDefaultFormatOption: 'mergeAll' | 'keepSourceEmphasisFormat', - targetformat: ContentModelSegmentFormat, + targetFormat: ContentModelSegmentFormat, sourceFormat: ContentModelSegmentFormat ): ContentModelSegmentFormat { return applyDefaultFormatOption == 'mergeAll' - ? { ...targetformat, ...sourceFormat } + ? { ...targetFormat, ...sourceFormat } : { - ...targetformat, + ...targetFormat, ...getSemanticFormat(sourceFormat), }; } diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/editing/normalizeTable.ts b/packages/roosterjs-content-model-dom/lib/modelApi/editing/normalizeTable.ts index 354decd7884..1760a8c7d87 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/editing/normalizeTable.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/editing/normalizeTable.ts @@ -2,11 +2,12 @@ import { addBlock } from '../common/addBlock'; import { addSegment } from '../common/addSegment'; import { createBr } from '../creators/createBr'; import { createParagraph } from '../creators/createParagraph'; +import { mutateBlock } from '../common/mutate'; import type { - ContentModelSegment, ContentModelSegmentFormat, - ContentModelTable, - ContentModelTableCell, + ReadonlyContentModelSegment, + ReadonlyContentModelTable, + ReadonlyContentModelTableCell, } from 'roosterjs-content-model-types'; /** @@ -18,18 +19,20 @@ const MIN_HEIGHT = 22; /** * Normalize a Content Model table, make sure: * 1. Fist cells are not spanned - * 2. Inner cells are not header + * 2. Only first column and row can have headers * 3. All cells have content and width * 4. Table and table row have correct width/height * 5. Spanned cell has no child blocks * 6. default format is correctly applied - * @param table The table to normalize + * @param readonlyTable The table to normalize * @param defaultSegmentFormat @optional Default segment format to apply to cell */ export function normalizeTable( - table: ContentModelTable, + readonlyTable: ReadonlyContentModelTable, defaultSegmentFormat?: ContentModelSegmentFormat ) { + const table = mutateBlock(readonlyTable); + // Always collapse border and use border box for table in roosterjs to make layout simpler const format = table.format; @@ -42,7 +45,9 @@ export function normalizeTable( // Make sure all inner cells are not header // Make sure all cells have content and width table.rows.forEach((row, rowIndex) => { - row.cells.forEach((cell, colIndex) => { + row.cells.forEach((readonlyCell, colIndex) => { + const cell = mutateBlock(readonlyCell); + if (cell.blocks.length == 0) { const format = cell.format.textColor ? { @@ -59,7 +64,7 @@ export function normalizeTable( if (rowIndex == 0) { cell.spanAbove = false; - } else if (rowIndex > 0 && cell.isHeader) { + } else if (rowIndex > 0 && colIndex > 0 && cell.isHeader) { cell.isHeader = false; delete cell.cachedElement; } @@ -137,18 +142,21 @@ function getTableCellWidth(columns: number): number { } } -function tryMoveBlocks(targetCell: ContentModelTableCell, sourceCell: ContentModelTableCell) { +function tryMoveBlocks( + targetCell: ReadonlyContentModelTableCell, + sourceCell: ReadonlyContentModelTableCell +) { const onlyHasEmptyOrBr = sourceCell.blocks.every( block => block.blockType == 'Paragraph' && hasOnlyBrSegment(block.segments) ); if (!onlyHasEmptyOrBr) { - targetCell.blocks.push(...sourceCell.blocks); - sourceCell.blocks = []; + mutateBlock(targetCell).blocks.push(...sourceCell.blocks); + mutateBlock(sourceCell).blocks = []; } } -function hasOnlyBrSegment(segments: ContentModelSegment[]): boolean { +function hasOnlyBrSegment(segments: ReadonlyArray): boolean { segments = segments.filter(s => s.segmentType != 'SelectionMarker'); return segments.length == 0 || (segments.length == 1 && segments[0].segmentType == 'Br'); diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/editing/retrieveModelFormatState.ts b/packages/roosterjs-content-model-dom/lib/modelApi/editing/retrieveModelFormatState.ts index 5cfd4d0333e..8b7cbb0a19b 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/editing/retrieveModelFormatState.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/editing/retrieveModelFormatState.ts @@ -1,20 +1,20 @@ import { extractBorderValues } from '../../domUtils/style/borderValues'; import { getClosestAncestorBlockGroupIndex } from './getClosestAncestorBlockGroupIndex'; +import { getTableMetadata } from '../metadata/updateTableMetadata'; import { isBold } from '../../domUtils/style/isBold'; import { iterateSelections } from '../selection/iterateSelections'; import { parseValueWithUnit } from '../../formatHandlers/utils/parseValueWithUnit'; -import { updateTableMetadata } from '../metadata/updateTableMetadata'; import type { ContentModelFormatState, - ContentModelBlock, - ContentModelBlockGroup, - ContentModelDocument, - ContentModelFormatContainer, - ContentModelImage, - ContentModelListItem, - ContentModelParagraph, ContentModelSegmentFormat, - TableSelectionContext, + ReadonlyContentModelBlockGroup, + ReadonlyContentModelBlock, + ReadonlyContentModelImage, + ReadonlyTableSelectionContext, + ReadonlyContentModelParagraph, + ReadonlyContentModelFormatContainer, + ReadonlyContentModelListItem, + ReadonlyContentModelDocument, } from 'roosterjs-content-model-types'; /** @@ -24,12 +24,12 @@ import type { * @param formatState Existing format state object, used for receiving the result */ export function retrieveModelFormatState( - model: ContentModelDocument, + model: ReadonlyContentModelDocument, pendingFormat: ContentModelSegmentFormat | null, formatState: ContentModelFormatState ) { - let firstTableContext: TableSelectionContext | undefined; - let firstBlock: ContentModelBlock | undefined; + let firstTableContext: ReadonlyTableSelectionContext | undefined; + let firstBlock: ReadonlyContentModelBlock | undefined; let isFirst = true; let isFirstImage = true; let isFirstSegment = true; @@ -56,10 +56,11 @@ export function retrieveModelFormatState( // Segment formats segments?.forEach(segment => { if (isFirstSegment || segment.segmentType != 'SelectionMarker') { - const modelFormat = Object.assign({}, model.format); - delete modelFormat?.italic; - delete modelFormat?.underline; - delete modelFormat?.fontWeight; + const modelFormat = { ...model.format }; + + delete modelFormat.italic; + delete modelFormat.underline; + delete modelFormat.fontWeight; retrieveSegmentFormat( formatState, @@ -165,7 +166,7 @@ function retrieveSegmentFormat( function retrieveParagraphFormat( result: ContentModelFormatState, - paragraph: ContentModelParagraph, + paragraph: ReadonlyContentModelParagraph, isFirst: boolean ) { const headingLevel = parseInt((paragraph.decorator?.tagName || '').substring(1)); @@ -180,14 +181,14 @@ function retrieveParagraphFormat( function retrieveStructureFormat( result: ContentModelFormatState, - path: ContentModelBlockGroup[], + path: ReadonlyContentModelBlockGroup[], isFirst: boolean ) { const listItemIndex = getClosestAncestorBlockGroupIndex(path, ['ListItem'], []); const containerIndex = getClosestAncestorBlockGroupIndex(path, ['FormatContainer'], []); if (listItemIndex >= 0) { - const listItem = path[listItemIndex] as ContentModelListItem; + const listItem = path[listItemIndex] as ReadonlyContentModelListItem; const listType = listItem?.levels[listItem.levels.length - 1]?.listType; mergeValue(result, 'isBullet', listType == 'UL', isFirst); @@ -198,13 +199,16 @@ function retrieveStructureFormat( result, 'isBlockQuote', containerIndex >= 0 && - (path[containerIndex] as ContentModelFormatContainer)?.tagName == 'blockquote', + (path[containerIndex] as ReadonlyContentModelFormatContainer)?.tagName == 'blockquote', isFirst ); } -function retrieveTableFormat(tableContext: TableSelectionContext, result: ContentModelFormatState) { - const tableFormat = updateTableMetadata(tableContext.table); +function retrieveTableFormat( + tableContext: ReadonlyTableSelectionContext, + result: ContentModelFormatState +) { + const tableFormat = getTableMetadata(tableContext.table); result.isInTable = true; result.tableHasHeader = tableContext.table.rows.some(row => @@ -216,7 +220,7 @@ function retrieveTableFormat(tableContext: TableSelectionContext, result: Conten } } -function retrieveImageFormat(image: ContentModelImage, result: ContentModelFormatState) { +function retrieveImageFormat(image: ReadonlyContentModelImage, result: ContentModelFormatState) { const { format } = image; const borderKey = 'borderTop'; const extractedBorder = extractBorderValues(format[borderKey]); diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/editing/setTableCellBackgroundColor.ts b/packages/roosterjs-content-model-dom/lib/modelApi/editing/setTableCellBackgroundColor.ts index 96df09590b7..258e3cbb24f 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/editing/setTableCellBackgroundColor.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/editing/setTableCellBackgroundColor.ts @@ -1,6 +1,7 @@ +import { mutateBlock } from '../common/mutate'; import { parseColor } from '../../formatHandlers/utils/color'; import { updateTableCellMetadata } from '../metadata/updateTableCellMetadata'; -import type { ContentModelTableCell } from 'roosterjs-content-model-types'; +import type { ShallowMutableContentModelTableCell } from 'roosterjs-content-model-types'; // Using the HSL (hue, saturation and lightness) representation for RGB color values. // If the value of the lightness is less than 20, the color is dark. @@ -18,7 +19,7 @@ const Black = '#000000'; * @param applyToSegments @optional When pass true, we will also apply text color from table cell to its child blocks and segments */ export function setTableCellBackgroundColor( - cell: ContentModelTableCell, + cell: ShallowMutableContentModelTableCell, color: string | null | undefined, isColorOverride?: boolean, applyToSegments?: boolean @@ -58,9 +59,11 @@ export function setTableCellBackgroundColor( delete cell.cachedElement; } -function removeAdaptiveCellColor(cell: ContentModelTableCell) { - cell.blocks.forEach(block => { - if (block.blockType == 'Paragraph') { +function removeAdaptiveCellColor(cell: ShallowMutableContentModelTableCell) { + cell.blocks.forEach(readonlyBlock => { + if (readonlyBlock.blockType == 'Paragraph') { + const block = mutateBlock(readonlyBlock); + if ( block.segmentFormat?.textColor && shouldRemoveColor(block.segmentFormat?.textColor, cell.format.backgroundColor || '') @@ -79,10 +82,12 @@ function removeAdaptiveCellColor(cell: ContentModelTableCell) { }); } -function setAdaptiveCellColor(cell: ContentModelTableCell) { +function setAdaptiveCellColor(cell: ShallowMutableContentModelTableCell) { if (cell.format.textColor) { - cell.blocks.forEach(block => { - if (block.blockType == 'Paragraph') { + cell.blocks.forEach(readonlyBlock => { + if (readonlyBlock.blockType == 'Paragraph') { + const block = mutateBlock(readonlyBlock); + if (!block.segmentFormat?.textColor) { block.segmentFormat = { ...block.segmentFormat, diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/metadata/updateImageMetadata.ts b/packages/roosterjs-content-model-dom/lib/modelApi/metadata/updateImageMetadata.ts index 840bfa87014..bf5ca0bb4d0 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/metadata/updateImageMetadata.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/metadata/updateImageMetadata.ts @@ -1,10 +1,14 @@ -import { updateMetadata } from './updateMetadata'; +import { getMetadata, updateMetadata } from './updateMetadata'; import { createNumberDefinition, createObjectDefinition, createStringDefinition, } from './definitionCreators'; -import type { ContentModelImage, ImageMetadataFormat } from 'roosterjs-content-model-types'; +import type { + ContentModelImage, + ImageMetadataFormat, + ReadonlyContentModelImage, +} from 'roosterjs-content-model-types'; const NumberDefinition = createNumberDefinition(); @@ -21,6 +25,14 @@ const ImageMetadataFormatDefinition = createObjectDefinition = crea true /** allowNull */ ); +/** + * Get list metadata + * @param list The list Content Model (metadata holder) + */ +export function getListMetadata( + list: ReadonlyContentModelWithDataset +): ListMetadataFormat | null { + return getMetadata(list, ListMetadataDefinition); +} + /** * Update list metadata with a callback * @param list The list Content Model (metadata holder) * @param callback The callback function used for updating metadata */ export function updateListMetadata( - list: ContentModelWithDataset, + list: ShallowMutableContentModelWithDataset, callback?: (format: ListMetadataFormat | null) => ListMetadataFormat | null ): ListMetadataFormat | null { return updateMetadata(list, callback, ListMetadataDefinition); diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/metadata/updateMetadata.ts b/packages/roosterjs-content-model-dom/lib/modelApi/metadata/updateMetadata.ts index debd77304db..1e5fce4fbd7 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/metadata/updateMetadata.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/metadata/updateMetadata.ts @@ -1,8 +1,32 @@ import { validate } from './validate'; -import type { ContentModelWithDataset, Definition } from 'roosterjs-content-model-types'; +import type { + Definition, + ReadonlyContentModelWithDataset, + ShallowMutableContentModelWithDataset, +} from 'roosterjs-content-model-types'; const EditingInfoDatasetName = 'editingInfo'; +/** + * Retrieve metadata from the given model. + * @param model The Content Model to retrieve metadata from + * @param definition Definition of this metadata type, used for validate the metadata object + * @returns Metadata of the model, or null if it does not contain a valid metadata + */ +export function getMetadata( + model: ReadonlyContentModelWithDataset, + definition?: Definition +): T | null { + const metadataString = model.dataset[EditingInfoDatasetName]; + let obj: Object | null = null; + + try { + obj = JSON.parse(metadataString); + } catch {} + + return !definition || validate(obj, definition) ? (obj as T) : null; +} + /** * Update metadata of the given model * @param model The model to update metadata to @@ -11,20 +35,11 @@ const EditingInfoDatasetName = 'editingInfo'; * @returns The metadata object if any, or null */ export function updateMetadata( - model: ContentModelWithDataset, + model: ShallowMutableContentModelWithDataset, callback?: (metadata: T | null) => T | null, definition?: Definition ): T | null { - const metadataString = model.dataset[EditingInfoDatasetName]; - let obj: T | null = null; - - try { - obj = JSON.parse(metadataString) as T; - } catch {} - - if (definition && !validate(obj, definition)) { - obj = null; - } + let obj = getMetadata(model, definition); if (callback) { obj = callback(obj); @@ -43,6 +58,6 @@ export function updateMetadata( * Check if the given model has metadata * @param model The content model to check */ -export function hasMetadata(model: ContentModelWithDataset | HTMLElement): boolean { +export function hasMetadata(model: ReadonlyContentModelWithDataset | HTMLElement): boolean { return !!model.dataset[EditingInfoDatasetName]; } diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/metadata/updateTableCellMetadata.ts b/packages/roosterjs-content-model-dom/lib/modelApi/metadata/updateTableCellMetadata.ts index 274cd905063..f718596fc71 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/metadata/updateTableCellMetadata.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/metadata/updateTableCellMetadata.ts @@ -1,6 +1,10 @@ import { createBooleanDefinition, createObjectDefinition } from './definitionCreators'; -import { updateMetadata } from './updateMetadata'; -import type { ContentModelTableCell, TableCellMetadataFormat } from 'roosterjs-content-model-types'; +import { getMetadata, updateMetadata } from './updateMetadata'; +import type { + ReadonlyContentModelTableCell, + ShallowMutableContentModelTableCell, + TableCellMetadataFormat, +} from 'roosterjs-content-model-types'; const TableCellMetadataFormatDefinition = createObjectDefinition>( { @@ -12,13 +16,23 @@ const TableCellMetadataFormatDefinition = createObjectDefinition TableCellMetadataFormat | null ): TableCellMetadataFormat | null { return updateMetadata(cell, callback, TableCellMetadataFormatDefinition); diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/metadata/updateTableMetadata.ts b/packages/roosterjs-content-model-dom/lib/modelApi/metadata/updateTableMetadata.ts index 0ad6985f958..cc9bd3ef3bb 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/metadata/updateTableMetadata.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/metadata/updateTableMetadata.ts @@ -1,12 +1,16 @@ +import { getMetadata, updateMetadata } from './updateMetadata'; import { TableBorderFormat } from '../../constants/TableBorderFormat'; -import { updateMetadata } from './updateMetadata'; import { createBooleanDefinition, createNumberDefinition, createObjectDefinition, createStringDefinition, } from './definitionCreators'; -import type { ContentModelTable, TableMetadataFormat } from 'roosterjs-content-model-types'; +import type { + ReadonlyContentModelTable, + ShallowMutableContentModelTable, + TableMetadataFormat, +} from 'roosterjs-content-model-types'; const NullStringDefinition = createStringDefinition( false /** isOptional */, @@ -40,13 +44,21 @@ const TableFormatDefinition = createObjectDefinition TableMetadataFormat | null ): TableMetadataFormat | null { return updateMetadata(table, callback, TableFormatDefinition); diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/selection/collectSelections.ts b/packages/roosterjs-content-model-dom/lib/modelApi/selection/collectSelections.ts index eeb023bb2c1..4d6c2c8e4f4 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/selection/collectSelections.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/selection/collectSelections.ts @@ -1,6 +1,7 @@ import { getClosestAncestorBlockGroupIndex } from '../editing/getClosestAncestorBlockGroupIndex'; import { isBlockGroupOfType } from '../typeCheck/isBlockGroupOfType'; import { iterateSelections } from './iterateSelections'; +import { mutateBlock } from '../common/mutate'; import type { ContentModelBlock, ContentModelBlockGroup, @@ -12,46 +13,126 @@ import type { ContentModelTable, IterateSelectionsOption, OperationalBlocks, + ReadonlyContentModelBlock, + ReadonlyContentModelBlockGroup, + ReadonlyContentModelDocument, + ReadonlyContentModelListItem, + ReadonlyContentModelParagraph, + ReadonlyContentModelSegment, + ReadonlyContentModelTable, + ReadonlyOperationalBlocks, + ReadonlyTableSelectionContext, + ShallowMutableContentModelParagraph, + ShallowMutableContentModelSegment, TableSelectionContext, TypeOfBlockGroup, } from 'roosterjs-content-model-types'; +//#region getSelectedSegmentsAndParagraphs /** * Get an array of selected parent paragraph and child segment pair * @param model The Content Model to get selection from * @param includingFormatHolder True means also include format holder as segment from list item, in that case paragraph will be null + * @param includingEntity True to include entity in result as well */ export function getSelectedSegmentsAndParagraphs( model: ContentModelDocument, includingFormatHolder: boolean, includingEntity?: boolean -): [ContentModelSegment, ContentModelParagraph | null, ContentModelBlockGroup[]][] { +): [ContentModelSegment, ContentModelParagraph | null, ContentModelBlockGroup[]][]; + +/** + * Get an array of selected parent paragraph and child segment pair, return mutable paragraph and segments + * @param model The Content Model to get selection from + * @param includingFormatHolder True means also include format holder as segment from list item, in that case paragraph will be null + * @param includingEntity True to include entity in result as well + * @param mutate Set to true to indicate we will mutate the selected paragraphs + */ +export function getSelectedSegmentsAndParagraphs( + model: ReadonlyContentModelDocument, + includingFormatHolder: boolean, + includingEntity: boolean, + mutate: true +): [ + ShallowMutableContentModelSegment, + ContentModelParagraph | null, + ReadonlyContentModelBlockGroup[] +][]; + +/** + * Get an array of selected parent paragraph and child segment pair (Readonly) + * @param model The Content Model to get selection from + * @param includingFormatHolder True means also include format holder as segment from list item, in that case paragraph will be null + * @param includingEntity True to include entity in result as well + */ +export function getSelectedSegmentsAndParagraphs( + model: ReadonlyContentModelDocument, + includingFormatHolder: boolean, + includingEntity?: boolean +): [ + ReadonlyContentModelSegment, + ReadonlyContentModelParagraph | null, + ReadonlyContentModelBlockGroup[] +][]; + +export function getSelectedSegmentsAndParagraphs( + model: ReadonlyContentModelDocument, + includingFormatHolder: boolean, + includingEntity?: boolean, + mutate?: boolean +): [ + ReadonlyContentModelSegment, + ReadonlyContentModelParagraph | null, + ReadonlyContentModelBlockGroup[] +][] { const selections = collectSelections(model, { includeListFormatHolder: includingFormatHolder ? 'allSegments' : 'never', }); const result: [ - ContentModelSegment, - ContentModelParagraph | null, - ContentModelBlockGroup[] + ReadonlyContentModelSegment, + ReadonlyContentModelParagraph | null, + ReadonlyContentModelBlockGroup[] ][] = []; selections.forEach(({ segments, block, path }) => { - if (segments && ((includingFormatHolder && !block) || block?.blockType == 'Paragraph')) { - segments.forEach(segment => { - if ( - includingEntity || - segment.segmentType != 'Entity' || - !segment.entityFormat.isReadonly - ) { - result.push([segment, block?.blockType == 'Paragraph' ? block : null, path]); + if (segments) { + if ( + includingFormatHolder && + !block && + segments.length == 1 && + path[0].blockGroupType == 'ListItem' && + segments[0] == path[0].formatHolder + ) { + const list = path[0]; + + if (mutate) { + mutateBlock(list); } - }); + + result.push([list.formatHolder, null, path]); + } else if (block?.blockType == 'Paragraph') { + if (mutate) { + mutateBlock(block); + } + + segments.forEach(segment => { + if ( + includingEntity || + segment.segmentType != 'Entity' || + !segment.entityFormat.isReadonly + ) { + result.push([segment, block, path]); + } + }); + } } }); return result; } +//#endregion +//#region getSelectedSegments /** * Get an array of selected segments from a content model * @param model The Content Model to get selection from @@ -60,29 +141,93 @@ export function getSelectedSegmentsAndParagraphs( export function getSelectedSegments( model: ContentModelDocument, includingFormatHolder: boolean -): ContentModelSegment[] { - return getSelectedSegmentsAndParagraphs(model, includingFormatHolder).map(x => x[0]); +): ContentModelSegment[]; + +/** + * Get an array of selected segments from a content model, return mutable segments + * @param model The Content Model to get selection from + * @param includingFormatHolder True means also include format holder as segment from list item + * @param mutate Set to true to indicate we will mutate the selected paragraphs + */ +export function getSelectedSegments( + model: ReadonlyContentModelDocument, + includingFormatHolder: boolean, + mutate: true +): ShallowMutableContentModelSegment[]; + +/** + * Get an array of selected segments from a content model (Readonly) + * @param model The Content Model to get selection from + * @param includingFormatHolder True means also include format holder as segment from list item + */ +export function getSelectedSegments( + model: ReadonlyContentModelDocument, + includingFormatHolder: boolean +): ReadonlyContentModelSegment[]; + +export function getSelectedSegments( + model: ReadonlyContentModelDocument, + includingFormatHolder: boolean, + mutate?: boolean +): ReadonlyContentModelSegment[] { + const segments = mutate + ? getSelectedSegmentsAndParagraphs( + model, + includingFormatHolder, + false /*includeEntity*/, + true /*mutate*/ + ) + : getSelectedSegmentsAndParagraphs(model, includingFormatHolder); + + return segments.map(x => x[0]); } +//#endregion +//#region getSelectedParagraphs /** * Get any array of selected paragraphs from a content model * @param model The Content Model to get selection from */ -export function getSelectedParagraphs(model: ContentModelDocument): ContentModelParagraph[] { +export function getSelectedParagraphs(model: ContentModelDocument): ContentModelParagraph[]; + +/** + * Get any array of selected paragraphs from a content model, return mutable paragraphs + * @param model The Content Model to get selection from + * @param mutate Set to true to indicate we will mutate the selected paragraphs + */ +export function getSelectedParagraphs( + model: ReadonlyContentModelDocument, + mutate: true +): ShallowMutableContentModelParagraph[]; + +/** + * Get any array of selected paragraphs from a content model (Readonly) + * @param model The Content Model to get selection from + */ +export function getSelectedParagraphs( + model: ReadonlyContentModelDocument +): ReadonlyContentModelParagraph[]; + +export function getSelectedParagraphs( + model: ReadonlyContentModelDocument, + mutate?: boolean +): ReadonlyContentModelParagraph[] { const selections = collectSelections(model, { includeListFormatHolder: 'never' }); - const result: ContentModelParagraph[] = []; + const result: ReadonlyContentModelParagraph[] = []; removeUnmeaningfulSelections(selections); selections.forEach(({ block }) => { if (block?.blockType == 'Paragraph') { - result.push(block); + result.push(mutate ? mutateBlock(block) : block); } }); return result; } +//#endregion +//#region getOperationalBlocks /** * Get an array of block group - block pair that is of the expected block group type from selection * @param group The root block group to search @@ -95,8 +240,29 @@ export function getOperationalBlocks( blockGroupTypes: TypeOfBlockGroup[], stopTypes: ContentModelBlockGroupType[], deepFirst?: boolean -): OperationalBlocks[] { - const result: OperationalBlocks[] = []; +): OperationalBlocks[]; + +/** + * Get an array of block group - block pair that is of the expected block group type from selection (Readonly) + * @param group The root block group to search + * @param blockGroupTypes The expected block group types + * @param stopTypes Block group types that will stop searching when hit + * @param deepFirst True means search in deep first, otherwise wide first + */ +export function getOperationalBlocks( + group: ReadonlyContentModelBlockGroup, + blockGroupTypes: TypeOfBlockGroup[], + stopTypes: ContentModelBlockGroupType[], + deepFirst?: boolean +): ReadonlyOperationalBlocks[]; + +export function getOperationalBlocks( + group: ReadonlyContentModelBlockGroup, + blockGroupTypes: TypeOfBlockGroup[], + stopTypes: ContentModelBlockGroupType[], + deepFirst?: boolean +): ReadonlyOperationalBlocks[] { + const result: ReadonlyOperationalBlocks[] = []; const findSequence = deepFirst ? blockGroupTypes.map(type => [type]) : [blockGroupTypes]; const selections = collectSelections(group, { includeListFormatHolder: 'never', @@ -131,17 +297,31 @@ export function getOperationalBlocks( return result; } +//#endregion +//#region getFirstSelectedTable /** * Get the first selected table from content model * @param model The Content Model to get selection from */ export function getFirstSelectedTable( model: ContentModelDocument -): [ContentModelTable | undefined, ContentModelBlockGroup[]] { +): [ContentModelTable | undefined, ContentModelBlockGroup[]]; + +/** + * Get the first selected table from content model (Readonly) + * @param model The Content Model to get selection from + */ +export function getFirstSelectedTable( + model: ReadonlyContentModelDocument +): [ReadonlyContentModelTable | undefined, ReadonlyContentModelBlockGroup[]]; + +export function getFirstSelectedTable( + model: ReadonlyContentModelDocument +): [ReadonlyContentModelTable | undefined, ReadonlyContentModelBlockGroup[]] { const selections = collectSelections(model, { includeListFormatHolder: 'never' }); - let table: ContentModelTable | undefined; - let resultPath: ContentModelBlockGroup[] = []; + let table: ReadonlyContentModelTable | undefined; + let resultPath: ReadonlyContentModelBlockGroup[] = []; removeUnmeaningfulSelections(selections); @@ -164,14 +344,28 @@ export function getFirstSelectedTable( return [table, resultPath]; } +//#endregion +//#region getFirstSelectedListItem /** * Get the first selected list item from content model * @param model The Content Model to get selection from */ export function getFirstSelectedListItem( model: ContentModelDocument -): ContentModelListItem | undefined { +): ContentModelListItem | undefined; + +/** + * Get the first selected list item from content model (Readonly) + * @param model The Content Model to get selection from + */ +export function getFirstSelectedListItem( + model: ReadonlyContentModelDocument +): ReadonlyContentModelListItem | undefined; + +export function getFirstSelectedListItem( + model: ReadonlyContentModelDocument +): ReadonlyContentModelListItem | undefined { let listItem: ContentModelListItem | undefined; getOperationalBlocks(model, ['ListItem'], ['TableCell']).forEach(r => { @@ -182,7 +376,9 @@ export function getFirstSelectedListItem( return listItem; } +//#endregion +//#region collectSelections interface SelectionInfo { path: ContentModelBlockGroup[]; segments?: ContentModelSegment[]; @@ -190,11 +386,28 @@ interface SelectionInfo { tableContext?: TableSelectionContext; } +interface ReadonlySelectionInfo { + path: ReadonlyContentModelBlockGroup[]; + segments?: ReadonlyContentModelSegment[]; + block?: ReadonlyContentModelBlock; + tableContext?: ReadonlyTableSelectionContext; +} + function collectSelections( group: ContentModelBlockGroup, option?: IterateSelectionsOption -): SelectionInfo[] { - const selections: SelectionInfo[] = []; +): SelectionInfo[]; + +function collectSelections( + group: ReadonlyContentModelBlockGroup, + option?: IterateSelectionsOption +): ReadonlySelectionInfo[]; + +function collectSelections( + group: ReadonlyContentModelBlockGroup, + option?: IterateSelectionsOption +): ReadonlySelectionInfo[] { + const selections: ReadonlySelectionInfo[] = []; iterateSelections( group, @@ -211,8 +424,10 @@ function collectSelections( return selections; } +//#endregion -function removeUnmeaningfulSelections(selections: SelectionInfo[]) { +//#region utils +function removeUnmeaningfulSelections(selections: ReadonlySelectionInfo[]) { if ( selections.length > 1 && isOnlySelectionMarkerSelected(selections, false /*checkFirstParagraph*/) @@ -230,7 +445,7 @@ function removeUnmeaningfulSelections(selections: SelectionInfo[]) { } function isOnlySelectionMarkerSelected( - selections: SelectionInfo[], + selections: ReadonlySelectionInfo[], checkFirstParagraph: boolean ): boolean { const selection = selections[checkFirstParagraph ? 0 : selections.length - 1]; @@ -252,3 +467,4 @@ function isOnlySelectionMarkerSelected( return false; } } +//#endregion diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/selection/getSelectedCells.ts b/packages/roosterjs-content-model-dom/lib/modelApi/selection/getSelectedCells.ts index beb8b46fe86..ab7484c24a5 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/selection/getSelectedCells.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/selection/getSelectedCells.ts @@ -1,11 +1,16 @@ import { hasSelectionInBlockGroup } from '../selection/hasSelectionInBlockGroup'; -import type { ContentModelTable, TableSelectionCoordinates } from 'roosterjs-content-model-types'; +import type { + ReadonlyContentModelTable, + TableSelectionCoordinates, +} from 'roosterjs-content-model-types'; /** * Get selection coordinates of a table. If there is no selection, return null * @param table The table model to get selection from */ -export function getSelectedCells(table: ContentModelTable): TableSelectionCoordinates | null { +export function getSelectedCells( + table: ReadonlyContentModelTable +): TableSelectionCoordinates | null { let firstRow = -1; let firstColumn = -1; let lastRow = -1; diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/selection/hasSelectionInBlock.ts b/packages/roosterjs-content-model-dom/lib/modelApi/selection/hasSelectionInBlock.ts index 8d721e4f048..395fb46da8d 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/selection/hasSelectionInBlock.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/selection/hasSelectionInBlock.ts @@ -1,12 +1,12 @@ import { hasSelectionInBlockGroup } from './hasSelectionInBlockGroup'; import { hasSelectionInSegment } from './hasSelectionInSegment'; -import type { ContentModelBlock } from 'roosterjs-content-model-types'; +import type { ReadonlyContentModelBlock } from 'roosterjs-content-model-types'; /** * Check if there is selection within the given block * @param block The block to check */ -export function hasSelectionInBlock(block: ContentModelBlock): boolean { +export function hasSelectionInBlock(block: ReadonlyContentModelBlock): boolean { switch (block.blockType) { case 'Paragraph': return block.segments.some(hasSelectionInSegment); diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/selection/hasSelectionInBlockGroup.ts b/packages/roosterjs-content-model-dom/lib/modelApi/selection/hasSelectionInBlockGroup.ts index eb9b2199e61..e01e82da76b 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/selection/hasSelectionInBlockGroup.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/selection/hasSelectionInBlockGroup.ts @@ -1,11 +1,11 @@ import { hasSelectionInBlock } from './hasSelectionInBlock'; -import type { ContentModelBlockGroup } from 'roosterjs-content-model-types'; +import type { ReadonlyContentModelBlockGroup } from 'roosterjs-content-model-types'; /** * Check if there is selection within the given block * @param block The block to check */ -export function hasSelectionInBlockGroup(group: ContentModelBlockGroup): boolean { +export function hasSelectionInBlockGroup(group: ReadonlyContentModelBlockGroup): boolean { if (group.blockGroupType == 'TableCell' && group.isSelected) { return true; } diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/selection/hasSelectionInSegment.ts b/packages/roosterjs-content-model-dom/lib/modelApi/selection/hasSelectionInSegment.ts index 8bc58860ef8..07238f1e8dd 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/selection/hasSelectionInSegment.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/selection/hasSelectionInSegment.ts @@ -1,11 +1,11 @@ import { hasSelectionInBlock } from './hasSelectionInBlock'; -import type { ContentModelSegment } from 'roosterjs-content-model-types'; +import type { ReadonlyContentModelSegment } from 'roosterjs-content-model-types'; /** * Check if there is selection within the given segment * @param segment The segment to check */ -export function hasSelectionInSegment(segment: ContentModelSegment): boolean { +export function hasSelectionInSegment(segment: ReadonlyContentModelSegment): boolean { return ( segment.isSelected || (segment.segmentType == 'General' && segment.blocks.some(hasSelectionInBlock)) diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/selection/iterateSelections.ts b/packages/roosterjs-content-model-dom/lib/modelApi/selection/iterateSelections.ts index 9fe47705b7f..05db43d0348 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/selection/iterateSelections.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/selection/iterateSelections.ts @@ -1,10 +1,12 @@ import type { ContentModelBlockGroup, ContentModelBlockWithCache, - ContentModelSegment, IterateSelectionsCallback, IterateSelectionsOption, - TableSelectionContext, + ReadonlyContentModelBlockGroup, + ReadonlyContentModelSegment, + ReadonlyIterateSelectionsCallback, + ReadonlyTableSelectionContext, } from 'roosterjs-content-model-types'; /** @@ -17,23 +19,46 @@ export function iterateSelections( group: ContentModelBlockGroup, callback: IterateSelectionsCallback, option?: IterateSelectionsOption +): void; + +/** + * Iterate all selected elements in a given model (Readonly) + * @param group The given Content Model to iterate selection from + * @param callback The callback function to access the selected element + * @param option Option to determine how to iterate + */ +export function iterateSelections( + group: ReadonlyContentModelBlockGroup, + callback: ReadonlyIterateSelectionsCallback, + option?: IterateSelectionsOption +): void; + +export function iterateSelections( + group: ReadonlyContentModelBlockGroup, + callback: ReadonlyIterateSelectionsCallback | IterateSelectionsCallback, + option?: IterateSelectionsOption ): void { - const internalCallback: IterateSelectionsCallback = (path, tableContext, block, segments) => { + const internalCallback: ReadonlyIterateSelectionsCallback = ( + path, + tableContext, + block, + segments + ) => { if (!!(block as ContentModelBlockWithCache)?.cachedElement) { delete (block as ContentModelBlockWithCache).cachedElement; } - return callback(path, tableContext, block, segments); + return (callback as ReadonlyIterateSelectionsCallback)(path, tableContext, block, segments); }; internalIterateSelections([group], internalCallback, option); } function internalIterateSelections( - path: ContentModelBlockGroup[], - callback: IterateSelectionsCallback, + path: ReadonlyContentModelBlockGroup[], + callback: ReadonlyIterateSelectionsCallback, option?: IterateSelectionsOption, - table?: TableSelectionContext, + table?: ReadonlyTableSelectionContext, treatAllAsSelect?: boolean ): boolean { const parent = path[0]; @@ -104,7 +129,7 @@ function internalIterateSelections( continue; } - const newTable: TableSelectionContext = { + const newTable: ReadonlyTableSelectionContext = { table: block, rowIndex, colIndex, @@ -141,7 +166,7 @@ function internalIterateSelections( break; case 'Paragraph': - const segments: ContentModelSegment[] = []; + const segments: ReadonlyContentModelSegment[] = []; for (let i = 0; i < block.segments.length; i++) { const segment = block.segments[i]; diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/selection/setSelection.ts b/packages/roosterjs-content-model-dom/lib/modelApi/selection/setSelection.ts index ca23f3468b2..484f74d863e 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/selection/setSelection.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/selection/setSelection.ts @@ -1,10 +1,14 @@ import { isGeneralSegment } from '../typeCheck/isGeneralSegment'; +import { mutateBlock, mutateSegment } from '../common/mutate'; import type { - ContentModelBlock, - ContentModelBlockGroup, - ContentModelSegment, - ContentModelTable, - Selectable, + MutableType, + ReadonlyContentModelBlock, + ReadonlyContentModelBlockGroup, + ReadonlyContentModelParagraph, + ReadonlyContentModelSegment, + ReadonlyContentModelTable, + ReadonlySelectable, + ShallowMutableSelectable, TableCellCoordinate, } from 'roosterjs-content-model-types'; @@ -14,19 +18,23 @@ import type { * @param start The start selected element. If not passed, existing selection of content model will be cleared * @param end The end selected element. If not passed, only the start element will be selected. If passed, all elements between start and end elements will be selected */ -export function setSelection(group: ContentModelBlockGroup, start?: Selectable, end?: Selectable) { +export function setSelection( + group: ReadonlyContentModelBlockGroup, + start?: ReadonlySelectable, + end?: ReadonlySelectable +) { setSelectionToBlockGroup(group, false /*isInSelection*/, start || null, end || null); } function setSelectionToBlockGroup( - group: ContentModelBlockGroup, + group: ReadonlyContentModelBlockGroup, isInSelection: boolean, - start: Selectable | null, - end: Selectable | null + start: ReadonlySelectable | null, + end: ReadonlySelectable | null ): boolean { return handleSelection(isInSelection, group, start, end, isInSelection => { - if (isGeneralSegment(group)) { - setIsSelected(group, isInSelection); + if (isGeneralSegment(group) && needToSetSelection(group, isInSelection)) { + setIsSelected(mutateBlock(group), isInSelection); } group.blocks.forEach(block => { @@ -38,10 +46,10 @@ function setSelectionToBlockGroup( } function setSelectionToBlock( - block: ContentModelBlock, + block: ReadonlyContentModelBlock, isInSelection: boolean, - start: Selectable | null, - end: Selectable | null + start: ReadonlySelectable | null, + end: ReadonlySelectable | null ) { switch (block.blockType) { case 'BlockGroup': @@ -53,10 +61,14 @@ function setSelectionToBlock( case 'Divider': case 'Entity': return handleSelection(isInSelection, block, start, end, isInSelection => { - if (isInSelection) { - block.isSelected = true; - } else { - delete block.isSelected; + if (needToSetSelection(block, isInSelection)) { + const mutableBlock = mutateBlock(block); + + if (isInSelection) { + mutableBlock.isSelected = true; + } else { + delete mutableBlock.isSelected; + } } return isInSelection; @@ -73,6 +85,7 @@ function setSelectionToBlock( end, isInSelection => { return setSelectionToSegment( + block, segment, isInSelection, segmentsToDelete, @@ -84,11 +97,11 @@ function setSelectionToBlock( ); }); - while (segmentsToDelete.length > 0) { - const index = segmentsToDelete.pop()!; + let index: number | undefined; + while ((index = segmentsToDelete.pop()) !== undefined) { if (index >= 0) { - block.segments.splice(index, 1); + mutateBlock(block).segments.splice(index, 1); } } @@ -100,10 +113,10 @@ function setSelectionToBlock( } function setSelectionToTable( - table: ContentModelTable, + table: ReadonlyContentModelTable, isInSelection: boolean, - start: Selectable | null, - end: Selectable | null + start: ReadonlySelectable | null, + end: ReadonlySelectable | null ): boolean { const first = findCell(table, start); const last = end ? findCell(table, end) : first; @@ -116,7 +129,9 @@ function setSelectionToTable( const isSelected = row >= first.row && row <= last.row && col >= first.col && col <= last.col; - setIsSelected(currentCell, isSelected); + if (needToSetSelection(currentCell, isSelected)) { + setIsSelected(mutateBlock(currentCell), isSelected); + } if (!isSelected) { setSelectionToBlockGroup(currentCell, false /*isInSelection*/, start, end); @@ -134,21 +149,27 @@ function setSelectionToTable( return isInSelection; } -function findCell(table: ContentModelTable, cell: Selectable | null): TableCellCoordinate { +function findCell( + table: ReadonlyContentModelTable, + cell: ReadonlySelectable | null +): TableCellCoordinate { let col = -1; const row = cell - ? table.rows.findIndex(row => (col = (row.cells as Selectable[]).indexOf(cell)) >= 0) + ? table.rows.findIndex( + row => (col = (row.cells as ReadonlyArray).indexOf(cell)) >= 0 + ) : -1; return { row, col }; } function setSelectionToSegment( - segment: ContentModelSegment, + paragraph: ReadonlyContentModelParagraph, + segment: ReadonlyContentModelSegment, isInSelection: boolean, segmentsToDelete: number[], - start: Selectable | null, - end: Selectable | null, + start: ReadonlySelectable | null, + end: ReadonlySelectable | null, i: number ) { switch (segment.segmentType) { @@ -162,23 +183,50 @@ function setSelectionToSegment( return isInSelection; case 'General': - setIsSelected(segment, isInSelection); + internalSetSelectionToSegment(paragraph, segment, isInSelection); return segment != start && segment != end ? setSelectionToBlockGroup(segment, isInSelection, start, end) : isInSelection; case 'Image': - setIsSelected(segment, isInSelection); - segment.isSelectedAsImageSelection = start == segment && (!end || end == segment); + const isSelectedAsImageSelection = start == segment && (!end || end == segment); + + internalSetSelectionToSegment( + paragraph, + segment, + isInSelection, + !segment.isSelectedAsImageSelection != !isSelectedAsImageSelection + ? image => (image.isSelectedAsImageSelection = isSelectedAsImageSelection) + : undefined + ); + return isInSelection; default: - setIsSelected(segment, isInSelection); + internalSetSelectionToSegment(paragraph, segment, isInSelection); return isInSelection; } } -function setIsSelected(selectable: Selectable, value: boolean) { +function internalSetSelectionToSegment( + paragraph: ReadonlyContentModelParagraph, + segment: T, + isInSelection: boolean, + additionAction?: (segment: MutableType) => void +) { + if (additionAction || needToSetSelection(segment, isInSelection)) { + mutateSegment(paragraph, segment, mutableSegment => { + setIsSelected(mutableSegment, isInSelection); + additionAction?.(mutableSegment); + }); + } +} + +function needToSetSelection(selectable: ReadonlySelectable, isSelected: boolean) { + return !selectable.isSelected != !isSelected; +} + +function setIsSelected(selectable: ShallowMutableSelectable, value: boolean) { if (value) { selectable.isSelected = true; } else { @@ -190,9 +238,9 @@ function setIsSelected(selectable: Selectable, value: boolean) { function handleSelection( isInSelection: boolean, - model: ContentModelBlockGroup | ContentModelBlock | ContentModelSegment, - start: Selectable | null, - end: Selectable | null, + model: ReadonlyContentModelBlockGroup | ReadonlyContentModelBlock | ReadonlyContentModelSegment, + start: ReadonlySelectable | null, + end: ReadonlySelectable | null, callback: (isInSelection: boolean) => boolean ) { isInSelection = isInSelection || model == start; diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/typeCheck/isBlockGroupOfType.ts b/packages/roosterjs-content-model-dom/lib/modelApi/typeCheck/isBlockGroupOfType.ts index a851e2fe166..afadd639e39 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/typeCheck/isBlockGroupOfType.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/typeCheck/isBlockGroupOfType.ts @@ -1,6 +1,8 @@ import type { ContentModelBlock, ContentModelBlockGroup, + ReadonlyContentModelBlock, + ReadonlyContentModelBlockGroup, TypeOfBlockGroup, } from 'roosterjs-content-model-types'; @@ -12,6 +14,29 @@ import type { export function isBlockGroupOfType( input: ContentModelBlock | ContentModelBlockGroup | null | undefined, type: TypeOfBlockGroup +): input is T; + +/** + * Check if the given content model block or block group is of the expected block group type (Readonly) + * @param input The object to check + * @param type The expected type + */ +export function isBlockGroupOfType( + input: ReadonlyContentModelBlock | ReadonlyContentModelBlockGroup | null | undefined, + type: TypeOfBlockGroup +): input is T; + +export function isBlockGroupOfType< + T extends ContentModelBlockGroup | ReadonlyContentModelBlockGroup +>( + input: + | ReadonlyContentModelBlock + | ReadonlyContentModelBlockGroup + | ContentModelBlock + | ContentModelBlockGroup + | null + | undefined, + type: TypeOfBlockGroup ): input is T { const item = input; diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/typeCheck/isGeneralSegment.ts b/packages/roosterjs-content-model-dom/lib/modelApi/typeCheck/isGeneralSegment.ts index 56163aa0f8b..f7baf3d6e68 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/typeCheck/isGeneralSegment.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/typeCheck/isGeneralSegment.ts @@ -1,6 +1,10 @@ import type { ContentModelBlockGroup, ContentModelGeneralSegment, + ReadonlyContentModelBlockGroup, + ReadonlyContentModelGeneralSegment, + ShallowMutableContentModelBlockGroup, + ShallowMutableContentModelGeneralSegment, } from 'roosterjs-content-model-types'; /** @@ -9,6 +13,26 @@ import type { */ export function isGeneralSegment( group: ContentModelBlockGroup | ContentModelGeneralSegment +): group is ContentModelGeneralSegment; + +/** + * Check if the given block group is a general segment (Shallow mutable) + * @param group The group to check + */ +export function isGeneralSegment( + group: ShallowMutableContentModelBlockGroup | ShallowMutableContentModelGeneralSegment +): group is ShallowMutableContentModelGeneralSegment; + +/** + * Check if the given block group is a general segment (Readonly) + * @param group The group to check + */ +export function isGeneralSegment( + group: ReadonlyContentModelBlockGroup | ReadonlyContentModelGeneralSegment +): group is ReadonlyContentModelGeneralSegment; + +export function isGeneralSegment( + group: ReadonlyContentModelBlockGroup | ReadonlyContentModelGeneralSegment ): group is ContentModelGeneralSegment { return ( group.blockGroupType == 'General' && diff --git a/packages/roosterjs-content-model-dom/test/modelApi/block/setParagraphNotImplicitTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/block/setParagraphNotImplicitTest.ts index d87520a13c7..192964ca13c 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/block/setParagraphNotImplicitTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/block/setParagraphNotImplicitTest.ts @@ -1,5 +1,6 @@ import { createDivider } from '../../../lib/modelApi/creators/createDivider'; import { createParagraph } from '../../../lib/modelApi/creators/createParagraph'; +import { ReadonlyContentModelParagraph } from 'roosterjs-content-model-types'; import { setParagraphNotImplicit } from '../../../lib/modelApi/block/setParagraphNotImplicit'; describe('setParagraphNotImplicit', () => { @@ -33,4 +34,19 @@ describe('setParagraphNotImplicit', () => { isImplicit: false, }); }); + + it('Readonly paragraph', () => { + const block: ReadonlyContentModelParagraph = createParagraph(true); + + block.cachedElement = {} as any; + + setParagraphNotImplicit(block); + + expect(block).toEqual({ + blockType: 'Paragraph', + format: {}, + segments: [], + isImplicit: false, + }); + }); }); diff --git a/packages/roosterjs-content-model-dom/test/modelApi/common/addSegmentTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/common/addSegmentTest.ts index 9bcde0996dc..69bf30d8277 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/common/addSegmentTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/common/addSegmentTest.ts @@ -1,15 +1,19 @@ import { addBlock } from '../../../lib/modelApi/common/addBlock'; import { addSegment } from '../../../lib/modelApi/common/addSegment'; -import { ContentModelGeneralBlock, ContentModelParagraph } from 'roosterjs-content-model-types'; import { createBr } from '../../../lib/modelApi/creators/createBr'; import { createContentModelDocument } from '../../../lib/modelApi/creators/createContentModelDocument'; import { createParagraph } from '../../../lib/modelApi/creators/createParagraph'; import { createSelectionMarker } from '../../../lib/modelApi/creators/createSelectionMarker'; import { createText } from '../../../lib/modelApi/creators/createText'; +import { + ContentModelGeneralBlock, + ContentModelParagraph, + ShallowMutableContentModelDocument, +} from 'roosterjs-content-model-types'; describe('addSegment', () => { it('Add segment to empty document', () => { - const doc = createContentModelDocument(); + const doc: ShallowMutableContentModelDocument = createContentModelDocument(); const segment = createText('test'); const result = addSegment(doc, segment); @@ -34,7 +38,7 @@ describe('addSegment', () => { }); it('Add segment to document contains an empty paragraph', () => { - const doc = createContentModelDocument(); + const doc: ShallowMutableContentModelDocument = createContentModelDocument(); const para = createParagraph(false); addBlock(doc, para); @@ -62,7 +66,7 @@ describe('addSegment', () => { }); it('Add segment to document contains a paragraph with existing text', () => { - const doc = createContentModelDocument(); + const doc: ShallowMutableContentModelDocument = createContentModelDocument(); const block: ContentModelParagraph = { blockType: 'Paragraph', segments: [ @@ -104,7 +108,7 @@ describe('addSegment', () => { }); it('Add segment to document contains a paragraph with other type of block', () => { - const doc = createContentModelDocument(); + const doc: ShallowMutableContentModelDocument = createContentModelDocument(); const div = document.createElement('div'); const block: ContentModelGeneralBlock = { blockType: 'BlockGroup', @@ -140,7 +144,7 @@ describe('addSegment', () => { }); it('Add selection marker in empty paragraph', () => { - const doc = createContentModelDocument(); + const doc: ShallowMutableContentModelDocument = createContentModelDocument(); const para = createParagraph(); doc.blocks.push(para); @@ -168,7 +172,7 @@ describe('addSegment', () => { }); it('Add selection marker after selection marker', () => { - const doc = createContentModelDocument(); + const doc: ShallowMutableContentModelDocument = createContentModelDocument(); const para = createParagraph(); const marker = createSelectionMarker(); @@ -198,7 +202,7 @@ describe('addSegment', () => { }); it('Add selection marker after selected segment', () => { - const doc = createContentModelDocument(); + const doc: ShallowMutableContentModelDocument = createContentModelDocument(); const para = createParagraph(); const br = createBr(); @@ -229,7 +233,7 @@ describe('addSegment', () => { }); it('Add selection marker after selection marker', () => { - const doc = createContentModelDocument(); + const doc: ShallowMutableContentModelDocument = createContentModelDocument(); const para = createParagraph(); const marker = createSelectionMarker(); @@ -259,7 +263,7 @@ describe('addSegment', () => { }); it('Add selection marker after selection marker that is not selected', () => { - const doc = createContentModelDocument(); + const doc: ShallowMutableContentModelDocument = createContentModelDocument(); const para = createParagraph(); const marker = createSelectionMarker(); @@ -295,7 +299,7 @@ describe('addSegment', () => { }); it('Add unselected selection marker after selection marker', () => { - const doc = createContentModelDocument(); + const doc: ShallowMutableContentModelDocument = createContentModelDocument(); const para = createParagraph(); const marker = createSelectionMarker(); @@ -332,7 +336,7 @@ describe('addSegment', () => { }); it('Add selected segment after selection marker', () => { - const doc = createContentModelDocument(); + const doc: ShallowMutableContentModelDocument = createContentModelDocument(); const para = createParagraph(); const marker = createSelectionMarker(); @@ -364,7 +368,7 @@ describe('addSegment', () => { }); it('Add selected segment after unselected selection marker', () => { - const doc = createContentModelDocument(); + const doc: ShallowMutableContentModelDocument = createContentModelDocument(); const para = createParagraph(); const marker = createSelectionMarker(); diff --git a/packages/roosterjs-content-model-dom/test/modelApi/common/ensureParagraphTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/common/ensureParagraphTest.ts index 5d4c57b8f81..cec013a545d 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/common/ensureParagraphTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/common/ensureParagraphTest.ts @@ -1,12 +1,15 @@ -import { ContentModelBlockFormat } from 'roosterjs-content-model-types'; import { createContentModelDocument } from '../../../lib/modelApi/creators/createContentModelDocument'; import { createDivider } from '../../../lib/modelApi/creators/createDivider'; import { createParagraph } from '../../../lib/modelApi/creators/createParagraph'; import { ensureParagraph } from '../../../lib/modelApi/common/ensureParagraph'; +import { + ContentModelBlockFormat, + ShallowMutableContentModelDocument, +} from 'roosterjs-content-model-types'; describe('ensureParagraph', () => { it('Empty group', () => { - const doc = createContentModelDocument(); + const doc: ShallowMutableContentModelDocument = createContentModelDocument(); const result = ensureParagraph(doc); expect(doc).toEqual({ @@ -22,7 +25,7 @@ describe('ensureParagraph', () => { }); it('Empty group with format', () => { - const doc = createContentModelDocument(); + const doc: ShallowMutableContentModelDocument = createContentModelDocument(); const format: ContentModelBlockFormat = { backgroundColor: 'red', }; @@ -43,7 +46,7 @@ describe('ensureParagraph', () => { }); it('Last block is not paragraph', () => { - const doc = createContentModelDocument(); + const doc: ShallowMutableContentModelDocument = createContentModelDocument(); const divider = createDivider('hr'); doc.blocks.push(divider); @@ -63,9 +66,11 @@ describe('ensureParagraph', () => { }); it('Last block is paragraph', () => { - const doc = createContentModelDocument(); + const doc: ShallowMutableContentModelDocument = createContentModelDocument(); const paragraph = createParagraph(); + paragraph.cachedElement = {} as any; + doc.blocks.push(paragraph); const result = ensureParagraph(doc); @@ -83,7 +88,7 @@ describe('ensureParagraph', () => { }); it('Last block is paragraph, do not overwrite format', () => { - const doc = createContentModelDocument(); + const doc: ShallowMutableContentModelDocument = createContentModelDocument(); const format: ContentModelBlockFormat = { backgroundColor: 'red', }; diff --git a/packages/roosterjs-content-model-dom/test/modelApi/common/mutateTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/common/mutateTest.ts new file mode 100644 index 00000000000..d4b31594cc9 --- /dev/null +++ b/packages/roosterjs-content-model-dom/test/modelApi/common/mutateTest.ts @@ -0,0 +1,235 @@ +import { createContentModelDocument } from '../../../lib/modelApi/creators/createContentModelDocument'; +import { createListItem } from '../../../lib/modelApi/creators/createListItem'; +import { createListLevel } from '../../../lib/modelApi/creators/createListLevel'; +import { createParagraph } from '../../../lib/modelApi/creators/createParagraph'; +import { createTable } from '../../../lib/modelApi/creators/createTable'; +import { createTableCell } from '../../../lib/modelApi/creators/createTableCell'; +import { createText } from '../../../lib/modelApi/creators/createText'; +import { mutateBlock, mutateSegment, mutateSegments } from '../../../lib/modelApi/common/mutate'; + +const mockedCache = 'CACHE' as any; + +describe('mutate', () => { + it('mutate a block without cache', () => { + const block = {} as any; + + const mutatedBlock = mutateBlock(block); + + expect(mutatedBlock).toBe(block); + expect(mutatedBlock).toEqual({} as any); + }); + + it('mutate a block with cache', () => { + const block = {} as any; + + block.cachedElement = mockedCache; + + const mutatedBlock = mutateBlock(block); + + expect(mutatedBlock).toBe(block); + expect(mutatedBlock).toEqual({} as any); + }); + + it('mutate a block group with cache', () => { + const doc = createContentModelDocument(); + const para = createParagraph(); + + doc.cachedElement = mockedCache; + para.cachedElement = mockedCache; + + doc.blocks.push(para); + + const mutatedBlock = mutateBlock(doc); + + expect(mutatedBlock).toBe(doc); + expect(mutatedBlock).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + cachedElement: mockedCache, + }, + ], + } as any); + }); + + it('mutate a table', () => { + const table = createTable(1); + const cell = createTableCell(); + const para = createParagraph(); + + table.cachedElement = mockedCache; + table.rows[0].cachedElement = mockedCache; + cell.cachedElement = mockedCache; + para.cachedElement = mockedCache; + + cell.blocks.push(para); + table.rows[0].cells.push(cell); + + const mutatedBlock = mutateBlock(table); + + expect(mutatedBlock).toBe(table); + expect(mutatedBlock).toEqual({ + blockType: 'Table', + rows: [ + { + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + cachedElement: mockedCache, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + cachedElement: mockedCache, + }, + ], + height: 0, + format: {}, + }, + ], + format: {}, + widths: [], + dataset: {}, + } as any); + }); + + it('mutate a list', () => { + const level = createListLevel('OL'); + const list = createListItem([level]); + const para = createParagraph(); + + level.cachedElement = mockedCache; + list.cachedElement = mockedCache; + para.cachedElement = mockedCache; + + list.blocks.push(para); + + const mutatedBlock = mutateBlock(list); + + expect(mutatedBlock).toBe(list); + expect(mutatedBlock).toEqual({ + blockType: 'BlockGroup', + format: {}, + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + cachedElement: mockedCache, + }, + ], + levels: [{ listType: 'OL', format: {}, dataset: {} }], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, + } as any); + }); +}); + +describe('mutateSegments', () => { + it('empty paragraph', () => { + const para = createParagraph(); + + para.cachedElement = mockedCache; + + const result = mutateSegments(para, []); + + expect(result).toEqual([para, [], []]); + expect(result[0].cachedElement).toBeUndefined(); + }); + + it('Paragraph with correct segments', () => { + const para = createParagraph(); + + para.cachedElement = mockedCache; + + const text1 = createText('test1'); + const text2 = createText('test2'); + const text3 = createText('test3'); + const text4 = createText('test4'); + + para.segments.push(text1, text2, text3, text4); + + const result = mutateSegments(para, [text2, text4]); + + expect(result).toEqual([para, [text2, text4], [1, 3]]); + expect(result[0].cachedElement).toBeUndefined(); + }); + + it('Paragraph with incorrect segments', () => { + const para = createParagraph(); + + para.cachedElement = mockedCache; + + const text1 = createText('test1'); + const text2 = createText('test2'); + const text3 = createText('test3'); + const text4 = createText('test4'); + + para.segments.push(text1, text2, text3); + + const result = mutateSegments(para, [text2, text4]); + + expect(result).toEqual([para, [text2], [1]]); + expect(result[0].cachedElement).toBeUndefined(); + }); +}); + +describe('mutateSegment', () => { + let callbackSpy: jasmine.Spy; + + beforeEach(() => { + callbackSpy = jasmine.createSpy('callback'); + }); + + it('Paragraph with correct segment', () => { + const para = createParagraph(); + + para.cachedElement = mockedCache; + + const text1 = createText('test1'); + const text2 = createText('test2'); + const text3 = createText('test3'); + + para.segments.push(text1, text2, text3); + + const result = mutateSegment(para, text2, callbackSpy); + + expect(result).toEqual([para, text2, 1]); + expect(result[0].cachedElement).toBeUndefined(); + expect(callbackSpy).toHaveBeenCalledTimes(1); + expect(callbackSpy).toHaveBeenCalledWith(text2, para, 1); + }); + + it('Paragraph with incorrect segment', () => { + const para = createParagraph(); + + para.cachedElement = mockedCache; + + const text1 = createText('test1'); + const text2 = createText('test2'); + const text3 = createText('test3'); + + para.segments.push(text1, text3); + + const result = mutateSegment(para, text2, callbackSpy); + + expect(result).toEqual([para, null, -1]); + expect(result[0].cachedElement).toBeUndefined(); + expect(callbackSpy).toHaveBeenCalledTimes(0); + }); +}); diff --git a/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeContentModelTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeContentModelTest.ts index a821e02816a..bc3aa260c52 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeContentModelTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeContentModelTest.ts @@ -7,12 +7,13 @@ import { createTable } from '../../../lib/modelApi/creators/createTable'; import { createTableCell } from '../../../lib/modelApi/creators/createTableCell'; import { createText } from '../../../lib/modelApi/creators/createText'; import { normalizeContentModel } from '../../../lib/modelApi/common/normalizeContentModel'; +import { ReadonlyContentModelDocument } from 'roosterjs-content-model-types'; describe('normalizeContentModel', () => { it('Empty model', () => { const model = createContentModelDocument(); - normalizeContentModel(model); + normalizeContentModel(model as ReadonlyContentModelDocument); expect(model).toEqual({ blockGroupType: 'Document', @@ -28,7 +29,7 @@ describe('normalizeContentModel', () => { para.segments.push(text); model.blocks.push(para); - normalizeContentModel(model); + normalizeContentModel(model as ReadonlyContentModelDocument); expect(model).toEqual({ blockGroupType: 'Document', @@ -46,7 +47,7 @@ describe('normalizeContentModel', () => { para.segments.push(text1, text2, text3); model.blocks.push(para); - normalizeContentModel(model); + normalizeContentModel(model as ReadonlyContentModelDocument); expect(model).toEqual({ blockGroupType: 'Document', @@ -83,7 +84,7 @@ describe('normalizeContentModel', () => { para2.segments.push(text, br); model.blocks.push(para1, para2); - normalizeContentModel(model); + normalizeContentModel(model as ReadonlyContentModelDocument); expect(model).toEqual({ blockGroupType: 'Document', @@ -132,7 +133,7 @@ describe('normalizeContentModel', () => { para2.segments.push(br3, br4); model.blocks.push(para1, para2); - normalizeContentModel(model); + normalizeContentModel(model as ReadonlyContentModelDocument); expect(model).toEqual({ blockGroupType: 'Document', @@ -186,7 +187,7 @@ describe('normalizeContentModel', () => { para2.segments.push(text, br); model.blocks.push(para1, para2); - normalizeContentModel(model); + normalizeContentModel(model as ReadonlyContentModelDocument); expect(model).toEqual({ blockGroupType: 'Document', @@ -235,7 +236,7 @@ describe('normalizeContentModel', () => { table.rows[0].cells.push(cell); model.blocks.push(table); - normalizeContentModel(model); + normalizeContentModel(model as ReadonlyContentModelDocument); expect(model).toEqual({ blockGroupType: 'Document', @@ -277,7 +278,7 @@ describe('normalizeContentModel', () => { listItem.blocks.push(para); model.blocks.push(listItem); - normalizeContentModel(model); + normalizeContentModel(model as ReadonlyContentModelDocument); expect(model).toEqual({ blockGroupType: 'Document', diff --git a/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeParagraphTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeParagraphTest.ts index 95f6f6175f5..a28e3aa5a38 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeParagraphTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeParagraphTest.ts @@ -6,6 +6,7 @@ import { createSelectionMarker } from '../../../lib/modelApi/creators/createSele import { createText } from '../../../lib/modelApi/creators/createText'; import { normalizeContentModel } from '../../../lib/modelApi/common/normalizeContentModel'; import { normalizeParagraph } from '../../../lib/modelApi/common/normalizeParagraph'; +import { ReadonlyContentModelParagraph } from 'roosterjs-content-model-types'; describe('Normalize text that contains space', () => { function runTest(texts: string[], expected: string[], whiteSpace?: string) { @@ -360,7 +361,7 @@ describe('Normalize paragraph with segmentFormat', () => { it('Empty paragraph', () => { const paragraph = createParagraph(); - normalizeParagraph(paragraph); + normalizeParagraph(paragraph as ReadonlyContentModelParagraph); expect(paragraph).toEqual({ blockType: 'Paragraph', @@ -377,7 +378,7 @@ describe('Normalize paragraph with segmentFormat', () => { paragraph.segments.push(text); - normalizeParagraph(paragraph); + normalizeParagraph(paragraph as ReadonlyContentModelParagraph); expect(paragraph).toEqual({ blockType: 'Paragraph', @@ -405,7 +406,7 @@ describe('Normalize paragraph with segmentFormat', () => { paragraph.segments.push(text1, marker, text2); - normalizeParagraph(paragraph); + normalizeParagraph(paragraph as ReadonlyContentModelParagraph); expect(paragraph).toEqual({ blockType: 'Paragraph', @@ -443,7 +444,7 @@ describe('Normalize paragraph with segmentFormat', () => { paragraph.segments.push(text1, marker, text2); - normalizeParagraph(paragraph); + normalizeParagraph(paragraph as ReadonlyContentModelParagraph); expect(paragraph).toEqual({ blockType: 'Paragraph', @@ -482,7 +483,7 @@ describe('Normalize paragraph with segmentFormat', () => { paragraph.segments.push(text1, marker, text2); - normalizeParagraph(paragraph); + normalizeParagraph(paragraph as ReadonlyContentModelParagraph); expect(paragraph).toEqual({ blockType: 'Paragraph', diff --git a/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeSegmentTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeSegmentTest.ts index e33785530fd..7faa0cc8996 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeSegmentTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeSegmentTest.ts @@ -1,6 +1,8 @@ import { createBr } from '../../../lib/modelApi/creators/createBr'; import { createImage } from '../../../lib/modelApi/creators/createImage'; +import { createParagraph } from '../../../lib/modelApi/creators/createParagraph'; import { createText } from '../../../lib/modelApi/creators/createText'; +import { ReadonlyContentModelParagraph } from 'roosterjs-content-model-types'; import { createNormalizeSegmentContext, normalizeSegment, @@ -10,8 +12,11 @@ describe('normalizeSegment', () => { it('With initial context, image', () => { const context = createNormalizeSegmentContext(); const image = createImage('test'); + const para = createParagraph(); - normalizeSegment(image, context); + para.segments.push(image); + + normalizeSegment(para as ReadonlyContentModelParagraph, image, context); expect(image).toEqual({ segmentType: 'Image', @@ -32,8 +37,11 @@ describe('normalizeSegment', () => { it('With initial context, regular text', () => { const context = createNormalizeSegmentContext(); const text = createText('test'); + const para = createParagraph(); + + para.segments.push(text); - normalizeSegment(text, context); + normalizeSegment(para as ReadonlyContentModelParagraph, text, context); expect(text).toEqual({ segmentType: 'Text', @@ -53,8 +61,11 @@ describe('normalizeSegment', () => { it('With initial context, br', () => { const context = createNormalizeSegmentContext(); const br = createBr(); + const para = createParagraph(); - normalizeSegment(br, context); + para.segments.push(br); + + normalizeSegment(para as ReadonlyContentModelParagraph, br, context); expect(br).toEqual({ segmentType: 'Br', @@ -73,8 +84,11 @@ describe('normalizeSegment', () => { it('Normalize an empty string', () => { const context = createNormalizeSegmentContext(); const text = createText(''); + const para = createParagraph(); + + para.segments.push(text); - normalizeSegment(text, context); + normalizeSegment(para as ReadonlyContentModelParagraph, text, context); expect(text).toEqual({ segmentType: 'Text', @@ -94,8 +108,11 @@ describe('normalizeSegment', () => { it('Normalize an string with spaces', () => { const context = createNormalizeSegmentContext(); const text = createText(' aa '); + const para = createParagraph(); - normalizeSegment(text, context); + para.segments.push(text); + + normalizeSegment(para as ReadonlyContentModelParagraph, text, context); expect(text).toEqual({ segmentType: 'Text', @@ -115,8 +132,11 @@ describe('normalizeSegment', () => { it('Normalize an string with  ', () => { const context = createNormalizeSegmentContext(); const text = createText('\u00A0\u00A0aa\u00A0\u00A0'); + const para = createParagraph(); + + para.segments.push(text); - normalizeSegment(text, context); + normalizeSegment(para as ReadonlyContentModelParagraph, text, context); expect(text).toEqual({ segmentType: 'Text', @@ -136,10 +156,13 @@ describe('normalizeSegment', () => { it('Normalize an string space and ignoreLeadingSpaces = false', () => { const context = createNormalizeSegmentContext(); const text = createText(' aa '); + const para = createParagraph(); + + para.segments.push(text); context.ignoreLeadingSpaces = false; - normalizeSegment(text, context); + normalizeSegment(para as ReadonlyContentModelParagraph, text, context); expect(text).toEqual({ segmentType: 'Text', @@ -159,10 +182,13 @@ describe('normalizeSegment', () => { it('Normalize an string space and ignoreTrailingSpaces = false', () => { const context = createNormalizeSegmentContext(); const text = createText(' aa '); + const para = createParagraph(); + + para.segments.push(text); context.ignoreTrailingSpaces = false; - normalizeSegment(text, context); + normalizeSegment(para as ReadonlyContentModelParagraph, text, context); expect(text).toEqual({ segmentType: 'Text', diff --git a/packages/roosterjs-content-model-dom/test/modelApi/common/unwrapBlockTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/common/unwrapBlockTest.ts index ad8c24cfecb..3bd0e062abb 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/common/unwrapBlockTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/common/unwrapBlockTest.ts @@ -1,7 +1,11 @@ -import { ContentModelDocument } from 'roosterjs-content-model-types'; import { createFormatContainer } from '../../../lib/modelApi/creators/createFormatContainer'; import { createParagraph } from '../../../lib/modelApi/creators/createParagraph'; import { unwrapBlock } from '../../../lib/modelApi/common/unwrapBlock'; +import { + ContentModelDocument, + ReadonlyContentModelDocument, + ReadonlyContentModelFormatContainer, +} from 'roosterjs-content-model-types'; describe('unwrapBlock', () => { it('no parent', () => { @@ -40,7 +44,10 @@ describe('unwrapBlock', () => { para.isImplicit = true; quote.blocks.push(para); - unwrapBlock(doc, quote); + unwrapBlock( + doc as ReadonlyContentModelDocument, + quote as ReadonlyContentModelFormatContainer + ); expect(doc).toEqual({ blockGroupType: 'Document', @@ -76,7 +83,10 @@ describe('unwrapBlock', () => { para.isImplicit = true; quote.blocks.push(para); - unwrapBlock(doc, quote); + unwrapBlock( + doc as ReadonlyContentModelDocument, + quote as ReadonlyContentModelFormatContainer + ); expect(doc).toEqual({ blockGroupType: 'Document', diff --git a/packages/roosterjs-content-model-dom/test/modelApi/creators/creatorsTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/creators/creatorsTest.ts index 019b533068e..c0441215e0e 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/creators/creatorsTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/creators/creatorsTest.ts @@ -13,6 +13,7 @@ import { createParagraphDecorator } from '../../../lib/modelApi/creators/createP import { createSelectionMarker } from '../../../lib/modelApi/creators/createSelectionMarker'; import { createTable } from '../../../lib/modelApi/creators/createTable'; import { createTableCell } from '../../../lib/modelApi/creators/createTableCell'; +import { createTableRow } from '../../../lib/modelApi/creators/createTableRow'; import { createText } from '../../../lib/modelApi/creators/createText'; import { ContentModelCode, @@ -232,6 +233,26 @@ describe('Creators', () => { }); }); + it('createTableRow', () => { + const row = createTableRow(); + + expect(row).toEqual({ + height: 0, + format: {}, + cells: [], + }); + }); + + it('createTableRow with format', () => { + const row = createTableRow({ direction: 'ltr' }, 100); + + expect(row).toEqual({ + height: 100, + format: { direction: 'ltr' }, + cells: [], + }); + }); + it('createTable', () => { const tableModel = createTable(2); diff --git a/packages/roosterjs-content-model-dom/test/modelApi/editing/applyTableFormatTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/editing/applyTableFormatTest.ts index e7810d76afc..548505bbe12 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/editing/applyTableFormatTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/editing/applyTableFormatTest.ts @@ -4,6 +4,7 @@ import { ContentModelTable, ContentModelTableCell, ContentModelTableRow, + ReadonlyContentModelTable, TableMetadataFormat, } from 'roosterjs-content-model-types'; @@ -57,7 +58,7 @@ describe('applyTableFormat', () => { exportedBackgroundColors: string[][], expectedBorders: string[][][] ) { - const table = createTable(3, 4); + const table: ReadonlyContentModelTable = createTable(3, 4); applyTableFormat(table, format); @@ -457,7 +458,7 @@ describe('applyTableFormat', () => { const table = createTable(1, 1); table.rows[0].cells[0].format.backgroundColor = 'red'; - applyTableFormat(table, { + applyTableFormat(table as ReadonlyContentModelTable, { bgColorEven: 'green', }); @@ -466,7 +467,7 @@ describe('applyTableFormat', () => { table.rows[0].cells[0].dataset.editingInfo = '{"bgColorOverride":true}'; - applyTableFormat(table, { + applyTableFormat(table as ReadonlyContentModelTable, { bgColorEven: 'blue', }); @@ -476,7 +477,7 @@ describe('applyTableFormat', () => { table.rows[0].cells[0].dataset.editingInfo = '{"bgColorOverride":true}'; applyTableFormat( - table, + table as ReadonlyContentModelTable, { bgColorEven: 'yellow', }, @@ -492,7 +493,7 @@ describe('applyTableFormat', () => { table.rows[0].cells[0].format.borderLeft = '1px solid red'; // Try to apply green - applyTableFormat(table, { + applyTableFormat(table as ReadonlyContentModelTable, { topBorderColor: 'green', }); @@ -503,7 +504,7 @@ describe('applyTableFormat', () => { table.rows[0].cells[0].dataset.editingInfo = '{"borderOverride":true}'; // Try to apply blue - applyTableFormat(table, { + applyTableFormat(table as ReadonlyContentModelTable, { topBorderColor: 'blue', }); @@ -531,10 +532,10 @@ describe('applyTableFormat', () => { }; // Try to apply default format black - applyTableFormat(table, format); + applyTableFormat(table as ReadonlyContentModelTable, format); //apply HeaderRowColor - applyTableFormat(table, { ...format, hasHeaderRow: true }); + applyTableFormat(table as ReadonlyContentModelTable, { ...format, hasHeaderRow: true }); //expect HeaderRowColor text color to be applied table.rows[0].cells[0].blocks.forEach(block => { @@ -578,10 +579,10 @@ describe('applyTableFormat', () => { }; // Try to apply default format black - applyTableFormat(table, format); + applyTableFormat(table as ReadonlyContentModelTable, format); //apply HeaderRowColor - applyTableFormat(table, { ...format, hasHeaderRow: true }); + applyTableFormat(table as ReadonlyContentModelTable, { ...format, hasHeaderRow: true }); //expect HeaderRowColor text color to be applied table.rows[0].cells[0].blocks.forEach(block => { @@ -613,13 +614,13 @@ describe('applyTableFormat', () => { }; // Try to apply default format black - applyTableFormat(table, format); + applyTableFormat(table as ReadonlyContentModelTable, format); //apply HeaderRowColor - applyTableFormat(table, { ...format, hasHeaderRow: true }); + applyTableFormat(table as ReadonlyContentModelTable, { ...format, hasHeaderRow: true }); //Toggle HeaderRowColor - applyTableFormat(table, { ...format, hasHeaderRow: false }); + applyTableFormat(table as ReadonlyContentModelTable, { ...format, hasHeaderRow: false }); //expect HeaderRowColor text color to be applied table.rows[0].cells[0].blocks.forEach(block => { diff --git a/packages/roosterjs-content-model-dom/test/modelApi/editing/normalizeTableTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/editing/normalizeTableTest.ts index 9e81b4d9d37..3b5009f827a 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/editing/normalizeTableTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/editing/normalizeTableTest.ts @@ -5,9 +5,10 @@ import { ContentModelTable, ContentModelTableCellFormat, ContentModelTableFormat, + ReadonlyContentModelTable, } from 'roosterjs-content-model-types'; import { - createParagraph, + createParagraph as originalCreateParagraph, createTable as originalCreateTable, createTableCell as originalCreateTableCell, createText, @@ -15,6 +16,14 @@ import { const mockedCachedElement = {} as any; +function createParagraph(): ContentModelParagraph { + const paragraph = originalCreateParagraph(); + + paragraph.cachedElement = mockedCachedElement; + + return paragraph; +} + function createTable(rowCount: number, format?: ContentModelTableFormat): ContentModelTable { const table = originalCreateTable(rowCount, format); @@ -40,7 +49,7 @@ describe('normalizeTable', () => { it('Normalize an empty table', () => { const table = createTable(0); - normalizeTable(table); + normalizeTable(table as ReadonlyContentModelTable); expect(table).toEqual({ blockType: 'Table', @@ -51,7 +60,6 @@ describe('normalizeTable', () => { }, widths: [], dataset: {}, - cachedElement: mockedCachedElement, }); }); @@ -60,7 +68,7 @@ describe('normalizeTable', () => { table.rows[0].cells.push(createTableCell(1, 1, false)); - normalizeTable(table); + normalizeTable(table as ReadonlyContentModelTable); expect(table).toEqual({ blockType: 'Table', @@ -88,7 +96,6 @@ describe('normalizeTable', () => { }, ], dataset: {}, - cachedElement: mockedCachedElement, }, ], }, @@ -99,7 +106,6 @@ describe('normalizeTable', () => { }, widths: [120], dataset: {}, - cachedElement: mockedCachedElement, }); }); @@ -118,7 +124,7 @@ describe('normalizeTable', () => { table.rows[0].cells.push(cell1); table.rows[1].cells.push(cell2); - normalizeTable(table); + normalizeTable(table as ReadonlyContentModelTable); expect(table).toEqual({ blockType: 'Table', @@ -138,10 +144,10 @@ describe('normalizeTable', () => { blockType: 'Paragraph', segments: [], format: {}, + cachedElement: mockedCachedElement, }, ], dataset: {}, - cachedElement: mockedCachedElement, }, ], }, @@ -153,13 +159,14 @@ describe('normalizeTable', () => { blockGroupType: 'TableCell', spanLeft: false, spanAbove: false, - isHeader: false, + isHeader: true, format: { useBorderBox: true }, blocks: [ { blockType: 'Paragraph', segments: [], format: {}, + cachedElement: mockedCachedElement, }, ], dataset: {}, @@ -173,7 +180,133 @@ describe('normalizeTable', () => { }, widths: [120], dataset: {}, - cachedElement: mockedCachedElement, + }); + }); + + it('Normalize a table with only TH', () => { + const table = createTable(2); + + table.rows[0].cells.push(createTableCell(1, 1, true)); + table.rows[0].cells.push(createTableCell(1, 1, true)); + table.rows[1].cells.push(createTableCell(1, 1, true)); + table.rows[1].cells.push(createTableCell(1, 1, true)); + + table.widths = [100, 100]; + table.rows[0].height = 200; + table.rows[1].height = 200; + + normalizeTable(table); + + expect(table).toEqual({ + blockType: 'Table', + rows: [ + { + height: 200, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: { + useBorderBox: true, + }, + spanLeft: false, + spanAbove: false, + isHeader: true, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: { + useBorderBox: true, + }, + spanLeft: false, + spanAbove: false, + isHeader: true, + dataset: {}, + }, + ], + }, + { + height: 200, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: { + useBorderBox: true, + }, + spanLeft: false, + spanAbove: false, + isHeader: true, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: { + useBorderBox: true, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: { + borderCollapse: true, + useBorderBox: true, + }, + widths: [100, 100], + dataset: {}, }); }); @@ -204,7 +337,7 @@ describe('normalizeTable', () => { table.rows[0].cells.push(cell2); table.rows[0].cells.push(cell3); - normalizeTable(table); + normalizeTable(table as ReadonlyContentModelTable); expect(table).toEqual({ blockType: 'Table', @@ -230,6 +363,7 @@ describe('normalizeTable', () => { }, ], format: {}, + cachedElement: mockedCachedElement, }, { blockType: 'Paragraph', @@ -241,10 +375,10 @@ describe('normalizeTable', () => { }, ], format: {}, + cachedElement: mockedCachedElement, }, ], dataset: {}, - cachedElement: mockedCachedElement, }, { blockGroupType: 'TableCell', @@ -263,10 +397,10 @@ describe('normalizeTable', () => { }, ], format: {}, + cachedElement: mockedCachedElement, }, ], dataset: {}, - cachedElement: mockedCachedElement, }, ], }, @@ -277,7 +411,6 @@ describe('normalizeTable', () => { }, widths: [240, 120], dataset: {}, - cachedElement: mockedCachedElement, }); }); @@ -296,7 +429,7 @@ describe('normalizeTable', () => { table.rows[0].cells.push(cell1); table.rows[0].cells.push(cell2); - normalizeTable(table); + normalizeTable(table as ReadonlyContentModelTable); expect(table).toEqual({ blockType: 'Table', @@ -316,10 +449,10 @@ describe('normalizeTable', () => { blockType: 'Paragraph', segments: [], format: {}, + cachedElement: mockedCachedElement, }, ], dataset: {}, - cachedElement: mockedCachedElement, }, ], }, @@ -330,7 +463,6 @@ describe('normalizeTable', () => { }, widths: [240], dataset: {}, - cachedElement: mockedCachedElement, }); }); @@ -362,7 +494,7 @@ describe('normalizeTable', () => { table.rows[1].cells.push(cell3); table.rows[1].cells.push(cell4); - normalizeTable(table); + normalizeTable(table as ReadonlyContentModelTable); expect(table).toEqual({ blockType: 'Table', @@ -379,7 +511,6 @@ describe('normalizeTable', () => { format: { useBorderBox: true }, blocks: [block1, block2], dataset: {}, - cachedElement: mockedCachedElement, }, { blockGroupType: 'TableCell', @@ -389,7 +520,6 @@ describe('normalizeTable', () => { format: { useBorderBox: true }, blocks: [], dataset: {}, - cachedElement: mockedCachedElement, }, ], }, @@ -405,7 +535,6 @@ describe('normalizeTable', () => { format: { useBorderBox: true }, blocks: [block3], dataset: {}, - cachedElement: mockedCachedElement, }, { blockGroupType: 'TableCell', @@ -415,7 +544,6 @@ describe('normalizeTable', () => { format: { useBorderBox: true }, blocks: [block4], dataset: {}, - cachedElement: mockedCachedElement, }, ], }, @@ -426,7 +554,6 @@ describe('normalizeTable', () => { }, widths: [120, 120], dataset: {}, - cachedElement: mockedCachedElement, }); }); @@ -458,7 +585,7 @@ describe('normalizeTable', () => { table.rows[1].cells.push(cell3); table.rows[1].cells.push(cell4); - normalizeTable(table); + normalizeTable(table as ReadonlyContentModelTable); expect(table).toEqual({ blockType: 'Table', @@ -484,6 +611,7 @@ describe('normalizeTable', () => { }, ], format: {}, + cachedElement: mockedCachedElement, }, { blockType: 'Paragraph', @@ -495,10 +623,10 @@ describe('normalizeTable', () => { }, ], format: {}, + cachedElement: mockedCachedElement, }, ], dataset: {}, - cachedElement: mockedCachedElement, }, { blockGroupType: 'TableCell', @@ -517,6 +645,7 @@ describe('normalizeTable', () => { }, ], format: {}, + cachedElement: mockedCachedElement, }, { blockType: 'Paragraph', @@ -528,10 +657,10 @@ describe('normalizeTable', () => { }, ], format: {}, + cachedElement: mockedCachedElement, }, ], dataset: {}, - cachedElement: mockedCachedElement, }, ], }, @@ -542,7 +671,6 @@ describe('normalizeTable', () => { }, widths: [120, 120], dataset: {}, - cachedElement: mockedCachedElement, }); }); @@ -574,7 +702,7 @@ describe('normalizeTable', () => { table.rows[1].cells.push(cell3); table.rows[1].cells.push(cell4); - normalizeTable(table); + normalizeTable(table as ReadonlyContentModelTable); expect(table).toEqual({ blockType: 'Table', @@ -601,6 +729,7 @@ describe('normalizeTable', () => { }, ], format: {}, + cachedElement: mockedCachedElement, }, { blockType: 'Paragraph', @@ -612,6 +741,7 @@ describe('normalizeTable', () => { }, ], format: {}, + cachedElement: mockedCachedElement, }, { blockType: 'Paragraph', @@ -623,6 +753,7 @@ describe('normalizeTable', () => { }, ], format: {}, + cachedElement: mockedCachedElement, }, { blockType: 'Paragraph', @@ -634,9 +765,9 @@ describe('normalizeTable', () => { }, ], format: {}, + cachedElement: mockedCachedElement, }, ], - cachedElement: mockedCachedElement, }, ], }, @@ -647,7 +778,6 @@ describe('normalizeTable', () => { }, widths: [240], dataset: {}, - cachedElement: mockedCachedElement, }); }); @@ -659,7 +789,7 @@ describe('normalizeTable', () => { table.rows[0].cells.push(createTableCell(1, 1, false)); - normalizeTable(table, format); + normalizeTable(table as ReadonlyContentModelTable, format); expect(table).toEqual({ blockType: 'Table', @@ -690,7 +820,6 @@ describe('normalizeTable', () => { }, ], dataset: {}, - cachedElement: mockedCachedElement, }, ], }, @@ -701,7 +830,6 @@ describe('normalizeTable', () => { }, widths: [120], dataset: {}, - cachedElement: mockedCachedElement, }); }); @@ -720,7 +848,7 @@ describe('normalizeTable', () => { table.rows[0].height = 200; table.rows[1].height = 200; - normalizeTable(table); + normalizeTable(table as ReadonlyContentModelTable); const block: ContentModelParagraph = { blockType: 'Paragraph', @@ -748,7 +876,6 @@ describe('normalizeTable', () => { format: { useBorderBox: true }, blocks: [block], dataset: {}, - cachedElement: mockedCachedElement, }, { blockGroupType: 'TableCell', @@ -758,7 +885,6 @@ describe('normalizeTable', () => { format: { useBorderBox: true }, blocks: [block], dataset: {}, - cachedElement: mockedCachedElement, }, ], }, @@ -774,7 +900,6 @@ describe('normalizeTable', () => { format: { useBorderBox: true }, blocks: [block], dataset: {}, - cachedElement: mockedCachedElement, }, { blockGroupType: 'TableCell', @@ -784,7 +909,6 @@ describe('normalizeTable', () => { format: { useBorderBox: true }, blocks: [block], dataset: {}, - cachedElement: mockedCachedElement, }, ], }, @@ -795,7 +919,6 @@ describe('normalizeTable', () => { }, widths: [100, 100], dataset: {}, - cachedElement: mockedCachedElement, }); }); }); diff --git a/packages/roosterjs-content-model-dom/test/modelApi/editing/retrieveModelFormatStateTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/editing/retrieveModelFormatStateTest.ts index accfa4a4f09..6e4e485bdf5 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/editing/retrieveModelFormatStateTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/editing/retrieveModelFormatStateTest.ts @@ -60,7 +60,7 @@ describe('retrieveModelFormatState', () => { const para = createParagraph(); const marker = createSelectionMarker(segmentFormat); - spyOn(iterateSelections, 'iterateSelections').and.callFake((path, callback) => { + spyOn(iterateSelections, 'iterateSelections').and.callFake((path: any, callback) => { callback([path], undefined, para, [marker]); return false; }); @@ -78,7 +78,7 @@ describe('retrieveModelFormatState', () => { addCode(marker, { format: { fontFamily: 'monospace' } }); - spyOn(iterateSelections, 'iterateSelections').and.callFake((path, callback) => { + spyOn(iterateSelections, 'iterateSelections').and.callFake((path: any, callback) => { callback([path], undefined, para, [marker]); return false; }); @@ -100,7 +100,7 @@ describe('retrieveModelFormatState', () => { const listItem = createListItem([createListLevel('OL')]); const marker = createSelectionMarker(segmentFormat); - spyOn(iterateSelections, 'iterateSelections').and.callFake((path, callback) => { + spyOn(iterateSelections, 'iterateSelections').and.callFake((_, callback) => { callback([listItem], undefined, para, [marker]); return false; }); @@ -123,7 +123,7 @@ describe('retrieveModelFormatState', () => { const quote = createFormatContainer('blockquote'); const marker = createSelectionMarker(segmentFormat); - spyOn(iterateSelections, 'iterateSelections').and.callFake((path, callback) => { + spyOn(iterateSelections, 'iterateSelections').and.callFake((_, callback) => { callback([quote], undefined, para, [marker]); return false; }); @@ -146,7 +146,7 @@ describe('retrieveModelFormatState', () => { }); const marker = createSelectionMarker(segmentFormat); - spyOn(iterateSelections, 'iterateSelections').and.callFake((path, callback) => { + spyOn(iterateSelections, 'iterateSelections').and.callFake((path: any, callback) => { callback([path], undefined, para, [marker]); return false; }); @@ -171,7 +171,7 @@ describe('retrieveModelFormatState', () => { const para = createParagraph(false, paraFormat); const marker = createSelectionMarker(segmentFormat); - spyOn(iterateSelections, 'iterateSelections').and.callFake((path, callback) => { + spyOn(iterateSelections, 'iterateSelections').and.callFake((path: any, callback) => { callback([path], undefined, para, [marker]); return false; }); @@ -196,7 +196,7 @@ describe('retrieveModelFormatState', () => { table.rows[0].cells.push(cell); - spyOn(iterateSelections, 'iterateSelections').and.callFake((path, callback) => { + spyOn(iterateSelections, 'iterateSelections').and.callFake((path: any, callback) => { callback( [path], { @@ -233,7 +233,7 @@ describe('retrieveModelFormatState', () => { table.rows[0].cells.push(cell); applyTableFormat(table); - spyOn(iterateSelections, 'iterateSelections').and.callFake((path, callback) => { + spyOn(iterateSelections, 'iterateSelections').and.callFake((path: any, callback) => { callback( [path], { @@ -283,7 +283,7 @@ describe('retrieveModelFormatState', () => { table.rows[0].cells.push(cell1, cell2); model.blocks.push(table); - spyOn(iterateSelections, 'iterateSelections').and.callFake((path, callback) => { + spyOn(iterateSelections, 'iterateSelections').and.callFake((path: any, callback) => { callback([path], { table: table, rowIndex: 0, @@ -310,7 +310,7 @@ describe('retrieveModelFormatState', () => { const marker1 = createSelectionMarker(segmentFormat); const marker2 = createSelectionMarker(); - spyOn(iterateSelections, 'iterateSelections').and.callFake((path, callback) => { + spyOn(iterateSelections, 'iterateSelections').and.callFake((path: any, callback) => { callback([path], undefined, para1, [marker1]); callback([path], undefined, para2, [marker2]); return false; @@ -345,7 +345,7 @@ describe('retrieveModelFormatState', () => { const text2 = createText('test2', { fontFamily: 'Arial' }); const result: ContentModelFormatState = {}; - spyOn(iterateSelections, 'iterateSelections').and.callFake((path, callback) => { + spyOn(iterateSelections, 'iterateSelections').and.callFake((path: any, callback) => { callback([path], undefined, para, [text1, text2]); return false; }); @@ -371,7 +371,7 @@ describe('retrieveModelFormatState', () => { const text2 = createText('test2', { fontFamily: 'Times' }); const result: ContentModelFormatState = {}; - spyOn(iterateSelections, 'iterateSelections').and.callFake((path, callback) => { + spyOn(iterateSelections, 'iterateSelections').and.callFake((path: any, callback) => { callback([path], undefined, para, [text1, text2]); return false; }); @@ -396,7 +396,7 @@ describe('retrieveModelFormatState', () => { const marker = createSelectionMarker({ fontFamily: 'Times' }); const result: ContentModelFormatState = {}; - spyOn(iterateSelections, 'iterateSelections').and.callFake((path, callback) => { + spyOn(iterateSelections, 'iterateSelections').and.callFake((path: any, callback) => { callback([path], undefined, para, [text, marker]); return false; }); @@ -422,7 +422,7 @@ describe('retrieveModelFormatState', () => { const divider = createDivider('hr'); const marker1 = createSelectionMarker(segmentFormat); - spyOn(iterateSelections, 'iterateSelections').and.callFake((path, callback) => { + spyOn(iterateSelections, 'iterateSelections').and.callFake((path: any, callback) => { callback([path], undefined, para1, [marker1]); callback([path], undefined, divider); return false; @@ -445,7 +445,7 @@ describe('retrieveModelFormatState', () => { const divider = createDivider('hr'); const marker1 = createSelectionMarker(segmentFormat); - spyOn(iterateSelections, 'iterateSelections').and.callFake((path, callback) => { + spyOn(iterateSelections, 'iterateSelections').and.callFake((path: any, callback) => { callback([path], undefined, divider); callback([path], undefined, para1, [marker1]); return false; @@ -472,7 +472,7 @@ describe('retrieveModelFormatState', () => { textColor: 'block', }; - spyOn(iterateSelections, 'iterateSelections').and.callFake((path, callback) => { + spyOn(iterateSelections, 'iterateSelections').and.callFake((path: any, callback) => { callback([path], undefined, para1, [marker1]); return false; }); @@ -623,7 +623,7 @@ describe('retrieveModelFormatState', () => { image.isSelected = true; para.segments.push(image); - spyOn(iterateSelections, 'iterateSelections').and.callFake((path, callback) => { + spyOn(iterateSelections, 'iterateSelections').and.callFake((path: any, callback) => { callback([path], undefined, para, [image]); return false; }); @@ -663,7 +663,7 @@ describe('retrieveModelFormatState', () => { para.segments.push(image); para.segments.push(image2); - spyOn(iterateSelections, 'iterateSelections').and.callFake((path, callback) => { + spyOn(iterateSelections, 'iterateSelections').and.callFake((path: any, callback) => { callback([path], undefined, para, [image, image2]); return false; }); @@ -692,7 +692,7 @@ describe('retrieveModelFormatState', () => { const marker = createSelectionMarker(); para.segments.push(marker); - spyOn(iterateSelections, 'iterateSelections').and.callFake((path, callback) => { + spyOn(iterateSelections, 'iterateSelections').and.callFake((path: any, callback) => { callback([path], undefined, para, [marker]); return false; }); @@ -727,7 +727,7 @@ describe('retrieveModelFormatState', () => { text1.isSelected = true; - spyOn(iterateSelections, 'iterateSelections').and.callFake((path, callback) => { + spyOn(iterateSelections, 'iterateSelections').and.callFake((path: any, callback) => { callback([path], undefined, para, [text1]); return false; }); @@ -761,7 +761,7 @@ describe('retrieveModelFormatState', () => { text1.isSelected = true; text2.isSelected = true; - spyOn(iterateSelections, 'iterateSelections').and.callFake((path, callback) => { + spyOn(iterateSelections, 'iterateSelections').and.callFake((path: any, callback) => { callback([path], undefined, para, [text1, text2]); return false; }); @@ -791,7 +791,7 @@ describe('retrieveModelFormatState', () => { text1.isSelected = true; text2.isSelected = true; - spyOn(iterateSelections, 'iterateSelections').and.callFake((path, callback) => { + spyOn(iterateSelections, 'iterateSelections').and.callFake((path: any, callback) => { callback([path], undefined, para, [text1, text2]); return false; }); diff --git a/packages/roosterjs-content-model-dom/test/modelApi/editing/setTableCellBackgroundColorTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/editing/setTableCellBackgroundColorTest.ts index 6fbe2ce6fc2..ca884d9774a 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/editing/setTableCellBackgroundColorTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/editing/setTableCellBackgroundColorTest.ts @@ -1,13 +1,16 @@ -import { ContentModelTableCell, ContentModelTableCellFormat } from 'roosterjs-content-model-types'; import { createTableCell as originalCreateTableCell } from 'roosterjs-content-model-dom'; import { setTableCellBackgroundColor } from '../../../lib/modelApi/editing/setTableCellBackgroundColor'; +import { + ContentModelTableCellFormat, + ShallowMutableContentModelTableCell, +} from 'roosterjs-content-model-types'; function createTableCell( spanLeftOrColSpan?: boolean | number, spanAboveOrRowSpan?: boolean | number, isHeader?: boolean, format?: ContentModelTableCellFormat -): ContentModelTableCell { +): ShallowMutableContentModelTableCell { const cell = originalCreateTableCell(spanLeftOrColSpan, spanAboveOrRowSpan, isHeader, format); cell.cachedElement = {} as any; diff --git a/packages/roosterjs-content-model-dom/test/modelApi/metadata/updateImageMetadataTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/metadata/updateImageMetadataTest.ts index fa1d7115695..1912b6b21a4 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/metadata/updateImageMetadataTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/metadata/updateImageMetadataTest.ts @@ -1,5 +1,75 @@ import { ContentModelImage, ImageMetadataFormat } from 'roosterjs-content-model-types'; -import { updateImageMetadata } from '../../../lib/modelApi/metadata/updateImageMetadata'; +import { + getImageMetadata, + updateImageMetadata, +} from '../../../lib/modelApi/metadata/updateImageMetadata'; + +describe('getImageMetadataTest', () => { + it('No value', () => { + const image: ContentModelImage = { + segmentType: 'Image', + format: {}, + src: 'test', + dataset: {}, + }; + + const result = getImageMetadata(image); + + expect(result).toBeNull(); + }); + + it('Empty value', () => { + const image: ContentModelImage = { + segmentType: 'Image', + format: {}, + src: 'test', + dataset: { + editingInfo: '', + }, + }; + + const result = getImageMetadata(image); + + expect(result).toBeNull(); + }); + + it('Full valid value, return original value', () => { + const imageFormat: ImageMetadataFormat = { + widthPx: 1, + heightPx: 2, + leftPercent: 3, + rightPercent: 4, + topPercent: 5, + bottomPercent: 6, + angleRad: 7, + naturalHeight: 8, + naturalWidth: 9, + src: 'test', + }; + const image: ContentModelImage = { + segmentType: 'Image', + format: {}, + src: 'test', + dataset: { + editingInfo: JSON.stringify(imageFormat), + }, + }; + const result = getImageMetadata(image); + + expect(result).toEqual({ + widthPx: 1, + heightPx: 2, + leftPercent: 3, + rightPercent: 4, + topPercent: 5, + bottomPercent: 6, + angleRad: 7, + naturalHeight: 8, + naturalWidth: 9, + src: 'test', + }); + }); +}); describe('updateImageMetadataTest', () => { it('No value', () => { diff --git a/packages/roosterjs-content-model-dom/test/modelApi/metadata/updateListMetadataTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/metadata/updateListMetadataTest.ts index 528746e83e2..ed49e4af6a8 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/metadata/updateListMetadataTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/metadata/updateListMetadataTest.ts @@ -1,5 +1,48 @@ import { ContentModelWithDataset, ListMetadataFormat } from 'roosterjs-content-model-types'; -import { updateListMetadata } from '../../../lib/modelApi/metadata/updateListMetadata'; +import { + getListMetadata, + updateListMetadata, +} from '../../../lib/modelApi/metadata/updateListMetadata'; + +describe('getListMetadata', () => { + it('No value', () => { + const list: ContentModelWithDataset = { + dataset: {}, + }; + const result = getListMetadata(list); + + expect(result).toBeNull(); + }); + + it('Empty value', () => { + const list: ContentModelWithDataset = { + dataset: { + editingInfo: '', + }, + }; + const result = getListMetadata(list); + + expect(result).toBeNull(); + }); + + it('Full valid value, return original value', () => { + const listFormat: ListMetadataFormat = { + orderedStyleType: 2, + unorderedStyleType: 3, + }; + const list: ContentModelWithDataset = { + dataset: { + editingInfo: JSON.stringify(listFormat), + }, + }; + const result = getListMetadata(list); + + expect(result).toEqual({ + orderedStyleType: 2, + unorderedStyleType: 3, + }); + }); +}); describe('updateListMetadata', () => { it('No value', () => { diff --git a/packages/roosterjs-content-model-dom/test/modelApi/metadata/updateMetadataTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/metadata/updateMetadataTest.ts index 081280cc381..9dc34a783b3 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/metadata/updateMetadataTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/metadata/updateMetadataTest.ts @@ -1,6 +1,97 @@ import * as validate from '../../../lib/modelApi/metadata/validate'; -import { ContentModelWithDataset, Definition } from 'roosterjs-content-model-types'; -import { hasMetadata, updateMetadata } from '../../../lib/modelApi/metadata/updateMetadata'; +import { + getMetadata, + hasMetadata, + updateMetadata, +} from '../../../lib/modelApi/metadata/updateMetadata'; +import { + ContentModelWithDataset, + Definition, + ReadonlyContentModelWithDataset, +} from 'roosterjs-content-model-types'; + +describe('getMetadata', () => { + it('no metadata', () => { + const model: ReadonlyContentModelWithDataset = { + dataset: {}, + }; + const result = getMetadata(model); + + expect(model).toEqual({ + dataset: {}, + }); + expect(result).toBeNull(); + }); + + it('with metadata', () => { + const model: ReadonlyContentModelWithDataset = { + dataset: { + editingInfo: '{"a":"b"}', + }, + }; + + const result = getMetadata(model); + + expect(model).toEqual({ + dataset: { + editingInfo: '{"a":"b"}', + }, + }); + expect(result).toEqual({ + a: 'b', + }); + }); + + it('with metadata, pass the validation', () => { + const model: ContentModelWithDataset = { + dataset: { + editingInfo: '{"a":"b"}', + }, + }; + + const validateSpy = spyOn(validate, 'validate').and.returnValue(true); + + const mockedDefinition = 'DEFINITION' as any; + const result = getMetadata(model, mockedDefinition); + + expect(validateSpy).toHaveBeenCalledWith( + { + a: 'b', + }, + mockedDefinition + ); + expect(model).toEqual({ + dataset: { editingInfo: '{"a":"b"}' }, + }); + expect(result).toEqual({ + a: 'b', + }); + }); + + it('with metadata, fail the validation, return new value', () => { + const model: ContentModelWithDataset = { + dataset: { + editingInfo: '{"a":"b"}', + }, + }; + + const validateSpy = spyOn(validate, 'validate').and.returnValue(false); + + const mockedDefinition = 'DEFINITION' as any; + const result = getMetadata(model, mockedDefinition); + + expect(validateSpy).toHaveBeenCalledWith( + { + a: 'b', + }, + mockedDefinition + ); + expect(model).toEqual({ + dataset: { editingInfo: '{"a":"b"}' }, + }); + expect(result).toBeNull(); + }); +}); describe('updateMetadata', () => { it('no metadata', () => { diff --git a/packages/roosterjs-content-model-dom/test/modelApi/metadata/updateTableCellMetadataTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/metadata/updateTableCellMetadataTest.ts index bf0bbd273b3..bf43f76c1f0 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/metadata/updateTableCellMetadataTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/metadata/updateTableCellMetadataTest.ts @@ -1,5 +1,61 @@ import { ContentModelTableCell, TableCellMetadataFormat } from 'roosterjs-content-model-types'; -import { updateTableCellMetadata } from '../../../lib//modelApi/metadata/updateTableCellMetadata'; +import { + getTableCellMetadata, + updateTableCellMetadata, +} from '../../../lib//modelApi/metadata/updateTableCellMetadata'; + +describe('getTableCellMetadata', () => { + it('No value', () => { + const cell: ContentModelTableCell = { + blockGroupType: 'TableCell', + format: {}, + blocks: [], + spanAbove: false, + spanLeft: false, + dataset: {}, + }; + const result = getTableCellMetadata(cell); + + expect(result).toBeNull(); + }); + + it('Empty value', () => { + const cell: ContentModelTableCell = { + blockGroupType: 'TableCell', + format: {}, + blocks: [], + spanAbove: false, + spanLeft: false, + dataset: { + editingInfo: '', + }, + }; + const result = getTableCellMetadata(cell); + + expect(result).toBeNull(); + }); + + it('Full valid value, return original value', () => { + const cellFormat: TableCellMetadataFormat = { + bgColorOverride: true, + }; + const cell: ContentModelTableCell = { + blockGroupType: 'TableCell', + format: {}, + blocks: [], + spanAbove: false, + spanLeft: false, + dataset: { + editingInfo: JSON.stringify(cellFormat), + }, + }; + const result = getTableCellMetadata(cell); + + expect(result).toEqual({ + bgColorOverride: true, + }); + }); +}); describe('updateTableCellMetadata', () => { it('No value', () => { diff --git a/packages/roosterjs-content-model-dom/test/modelApi/metadata/updateTableMetadataTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/metadata/updateTableMetadataTest.ts index 250731ca03b..1501e5c9137 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/metadata/updateTableMetadataTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/metadata/updateTableMetadataTest.ts @@ -1,6 +1,81 @@ import { ContentModelTable, TableMetadataFormat } from 'roosterjs-content-model-types'; import { TableBorderFormat } from '../../../lib/constants/TableBorderFormat'; -import { updateTableMetadata } from '../../../lib/modelApi/metadata/updateTableMetadata'; +import { + getTableMetadata, + updateTableMetadata, +} from '../../../lib/modelApi/metadata/updateTableMetadata'; + +describe('getTableMetadata', () => { + it('No value', () => { + const table: ContentModelTable = { + blockType: 'Table', + format: {}, + rows: [], + widths: [], + dataset: {}, + }; + const result = getTableMetadata(table); + + expect(result).toBeNull(); + }); + + it('Empty value', () => { + const table: ContentModelTable = { + blockType: 'Table', + format: {}, + rows: [], + widths: [], + dataset: { + editingInfo: '', + }, + }; + const result = getTableMetadata(table); + + expect(result).toBeNull(); + }); + + it('Full valid value, return original value', () => { + const tableFormat: TableMetadataFormat = { + topBorderColor: 'red', + bottomBorderColor: 'blue', + verticalBorderColor: 'green', + hasHeaderRow: true, + headerRowColor: 'orange', + hasFirstColumn: true, + hasBandedColumns: false, + hasBandedRows: true, + bgColorEven: 'yellow', + bgColorOdd: 'gray', + tableBorderFormat: TableBorderFormat.Default, + verticalAlign: 'top', + }; + const table: ContentModelTable = { + blockType: 'Table', + format: {}, + rows: [], + widths: [], + dataset: { + editingInfo: JSON.stringify(tableFormat), + }, + }; + const result = getTableMetadata(table); + + expect(result).toEqual({ + topBorderColor: 'red', + bottomBorderColor: 'blue', + verticalBorderColor: 'green', + hasHeaderRow: true, + headerRowColor: 'orange', + hasFirstColumn: true, + hasBandedColumns: false, + hasBandedRows: true, + bgColorEven: 'yellow', + bgColorOdd: 'gray', + tableBorderFormat: TableBorderFormat.Default, + verticalAlign: 'top', + }); + }); +}); describe('updateTableMetadata', () => { it('No value', () => { diff --git a/packages/roosterjs-content-model-dom/test/modelApi/selection/collectSelectionsTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/selection/collectSelectionsTest.ts index 1a3b5c23b47..bd950e01685 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/selection/collectSelectionsTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/selection/collectSelectionsTest.ts @@ -77,6 +77,9 @@ describe('getSelectedSegmentsAndParagraphs', () => { const p1 = createParagraph(); const p2 = createParagraph(); + p1.segments.push(s1, s2); + p2.segments.push(s3, s4); + runTest( [ { @@ -132,6 +135,7 @@ describe('getSelectedSegmentsAndParagraphs', () => { const s3 = createText('test3'); const s4 = createText('test4'); const b1 = createDivider('div'); + const doc = createContentModelDocument(); runTest( [ @@ -141,16 +145,13 @@ describe('getSelectedSegmentsAndParagraphs', () => { segments: [s1, s2], }, { - path: [], + path: [doc], segments: [s3, s4], }, ], true, false, - [ - [s3, null, []], - [s4, null, []], - ] + [] ); }); diff --git a/packages/roosterjs-content-model-dom/test/modelApi/selection/getSelectedSegmentsTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/selection/getSelectedSegmentsTest.ts index e2fa50eeb64..aea8c2ee375 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/selection/getSelectedSegmentsTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/selection/getSelectedSegmentsTest.ts @@ -7,8 +7,10 @@ import { TableSelectionContext, } from 'roosterjs-content-model-types'; import { + createContentModelDocument, createDivider, createEntity, + createListItem, createParagraph, createSelectionMarker, createText, @@ -52,6 +54,9 @@ describe('getSelectedSegments', () => { const p1 = createParagraph(); const p2 = createParagraph(); + p1.segments.push(s1, s2); + p2.segments.push(s3, s4); + runTest( [ { @@ -100,21 +105,46 @@ describe('getSelectedSegments', () => { const s3 = createText('test3'); const s4 = createText('test4'); const b1 = createDivider('div'); + const doc = createContentModelDocument(); runTest( [ { - path: [], + path: [doc], block: b1, segments: [s1, s2], }, { - path: [], + path: [doc], segments: [s3, s4], }, ], true, - [s3, s4] + [] + ); + }); + + it('Block with list item, include format holder', () => { + const s1 = createText('test1'); + const s2 = createText('test2'); + const b1 = createDivider('div'); + const doc = createContentModelDocument(); + const listItem = createListItem([]); + + runTest( + [ + { + path: [doc], + block: b1, + segments: [s1, s2], + }, + { + path: [listItem, doc], + segments: [listItem.formatHolder], + }, + ], + true, + [listItem.formatHolder] ); }); diff --git a/packages/roosterjs-content-model-dom/test/modelApi/selection/iterateSelectionsTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/selection/iterateSelectionsTest.ts index 55f2664bc5d..f1761b764a3 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/selection/iterateSelectionsTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/selection/iterateSelectionsTest.ts @@ -1219,6 +1219,10 @@ describe('iterateSelections', () => { const marker2 = createSelectionMarker(); const cache = 'CACHE' as any; + addSegment(quote1, marker1); + para1.segments.push(marker2); + divider1.isSelected = true; + quote1.cachedElement = cache; para1.cachedElement = cache; divider1.cachedElement = cache; @@ -1226,10 +1230,6 @@ describe('iterateSelections', () => { para2.cachedElement = cache; divider2.cachedElement = cache; - addSegment(quote1, marker1); - para1.segments.push(marker2); - divider1.isSelected = true; - const doc = createContentModelDocument(); doc.blocks.push(quote1, quote2, para1, para2, divider1, divider2); diff --git a/packages/roosterjs-content-model-dom/test/modelApi/selection/setSelectionTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/selection/setSelectionTest.ts index c7c5d7fcefe..b87aa5d56b9 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/selection/setSelectionTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/selection/setSelectionTest.ts @@ -599,7 +599,6 @@ describe('setSelection', () => { src: '', dataset: {}, isSelected: true, - isSelectedAsImageSelection: false, }, { segmentType: 'Text', diff --git a/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLink.ts b/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLink.ts index 7d997c765fc..21efd02a57a 100644 --- a/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLink.ts +++ b/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLink.ts @@ -6,9 +6,13 @@ import type { IEditor, LinkData } from 'roosterjs-content-model-types'; * @internal */ export function createLink(editor: IEditor) { + let anchorNode: Node | null = null; formatTextSegmentBeforeSelectionMarker( editor, (_model, linkSegment, _paragraph) => { + if (linkSegment.link) { + return true; + } let linkData: LinkData | null = null; if (!linkSegment.link && (linkData = matchLink(linkSegment.text))) { addLink(linkSegment, { @@ -20,10 +24,17 @@ export function createLink(editor: IEditor) { }); return true; } + return false; }, { changeSource: ChangeSource.AutoLink, + onNodeCreated: (_modelElement, node) => { + if (!anchorNode) { + anchorNode = node; + } + }, + getChangeData: () => anchorNode, } ); } diff --git a/packages/roosterjs-content-model-plugins/lib/autoFormat/list/getListTypeStyle.ts b/packages/roosterjs-content-model-plugins/lib/autoFormat/list/getListTypeStyle.ts index 7a6c64dc584..8ab4ec1ccb0 100644 --- a/packages/roosterjs-content-model-plugins/lib/autoFormat/list/getListTypeStyle.ts +++ b/packages/roosterjs-content-model-plugins/lib/autoFormat/list/getListTypeStyle.ts @@ -1,9 +1,10 @@ import { findListItemsInSameThread } from 'roosterjs-content-model-api'; import { getNumberingListStyle } from './getNumberingListStyle'; import type { - ContentModelDocument, ContentModelListItem, - ContentModelParagraph, + ReadonlyContentModelDocument, + ReadonlyContentModelListItem, + ReadonlyContentModelParagraph, } from 'roosterjs-content-model-types'; import { BulletListType, @@ -26,7 +27,7 @@ interface ListTypeStyle { * @internal */ export function getListTypeStyle( - model: ContentModelDocument, + model: ReadonlyContentModelDocument, shouldSearchForBullet: boolean = true, shouldSearchForNumbering: boolean = true ): ListTypeStyle | undefined { @@ -77,13 +78,16 @@ export function getListTypeStyle( } const getPreviousListIndex = ( - model: ContentModelDocument, - previousListItem?: ContentModelListItem + model: ReadonlyContentModelDocument, + previousListItem?: ReadonlyContentModelListItem ) => { return previousListItem ? findListItemsInSameThread(model, previousListItem).length : undefined; }; -const getPreviousListLevel = (model: ContentModelDocument, paragraph: ContentModelParagraph) => { +const getPreviousListLevel = ( + model: ReadonlyContentModelDocument, + paragraph: ReadonlyContentModelParagraph +) => { const blocks = getOperationalBlocks( model, ['ListItem'], diff --git a/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts index 59cfe12ccc8..ddaee7906e8 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts @@ -2,6 +2,7 @@ import { keyboardDelete } from './keyboardDelete'; import { keyboardInput } from './keyboardInput'; import { keyboardTab } from './keyboardTab'; import type { + DOMSelection, EditorPlugin, IEditor, KeyDownEvent, @@ -22,6 +23,7 @@ export class EditPlugin implements EditorPlugin { private editor: IEditor | null = null; private disposer: (() => void) | null = null; private shouldHandleNextInputEvent = false; + private selectionAfterDelete: DOMSelection | null = null; /** * Get name of this plugin @@ -70,6 +72,12 @@ export class EditPlugin implements EditorPlugin { case 'keyDown': this.handleKeyDownEvent(this.editor, event); break; + case 'keyUp': + if (this.selectionAfterDelete) { + this.editor.setDOMSelection(this.selectionAfterDelete); + this.selectionAfterDelete = null; + } + break; } } } @@ -150,15 +158,9 @@ export class EditPlugin implements EditorPlugin { if (handled) { rawEvent.preventDefault(); - // Restore the selection to avoid the cursor jump issue + // Restore the selection on keyup event to avoid the cursor jump issue // See: https://issues.chromium.org/issues/330596261 - const selection = editor.getDOMSelection(); - const doc = this.editor?.getDocument(); - doc?.defaultView?.requestAnimationFrame(() => { - if (this.editor) { - this.editor.setDOMSelection(selection); - } - }); + this.selectionAfterDelete = editor.getDOMSelection(); } } } diff --git a/packages/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteCollapsedSelection.ts b/packages/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteCollapsedSelection.ts index fdaf459ab6f..06d6c066100 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteCollapsedSelection.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteCollapsedSelection.ts @@ -4,15 +4,16 @@ import { deleteBlock, deleteSegment, getClosestAncestorBlockGroupIndex, + mutateBlock, setParagraphNotImplicit, } from 'roosterjs-content-model-dom'; -import type { BlockAndPath } from '../utils/getLeafSiblingBlock'; +import type { ReadonlyBlockAndPath } from '../utils/getLeafSiblingBlock'; import type { - ContentModelBlockGroup, - ContentModelDocument, - ContentModelParagraph, - ContentModelSegment, DeleteSelectionStep, + ReadonlyContentModelBlockGroup, + ReadonlyContentModelDocument, + ShallowMutableContentModelParagraph, + ShallowMutableContentModelSegment, } from 'roosterjs-content-model-types'; function getDeleteCollapsedSelection(direction: 'forward' | 'backward'): DeleteSelectionStep { @@ -29,8 +30,8 @@ function getDeleteCollapsedSelection(direction: 'forward' | 'backward'): DeleteS const index = segments.indexOf(marker) + (isForward ? 1 : -1); const segmentToDelete = segments[index]; - let blockToDelete: BlockAndPath | null; - let root: ContentModelDocument | null; + let blockToDelete: ReadonlyBlockAndPath | null; + let root: ReadonlyContentModelDocument | null; if (segmentToDelete) { if (deleteSegment(paragraph, segmentToDelete, context.formatContext, direction)) { @@ -47,9 +48,11 @@ function getDeleteCollapsedSelection(direction: 'forward' | 'backward'): DeleteS setModelIndentation(root, 'outdent'); context.deleteResult = 'range'; } else if ((blockToDelete = getLeafSiblingBlock(path, paragraph, isForward))) { - const { block, path, siblingSegment } = blockToDelete; + const { block: readonlyBlock, path, siblingSegment } = blockToDelete; + + if (readonlyBlock.blockType == 'Paragraph') { + const block = mutateBlock(readonlyBlock); - if (block.blockType == 'Paragraph') { if (siblingSegment) { // When selection is under general segment, need to check if it has a sibling sibling, and delete from it if (deleteSegment(block, siblingSegment, context.formatContext, direction)) { @@ -70,7 +73,6 @@ function getDeleteCollapsedSelection(direction: 'forward' | 'backward'): DeleteS tableContext, }; context.lastParagraph = paragraph; - delete block.cachedElement; } context.deleteResult = 'range'; @@ -81,8 +83,8 @@ function getDeleteCollapsedSelection(direction: 'forward' | 'backward'): DeleteS } else { if ( deleteBlock( - path[0].blocks, - block, + mutateBlock(path[0]).blocks, + readonlyBlock, undefined /*replacement*/, context.formatContext, direction @@ -100,16 +102,16 @@ function getDeleteCollapsedSelection(direction: 'forward' | 'backward'): DeleteS }; } -function getRoot(path: ContentModelBlockGroup[]): ContentModelDocument | null { +function getRoot(path: ReadonlyContentModelBlockGroup[]): ReadonlyContentModelDocument | null { const lastInPath = path[path.length - 1]; return lastInPath.blockGroupType == 'Document' ? lastInPath : null; } function shouldOutdentParagraph( isForward: boolean, - segments: ContentModelSegment[], - paragraph: ContentModelParagraph, - path: ContentModelBlockGroup[] + segments: ShallowMutableContentModelSegment[], + paragraph: ShallowMutableContentModelParagraph, + path: ReadonlyContentModelBlockGroup[] ) { return ( !isForward && @@ -125,7 +127,7 @@ function shouldOutdentParagraph( * If the last segment is BR, remove it for now. We may add it back later when normalize model. * So that if this is an empty paragraph, it will start to delete next block */ -function fixupBr(segments: ContentModelSegment[]) { +function fixupBr(segments: ShallowMutableContentModelSegment[]) { if (segments[segments.length - 1]?.segmentType == 'Br') { const segmentsWithoutBr = segments.filter(x => x.segmentType != 'SelectionMarker'); diff --git a/packages/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteEmptyQuote.ts b/packages/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteEmptyQuote.ts index cb337857438..8148d5f9a66 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteEmptyQuote.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteEmptyQuote.ts @@ -4,11 +4,12 @@ import { unwrapBlock, getClosestAncestorBlockGroupIndex, isBlockGroupOfType, + mutateBlock, } from 'roosterjs-content-model-dom'; import type { - ContentModelBlockGroup, ContentModelFormatContainer, DeleteSelectionStep, + ReadonlyContentModelBlockGroup, } from 'roosterjs-content-model-types'; /** @@ -75,7 +76,7 @@ const isSelectionOnEmptyLine = (quote: ContentModelFormatContainer) => { const insertNewLine = ( quote: ContentModelFormatContainer, - parent: ContentModelBlockGroup, + parent: ReadonlyContentModelBlockGroup, index: number ) => { const quoteLength = quote.blocks.length; @@ -83,5 +84,5 @@ const insertNewLine = ( const marker = createSelectionMarker(); const newParagraph = createParagraph(false /* isImplicit */); newParagraph.segments.push(marker); - parent.blocks.splice(index + 1, 0, newParagraph); + mutateBlock(parent).blocks.splice(index + 1, 0, newParagraph); }; diff --git a/packages/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteWordSelection.ts b/packages/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteWordSelection.ts index 303dd449eae..b612d813a53 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteWordSelection.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteWordSelection.ts @@ -5,9 +5,9 @@ import { normalizeText, } from 'roosterjs-content-model-dom'; import type { - ContentModelParagraph, DeleteSelectionContext, DeleteSelectionStep, + ShallowMutableContentModelParagraph, } from 'roosterjs-content-model-types'; const enum DeleteWordState { @@ -101,7 +101,7 @@ function getDeleteWordSelection(direction: 'forward' | 'backward'): DeleteSelect } function* iterateSegments( - paragraph: ContentModelParagraph, + paragraph: ShallowMutableContentModelParagraph, markerIndex: number, forward: boolean, context: DeleteSelectionContext diff --git a/packages/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts b/packages/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts index 869d20ac8f5..112cbb1a6c9 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts @@ -10,12 +10,16 @@ import { setParagraphNotImplicit, getClosestAncestorBlockGroupIndex, isBlockGroupOfType, + mutateBlock, } from 'roosterjs-content-model-dom'; import type { - ContentModelBlockGroup, ContentModelListItem, DeleteSelectionStep, InsertPoint, + ReadonlyContentModelBlockGroup, + ReadonlyContentModelListItem, + ShallowMutableContentModelListItem, + ShallowMutableContentModelParagraph, ValidDeleteSelectionContext, } from 'roosterjs-content-model-types'; @@ -66,7 +70,7 @@ export const handleEnterOnList: DeleteSelectionStep = context => { lastParagraph.segments[lastParagraph.segments.length - 1].segmentType === 'SelectionMarker' ) { - lastParagraph.segments.pop(); + mutateBlock(lastParagraph).segments.pop(); nextParagraph.segments.unshift( createSelectionMarker(insertPoint.marker.format) @@ -77,7 +81,7 @@ export const handleEnterOnList: DeleteSelectionStep = context => { } } else if (deleteResult !== 'range') { if (isEmptyListItem(listItem)) { - listItem.levels.pop(); + mutateBlock(listItem).levels.pop(); } else { const newListItem = createNewListItem(context, listItem, listParent); @@ -96,7 +100,7 @@ export const handleEnterOnList: DeleteSelectionStep = context => { } }; -const isEmptyListItem = (listItem: ContentModelListItem) => { +const isEmptyListItem = (listItem: ReadonlyContentModelListItem) => { return ( listItem.blocks.length === 1 && listItem.blocks[0].blockType === 'Paragraph' && @@ -108,24 +112,27 @@ const isEmptyListItem = (listItem: ContentModelListItem) => { const createNewListItem = ( context: ValidDeleteSelectionContext, - listItem: ContentModelListItem, - listParent: ContentModelBlockGroup + listItem: ReadonlyContentModelListItem, + listParent: ReadonlyContentModelBlockGroup ) => { const { insertPoint } = context; const listIndex = listParent.blocks.indexOf(listItem); const newParagraph = createNewParagraph(insertPoint); const levels = createNewListLevel(listItem); - const newListItem = createListItem(levels, insertPoint.marker.format); + const newListItem: ShallowMutableContentModelListItem = createListItem( + levels, + insertPoint.marker.format + ); newListItem.blocks.push(newParagraph); insertPoint.paragraph = newParagraph; context.lastParagraph = newParagraph; - listParent.blocks.splice(listIndex + 1, 0, newListItem); + mutateBlock(listParent).blocks.splice(listIndex + 1, 0, newListItem); return newListItem; }; -const createNewListLevel = (listItem: ContentModelListItem) => { +const createNewListLevel = (listItem: ReadonlyContentModelListItem) => { return listItem.levels.map(level => { return createListLevel( level.listType, @@ -141,7 +148,7 @@ const createNewListLevel = (listItem: ContentModelListItem) => { const createNewParagraph = (insertPoint: InsertPoint) => { const { paragraph, marker } = insertPoint; - const newParagraph = createParagraph( + const newParagraph: ShallowMutableContentModelParagraph = createParagraph( false /*isImplicit*/, paragraph.format, paragraph.segmentFormat diff --git a/packages/roosterjs-content-model-plugins/lib/edit/utils/getLeafSiblingBlock.ts b/packages/roosterjs-content-model-plugins/lib/edit/utils/getLeafSiblingBlock.ts index b5913fce32c..f99390a79bc 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/utils/getLeafSiblingBlock.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/utils/getLeafSiblingBlock.ts @@ -4,6 +4,9 @@ import type { ContentModelBlockGroup, ContentModelParagraph, ContentModelSegment, + ReadonlyContentModelBlock, + ReadonlyContentModelBlockGroup, + ReadonlyContentModelSegment, } from 'roosterjs-content-model-types'; /** @@ -27,6 +30,27 @@ export type BlockAndPath = { siblingSegment?: ContentModelSegment; }; +/** + * @internal + */ +export type ReadonlyBlockAndPath = { + /** + * The sibling block + */ + block: ReadonlyContentModelBlock; + + /** + * Path of this sibling block + */ + path: ReadonlyContentModelBlockGroup[]; + + /** + * If the input block is under a general segment, it is possible there are sibling segments under the same paragraph. + * Use this property to return the sibling sibling under the same paragraph + */ + siblingSegment?: ReadonlyContentModelSegment; +}; + /** * @internal */ @@ -34,7 +58,22 @@ export function getLeafSiblingBlock( path: ContentModelBlockGroup[], block: ContentModelBlock, isNext: boolean -): BlockAndPath | null { +): BlockAndPath | null; + +/** + * @internal (Readonly) + */ +export function getLeafSiblingBlock( + path: ReadonlyContentModelBlockGroup[], + block: ReadonlyContentModelBlock, + isNext: boolean +): ReadonlyBlockAndPath | null; + +export function getLeafSiblingBlock( + path: ReadonlyContentModelBlockGroup[], + block: ReadonlyContentModelBlock, + isNext: boolean +): ReadonlyBlockAndPath | null { const newPath = [...path]; while (newPath.length > 0) { diff --git a/packages/roosterjs-content-model-plugins/lib/tableEdit/editors/features/CellResizer.ts b/packages/roosterjs-content-model-plugins/lib/tableEdit/editors/features/CellResizer.ts index 982bdac877e..759025c1493 100644 --- a/packages/roosterjs-content-model-plugins/lib/tableEdit/editors/features/CellResizer.ts +++ b/packages/roosterjs-content-model-plugins/lib/tableEdit/editors/features/CellResizer.ts @@ -1,10 +1,10 @@ import { createElement } from '../../../pluginUtils/CreateElement/createElement'; import { DragAndDropHelper } from '../../../pluginUtils/DragAndDrop/DragAndDropHelper'; +import { getCMTableFromTable } from '../utils/getTableFromContentModel'; import type { TableEditFeature } from './TableEditFeature'; import { isElementOfType, normalizeRect, - getFirstSelectedTable, MIN_ALLOWED_TABLE_CELL_WIDTH, normalizeTable, } from 'roosterjs-content-model-dom'; @@ -107,24 +107,8 @@ function onDragStart(context: DragAndDropContext, event: MouseEvent): DragAndDro const { editor, table } = context; - // Get current selection - const selection = editor.getDOMSelection(); - - // Select first cell of the table - editor.setDOMSelection({ - type: 'table', - firstColumn: 0, - firstRow: 0, - lastColumn: 0, - lastRow: 0, - table: table, - }); - - // Get the table content model - const cmTable = getFirstSelectedTable(editor.getContentModelCopy('disconnected'))[0]; - - // Restore selection - editor.setDOMSelection(selection); + // Get Table block in content model + const cmTable = getCMTableFromTable(editor, table); if (rect && cmTable) { onStart(); diff --git a/packages/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableMover.ts b/packages/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableMover.ts index 6af1d7482a0..cacc513e56e 100644 --- a/packages/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableMover.ts +++ b/packages/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableMover.ts @@ -1,6 +1,7 @@ import { createElement } from '../../../pluginUtils/CreateElement/createElement'; import { DragAndDropHelper } from '../../../pluginUtils/DragAndDrop/DragAndDropHelper'; import { formatInsertPointWithContentModel } from 'roosterjs-content-model-api'; +import { getCMTableFromTable } from '../utils/getTableFromContentModel'; import type { TableEditFeature } from './TableEditFeature'; import type { OnTableEditorCreatedCallback } from '../../OnTableEditorCreatedCallback'; import type { DragAndDropHandler } from '../../../pluginUtils/DragAndDrop/DragAndDropHandler'; @@ -234,24 +235,11 @@ export function onDragStart(context: TableMoverContext): TableMoverInitValue { tableRect.style.left = `${trect.left}px`; div.parentNode?.appendChild(tableRect); - // Get current selection + // Get drag start selection const initialSelection = editor.getDOMSelection(); - // Select first cell of the table - editor.setDOMSelection({ - type: 'table', - firstColumn: 0, - firstRow: 0, - lastColumn: 0, - lastRow: 0, - table: table, - }); - - // Get the table content model - const [cmTable] = getFirstSelectedTable(editor.getContentModelCopy('disconnected')); - - // Restore selection - editor.setDOMSelection(initialSelection); + // Get Table block in content model + const cmTable = getCMTableFromTable(editor, table); return { cmTable, diff --git a/packages/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableResizer.ts b/packages/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableResizer.ts index 4ee986a1bdc..9e256cad719 100644 --- a/packages/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableResizer.ts +++ b/packages/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableResizer.ts @@ -1,13 +1,9 @@ import { createElement } from '../../../pluginUtils/CreateElement/createElement'; import { DragAndDropHelper } from '../../../pluginUtils/DragAndDrop/DragAndDropHelper'; +import { getCMTableFromTable } from '../utils/getTableFromContentModel'; +import { isNodeOfType, normalizeRect, normalizeTable } from 'roosterjs-content-model-dom'; import type { TableEditFeature } from './TableEditFeature'; import type { OnTableEditorCreatedCallback } from '../../OnTableEditorCreatedCallback'; -import { - getFirstSelectedTable, - isNodeOfType, - normalizeRect, - normalizeTable, -} from 'roosterjs-content-model-dom'; import type { ContentModelTable, IEditor, Rect } from 'roosterjs-content-model-types'; import type { DragAndDropHandler } from '../../../pluginUtils/DragAndDrop/DragAndDropHandler'; @@ -129,24 +125,8 @@ function onDragStart(context: DragAndDropContext, event: MouseEvent) { const { editor, table } = context; - // Get current selection - const selection = editor.getDOMSelection(); - - // Select first cell of the table - editor.setDOMSelection({ - type: 'table', - firstColumn: 0, - firstRow: 0, - lastColumn: 0, - lastRow: 0, - table: table, - }); - - // Get the table content model - const cmTable = getFirstSelectedTable(editor.getContentModelCopy('disconnected'))[0]; - - // Restore selection - editor.setDOMSelection(selection); + // Get Table block in content model + const cmTable = getCMTableFromTable(editor, table); // Save original widths and heights const heights: number[] = []; diff --git a/packages/roosterjs-content-model-plugins/lib/tableEdit/editors/utils/getTableFromContentModel.ts b/packages/roosterjs-content-model-plugins/lib/tableEdit/editors/utils/getTableFromContentModel.ts new file mode 100644 index 00000000000..cfc21aa6585 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/tableEdit/editors/utils/getTableFromContentModel.ts @@ -0,0 +1,29 @@ +import { getFirstSelectedTable } from 'roosterjs-content-model-dom'; +import type { ContentModelTable, IEditor } from 'roosterjs-content-model-types'; + +/** + * @internal + * Get ContentModelTable from a table element if it is present in the content model + */ +export function getCMTableFromTable(editor: IEditor, table: HTMLTableElement) { + let cmTable: ContentModelTable | undefined; + + editor.formatContentModel( + model => { + [cmTable] = getFirstSelectedTable(model); + return false; + }, + { + selectionOverride: { + type: 'table', + firstColumn: 0, + firstRow: 0, + lastColumn: 0, + lastRow: 0, + table: table, + }, + } + ); + + return cmTable; +} diff --git a/packages/roosterjs-content-model-plugins/lib/watermark/isModelEmptyFast.ts b/packages/roosterjs-content-model-plugins/lib/watermark/isModelEmptyFast.ts index 4265e799e34..b17d285d964 100644 --- a/packages/roosterjs-content-model-plugins/lib/watermark/isModelEmptyFast.ts +++ b/packages/roosterjs-content-model-plugins/lib/watermark/isModelEmptyFast.ts @@ -1,10 +1,10 @@ -import type { ContentModelDocument } from 'roosterjs-content-model-types'; +import type { ReadonlyContentModelDocument } from 'roosterjs-content-model-types'; /** * @internal * A fast way to check if content model is empty */ -export function isModelEmptyFast(model: ContentModelDocument): boolean { +export function isModelEmptyFast(model: ReadonlyContentModelDocument): boolean { const firstBlock = model.blocks[0]; if (model.blocks.length > 1) { diff --git a/packages/roosterjs-content-model-plugins/test/autoFormat/link/createLinkTest.ts b/packages/roosterjs-content-model-plugins/test/autoFormat/link/createLinkTest.ts index e3496c40d77..edd4e5e7e31 100644 --- a/packages/roosterjs-content-model-plugins/test/autoFormat/link/createLinkTest.ts +++ b/packages/roosterjs-content-model-plugins/test/autoFormat/link/createLinkTest.ts @@ -163,6 +163,6 @@ describe('createLink', () => { format: {}, }; - runTest(input, input, false); + runTest(input, input, true); }); }); diff --git a/packages/roosterjs-content-model-plugins/test/tableEdit/tableInserterTest.ts b/packages/roosterjs-content-model-plugins/test/tableEdit/tableInserterTest.ts index 48e6c778b47..74386776bf7 100644 --- a/packages/roosterjs-content-model-plugins/test/tableEdit/tableInserterTest.ts +++ b/packages/roosterjs-content-model-plugins/test/tableEdit/tableInserterTest.ts @@ -59,10 +59,12 @@ describe('Table Inserter tests', () => { // Inserter is visible, but pointer is not over it return 'not clickable'; } - const table = getCurrentTable(editor); + let table = getCurrentTable(editor); const rows = getTableRows(table); const cols = getTableColumns(table); inserter.dispatchEvent(new MouseEvent('click')); + + table = getCurrentTable(editor); const newRows = getTableRows(table); const newCols = getTableColumns(table); expect(newRows).toBe(inserterType == VERTICAL_INSERTER_ID ? rows : rows + 1); diff --git a/packages/roosterjs-content-model-types/lib/contentModel/block/ContentModelBlock.ts b/packages/roosterjs-content-model-types/lib/contentModel/block/ContentModelBlock.ts index 59179bdd73c..7ec515f4e77 100644 --- a/packages/roosterjs-content-model-types/lib/contentModel/block/ContentModelBlock.ts +++ b/packages/roosterjs-content-model-types/lib/contentModel/block/ContentModelBlock.ts @@ -1,10 +1,30 @@ -import type { ContentModelDivider } from './ContentModelDivider'; +import type { ContentModelDivider, ReadonlyContentModelDivider } from './ContentModelDivider'; import type { ContentModelEntity } from '../entity/ContentModelEntity'; -import type { ContentModelFormatContainer } from '../blockGroup/ContentModelFormatContainer'; -import type { ContentModelGeneralBlock } from '../blockGroup/ContentModelGeneralBlock'; -import type { ContentModelListItem } from '../blockGroup/ContentModelListItem'; -import type { ContentModelParagraph } from './ContentModelParagraph'; -import type { ContentModelTable } from './ContentModelTable'; +import type { + ContentModelFormatContainer, + ReadonlyContentModelFormatContainer, + ShallowMutableContentModelFormatContainer, +} from '../blockGroup/ContentModelFormatContainer'; +import type { + ContentModelGeneralBlock, + ReadonlyContentModelGeneralBlock, + ShallowMutableContentModelGeneralBlock, +} from '../blockGroup/ContentModelGeneralBlock'; +import type { + ContentModelListItem, + ReadonlyContentModelListItem, + ShallowMutableContentModelListItem, +} from '../blockGroup/ContentModelListItem'; +import type { + ContentModelParagraph, + ReadonlyContentModelParagraph, + ShallowMutableContentModelParagraph, +} from './ContentModelParagraph'; +import type { + ContentModelTable, + ReadonlyContentModelTable, + ShallowMutableContentModelTable, +} from './ContentModelTable'; /** * A union type of Content Model Block @@ -17,3 +37,27 @@ export type ContentModelBlock = | ContentModelParagraph | ContentModelEntity | ContentModelDivider; + +/** + * A union type of Content Model Block (Readonly) + */ +export type ReadonlyContentModelBlock = + | ReadonlyContentModelFormatContainer + | ReadonlyContentModelListItem + | ReadonlyContentModelGeneralBlock + | ReadonlyContentModelTable + | ReadonlyContentModelParagraph + | ContentModelEntity + | ReadonlyContentModelDivider; + +/** + * A union type of Content Model Block (Shallow mutable) + */ +export type ShallowMutableContentModelBlock = + | ShallowMutableContentModelFormatContainer + | ShallowMutableContentModelListItem + | ShallowMutableContentModelGeneralBlock + | ShallowMutableContentModelTable + | ShallowMutableContentModelParagraph + | ContentModelEntity + | ContentModelDivider; diff --git a/packages/roosterjs-content-model-types/lib/contentModel/block/ContentModelBlockBase.ts b/packages/roosterjs-content-model-types/lib/contentModel/block/ContentModelBlockBase.ts index 8e05b45151f..7fef63cb17a 100644 --- a/packages/roosterjs-content-model-types/lib/contentModel/block/ContentModelBlockBase.ts +++ b/packages/roosterjs-content-model-types/lib/contentModel/block/ContentModelBlockBase.ts @@ -1,16 +1,57 @@ +import type { ContentModelBlockWithCache } from '../common/ContentModelBlockWithCache'; +import type { MutableMark, ReadonlyMark, ShallowMutableMark } from '../common/MutableMark'; import type { ContentModelBlockFormat } from '../format/ContentModelBlockFormat'; import type { ContentModelBlockType } from './BlockType'; -import type { ContentModelWithFormat } from '../format/ContentModelWithFormat'; +import type { + ContentModelWithFormat, + ReadonlyContentModelWithFormat, +} from '../format/ContentModelWithFormat'; /** - * Base type of a block + * Common part of base type of a block */ -export interface ContentModelBlockBase< - T extends ContentModelBlockType, - TFormat extends ContentModelBlockFormat = ContentModelBlockFormat -> extends ContentModelWithFormat { +export interface ContentModelBlockBaseCommon { /** * Type of this block */ - blockType: T; + readonly blockType: T; } + +/** + * Base type of a block + */ +export interface ContentModelBlockBase< + T extends ContentModelBlockType, + TFormat extends ContentModelBlockFormat = ContentModelBlockFormat, + TCacheElement extends HTMLElement = HTMLElement +> + extends MutableMark, + ContentModelBlockBaseCommon, + ContentModelWithFormat, + ContentModelBlockWithCache {} + +/** + * Base type of a block (Readonly) + */ +export interface ReadonlyContentModelBlockBase< + T extends ContentModelBlockType, + TFormat extends ContentModelBlockFormat = ContentModelBlockFormat, + TCacheElement extends HTMLElement = HTMLElement +> + extends ReadonlyMark, + ContentModelBlockBaseCommon, + ReadonlyContentModelWithFormat, + ContentModelBlockWithCache {} + +/** + * Base type of a block (Shallow mutable) + */ +export interface ShallowMutableContentModelBlockBase< + T extends ContentModelBlockType, + TFormat extends ContentModelBlockFormat = ContentModelBlockFormat, + TCacheElement extends HTMLElement = HTMLElement +> + extends ShallowMutableMark, + ContentModelBlockBaseCommon, + ContentModelWithFormat, + ContentModelBlockWithCache {} diff --git a/packages/roosterjs-content-model-types/lib/contentModel/block/ContentModelDivider.ts b/packages/roosterjs-content-model-types/lib/contentModel/block/ContentModelDivider.ts index 23fe4386273..c38c9d15734 100644 --- a/packages/roosterjs-content-model-types/lib/contentModel/block/ContentModelDivider.ts +++ b/packages/roosterjs-content-model-types/lib/contentModel/block/ContentModelDivider.ts @@ -1,15 +1,11 @@ -import type { ContentModelBlockBase } from './ContentModelBlockBase'; -import type { ContentModelBlockWithCache } from '../common/ContentModelBlockWithCache'; +import type { ContentModelBlockBase, ReadonlyContentModelBlockBase } from './ContentModelBlockBase'; import type { ContentModelDividerFormat } from '../format/ContentModelDividerFormat'; -import type { Selectable } from '../common/Selectable'; +import type { ReadonlySelectable, Selectable } from '../common/Selectable'; /** - * Content Model of horizontal divider + * Common part of Content Model of horizontal divider */ -export interface ContentModelDivider - extends Selectable, - ContentModelBlockWithCache, - ContentModelBlockBase<'Divider', ContentModelDividerFormat> { +export interface ContentModelDividerCommon { /** * Tag name of this element, either HR or DIV */ @@ -20,3 +16,19 @@ export interface ContentModelDivider */ size?: string; } + +/** + * Content Model of horizontal divider + */ +export interface ContentModelDivider + extends Selectable, + ContentModelDividerCommon, + ContentModelBlockBase<'Divider', ContentModelDividerFormat> {} + +/** + * Content Model of horizontal divider (Readonly) + */ +export interface ReadonlyContentModelDivider + extends ReadonlySelectable, + ReadonlyContentModelBlockBase<'Divider', ContentModelDividerFormat>, + Readonly {} diff --git a/packages/roosterjs-content-model-types/lib/contentModel/block/ContentModelParagraph.ts b/packages/roosterjs-content-model-types/lib/contentModel/block/ContentModelParagraph.ts index cb47999a4a4..94a46391352 100644 --- a/packages/roosterjs-content-model-types/lib/contentModel/block/ContentModelParagraph.ts +++ b/packages/roosterjs-content-model-types/lib/contentModel/block/ContentModelParagraph.ts @@ -1,14 +1,31 @@ -import type { ContentModelBlockBase } from './ContentModelBlockBase'; -import type { ContentModelBlockWithCache } from '../common/ContentModelBlockWithCache'; -import type { ContentModelParagraphDecorator } from '../decorator/ContentModelParagraphDecorator'; -import type { ContentModelSegment } from '../segment/ContentModelSegment'; +import type { ContentModelBlockBase, ReadonlyContentModelBlockBase } from './ContentModelBlockBase'; +import type { + ContentModelParagraphDecorator, + ReadonlyContentModelParagraphDecorator, +} from '../decorator/ContentModelParagraphDecorator'; +import type { + ContentModelSegment, + ReadonlyContentModelSegment, + ShallowMutableContentModelSegment, +} from '../segment/ContentModelSegment'; import type { ContentModelSegmentFormat } from '../format/ContentModelSegmentFormat'; +/** + * Common part of Content Model of Paragraph + */ +export interface ContentModelParagraphCommon { + /** + * Whether this block was created from a block HTML element or just some simple segment between other block elements. + * True means it doesn't have a related block element, false means it was from a block element + */ + isImplicit?: boolean; +} + /** * Content Model of Paragraph */ export interface ContentModelParagraph - extends ContentModelBlockWithCache, + extends ContentModelParagraphCommon, ContentModelBlockBase<'Paragraph'> { /** * Segments within this paragraph @@ -24,10 +41,48 @@ export interface ContentModelParagraph * Decorator info for this paragraph, used by heading and P tags */ decorator?: ContentModelParagraphDecorator; +} +/** + * Content Model of Paragraph (Readonly) + */ +export interface ReadonlyContentModelParagraph + extends ReadonlyContentModelBlockBase<'Paragraph'>, + Readonly { /** - * Whether this block was created from a block HTML element or just some simple segment between other block elements. - * True means it doesn't have a related block element, false means it was from a block element + * Segments within this paragraph */ - isImplicit?: boolean; + readonly segments: ReadonlyArray; + + /** + * Segment format on this paragraph. This is mostly used for default format + */ + readonly segmentFormat?: Readonly; + + /** + * Decorator info for this paragraph, used by heading and P tags + */ + readonly decorator?: ReadonlyContentModelParagraphDecorator; +} + +/** + * Content Model of Paragraph (Shallow mutable) + */ +export interface ShallowMutableContentModelParagraph + extends ContentModelParagraphCommon, + ContentModelBlockBase<'Paragraph'> { + /** + * Segments within this paragraph + */ + segments: ShallowMutableContentModelSegment[]; + + /** + * Segment format on this paragraph. This is mostly used for default format + */ + segmentFormat?: ContentModelSegmentFormat; + + /** + * Decorator info for this paragraph, used by heading and P tags + */ + decorator?: ContentModelParagraphDecorator; } diff --git a/packages/roosterjs-content-model-types/lib/contentModel/block/ContentModelTable.ts b/packages/roosterjs-content-model-types/lib/contentModel/block/ContentModelTable.ts index c9c70ebe6c4..741fd7f8abc 100644 --- a/packages/roosterjs-content-model-types/lib/contentModel/block/ContentModelTable.ts +++ b/packages/roosterjs-content-model-types/lib/contentModel/block/ContentModelTable.ts @@ -1,17 +1,27 @@ -import type { ContentModelBlockBase } from './ContentModelBlockBase'; -import type { ContentModelBlockWithCache } from '../common/ContentModelBlockWithCache'; +import type { + ContentModelBlockBase, + ReadonlyContentModelBlockBase, + ShallowMutableContentModelBlockBase, +} from './ContentModelBlockBase'; import type { ContentModelTableFormat } from '../format/ContentModelTableFormat'; -import type { ContentModelTableRow } from './ContentModelTableRow'; -import type { ContentModelWithDataset } from '../format/ContentModelWithDataset'; +import type { + ContentModelTableRow, + ReadonlyContentModelTableRow, + ShallowMutableContentModelTableRow, +} from './ContentModelTableRow'; +import type { + ContentModelWithDataset, + ReadonlyContentModelWithDataset, + ShallowMutableContentModelWithDataset, +} from '../format/ContentModelWithDataset'; import type { TableMetadataFormat } from '../format/metadata/TableMetadataFormat'; /** * Content Model of Table */ export interface ContentModelTable - extends ContentModelBlockBase<'Table', ContentModelTableFormat>, - ContentModelWithDataset, - ContentModelBlockWithCache { + extends ContentModelBlockBase<'Table', ContentModelTableFormat, HTMLTableElement>, + ContentModelWithDataset { /** * Widths of each column */ @@ -22,3 +32,37 @@ export interface ContentModelTable */ rows: ContentModelTableRow[]; } + +/** + * Content Model of Table (Readonly) + */ +export interface ReadonlyContentModelTable + extends ReadonlyContentModelBlockBase<'Table', ContentModelTableFormat, HTMLTableElement>, + ReadonlyContentModelWithDataset { + /** + * Widths of each column + */ + readonly widths: ReadonlyArray; + + /** + * Cells of this table + */ + readonly rows: ReadonlyArray; +} + +/** + * Content Model of Table (Shallow mutable) + */ +export interface ShallowMutableContentModelTable + extends ShallowMutableContentModelBlockBase<'Table', ContentModelTableFormat, HTMLTableElement>, + ShallowMutableContentModelWithDataset { + /** + * Widths of each column + */ + widths: number[]; + + /** + * Cells of this table + */ + rows: ShallowMutableContentModelTableRow[]; +} diff --git a/packages/roosterjs-content-model-types/lib/contentModel/block/ContentModelTableRow.ts b/packages/roosterjs-content-model-types/lib/contentModel/block/ContentModelTableRow.ts index 7a5327feb01..3dd881f7309 100644 --- a/packages/roosterjs-content-model-types/lib/contentModel/block/ContentModelTableRow.ts +++ b/packages/roosterjs-content-model-types/lib/contentModel/block/ContentModelTableRow.ts @@ -1,21 +1,63 @@ +import type { MutableMark, ReadonlyMark, ShallowMutableMark } from '../common/MutableMark'; import type { ContentModelBlockFormat } from '../format/ContentModelBlockFormat'; import type { ContentModelBlockWithCache } from '../common/ContentModelBlockWithCache'; -import type { ContentModelTableCell } from '../blockGroup/ContentModelTableCell'; -import type { ContentModelWithFormat } from '../format/ContentModelWithFormat'; +import type { + ContentModelTableCell, + ReadonlyContentModelTableCell, +} from '../blockGroup/ContentModelTableCell'; +import type { + ContentModelWithFormat, + ReadonlyContentModelWithFormat, +} from '../format/ContentModelWithFormat'; /** - * Content Model of Table + * Common part of Content Model of Table */ -export interface ContentModelTableRow - extends ContentModelBlockWithCache, - ContentModelWithFormat { +export interface ContentModelTableRowCommon { /** * Heights of each row */ height: number; +} +/** + * Content Model of Table + */ +export interface ContentModelTableRow + extends MutableMark, + ContentModelTableRowCommon, + ContentModelBlockWithCache, + ContentModelWithFormat { /** * Cells of this table */ cells: ContentModelTableCell[]; } + +/** + * Content Model of Table (Readonly) + */ +export interface ReadonlyContentModelTableRow + extends ReadonlyMark, + Readonly, + ContentModelBlockWithCache, + ReadonlyContentModelWithFormat { + /** + * Cells of this table + */ + readonly cells: ReadonlyArray; +} + +/** + * Content Model of Table (Readonly) + */ +export interface ShallowMutableContentModelTableRow + extends ShallowMutableMark, + ContentModelTableRowCommon, + ContentModelBlockWithCache, + ContentModelWithFormat { + /** + * Cells of this table + */ + cells: ReadonlyContentModelTableCell[]; +} diff --git a/packages/roosterjs-content-model-types/lib/contentModel/blockGroup/ContentModelBlockGroup.ts b/packages/roosterjs-content-model-types/lib/contentModel/blockGroup/ContentModelBlockGroup.ts index 9462d998e10..d526e962738 100644 --- a/packages/roosterjs-content-model-types/lib/contentModel/blockGroup/ContentModelBlockGroup.ts +++ b/packages/roosterjs-content-model-types/lib/contentModel/blockGroup/ContentModelBlockGroup.ts @@ -1,8 +1,28 @@ -import type { ContentModelDocument } from './ContentModelDocument'; -import type { ContentModelFormatContainer } from './ContentModelFormatContainer'; -import type { ContentModelGeneralBlock } from './ContentModelGeneralBlock'; -import type { ContentModelListItem } from './ContentModelListItem'; -import type { ContentModelTableCell } from './ContentModelTableCell'; +import type { + ContentModelDocument, + ReadonlyContentModelDocument, + ShallowMutableContentModelDocument, +} from './ContentModelDocument'; +import type { + ContentModelFormatContainer, + ReadonlyContentModelFormatContainer, + ShallowMutableContentModelFormatContainer, +} from './ContentModelFormatContainer'; +import type { + ContentModelGeneralBlock, + ReadonlyContentModelGeneralBlock, + ShallowMutableContentModelGeneralBlock, +} from './ContentModelGeneralBlock'; +import type { + ContentModelListItem, + ReadonlyContentModelListItem, + ShallowMutableContentModelListItem, +} from './ContentModelListItem'; +import type { + ContentModelTableCell, + ReadonlyContentModelTableCell, + ShallowMutableContentModelTableCell, +} from './ContentModelTableCell'; /** * The union type of Content Model Block Group @@ -13,3 +33,23 @@ export type ContentModelBlockGroup = | ContentModelListItem | ContentModelTableCell | ContentModelGeneralBlock; + +/** + * The union type of Content Model Block Group (Readonly) + */ +export type ReadonlyContentModelBlockGroup = + | ReadonlyContentModelDocument + | ReadonlyContentModelFormatContainer + | ReadonlyContentModelListItem + | ReadonlyContentModelTableCell + | ReadonlyContentModelGeneralBlock; + +/** + * The union type of Content Model Block Group (Shallow mutable) + */ +export type ShallowMutableContentModelBlockGroup = + | ShallowMutableContentModelDocument + | ShallowMutableContentModelFormatContainer + | ShallowMutableContentModelListItem + | ShallowMutableContentModelTableCell + | ShallowMutableContentModelGeneralBlock; diff --git a/packages/roosterjs-content-model-types/lib/contentModel/blockGroup/ContentModelBlockGroupBase.ts b/packages/roosterjs-content-model-types/lib/contentModel/blockGroup/ContentModelBlockGroupBase.ts index 648f9b1bd70..3235a5e4acb 100644 --- a/packages/roosterjs-content-model-types/lib/contentModel/blockGroup/ContentModelBlockGroupBase.ts +++ b/packages/roosterjs-content-model-types/lib/contentModel/blockGroup/ContentModelBlockGroupBase.ts @@ -1,17 +1,56 @@ -import type { ContentModelBlock } from '../block/ContentModelBlock'; +import type { ContentModelBlockWithCache } from '../common/ContentModelBlockWithCache'; +import type { MutableMark, ReadonlyMark, ShallowMutableMark } from '../common/MutableMark'; +import type { ContentModelBlock, ReadonlyContentModelBlock } from '../block/ContentModelBlock'; import type { ContentModelBlockGroupType } from './BlockGroupType'; /** - * Base type of Content Model Block Group + * Common part of base type of Content Model Block Group */ -export interface ContentModelBlockGroupBase { +export interface ContentModelBlockGroupBaseCommon< + T extends ContentModelBlockGroupType, + TElement extends HTMLElement = HTMLElement +> extends ContentModelBlockWithCache { /** * Type of this block group */ - blockGroupType: T; + readonly blockGroupType: T; +} +/** + * Base type of Content Model Block Group + */ +export interface ContentModelBlockGroupBase< + T extends ContentModelBlockGroupType, + TElement extends HTMLElement = HTMLElement +> extends MutableMark, ContentModelBlockGroupBaseCommon { /** * Blocks under this group */ blocks: ContentModelBlock[]; } + +/** + * Base type of Content Model Block Group (Readonly) + */ +export interface ReadonlyContentModelBlockGroupBase< + T extends ContentModelBlockGroupType, + TElement extends HTMLElement = HTMLElement +> extends ReadonlyMark, ContentModelBlockGroupBaseCommon { + /** + * Blocks under this group + */ + readonly blocks: ReadonlyArray; +} + +/** + * Base type of Content Model Block Group (Shallow mutable) + */ +export interface ShallowMutableContentModelBlockGroupBase< + T extends ContentModelBlockGroupType, + TElement extends HTMLElement = HTMLElement +> extends ShallowMutableMark, ContentModelBlockGroupBaseCommon { + /** + * Blocks under this group + */ + blocks: ReadonlyContentModelBlock[]; +} diff --git a/packages/roosterjs-content-model-types/lib/contentModel/blockGroup/ContentModelDocument.ts b/packages/roosterjs-content-model-types/lib/contentModel/blockGroup/ContentModelDocument.ts index 7f1eac6188c..0584de310d6 100644 --- a/packages/roosterjs-content-model-types/lib/contentModel/blockGroup/ContentModelDocument.ts +++ b/packages/roosterjs-content-model-types/lib/contentModel/blockGroup/ContentModelDocument.ts @@ -1,15 +1,44 @@ -import type { ContentModelBlockGroupBase } from './ContentModelBlockGroupBase'; +import type { + ContentModelBlockGroupBase, + ReadonlyContentModelBlockGroupBase, + ShallowMutableContentModelBlockGroupBase, +} from './ContentModelBlockGroupBase'; import type { ContentModelSegmentFormat } from '../format/ContentModelSegmentFormat'; -import type { ContentModelWithFormat } from '../format/ContentModelWithFormat'; +import type { + ContentModelWithFormat, + ReadonlyContentModelWithFormat, +} from '../format/ContentModelWithFormat'; /** - * Content Model document entry point + * Common part of Content Model document entry point */ -export interface ContentModelDocument - extends ContentModelBlockGroupBase<'Document'>, - Partial> { +export interface ContentModelDocumentCommon { /** * Whether the selection in model (if any) is a revert selection (end is before start) */ hasRevertedRangeSelection?: boolean; } + +/** + * Content Model document entry point + */ +export interface ContentModelDocument + extends ContentModelDocumentCommon, + ContentModelBlockGroupBase<'Document'>, + Partial> {} + +/** + * Content Model document entry point (Readonly) + */ +export interface ReadonlyContentModelDocument + extends Readonly, + ReadonlyContentModelBlockGroupBase<'Document'>, + Partial> {} + +/** + * Content Model document entry point (Shallow mutable) + */ +export interface ShallowMutableContentModelDocument + extends ContentModelDocumentCommon, + ShallowMutableContentModelBlockGroupBase<'Document'>, + Partial> {} diff --git a/packages/roosterjs-content-model-types/lib/contentModel/blockGroup/ContentModelFormatContainer.ts b/packages/roosterjs-content-model-types/lib/contentModel/blockGroup/ContentModelFormatContainer.ts index 5abe704bdea..8ebcc01c06e 100644 --- a/packages/roosterjs-content-model-types/lib/contentModel/blockGroup/ContentModelFormatContainer.ts +++ b/packages/roosterjs-content-model-types/lib/contentModel/blockGroup/ContentModelFormatContainer.ts @@ -1,15 +1,19 @@ -import type { ContentModelBlockBase } from '../block/ContentModelBlockBase'; -import type { ContentModelBlockGroupBase } from './ContentModelBlockGroupBase'; -import type { ContentModelBlockWithCache } from '../common/ContentModelBlockWithCache'; +import type { + ContentModelBlockBase, + ReadonlyContentModelBlockBase, + ShallowMutableContentModelBlockBase, +} from '../block/ContentModelBlockBase'; +import type { + ContentModelBlockGroupBase, + ReadonlyContentModelBlockGroupBase, + ShallowMutableContentModelBlockGroupBase, +} from './ContentModelBlockGroupBase'; import type { ContentModelFormatContainerFormat } from '../format/ContentModelFormatContainerFormat'; /** - * Content Model of Format Container + * Common part of Content Model of Format Container */ -export interface ContentModelFormatContainer - extends ContentModelBlockWithCache, - ContentModelBlockGroupBase<'FormatContainer'>, - ContentModelBlockBase<'BlockGroup', ContentModelFormatContainerFormat> { +export interface ContentModelFormatContainerCommon { /** * Tag name of this container */ @@ -21,3 +25,35 @@ export interface ContentModelFormatContainer */ zeroFontSize?: boolean; } + +/** + * Content Model of Format Container + */ +export interface ContentModelFormatContainer + extends ContentModelFormatContainerCommon, + ContentModelBlockGroupBase<'FormatContainer'>, + ContentModelBlockBase<'BlockGroup', ContentModelFormatContainerFormat, HTMLElement> {} + +/** + * Content Model of Format Container (Readonly) + */ +export interface ReadonlyContentModelFormatContainer + extends Readonly, + ReadonlyContentModelBlockGroupBase<'FormatContainer'>, + ReadonlyContentModelBlockBase< + 'BlockGroup', + ContentModelFormatContainerFormat, + HTMLElement + > {} + +/** + * Content Model of Format Container (Shallow mutable) + */ +export interface ShallowMutableContentModelFormatContainer + extends ContentModelFormatContainerCommon, + ShallowMutableContentModelBlockGroupBase<'FormatContainer'>, + ShallowMutableContentModelBlockBase< + 'BlockGroup', + ContentModelFormatContainerFormat, + HTMLElement + > {} diff --git a/packages/roosterjs-content-model-types/lib/contentModel/blockGroup/ContentModelGeneralBlock.ts b/packages/roosterjs-content-model-types/lib/contentModel/blockGroup/ContentModelGeneralBlock.ts index ff3899baa4f..e6f577dc99b 100644 --- a/packages/roosterjs-content-model-types/lib/contentModel/blockGroup/ContentModelGeneralBlock.ts +++ b/packages/roosterjs-content-model-types/lib/contentModel/blockGroup/ContentModelGeneralBlock.ts @@ -1,18 +1,60 @@ -import type { ContentModelBlockBase } from '../block/ContentModelBlockBase'; +import type { + ContentModelBlockBase, + ReadonlyContentModelBlockBase, + ShallowMutableContentModelBlockBase, +} from '../block/ContentModelBlockBase'; import type { ContentModelBlockFormat } from '../format/ContentModelBlockFormat'; -import type { ContentModelBlockGroupBase } from './ContentModelBlockGroupBase'; +import type { + ContentModelBlockGroupBase, + ShallowMutableContentModelBlockGroupBase, + ReadonlyContentModelBlockGroupBase, +} from './ContentModelBlockGroupBase'; import type { ContentModelSegmentFormat } from '../format/ContentModelSegmentFormat'; -import type { Selectable } from '../common/Selectable'; +import type { + ReadonlySelectable, + Selectable, + ShallowMutableSelectable, +} from '../common/Selectable'; /** - * Content Model for general Block element + * Common part of Content Model for general Block element */ -export interface ContentModelGeneralBlock - extends Selectable, - ContentModelBlockGroupBase<'General'>, - ContentModelBlockBase<'BlockGroup', ContentModelBlockFormat & ContentModelSegmentFormat> { +export interface ContentModelGeneralBlockCommon { /** * A reference to original HTML node that this model was created from */ element: HTMLElement; } + +/** + * Content Model for general Block element + */ +export interface ContentModelGeneralBlock + extends Selectable, + ContentModelGeneralBlockCommon, + ContentModelBlockGroupBase<'General'>, + ContentModelBlockBase<'BlockGroup', ContentModelBlockFormat & ContentModelSegmentFormat> {} + +/** + * Content Model for general Block element (Readonly) + */ +export interface ReadonlyContentModelGeneralBlock + extends ReadonlySelectable, + Readonly, + ReadonlyContentModelBlockGroupBase<'General'>, + ReadonlyContentModelBlockBase< + 'BlockGroup', + ContentModelBlockFormat & ContentModelSegmentFormat + > {} + +/** + * Content Model for general Block element (Shallow mutable) + */ +export interface ShallowMutableContentModelGeneralBlock + extends ShallowMutableSelectable, + ContentModelGeneralBlockCommon, + ShallowMutableContentModelBlockGroupBase<'General'>, + ShallowMutableContentModelBlockBase< + 'BlockGroup', + ContentModelBlockFormat & ContentModelSegmentFormat + > {} diff --git a/packages/roosterjs-content-model-types/lib/contentModel/blockGroup/ContentModelListItem.ts b/packages/roosterjs-content-model-types/lib/contentModel/blockGroup/ContentModelListItem.ts index a7b7eabd5ff..42029807129 100644 --- a/packages/roosterjs-content-model-types/lib/contentModel/blockGroup/ContentModelListItem.ts +++ b/packages/roosterjs-content-model-types/lib/contentModel/blockGroup/ContentModelListItem.ts @@ -1,15 +1,67 @@ -import type { ContentModelBlockBase } from '../block/ContentModelBlockBase'; -import type { ContentModelBlockGroupBase } from './ContentModelBlockGroupBase'; +import type { + ContentModelBlockBase, + ReadonlyContentModelBlockBase, + ShallowMutableContentModelBlockBase, +} from '../block/ContentModelBlockBase'; +import type { + ContentModelBlockGroupBase, + ReadonlyContentModelBlockGroupBase, + ShallowMutableContentModelBlockGroupBase, +} from './ContentModelBlockGroupBase'; import type { ContentModelListItemFormat } from '../format/ContentModelListItemFormat'; -import type { ContentModelListLevel } from '../decorator/ContentModelListLevel'; -import type { ContentModelSelectionMarker } from '../segment/ContentModelSelectionMarker'; +import type { + ContentModelListLevel, + ReadonlyContentModelListLevel, +} from '../decorator/ContentModelListLevel'; +import type { + ContentModelSelectionMarker, + ReadonlyContentModelSelectionMarker, +} from '../segment/ContentModelSelectionMarker'; /** * Content Model of List Item */ export interface ContentModelListItem - extends ContentModelBlockGroupBase<'ListItem'>, - ContentModelBlockBase<'BlockGroup', ContentModelListItemFormat> { + extends ContentModelBlockGroupBase<'ListItem', HTMLLIElement>, + ContentModelBlockBase<'BlockGroup', ContentModelListItemFormat, HTMLLIElement> { + /** + * Type of this list, either ordered or unordered + */ + levels: ContentModelListLevel[]; + + /** + * A dummy segment to hold format of this list item + */ + formatHolder: ContentModelSelectionMarker; +} + +/** + * Content Model of List Item (Readonly) + */ +export interface ReadonlyContentModelListItem + extends ReadonlyContentModelBlockGroupBase<'ListItem', HTMLLIElement>, + ReadonlyContentModelBlockBase<'BlockGroup', ContentModelListItemFormat, HTMLLIElement> { + /** + * Type of this list, either ordered or unordered + */ + readonly levels: ReadonlyArray; + + /** + * A dummy segment to hold format of this list item + */ + readonly formatHolder: ReadonlyContentModelSelectionMarker; +} + +/** + * Content Model of List Item (Shallow mutable) + */ +export interface ShallowMutableContentModelListItem + extends ShallowMutableContentModelBlockGroupBase<'ListItem', HTMLLIElement>, + ShallowMutableContentModelBlockBase< + 'BlockGroup', + ContentModelListItemFormat, + HTMLLIElement + > { /** * Type of this list, either ordered or unordered */ diff --git a/packages/roosterjs-content-model-types/lib/contentModel/blockGroup/ContentModelTableCell.ts b/packages/roosterjs-content-model-types/lib/contentModel/blockGroup/ContentModelTableCell.ts index 4e51590c48b..03749696b78 100644 --- a/packages/roosterjs-content-model-types/lib/contentModel/blockGroup/ContentModelTableCell.ts +++ b/packages/roosterjs-content-model-types/lib/contentModel/blockGroup/ContentModelTableCell.ts @@ -1,20 +1,29 @@ import type { TableCellMetadataFormat } from '../format/metadata/TableCellMetadataFormat'; -import type { ContentModelBlockGroupBase } from './ContentModelBlockGroupBase'; -import type { ContentModelBlockWithCache } from '../common/ContentModelBlockWithCache'; +import type { + ContentModelBlockGroupBase, + ReadonlyContentModelBlockGroupBase, + ShallowMutableContentModelBlockGroupBase, +} from './ContentModelBlockGroupBase'; import type { ContentModelTableCellFormat } from '../format/ContentModelTableCellFormat'; -import type { ContentModelWithDataset } from '../format/ContentModelWithDataset'; -import type { ContentModelWithFormat } from '../format/ContentModelWithFormat'; -import type { Selectable } from '../common/Selectable'; +import type { + ContentModelWithDataset, + ReadonlyContentModelWithDataset, + ShallowMutableContentModelWithDataset, +} from '../format/ContentModelWithDataset'; +import type { + ContentModelWithFormat, + ReadonlyContentModelWithFormat, +} from '../format/ContentModelWithFormat'; +import type { + ReadonlySelectable, + Selectable, + ShallowMutableSelectable, +} from '../common/Selectable'; /** - * Content Model of Table Cell + * Common part of Content Model of Table Cell */ -export interface ContentModelTableCell - extends Selectable, - ContentModelBlockGroupBase<'TableCell'>, - ContentModelWithFormat, - ContentModelWithDataset, - ContentModelBlockWithCache { +export interface ContentModelTableCellCommon { /** * Whether this cell is spanned from left cell */ @@ -30,3 +39,33 @@ export interface ContentModelTableCell */ isHeader?: boolean; } + +/** + * Content Model of Table Cell + */ +export interface ContentModelTableCell + extends Selectable, + ContentModelTableCellCommon, + ContentModelBlockGroupBase<'TableCell', HTMLTableCellElement>, + ContentModelWithFormat, + ContentModelWithDataset {} + +/** + * Content Model of Table Cell (Readonly) + */ +export interface ReadonlyContentModelTableCell + extends ReadonlySelectable, + Readonly, + ReadonlyContentModelBlockGroupBase<'TableCell', HTMLTableCellElement>, + ReadonlyContentModelWithFormat, + ReadonlyContentModelWithDataset {} + +/** + * Content Model of Table Cell (Shallow mutable) + */ +export interface ShallowMutableContentModelTableCell + extends ShallowMutableSelectable, + ContentModelTableCellCommon, + ShallowMutableContentModelBlockGroupBase<'TableCell', HTMLTableCellElement>, + ContentModelWithFormat, + ShallowMutableContentModelWithDataset {} diff --git a/packages/roosterjs-content-model-types/lib/contentModel/common/MutableMark.ts b/packages/roosterjs-content-model-types/lib/contentModel/common/MutableMark.ts new file mode 100644 index 00000000000..9912a147c44 --- /dev/null +++ b/packages/roosterjs-content-model-types/lib/contentModel/common/MutableMark.ts @@ -0,0 +1,73 @@ +// We are using a tag type to mark a content model type as mutable. + +// This is generally a workaround to https://github.com/microsoft/TypeScript/issues/13347 + +// In order to know if a block has been changed, we want to mark all blocks, blocks groups, segments and their members as readonly, +// When we want to change a block/segment/block group, we need to call a function to convert it to mutable. Inside this function +// we can make some change to the object (e.g. remove cached element if any) so later we know this object is changed. +// So that we expect there is a build time error if we assign a readonly object to a function that accepts mutable object only. +// However this does not happen today. + +// To workaround it, we manually add a hidden member (dummy) to all mutable types, and add another member with readonly array type to +// readonly types. When we assign readonly object to mutable one, compiler will fail to build since the two arrays are not matching. +// So that we can know where to fix from build time. And since the dummy value is optional, it won't break existing creator code. + +// @example +// let readonly: ReadonlyMark = {}; +// let mutable: MutableMark = {}; + +// readonly = mutable; // OK +// mutable = readonly; // Error: Type 'ReadonlyMark' is not assignable to type 'MutableMark'. + +/** + * Mark an object as mutable + */ +export type MutableMark = { + /** + * The mutable marker to mark an object as mutable. When assign readonly object to a mutable type, compile will fail to build + * due to this member does not exist from source type. + */ + readonly dummy1?: never[]; + + /** + * The mutable marker to mark an object as mutable. When assign readonly object to a mutable type, compile will fail to build + * due to this member does not exist from source type. + */ + readonly dummy2?: never[]; +}; + +/** + * Mark an object as single level mutable (child models are still readonly) + */ +export type ShallowMutableMark = { + /** + * The mutable marker to mark an object as mutable. When assign readonly object to a mutable type, compile will fail to build + * due to this member does not exist from source type. + */ + readonly dummy1?: never[]; + + /** + * The mutable marker to mark an object as mutable. When assign readonly object to a mutable type, compile will fail to build + * due to this member does not exist from source type. + * This is used for preventing assigning ShallowMutableMark to MutableMark + */ + readonly dummy2?: ReadonlyArray; +}; + +/** + * Mark an object as readonly + */ +export type ReadonlyMark = { + /** + * The mutable marker to mark an object as mutable. When assign readonly object to a mutable type, compile will fail to build + * due to this member does not exist from source type. + * This is used for preventing assigning ReadonlyMark to ShallowMutableMark or MutableMark + */ + readonly dummy1?: ReadonlyArray; + + /** + * The mutable marker to mark an object as mutable. When assign readonly object to a mutable type, compile will fail to build + * due to this member does not exist from source type. + */ + readonly dummy2?: ReadonlyArray; +}; diff --git a/packages/roosterjs-content-model-types/lib/contentModel/common/MutableType.ts b/packages/roosterjs-content-model-types/lib/contentModel/common/MutableType.ts new file mode 100644 index 00000000000..ecc921b98e6 --- /dev/null +++ b/packages/roosterjs-content-model-types/lib/contentModel/common/MutableType.ts @@ -0,0 +1,101 @@ +import type { ContentModelBr, ReadonlyContentModelBr } from '../segment/ContentModelBr'; +import type { ContentModelCode, ReadonlyContentModelCode } from '../decorator/ContentModelCode'; +import type { + ContentModelDivider, + ReadonlyContentModelDivider, +} from '../block/ContentModelDivider'; +import type { + ShallowMutableContentModelDocument, + ReadonlyContentModelDocument, +} from '../blockGroup/ContentModelDocument'; +import type { ContentModelEntity } from '../entity/ContentModelEntity'; +import type { + ShallowMutableContentModelFormatContainer, + ReadonlyContentModelFormatContainer, +} from '../blockGroup/ContentModelFormatContainer'; +import type { + ShallowMutableContentModelGeneralBlock, + ReadonlyContentModelGeneralBlock, +} from '../blockGroup/ContentModelGeneralBlock'; +import type { + ShallowMutableContentModelGeneralSegment, + ReadonlyContentModelGeneralSegment, +} from '../segment/ContentModelGeneralSegment'; +import type { ContentModelImage, ReadonlyContentModelImage } from '../segment/ContentModelImage'; +import type { ContentModelLink, ReadonlyContentModelLink } from '../decorator/ContentModelLink'; +import type { + ShallowMutableContentModelListItem, + ReadonlyContentModelListItem, +} from '../blockGroup/ContentModelListItem'; +import type { + ContentModelListLevel, + ReadonlyContentModelListLevel, +} from '../decorator/ContentModelListLevel'; +import type { + ReadonlyContentModelParagraph, + ShallowMutableContentModelParagraph, +} from '../block/ContentModelParagraph'; +import type { + ContentModelParagraphDecorator, + ReadonlyContentModelParagraphDecorator, +} from '../decorator/ContentModelParagraphDecorator'; +import type { + ContentModelSelectionMarker, + ReadonlyContentModelSelectionMarker, +} from '../segment/ContentModelSelectionMarker'; +import type { + ReadonlyContentModelTable, + ShallowMutableContentModelTable, +} from '../block/ContentModelTable'; +import type { + ShallowMutableContentModelTableCell, + ReadonlyContentModelTableCell, +} from '../blockGroup/ContentModelTableCell'; +import type { + ContentModelTableRow, + ReadonlyContentModelTableRow, +} from '../block/ContentModelTableRow'; +import type { ContentModelText, ReadonlyContentModelText } from '../segment/ContentModelText'; + +/** + * Get mutable type from its related readonly type + */ +export type MutableType = T extends ReadonlyContentModelGeneralSegment + ? ShallowMutableContentModelGeneralSegment + : T extends ReadonlyContentModelSelectionMarker + ? ContentModelSelectionMarker + : T extends ReadonlyContentModelImage + ? ContentModelImage + : T extends ContentModelEntity + ? ContentModelEntity + : T extends ReadonlyContentModelText + ? ContentModelText + : T extends ReadonlyContentModelBr + ? ContentModelBr + : T extends ReadonlyContentModelParagraph + ? ShallowMutableContentModelParagraph + : T extends ReadonlyContentModelTable + ? ShallowMutableContentModelTable + : T extends ReadonlyContentModelTableRow + ? ContentModelTableRow + : T extends ReadonlyContentModelTableCell + ? ShallowMutableContentModelTableCell + : T extends ReadonlyContentModelFormatContainer + ? ShallowMutableContentModelFormatContainer + : T extends ReadonlyContentModelListItem + ? ShallowMutableContentModelListItem + : T extends ReadonlyContentModelListLevel + ? ContentModelListLevel + : T extends ReadonlyContentModelDivider + ? ContentModelDivider + : T extends ReadonlyContentModelDocument + ? ShallowMutableContentModelDocument + : T extends ReadonlyContentModelGeneralBlock + ? ShallowMutableContentModelGeneralBlock + : T extends ReadonlyContentModelParagraphDecorator + ? ContentModelParagraphDecorator + : T extends ReadonlyContentModelLink + ? ContentModelLink + : T extends ReadonlyContentModelCode + ? ContentModelCode + : never; diff --git a/packages/roosterjs-content-model-types/lib/contentModel/common/Selectable.ts b/packages/roosterjs-content-model-types/lib/contentModel/common/Selectable.ts index 2e28a98e1e2..d9aa25b08e8 100644 --- a/packages/roosterjs-content-model-types/lib/contentModel/common/Selectable.ts +++ b/packages/roosterjs-content-model-types/lib/contentModel/common/Selectable.ts @@ -1,7 +1,29 @@ +import type { MutableMark, ReadonlyMark, ShallowMutableMark } from '../common/MutableMark'; + /** * Represents a selectable Content Model object */ -export interface Selectable { +export interface Selectable extends MutableMark { + /** + * Whether this model object is selected + */ + isSelected?: boolean; +} + +/** + * Represents a selectable Content Model object (Readonly) + */ +export interface ReadonlySelectable extends ReadonlyMark { + /** + * Whether this model object is selected + */ + readonly isSelected?: boolean; +} + +/** + * Represents a selectable Content Model object (Shallow mutable) + */ +export interface ShallowMutableSelectable extends ShallowMutableMark { /** * Whether this model object is selected */ diff --git a/packages/roosterjs-content-model-types/lib/contentModel/decorator/ContentModelCode.ts b/packages/roosterjs-content-model-types/lib/contentModel/decorator/ContentModelCode.ts index b124d0e61b5..d6823d9b496 100644 --- a/packages/roosterjs-content-model-types/lib/contentModel/decorator/ContentModelCode.ts +++ b/packages/roosterjs-content-model-types/lib/contentModel/decorator/ContentModelCode.ts @@ -1,9 +1,24 @@ +import type { MutableMark, ReadonlyMark } from '../common/MutableMark'; import type { ContentModelCodeFormat } from '../format/ContentModelCodeFormat'; -import type { ContentModelWithFormat } from '../format/ContentModelWithFormat'; +import type { + ContentModelWithFormat, + ReadonlyContentModelWithFormat, +} from '../format/ContentModelWithFormat'; /** * Represent code info of Content Model. * ContentModelCode is a decorator but not a standalone model type, instead it need to be put inside a ContentModelSegment * since code is also a kind of segment, with some extra information */ -export interface ContentModelCode extends ContentModelWithFormat {} +export interface ContentModelCode + extends MutableMark, + ContentModelWithFormat {} + +/** + * Represent code info of Content Model. (Readonly) + * ContentModelCode is a decorator but not a standalone model type, instead it need to be put inside a ContentModelSegment + * since code is also a kind of segment, with some extra information + */ +export interface ReadonlyContentModelCode + extends ReadonlyMark, + ReadonlyContentModelWithFormat {} diff --git a/packages/roosterjs-content-model-types/lib/contentModel/decorator/ContentModelDecorator.ts b/packages/roosterjs-content-model-types/lib/contentModel/decorator/ContentModelDecorator.ts index b8fe53b3f2d..44e2a394627 100644 --- a/packages/roosterjs-content-model-types/lib/contentModel/decorator/ContentModelDecorator.ts +++ b/packages/roosterjs-content-model-types/lib/contentModel/decorator/ContentModelDecorator.ts @@ -1,8 +1,16 @@ -import type { ContentModelCode } from './ContentModelCode'; -import type { ContentModelLink } from './ContentModelLink'; -import type { ContentModelListLevel } from './ContentModelListLevel'; +import type { ContentModelCode, ReadonlyContentModelCode } from './ContentModelCode'; +import type { ContentModelLink, ReadonlyContentModelLink } from './ContentModelLink'; +import type { ContentModelListLevel, ReadonlyContentModelListLevel } from './ContentModelListLevel'; /** * Union type for segment decorators */ export type ContentModelDecorator = ContentModelLink | ContentModelCode | ContentModelListLevel; + +/** + * Union type for segment decorators (Readonly) + */ +export type ReadonlyContentModelDecorator = + | ReadonlyContentModelLink + | ReadonlyContentModelCode + | ReadonlyContentModelListLevel; diff --git a/packages/roosterjs-content-model-types/lib/contentModel/decorator/ContentModelLink.ts b/packages/roosterjs-content-model-types/lib/contentModel/decorator/ContentModelLink.ts index c0622fba7ef..4aa98459542 100644 --- a/packages/roosterjs-content-model-types/lib/contentModel/decorator/ContentModelLink.ts +++ b/packages/roosterjs-content-model-types/lib/contentModel/decorator/ContentModelLink.ts @@ -1,6 +1,13 @@ +import type { MutableMark, ReadonlyMark } from '../common/MutableMark'; import type { ContentModelHyperLinkFormat } from '../format/ContentModelHyperLinkFormat'; -import type { ContentModelWithDataset } from '../format/ContentModelWithDataset'; -import type { ContentModelWithFormat } from '../format/ContentModelWithFormat'; +import type { + ContentModelWithDataset, + ReadonlyContentModelWithDataset, +} from '../format/ContentModelWithDataset'; +import type { + ContentModelWithFormat, + ReadonlyContentModelWithFormat, +} from '../format/ContentModelWithFormat'; /** * Represent link info of Content Model. @@ -8,5 +15,16 @@ import type { ContentModelWithFormat } from '../format/ContentModelWithFormat'; * since link is also a kind of segment, with some extra information */ export interface ContentModelLink - extends ContentModelWithFormat, + extends MutableMark, + ContentModelWithFormat, ContentModelWithDataset {} + +/** + * Represent link info of Content Model (Readonly). + * ContentModelLink is not a standalone model type, instead it need to be put inside a ContentModelSegment + * since link is also a kind of segment, with some extra information + */ +export interface ReadonlyContentModelLink + extends ReadonlyMark, + ReadonlyContentModelWithFormat, + ReadonlyContentModelWithDataset {} diff --git a/packages/roosterjs-content-model-types/lib/contentModel/decorator/ContentModelListLevel.ts b/packages/roosterjs-content-model-types/lib/contentModel/decorator/ContentModelListLevel.ts index 206d541701e..585b0dce4ca 100644 --- a/packages/roosterjs-content-model-types/lib/contentModel/decorator/ContentModelListLevel.ts +++ b/packages/roosterjs-content-model-types/lib/contentModel/decorator/ContentModelListLevel.ts @@ -1,16 +1,42 @@ +import type { ContentModelBlockWithCache } from '../common/ContentModelBlockWithCache'; +import type { MutableMark, ReadonlyMark } from '../common/MutableMark'; import type { ContentModelListItemLevelFormat } from '../format/ContentModelListItemLevelFormat'; -import type { ContentModelWithDataset } from '../format/ContentModelWithDataset'; -import type { ContentModelWithFormat } from '../format/ContentModelWithFormat'; +import type { + ContentModelWithDataset, + ReadonlyContentModelWithDataset, +} from '../format/ContentModelWithDataset'; +import type { + ContentModelWithFormat, + ReadonlyContentModelWithFormat, +} from '../format/ContentModelWithFormat'; import type { ListMetadataFormat } from '../format/metadata/ListMetadataFormat'; /** - * Content Model of List Level + * Common part of Content Model of List Level */ -export interface ContentModelListLevel - extends ContentModelWithFormat, - ContentModelWithDataset { +export interface ContentModelListLevelCommon { /** * Type of a list, order (OL) or unordered (UL) */ listType: 'OL' | 'UL'; } + +/** + * Content Model of List Level + */ +export interface ContentModelListLevel + extends MutableMark, + ContentModelBlockWithCache, + ContentModelListLevelCommon, + ContentModelWithFormat, + ContentModelWithDataset {} + +/** + * Content Model of List Level (Readonly) + */ +export interface ReadonlyContentModelListLevel + extends ReadonlyMark, + ContentModelBlockWithCache, + ReadonlyContentModelWithFormat, + ReadonlyContentModelWithDataset, + Readonly {} diff --git a/packages/roosterjs-content-model-types/lib/contentModel/decorator/ContentModelParagraphDecorator.ts b/packages/roosterjs-content-model-types/lib/contentModel/decorator/ContentModelParagraphDecorator.ts index b3c154b6ff1..7eeebcc19b4 100644 --- a/packages/roosterjs-content-model-types/lib/contentModel/decorator/ContentModelParagraphDecorator.ts +++ b/packages/roosterjs-content-model-types/lib/contentModel/decorator/ContentModelParagraphDecorator.ts @@ -1,15 +1,36 @@ +import type { MutableMark, ReadonlyMark } from '../common/MutableMark'; import type { ContentModelSegmentFormat } from '../format/ContentModelSegmentFormat'; -import type { ContentModelWithFormat } from '../format/ContentModelWithFormat'; +import type { + ContentModelWithFormat, + ReadonlyContentModelWithFormat, +} from '../format/ContentModelWithFormat'; /** - * Represent decorator for a paragraph in Content Model - * A decorator of paragraph can represent a heading, or a P tag that act likes a paragraph but with some extra format info - * since heading is also a kind of paragraph, with some extra information + * Common part of decorator for a paragraph in Content Model */ -export interface ContentModelParagraphDecorator - extends ContentModelWithFormat { +export interface ContentModelParagraphDecoratorCommon { /** * Tag name of this paragraph */ tagName: string; } + +/** + * Represent decorator for a paragraph in Content Model + * A decorator of paragraph can represent a heading, or a P tag that act likes a paragraph but with some extra format info + * since heading is also a kind of paragraph, with some extra information + */ +export interface ContentModelParagraphDecorator + extends MutableMark, + ContentModelParagraphDecoratorCommon, + ContentModelWithFormat {} + +/** + * Represent decorator for a paragraph in Content Model (Readonly) + * A decorator of paragraph can represent a heading, or a P tag that act likes a paragraph but with some extra format info + * since heading is also a kind of paragraph, with some extra information + */ +export interface ReadonlyContentModelParagraphDecorator + extends ReadonlyMark, + ReadonlyContentModelWithFormat, + Readonly {} diff --git a/packages/roosterjs-content-model-types/lib/contentModel/format/ContentModelWithDataset.ts b/packages/roosterjs-content-model-types/lib/contentModel/format/ContentModelWithDataset.ts index 0fe8f417c78..902f4dd2d5e 100644 --- a/packages/roosterjs-content-model-types/lib/contentModel/format/ContentModelWithDataset.ts +++ b/packages/roosterjs-content-model-types/lib/contentModel/format/ContentModelWithDataset.ts @@ -1,11 +1,32 @@ -import type { DatasetFormat } from './metadata/DatasetFormat'; +import type { MutableMark, ReadonlyMark, ShallowMutableMark } from '../common/MutableMark'; +import type { DatasetFormat, ReadonlyDatasetFormat } from './metadata/DatasetFormat'; /** * Represents base format of an element that supports dataset and/or metadata */ -export interface ContentModelWithDataset { +export type ContentModelWithDataset = MutableMark & { /** * dataset of this element */ dataset: DatasetFormat; -} +}; + +/** + * Represents base format of an element that supports dataset and/or metadata (Readonly) + */ +export type ReadonlyContentModelWithDataset = ReadonlyMark & { + /** + * dataset of this element + */ + readonly dataset: ReadonlyDatasetFormat; +}; + +/** + * Represents base format of an element that supports dataset and/or metadata (Readonly) + */ +export type ShallowMutableContentModelWithDataset = ShallowMutableMark & { + /** + * dataset of this element + */ + dataset: DatasetFormat; +}; diff --git a/packages/roosterjs-content-model-types/lib/contentModel/format/ContentModelWithFormat.ts b/packages/roosterjs-content-model-types/lib/contentModel/format/ContentModelWithFormat.ts index 53f52e67522..cfbe0d6b821 100644 --- a/packages/roosterjs-content-model-types/lib/contentModel/format/ContentModelWithFormat.ts +++ b/packages/roosterjs-content-model-types/lib/contentModel/format/ContentModelWithFormat.ts @@ -9,3 +9,13 @@ export interface ContentModelWithFormat { */ format: T; } + +/** + * Represent a content model with format (Readonly) + */ +export interface ReadonlyContentModelWithFormat { + /** + * Format of this model + */ + readonly format: Readonly; +} diff --git a/packages/roosterjs-content-model-types/lib/contentModel/format/metadata/DatasetFormat.ts b/packages/roosterjs-content-model-types/lib/contentModel/format/metadata/DatasetFormat.ts index e71cadf3c37..11625671ba6 100644 --- a/packages/roosterjs-content-model-types/lib/contentModel/format/metadata/DatasetFormat.ts +++ b/packages/roosterjs-content-model-types/lib/contentModel/format/metadata/DatasetFormat.ts @@ -2,3 +2,8 @@ * Represents dataset format of Content Model */ export type DatasetFormat = Record; + +/** + * Represents dataset format of Content Model (Readonly) + */ +export type ReadonlyDatasetFormat = Readonly>; diff --git a/packages/roosterjs-content-model-types/lib/contentModel/format/metadata/TableMetadataFormat.ts b/packages/roosterjs-content-model-types/lib/contentModel/format/metadata/TableMetadataFormat.ts index 394581cc8f7..9e6090779b0 100644 --- a/packages/roosterjs-content-model-types/lib/contentModel/format/metadata/TableMetadataFormat.ts +++ b/packages/roosterjs-content-model-types/lib/contentModel/format/metadata/TableMetadataFormat.ts @@ -56,6 +56,7 @@ export type TableMetadataFormat = { * Table Borders Type. Use value of constant TableBorderFormat as value */ tableBorderFormat?: number; + /** * Vertical alignment for each row */ diff --git a/packages/roosterjs-content-model-types/lib/contentModel/segment/ContentModelBr.ts b/packages/roosterjs-content-model-types/lib/contentModel/segment/ContentModelBr.ts index d8bf1b04871..e414626c817 100644 --- a/packages/roosterjs-content-model-types/lib/contentModel/segment/ContentModelBr.ts +++ b/packages/roosterjs-content-model-types/lib/contentModel/segment/ContentModelBr.ts @@ -1,6 +1,14 @@ -import type { ContentModelSegmentBase } from './ContentModelSegmentBase'; +import type { + ContentModelSegmentBase, + ReadonlyContentModelSegmentBase, +} from './ContentModelSegmentBase'; /** * Content Model of BR */ export interface ContentModelBr extends ContentModelSegmentBase<'Br'> {} + +/** + * Content Model of BR (Readonly) + */ +export interface ReadonlyContentModelBr extends ReadonlyContentModelSegmentBase<'Br'> {} diff --git a/packages/roosterjs-content-model-types/lib/contentModel/segment/ContentModelGeneralSegment.ts b/packages/roosterjs-content-model-types/lib/contentModel/segment/ContentModelGeneralSegment.ts index bbd0741c7ac..3bfded832e0 100644 --- a/packages/roosterjs-content-model-types/lib/contentModel/segment/ContentModelGeneralSegment.ts +++ b/packages/roosterjs-content-model-types/lib/contentModel/segment/ContentModelGeneralSegment.ts @@ -1,6 +1,14 @@ import type { ContentModelBlockFormat } from '../format/ContentModelBlockFormat'; -import type { ContentModelGeneralBlock } from '../blockGroup/ContentModelGeneralBlock'; -import type { ContentModelSegmentBase } from './ContentModelSegmentBase'; +import type { + ContentModelGeneralBlock, + ReadonlyContentModelGeneralBlock, + ShallowMutableContentModelGeneralBlock, +} from '../blockGroup/ContentModelGeneralBlock'; +import type { + ContentModelSegmentBase, + ReadonlyContentModelSegmentBase, + ShallowMutableContentModelSegmentBase, +} from './ContentModelSegmentBase'; import type { ContentModelSegmentFormat } from '../format/ContentModelSegmentFormat'; /** @@ -9,3 +17,23 @@ import type { ContentModelSegmentFormat } from '../format/ContentModelSegmentFor export interface ContentModelGeneralSegment extends ContentModelGeneralBlock, ContentModelSegmentBase<'General', ContentModelBlockFormat & ContentModelSegmentFormat> {} + +/** + * Content Model of general Segment (Readonly) + */ +export interface ReadonlyContentModelGeneralSegment + extends ReadonlyContentModelGeneralBlock, + ReadonlyContentModelSegmentBase< + 'General', + ContentModelBlockFormat & ContentModelSegmentFormat + > {} + +/** + * Content Model of general Segment (Shallow mutable) + */ +export interface ShallowMutableContentModelGeneralSegment + extends ShallowMutableContentModelGeneralBlock, + ShallowMutableContentModelSegmentBase< + 'General', + ContentModelBlockFormat & ContentModelSegmentFormat + > {} diff --git a/packages/roosterjs-content-model-types/lib/contentModel/segment/ContentModelImage.ts b/packages/roosterjs-content-model-types/lib/contentModel/segment/ContentModelImage.ts index 8b457ce4f0c..9b7e531d728 100644 --- a/packages/roosterjs-content-model-types/lib/contentModel/segment/ContentModelImage.ts +++ b/packages/roosterjs-content-model-types/lib/contentModel/segment/ContentModelImage.ts @@ -1,14 +1,18 @@ import type { ContentModelImageFormat } from '../format/ContentModelImageFormat'; -import type { ContentModelSegmentBase } from './ContentModelSegmentBase'; -import type { ContentModelWithDataset } from '../format/ContentModelWithDataset'; +import type { + ContentModelSegmentBase, + ReadonlyContentModelSegmentBase, +} from './ContentModelSegmentBase'; +import type { + ContentModelWithDataset, + ReadonlyContentModelWithDataset, +} from '../format/ContentModelWithDataset'; import type { ImageMetadataFormat } from '../format/metadata/ImageMetadataFormat'; /** - * Content Model of IMG + * Common part of Content Model of IMG */ -export interface ContentModelImage - extends ContentModelSegmentBase<'Image', ContentModelImageFormat>, - ContentModelWithDataset { +export interface ContentModelImageCommon { /** * Image source of this IMG element */ @@ -29,3 +33,19 @@ export interface ContentModelImage */ isSelectedAsImageSelection?: boolean; } + +/** + * Content Model of IMG + */ +export interface ContentModelImage + extends ContentModelImageCommon, + ContentModelSegmentBase<'Image', ContentModelImageFormat>, + ContentModelWithDataset {} + +/** + * Content Model of IMG (Readonly) + */ +export interface ReadonlyContentModelImage + extends ReadonlyContentModelSegmentBase<'Image', ContentModelImageFormat>, + ReadonlyContentModelWithDataset, + Readonly {} diff --git a/packages/roosterjs-content-model-types/lib/contentModel/segment/ContentModelSegment.ts b/packages/roosterjs-content-model-types/lib/contentModel/segment/ContentModelSegment.ts index 77fd2c68cad..4989fddc1c4 100644 --- a/packages/roosterjs-content-model-types/lib/contentModel/segment/ContentModelSegment.ts +++ b/packages/roosterjs-content-model-types/lib/contentModel/segment/ContentModelSegment.ts @@ -1,9 +1,16 @@ -import type { ContentModelBr } from './ContentModelBr'; +import type { ContentModelBr, ReadonlyContentModelBr } from './ContentModelBr'; import type { ContentModelEntity } from '../entity/ContentModelEntity'; -import type { ContentModelGeneralSegment } from './ContentModelGeneralSegment'; -import type { ContentModelImage } from './ContentModelImage'; -import type { ContentModelSelectionMarker } from './ContentModelSelectionMarker'; -import type { ContentModelText } from './ContentModelText'; +import type { + ContentModelGeneralSegment, + ReadonlyContentModelGeneralSegment, + ShallowMutableContentModelGeneralSegment, +} from './ContentModelGeneralSegment'; +import type { ContentModelImage, ReadonlyContentModelImage } from './ContentModelImage'; +import type { + ContentModelSelectionMarker, + ReadonlyContentModelSelectionMarker, +} from './ContentModelSelectionMarker'; +import type { ContentModelText, ReadonlyContentModelText } from './ContentModelText'; /** * Union type of Content Model Segment @@ -15,3 +22,25 @@ export type ContentModelSegment = | ContentModelGeneralSegment | ContentModelEntity | ContentModelImage; + +/** + * Union type of Content Model Segment (Readonly) + */ +export type ReadonlyContentModelSegment = + | ReadonlyContentModelSelectionMarker + | ReadonlyContentModelText + | ReadonlyContentModelBr + | ReadonlyContentModelGeneralSegment + | ContentModelEntity + | ReadonlyContentModelImage; + +/** + * Union type of Content Model Segment (Shallow mutable) + */ +export type ShallowMutableContentModelSegment = + | ContentModelSelectionMarker + | ContentModelText + | ContentModelBr + | ShallowMutableContentModelGeneralSegment + | ContentModelEntity + | ContentModelImage; diff --git a/packages/roosterjs-content-model-types/lib/contentModel/segment/ContentModelSegmentBase.ts b/packages/roosterjs-content-model-types/lib/contentModel/segment/ContentModelSegmentBase.ts index 22df2695372..5f169c3eac7 100644 --- a/packages/roosterjs-content-model-types/lib/contentModel/segment/ContentModelSegmentBase.ts +++ b/packages/roosterjs-content-model-types/lib/contentModel/segment/ContentModelSegmentBase.ts @@ -1,9 +1,27 @@ -import type { ContentModelCode } from '../decorator/ContentModelCode'; -import type { ContentModelLink } from '../decorator/ContentModelLink'; +import type { MutableMark, ReadonlyMark, ShallowMutableMark } from '../common/MutableMark'; +import type { ContentModelCode, ReadonlyContentModelCode } from '../decorator/ContentModelCode'; +import type { ContentModelLink, ReadonlyContentModelLink } from '../decorator/ContentModelLink'; import type { ContentModelSegmentFormat } from '../format/ContentModelSegmentFormat'; import type { ContentModelSegmentType } from './SegmentType'; -import type { ContentModelWithFormat } from '../format/ContentModelWithFormat'; -import type { Selectable } from '../common/Selectable'; +import type { + ContentModelWithFormat, + ReadonlyContentModelWithFormat, +} from '../format/ContentModelWithFormat'; +import type { + ReadonlySelectable, + Selectable, + ShallowMutableSelectable, +} from '../common/Selectable'; + +/** + * Common part of base type of Content Model Segment + */ +export interface ContentModelSegmentBaseCommon { + /** + * Type of this segment + */ + readonly segmentType: T; +} /** * Base type of Content Model Segment @@ -11,12 +29,55 @@ import type { Selectable } from '../common/Selectable'; export interface ContentModelSegmentBase< T extends ContentModelSegmentType, TFormat extends ContentModelSegmentFormat = ContentModelSegmentFormat -> extends Selectable, ContentModelWithFormat { +> + extends MutableMark, + Selectable, + ContentModelWithFormat, + ContentModelSegmentBaseCommon { /** - * Type of this segment + * Hyperlink info */ - segmentType: T; + link?: ContentModelLink; + + /** + * Code info + */ + code?: ContentModelCode; +} +/** + * Base type of Content Model Segment (Readonly) + */ +export interface ReadonlyContentModelSegmentBase< + T extends ContentModelSegmentType, + TFormat extends ContentModelSegmentFormat = ContentModelSegmentFormat +> + extends ReadonlyMark, + ReadonlySelectable, + ReadonlyContentModelWithFormat, + Readonly> { + /** + * Hyperlink info + */ + readonly link?: ReadonlyContentModelLink; + + /** + * Code info + */ + readonly code?: ReadonlyContentModelCode; +} + +/** + * Base type of Content Model Segment (Shallow mutable) + */ +export interface ShallowMutableContentModelSegmentBase< + T extends ContentModelSegmentType, + TFormat extends ContentModelSegmentFormat = ContentModelSegmentFormat +> + extends ShallowMutableMark, + ShallowMutableSelectable, + ContentModelWithFormat, + ContentModelSegmentBaseCommon { /** * Hyperlink info */ diff --git a/packages/roosterjs-content-model-types/lib/contentModel/segment/ContentModelSelectionMarker.ts b/packages/roosterjs-content-model-types/lib/contentModel/segment/ContentModelSelectionMarker.ts index e99db906635..2e1f9b400eb 100644 --- a/packages/roosterjs-content-model-types/lib/contentModel/segment/ContentModelSelectionMarker.ts +++ b/packages/roosterjs-content-model-types/lib/contentModel/segment/ContentModelSelectionMarker.ts @@ -1,6 +1,15 @@ -import type { ContentModelSegmentBase } from './ContentModelSegmentBase'; +import type { + ContentModelSegmentBase, + ReadonlyContentModelSegmentBase, +} from './ContentModelSegmentBase'; /** * Content Model of Selection Marker */ export interface ContentModelSelectionMarker extends ContentModelSegmentBase<'SelectionMarker'> {} + +/** + * Content Model of Selection Marker (Readonly) + */ +export interface ReadonlyContentModelSelectionMarker + extends ReadonlyContentModelSegmentBase<'SelectionMarker'> {} diff --git a/packages/roosterjs-content-model-types/lib/contentModel/segment/ContentModelText.ts b/packages/roosterjs-content-model-types/lib/contentModel/segment/ContentModelText.ts index 5cd7c79f19b..7cee676ad6c 100644 --- a/packages/roosterjs-content-model-types/lib/contentModel/segment/ContentModelText.ts +++ b/packages/roosterjs-content-model-types/lib/contentModel/segment/ContentModelText.ts @@ -1,11 +1,26 @@ -import type { ContentModelSegmentBase } from './ContentModelSegmentBase'; +import type { + ContentModelSegmentBase, + ReadonlyContentModelSegmentBase, +} from './ContentModelSegmentBase'; /** - * Content Model for Text + * Common port of Content Model for Text */ -export interface ContentModelText extends ContentModelSegmentBase<'Text'> { +export interface ContentModelTextCommon { /** * Text content of this segment */ text: string; } + +/** + * Content Model for Text + */ +export interface ContentModelText extends ContentModelTextCommon, ContentModelSegmentBase<'Text'> {} + +/** + * Content Model for Text (Readonly) + */ +export interface ReadonlyContentModelText + extends ReadonlyContentModelSegmentBase<'Text'>, + Readonly {} diff --git a/packages/roosterjs-content-model-types/lib/editor/EditorOptions.ts b/packages/roosterjs-content-model-types/lib/editor/EditorOptions.ts index 82996e62c06..3288eb3c88f 100644 --- a/packages/roosterjs-content-model-types/lib/editor/EditorOptions.ts +++ b/packages/roosterjs-content-model-types/lib/editor/EditorOptions.ts @@ -87,6 +87,11 @@ export interface EditorOptions { */ imageSelectionBorderColor?: string; + /** + * Background color of a selected table cell. Default color: '#C6C6C6' + */ + tableCellSelectionBackgroundColor?: string; + /** * Initial Content Model */ diff --git a/packages/roosterjs-content-model-types/lib/index.ts b/packages/roosterjs-content-model-types/lib/index.ts index a6b748c4c68..e20fc283675 100644 --- a/packages/roosterjs-content-model-types/lib/index.ts +++ b/packages/roosterjs-content-model-types/lib/index.ts @@ -1,7 +1,14 @@ export { ContentModelSegmentFormat } from './contentModel/format/ContentModelSegmentFormat'; -export { ContentModelWithFormat } from './contentModel/format/ContentModelWithFormat'; +export { + ContentModelWithFormat, + ReadonlyContentModelWithFormat, +} from './contentModel/format/ContentModelWithFormat'; export { ContentModelTableFormat } from './contentModel/format/ContentModelTableFormat'; -export { ContentModelWithDataset } from './contentModel/format/ContentModelWithDataset'; +export { + ContentModelWithDataset, + ReadonlyContentModelWithDataset, + ShallowMutableContentModelWithDataset, +} from './contentModel/format/ContentModelWithDataset'; export { ContentModelBlockFormat } from './contentModel/format/ContentModelBlockFormat'; export { ContentModelTableCellFormat } from './contentModel/format/ContentModelTableCellFormat'; export { ContentModelListItemFormat } from './contentModel/format/ContentModelListItemFormat'; @@ -50,7 +57,7 @@ export { ListStyleFormat } from './contentModel/format/formatParts/ListStyleForm export { FloatFormat } from './contentModel/format/formatParts/FloatFormat'; export { EntityInfoFormat } from './contentModel/format/formatParts/EntityInfoFormat'; -export { DatasetFormat } from './contentModel/format/metadata/DatasetFormat'; +export { DatasetFormat, ReadonlyDatasetFormat } from './contentModel/format/metadata/DatasetFormat'; export { TableMetadataFormat } from './contentModel/format/metadata/TableMetadataFormat'; export { ListMetadataFormat } from './contentModel/format/metadata/ListMetadataFormat'; export { @@ -89,39 +96,147 @@ export { DeleteResult } from './enum/DeleteResult'; export { InsertEntityPosition } from './enum/InsertEntityPosition'; export { ExportContentMode } from './enum/ExportContentMode'; -export { ContentModelBlock } from './contentModel/block/ContentModelBlock'; -export { ContentModelParagraph } from './contentModel/block/ContentModelParagraph'; -export { ContentModelTable } from './contentModel/block/ContentModelTable'; -export { ContentModelDivider } from './contentModel/block/ContentModelDivider'; -export { ContentModelBlockBase } from './contentModel/block/ContentModelBlockBase'; +export { + ContentModelBlock, + ReadonlyContentModelBlock, + ShallowMutableContentModelBlock, +} from './contentModel/block/ContentModelBlock'; +export { + ContentModelParagraph, + ContentModelParagraphCommon, + ReadonlyContentModelParagraph, + ShallowMutableContentModelParagraph, +} from './contentModel/block/ContentModelParagraph'; +export { + ContentModelTable, + ReadonlyContentModelTable, + ShallowMutableContentModelTable, +} from './contentModel/block/ContentModelTable'; +export { + ContentModelDivider, + ContentModelDividerCommon, + ReadonlyContentModelDivider, +} from './contentModel/block/ContentModelDivider'; +export { + ContentModelBlockBase, + ContentModelBlockBaseCommon, + ReadonlyContentModelBlockBase, + ShallowMutableContentModelBlockBase, +} from './contentModel/block/ContentModelBlockBase'; export { ContentModelBlockWithCache } from './contentModel/common/ContentModelBlockWithCache'; -export { ContentModelTableRow } from './contentModel/block/ContentModelTableRow'; +export { + ContentModelTableRow, + ContentModelTableRowCommon, + ReadonlyContentModelTableRow, + ShallowMutableContentModelTableRow, +} from './contentModel/block/ContentModelTableRow'; export { ContentModelEntity } from './contentModel/entity/ContentModelEntity'; -export { ContentModelDocument } from './contentModel/blockGroup/ContentModelDocument'; -export { ContentModelBlockGroupBase } from './contentModel/blockGroup/ContentModelBlockGroupBase'; -export { ContentModelFormatContainer } from './contentModel/blockGroup/ContentModelFormatContainer'; -export { ContentModelGeneralBlock } from './contentModel/blockGroup/ContentModelGeneralBlock'; -export { ContentModelListItem } from './contentModel/blockGroup/ContentModelListItem'; -export { ContentModelTableCell } from './contentModel/blockGroup/ContentModelTableCell'; -export { ContentModelBlockGroup } from './contentModel/blockGroup/ContentModelBlockGroup'; +export { + ContentModelDocument, + ContentModelDocumentCommon, + ReadonlyContentModelDocument, + ShallowMutableContentModelDocument, +} from './contentModel/blockGroup/ContentModelDocument'; +export { + ContentModelBlockGroupBase, + ContentModelBlockGroupBaseCommon, + ReadonlyContentModelBlockGroupBase, + ShallowMutableContentModelBlockGroupBase, +} from './contentModel/blockGroup/ContentModelBlockGroupBase'; +export { + ContentModelFormatContainer, + ContentModelFormatContainerCommon, + ReadonlyContentModelFormatContainer, + ShallowMutableContentModelFormatContainer, +} from './contentModel/blockGroup/ContentModelFormatContainer'; +export { + ContentModelGeneralBlock, + ContentModelGeneralBlockCommon, + ReadonlyContentModelGeneralBlock, + ShallowMutableContentModelGeneralBlock, +} from './contentModel/blockGroup/ContentModelGeneralBlock'; +export { + ContentModelListItem, + ReadonlyContentModelListItem, + ShallowMutableContentModelListItem, +} from './contentModel/blockGroup/ContentModelListItem'; +export { + ContentModelTableCell, + ContentModelTableCellCommon, + ReadonlyContentModelTableCell, + ShallowMutableContentModelTableCell, +} from './contentModel/blockGroup/ContentModelTableCell'; +export { + ContentModelBlockGroup, + ReadonlyContentModelBlockGroup, + ShallowMutableContentModelBlockGroup, +} from './contentModel/blockGroup/ContentModelBlockGroup'; -export { ContentModelBr } from './contentModel/segment/ContentModelBr'; -export { ContentModelGeneralSegment } from './contentModel/segment/ContentModelGeneralSegment'; -export { ContentModelImage } from './contentModel/segment/ContentModelImage'; -export { ContentModelText } from './contentModel/segment/ContentModelText'; -export { ContentModelSelectionMarker } from './contentModel/segment/ContentModelSelectionMarker'; -export { ContentModelSegmentBase } from './contentModel/segment/ContentModelSegmentBase'; -export { ContentModelSegment } from './contentModel/segment/ContentModelSegment'; +export { ContentModelBr, ReadonlyContentModelBr } from './contentModel/segment/ContentModelBr'; +export { + ContentModelGeneralSegment, + ReadonlyContentModelGeneralSegment, + ShallowMutableContentModelGeneralSegment, +} from './contentModel/segment/ContentModelGeneralSegment'; +export { + ContentModelImage, + ContentModelImageCommon, + ReadonlyContentModelImage, +} from './contentModel/segment/ContentModelImage'; +export { + ContentModelText, + ContentModelTextCommon, + ReadonlyContentModelText, +} from './contentModel/segment/ContentModelText'; +export { + ContentModelSelectionMarker, + ReadonlyContentModelSelectionMarker, +} from './contentModel/segment/ContentModelSelectionMarker'; +export { + ContentModelSegmentBase, + ContentModelSegmentBaseCommon, + ReadonlyContentModelSegmentBase, + ShallowMutableContentModelSegmentBase, +} from './contentModel/segment/ContentModelSegmentBase'; +export { + ContentModelSegment, + ReadonlyContentModelSegment, + ShallowMutableContentModelSegment, +} from './contentModel/segment/ContentModelSegment'; + +export { + ContentModelCode, + ReadonlyContentModelCode, +} from './contentModel/decorator/ContentModelCode'; +export { + ContentModelLink, + ReadonlyContentModelLink, +} from './contentModel/decorator/ContentModelLink'; +export { + ContentModelParagraphDecorator, + ContentModelParagraphDecoratorCommon, + ReadonlyContentModelParagraphDecorator, +} from './contentModel/decorator/ContentModelParagraphDecorator'; +export { + ContentModelDecorator, + ReadonlyContentModelDecorator, +} from './contentModel/decorator/ContentModelDecorator'; +export { + ContentModelListLevel, + ContentModelListLevelCommon, + ReadonlyContentModelListLevel, +} from './contentModel/decorator/ContentModelListLevel'; -export { ContentModelCode } from './contentModel/decorator/ContentModelCode'; -export { ContentModelLink } from './contentModel/decorator/ContentModelLink'; -export { ContentModelParagraphDecorator } from './contentModel/decorator/ContentModelParagraphDecorator'; -export { ContentModelDecorator } from './contentModel/decorator/ContentModelDecorator'; -export { ContentModelListLevel } from './contentModel/decorator/ContentModelListLevel'; +export { + Selectable, + ReadonlySelectable, + ShallowMutableSelectable, +} from './contentModel/common/Selectable'; +export { MutableMark, ShallowMutableMark, ReadonlyMark } from './contentModel/common/MutableMark'; +export { MutableType } from './contentModel/common/MutableType'; -export { Selectable } from './contentModel/common/Selectable'; export { DOMSelection, SelectionType, @@ -132,7 +247,10 @@ export { DOMInsertPoint, } from './selection/DOMSelection'; export { InsertPoint } from './selection/InsertPoint'; -export { TableSelectionContext } from './selection/TableSelectionContext'; +export { + TableSelectionContext, + ReadonlyTableSelectionContext, +} from './selection/TableSelectionContext'; export { TableSelectionCoordinates } from './selection/TableSelectionCoordinates'; export { @@ -305,10 +423,11 @@ export { MergeModelOption } from './parameter/MergeModelOption'; export { IterateSelectionsCallback, IterateSelectionsOption, + ReadonlyIterateSelectionsCallback, } from './parameter/IterateSelectionsOption'; export { NodeTypeMap } from './parameter/NodeTypeMap'; export { TypeOfBlockGroup } from './parameter/TypeOfBlockGroup'; -export { OperationalBlocks } from './parameter/OperationalBlocks'; +export { OperationalBlocks, ReadonlyOperationalBlocks } from './parameter/OperationalBlocks'; export { ParsedTable, ParsedTableCell } from './parameter/ParsedTable'; export { ModelToTextCallback, diff --git a/packages/roosterjs-content-model-types/lib/parameter/DeleteSelectionStep.ts b/packages/roosterjs-content-model-types/lib/parameter/DeleteSelectionStep.ts index f396642b126..012f4987545 100644 --- a/packages/roosterjs-content-model-types/lib/parameter/DeleteSelectionStep.ts +++ b/packages/roosterjs-content-model-types/lib/parameter/DeleteSelectionStep.ts @@ -1,8 +1,8 @@ -import type { ContentModelParagraph } from '../contentModel/block/ContentModelParagraph'; +import type { ShallowMutableContentModelParagraph } from '../contentModel/block/ContentModelParagraph'; import type { DeleteResult } from '../enum/DeleteResult'; import type { FormatContentModelContext } from './FormatContentModelContext'; import type { InsertPoint } from '../selection/InsertPoint'; -import type { TableSelectionContext } from '../selection/TableSelectionContext'; +import type { ReadonlyTableSelectionContext } from '../selection/TableSelectionContext'; /** * Result of deleteSelection API @@ -26,12 +26,12 @@ export interface DeleteSelectionContext extends DeleteSelectionResult { /** * Last paragraph after previous step */ - lastParagraph?: ContentModelParagraph; + lastParagraph?: ShallowMutableContentModelParagraph; /** * Last table context after previous step */ - lastTableContext?: TableSelectionContext; + lastTableContext?: ReadonlyTableSelectionContext; /** * Format context provided by formatContentModel API diff --git a/packages/roosterjs-content-model-types/lib/parameter/IterateSelectionsOption.ts b/packages/roosterjs-content-model-types/lib/parameter/IterateSelectionsOption.ts index 7cce60f6fba..b2b010dfd96 100644 --- a/packages/roosterjs-content-model-types/lib/parameter/IterateSelectionsOption.ts +++ b/packages/roosterjs-content-model-types/lib/parameter/IterateSelectionsOption.ts @@ -1,7 +1,19 @@ -import type { ContentModelBlock } from '../contentModel/block/ContentModelBlock'; -import type { ContentModelBlockGroup } from '../contentModel/blockGroup/ContentModelBlockGroup'; -import type { ContentModelSegment } from '../contentModel/segment/ContentModelSegment'; -import type { TableSelectionContext } from '../selection/TableSelectionContext'; +import type { + ContentModelBlock, + ReadonlyContentModelBlock, +} from '../contentModel/block/ContentModelBlock'; +import type { + ContentModelBlockGroup, + ReadonlyContentModelBlockGroup, +} from '../contentModel/blockGroup/ContentModelBlockGroup'; +import type { + ContentModelSegment, + ReadonlyContentModelSegment, +} from '../contentModel/segment/ContentModelSegment'; +import type { + ReadonlyTableSelectionContext, + TableSelectionContext, +} from '../selection/TableSelectionContext'; /** * Options for iterateSelections API @@ -51,3 +63,18 @@ export type IterateSelectionsCallback = ( block?: ContentModelBlock, segments?: ContentModelSegment[] ) => void | boolean; + +/** + * The callback function type for iterateSelections (Readonly) + * @param path The block group path of current selection + * @param tableContext Table context of current selection + * @param block Block of current selection + * @param segments Segments of current selection + * @returns True to stop iterating, otherwise keep going + */ +export type ReadonlyIterateSelectionsCallback = ( + path: ReadonlyContentModelBlockGroup[], + tableContext?: ReadonlyTableSelectionContext, + block?: ReadonlyContentModelBlock, + segments?: ReadonlyContentModelSegment[] +) => void | boolean; diff --git a/packages/roosterjs-content-model-types/lib/parameter/OperationalBlocks.ts b/packages/roosterjs-content-model-types/lib/parameter/OperationalBlocks.ts index b83e3e515d8..ca69ff44ac6 100644 --- a/packages/roosterjs-content-model-types/lib/parameter/OperationalBlocks.ts +++ b/packages/roosterjs-content-model-types/lib/parameter/OperationalBlocks.ts @@ -1,5 +1,11 @@ -import type { ContentModelBlock } from '../contentModel/block/ContentModelBlock'; -import type { ContentModelBlockGroup } from '../contentModel/blockGroup/ContentModelBlockGroup'; +import type { + ContentModelBlock, + ReadonlyContentModelBlock, +} from '../contentModel/block/ContentModelBlock'; +import type { + ContentModelBlockGroup, + ReadonlyContentModelBlockGroup, +} from '../contentModel/blockGroup/ContentModelBlockGroup'; /** * Represent a pair of parent block group and child block @@ -20,3 +26,23 @@ export type OperationalBlocks = { */ path: ContentModelBlockGroup[]; }; + +/** + * Represent a pair of parent block group and child block (Readonly) + */ +export type ReadonlyOperationalBlocks = { + /** + * The parent block group + */ + parent: ReadonlyContentModelBlockGroup; + + /** + * The child block + */ + block: ReadonlyContentModelBlock | T; + + /** + * Selection path of this block + */ + path: ReadonlyContentModelBlockGroup[]; +}; diff --git a/packages/roosterjs-content-model-types/lib/parameter/TypeOfBlockGroup.ts b/packages/roosterjs-content-model-types/lib/parameter/TypeOfBlockGroup.ts index 5cc421f7ce3..75099cce0a9 100644 --- a/packages/roosterjs-content-model-types/lib/parameter/TypeOfBlockGroup.ts +++ b/packages/roosterjs-content-model-types/lib/parameter/TypeOfBlockGroup.ts @@ -1,9 +1,17 @@ -import type { ContentModelBlockGroup } from '../contentModel/blockGroup/ContentModelBlockGroup'; -import type { ContentModelBlockGroupBase } from '../contentModel/blockGroup/ContentModelBlockGroupBase'; +import type { + ContentModelBlockGroup, + ReadonlyContentModelBlockGroup, +} from '../contentModel/blockGroup/ContentModelBlockGroup'; +import type { + ContentModelBlockGroupBase, + ReadonlyContentModelBlockGroupBase, +} from '../contentModel/blockGroup/ContentModelBlockGroupBase'; /** * Retrieve block group type string from a given block group */ export type TypeOfBlockGroup< - T extends ContentModelBlockGroup -> = T extends ContentModelBlockGroupBase ? U : never; + T extends ContentModelBlockGroup | ReadonlyContentModelBlockGroup +> = T extends ContentModelBlockGroupBase | ReadonlyContentModelBlockGroupBase + ? U + : never; diff --git a/packages/roosterjs-content-model-types/lib/pluginState/SelectionPluginState.ts b/packages/roosterjs-content-model-types/lib/pluginState/SelectionPluginState.ts index 556f4ecccb3..8c9d922a6fb 100644 --- a/packages/roosterjs-content-model-types/lib/pluginState/SelectionPluginState.ts +++ b/packages/roosterjs-content-model-types/lib/pluginState/SelectionPluginState.ts @@ -74,4 +74,19 @@ export interface SelectionPluginState { * Color of the border of a selectedImage. Default color: '#DB626C' */ imageSelectionBorderColor?: string; + + /** + * Color of the border of a selectedImage in dark mode. Default color: '#DB626C' + */ + imageSelectionBorderColorDark?: string; + + /** + * Background color of a selected table cell. Default color: '#C6C6C6' + */ + tableCellSelectionBackgroundColor?: string; + + /** + * Background color of a selected table cell in dark mode. Default color: '#C6C6C6' + */ + tableCellSelectionBackgroundColorDark?: string; } diff --git a/packages/roosterjs-content-model-types/lib/selection/InsertPoint.ts b/packages/roosterjs-content-model-types/lib/selection/InsertPoint.ts index 49f1057d918..493d5073ee4 100644 --- a/packages/roosterjs-content-model-types/lib/selection/InsertPoint.ts +++ b/packages/roosterjs-content-model-types/lib/selection/InsertPoint.ts @@ -1,7 +1,7 @@ -import type { ContentModelBlockGroup } from '../contentModel/blockGroup/ContentModelBlockGroup'; -import type { ContentModelParagraph } from '../contentModel/block/ContentModelParagraph'; +import type { ReadonlyContentModelBlockGroup } from '../contentModel/blockGroup/ContentModelBlockGroup'; +import type { ShallowMutableContentModelParagraph } from '../contentModel/block/ContentModelParagraph'; import type { ContentModelSelectionMarker } from '../contentModel/segment/ContentModelSelectionMarker'; -import type { TableSelectionContext } from './TableSelectionContext'; +import type { ReadonlyTableSelectionContext } from './TableSelectionContext'; /** * Represent all related information of an insert point @@ -15,15 +15,15 @@ export interface InsertPoint { /** * The paragraph that contains this insert point */ - paragraph: ContentModelParagraph; + paragraph: ShallowMutableContentModelParagraph; /** * Block group path of this insert point, from direct parent group to the root group */ - path: ContentModelBlockGroup[]; + path: ReadonlyContentModelBlockGroup[]; /** * Table context of this insert point */ - tableContext?: TableSelectionContext; + tableContext?: ReadonlyTableSelectionContext; } diff --git a/packages/roosterjs-content-model-types/lib/selection/TableSelectionContext.ts b/packages/roosterjs-content-model-types/lib/selection/TableSelectionContext.ts index 50d85d6f108..25cd46f5f0a 100644 --- a/packages/roosterjs-content-model-types/lib/selection/TableSelectionContext.ts +++ b/packages/roosterjs-content-model-types/lib/selection/TableSelectionContext.ts @@ -1,4 +1,7 @@ -import type { ContentModelTable } from '../contentModel/block/ContentModelTable'; +import type { + ContentModelTable, + ReadonlyContentModelTable, +} from '../contentModel/block/ContentModelTable'; /** * Context object for table in a selection @@ -24,3 +27,28 @@ export interface TableSelectionContext { */ isWholeTableSelected: boolean; } + +/** + * Context object for table in a selection (Readonly) + */ +export interface ReadonlyTableSelectionContext { + /** + * The table that contains the selection + */ + table: ReadonlyContentModelTable; + + /** + * Row Index of the selected table cell + */ + rowIndex: number; + + /** + * Column Index of the selected table cell + */ + colIndex: number; + + /** + * Whether the whole table is selected + */ + isWholeTableSelected: boolean; +} diff --git a/versions.json b/versions.json index 67a77bc531f..25149e1d91b 100644 --- a/versions.json +++ b/versions.json @@ -1,6 +1,7 @@ -{ - "legacy": "8.62.0", - "react": "8.56.0", - "main": "9.3.1", - "legacyAdapter": "8.62.0" -} +{ + "legacy": "8.62.0", + "react": "8.56.0", + "main": "9.4.0", + "legacyAdapter": "8.62.0", + "overrides": {} +}