1
0
mirror of https://github.com/ianstormtaylor/slate.git synced 2025-03-06 05:49:47 +01:00

add full support for file data transfers

This commit is contained in:
Ian Storm Taylor 2016-08-09 12:25:08 -07:00
parent c485dfc5c8
commit 454bc8020b
7 changed files with 331 additions and 96 deletions

View File

@ -5,6 +5,13 @@ This document maintains a list of changes to Slate with each new version. Until
--- ---
### `0.12.0` — _August 9, 2016_
#### BREAKING CHANGES
- **The `data.files` property is now an `Array`. Previously it was a native `FileList` object, but needed to be changed to add full support for pasting an dropping files in all browsers. This shouldn't affect you unless you were specifically depending on it being array-like instead of a true `Array`.
### `0.11.0` — _August 4, 2016_ ### `0.11.0` — _August 4, 2016_
#### BREAKING CHANGES #### BREAKING CHANGES

View File

@ -164,11 +164,27 @@ class Images extends React.Component {
* @param {Event} e * @param {Event} e
* @param {Object} data * @param {Object} data
* @param {State} state * @param {State} state
* @param {Editor} editor
* @return {State} * @return {State}
*/ */
onDrop = (e, data, state) => { onDrop = (e, data, state, editor) => {
if (data.type != 'node') return switch (data.type) {
case 'files': return this.onDropOrPasteFiles(e, data, state, editor)
case 'node': return this.onDropNode(e, data, state)
}
}
/**
* On drop node, insert the node wherever it is dropped.
*
* @param {Event} e
* @param {Object} data
* @param {State} state
* @return {State}
*/
onDropNode = (e, data, state) => {
return state return state
.transform() .transform()
.removeNodeByKey(data.node.key) .removeNodeByKey(data.node.key)
@ -177,17 +193,59 @@ class Images extends React.Component {
.apply() .apply()
} }
/**
* On drop or paste files, read and insert the image files.
*
* @param {Event} e
* @param {Object} data
* @param {State} state
* @param {Editor} editor
* @return {State}
*/
onDropOrPasteFiles = (e, data, state, editor) => {
for (const file of data.files) {
const reader = new FileReader()
const [ type, ext ] = file.type.split('/')
if (type != 'image') continue
reader.addEventListener('load', () => {
state = editor.getState()
state = this.insertImage(state, reader.result)
editor.onChange(state)
})
reader.readAsDataURL(file)
}
}
/** /**
* On paste, if the pasted content is an image URL, insert it. * On paste, if the pasted content is an image URL, insert it.
* *
* @param {Event} e * @param {Event} e
* @param {Object} data * @param {Object} data
* @param {State} state * @param {State} state
* @param {Editor} editor
* @return {State}
*/
onPaste = (e, data, state, editor) => {
switch (data.type) {
case 'files': return this.onDropOrPasteFiles(e, data, state, editor)
case 'text': return this.onPasteText(e, data, state)
}
}
/**
* On paste text, if the pasted content is an image URL, insert it.
*
* @param {Event} e
* @param {Object} data
* @param {State} state
* @return {State} * @return {State}
*/ */
onPaste = (e, data, state) => { onPasteText = (e, data, state) => {
if (data.type != 'text') return
if (!isUrl(data.text)) return if (!isUrl(data.text)) return
if (!isImage(data.text)) return if (!isImage(data.text)) return
return this.insertImage(state, data.text) return this.insertImage(state, data.text)

View File

@ -5,6 +5,7 @@ import Node from './node'
import OffsetKey from '../utils/offset-key' import OffsetKey from '../utils/offset-key'
import React from 'react' import React from 'react'
import Selection from '../models/selection' import Selection from '../models/selection'
import Transfer from '../utils/transfer'
import TYPES from '../utils/types' import TYPES from '../utils/types'
import getWindow from 'get-window' import getWindow from 'get-window'
import includes from 'lodash/includes' import includes from 'lodash/includes'
@ -318,12 +319,11 @@ class Content extends React.Component {
onDragOver = (e) => { onDragOver = (e) => {
if (isNonEditable(e)) return if (isNonEditable(e)) return
const data = e.nativeEvent.dataTransfer const { dataTransfer } = e.nativeEvent
// COMPAT: In Firefox, `types` is array-like. (2016/06/21) const transfer = new Transfer(dataTransfer)
const types = Array.from(data.types)
// Prevent default when nodes are dragged to allow dropping. // Prevent default when nodes are dragged to allow dropping.
if (includes(types, TYPES.NODE)) { if (transfer.getType() == 'node') {
e.preventDefault() e.preventDefault()
} }
@ -345,17 +345,16 @@ class Content extends React.Component {
this.tmp.isDragging = true this.tmp.isDragging = true
this.tmp.isInternalDrag = true this.tmp.isInternalDrag = true
const data = e.nativeEvent.dataTransfer const { dataTransfer } = e.nativeEvent
// COMPAT: In Firefox, `types` is array-like. (2016/06/21) const transfer = new Transfer(dataTransfer)
const types = Array.from(data.types)
// If it's a node being dragged, the data type is already set. // If it's a node being dragged, the data type is already set.
if (includes(types, TYPES.NODE)) return if (transfer.getType() == 'node') return
const { state } = this.props const { state } = this.props
const { fragment } = state const { fragment } = state
const encoded = Base64.serializeNode(fragment) const encoded = Base64.serializeNode(fragment)
data.setData(TYPES.FRAGMENT, encoded) dataTransfer.setData(TYPES.FRAGMENT, encoded)
debug('onDragStart') debug('onDragStart')
} }
@ -376,10 +375,8 @@ class Content extends React.Component {
const { state, renderDecorations } = this.props const { state, renderDecorations } = this.props
const { selection } = state const { selection } = state
const { dataTransfer, x, y } = e.nativeEvent const { dataTransfer, x, y } = e.nativeEvent
const data = {} const transfer = new Transfer(dataTransfer)
const data = transfer.getData()
// COMPAT: In Firefox, `types` is array-like. (2016/06/21)
const types = Array.from(dataTransfer.types)
// Resolve the point where the drop occured. // Resolve the point where the drop occured.
let range let range
@ -406,46 +403,14 @@ class Content extends React.Component {
// If the target is inside a void node, abort. // If the target is inside a void node, abort.
if (state.document.hasVoidParent(point.key)) return if (state.document.hasVoidParent(point.key)) return
// Handle Slate fragments. // Add drop-specific information to the data.
if (includes(types, TYPES.FRAGMENT)) {
const encoded = dataTransfer.getData(TYPES.FRAGMENT)
const fragment = Base64.deserializeNode(encoded)
data.type = 'fragment'
data.fragment = fragment
data.isInternal = this.tmp.isInternalDrag
}
// Handle Slate nodes.
else if (includes(types, TYPES.NODE)) {
const encoded = dataTransfer.getData(TYPES.NODE)
const node = Base64.deserializeNode(encoded)
data.type = 'node'
data.node = node
data.isInternal = this.tmp.isInternalDrag
}
// Handle files.
else if (dataTransfer.files.length) {
data.type = 'files'
data.files = dataTransfer.files
}
// Handle HTML.
else if (includes(types, TYPES.HTML)) {
data.type = 'html'
data.text = dataTransfer.getData(TYPES.TEXT)
data.html = dataTransfer.getData(TYPES.HTML)
}
// Handle plain text.
else {
data.type = 'text'
data.text = dataTransfer.getData(TYPES.TEXT)
}
data.target = target data.target = target
data.effect = dataTransfer.dropEffect data.effect = dataTransfer.dropEffect
if (data.type == 'fragment' || data.type == 'node') {
data.isInternal = this.tmp.isInternalDrag
}
debug('onDrop', data) debug('onDrop', data)
this.props.onDrop(e, data) this.props.onDrop(e, data)
} }
@ -575,42 +540,8 @@ class Content extends React.Component {
if (isNonEditable(e)) return if (isNonEditable(e)) return
e.preventDefault() e.preventDefault()
const transfer = new Transfer(e.clipboardData)
const { clipboardData } = e const data = transfer.getData()
const data = {}
// COMPAT: In Firefox, `types` is array-like. (2016/06/21)
const types = Array.from(clipboardData.types)
// Handle files.
if (clipboardData.files.length) {
data.type = 'files'
data.files = clipboardData.files
}
// Treat it as rich text if there is HTML content.
else if (includes(types, TYPES.HTML)) {
data.type = 'html'
data.text = clipboardData.getData(TYPES.TEXT)
data.html = clipboardData.getData(TYPES.HTML)
}
// Treat everything else as plain text.
else {
data.type = 'text'
data.text = clipboardData.getData(TYPES.TEXT)
}
// If html, and the html includes a `data-fragment` attribute, it's actually
// a raw-serialized JSON fragment from a previous cut/copy, so deserialize
// it and update the data.
if (data.type == 'html' && ~data.html.indexOf('<span data-fragment="')) {
const regexp = /data-fragment="([^\s]+)"/
const matches = regexp.exec(data.html)
const [ full, encoded ] = matches
data.type = 'fragment'
data.fragment = Base64.deserializeNode(encoded)
}
debug('onPaste', data) debug('onPaste', data)
this.props.onPaste(e, data) this.props.onPaste(e, data)
@ -716,7 +647,7 @@ class Content extends React.Component {
return ( return (
<div <div
key={`slate-content-${this.forces}`} key={this.forces}
contentEditable={!readOnly} contentEditable={!readOnly}
suppressContentEditableWarning suppressContentEditableWarning
className={className} className={className}

View File

@ -224,7 +224,7 @@ function Plugin(options = {}) {
const rest = text.textContent.slice(1) const rest = text.textContent.slice(1)
text.textContent = rest text.textContent = rest
wrapper.appendChild(first) wrapper.appendChild(first)
wrapper.setAttribute('data-fragment', encoded) wrapper.setAttribute('data-slate-fragment', encoded)
contents.insertBefore(wrapper, text) contents.insertBefore(wrapper, text)
// Add the phony content to the DOM, and select it, so it will be copied. // Add the phony content to the DOM, and select it, so it will be copied.

241
lib/utils/transfer.js Normal file
View File

@ -0,0 +1,241 @@
import Base64 from '../serializers/base-64'
import TYPES from './types'
/**
* Fragment matching regexp for HTML nodes.
*
* @type {RegExp}
*/
const FRAGMENT_MATCHER = /data-slate-fragment="([^\s]+)"/
/**
* Data transfer helper.
*
* @type {Transfer}
*/
class Transfer {
/**
* Constructor.
*
* @param {DataTransfer} data
*/
constructor(data) {
this.data = data
this.cache = {}
}
/**
* Get a data object representing the transfer's primary content type.
*
* @return {Object}
*/
getData() {
const type = this.getType()
const data = {}
data.type = type
switch (type) {
case 'files':
data.files = this.getFiles()
break
case 'fragment':
data.fragment = this.getFragment()
break
case 'html':
data.html = this.getHtml()
data.text = this.getText()
break
case 'node':
data.node = this.getNode()
break
case 'text':
data.text = this.getText()
break
}
return data
}
/**
* Get the Files content of the data transfer.
*
* @return {Array || Void}
*/
getFiles() {
if ('files' in this.cache) return this.cache.files
const { data } = this
let files
if (data.items && data.items.length) {
const fileItems = Array.from(data.items)
.map(item => item.kind == 'file' ? item.getAsFile() : null)
.filter(exists => exists)
if (fileItems.length) files = fileItems
}
if (data.files && data.files.length) {
files = Array.from(data.files)
}
this.cache.files = files
return files
}
/**
* Get the Slate document fragment content of the data transfer.
*
* @return {Document || Void}
*/
getFragment() {
if ('fragment' in this.cache) return this.cache.fragment
const html = this.getHtml()
let encoded = this.data.getData(TYPES.FRAGMENT)
let fragment
// If there's html content, and the html includes a `data-fragment`
// attribute, it's actually a Base64-serialized fragment from a cut/copy.
if (!encoded && html && ~html.indexOf('<span data-slate-fragment="')) {
const matches = FRAGMENT_MATCHER.exec(html)
const [ full, attribute ] = matches
encoded = attribute
}
if (encoded) {
fragment = Base64.deserializeNode(encoded)
}
this.cache.fragment = fragment
return fragment
}
/**
* Get the HTML content of the data transfer.
*
* @return {String || Void}
*/
getHtml() {
if ('html' in this.cache) return this.cache.html
const html = this.data.getData('text/html')
this.cache.html = html
return html
}
/**
* Get the Slate node content of the data transfer.
*
* @return {Node || Void}
*/
getNode() {
if ('node' in this.cache) return this.cache.node
const encoded = this.data.getData(TYPES.NODE)
let node
if (encoded) {
node = Base64.deserializeNode(encoded)
}
this.cache.node = node
return node
}
/**
* Get the text content of the data transfer.
*
* @return {String || Void}
*/
getText() {
if ('text' in this.cache) return this.cache.text
const text = this.data.getData('text/plain')
this.cache.text = text
return text
}
/**
* Get the primary type of the data transfer.
*
* @return {String}
*/
getType() {
if (this.hasFragment()) return 'fragment'
if (this.hasNode()) return 'node'
if (this.hasFiles()) return 'files'
if (this.hasHtml()) return 'html'
if (this.hasText()) return 'text'
return 'unknown'
}
/**
* Check whether the data transfer has File content.
*
* @return {Boolean}
*/
hasFiles() {
return this.getFiles() != null
}
/**
* Check whether the data transfer has HTML content.
*
* @return {Boolean}
*/
hasHtml() {
return this.getHtml() != null
}
/**
* Check whether the data transfer has text content.
*
* @return {Boolean}
*/
hasText() {
return this.getText() != null
}
/**
* Check whether the data transfer has a Slate document fragment as content.
*
* @return {Boolean}
*/
hasFragment() {
return this.getFragment() != null
}
/**
* Check whether the data transfer has a Slate node as content.
*
* @return {Boolean}
*/
hasNode() {
return this.getFragment() != null
}
}
/**
* Export.
*/
export default Transfer

View File

@ -1,15 +1,13 @@
/** /**
* Content types. * Slate-specific data transfer types.
* *
* @type {Object} * @type {Object}
*/ */
const TYPES = { const TYPES = {
FRAGMENT: 'application/x-slate-fragment', FRAGMENT: 'application/x-slate-fragment',
HTML: 'text/html',
NODE: 'application/x-slate-node', NODE: 'application/x-slate-node',
TEXT: 'text/plain'
} }
/** /**

View File

@ -12,8 +12,8 @@
"direction": "^0.1.5", "direction": "^0.1.5",
"esrever": "^0.2.0", "esrever": "^0.2.0",
"get-window": "^1.1.1", "get-window": "^1.1.1",
"is-empty": "^1.0.0",
"immutable": "^3.8.1", "immutable": "^3.8.1",
"is-empty": "^1.0.0",
"keycode": "^2.1.2", "keycode": "^2.1.2",
"lodash": "^4.13.1", "lodash": "^4.13.1",
"type-of": "^2.0.1", "type-of": "^2.0.1",