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 bb128dd0a46..9eea7ba1a28 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/addRangeToSelection.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/addRangeToSelection.ts @@ -5,6 +5,16 @@ export function addRangeToSelection(doc: Document, range: Range, isReverted: boo const selection = doc.defaultView?.getSelection(); 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 + ) { + return; + } selection.removeAllRanges(); if (!isReverted) { 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 d8ddd82d6ea..2d199b1889d 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/setDOMSelection.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/setDOMSelection.ts @@ -12,11 +12,14 @@ import type { const DOM_SELECTION_CSS_KEY = '_DOMSelection'; const HIDE_CURSOR_CSS_KEY = '_DOMSelectionHideCursor'; +const HIDE_SELECTION_CSS_KEY = '_DOMSelectionHideSelection'; const IMAGE_ID = 'image'; const TABLE_ID = 'table'; const DEFAULT_SELECTION_BORDER_COLOR = '#DB626C'; const TABLE_CSS_RULE = 'background-color:#C6C6C6!important;'; const CARET_CSS_RULE = 'caret-color: transparent'; +const TRANSPARENT_SELECTION_CSS_RULE = 'background-color: transparent !important'; +const SELECTION_SELECTOR = '*::selection'; /** * @internal @@ -31,6 +34,7 @@ export const setDOMSelection: SetDOMSelection = (core, selection, skipSelectionC core.selection.skipReselectOnFocus = true; core.api.setEditorStyle(core, DOM_SELECTION_CSS_KEY, null /*cssRule*/); core.api.setEditorStyle(core, HIDE_CURSOR_CSS_KEY, null /*cssRule*/); + core.api.setEditorStyle(core, HIDE_SELECTION_CSS_KEY, null /*cssRule*/); try { switch (selection?.type) { @@ -46,9 +50,14 @@ export const setDOMSelection: SetDOMSelection = (core, selection, skipSelectionC }!important;`, [`#${ensureUniqueId(image, IMAGE_ID)}`] ); - core.api.setEditorStyle(core, HIDE_CURSOR_CSS_KEY, CARET_CSS_RULE); + core.api.setEditorStyle( + core, + HIDE_SELECTION_CSS_KEY, + TRANSPARENT_SELECTION_CSS_RULE, + [SELECTION_SELECTOR] + ); - setRangeSelection(doc, image); + setRangeSelection(doc, image, false /* collapse */); break; case 'table': const { table, firstColumn, firstRow, lastColumn, lastRow } = selection; @@ -105,7 +114,11 @@ export const setDOMSelection: SetDOMSelection = (core, selection, skipSelectionC const nodeToSelect = firstCell.cell?.firstElementChild || firstCell.cell; if (nodeToSelect) { - setRangeSelection(doc, (nodeToSelect as HTMLElement) || undefined); + setRangeSelection( + doc, + (nodeToSelect as HTMLElement) || undefined, + true /* collapse */ + ); } break; @@ -197,13 +210,24 @@ function handleTableSelected( return selectors; } -function setRangeSelection(doc: Document, element: HTMLElement | undefined) { +function setRangeSelection(doc: Document, element: HTMLElement | undefined, collapse: boolean) { if (element && doc.contains(element)) { const range = doc.createRange(); + let isReverted: boolean | undefined = undefined; range.selectNode(element); - range.collapse(); + if (collapse) { + range.collapse(); + } else { + const selection = doc.defaultView?.getSelection(); + const range = selection && selection.rangeCount > 0 && selection.getRangeAt(0); + if (selection && range) { + isReverted = + selection.focusNode != range.endContainer || + selection.focusOffset != range.endOffset; + } + } - addRangeToSelection(doc, range); + addRangeToSelection(doc, range, isReverted); } } 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 72ad8218efa..e7bf054ef73 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts @@ -1,5 +1,6 @@ import { findCoordinate } from './findCoordinate'; import { findTableCellElement } from '../../coreApi/setDOMSelection/findTableCellElement'; +import { isSingleImageInSelection } from './isSingleImageInSelection'; import { normalizePos } from './normalizePos'; import { isCharacterValue, @@ -21,6 +22,7 @@ import type { ParsedTable, TableSelectionInfo, TableCellCoordinate, + RangeSelection, } from 'roosterjs-content-model-types'; const MouseLeftButton = 0; @@ -126,8 +128,7 @@ class SelectionPlugin implements PluginWithState { this.getContainedTargetImage(rawEvent, selection)) && image.isContentEditable ) { - this.selectImage(image); - + this.selectImageWithRange(image, rawEvent); return; } else if (selection?.type == 'image' && selection.image !== rawEvent.target) { this.selectBeforeOrAfterElement(editor, selection.image); @@ -232,6 +233,25 @@ 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; @@ -243,7 +263,7 @@ class SelectionPlugin implements PluginWithState { MouseRightButton /* it's not possible to drag using right click */ || event.isClicking) ) { - this.selectImage(image); + this.selectImageWithRange(image, event.rawEvent); } this.detachMouseEvent(); @@ -442,16 +462,6 @@ class SelectionPlugin implements PluginWithState { } } - private selectImage(image: HTMLImageElement) { - this.setDOMSelection( - { - type: 'image', - image: image, - }, - null /*tableSelection*/ - ); - } - private selectBeforeOrAfterElement(editor: IEditor, element: HTMLElement, after?: boolean) { const doc = editor.getDocument(); const parent = element.parentNode; @@ -525,22 +535,27 @@ 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 && - selection.containsNode(newSelection.image, false /*partiallyContained*/) - ) { - this.editor.setDOMSelection({ - type: 'range', - range: selection.getRangeAt(0), - isReverted: false, - }); + + 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, + }); + } } // Safari has problem to handle onBlur event. When blur, we cannot get the original selection from editor. // So we always save a selection whenever editor has focus. Then after blur, we can still use this cached selection. - if (newSelection?.type == 'range' && this.isSafari) { - this.state.selection = newSelection; + if (newSelection?.type == 'range') { + if (this.isSafari) { + this.state.selection = newSelection; + } + this.trySelectSingleImage(newSelection); } } }; @@ -611,6 +626,21 @@ class SelectionPlugin implements PluginWithState { this.state.mouseDisposer = undefined; } } + + private trySelectSingleImage(selection: RangeSelection) { + if (!selection.range.collapsed) { + const image = isSingleImageInSelection(selection.range); + if (image) { + this.setDOMSelection( + { + type: 'image', + image: image, + }, + null /*tableSelection*/ + ); + } + } + } } /** diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/selection/isSingleImageInSelection.ts b/packages/roosterjs-content-model-core/lib/corePlugin/selection/isSingleImageInSelection.ts new file mode 100644 index 00000000000..a63d9e80f91 --- /dev/null +++ b/packages/roosterjs-content-model-core/lib/corePlugin/selection/isSingleImageInSelection.ts @@ -0,0 +1,42 @@ +import { isElementOfType, isNodeOfType } from 'roosterjs-content-model-dom'; + +/** + * @internal + */ +export function isSingleImageInSelection(selection: Selection | Range): HTMLImageElement | null { + const { startNode, endNode, startOffset, endOffset } = getProps(selection); + + const max = Math.max(startOffset, endOffset); + const min = Math.min(startOffset, endOffset); + + if (startNode && endNode && startNode == endNode && max - min == 1) { + const node = startNode?.childNodes.item(min); + if (isNodeOfType(node, 'ELEMENT_NODE') && isElementOfType(node, 'img')) { + return node; + } + } + return null; +} +function getProps( + selection: Selection | Range +): { startNode: Node | null; endNode: Node | null; startOffset: number; endOffset: number } { + if (isSelection(selection)) { + return { + startNode: selection.anchorNode, + endNode: selection.focusNode, + startOffset: selection.anchorOffset, + endOffset: selection.focusOffset, + }; + } else { + return { + startNode: selection.startContainer, + endNode: selection.endContainer, + startOffset: selection.startOffset, + endOffset: selection.endOffset, + }; + } +} + +function isSelection(selection: Selection | Range): selection is Selection { + return !!(selection as Selection).getRangeAt; +} 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 51f72c581a6..a2dd2548a3c 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/setDOMSelection/setDOMSelectionTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/setDOMSelection/setDOMSelectionTest.ts @@ -74,9 +74,14 @@ describe('setDOMSelection', () => { true ); expect(addRangeToSelectionSpy).not.toHaveBeenCalled(); - expect(setEditorStyleSpy).toHaveBeenCalledTimes(2); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(3); expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelection', null); expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelectionHideCursor', null); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + core, + '_DOMSelectionHideSelection', + null + ); } it('From null selection', () => { @@ -127,9 +132,14 @@ describe('setDOMSelection', () => { skipReselectOnFocus: undefined, selection: null, } as any); - expect(setEditorStyleSpy).toHaveBeenCalledTimes(2); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(3); expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelection', null); expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelectionHideCursor', null); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + core, + '_DOMSelectionHideSelection', + null + ); expect(triggerEventSpy).toHaveBeenCalledWith( core, { @@ -163,9 +173,14 @@ describe('setDOMSelection', () => { } as any); expect(triggerEventSpy).not.toHaveBeenCalled(); expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange, false); - expect(setEditorStyleSpy).toHaveBeenCalledTimes(2); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(3); expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelection', null); expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelectionHideCursor', null); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + core, + '_DOMSelectionHideSelection', + null + ); }); it('range selection, editor id is unique, editor does not have focus', () => { @@ -193,9 +208,14 @@ describe('setDOMSelection', () => { true ); expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange, false); - expect(setEditorStyleSpy).toHaveBeenCalledTimes(2); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(3); expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelection', null); expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelectionHideCursor', null); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + core, + '_DOMSelectionHideSelection', + null + ); }); }); @@ -240,22 +260,21 @@ describe('setDOMSelection', () => { true ); expect(selectNodeSpy).toHaveBeenCalledWith(mockedImage); - expect(collapseSpy).toHaveBeenCalledWith(); - expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange); + expect(collapseSpy).not.toHaveBeenCalledWith(); + expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange, undefined); expect(mockedImage.id).toBe('image_0'); - expect(setEditorStyleSpy).toHaveBeenCalledTimes(4); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(5); expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelection', null); - expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelectionHideCursor', null); expect(setEditorStyleSpy).toHaveBeenCalledWith( core, - '_DOMSelection', - 'outline-style:auto!important; outline-color:#DB626C!important;', - ['#image_0'] + '_DOMSelectionHideSelection', + null ); expect(setEditorStyleSpy).toHaveBeenCalledWith( core, - '_DOMSelectionHideCursor', - 'caret-color: transparent' + '_DOMSelection', + 'outline-style:auto!important; outline-color:#DB626C!important;', + ['#image_0'] ); }); @@ -294,11 +313,15 @@ describe('setDOMSelection', () => { true ); expect(selectNodeSpy).toHaveBeenCalledWith(mockedImage); - expect(collapseSpy).toHaveBeenCalledWith(); - expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange); - expect(setEditorStyleSpy).toHaveBeenCalledTimes(4); + expect(collapseSpy).not.toHaveBeenCalledWith(); + expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange, undefined); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(5); expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelection', null); - expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelectionHideCursor', null); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + core, + '_DOMSelectionHideSelection', + null + ); expect(setEditorStyleSpy).toHaveBeenCalledWith( core, '_DOMSelection', @@ -307,8 +330,9 @@ describe('setDOMSelection', () => { ); expect(setEditorStyleSpy).toHaveBeenCalledWith( core, - '_DOMSelectionHideCursor', - 'caret-color: transparent' + '_DOMSelectionHideSelection', + 'background-color: transparent !important', + ['*::selection'] ); }); @@ -347,11 +371,16 @@ describe('setDOMSelection', () => { true ); expect(selectNodeSpy).toHaveBeenCalledWith(mockedImage); - expect(collapseSpy).toHaveBeenCalledWith(); - expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange); - expect(setEditorStyleSpy).toHaveBeenCalledTimes(4); + expect(collapseSpy).not.toHaveBeenCalledWith(); + expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange, undefined); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(5); expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelection', null); expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelectionHideCursor', null); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + core, + '_DOMSelectionHideSelection', + null + ); expect(setEditorStyleSpy).toHaveBeenCalledWith( core, '_DOMSelection', @@ -360,8 +389,9 @@ describe('setDOMSelection', () => { ); expect(setEditorStyleSpy).toHaveBeenCalledWith( core, - '_DOMSelectionHideCursor', - 'caret-color: transparent' + '_DOMSelectionHideSelection', + 'background-color: transparent !important', + ['*::selection'] ); }); @@ -402,7 +432,7 @@ describe('setDOMSelection', () => { expect(collapseSpy).not.toHaveBeenCalled(); expect(addRangeToSelectionSpy).not.toHaveBeenCalled(); expect(mockedImage.id).toBe('image_0'); - expect(setEditorStyleSpy).toHaveBeenCalledTimes(4); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(5); expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelection', null); expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelectionHideCursor', null); expect(setEditorStyleSpy).toHaveBeenCalledWith( @@ -413,8 +443,9 @@ describe('setDOMSelection', () => { ); expect(setEditorStyleSpy).toHaveBeenCalledWith( core, - '_DOMSelectionHideCursor', - 'caret-color: transparent' + '_DOMSelectionHideSelection', + 'background-color: transparent !important', + ['*::selection'] ); }); }); @@ -458,9 +489,14 @@ describe('setDOMSelection', () => { expect(addRangeToSelectionSpy).not.toHaveBeenCalled(); expect(mockedTable.id).toBeUndefined(); - expect(setEditorStyleSpy).toHaveBeenCalledTimes(2); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(3); expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelection', null); expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelectionHideCursor', null); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + core, + '_DOMSelectionHideSelection', + null + ); }); function runTest( @@ -506,9 +542,14 @@ describe('setDOMSelection', () => { true ); expect(mockedTable.id).toBe('table_0'); - expect(setEditorStyleSpy).toHaveBeenCalledTimes(4); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(5); expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelection', null); expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelectionHideCursor', null); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + core, + '_DOMSelectionHideSelection', + null + ); expect(setEditorStyleSpy).toHaveBeenCalledWith( core, '_DOMSelection', @@ -641,9 +682,14 @@ describe('setDOMSelection', () => { true ); expect(table.id).toBe('table_0'); - expect(setEditorStyleSpy).toHaveBeenCalledTimes(4); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(5); expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelection', null); expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelectionHideCursor', null); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + core, + '_DOMSelectionHideSelection', + null + ); expect(setEditorStyleSpy).toHaveBeenCalledWith( core, '_DOMSelection', 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 3f0c0891ad7..65e44345e26 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts @@ -1,3 +1,4 @@ +import * as isSingleImageInSelection from '../../../lib/corePlugin/selection/isSingleImageInSelection'; import { createDOMHelper } from '../../../lib/editor/core/DOMHelperImpl'; import { createSelectionPlugin } from '../../../lib/corePlugin/selection/SelectionPlugin'; import { @@ -160,19 +161,27 @@ describe('SelectionPlugin handle image selection', () => { let setDOMSelectionSpy: jasmine.Spy; let getDocumentSpy: jasmine.Spy; let createRangeSpy: jasmine.Spy; + let domHelperSpy: jasmine.Spy; + let requestAnimationFrameSpy: jasmine.Spy; let addEventListenerSpy: jasmine.Spy; beforeEach(() => { getDOMSelectionSpy = jasmine.createSpy('getDOMSelection'); setDOMSelectionSpy = jasmine.createSpy('setDOMSelection'); createRangeSpy = jasmine.createSpy('createRange'); + requestAnimationFrameSpy = jasmine.createSpy('requestAnimationFrame'); addEventListenerSpy = jasmine.createSpy('addEventListener'); getDocumentSpy = jasmine.createSpy('getDocument').and.returnValue({ createRange: createRangeSpy, addEventListener: addEventListenerSpy, + defaultView: { + requestAnimationFrame: requestAnimationFrameSpy, + }, }); + domHelperSpy = jasmine.createSpy('domHelperSpy'); editor = { + getDOMHelper: domHelperSpy, getDOMSelection: getDOMSelectionSpy, setDOMSelection: setDOMSelectionSpy, getDocument: getDocumentSpy, @@ -279,7 +288,13 @@ describe('SelectionPlugin handle image selection', () => { }); 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'; @@ -290,17 +305,21 @@ describe('SelectionPlugin handle image selection', () => { plugin.onPluginEvent!({ eventType: 'mouseDown', - rawEvent: { + rawEvent: (>{ target: mockedImage, button: 2, - } as any, + preventDefault: preventDefaultSpy, + }) as any, }); - expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + 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!({ @@ -329,7 +348,11 @@ describe('SelectionPlugin handle image selection', () => { }); 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'; @@ -343,8 +366,9 @@ describe('SelectionPlugin handle image selection', () => { expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); expect(setDOMSelectionSpy).toHaveBeenCalledWith({ - type: 'image', - image: mockedImage, + type: 'range', + range, + isReverted: false, }); }); @@ -1982,6 +2006,9 @@ describe('SelectionPlugin on Safari', () => { const onSelectionChange = addEventListenerSpy.calls.argsFor(0)[1] as Function; const mockedNewSelection = { type: 'range', + range: >{ + collapsed: true, + }, } as any; hasFocusSpy.and.returnValue(true); @@ -2125,7 +2152,6 @@ describe('SelectionPlugin selectionChange on image selected', () => { let getDOMSelectionSpy: jasmine.Spy; let editor: IEditor; let setDOMSelectionSpy: jasmine.Spy; - let containsNodeSpy: jasmine.Spy; let getRangeAtSpy: jasmine.Spy; let getSelectionSpy: jasmine.Spy; @@ -2135,10 +2161,8 @@ describe('SelectionPlugin selectionChange on image selected', () => { attachDomEvent = jasmine.createSpy('attachDomEvent').and.returnValue(disposer); removeEventListenerSpy = jasmine.createSpy('removeEventListener'); addEventListenerSpy = jasmine.createSpy('addEventListener'); - containsNodeSpy = jasmine.createSpy('containsNode'); getRangeAtSpy = jasmine.createSpy('getRangeAt'); getSelectionSpy = jasmine.createSpy('getSelection').and.returnValue({ - containsNode: containsNodeSpy, getRangeAt: getRangeAtSpy, }); getDocumentSpy = jasmine.createSpy('getDocument').and.returnValue({ @@ -2167,7 +2191,8 @@ describe('SelectionPlugin selectionChange on image selected', () => { } as any) as IEditor; }); - it('onSelectionChange on image', () => { + it('onSelectionChange on image | 1', () => { + spyOn(isSingleImageInSelection, 'isSingleImageInSelection').and.returnValue(null); const plugin = createSelectionPlugin({}); const state = plugin.getState(); const mockedOldSelection = { @@ -2188,7 +2213,6 @@ describe('SelectionPlugin selectionChange on image selected', () => { hasFocusSpy.and.returnValue(true); isInShadowEditSpy.and.returnValue(false); getDOMSelectionSpy.and.returnValue(mockedNewSelection); - containsNodeSpy.and.returnValue(true); getRangeAtSpy.and.returnValue({ startContainer: {} }); onSelectionChange(); @@ -2201,7 +2225,10 @@ describe('SelectionPlugin selectionChange on image selected', () => { expect(getDOMSelectionSpy).toHaveBeenCalledTimes(1); }); - it('onSelectionChange on image', () => { + it('onSelectionChange on image | 2', () => { + const image = document.createElement('img'); + spyOn(isSingleImageInSelection, 'isSingleImageInSelection').and.returnValue(image); + const plugin = createSelectionPlugin({}); const state = plugin.getState(); const mockedOldSelection = { @@ -2222,7 +2249,6 @@ describe('SelectionPlugin selectionChange on image selected', () => { hasFocusSpy.and.returnValue(true); isInShadowEditSpy.and.returnValue(false); getDOMSelectionSpy.and.returnValue(mockedNewSelection); - containsNodeSpy.and.returnValue(false); getRangeAtSpy.and.returnValue({ startContainer: {} }); onSelectionChange(); @@ -2231,7 +2257,9 @@ describe('SelectionPlugin selectionChange on image selected', () => { expect(getDOMSelectionSpy).toHaveBeenCalledTimes(1); }); - it('onSelectionChange on image', () => { + it('onSelectionChange on image | 3', () => { + spyOn(isSingleImageInSelection, 'isSingleImageInSelection').and.returnValue(null); + const plugin = createSelectionPlugin({}); const state = plugin.getState(); const mockedOldSelection = { @@ -2252,7 +2280,6 @@ describe('SelectionPlugin selectionChange on image selected', () => { hasFocusSpy.and.returnValue(true); isInShadowEditSpy.and.returnValue(false); getDOMSelectionSpy.and.returnValue(mockedNewSelection); - containsNodeSpy.and.returnValue(true); getRangeAtSpy.and.returnValue({ startContainer: {} }); onSelectionChange(); @@ -2260,4 +2287,40 @@ describe('SelectionPlugin selectionChange on image selected', () => { expect(setDOMSelectionSpy).not.toHaveBeenCalled(); expect(getDOMSelectionSpy).toHaveBeenCalledTimes(1); }); + + it('onSelectionChange on image | 4', () => { + const image = document.createElement('img'); + spyOn(isSingleImageInSelection, 'isSingleImageInSelection').and.returnValue(image); + + const plugin = createSelectionPlugin({}); + const state = plugin.getState(); + const mockedOldSelection = { + type: 'image', + image: {} as any, + } as DOMSelection; + + state.selection = mockedOldSelection; + + plugin.initialize(editor); + + const onSelectionChange = addEventListenerSpy.calls.argsFor(0)[1] as Function; + const mockedNewSelection = { + type: 'range', + range: {} as any, + } as any; + + hasFocusSpy.and.returnValue(true); + isInShadowEditSpy.and.returnValue(false); + getDOMSelectionSpy.and.returnValue(mockedNewSelection); + getRangeAtSpy.and.returnValue({ startContainer: {} }); + + onSelectionChange(); + + expect(setDOMSelectionSpy).toHaveBeenCalled(); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'image', + image, + }); + expect(getDOMSelectionSpy).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/roosterjs-content-model-core/test/corePlugin/selection/isSingleImageInSelectionTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/selection/isSingleImageInSelectionTest.ts new file mode 100644 index 00000000000..5e413d867a4 --- /dev/null +++ b/packages/roosterjs-content-model-core/test/corePlugin/selection/isSingleImageInSelectionTest.ts @@ -0,0 +1,145 @@ +import { isSingleImageInSelection } from '../../../lib/corePlugin/selection/isSingleImageInSelection'; + +describe('isSingleImageInSelection |', () => { + describe('With selection', () => { + it('Is not single image in Selection: Selection offsets substraction is not equal to 0', () => { + const focusNode: any = {}; + const selection: any = >{ + focusNode, + anchorNode: focusNode, + focusOffset: 0, + anchorOffset: 2, + getRangeAt: () => {}, + }; + + const result = isSingleImageInSelection(selection); + + expect(result).toBeNull(); + }); + + it('Is not single image in Selection: Containers are not the same', () => { + const focusNode: any = {}; + const anchorNode: any = { test: '' }; + const selection: any = >{ + focusNode, + anchorNode, + focusOffset: 0, + anchorOffset: 1, + getRangeAt: () => {}, + }; + + const result = isSingleImageInSelection(selection); + + expect(result).toBeNull(); + }); + + it('Is not single image in Selection: Element is not image', () => { + const mockedElement = document.createElement('div'); + const focusNode: any = >{ + childNodes: { + item: () => mockedElement, + }, + }; + const selection: any = >{ + focusNode, + anchorNode: focusNode, + focusOffset: 0, + anchorOffset: 1, + getRangeAt: () => {}, + }; + + const result = isSingleImageInSelection(selection); + + expect(result).toBeNull(); + }); + + it('Is single image in selection', () => { + const mockedElement = document.createElement('img'); + const focusNode: any = >{ + childNodes: { + item: () => mockedElement, + }, + }; + const selection: any = >{ + focusNode, + anchorNode: focusNode, + focusOffset: 0, + anchorOffset: 1, + getRangeAt: () => {}, + }; + + const result = isSingleImageInSelection(selection); + + expect(result).toBe(mockedElement); + }); + }); + + describe('With Range', () => { + it('Is not single image in Selection: Selection offsets substraction is not equal to 0', () => { + const endContainer: any = {}; + const selection: any = >{ + endContainer, + startContainer: endContainer, + endOffset: 0, + startOffset: 2, + }; + + const result = isSingleImageInSelection(selection); + + expect(result).toBeNull(); + }); + + it('Is not single image in Selection: Containers are not the same', () => { + const endContainer: any = {}; + const startContainer: any = { test: '' }; + const selection: any = >{ + endContainer, + startContainer, + endOffset: 0, + startOffset: 1, + }; + + const result = isSingleImageInSelection(selection); + + expect(result).toBeNull(); + }); + + it('Is not single image in Selection: Element is not image', () => { + const mockedElement = document.createElement('div'); + const endContainer: any = >{ + childNodes: { + item: () => mockedElement, + }, + }; + const selection: any = >{ + endContainer, + startContainer: endContainer, + endOffset: 0, + startOffset: 1, + }; + + const result = isSingleImageInSelection(selection); + + expect(result).toBeNull(); + }); + + it('Is single image in selection', () => { + const mockedElement = document.createElement('img'); + const endContainer: any = >{ + childNodes: { + item: () => mockedElement, + }, + }; + const selection: any = >{ + endContainer, + startContainer: endContainer, + endOffset: 0, + startOffset: 1, + }; + + const result = isSingleImageInSelection(selection); + + expect(result).toBe(mockedElement); + }); + }); +});