From ebb1625e29c1f26dbc561361c956f90f6be59e32 Mon Sep 17 00:00:00 2001 From: Ian Storm Taylor Date: Fri, 22 Jul 2016 16:58:24 -0700 Subject: [PATCH] add drag and drop support --- lib/components/content.js | 133 +++++++++++++++++++++++++++++++++++--- lib/components/editor.js | 20 ++++-- lib/plugins/core.js | 70 +++++++++++--------- 3 files changed, 182 insertions(+), 41 deletions(-) diff --git a/lib/components/content.js b/lib/components/content.js index bebdb0fd1..52f2524e0 100644 --- a/lib/components/content.js +++ b/lib/components/content.js @@ -1,5 +1,6 @@ import Key from '../utils/key' +import Selection from '../models/selection' import OffsetKey from '../utils/offset-key' import Raw from '../serializers/raw' import React from 'react' @@ -120,7 +121,6 @@ class Content extends React.Component { onBlur = (e) => { if (this.props.readOnly) return if (this.tmp.isCopying) return - if (this.tmp.isComposing) return let { state } = this.props state = state @@ -180,7 +180,6 @@ class Content extends React.Component { */ onCopy = (e) => { - if (this.tmp.isComposing) return this.onCutCopy(e) } @@ -192,7 +191,6 @@ class Content extends React.Component { onCut = (e) => { if (this.props.readOnly) return - if (this.tmp.isComposing) return this.onCutCopy(e) // Once the cut has successfully executed, delete the current selection. @@ -260,6 +258,121 @@ class Content extends React.Component { }) } + /** + * On drag end, unset the `isDragging` flag. + * + * @param {Event} e + */ + + onDragEnd = (e) => { + this.tmp.isDragging = false + } + + /** + * On drag over, set the `isDragging` flag and the `isInternalDrag` flag. + * + * @param {Event} e + */ + + onDragOver = (e) => { + if (this.tmp.isDragging) return + this.tmp.isDragging = true + this.tmp.isInternalDrag = false + } + + /** + * On drag start, set the `isDragging` flag and the `isInternalDrag` flag. + * + * @param {Event} e + */ + + onDragStart = (e) => { + this.tmp.isDragging = true + this.tmp.isInternalDrag = true + } + + /** + * On drop. + * + * @param {Event} e + */ + + onDrop = (e) => { + if (this.props.readOnly) return + e.preventDefault() + + const { state } = this.props + const { selection } = state + const data = e.nativeEvent.dataTransfer + const drop = {} + + // Resolve the point where the drop occured. + const { x, y } = e.nativeEvent + const range = window.document.caretRangeFromPoint(x, y) + const startNode = range.startContainer + const startOffset = range.startOffset + const point = OffsetKey.findPoint(startNode, startOffset, state) + let target = Selection.create({ + anchorKey: point.key, + anchorOffset: point.offset, + focusKey: point.key, + focusOffset: point.offset, + isFocused: true + }) + + // If the drag is internal, handle it now. And it the target is after the + // selection, it needs to account for the selection's content being deleted. + if (this.tmp.isInternalDrag) { + if ( + selection.endKey == target.endKey && + selection.endOffset < target.endOffset + ) { + const width = selection.startKey == selection.endKey + ? selection.endOffset - selection.startOffset + : selection.endOffset + + target = target.moveBackward(width) + } + + const fragment = state.fragment + const next = state + .transform() + .delete() + .moveTo(target) + .insertFragment(fragment) + .apply() + + this.onChange(next) + return + } + + // COMPAT: In Firefox, `types` is array-like. (2016/06/21) + const types = Array.from(data.types) + + // Handle files. + if (data.files.length) { + drop.type = 'files' + drop.files = data.files + } + + // Handle HTML. + else if (includes(types, 'text/html')) { + drop.type = 'html' + drop.text = data.getData('text/plain') + drop.html = data.getData('text/html') + } + + // Handle plain text. + else { + drop.type = 'text' + drop.text = data.getData('text/plain') + } + + drop.data = data + drop.target = target + this.props.onDrop(e, drop) + } + /** * On key down, prevent the default behavior of certain commands that will * leave the editor in an out-of-sync state, then bubble up. @@ -302,8 +415,8 @@ class Content extends React.Component { onPaste = (e) => { if (this.props.readOnly) return - if (this.tmp.isComposing) return e.preventDefault() + const data = e.clipboardData const paste = {} @@ -311,7 +424,7 @@ class Content extends React.Component { const types = Array.from(data.types) // Handle files. - if (data.files.length != 0) { + if (data.files.length) { paste.type = 'files' paste.files = data.files } @@ -434,20 +547,24 @@ class Content extends React.Component { return (
{children}
diff --git a/lib/components/editor.js b/lib/components/editor.js index 096c736b2..47603a6bf 100644 --- a/lib/components/editor.js +++ b/lib/components/editor.js @@ -25,6 +25,7 @@ class Editor extends React.Component { onBeforeInput: React.PropTypes.func, onChange: React.PropTypes.func.isRequired, onDocumentChange: React.PropTypes.func, + onDrop: React.PropTypes.func, onKeyDown: React.PropTypes.func, onPaste: React.PropTypes.func, onSelectionChange: React.PropTypes.func, @@ -170,6 +171,16 @@ class Editor extends React.Component { this.onEvent('onBeforeInput', ...args) } + /** + * On drop. + * + * @param {Mixed} ...args + */ + + onDrop = (...args) => { + this.onEvent('onDrop', ...args) + } + /** * On key down. * @@ -201,14 +212,15 @@ class Editor extends React.Component { ) diff --git a/lib/plugins/core.js b/lib/plugins/core.js index 025cc984e..bfab49468 100644 --- a/lib/plugins/core.js +++ b/lib/plugins/core.js @@ -110,43 +110,26 @@ function Plugin(options = {}) { /** * The core `onBeforeInput` handler. * - * - * - * - * - * Otherwise, we can allow the default, native text insertion, avoiding a - * re-render for improved performance. - * * @param {Event} e * @param {State} state * @param {Editor} editor - * @return {State or Null} newState + * @return {State or Null} */ onBeforeInput(e, state, editor) { const transform = state.transform().insertText(e.data) const synthetic = transform.apply() const resolved = editor.resolveState(synthetic) - let isNative = true - // If the current selection is expanded, we have to re-render. - if (state.isExpanded) { - isNative = false - } + // We do not have to re-render if the current selection is collapsed, the + // current node is not empty, and the new state has the same decorations + // as the current one. + const isNative = ( + state.isCollapsed && + state.startText.text != '' && + resolved.equals(synthetic) + ) - // If the current node was empty, we have to re-render so that any empty - // placeholder logic will be updated. - if (state.startText.text == '') { - isNative = false - } - - // If the next state resolves a new list of decorations for any of its - // text nodes, we have to re-render. - else if (!resolved.equals(synthetic)) { - isNative = false - } - - // Update the state with the proper `isNative`. state = isNative ? transform.apply({ isNative }) : synthetic @@ -155,13 +138,43 @@ function Plugin(options = {}) { return state }, + /** + * The core `onDrop` handler. + * + * @param {Event} e + * @param {Object} drop + * @param {State} state + * @param {Editor} editor + * @return {State or Null} + */ + + onDrop(e, drop, state, editor) { + switch (drop.type) { + case 'text': + case 'html': { + let transform = state + .transform() + .moveTo(drop.target) + + drop.text + .split('\n') + .forEach((line, i) => { + if (i > 0) transform = transform.splitBlock() + transform = transform.insertText(line) + }) + + return transform.apply() + } + } + }, + /** * The core `onKeyDown` handler. * * @param {Event} e * @param {State} state * @param {Editor} editor - * @return {State or Null} newState + * @return {State or Null} */ onKeyDown(e, state, editor) { @@ -267,7 +280,7 @@ function Plugin(options = {}) { * @param {Object} paste * @param {State} state * @param {Editor} editor - * @return {State or Null} newState + * @return {State or Null} */ onPaste(e, paste, state, editor) { @@ -301,7 +314,6 @@ function Plugin(options = {}) { } } - /** * Export. */