fix(drag-n-drop): Remove react-dnd, display dropzone, allow tokens to be dragged out of app as plaintext

React-dnd is a nice library but it assumes it's going to be the only thing managing your drag and drop in the entire app. It also hides the underlying dataTransfer object so it's difficult to implement native drag and drop behaviors other than for Files and Urls, which they special-case.

Implementing it manually really isn't hard, and we can do things like attach a text/plain version of contact data to the contact drag.

Fixes T2054
This commit is contained in:
Ben Gotow 2015-07-15 20:06:11 -07:00
parent e6881573d9
commit b0f93dc52c
9 changed files with 107 additions and 85 deletions

View file

@ -4,37 +4,6 @@ React = require 'react'
{RetinaImg, Flexbox} = require 'nylas-component-kit'
{Actions, Utils, FileDownloadStore} = require 'nylas-exports'
# Passed in as props from MessageItem and FileDownloadStore
# This is empty if the attachment isn't downloading.
# @props.download is a FileDownloadStore.Download object
# @props.file is a File object
{DragDropMixin} = require 'react-dnd'
AttachmentDragContainer = React.createClass
displayName: "AttachmentDragContainer"
mixins: [DragDropMixin]
statics:
configureDragDrop: (registerType) =>
registerType('attachment', {
dragSource:
beginDrag: (component) =>
# Why is event defined in this scope? Magic. We need to use react-dnd
# because otherwise it's global onDragStart listener will cancel the
# drag. We don't actually intend to do a react-dnd drag/drop, but we
# can use this hook to populate the event.dataTransfer
DownloadURL = component.props.downloadUrl
event.dataTransfer.setData("DownloadURL", DownloadURL)
event.dataTransfer.setData("text/nylas-file-url", DownloadURL)
# This is bogus we don't care about the rest of the react-dnd lifecycle.
return {item: {DownloadURL}}
})
render: ->
<div {...@dragSourceFor('attachment')} draggable="true">
{@props.children}
</div>
class AttachmentComponent extends React.Component
@displayName: 'AttachmentComponent'
@ -49,22 +18,20 @@ class AttachmentComponent extends React.Component
@state = progressPercent: 0
render: =>
<AttachmentDragContainer downloadUrl={@_getDragDownloadURL()}>
<div className="inner" onDoubleClick={@_onClickView}>
<span className={"progress-bar-wrap state-#{@props.download?.state ? ""}"}>
<span className="progress-background"></span>
<span className="progress-foreground" style={@_downloadProgressStyle()}></span>
</span>
<div className="inner" onDoubleClick={@_onClickView} onDragStart={@_onDragStart} draggable="true">
<span className={"progress-bar-wrap state-#{@props.download?.state ? ""}"}>
<span className="progress-background"></span>
<span className="progress-foreground" style={@_downloadProgressStyle()}></span>
</span>
<Flexbox direction="row" style={alignItems: 'center'}>
<RetinaImg className="file-icon"
fallback="file-fallback.png"
name="file-#{@_extension()}.png"/>
<span className="file-name">{@props.file.displayName()}</span>
{@_renderFileActions()}
</Flexbox>
</div>
</AttachmentDragContainer>
<Flexbox direction="row" style={alignItems: 'center'}>
<RetinaImg className="file-icon"
fallback="file-fallback.png"
name="file-#{@_extension()}.png"/>
<span className="file-name">{@props.file.displayName()}</span>
{@_renderFileActions()}
</Flexbox>
</div>
_renderFileActions: =>
if @props.removable
@ -95,9 +62,12 @@ class AttachmentComponent extends React.Component
_renderDownloadButton: ->
<RetinaImg name="icon-attachment-download.png"/>
_getDragDownloadURL: (event) =>
_onDragStart: (event) =>
path = FileDownloadStore.pathForFile(@props.file)
return "#{@props.file.contentType}:#{@props.file.displayName()}:file://#{path}"
DownloadURL = "#{@props.file.contentType}:#{@props.file.displayName()}:file://#{path}"
event.dataTransfer.setData("DownloadURL", DownloadURL)
event.dataTransfer.setData("text/nylas-file-url", DownloadURL)
return
_onClickView: => Actions.fetchAndOpenFile(@props.file) if @_canClickToView()

