mirror of
https://github.com/Foundry376/Mailspring.git
synced 2024-12-26 10:00:50 +08:00
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:
parent
e6881573d9
commit
b0f93dc52c
9 changed files with 107 additions and 85 deletions
|
@ -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,8 +18,7 @@ class AttachmentComponent extends React.Component
|
|||
@state = progressPercent: 0
|
||||
|
||||
render: =>
|
||||
<AttachmentDragContainer downloadUrl={@_getDragDownloadURL()}>
|
||||
<div className="inner" onDoubleClick={@_onClickView}>
|
||||
<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>
|
||||
|
@ -64,7 +32,6 @@ class AttachmentComponent extends React.Component
|
|||
{@_renderFileActions()}
|
||||
</Flexbox>
|
||||
</div>
|
||||
</AttachmentDragContainer>
|
||||
|
||||
_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()
|
||||
|
||||
|
|
|
@ -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})
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -118,7 +118,7 @@
|
|||
width: calc(~"100% - 12px");
|
||||
|
||||
margin: @message-spacing auto;
|
||||
padding: 0 0 5px 0;
|
||||
padding: 0;
|
||||
|
||||
background: @background-primary;
|
||||
border: 0;
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
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))
|
||||
|
|
BIN
static/images/composer/composer-drop-to-attach@2x.png
Normal file
BIN
static/images/composer/composer-drop-to-attach@2x.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.9 KiB |
Loading…
Reference in a new issue