substance
Version:
Substance is a JavaScript library for web-based content editing. It provides building blocks for realizing custom text editors and web-based publishing systems.
509 lines (464 loc) • 15.3 kB
JavaScript
import { DefaultDOMElement } from '../dom'
import { EventEmitter, platform, getDOMRangeFromEvent, isMouseInsideDOMSelection } from '../util'
import { operationHelpers } from '../model'
import Component from './Component'
import DragAndDropHandler from './DragAndDropHandler'
class DragManager extends EventEmitter {
constructor(customDropHandlers, context) {
super()
this.context = context
let dropAssetHandlers = []
let moveInlineHandlers = []
customDropHandlers.forEach((h) => {
// legacy: default type = 'asset'
let type = h.type || 'drop-asset'
switch (type) {
case 'drop-asset': {
dropAssetHandlers.push(h)
break
}
case 'move-inline': {
moveInlineHandlers.push(h)
break
}
default:
console.warn('Unknown type of drop handler.', h)
}
})
// TODO: This could live in the configurator at some point
this.dropHandlers = [
// source is a PropertySelection, target is a property
new MoveInline(moveInlineHandlers),
// source is a NodeSelection, target is a container position
new MoveBlockNode(),
// drop external files
new InsertNodes(dropAssetHandlers, this.context),
// dynamic custom handler, activated via custom dropzone
// not via configuration
new CustomHandler(),
]
if (platform.inBrowser) {
this.el = DefaultDOMElement.wrapNativeElement(document)
this.el.on('dragstart', this.onDragStart, this)
// this.el.on('dragend', this.onDragEnd, this)
this.el.on('drop', this.onDragEnd, this)
this.el.on('dragenter', this.onDragEnter, this)
this.el.on('dragexit', this.onDragExit, this)
this.el.on('mousedown', this.onMousedown, this)
}
}
dispose() {
if (this.el) {
this.el.off(this)
}
}
onDragStart(e) {
// console.log('#### DragManager.onDragStart')
this._initDrag(e, { external: false })
// Ensure we have a small dragIcon, so dragged content does not eat up
// all screen space.
var img = document.createElement("img")
img.src = "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==";
e.dataTransfer.setDragImage(img, 0, 0)
// TODO: the following might probably not work in FF as it disallows setting the drag data
// Note: setData('text/html', ... ) is necessary so that the browser shows
// the target caret while dragging, the content must be allowed to drop inline
// console.log('#### dragState.mode', )
// TODO: at this point we should do it similar as in Clipboard
// i.e. copy the selection and export it to HTML
if (this.dragState.mode === 'inline') {
e.dataTransfer.setData('text/html', img.outerHTML)
} else {
// otherwise we clear the data trying to make the caret
// invisible this way
e.dataTransfer.setData('text/html', '<div></div>')
}
// console.log('####', this.dragState)
}
/*
When drag starts externally, e.g. draggin a file into the workspace
*/
onDragEnter(e) {
if (!this.dragState) {
// console.log('onDragEnter(e)', e)
this._initDrag(e, {external: true})
}
}
onDragEnd(event) {
if (event.__reserved__) return
// console.log('onDragEnd', event)
if (this.dragState) {
event.stopPropagation()
event.preventDefault()
// HACK: there is no way to know if Dropzones wants to
// extend state, as it can only do it on drop
this._onDragEnd(event)
}
}
onDragExit(e) {
if (platform.isFF) {
// FF fires this quite rapidly
} else {
// TODO: OTOH, we need to find out if this
// is really necessary in the other browsers
// console.log('onDragExit', e)
this._onDragEnd(e)
}
}
extendDragState(extState) {
Object.assign(this.dragState, extState)
}
// used to at least reset on the next mousedown
// TODO: figure out if we could make this only 'once'
onMousedown(event) {
if (this.dragState) {
this.dragState = null
this._onDragEnd(event)
}
}
_onDragEnd(event) {
if (!this.dragState) {
// TODO: There are cases where _onDragEnd is called manually via
// handleDrop and another time via the native dragend event. check
// why this happens and how it can be avoided
console.warn('Not in a valid drag state.')
} else {
this._handleDrop(event)
}
this.emit('drag:finished')
this.dragState = null
}
/*
Called by Dropzones component after drop received
*/
_handleDrop(e) {
let dragState = this.dragState
let i, handler
let match = false
dragState.event = e
dragState.data = this._getData(e)
// Run through drop handlers and call the first that matches
for (i = 0; i < this.dropHandlers.length && !match; i++) {
handler = this.dropHandlers[i]
match = handler.match(dragState)
}
if (match) {
let editorSession = this.context.editorSession
editorSession.transaction((tx) => {
handler.drop(tx, dragState)
})
} else {
console.error('No drop handler could be found.')
}
}
/*
Initializes dragState, which encapsulate state through the whole
drag + drop operation.
ATTENTION: This can not be debugged properly in Chrome
*/
_initDrag(event, options) {
// TODO: we need to figure out how to enable dragging cursors
// e.g., when dragging an inline node containing an img, it looks
// nice, showing the target caret and a dragging cursor.
// Doing the same just with text content does show the forbidden symbol
// console.log('_initDrag')
let sel = this._getSelection()
let dragState = Object.assign({ startEvent: event }, options)
this.dragState = dragState
// external drag
// Note: we only consider drops on the block-level or with custom dropzones
if (dragState.external) {
dragState.selectionDrag = false
dragState.sourceSelection = null
dragState.scrollPanes = this._getSurfacesGroupedByScrollPane()
this.emit('drag:started', dragState)
return
}
// Note: selection drags are always without drop-zones,
// but using the native cursor
let isSelectionDrag = (
(sel.isPropertySelection() || sel.isContainerSelection()) &&
isMouseInsideDOMSelection(event)
)
if (isSelectionDrag) {
// TODO: we do not support dragging of ContainerSelection yet
if (sel.isContainerSelection()) {
console.warn('Dragging of ContainerSelection is not supported yet.')
return _stop()
}
// console.log('DragManager: starting a selection drag', sel.toString())
dragState.inline = true
dragState.selectionDrag = true
dragState.sourceSelection = sel
// TODO: should we emit for dropzones?
return
}
let comp = Component.unwrap(event.target)
if (!comp) return _stop()
let isolatedNodeComponent
if (comp._isInlineNodeComponent) {
isolatedNodeComponent = comp
dragState.inline = true
dragState.sourceNode = comp.props.node
} else {
isolatedNodeComponent = comp.context.isolatedNodeComponent
}
if (!isolatedNodeComponent) return _stop()
let surface = isolatedNodeComponent.context.surface
// dragging an InlineNode
if(isolatedNodeComponent._isInlineNodeComponent) {
let inlineNode = isolatedNodeComponent.props.node
dragState.inline = true
dragState.selectionDrag = true
dragState.sourceSelection = {
type: 'property',
path: inlineNode.start.path,
startOffset: inlineNode.start.offset,
endOffset: inlineNode.end.offset,
containerId: surface.getContainerId(),
surfaceId: surface.id
}
return
}
// dragging an IsolatedNode
// console.log('DragManager: started dragging a node or from external')
dragState.selectionDrag = false
dragState.nodeDrag = true
dragState.sourceSelection = {
type: 'node',
nodeId: isolatedNodeComponent.props.node.id,
containerId: surface.getContainerId(),
surfaceId: surface.id
}
// We store the scrollPanes in dragState so the Dropzones component
// can use it to compute dropzones per scrollpane for each contained
// surface
dragState.scrollPanes = this._getSurfacesGroupedByScrollPane()
// console.log('... emitting dragstart for Dropzones')
this.emit('drag:started', dragState)
function _stop() {
// console.log('.... NOPE')
event.preventDefault()
event.stopPropagation()
}
}
_getSurfacesGroupedByScrollPane() {
// We need to determine all ContainerEditors and their scrollPanes; those have the drop
// zones attached
let surfaces = this.context.surfaceManager.getSurfaces()
let scrollPanes = {}
surfaces.forEach((surface) => {
// Skip for everything but container editors
if (!surface.isContainerEditor()) return
let scrollPane = surface.context.scrollPane
let scrollPaneName = scrollPane.getName()
let surfaceName = surface.getName()
if (!scrollPanes[scrollPaneName]) {
let surfaces = {}
surfaces[surfaceName] = surface
scrollPanes[scrollPaneName] = { scrollPane, surfaces }
} else {
scrollPanes[scrollPaneName].surfaces[surfaceName] = surface
}
})
return scrollPanes
}
_getSelection() {
return this.context.editorSession.getSelection()
}
_getComponents(targetEl) {
let res = []
let curr = targetEl
while (curr) {
let comp = Component.getComponentForDOMElement(curr)
if (comp) {
res.unshift(comp)
if(comp._isSurface) {
return res
}
}
curr = curr.parentNode
}
return null
}
_getIsolatedNodeOrContainerChild(targetEl) {
let parent, current
current = targetEl
parent = current.parentNode
while(parent) {
if (parent._comp && parent._comp._isContainerEditor) {
return current._comp
} else if (current._comp && current._comp._isIsolatedNode) {
return current._comp
}
current = parent
parent = current.parentNode
}
}
/*
Following best practice from Mozilla for URI extraction
See: https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Recommended_Drag_Types#link
*/
_extractUris(dataTransfer) {
let uris = []
let rawUriList = dataTransfer.getData('text/uri-list')
if (rawUriList) {
uris = rawUriList.split('\n').filter(function(item) {
return !item.startsWith('#')
})
}
return uris
}
/*
Extracts information from e.dataTransfer (files, uris, text, html)
*/
_getData(e) {
let dataTransfer = e.dataTransfer
if (dataTransfer) {
return {
files: Array.prototype.slice.call(dataTransfer.files),
uris: this._extractUris(dataTransfer),
text: dataTransfer.getData('text/plain'),
html: dataTransfer.getData('text/html')
}
}
}
}
/*
Moves a selected node to a new location.
Used as a drop handler for internal drags with NodeSelections.
*/
class MoveBlockNode extends DragAndDropHandler {
match(dragState) {
let {insertPos} = dragState.dropParams
// - sourceSeletion must be a NodeSelection
return (!dragState.external && dragState.nodeDrag &&
dragState.dropType === 'place' && insertPos >= 0
)
}
drop(tx, dragState) {
// - remember current selection (node that is dragged)
// - delete current selection (removes node from original position)
// - determine node selection based on given insertPos
// - paste node at new insert position
let { insertPos } = dragState.dropParams
tx.setSelection(dragState.sourceSelection)
let copy = tx.copySelection()
// just clear, but don't merge or don't insert a new node
tx.deleteSelection({ clear: true })
let containerId = dragState.targetSurface.getContainerId()
let surfaceId = dragState.targetSurface.getName()
let container = tx.get(containerId)
let targetNodeId = container.getNodeIdAt(insertPos)
let insertMode = 'before'
if (!targetNodeId) {
targetNodeId = container.getNodeIdAt(insertPos-1)
insertMode = 'after'
}
tx.setSelection({
type: 'node',
nodeId: targetNodeId,
mode: insertMode,
containerId: containerId,
surfaceId: surfaceId
})
tx.paste(copy)
}
}
class MoveInline extends DragAndDropHandler {
match(dragState) {
return !dragState.external && dragState.inline
}
drop(tx, dragState) {
let event = dragState.event
let sourceSel = dragState.sourceSelection
let wrange = getDOMRangeFromEvent(event)
if (!wrange) return
let comp = Component.unwrap(event.target)
if (!comp) return
let domSelection = comp.context.domSelection
if (!domSelection) return
let range = domSelection.mapDOMRange(wrange)
if (!range) return
let targetSel = tx.getDocument()._createSelectionFromRange(range)
// TODO: iterate custom move-inline handlers
tx.selection = sourceSel
let snippet = tx.copySelection()
tx.deleteSelection()
tx.selection = operationHelpers.transformSelection(targetSel, tx)
tx.paste(snippet)
}
}
class InsertNodes extends DragAndDropHandler {
constructor(assetHandlers, context) {
super()
this.assetHandlers = assetHandlers
this.context = context
}
match(dragState) {
return dragState.dropType === 'place' && dragState.external
}
drop(tx, dragState) {
let { insertPos } = dragState.dropParams
let files = dragState.data.files
let uris = dragState.data.uris
let containerId = dragState.targetSurface.getContainerId()
let surfaceId = dragState.targetSurface.id
let container = tx.get(containerId)
let targetNode = container.getNodeIdAt(insertPos)
let insertMode = 'before'
if (!targetNode) {
targetNode = container.getNodeIdAt(insertPos-1)
insertMode = 'after'
}
tx.setSelection({
type: 'node',
nodeId: targetNode,
mode: insertMode,
containerId: containerId,
surfaceId: surfaceId
})
if (files.length > 0) {
files.forEach((file) => {
this._callHandlers(tx, {
file: file,
type: 'file'
})
})
} else if (uris.length > 0) {
uris.forEach((uri) => {
this._callHandlers(tx, {
uri: uri,
type: 'uri'
})
})
} else {
console.info('TODO: implement html/text drop here')
}
}
_callHandlers(tx, params) {
let i, handler;
for (i = 0; i < this.assetHandlers.length; i++) {
handler = this.assetHandlers[i]
let match = handler.match(params, this.context)
if (match) {
handler.drop(tx, params, this.context)
break
}
}
}
}
/*
Built-in handler that calls a custom handler, specified
on the component (e.g. see ImageComponent).
*/
class CustomHandler extends DragAndDropHandler {
match(dragState) {
return dragState.dropType === 'custom'
}
drop(tx, dragState) {
// Delegate handling to component which set up the custom dropzone
dragState.component.handleDrop(tx, dragState)
}
}
export default DragManager