import { injectable, inject, optional } from 'inversify'; import { Disposable, PositionSchema } from '@gedit/utils'; import { Rectangle } from '@gedit/math'; import { domUtils } from '@gedit/utils/lib/browser'; import { Tree, TreeNode } from './tree'; import { ExpandableTreeNode } from './tree-expansion'; import { Drag, IDragEvent } from '@phosphor/dragdrop'; import { MimeData } from '@phosphor/coreutils'; import { LabelProvider } from '@gedit/theme'; import { TopDownTreeIterator } from './tree-iterator'; import { SelectableTreeNode } from './tree-selection'; export const TREE_DROP_KEY = 'data-tree-move-drop'; const DRAGGING_DELTA = 20; // 拖动距离 export enum TreeDropType { SIBLING, CHILD, NONE } function toArray(items: NodeList): T[] { const nodes: T[] = []; items.forEach(item => nodes.push(item as T)); return nodes; }; function getSelectedNodesWithoutRoot(tree: Tree): TreeNode[] { if (!tree.root) return []; const iter = new TopDownTreeIterator(tree.root); const nodes: TreeNode[] = []; for (const item of iter) { if (item !== tree.root && SelectableTreeNode.isSelected(item)) { nodes.push(item); } } return nodes; } function draggingSuccess(start: PositionSchema & { time: number }, moving: PositionSchema & { time: number} ): boolean { // 必须超过100毫秒 if (moving.time - start.time <= 100) return false; return Math.abs(start.x - moving.x) >= DRAGGING_DELTA || Math.abs(start.y - moving.y) >= DRAGGING_DELTA; } export const TreeDragdropOpts = Symbol('TreeDragdropOpts'); export interface TreeDragdropOpts { /** * 拖拽结束是否要触发点击, 默认true */ dragEndWithSelect?: boolean canbeDrag?(clickNode: T, tree: TREE): boolean canbeDrop?(dropNode: T, source: T[], tree: TREE, asChild: boolean): boolean /** * 默认拖拽选中的节点, 不包含根节点 * @param currentNode * @param tree */ getDraggingNodes?(currentNode: T, tree: TREE): T[] dropSuccess(source: T[], dropNode: T, tree: TREE, asChild: boolean): void } export const TreeDragdropDefault: TreeDragdropOpts = { dragEndWithSelect: true, canbeDrag(node: TreeNode, tree: Tree): boolean { return true; }, getDraggingNodes(node: TreeNode, tree: Tree): TreeNode[] { // Ignore root node; const nodes = getSelectedNodesWithoutRoot(tree); if (nodes.find(n => n.id === node.id)) return nodes; return [node]; }, canbeDrop(dropNode: TreeNode, source: TreeNode[], tree: Tree, asChild: boolean): boolean { if (asChild && !ExpandableTreeNode.is(dropNode)) { return false; } return true; }, dropSuccess(source: TreeNode[], dropNode: TreeNode, tree: Tree, asChild: boolean): void { console.log('Drop sucess, shoud be override: ', source, dropNode); } }; let dragId = 0; const EXPAND_WAIT_TIME = 1000; // 停一秒 @injectable() export class TreeDragdrop implements Disposable { private droppingLine: HTMLElement; private treeElement: HTMLElement; private currentDropId: string | undefined; private handleContextmenu: boolean = false; private dropType: TreeDropType = TreeDropType.NONE; protected treeNodeElements: HTMLElement[] = []; protected dragId = dragId++; protected opts: Required>; constructor( @inject(Tree) readonly tree: TREE, @inject(TreeDragdropOpts) @optional() opts: TreeDragdropOpts, @inject(LabelProvider) protected readonly labelProvider: LabelProvider ) { this.opts = { ...TreeDragdropDefault, ...opts } as Required>; } private _tryToExpandedNode: T | undefined; private _expandedTimeout: number | undefined; get isDropping(): boolean { return !!this.currentDropId; } private clearExpandedNode(dropNode?: T): void { if (this._expandedTimeout) { if (!dropNode || this._tryToExpandedNode !== dropNode) { window.clearTimeout(this._expandedTimeout); this._tryToExpandedNode = undefined; this._expandedTimeout = undefined; } } } tryToExpanded(dropNode: T, cb: () => void): boolean { if (ExpandableTreeNode.is(dropNode)) { if (!dropNode.expanded) { if (!this._expandedTimeout) { this._expandedTimeout = window.setTimeout(() => { if (this.isDropping && this._tryToExpandedNode && this._tryToExpandedNode === dropNode) { cb(); this._expandedTimeout = undefined; if (this.dropType === TreeDropType.CHILD) { dropNode.expanded = true; this.tree.refresh(dropNode); } } }, EXPAND_WAIT_TIME); } } this._tryToExpandedNode = dropNode; return dropNode.expanded; } this._tryToExpandedNode = dropNode; return true; } dispose(): void { if (this.droppingLine) document.body.removeChild(this.droppingLine); if (this.treeElement) { this.treeElement.removeEventListener('p-dragenter', this); this.treeElement.removeEventListener('p-dragover', this); this.treeElement.removeEventListener('p-dragleave', this); this.treeElement.removeEventListener('p-drop', this); } } init(element: HTMLElement): void { this.droppingLine = domUtils.createDivWithClass('theia-tree-dropping-line'); this.treeElement = element; document.body.appendChild(this.droppingLine); element.addEventListener('p-dragenter', this); element.addEventListener('p-dragover', this); element.addEventListener('p-dragleave', this); element.addEventListener('p-drop', this); } getDropProps(dropTarget: HTMLElement, source: T[], event: IDragEvent): { node?: T, rect?: Rectangle, type: TreeDropType } { const clientX = event.clientX; const dropId = dropTarget.getAttribute(TREE_DROP_KEY) || ''; const dropNode = this.tree.getNode(dropId) as T; if (dropNode && !source.find(n => n.id === dropId || this.isParent(n, dropId))) { const boundingRect = dropTarget.querySelector('.theia-TreeNodeContent')!.getBoundingClientRect(); // 箭头标记 const expansionRect = dropTarget.querySelector('.theia-ExpansionToggle')?.getBoundingClientRect(); const rect = new Rectangle(boundingRect.x, boundingRect.y, boundingRect.width, boundingRect.height); const rectLeft = expansionRect ? rect.x + expansionRect.width : rect.x; const dropType = clientX < rectLeft ? TreeDropType.SIBLING : TreeDropType.CHILD; // As child or sibling if (this.opts.canbeDrop(dropNode, source, this.tree, dropType === TreeDropType.CHILD)) { return { node: dropNode, rect, type: dropType }; } // As sibling if (dropType === TreeDropType.CHILD && this.opts.canbeDrop(dropNode, source, this.tree, false)) { return { node: dropNode, rect, type: TreeDropType.SIBLING }; } return { type: TreeDropType.NONE }; } else { return { type: TreeDropType.NONE }; } } handleEvent(event: IDragEvent): void { event.preventDefault(); event.stopPropagation(); event.dropAction = 'move'; const line = this.droppingLine; if (event.type === 'p-dragleave') { this.currentDropId = undefined; line.style.display = 'none'; return; } else if (event.type === 'p-drop') { line.style.display = 'none'; return; } const dropTarget = this.findDropTarget(event.target as HTMLElement, event); const source = event.source as T[]; let dropId: string = ''; if (dropTarget) { let dropProps: { node?: T, rect?: Rectangle, type: TreeDropType } | undefined; // 拖到根节点 if (dropTarget === 'root') { const root = this.tree.root! as T; const tree = this.treeElement.getBoundingClientRect(); if (this.opts.canbeDrop(root, source, this.tree, true)) { dropProps = { node: root as T, rect: new Rectangle(tree.x - 25, tree.y, tree.width, 0), type: TreeDropType.CHILD, }; dropId = root.id; } } else { dropId = dropTarget.getAttribute(TREE_DROP_KEY) || ''; dropProps = this.getDropProps(dropTarget, source, event); } if (!dropProps || !dropProps.node || !dropProps.rect || dropProps.type === TreeDropType.NONE) { this.currentDropId = undefined; line.style.display = 'none'; return; } const rect = dropProps.rect; const dropNode = dropProps.node; domUtils.setStyle(line, { display: 'block', width: rect.width, left: rect.x, top: rect.y + rect.height }); switch (dropProps.type) { case TreeDropType.CHILD: this.clearExpandedNode(dropNode); const updateLineStyle = () => { line.style.left = (rect.x + 25) + 'px'; line.style.width = (rect.width - 25) + 'px'; }; const expanded = this.tryToExpanded(dropNode, () => { this.dropType = TreeDropType.CHILD; updateLineStyle(); }); this.dropType = expanded ? TreeDropType.CHILD : TreeDropType.SIBLING; this.currentDropId = dropId || undefined; if (expanded) { updateLineStyle(); } break; case TreeDropType.SIBLING: this.clearExpandedNode(); this.dropType = TreeDropType.SIBLING; this.currentDropId = dropId || undefined; if (ExpandableTreeNode.is(dropNode)) { if (dropNode.expanded) { dropNode.expanded = false; this.tree.refresh(dropNode); } } break; } return; } this.currentDropId = undefined; line.style.display = 'none'; } protected isParent(parent: T, id: string): boolean { let target: T | undefined = this.tree.getNode(id) as T; while (target) { if (target.id === parent.id) return true; target = target.parent as T | undefined; } return false; } async startDrag(node: T, e: React.MouseEvent): Promise<{ dropId?: string, shouldTriggerClick: boolean }> { if (!this.opts.canbeDrag(node, this.tree)) return { shouldTriggerClick: true }; const dragImage = document.createElement('div'); const rect = this.treeElement.getBoundingClientRect(); dragImage.style.width = rect.width + 'px'; dragImage.style.display = 'none'; dragImage.style.marginLeft = `${rect.x - e.clientX}px`; dragImage.className = 'theia-tree-dragging-nodes'; const mimeData = new MimeData(); const draggingElements: HTMLElement[] = []; const start = { x: e.clientX, y: e.clientY, time: Date.now() }; let started = false; const dragStart = () => { dragImage.style.display = 'block'; dragImage.innerHTML = draggingSource.map((n: T) => { const icon = this.labelProvider.getIcon(n); const name = this.labelProvider.getName(n); return `
${name}
`; }).join(''); const bound = dragImage.getBoundingClientRect(); dragImage.style.marginTop = - (bound.height / 2) + 'px'; this.treeNodeElements = toArray(this.treeElement.querySelectorAll(`div[${TREE_DROP_KEY}]`)); this.treeNodeElements .forEach(el => { const id = el.getAttribute(TREE_DROP_KEY); if (draggingSource.find(n => n.id === id || this.isParent(n as T, id || ''))) { draggingElements.push(el as HTMLElement); el.classList.add('theia-tree-dragging-node'); } }); // 加入这个拖拽的时候才能自动滚动 this.treeElement.setAttribute('data-p-dragscroll', ''); }; const draggingSource = this.opts.getDraggingNodes ? this.opts.getDraggingNodes(node, this.tree) : TreeDragdropDefault.getDraggingNodes!(node, this.tree); const drag = new Drag({ source: draggingSource, mimeData, dragImage, supportedActions: 'move', proposedAction: 'move' }); this.currentDropId = undefined; const dragEnd = () => { this.treeNodeElements = []; this.treeElement.removeAttribute('data-p-dragscroll'); this.droppingLine.style.display = 'none'; this.handleContextmenu = false; this.dropType = TreeDropType.NONE; this.currentDropId = undefined; draggingElements.forEach(el => { el.classList.remove('theia-tree-dragging-node'); }); drag.dispose(); }; drag.handleEvent = (event: MouseEvent) => { // 默认点击的时候会屏蔽contextmenu,所以这里做了一层hack if (event.type === 'contextmenu') { this.handleContextmenu = true; drag.dispose(); return; } else if (event.type === 'mousemove') { if (!started && draggingSuccess(start, { x: event.clientX, y: event.clientY, time: Date.now() })) { started = true; dragStart(); } } Drag.prototype.handleEvent.call(drag, event); }; return drag.start(e.clientX, e.clientY).then((action: string) => { let shouldTriggerClick = !this.handleContextmenu; const dropId = this.currentDropId; const dropType = this.dropType; dragEnd(); // 拖拽成功不触发选中 if (dropId) { this.opts.dropSuccess(draggingSource as T[], this.tree.getNode(dropId)! as T, this.tree, dropType === TreeDropType.CHILD); if (!this.opts.dragEndWithSelect) { shouldTriggerClick = false; } else { shouldTriggerClick = !SelectableTreeNode.isSelected(node); } } return { dropId, shouldTriggerClick, }; }); } protected findDropTarget(element: HTMLElement, e: MouseEvent): HTMLElement | 'root' | undefined { if (!element || element === this.treeElement || element === document.body) return undefined; const treeBounds = this.treeElement.getBoundingClientRect(); if (e.clientY - treeBounds.y <= 10) return 'root'; // if (element && element.hasAttribute(TREE_DROP_KEY)) { // return element; // } if (element && this.treeNodeElements.includes(element)) { return element; } return this.findDropTarget(element.parentElement as HTMLElement, e); } }