sl-vue-tree
Version:
draggable tree vue component
770 lines (621 loc) • 21.8 kB
JavaScript
export default {
name: 'sl-vue-tree',
props: {
value: {
type: Array,
default: () => []
},
edgeSize: {
type: Number,
default: 3
},
showBranches: {
type: Boolean,
default: false
},
level: {
type: Number,
default: 0
},
parentInd: {
type: Number
},
allowMultiselect: {
type: Boolean,
default: true
},
allowToggleBranch: {
type: Boolean,
default: true
},
multiselectKey: {
type: [String, Array],
default: function () {
return ['ctrlKey', 'metaKey']
},
validator: function (value) {
let allowedKeys = ['ctrlKey', 'metaKey', 'altKey'];
let multiselectKeys = Array.isArray(value) ? value : [value];
multiselectKeys = multiselectKeys.filter(keyName => allowedKeys.indexOf(keyName ) !== -1);
return !!multiselectKeys.length;
}
},
scrollAreaHeight: {
type: Number,
default: 70
},
maxScrollSpeed: {
type: Number,
default: 20
}
},
data() {
return {
rootCursorPosition: null,
scrollIntervalId: 0,
scrollSpeed: 0,
lastSelectedNode: null,
mouseIsDown: false,
isDragging: false,
lastMousePos: {x: 0, y: 0},
preventDrag: false,
currentValue: this.value
};
},
mounted() {
if (this.isRoot) {
document.addEventListener('mouseup', this.onDocumentMouseupHandler);
}
},
beforeDestroy() {
document.removeEventListener('mouseup', this.onDocumentMouseupHandler);
},
watch: {
value: function (newValue) {
this.currentValue = newValue;
}
},
computed: {
cursorPosition() {
if (this.isRoot) return this.rootCursorPosition;
return this.getParent().cursorPosition;
},
depth() {
return this.gaps.length
},
nodes() {
if (this.isRoot) {
const nodeModels = this.copy(this.currentValue);
return this.getNodes(nodeModels);
}
return this.getParent().nodes[this.parentInd].children;
},
/**
* gaps is using for nodes indentation
* @returns {number[]}
*/
gaps() {
const gaps = [];
let i = this.level - 1;
if (!this.showBranches) i++;
while (i-- > 0) gaps.push(i);
return gaps;
},
isRoot() {
return !this.level
},
selectionSize() {
return this.getSelected().length;
},
dragSize() {
return this.getDraggable().length;
}
},
methods: {
setCursorPosition(pos) {
if (this.isRoot) {
this.rootCursorPosition = pos;
return;
}
this.getParent().setCursorPosition(pos);
},
getNodes(nodeModels, parentPath = [], isVisible = true) {
return nodeModels.map((nodeModel, ind) => {
const nodePath = parentPath.concat(ind);
return this.getNode(nodePath, nodeModel, nodeModels, isVisible);
})
},
getNode(
path,
nodeModel = null,
siblings = null,
isVisible = null
) {
const ind = path.slice(-1)[0];
// calculate nodeModel, siblings, isVisible fields if it is not passed as arguments
siblings = siblings || this.getNodeSiblings(this.currentValue, path);
nodeModel = nodeModel || (siblings && siblings[ind]) || null;
if (isVisible == null) {
isVisible = this.isVisible(path);
}
if (!nodeModel) return null;
const isExpanded = nodeModel.isExpanded == void 0 ? true : !!nodeModel.isExpanded;
const isDraggable = nodeModel.isDraggable == void 0 ? true : !!nodeModel.isDraggable;
const isSelectable = nodeModel.isSelectable == void 0 ? true : !!nodeModel.isSelectable;
const node = {
// define the all ISlTreeNodeModel props
title: nodeModel.title,
isLeaf: !!nodeModel.isLeaf,
children: nodeModel.children ? this.getNodes(nodeModel.children, path, isExpanded) : [],
isSelected: !!nodeModel.isSelected,
isExpanded,
isVisible,
isDraggable,
isSelectable,
data: nodeModel.data !== void 0 ? nodeModel.data : {},
// define the all ISlTreeNode computed props
path: path,
pathStr: JSON.stringify(path),
level: path.length,
ind,
isFirstChild: ind == 0,
isLastChild: ind === siblings.length - 1
};
return node;
},
isVisible(path) {
if (path.length < 2) return true;
let nodeModels = this.currentValue;
for (let i = 0; i < path.length - 1; i++) {
let ind = path[i];
let nodeModel = nodeModels[ind];
let isExpanded = nodeModel.isExpanded == void 0 ? true : !!nodeModel.isExpanded;
if (!isExpanded) return false;
nodeModels = nodeModel.children;
}
return true;
},
emitInput(newValue) {
this.currentValue = newValue;
this.getRoot().$emit('input', newValue);
},
emitSelect(selectedNodes, event) {
this.getRoot().$emit('select', selectedNodes, event);
},
emitBeforeDrop(draggingNodes, position, cancel) {
this.getRoot().$emit('beforedrop', draggingNodes, position, cancel);
},
emitDrop(draggingNodes, position, event) {
this.getRoot().$emit('drop', draggingNodes, position, event);
},
emitToggle(toggledNode, event) {
this.getRoot().$emit('toggle', toggledNode, event);
},
emitNodeClick(node, event) {
this.getRoot().$emit('nodeclick', node, event);
},
emitNodeDblclick(node, event) {
this.getRoot().$emit('nodedblclick', node, event);
},
emitNodeContextmenu(node, event) {
this.getRoot().$emit('nodecontextmenu', node, event);
},
onExternalDragoverHandler(node, event) {
event.preventDefault();
const root = this.getRoot();
const cursorPosition = root.getCursorPositionFromCoords(event.clientX, event.clientY);
root.setCursorPosition(cursorPosition);
root.$emit('externaldragover', cursorPosition, event);
},
onExternalDropHandler(node, event) {
const root = this.getRoot();
const cursorPosition = root.getCursorPositionFromCoords(event.clientX, event.clientY);
root.$emit('externaldrop', cursorPosition, event);
this.setCursorPosition(null);
},
select(path, addToSelection = false, event = null) {
const multiselectKeys = Array.isArray(this.multiselectKey) ?
this.multiselectKey:
[this.multiselectKey];
const multiselectKeyIsPressed = event && !!multiselectKeys.find(key => event[key]);
addToSelection = (multiselectKeyIsPressed || addToSelection) && this.allowMultiselect ;
const selectedNode = this.getNode(path);
if (!selectedNode) return null;
const newNodes = this.copy(this.currentValue);
const shiftSelectionMode = this.allowMultiselect && event && event.shiftKey && this.lastSelectedNode;
const selectedNodes = [];
let shiftSelectionStarted = false;
this.traverse((node, nodeModel) => {
if (shiftSelectionMode) {
if (node.pathStr === selectedNode.pathStr || node.pathStr === this.lastSelectedNode.pathStr) {
nodeModel.isSelected = node.isSelectable;
shiftSelectionStarted = !shiftSelectionStarted;
}
if (shiftSelectionStarted) nodeModel.isSelected = node.isSelectable;
} else if (node.pathStr === selectedNode.pathStr) {
nodeModel.isSelected = node.isSelectable;
} else if (!addToSelection) {
if (nodeModel.isSelected) nodeModel.isSelected = false;
}
if (nodeModel.isSelected) selectedNodes.push(node);
}, newNodes);
this.lastSelectedNode = selectedNode;
this.emitInput(newNodes);
this.emitSelect(selectedNodes, event);
return selectedNode;
},
onMousemoveHandler(event) {
if (!this.isRoot) {
this.getRoot().onMousemoveHandler(event);
return;
}
if (this.preventDrag) return;
const initialDraggingState = this.isDragging;
const isDragging =
this.isDragging || (
this.mouseIsDown &&
(this.lastMousePos.x !== event.clientX || this.lastMousePos.y !== event.clientY)
);
const isDragStarted = initialDraggingState === false && isDragging === true;
this.lastMousePos = {
x: event.clientX,
y: event.clientY
};
if (!isDragging) return;
const $root = this.getRoot().$el;
const rootRect = $root.getBoundingClientRect();
const $dragInfo = this.$refs.dragInfo;
const dragInfoTop = (event.clientY - rootRect.top + $root.scrollTop - ($dragInfo.style.marginBottom | 0) );
const dragInfoLeft = (event.clientX - rootRect.left);
$dragInfo.style.top = dragInfoTop + 'px';
$dragInfo.style.left = dragInfoLeft + 'px';
const cursorPosition = this.getCursorPositionFromCoords(event.clientX, event.clientY);
const destNode = cursorPosition.node;
const placement = cursorPosition.placement;
if (isDragStarted && !destNode.isSelected) {
this.select(destNode.path, false, event);
}
const draggableNodes = this.getDraggable();
if (!draggableNodes.length) {
this.preventDrag = true;
return;
}
this.isDragging = isDragging;
this.setCursorPosition({ node: destNode, placement });
const scrollBottomLine = rootRect.bottom - this.scrollAreaHeight;
const scrollDownSpeed = (event.clientY - scrollBottomLine) / (rootRect.bottom - scrollBottomLine);
const scrollTopLine = rootRect.top + this.scrollAreaHeight;
const scrollTopSpeed = (scrollTopLine - event.clientY) / (scrollTopLine - rootRect.top);
if (scrollDownSpeed > 0) {
this.startScroll(scrollDownSpeed);
} else if (scrollTopSpeed > 0) {
this.startScroll(-scrollTopSpeed)
} else {
this.stopScroll();
}
},
getCursorPositionFromCoords(x, y) {
const $target = document.elementFromPoint(x, y);
const $nodeItem = $target.getAttribute('path') ? $target : this.getClosetElementWithPath($target);
let destNode;
let placement;
if ($nodeItem) {
if (!$nodeItem) return;
destNode = this.getNode(JSON.parse($nodeItem.getAttribute('path')));
const nodeHeight = $nodeItem.offsetHeight;
const edgeSize = this.edgeSize;
const offsetY = y - $nodeItem.getBoundingClientRect().top;
if (destNode.isLeaf) {
placement = offsetY >= nodeHeight / 2 ? 'after' : 'before';
} else {
if (offsetY <= edgeSize) {
placement = 'before';
} else if (offsetY >= nodeHeight - edgeSize) {
placement = 'after';
} else {
placement = 'inside';
}
}
} else {
const $root = this.getRoot().$el;
const rootRect = $root.getBoundingClientRect();
if (y > rootRect.top + (rootRect.height / 2)) {
placement = 'after';
destNode = this.getLastNode();
} else {
placement = 'before';
destNode = this.getFirstNode();
}
}
return { node: destNode, placement };
},
getClosetElementWithPath($el) {
if (!$el) return null;
if ($el.getAttribute('path')) return $el;
return this.getClosetElementWithPath($el.parentElement);
},
onMouseleaveHandler(event) {
if (!this.isRoot || !this.isDragging) return;
const $root = this.getRoot().$el;
const rootRect = $root.getBoundingClientRect();
if (event.clientY >= rootRect.bottom) {
this.setCursorPosition({ node: this.nodes.slice(-1)[0], placement: 'after' });
} else if (event.clientY < rootRect.top) {
this.setCursorPosition({ node: this.getFirstNode(), placement: 'before'});
}
},
getNodeEl(path) {
this.getRoot().$el.querySelector(`[path="${JSON.stringify(path)}"]`);
},
getLastNode() {
let lastNode = null;
this.traverse((node) => {
lastNode = node;
});
return lastNode;
},
getFirstNode() {
return this.getNode([0]);
},
getNextNode(path, filter = null) {
let resultNode = null;
this.traverse((node) => {
if (this.comparePaths(node.path, path) < 1) return;
if (!filter || filter(node)) {
resultNode = node;
return false; // stop traverse
}
});
return resultNode;
},
getPrevNode(path, filter) {
let prevNodes = [];
this.traverse((node) => {
if (this.comparePaths(node.path, path) >= 0) {
return false;
}
prevNodes.push(node);
});
let i = prevNodes.length;
while (i--) {
const node = prevNodes[i];
if (!filter || filter(node)) return node;
}
return null;
},
/**
* returns 1 if path1 > path2
* returns -1 if path1 < path2
* returns 0 if path1 == path2
*
* examples
*
* [1, 2, 3] < [1, 2, 4]
* [1, 1, 3] < [1, 2, 3]
* [1, 2, 3] > [1, 2, 0]
* [1, 2, 3] > [1, 1, 3]
* [1, 2] < [1, 2, 0]
*
*/
comparePaths(path1, path2) {
for (let i = 0; i < path1.length; i++) {
if (path2[i] == void 0) return 1;
if (path1[i] > path2[i]) return 1;
if (path1[i] < path2[i]) return -1;
}
return path2[path1.length] == void 0 ? 0 : -1;
},
onNodeMousedownHandler(event, node) {
// handle only left mouse button
if (event.button !== 0) return;
if (!this.isRoot) {
this.getRoot().onNodeMousedownHandler(event, node);
return;
}
this.mouseIsDown = true;
},
startScroll(speed) {
const $root = this.getRoot().$el;
if (this.scrollSpeed === speed) {
return;
} else if (this.scrollIntervalId) {
this.stopScroll();
}
this.scrollSpeed = speed;
this.scrollIntervalId = setInterval(() => {
$root.scrollTop += this.maxScrollSpeed * speed;
}, 20);
},
stopScroll() {
clearInterval(this.scrollIntervalId);
this.scrollIntervalId = 0;
this.scrollSpeed = 0;
},
onDocumentMouseupHandler(event) {
if (this.isDragging) this.onNodeMouseupHandler(event);
},
onNodeMouseupHandler(event, targetNode = null) {
// handle only left mouse button
if (event.button !== 0) return;
if (!this.isRoot) {
this.getRoot().onNodeMouseupHandler(event, targetNode);
return;
}
this.mouseIsDown = false;
if (!this.isDragging && targetNode && !this.preventDrag) {
this.select(targetNode.path, false, event);
}
this.preventDrag = false;
if (!this.cursorPosition) {
this.stopDrag();
return;
};
const draggingNodes = this.getDraggable();
// check that nodes is possible to insert
for (let draggingNode of draggingNodes) {
if (draggingNode.pathStr == this.cursorPosition.node.pathStr) {
this.stopDrag();
return;
}
if (this.checkNodeIsParent(draggingNode, this.cursorPosition.node)) {
this.stopDrag();
return;
}
}
const newNodes = this.copy(this.currentValue);
const nodeModelsSubjectToInsert = [];
// find dragging model to delete
for (let draggingNode of draggingNodes) {
const sourceSiblings = this.getNodeSiblings(newNodes, draggingNode.path);
const draggingNodeModel = sourceSiblings[draggingNode.ind];
nodeModelsSubjectToInsert.push(draggingNodeModel);
}
// allow the drop to be cancelled
let cancelled = false;
this.emitBeforeDrop(draggingNodes, this.cursorPosition, () => cancelled = true);
if (cancelled) {
this.stopDrag();
return;
}
const nodeModelsToInsert = [];
// mark dragging model to delete
for (let draggingNodeModel of nodeModelsSubjectToInsert) {
nodeModelsToInsert.push(this.copy(draggingNodeModel));
draggingNodeModel['_markToDelete'] = true;
}
// insert dragging nodes to the new place
this.insertModels(this.cursorPosition, nodeModelsToInsert, newNodes);
// delete dragging node from the old place
this.traverseModels((nodeModel, siblings, ind) => {
if (!nodeModel._markToDelete) return;
siblings.splice(ind, 1);
}, newNodes);
this.lastSelectedNode = null;
this.emitInput(newNodes);
this.emitDrop(draggingNodes, this.cursorPosition, event);
this.stopDrag();
},
onToggleHandler(event, node) {
if (!this.allowToggleBranch) return;
this.updateNode(node.path, { isExpanded: !node.isExpanded });
this.emitToggle(node, event);
event.stopPropagation();
},
stopDrag() {
this.isDragging = false;
this.mouseIsDown = false;
this.setCursorPosition(null);
this.stopScroll();
},
getParent() {
return this.$parent;
},
getRoot() {
if (this.isRoot) return this;
return this.getParent().getRoot();
},
getNodeSiblings(nodes, path) {
if (path.length === 1) return nodes;
return this.getNodeSiblings(nodes[path[0]].children, path.slice(1));
},
updateNode(path, patch) {
if (!this.isRoot) {
this.getParent().updateNode(path, patch);
return;
}
const pathStr = JSON.stringify(path);
const newNodes = this.copy(this.currentValue);
this.traverse((node, nodeModel) => {
if (node.pathStr !== pathStr) return;
Object.assign(nodeModel, patch);
}, newNodes);
this.emitInput(newNodes);
},
getSelected() {
const selectedNodes = [];
this.traverse((node) => {
if (node.isSelected) selectedNodes.push(node);
});
return selectedNodes;
},
getDraggable() {
const selectedNodes = [];
this.traverse((node) => {
if (node.isSelected && node.isDraggable) selectedNodes.push(node);
});
return selectedNodes;
},
traverse(
cb,
nodeModels = null,
parentPath = []
) {
if (!nodeModels) nodeModels = this.currentValue;
let shouldStop = false;
const nodes = [];
for (let nodeInd = 0; nodeInd < nodeModels.length; nodeInd++) {
const nodeModel = nodeModels[nodeInd];
const itemPath = parentPath.concat(nodeInd);
const node = this.getNode(itemPath, nodeModel, nodeModels);
shouldStop = cb(node, nodeModel, nodeModels) === false;
nodes.push(node);
if (shouldStop) break;
if (nodeModel.children) {
shouldStop = this.traverse(cb, nodeModel.children, itemPath) === false;
if (shouldStop) break;
}
}
return !shouldStop ? nodes : false;
},
traverseModels(cb, nodeModels) {
let i = nodeModels.length;
while (i--) {
const nodeModel = nodeModels[i];
if (nodeModel.children) this.traverseModels(cb, nodeModel.children);
cb(nodeModel, nodeModels, i);
}
return nodeModels;
},
remove(paths) {
const pathsStr = paths.map(path => JSON.stringify(path));
const newNodes = this.copy(this.currentValue);
this.traverse( (node, nodeModel, siblings) => {
for (const pathStr of pathsStr) {
if (node.pathStr === pathStr) nodeModel._markToDelete = true;
}
}, newNodes);
this.traverseModels((nodeModel, siblings, ind) => {
if (!nodeModel._markToDelete) return;
siblings.splice(ind, 1);
}, newNodes);
this.emitInput(newNodes);
},
insertModels(cursorPosition, nodeModels, newNodes) {
const destNode = cursorPosition.node;
const destSiblings = this.getNodeSiblings(newNodes, destNode.path);
const destNodeModel = destSiblings[destNode.ind];
if (cursorPosition.placement === 'inside') {
destNodeModel.children = destNodeModel.children || [];
destNodeModel.children.unshift(...nodeModels);
} else {
const insertInd = cursorPosition.placement === 'before' ?
destNode.ind :
destNode.ind + 1;
destSiblings.splice(insertInd, 0, ...nodeModels);
}
},
insert(cursorPosition, nodeModel) {
const nodeModels = Array.isArray(nodeModel) ? nodeModel : [nodeModel];
const newNodes = this.copy(this.currentValue);
this.insertModels(cursorPosition, nodeModels, newNodes);
this.emitInput(newNodes);
},
checkNodeIsParent(sourceNode, destNode) {
const destPath = destNode.path;
return JSON.stringify(destPath.slice(0, sourceNode.path.length)) == sourceNode.pathStr;
},
copy(entity) {
return JSON.parse(JSON.stringify(entity));
}
}
};