diff --git a/demo/scripts/controlsV2/mainPane/MainPane.tsx b/demo/scripts/controlsV2/mainPane/MainPane.tsx index 94132a551d4..22296506b4d 100644 --- a/demo/scripts/controlsV2/mainPane/MainPane.tsx +++ b/demo/scripts/controlsV2/mainPane/MainPane.tsx @@ -496,10 +496,11 @@ export class MainPane extends React.Component<{}, MainPaneState> { autoFormatOptions, linkTitle, customReplacements, + editPluginOptions, } = this.state.initState; return [ pluginList.autoFormat && new AutoFormatPlugin(autoFormatOptions), - pluginList.edit && new EditPlugin(), + pluginList.edit && new EditPlugin(editPluginOptions), pluginList.paste && new PastePlugin(allowExcelNoBorderTable), pluginList.shortcut && new ShortcutPlugin(), pluginList.tableEdit && new TableEditPlugin(), diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts b/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts index 53df2b411aa..a6d4c5af32a 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts @@ -52,6 +52,9 @@ const initialState: OptionState = { strikethrough: true, codeFormat: {}, }, + editPluginOptions: { + handleTabKey: true, + }, customReplacements: emojiReplacements, experimentalFeatures: new Set(['PersistCache']), }; diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts b/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts index 8283c17f874..dbf2a967302 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts @@ -1,4 +1,9 @@ -import { AutoFormatOptions, CustomReplace, MarkdownOptions } from 'roosterjs-content-model-plugins'; +import { + AutoFormatOptions, + CustomReplace, + EditOptions, + MarkdownOptions, +} from 'roosterjs-content-model-plugins'; import type { SidePaneElementProps } from '../SidePaneElement'; import type { ContentModelSegmentFormat, ExperimentalFeature } from 'roosterjs-content-model-types'; @@ -31,6 +36,7 @@ export interface OptionState { autoFormatOptions: AutoFormatOptions; markdownOptions: MarkdownOptions; customReplacements: CustomReplace[]; + editPluginOptions: EditOptions; // Legacy plugin options defaultFormat: ContentModelSegmentFormat; diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/OptionsPane.tsx b/demo/scripts/controlsV2/sidePane/editorOptions/OptionsPane.tsx index 6e775fa3a14..951aa42d49f 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/OptionsPane.tsx +++ b/demo/scripts/controlsV2/sidePane/editorOptions/OptionsPane.tsx @@ -140,6 +140,7 @@ export class OptionsPane extends React.Component { markdownOptions: { ...this.state.markdownOptions }, customReplacements: this.state.customReplacements, experimentalFeatures: this.state.experimentalFeatures, + editPluginOptions: { ...this.state.editPluginOptions }, }; if (callback) { diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx b/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx index be1b559a310..86ebfe53fdf 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx +++ b/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx @@ -98,6 +98,7 @@ abstract class PluginsBase extends Re export class Plugins extends PluginsBase { private allowExcelNoBorderTable = React.createRef(); + private handleTabKey = React.createRef(); private listMenu = React.createRef(); private tableMenu = React.createRef(); private imageMenu = React.createRef(); @@ -167,7 +168,16 @@ export class Plugins extends PluginsBase { )} )} - {this.renderPluginItem('edit', 'Edit')} + {this.renderPluginItem( + 'edit', + 'Edit', + this.renderCheckBox( + 'Handle Tab Key', + this.handleTabKey, + this.props.state.editPluginOptions.handleTabKey, + (state, value) => (state.editPluginOptions.handleTabKey = value) + ) + )} {this.renderPluginItem( 'paste', 'Paste', diff --git a/packages/roosterjs-content-model-api/lib/index.ts b/packages/roosterjs-content-model-api/lib/index.ts index 4209e3a1a5c..ffcaca8e65a 100644 --- a/packages/roosterjs-content-model-api/lib/index.ts +++ b/packages/roosterjs-content-model-api/lib/index.ts @@ -18,6 +18,7 @@ export { setTextColor } from './publicApi/segment/setTextColor'; export { changeFontSize } from './publicApi/segment/changeFontSize'; export { applySegmentFormat } from './publicApi/segment/applySegmentFormat'; export { changeCapitalization } from './publicApi/segment/changeCapitalization'; +export { splitTextSegment } from './publicApi/segment/splitTextSegment'; export { insertImage } from './publicApi/image/insertImage'; export { setListStyle } from './publicApi/list/setListStyle'; export { setListStartNumber } from './publicApi/list/setListStartNumber'; diff --git a/packages/roosterjs-content-model-plugins/lib/pluginUtils/splitTextSegment.ts b/packages/roosterjs-content-model-api/lib/publicApi/segment/splitTextSegment.ts similarity index 81% rename from packages/roosterjs-content-model-plugins/lib/pluginUtils/splitTextSegment.ts rename to packages/roosterjs-content-model-api/lib/publicApi/segment/splitTextSegment.ts index 4f7a034e8ae..b26623d43d2 100644 --- a/packages/roosterjs-content-model-plugins/lib/pluginUtils/splitTextSegment.ts +++ b/packages/roosterjs-content-model-api/lib/publicApi/segment/splitTextSegment.ts @@ -5,7 +5,12 @@ import type { } from 'roosterjs-content-model-types'; /** - * @internal + * Split given text segments from the given range + * @param textSegment segment to split + * @param parent parent paragraph the text segment exist in + * @param start starting point of the split + * @param end ending point of the split + * @returns text segment from the indicated split. */ export function splitTextSegment( textSegment: ContentModelText, diff --git a/packages/roosterjs-content-model-plugins/test/pluginUtils/splitTextSegmentTest.ts b/packages/roosterjs-content-model-api/test/publicApi/segment/splitTextSegmentTest.ts similarity index 96% rename from packages/roosterjs-content-model-plugins/test/pluginUtils/splitTextSegmentTest.ts rename to packages/roosterjs-content-model-api/test/publicApi/segment/splitTextSegmentTest.ts index 5d9363fc413..3d1b4779059 100644 --- a/packages/roosterjs-content-model-plugins/test/pluginUtils/splitTextSegmentTest.ts +++ b/packages/roosterjs-content-model-api/test/publicApi/segment/splitTextSegmentTest.ts @@ -1,5 +1,5 @@ import { ContentModelParagraph, ContentModelText } from 'roosterjs-content-model-types'; -import { splitTextSegment } from '../../lib/pluginUtils/splitTextSegment'; +import { splitTextSegment } from '../../../lib/publicApi/segment/splitTextSegment'; describe('splitTextSegment', () => { function runTest( diff --git a/packages/roosterjs-content-model-core/lib/coreApi/createContentModel/createContentModel.ts b/packages/roosterjs-content-model-core/lib/coreApi/createContentModel/createContentModel.ts index 31146042181..a58efe7e0b9 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/createContentModel/createContentModel.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/createContentModel/createContentModel.ts @@ -1,4 +1,4 @@ -import { updateCachedSelection } from '../../corePlugin/cache/updateCachedSelection'; +import { updateCache } from '../../corePlugin/cache/updateCache'; import { cloneModel, createDomToModelContext, @@ -47,8 +47,7 @@ export const createContentModel: CreateContentModel = (core, option, selectionOv const model = domToContentModel(core.logicalRoot, domToModelContext); if (saveIndex) { - core.cache.cachedModel = model; - updateCachedSelection(core.cache, selection); + updateCache(core.cache, model, selection); } return model; diff --git a/packages/roosterjs-content-model-core/lib/coreApi/setContentModel/setContentModel.ts b/packages/roosterjs-content-model-core/lib/coreApi/setContentModel/setContentModel.ts index 240b0329d95..19d3da8e411 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/setContentModel/setContentModel.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/setContentModel/setContentModel.ts @@ -1,4 +1,4 @@ -import { updateCachedSelection } from '../../corePlugin/cache/updateCachedSelection'; +import { updateCache } from '../../corePlugin/cache/updateCache'; import { contentModelToDom, createModelToDomContext, @@ -37,16 +37,16 @@ export const setContentModel: SetContentModel = (core, model, option, onNodeCrea ); if (!core.lifecycle.shadowEditFragment) { - updateCachedSelection(core.cache, selection || undefined); + // Clear pending mutations since we will use our latest model object to replace existing cache + core.cache.textMutationObserver?.flushMutations(true /*ignoreMutations*/); + + updateCache(core.cache, model, selection); if (!option?.ignoreSelection && selection) { core.api.setDOMSelection(core, selection); } else { core.selection.selection = selection; } - - // Clear pending mutations since we will use our latest model object to replace existing cache - core.cache.textMutationObserver?.flushMutations(model); } return selection; diff --git a/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/addRangeToSelection.ts b/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/addRangeToSelection.ts index 9eea7ba1a28..b0dc3607d3f 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/addRangeToSelection.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/addRangeToSelection.ts @@ -1,3 +1,5 @@ +import { areSameRanges } from '../../corePlugin/cache/areSameSelections'; + /** * @internal */ @@ -6,13 +8,7 @@ export function addRangeToSelection(doc: Document, range: Range, isReverted: boo if (selection) { const currentRange = selection.rangeCount > 0 && selection.getRangeAt(0); - if ( - currentRange && - currentRange.startContainer == range.startContainer && - currentRange.endContainer == range.endContainer && - currentRange.startOffset == range.startOffset && - currentRange.endOffset == range.endOffset - ) { + if (currentRange && areSameRanges(currentRange, range)) { return; } selection.removeAllRanges(); diff --git a/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/ensureImageHasSpanParent.ts b/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/ensureImageHasSpanParent.ts deleted file mode 100644 index 7ede477d9a8..00000000000 --- a/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/ensureImageHasSpanParent.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { isElementOfType, isNodeOfType, wrap } from 'roosterjs-content-model-dom'; - -/** - * @internal - * Ensure image is wrapped by a span element - * @param image - * @returns the image - */ -export function ensureImageHasSpanParent(image: HTMLImageElement): HTMLImageElement { - const parent = image.parentElement; - - if ( - parent && - isNodeOfType(parent, 'ELEMENT_NODE') && - isElementOfType(parent, 'span') && - parent.firstChild == image && - parent.lastChild == image - ) { - return image; - } - - wrap(image.ownerDocument, image, 'span'); - return image; -} diff --git a/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/setDOMSelection.ts b/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/setDOMSelection.ts index b547f8107f6..39cdd9828be 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/setDOMSelection.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/setDOMSelection.ts @@ -1,5 +1,5 @@ import { addRangeToSelection } from './addRangeToSelection'; -import { ensureImageHasSpanParent } from './ensureImageHasSpanParent'; +import { areSameSelections } from '../../corePlugin/cache/areSameSelections'; import { ensureUniqueId } from '../setEditorStyle/ensureUniqueId'; import { findLastedCoInMergedCell } from './findLastedCoInMergedCell'; import { findTableCellElement } from './findTableCellElement'; @@ -19,11 +19,18 @@ const TABLE_ID = 'table'; const CARET_CSS_RULE = 'caret-color: transparent'; const TRANSPARENT_SELECTION_CSS_RULE = 'background-color: transparent !important;'; const SELECTION_SELECTOR = '*::selection'; +const DEFAULT_SELECTION_BORDER_COLOR = '#DB626C'; /** * @internal */ export const setDOMSelection: SetDOMSelection = (core, selection, skipSelectionChangedEvent) => { + const existingSelection = core.api.getDOMSelection(core); + + if (existingSelection && selection && areSameSelections(existingSelection, selection)) { + return; + } + // We are applying a new selection, so we don't need to apply cached selection in DOMEventPlugin. // Set skipReselectOnFocus to skip this behavior const skipReselectOnFocus = core.selection.skipReselectOnFocus; @@ -38,12 +45,10 @@ export const setDOMSelection: SetDOMSelection = (core, selection, skipSelectionC try { switch (selection?.type) { case 'image': - const image = ensureImageHasSpanParent(selection.image); + const image = selection.image; + + core.selection.selection = selection; - core.selection.selection = { - type: 'image', - image, - }; const imageSelectionColor = isDarkMode ? core.selection.imageSelectionBorderColorDark : core.selection.imageSelectionBorderColor; @@ -51,10 +56,10 @@ export const setDOMSelection: SetDOMSelection = (core, selection, skipSelectionC core.api.setEditorStyle( core, DOM_SELECTION_CSS_KEY, - `outline-style:solid!important; outline-color:${imageSelectionColor}!important;display: ${ - core.environment.isSafari ? '-webkit-inline-flex' : 'inline-flex' - };`, - [`span:has(>img#${ensureUniqueId(image, IMAGE_ID)})`] + `outline-style:solid!important; outline-color:${ + imageSelectionColor || DEFAULT_SELECTION_BORDER_COLOR + }!important;`, + [`#${ensureUniqueId(image, IMAGE_ID)}`] ); core.api.setEditorStyle( core, diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/cache/CachePlugin.ts b/packages/roosterjs-content-model-core/lib/corePlugin/cache/CachePlugin.ts index e7723a17d27..4932076f5b2 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/cache/CachePlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/cache/CachePlugin.ts @@ -1,14 +1,14 @@ -import { areSameSelection } from './areSameSelection'; +import { areSameSelections } from './areSameSelections'; import { createTextMutationObserver } from './textMutationObserver'; import { DomIndexerImpl } from './domIndexerImpl'; -import { updateCachedSelection } from './updateCachedSelection'; +import { updateCache } from './updateCache'; +import type { Mutation } from './textMutationObserver'; import type { CachePluginState, IEditor, PluginEvent, PluginWithState, EditorOptions, - ContentModelDocument, } from 'roosterjs-content-model-types'; /** @@ -24,24 +24,15 @@ class CachePlugin implements PluginWithState { * @param contentDiv The editor content DIV */ constructor(option: EditorOptions, contentDiv: HTMLDivElement) { - if (option.disableCache) { - this.state = {}; - } else { - const domIndexer = new DomIndexerImpl( - option.experimentalFeatures && - option.experimentalFeatures.indexOf('PersistCache') >= 0 - ); - - this.state = { - domIndexer: domIndexer, - textMutationObserver: createTextMutationObserver( - contentDiv, - domIndexer, - this.onMutation, - this.onSkipMutation - ), - }; - } + this.state = option.disableCache + ? {} + : { + domIndexer: new DomIndexerImpl( + option.experimentalFeatures && + option.experimentalFeatures.indexOf('PersistCache') >= 0 + ), + textMutationObserver: createTextMutationObserver(contentDiv, this.onMutation), + }; } /** @@ -115,8 +106,7 @@ class CachePlugin implements PluginWithState { const { contentModel, selection } = event; if (contentModel && this.state.domIndexer) { - this.state.cachedModel = contentModel; - updateCachedSelection(this.state, selection); + updateCache(this.state, contentModel, selection); } else { this.invalidateCache(); } @@ -125,23 +115,31 @@ class CachePlugin implements PluginWithState { } } - private onMutation = (isTextChangeOnly: boolean) => { + private onMutation = (mutation: Mutation) => { if (this.editor) { - if (isTextChangeOnly) { - this.updateCachedModel(this.editor, true /*forceUpdate*/); - } else { - this.invalidateCache(); + switch (mutation.type) { + case 'childList': + if ( + !this.state.domIndexer?.reconcileChildList( + mutation.addedNodes, + mutation.removedNodes + ) + ) { + this.invalidateCache(); + } + break; + + case 'text': + this.updateCachedModel(this.editor, true /*forceUpdate*/); + break; + + case 'unknown': + this.invalidateCache(); + break; } } }; - private onSkipMutation = (newModel: ContentModelDocument) => { - if (!this.editor?.isInShadowEdit()) { - this.state.cachedModel = newModel; - this.state.cachedSelection = undefined; - } - }; - private onNativeSelectionChange = () => { if (this.editor?.hasFocus()) { this.updateCachedModel(this.editor); @@ -156,6 +154,10 @@ class CachePlugin implements PluginWithState { } private updateCachedModel(editor: IEditor, forceUpdate?: boolean) { + if (editor.isInShadowEdit()) { + return; + } + const cachedSelection = this.state.cachedSelection; this.state.cachedSelection = undefined; // Clear it to force getDOMSelection() retrieve the latest selection range @@ -165,7 +167,7 @@ class CachePlugin implements PluginWithState { forceUpdate || !cachedSelection || !newRangeEx || - !areSameSelection(newRangeEx, cachedSelection); + !areSameSelections(newRangeEx, cachedSelection); if (isSelectionChanged) { if ( @@ -175,7 +177,7 @@ class CachePlugin implements PluginWithState { ) { this.invalidateCache(); } else { - updateCachedSelection(this.state, newRangeEx); + updateCache(this.state, model, newRangeEx); } } else { this.state.cachedSelection = cachedSelection; diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/cache/areSameSelection.ts b/packages/roosterjs-content-model-core/lib/corePlugin/cache/areSameSelection.ts deleted file mode 100644 index d78f569be94..00000000000 --- a/packages/roosterjs-content-model-core/lib/corePlugin/cache/areSameSelection.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { CacheSelection, DOMSelection } from 'roosterjs-content-model-types'; - -/** - * @internal - * Check if the given selections are the same - */ -export function areSameSelection(sel1: DOMSelection, sel2: CacheSelection): boolean { - if (sel1 == sel2) { - return true; - } - - switch (sel1.type) { - case 'image': - return sel2.type == 'image' && sel2.image == sel1.image; - - case 'table': - return ( - sel2.type == 'table' && - sel2.table == sel1.table && - sel2.firstColumn == sel1.firstColumn && - sel2.lastColumn == sel1.lastColumn && - sel2.firstRow == sel1.firstRow && - sel2.lastRow == sel1.lastRow - ); - - case 'range': - default: - return ( - sel2.type == 'range' && - sel1.range.startContainer == sel2.start.node && - sel1.range.endContainer == sel2.end.node && - sel1.range.startOffset == sel2.start.offset && - sel1.range.endOffset == sel2.end.offset - ); - } -} diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/cache/areSameSelections.ts b/packages/roosterjs-content-model-core/lib/corePlugin/cache/areSameSelections.ts new file mode 100644 index 00000000000..7bb7d2414f1 --- /dev/null +++ b/packages/roosterjs-content-model-core/lib/corePlugin/cache/areSameSelections.ts @@ -0,0 +1,82 @@ +import type { + CacheSelection, + DOMSelection, + RangeSelection, + RangeSelectionForCache, + TableSelection, +} from 'roosterjs-content-model-types'; + +/** + * @internal + * Check if the given selections are the same + */ +export function areSameSelections( + sel1: DOMSelection, + sel2: DOMSelection | CacheSelection +): boolean { + if (sel1 == sel2) { + return true; + } + + switch (sel1.type) { + case 'image': + return sel2.type == 'image' && sel2.image == sel1.image; + + case 'table': + return sel2.type == 'table' && areSameTableSelections(sel1, sel2); + + case 'range': + default: + if (sel2.type == 'range') { + const range1 = sel1.range; + + if (isCacheSelection(sel2)) { + const { start, end } = sel2; + + return ( + range1.startContainer == start.node && + range1.endContainer == end.node && + range1.startOffset == start.offset && + range1.endOffset == end.offset + ); + } else { + return areSameRanges(range1, sel2.range); + } + } else { + return false; + } + } +} + +function areSame(o1: O, o2: O, keys: (keyof O)[]) { + return keys.every(k => o1[k] == o2[k]); +} + +const TableSelectionKeys: (keyof TableSelection)[] = [ + 'table', + 'firstColumn', + 'lastColumn', + 'firstRow', + 'lastRow', +]; +const RangeKeys: (keyof Range)[] = ['startContainer', 'endContainer', 'startOffset', 'endOffset']; + +/** + * @internal + */ +export function areSameTableSelections(t1: TableSelection, t2: TableSelection): boolean { + return areSame(t1, t2, TableSelectionKeys); +} + +/** + * @internal + */ +export function areSameRanges(r1: Range, r2: Range): boolean { + return areSame(r1, r2, RangeKeys); +} + +function isCacheSelection( + sel: RangeSelectionForCache | RangeSelection +): sel is RangeSelectionForCache { + return !!(sel as RangeSelectionForCache).start; +} diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/cache/textMutationObserver.ts b/packages/roosterjs-content-model-core/lib/corePlugin/cache/textMutationObserver.ts index bcaae8cc3e5..4f833395646 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/cache/textMutationObserver.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/cache/textMutationObserver.ts @@ -1,17 +1,11 @@ -import type { - ContentModelDocument, - DomIndexer, - TextMutationObserver, -} from 'roosterjs-content-model-types'; +import type { TextMutationObserver } from 'roosterjs-content-model-types'; class TextMutationObserverImpl implements TextMutationObserver { private observer: MutationObserver; constructor( private contentDiv: HTMLDivElement, - private domIndexer: DomIndexer, - private onMutation: (isTextChangeOnly: boolean) => void, - private onSkipMutation: (newModel: ContentModelDocument) => void + private onMutation: (mutation: Mutation) => void ) { this.observer = new MutationObserver(this.onMutationInternal); } @@ -29,12 +23,10 @@ class TextMutationObserverImpl implements TextMutationObserver { this.observer.disconnect(); } - flushMutations(model: ContentModelDocument) { + flushMutations(ignoreMutations?: boolean) { const mutations = this.observer.takeRecords(); - if (model) { - this.onSkipMutation(model); - } else { + if (!ignoreMutations) { this.onMutationInternal(mutations); } } @@ -84,26 +76,77 @@ class TextMutationObserverImpl implements TextMutationObserver { } } - if (canHandle && (addedNodes.length > 0 || removedNodes.length > 0)) { - canHandle = this.domIndexer.reconcileChildList(addedNodes, removedNodes); - } + if (canHandle) { + if (addedNodes.length > 0 || removedNodes.length > 0) { + this.onMutation({ + type: 'childList', + addedNodes, + removedNodes, + }); + } - if (canHandle && reconcileText) { - this.onMutation(true /*textOnly*/); - } else if (!canHandle) { - this.onMutation(false /*textOnly*/); + if (reconcileText) { + this.onMutation({ type: 'text' }); + } + } else { + this.onMutation({ type: 'unknown' }); } }; } +/** + * @internal Type of mutations + */ +export type MutationType = + /** + * We found some change happened but we cannot handle it, so set mutation type as "unknown" + */ + | 'unknown' + /** + * Only text is changed + */ + | 'text' + /** + * Child list is changed + */ + | 'childList'; + +/** + * @internal + */ +export interface MutationBase { + type: T; +} + +/** + * @internal + */ +export interface UnknownMutation extends MutationBase<'unknown'> {} + +/** + * @internal + */ +export interface TextMutation extends MutationBase<'text'> {} + +/** + * @internal + */ +export interface ChildListMutation extends MutationBase<'childList'> { + addedNodes: Node[]; + removedNodes: Node[]; +} + +/** + * @internal + */ +export type Mutation = UnknownMutation | TextMutation | ChildListMutation; + /** * @internal */ export function createTextMutationObserver( contentDiv: HTMLDivElement, - domIndexer: DomIndexer, - onMutation: (isTextChangeOnly: boolean) => void, - onSkipMutation: (newModel: ContentModelDocument) => void + onMutation: (mutation: Mutation) => void ): TextMutationObserver { - return new TextMutationObserverImpl(contentDiv, domIndexer, onMutation, onSkipMutation); + return new TextMutationObserverImpl(contentDiv, onMutation); } diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/cache/updateCachedSelection.ts b/packages/roosterjs-content-model-core/lib/corePlugin/cache/updateCache.ts similarity index 64% rename from packages/roosterjs-content-model-core/lib/corePlugin/cache/updateCachedSelection.ts rename to packages/roosterjs-content-model-core/lib/corePlugin/cache/updateCache.ts index 09275d14289..b119a23a193 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/cache/updateCachedSelection.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/cache/updateCache.ts @@ -1,12 +1,19 @@ -import type { CachePluginState, DOMSelection } from 'roosterjs-content-model-types'; +import type { + CachePluginState, + ContentModelDocument, + DOMSelection, +} from 'roosterjs-content-model-types'; /** * @internal */ -export function updateCachedSelection( +export function updateCache( state: CachePluginState, - selection: DOMSelection | undefined + model: ContentModelDocument, + selection: DOMSelection | null | undefined ) { + state.cachedModel = model; + if (selection?.type == 'range') { const { range: { startContainer, startOffset, endContainer, endOffset }, @@ -25,6 +32,6 @@ export function updateCachedSelection( }, }; } else { - state.cachedSelection = selection; + state.cachedSelection = selection ?? undefined; } } diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts b/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts index 395a0ef3684..b9245e0c0f9 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts @@ -18,7 +18,6 @@ import type { SelectionPluginState, EditorOptions, DOMHelper, - MouseUpEvent, ParsedTable, TableSelectionInfo, TableCellCoordinate, @@ -26,7 +25,6 @@ import type { } from 'roosterjs-content-model-types'; const MouseLeftButton = 0; -const MouseMiddleButton = 1; const MouseRightButton = 2; const Up = 'ArrowUp'; const Down = 'ArrowDown'; @@ -142,7 +140,7 @@ class SelectionPlugin implements PluginWithState { break; case 'mouseUp': - this.onMouseUp(event); + this.onMouseUp(); break; case 'keyDown': @@ -167,16 +165,28 @@ class SelectionPlugin implements PluginWithState { // Image selection if ( - rawEvent.button === MouseRightButton && + selection?.type == 'image' && + (rawEvent.button == MouseLeftButton || + (rawEvent.button == MouseRightButton && + !this.getClickingImage(rawEvent) && + !this.getContainedTargetImage(rawEvent, selection))) + ) { + this.setDOMSelection(null /*domSelection*/, null /*tableSelection*/); + } + + if ( (image = this.getClickingImage(rawEvent) ?? this.getContainedTargetImage(rawEvent, selection)) && image.isContentEditable ) { - this.selectImageWithRange(image, rawEvent); - return; - } else if (selection?.type == 'image' && selection.image !== rawEvent.target) { - this.selectBeforeOrAfterElement(editor, selection.image); + this.setDOMSelection( + { + type: 'image', + image: image, + }, + null + ); return; } @@ -278,39 +288,7 @@ class SelectionPlugin implements PluginWithState { } }; - private selectImageWithRange(image: HTMLImageElement, event: Event) { - const range = image.ownerDocument.createRange(); - range.selectNode(image); - - const domSelection = this.editor?.getDOMSelection(); - if (domSelection?.type == 'image' && image == domSelection.image) { - event.preventDefault(); - } else { - this.setDOMSelection( - { - type: 'range', - isReverted: false, - range, - }, - null - ); - } - } - - private onMouseUp(event: MouseUpEvent) { - let image: HTMLImageElement | null; - - if ( - (image = this.getClickingImage(event.rawEvent)) && - image.isContentEditable && - event.rawEvent.button != MouseMiddleButton && - (event.rawEvent.button == - MouseRightButton /* it's not possible to drag using right click */ || - event.isClicking) - ) { - this.selectImageWithRange(image, event.rawEvent); - } - + private onMouseUp() { this.detachMouseEvent(); } @@ -411,6 +389,7 @@ class SelectionPlugin implements PluginWithState { } let lastCo = findCoordinate(tableSel?.parsedTable, end, domHelper); + let tabMove = false; const { parsedTable, firstCo: oldCo, table } = this.state.tableSelection; if (lastCo && tableSel.table == table) { @@ -465,7 +444,13 @@ class SelectionPlugin implements PluginWithState { const cell = parsedTable[row][col]; if (typeof cell != 'string') { - this.setRangeSelectionInTable(cell, 0, this.editor); + tabMove = true; + this.setRangeSelectionInTable( + cell, + 0, + this.editor, + true /* selectAll */ + ); lastCo.row = row; lastCo.col = col; break; @@ -486,20 +471,29 @@ class SelectionPlugin implements PluginWithState { } } - if (!collapsed && lastCo) { + if (!collapsed && lastCo && !tabMove) { this.state.tableSelection = tableSel; this.updateTableSelection(lastCo); } } } - private setRangeSelectionInTable(cell: Node, nodeOffset: number, editor: IEditor) { - // Get deepest editable position in the cell - const { node, offset } = normalizePos(cell, nodeOffset); - + private setRangeSelectionInTable( + cell: Node, + nodeOffset: number, + editor: IEditor, + selectAll?: boolean + ) { const range = editor.getDocument().createRange(); - range.setStart(node, offset); - range.collapse(true /*toStart*/); + if (selectAll) { + range.selectNodeContents(cell); + } else { + // Get deepest editable position in the cell + const { node, offset } = normalizePos(cell, nodeOffset); + + range.setStart(node, offset); + range.collapse(true /* toStart */); + } this.setDOMSelection( { @@ -607,17 +601,20 @@ class SelectionPlugin implements PluginWithState { //If am image selection changed to a wider range due a keyboard event, we should update the selection const selection = this.editor.getDocument().getSelection(); - if (newSelection?.type == 'image' && selection) { - if (selection && !isSingleImageInSelection(selection)) { - const range = selection.getRangeAt(0); - this.editor.setDOMSelection({ - type: 'range', - range, - isReverted: - selection.focusNode != range.endContainer || - selection.focusOffset != range.endOffset, - }); - } + if ( + newSelection?.type == 'image' && + selection && + selection.focusNode && + !isSingleImageInSelection(selection) + ) { + const range = selection.getRangeAt(0); + this.editor.setDOMSelection({ + type: 'range', + range, + isReverted: + selection.focusNode != range.endContainer || + selection.focusOffset != range.endOffset, + }); } // Safari has problem to handle onBlur event. When blur, we cannot get the original selection from editor. diff --git a/packages/roosterjs-content-model-core/test/coreApi/createContentModel/createContentModelTest.ts b/packages/roosterjs-content-model-core/test/coreApi/createContentModel/createContentModelTest.ts index bb223138b34..cbc91567852 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/createContentModel/createContentModelTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/createContentModel/createContentModelTest.ts @@ -1,7 +1,7 @@ import * as cloneModel from 'roosterjs-content-model-dom/lib/modelApi/editing/cloneModel'; import * as createDomToModelContext from 'roosterjs-content-model-dom/lib/domToModel/context/createDomToModelContext'; import * as domToContentModel from 'roosterjs-content-model-dom/lib/domToModel/domToContentModel'; -import * as updateCachedSelection from '../../../lib/corePlugin/cache/updateCachedSelection'; +import * as updateCache from '../../../lib/corePlugin/cache/updateCache'; import { createContentModel } from '../../../lib/coreApi/createContentModel/createContentModel'; import { ContentModelDocument, @@ -326,7 +326,7 @@ describe('createContentModel and cache management', () => { let cloneModelSpy: jasmine.Spy; let getDOMSelectionSpy: jasmine.Spy; let createEditorContextSpy: jasmine.Spy; - let updateCachedSelectionSpy: jasmine.Spy; + let updateCacheSpy: jasmine.Spy; const mockedSelection = 'SELECTION' as any; const mockedFragment = 'FRAGMENT' as any; @@ -345,7 +345,7 @@ describe('createContentModel and cache management', () => { flushMutationsSpy = jasmine.createSpy('flushMutations'); getDOMSelectionSpy = jasmine.createSpy('getDOMSelection').and.returnValue(mockedSelection); createEditorContextSpy = jasmine.createSpy('createEditorContext'); - updateCachedSelectionSpy = spyOn(updateCachedSelection, 'updateCachedSelection'); + updateCacheSpy = spyOn(updateCache, 'updateCache'); textMutationObserver = { flushMutations: flushMutationsSpy } as any; @@ -385,14 +385,17 @@ describe('createContentModel and cache management', () => { } if (allowIndex && !useCache) { - expect(core.cache.cachedModel).toBe(mockedNewModel); - expect(updateCachedSelectionSpy).toHaveBeenCalled(); + expect(updateCacheSpy).toHaveBeenCalledWith( + core.cache, + mockedNewModel, + mockedSelection + ); } else if (hasCache) { expect(core.cache.cachedModel).toBe(mockedModel); - expect(updateCachedSelectionSpy).not.toHaveBeenCalled(); + expect(updateCacheSpy).not.toHaveBeenCalled(); } else { expect(core.cache.cachedModel).toBe(null!); - expect(updateCachedSelectionSpy).not.toHaveBeenCalled(); + expect(updateCacheSpy).not.toHaveBeenCalled(); } } diff --git a/packages/roosterjs-content-model-core/test/coreApi/setContentModel/setContentModelTest.ts b/packages/roosterjs-content-model-core/test/coreApi/setContentModel/setContentModelTest.ts index 7b86cb7c2ab..201e90ae96c 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/setContentModel/setContentModelTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/setContentModel/setContentModelTest.ts @@ -1,5 +1,6 @@ import * as contentModelToDom from 'roosterjs-content-model-dom/lib/modelToDom/contentModelToDom'; import * as createModelToDomContext from 'roosterjs-content-model-dom/lib/modelToDom/context/createModelToDomContext'; +import * as updateCache from '../../../lib/corePlugin/cache/updateCache'; import { EditorCore } from 'roosterjs-content-model-types'; import { setContentModel } from '../../../lib/coreApi/setContentModel/setContentModel'; @@ -19,6 +20,7 @@ describe('setContentModel', () => { let setDOMSelectionSpy: jasmine.Spy; let getDOMSelectionSpy: jasmine.Spy; let flushMutationsSpy: jasmine.Spy; + let updateCacheSpy: jasmine.Spy; beforeEach(() => { contentModelToDomSpy = spyOn(contentModelToDom, 'contentModelToDom'); @@ -81,7 +83,7 @@ describe('setContentModel', () => { ); expect(setDOMSelectionSpy).toHaveBeenCalledWith(core, mockedRange); expect(core.cache.cachedSelection).toBe(mockedRange); - expect(flushMutationsSpy).toHaveBeenCalledWith(mockedModel); + expect(flushMutationsSpy).toHaveBeenCalledWith(true); }); it('with default option, no shadow edit', () => { @@ -251,4 +253,22 @@ describe('setContentModel', () => { expect(setDOMSelectionSpy).not.toHaveBeenCalled(); expect(core.selection.selection).toBe(null); }); + + it('Flush mutation before update cache', () => { + const mockedRange = { + type: 'image', + } as any; + + updateCacheSpy = spyOn(updateCache, 'updateCache'); + contentModelToDomSpy.and.returnValue(mockedRange); + + core.selection = { + selection: 'SELECTION' as any, + tableSelection: null, + }; + setContentModel(core, mockedModel); + + expect(flushMutationsSpy).toHaveBeenCalledBefore(updateCacheSpy); + expect(updateCacheSpy).toHaveBeenCalledBefore(setDOMSelectionSpy); + }); }); diff --git a/packages/roosterjs-content-model-core/test/coreApi/setDOMSelection/setDOMSelectionTest.ts b/packages/roosterjs-content-model-core/test/coreApi/setDOMSelection/setDOMSelectionTest.ts index 0a8fab6898d..5ecbc2ffb25 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/setDOMSelection/setDOMSelectionTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/setDOMSelection/setDOMSelectionTest.ts @@ -1,6 +1,7 @@ import * as addRangeToSelection from '../../../lib/coreApi/setDOMSelection/addRangeToSelection'; import { DOMSelection, EditorCore } from 'roosterjs-content-model-types'; import { setDOMSelection } from '../../../lib/coreApi/setDOMSelection/setDOMSelection'; + import { DEFAULT_SELECTION_BORDER_COLOR, DEFAULT_TABLE_CELL_SELECTION_BACKGROUND_COLOR, @@ -21,6 +22,7 @@ describe('setDOMSelection', () => { let mockedRange = 'RANGE' as any; let createElementSpy: jasmine.Spy; let appendChildSpy: jasmine.Spy; + let getDOMSelectionSpy: jasmine.Spy; beforeEach(() => { querySelectorAllSpy = jasmine.createSpy('querySelectorAll'); @@ -38,6 +40,7 @@ describe('setDOMSelection', () => { createElementSpy = jasmine.createSpy('createElement').and.returnValue({ appendChild: appendChildSpy, }); + getDOMSelectionSpy = jasmine.createSpy('getDOMSelectionSpy').and.returnValue(null); doc = { querySelectorAll: querySelectorAllSpy, @@ -59,6 +62,7 @@ describe('setDOMSelection', () => { api: { triggerEvent: triggerEventSpy, setEditorStyle: setEditorStyleSpy, + getDOMSelection: getDOMSelectionSpy, }, domHelper: { hasFocus: hasFocusSpy, @@ -310,8 +314,8 @@ describe('setDOMSelection', () => { expect(setEditorStyleSpy).toHaveBeenCalledWith( core, '_DOMSelection', - 'outline-style:solid!important; outline-color:#DB626C!important;display: inline-flex;', - ['span:has(>img#image_0)'] + 'outline-style:solid!important; outline-color:#DB626C!important;', + ['#image_0'] ); expect(setEditorStyleSpy).toHaveBeenCalledWith( core, @@ -370,8 +374,8 @@ describe('setDOMSelection', () => { expect(setEditorStyleSpy).toHaveBeenCalledWith( core, '_DOMSelection', - 'outline-style:solid!important; outline-color:red!important;display: inline-flex;', - ['span:has(>img#image_0)'] + 'outline-style:solid!important; outline-color:red!important;', + ['#image_0'] ); expect(setEditorStyleSpy).toHaveBeenCalledWith( core, @@ -437,8 +441,8 @@ describe('setDOMSelection', () => { expect(setEditorStyleSpy).toHaveBeenCalledWith( coreValue, '_DOMSelection', - 'outline-style:solid!important; outline-color:DarkColorMock-red!important;display: inline-flex;', - ['span:has(>img#image_0)'] + 'outline-style:solid!important; outline-color:DarkColorMock-red!important;', + ['#image_0'] ); expect(setEditorStyleSpy).toHaveBeenCalledWith( coreValue, @@ -498,8 +502,8 @@ describe('setDOMSelection', () => { expect(setEditorStyleSpy).toHaveBeenCalledWith( core, '_DOMSelection', - 'outline-style:solid!important; outline-color:#DB626C!important;display: inline-flex;', - ['span:has(>img#image_0)'] + 'outline-style:solid!important; outline-color:#DB626C!important;', + ['#image_0'] ); expect(setEditorStyleSpy).toHaveBeenCalledWith( core, @@ -559,8 +563,8 @@ describe('setDOMSelection', () => { expect(setEditorStyleSpy).toHaveBeenCalledWith( core, '_DOMSelection', - 'outline-style:solid!important; outline-color:#DB626C!important;display: inline-flex;', - ['span:has(>img#image_0_0)'] + 'outline-style:solid!important; outline-color:#DB626C!important;', + ['#image_0_0'] ); expect(setEditorStyleSpy).toHaveBeenCalledWith( core, @@ -919,6 +923,128 @@ describe('setDOMSelection', () => { ); }); }); + + describe('Same selection', () => { + beforeEach(() => { + querySelectorAllSpy.and.returnValue([]); + }); + + function runTest( + originalSelection: DOMSelection | null, + newSelection: DOMSelection | null, + expectedCalled: boolean + ) { + getDOMSelectionSpy.and.returnValue(originalSelection); + + setDOMSelection(core, newSelection); + + if (expectedCalled) { + expect(triggerEventSpy).toHaveBeenCalledWith( + core, + { + eventType: 'selectionChanged', + newSelection: null, + }, + true + ); + expect(addRangeToSelectionSpy).not.toHaveBeenCalled(); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(3); + expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelection', null); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + core, + '_DOMSelectionHideCursor', + null + ); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + core, + '_DOMSelectionHideSelection', + null + ); + } else { + expect(triggerEventSpy).not.toHaveBeenCalled(); + expect(addRangeToSelectionSpy).not.toHaveBeenCalled(); + expect(setEditorStyleSpy).not.toHaveBeenCalled(); + } + } + + it('From null selection', () => { + runTest(null, null, true); + }); + + it('From range selection, same', () => { + runTest( + { + type: 'range', + range: { + startContainer: 'C1', + startOffset: 'O1', + endContainer: 'C2', + endOffset: 'O2', + } as any, + isReverted: false, + }, + { + type: 'range', + range: { + startContainer: 'C1', + startOffset: 'O1', + endContainer: 'C2', + endOffset: 'O2', + } as any, + isReverted: false, + }, + false + ); + }); + + it('From image selection, same', () => { + let mockedImage: any; + + mockedImage = { + parentElement: { + ownerDocument: doc, + firstElementChild: mockedImage, + lastElementChild: mockedImage, + appendChild: appendChildSpy, + }, + ownerDocument: doc, + } as any; + + runTest( + { + type: 'image', + image: mockedImage, + }, + { + type: 'image', + image: mockedImage, + }, + false + ); + }); + + it('From table selection, same', () => { + runTest( + { + type: 'table', + table: 'T1' as any, + firstColumn: 0, + firstRow: 0, + lastColumn: 1, + lastRow: 1, + }, + { + type: 'table', + table: 'T1' as any, + firstColumn: 0, + firstRow: 0, + lastColumn: 1, + lastRow: 1, + }, + false + ); + }); + }); }); function buildTable(tbody: boolean, thead: boolean = false, tfoot: boolean = false) { diff --git a/packages/roosterjs-content-model-core/test/corePlugin/cache/CachePluginTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/cache/CachePluginTest.ts index f924e9eb31d..c67de4cec50 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/cache/CachePluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/cache/CachePluginTest.ts @@ -373,4 +373,162 @@ describe('CachePlugin', () => { expect(reconcileSelectionSpy).not.toHaveBeenCalled(); }); }); + + describe('onMutation', () => { + let onMutation: (mutation: textMutationObserver.Mutation) => void; + let startObservingSpy: jasmine.Spy; + let stopObservingSpy: jasmine.Spy; + let mockedObserver: any; + let reconcileChildListSpy: jasmine.Spy; + let mockedIndexer: DomIndexer; + + beforeEach(() => { + reconcileChildListSpy = jasmine.createSpy('reconcileChildList'); + startObservingSpy = jasmine.createSpy('startObserving'); + stopObservingSpy = jasmine.createSpy('stopObserving'); + + mockedObserver = { + startObserving: startObservingSpy, + stopObserving: stopObservingSpy, + } as any; + + spyOn(textMutationObserver, 'createTextMutationObserver').and.callFake( + (_: any, _onMutation: any) => { + onMutation = _onMutation; + return mockedObserver; + } + ); + + init({}); + + mockedIndexer = { + reconcileSelection: reconcileSelectionSpy, + reconcileChildList: reconcileChildListSpy, + } as any; + }); + + it('unknown', () => { + const state = plugin.getState(); + state.cachedModel = 'MODEL' as any; + state.cachedSelection = 'SELECTION' as any; + state.domIndexer = mockedIndexer; + + onMutation({ type: 'unknown' }); + + expect(reconcileSelectionSpy).toHaveBeenCalledTimes(0); + expect(reconcileChildListSpy).toHaveBeenCalledTimes(0); + expect(state).toEqual({ + domIndexer: mockedIndexer, + textMutationObserver: mockedObserver, + cachedModel: undefined, + cachedSelection: undefined, + }); + }); + + it('text, can reconcile', () => { + reconcileSelectionSpy.and.returnValue(true); + + const state = plugin.getState(); + state.cachedModel = 'MODEL' as any; + state.cachedSelection = 'SELECTION' as any; + state.domIndexer = mockedIndexer; + + const mockedSelection = 'NEWSELECTION' as any; + + getDOMSelectionSpy.and.returnValue(mockedSelection); + + onMutation({ type: 'text' }); + + expect(reconcileSelectionSpy).toHaveBeenCalledTimes(1); + expect(reconcileChildListSpy).toHaveBeenCalledTimes(0); + expect(state).toEqual({ + domIndexer: mockedIndexer, + textMutationObserver: mockedObserver, + cachedModel: 'MODEL' as any, + cachedSelection: mockedSelection, + }); + }); + + it('text, cannot reconcile', () => { + reconcileSelectionSpy.and.returnValue(false); + + const state = plugin.getState(); + state.cachedModel = 'MODEL' as any; + state.cachedSelection = 'SELECTION' as any; + state.domIndexer = mockedIndexer; + + const mockedSelection = 'NEWSELECTION' as any; + + getDOMSelectionSpy.and.returnValue(mockedSelection); + + onMutation({ type: 'text' }); + + expect(reconcileSelectionSpy).toHaveBeenCalledTimes(1); + expect(reconcileChildListSpy).toHaveBeenCalledTimes(0); + expect(state).toEqual({ + domIndexer: mockedIndexer, + textMutationObserver: mockedObserver, + cachedModel: undefined, + cachedSelection: undefined, + }); + }); + + it('childList, cannot reconcile', () => { + const state = plugin.getState(); + state.cachedModel = 'MODEL' as any; + state.cachedSelection = 'SELECTION' as any; + state.domIndexer = mockedIndexer; + + const mockedSelection = 'NEWSELECTION' as any; + + getDOMSelectionSpy.and.returnValue(mockedSelection); + + reconcileChildListSpy.and.returnValue(false); + + onMutation({ + type: 'childList', + addedNodes: 'ADDED' as any, + removedNodes: 'REMOVED' as any, + }); + + expect(reconcileSelectionSpy).toHaveBeenCalledTimes(0); + expect(reconcileChildListSpy).toHaveBeenCalledTimes(1); + expect(reconcileChildListSpy).toHaveBeenCalledWith('ADDED', 'REMOVED'); + expect(state).toEqual({ + domIndexer: mockedIndexer, + textMutationObserver: mockedObserver, + cachedModel: undefined, + cachedSelection: undefined, + }); + }); + + it('childList, can reconcile', () => { + const state = plugin.getState(); + state.cachedModel = 'MODEL' as any; + state.cachedSelection = 'SELECTION' as any; + state.domIndexer = mockedIndexer; + + const mockedSelection = 'NEWSELECTION' as any; + + getDOMSelectionSpy.and.returnValue(mockedSelection); + + reconcileChildListSpy.and.returnValue(true); + + onMutation({ + type: 'childList', + addedNodes: 'ADDED' as any, + removedNodes: 'REMOVED' as any, + }); + + expect(reconcileSelectionSpy).toHaveBeenCalledTimes(0); + expect(reconcileChildListSpy).toHaveBeenCalledTimes(1); + expect(reconcileChildListSpy).toHaveBeenCalledWith('ADDED', 'REMOVED'); + expect(state).toEqual({ + domIndexer: mockedIndexer, + textMutationObserver: mockedObserver, + cachedModel: 'MODEL' as any, + cachedSelection: 'SELECTION' as any, + }); + }); + }); }); diff --git a/packages/roosterjs-content-model-core/test/corePlugin/cache/areSameSelectionTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/cache/areSameSelectionsTest.ts similarity index 74% rename from packages/roosterjs-content-model-core/test/corePlugin/cache/areSameSelectionTest.ts rename to packages/roosterjs-content-model-core/test/corePlugin/cache/areSameSelectionsTest.ts index b1b9be70ca2..6fe1da82186 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/cache/areSameSelectionTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/cache/areSameSelectionsTest.ts @@ -1,7 +1,7 @@ -import { areSameSelection } from '../../../lib/corePlugin/cache/areSameSelection'; +import { areSameSelections } from '../../../lib/corePlugin/cache/areSameSelections'; import { CacheSelection, DOMSelection } from 'roosterjs-content-model-types'; -describe('areSameSelection', () => { +describe('areSameSelections', () => { const startContainer = 'MockedStartContainer' as any; const endContainer = 'MockedEndContainer' as any; const startOffset = 1; @@ -9,8 +9,8 @@ describe('areSameSelection', () => { const table = 'MockedTable' as any; const image = 'MockedImage' as any; - function runTest(r1: DOMSelection, r2: CacheSelection, result: boolean) { - expect(areSameSelection(r1, r2)).toBe(result); + function runTest(r1: DOMSelection, r2: DOMSelection | CacheSelection, result: boolean) { + expect(areSameSelections(r1, r2)).toBe(result); } it('Same object', () => { @@ -256,6 +256,110 @@ describe('areSameSelection', () => { ); }); + it('different normal range - 5', () => { + runTest( + { + type: 'range', + range: { + startContainer, + endContainer, + startOffset, + endOffset, + } as any, + isReverted: false, + }, + { + type: 'range', + range: { + startContainer: 'Container 2' as any, + startOffset: startOffset, + endContainer: endContainer, + endOffset: endOffset, + } as any, + isReverted: false, + }, + false + ); + }); + + it('different normal range - 6', () => { + runTest( + { + type: 'range', + range: { + startContainer, + endContainer, + startOffset, + endOffset, + } as any, + isReverted: false, + }, + { + type: 'range', + range: { + startContainer: startContainer, + startOffset: startOffset, + endContainer: 'Container 2' as any, + endOffset: endOffset, + } as any, + isReverted: false, + }, + false + ); + }); + + it('different normal range - 7', () => { + runTest( + { + type: 'range', + range: { + startContainer, + endContainer, + startOffset, + endOffset, + } as any, + isReverted: false, + }, + { + type: 'range', + range: { + startContainer: startContainer, + startOffset: 3, + endContainer: endContainer, + endOffset: endOffset, + } as any, + isReverted: false, + }, + false + ); + }); + + it('different normal range - 8', () => { + runTest( + { + type: 'range', + range: { + startContainer, + endContainer, + startOffset, + endOffset, + } as any, + isReverted: false, + }, + { + type: 'range', + range: { + startContainer: startContainer, + startOffset: startOffset, + endContainer: endContainer, + endOffset: 4, + } as any, + isReverted: false, + }, + false + ); + }); + it('different table range - 1', () => { runTest( { diff --git a/packages/roosterjs-content-model-core/test/corePlugin/cache/textMutationObserverTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/cache/textMutationObserverTest.ts index d784fdd1435..16835b04b7a 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/cache/textMutationObserverTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/cache/textMutationObserverTest.ts @@ -1,17 +1,9 @@ import * as textMutationObserver from '../../../lib/corePlugin/cache/textMutationObserver'; -import { DomIndexer, TextMutationObserver } from 'roosterjs-content-model-types'; -import { DomIndexerImpl } from '../../../lib/corePlugin/cache/domIndexerImpl'; +import { TextMutationObserver } from 'roosterjs-content-model-types'; describe('TextMutationObserverImpl', () => { - let domIndexer: DomIndexer; - let onSkipMutation: jasmine.Spy; let observer: TextMutationObserver; - beforeEach(() => { - domIndexer = new DomIndexerImpl(); - onSkipMutation = jasmine.createSpy('onSkipMutation'); - }); - afterEach(() => { observer?.stopObserving(); }); @@ -19,39 +11,32 @@ describe('TextMutationObserverImpl', () => { it('init', () => { const div = document.createElement('div'); const onMutation = jasmine.createSpy('onMutation'); - textMutationObserver.createTextMutationObserver( - div, - domIndexer, - onMutation, - onSkipMutation - ); + textMutationObserver.createTextMutationObserver(div, onMutation); expect(onMutation).not.toHaveBeenCalled(); - expect(onSkipMutation).not.toHaveBeenCalled(); }); - it('not text change', async () => { + it('no text change', async () => { const div = document.createElement('div'); const onMutation = jasmine.createSpy('onMutation'); - observer = textMutationObserver.createTextMutationObserver( - div, - domIndexer, - onMutation, - onSkipMutation - ); + observer = textMutationObserver.createTextMutationObserver(div, onMutation); observer.startObserving(); - div.appendChild(document.createElement('br')); + const br = document.createElement('br'); + div.appendChild(br); await new Promise(resolve => { window.setTimeout(resolve, 10); }); expect(onMutation).toHaveBeenCalledTimes(1); - expect(onMutation).toHaveBeenCalledWith(false); - expect(onSkipMutation).not.toHaveBeenCalled(); + expect(onMutation).toHaveBeenCalledWith({ + type: 'childList', + addedNodes: [br], + removedNodes: [], + }); }); it('text change', async () => { @@ -61,12 +46,7 @@ describe('TextMutationObserverImpl', () => { div.appendChild(text); const onMutation = jasmine.createSpy('onMutation'); - observer = textMutationObserver.createTextMutationObserver( - div, - domIndexer, - onMutation, - onSkipMutation - ); + observer = textMutationObserver.createTextMutationObserver(div, onMutation); observer.startObserving(); @@ -77,8 +57,7 @@ describe('TextMutationObserverImpl', () => { }); expect(onMutation).toHaveBeenCalledTimes(1); - expect(onMutation).toHaveBeenCalledWith(true); - expect(onSkipMutation).not.toHaveBeenCalled(); + expect(onMutation).toHaveBeenCalledWith({ type: 'text' }); }); it('text change in deeper node', async () => { @@ -91,12 +70,7 @@ describe('TextMutationObserverImpl', () => { const onMutation = jasmine.createSpy('onMutation'); - observer = textMutationObserver.createTextMutationObserver( - div, - domIndexer, - onMutation, - onSkipMutation - ); + observer = textMutationObserver.createTextMutationObserver(div, onMutation); observer.startObserving(); @@ -107,8 +81,7 @@ describe('TextMutationObserverImpl', () => { }); expect(onMutation).toHaveBeenCalledTimes(1); - expect(onMutation).toHaveBeenCalledWith(true); - expect(onSkipMutation).not.toHaveBeenCalled(); + expect(onMutation).toHaveBeenCalledWith({ type: 'text' }); }); it('text and non-text change', async () => { @@ -119,25 +92,26 @@ describe('TextMutationObserverImpl', () => { const onMutation = jasmine.createSpy('onMutation'); - observer = textMutationObserver.createTextMutationObserver( - div, - domIndexer, - onMutation, - onSkipMutation - ); + observer = textMutationObserver.createTextMutationObserver(div, onMutation); observer.startObserving(); text.nodeValue = '1'; - div.appendChild(document.createElement('br')); + + const br = document.createElement('br'); + div.appendChild(br); await new Promise(resolve => { window.setTimeout(resolve, 10); }); - expect(onMutation).toHaveBeenCalledTimes(1); - expect(onMutation).toHaveBeenCalledWith(false); - expect(onSkipMutation).not.toHaveBeenCalled(); + expect(onMutation).toHaveBeenCalledTimes(2); + expect(onMutation).toHaveBeenCalledWith({ + type: 'childList', + addedNodes: [br], + removedNodes: [], + }); + expect(onMutation).toHaveBeenCalledWith({ type: 'text' }); }); it('flush mutation', async () => { @@ -147,12 +121,7 @@ describe('TextMutationObserverImpl', () => { div.appendChild(text); const onMutation = jasmine.createSpy('onMutation'); - observer = textMutationObserver.createTextMutationObserver( - div, - domIndexer, - onMutation, - onSkipMutation - ); + observer = textMutationObserver.createTextMutationObserver(div, onMutation); observer.startObserving(); @@ -163,8 +132,9 @@ describe('TextMutationObserverImpl', () => { window.setTimeout(resolve, 10); }); - expect(onMutation).toHaveBeenCalledWith(true); - expect(onSkipMutation).not.toHaveBeenCalled(); + expect(onMutation).toHaveBeenCalledWith({ + type: 'text', + }); }); it('flush mutation without change', async () => { @@ -174,12 +144,7 @@ describe('TextMutationObserverImpl', () => { div.appendChild(text); const onMutation = jasmine.createSpy('onMutation'); - observer = textMutationObserver.createTextMutationObserver( - div, - domIndexer, - onMutation, - onSkipMutation - ); + observer = textMutationObserver.createTextMutationObserver(div, onMutation); observer.startObserving(); observer.flushMutations(); @@ -189,7 +154,6 @@ describe('TextMutationObserverImpl', () => { }); expect(onMutation).not.toHaveBeenCalled(); - expect(onSkipMutation).not.toHaveBeenCalled(); }); it('flush mutation with a new model', async () => { @@ -199,12 +163,7 @@ describe('TextMutationObserverImpl', () => { div.appendChild(text); const onMutation = jasmine.createSpy('onMutation'); - observer = textMutationObserver.createTextMutationObserver( - div, - domIndexer, - onMutation, - onSkipMutation - ); + observer = textMutationObserver.createTextMutationObserver(div, onMutation); observer.startObserving(); @@ -218,7 +177,6 @@ describe('TextMutationObserverImpl', () => { }); expect(onMutation).not.toHaveBeenCalled(); - expect(onSkipMutation).toHaveBeenCalledWith(newModel); }); it('flush mutation when type in new line - 1', async () => { @@ -229,28 +187,24 @@ describe('TextMutationObserverImpl', () => { div.appendChild(br); const onMutation = jasmine.createSpy('onMutation'); - observer = textMutationObserver.createTextMutationObserver( - div, - domIndexer, - onMutation, - onSkipMutation - ); + observer = textMutationObserver.createTextMutationObserver(div, onMutation); observer.startObserving(); div.replaceChild(text, br); - const reconcileChildListSpy = spyOn(domIndexer, 'reconcileChildList').and.returnValue(true); - observer.flushMutations(); await new Promise(resolve => { window.setTimeout(resolve, 10); }); - expect(reconcileChildListSpy).toHaveBeenCalledWith([text], [br]); - expect(onMutation).not.toHaveBeenCalled(); - expect(onSkipMutation).not.toHaveBeenCalled(); + expect(onMutation).toHaveBeenCalledTimes(1); + expect(onMutation).toHaveBeenCalledWith({ + type: 'childList', + addedNodes: [text], + removedNodes: [br], + }); }); it('flush mutation when type in new line - 2', async () => { @@ -261,12 +215,7 @@ describe('TextMutationObserverImpl', () => { div.appendChild(br); const onMutation = jasmine.createSpy('onMutation'); - observer = textMutationObserver.createTextMutationObserver( - div, - domIndexer, - onMutation, - onSkipMutation - ); + observer = textMutationObserver.createTextMutationObserver(div, onMutation); observer.startObserving(); @@ -274,17 +223,19 @@ describe('TextMutationObserverImpl', () => { div.removeChild(br); text.nodeValue = 'test'; - const reconcileChildListSpy = spyOn(domIndexer, 'reconcileChildList').and.returnValue(true); - observer.flushMutations(); await new Promise(resolve => { window.setTimeout(resolve, 10); }); - expect(reconcileChildListSpy).toHaveBeenCalledWith([text], [br]); - expect(onMutation).toHaveBeenCalledWith(true); - expect(onSkipMutation).not.toHaveBeenCalled(); + expect(onMutation).toHaveBeenCalledTimes(2); + expect(onMutation).toHaveBeenCalledWith({ + type: 'childList', + addedNodes: [text], + removedNodes: [br], + }); + expect(onMutation).toHaveBeenCalledWith({ type: 'text' }); }); it('flush mutation when type in new line, fail to reconcile', async () => { @@ -295,30 +246,24 @@ describe('TextMutationObserverImpl', () => { div.appendChild(br); const onMutation = jasmine.createSpy('onMutation'); - observer = textMutationObserver.createTextMutationObserver( - div, - domIndexer, - onMutation, - onSkipMutation - ); + observer = textMutationObserver.createTextMutationObserver(div, onMutation); observer.startObserving(); div.replaceChild(text, br); - const reconcileChildListSpy = spyOn(domIndexer, 'reconcileChildList').and.returnValue( - false - ); - observer.flushMutations(); await new Promise(resolve => { window.setTimeout(resolve, 10); }); - expect(reconcileChildListSpy).toHaveBeenCalledWith([text], [br]); - expect(onMutation).toHaveBeenCalledWith(false); - expect(onSkipMutation).not.toHaveBeenCalled(); + expect(onMutation).toHaveBeenCalledTimes(1); + expect(onMutation).toHaveBeenCalledWith({ + type: 'childList', + addedNodes: [text], + removedNodes: [br], + }); }); it('mutation happens in different root', async () => { @@ -333,31 +278,21 @@ describe('TextMutationObserverImpl', () => { div.appendChild(div2); const onMutation = jasmine.createSpy('onMutation'); - observer = textMutationObserver.createTextMutationObserver( - div, - domIndexer, - onMutation, - onSkipMutation - ); + observer = textMutationObserver.createTextMutationObserver(div, onMutation); observer.startObserving(); div1.removeChild(br); div2.appendChild(text); - const reconcileChildListSpy = spyOn(domIndexer, 'reconcileChildList').and.returnValue( - false - ); - observer.flushMutations(); await new Promise(resolve => { window.setTimeout(resolve, 10); }); - expect(reconcileChildListSpy).not.toHaveBeenCalled(); - expect(onMutation).toHaveBeenCalledWith(false); - expect(onSkipMutation).not.toHaveBeenCalled(); + expect(onMutation).toHaveBeenCalledTimes(1); + expect(onMutation).toHaveBeenCalledWith({ type: 'unknown' }); }); it('attribute change', async () => { @@ -367,29 +302,19 @@ describe('TextMutationObserverImpl', () => { div.appendChild(div1); const onMutation = jasmine.createSpy('onMutation'); - observer = textMutationObserver.createTextMutationObserver( - div, - domIndexer, - onMutation, - onSkipMutation - ); + observer = textMutationObserver.createTextMutationObserver(div, onMutation); observer.startObserving(); div1.id = 'div1'; - const reconcileChildListSpy = spyOn(domIndexer, 'reconcileChildList').and.returnValue( - false - ); - observer.flushMutations(); await new Promise(resolve => { window.setTimeout(resolve, 10); }); - expect(reconcileChildListSpy).not.toHaveBeenCalled(); - expect(onMutation).toHaveBeenCalledWith(false); - expect(onSkipMutation).not.toHaveBeenCalled(); + expect(onMutation).toHaveBeenCalledTimes(1); + expect(onMutation).toHaveBeenCalledWith({ type: 'unknown' }); }); }); diff --git a/packages/roosterjs-content-model-core/test/corePlugin/cache/updateCachedSelectionTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/cache/updateCacheTest.ts similarity index 75% rename from packages/roosterjs-content-model-core/test/corePlugin/cache/updateCachedSelectionTest.ts rename to packages/roosterjs-content-model-core/test/corePlugin/cache/updateCacheTest.ts index eb42933c3f2..7ebfc132833 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/cache/updateCachedSelectionTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/cache/updateCacheTest.ts @@ -1,14 +1,17 @@ import { CachePluginState } from 'roosterjs-content-model-types'; -import { updateCachedSelection } from '../../../lib/corePlugin/cache/updateCachedSelection'; +import { updateCache } from '../../../lib/corePlugin/cache/updateCache'; + +describe('updateCache', () => { + const mockedModel = 'MODEL' as any; -describe('updateCachedSelection', () => { it('Update to undefined', () => { const state: CachePluginState = {}; - updateCachedSelection(state, undefined); + updateCache(state, mockedModel, undefined); expect(state).toEqual({ cachedSelection: undefined, + cachedModel: mockedModel, }); }); @@ -18,10 +21,11 @@ describe('updateCachedSelection', () => { type: 'table', } as any; - updateCachedSelection(state, mockedSelection); + updateCache(state, mockedModel, mockedSelection); expect(state).toEqual({ cachedSelection: mockedSelection, + cachedModel: mockedModel, }); }); @@ -31,10 +35,11 @@ describe('updateCachedSelection', () => { type: 'image', } as any; - updateCachedSelection(state, mockedSelection); + updateCache(state, mockedModel, mockedSelection); expect(state).toEqual({ cachedSelection: mockedSelection, + cachedModel: mockedModel, }); }); @@ -51,7 +56,7 @@ describe('updateCachedSelection', () => { isReverted: false, } as any; - updateCachedSelection(state, mockedSelection); + updateCache(state, mockedModel, mockedSelection); expect(state).toEqual({ cachedSelection: { @@ -66,6 +71,7 @@ describe('updateCachedSelection', () => { }, isReverted: false, } as any, + cachedModel: mockedModel, }); }); }); diff --git a/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts index 86a46a42508..e2243a23ec1 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts @@ -338,6 +338,7 @@ describe('SelectionPlugin handle image selection', () => { let domHelperSpy: jasmine.Spy; let requestAnimationFrameSpy: jasmine.Spy; let addEventListenerSpy: jasmine.Spy; + let findClosestElementAncestor: jasmine.Spy; beforeEach(() => { getDOMSelectionSpy = jasmine.createSpy('getDOMSelection'); @@ -352,7 +353,10 @@ describe('SelectionPlugin handle image selection', () => { requestAnimationFrame: requestAnimationFrameSpy, }, }); - domHelperSpy = jasmine.createSpy('domHelperSpy'); + findClosestElementAncestor = jasmine.createSpy('findClosestElementAncestor'); + domHelperSpy = jasmine.createSpy('domHelperSpy').and.returnValue({ + findClosestElementAncestor: findClosestElementAncestor, + }); editor = { getDOMHelper: domHelperSpy, @@ -407,21 +411,21 @@ describe('SelectionPlugin handle image selection', () => { eventType: 'mouseDown', rawEvent: { target: node, + button: 0, } as any, }); expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); - expect(setDOMSelectionSpy).toHaveBeenCalledWith({ - type: 'range', - range: mockedRange, - isReverted: false, - }); + expect(setDOMSelectionSpy).toHaveBeenCalledWith(null); }); - it('Image selection, mouse down to div, no parent of image', () => { + it('Image selection, mouse down with right click to div', () => { const mockedImage = { parentNode: { childNodes: [] }, } as any; + + mockedImage.parentNode.childNodes.push(mockedImage); + const mockedRange = { setStart: jasmine.createSpy('setStart'), collapse: jasmine.createSpy('collapse'), @@ -439,146 +443,95 @@ describe('SelectionPlugin handle image selection', () => { eventType: 'mouseDown', rawEvent: { target: node, + button: 2, } as any, }); - expect(setDOMSelectionSpy).toHaveBeenCalledTimes(0); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + expect(setDOMSelectionSpy).toHaveBeenCalledWith(null); }); - it('Image selection, mouse down to same image', () => { + it('Image selection, mouse down to div, no parent of image', () => { const mockedImage = { parentNode: { childNodes: [] }, } as any; - getDOMSelectionSpy.and.returnValue({ - type: 'image', - image: mockedImage, - }); - - plugin.onPluginEvent!({ - eventType: 'mouseDown', - rawEvent: { - target: mockedImage, - } as any, - }); - - expect(setDOMSelectionSpy).toHaveBeenCalledTimes(0); - }); - - it('Image selection, mouse down to same image right click', () => { - const parent = document.createElement('div'); - const mockedImage = document.createElement('img'); - parent.appendChild(mockedImage); - const range = document.createRange(); - range.selectNode(mockedImage); - - const preventDefaultSpy = jasmine.createSpy('preventDefault'); - - mockedImage.contentEditable = 'true'; + const mockedRange = { + setStart: jasmine.createSpy('setStart'), + collapse: jasmine.createSpy('collapse'), + }; getDOMSelectionSpy.and.returnValue({ type: 'image', image: mockedImage, }); - plugin.onPluginEvent!({ - eventType: 'mouseDown', - rawEvent: (>{ - target: mockedImage, - button: 2, - preventDefault: preventDefaultSpy, - }) as any, - }); - - expect(setDOMSelectionSpy).toHaveBeenCalledTimes(0); - expect(preventDefaultSpy).toHaveBeenCalled(); - }); - - it('Image selection, mouse down to image right click', () => { - const parent = document.createElement('div'); - const mockedImage = document.createElement('img'); - parent.appendChild(mockedImage); - - mockedImage.contentEditable = 'true'; - plugin.onPluginEvent!({ - eventType: 'mouseDown', - rawEvent: { - target: mockedImage, - button: 2, - } as any, - }); - - expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); - }); + createRangeSpy.and.returnValue(mockedRange); - it('Image selection, mouse down to div right click', () => { const node = document.createElement('div'); - plugin.onPluginEvent!({ eventType: 'mouseDown', rawEvent: { target: node, - button: 2, + button: 0, } as any, }); - expect(setDOMSelectionSpy).toHaveBeenCalledTimes(0); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + expect(setDOMSelectionSpy).toHaveBeenCalledWith(null); }); - it('no selection, mouse up to image, is clicking, isEditable', () => { - const parent = document.createElement('div'); - const mockedImage = document.createElement('img'); - parent.appendChild(mockedImage); - const range = document.createRange(); - range.selectNode(mockedImage); - - mockedImage.contentEditable = 'true'; + it('Image selection, mouse down to a image', () => { + const mockedImage = { + parentNode: { childNodes: [] }, + isContentEditable: true, + nodeType: 1, + tagName: 'IMG', + dispatchEvent: jasmine.createSpy('dispatchEvent'), + } as any; + getDOMSelectionSpy.and.returnValue(null); plugin.onPluginEvent!({ - eventType: 'mouseUp', - isClicking: true, + eventType: 'mouseDown', rawEvent: { target: mockedImage, + button: 0, } as any, }); expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); expect(setDOMSelectionSpy).toHaveBeenCalledWith({ - type: 'range', - range, - isReverted: false, + type: 'image', + image: mockedImage, }); }); - it('no selection, mouse up to image, is clicking, not isEditable', () => { - const mockedImage = document.createElement('img'); - - mockedImage.contentEditable = 'false'; - - plugin.onPluginEvent!({ - eventType: 'mouseUp', - isClicking: true, - rawEvent: { - target: mockedImage, - } as any, + it('Image selection, mouse down to same image', () => { + const mockedImage = { + parentNode: { childNodes: [] }, + isContentEditable: true, + nodeType: 1, + tagName: 'IMG', + dispatchEvent: jasmine.createSpy('dispatchEvent'), + } as any; + getDOMSelectionSpy.and.returnValue({ + type: 'image', + image: mockedImage, }); - expect(setDOMSelectionSpy).toHaveBeenCalledTimes(0); - }); - - it('no selection, mouse up to image, is not clicking, isEditable', () => { - const mockedImage = document.createElement('img'); - - mockedImage.contentEditable = 'true'; - plugin.onPluginEvent!({ - eventType: 'mouseUp', - isClicking: false, + eventType: 'mouseDown', rawEvent: { target: mockedImage, + button: 0, } as any, }); - expect(setDOMSelectionSpy).toHaveBeenCalledTimes(0); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(2); + expect(setDOMSelectionSpy).toHaveBeenCalledWith(null); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'image', + image: mockedImage, + }); }); it('key down - ESCAPE, no selection', () => { @@ -1436,11 +1389,11 @@ describe('SelectionPlugin handle table selection', () => { }; }); - const setStartSpy = jasmine.createSpy('setStart'); + const selectNodeContentsSpy = jasmine.createSpy('selectNodeContents'); const collapseSpy = jasmine.createSpy('collapse'); const preventDefaultSpy = jasmine.createSpy('preventDefault'); const mockedRange = { - setStart: setStartSpy, + selectNodeContents: selectNodeContentsSpy, collapse: collapseSpy, } as any; @@ -1469,7 +1422,8 @@ describe('SelectionPlugin handle table selection', () => { range: mockedRange, isReverted: false, }); - expect(setStartSpy).toHaveBeenCalledWith(td2, 0); + expect(selectNodeContentsSpy).toHaveBeenCalledWith(td2); + expect(collapseSpy).not.toHaveBeenCalled(); expect(announceSpy).not.toHaveBeenCalled(); expect(preventDefaultSpy).toHaveBeenCalledTimes(1); expect(time).toBe(2); @@ -1506,11 +1460,11 @@ describe('SelectionPlugin handle table selection', () => { }; }); - const setStartSpy = jasmine.createSpy('setStart'); + const selectNodeContentsSpy = jasmine.createSpy('selectNodeContents'); const collapseSpy = jasmine.createSpy('collapse'); const preventDefaultSpy = jasmine.createSpy('preventDefault'); const mockedRange = { - setStart: setStartSpy, + selectNodeContents: selectNodeContentsSpy, collapse: collapseSpy, } as any; @@ -1540,7 +1494,8 @@ describe('SelectionPlugin handle table selection', () => { range: mockedRange, isReverted: false, }); - expect(setStartSpy).toHaveBeenCalledWith(td1, 0); + expect(selectNodeContentsSpy).toHaveBeenCalledWith(td1); + expect(collapseSpy).not.toHaveBeenCalled(); expect(announceSpy).not.toHaveBeenCalled(); expect(preventDefaultSpy).toHaveBeenCalledTimes(1); expect(time).toBe(2); @@ -1577,11 +1532,11 @@ describe('SelectionPlugin handle table selection', () => { }; }); - const setStartSpy = jasmine.createSpy('setStart'); + const selectNodeContentsSpy = jasmine.createSpy('selectNodeContents'); const collapseSpy = jasmine.createSpy('collapse'); const preventDefaultSpy = jasmine.createSpy('preventDefault'); const mockedRange = { - setStart: setStartSpy, + selectNodeContents: selectNodeContentsSpy, collapse: collapseSpy, } as any; @@ -1610,7 +1565,8 @@ describe('SelectionPlugin handle table selection', () => { range: mockedRange, isReverted: false, }); - expect(setStartSpy).toHaveBeenCalledWith(td3, 0); + expect(selectNodeContentsSpy).toHaveBeenCalledWith(td3); + expect(collapseSpy).not.toHaveBeenCalled(); expect(announceSpy).not.toHaveBeenCalled(); expect(preventDefaultSpy).toHaveBeenCalledTimes(1); expect(time).toBe(2); @@ -2532,6 +2488,9 @@ describe('SelectionPlugin selectionChange on image selected', () => { addEventListenerSpy = jasmine.createSpy('addEventListener'); getRangeAtSpy = jasmine.createSpy('getRangeAt'); getSelectionSpy = jasmine.createSpy('getSelection').and.returnValue({ + focusNode: { + nodeName: 'SPAN', + }, getRangeAt: getRangeAtSpy, }); getDocumentSpy = jasmine.createSpy('getDocument').and.returnValue({ @@ -2589,7 +2548,7 @@ describe('SelectionPlugin selectionChange on image selected', () => { expect(setDOMSelectionSpy).toHaveBeenCalledWith({ type: 'range', range: { startContainer: {} } as Range, - isReverted: false, + isReverted: true, }); expect(getDOMSelectionSpy).toHaveBeenCalledTimes(1); }); diff --git a/packages/roosterjs-content-model-dom/lib/domUtils/selection/getDOMInsertPointRect.ts b/packages/roosterjs-content-model-dom/lib/domUtils/selection/getDOMInsertPointRect.ts index b4fb3795f55..b302cc7e284 100644 --- a/packages/roosterjs-content-model-dom/lib/domUtils/selection/getDOMInsertPointRect.ts +++ b/packages/roosterjs-content-model-dom/lib/domUtils/selection/getDOMInsertPointRect.ts @@ -8,19 +8,18 @@ import type { DOMInsertPoint, Rect } from 'roosterjs-content-model-types'; * @param pos The input DOM insert point */ export function getDOMInsertPointRect(doc: Document, pos: DOMInsertPoint): Rect | null { - let { node, offset } = pos; const range = doc.createRange(); - range.setStart(node, offset); - - // 1) try to get rect using range.getBoundingClientRect() - let rect = normalizeRect(range.getBoundingClientRect()); + return ( + tryGetRectFromPos(pos, range) ?? // 1. try get from the pos directly using getBoundingClientRect or getClientRects + tryGetRectFromPos((pos = normalizeInsertPoint(pos)), range) ?? // 2. try get normalized pos, this can work when insert point is inside text node + tryGetRectFromNode(pos.node) // 3. fallback to node rect using getBoundingClientRect + ); +} - if (rect) { - return rect; - } +function normalizeInsertPoint(pos: DOMInsertPoint) { + let { node, offset } = pos; - // 2) try to get rect using range.getClientRects while (node.lastChild) { if (offset == node.childNodes.length) { node = node.lastChild; @@ -31,34 +30,28 @@ export function getDOMInsertPointRect(doc: Document, pos: DOMInsertPoint): Rect } } - const rects = range.getClientRects && range.getClientRects(); - rect = rects && rects.length == 1 ? normalizeRect(rects[0]) : null; - if (rect) { - return rect; - } + return { node, offset }; +} - // 3) if node is text node, try inserting a SPAN and get the rect of SPAN for others - if (isNodeOfType(node, 'TEXT_NODE')) { - const span = node.ownerDocument.createElement('span'); +function tryGetRectFromPos(pos: DOMInsertPoint, range: Range): Rect | null { + const { node, offset } = pos; - span.textContent = '\u200b'; - range.insertNode(span); - rect = normalizeRect(span.getBoundingClientRect()); - span.parentNode?.removeChild(span); + range.setStart(node, offset); + range.setEnd(node, offset); - if (rect) { - return rect; - } - } + const rect = normalizeRect(range.getBoundingClientRect()); - // 4) try getBoundingClientRect on element - if (isNodeOfType(node, 'ELEMENT_NODE') && node.getBoundingClientRect) { - rect = normalizeRect(node.getBoundingClientRect()); + if (rect) { + return rect; + } else { + const rects = range.getClientRects && range.getClientRects(); - if (rect) { - return rect; - } + return rects && rects.length == 1 ? normalizeRect(rects[0]) : null; } +} - return null; +function tryGetRectFromNode(node: Node) { + return isNodeOfType(node, 'ELEMENT_NODE') && node.getBoundingClientRect + ? normalizeRect(node.getBoundingClientRect()) + : null; } diff --git a/packages/roosterjs-content-model-dom/lib/modelToDom/optimizers/removeUnnecessarySpan.ts b/packages/roosterjs-content-model-dom/lib/modelToDom/optimizers/removeUnnecessarySpan.ts index 3b0ccb11b88..ee048a395bb 100644 --- a/packages/roosterjs-content-model-dom/lib/modelToDom/optimizers/removeUnnecessarySpan.ts +++ b/packages/roosterjs-content-model-dom/lib/modelToDom/optimizers/removeUnnecessarySpan.ts @@ -8,8 +8,7 @@ export function removeUnnecessarySpan(root: Node) { if ( isNodeOfType(child, 'ELEMENT_NODE') && child.tagName == 'SPAN' && - child.attributes.length == 0 && - !isImageSpan(child) + child.attributes.length == 0 ) { const node = child; let refNode = child.nextSibling; @@ -27,11 +26,3 @@ export function removeUnnecessarySpan(root: Node) { } } } - -const isImageSpan = (child: HTMLElement) => { - return ( - isNodeOfType(child.firstChild, 'ELEMENT_NODE') && - child.firstChild.tagName == 'IMG' && - child.firstChild == child.lastChild - ); -}; diff --git a/packages/roosterjs-content-model-dom/test/modelToDom/optimizers/removeUnnecessarySpanTest.ts b/packages/roosterjs-content-model-dom/test/modelToDom/optimizers/removeUnnecessarySpanTest.ts index f2aab1c27e2..e3e1b42d363 100644 --- a/packages/roosterjs-content-model-dom/test/modelToDom/optimizers/removeUnnecessarySpanTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelToDom/optimizers/removeUnnecessarySpanTest.ts @@ -54,12 +54,12 @@ describe('removeUnnecessarySpan', () => { expect(div.innerHTML).toBe('test1test2test3'); }); - it('Do not remove image span', () => { + it('Remove image span', () => { const div = document.createElement('div'); - div.innerHTML = ''; + div.innerHTML = ''; removeUnnecessarySpan(div); - expect(div.innerHTML).toBe(''); + expect(div.innerHTML).toBe(''); }); }); diff --git a/packages/roosterjs-content-model-plugins/lib/autoFormat/hyphen/transformHyphen.ts b/packages/roosterjs-content-model-plugins/lib/autoFormat/hyphen/transformHyphen.ts index faf8a0bc51e..3d9e6907564 100644 --- a/packages/roosterjs-content-model-plugins/lib/autoFormat/hyphen/transformHyphen.ts +++ b/packages/roosterjs-content-model-plugins/lib/autoFormat/hyphen/transformHyphen.ts @@ -1,4 +1,4 @@ -import { splitTextSegment } from '../../pluginUtils/splitTextSegment'; +import { splitTextSegment } from 'roosterjs-content-model-api'; import type { ContentModelText, FormatContentModelContext, diff --git a/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLink.ts b/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLink.ts index 21efd02a57a..65993fffcc4 100644 --- a/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLink.ts +++ b/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLink.ts @@ -1,16 +1,18 @@ import { addLink, ChangeSource } from 'roosterjs-content-model-dom'; import { formatTextSegmentBeforeSelectionMarker, matchLink } from 'roosterjs-content-model-api'; -import type { IEditor, LinkData } from 'roosterjs-content-model-types'; +import type { ContentModelLink, IEditor, LinkData } from 'roosterjs-content-model-types'; /** * @internal */ export function createLink(editor: IEditor) { let anchorNode: Node | null = null; + const links: ContentModelLink[] = []; formatTextSegmentBeforeSelectionMarker( editor, (_model, linkSegment, _paragraph) => { if (linkSegment.link) { + links.push(linkSegment.link); return true; } let linkData: LinkData | null = null; @@ -22,6 +24,9 @@ export function createLink(editor: IEditor) { }, dataset: {}, }); + if (linkSegment.link) { + links.push(linkSegment.link); + } return true; } @@ -29,8 +34,8 @@ export function createLink(editor: IEditor) { }, { changeSource: ChangeSource.AutoLink, - onNodeCreated: (_modelElement, node) => { - if (!anchorNode) { + onNodeCreated: (modelElement, node) => { + if (!anchorNode && links.indexOf(modelElement as ContentModelLink) >= 0) { anchorNode = node; } }, diff --git a/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLinkAfterSpace.ts b/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLinkAfterSpace.ts index 91d293953ac..95898e30d08 100644 --- a/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLinkAfterSpace.ts +++ b/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLinkAfterSpace.ts @@ -1,5 +1,4 @@ -import { matchLink } from 'roosterjs-content-model-api'; -import { splitTextSegment } from '../../pluginUtils/splitTextSegment'; +import { matchLink, splitTextSegment } from 'roosterjs-content-model-api'; import type { ContentModelText, FormatContentModelContext, diff --git a/packages/roosterjs-content-model-plugins/lib/autoFormat/numbers/transformFraction.ts b/packages/roosterjs-content-model-plugins/lib/autoFormat/numbers/transformFraction.ts index 098fb556b7c..73012fdb174 100644 --- a/packages/roosterjs-content-model-plugins/lib/autoFormat/numbers/transformFraction.ts +++ b/packages/roosterjs-content-model-plugins/lib/autoFormat/numbers/transformFraction.ts @@ -1,4 +1,4 @@ -import { splitTextSegment } from '../../pluginUtils/splitTextSegment'; +import { splitTextSegment } from 'roosterjs-content-model-api'; import type { ContentModelText, FormatContentModelContext, diff --git a/packages/roosterjs-content-model-plugins/lib/autoFormat/numbers/transformOrdinals.ts b/packages/roosterjs-content-model-plugins/lib/autoFormat/numbers/transformOrdinals.ts index 2bef05ac209..49da2c899be 100644 --- a/packages/roosterjs-content-model-plugins/lib/autoFormat/numbers/transformOrdinals.ts +++ b/packages/roosterjs-content-model-plugins/lib/autoFormat/numbers/transformOrdinals.ts @@ -1,4 +1,4 @@ -import { splitTextSegment } from '../../pluginUtils/splitTextSegment'; +import { splitTextSegment } from 'roosterjs-content-model-api'; import type { ContentModelText, FormatContentModelContext, diff --git a/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts index add783a2ab1..82bf6f54b52 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts @@ -11,9 +11,23 @@ import type { PluginEvent, } from 'roosterjs-content-model-types'; +/** + * Options to customize the keyboard handling behavior of Edit plugin + */ +export type EditOptions = { + /** + * Whether to handle Tab key in keyboard. @default true + */ + handleTabKey?: boolean; +}; + const BACKSPACE_KEY = 8; const DELETE_KEY = 46; +const DefaultOptions: Partial = { + handleTabKey: true, +}; + /** * Edit plugins helps editor to do editing operation on top of content model. * This includes: @@ -28,6 +42,12 @@ export class EditPlugin implements EditorPlugin { private selectionAfterDelete: DOMSelection | null = null; private handleNormalEnter = false; + /** + * @param options An optional parameter that takes in an object of type EditOptions, which includes the following properties: + * handleTabKey: A boolean that enables or disables Tab key handling. Defaults to true. + */ + constructor(private options: EditOptions = DefaultOptions) {} + /** * Get name of this plugin */ @@ -98,6 +118,7 @@ export class EditPlugin implements EditorPlugin { willHandleEventExclusively(event: PluginEvent) { if ( this.editor && + this.options.handleTabKey && event.eventType == 'keyDown' && event.rawEvent.key == 'Tab' && !event.rawEvent.shiftKey @@ -148,7 +169,9 @@ export class EditPlugin implements EditorPlugin { break; case 'Tab': - keyboardTab(editor, rawEvent); + if (this.options.handleTabKey) { + keyboardTab(editor, rawEvent); + } break; case 'Unidentified': if (editor.getEnvironment().isAndroid) { diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts index 6cccb46a8e4..ba856beb358 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts @@ -3,19 +3,23 @@ import { canRegenerateImage } from './utils/canRegenerateImage'; import { checkIfImageWasResized, isASmallImage } from './utils/imageEditUtils'; import { createImageWrapper } from './utils/createImageWrapper'; import { Cropper } from './Cropper/cropperContext'; +import { findEditingImage } from './utils/findEditingImage'; import { getDropAndDragHelpers } from './utils/getDropAndDragHelpers'; import { getHTMLImageOptions } from './utils/getHTMLImageOptions'; -import { getSelectedImageMetadata } from './utils/updateImageEditInfo'; +import { getSelectedImage } from './utils/getSelectedImage'; +import { getSelectedImageMetadata, updateImageEditInfo } from './utils/updateImageEditInfo'; import { ImageEditElementClass } from './types/ImageEditElementClass'; import { Resizer } from './Resizer/resizerContext'; import { Rotator } from './Rotator/rotatorContext'; import { updateRotateHandle } from './Rotator/updateRotateHandle'; import { updateWrapper } from './utils/updateWrapper'; + import { - getSelectedSegmentsAndParagraphs, isElementOfType, + isModifierKey, isNodeOfType, mutateSegment, + toArray, unwrap, } from 'roosterjs-content-model-dom'; import type { DragAndDropHelper } from '../pluginUtils/DragAndDrop/DragAndDropHelper'; @@ -23,11 +27,14 @@ import type { DragAndDropContext } from './types/DragAndDropContext'; import type { ImageHtmlOptions } from './types/ImageHtmlOptions'; import type { ImageEditOptions } from './types/ImageEditOptions'; import type { + ContentModelImage, EditorPlugin, IEditor, ImageEditOperation, ImageEditor, ImageMetadataFormat, + KeyDownEvent, + MouseUpEvent, PluginEvent, } from 'roosterjs-content-model-types'; @@ -38,10 +45,10 @@ const DefaultOptions: Partial = { preserveRatio: true, disableRotate: false, disableSideResize: false, - onSelectState: 'resize', + onSelectState: ['resize', 'rotate'], }; -const IMAGE_EDIT_CHANGE_SOURCE = 'ImageEdit'; +const MouseRightButton = 2; /** * ImageEdit plugin handles the following image editing features: @@ -54,7 +61,7 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { protected editor: IEditor | null = null; private shadowSpan: HTMLSpanElement | null = null; private selectedImage: HTMLImageElement | null = null; - public wrapper: HTMLSpanElement | null = null; + protected wrapper: HTMLSpanElement | null = null; private imageEditInfo: ImageMetadataFormat | null = null; private imageHTMLOptions: ImageHtmlOptions | null = null; private dndHelpers: DragAndDropHelper[] = []; @@ -67,6 +74,8 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { private croppers: HTMLDivElement[] = []; private zoomScale: number = 1; private disposer: (() => void) | null = null; + //EXPOSED FOR TEST ONLY + protected isEditing = false; constructor(protected options: ImageEditOptions = DefaultOptions) {} @@ -88,11 +97,13 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { this.disposer = editor.attachDomEvent({ blur: { beforeDispatch: () => { - this.formatImageWithContentModel( - editor, - true /* shouldSelectImage */, - true /* shouldSelectAsImageSelection*/ - ); + if (this.editor) { + this.applyFormatWithContentModel( + this.editor, + this.isCropMode, + true /* shouldSelectImage */ + ); + } }, }, }); @@ -105,6 +116,7 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { */ dispose() { this.editor = null; + this.isEditing = false; this.cleanInfo(); if (this.disposer) { this.disposer(); @@ -118,18 +130,191 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { * exclusively by another plugin. * @param event The event to handle: */ - onPluginEvent(_event: PluginEvent) {} + onPluginEvent(event: PluginEvent) { + if (!this.editor) { + return; + } + switch (event.eventType) { + case 'mouseUp': + this.mouseUpHandler(this.editor, event); + break; + case 'keyDown': + this.keyDownHandler(this.editor, event); + break; + } + } + + private isImageSelection(target: Node) { + return ( + isNodeOfType(target, 'ELEMENT_NODE') && + (isElementOfType(target, 'img') || + !!( + isElementOfType(target, 'span') && + target.firstElementChild && + isNodeOfType(target.firstElementChild, 'ELEMENT_NODE') && + isElementOfType(target.firstElementChild, 'img') + )) + ); + } + + private mouseUpHandler(editor: IEditor, event: MouseUpEvent) { + const selection = editor.getDOMSelection(); + if ((selection && selection.type == 'image') || this.isEditing) { + const shouldSelectImage = + this.isImageSelection(event.rawEvent.target as Node) && + event.rawEvent.button === MouseRightButton; + this.applyFormatWithContentModel(editor, this.isCropMode, shouldSelectImage); + } + } + + //Sometimes the cursor can be inside the editing image and inside shadow dom, then the cursor need to moved out of shadow dom + private selectBeforeEditingImage(editor: IEditor, element: HTMLElement) { + let parent = element.parentNode; + if (parent && isNodeOfType(parent, 'ELEMENT_NODE') && parent.shadowRoot) { + element = parent; + parent = parent.parentNode; + } + const index = parent && toArray(parent.childNodes).indexOf(element); + if (index !== null && index >= 0 && parent) { + const doc = editor.getDocument(); + const range = doc.createRange(); + range.setStart(parent, index); + range.collapse(); + editor.setDOMSelection({ + type: 'range', + range, + isReverted: false, + }); + } + } + + private keyDownHandler(editor: IEditor, event: KeyDownEvent) { + if (this.isEditing) { + if (event.rawEvent.key === 'Escape') { + this.removeImageWrapper(); + } else { + const selection = editor.getDOMSelection(); + const isImageSelection = selection?.type == 'image'; + if (isImageSelection) { + this.selectBeforeEditingImage(editor, selection.image); + } + this.applyFormatWithContentModel( + editor, + this.isCropMode, + (isModifierKey(event.rawEvent) || event.rawEvent.shiftKey) && isImageSelection //if it's a modifier key over a image, the image should select the image + ); + } + } + } + + private applyFormatWithContentModel( + editor: IEditor, + isCropMode: boolean, + shouldSelectImage: boolean, + isApiOperation?: boolean + ) { + let editingImageModel: ContentModelImage | undefined; + const selection = editor.getDOMSelection(); + editor.formatContentModel( + model => { + const editingImage = getSelectedImage(model); + const previousSelectedImage = isApiOperation + ? editingImage + : findEditingImage(model); + + let result = false; + if ( + shouldSelectImage || + previousSelectedImage?.image != editingImage?.image || + previousSelectedImage?.image.dataset.isEditing || + isApiOperation + ) { + const { lastSrc, selectedImage, imageEditInfo, clonedImage } = this; + if ( + (this.isEditing || isApiOperation) && + previousSelectedImage && + lastSrc && + selectedImage && + imageEditInfo && + clonedImage + ) { + mutateSegment( + previousSelectedImage.paragraph, + previousSelectedImage.image, + image => { + applyChange( + editor, + selectedImage, + image, + imageEditInfo, + lastSrc, + this.wasImageResized || this.isCropMode, + clonedImage + ); + delete image.dataset.isEditing; + image.isSelected = shouldSelectImage; + image.isSelectedAsImageSelection = shouldSelectImage; + } + ); + this.cleanInfo(); + result = true; + } + + this.isEditing = false; + this.isCropMode = false; + + if ( + editingImage && + selection?.type == 'image' && + !shouldSelectImage && + !isApiOperation + ) { + this.isEditing = true; + this.isCropMode = isCropMode; + mutateSegment(editingImage.paragraph, editingImage.image, image => { + editingImageModel = image; + this.imageEditInfo = updateImageEditInfo(image, selection.image); + image.dataset.isEditing = 'true'; + }); + + result = true; + } + } + + return result; + }, + { + onNodeCreated: (model, node) => { + if ( + !isApiOperation && + editingImageModel && + editingImageModel == model && + editingImageModel.dataset.isEditing && + isNodeOfType(node, 'ELEMENT_NODE') && + isElementOfType(node, 'img') + ) { + if (isCropMode) { + this.startCropMode(editor, node); + } else { + this.startRotateAndResize(editor, node); + } + } + }, + }, + { + tryGetFromCache: true, + } + ); + } private startEditing( editor: IEditor, image: HTMLImageElement, - apiOperation?: ImageEditOperation + apiOperation: ImageEditOperation[] ) { - const imageSpan = image.parentElement; - if (!imageSpan || (imageSpan && !isElementOfType(imageSpan, 'span'))) { - return; + if (!this.imageEditInfo) { + this.imageEditInfo = getSelectedImageMetadata(editor, image); } - this.imageEditInfo = getSelectedImageMetadata(editor, image); this.lastSrc = image.getAttribute('src'); this.imageHTMLOptions = getHTMLImageOptions(editor, this.options, this.imageEditInfo); const { @@ -142,11 +327,10 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { } = createImageWrapper( editor, image, - imageSpan, this.options, this.imageEditInfo, this.imageHTMLOptions, - apiOperation || this.options.onSelectState + apiOperation ); this.shadowSpan = shadowSpan; this.selectedImage = image; @@ -163,93 +347,87 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { ]); } - public startRotateAndResize( - editor: IEditor, - image: HTMLImageElement, - apiOperation?: 'resize' | 'rotate' - ) { - if (this.wrapper && this.selectedImage && this.shadowSpan) { - this.removeImageWrapper(); - } - this.startEditing(editor, image, apiOperation); - if (this.selectedImage && this.imageEditInfo && this.wrapper && this.clonedImage) { - this.dndHelpers = [ - ...getDropAndDragHelpers( - this.wrapper, + public startRotateAndResize(editor: IEditor, image: HTMLImageElement) { + if (this.imageEditInfo) { + this.startEditing(editor, image, ['resize', 'rotate']); + if (this.selectedImage && this.imageEditInfo && this.wrapper && this.clonedImage) { + this.dndHelpers = [ + ...getDropAndDragHelpers( + this.wrapper, + this.imageEditInfo, + this.options, + ImageEditElementClass.ResizeHandle, + Resizer, + () => { + if ( + this.imageEditInfo && + this.selectedImage && + this.wrapper && + this.clonedImage + ) { + updateWrapper( + this.imageEditInfo, + this.options, + this.selectedImage, + this.clonedImage, + this.wrapper, + this.resizers + ); + this.wasImageResized = true; + } + }, + this.zoomScale + ), + ...getDropAndDragHelpers( + this.wrapper, + this.imageEditInfo, + this.options, + ImageEditElementClass.RotateHandle, + Rotator, + () => { + if ( + this.imageEditInfo && + this.selectedImage && + this.wrapper && + this.clonedImage + ) { + updateWrapper( + this.imageEditInfo, + this.options, + this.selectedImage, + this.clonedImage, + this.wrapper + ); + this.updateRotateHandleState( + editor, + this.selectedImage, + this.wrapper, + this.rotators, + this.imageEditInfo?.angleRad + ); + } + }, + this.zoomScale + ), + ]; + + updateWrapper( this.imageEditInfo, this.options, - ImageEditElementClass.ResizeHandle, - Resizer, - () => { - if ( - this.imageEditInfo && - this.selectedImage && - this.wrapper && - this.clonedImage - ) { - updateWrapper( - this.imageEditInfo, - this.options, - this.selectedImage, - this.clonedImage, - this.wrapper, - this.resizers - ); - this.wasImageResized = true; - } - }, - this.zoomScale - ), - ...getDropAndDragHelpers( + this.selectedImage, + this.clonedImage, this.wrapper, - this.imageEditInfo, - this.options, - ImageEditElementClass.RotateHandle, - Rotator, - () => { - if ( - this.imageEditInfo && - this.selectedImage && - this.wrapper && - this.clonedImage - ) { - updateWrapper( - this.imageEditInfo, - this.options, - this.selectedImage, - this.clonedImage, - this.wrapper, - this.rotators - ); - this.updateRotateHandleState( - editor, - this.selectedImage, - this.wrapper, - this.rotators, - this.imageEditInfo?.angleRad - ); - } - }, - this.zoomScale - ), - ]; - - updateWrapper( - this.imageEditInfo, - this.options, - this.selectedImage, - this.clonedImage, - this.wrapper, - this.resizers - ); + this.resizers + ); - this.updateRotateHandleState( - editor, - this.selectedImage, - this.wrapper, - this.rotators, - this.imageEditInfo?.angleRad - ); + this.updateRotateHandleState( + editor, + this.selectedImage, + this.wrapper, + this.rotators, + this.imageEditInfo?.angleRad + ); + } } } @@ -282,77 +460,87 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { } public isOperationAllowed(operation: ImageEditOperation): boolean { - return operation === 'resize' || operation === 'rotate' || operation === 'flip'; + return ( + operation === 'resize' || + operation === 'rotate' || + operation === 'flip' || + operation === 'crop' + ); } public canRegenerateImage(image: HTMLImageElement): boolean { return canRegenerateImage(image); } + private startCropMode(editor: IEditor, image: HTMLImageElement) { + if (this.imageEditInfo) { + this.startEditing(editor, image, ['crop']); + if (this.imageEditInfo && this.selectedImage && this.wrapper && this.clonedImage) { + this.dndHelpers = [ + ...getDropAndDragHelpers( + this.wrapper, + this.imageEditInfo, + this.options, + ImageEditElementClass.CropHandle, + Cropper, + () => { + if ( + this.imageEditInfo && + this.selectedImage && + this.wrapper && + this.clonedImage + ) { + updateWrapper( + this.imageEditInfo, + this.options, + this.selectedImage, + this.clonedImage, + this.wrapper, + undefined, + this.croppers + ); + this.isCropMode = true; + } + }, + this.zoomScale + ), + ]; + updateWrapper( + this.imageEditInfo, + this.options, + this.selectedImage, + this.clonedImage, + this.wrapper, + undefined, + this.croppers + ); + } + } + } + public cropImage() { - const selection = this.editor?.getDOMSelection(); - if (!this.editor || !selection || selection.type !== 'image') { + if (!this.editor) { return; } - let image = selection.image; - if (this.wrapper && this.selectedImage && this.shadowSpan) { - image = this.removeImageWrapper() ?? image; + if (!this.editor.getEnvironment().isSafari) { + this.editor.focus(); // Safari will keep the selection when click crop, then the focus() call should not be called } - - this.startEditing(this.editor, image, 'crop'); - if (!this.selectedImage || !this.imageEditInfo || !this.wrapper || !this.clonedImage) { - return; + const selection = this.editor.getDOMSelection(); + if (selection?.type == 'image') { + this.applyFormatWithContentModel( + this.editor, + true /* isCropMode */, + false /* shouldSelectImage */ + ); } - this.dndHelpers = [ - ...getDropAndDragHelpers( - this.wrapper, - this.imageEditInfo, - this.options, - ImageEditElementClass.CropHandle, - Cropper, - () => { - if ( - this.imageEditInfo && - this.selectedImage && - this.wrapper && - this.clonedImage - ) { - updateWrapper( - this.imageEditInfo, - this.options, - this.selectedImage, - this.clonedImage, - this.wrapper, - undefined, - this.croppers - ); - this.isCropMode = true; - } - }, - this.zoomScale - ), - ]; - - updateWrapper( - this.imageEditInfo, - this.options, - this.selectedImage, - this.clonedImage, - this.wrapper, - undefined, - this.croppers - ); } private editImage( editor: IEditor, image: HTMLImageElement, - apiOperation: ImageEditOperation, + apiOperation: ImageEditOperation[], operation: (imageEditInfo: ImageMetadataFormat) => void ) { - if (this.wrapper && this.selectedImage && this.shadowSpan) { - image = this.removeImageWrapper() ?? image; - } this.startEditing(editor, image, apiOperation); if (!this.selectedImage || !this.imageEditInfo || !this.wrapper || !this.clonedImage) { return; @@ -368,10 +556,11 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { this.wrapper ); - this.formatImageWithContentModel( + this.applyFormatWithContentModel( editor, + false /* isCrop */, true /* shouldSelect*/, - true /* shouldSelectAsImageSelection*/ + true /* isApiOperation */ ); } @@ -393,67 +582,6 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { this.croppers = []; } - private formatImageWithContentModel( - editor: IEditor, - shouldSelectImage: boolean, - shouldSelectAsImageSelection: boolean - ) { - if ( - this.lastSrc && - this.selectedImage && - this.imageEditInfo && - this.clonedImage && - this.shadowSpan - ) { - editor.formatContentModel( - model => { - const selectedSegmentsAndParagraphs = getSelectedSegmentsAndParagraphs( - model, - false - ); - if (!selectedSegmentsAndParagraphs[0]) { - return false; - } - - const segment = selectedSegmentsAndParagraphs[0][0]; - const paragraph = selectedSegmentsAndParagraphs[0][1]; - - if (paragraph && segment.segmentType == 'Image') { - mutateSegment(paragraph, segment, image => { - if ( - this.lastSrc && - this.selectedImage && - this.imageEditInfo && - this.clonedImage - ) { - applyChange( - editor, - this.selectedImage, - image, - this.imageEditInfo, - this.lastSrc, - this.wasImageResized || this.isCropMode, - this.clonedImage - ); - image.isSelected = shouldSelectImage; - image.isSelectedAsImageSelection = shouldSelectAsImageSelection; - } - }); - return true; - } - - return false; - }, - { - changeSource: IMAGE_EDIT_CHANGE_SOURCE, - onNodeCreated: () => { - this.cleanInfo(); - }, - } - ); - } - } - private removeImageWrapper() { let image: HTMLImageElement | null = null; if (this.shadowSpan && this.shadowSpan.parentElement) { @@ -479,7 +607,7 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { } const image = selection.image; if (this.editor) { - this.editImage(this.editor, image, 'flip', imageEditInfo => { + this.editImage(this.editor, image, ['flip'], imageEditInfo => { const angleRad = imageEditInfo.angleRad || 0; const isInVerticalPostion = (angleRad >= Math.PI / 2 && angleRad < (3 * Math.PI) / 4) || @@ -508,14 +636,14 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { } const image = selection.image; if (this.editor) { - this.editImage(this.editor, image, 'rotate', imageEditInfo => { + this.editImage(this.editor, image, [], imageEditInfo => { imageEditInfo.angleRad = (imageEditInfo.angleRad || 0) + angleRad; }); } } //EXPOSED FOR TEST ONLY - public getWrapper() { - return this.wrapper; + public get isEditingImage() { + return this.isEditing; } } diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/types/ImageAndParagraph.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/types/ImageAndParagraph.ts new file mode 100644 index 00000000000..c767ff662ef --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/types/ImageAndParagraph.ts @@ -0,0 +1,12 @@ +import type { + ReadonlyContentModelImage, + ReadonlyContentModelParagraph, +} from 'roosterjs-content-model-types'; + +/** + * @internal + */ +export interface ImageAndParagraph { + image: ReadonlyContentModelImage; + paragraph: ReadonlyContentModelParagraph; +} diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/types/ImageEditOptions.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/types/ImageEditOptions.ts index 9aec93b20a1..b0dd532b7a1 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/types/ImageEditOptions.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/types/ImageEditOptions.ts @@ -61,5 +61,5 @@ export interface ImageEditOptions { * Which operations will be executed when image is selected * @default resizeAndRotate */ - onSelectState?: ImageEditOperation; + onSelectState?: ImageEditOperation[]; } diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/applyChange.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/applyChange.ts index af259f85fdb..afcacbcaaf0 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/applyChange.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/applyChange.ts @@ -1,7 +1,7 @@ import { checkEditInfoState } from './checkEditInfoState'; import { generateDataURL } from './generateDataURL'; import { getGeneratedImageSize } from './generateImageSize'; -import { getSelectedImageMetadata, updateImageEditInfo } from './updateImageEditInfo'; +import { updateImageEditInfo } from './updateImageEditInfo'; import type { ContentModelImage, IEditor, @@ -28,7 +28,8 @@ export function applyChange( editingImage?: HTMLImageElement ) { let newSrc = ''; - const initEditInfo = getSelectedImageMetadata(editor, editingImage ?? image) ?? undefined; + const imageEditing = editingImage ?? image; + const initEditInfo = updateImageEditInfo(contentModelImage, imageEditing) ?? undefined; const state = checkEditInfoState(editInfo, initEditInfo); switch (state) { @@ -64,11 +65,11 @@ export function applyChange( if (newSrc == editInfo.src) { // If newSrc is the same with original one, it means there is only size change, but no rotation, no cropping, // so we don't need to keep edit info, we can delete it - updateImageEditInfo(contentModelImage, null); + updateImageEditInfo(contentModelImage, imageEditing, null); } else { // Otherwise, save the new edit info to the image so that next time when we edit the same image, we know // the edit info - updateImageEditInfo(contentModelImage, editInfo); + updateImageEditInfo(contentModelImage, imageEditing, editInfo); } // Write back the change to image, and set its new size @@ -82,11 +83,5 @@ export function applyChange( if (wasResizedOrCropped || state == 'FullyChanged') { contentModelImage.format.width = generatedImageSize.targetWidth + 'px'; contentModelImage.format.height = generatedImageSize.targetHeight + 'px'; - - // Remove width/height style so that it won't affect the image size, since style width/height has higher priority - image.style.removeProperty('width'); - image.style.removeProperty('height'); - image.style.removeProperty('max-width'); - image.style.removeProperty('max-height'); } } diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/createImageWrapper.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/createImageWrapper.ts index 9a11e44565f..b1fceadb007 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/createImageWrapper.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/createImageWrapper.ts @@ -1,6 +1,7 @@ import { createImageCropper } from '../Cropper/createImageCropper'; import { createImageResizer } from '../Resizer/createImageResizer'; import { createImageRotator } from '../Rotator/createImageRotator'; +import { wrap } from 'roosterjs-content-model-dom'; import type { IEditor, @@ -28,26 +29,25 @@ export interface WrapperElements { export function createImageWrapper( editor: IEditor, image: HTMLImageElement, - imageSpan: HTMLSpanElement, options: ImageEditOptions, editInfo: ImageMetadataFormat, htmlOptions: ImageHtmlOptions, - operation?: ImageEditOperation + operation: ImageEditOperation[] ): WrapperElements { const imageClone = cloneImage(image, editInfo); const doc = editor.getDocument(); let rotators: HTMLDivElement[] = []; - if (!options.disableRotate && operation === 'rotate') { + if (!options.disableRotate && operation.indexOf('rotate') > -1) { rotators = createImageRotator(doc, htmlOptions); } let resizers: HTMLDivElement[] = []; - if (operation === 'resize') { + if (operation.indexOf('resize') > -1) { resizers = createImageResizer(doc); } let croppers: HTMLDivElement[] = []; - if (operation === 'crop') { + if (operation.indexOf('crop') > -1) { croppers = createImageCropper(doc); } @@ -60,6 +60,7 @@ export function createImageWrapper( rotators, croppers ); + const imageSpan = wrap(doc, image, 'span'); const shadowSpan = createShadowSpan(wrapper, imageSpan); return { wrapper, shadowSpan, imageClone, resizers, rotators, croppers }; } @@ -93,11 +94,11 @@ const createWrapper = ( imageBox.appendChild(image); wrapper.setAttribute( 'style', - `max-width: 100%; position: relative; display: inline-flex; font-size: 24px; margin: 0px; transform: rotate(${ - editInfo.angleRad ?? 0 - }rad); text-align: left;` + `font-size: 24px; margin: 0px; transform: rotate(${editInfo.angleRad ?? 0}rad);` ); - wrapper.style.display = editor.getEnvironment().isSafari ? 'inline-block' : 'inline-flex'; + wrapper.style.display = editor.getEnvironment().isSafari + ? '-webkit-inline-flex' + : 'inline-flex'; const border = createBorder(editor, options.borderColor); wrapper.appendChild(imageBox); diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/findEditingImage.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/findEditingImage.ts new file mode 100644 index 00000000000..d4c2351dd75 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/findEditingImage.ts @@ -0,0 +1,48 @@ +import type { ReadonlyContentModelBlockGroup } from 'roosterjs-content-model-types'; +import type { ImageAndParagraph } from '../types/ImageAndParagraph'; + +/** + * @internal + */ +export function findEditingImage(group: ReadonlyContentModelBlockGroup): ImageAndParagraph | null { + for (let i = 0; i < group.blocks.length; i++) { + const block = group.blocks[i]; + + switch (block.blockType) { + case 'BlockGroup': + const result = findEditingImage(block); + + if (result) { + return result; + } + break; + + case 'Paragraph': + for (let j = 0; j < block.segments.length; j++) { + const segment = block.segments[j]; + switch (segment.segmentType) { + case 'Image': + if (segment.dataset.isEditing) { + return { + paragraph: block, + image: segment, + }; + } + break; + + case 'General': + const result = findEditingImage(segment); + + if (result) { + return result; + } + break; + } + } + + break; + } + } + + return null; +} diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/getSelectedContentModelImage.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/getSelectedContentModelImage.ts deleted file mode 100644 index 3d9085f8778..00000000000 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/getSelectedContentModelImage.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { getSelectedSegments } from 'roosterjs-content-model-dom'; -import type { - ReadonlyContentModelImage, - ShallowMutableContentModelDocument, -} from 'roosterjs-content-model-types'; - -/** - * @internal - */ -export function getSelectedContentModelImage( - model: ShallowMutableContentModelDocument -): ReadonlyContentModelImage | null { - const selectedSegments = getSelectedSegments(model, false /*includeFormatHolder*/); - if (selectedSegments.length == 1 && selectedSegments[0].segmentType == 'Image') { - return selectedSegments[0]; - } - - return null; -} diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/getSelectedImage.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/getSelectedImage.ts new file mode 100644 index 00000000000..c517106f2b8 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/getSelectedImage.ts @@ -0,0 +1,19 @@ +import { getSelectedSegmentsAndParagraphs } from 'roosterjs-content-model-dom'; +import type { ReadonlyContentModelDocument } from 'roosterjs-content-model-types'; +import type { ImageAndParagraph } from '../types/ImageAndParagraph'; + +/** + * @internal + */ +export function getSelectedImage(model: ReadonlyContentModelDocument): ImageAndParagraph | null { + const selections = getSelectedSegmentsAndParagraphs(model, false); + + if (selections.length == 1 && selections[0][0].segmentType == 'Image' && selections[0][1]) { + return { + image: selections[0][0], + paragraph: selections[0][1], + }; + } else { + return null; + } +} diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/updateImageEditInfo.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/updateImageEditInfo.ts index 7edf511774d..0831491d762 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/updateImageEditInfo.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/updateImageEditInfo.ts @@ -1,5 +1,6 @@ -import { getSelectedContentModelImage } from './getSelectedContentModelImage'; -import { updateImageMetadata } from 'roosterjs-content-model-dom'; +import { getSelectedImage } from './getSelectedImage'; +import { mutateSegment, updateImageMetadata } from 'roosterjs-content-model-dom'; + import type { ContentModelImage, IEditor, @@ -11,9 +12,10 @@ import type { */ export function updateImageEditInfo( contentModelImage: ContentModelImage, - newImageMetadata?: ImageMetadataFormat | null -) { - updateImageMetadata( + image: HTMLImageElement, + newImageMetadata?: ImageMetadataFormat | null | undefined +): ImageMetadataFormat { + const contentModelMetadata = updateImageMetadata( contentModelImage, newImageMetadata !== undefined ? format => { @@ -22,6 +24,7 @@ export function updateImageEditInfo( } : undefined ); + return { ...getInitialEditInfo(image), ...contentModelMetadata }; } function getInitialEditInfo(image: HTMLImageElement): ImageMetadataFormat { @@ -49,9 +52,13 @@ export function getSelectedImageMetadata( ): ImageMetadataFormat { let imageMetadata: ImageMetadataFormat = getInitialEditInfo(image); editor.formatContentModel(model => { - const selectedImage = getSelectedContentModelImage(model); - if (selectedImage) { - imageMetadata = { ...imageMetadata, ...selectedImage.dataset }; + const selectedImage = getSelectedImage(model); + if (selectedImage?.image) { + mutateSegment(selectedImage.paragraph, selectedImage?.image, modelImage => { + imageMetadata = updateImageEditInfo(modelImage, image); + }); + + return true; } return false; }); diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/updateWrapper.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/updateWrapper.ts index 43a8b58980d..4911d5860ad 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/updateWrapper.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/updateWrapper.ts @@ -109,7 +109,7 @@ export function updateWrapper( setSize(cropOverlays[2], cropLeftPx, undefined, 0, 0, undefined, cropBottomPx); setSize(cropOverlays[3], 0, cropTopPx, undefined, 0, cropLeftPx, undefined); - if (angleRad) { + if (angleRad !== undefined) { updateHandleCursor(croppers, angleRad); } } @@ -132,7 +132,7 @@ export function updateWrapper( }) .filter(handle => !!handle) as HTMLDivElement[]; - if (angleRad) { + if (angleRad !== undefined) { updateHandleCursor(resizeHandles, angleRad); } diff --git a/packages/roosterjs-content-model-plugins/lib/index.ts b/packages/roosterjs-content-model-plugins/lib/index.ts index 50920741895..e00daab314d 100644 --- a/packages/roosterjs-content-model-plugins/lib/index.ts +++ b/packages/roosterjs-content-model-plugins/lib/index.ts @@ -2,7 +2,7 @@ export { TableEditPlugin } from './tableEdit/TableEditPlugin'; export { OnTableEditorCreatedCallback } from './tableEdit/OnTableEditorCreatedCallback'; export { TableEditFeatureName } from './tableEdit/editors/features/TableEditFeatureName'; export { PastePlugin } from './paste/PastePlugin'; -export { EditPlugin } from './edit/EditPlugin'; +export { EditPlugin, EditOptions } from './edit/EditPlugin'; export { AutoFormatPlugin, AutoFormatOptions } from './autoFormat/AutoFormatPlugin'; export { diff --git a/packages/roosterjs-content-model-plugins/lib/markdown/utils/setFormat.ts b/packages/roosterjs-content-model-plugins/lib/markdown/utils/setFormat.ts index 9d03d41447c..3205c5ce8b0 100644 --- a/packages/roosterjs-content-model-plugins/lib/markdown/utils/setFormat.ts +++ b/packages/roosterjs-content-model-plugins/lib/markdown/utils/setFormat.ts @@ -1,6 +1,7 @@ -import { formatTextSegmentBeforeSelectionMarker } from 'roosterjs-content-model-api'; -import { splitTextSegment } from '../../pluginUtils/splitTextSegment'; - +import { + formatTextSegmentBeforeSelectionMarker, + splitTextSegment, +} from 'roosterjs-content-model-api'; import type { ContentModelCodeFormat, ContentModelSegmentFormat, diff --git a/packages/roosterjs-content-model-plugins/lib/picker/getQueryString.ts b/packages/roosterjs-content-model-plugins/lib/picker/getQueryString.ts index c52ddc76e9e..5e156477737 100644 --- a/packages/roosterjs-content-model-plugins/lib/picker/getQueryString.ts +++ b/packages/roosterjs-content-model-plugins/lib/picker/getQueryString.ts @@ -1,4 +1,4 @@ -import { splitTextSegment } from '../pluginUtils/splitTextSegment'; +import { splitTextSegment } from 'roosterjs-content-model-api'; import type { ContentModelText, ShallowMutableContentModelParagraph, diff --git a/packages/roosterjs-content-model-plugins/lib/tableEdit/editors/TableEditor.ts b/packages/roosterjs-content-model-plugins/lib/tableEdit/editors/TableEditor.ts index ed296dc3900..818f11056c5 100644 --- a/packages/roosterjs-content-model-plugins/lib/tableEdit/editors/TableEditor.ts +++ b/packages/roosterjs-content-model-plugins/lib/tableEdit/editors/TableEditor.ts @@ -379,8 +379,10 @@ export class TableEditor { this.editor.takeSnapshot(); } - private onEndTableMove = () => { - this.disposeTableMover(); + private onEndTableMove = (disposeHandler: boolean) => { + if (disposeHandler) { + this.disposeTableMover(); + } return this.onFinishEditing(); }; diff --git a/packages/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableMover.ts b/packages/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableMover.ts index 6cef76ca3a3..0585e056fac 100644 --- a/packages/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableMover.ts +++ b/packages/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableMover.ts @@ -44,7 +44,7 @@ export function createTableMover( isRTL: boolean, onFinishDragging: (table: HTMLTableElement) => void, onStart: () => void, - onEnd: () => void, + onEnd: (disposeHandler: boolean) => void, contentDiv?: EventTarget | null, anchorContainer?: HTMLElement, onTableEditorCreated?: OnTableEditorCreatedCallback, @@ -118,7 +118,7 @@ export interface TableMoverContext { div: HTMLElement; onFinishDragging: (table: HTMLTableElement) => void; onStart: () => void; - onEnd: () => void; + onEnd: (disposeHandler: boolean) => void; disableMovement?: boolean; } @@ -298,9 +298,9 @@ export function onDragEnd( setTableMoverCursor(editor, false); if (element == context.div) { - // Table mover was only clicked, select whole table + // Table mover was only clicked, select whole table and do not dismiss the handler element. selectWholeTable(table); - context.onEnd(); + context.onEnd(false /* disposeHandler */); return true; } else { // Check if table was dragged on itself, element is not in editor, or movement is disabled @@ -310,7 +310,7 @@ export function onDragEnd( disableMovement ) { editor.setDOMSelection(initValue?.initialSelection ?? null); - context.onEnd(); + context.onEnd(true /* disposeHandler */); return false; } @@ -376,7 +376,7 @@ export function onDragEnd( // No movement, restore initial selection editor.setDOMSelection(initValue?.initialSelection ?? null); } - context.onEnd(); + context.onEnd(true /* disposeHandler */); return insertionSuccess; } } diff --git a/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts b/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts index 9296ddbc17a..bbe1a0acb9b 100644 --- a/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts @@ -123,6 +123,23 @@ describe('EditPlugin', () => { expect(keyboardEnterSpy).not.toHaveBeenCalled(); }); + it('Tab, Tab handling not enabled', () => { + plugin = new EditPlugin({ handleTabKey: false }); + const rawEvent = { key: 'Tab' } as any; + + plugin.initialize(editor); + + plugin.onPluginEvent({ + eventType: 'keyDown', + rawEvent, + }); + + expect(keyboardTabSpy).not.toHaveBeenCalled(); + expect(keyboardInputSpy).not.toHaveBeenCalled(); + expect(keyboardDeleteSpy).not.toHaveBeenCalled(); + expect(keyboardEnterSpy).not.toHaveBeenCalled(); + }); + it('Enter, normal enter not enabled', () => { plugin = new EditPlugin(); const rawEvent = { which: 13, key: 'Enter' } as any; diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts index 4b6c7dba2f5..8ab5af40299 100644 --- a/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts @@ -40,10 +40,244 @@ const model: ContentModelDocument = { }; describe('ImageEditPlugin', () => { - const plugin = new ImageEditPlugin(); - const editor = initEditor('image_edit', [plugin], model); + it('keyDown', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Image', + src: 'test', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + id: 'image_0', + maxWidth: '1800px', + }, + dataset: { + isEditing: 'true', + }, + isSelectedAsImageSelection: true, + isSelected: true, + }, + ], + format: {}, + segmentFormat: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + }, + }, + ], + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + }; + const plugin = new ImageEditPlugin(); + const editor = initEditor('image_edit', [plugin], model); + plugin.initialize(editor); + plugin.onPluginEvent({ + eventType: 'mouseUp', + isClicking: true, + rawEvent: { + button: 0, + } as any, + }); + plugin.onPluginEvent({ + eventType: 'keyDown', + rawEvent: { + key: 'k', + } as any, + }); + expect(plugin.isEditingImage).toBeFalsy(); + plugin.dispose(); + }); + + it('mouseUp', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Image', + src: 'test', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + id: 'image_0', + maxWidth: '1800px', + }, + dataset: {}, + isSelectedAsImageSelection: true, + isSelected: true, + }, + ], + format: {}, + segmentFormat: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + }, + }, + ], + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + }; + const plugin = new ImageEditPlugin(); + const editor = initEditor('image_edit', [plugin], model); + plugin.initialize(editor); + plugin.onPluginEvent({ + eventType: 'mouseUp', + isClicking: true, + rawEvent: { + button: 0, + } as any, + }); + + expect(plugin.isEditingImage).toBeTruthy(); + plugin.dispose(); + }); + + it('mouseUp - left click - remove selection', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Image', + src: 'test', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + id: 'image_0', + maxWidth: '1800px', + }, + dataset: { + isEditing: 'true', + }, + isSelectedAsImageSelection: false, + isSelected: false, + }, + ], + format: {}, + segmentFormat: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + }, + }, + ], + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + }; + const plugin = new ImageEditPlugin(); + const editor = initEditor('image_edit', [plugin], model); + plugin.initialize(editor); + plugin.onPluginEvent({ + eventType: 'mouseUp', + isClicking: true, + rawEvent: { + button: 0, + } as any, + }); + expect(plugin.isEditingImage).toBeFalsy(); + plugin.dispose(); + }); + + it('mouseUp - right click - remove wrapper', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Image', + src: 'test', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + id: 'image_0', + maxWidth: '1800px', + }, + dataset: {}, + isSelectedAsImageSelection: true, + isSelected: true, + }, + ], + format: {}, + segmentFormat: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + }, + }, + ], + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + }; + const plugin = new ImageEditPlugin(); + const editor = initEditor('image_edit', [plugin], model); + plugin.initialize(editor); + plugin.onPluginEvent({ + eventType: 'mouseUp', + isClicking: true, + rawEvent: { + button: 0, + target: { + tagName: 'IMG', + } as any, + } as any, + }); + plugin.onPluginEvent({ + eventType: 'mouseUp', + isClicking: true, + rawEvent: { + button: 2, + target: { + tagName: 'IMG', + nodeType: 1, + } as any, + } as any, + }); + + expect(plugin.isEditingImage).toBeFalsy(); + plugin.dispose(); + }); + + it('cropImage', () => { + const plugin = new ImageEditPlugin(); + const editor = initEditor('image_edit', [plugin], model); + plugin.initialize(editor); + plugin.cropImage(); + expect(plugin.isEditingImage).toBeTruthy(); + plugin.dispose(); + }); it('flip', () => { + const plugin = new ImageEditPlugin(); + const editor = initEditor('image_edit', [plugin], model); const image = new Image(); image.src = 'test'; plugin.initialize(editor); @@ -54,6 +288,8 @@ describe('ImageEditPlugin', () => { }); it('rotate', () => { + const plugin = new ImageEditPlugin(); + const editor = initEditor('image_edit', [plugin], model); const image = new Image(); image.src = 'test'; plugin.initialize(editor); diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/Rotator/updateRotateHandleTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/Rotator/updateRotateHandleTest.ts index 98cbfaa7155..94d4691bc95 100644 --- a/packages/roosterjs-content-model-plugins/test/imageEdit/Rotator/updateRotateHandleTest.ts +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/Rotator/updateRotateHandleTest.ts @@ -57,15 +57,7 @@ xdescribe('updateRotateHandlePosition', () => { bottomPercent: 0, angleRad: 0, }; - const { wrapper } = createImageWrapper( - editor, - image, - imageSpan, - {}, - imageInfo, - options, - 'rotate' - ); + const { wrapper } = createImageWrapper(editor, image, {}, imageInfo, options, ['rotate']); const rotateCenter = wrapper.querySelector('.r_rotateC')! as HTMLElement; const rotateHandle = wrapper.querySelector('.r_rotateH')! as HTMLElement; spyOn(rotateHandle, 'getBoundingClientRect').and.returnValues(rotatePosition); diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/createImageWrapperTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/createImageWrapperTest.ts index b2cf97173e9..33ccfa11cee 100644 --- a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/createImageWrapperTest.ts +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/createImageWrapperTest.ts @@ -18,13 +18,13 @@ describe('createImageWrapper', () => { options: ImageEditOptions, editInfo: ImageMetadataFormat, htmlOptions: ImageHtmlOptions, - operation: ImageEditOperation | undefined, + operation: ImageEditOperation[], expectResult: WrapperElements ) { const result = createImageWrapper( editor, image, - imageSpan, + options, editInfo, htmlOptions, @@ -45,7 +45,7 @@ describe('createImageWrapper', () => { preserveRatio: true, disableRotate: false, disableSideResize: false, - onSelectState: 'resize', + onSelectState: ['resize'], }; const editInfo = { src: 'test', @@ -69,7 +69,7 @@ describe('createImageWrapper', () => { const shadowSpan = createShadowSpan(wrapper); const imageClone = cloneImage(image, editInfo); - runTest(image, imageSpan, options, editInfo, htmlOptions, 'resize', { + runTest(image, imageSpan, options, editInfo, htmlOptions, ['resize'], { wrapper, shadowSpan, imageClone, @@ -92,7 +92,7 @@ describe('createImageWrapper', () => { preserveRatio: true, disableRotate: false, disableSideResize: false, - onSelectState: 'rotate', + onSelectState: ['rotate'], }; const editInfo = { src: 'test', @@ -116,7 +116,7 @@ describe('createImageWrapper', () => { const shadowSpan = createShadowSpan(wrapper); const imageClone = cloneImage(image, editInfo); - runTest(image, imageSpan, options, editInfo, htmlOptions, 'rotate', { + runTest(image, imageSpan, options, editInfo, htmlOptions, ['rotate'], { wrapper: wrapper, shadowSpan: shadowSpan, imageClone: imageClone, @@ -139,7 +139,7 @@ describe('createImageWrapper', () => { preserveRatio: true, disableRotate: false, disableSideResize: false, - onSelectState: 'resize', + onSelectState: ['resize'], }; const editInfo = { src: 'test', @@ -171,7 +171,7 @@ describe('createImageWrapper', () => { const shadowSpan = createShadowSpan(wrapper); const imageClone = cloneImage(image, editInfo); - runTest(image, imageSpan, options, editInfo, htmlOptions, 'crop', { + runTest(image, imageSpan, options, editInfo, htmlOptions, ['crop'], { wrapper, shadowSpan, imageClone, diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/findEditingImageTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/findEditingImageTest.ts new file mode 100644 index 00000000000..c85e55b326c --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/findEditingImageTest.ts @@ -0,0 +1,109 @@ +import { ContentModelDocument } from 'roosterjs-content-model-types'; +import { findEditingImage } from '../../../lib/imageEdit/utils/findEditingImage'; + +describe('findEditingImage', () => { + it('no image', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + segmentFormat: {}, + }, + ], + format: {}, + }; + + const image = findEditingImage(model); + expect(image).toBeNull(); + }); + + it('editing image', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Image', + src: 'test', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + id: 'image_0', + maxWidth: '1800px', + }, + dataset: { + isEditing: 'true', + }, + }, + ], + format: {}, + segmentFormat: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + }, + }, + ], + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + }; + + const image = findEditingImage(model); + expect(image).toEqual({ + image: { + segmentType: 'Image', + src: 'test', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + id: 'image_0', + maxWidth: '1800px', + }, + dataset: { + isEditing: 'true', + }, + }, + paragraph: { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Image', + src: 'test', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + id: 'image_0', + maxWidth: '1800px', + }, + dataset: { + isEditing: 'true', + }, + }, + ], + format: {}, + segmentFormat: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + }, + }, + }); + }); +}); diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/getDropAndDragHelpersTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/getDropAndDragHelpersTest.ts index 74f61b59aec..8a7fd5ccfe1 100644 --- a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/getDropAndDragHelpersTest.ts +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/getDropAndDragHelpersTest.ts @@ -23,7 +23,7 @@ describe('getDropAndDragHelpers', () => { preserveRatio: true, disableRotate: false, disableSideResize: false, - onSelectState: 'resize', + onSelectState: ['resize'], }; const editInfo = { src: 'test', diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/getHTMLImageOptionsTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/getHTMLImageOptionsTest.ts index 55381e09dac..0388b02ae3e 100644 --- a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/getHTMLImageOptionsTest.ts +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/getHTMLImageOptionsTest.ts @@ -29,7 +29,7 @@ describe('getHTMLImageOptions', () => { preserveRatio: true, disableRotate: false, disableSideResize: false, - onSelectState: 'resize', + onSelectState: ['resize'], }, { src: 'test', @@ -61,7 +61,7 @@ describe('getHTMLImageOptions', () => { preserveRatio: true, disableRotate: false, disableSideResize: false, - onSelectState: 'resize', + onSelectState: ['resize'], }, { src: 'test', diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/getSelectedContentModelImageTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/getSelectedImageTest.ts similarity index 51% rename from packages/roosterjs-content-model-plugins/test/imageEdit/utils/getSelectedContentModelImageTest.ts rename to packages/roosterjs-content-model-plugins/test/imageEdit/utils/getSelectedImageTest.ts index c4c152e3ca5..29261a8f640 100644 --- a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/getSelectedContentModelImageTest.ts +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/getSelectedImageTest.ts @@ -1,8 +1,8 @@ import { ContentModelDocument } from 'roosterjs-content-model-types'; -import { getSelectedContentModelImage } from '../../../lib/imageEdit/utils/getSelectedContentModelImage'; +import { getSelectedImage } from '../../../lib/imageEdit/utils/getSelectedImage'; -describe('getSelectedContentModelImage', () => { - it('should return image model', () => { +describe('getSelectedImage', () => { + it('get selected image', () => { const model: ContentModelDocument = { blockGroupType: 'Document', blocks: [ @@ -38,24 +38,52 @@ describe('getSelectedContentModelImage', () => { textColor: '#000000', }, }; - const result = getSelectedContentModelImage(model); - expect(result).toEqual({ - segmentType: 'Image', - src: 'test', - format: { - fontFamily: 'Calibri', - fontSize: '11pt', - textColor: 'rgb(0, 0, 0)', - id: 'image_0', - maxWidth: '1800px', + + const selections = getSelectedImage(model); + expect(selections).toEqual({ + image: { + segmentType: 'Image', + src: 'test', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + id: 'image_0', + maxWidth: '1800px', + }, + dataset: {}, + isSelectedAsImageSelection: true, + isSelected: true, + }, + paragraph: { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Image', + src: 'test', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + id: 'image_0', + maxWidth: '1800px', + }, + dataset: {}, + isSelectedAsImageSelection: true, + isSelected: true, + }, + ], + format: {}, + segmentFormat: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + }, }, - dataset: {}, - isSelectedAsImageSelection: true, - isSelected: true, }); }); - it('should not return image model', () => { + it('no image selected', () => { const model: ContentModelDocument = { blockGroupType: 'Document', blocks: [ @@ -63,18 +91,13 @@ describe('getSelectedContentModelImage', () => { blockType: 'Paragraph', segments: [ { - segmentType: 'Image', - src: 'test', - format: { - fontFamily: 'Calibri', - fontSize: '11pt', - textColor: 'rgb(0, 0, 0)', - id: 'image_0', - maxWidth: '1800px', - }, - dataset: {}, - isSelectedAsImageSelection: false, - isSelected: false, + segmentType: 'Text', + format: {}, + text: 'test', + }, + { + segmentType: 'SelectionMarker', + format: {}, }, ], format: {}, @@ -91,7 +114,8 @@ describe('getSelectedContentModelImage', () => { textColor: '#000000', }, }; - const result = getSelectedContentModelImage(model); - expect(result).toEqual(null); + + const selections = getSelectedImage(model); + expect(selections).toEqual(null); }); }); diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/updateImageEditInfoTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/updateImageEditInfoTest.ts index 90ce33e4961..03a333b7746 100644 --- a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/updateImageEditInfoTest.ts +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/updateImageEditInfoTest.ts @@ -51,7 +51,7 @@ describe('updateImageEditInfo', () => { it('update image edit info', () => { const updateImageMetadataSpy = spyOn(updateImageMetadata, 'updateImageMetadata'); const contentModelImage = createImage('test'); - updateImageEditInfo(contentModelImage, { + updateImageEditInfo(contentModelImage, new Image(), { widthPx: 10, heightPx: 10, }); @@ -65,7 +65,7 @@ describe('getSelectedImageMetadata', () => { const image = new Image(10, 10); const metadata = getSelectedImageMetadata(editor, image); const expected = { - src: '', + src: 'test', widthPx: 0, heightPx: 0, naturalWidth: 0, @@ -75,7 +75,6 @@ describe('getSelectedImageMetadata', () => { topPercent: 0, bottomPercent: 0, angleRad: 0, - editingInfo: '{"src":"test"}', }; expect(metadata).toEqual(expected); }); diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/updateWrapperTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/updateWrapperTest.ts index df74a226e3b..9575c9c8b5b 100644 --- a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/updateWrapperTest.ts +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/updateWrapperTest.ts @@ -12,7 +12,7 @@ describe('updateWrapper', () => { preserveRatio: true, disableRotate: false, disableSideResize: false, - onSelectState: 'resize', + onSelectState: ['resize'], }; const editInfo = { src: 'test', @@ -32,19 +32,16 @@ describe('updateWrapper', () => { isSmallImage: false, }; const image = document.createElement('img'); - const imageSpan = document.createElement('span'); - imageSpan.appendChild(image); - document.body.appendChild(imageSpan); + document.body.appendChild(image); it('should update size', () => { const { wrapper, imageClone, resizers } = createImageWrapper( editor, image, - imageSpan, options, editInfo, htmlOptions, - 'resize' + ['resize'] ); editInfo.heightPx = 12; updateWrapper(editInfo, options, image, imageClone, wrapper, resizers); diff --git a/packages/roosterjs-content-model-plugins/test/tableEdit/tableEditorTest.ts b/packages/roosterjs-content-model-plugins/test/tableEdit/tableEditorTest.ts index 7cbe0623633..40c4c230a79 100644 --- a/packages/roosterjs-content-model-plugins/test/tableEdit/tableEditorTest.ts +++ b/packages/roosterjs-content-model-plugins/test/tableEdit/tableEditorTest.ts @@ -15,7 +15,7 @@ import { } from '../../lib/tableEdit/editors/features/CellResizer'; describe('TableEditor', () => { - describe('disableFeatures', () => { + xdescribe('disableFeatures', () => { const insideTheOffset = 5; let editor: IEditor; let table: HTMLTableElement; diff --git a/packages/roosterjs-content-model-plugins/test/tableEdit/tableMoverTest.ts b/packages/roosterjs-content-model-plugins/test/tableEdit/tableMoverTest.ts index 8650338381d..a2da136c503 100644 --- a/packages/roosterjs-content-model-plugins/test/tableEdit/tableMoverTest.ts +++ b/packages/roosterjs-content-model-plugins/test/tableEdit/tableMoverTest.ts @@ -18,119 +18,122 @@ describe('Table Mover Tests', () => { let targetId = 'tableSelectionTestId'; let tableEdit: TableEditPlugin; let node: HTMLDivElement; - const cmTable: ContentModelTable = { - blockType: 'Table', - rows: [ - { - height: 20, - format: {}, - cells: [ - { - blockGroupType: 'TableCell', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: 'a1', - format: {}, - }, - ], - format: {}, - isImplicit: true, - }, - ], - format: {}, - spanLeft: false, - spanAbove: false, - isHeader: false, - dataset: {}, - }, - { - blockGroupType: 'TableCell', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: 'z1', - format: {}, - }, - ], - format: {}, - }, - ], - format: {}, - spanLeft: false, - spanAbove: false, - isHeader: false, - dataset: {}, - }, - ], - }, - { - height: 20, - format: {}, - cells: [ - { - blockGroupType: 'TableCell', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: 'a2', - format: {}, - }, - ], - format: {}, - isImplicit: true, - }, - ], - format: {}, - spanLeft: false, - spanAbove: false, - isHeader: false, - dataset: {}, - }, - { - blockGroupType: 'TableCell', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: 'z2', - format: {}, - }, - { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, - ], - format: {}, - }, - ], - format: {}, - spanLeft: false, - spanAbove: false, - isHeader: false, - dataset: {}, - }, - ], + + function createCmTable(): ContentModelTable { + return { + blockType: 'Table', + rows: [ + { + height: 20, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'a1', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'z1', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + { + height: 20, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'a2', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'z2', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: { + id: `${targetId}`, }, - ], - format: { - id: `${targetId}`, - }, - widths: [10, 10], - dataset: {}, - }; + widths: [10, 10], + dataset: {}, + }; + } beforeEach(() => { document.body.innerHTML = ''; @@ -143,7 +146,7 @@ describe('Table Mover Tests', () => { plugins: [tableEdit], initialModel: { blockGroupType: 'Document', - blocks: [{ ...cmTable }], + blocks: [createCmTable()], format: {}, }, }; @@ -362,6 +365,70 @@ describe('Table Mover Tests', () => { expect(parseFloat(divRect.style.left)).toBeGreaterThan(0); }); + it('Do not dismiss the TableMover if only clicking the handler element', () => { + //Act + const table = document.createElement('table'); + const div = document.createElement('div'); + const onFinishDragging = jasmine.createSpy('onFinishDragging'); + const onStart = jasmine.createSpy('onStart'); + const onEnd = jasmine.createSpy('onEnd'); + + const context: TableMoverContext = { + table, + zoomScale: 1, + rect: null, + isRTL: true, + editor, + div, + onFinishDragging, + onStart, + onEnd, + disableMovement: false, + }; + + onDragEnd( + context, + { + target: div, + }, + undefined + ); + + expect(onEnd).toHaveBeenCalledWith(false); + }); + + it('Dismiss the TableMover if drag end did not end in the handler element', () => { + //Act + const table = document.createElement('table'); + const div = document.createElement('div'); + const onFinishDragging = jasmine.createSpy('onFinishDragging'); + const onStart = jasmine.createSpy('onStart'); + const onEnd = jasmine.createSpy('onEnd'); + + const context: TableMoverContext = { + table, + zoomScale: 1, + rect: null, + isRTL: true, + editor, + div, + onFinishDragging, + onStart, + onEnd, + disableMovement: false, + }; + + onDragEnd( + context, + { + target: table, + }, + undefined + ); + + expect(onEnd).toHaveBeenCalledWith(true); + }); + describe('Move - onDragEnd', () => { let target: HTMLTableElement; const nodeHeight = 300; @@ -529,7 +596,7 @@ describe('Table Mover Tests', () => { const divRect = document.createElement('div'); const initValue: TableMoverInitValue = { - cmTable: cmTable, + cmTable: createCmTable(), initialSelection: null, tableRect: divRect, }; @@ -619,7 +686,7 @@ describe('Table Mover Tests', () => { const divRect = document.createElement('div'); const initValue: TableMoverInitValue = { - cmTable: cmTable, + cmTable: createCmTable(), initialSelection: null, tableRect: divRect, }; @@ -701,7 +768,7 @@ describe('Table Mover Tests', () => { const divRect = document.createElement('div'); const initValue: TableMoverInitValue = { - cmTable: cmTable, + cmTable: createCmTable(), initialSelection: null, tableRect: divRect, }; diff --git a/packages/roosterjs-content-model-types/lib/context/TextMutationObserver.ts b/packages/roosterjs-content-model-types/lib/context/TextMutationObserver.ts index f24566d9a8f..187dae08b22 100644 --- a/packages/roosterjs-content-model-types/lib/context/TextMutationObserver.ts +++ b/packages/roosterjs-content-model-types/lib/context/TextMutationObserver.ts @@ -1,5 +1,3 @@ -import type { ContentModelDocument } from '../contentModel/blockGroup/ContentModelDocument'; - /** * A wrapper of MutationObserver to observe text change from editor */ @@ -15,7 +13,9 @@ export interface TextMutationObserver { stopObserving(): void; /** - * Flush all pending mutations that have not be handled in order to ignore them + * Flush all pending mutations and update cached model if need + * @param ignoreMutations When pass true, all mutations will be ignored and do not update content model. + * This should only be used when we already have a up-to-date content model and will set it as latest cache */ - flushMutations(newModel?: ContentModelDocument): void; + flushMutations(ignoreMutations?: boolean): void; } diff --git a/versions.json b/versions.json index d89a4fc5514..ab18c15499d 100644 --- a/versions.json +++ b/versions.json @@ -1,8 +1,5 @@ { "react": "9.0.0", - "main": "9.6.0", - "legacyAdapter": "8.62.1", - "overrides": { - "roosterjs-content-model-plugins": "9.6.1" - } + "main": "9.7.0", + "legacyAdapter": "8.62.1" } diff --git a/yarn.lock b/yarn.lock index f216a2a5ad8..536a974e869 100644 --- a/yarn.lock +++ b/yarn.lock @@ -573,10 +573,10 @@ dependencies: esquery "^1.4.0" -"@socket.io/base64-arraybuffer@~1.0.2": - version "1.0.2" - resolved "https://registry.yarnpkg.com/@socket.io/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz#568d9beae00b0d835f4f8c53fd55714986492e61" - integrity sha512-dOlCBKnDw4iShaIsH/bxujKTM18+2TOAsYz+KSc11Am38H4q5Xw8Bbz97ZYdrVNM+um3p7w86Bvvmcn9q+5+eQ== +"@socket.io/component-emitter@~3.1.0": + version "3.1.2" + resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz#821f8442f4175d8f0467b9daf26e3a18e2d02af2" + integrity sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA== "@types/color-convert@*": version "2.0.0" @@ -597,11 +597,6 @@ dependencies: "@types/color-convert" "*" -"@types/component-emitter@^1.2.10": - version "1.2.11" - resolved "https://registry.yarnpkg.com/@types/component-emitter/-/component-emitter-1.2.11.tgz#50d47d42b347253817a39709fef03ce66a108506" - integrity sha512-SRXjM+tfsSlA9VuG8hGO2nft2p8zjXCK1VcC6N4NXbBbYbSia9kzCChYQajIjzIqOOOuh5Ock6MmV2oux4jDZQ== - "@types/cookie@^0.4.1": version "0.4.1" resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.4.1.tgz#bfd02c1f2224567676c1545199f87c3a861d878d" @@ -1813,7 +1808,7 @@ compare-versions@^3.6.0: resolved "https://registry.yarnpkg.com/compare-versions/-/compare-versions-3.6.0.tgz#1a5689913685e5a87637b8d3ffca75514ec41d62" integrity sha512-W6Af2Iw1z4CB7q4uU4hv646dW9GQuBM+YpC0UvUCWSD8w90SJjp+ujJuXaEMtAXBtSqGfMPuFOVn4/+FlaqfBA== -component-emitter@^1.2.1, component-emitter@~1.3.0: +component-emitter@^1.2.1: version "1.3.0" resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0" integrity sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg== @@ -2046,6 +2041,13 @@ debug@^4.1.0, debug@^4.1.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4, debug@~4.3 dependencies: ms "2.1.2" +debug@~4.3.4: + version "4.3.5" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.5.tgz#e83444eceb9fedd4a1da56d671ae2446a01a6e1e" + integrity sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg== + dependencies: + ms "2.1.2" + decamelize@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" @@ -2289,17 +2291,15 @@ end-of-stream@^1.1.0: dependencies: once "^1.4.0" -engine.io-parser@~5.0.0: - version "5.0.3" - resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-5.0.3.tgz#ca1f0d7b11e290b4bfda251803baea765ed89c09" - integrity sha512-BtQxwF27XUNnSafQLvDi0dQ8s3i6VgzSoQMJacpIcGNrlUdfHSKbgm3jmjCVvQluGzqwujQMPAoMai3oYSTurg== - dependencies: - "@socket.io/base64-arraybuffer" "~1.0.2" +engine.io-parser@~5.2.1: + version "5.2.2" + resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-5.2.2.tgz#37b48e2d23116919a3453738c5720455e64e1c49" + integrity sha512-RcyUFKA93/CXH20l4SoVvzZfrSDMOTUS3bWVpTt2FuFP+XYrL8i8oonHP7WInRyVHXh0n/ORtoeiE1os+8qkSw== -engine.io@~6.1.0: - version "6.1.2" - resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-6.1.2.tgz#e7b9d546d90c62246ffcba4d88594be980d3855a" - integrity sha512-v/7eGHxPvO2AWsksyx2PUsQvBafuvqs0jJJQ0FdmJG1b9qIvgSbqDRGwNhfk2XHaTTbTXiC4quRE8Q9nRjsrQQ== +engine.io@~6.5.2: + version "6.5.5" + resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-6.5.5.tgz#430b80d8840caab91a50e9e23cb551455195fc93" + integrity sha512-C5Pn8Wk+1vKBoHghJODM63yk8MvrO9EWZUfkAt5HAqIgPE4/8FF0PEGHXtEd40l223+cE5ABWuPzm38PHFXfMA== dependencies: "@types/cookie" "^0.4.1" "@types/cors" "^2.8.12" @@ -2309,8 +2309,8 @@ engine.io@~6.1.0: cookie "~0.4.1" cors "~2.8.5" debug "~4.3.1" - engine.io-parser "~5.0.0" - ws "~8.2.3" + engine.io-parser "~5.2.1" + ws "~8.17.1" enhanced-resolve@4.1.0: version "4.1.0" @@ -6173,31 +6173,34 @@ snapdragon@^0.8.1: source-map-resolve "^0.5.0" use "^3.1.0" -socket.io-adapter@~2.3.3: - version "2.3.3" - resolved "https://registry.yarnpkg.com/socket.io-adapter/-/socket.io-adapter-2.3.3.tgz#4d6111e4d42e9f7646e365b4f578269821f13486" - integrity sha512-Qd/iwn3VskrpNO60BeRyCyr8ZWw9CPZyitW4AQwmRZ8zCiyDiL+znRnWX6tDHXnWn1sJrM1+b6Mn6wEDJJ4aYQ== +socket.io-adapter@~2.5.2: + version "2.5.5" + resolved "https://registry.yarnpkg.com/socket.io-adapter/-/socket.io-adapter-2.5.5.tgz#c7a1f9c703d7756844751b6ff9abfc1780664082" + integrity sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg== + dependencies: + debug "~4.3.4" + ws "~8.17.1" -socket.io-parser@~4.0.4: - version "4.0.5" - resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-4.0.5.tgz#cb404382c32324cc962f27f3a44058cf6e0552df" - integrity sha512-sNjbT9dX63nqUFIOv95tTVm6elyIU4RvB1m8dOeZt+IgWwcWklFDOdmGcfo3zSiRsnR/3pJkjY5lfoGqEe4Eig== +socket.io-parser@~4.2.4: + version "4.2.4" + resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-4.2.4.tgz#c806966cf7270601e47469ddeec30fbdfda44c83" + integrity sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew== dependencies: - "@types/component-emitter" "^1.2.10" - component-emitter "~1.3.0" + "@socket.io/component-emitter" "~3.1.0" debug "~4.3.1" socket.io@^4.2.0: - version "4.4.1" - resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-4.4.1.tgz#cd6de29e277a161d176832bb24f64ee045c56ab8" - integrity sha512-s04vrBswdQBUmuWJuuNTmXUVJhP0cVky8bBDhdkf8y0Ptsu7fKU2LuLbts9g+pdmAdyMMn8F/9Mf1/wbtUN0fg== + version "4.7.5" + resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-4.7.5.tgz#56eb2d976aef9d1445f373a62d781a41c7add8f8" + integrity sha512-DmeAkF6cwM9jSfmp6Dr/5/mfMwb5Z5qRrSXLpo3Fq5SqyU8CMF15jIN4ZhfSwu35ksM1qmHZDQ/DK5XTccSTvA== dependencies: accepts "~1.3.4" base64id "~2.0.0" + cors "~2.8.5" debug "~4.3.2" - engine.io "~6.1.0" - socket.io-adapter "~2.3.3" - socket.io-parser "~4.0.4" + engine.io "~6.5.2" + socket.io-adapter "~2.5.2" + socket.io-parser "~4.2.4" sockjs-client@1.4.0: version "1.4.0" @@ -7270,16 +7273,16 @@ wrappy@1: integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= ws@^6.2.1: - version "6.2.1" - resolved "https://registry.yarnpkg.com/ws/-/ws-6.2.1.tgz#442fdf0a47ed64f59b6a5d8ff130f4748ed524fb" - integrity sha512-GIyAXC2cB7LjvpgMt9EKS2ldqr0MTrORaleiOno6TweZ6r3TKtoFQWay/2PceJ3RuBasOHzXNn5Lrw1X0bEjqA== + version "6.2.3" + resolved "https://registry.yarnpkg.com/ws/-/ws-6.2.3.tgz#ccc96e4add5fd6fedbc491903075c85c5a11d9ee" + integrity sha512-jmTjYU0j60B+vHey6TfR3Z7RD61z/hmxBS3VMSGIrroOWXQEneK1zNuotOUrGyBHQj0yrpsLHPWtigEFd13ndA== dependencies: async-limiter "~1.0.0" -ws@~8.2.3: - version "8.2.3" - resolved "https://registry.yarnpkg.com/ws/-/ws-8.2.3.tgz#63a56456db1b04367d0b721a0b80cae6d8becbba" - integrity sha512-wBuoj1BDpC6ZQ1B7DWQBYVLphPWkm8i9Y0/3YdHjHKHiohOJ1ws+3OccDWtH+PoC9DZD5WOTrJvNbWvjS6JWaA== +ws@~8.17.1: + version "8.17.1" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.1.tgz#9293da530bb548febc95371d90f9c878727d919b" + integrity sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ== "y18n@^3.2.1 || ^4.0.0", y18n@^4.0.0: version "4.0.0"