UNPKG

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
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