You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
gristlabs_grist-core/app/client/ui/TreeViewComponent.ts

659 lines
25 KiB

import { TreeItem, TreeModel, TreeNode, walkTree } from "app/client/models/TreeModel";
import { mouseDrag, MouseDragHandler, MouseDragStart } from "app/client/ui/mouseDrag";
import * as css from 'app/client/ui/TreeViewComponentCss';
import { Computed, dom, DomArg, Holder } from "grainjs";
import { Disposable, IDisposable, makeTestId, ObsArray, Observable, observable } from "grainjs";
import debounce = require('lodash/debounce');
import defaults = require("lodash/defaults");
import noop = require('lodash/noop');
// DropZone identifies a location where an item can be inserted
interface DropZone {
zone: 'above'|'below'|'within';
item: ItemModel;
// `locked` allows to lock the dropzone until the cursor leaves the current item. This is useful
// when the dropzone is not computed from the cursor position but rather is set to 'within' an
// item when the auto-expander's timeout expires (defined in `this._updateExpander(...)`). In such
// a case it would be nearly impossible for the user to properly drop the item without the
// lock. Because when she releases the mouse she is very likely to move cursor unintentionally
// which would update the dropZone to either 'above' or 'below' and would insert item in the
// desired position.
locked?: boolean;
}
// The view model for a TreeItem
interface ItemModel {
highlight: Observable<boolean>;
collapsed: Observable<boolean>;
dragged: Observable<boolean>;
// vertical distance in px the user is dragging the item
deltaY: Observable<number>;
// the px distance from the left side of the container to the label
offsetLeft: () => number;
treeItem: TreeItem;
headerElement: HTMLElement;
containerElement: HTMLElement;
handleElement: HTMLElement;
labelElement: HTMLElement;
offsetElement: HTMLElement;
arrowElement: HTMLElement;
}
// Whether item1 and item2 are models of the same item.
function eq(item1: ItemModel|"root", item2: ItemModel|"root") {
if (item1 === "root" && item2 === "root") {
return true;
}
if (item1 === "root" || item2 === "root") {
return false;
}
return item1.treeItem === item2.treeItem;
}
// Set when a drag starts.
interface Drag extends IDisposable {
startY: number;
item: ItemModel;
// a holder used to update the highlight surrounding the target's parent
highlightedBox: Holder<IDisposable>;
autoExpander: Holder<{item: ItemModel} & IDisposable>;
}
// The geometry of the target which is a visual artifact showing where the user can drop an item.
interface Target {
width: number;
top: number;
left: number;
}
export interface TreeViewOptions {
// the number of pixels used for indentation.
offset?: number;
expanderDelay?: number;
// the delay a user has to keep the mouse down on a item before dragging starts
dragStartDelay?: number;
isOpen?: Observable<boolean>;
selected: Observable<TreeItem|null>;
// When true turns readonly mode on, defaults to false.
isReadonly?: Observable<boolean>;
}
const testId = makeTestId('test-treeview-');
/**
* The TreeViewComponent is a component that can show hierarchical data. It supports collapsing
* children and dragging and dropping to move items in the tree.
*
* Hovering an item reveals a handle. User must then grab that handle to drag an item. During drag
* the handle slides vertically to follow cursor's motion. During drag, the component highlights
* visually where the user can drop the item by displaying a target (above or below) the targeted
* item and by highlighting its parent. In order to ensure data consistency, the component prevents
* dropping an item within its own children. If the cursor leaves the component during a drag, all
* such visual artifact (handle, target and target's parent) are hidden, but if the cursor re-enter
* the component without releasing the mouse, they will show again allowing user to resume dragging.
*/
// note to self: in the future the model will be updated by the server, which could cause conflicts
// if the user is dragging at the same time. It could be simpler to freeze the model and to differ
// their resolution until after the drag terminates.
export class TreeViewComponent extends Disposable {
private readonly _options: Required<TreeViewOptions>;
private readonly _containerElement: Element;
private _drag: Holder<Drag> = Holder.create(this);
private _hoveredItem: ItemModel|"root" = "root";
private _dropZone: DropZone|null = null;
private readonly _hideTarget = observable(true);
private readonly _target = observable<Target>({width: 0, top: 0, left: 0});
private readonly _dragging = observable(false);
private readonly _isClosed: Computed<boolean>;
private _treeItemMap: Map<TreeItem, Element> = new Map();
private _childrenDom: Observable<Node>;
constructor(private _model: Observable<TreeModel>, options: TreeViewOptions) {
super();
this._options = defaults(options, {
offset: 10,
expanderDelay: 1000,
dragStartDelay: 1000,
isOpen: Observable.create(this, true),
isReadonly: Observable.create(this, false),
});
// While building dom we add listeners to the children of all tree nodes to watch for changes
// and call this._update. Hence, repeated calls to this._update is likely to add or remove
// listeners to the observable that triggered the update which is not supported by grainjs and
// could fail (possibly infinite loop). Debounce allows for several change to resolve to a
// single update.
this._update = debounce(this._update.bind(this), 0, {leading: false});
// build dom for the tree of children
this._childrenDom = observable(this._buildChildren(this._model.get().children()));
this.autoDispose(this._model.addListener(this._update, this));
this._isClosed = Computed.create(this, (use) => !use(this._options.isOpen));
this._containerElement = css.treeViewContainer(
// hides the drop zone and target when the cursor leaves the component
dom.on('mouseleave', () => {
this._setDropZone(null);
const drag = this._drag.get();
if (drag) {
drag.autoExpander.clear();
drag.item.handleElement.style.display = 'none';
}
}),
dom.on('mouseenter', () => {
const drag = this._drag.get();
if (drag) {
drag.item.handleElement.style.display = '';
}
}),
// it's important to insert drop zone indicator before children, otherwise it could prevent
// some mouse events to hit children's dom
this._buildTarget(),
// insert children
dom.domComputed(this._childrenDom),
css.treeViewContainer.cls('-close', this._isClosed),
css.treeViewContainer.cls('-dragging', this._dragging),
testId('container'),
);
}
public buildDom() { return this._containerElement; }
// Starts a drag.
private _startDrag(ev: MouseEvent) {
if (this._options.isReadonly.get()) { return null; }
if (this._isClosed.get()) { return null; }
this._hoveredItem = this._closestItem(ev.target as HTMLElement|null);
if (this._hoveredItem === "root") {
return null;
}
const drag = {
startY: ev.clientY - this._hoveredItem.headerElement.getBoundingClientRect().top,
item: this._hoveredItem,
highlightedBox: Holder.create(this),
autoExpander: Holder.create<IDisposable & {item: ItemModel }>(this),
dispose: () => {
drag.autoExpander.dispose();
drag.highlightedBox.dispose();
drag.item.dragged.set(false);
drag.item.handleElement.style.display = '';
drag.item.deltaY.set(0);
}
};
this._drag.autoDispose(drag);
this._hoveredItem.dragged.set(true);
this._dragging.set(true);
return {
onMove: (mouseEvent: MouseEvent) => this._onMouseMove(mouseEvent),
onStop: () => this._terminateDragging(),
};
}
// Terminates a drag.
private _terminateDragging() {
const drag = this._drag.get();
// Clearing the `drag` instance before moving the item allow to revert the style of the item
// being dragged before it gets removed from the model.
this._drag.clear();
if (drag && this._dropZone) {
this._moveTreeNode(drag.item, this._dropZone);
}
this._setDropZone(null);
this._hideTarget.set(true);
this._dragging.set(false);
}
// The target is an horizontal bar indicating where user can drop an item. It typically shows
// above or below any particular item to indicate where the dragged item would be inserted.
private _buildTarget() {
return css.target(
testId('target'),
// show only if a drop zone is set
dom.hide(this._hideTarget),
dom.style('width', (use) => use(this._target).width + 'px'),
dom.style('top', (use) => use(this._target).top + 'px'),
dom.style('left', (use) => use(this._target).left + 'px'),
);
}
// Update this._childrenDom with the content of the new tree. Its rebuilds entirely the tree of
// items and reuses dom from the old content for each item that were already part of the old
// tree. Then takes care of disposing dom for those items that were removed from the old tree.
private _update() {
this._childrenDom.set(this._buildChildren(this._model.get().children(), 0));
// Dispose all the items from this._treeItemMap that are not in the new tree. Note an item
// already takes care of removing itself from the this._treeItemMap on dispose (thanks to the
// dom.onDispose(() => this._treeItemMap.delete(treeItem)) in this._getOrCreateItem). First
// create a map with all the items from _treeItemMap (they may or may not be included in the new
// tree), then walk the new tree and remove all of its items from the map. Eventually, what
// remains in the map are the elements that need disposal.
const map = new Map(this._treeItemMap);
walkTree(this._model.get(), (treeItem) => map.delete(treeItem));
map.forEach((elem, key) => dom.domDispose(elem));
}
// Build list of children. For each child reuses item's dom if already exist and update the offset
// and the list of children. Also add a listener that calls this._update to children.
private _buildChildren(children: ObsArray<TreeItem>, level: number = 0) {
return css.itemChildren(
children.get().map(treeItem => {
const elem = this._getOrCreateItem(treeItem);
this._setOffset(elem, level);
const itemHeaderElem = elem.children[0];
const itemChildren = treeItem.children();
const arrowElement = dom.getData(elem, 'item').arrowElement;
if (itemChildren) {
const itemChildrenElem = this._buildChildren(treeItem.children()!, level + 1);
replaceChildren(elem, itemHeaderElem, itemChildrenElem);
dom.styleElem(arrowElement, 'visibility', itemChildren.get().length ? 'visible' : 'hidden');
} else {
replaceChildren(elem, itemHeaderElem);
dom.styleElem(arrowElement, 'visibility', 'hidden');
}
return elem;
}),
dom.autoDispose(children.addListener(this._update, this)),
);
}
// Get or create dom for treeItem.
private _getOrCreateItem(treeItem: TreeItem): Element {
let item = this._treeItemMap.get(treeItem);
if (!item) {
item = this._buildTreeItemDom(treeItem,
dom.onDispose(() => this._treeItemMap.delete(treeItem))
);
this._treeItemMap.set(treeItem, item);
}
return item;
}
private _setOffset(el: Element, level: number) {
const item = dom.getData(el, 'item') as ItemModel;
item.offsetElement.style.width = level * this._options.offset + "px";
}
private _buildTreeItemDom(treeItem: TreeItem, ...args: DomArg[]): Element {
const collapsed = observable(false);
const dragged = observable(false);
// vertical distance in px the user is dragging the item
const deltaY = observable(0);
const children = treeItem.children();
const offsetLeft = () =>
labelElement.getBoundingClientRect().left - this._containerElement.getBoundingClientRect().left;
const highlight = observable(false);
let headerElement: HTMLElement;
let labelElement: HTMLElement;
let handleElement: HTMLElement;
let offsetElement: HTMLElement;
let arrowElement: HTMLElement;
const containerElement = dom('div.itemContainer',
testId('itemContainer'),
dom.cls('collapsed', collapsed),
css.itemHeaderWrapper(
testId('itemHeaderWrapper'),
dom.cls('dragged', dragged),
css.itemHeaderWrapper.cls('-not-dragging', (use) => !use(this._dragging)),
headerElement = css.itemHeader(
testId('itemHeader'),
dom.cls('highlight', highlight),
dom.cls('selected', (use) => use(this._options.selected) === treeItem),
offsetElement = css.offset(testId('offset')),
arrowElement = css.arrow(
css.dropdown('Dropdown'),
testId('itemArrow'),
dom.style('transform', (use) => use(collapsed) ? 'rotate(-90deg)' : ''),
dom.on('click', (ev) => toggle(collapsed)),
// Let's prevent dragging to start when un-intentionally holding the mouse down on an arrow.
dom.on('mousedown', (ev) => ev.stopPropagation()),
),
labelElement = css.itemLabel(
testId('label'),
treeItem.buildDom(),
dom.style('top', (use) => use(deltaY) + 'px')
),
delayedMouseDrag(this._startDrag.bind(this), this._options.dragStartDelay),
),
css.itemLabelRight(
handleElement = css.centeredIcon('DragDrop',
dom.style('top', (use) => use(deltaY) + 'px'),
testId('handle'),
dom.hide(this._options.isReadonly),
),
mouseDrag((startEvent, elem) => this._startDrag(startEvent))),
),
...args
);
// Associates some of this item internals to the dom element. This is what makes possible to
// find which item user is currently pointing at using `const item =
// this._closestItem(ev.target);` where ev is a mouse event.
const itemModel = {
collapsed, dragged, children, treeItem, offsetLeft, highlight, deltaY,
headerElement,
containerElement,
handleElement,
labelElement,
offsetElement,
arrowElement,
} as ItemModel;
dom.dataElem(containerElement, 'item', itemModel);
return containerElement;
}
private _updateHandle(y: number) {
const drag = this._drag.get();
if (drag) {
drag.item.deltaY.set(y - drag.startY - drag.item.headerElement.getBoundingClientRect().top);
}
}
private _onMouseMove(ev: MouseEvent) {
if (!(ev.target instanceof HTMLElement)) {
return null;
}
const item = this._closestItem(ev.target);
if (item === "root") {
return;
}
// updates the expander when cursor is entering a new item while dragging
const drag = this._drag.get();
if (drag && !eq(this._hoveredItem, item)) {
this._updateExpander(drag, item);
}
this._hoveredItem = item;
this._updateHandle(ev.clientY);
// update the target, update the target's parent
const dropZone = this._getDropZone(ev.clientY);
this._setDropZone(dropZone);
}
// Set the drop zone and update the target and target's parent
private _setDropZone(dropZone: DropZone|null) {
// if there is a locked dropzone on the hovered item already set, do nothing (see
// `DropZone#locked` documentation at the begin of this file for more detail)
if (this._dropZone && this._dropZone.locked && eq(this._dropZone.item, this._hoveredItem)) {
return;
}
this._dropZone = dropZone;
this._updateTarget();
this._updateTargetParent();
}
// Update the target based on this._dropZone.
private _updateTarget() {
const dropZone = this._dropZone;
if (dropZone) {
const left = this._getDropZoneOffsetLeft(dropZone);
const width = this._getDropZoneRight(dropZone) - left;
const top = this._getDropZoneTop(dropZone);
this._target.set({width, left, top});
this._hideTarget.set(false);
} else {
this._hideTarget.set(true);
}
}
// compute the px distance between the left side of the container and the right side of the header
private _getDropZoneRight(dropZone: DropZone): number {
const headerRight = dropZone.item.headerElement.getBoundingClientRect().right;
const containerRight = this._containerElement.getBoundingClientRect().left;
return headerRight - containerRight;
}
// compute the px distance between the left side of the container and the drop zone
private _getDropZoneOffsetLeft(dropZone: DropZone): number {
// when target is 'within' the item we must add one level of indentation to the items left offset
return dropZone.item.offsetLeft() + (dropZone.zone === 'within' ? this._options.offset : 0);
}
// compute the px distance between the top of the container and the drop zone
private _getDropZoneTop(dropZone: DropZone): number {
const el = dropZone.item.headerElement;
// when crossing the border between 2 consecutive items A and B while dragging another item, in
// order to allow the target to remain steady between A and B we need to remove 2 px when
// dropzone is 'above', otherwise it causes the target to flicker.
return dropZone.zone === 'above' ? el.offsetTop - 2 : el.offsetTop + el.clientHeight;
}
// Turns off the highlight on the former parent, and turns it on the new parent.
private _updateTargetParent() {
const drag = this._drag.get();
if (!drag) {
return;
}
const newParent = this._dropZone ? this._getDropZoneParent(this._dropZone) : null;
if (newParent && newParent !== "root") {
drag.highlightedBox.autoDispose({dispose: () => newParent.highlight.set(false)});
newParent.highlight.set(true);
} else {
// setting holder to a dump value allows to dispose the previous value
drag.highlightedBox.autoDispose({dispose: noop});
}
}
private _getDropZone(mouseY: number): DropZone|null {
const item = this._hoveredItem;
const drag = this._drag.get();
if (!drag || item === "root") {
return null;
}
// let's not permit dropping above or below the dragged item
if (eq(drag.item, item)) {
return null;
}
// prevents dropping items into their own children
if (this._isInChildOf(item.containerElement, drag.item.containerElement)) {
return null;
}
const children = item.treeItem.children();
const rect = item.headerElement.getBoundingClientRect();
// if cursor is over the top half of the header set the drop zone to above this item
if ((mouseY - rect.top) <= rect.height / 2) {
return {zone: 'above', item};
}
// if cursor is over the bottom half of the header set the drop zone to below this item, unless
// the children are expanded in which case set the drop zone to 'within' this item.
if ((mouseY - rect.top) > rect.height / 2) {
if (!item.collapsed.get() && children && children.get().length) {
// set drop zone to above the first child only if the dragged item is not this item, because
// it is not allowed to drop item into their own children.
if (eq(item, drag.item)) {
return null;
}
return {zone: 'within', item};
} else {
return {zone: 'below', item};
}
}
return null;
}
// Returns whether `element` is nested in a child of `parent`. Both `el` and `parent` must be
// a child of this._containerElement.
private _isInChildOf(el: Element, parent: Element) {
while (el.parentElement
&& el.parentElement !== parent
&& el.parentElement !== this._containerElement // let's stop at the top element
) {
el = el.parentElement;
}
return el.parentElement === parent;
}
// Finds the closest ancestor with '.itemContainer' and returns the attached ItemModel. Returns
// "root" if none are found.
private _closestItem(element: HTMLElement|null): ItemModel|"root" {
if (element) {
let el: HTMLElement|null = element;
while (el && el !== this._containerElement) {
if (el.classList.contains('itemContainer')) {
return dom.getData(el, 'item');
}
el = el.parentElement;
}
}
return "root";
}
// Return the ItemModel of the item's parent or 'root' if parent is the root.
private _getParent(item: ItemModel): ItemModel|"root" {
return this._closestItem(item.containerElement.parentElement);
}
// Return the ItemModel of the dropZone's parent or 'root' if parent is the root.
private _getDropZoneParent(zone: DropZone): ItemModel|"root" {
return zone.zone === 'within' ? zone.item : this._getParent(zone.item);
}
// Returns the TreeNode associated with the item or the TreeModel if item is the root.
private _getTreeNode(item: ItemModel|"root"): TreeNode {
return item === "root" ? this._model.get() : item.treeItem;
}
// returns the item that is just after where zone is pointing to
private _getNextChild(zone: DropZone): TreeItem|undefined {
const children = this._getTreeNode(this._getDropZoneParent(zone)).children();
if (!children) {
return undefined;
}
switch (zone.zone) {
case "within": return children.get()[0];
case "above": return zone.item.treeItem;
case "below": return findNext(children.get(), zone.item.treeItem);
}
}
// trigger calls to TreeNode#insertBefore(...) and TreeNode#removeChild(...) to move draggedItem
// to where zone is pointing to.
private _moveTreeNode(draggedItem: ItemModel, zone: DropZone) {
const parentTo = this._getTreeNode(this._getDropZoneParent(zone));
const childrenTo = parentTo.children();
const nextChild = this._getNextChild(zone);
const parentFrom = this._getTreeNode(this._getParent(draggedItem));
if (!childrenTo) {
throw new Error('Should not be possible to drop into an item with `null` children');
}
if (parentTo === parentFrom) {
// if dropping an item below the above item, do nothing.
if (nextChild === draggedItem.treeItem) {
return;
}
// if dropping and item above the below item, do nothing.
if (findNext(childrenTo.get(), draggedItem.treeItem) === nextChild) {
return;
}
}
// call callbacks
parentTo.insertBefore(draggedItem.treeItem, nextChild || null);
}
// Shuts down the previous expander, and sets a new one for item if it is not the dragged item and
// it can be expanded (ie: it has collapsed children or it has empty list of children).
private _updateExpander(drag: Drag, item: ItemModel) {
const children = item.treeItem.children();
if (eq(drag.item, item) || !children || children.get().length && !item.collapsed.get()) {
drag.autoExpander.clear();
} else {
const callback = () => {
// Expanding the item needs some extra care. Because we could push the dragged item
// downwards in the view (if the dragged item is below the item to be expanded). In which
// case we must update `item.deltaY` to reflect the offset in order to prevent an offset
// between the handle (and the drag image) and the cursor. So let's first save the old pos of the item.
const oldItemTop = drag.item.headerElement.getBoundingClientRect().top;
// let's expand the item
item.collapsed.set(false);
// let's get the new pos for the dragged item, and get the diff
const newItemTop = drag.item.headerElement.getBoundingClientRect().top;
const offset = newItemTop - oldItemTop;
// let's reflect the offset on `item.deltaY`
drag.item.deltaY.set(drag.item.deltaY.get() - offset);
// then set the dropzone.
this._setDropZone({zone: 'within', item, locked: true});
};
const timeoutId = window.setTimeout(callback, this._options.expanderDelay);
const dispose = () => window.clearTimeout(timeoutId);
drag.autoExpander.autoDispose({item, dispose});
}
}
}
// returns the item next to item in children, or null.
function findNext(children: TreeItem[], item: TreeItem) {
return children.find((val, i, array) => Boolean(i) && array[i - 1] === item);
}
function toggle(obs: Observable<boolean>) {
obs.set(!obs.get());
}
export function addTreeView(model: Observable<TreeModel>, options: TreeViewOptions) {
return dom.create(TreeViewComponent, model, options);
}
// Starts dragging only when the user keeps the mouse down for a while. Also the cursor must not
// move until the timer expires. Implementation relies on `./mouseDrag` and a timer that will call
// `startDrag` only after a timer expires.
function delayedMouseDrag(startDrag: MouseDragStart, delay: number) {
return mouseDrag((startEvent, el) => {
// the drag handler is assigned when the timer expires
let handler: MouseDragHandler|null;
const timeoutId = setTimeout(() => handler = startDrag(startEvent, el), delay);
dom.onDisposeElem(el, () => clearTimeout(timeoutId));
function onMove(ev: MouseEvent) {
// Clears timeout if cursor moves before timer expires, ie: the startDrag won't be called.
handler ? handler.onMove(ev) : clearTimeout(timeoutId);
}
function onStop(ev: MouseEvent) {
handler ? handler.onStop(ev) : clearTimeout(timeoutId);
}
return {onMove, onStop};
});
}
// Replaces the children of elem with children.
function replaceChildren(elem: Element, ...children: Element[]) {
while (elem.firstChild) {
elem.removeChild(elem.firstChild);
}
for (const child of children) {
elem.appendChild(child);
}
}