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:
parent
c485dfc5c8
commit
454bc8020b
@ -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
|
||||||
|
@ -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)
|
||||||
|
@ -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}
|
||||||
|
@ -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
241
lib/utils/transfer.js
Normal 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
|
@ -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'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -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",
|
||||||
|
Loading…
x
Reference in New Issue
Block a user