View file

@ -154,9 +154,13 @@ class ComposerView extends React.Component
"composer-outer-wrap #{@props.className ? ""}"
_renderComposer: =>
<div className="composer-inner-wrap" onDragOver={@_onDragNoop} onDragLeave={@_onDragNoop} onDragEnd={@_onDragNoop} onDrop={@_onDrop}>
<div className="composer-cover"
style={display: (if @state.isSending then "block" else "none")}>
<div className="composer-inner-wrap" onDragEnter={@_onDragEnter} onDragLeave={@_onDragLeave} onDrop={@_onDrop}>
<div className="composer-cover" style={display: if @state.isSending then 'block' else 'none'}></div>
<div className="composer-drop-cover" style={display: if @state.isDropping then 'block' else 'none'}>
<div className="centered">
<RetinaImg name="composer-drop-to-attach.png" mode={RetinaImg.Mode.ContentIsMask}/>
Drop to attach
</div>
</div>
<div className="composer-content-wrap">
@ -460,11 +464,34 @@ class ComposerView extends React.Component
else if @isForwardedMessage() then return true
else return false
_onDragNoop: (e) =>
e.preventDefault()
_shouldAcceptDrop: (event) ->
(event.dataTransfer.files.length or
"text/nylas-file-url" in event.dataTransfer.types or
"text/uri-list" in event.dataTransfer.types)
# We maintain a "dragCounter" because dragEnter and dragLeave events *stack*
# when the user moves the item in and out of DOM elements inside of our container.
# It's really awful and everyone hates it.
#
# Alternative solution *maybe* is to set pointer-events:none; during drag.
_onDragEnter: (e) =>
return unless @_shouldAcceptDrop(e)
@_dragCounter ?= 0
@_dragCounter += 1
if @_dragCounter is 1
@setState(isDropping: true)
_onDragLeave: (e) =>
return unless @_shouldAcceptDrop(e)
@_dragCounter -= 1
if @_dragCounter is 0
@setState(isDropping: false)
_onDrop: (e) =>
e.preventDefault()
return unless @_shouldAcceptDrop(e)
@setState(isDropping: false)
@_dragCounter = 0
# Accept drops of real files from other applications
for file in e.dataTransfer.files
@ -483,7 +510,7 @@ class ComposerView extends React.Component
uri = uri.split('file://')[1]
Actions.attachFilePath({path: uri, messageLocalId: @props.localId})
true
return
_onFilePaste: (path) =>
Actions.attachFilePath({path: path, messageLocalId: @props.localId})

View file

