diff --git a/demo/scripts/controlsV2/mainPane/MainPane.tsx b/demo/scripts/controlsV2/mainPane/MainPane.tsx index 1b2c912928f3..6dc8a389581d 100644 --- a/demo/scripts/controlsV2/mainPane/MainPane.tsx +++ b/demo/scripts/controlsV2/mainPane/MainPane.tsx @@ -62,6 +62,7 @@ import { AnnouncePlugin, AutoFormatPlugin, CustomReplacePlugin, + DragAndDropPlugin, EditPlugin, HiddenPropertyPlugin, HyperlinkPlugin, @@ -577,6 +578,7 @@ export class MainPane extends React.Component<{}, MainPaneState> { }), pluginList.touch && new TouchPlugin(), pluginList.announce && new AnnouncePlugin(), + pluginList.dragAndDrop && new DragAndDropPlugin(), ].filter(x => !!x); } } diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts b/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts index 83b03db04669..319412e4e6c2 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts @@ -24,6 +24,7 @@ const initialState: OptionState = { hiddenProperty: true, touch: true, announce: true, + dragAndDrop: true, }, defaultFormat: { fontFamily: 'Calibri', diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts b/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts index 291915581f5c..c4acf529d1f4 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts @@ -26,6 +26,7 @@ export interface BuildInPluginList { hiddenProperty: boolean; touch: boolean; announce: boolean; + dragAndDrop: boolean; } export interface OptionState { diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx b/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx index cad56c7d3920..44022a59ccb0 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx +++ b/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx @@ -360,6 +360,7 @@ export class Plugins extends PluginsBase { {this.renderPluginItem('hiddenProperty', 'Hidden Property')} {this.renderPluginItem('touch', 'Touch')} {this.renderPluginItem('announce', 'Announce')} + {this.renderPluginItem('dragAndDrop', 'DragAndDrop')} ); diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/codes/PluginsCode.ts b/demo/scripts/controlsV2/sidePane/editorOptions/codes/PluginsCode.ts index 63cbde5f6751..c33dad30f78f 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/codes/PluginsCode.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/codes/PluginsCode.ts @@ -5,6 +5,7 @@ import { OptionState } from '../OptionState'; import { WatermarkCode } from './WatermarkCode'; import { + DragAndDropPluginCode, EditPluginCode, PastePluginCode, TableEditPluginCode, @@ -45,6 +46,7 @@ export class PluginsCode extends PluginsCodeBase { pluginList.watermark && new WatermarkCode(state.watermarkText), pluginList.markdown && new MarkdownCode(state.markdownOptions), pluginList.imageEditPlugin && new ImageEditPluginCode(), + pluginList.dragAndDrop && new DragAndDropPluginCode(), ]); } } diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/codes/SimplePluginCode.ts b/demo/scripts/controlsV2/sidePane/editorOptions/codes/SimplePluginCode.ts index b078ab59a2ac..c3ff947cace6 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/codes/SimplePluginCode.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/codes/SimplePluginCode.ts @@ -39,3 +39,9 @@ export class ImageEditPluginCode extends SimplePluginCode { super('ImageEditPlugin'); } } + +export class DragAndDropPluginCode extends SimplePluginCode { + constructor() { + super('DragAndDropPlugin'); + } +} diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/domEvent/DOMEventPlugin.ts b/packages/roosterjs-content-model-core/lib/corePlugin/domEvent/DOMEventPlugin.ts index 74135ff0fb5b..317dbb77559c 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/domEvent/DOMEventPlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/domEvent/DOMEventPlugin.ts @@ -85,7 +85,7 @@ class DOMEventPlugin implements PluginWithState { // 4. Drag and Drop event dragstart: { beforeDispatch: this.onDragStart }, - drop: { beforeDispatch: this.onDrop }, + drop: { beforeDispatch: (event: DragEvent) => this.onDrop(event) }, // 5. Pointer event pointerdown: { beforeDispatch: (event: PointerEvent) => this.onPointerDown(event) }, @@ -137,17 +137,23 @@ class DOMEventPlugin implements PluginWithState { } }; - private onDrop = () => { - const doc = this.editor?.getDocument(); - - doc?.defaultView?.requestAnimationFrame(() => { - if (this.editor) { - this.editor.takeSnapshot(); - this.editor.triggerEvent('contentChanged', { - source: ChangeSource.Drop, + private onDrop = (e: DragEvent) => { + if (this.editor) { + const beforeDropEvent = this.editor.triggerEvent('beforeDrop', { + rawEvent: e, + }); + if (!beforeDropEvent?.rawEvent.defaultPrevented) { + const doc = this.editor.getDocument(); + doc?.defaultView?.requestAnimationFrame(() => { + if (this.editor) { + this.editor.takeSnapshot(); + this.editor.triggerEvent('contentChanged', { + source: ChangeSource.Drop, + }); + } }); } - }); + } }; private onScroll = (e: Event) => { diff --git a/packages/roosterjs-content-model-core/test/corePlugin/domEvent/DomEventPluginTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/domEvent/DomEventPluginTest.ts index 7285bbb16376..25b6897d9562 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/domEvent/DomEventPluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/domEvent/DomEventPluginTest.ts @@ -209,7 +209,6 @@ describe('DOMEventPlugin verify event handlers while disallow keyboard event pro expect(triggerEventSpy).toHaveBeenCalled(); }); - it('verify input event for non-character value', () => { spyOn(eventUtils, 'isCharacterValue').and.returnValue(false); const stopPropagation = jasmine.createSpy(); @@ -282,7 +281,6 @@ describe('DOMEventPlugin verify event handlers while disallow keyboard event pro expect(stopPropagation).toHaveBeenCalled(); expect(triggerEventSpy).toHaveBeenCalled(); }); - }); describe('DOMEventPlugin handle mouse down and mouse up event', () => { @@ -553,8 +551,16 @@ describe('DOMEventPlugin handle other event', () => { it('Trigger onDrop event', () => { const takeSnapshotSpy = jasmine.createSpy('takeSnapshot'); editor.takeSnapshot = takeSnapshotSpy; + const mockedEvent = { + dataTransfer: { + getData: () => '', + }, + defaultPrevented: false, + } as any; - eventMap.drop.beforeDispatch(); + triggerEvent.and.returnValue({ rawEvent: mockedEvent }); + + eventMap.drop.beforeDispatch(mockedEvent); expect(plugin.getState()).toEqual({ isInIME: false, scrollContainer: scrollContainer, @@ -562,9 +568,33 @@ describe('DOMEventPlugin handle other event', () => { mouseDownY: null, mouseUpEventListerAdded: false, }); + expect(triggerEvent).toHaveBeenCalledWith('beforeDrop', { + rawEvent: mockedEvent, + }); expect(takeSnapshotSpy).toHaveBeenCalledWith(); expect(triggerEvent).toHaveBeenCalledWith('contentChanged', { source: ChangeSource.Drop, }); }); + + it('Trigger onDrop event with defaultPrevented', () => { + const takeSnapshotSpy = jasmine.createSpy('takeSnapshot'); + editor.takeSnapshot = takeSnapshotSpy; + const mockedEvent = { + dataTransfer: { + getData: () => '', + }, + defaultPrevented: true, + } as any; + + triggerEvent.and.returnValue({ rawEvent: mockedEvent }); + + eventMap.drop.beforeDispatch(mockedEvent); + + expect(triggerEvent).toHaveBeenCalledWith('beforeDrop', { + rawEvent: mockedEvent, + }); + expect(takeSnapshotSpy).not.toHaveBeenCalled(); + expect(triggerEvent).not.toHaveBeenCalledWith('contentChanged', jasmine.anything()); + }); }); diff --git a/packages/roosterjs-content-model-plugins/lib/dragAndDrop/DragAndDropPlugin.ts b/packages/roosterjs-content-model-plugins/lib/dragAndDrop/DragAndDropPlugin.ts new file mode 100644 index 000000000000..2f9dcab15887 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/dragAndDrop/DragAndDropPlugin.ts @@ -0,0 +1,96 @@ +import { handleDroppedContent } from './utils/handleDroppedContent'; +import type { EditorPlugin, IEditor, PluginEvent } from 'roosterjs-content-model-types'; + +/** + * Options for DragAndDrop plugin + */ +export interface DragAndDropOptions { + /** + * Forbidden elements that cannot be dropped in the editor + * @default ['iframe'] + */ + forbiddenElements?: string[]; +} + +const DefaultOptions = { + forbiddenElements: ['iframe'], +}; + +/** + * DragAndDrop plugin, handles ContentChanged event when change source is "Drop" + * to sanitize dropped content, similar to how PastePlugin sanitizes pasted content. + */ +export class DragAndDropPlugin implements EditorPlugin { + private editor: IEditor | null = null; + private forbiddenElements: string[] = []; + private isInternalDragging: boolean = false; + private disposer: (() => void) | null = null; + + /** + * Construct a new instance of DragAndDropPlugin + */ + constructor(options: DragAndDropOptions = DefaultOptions) { + this.forbiddenElements = options.forbiddenElements ?? []; + } + + /** + * Get name of this plugin + */ + getName() { + return 'DragAndDrop'; + } + + /** + * The first method that editor will call to a plugin when editor is initializing. + * It will pass in the editor instance, plugin should take this chance to save the + * editor reference so that it can call to any editor method or format API later. + * @param editor The editor object + */ + initialize(editor: IEditor) { + this.editor = editor; + this.disposer = editor.attachDomEvent({ + dragstart: { + beforeDispatch: _ev => { + this.isInternalDragging = true; + }, + }, + }); + } + + /** + * The last method that editor will call to a plugin before it is disposed. + * Plugin can take this chance to clear the reference to editor. After this method is + * called, plugin should not call to any editor method since it will result in error. + */ + dispose() { + this.editor = null; + if (this.disposer) { + this.disposer(); + this.disposer = null; + } + this.isInternalDragging = false; + this.forbiddenElements = []; + } + + /** + * Core method for a plugin. Once an event happens in editor, editor will call this + * method of each plugin to handle the event as long as the event is not handled + * exclusively by another plugin. + * @param event The event to handle: + */ + onPluginEvent(event: PluginEvent) { + if (this.editor && event.eventType == 'beforeDrop') { + if (this.isInternalDragging) { + this.isInternalDragging = false; + } else { + const dropEvent = event.rawEvent; + const html = dropEvent.dataTransfer?.getData('text/html'); + + if (html) { + handleDroppedContent(this.editor, dropEvent, html, this.forbiddenElements); + } + } + return; + } + } +} diff --git a/packages/roosterjs-content-model-plugins/lib/dragAndDrop/utils/cleanForbiddenElements.ts b/packages/roosterjs-content-model-plugins/lib/dragAndDrop/utils/cleanForbiddenElements.ts new file mode 100644 index 000000000000..881280cc164e --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/dragAndDrop/utils/cleanForbiddenElements.ts @@ -0,0 +1,18 @@ +/** + * @internal + * Remove all forbidden elements from a parsed HTML document + * @param doc The parsed HTML document to clean + * @param forbiddenElements Array of tag names to remove (e.g., ['iframe', 'script']) + */ +export function cleanForbiddenElements(doc: Document, forbiddenElements: string[]): void { + if (forbiddenElements.length === 0) { + return; + } + + const selector = forbiddenElements.join(','); + const elements = Array.from(doc.body.querySelectorAll(selector)); + + for (const element of elements) { + element.parentNode?.removeChild(element); + } +} diff --git a/packages/roosterjs-content-model-plugins/lib/dragAndDrop/utils/handleDroppedContent.ts b/packages/roosterjs-content-model-plugins/lib/dragAndDrop/utils/handleDroppedContent.ts new file mode 100644 index 000000000000..a6e27b65badb --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/dragAndDrop/utils/handleDroppedContent.ts @@ -0,0 +1,50 @@ +import { cleanForbiddenElements } from './cleanForbiddenElements'; +import { + createDomToModelContext, + domToContentModel, + getNodePositionFromEvent, + mergeModel, +} from 'roosterjs-content-model-dom'; +import type { IEditor } from 'roosterjs-content-model-types'; + +/** + * @internal + * Handle dropped HTML content by inserting it at the drop position + */ +export function handleDroppedContent( + editor: IEditor, + event: DragEvent, + html: string, + forbiddenElements: string[] +): void { + const doc = editor.getDocument(); + const domPosition = getNodePositionFromEvent(doc, editor.getDOMHelper(), event.x, event.y); + + if (domPosition) { + event.preventDefault(); + event.stopPropagation(); + + const range = doc.createRange(); + range.setStart(domPosition.node, domPosition.offset); + range.collapse(true); + + const parsedHtml = editor.getDOMCreator().htmlToDOM(html); + cleanForbiddenElements(parsedHtml, forbiddenElements); + + const droppedModel = domToContentModel(parsedHtml.body, createDomToModelContext()); + + editor.formatContentModel( + (model, context) => { + mergeModel(model, droppedModel, context); + return true; + }, + { + selectionOverride: { + type: 'range', + range, + isReverted: false, + }, + } + ); + } +} diff --git a/packages/roosterjs-content-model-plugins/lib/index.ts b/packages/roosterjs-content-model-plugins/lib/index.ts index e5f452e5c837..f8a6a87222fe 100644 --- a/packages/roosterjs-content-model-plugins/lib/index.ts +++ b/packages/roosterjs-content-model-plugins/lib/index.ts @@ -53,3 +53,4 @@ export { FindReplaceContext } from './findReplace/types/FindReplaceContext'; export { HighlightHelper } from './findReplace/types/HighlightHelper'; export { FindReplaceHighlightOptions } from './findReplace/types/FindReplaceHighlightOptions'; export { AnnouncePlugin } from './announce/AnnouncePlugin'; +export { DragAndDropPlugin, DragAndDropOptions } from './dragAndDrop/DragAndDropPlugin'; diff --git a/packages/roosterjs-content-model-plugins/test/dragAndDrop/DragAndDropPluginTest.ts b/packages/roosterjs-content-model-plugins/test/dragAndDrop/DragAndDropPluginTest.ts new file mode 100644 index 000000000000..c053c4da05c1 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/dragAndDrop/DragAndDropPluginTest.ts @@ -0,0 +1,232 @@ +import * as handleDroppedContentFile from '../../lib/dragAndDrop/utils/handleDroppedContent'; +import { DragAndDropPlugin } from '../../lib/dragAndDrop/DragAndDropPlugin'; +import { IEditor } from 'roosterjs-content-model-types'; + +describe('DragAndDropPlugin', () => { + let plugin: DragAndDropPlugin; + let editor: IEditor; + let attachDomEventSpy: jasmine.Spy; + let disposerSpy: jasmine.Spy; + let eventMap: Record; + + beforeEach(() => { + disposerSpy = jasmine.createSpy('disposer'); + attachDomEventSpy = jasmine.createSpy('attachDomEvent').and.callFake((map: any) => { + eventMap = map; + return disposerSpy; + }); + + editor = ({ + attachDomEvent: attachDomEventSpy, + } as any) as IEditor; + }); + + afterEach(() => { + plugin?.dispose(); + }); + + describe('initialization', () => { + it('should return correct name', () => { + plugin = new DragAndDropPlugin(); + expect(plugin.getName()).toBe('DragAndDrop'); + }); + + it('should initialize with default options', () => { + plugin = new DragAndDropPlugin(); + plugin.initialize(editor); + + expect(attachDomEventSpy).toHaveBeenCalled(); + expect(eventMap.dragstart).toBeDefined(); + }); + + it('should initialize with custom forbidden elements', () => { + plugin = new DragAndDropPlugin({ forbiddenElements: ['script', 'object'] }); + plugin.initialize(editor); + + expect(attachDomEventSpy).toHaveBeenCalled(); + }); + + it('should dispose correctly', () => { + plugin = new DragAndDropPlugin(); + plugin.initialize(editor); + + plugin.dispose(); + + expect(disposerSpy).toHaveBeenCalled(); + }); + }); + + describe('dragstart event', () => { + it('should set isInternalDragging to true when drag starts', () => { + plugin = new DragAndDropPlugin(); + plugin.initialize(editor); + + const target = document.createElement('div'); + + eventMap.dragstart.beforeDispatch({ target } as any); + + // Verify by checking that beforeDrop event with HTML does not call handleDroppedContent + const handleDroppedContentSpy = spyOn(handleDroppedContentFile, 'handleDroppedContent'); + + plugin.onPluginEvent({ + eventType: 'beforeDrop', + rawEvent: { + dataTransfer: { + getData: () => '
test
', + }, + } as any, + }); + + expect(handleDroppedContentSpy).not.toHaveBeenCalled(); + }); + }); + + describe('onPluginEvent - beforeDrop', () => { + let handleDroppedContentSpy: jasmine.Spy; + + beforeEach(() => { + handleDroppedContentSpy = spyOn(handleDroppedContentFile, 'handleDroppedContent'); + plugin = new DragAndDropPlugin(); + plugin.initialize(editor); + }); + + it('should call handleDroppedContent when HTML is dropped from external source', () => { + const html = '
dropped content
'; + const dropEvent = { + dataTransfer: { + getData: () => html, + }, + } as any; + + plugin.onPluginEvent({ + eventType: 'beforeDrop', + rawEvent: dropEvent, + }); + + expect(handleDroppedContentSpy).toHaveBeenCalledWith(editor, dropEvent, html, [ + 'iframe', + ]); + }); + + it('should use custom forbidden elements', () => { + plugin.dispose(); + plugin = new DragAndDropPlugin({ forbiddenElements: ['script', 'object'] }); + plugin.initialize(editor); + + const html = '
dropped content
'; + const dropEvent = { + dataTransfer: { + getData: () => html, + }, + } as any; + + plugin.onPluginEvent({ + eventType: 'beforeDrop', + rawEvent: dropEvent, + }); + + expect(handleDroppedContentSpy).toHaveBeenCalledWith(editor, dropEvent, html, [ + 'script', + 'object', + ]); + }); + + it('should not call handleDroppedContent when no HTML in dataTransfer', () => { + const dropEvent = { + dataTransfer: { + getData: () => '', + }, + } as any; + + plugin.onPluginEvent({ + eventType: 'beforeDrop', + rawEvent: dropEvent, + }); + + expect(handleDroppedContentSpy).not.toHaveBeenCalled(); + }); + + it('should not call handleDroppedContent when dataTransfer is null', () => { + const dropEvent = { + dataTransfer: null, + } as any; + + plugin.onPluginEvent({ + eventType: 'beforeDrop', + rawEvent: dropEvent, + }); + + expect(handleDroppedContentSpy).not.toHaveBeenCalled(); + }); + + it('should not call handleDroppedContent for internal drag and drop', () => { + // Simulate internal drag start + const target = document.createElement('div'); + eventMap.dragstart.beforeDispatch({ target } as any); + + const html = '
dragged content
'; + const dropEvent = { + dataTransfer: { + getData: () => html, + }, + } as any; + + plugin.onPluginEvent({ + eventType: 'beforeDrop', + rawEvent: dropEvent, + }); + + expect(handleDroppedContentSpy).not.toHaveBeenCalled(); + }); + + it('should ignore other event types', () => { + plugin.onPluginEvent({ + eventType: 'keyDown', + rawEvent: {} as any, + } as any); + + expect(handleDroppedContentSpy).not.toHaveBeenCalled(); + }); + }); + + describe('edge cases', () => { + it('should not process events when editor is null', () => { + const handleDroppedContentSpy = spyOn(handleDroppedContentFile, 'handleDroppedContent'); + + plugin = new DragAndDropPlugin(); + // Don't initialize, so editor is null + + plugin.onPluginEvent({ + eventType: 'beforeDrop', + rawEvent: { + dataTransfer: { + getData: () => '
test
', + }, + } as any, + }); + + expect(handleDroppedContentSpy).not.toHaveBeenCalled(); + }); + + it('should handle empty forbidden elements array', () => { + const handleDroppedContentSpy = spyOn(handleDroppedContentFile, 'handleDroppedContent'); + + plugin = new DragAndDropPlugin({ forbiddenElements: [] }); + plugin.initialize(editor); + + const html = '
content
'; + const dropEvent = { + dataTransfer: { + getData: () => html, + }, + } as any; + + plugin.onPluginEvent({ + eventType: 'beforeDrop', + rawEvent: dropEvent, + }); + + expect(handleDroppedContentSpy).toHaveBeenCalledWith(editor, dropEvent, html, []); + }); + }); +}); diff --git a/packages/roosterjs-content-model-plugins/test/dragAndDrop/utils/cleanForbiddenElementsTest.ts b/packages/roosterjs-content-model-plugins/test/dragAndDrop/utils/cleanForbiddenElementsTest.ts new file mode 100644 index 000000000000..9e96e918b992 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/dragAndDrop/utils/cleanForbiddenElementsTest.ts @@ -0,0 +1,100 @@ +import { cleanForbiddenElements } from '../../../lib/dragAndDrop/utils/cleanForbiddenElements'; + +describe('cleanForbiddenElements', () => { + it('should do nothing when forbiddenElements is empty', () => { + const doc = document.implementation.createHTMLDocument(); + doc.body.innerHTML = '
'; + + cleanForbiddenElements(doc, []); + + expect(doc.body.innerHTML).toBe( + '
' + ); + }); + + it('should remove iframe elements when iframe is in forbiddenElements', () => { + const doc = document.implementation.createHTMLDocument(); + doc.body.innerHTML = '

content

'; + + cleanForbiddenElements(doc, ['iframe']); + + expect(doc.body.innerHTML).toBe('

content

'); + }); + + it('should remove script elements when script is in forbiddenElements', () => { + const doc = document.implementation.createHTMLDocument(); + doc.body.innerHTML = '

content

'; + + cleanForbiddenElements(doc, ['script']); + + expect(doc.body.innerHTML).toBe('

content

'); + }); + + it('should remove multiple forbidden element types', () => { + const doc = document.implementation.createHTMLDocument(); + doc.body.innerHTML = + '

content

'; + + cleanForbiddenElements(doc, ['iframe', 'script']); + + expect(doc.body.innerHTML).toBe('

content

'); + }); + + it('should remove all instances of forbidden elements', () => { + const doc = document.implementation.createHTMLDocument(); + doc.body.innerHTML = + '

text'; + + cleanForbiddenElements(doc, ['iframe']); + + expect(doc.body.innerHTML).toBe('

text'); + }); + + it('should remove nested forbidden elements', () => { + const doc = document.implementation.createHTMLDocument(); + doc.body.innerHTML = '
'; + + cleanForbiddenElements(doc, ['iframe']); + + expect(doc.body.innerHTML).toBe('
'); + }); + + it('should handle custom forbidden elements', () => { + const doc = document.implementation.createHTMLDocument(); + doc.body.innerHTML = + '

safe

'; + + cleanForbiddenElements(doc, ['object', 'embed']); + + expect(doc.body.innerHTML).toBe('

safe

'); + }); + + it('should not remove elements not in forbiddenElements list', () => { + const doc = document.implementation.createHTMLDocument(); + doc.body.innerHTML = '

content

'; + + cleanForbiddenElements(doc, ['script']); + + expect(doc.body.innerHTML).toBe( + '

content

' + ); + }); + + it('should handle empty body', () => { + const doc = document.implementation.createHTMLDocument(); + doc.body.innerHTML = ''; + + cleanForbiddenElements(doc, ['iframe', 'script']); + + expect(doc.body.innerHTML).toBe(''); + }); + + it('should handle body with no forbidden elements', () => { + const doc = document.implementation.createHTMLDocument(); + doc.body.innerHTML = '

safe content

more content
'; + + cleanForbiddenElements(doc, ['iframe', 'script']); + + expect(doc.body.innerHTML).toBe('

safe content

more content
'); + }); +}); diff --git a/packages/roosterjs-content-model-plugins/test/dragAndDrop/utils/handleDroppedContentTest.ts b/packages/roosterjs-content-model-plugins/test/dragAndDrop/utils/handleDroppedContentTest.ts new file mode 100644 index 000000000000..3ddc805eb21c --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/dragAndDrop/utils/handleDroppedContentTest.ts @@ -0,0 +1,482 @@ +import * as cleanForbiddenElementsFile from '../../../lib/dragAndDrop/utils/cleanForbiddenElements'; +import * as getNodePositionFromEventFile from 'roosterjs-content-model-dom/lib/domUtils/event/getNodePositionFromEvent'; +import { handleDroppedContent } from '../../../lib/dragAndDrop/utils/handleDroppedContent'; +import { + ContentModelDocument, + ContentModelParagraph, + ContentModelText, + IEditor, +} from 'roosterjs-content-model-types'; +import { + createContentModelDocument, + createParagraph, + createSelectionMarker, + createText, +} from 'roosterjs-content-model-dom'; + +describe('handleDroppedContent', () => { + let editor: IEditor; + let doc: Document; + let getNodePositionFromEventSpy: jasmine.Spy; + let getDOMHelperSpy: jasmine.Spy; + let getDOMCreatorSpy: jasmine.Spy; + let htmlToDOMSpy: jasmine.Spy; + let formatContentModelSpy: jasmine.Spy; + let cleanForbiddenElementsSpy: jasmine.Spy; + + beforeEach(() => { + doc = document; + + getNodePositionFromEventSpy = spyOn( + getNodePositionFromEventFile, + 'getNodePositionFromEvent' + ); + cleanForbiddenElementsSpy = spyOn(cleanForbiddenElementsFile, 'cleanForbiddenElements'); + + getDOMHelperSpy = jasmine.createSpy('getDOMHelper').and.returnValue({}); + htmlToDOMSpy = jasmine.createSpy('htmlToDOM'); + getDOMCreatorSpy = jasmine.createSpy('getDOMCreator').and.returnValue({ + htmlToDOM: htmlToDOMSpy, + }); + formatContentModelSpy = jasmine.createSpy('formatContentModel'); + + editor = ({ + getDocument: () => doc, + getDOMHelper: getDOMHelperSpy, + getDOMCreator: getDOMCreatorSpy, + formatContentModel: formatContentModelSpy, + } as any) as IEditor; + }); + + it('should do nothing when domPosition is null', () => { + getNodePositionFromEventSpy.and.returnValue(null); + const preventDefaultSpy = jasmine.createSpy('preventDefault'); + const stopPropagationSpy = jasmine.createSpy('stopPropagation'); + + const event = { + x: 100, + y: 200, + preventDefault: preventDefaultSpy, + stopPropagation: stopPropagationSpy, + } as any; + + handleDroppedContent(editor, event, '

test

', ['iframe']); + + expect(getNodePositionFromEventSpy).toHaveBeenCalledWith(doc, {}, 100, 200); + expect(preventDefaultSpy).not.toHaveBeenCalled(); + expect(stopPropagationSpy).not.toHaveBeenCalled(); + expect(htmlToDOMSpy).not.toHaveBeenCalled(); + expect(formatContentModelSpy).not.toHaveBeenCalled(); + }); + + it('should insert dropped content at the correct position', () => { + const textNode = document.createTextNode('test'); + getNodePositionFromEventSpy.and.returnValue({ + node: textNode, + offset: 2, + }); + + const parsedDoc = document.implementation.createHTMLDocument(); + parsedDoc.body.innerHTML = '

dropped content

'; + htmlToDOMSpy.and.returnValue(parsedDoc); + + const preventDefaultSpy = jasmine.createSpy('preventDefault'); + const stopPropagationSpy = jasmine.createSpy('stopPropagation'); + + const event = { + x: 100, + y: 200, + preventDefault: preventDefaultSpy, + stopPropagation: stopPropagationSpy, + } as any; + + handleDroppedContent(editor, event, '

dropped content

', ['iframe', 'script']); + + expect(preventDefaultSpy).toHaveBeenCalled(); + expect(stopPropagationSpy).toHaveBeenCalled(); + expect(htmlToDOMSpy).toHaveBeenCalledWith('

dropped content

'); + expect(cleanForbiddenElementsSpy).toHaveBeenCalledWith(parsedDoc, ['iframe', 'script']); + expect(formatContentModelSpy).toHaveBeenCalled(); + + const formatCall = formatContentModelSpy.calls.mostRecent(); + const options = formatCall.args[1]; + expect(options.selectionOverride.type).toBe('range'); + expect(options.selectionOverride.isReverted).toBe(false); + }); + + it('should create range at correct position', () => { + const container = document.createElement('div'); + container.innerHTML = 'hello world'; + const textNode = container.firstChild!; + + getNodePositionFromEventSpy.and.returnValue({ + node: textNode, + offset: 5, + }); + + const parsedDoc = document.implementation.createHTMLDocument(); + parsedDoc.body.innerHTML = 'inserted'; + htmlToDOMSpy.and.returnValue(parsedDoc); + + const event = { + x: 50, + y: 75, + preventDefault: jasmine.createSpy('preventDefault'), + stopPropagation: jasmine.createSpy('stopPropagation'), + } as any; + + handleDroppedContent(editor, event, 'inserted', []); + + const formatCall = formatContentModelSpy.calls.mostRecent(); + const options = formatCall.args[1]; + const range = options.selectionOverride.range as Range; + + expect(range.startContainer).toBe(textNode); + expect(range.startOffset).toBe(5); + expect(range.collapsed).toBe(true); + }); + + it('should call cleanForbiddenElements with correct parameters', () => { + const textNode = document.createTextNode('test'); + getNodePositionFromEventSpy.and.returnValue({ + node: textNode, + offset: 0, + }); + + const parsedDoc = document.implementation.createHTMLDocument(); + parsedDoc.body.innerHTML = '
'; + htmlToDOMSpy.and.returnValue(parsedDoc); + + const event = { + x: 0, + y: 0, + preventDefault: jasmine.createSpy('preventDefault'), + stopPropagation: jasmine.createSpy('stopPropagation'), + } as any; + + const forbiddenElements = ['iframe', 'script', 'object']; + handleDroppedContent(editor, event, '
', forbiddenElements); + + expect(cleanForbiddenElementsSpy).toHaveBeenCalledWith(parsedDoc, forbiddenElements); + }); + + it('should handle empty forbidden elements list', () => { + const textNode = document.createTextNode('test'); + getNodePositionFromEventSpy.and.returnValue({ + node: textNode, + offset: 0, + }); + + const parsedDoc = document.implementation.createHTMLDocument(); + parsedDoc.body.innerHTML = '

content

'; + htmlToDOMSpy.and.returnValue(parsedDoc); + + const event = { + x: 0, + y: 0, + preventDefault: jasmine.createSpy('preventDefault'), + stopPropagation: jasmine.createSpy('stopPropagation'), + } as any; + + handleDroppedContent(editor, event, '

content

', []); + + expect(cleanForbiddenElementsSpy).toHaveBeenCalledWith(parsedDoc, []); + expect(formatContentModelSpy).toHaveBeenCalled(); + }); +}); + +describe('handleDroppedContent - model verification', () => { + let editor: IEditor; + let doc: Document; + let getNodePositionFromEventSpy: jasmine.Spy; + let getDOMHelperSpy: jasmine.Spy; + let getDOMCreatorSpy: jasmine.Spy; + let htmlToDOMSpy: jasmine.Spy; + let capturedCallback: ((model: ContentModelDocument, context: any) => boolean) | null; + + beforeEach(() => { + doc = document; + capturedCallback = null; + + getNodePositionFromEventSpy = spyOn( + getNodePositionFromEventFile, + 'getNodePositionFromEvent' + ); + + getDOMHelperSpy = jasmine.createSpy('getDOMHelper').and.returnValue({}); + htmlToDOMSpy = jasmine.createSpy('htmlToDOM'); + getDOMCreatorSpy = jasmine.createSpy('getDOMCreator').and.returnValue({ + htmlToDOM: htmlToDOMSpy, + }); + + editor = ({ + getDocument: () => doc, + getDOMHelper: getDOMHelperSpy, + getDOMCreator: getDOMCreatorSpy, + formatContentModel: (callback: any, _options: any) => { + capturedCallback = callback; + }, + } as any) as IEditor; + }); + + it('should merge dropped paragraph with text into model', () => { + const textNode = document.createTextNode('existing'); + getNodePositionFromEventSpy.and.returnValue({ + node: textNode, + offset: 0, + }); + + const parsedDoc = document.implementation.createHTMLDocument(); + parsedDoc.body.innerHTML = '

dropped text

'; + htmlToDOMSpy.and.returnValue(parsedDoc); + + const event = { + x: 0, + y: 0, + preventDefault: jasmine.createSpy('preventDefault'), + stopPropagation: jasmine.createSpy('stopPropagation'), + } as any; + + handleDroppedContent(editor, event, '

dropped text

', []); + + // Create a model to merge into + const model = createContentModelDocument(); + const para = createParagraph(); + para.segments.push(createSelectionMarker()); + model.blocks.push(para); + + // Execute the captured callback + expect(capturedCallback).not.toBeNull(); + const result = capturedCallback!(model, {}); + + expect(result).toBe(true); + // Verify model has been modified - should now contain the dropped content + expect(model.blocks.length).toBeGreaterThan(0); + + // Find text segments in the model + const textSegments: ContentModelText[] = []; + model.blocks.forEach(block => { + if (block.blockType === 'Paragraph') { + (block as ContentModelParagraph).segments.forEach(segment => { + if (segment.segmentType === 'Text') { + textSegments.push(segment as ContentModelText); + } + }); + } + }); + + expect(textSegments.length).toBeGreaterThan(0); + expect(textSegments.some(seg => seg.text === 'dropped text')).toBe(true); + }); + + it('should merge dropped bold text into model', () => { + const textNode = document.createTextNode('existing'); + getNodePositionFromEventSpy.and.returnValue({ + node: textNode, + offset: 0, + }); + + const parsedDoc = document.implementation.createHTMLDocument(); + parsedDoc.body.innerHTML = '

bold text

'; + htmlToDOMSpy.and.returnValue(parsedDoc); + + const event = { + x: 0, + y: 0, + preventDefault: jasmine.createSpy('preventDefault'), + stopPropagation: jasmine.createSpy('stopPropagation'), + } as any; + + handleDroppedContent(editor, event, '

bold text

', []); + + // Create initial model with selection + const model = createContentModelDocument(); + const para = createParagraph(); + para.segments.push(createSelectionMarker()); + model.blocks.push(para); + + // Execute callback + expect(capturedCallback).not.toBeNull(); + const result = capturedCallback!(model, {}); + + expect(result).toBe(true); + + // Find text segments and verify bold formatting + const textSegments: ContentModelText[] = []; + model.blocks.forEach(block => { + if (block.blockType === 'Paragraph') { + (block as ContentModelParagraph).segments.forEach(segment => { + if (segment.segmentType === 'Text') { + textSegments.push(segment as ContentModelText); + } + }); + } + }); + + const boldSegment = textSegments.find(seg => seg.text === 'bold text'); + expect(boldSegment).toBeDefined(); + expect(boldSegment?.format.fontWeight).toBe('bold'); + }); + + it('should merge dropped content into existing model with text', () => { + const textNode = document.createTextNode('existing'); + getNodePositionFromEventSpy.and.returnValue({ + node: textNode, + offset: 0, + }); + + const parsedDoc = document.implementation.createHTMLDocument(); + parsedDoc.body.innerHTML = '

new content

'; + htmlToDOMSpy.and.returnValue(parsedDoc); + + const event = { + x: 0, + y: 0, + preventDefault: jasmine.createSpy('preventDefault'), + stopPropagation: jasmine.createSpy('stopPropagation'), + } as any; + + handleDroppedContent(editor, event, '

new content

', []); + + // Create model with existing text + const model = createContentModelDocument(); + const para = createParagraph(); + para.segments.push(createText('existing text'), createSelectionMarker()); + model.blocks.push(para); + + // Verify initial state + expect(model.blocks.length).toBe(1); + + // Execute callback + expect(capturedCallback).not.toBeNull(); + const result = capturedCallback!(model, {}); + + expect(result).toBe(true); + + // Find all text in the model after merge + const allText: string[] = []; + model.blocks.forEach(block => { + if (block.blockType === 'Paragraph') { + (block as ContentModelParagraph).segments.forEach(segment => { + if (segment.segmentType === 'Text') { + allText.push((segment as ContentModelText).text); + } + }); + } + }); + + // Model should contain both existing and new content + expect(allText.some(text => text.includes('existing text'))).toBe(true); + expect(allText.some(text => text === 'new content')).toBe(true); + }); + + it('should remove forbidden elements before merging into model', () => { + const textNode = document.createTextNode('existing'); + getNodePositionFromEventSpy.and.returnValue({ + node: textNode, + offset: 0, + }); + + const parsedDoc = document.implementation.createHTMLDocument(); + parsedDoc.body.innerHTML = '

safe content

'; + htmlToDOMSpy.and.returnValue(parsedDoc); + + const event = { + x: 0, + y: 0, + preventDefault: jasmine.createSpy('preventDefault'), + stopPropagation: jasmine.createSpy('stopPropagation'), + } as any; + + handleDroppedContent(editor, event, '

safe content

', [ + 'iframe', + ]); + + // Create model + const model = createContentModelDocument(); + const para = createParagraph(); + para.segments.push(createSelectionMarker()); + model.blocks.push(para); + + // Execute callback + expect(capturedCallback).not.toBeNull(); + const result = capturedCallback!(model, {}); + + expect(result).toBe(true); + + // Verify no iframe entity in the model (iframe would become an entity) + let hasIframeEntity = false; + model.blocks.forEach(block => { + if (block.blockType === 'Entity') { + const wrapper = (block as any).wrapper as HTMLElement; + if (wrapper?.tagName?.toLowerCase() === 'iframe') { + hasIframeEntity = true; + } + } + }); + + expect(hasIframeEntity).toBe(false); + + // Verify safe content is present + const textSegments: ContentModelText[] = []; + model.blocks.forEach(block => { + if (block.blockType === 'Paragraph') { + (block as ContentModelParagraph).segments.forEach(segment => { + if (segment.segmentType === 'Text') { + textSegments.push(segment as ContentModelText); + } + }); + } + }); + + expect(textSegments.some(seg => seg.text === 'safe content')).toBe(true); + }); + + it('should merge multiple paragraphs into model', () => { + const textNode = document.createTextNode('existing'); + getNodePositionFromEventSpy.and.returnValue({ + node: textNode, + offset: 0, + }); + + const parsedDoc = document.implementation.createHTMLDocument(); + parsedDoc.body.innerHTML = '

first paragraph

second paragraph

'; + htmlToDOMSpy.and.returnValue(parsedDoc); + + const event = { + x: 0, + y: 0, + preventDefault: jasmine.createSpy('preventDefault'), + stopPropagation: jasmine.createSpy('stopPropagation'), + } as any; + + handleDroppedContent(editor, event, '

first paragraph

second paragraph

', []); + + // Create model + const model = createContentModelDocument(); + const para = createParagraph(); + para.segments.push(createSelectionMarker()); + model.blocks.push(para); + + // Execute callback + expect(capturedCallback).not.toBeNull(); + const result = capturedCallback!(model, {}); + + expect(result).toBe(true); + + // Find all text content + const allText: string[] = []; + model.blocks.forEach(block => { + if (block.blockType === 'Paragraph') { + (block as ContentModelParagraph).segments.forEach(segment => { + if (segment.segmentType === 'Text') { + allText.push((segment as ContentModelText).text); + } + }); + } + }); + + expect(allText.some(text => text === 'first paragraph')).toBe(true); + expect(allText.some(text => text === 'second paragraph')).toBe(true); + }); +}); diff --git a/packages/roosterjs-content-model-types/lib/event/BeforeDropEvent.ts b/packages/roosterjs-content-model-types/lib/event/BeforeDropEvent.ts new file mode 100644 index 000000000000..aba3290d9b8e --- /dev/null +++ b/packages/roosterjs-content-model-types/lib/event/BeforeDropEvent.ts @@ -0,0 +1,6 @@ +import type { BasePluginDomEvent } from './BasePluginEvent'; + +/** + * Data of BeforeDropEvent + */ +export interface BeforeDropEvent extends BasePluginDomEvent<'beforeDrop', DragEvent> {} diff --git a/packages/roosterjs-content-model-types/lib/event/PluginEvent.ts b/packages/roosterjs-content-model-types/lib/event/PluginEvent.ts index 0df043681822..0480c66e284f 100644 --- a/packages/roosterjs-content-model-types/lib/event/PluginEvent.ts +++ b/packages/roosterjs-content-model-types/lib/event/PluginEvent.ts @@ -2,6 +2,7 @@ import type { FindResultChangedEvent } from './FindResultChangedEvent'; import type { BeforeAddUndoSnapshotEvent } from './BeforeAddUndoSnapshotEvent'; import type { BeforeCutCopyEvent } from './BeforeCutCopyEvent'; import type { BeforeDisposeEvent } from './BeforeDisposeEvent'; +import type { BeforeDropEvent } from './BeforeDropEvent'; import type { BeforeKeyboardEditingEvent } from './BeforeKeyboardEditingEvent'; import type { BeforePasteEvent } from './BeforePasteEvent'; import type { BeforeSetContentEvent } from './BeforeSetContentEvent'; @@ -32,6 +33,7 @@ export type PluginEvent = | BeforeAddUndoSnapshotEvent | BeforeCutCopyEvent | BeforeDisposeEvent + | BeforeDropEvent | BeforeKeyboardEditingEvent | BeforeLogicalRootChangeEvent | BeforePasteEvent diff --git a/packages/roosterjs-content-model-types/lib/event/PluginEventType.ts b/packages/roosterjs-content-model-types/lib/event/PluginEventType.ts index 310732fb9f1b..5b2371532eec 100644 --- a/packages/roosterjs-content-model-types/lib/event/PluginEventType.ts +++ b/packages/roosterjs-content-model-types/lib/event/PluginEventType.ts @@ -166,4 +166,9 @@ export type PluginEventType = /** * Find result changed event */ - | 'findResultChanged'; + | 'findResultChanged' + + /** + * Let plugin know when a content will be dropped + */ + | 'beforeDrop'; diff --git a/packages/roosterjs-content-model-types/lib/index.ts b/packages/roosterjs-content-model-types/lib/index.ts index e27aa0946737..028f45357bfa 100644 --- a/packages/roosterjs-content-model-types/lib/index.ts +++ b/packages/roosterjs-content-model-types/lib/index.ts @@ -469,6 +469,7 @@ export { BasePluginEvent, BasePluginDomEvent } from './event/BasePluginEvent'; export { BeforeAddUndoSnapshotEvent } from './event/BeforeAddUndoSnapshotEvent'; export { BeforeCutCopyEvent } from './event/BeforeCutCopyEvent'; export { BeforeDisposeEvent } from './event/BeforeDisposeEvent'; +export { BeforeDropEvent } from './event/BeforeDropEvent'; export { BeforeKeyboardEditingEvent } from './event/BeforeKeyboardEditingEvent'; export { BeforePasteEvent, MergePastedContentFunc } from './event/BeforePasteEvent'; export { BeforeSetContentEvent } from './event/BeforeSetContentEvent';