diff --git a/packages/roosterjs-content-model-dom/lib/constants/ChangeSource.ts b/packages/roosterjs-content-model-dom/lib/constants/ChangeSource.ts index cbaa372038f1..e2004df5624b 100644 --- a/packages/roosterjs-content-model-dom/lib/constants/ChangeSource.ts +++ b/packages/roosterjs-content-model-dom/lib/constants/ChangeSource.ts @@ -70,4 +70,9 @@ export const ChangeSource = { * Content changed by replace */ Replace: 'Replace', + + /** + * Content changed by dragging content out the editor + */ + DragOutOfEditor: 'DragOutOfEditor', }; diff --git a/packages/roosterjs-content-model-plugins/lib/dragAndDrop/DragAndDropPlugin.ts b/packages/roosterjs-content-model-plugins/lib/dragAndDrop/DragAndDropPlugin.ts index e09254bcc13e..466e800f8db3 100644 --- a/packages/roosterjs-content-model-plugins/lib/dragAndDrop/DragAndDropPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/dragAndDrop/DragAndDropPlugin.ts @@ -1,6 +1,16 @@ import { handleDroppedExternalContent } from './utils/handleDroppedExternalContent'; -import type { EditorPlugin, IEditor, PluginEvent } from 'roosterjs-content-model-types'; +import type { + EditorPlugin, + IEditor, + PluginEvent, + ReadonlyContentModelSegment, +} from 'roosterjs-content-model-types'; import { handleDroppedInternalContent } from './utils/handleDroppedInternalContent'; +import { + ChangeSource, + getNodePositionFromEvent, + getSelectedSegments, +} from 'roosterjs-content-model-dom'; /** * Options for DragAndDrop plugin @@ -26,6 +36,7 @@ export class DragAndDropPlugin implements EditorPlugin { private forbiddenElements: string[] = []; private internalDrag: boolean = false; private disposer: (() => void) | null = null; + private lastSelectSegments: ReadonlyContentModelSegment[] = []; /** * Construct a new instance of DragAndDropPlugin @@ -59,6 +70,21 @@ export class DragAndDropPlugin implements EditorPlugin { ) { this.adjustDraggingCursor(this.editor, ev as DragEvent); } + editor.formatContentModel(model => { + this.lastSelectSegments = getSelectedSegments( + model, + false /* includingFormatHolder */ + ); + return false; + }); + }, + }, + dragend: { + beforeDispatch: ev => { + if (this.internalDrag) { + const dropEvent = ev as DragEvent; + this.triggerDragOutOfTheEditor(editor, dropEvent); + } }, }, }); @@ -76,6 +102,8 @@ export class DragAndDropPlugin implements EditorPlugin { this.disposer = null; } this.forbiddenElements = []; + this.internalDrag = false; + this.lastSelectSegments = []; } /** @@ -92,6 +120,7 @@ export class DragAndDropPlugin implements EditorPlugin { this.editor.isExperimentalFeatureEnabled('HandleDropInternalContent') ) { handleDroppedInternalContent(this.editor, dropEvent); + this.lastSelectSegments = []; } else if (!this.internalDrag) { const html = dropEvent.dataTransfer?.getData('text/html'); if (html) { @@ -120,4 +149,31 @@ export class DragAndDropPlugin implements EditorPlugin { } } } + + private triggerDragOutOfTheEditor(editor: IEditor, dropEvent: DragEvent) { + const dropPosition = editor.isExperimentalFeatureEnabled('HandleDropInternalContent') + ? undefined + : getNodePositionFromEvent( + editor.getDocument(), + editor.getDOMHelper(), + dropEvent.clientX, + dropEvent.clientY + ); + let modelChanged: boolean = false; + editor.formatContentModel(model => { + const selectedSegments = getSelectedSegments(model, false /* includingFormatHolder */); + modelChanged = + this.lastSelectSegments.length !== selectedSegments.length || + this.lastSelectSegments.some( + (segment, index) => segment !== selectedSegments[index] + ); + return false; + }); + if (!dropPosition && modelChanged) { + editor.triggerEvent('contentChanged', { + source: ChangeSource.DragOutOfEditor, + }); + editor.takeSnapshot(); + } + } } diff --git a/packages/roosterjs-content-model-plugins/test/dragAndDrop/DragAndDropPluginTest.ts b/packages/roosterjs-content-model-plugins/test/dragAndDrop/DragAndDropPluginTest.ts index 972640282804..e297c3fafc8b 100644 --- a/packages/roosterjs-content-model-plugins/test/dragAndDrop/DragAndDropPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/dragAndDrop/DragAndDropPluginTest.ts @@ -1,7 +1,8 @@ import * as handleDroppedContentFile from '../../lib/dragAndDrop/utils/handleDroppedExternalContent'; import * as handleDroppedInternalContentFile from '../../lib/dragAndDrop/utils/handleDroppedInternalContent'; import { DragAndDropPlugin } from '../../lib/dragAndDrop/DragAndDropPlugin'; -import { IEditor } from 'roosterjs-content-model-types'; +import { ContentModelDocument, IEditor } from 'roosterjs-content-model-types'; +import { ChangeSource } from 'roosterjs-content-model-dom'; describe('DragAndDropPlugin', () => { let plugin: DragAndDropPlugin; @@ -11,6 +12,9 @@ describe('DragAndDropPlugin', () => { let isExperimentalFeatureEnabledSpy: jasmine.Spy; let eventMap: Record; let getDOMSelectionSpy: jasmine.Spy; + let takeSnapshotSpy: jasmine.Spy; + let triggerEventSpy: jasmine.Spy; + let contentModel: ContentModelDocument; beforeEach(() => { disposerSpy = jasmine.createSpy('disposer'); @@ -22,11 +26,21 @@ describe('DragAndDropPlugin', () => { .createSpy('isExperimentalFeatureEnabled') .and.returnValue(true); getDOMSelectionSpy = jasmine.createSpy('getDOMSelection'); + takeSnapshotSpy = jasmine.createSpy('takeSnapshot'); + triggerEventSpy = jasmine.createSpy('triggerEvent'); + contentModel = { + blockGroupType: 'Document', + blocks: [], + }; editor = ({ attachDomEvent: attachDomEventSpy, isExperimentalFeatureEnabled: isExperimentalFeatureEnabledSpy, getDOMSelection: getDOMSelectionSpy, + takeSnapshot: takeSnapshotSpy, + triggerEvent: triggerEventSpy, + formatContentModel: (callback: (model: ContentModelDocument) => void) => + callback(contentModel), } as any) as IEditor; }); @@ -46,6 +60,7 @@ describe('DragAndDropPlugin', () => { expect(attachDomEventSpy).toHaveBeenCalled(); expect(eventMap.dragstart).toBeDefined(); + expect(eventMap.dragend).toBeDefined(); }); it('should initialize with custom forbidden elements', () => { @@ -94,6 +109,58 @@ describe('DragAndDropPlugin', () => { }); }); + describe('dragend event', () => { + it('should trigger contentChanged and take a snapshot when the model changed', () => { + spyOn(handleDroppedInternalContentFile, 'handleDroppedInternalContent'); + plugin = new DragAndDropPlugin(); + plugin.initialize(editor); + + contentModel.blocks.push({ + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + isSelected: true, + }, + ], + format: {}, + }); + eventMap.dragstart.beforeDispatch({} as DragEvent); + contentModel.blocks = []; + + eventMap.dragend.beforeDispatch({} as DragEvent); + + expect(triggerEventSpy).toHaveBeenCalledWith('contentChanged', { + source: ChangeSource.DragOutOfEditor, + }); + expect(takeSnapshotSpy).toHaveBeenCalledTimes(1); + }); + + it('should not take a snapshot when the model did not change', () => { + plugin = new DragAndDropPlugin(); + plugin.initialize(editor); + + const target = document.createElement('div'); + eventMap.dragstart.beforeDispatch({ target } as any); + eventMap.dragend.beforeDispatch({} as any); + + expect(triggerEventSpy).not.toHaveBeenCalled(); + expect(takeSnapshotSpy).not.toHaveBeenCalled(); + }); + + it('should not take a snapshot when drag ends without an internal drag', () => { + plugin = new DragAndDropPlugin(); + plugin.initialize(editor); + + eventMap.dragend.beforeDispatch({} as any); + + expect(triggerEventSpy).not.toHaveBeenCalled(); + expect(takeSnapshotSpy).not.toHaveBeenCalled(); + }); + }); + describe('onPluginEvent - beforeDrop', () => { let handleDroppedExternalContentSpy: jasmine.Spy;