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-markdown/lib/markdownToModel/utils/splitParagraphSegments.ts b/packages/roosterjs-content-model-markdown/lib/markdownToModel/utils/splitParagraphSegments.ts index 21fcc43c358a..1aca3f61713a 100644 --- a/packages/roosterjs-content-model-markdown/lib/markdownToModel/utils/splitParagraphSegments.ts +++ b/packages/roosterjs-content-model-markdown/lib/markdownToModel/utils/splitParagraphSegments.ts @@ -1,4 +1,11 @@ -const linkRegex = /(\[([^\[]+)\]\((https?:\/\/[^\)]+)\))|(\!\[([^\[]+)\]\((https?:\/\/[^\)]+)\))/g; +// Matches markdown links and images in a string. +// Group 1 (full link): [text](url) e.g. [Click here](https://example.com) +// Group 2: link text e.g. "Click here" +// Group 3: link url e.g. "https://example.com" +// Group 4 (full image): ![alt](url) e.g. ![Logo](https://example.com/logo.png) +// Group 5: alt text e.g. "Logo" +// Group 6: image url e.g. "https://example.com/logo.png" +const linkRegex = /(\[([^\[]+)\]\(([^\)]+)\))|(\!\[([^\[]+)\]\(([^\)]+)\))/g; /** * @internal @@ -10,14 +17,38 @@ interface MarkdownSegment { } const isValidUrl = (url: string) => { - try { - new URL(url); + if (!url) { + return false; + } + + // Accept common non-http schemes and relative paths + if ( + url.startsWith('data:') || + url.startsWith('blob:') || + url.startsWith('/') || + url.startsWith('./') || + url.startsWith('../') + ) { return true; + } + + try { + const parsed = new URL(url); + return parsed.protocol === 'http:' || parsed.protocol === 'https:'; } catch (_) { return false; } }; +function pushText(result: MarkdownSegment[], text: string) { + const last = result[result.length - 1]; + if (last && last.type === 'text') { + last.text += text; + } else { + result.push({ type: 'text', text, url: '' }); + } +} + /** * @internal */ @@ -28,28 +59,28 @@ export function splitParagraphSegments(text: string): MarkdownSegment[] { while ((match = linkRegex.exec(text)) !== null) { if (match.index > lastIndex) { - result.push({ type: 'text', text: text.slice(lastIndex, match.index), url: '' }); + pushText(result, text.slice(lastIndex, match.index)); } if (match[2] && match[3]) { - result.push( - isValidUrl(match[3]) - ? { type: 'link', text: match[2], url: match[3] } - : { type: 'text', text: match[0], url: '' } - ); + if (isValidUrl(match[3])) { + result.push({ type: 'link', text: match[2], url: match[3] }); + } else { + pushText(result, match[0]); + } } else if (match[5] && match[6]) { - result.push( - isValidUrl(match[6]) - ? { type: 'image', text: match[5], url: match[6] } - : { type: 'text', text: match[0], url: '' } - ); + if (isValidUrl(match[6])) { + result.push({ type: 'image', text: match[5], url: match[6] }); + } else { + pushText(result, match[0]); + } } lastIndex = linkRegex.lastIndex; } if (lastIndex < text.length) { - result.push({ type: 'text', text: text.slice(lastIndex), url: '' }); + pushText(result, text.slice(lastIndex)); } return result; diff --git a/packages/roosterjs-content-model-markdown/test/markdownToModel/utils/splitParagraphSegmentsTest.ts b/packages/roosterjs-content-model-markdown/test/markdownToModel/utils/splitParagraphSegmentsTest.ts index 64964704bd31..c3171417916f 100644 --- a/packages/roosterjs-content-model-markdown/test/markdownToModel/utils/splitParagraphSegmentsTest.ts +++ b/packages/roosterjs-content-model-markdown/test/markdownToModel/utils/splitParagraphSegmentsTest.ts @@ -81,4 +81,110 @@ describe('splitLinksAndImages', () => { ] ); }); + + it('should treat invalid link as text but still render valid image', () => { + runTest('[link](ht3tps://www.example.com) and ![image](https://www.example.com)', [ + { text: '[link](ht3tps://www.example.com) and ', type: 'text', url: '' }, + { text: 'image', type: 'image', url: 'https://www.example.com' }, + ]); + }); + + it('should render valid link but treat invalid image as text', () => { + runTest('[link](https://www.example.com) and ![image](http3s://www.example.com)', [ + { text: 'link', type: 'link', url: 'https://www.example.com' }, + { text: ' and ![image](http3s://www.example.com)', type: 'text', url: '' }, + ]); + }); + + it('should accept data: URL for image', () => { + runTest('![image](data:image/png;base64,abc123)', [ + { text: 'image', type: 'image', url: 'data:image/png;base64,abc123' }, + ]); + }); + + it('should accept blob: URL for image', () => { + runTest('![image](blob:https://example.com/some-id)', [ + { text: 'image', type: 'image', url: 'blob:https://example.com/some-id' }, + ]); + }); + + it('should accept absolute path for link', () => { + runTest('[link](/path/to/page)', [{ text: 'link', type: 'link', url: '/path/to/page' }]); + }); + + it('should accept relative path with ./ for link', () => { + runTest('[link](./relative/path)', [ + { text: 'link', type: 'link', url: './relative/path' }, + ]); + }); + + it('should accept relative path with ../ for link', () => { + runTest('[link](../parent/path)', [{ text: 'link', type: 'link', url: '../parent/path' }]); + }); + + it('should handle text before and after a link', () => { + runTest('before [link](https://www.example.com) after', [ + { text: 'before ', type: 'text', url: '' }, + { text: 'link', type: 'link', url: 'https://www.example.com' }, + { text: ' after', type: 'text', url: '' }, + ]); + }); + + it('should accept http: URL', () => { + runTest('[link](http://www.example.com)', [ + { text: 'link', type: 'link', url: 'http://www.example.com' }, + ]); + }); + + it('should accept URL with query string and fragment', () => { + runTest('[link](https://www.example.com/page?q=1&r=2#section)', [ + { + text: 'link', + type: 'link', + url: 'https://www.example.com/page?q=1&r=2#section', + }, + ]); + }); + + it('should handle two adjacent links with no text between', () => { + runTest('[first](https://www.example.com/1)[second](https://www.example.com/2)', [ + { text: 'first', type: 'link', url: 'https://www.example.com/1' }, + { text: 'second', type: 'link', url: 'https://www.example.com/2' }, + ]); + }); + + it('should treat a single invalid link as plain text', () => { + runTest('[link](ht3tps://www.example.com)', [ + { text: '[link](ht3tps://www.example.com)', type: 'text', url: '' }, + ]); + }); + + it('should treat a single invalid image as plain text', () => { + runTest('![image](http3s://www.example.com)', [ + { text: '![image](http3s://www.example.com)', type: 'text', url: '' }, + ]); + }); + + it('should treat partial markdown syntax as plain text', () => { + runTest('[not a link] and (not a url)', [ + { text: '[not a link] and (not a url)', type: 'text', url: '' }, + ]); + }); + + it('should accept relative path for image', () => { + runTest('![image](./images/photo.png)', [ + { text: 'image', type: 'image', url: './images/photo.png' }, + ]); + }); + + it('should handle multiple images in a row', () => { + runTest( + '![first](https://www.example.com/1.png) ![second](https://www.example.com/2.png)', + [ + { text: 'first', type: 'image', url: 'https://www.example.com/1.png' }, + { text: ' ', type: 'text', url: '' }, + { text: 'second', type: 'image', url: 'https://www.example.com/2.png' }, + ] + ); + }); }); 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'; diff --git a/versions.json b/versions.json index ab65f26dbaf9..5069611a0ddf 100644 --- a/versions.json +++ b/versions.json @@ -1,6 +1,6 @@ { "react": "9.0.4", - "main": "9.49.0", + "main": "9.50.0", "legacyAdapter": "8.65.3", "overrides": {} } diff --git a/yarn.lock b/yarn.lock index 70a8cee8f0f8..b7075d7ea2fc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3426,12 +3426,12 @@ handle-thing@^2.0.0: integrity sha512-d4sze1JNC454Wdo2fkuyzCr6aHcbL6PGGuFAz0Li/NcOm1tCHGnWDRmJP85dh9IhQErTc2svWFEX5xHIOo//kQ== handlebars@^4.7.7: - version "4.7.7" - resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.7.tgz#9ce33416aad02dbd6c8fafa8240d5d98004945a1" - integrity sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA== + version "4.7.9" + resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.9.tgz#6f139082ab58dc4e5a0e51efe7db5ae890d56a0f" + integrity sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ== dependencies: minimist "^1.2.5" - neo-async "^2.6.0" + neo-async "^2.6.2" source-map "^0.6.1" wordwrap "^1.0.0" optionalDependencies: @@ -4950,7 +4950,7 @@ negotiator@0.6.3: resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== -neo-async@^2.6.0, neo-async@^2.6.1, neo-async@^2.6.2: +neo-async@^2.6.1, neo-async@^2.6.2: version "2.6.2" resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== @@ -5388,25 +5388,10 @@ picocolors@^1.1.1: resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== -picomatch@^2.0.4: - version "2.2.1" - resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.2.1.tgz#21bac888b6ed8601f831ce7816e335bc779f0a4a" - integrity sha512-ISBaA8xQNmwELC7eOjqFKMESB2VIqt4PPDD0nsS95b/9dZXvVKOlz9keMSnoGGKcOHXfTvDD6WMaRoSc9UuhRA== - -picomatch@^2.0.5: - version "2.0.7" - resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.0.7.tgz#514169d8c7cd0bdbeecc8a2609e34a7163de69f6" - integrity sha512-oLHIdio3tZ0qH76NybpeneBhYVj0QFTfXEFTc/B3zKQspYfYYkWYgFsmzo+4kvId/bQRcNkVeguI3y+CD22BtA== - -picomatch@^2.2.1: - version "2.3.0" - resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.0.tgz#f1f061de8f6a4bf022892e2d128234fb98302972" - integrity sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw== - -picomatch@^2.3.1: - version "2.3.1" - resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" - integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== +picomatch@^2.0.4, picomatch@^2.0.5, picomatch@^2.2.1, picomatch@^2.3.1: + version "2.3.2" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.2.tgz#5a942915e26b372dc0f0e6753149a16e6b1c5601" + integrity sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA== pify@^2.0.0: version "2.3.0"