Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions demo/scripts/controlsV2/mainPane/MainPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import {
AnnouncePlugin,
AutoFormatPlugin,
CustomReplacePlugin,
DragAndDropPlugin,
EditPlugin,
HiddenPropertyPlugin,
HyperlinkPlugin,
Expand Down Expand Up @@ -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);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const initialState: OptionState = {
hiddenProperty: true,
touch: true,
announce: true,
dragAndDrop: true,
},
defaultFormat: {
fontFamily: 'Calibri',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export interface BuildInPluginList {
hiddenProperty: boolean;
touch: boolean;
announce: boolean;
dragAndDrop: boolean;
}

export interface OptionState {
Expand Down
1 change: 1 addition & 0 deletions demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,7 @@ export class Plugins extends PluginsBase<keyof BuildInPluginList> {
{this.renderPluginItem('hiddenProperty', 'Hidden Property')}
{this.renderPluginItem('touch', 'Touch')}
{this.renderPluginItem('announce', 'Announce')}
{this.renderPluginItem('dragAndDrop', 'DragAndDrop')}
</tbody>
</table>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { OptionState } from '../OptionState';
import { WatermarkCode } from './WatermarkCode';

import {
DragAndDropPluginCode,
EditPluginCode,
PastePluginCode,
TableEditPluginCode,
Expand Down Expand Up @@ -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(),
]);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,9 @@ export class ImageEditPluginCode extends SimplePluginCode {
super('ImageEditPlugin');
}
}

export class DragAndDropPluginCode extends SimplePluginCode {
constructor() {
super('DragAndDropPlugin');
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ class DOMEventPlugin implements PluginWithState<DOMEventPluginState> {

// 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) },
Expand Down Expand Up @@ -137,17 +137,23 @@ class DOMEventPlugin implements PluginWithState<DOMEventPluginState> {
}
};

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) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -553,18 +551,50 @@ 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,
mouseDownX: null,
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());
});
});
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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,
},
}
);
}
}
1 change: 1 addition & 0 deletions packages/roosterjs-content-model-plugins/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Loading
Loading