From 878aab544d030172e34da2ff98ac22cab2d5217e Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Fri, 27 Jan 2023 10:32:33 -0800 Subject: [PATCH] Version bump: Roosterjs 8.41.0 (#1537) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * convert alpha to decimals * fix auto format list * add null and refactor * Content Model Selection API step 4: Refactor existing table API (#1479) * Selection API step 1 * Selection API 2 * New selection API * Refactor table API * add test * Support element with namespace (#1489) * Content Model: Fix a bug when process margin (#1493) * Fix margin issue * Fix test * Fix A tag without href (#1495) * Fix Cut/Copy page scroll issue (#1496) * Fix Cut/Copy page scroll issue * Fix test * fix image plugin z-index calc * Content Model Format State Step 1: Refactor formatSegmentWithContentModel() (#1490) * Selection API step 1 * Selection API 2 * New selection API * Refactor table API * add test * Format state step 1 * Improve * update condition per comments * Content Model Format State Step 2: Allow retrieving metadata directly (#1491) * Selection API step 1 * Selection API 2 * New selection API * Refactor table API * add test * Format state step 1 * FormatState step 2 * Improve * Content Model Format State Step 3: Add getFormatState API and ContentModelPlugin (#1492) * Selection API step 1 * Selection API 2 * New selection API * Refactor table API * add test * Format state step 1 * FormatState step 2 * FormatState step 3: Add getFormatState API and ContentModel plugin * Improve * Improve * Improve * fix test * improve, fix safari issue * fix test * Content Model: Add API clearFormat (#1497) * Selection API step 1 * Selection API 2 * New selection API * Refactor table API * add test * Format state step 1 * FormatState step 2 * FormatState step 3: Add getFormatState API and ContentModel plugin * Improve * Content Model: clearFormat * fix build * Improve * Improve * fix test * improve, fix safari issue * fix test * remove wrapper when content change * fix * Content Model: Move format API: link, image, captalization, ... (#1506) * Selection API step 1 * Selection API 2 * New selection API * Refactor table API * add test * Format state step 1 * FormatState step 2 * FormatState step 3: Add getFormatState API and ContentModel plugin * Improve * Content Model: clearFormat * fix build * Improve * Improve * fix test * improve, fix safari issue * fix test * ContentModel: Support insertLink and removeLink * changeCapitalization and setImageAltText * fix for image selection * refactor * refactor * Test image edit with ShadowDOM * improve * Fix #1509 (#1511) * ContentModel: Improve Divider (#1513) * ContentModel: Improve Divider * Add BorderFormat to ContentModelBlockFormat * Add test * fix build * Content Model: Support "no color" when set color (#1514) * Content Model: Support "no color" when set color * improve * Content Model: Use Entity handle readonly element (#1515) * image wrapper using shadow dom * Content Model: Support get and apply segment format (#1518) * Do not merge table when insert a table (#1519) * Content Model: Fix #1239 (#1521) * WIP AND fix for span height * stop dragging * comment * prevent drag * remove new max-width * Load fluent ui from cdnjs (#1525) * Content Model: Improve selection (#1526) * Apply format to word where cursor is located (#1367) * attempt with traversers * attempt using splitTextNode * Return to original implementation * Fix build * implementation with content model * Implement word selection with new content model * removed selectWordFromCollapsedRange.ts * optimization fixes and file changes * standardize function and remove castings * fix paragraph and pending state * fix pending state, name change * Added test cases, disabled end or start of word * fixed dependency * fix pending state * more tests * fixed tests * End of word format fix (#1528) * End of word format fix Fix scenario where format was wrongly applied where the cursor was located at the end of a word * add tests * Variable based dark color (#1531) * Variable based dark color * fix test * improve * Improve * Fix comment * Fix #1532: Support isCode in FormatState (#1533) * Fix #1532 * add comment * fix build * fix comment * Bump ua-parser-js from 0.7.31 to 0.7.33 (#1535) Bumps [ua-parser-js](https://github.com/faisalman/ua-parser-js) from 0.7.31 to 0.7.33. - [Release notes](https://github.com/faisalman/ua-parser-js/releases) - [Changelog](https://github.com/faisalman/ua-parser-js/blob/master/changelog.md) - [Commits](https://github.com/faisalman/ua-parser-js/compare/0.7.31...0.7.33) --- updated-dependencies: - dependency-name: ua-parser-js dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Fix #1529 #1530 and 187095 (#1534) --------- Signed-off-by: dependabot[bot] Co-authored-by: Júlia Roldi Co-authored-by: Julia Roldi <87443959+juliaroldi@users.noreply.github.com> Co-authored-by: Shai Petel Co-authored-by: Shai Petel Co-authored-by: Andres-CT98 <107568016+Andres-CT98@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- demo/index.html | 2 +- .../model/ContentModelEntityView.tsx | 1 + .../model/ContentModelListItemView.tsx | 2 + .../editor/ExperimentalContentModelEditor.ts | 1 + .../editorOptions/EditorOptionsPlugin.ts | 1 + .../editorOptions/ExperimentalFeatures.tsx | 1 + .../sidePane/editorOptions/OptionsPane.tsx | 2 +- index.html | 2 +- package.json | 2 +- .../lib/ribbon/component/buttons/code.ts | 1 + .../domToModel/processors/entityProcessor.ts | 37 +- .../common/backgroundColorFormatHandler.ts | 9 +- .../formatHandlers/defaultFormatHandlers.ts | 2 +- .../segment/textColorFormatHandler.ts | 8 +- .../lib/formatHandlers/utils/color.ts | 39 +- .../lib/modelApi/block/setModelIndentation.ts | 10 +- .../common/retrieveModelFormatState.ts | 16 + .../lib/modelApi/creators/createEntity.ts | 4 +- .../lib/modelApi/list/setListType.ts | 10 +- .../modelApi/selection/adjustWordSelection.ts | 143 ++++ .../modelApi/selection/collectSelections.ts | 5 +- .../modelApi/selection/iterateSelections.ts | 1 - .../lib/publicApi/block/setDirection.ts | 19 +- .../utils/formatSegmentWithContentModel.ts | 17 +- .../lib/publicTypes/context/EditorContext.ts | 7 + .../publicTypes/entity/ContentModelEntity.ts | 10 +- .../format/ContentModelListItemLevelFormat.ts | 4 +- packages/roosterjs-content-model/package.json | 2 +- .../processors/entityProcessorTest.ts | 50 +- .../test/formatHandlers/utils/colorTest.ts | 161 +++- .../modelApi/block/setModelIndentationTest.ts | 22 +- .../common/retrieveModelFormatStateTest.ts | 43 + .../test/modelApi/creators/creatorsTest.ts | 6 +- .../test/modelApi/list/setListTypeTest.ts | 110 ++- .../selection/adjustWordSelectionTest.ts | 778 ++++++++++++++++++ .../selection/collectSelectionsTest.ts | 21 + .../selection/iterateSelectionsTest.ts | 17 + .../modelToDom/handlers/handleEntityTest.ts | 1 + .../test/publicApi/block/setDirectionTest.ts | 139 ++++ .../lib/format/getFormatState.ts | 22 + .../lib/format/setBackgroundColor.ts | 9 +- .../lib/format/setTextColor.ts | 4 +- .../lib/table/applyCellShading.ts | 3 +- .../lib/utils/execCommand.ts | 130 +-- .../lib/coreApi/ensureTypeInContainer.ts | 7 +- .../lib/coreApi/getContent.ts | 7 +- .../lib/coreApi/getStyleBasedFormatState.ts | 140 +++- .../lib/coreApi/transformColor.ts | 40 +- .../lib/corePlugins/LifecyclePlugin.ts | 19 +- .../corePlugins/PendingFormatStatePlugin.ts | 16 +- .../lib/editor/DarkColorHandlerImpl.ts | 84 ++ .../lib/editor/Editor.ts | 18 + .../coreApi/getStyleBasedFormatStateTest.ts | 83 ++ .../test/coreApi/transformColorTest.ts | 214 ++++- .../test/corePlugins/lifecyclePluginTest.ts | 17 +- .../test/editor/DarkColorHandlerImplTest.ts | 236 ++++++ .../lib/utils/applyFormat.ts | 43 +- .../lib/utils/createElement.ts | 2 +- .../lib/utils/setColor.ts | 94 ++- .../lib/plugins/ImageEdit/ImageEdit.ts | 153 ++-- .../ImageEdit/editInfoUtils/applyChange.ts | 1 - .../plugins/ImageEdit/imageEditors/Rotator.ts | 31 +- .../lib/plugins/Watermark/Watermark.ts | 7 +- .../lib/enum/ExperimentalFeatures.ts | 7 + .../lib/interface/DarkColorHandler.ts | 45 + .../lib/interface/EditorCore.ts | 7 + .../lib/interface/FormatState.ts | 10 + .../lib/interface/IEditor.ts | 6 + .../lib/interface/index.ts | 1 + yarn.lock | 6 +- 70 files changed, 2782 insertions(+), 386 deletions(-) create mode 100644 packages/roosterjs-content-model/lib/modelApi/selection/adjustWordSelection.ts create mode 100644 packages/roosterjs-content-model/test/modelApi/selection/adjustWordSelectionTest.ts create mode 100644 packages/roosterjs-editor-core/lib/editor/DarkColorHandlerImpl.ts create mode 100644 packages/roosterjs-editor-core/test/editor/DarkColorHandlerImplTest.ts create mode 100644 packages/roosterjs-editor-types/lib/interface/DarkColorHandler.ts diff --git a/demo/index.html b/demo/index.html index 7e6705c8732..623529f84ad 100644 --- a/demo/index.html +++ b/demo/index.html @@ -20,7 +20,7 @@
- + diff --git a/demo/scripts/controls/contentModel/components/model/ContentModelEntityView.tsx b/demo/scripts/controls/contentModel/components/model/ContentModelEntityView.tsx index dab8514f712..841dbfac805 100644 --- a/demo/scripts/controls/contentModel/components/model/ContentModelEntityView.tsx +++ b/demo/scripts/controls/contentModel/components/model/ContentModelEntityView.tsx @@ -71,6 +71,7 @@ export function ContentModelEntityView(props: { entity: ContentModelEntity }) { title="Entity" subTitle={id} className={styles.modelEntity} + isSelected={entity.isSelected} jsonSource={entity} getContent={getContent} getFormat={getFormat} diff --git a/demo/scripts/controls/contentModel/components/model/ContentModelListItemView.tsx b/demo/scripts/controls/contentModel/components/model/ContentModelListItemView.tsx index 3bd7436710d..93b9e876018 100644 --- a/demo/scripts/controls/contentModel/components/model/ContentModelListItemView.tsx +++ b/demo/scripts/controls/contentModel/components/model/ContentModelListItemView.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { ContentModelBlockView } from './ContentModelBlockView'; import { ContentModelView } from '../ContentModelView'; +import { DirectionFormatRenderers } from '../format/formatPart/DirectionFormatRenderers'; import { FontFamilyFormatRenderer } from '../format/formatPart/FontFamilyFormatRenderer'; import { FontSizeFormatRenderer } from '../format/formatPart/FontSizeFormatRenderer'; import { FormatRenderer } from '../format/utils/FormatRenderer'; @@ -23,6 +24,7 @@ const ListLevelFormatRenders: FormatRenderer[] ListTypeFormatRenderer, ...ListThreadFormatRenderers, ...ListMetadataFormatRenderers, + ...DirectionFormatRenderers, ]; const ListItemFormatHolderRenderers: FormatRenderer[] = [ diff --git a/demo/scripts/controls/editor/ExperimentalContentModelEditor.ts b/demo/scripts/controls/editor/ExperimentalContentModelEditor.ts index 87af8988362..4bc47edd64e 100644 --- a/demo/scripts/controls/editor/ExperimentalContentModelEditor.ts +++ b/demo/scripts/controls/editor/ExperimentalContentModelEditor.ts @@ -45,6 +45,7 @@ export default class ExperimentalContentModelEditor extends Editor zoomScale: this.getZoomScale(), isRightToLeft: getComputedStyles(this.contentDiv, 'direction')[0] == 'rtl', getDarkColor: this.getDarkColor, + darkColorHandler: this.getDarkColorHandler(), }; } diff --git a/demo/scripts/controls/sidePane/editorOptions/EditorOptionsPlugin.ts b/demo/scripts/controls/sidePane/editorOptions/EditorOptionsPlugin.ts index fce6cd13160..fa412a84514 100644 --- a/demo/scripts/controls/sidePane/editorOptions/EditorOptionsPlugin.ts +++ b/demo/scripts/controls/sidePane/editorOptions/EditorOptionsPlugin.ts @@ -31,6 +31,7 @@ const initialState: BuildInPluginState = { ExperimentalFeatures.ListItemAlignment, ExperimentalFeatures.DefaultFormatInSpan, ExperimentalFeatures.AutoFormatList, + ExperimentalFeatures.VariableBasedDarkColor, ], isRtl: false, }; diff --git a/demo/scripts/controls/sidePane/editorOptions/ExperimentalFeatures.tsx b/demo/scripts/controls/sidePane/editorOptions/ExperimentalFeatures.tsx index e26cef8f466..87c1b618ba9 100644 --- a/demo/scripts/controls/sidePane/editorOptions/ExperimentalFeatures.tsx +++ b/demo/scripts/controls/sidePane/editorOptions/ExperimentalFeatures.tsx @@ -18,6 +18,7 @@ const FeatureNames: Partial> = { "Reuse ancestor list elements even if they don't match the types from the list item.", [ExperimentalFeatures.DefaultFormatInSpan]: 'When apply default format when initialize or user type, apply the format on a SPAN element.', + [ExperimentalFeatures.VariableBasedDarkColor]: 'Use variable-based color for dark mode', }; export default class ExperimentalFeaturesPane extends React.Component< diff --git a/demo/scripts/controls/sidePane/editorOptions/OptionsPane.tsx b/demo/scripts/controls/sidePane/editorOptions/OptionsPane.tsx index a2954153510..bce75b70d8c 100644 --- a/demo/scripts/controls/sidePane/editorOptions/OptionsPane.tsx +++ b/demo/scripts/controls/sidePane/editorOptions/OptionsPane.tsx @@ -33,7 +33,7 @@ const htmlRoosterReact = '
\n' + '\n' + '\n' + - '\n' + + '\n' + '\n' + '\n' + '\n' + diff --git a/index.html b/index.html index 738c68a2866..0b65d8b15ce 100644 --- a/index.html +++ b/index.html @@ -20,7 +20,7 @@
- + diff --git a/package.json b/package.json index 05168c64a5b..e527154b996 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "roosterjs", - "version": "8.40.2", + "version": "8.41.0", "description": "Framework-independent javascript editor", "repository": { "type": "git", diff --git a/packages-ui/roosterjs-react/lib/ribbon/component/buttons/code.ts b/packages-ui/roosterjs-react/lib/ribbon/component/buttons/code.ts index 3585d45a28e..5dadb5ad747 100644 --- a/packages-ui/roosterjs-react/lib/ribbon/component/buttons/code.ts +++ b/packages-ui/roosterjs-react/lib/ribbon/component/buttons/code.ts @@ -10,6 +10,7 @@ export const code: RibbonButton = { key: 'buttonNameCode', unlocalizedText: 'Code', iconName: 'Code', + isChecked: formatState => !!formatState.isCodeBlock, onClick: editor => { toggleCodeBlock(editor); }, diff --git a/packages/roosterjs-content-model/lib/domToModel/processors/entityProcessor.ts b/packages/roosterjs-content-model/lib/domToModel/processors/entityProcessor.ts index 39c1bd1c2b3..e0d2f4fd7cd 100644 --- a/packages/roosterjs-content-model/lib/domToModel/processors/entityProcessor.ts +++ b/packages/roosterjs-content-model/lib/domToModel/processors/entityProcessor.ts @@ -13,28 +13,21 @@ export const entityProcessor: ElementProcessor = (group, element, c const entity = getEntityFromElement(element); // In Content Model we also treat read only element as an entity since we cannot edit it - if (entity || element.contentEditable == 'false') { - const { id, type, isReadonly } = entity || {}; - const isBlockEntity = isBlockElement(element, context); + const { id, type, isReadonly } = entity || { isReadonly: true }; + const isBlockEntity = isBlockElement(element, context); - stackFormat( - context, - { segment: isBlockEntity ? 'shallowCloneForBlock' : undefined }, - () => { - const entityModel = createEntity( - element, - context.segmentFormat, - id, - type, - isReadonly - ); + stackFormat(context, { segment: isBlockEntity ? 'shallowCloneForBlock' : undefined }, () => { + const entityModel = createEntity(element, isReadonly, context.segmentFormat, id, type); - if (isBlockEntity) { - addBlock(group, entityModel); - } else { - addSegment(group, entityModel); - } - } - ); - } + // TODO: Need to handle selection for editable entity + if (context.isInSelection) { + entityModel.isSelected = true; + } + + if (isBlockEntity) { + addBlock(group, entityModel); + } else { + addSegment(group, entityModel); + } + }); }; diff --git a/packages/roosterjs-content-model/lib/formatHandlers/common/backgroundColorFormatHandler.ts b/packages/roosterjs-content-model/lib/formatHandlers/common/backgroundColorFormatHandler.ts index 1f1543e20b5..76a319fd29c 100644 --- a/packages/roosterjs-content-model/lib/formatHandlers/common/backgroundColorFormatHandler.ts +++ b/packages/roosterjs-content-model/lib/formatHandlers/common/backgroundColorFormatHandler.ts @@ -8,8 +8,12 @@ import { getColor, setColor } from '../utils/color'; export const backgroundColorFormatHandler: FormatHandler = { parse: (format, element, context, defaultStyle) => { const backgroundColor = - getColor(element, true /*isBackground*/, context.isDarkMode) || - defaultStyle.backgroundColor; + getColor( + element, + true /*isBackground*/, + context.darkColorHandler, + context.isDarkMode + ) || defaultStyle.backgroundColor; if (backgroundColor) { format.backgroundColor = backgroundColor; @@ -21,6 +25,7 @@ export const backgroundColorFormatHandler: FormatHandler element, format.backgroundColor, true /*isBackground*/, + context.darkColorHandler, context.isDarkMode, context.getDarkColor ); diff --git a/packages/roosterjs-content-model/lib/formatHandlers/defaultFormatHandlers.ts b/packages/roosterjs-content-model/lib/formatHandlers/defaultFormatHandlers.ts index 07affc07128..c9e7a64a849 100644 --- a/packages/roosterjs-content-model/lib/formatHandlers/defaultFormatHandlers.ts +++ b/packages/roosterjs-content-model/lib/formatHandlers/defaultFormatHandlers.ts @@ -93,7 +93,7 @@ const defaultFormatKeysPerCategory: { } = { block: blockFormatHandlers, listItem: ['listItemThread', 'listItemMetadata'], - listLevel: ['listType', 'listLevelThread', 'listLevelMetadata'], + listLevel: ['listType', 'listLevelThread', 'listLevelMetadata', 'direction'], segment: [ 'superOrSubScript', 'strike', diff --git a/packages/roosterjs-content-model/lib/formatHandlers/segment/textColorFormatHandler.ts b/packages/roosterjs-content-model/lib/formatHandlers/segment/textColorFormatHandler.ts index b740ff32a65..4a25942081b 100644 --- a/packages/roosterjs-content-model/lib/formatHandlers/segment/textColorFormatHandler.ts +++ b/packages/roosterjs-content-model/lib/formatHandlers/segment/textColorFormatHandler.ts @@ -8,7 +8,12 @@ import { TextColorFormat } from '../../publicTypes/format/formatParts/TextColorF export const textColorFormatHandler: FormatHandler = { parse: (format, element, context, defaultStyle) => { const textColor = - getColor(element, false /*isBackground*/, context.isDarkMode) || defaultStyle.color; + getColor( + element, + false /*isBackground*/, + context.darkColorHandler, + context.isDarkMode + ) || defaultStyle.color; if (textColor && textColor != 'inherit') { format.textColor = textColor; @@ -22,6 +27,7 @@ export const textColorFormatHandler: FormatHandler = { element, format.textColor, false /*isBackground*/, + context.darkColorHandler, context.isDarkMode, context.getDarkColor ); diff --git a/packages/roosterjs-content-model/lib/formatHandlers/utils/color.ts b/packages/roosterjs-content-model/lib/formatHandlers/utils/color.ts index ac3e9769e97..e884b2b47b4 100644 --- a/packages/roosterjs-content-model/lib/formatHandlers/utils/color.ts +++ b/packages/roosterjs-content-model/lib/formatHandlers/utils/color.ts @@ -1,4 +1,4 @@ -import { DarkModeDatasetNames, ModeIndependentColor } from 'roosterjs-editor-types'; +import { DarkColorHandler, DarkModeDatasetNames } from 'roosterjs-editor-types'; /** * @internal @@ -6,10 +6,11 @@ import { DarkModeDatasetNames, ModeIndependentColor } from 'roosterjs-editor-typ export function getColor( element: HTMLElement, isBackground: boolean, + darkColorHandler: DarkColorHandler | undefined | null, isDarkMode: boolean ): string | undefined { let color: string | undefined; - if (isDarkMode) { + if (isDarkMode && !darkColorHandler) { color = element.dataset[ isBackground @@ -30,6 +31,10 @@ export function getColor( undefined; } + if (darkColorHandler) { + color = darkColorHandler.parseColorValue(color).lightModeColor; + } + return color; } @@ -38,24 +43,28 @@ export function getColor( */ export function setColor( element: HTMLElement, - color: string | ModeIndependentColor, + lightModeColor: string, isBackground: boolean, + darkColorHandler: DarkColorHandler | undefined | null, isDarkMode: boolean, getDarkColor?: (color: string) => string ) { - const originalColor = typeof color === 'object' ? color.lightModeColor : color; - const effectiveColor = isDarkMode - ? typeof color === 'object' - ? color.darkModeColor - : getDarkColor?.(color) || color - : originalColor; + let effectiveColor: string; + + if (darkColorHandler) { + effectiveColor = darkColorHandler.registerColor(lightModeColor, isDarkMode); + } else { + effectiveColor = isDarkMode + ? getDarkColor?.(lightModeColor) || lightModeColor + : lightModeColor; - if (isDarkMode && originalColor) { - element.dataset[ - isBackground - ? DarkModeDatasetNames.OriginalStyleBackgroundColor - : DarkModeDatasetNames.OriginalStyleColor - ] = originalColor; + if (isDarkMode && lightModeColor) { + element.dataset[ + isBackground + ? DarkModeDatasetNames.OriginalStyleBackgroundColor + : DarkModeDatasetNames.OriginalStyleColor + ] = lightModeColor; + } } if (isBackground) { diff --git a/packages/roosterjs-content-model/lib/modelApi/block/setModelIndentation.ts b/packages/roosterjs-content-model/lib/modelApi/block/setModelIndentation.ts index 478e0a0a49c..84e0d6557e9 100644 --- a/packages/roosterjs-content-model/lib/modelApi/block/setModelIndentation.ts +++ b/packages/roosterjs-content-model/lib/modelApi/block/setModelIndentation.ts @@ -41,15 +41,19 @@ export function setModelIndentation( } else if (block) { const { format } = block; const { marginLeft, marginRight, direction } = format; - const originalValue = parseValueWithUnit(direction == 'rtl' ? marginRight : marginLeft); + const isRtl = direction == 'rtl'; + const originalValue = parseValueWithUnit(isRtl ? marginRight : marginLeft); let newValue = (isIndent ? Math.ceil : Math.floor)(originalValue / length) * length; if (newValue == originalValue) { newValue = Math.max(newValue + length * (isIndent ? 1 : -1), 0); } - format.marginLeft = newValue + 'px'; - format.marginRight = newValue + 'px'; + if (isRtl) { + format.marginRight = newValue + 'px'; + } else { + format.marginLeft = newValue + 'px'; + } } }); diff --git a/packages/roosterjs-content-model/lib/modelApi/common/retrieveModelFormatState.ts b/packages/roosterjs-content-model/lib/modelApi/common/retrieveModelFormatState.ts index 98d351eed87..c65ee34850f 100644 --- a/packages/roosterjs-content-model/lib/modelApi/common/retrieveModelFormatState.ts +++ b/packages/roosterjs-content-model/lib/modelApi/common/retrieveModelFormatState.ts @@ -19,6 +19,7 @@ export function retrieveModelFormatState( formatState: FormatState ) { let isFirst = true; + let previousTableContext: TableSelectionContext | undefined; iterateSelections( [model], @@ -35,11 +36,23 @@ export function retrieveModelFormatState( ); } else if (tableContext) { retrieveTableFormat(tableContext, formatState); + previousTableContext = tableContext; } isFirst = false; } else { formatState.isMultilineSelection = true; + if (tableContext && previousTableContext) { + const { table, colIndex, rowIndex } = previousTableContext; + + if ( + tableContext.table == table && + (tableContext.colIndex != colIndex || tableContext.rowIndex != rowIndex) + ) { + formatState.canMergeTableCell = true; + } + } + // Return true to stop iteration since we have already got everything we need return true; } @@ -99,7 +112,10 @@ function retrieveFormatStateInternal( if (tableContext) { retrieveTableFormat(tableContext, result); } + + // TODO: Support Code block in format state for Content Model } + function retrieveTableFormat(tableContext: TableSelectionContext, result: FormatState) { const tableFormat = updateTableMetadata(tableContext.table); diff --git a/packages/roosterjs-content-model/lib/modelApi/creators/createEntity.ts b/packages/roosterjs-content-model/lib/modelApi/creators/createEntity.ts index a4856e580ca..35ca4f87155 100644 --- a/packages/roosterjs-content-model/lib/modelApi/creators/createEntity.ts +++ b/packages/roosterjs-content-model/lib/modelApi/creators/createEntity.ts @@ -6,10 +6,10 @@ import { ContentModelSegmentFormat } from '../../publicTypes/format/ContentModel */ export function createEntity( wrapper: HTMLElement, + isReadonly: boolean, segmentFormat?: ContentModelSegmentFormat, id?: string, - type?: string, - isReadonly?: boolean + type?: string ): ContentModelEntity { return { segmentType: 'Entity', diff --git a/packages/roosterjs-content-model/lib/modelApi/list/setListType.ts b/packages/roosterjs-content-model/lib/modelApi/list/setListType.ts index 741625aca07..ad0dbf20762 100644 --- a/packages/roosterjs-content-model/lib/modelApi/list/setListType.ts +++ b/packages/roosterjs-content-model/lib/modelApi/list/setListType.ts @@ -35,6 +35,7 @@ export function setListType(model: ContentModelDocument, listType: 'OL' | 'UL') if (index >= 0) { const prevBlock = parent.blocks[index - 1]; + const segmentFormat = block.segments[0]?.format || {}; const newListItem = createListItem( [ { @@ -46,9 +47,16 @@ export function setListType(model: ContentModelDocument, listType: 'OL' | 'UL') prevBlock.levels[0]?.listType == 'OL') ? undefined : 1, + direction: block.format.direction, + textAlign: block.format.textAlign, }, ], - block.segments[0]?.format + // For list bullet, we only want to carry over these formats from segments: + { + fontFamily: segmentFormat.fontFamily, + fontSize: segmentFormat.fontSize, + textColor: segmentFormat.textColor, + } ); // Since there is only one paragraph under the list item, no need to keep its paragraph element (DIV). diff --git a/packages/roosterjs-content-model/lib/modelApi/selection/adjustWordSelection.ts b/packages/roosterjs-content-model/lib/modelApi/selection/adjustWordSelection.ts new file mode 100644 index 00000000000..593659dc581 --- /dev/null +++ b/packages/roosterjs-content-model/lib/modelApi/selection/adjustWordSelection.ts @@ -0,0 +1,143 @@ +import { ContentModelDocument } from '../../publicTypes/group/ContentModelDocument'; +import { ContentModelParagraph } from '../../publicTypes/block/ContentModelParagraph'; +import { ContentModelSegment } from '../../publicTypes/segment/ContentModelSegment'; +import { ContentModelText } from '../../publicTypes/segment/ContentModelText'; +import { createText } from '../creators/createText'; +import { iterateSelections } from '../../modelApi/selection/iterateSelections'; + +/** + * @internal + */ +export function adjustWordSelection( + model: ContentModelDocument, + marker: ContentModelSegment +): ContentModelSegment[] { + let markerBlock: ContentModelParagraph | undefined; + + iterateSelections([model], (path, tableContext, block, segments) => { + //Find the block with the selection marker + if (block?.blockType == 'Paragraph' && segments?.length == 1 && segments[0] == marker) { + markerBlock = block; + } + return true; + }); + + if (markerBlock) { + const segments: ContentModelSegment[] = []; + let markerSelectionIndex = markerBlock.segments.indexOf(marker); + for (let i = markerSelectionIndex - 1; i >= 0; i--) { + const currentSegment = markerBlock.segments[i]; + if (currentSegment.segmentType == 'Text') { + const found = findDelimiter(currentSegment, false /*moveRightward*/); + if (found > -1) { + if (found == currentSegment.text.length) { + break; + } + splitTextSegment(markerBlock.segments, currentSegment, i, found); + segments.push(markerBlock.segments[i + 1]); + break; + } else { + segments.push(markerBlock.segments[i]); + } + } else { + break; + } + } + markerSelectionIndex = markerBlock.segments.indexOf(marker); + segments.push(marker); + + // Marker is at start of word + if (segments.length <= 1) { + return segments; + } + + for (let i = markerSelectionIndex + 1; i < markerBlock.segments.length; i++) { + const currentSegment = markerBlock.segments[i]; + if (currentSegment.segmentType == 'Text') { + const found = findDelimiter(currentSegment, true /*moveRightward*/); + if (found > -1) { + if (found == 0) { + break; + } + splitTextSegment(markerBlock.segments, currentSegment, i, found); + segments.push(markerBlock.segments[i]); + break; + } else { + segments.push(markerBlock.segments[i]); + } + } else { + break; + } + } + + // Marker is at end of word + if (segments[segments.length - 1] == marker) { + return [marker]; + } + + return segments; + } else { + return [marker]; + } +} + +/* +// These are unicode characters mostly from the Category Space Separator (Zs) +https://unicode.org/Public/UNIDATA/Scripts.txt + +\u2000 = EN QUAD +\u2009 = THIN SPACE +\u200a = HAIR SPACE +​\u200b = ZERO WIDTH SPACE +​\u202f = NARROW NO-BREAK SPACE +\u205f​ = MEDIUM MATHEMATICAL SPACE +\u3000 = IDEOGRAPHIC SPACE +*/ +const SPACES_REGEX = /[\u2000\u2009\u200a​\u200b​\u202f\u205f​\u3000\s\t\r\n]/gm; +const PUNCTUATION_REGEX = /[.,?!:"()\[\]\\/]/gu; + +export function findDelimiter(segment: ContentModelText, moveRightward: boolean): number { + const word = segment.text; + let offset = -1; + if (moveRightward) { + for (let i = 0; i < word.length; i++) { + if (isWordDelimiter(word[i])) { + offset = i; + break; + } + } + } else { + for (let i = word.length - 1; i >= 0; i--) { + if (isWordDelimiter(word[i])) { + offset = i + 1; + break; + } + } + } + return offset; +} + +function splitTextSegment( + segments: ContentModelSegment[], + textSegment: ContentModelText, + index: number, + found: number +) { + const text = textSegment.text; + const newSegment = createText(text.substring(0, found), segments[index].format); + textSegment.text = text.substring(found, text.length); + segments.splice(index, 0, newSegment); +} + +function isWordDelimiter(char: string) { + return PUNCTUATION_REGEX.test(char) || isSpace(char); +} + +function isSpace(char: string) { + return ( + char && + (char.toString() == String.fromCharCode(160) /*   | \u00A0*/ || + char.toString() == String.fromCharCode(32) /* RegularSpace | \u0020*/ || + SPACES_REGEX.test(char)) + ); +} diff --git a/packages/roosterjs-content-model/lib/modelApi/selection/collectSelections.ts b/packages/roosterjs-content-model/lib/modelApi/selection/collectSelections.ts index 3cc565bfd5d..9df0fb4fecf 100644 --- a/packages/roosterjs-content-model/lib/modelApi/selection/collectSelections.ts +++ b/packages/roosterjs-content-model/lib/modelApi/selection/collectSelections.ts @@ -40,7 +40,10 @@ export function getSelectedSegments( selections.forEach(({ segments, block }) => { if (segments && ((includingFormatHolder && !block) || block?.blockType == 'Paragraph')) { - arrayPush(result, segments); + arrayPush( + result, + segments.filter(x => x.segmentType != 'Entity' || !x.isReadonly) + ); } }); diff --git a/packages/roosterjs-content-model/lib/modelApi/selection/iterateSelections.ts b/packages/roosterjs-content-model/lib/modelApi/selection/iterateSelections.ts index 23b35211c51..5204e38f8d7 100644 --- a/packages/roosterjs-content-model/lib/modelApi/selection/iterateSelections.ts +++ b/packages/roosterjs-content-model/lib/modelApi/selection/iterateSelections.ts @@ -141,7 +141,6 @@ export function iterateSelections( if (segments.length > 0 && callback(path, table, block, segments)) { return true; } - break; case 'Divider': diff --git a/packages/roosterjs-content-model/lib/publicApi/block/setDirection.ts b/packages/roosterjs-content-model/lib/publicApi/block/setDirection.ts index b1a0f9404f3..52cfe3f0dad 100644 --- a/packages/roosterjs-content-model/lib/publicApi/block/setDirection.ts +++ b/packages/roosterjs-content-model/lib/publicApi/block/setDirection.ts @@ -11,6 +11,23 @@ export default function setDirection( direction: 'ltr' | 'rtl' ) { formatParagraphWithContentModel(editor, 'setDirection', para => { - para.format.direction = direction; + const isOldValueRtl = para.format.direction == 'rtl'; + const isNewValueRtl = direction == 'rtl'; + + if (isOldValueRtl != isNewValueRtl) { + para.format.direction = direction; + + // Adjust margin when change direction + // TODO: make margin and padding direction-aware, like what we did for textAlign. So no need to adjust them here + // TODO: Do we also need to handle border here? + const marginLeft = para.format.marginLeft; + const paddingLeft = para.format.paddingLeft; + + para.format.marginLeft = para.format.marginRight; + para.format.marginRight = marginLeft; + + para.format.paddingLeft = para.format.paddingRight; + para.format.paddingRight = paddingLeft; + } }); } diff --git a/packages/roosterjs-content-model/lib/publicApi/utils/formatSegmentWithContentModel.ts b/packages/roosterjs-content-model/lib/publicApi/utils/formatSegmentWithContentModel.ts index 918f6ad878d..8e1e6548a34 100644 --- a/packages/roosterjs-content-model/lib/publicApi/utils/formatSegmentWithContentModel.ts +++ b/packages/roosterjs-content-model/lib/publicApi/utils/formatSegmentWithContentModel.ts @@ -1,3 +1,4 @@ +import { adjustWordSelection } from '../../modelApi/selection/adjustWordSelection'; import { ContentModelSegment } from '../../publicTypes/segment/ContentModelSegment'; import { ContentModelSegmentFormat } from '../../publicTypes/format/ContentModelSegmentFormat'; import { formatWithContentModel } from './formatWithContentModel'; @@ -6,7 +7,6 @@ import { DomToModelOption, IExperimentalContentModelEditor, } from '../../publicTypes/IExperimentalContentModelEditor'; - /** * @internal */ @@ -29,8 +29,18 @@ export function formatSegmentWithContentModel( editor, apiName, model => { - const segments = getSelectedSegments(model, !!includingFormatHolder); + let segments = getSelectedSegments(model, !!includingFormatHolder); const pendingFormat = editor.getPendingFormat(); + let isCollapsedSelection = + segments.length == 1 && segments[0].segmentType == 'SelectionMarker'; + + if (isCollapsedSelection) { + segments = adjustWordSelection(model, segments[0]); + if (segments.length > 1) { + isCollapsedSelection = false; + } + } + const formatsAndSegments: [ ContentModelSegmentFormat, ContentModelSegment | null @@ -48,9 +58,6 @@ export function formatSegmentWithContentModel( toggleStyleCallback(format, !isTurningOff, segment) ); - const isCollapsedSelection = - segments.length == 1 && segments[0].segmentType == 'SelectionMarker'; - if (!pendingFormat && isCollapsedSelection) { editor.setPendingFormat(segments[0].format); } diff --git a/packages/roosterjs-content-model/lib/publicTypes/context/EditorContext.ts b/packages/roosterjs-content-model/lib/publicTypes/context/EditorContext.ts index 4d2c0aafaf2..ef773d3ed3c 100644 --- a/packages/roosterjs-content-model/lib/publicTypes/context/EditorContext.ts +++ b/packages/roosterjs-content-model/lib/publicTypes/context/EditorContext.ts @@ -1,3 +1,5 @@ +import { DarkColorHandler } from 'roosterjs-editor-types'; + /** * An editor context interface used by ContentModel PAI */ @@ -23,4 +25,9 @@ export interface EditorContext { * @returns Dark mode color calculated from lightColor */ getDarkColor?: (lightColor: string) => string; + + /** + * Dark model color handler + */ + darkColorHandler?: DarkColorHandler | null; } diff --git a/packages/roosterjs-content-model/lib/publicTypes/entity/ContentModelEntity.ts b/packages/roosterjs-content-model/lib/publicTypes/entity/ContentModelEntity.ts index e03be4c06b8..5b6d899c8d9 100644 --- a/packages/roosterjs-content-model/lib/publicTypes/entity/ContentModelEntity.ts +++ b/packages/roosterjs-content-model/lib/publicTypes/entity/ContentModelEntity.ts @@ -14,6 +14,11 @@ export interface ContentModelEntity */ wrapper: HTMLElement; + /** + * Whether this is a readonly entity + */ + isReadonly: boolean; + /** * Type of this entity. Specified when insert an entity, can be an valid CSS class-like string. */ @@ -23,9 +28,4 @@ export interface ContentModelEntity * Id of this entity, generated by editor code and will be unique within an editor */ id?: string; - - /** - * Whether this is a readonly entity - */ - isReadonly?: boolean; } diff --git a/packages/roosterjs-content-model/lib/publicTypes/format/ContentModelListItemLevelFormat.ts b/packages/roosterjs-content-model/lib/publicTypes/format/ContentModelListItemLevelFormat.ts index 9edb3d82db8..cee08218cca 100644 --- a/packages/roosterjs-content-model/lib/publicTypes/format/ContentModelListItemLevelFormat.ts +++ b/packages/roosterjs-content-model/lib/publicTypes/format/ContentModelListItemLevelFormat.ts @@ -1,3 +1,4 @@ +import { DirectionFormat } from './formatParts/DirectionFormat'; import { ListMetadataFormat } from './formatParts/ListMetadataFormat'; import { ListThreadFormat } from './formatParts/ListThreadFormat'; import { ListTypeFormat } from './formatParts/ListTypeFormat'; @@ -7,4 +8,5 @@ import { ListTypeFormat } from './formatParts/ListTypeFormat'; */ export type ContentModelListItemLevelFormat = ListTypeFormat & ListThreadFormat & - ListMetadataFormat; + ListMetadataFormat & + DirectionFormat; diff --git a/packages/roosterjs-content-model/package.json b/packages/roosterjs-content-model/package.json index b2c5bb0ec9b..56c463c15ec 100644 --- a/packages/roosterjs-content-model/package.json +++ b/packages/roosterjs-content-model/package.json @@ -6,5 +6,5 @@ "roosterjs-editor-dom": "" }, "main": "./lib/index.ts", - "version": "0.0.13" + "version": "0.0.14" } diff --git a/packages/roosterjs-content-model/test/domToModel/processors/entityProcessorTest.ts b/packages/roosterjs-content-model/test/domToModel/processors/entityProcessorTest.ts index b562a478a49..5aa293622c1 100644 --- a/packages/roosterjs-content-model/test/domToModel/processors/entityProcessorTest.ts +++ b/packages/roosterjs-content-model/test/domToModel/processors/entityProcessorTest.ts @@ -19,8 +19,18 @@ describe('entityProcessor', () => { expect(group).toEqual({ blockGroupType: 'Document', - - blocks: [], + blocks: [ + // We now treat everything as entity as long as it is passed into entity processor + { + blockType: 'Entity', + segmentType: 'Entity', + format: {}, + id: undefined, + type: undefined, + isReadonly: true, + wrapper: div, + }, + ], }); }); @@ -103,8 +113,42 @@ describe('entityProcessor', () => { format: {}, id: undefined, type: undefined, - isReadonly: undefined, + isReadonly: true, + wrapper: span, + }, + ], + format: {}, + }, + ], + }); + }); + + it('Entity in selection', () => { + const group = createContentModelDocument(); + const span = document.createElement('span'); + + commitEntity(span, 'entity', true, 'entity_1'); + context.isInSelection = true; + + entityProcessor(group, span, context); + + expect(group).toEqual({ + blockGroupType: 'Document', + + blocks: [ + { + blockType: 'Paragraph', + isImplicit: true, + segments: [ + { + blockType: 'Entity', + segmentType: 'Entity', + format: {}, + id: 'entity_1', + type: 'entity', + isReadonly: true, wrapper: span, + isSelected: true, }, ], format: {}, diff --git a/packages/roosterjs-content-model/test/formatHandlers/utils/colorTest.ts b/packages/roosterjs-content-model/test/formatHandlers/utils/colorTest.ts index d52e7501c14..07e553e53b0 100644 --- a/packages/roosterjs-content-model/test/formatHandlers/utils/colorTest.ts +++ b/packages/roosterjs-content-model/test/formatHandlers/utils/colorTest.ts @@ -1,6 +1,6 @@ +import { DarkColorHandler } from 'roosterjs-editor-types'; import { getColor, setColor } from '../../../lib/formatHandlers/utils/color'; import { itChromeOnly } from 'roosterjs-editor-dom/test/DomTestHelper'; -import { ModeIndependentColor } from 'roosterjs-editor-types'; describe('getColor', () => { let div: HTMLElement; @@ -15,10 +15,10 @@ describe('getColor', () => { expectedDarkTextColor: string | undefined, expectedDarkBackColor: string | undefined ) { - const lightTextColor = getColor(div, false, false); - const lightBackColor = getColor(div, true, false); - const darkTextColor = getColor(div, false, true); - const darkBackColor = getColor(div, true, true); + const lightTextColor = getColor(div, false, null, false); + const lightBackColor = getColor(div, true, null, false); + const darkTextColor = getColor(div, false, null, true); + const darkBackColor = getColor(div, true, null, true); expect(lightTextColor).toBe(expectedLightTextColor); expect(lightBackColor).toBe(expectedLightBackColor); @@ -81,6 +81,96 @@ describe('getColor', () => { }); }); +describe('getColor with darkColorHandler', () => { + it('getColor from no color, light mode', () => { + const parseColorValue = jasmine.createSpy().and.returnValue({ + lightModeColor: 'green', + }); + const darkColorHandler = ({ parseColorValue } as any) as DarkColorHandler; + const div = document.createElement('div'); + const color = getColor(div, false, darkColorHandler, false); + + expect(color).toBe('green'); + expect(parseColorValue).toHaveBeenCalledTimes(1); + expect(parseColorValue).toHaveBeenCalledWith(undefined); + }); + + it('getColor from no color, dark mode', () => { + const parseColorValue = jasmine.createSpy().and.returnValue({ + lightModeColor: 'green', + }); + const darkColorHandler = ({ parseColorValue } as any) as DarkColorHandler; + const div = document.createElement('div'); + const color = getColor(div, false, darkColorHandler, true); + + expect(color).toBe('green'); + expect(parseColorValue).toHaveBeenCalledTimes(1); + expect(parseColorValue).toHaveBeenCalledWith(undefined); + }); + + it('getColor from style color, light mode', () => { + const parseColorValue = jasmine.createSpy().and.returnValue({ + lightModeColor: 'green', + }); + const darkColorHandler = ({ parseColorValue } as any) as DarkColorHandler; + const div = document.createElement('div'); + + div.style.color = 'red'; + div.setAttribute('color', 'blue'); + const color = getColor(div, false, darkColorHandler, true); + + expect(color).toBe('green'); + expect(parseColorValue).toHaveBeenCalledTimes(1); + expect(parseColorValue).toHaveBeenCalledWith('red'); + }); + + it('getColor from attr color, light mode', () => { + const parseColorValue = jasmine.createSpy().and.returnValue({ + lightModeColor: 'green', + }); + const darkColorHandler = ({ parseColorValue } as any) as DarkColorHandler; + const div = document.createElement('div'); + + div.setAttribute('color', 'blue'); + const color = getColor(div, false, darkColorHandler, true); + + expect(color).toBe('green'); + expect(parseColorValue).toHaveBeenCalledTimes(1); + expect(parseColorValue).toHaveBeenCalledWith('blue'); + }); + + it('getColor from attr color with var, light mode', () => { + const parseColorValue = jasmine.createSpy().and.returnValue({ + lightModeColor: 'green', + }); + const darkColorHandler = ({ parseColorValue } as any) as DarkColorHandler; + const div = document.createElement('div'); + + div.style.color = 'var(--varName, blue)'; + const color = getColor(div, false, darkColorHandler, true); + + expect(color).toBe('green'); + expect(parseColorValue).toHaveBeenCalledTimes(1); + expect(parseColorValue).toHaveBeenCalledWith('var(--varName, blue)'); + }); + + it('getColor from style color with data-ogsc, light mode', () => { + const parseColorValue = jasmine.createSpy().and.returnValue({ + lightModeColor: 'green', + }); + const darkColorHandler = ({ parseColorValue } as any) as DarkColorHandler; + const div = document.createElement('div'); + + div.dataset.ogsc = 'yellow'; + div.style.color = 'red'; + const color = getColor(div, false, darkColorHandler, true); + + expect(color).toBe('green'); + expect(parseColorValue).toHaveBeenCalledTimes(1); + expect(parseColorValue).toHaveBeenCalledWith('red'); + }); +}); + describe('setColor', () => { function getDarkColor(color: string) { // just a fake color @@ -88,18 +178,18 @@ describe('setColor', () => { } function runTest( - textColor: string | ModeIndependentColor, - backColor: string | ModeIndependentColor, + textColor: string, + backColor: string, expectedLightHtml: string, expectedDarkHtml: string ) { const lightDiv = document.createElement('div'); const darkDiv = document.createElement('div'); - setColor(lightDiv, textColor, false, false, getDarkColor); - setColor(lightDiv, backColor, true, false, getDarkColor); - setColor(darkDiv, textColor, false, true, getDarkColor); - setColor(darkDiv, backColor, true, true, getDarkColor); + setColor(lightDiv, textColor, false, null, false, getDarkColor); + setColor(lightDiv, backColor, true, null, false, getDarkColor); + setColor(darkDiv, textColor, false, null, true, getDarkColor); + setColor(darkDiv, backColor, true, null, true, getDarkColor); expect(lightDiv.outerHTML).toBe(expectedLightHtml); expect(darkDiv.outerHTML).toBe(expectedDarkHtml); @@ -117,19 +207,42 @@ describe('setColor', () => { '
' ); }); +}); - itChromeOnly('Mode independent color', () => { - runTest( - { - lightModeColor: '#aaa', - darkModeColor: '#bbb', - }, - { - lightModeColor: '#ccc', - darkModeColor: '#ddd', - }, - '
', - '
' - ); +describe('setColor with darkColorHandler', () => { + it('setColor from no color, light mode', () => { + const registerColor = jasmine.createSpy().and.returnValue('green'); + const darkColorHandler = ({ registerColor } as any) as DarkColorHandler; + const div = document.createElement('div'); + setColor(div, '', false, darkColorHandler, false); + + expect(div.outerHTML).toBe('
'); + expect(registerColor).toHaveBeenCalledTimes(1); + expect(registerColor).toHaveBeenCalledWith('', false); + }); + + it('setColor from no color, dark mode', () => { + const registerColor = jasmine.createSpy().and.returnValue('green'); + const darkColorHandler = ({ registerColor } as any) as DarkColorHandler; + const div = document.createElement('div'); + setColor(div, '', false, darkColorHandler, true); + + expect(div.outerHTML).toBe('
'); + expect(registerColor).toHaveBeenCalledTimes(1); + expect(registerColor).toHaveBeenCalledWith('', true); + }); + + itChromeOnly('setColor from a color with existing color, dark mode', () => { + const registerColor = jasmine.createSpy().and.returnValue('green'); + const darkColorHandler = ({ registerColor } as any) as DarkColorHandler; + const div = document.createElement('div'); + + div.style.color = 'blue'; + div.setAttribute('color', 'yellow'); + setColor(div, 'red', false, darkColorHandler, true); + + expect(div.outerHTML).toBe('
'); + expect(registerColor).toHaveBeenCalledTimes(1); + expect(registerColor).toHaveBeenCalledWith('red', true); }); }); diff --git a/packages/roosterjs-content-model/test/modelApi/block/setModelIndentationTest.ts b/packages/roosterjs-content-model/test/modelApi/block/setModelIndentationTest.ts index 2217f788cfb..95a948d0602 100644 --- a/packages/roosterjs-content-model/test/modelApi/block/setModelIndentationTest.ts +++ b/packages/roosterjs-content-model/test/modelApi/block/setModelIndentationTest.ts @@ -62,7 +62,6 @@ describe('indent', () => { blockType: 'Paragraph', format: { marginLeft: '40px', - marginRight: '40px', }, segments: [text2], }, @@ -104,7 +103,6 @@ describe('indent', () => { blockType: 'Paragraph', format: { marginLeft: '40px', - marginRight: '40px', }, segments: [text1], }, @@ -112,7 +110,6 @@ describe('indent', () => { blockType: 'Paragraph', format: { marginLeft: '80px', - marginRight: '80px', }, segments: [text2], }, @@ -120,7 +117,6 @@ describe('indent', () => { blockType: 'Paragraph', format: { marginLeft: '80px', - marginRight: '80px', }, segments: [text3], }, @@ -150,7 +146,7 @@ describe('indent', () => { { blockType: 'Paragraph', format: { - marginLeft: '120px', + marginLeft: '20px', marginRight: '120px', direction: 'rtl', }, @@ -193,7 +189,6 @@ describe('indent', () => { blockType: 'Paragraph', format: { marginLeft: '40px', - marginRight: '40px', }, segments: [text2], }, @@ -201,7 +196,6 @@ describe('indent', () => { blockType: 'Paragraph', format: { marginLeft: '40px', - marginRight: '40px', }, segments: [text3], }, @@ -237,7 +231,6 @@ describe('indent', () => { blockType: 'Paragraph', format: { marginLeft: '40px', - marginRight: '40px', }, segments: [text1], }, @@ -250,7 +243,6 @@ describe('indent', () => { blockType: 'Paragraph', format: { marginLeft: '40px', - marginRight: '40px', }, segments: [text3], }, @@ -539,7 +531,6 @@ describe('indent', () => { blockType: 'Paragraph', format: { marginLeft: '40px', - marginRight: '40px', }, segments: [text3], }, @@ -568,7 +559,6 @@ describe('indent', () => { blockType: 'Paragraph', format: { marginLeft: '75px', - marginRight: '75px', }, segments: [text1], }, @@ -670,7 +660,6 @@ describe('outdent', () => { blockType: 'Paragraph', format: { marginLeft: '0px', - marginRight: '0px', }, segments: [text2], }, @@ -724,7 +713,6 @@ describe('outdent', () => { blockType: 'Paragraph', format: { marginLeft: '0px', - marginRight: '0px', }, segments: [text2], }, @@ -732,7 +720,6 @@ describe('outdent', () => { blockType: 'Paragraph', format: { marginLeft: '40px', - marginRight: '40px', }, segments: [text3], }, @@ -775,7 +762,6 @@ describe('outdent', () => { blockType: 'Paragraph', format: { marginLeft: '0px', - marginRight: '0px', }, segments: [text1], }, @@ -790,7 +776,6 @@ describe('outdent', () => { blockType: 'Paragraph', format: { marginLeft: '80px', - marginRight: '80px', }, segments: [text3], }, @@ -895,7 +880,6 @@ describe('outdent', () => { blockType: 'Paragraph', format: { marginLeft: '0px', - marginRight: '0px', }, segments: [text2], }, @@ -903,7 +887,6 @@ describe('outdent', () => { blockType: 'Paragraph', format: { marginLeft: '40px', - marginRight: '40px', }, segments: [text3], }, @@ -933,7 +916,7 @@ describe('outdent', () => { { blockType: 'Paragraph', format: { - marginLeft: '40px', + marginLeft: '20px', marginRight: '40px', direction: 'rtl', }, @@ -964,7 +947,6 @@ describe('outdent', () => { blockType: 'Paragraph', format: { marginLeft: '45px', - marginRight: '45px', }, segments: [text1], }, diff --git a/packages/roosterjs-content-model/test/modelApi/common/retrieveModelFormatStateTest.ts b/packages/roosterjs-content-model/test/modelApi/common/retrieveModelFormatStateTest.ts index f5c037df3ab..c2749c0cf13 100644 --- a/packages/roosterjs-content-model/test/modelApi/common/retrieveModelFormatStateTest.ts +++ b/packages/roosterjs-content-model/test/modelApi/common/retrieveModelFormatStateTest.ts @@ -329,4 +329,47 @@ describe('retrieveModelFormatState', () => { canAddImageAltText: false, }); }); + + it('With single table cell selected', () => { + const model = createContentModelDocument(); + const result: FormatState = {}; + const cell1 = createTableCell(); + const cell2 = createTableCell(); + const cell3 = createTableCell(); + const table = createTable(1); + + cell2.isSelected = true; + table.cells[0] = [cell1, cell2, cell3]; + model.blocks.push(table); + + retrieveModelFormatState(model, null, result); + + expect(result).toEqual({ + isInTable: true, + tableHasHeader: false, + }); + }); + + it('With multiple table cell selected', () => { + const model = createContentModelDocument(); + const result: FormatState = {}; + const cell1 = createTableCell(); + const cell2 = createTableCell(); + const cell3 = createTableCell(); + const table = createTable(1); + + cell2.isSelected = true; + cell3.isSelected = true; + table.cells[0] = [cell1, cell2, cell3]; + model.blocks.push(table); + + retrieveModelFormatState(model, null, result); + + expect(result).toEqual({ + isInTable: true, + tableHasHeader: false, + isMultilineSelection: true, + canMergeTableCell: true, + }); + }); }); diff --git a/packages/roosterjs-content-model/test/modelApi/creators/creatorsTest.ts b/packages/roosterjs-content-model/test/modelApi/creators/creatorsTest.ts index df9a4cda1ae..5645d7d7b23 100644 --- a/packages/roosterjs-content-model/test/modelApi/creators/creatorsTest.ts +++ b/packages/roosterjs-content-model/test/modelApi/creators/creatorsTest.ts @@ -388,7 +388,7 @@ describe('Creators', () => { const type = 'entity'; const isReadonly = true; const wrapper = document.createElement('div'); - const entityModel = createEntity(wrapper, undefined, id, type, isReadonly); + const entityModel = createEntity(wrapper, isReadonly, undefined, id, type); expect(entityModel).toEqual({ blockType: 'Entity', @@ -408,12 +408,12 @@ describe('Creators', () => { const wrapper = document.createElement('div'); const entityModel = createEntity( wrapper, + isReadonly, { fontSize: '10pt', }, id, - type, - isReadonly + type ); expect(entityModel).toEqual({ diff --git a/packages/roosterjs-content-model/test/modelApi/list/setListTypeTest.ts b/packages/roosterjs-content-model/test/modelApi/list/setListTypeTest.ts index 7426a93c49a..eeb29de92fb 100644 --- a/packages/roosterjs-content-model/test/modelApi/list/setListTypeTest.ts +++ b/packages/roosterjs-content-model/test/modelApi/list/setListTypeTest.ts @@ -51,9 +51,24 @@ describe('indent', () => { { blockGroupType: 'ListItem', blockType: 'BlockGroup', - levels: [{ listType: 'OL', startNumberOverride: 1 }], + levels: [ + { + listType: 'OL', + startNumberOverride: 1, + direction: undefined, + textAlign: undefined, + }, + ], blocks: [para], - formatHolder: { segmentType: 'SelectionMarker', format: {}, isSelected: true }, + formatHolder: { + segmentType: 'SelectionMarker', + format: { + fontFamily: undefined, + fontSize: undefined, + textColor: undefined, + }, + isSelected: true, + }, format: {}, }, ], @@ -232,11 +247,22 @@ describe('indent', () => { { blockGroupType: 'ListItem', blockType: 'BlockGroup', - levels: [{ listType: 'OL', startNumberOverride: undefined }], + levels: [ + { + listType: 'OL', + startNumberOverride: undefined, + direction: undefined, + textAlign: undefined, + }, + ], blocks: [para3], formatHolder: { segmentType: 'SelectionMarker', - format: {}, + format: { + fontFamily: undefined, + fontSize: undefined, + textColor: undefined, + }, isSelected: true, }, format: {}, @@ -249,4 +275,80 @@ describe('indent', () => { }); expect(result).toBeTrue(); }); + + it('Carry over existing segment and direction format', () => { + const group = createContentModelDocument(); + const para = createParagraph(false, { + direction: 'rtl', + textAlign: 'start', + backgroundColor: 'yellow', + }); + const text = createText('test', { + fontFamily: 'Arial', + fontSize: '10px', + textColor: 'black', + backgroundColor: 'white', + fontWeight: 'bold', + italic: true, + underline: true, + }); + + para.segments.push(text); + group.blocks.push(para); + + text.isSelected = true; + + const result = setListType(group, 'OL'); + + expect(group).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockGroupType: 'ListItem', + blockType: 'BlockGroup', + levels: [ + { + listType: 'OL', + startNumberOverride: 1, + direction: 'rtl', + textAlign: 'start', + }, + ], + blocks: [para], + formatHolder: { + segmentType: 'SelectionMarker', + format: { + fontFamily: 'Arial', + fontSize: '10px', + textColor: 'black', + }, + isSelected: true, + }, + format: {}, + }, + ], + }); + expect(result).toBeTrue(); + expect(para).toEqual({ + blockType: 'Paragraph', + format: { direction: 'rtl', textAlign: 'start', backgroundColor: 'yellow' }, + isImplicit: true, + segments: [ + { + segmentType: 'Text', + text: 'test', + format: { + fontFamily: 'Arial', + fontSize: '10px', + textColor: 'black', + backgroundColor: 'white', + fontWeight: 'bold', + italic: true, + underline: true, + }, + isSelected: true, + }, + ], + }); + }); }); diff --git a/packages/roosterjs-content-model/test/modelApi/selection/adjustWordSelectionTest.ts b/packages/roosterjs-content-model/test/modelApi/selection/adjustWordSelectionTest.ts new file mode 100644 index 00000000000..e9a7fd7b914 --- /dev/null +++ b/packages/roosterjs-content-model/test/modelApi/selection/adjustWordSelectionTest.ts @@ -0,0 +1,778 @@ +import { adjustWordSelection } from '../../../lib/modelApi/selection/adjustWordSelection'; +import { ContentModelBlock } from '../../../lib/publicTypes/block/ContentModelBlock'; +import { ContentModelDocument } from '../../../lib/publicTypes/group/ContentModelDocument'; +import { ContentModelSegment } from '../../../lib/publicTypes/segment/ContentModelSegment'; + +const defaultMarker: ContentModelSegment = { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, +}; + +describe('adjustWordSelection', () => { + function runTest( + model: ContentModelDocument, + result: ContentModelSegment[], + modelResult: ContentModelDocument + ) { + const adjustedResult = adjustWordSelection(model, defaultMarker); + expect(adjustedResult).toEqual(result); + expect(model).toEqual(modelResult); + expect(adjustedResult).toContain(defaultMarker); + } + + describe('No format -', () => { + it('Adjust No Words', () => { + //'|' + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [defaultMarker], + }, + ], + }; + runTest(model, [defaultMarker], { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [defaultMarker], + }, + ], + }); + }); + + it('Adjust Spaces', () => { + //' | ' + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: ' ', + format: {}, + }, + defaultMarker, + { + segmentType: 'Text', + text: ' ', + format: {}, + }, + ], + }, + ], + }; + runTest(model, [defaultMarker], { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: ' ', + format: {}, + }, + defaultMarker, + { + segmentType: 'Text', + text: ' ', + format: {}, + }, + ], + }, + ], + }); + }); + + it('Adjust Single Word - Before', () => { + //'|Word' + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + defaultMarker, + { + segmentType: 'Text', + text: 'Word', + format: {}, + }, + ], + }, + ], + }; + runTest(model, [defaultMarker], { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + defaultMarker, + { + segmentType: 'Text', + text: 'Word', + format: {}, + }, + ], + }, + ], + }); + }); + + it('Adjust Single Word - Middle', () => { + //'Wo|rd' + const result: ContentModelSegment[] = [ + { + segmentType: 'Text', + text: 'Wo', + format: {}, + }, + defaultMarker, + { + segmentType: 'Text', + text: 'rd', + format: {}, + }, + ]; + runTest( + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: result, + }, + ], + }, + result, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: result, + }, + ], + } + ); + }); + + it('Adjust Single Word - After', () => { + //'Word|' + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'Word', + format: {}, + }, + defaultMarker, + ], + }, + ], + }; + runTest(model, [defaultMarker], { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'Word', + format: {}, + }, + defaultMarker, + ], + }, + ], + }); + }); + + it('Adjust Multiple Words', () => { + //'Subject Ve|rb Object' + const result: ContentModelSegment[] = [ + { + segmentType: 'Text', + text: 'Ve', + format: {}, + }, + defaultMarker, + { + segmentType: 'Text', + text: 'rb', + format: {}, + }, + ]; + runTest( + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'Subject Ve', + format: {}, + }, + defaultMarker, + { + segmentType: 'Text', + text: 'rb Object', + format: {}, + }, + ], + }, + ], + }, + result, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'Subject ', + format: {}, + }, + { + segmentType: 'Text', + text: 'Ve', + format: {}, + }, + defaultMarker, + { + segmentType: 'Text', + text: 'rb', + format: {}, + }, + { + segmentType: 'Text', + text: ' Object', + format: {}, + }, + ], + }, + ], + } + ); + }); + }); + + describe('Formatted -', () => { + it('Adjust Single Word - Before', () => { + //'|Word' + // 'Wo' and 'rd' have different formats + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + defaultMarker, + { + segmentType: 'Text', + text: 'Wo', + format: { fontFamily: 'family' }, + }, + { + segmentType: 'Text', + text: 'rd', + format: { backgroundColor: 'color' }, + }, + ], + }, + ], + }; + runTest(model, [defaultMarker], { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + defaultMarker, + { + segmentType: 'Text', + text: 'Wo', + format: { fontFamily: 'family' }, + }, + { + segmentType: 'Text', + text: 'rd', + format: { backgroundColor: 'color' }, + }, + ], + }, + ], + }); + }); + + it('Adjust Single Word - Middle', () => { + //'Wo|rd' + // 'Wo' and 'rd' have different formats + const result: ContentModelSegment[] = [ + { + segmentType: 'Text', + text: 'Wo', + format: { fontFamily: 'family' }, + }, + defaultMarker, + { + segmentType: 'Text', + text: 'rd', + format: { backgroundColor: 'color' }, + }, + ]; + runTest( + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: result, + }, + ], + }, + result, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: result, + }, + ], + } + ); + }); + + it('Adjust Single Word - After', () => { + //'Word|' + // 'Wo' and 'rd' have different formats + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'Wo', + format: { fontFamily: 'family' }, + }, + { + segmentType: 'Text', + text: 'rd', + format: { backgroundColor: 'color' }, + }, + defaultMarker, + ], + }, + ], + }; + runTest(model, [defaultMarker], { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'Wo', + format: { fontFamily: 'family' }, + }, + { + segmentType: 'Text', + text: 'rd', + format: { backgroundColor: 'color' }, + }, + defaultMarker, + ], + }, + ], + }); + }); + + it('Adjust Multiple Words', () => { + //'Subject Ve|rb Object' + // Every letter on Verb has different formats + runTest( + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'Subject ', + format: {}, + }, + { + segmentType: 'Text', + text: 'V', + format: { fontFamily: 'V_font' }, + }, + { + segmentType: 'Text', + text: 'e', + format: { backgroundColor: 'e_color' }, + }, + defaultMarker, + { + segmentType: 'Text', + text: 'r', + format: { textColor: 'r_color' }, + }, + { + segmentType: 'Text', + text: 'b', + format: { fontWeight: 'b_weight' }, + }, + { + segmentType: 'Text', + text: ' Object', + format: {}, + }, + ], + }, + ], + }, + [ + { + segmentType: 'Text', + text: 'e', + format: { backgroundColor: 'e_color' }, + }, + { + segmentType: 'Text', + text: 'V', + format: { fontFamily: 'V_font' }, + }, + defaultMarker, + { + segmentType: 'Text', + text: 'r', + format: { textColor: 'r_color' }, + }, + { + segmentType: 'Text', + text: 'b', + format: { fontWeight: 'b_weight' }, + }, + ], + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'Subject ', + format: {}, + }, + { + segmentType: 'Text', + text: 'V', + format: { fontFamily: 'V_font' }, + }, + { + segmentType: 'Text', + text: 'e', + format: { backgroundColor: 'e_color' }, + }, + defaultMarker, + { + segmentType: 'Text', + text: 'r', + format: { textColor: 'r_color' }, + }, + { + segmentType: 'Text', + text: 'b', + format: { fontWeight: 'b_weight' }, + }, + { + segmentType: 'Text', + text: ' Object', + format: {}, + }, + ], + }, + ], + } + ); + }); + }); + describe('Different blockGroupTypes -', () => { + const result: ContentModelSegment[] = [ + { + segmentType: 'Text', + text: 'Wo', + format: {}, + }, + defaultMarker, + { + segmentType: 'Text', + text: 'rd', + format: {}, + }, + ]; + + it('ListItem', () => { + //'Wo|rd' + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: result, + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'UL', + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + ], + }; + runTest(model, result, { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: result, + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'UL', + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + ], + }); + }); + + it('Table', () => { + //'Wo|rd' + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + cells: [ + [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: result, + format: {}, + isImplicit: true, + }, + ], + format: { + borderTop: '1px solid rgb(171, 171, 171)', + borderRight: '1px solid rgb(171, 171, 171)', + borderBottom: '1px solid rgb(171, 171, 171)', + borderLeft: '1px solid rgb(171, 171, 171)', + useBorderBox: true, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + ], + format: { + useBorderBox: true, + borderCollapse: true, + }, + widths: [120], + heights: [22], + dataset: { + editingInfo: + '{"topBorderColor":"#ABABAB","bottomBorderColor":"#ABABAB","verticalBorderColor":"#ABABAB","hasHeaderRow":false,"hasFirstColumn":false,"hasBandedRows":false,"hasBandedColumns":false,"bgColorEven":null,"bgColorOdd":"#ABABAB20","headerRowColor":"#ABABAB","tableBorderFormat":0}', + }, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + }; + runTest(model, result, { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + cells: [ + [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: result, + format: {}, + isImplicit: true, + }, + ], + format: { + borderTop: '1px solid rgb(171, 171, 171)', + borderRight: '1px solid rgb(171, 171, 171)', + borderBottom: '1px solid rgb(171, 171, 171)', + borderLeft: '1px solid rgb(171, 171, 171)', + useBorderBox: true, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + ], + format: { + useBorderBox: true, + borderCollapse: true, + }, + widths: [120], + heights: [22], + dataset: { + editingInfo: + '{"topBorderColor":"#ABABAB","bottomBorderColor":"#ABABAB","verticalBorderColor":"#ABABAB","hasHeaderRow":false,"hasFirstColumn":false,"hasBandedRows":false,"hasBandedColumns":false,"bgColorEven":null,"bgColorOdd":"#ABABAB20","headerRowColor":"#ABABAB","tableBorderFormat":0}', + }, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + }); + }); + }); + + describe('Multiple Blocks -', () => { + const emptyBlock: ContentModelBlock = { + blockType: 'Paragraph', + segments: [], + format: {}, + }; + const result: ContentModelSegment[] = [ + { + segmentType: 'Text', + text: 'Wo', + format: {}, + }, + defaultMarker, + { + segmentType: 'Text', + text: 'rd', + format: {}, + }, + ]; + + it('Second Block', () => { + //'Wo|rd' + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + emptyBlock, + { + blockType: 'Paragraph', + format: {}, + segments: result, + }, + emptyBlock, + ], + }; + runTest(model, result, { + blockGroupType: 'Document', + blocks: [ + emptyBlock, + { + blockType: 'Paragraph', + format: {}, + segments: result, + }, + emptyBlock, + ], + }); + }); + }); +}); diff --git a/packages/roosterjs-content-model/test/modelApi/selection/collectSelectionsTest.ts b/packages/roosterjs-content-model/test/modelApi/selection/collectSelectionsTest.ts index c8ea75b2ff0..8c5a9b6eeee 100644 --- a/packages/roosterjs-content-model/test/modelApi/selection/collectSelectionsTest.ts +++ b/packages/roosterjs-content-model/test/modelApi/selection/collectSelectionsTest.ts @@ -7,6 +7,7 @@ import { ContentModelSegment } from '../../../lib/publicTypes/segment/ContentMod import { ContentModelTable } from '../../../lib/publicTypes/block/ContentModelTable'; import { createContentModelDocument } from '../../../lib/modelApi/creators/createContentModelDocument'; import { createDivider } from '../../../lib/modelApi/creators/createDivider'; +import { createEntity } from '../../../lib/modelApi/creators/createEntity'; import { createListItem } from '../../../lib/modelApi/creators/createListItem'; import { createParagraph } from '../../../lib/modelApi/creators/createParagraph'; import { createQuote } from '../../../lib/modelApi/creators/createQuote'; @@ -163,6 +164,26 @@ describe('getSelectedSegments', () => { [m1, s2, s3, m2] ); }); + + it('Include editable entity, but filter out readonly entity', () => { + const e1 = createEntity(null!, true); + const e2 = createEntity(null!, false); + const p1 = createParagraph(); + + p1.segments.push(e1, e2); + + runTest( + [ + { + path: [], + block: p1, + segments: [e1, e2], + }, + ], + false, + [e2] + ); + }); }); describe('getSelectedParagraphs', () => { diff --git a/packages/roosterjs-content-model/test/modelApi/selection/iterateSelectionsTest.ts b/packages/roosterjs-content-model/test/modelApi/selection/iterateSelectionsTest.ts index 834efbc73f7..d1b645e2057 100644 --- a/packages/roosterjs-content-model/test/modelApi/selection/iterateSelectionsTest.ts +++ b/packages/roosterjs-content-model/test/modelApi/selection/iterateSelectionsTest.ts @@ -1,5 +1,6 @@ import { createContentModelDocument } from '../../../lib/modelApi/creators/createContentModelDocument'; import { createDivider } from '../../../lib/modelApi/creators/createDivider'; +import { createEntity } from '../../../lib/modelApi/creators/createEntity'; import { createGeneralSegment } from '../../../lib/modelApi/creators/createGeneralSegment'; import { createListItem } from '../../../lib/modelApi/creators/createListItem'; import { createParagraph } from '../../../lib/modelApi/creators/createParagraph'; @@ -812,4 +813,20 @@ describe('iterateSelections', () => { expect(callback).toHaveBeenCalledTimes(1); expect(callback).toHaveBeenCalledWith([list, doc], undefined, para, [text1, text2]); }); + + it('With selected entity', () => { + const doc = createContentModelDocument(); + const para = createParagraph(); + const entity = createEntity(null!, true); + + entity.isSelected = true; + + para.segments.push(entity); + doc.blocks.push(para); + + iterateSelections([doc], callback, { includeListFormatHolder: 'never' }); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith([doc], undefined, para, [entity]); + }); }); diff --git a/packages/roosterjs-content-model/test/modelToDom/handlers/handleEntityTest.ts b/packages/roosterjs-content-model/test/modelToDom/handlers/handleEntityTest.ts index 73f2da0938f..76374d5469a 100644 --- a/packages/roosterjs-content-model/test/modelToDom/handlers/handleEntityTest.ts +++ b/packages/roosterjs-content-model/test/modelToDom/handlers/handleEntityTest.ts @@ -42,6 +42,7 @@ describe('handleEntity', () => { segmentType: 'Entity', format: {}, wrapper: div, + isReadonly: true, }; div.textContent = 'test'; diff --git a/packages/roosterjs-content-model/test/publicApi/block/setDirectionTest.ts b/packages/roosterjs-content-model/test/publicApi/block/setDirectionTest.ts index fcc9d2eb794..3f8b6f47d09 100644 --- a/packages/roosterjs-content-model/test/publicApi/block/setDirectionTest.ts +++ b/packages/roosterjs-content-model/test/publicApi/block/setDirectionTest.ts @@ -99,6 +99,10 @@ describe('setDirection', () => { blockType: 'Paragraph', format: { direction: 'rtl', + marginLeft: undefined, + marginRight: undefined, + paddingLeft: undefined, + paddingRight: undefined, }, segments: [ { @@ -145,6 +149,10 @@ describe('setDirection', () => { blockType: 'Paragraph', format: { direction: 'rtl', + marginLeft: undefined, + marginRight: undefined, + paddingLeft: undefined, + paddingRight: undefined, }, segments: [ { @@ -254,6 +262,10 @@ describe('setDirection', () => { blockType: 'Paragraph', format: { direction: 'rtl', + marginLeft: undefined, + marginRight: undefined, + paddingLeft: undefined, + paddingRight: undefined, }, segments: [ { @@ -268,6 +280,10 @@ describe('setDirection', () => { blockType: 'Paragraph', format: { direction: 'rtl', + marginLeft: undefined, + marginRight: undefined, + paddingLeft: undefined, + paddingRight: undefined, }, segments: [ { @@ -369,6 +385,10 @@ describe('setDirection', () => { blockType: 'Paragraph', format: { direction: 'rtl', + marginLeft: undefined, + marginRight: undefined, + paddingLeft: undefined, + paddingRight: undefined, }, segments: [ { @@ -400,4 +420,123 @@ describe('setDirection', () => { 1 ); }); + + it('Swap margin and padding when direction changes', () => { + runTest( + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: { + marginLeft: '10px', + marginRight: '20px', + paddingLeft: '30px', + paddingRight: '40px', + }, + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: { + direction: 'rtl', + marginLeft: '20px', + marginRight: '10px', + paddingLeft: '40px', + paddingRight: '30px', + }, + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + }, + 1 + ); + }); + + it('Do not swap margin and padding when direction is not changed', () => { + runTest( + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: { + marginLeft: '10px', + marginRight: '20px', + paddingLeft: '30px', + paddingRight: '40px', + direction: 'rtl', + }, + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: { + direction: 'rtl', + marginLeft: '10px', + marginRight: '20px', + paddingLeft: '30px', + paddingRight: '40px', + }, + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + }, + 1 + ); + }); }); diff --git a/packages/roosterjs-editor-api/lib/format/getFormatState.ts b/packages/roosterjs-editor-api/lib/format/getFormatState.ts index 3a79e3eda1a..e5dd79eb4ce 100644 --- a/packages/roosterjs-editor-api/lib/format/getFormatState.ts +++ b/packages/roosterjs-editor-api/lib/format/getFormatState.ts @@ -5,6 +5,7 @@ import { IEditor, PluginEvent, QueryScope, + SelectionRangeTypes, } from 'roosterjs-editor-types'; /** @@ -48,9 +49,11 @@ export function getElementBasedFormatState( canUnlink: !!editor.queryElements('a[href]', QueryScope.OnSelection)[0], canAddImageAltText: !!editor.queryElements('img', QueryScope.OnSelection)[0], isBlockQuote: !!editor.queryElements('blockquote', QueryScope.OnSelection)[0], + isCodeBlock: !!editor.queryElements('pre>code', QueryScope.OnSelection)[0], isInTable: !!table, tableFormat: tableFormat, tableHasHeader: hasHeader, + canMergeTableCell: canMergeTableCell(editor), }; } @@ -74,3 +77,22 @@ export default function getFormatState(editor: IEditor, event?: PluginEvent): Fo zoomScale: editor.getZoomScale(), }; } + +/** + * Checks whether the editor selection range is starting and ending at a table element. + * @param editor Editor Instance + * @returns + */ + +const canMergeTableCell = (editor: IEditor): boolean => { + const selection = editor.getSelectionRangeEx(); + const isATable = selection && selection.type === SelectionRangeTypes.TableSelection; + if (isATable && selection.coordinates) { + const { firstCell, lastCell } = selection.coordinates; + if (firstCell.x !== lastCell.x || firstCell.y !== lastCell.y) { + return true; + } + return false; + } + return false; +}; diff --git a/packages/roosterjs-editor-api/lib/format/setBackgroundColor.ts b/packages/roosterjs-editor-api/lib/format/setBackgroundColor.ts index 0a10c44d75f..f0618a74931 100644 --- a/packages/roosterjs-editor-api/lib/format/setBackgroundColor.ts +++ b/packages/roosterjs-editor-api/lib/format/setBackgroundColor.ts @@ -16,7 +16,14 @@ export default function setBackgroundColor(editor: IEditor, color: string | Mode applyInlineStyle( editor, (element, isInnerNode) => { - setColor(element, isInnerNode ? '' : color, true /*isBackground*/, editor.isDarkMode()); + setColor( + element, + isInnerNode ? '' : color, + true /*isBackground*/, + editor.isDarkMode(), + false /*shouldAdaptFontColor*/, + editor.getDarkColorHandler() + ); }, 'setBackgroundColor' ); diff --git a/packages/roosterjs-editor-api/lib/format/setTextColor.ts b/packages/roosterjs-editor-api/lib/format/setTextColor.ts index 722566db78f..3b3398bed36 100644 --- a/packages/roosterjs-editor-api/lib/format/setTextColor.ts +++ b/packages/roosterjs-editor-api/lib/format/setTextColor.ts @@ -27,7 +27,9 @@ export default function setTextColor( element, isInnerNode ? '' : color, false /*isBackground*/, - editor.isDarkMode() + editor.isDarkMode(), + false /*shouldAdaptFontColor*/, + editor.getDarkColorHandler() ); } }, diff --git a/packages/roosterjs-editor-api/lib/table/applyCellShading.ts b/packages/roosterjs-editor-api/lib/table/applyCellShading.ts index bdb13872085..7a52246a058 100644 --- a/packages/roosterjs-editor-api/lib/table/applyCellShading.ts +++ b/packages/roosterjs-editor-api/lib/table/applyCellShading.ts @@ -20,7 +20,8 @@ export default function applyCellShading(editor: IEditor, color: string | ModeIn color, true /* isBackgroundColor */, editor.isDarkMode(), - true /** shouldAdaptFontColor */ + true /** shouldAdaptFontColor */, + editor.getDarkColorHandler() ); saveTableCellMetadata(region.rootNode, { bgColorOverride: true }); } diff --git a/packages/roosterjs-editor-api/lib/utils/execCommand.ts b/packages/roosterjs-editor-api/lib/utils/execCommand.ts index ec158837b1b..d016ecf29c4 100644 --- a/packages/roosterjs-editor-api/lib/utils/execCommand.ts +++ b/packages/roosterjs-editor-api/lib/utils/execCommand.ts @@ -1,65 +1,65 @@ -import formatUndoSnapshot from './formatUndoSnapshot'; -import { - DocumentCommand, - IEditor, - PluginEventType, - SelectionRangeTypes, -} from 'roosterjs-editor-types'; -import { getObjectKeys, PendableFormatCommandMap, PendableFormatNames } from 'roosterjs-editor-dom'; -import type { CompatibleDocumentCommand } from 'roosterjs-editor-types/lib/compatibleTypes'; - -/** - * @internal - * Execute a document command - * @param editor The editor instance - * @param command The command to execute - * @param addUndoSnapshotWhenCollapsed Optional, set to true to always add undo snapshot even current selection is collapsed. - * Default value is false. - * @param doWorkaroundForList Optional, set to true to do workaround for list in order to keep current format. - * Default value is false. - */ -export default function execCommand( - editor: IEditor, - command: DocumentCommand | CompatibleDocumentCommand, - apiName?: string -) { - editor.focus(); - - let formatter = () => editor.getDocument().execCommand(command, false, null); - - let selection = editor.getSelectionRangeEx(); - if (selection && selection.areAllCollapsed) { - editor.addUndoSnapshot(); - const formatState = editor.getPendableFormatState(false /* forceGetStateFromDom */); - formatter(); - const formatName = getObjectKeys(PendableFormatCommandMap).filter( - x => PendableFormatCommandMap[x] == command - )[0] as PendableFormatNames; - - if (formatName) { - formatState[formatName] = !formatState[formatName]; - editor.triggerPluginEvent(PluginEventType.PendingFormatStateChanged, { - formatState: formatState, - }); - } - } else { - formatUndoSnapshot( - editor, - () => { - const needToSwitchSelection = selection.type != SelectionRangeTypes.Normal; - - selection.ranges.forEach(range => { - if (needToSwitchSelection) { - editor.select(range); - } - formatter(); - }); - - if (needToSwitchSelection) { - editor.select(selection); - } - }, - apiName - ); - } -} +import formatUndoSnapshot from './formatUndoSnapshot'; +import { getObjectKeys, PendableFormatCommandMap, PendableFormatNames } from 'roosterjs-editor-dom'; +import { + DocumentCommand, + IEditor, + PluginEventType, + SelectionRangeTypes, +} from 'roosterjs-editor-types'; +import type { CompatibleDocumentCommand } from 'roosterjs-editor-types/lib/compatibleTypes'; + +/** + * @internal + * Execute a document command + * @param editor The editor instance + * @param command The command to execute + * @param addUndoSnapshotWhenCollapsed Optional, set to true to always add undo snapshot even current selection is collapsed. + * Default value is false. + * @param doWorkaroundForList Optional, set to true to do workaround for list in order to keep current format. + * Default value is false. + */ +export default function execCommand( + editor: IEditor, + command: DocumentCommand | CompatibleDocumentCommand, + apiName?: string +) { + editor.focus(); + + let formatter = () => editor.getDocument().execCommand(command, false, null); + + let selection = editor.getSelectionRangeEx(); + if (selection && selection.areAllCollapsed) { + editor.addUndoSnapshot(); + const formatState = editor.getPendableFormatState(false /* forceGetStateFromDom */); + formatter(); + const formatName = getObjectKeys(PendableFormatCommandMap).filter( + x => PendableFormatCommandMap[x] == command + )[0] as PendableFormatNames; + + if (formatName) { + formatState[formatName] = !formatState[formatName]; + editor.triggerPluginEvent(PluginEventType.PendingFormatStateChanged, { + formatState: formatState, + }); + } + } else { + formatUndoSnapshot( + editor, + () => { + const needToSwitchSelection = selection.type != SelectionRangeTypes.Normal; + + selection.ranges.forEach(range => { + if (needToSwitchSelection) { + editor.select(range); + } + formatter(); + }); + + if (needToSwitchSelection) { + editor.select(selection); + } + }, + apiName + ); + } +} diff --git a/packages/roosterjs-editor-core/lib/coreApi/ensureTypeInContainer.ts b/packages/roosterjs-editor-core/lib/coreApi/ensureTypeInContainer.ts index bc8e2d76242..aa8b5d85a76 100644 --- a/packages/roosterjs-editor-core/lib/coreApi/ensureTypeInContainer.ts +++ b/packages/roosterjs-editor-core/lib/coreApi/ensureTypeInContainer.ts @@ -85,7 +85,12 @@ export const ensureTypeInContainer: EnsureTypeInContainer = ( } if (formatNode && core.lifecycle.defaultFormat) { - applyFormat(formatNode, core.lifecycle.defaultFormat, core.lifecycle.isDarkMode); + applyFormat( + formatNode, + core.lifecycle.defaultFormat, + core.lifecycle.isDarkMode, + core.darkColorHandler + ); } // If this is triggered by a keyboard event, let's select the new position diff --git a/packages/roosterjs-editor-core/lib/coreApi/getContent.ts b/packages/roosterjs-editor-core/lib/coreApi/getContent.ts index a0f8684b7df..93fb9be8b82 100644 --- a/packages/roosterjs-editor-core/lib/coreApi/getContent.ts +++ b/packages/roosterjs-editor-core/lib/coreApi/getContent.ts @@ -37,7 +37,7 @@ export const getContent: GetContent = ( content = root.textContent; } else if (mode == GetContentMode.PlainText) { content = getTextContent(root); - } else if (triggerExtractContentEvent || core.lifecycle.isDarkMode) { + } else if (triggerExtractContentEvent || core.lifecycle.isDarkMode || core.darkColorHandler) { const clonedRoot = cloneNode(root); clonedRoot.normalize(); @@ -51,13 +51,14 @@ export const getContent: GetContent = ( : null; const range = path && createRange(clonedRoot, path.start, path.end); - if (core.lifecycle.isDarkMode) { + if (core.lifecycle.isDarkMode || core.darkColorHandler) { core.api.transformColor( core, clonedRoot, false /*includeSelf*/, null /*callback*/, - ColorTransformDirection.DarkToLight + ColorTransformDirection.DarkToLight, + !!core.darkColorHandler ); } diff --git a/packages/roosterjs-editor-core/lib/coreApi/getStyleBasedFormatState.ts b/packages/roosterjs-editor-core/lib/coreApi/getStyleBasedFormatState.ts index 15a5b516b3a..12417f3c03a 100644 --- a/packages/roosterjs-editor-core/lib/coreApi/getStyleBasedFormatState.ts +++ b/packages/roosterjs-editor-core/lib/coreApi/getStyleBasedFormatState.ts @@ -1,5 +1,10 @@ -import { DarkModeDatasetNames, EditorCore, GetStyleBasedFormatState } from 'roosterjs-editor-types'; -import { findClosestElementAncestor, getComputedStyles } from 'roosterjs-editor-dom'; +import { contains, findClosestElementAncestor, getComputedStyles } from 'roosterjs-editor-dom'; +import { + DarkModeDatasetNames, + EditorCore, + GetStyleBasedFormatState, + NodeType, +} from 'roosterjs-editor-types'; const ORIGINAL_STYLE_COLOR_SELECTOR = `[data-${DarkModeDatasetNames.OriginalStyleColor}],[data-${DarkModeDatasetNames.OriginalAttributeColor}]`; const ORIGINAL_STYLE_BACK_COLOR_SELECTOR = `[data-${DarkModeDatasetNames.OriginalStyleBackgroundColor}],[data-${DarkModeDatasetNames.OriginalAttributeBackgroundColor}]`; @@ -31,45 +36,96 @@ export const getStyleBasedFormatState: GetStyleBasedFormatState = ( } const styles = node ? getComputedStyles(node) : []; - const isDarkMode = core.lifecycle.isDarkMode; - const root = core.contentDiv; - const ogTextColorNode = - isDarkMode && - (override[2] - ? pendableFormatSpan - : findClosestElementAncestor(node, root, ORIGINAL_STYLE_COLOR_SELECTOR)); - const ogBackgroundColorNode = - isDarkMode && - (override[3] - ? pendableFormatSpan - : findClosestElementAncestor(node, root, ORIGINAL_STYLE_BACK_COLOR_SELECTOR)); + const { + contentDiv, + darkColorHandler, + lifecycle: { isDarkMode }, + } = core; - return { - fontName: override[0] || styles[0], - fontSize: override[1] || styles[1], - textColor: override[2] || styles[2], - backgroundColor: override[3] || styles[3], - textColors: ogTextColorNode - ? { - darkModeColor: override[2] || styles[2], - lightModeColor: - ogTextColorNode.dataset[DarkModeDatasetNames.OriginalStyleColor] || - ogTextColorNode.dataset[DarkModeDatasetNames.OriginalAttributeColor] || - styles[2], - } - : undefined, - backgroundColors: ogBackgroundColorNode - ? { - darkModeColor: override[3] || styles[3], - lightModeColor: - ogBackgroundColorNode.dataset[ - DarkModeDatasetNames.OriginalStyleBackgroundColor - ] || - ogBackgroundColorNode.dataset[ - DarkModeDatasetNames.OriginalAttributeBackgroundColor - ] || - styles[3], - } - : undefined, - }; + if (darkColorHandler) { + let styleTextColor: string | undefined; + let styleBackColor: string | undefined; + + while ( + node && + contains(contentDiv, node, true /*treatSameNodeAsContain*/) && + !(styleTextColor && styleBackColor) + ) { + if (node.nodeType == NodeType.Element) { + const element = node as HTMLElement; + + styleTextColor = styleTextColor || element.style.getPropertyValue('color'); + styleBackColor = + styleBackColor || element.style.getPropertyValue('background-color'); + } + node = node.parentNode; + } + + if (!core.lifecycle.isDarkMode && node == core.contentDiv) { + styleTextColor = styleTextColor || styles[2]; + styleBackColor = styleBackColor || styles[3]; + } + + const textColor = darkColorHandler.parseColorValue(override[2] || styleTextColor); + const backColor = darkColorHandler.parseColorValue(override[3] || styleBackColor); + + return { + fontName: override[0] || styles[0], + fontSize: override[1] || styles[1], + textColor: textColor.lightModeColor, + backgroundColor: backColor.lightModeColor, + textColors: textColor.darkModeColor + ? { + lightModeColor: textColor.lightModeColor, + darkModeColor: textColor.darkModeColor, + } + : undefined, + backgroundColors: backColor.darkModeColor + ? { + lightModeColor: backColor.lightModeColor, + darkModeColor: backColor.darkModeColor, + } + : undefined, + }; + } else { + const ogTextColorNode = + isDarkMode && + (override[2] + ? pendableFormatSpan + : findClosestElementAncestor(node, contentDiv, ORIGINAL_STYLE_COLOR_SELECTOR)); + const ogBackgroundColorNode = + isDarkMode && + (override[3] + ? pendableFormatSpan + : findClosestElementAncestor(node, contentDiv, ORIGINAL_STYLE_BACK_COLOR_SELECTOR)); + + return { + fontName: override[0] || styles[0], + fontSize: override[1] || styles[1], + textColor: override[2] || styles[2], + backgroundColor: override[3] || styles[3], + textColors: ogTextColorNode + ? { + darkModeColor: override[2] || styles[2], + lightModeColor: + ogTextColorNode.dataset[DarkModeDatasetNames.OriginalStyleColor] || + ogTextColorNode.dataset[DarkModeDatasetNames.OriginalAttributeColor] || + styles[2], + } + : undefined, + backgroundColors: ogBackgroundColorNode + ? { + darkModeColor: override[3] || styles[3], + lightModeColor: + ogBackgroundColorNode.dataset[ + DarkModeDatasetNames.OriginalStyleBackgroundColor + ] || + ogBackgroundColorNode.dataset[ + DarkModeDatasetNames.OriginalAttributeBackgroundColor + ] || + styles[3], + } + : undefined, + }; + } }; diff --git a/packages/roosterjs-editor-core/lib/coreApi/transformColor.ts b/packages/roosterjs-editor-core/lib/coreApi/transformColor.ts index 77df8ad58fe..6fab3025278 100644 --- a/packages/roosterjs-editor-core/lib/coreApi/transformColor.ts +++ b/packages/roosterjs-editor-core/lib/coreApi/transformColor.ts @@ -1,6 +1,7 @@ -import { arrayPush, safeInstanceOf, toArray } from 'roosterjs-editor-dom'; +import { arrayPush, safeInstanceOf, setColor, toArray } from 'roosterjs-editor-dom'; import { ColorTransformDirection, + DarkColorHandler, DarkModeDatasetNames, EditorCore, TransformColor, @@ -48,6 +49,7 @@ export const transformColor: TransformColor = ( direction: ColorTransformDirection | CompatibleColorTransformDirection, forceTransform?: boolean ) => { + const { darkColorHandler } = core; const elements = rootNode && (forceTransform || core.lifecycle.isDarkMode) ? getAll(rootNode, includeSelf) @@ -55,15 +57,41 @@ export const transformColor: TransformColor = ( callback?.(); - if (direction == ColorTransformDirection.DarkToLight) { - transformToLightMode(elements); - } else if (core.lifecycle.onExternalContentTransform) { - elements.forEach(element => core.lifecycle.onExternalContentTransform!(element)); + if (darkColorHandler) { + transformV2(elements, darkColorHandler, direction == ColorTransformDirection.LightToDark); } else { - transformToDarkMode(elements, core.lifecycle.getDarkColor); + if (direction == ColorTransformDirection.DarkToLight) { + transformToLightMode(elements); + } else if (core.lifecycle.onExternalContentTransform) { + elements.forEach(element => core.lifecycle.onExternalContentTransform!(element)); + } else { + transformToDarkMode(elements, core.lifecycle.getDarkColor); + } } }; +function transformV2(elements: HTMLElement[], darkColorHandler: DarkColorHandler, toDark: boolean) { + elements.forEach(element => { + ColorAttributeName.forEach((names, i) => { + const color = darkColorHandler.parseColorValue( + element.style.getPropertyValue(names[ColorAttributeEnum.CssColor]) || + element.getAttribute(names[ColorAttributeEnum.HtmlColor]) + ).lightModeColor; + + if (color && color != 'inherit') { + setColor( + element, + color, + i != 0, + toDark, + false /*shouldAdaptFontColor*/, + darkColorHandler + ); + } + }); + }); +} + function transformToLightMode(elements: HTMLElement[]) { elements.forEach(element => { ColorAttributeName.forEach(names => { diff --git a/packages/roosterjs-editor-core/lib/corePlugins/LifecyclePlugin.ts b/packages/roosterjs-editor-core/lib/corePlugins/LifecyclePlugin.ts index 7864530c9bd..da56310de91 100644 --- a/packages/roosterjs-editor-core/lib/corePlugins/LifecyclePlugin.ts +++ b/packages/roosterjs-editor-core/lib/corePlugins/LifecyclePlugin.ts @@ -85,8 +85,23 @@ export default class LifecyclePlugin implements PluginWithState { const { textColors, backgroundColors } = DARK_MODE_DEFAULT_FORMAT; const { isDarkMode } = this.state; - setColor(contentDiv, textColors, false /*isBackground*/, isDarkMode); - setColor(contentDiv, backgroundColors, true /*isBackground*/, isDarkMode); + const darkColorHandler = this.editor?.getDarkColorHandler(); + setColor( + contentDiv, + textColors, + false /*isBackground*/, + isDarkMode, + false /*shouldAdaptFontColor*/, + darkColorHandler + ); + setColor( + contentDiv, + backgroundColors, + true /*isBackground*/, + isDarkMode, + false /*shouldAdaptFontColor*/, + darkColorHandler + ); }; this.state = { diff --git a/packages/roosterjs-editor-core/lib/corePlugins/PendingFormatStatePlugin.ts b/packages/roosterjs-editor-core/lib/corePlugins/PendingFormatStatePlugin.ts index 240cf944d1e..da38d8a0fd2 100644 --- a/packages/roosterjs-editor-core/lib/corePlugins/PendingFormatStatePlugin.ts +++ b/packages/roosterjs-editor-core/lib/corePlugins/PendingFormatStatePlugin.ts @@ -1,5 +1,6 @@ import { isCharacterValue, Position, setColor } from 'roosterjs-editor-dom'; import { + ChangeSource, IEditor, Keys, NodePosition, @@ -109,7 +110,10 @@ export default class PendingFormatStatePlugin event.rawEvent.which <= Keys.DOWN) || (this.state.pendableFormatPosition && (currentPosition = this.getCurrentPosition()) && - !this.state.pendableFormatPosition.equalTo(currentPosition)) + !this.state.pendableFormatPosition.equalTo(currentPosition)) || + (event.eventType == PluginEventType.ContentChanged && + (event.source == ChangeSource.SwitchToDarkMode || + event.source == ChangeSource.SwitchToLightMode)) ) { // If content or position is changed (by keyboard, mouse, or code), // check if current position is still the same with the cached one (if exist), @@ -149,12 +153,16 @@ export default class PendingFormatStatePlugin span.style.setProperty('font-family', currentStyle.fontName ?? null); span.style.setProperty('font-size', currentStyle.fontSize ?? null); + const darkColorHandler = this.editor.getDarkColorHandler(); + if (currentStyle.textColors || currentStyle.textColor) { setColor( span, (currentStyle.textColors || currentStyle.textColor)!, false /*isBackground*/, - isDarkMode + isDarkMode, + false /*shouldAdaptFontColor*/, + darkColorHandler ); } @@ -163,7 +171,9 @@ export default class PendingFormatStatePlugin span, (currentStyle.backgroundColors || currentStyle.backgroundColor)!, true /*isBackground*/, - isDarkMode + isDarkMode, + false /*shouldAdaptFontColor*/, + darkColorHandler ); } } diff --git a/packages/roosterjs-editor-core/lib/editor/DarkColorHandlerImpl.ts b/packages/roosterjs-editor-core/lib/editor/DarkColorHandlerImpl.ts new file mode 100644 index 00000000000..f61ba14effe --- /dev/null +++ b/packages/roosterjs-editor-core/lib/editor/DarkColorHandlerImpl.ts @@ -0,0 +1,84 @@ +import { ColorKeyAndValue, DarkColorHandler, ModeIndependentColor } from 'roosterjs-editor-types'; +import { getObjectKeys } from 'roosterjs-editor-dom'; + +const VARIABLE_REGEX = /^\s*var\(\s*(\-\-[a-zA-Z0-9\-_]+)\s*(?:,\s*(.*))?\)\s*$/; +const VARIABLE_PREFIX = 'var('; +const COLOR_VAR_PREFIX = 'darkColor'; + +/** + * @internal + */ +export default class DarkColorHandlerImpl implements DarkColorHandler { + private knownColors: Record = {}; + + constructor(private contentDiv: HTMLElement, private getDarkColor: (color: string) => string) {} + + /** + * Given a light mode color value and an optional dark mode color value, register this color + * so that editor can handle it, then return the CSS color value for current color mode. + * @param lightModeColor Light mode color value + * @param isDarkMode Whether current color mode is dark mode + * @param darkModeColor Optional dark mode color value. If not passed, we will calculate one. + */ + registerColor(lightModeColor: string, isDarkMode: boolean, darkModeColor?: string): string { + const parsedColor = this.parseColorValue(lightModeColor); + let colorKey: string | undefined; + + if (parsedColor) { + lightModeColor = parsedColor.lightModeColor; + darkModeColor = parsedColor.darkModeColor || darkModeColor; + colorKey = parsedColor.key; + } + + if (isDarkMode && lightModeColor) { + colorKey = + colorKey || `--${COLOR_VAR_PREFIX}_${lightModeColor.replace(/[^\d\w]/g, '_')}`; + + if (!this.knownColors[colorKey]) { + darkModeColor = darkModeColor || this.getDarkColor(lightModeColor); + + this.knownColors[colorKey] = { lightModeColor, darkModeColor }; + this.contentDiv.style.setProperty(colorKey, darkModeColor); + } + + return `var(${colorKey}, ${lightModeColor})`; + } else { + return lightModeColor; + } + } + + /** + * Reset known color record, clean up registered color variables. + */ + reset(): void { + getObjectKeys(this.knownColors).forEach(key => this.contentDiv.style.removeProperty(key)); + this.knownColors = {}; + } + + /** + * Parse an existing color value, if it is in variable-based color format, extract color key, + * light color and query related dark color if any + * @param color The color string to parse + */ + parseColorValue(color: string | undefined | null): ColorKeyAndValue { + let key: string | undefined; + let lightModeColor = color || ''; + let darkModeColor: string | undefined; + + if (color) { + const match = color.startsWith(VARIABLE_PREFIX) ? VARIABLE_REGEX.exec(color) : null; + + if (match) { + if (match[2]) { + key = match[1]; + lightModeColor = match[2]; + darkModeColor = this.knownColors[key]?.darkModeColor; + } else { + lightModeColor = ''; + } + } + } + + return { key, lightModeColor, darkModeColor }; + } +} diff --git a/packages/roosterjs-editor-core/lib/editor/Editor.ts b/packages/roosterjs-editor-core/lib/editor/Editor.ts index 78ceab37b2f..a07b5ceb45a 100644 --- a/packages/roosterjs-editor-core/lib/editor/Editor.ts +++ b/packages/roosterjs-editor-core/lib/editor/Editor.ts @@ -1,4 +1,5 @@ import createCorePlugins, { getPluginState } from '../corePlugins/createCorePlugins'; +import DarkColorHandlerImpl from './DarkColorHandlerImpl'; import { coreApiMap } from '../coreApi/coreApiMap'; import { BlockElement, @@ -7,6 +8,7 @@ import { ColorTransformDirection, ContentChangedData, ContentPosition, + DarkColorHandler, DefaultFormat, DOMEventHandler, EditorCore, @@ -132,6 +134,13 @@ export default class Editor implements IEditor { imageSelectionBorderColor: options.imageSelectionBorderColor, }; + if (this.isFeatureEnabled(ExperimentalFeatures.VariableBasedDarkColor)) { + this.core.darkColorHandler = new DarkColorHandlerImpl( + contentDiv, + this.core.lifecycle.getDarkColor + ); + } + // 3. Initialize plugins this.core.plugins.forEach(plugin => plugin.initialize(this)); @@ -150,6 +159,8 @@ export default class Editor implements IEditor { core.plugins[i].dispose(); } + core.darkColorHandler?.reset(); + this.core = null; } @@ -1037,6 +1048,13 @@ export default class Editor implements IEditor { ); } + /** + * Get a darkColorHandler object for this editor. It will return null if experimental feature "VariableBasedDarkColor" is not enabled + */ + public getDarkColorHandler(): DarkColorHandler | null { + return this.getCore().darkColorHandler || null; + } + /** * Make the editor in "Shadow Edit" mode. * In Shadow Edit mode, all format change will finally be ignored. diff --git a/packages/roosterjs-editor-core/test/coreApi/getStyleBasedFormatStateTest.ts b/packages/roosterjs-editor-core/test/coreApi/getStyleBasedFormatStateTest.ts index b50cecb77d8..0914e012801 100644 --- a/packages/roosterjs-editor-core/test/coreApi/getStyleBasedFormatStateTest.ts +++ b/packages/roosterjs-editor-core/test/coreApi/getStyleBasedFormatStateTest.ts @@ -1,4 +1,5 @@ import createEditorCore from './createMockEditorCore'; +import { DarkColorHandler } from 'roosterjs-editor-types'; import { getStyleBasedFormatState } from '../../lib/coreApi/getStyleBasedFormatState'; describe('getStyleBasedFormatState', () => { @@ -101,3 +102,85 @@ describe('getStyleBasedFormatState', () => { }); }); }); + +describe('getStyleBasedFormatState with var based color', () => { + let div: HTMLDivElement; + beforeEach(() => { + div = document.createElement('div'); + document.body.appendChild(div); + }); + + afterEach(() => { + document.body.removeChild(div); + div = null; + }); + + it('light mode', () => { + const core = createEditorCore(div, {}); + + core.darkColorHandler = ({ + parseColorValue: (color: string) => ({ + lightModeColor: color == 'black' ? 'green' : color == 'white' ? 'yellow' : 'brown', + darkModeColor: color == 'black' ? 'blue' : color == 'white' ? 'red' : 'gray', + }), + } as any) as DarkColorHandler; + + div.innerHTML = + '
test
'; + const node = document.getElementById('div1'); + const style = getStyleBasedFormatState(core, node); + expect(style.fontName).toBe('arial'); + expect(style.fontSize).toBe('12pt'); + expect(style.textColor).toBe('green'); + expect(style.backgroundColor).toBe('yellow'); + expect(style.textColors).toEqual({ + lightModeColor: 'green', + darkModeColor: 'blue', + }); + expect(style.backgroundColors).toEqual({ + lightModeColor: 'yellow', + darkModeColor: 'red', + }); + }); + + it('dark mode, no color node', () => { + const core = createEditorCore(div, { inDarkMode: true }); + core.darkColorHandler = ({ + parseColorValue: (color: string) => ({ + lightModeColor: color == 'black' ? 'green' : color == 'white' ? 'yellow' : 'brown', + darkModeColor: color == 'black' ? 'blue' : color == 'white' ? 'red' : 'gray', + }), + } as any) as DarkColorHandler; + + div.innerHTML = + '
test
'; + const node = document.getElementById('div1'); + const style = getStyleBasedFormatState(core, node); + expect(style.fontName).toBe('arial'); + expect(style.fontSize).toBe('12pt'); + expect(style.textColor).toBe('green'); + expect(style.backgroundColor).toBe('yellow'); + expect(style.textColors).toEqual({ + lightModeColor: 'green', + darkModeColor: 'blue', + }); + expect(style.backgroundColors).toEqual({ + lightModeColor: 'yellow', + darkModeColor: 'red', + }); + }); + + it('dark mode, no color', () => { + const core = createEditorCore(div, { inDarkMode: true }); + div.innerHTML = + '
test
'; + const node = document.getElementById('div1'); + const style = getStyleBasedFormatState(core, node); + expect(style.fontName).toBe('arial'); + expect(style.fontSize).toBe('12pt'); + expect(style.textColor).toBe('rgb(0, 0, 0)'); + expect(style.backgroundColor).toBe('rgba(0, 0, 0, 0)'); + expect(style.textColors).toBeUndefined(); + expect(style.backgroundColors).toBeUndefined(); + }); +}); diff --git a/packages/roosterjs-editor-core/test/coreApi/transformColorTest.ts b/packages/roosterjs-editor-core/test/coreApi/transformColorTest.ts index 3320c727151..8ebfcfaef5e 100644 --- a/packages/roosterjs-editor-core/test/coreApi/transformColorTest.ts +++ b/packages/roosterjs-editor-core/test/coreApi/transformColorTest.ts @@ -1,7 +1,7 @@ import createEditorCore from './createMockEditorCore'; -import { ColorTransformDirection } from 'roosterjs-editor-types'; +import { ColorTransformDirection, DarkColorHandler } from 'roosterjs-editor-types'; import { getDarkColor } from 'roosterjs-color-utils'; -import { itFirefoxOnly } from '../TestHelper'; +import { itChromeOnly, itFirefoxOnly } from '../TestHelper'; import { transformColor } from '../../lib/coreApi/transformColor'; describe('transformColor Dark to light', () => { @@ -230,3 +230,213 @@ describe('transformColor Light to dark', () => { ); }); }); + +describe('transform to dark mode v2', () => { + let div: HTMLDivElement; + + beforeEach(() => { + div = document.createElement('div'); + document.body.appendChild(div); + }); + + afterEach(() => { + document.body.removeChild(div); + div = null; + }); + + function runTest( + element: HTMLElement, + expectedHtml: string, + expectedParseValueCalls: string[], + expectedRegisterColorCalls: [string, boolean, string][] + ) { + const core = createEditorCore(div, { + inDarkMode: true, + getDarkColor, + }); + const parseColorValue = jasmine + .createSpy('parseColorValue') + .and.callFake((color: string) => ({ + lightModeColor: color == 'red' ? 'blue' : color == 'green' ? 'yellow' : '', + })); + const registerColor = jasmine + .createSpy('registerColor') + .and.callFake((color: string) => color); + + core.darkColorHandler = ({ parseColorValue, registerColor } as any) as DarkColorHandler; + + transformColor(core, element, true, null, ColorTransformDirection.LightToDark); + + expect(element.outerHTML).toBe(expectedHtml); + expect(parseColorValue).toHaveBeenCalledTimes(expectedParseValueCalls.length); + expect(registerColor).toHaveBeenCalledTimes(expectedRegisterColorCalls.length); + + expectedParseValueCalls.forEach(v => { + expect(parseColorValue).toHaveBeenCalledWith(v); + }); + expectedRegisterColorCalls.forEach(v => { + expect(registerColor).toHaveBeenCalledWith(...v); + }); + } + + it('no color', () => { + const element = document.createElement('div'); + + runTest(element, '
', [null!, null!], []); + }); + + it('has style colors', () => { + const element = document.createElement('div'); + element.style.color = 'red'; + element.style.backgroundColor = 'green'; + + runTest( + element, + '
', + ['red', 'green'], + [ + ['blue', true, undefined!], + ['yellow', true, undefined!], + ] + ); + }); + + it('has attribute colors', () => { + const element = document.createElement('div'); + element.setAttribute('color', 'red'); + element.setAttribute('bgcolor', 'green'); + + runTest( + element, + '
', + ['red', 'green'], + [ + ['blue', true, undefined!], + ['yellow', true, undefined!], + ] + ); + }); + + itChromeOnly('has both css and attribute colors', () => { + const element = document.createElement('div'); + element.style.color = 'red'; + element.style.backgroundColor = 'green'; + element.setAttribute('color', 'gray'); + element.setAttribute('bgcolor', 'brown'); + + runTest( + element, + '
', + ['red', 'green'], + [ + ['blue', true, undefined!], + ['yellow', true, undefined!], + ] + ); + }); +}); + +describe('transform to lgiht mode v2', () => { + let div: HTMLDivElement; + + beforeEach(() => { + div = document.createElement('div'); + document.body.appendChild(div); + }); + + afterEach(() => { + document.body.removeChild(div); + div = null; + }); + + function runTest( + element: HTMLElement, + expectedHtml: string, + expectedParseValueCalls: string[], + expectedRegisterColorCalls: [string, boolean, string][] + ) { + const core = createEditorCore(div, { + inDarkMode: true, + getDarkColor, + }); + const parseColorValue = jasmine + .createSpy('parseColorValue') + .and.callFake((color: string) => ({ + lightModeColor: color == 'red' ? 'blue' : color == 'green' ? 'yellow' : '', + })); + const registerColor = jasmine + .createSpy('registerColor') + .and.callFake((color: string) => color); + + core.darkColorHandler = ({ parseColorValue, registerColor } as any) as DarkColorHandler; + + transformColor(core, element, true, null, ColorTransformDirection.DarkToLight); + + expect(element.outerHTML).toBe(expectedHtml); + expect(parseColorValue).toHaveBeenCalledTimes(expectedParseValueCalls.length); + expect(registerColor).toHaveBeenCalledTimes(expectedRegisterColorCalls.length); + + expectedParseValueCalls.forEach(v => { + expect(parseColorValue).toHaveBeenCalledWith(v); + }); + expectedRegisterColorCalls.forEach(v => { + expect(registerColor).toHaveBeenCalledWith(...v); + }); + } + + it('no color', () => { + const element = document.createElement('div'); + + runTest(element, '
', [null!, null!], []); + }); + + it('has style colors', () => { + const element = document.createElement('div'); + element.style.color = 'red'; + element.style.backgroundColor = 'green'; + + runTest( + element, + '
', + ['red', 'green'], + [ + ['blue', false, undefined!], + ['yellow', false, undefined!], + ] + ); + }); + + it('has attribute colors', () => { + const element = document.createElement('div'); + element.setAttribute('color', 'red'); + element.setAttribute('bgcolor', 'green'); + + runTest( + element, + '
', + ['red', 'green'], + [ + ['blue', false, undefined!], + ['yellow', false, undefined!], + ] + ); + }); + + itChromeOnly('has both css and attribute colors', () => { + const element = document.createElement('div'); + element.style.color = 'red'; + element.style.backgroundColor = 'green'; + element.setAttribute('color', 'gray'); + element.setAttribute('bgcolor', 'brown'); + + runTest( + element, + '
', + ['red', 'green'], + [ + ['blue', false, undefined!], + ['yellow', false, undefined!], + ] + ); + }); +}); diff --git a/packages/roosterjs-editor-core/test/corePlugins/lifecyclePluginTest.ts b/packages/roosterjs-editor-core/test/corePlugins/lifecyclePluginTest.ts index ea0131c99ac..a3ec8991da5 100644 --- a/packages/roosterjs-editor-core/test/corePlugins/lifecyclePluginTest.ts +++ b/packages/roosterjs-editor-core/test/corePlugins/lifecyclePluginTest.ts @@ -1,5 +1,11 @@ import LifecyclePlugin from '../../lib/corePlugins/LifecyclePlugin'; -import { ChangeSource, IEditor, NodePosition, PluginEventType } from 'roosterjs-editor-types'; +import { + ChangeSource, + IEditor, + NodePosition, + PluginEventType, + DarkColorHandler, +} from 'roosterjs-editor-types'; describe('LifecyclePlugin', () => { const getDarkColor = (color: string) => color; @@ -13,6 +19,7 @@ describe('LifecyclePlugin', () => { triggerPluginEvent, setContent: (content: string) => (div.innerHTML = content), getFocusedPosition: () => null, + getDarkColorHandler: () => null, })); expect(state.defaultFormat.textColor).toBe(''); @@ -73,6 +80,7 @@ describe('LifecyclePlugin', () => { triggerPluginEvent, setContent: (content: string) => (div.innerHTML = content), getFocusedPosition: () => null, + getDarkColorHandler: () => null, })); expect(state).toEqual({ @@ -119,6 +127,7 @@ describe('LifecyclePlugin', () => { triggerPluginEvent, setContent: (content: string) => (div.innerHTML = content), getFocusedPosition: () => null, + getDarkColorHandler: () => null, })); expect(div.isContentEditable).toBeTrue(); @@ -140,6 +149,7 @@ describe('LifecyclePlugin', () => { triggerPluginEvent, setContent: (content: string) => (div.innerHTML = content), getFocusedPosition: () => null, + getDarkColorHandler: () => null, })); expect(div.isContentEditable).toBeFalse(); @@ -180,6 +190,7 @@ describe('recalculateDefaultFormat', () => { setContent: () => {}, triggerPluginEvent: () => {}, getFocusedPosition: () => null, + getDarkColorHandler: () => null, })); expect(plugin.getState().defaultFormat).toEqual({ @@ -201,6 +212,7 @@ describe('recalculateDefaultFormat', () => { setContent: () => {}, triggerPluginEvent: () => {}, getFocusedPosition: () => null, + getDarkColorHandler: () => null, })); // First time it initials the default format @@ -255,6 +267,7 @@ describe('recalculateDefaultFormat', () => { setContent: () => {}, triggerPluginEvent: () => {}, getFocusedPosition: () => null, + getDarkColorHandler: () => null, })); expect(plugin.getState().defaultFormat).toEqual({ @@ -285,6 +298,7 @@ describe('recalculateDefaultFormat', () => { setContent: () => {}, triggerPluginEvent: () => {}, getFocusedPosition: () => null, + getDarkColorHandler: () => null, })); expect(plugin.getState().defaultFormat).toEqual({ @@ -314,6 +328,7 @@ describe('recalculateDefaultFormat', () => { setContent: () => {}, triggerPluginEvent: () => {}, getFocusedPosition: () => null, + getDarkColorHandler: () => null, })); expect(plugin.getState().defaultFormat).toEqual({}); diff --git a/packages/roosterjs-editor-core/test/editor/DarkColorHandlerImplTest.ts b/packages/roosterjs-editor-core/test/editor/DarkColorHandlerImplTest.ts new file mode 100644 index 00000000000..7bbaa095437 --- /dev/null +++ b/packages/roosterjs-editor-core/test/editor/DarkColorHandlerImplTest.ts @@ -0,0 +1,236 @@ +import DarkColorHandlerImpl from '../../lib/editor/DarkColorHandlerImpl'; +import { ColorKeyAndValue } from 'roosterjs-editor-types'; + +describe('DarkColorHandlerImpl.parseColorValue', () => { + function getDarkColor(color: string) { + return color + color; + } + + let div: HTMLElement; + let handler: DarkColorHandlerImpl; + + beforeEach(() => { + div = document.createElement('div'); + handler = new DarkColorHandlerImpl(div, getDarkColor); + }); + + function runTest(input: string, expectedOutput: ColorKeyAndValue) { + const result = handler.parseColorValue(input); + + expect(result).toEqual(expectedOutput); + } + + it('empty color', () => { + runTest(null!, { + key: undefined, + lightModeColor: '', + darkModeColor: undefined, + }); + }); + + it('simple color', () => { + runTest('aa', { + key: undefined, + lightModeColor: 'aa', + darkModeColor: undefined, + }); + }); + + it('var color without fallback', () => { + runTest('var(--bb)', { + key: undefined, + lightModeColor: '', + darkModeColor: undefined, + }); + }); + + it('var color with fallback', () => { + runTest('var(--bb,cc)', { + key: '--bb', + lightModeColor: 'cc', + darkModeColor: undefined, + }); + }); + + it('var color with fallback, has dark color', () => { + (handler as any).knownColors = { + '--bb': { + lightModeColor: 'dd', + darkModeColor: 'ee', + }, + }; + runTest('var(--bb,cc)', { + key: '--bb', + lightModeColor: 'cc', + darkModeColor: 'ee', + }); + }); +}); + +describe('DarkColorHandlerImpl.registerColor', () => { + function getDarkColor(color: string) { + return color + color; + } + + let setProperty: jasmine.Spy; + let handler: DarkColorHandlerImpl; + + beforeEach(() => { + setProperty = jasmine.createSpy('setProperty'); + const div = ({ + style: { + setProperty, + }, + } as any) as HTMLElement; + handler = new DarkColorHandlerImpl(div, getDarkColor); + }); + + function runTest( + input: string, + isDark: boolean, + darkColor: string | undefined, + expectedOutput: string, + expectedKnownColors: Record, + expectedSetPropertyCalls: [string, string][] + ) { + const result = handler.registerColor(input, isDark, darkColor); + + expect(result).toEqual(expectedOutput); + expect((handler as any).knownColors).toEqual(expectedKnownColors); + expect(setProperty).toHaveBeenCalledTimes(expectedSetPropertyCalls.length); + + expectedSetPropertyCalls.forEach(v => { + expect(setProperty).toHaveBeenCalledWith(...v); + }); + } + + it('empty color, light mode', () => { + runTest('', false, undefined, '', {}, []); + }); + + it('simple color, light mode', () => { + runTest('red', false, undefined, 'red', {}, []); + }); + + it('empty color, dark mode', () => { + runTest('', true, undefined, '', {}, []); + }); + + it('simple color, dark mode', () => { + runTest( + 'red', + true, + undefined, + 'var(--darkColor_red, red)', + { + '--darkColor_red': { + lightModeColor: 'red', + darkModeColor: 'redred', + }, + }, + [['--darkColor_red', 'redred']] + ); + }); + + it('simple color, dark mode, with dark color', () => { + runTest( + 'red', + true, + 'blue', + 'var(--darkColor_red, red)', + { + '--darkColor_red': { + lightModeColor: 'red', + darkModeColor: 'blue', + }, + }, + [['--darkColor_red', 'blue']] + ); + }); + + it('var color, light mode', () => { + runTest('var(--aa, bb)', false, undefined, 'bb', {}, []); + }); + + it('var color, dark mode', () => { + runTest( + 'var(--aa, bb)', + true, + undefined, + 'var(--aa, bb)', + { + '--aa': { + lightModeColor: 'bb', + darkModeColor: 'bbbb', + }, + }, + [['--aa', 'bbbb']] + ); + }); + + it('var color, dark mode with dark color', () => { + runTest( + 'var(--aa, bb)', + true, + 'cc', + 'var(--aa, bb)', + { + '--aa': { + lightModeColor: 'bb', + darkModeColor: 'cc', + }, + }, + [['--aa', 'cc']] + ); + }); + + it('var color, dark mode with dark color and existing dark color', () => { + (handler as any).knownColors['--aa'] = { + lightModeColor: 'dd', + darkModeColor: 'ee', + }; + runTest( + 'var(--aa, bb)', + true, + 'cc', + 'var(--aa, bb)', + { + '--aa': { + lightModeColor: 'dd', + darkModeColor: 'ee', + }, + }, + [] + ); + }); +}); + +describe('DarkColorHandlerImpl.reset', () => { + it('Reset', () => { + const removeProperty = jasmine.createSpy('removeProperty'); + const div = ({ + style: { + removeProperty, + }, + } as any) as HTMLElement; + const handler = new DarkColorHandlerImpl(div, null!); + + (handler as any).knownColors = { + '--aa': { + lightModeColor: 'bb', + darkModeColor: 'cc', + }, + '--dd': { + lightModeColor: 'ee', + darkModeColor: 'ff', + }, + }; + + handler.reset(); + + expect((handler as any).knownColors).toEqual({}); + expect(removeProperty).toHaveBeenCalledTimes(2); + expect(removeProperty).toHaveBeenCalledWith('--aa'); + expect(removeProperty).toHaveBeenCalledWith('--dd'); + }); +}); diff --git a/packages/roosterjs-editor-dom/lib/utils/applyFormat.ts b/packages/roosterjs-editor-dom/lib/utils/applyFormat.ts index 5d3adaed396..a5828419de6 100644 --- a/packages/roosterjs-editor-dom/lib/utils/applyFormat.ts +++ b/packages/roosterjs-editor-dom/lib/utils/applyFormat.ts @@ -1,15 +1,18 @@ import setColor from './setColor'; -import { DefaultFormat } from 'roosterjs-editor-types'; +import { DarkColorHandler, DefaultFormat } from 'roosterjs-editor-types'; /** * Apply format to an HTML element * @param element The HTML element to apply format to * @param format The format to apply + * @param isDarkMode Whether the content should be formatted in dark mode + * @param darkColorHandler An optional dark color handler object. When it is passed, we will use this handler to do variable-based dark color instead of original dataset base dark color */ export default function applyFormat( element: HTMLElement, format: DefaultFormat, - isDarkMode?: boolean + isDarkMode?: boolean, + darkColorHandler?: DarkColorHandler | null ) { if (format) { let elementStyle = element.style; @@ -33,15 +36,43 @@ export default function applyFormat( } if (textColors) { - setColor(element, textColors, false /*isBackground*/, isDarkMode); + setColor( + element, + textColors, + false /*isBackground*/, + isDarkMode, + false /*shouldAdaptFontColor*/, + darkColorHandler + ); } else if (textColor) { - setColor(element, textColor, false /*isBackground*/, isDarkMode); + setColor( + element, + textColor, + false /*isBackground*/, + isDarkMode, + false /*shouldAdaptFontColor*/, + darkColorHandler + ); } if (backgroundColors) { - setColor(element, backgroundColors, true /*isBackground*/, isDarkMode); + setColor( + element, + backgroundColors, + true /*isBackground*/, + isDarkMode, + false /*shouldAdaptFontColor*/, + darkColorHandler + ); } else if (backgroundColor) { - setColor(element, backgroundColor, true /*isBackground*/, isDarkMode); + setColor( + element, + backgroundColor, + true /*isBackground*/, + isDarkMode, + false /*shouldAdaptFontColor*/, + darkColorHandler + ); } if (bold) { diff --git a/packages/roosterjs-editor-dom/lib/utils/createElement.ts b/packages/roosterjs-editor-dom/lib/utils/createElement.ts index d38ce0362c7..826fb28380b 100644 --- a/packages/roosterjs-editor-dom/lib/utils/createElement.ts +++ b/packages/roosterjs-editor-dom/lib/utils/createElement.ts @@ -34,7 +34,7 @@ export const KnownCreateElementData: Record this.onBlur, + dragstart: e => { + if (this.image) { + e.preventDefault(); + } + }, + }); } /** @@ -191,7 +202,16 @@ export default class ImageEdit implements EditorPlugin { } break; case PluginEventType.MouseDown: - this.setEditingImage(null); + // When left click in a image that already in editing mode, do not quit edit mode + const mouseTarget = e.rawEvent.target; + const button = e.rawEvent.button; + if ( + this.shadowSpan !== mouseTarget || + (this.shadowSpan === mouseTarget && button !== 0) || + this.isCropping + ) { + this.setEditingImage(null); + } break; case PluginEventType.KeyDown: this.setEditingImage(null); @@ -207,9 +227,8 @@ export default class ImageEdit implements EditorPlugin { deleteEditInfo(img as HTMLImageElement); }); break; - - case PluginEventType.Scroll: - this.setEditingImage(null); + case PluginEventType.BeforeDispose: + this.removeWrapper(); break; } } @@ -277,6 +296,7 @@ export default class ImageEdit implements EditorPlugin { this.editInfo = null; this.lastSrc = null; this.clonedImage = null; + this.isCropping = false; } if (!this.image && image?.isContentEditable) { @@ -307,7 +327,6 @@ export default class ImageEdit implements EditorPlugin { ]; this.editor.select(this.image); - this.toggleImageVisibility(this.image, false /** showImage */); } } @@ -317,7 +336,6 @@ export default class ImageEdit implements EditorPlugin { private onBlur = () => { this.setEditingImage(null, true); }; - /** * Create editing wrapper for the image */ @@ -330,13 +348,6 @@ export default class ImageEdit implements EditorPlugin { this.image.ownerDocument ) as HTMLSpanElement; this.wrapper.firstChild.appendChild(this.clonedImage); - - // keep the same vertical align - const originalVerticalAlign = getStylePropertyValue(this.image, 'vertical-align'); - if (originalVerticalAlign) { - this.wrapper.style.verticalAlign = originalVerticalAlign; - } - this.wrapper.style.display = Browser.isSafari ? 'inline-block' : 'inline-flex'; // Cache current src so that we can compare it after edit see if src is changed @@ -345,7 +356,6 @@ export default class ImageEdit implements EditorPlugin { // Set image src to original src to help show editing UI, also it will be used when regenerate image dataURL after editing this.clonedImage.src = this.editInfo.src; this.clonedImage.style.position = 'absolute'; - this.clonedImage.style.maxWidth = null; // Get HTML for all edit elements (resize handle, rotate handle, crop handle and overlay, ...) and create HTML element const options: ImageHtmlOptions = { @@ -373,44 +383,29 @@ export default class ImageEdit implements EditorPlugin { this.wrapper.appendChild(element); } }); - - this.insertImageWrapper(this.editor, this.image, this.wrapper, this.editor.getZoomScale()); + this.insertImageWrapper(this.wrapper); } - private toggleImageVisibility(image: HTMLImageElement, showImage: boolean) { - const editorId = this.editor.getEditorDomAttribute('id'); - const doc = this.editor.getDocument(); - const editingId = 'editingId' + editorId; - if (showImage) { - removeGlobalCssStyle(doc, editingId); - } else { - const cssRule = `#${editorId} #${image.id} {visibility: hidden}`; - setGlobalCssStyles(doc, cssRule, editingId); - } - } + private insertImageWrapper(wrapper: HTMLSpanElement) { + this.shadowSpan = wrap(this.image, 'span'); + const shadowRoot = this.shadowSpan.attachShadow({ + mode: 'open', + }); - private insertImageWrapper( - editor: IEditor, - image: HTMLImageElement, - wrapper: HTMLSpanElement, - scale: number - ) { - this.zoomWrapper = copyElementRect(image, createZoomWrapper(editor, wrapper, scale)); - this.zoomWrapper.style.zIndex = `${getLatestZIndex(editor.getScrollContainer()) + 1}`; - this.editor.getDocument().body.appendChild(this.zoomWrapper); + this.shadowSpan.style.verticalAlign = 'bottom'; + + shadowRoot.appendChild(wrapper); } /** * Remove the temp wrapper of the image */ private removeWrapper = () => { - const doc = this.editor.getDocument(); - if (this.zoomWrapper && doc.body?.contains(this.zoomWrapper)) { - doc.body?.removeChild(this.zoomWrapper); - this.toggleImageVisibility(this.image, true /** showImage */); + if (this.editor.contains(this.image) && this.wrapper) { + unwrap(this.image.parentNode); } this.wrapper = null; - this.zoomWrapper = null; + this.shadowSpan = null; }; /** @@ -424,10 +419,12 @@ export default class ImageEdit implements EditorPlugin { const cropContainers = getEditElements(wrapper, ImageEditElementClass.CropContainer); const cropOverlays = getEditElements(wrapper, ImageEditElementClass.CropOverlay); const resizeHandles = getEditElements(wrapper, ImageEditElementClass.ResizeHandle); + const rotateCenter = getEditElements(wrapper, ImageEditElementClass.RotateCenter)[0]; + const rotateHandle = getEditElements(wrapper, ImageEditElementClass.RotateHandle)[0]; const cropHandles = getEditElements(wrapper, ImageEditElementClass.CropHandle); // Cropping and resizing will show different UI, so check if it is cropping here first - const isCropping = cropContainers.length == 1 && cropOverlays.length == 4; + this.isCropping = cropContainers.length == 1 && cropOverlays.length == 4; const { angleRad, bottomPercent, @@ -444,7 +441,7 @@ export default class ImageEdit implements EditorPlugin { originalHeight, visibleWidth, visibleHeight, - } = getGeneratedImageSize(this.editInfo, isCropping); + } = getGeneratedImageSize(this.editInfo, this.isCropping); const marginHorizontal = (targetWidth - visibleWidth) / 2; const marginVertical = (targetHeight - visibleHeight) / 2; const cropLeftPx = originalWidth * leftPercent; @@ -457,9 +454,8 @@ export default class ImageEdit implements EditorPlugin { wrapper.style.height = getPx(visibleHeight); wrapper.style.margin = `${marginVertical}px ${marginHorizontal}px`; wrapper.style.transform = `rotate(${angleRad}rad)`; - this.zoomWrapper.style.width = getPx(visibleWidth); - this.zoomWrapper.style.height = getPx(visibleHeight); - fitImageContainer(this.editor, this.zoomWrapper, angleRad); + this.wrapper.style.width = getPx(visibleWidth); + this.wrapper.style.height = getPx(visibleHeight); // Update the text-alignment to avoid the image to overflow if the parent element have align center or right // or if the direction is Right To Left @@ -469,7 +465,7 @@ export default class ImageEdit implements EditorPlugin { this.clonedImage.style.width = getPx(originalWidth); this.clonedImage.style.height = getPx(originalHeight); - if (isCropping) { + if (this.isCropping) { // For crop, we also need to set position of the overlays setSize( cropContainers[0], @@ -484,6 +480,7 @@ export default class ImageEdit implements EditorPlugin { setSize(cropOverlays[1], undefined, 0, 0, cropBottomPx, cropRightPx, undefined); setSize(cropOverlays[2], cropLeftPx, undefined, 0, 0, undefined, cropBottomPx); setSize(cropOverlays[3], 0, cropTopPx, undefined, 0, cropLeftPx, undefined); + updateHandleCursor(cropHandles, angleRad); } else { // For rotate/resize, set the margin of the image so that cropped part won't be visible @@ -504,6 +501,14 @@ export default class ImageEdit implements EditorPlugin { this.updateWrapper(); } + updateRotateHandlePosition( + this.editInfo, + this.editor.getVisibleViewport(), + marginVertical, + rotateCenter, + rotateHandle + ); + updateHandleCursor(resizeHandles, angleRad); } } @@ -645,43 +650,3 @@ function getColorString(color: string | ModeIndependentColor, isDarkMode: boolea } return isDarkMode ? color.darkModeColor.trim() : color.lightModeColor.trim(); } - -function fitImageContainer(editor: IEditor, zoomWrapper: HTMLElement, angle: number) { - const angleIndex = handleRadIndexCalculator(angle); - const isVertical = (angleIndex >= 2 && angleIndex < 4) || angleIndex >= 6; - const editorTop = editor.getScrollContainer()?.getBoundingClientRect()?.top; - const { top, width, height } = zoomWrapper?.getBoundingClientRect(); - if (editorTop > top) { - const rotatePercent = 100 * Math.abs(angle); - const zoomWrapperHeight = editorTop - top; - const zoomWrapperHeightPercent = isVertical - ? rotatePercent * (zoomWrapperHeight / width) - : 100 * (zoomWrapperHeight / height); - - zoomWrapper.style.clipPath = `polygon(0 ${zoomWrapperHeightPercent}%, 100% ${zoomWrapperHeightPercent}%, 100% ${ - isVertical ? rotatePercent : '100' - }%, 0 ${isVertical ? rotatePercent : '100'}%)`; - } -} - -function copyElementRect(originalElement: HTMLElement, element: HTMLElement) { - const { top, left, right, bottom } = originalElement.getBoundingClientRect(); - element.style.top = `${top}px`; - element.style.bottom = `${bottom}px`; - element.style.right = `${right}px`; - element.style.left = `${left}px`; - return element; -} - -function createZoomWrapper(editor: IEditor, wrapper: HTMLSpanElement, scale: number) { - const zoomWrapper = editor.getDocument().createElement('div'); - zoomWrapper.style.transform = `scale(${scale || 1})`; - zoomWrapper.style.transformOrigin = 'top left'; - zoomWrapper.style.position = 'fixed'; - zoomWrapper.appendChild(wrapper); - return zoomWrapper; -} - -function getStylePropertyValue(element: HTMLElement, property: string): string { - return element.ownerDocument.defaultView.getComputedStyle(element).getPropertyValue(property); -} diff --git a/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/editInfoUtils/applyChange.ts b/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/editInfoUtils/applyChange.ts index 3fce6cc2818..1829c60677c 100644 --- a/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/editInfoUtils/applyChange.ts +++ b/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/editInfoUtils/applyChange.ts @@ -72,7 +72,6 @@ export default function applyChange( image.src = newSrc; if (wasResized || state == ImageEditInfoState.FullyChanged) { - image.style.maxWidth = 'initial'; image.width = targetWidth; image.height = targetHeight; image.style.width = targetWidth + 'px'; diff --git a/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/imageEditors/Rotator.ts b/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/imageEditors/Rotator.ts index a2aca4239a5..8a395da9bc8 100644 --- a/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/imageEditors/Rotator.ts +++ b/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/imageEditors/Rotator.ts @@ -1,9 +1,9 @@ import DragAndDropContext from '../types/DragAndDropContext'; import DragAndDropHandler from '../../../pluginUtils/DragAndDropHandler'; +import ImageEditInfo, { RotateInfo } from '../types/ImageEditInfo'; import ImageHtmlOptions from '../types/ImageHtmlOptions'; -import { CreateElementData } from 'roosterjs-editor-types'; +import { CreateElementData, Rect } from 'roosterjs-editor-types'; import { ImageEditElementClass } from '../types/ImageEditElementClass'; -import { RotateInfo } from '../types/ImageEditInfo'; const ROTATE_SIZE = 32; const ROTATE_GAP = 15; @@ -39,6 +39,33 @@ export const Rotator: DragAndDropHandler = { }, }; +/** + * @internal + * Move rotate handle. When image is very close to the border of editor, rotate handle may not be visible. + * Fix it by reduce the distance from image to rotate handle + */ +export function updateRotateHandlePosition( + editInfo: ImageEditInfo, + editorRect: Rect, + marginVertical: number, + rotateCenter: HTMLElement, + rotateHandle: HTMLElement +) { + const top = rotateHandle.getBoundingClientRect()?.top - editorRect?.top; + const { angleRad, heightPx } = editInfo; + const cosAngle = Math.cos(angleRad); + const adjustedDistance = + cosAngle <= 0 + ? Number.MAX_SAFE_INTEGER + : (top + heightPx / 2 + marginVertical) / cosAngle - heightPx / 2; + + const rotateGap = Math.max(Math.min(ROTATE_GAP, adjustedDistance), 0); + const rotateTop = Math.max(Math.min(ROTATE_SIZE, adjustedDistance - rotateGap), 0); + rotateCenter.style.top = -rotateGap + 'px'; + rotateCenter.style.height = rotateGap + 'px'; + rotateHandle.style.top = -rotateTop + 'px'; +} + /** * @internal * Get HTML for rotate elements, including the rotate handle with icon, and a line between the handle and the image diff --git a/packages/roosterjs-editor-plugins/lib/plugins/Watermark/Watermark.ts b/packages/roosterjs-editor-plugins/lib/plugins/Watermark/Watermark.ts index 691fae7ddd2..742788d5894 100644 --- a/packages/roosterjs-editor-plugins/lib/plugins/Watermark/Watermark.ts +++ b/packages/roosterjs-editor-plugins/lib/plugins/Watermark/Watermark.ts @@ -86,7 +86,12 @@ export default class Watermark implements EditorPlugin { if (operation == EntityOperation.ReplaceTemporaryContent) { this.removeWatermark(wrapper); } else if (event.operation == EntityOperation.NewEntity) { - applyFormat(wrapper, this.format, this.editor.isDarkMode()); + applyFormat( + wrapper, + this.format, + this.editor.isDarkMode(), + this.editor.getDarkColorHandler() + ); wrapper.spellcheck = false; } } diff --git a/packages/roosterjs-editor-types/lib/enum/ExperimentalFeatures.ts b/packages/roosterjs-editor-types/lib/enum/ExperimentalFeatures.ts index 74dc50c3891..944dbe2dd81 100644 --- a/packages/roosterjs-editor-types/lib/enum/ExperimentalFeatures.ts +++ b/packages/roosterjs-editor-types/lib/enum/ExperimentalFeatures.ts @@ -126,4 +126,11 @@ export const enum ExperimentalFeatures { * the block element (In most case, the DIV element) so keep the block element clean. */ DefaultFormatInSpan = 'DefaultFormatInSpan', + + /** + * Use variable-based dark mode solution rather than dataset-based solution. + * When enable this feature, need to pass in a DarkModelHandler object to each call of setColor and applyFormat + * if you need them work for dark mode + */ + VariableBasedDarkColor = 'VariableBasedDarkColor', } diff --git a/packages/roosterjs-editor-types/lib/interface/DarkColorHandler.ts b/packages/roosterjs-editor-types/lib/interface/DarkColorHandler.ts new file mode 100644 index 00000000000..60753e9bcb9 --- /dev/null +++ b/packages/roosterjs-editor-types/lib/interface/DarkColorHandler.ts @@ -0,0 +1,45 @@ +/** + * Represents a combination of color key, light color and dark color, parsed from existing color value + */ +export interface ColorKeyAndValue { + /** + * Key of color, if found, otherwise undefined + */ + key?: string; + + /** + * Light mode color value + */ + lightModeColor: string; + + /** + * Dark mode color value, if found, otherwise undefined + */ + darkModeColor?: string; +} + +/** + * A handler object for dark color, used for variable-based dark color solution + */ +export default interface DarkColorHandler { + /** + * Given a light mode color value and an optional dark mode color value, register this color + * so that editor can handle it, then return the CSS color value for current color mode. + * @param lightModeColor Light mode color value + * @param isDarkMode Whether current color mode is dark mode + * @param darkModeColor Optional dark mode color value. If not passed, we will calculate one. + */ + registerColor(lightModeColor: string, isDarkMode: boolean, darkModeColor?: string): string; + + /** + * Reset known color record, clean up registered color variables. + */ + reset(): void; + + /** + * Parse an existing color value, if it is in variable-based color format, extract color key, + * light color and query related dark color if any + * @param color The color string to parse + */ + parseColorValue(color: string | null | undefined): ColorKeyAndValue; +} diff --git a/packages/roosterjs-editor-types/lib/interface/EditorCore.ts b/packages/roosterjs-editor-types/lib/interface/EditorCore.ts index 76fd5e59106..bfc43d67120 100644 --- a/packages/roosterjs-editor-types/lib/interface/EditorCore.ts +++ b/packages/roosterjs-editor-types/lib/interface/EditorCore.ts @@ -1,5 +1,6 @@ import ClipboardData from './ClipboardData'; import ContentChangedData from './ContentChangedData'; +import DarkColorHandler from './DarkColorHandler'; import EditorPlugin from './EditorPlugin'; import NodePosition from './NodePosition'; import Rect from './Rect'; @@ -72,6 +73,12 @@ export default interface EditorCore extends PluginState { * Color of the border of a selectedImage. Default color: '#DB626C' */ imageSelectionBorderColor?: string; + + /** + * Dark model handler for the editor, used for variable-based solution. + * If keep it null, editor will still use original dataset-based dark mode solution. + */ + darkColorHandler?: DarkColorHandler; } /** diff --git a/packages/roosterjs-editor-types/lib/interface/FormatState.ts b/packages/roosterjs-editor-types/lib/interface/FormatState.ts index d1929419e20..b37945470a0 100644 --- a/packages/roosterjs-editor-types/lib/interface/FormatState.ts +++ b/packages/roosterjs-editor-types/lib/interface/FormatState.ts @@ -59,6 +59,11 @@ export interface ElementBasedFormatState { */ isBlockQuote?: boolean; + /** + * Whether the text is in Code block + */ + isCodeBlock?: boolean; + /** * Whether unlink command can be called to the text */ @@ -93,6 +98,11 @@ export interface ElementBasedFormatState { * If there is a table, whether the table has header row */ tableHasHeader?: boolean; + + /** + * Whether we can execute table cell merge operation + */ + canMergeTableCell?: boolean; } /** diff --git a/packages/roosterjs-editor-types/lib/interface/IEditor.ts b/packages/roosterjs-editor-types/lib/interface/IEditor.ts index 524122cc975..2a4574ae8de 100644 --- a/packages/roosterjs-editor-types/lib/interface/IEditor.ts +++ b/packages/roosterjs-editor-types/lib/interface/IEditor.ts @@ -1,6 +1,7 @@ import BlockElement from './BlockElement'; import ClipboardData from './ClipboardData'; import ContentChangedData from './ContentChangedData'; +import DarkColorHandler from './DarkColorHandler'; import DefaultFormat from './DefaultFormat'; import IContentTraverser from './IContentTraverser'; import IPositionContentSearcher from './IPositionContentSearcher'; @@ -590,6 +591,11 @@ export default interface IEditor { */ transformToDarkColor(node: Node): void; + /** + * Get a darkColorHandler object for this editor. It will return null if experimental feature "VariableBasedDarkColor" is not enabled + */ + getDarkColorHandler(): DarkColorHandler | null; + /** * Make the editor in "Shadow Edit" mode. * In Shadow Edit mode, all format change will finally be ignored. diff --git a/packages/roosterjs-editor-types/lib/interface/index.ts b/packages/roosterjs-editor-types/lib/interface/index.ts index e8107719fa7..37df36813d9 100644 --- a/packages/roosterjs-editor-types/lib/interface/index.ts +++ b/packages/roosterjs-editor-types/lib/interface/index.ts @@ -51,6 +51,7 @@ export { default as SanitizeHtmlOptions } from './SanitizeHtmlOptions'; export { default as TargetWindowBase } from './TargetWindowBase'; export { default as TargetWindow } from './TargetWindow'; export { default as IEditor } from './IEditor'; +export { default as DarkColorHandler, ColorKeyAndValue } from './DarkColorHandler'; export { ContentEditFeature, GenericContentEditFeature, diff --git a/yarn.lock b/yarn.lock index 753500d8f67..39784c13b71 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5940,9 +5940,9 @@ typescript@4.4.4: integrity sha512-DqGhF5IKoBl8WNf8C1gu8q0xZSInh9j1kJJMqT3a94w1JzVaBU4EXOSMrz9yDqMT0xt3selp83fuFMQ0uzv6qA== ua-parser-js@^0.7.30: - version "0.7.31" - resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.31.tgz#649a656b191dffab4f21d5e053e27ca17cbff5c6" - integrity sha512-qLK/Xe9E2uzmYI3qLeOmI0tEOt+TBBQyUIAh4aAgU05FVYzeZrKUdkAZfBNVGRaHVgV0TDkdEngJSw/SyQchkQ== + version "0.7.33" + resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.33.tgz#1d04acb4ccef9293df6f70f2c3d22f3030d8b532" + integrity sha512-s8ax/CeZdK9R/56Sui0WM6y9OFREJarMRHqLB2EwkovemBxNQ+Bqu8GAsUnVcXKgphb++ghr/B2BZx4mahujPw== uglify-js@^3.1.4: version "3.14.2"