@ -22,6 +22,28 @@
background: rgba(255,255,255,0.7);
}
.composer-drop-cover {
position: absolute;
top: 0; right: 0; bottom: 0; left: 0;
z-index: 1000;
background: rgba(255,255,255,0.7);
border-radius: @border-radius-base;
border: 4px dashed lighten(@gray, 30%);
text-align: center;
line-height:2.3em;
.centered {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
color: lighten(@gray, 20%);
font-weight: 500;
font-size:1.1em;
img { margin: auto; display:block; margin-bottom:20px; background-color: lighten(@gray, 20%); }
}
}
.composer-action-bar-wrap {
position: relative;
z-index: 1;

View file

@ -118,8 +118,8 @@
width: calc(~"100% - 12px");
margin: @message-spacing auto;
padding: 0 0 5px 0;
padding: 0;
background: @background-primary;
border: 0;
box-shadow: 0 0 0.5px rgba(0, 0, 0, 0.28), 0 1px 1.5px rgba(0, 0, 0, 0.08);

View file

@ -51,7 +51,6 @@
"raven": "0.7.2",
"react": "^0.13.2",
"react-atom-fork": "^0.11.5",
"react-dnd": "^0.9.8",
"reactionary-atom-fork": "^1.0.0",
"reflux": "0.1.13",
"request": "^2.53",

View file

@ -19,5 +19,6 @@ class DraggableImg extends React.Component
y = event.clientY - rect.top
x = event.clientX - rect.left
event.dataTransfer.setDragImage(img, x, y)
return
module.exports = DraggableImg

View file

@ -5,34 +5,25 @@ _ = require 'underscore'
{Utils, Contact, ContactStore} = require 'nylas-exports'
RetinaImg = require './retina-img'
{DragDropMixin} = require 'react-dnd'
Token = React.createClass
displayName: "Token"
mixins: [DragDropMixin]
propTypes:
selected: React.PropTypes.bool,
select: React.PropTypes.func.isRequired,
action: React.PropTypes.func,
item: React.PropTypes.object,
statics:
configureDragDrop: (registerType) =>
registerType('token', {
dragSource:
beginDrag: (component) =>
item: component.props.item
})
getInitialState: ->
{}
render: ->
classes = classNames
"token": true
"dragging": @getDragState('token').isDragging
"dragging": @state.dragging
"selected": @props.selected
<div {...@dragSourceFor('token')}
<div onDragStart={@_onDragStart} onDragEnd={@_onDragEnd} draggable="true"
className={classes}
onClick={@_onSelect}>
<button className="action" onClick={@_onAction} style={marginTop: "2px"}>
@ -41,6 +32,15 @@ Token = React.createClass
{@props.children}
</div>
_onDragStart: (event) ->
textValue = React.findDOMNode(@).innerText
event.dataTransfer.setData('nylas-token-item', JSON.stringify(@props.item))
event.dataTransfer.setData('text/plain', textValue)
@setState(dragging: true)
_onDragEnd: (event) ->
@setState(dragging: false)
_onSelect: (event) ->
@props.select(@props.item)
event.preventDefault()
@ -157,16 +157,6 @@ TokenizingTextField = React.createClass
# A classSet hash applied to the Menu item
menuClassSet: React.PropTypes.object
mixins: [DragDropMixin]
statics:
configureDragDrop: (registerType) =>
registerType('token', {
dropTarget:
acceptDrop: (component, token) =>
component._addToken(token)
})
getInitialState: ->
inputValue: ""
completions: []
@ -223,7 +213,7 @@ TokenizingTextField = React.createClass
/>
_fieldComponent: ->
<div key="field-component" onClick={@focus} {...@dropTargetFor('token')}>
<div key="field-component" onClick={@focus} onDrop={@_onDrop}>
{@_renderPrompt()}
<div className="tokenizing-field-input">
{@_placeholder()}
@ -294,6 +284,18 @@ TokenizingTextField = React.createClass
# Maintaining Input State
_onDrop: (event) ->
return unless 'nylas-token-item' in event.dataTransfer.types
try
data = event.dataTransfer.getData('nylas-token-item')
json = JSON.parse(data)
model = Utils.modelFromJSON(json)
catch
model = null
if model and model instanceof Contact
@_addToken(model)
_onInputFocused: ({noCompletions}={}) ->
@setState focus: true
@_refreshCompletions() unless noCompletions

View file

@ -61,9 +61,10 @@ FileUploadStore = Reflux.createStore
_onAttachFilePath: ({messageLocalId, path}) ->
@_verifyId(messageLocalId)
fs.stat path, (err, stats) =>
return if err
if stats.isDirectory()
filename = require('path').basename(path)
filename = require('path').basename(path)
if err
@_onAttachFileError("#{filename} could not be found, or has invalid file permissions.")
else if stats.isDirectory()
@_onAttachFileError("#{filename} is a directory. Try compressing it and attaching it again.")
else
Actions.queueTask(new FileUploadTask(path, messageLocalId))

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB