1108 lines
38 KiB
JavaScript
1108 lines
38 KiB
JavaScript
/*
|
|
* Copyright (C) 2007-2020 Apple Inc. All rights reserved.
|
|
*
|
|
* Redistribution and use in source and binary forms, with or without
|
|
* modification, are permitted provided that the following conditions
|
|
* are met:
|
|
*
|
|
* 1. Redistributions of source code must retain the above copyright
|
|
* notice, this list of conditions and the following disclaimer.
|
|
* 2. Redistributions in binary form must reproduce the above copyright
|
|
* notice, this list of conditions and the following disclaimer in the
|
|
* documentation and/or other materials provided with the distribution.
|
|
* 3. Neither the name of Apple Inc. ("Apple") nor the names of
|
|
* its contributors may be used to endorse or promote products derived
|
|
* from this software without specific prior written permission.
|
|
*
|
|
* THIS SOFTWARE IS PROVIDED BY APPLE AND ITS CONTRIBUTORS "AS IS" AND ANY
|
|
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
|
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
* DISCLAIMED. IN NO EVENT SHALL APPLE OR ITS CONTRIBUTORS BE LIABLE FOR ANY
|
|
* DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
|
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
|
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
|
|
* ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
|
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
|
|
* THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
*/
|
|
|
|
WI.TreeOutline = class TreeOutline extends WI.Object
|
|
{
|
|
constructor(selectable = true)
|
|
{
|
|
super();
|
|
|
|
this.element = document.createElement("ol");
|
|
this.element.classList.add(WI.TreeOutline.ElementStyleClassName);
|
|
this.element.role = "tree";
|
|
this.element.addEventListener("contextmenu", this._handleContextmenu.bind(this));
|
|
|
|
this.children = [];
|
|
this._childrenListNode = this.element;
|
|
this._childrenListNode.removeChildren();
|
|
this._knownTreeElements = [];
|
|
this._treeElementsExpandedState = [];
|
|
this.allowsRepeatSelection = false;
|
|
this.root = true;
|
|
this.hasChildren = false;
|
|
this.expanded = true;
|
|
this.selected = false;
|
|
this.treeOutline = this;
|
|
this._hidden = false;
|
|
this._compact = false;
|
|
this._large = false;
|
|
this._disclosureButtons = true;
|
|
this._customIndent = false;
|
|
this._selectable = selectable;
|
|
|
|
this._cachedNumberOfDescendants = 0;
|
|
|
|
let itemForRepresentedObject = this.getCachedTreeElement.bind(this);
|
|
let selectionComparator = WI.SelectionController.createTreeComparator(itemForRepresentedObject);
|
|
this._selectionController = new WI.SelectionController(this, selectionComparator);
|
|
|
|
this._itemWasSelectedByUser = false;
|
|
this._processingSelectionChange = false;
|
|
this._suppressNextSelectionDidChangeEvent = false;
|
|
|
|
this._virtualizedDebouncer = null;
|
|
this._virtualizedVisibleTreeElements = null;
|
|
this._virtualizedAttachedTreeElements = null;
|
|
this._virtualizedScrollContainer = null;
|
|
this._virtualizedTreeItemHeight = NaN;
|
|
this._virtualizedTopSpacer = null;
|
|
this._virtualizedBottomSpacer = null;
|
|
|
|
this._childrenListNode.tabIndex = 0;
|
|
this._childrenListNode.addEventListener("keydown", this._treeKeyDown.bind(this), true);
|
|
this._childrenListNode.addEventListener("mousedown", this._handleMouseDown.bind(this));
|
|
|
|
WI.TreeOutline._generateStyleRulesIfNeeded();
|
|
|
|
if (!this._selectable)
|
|
this.element.classList.add("non-selectable");
|
|
}
|
|
|
|
// Public
|
|
|
|
get allowsEmptySelection()
|
|
{
|
|
return this._selectionController.allowsEmptySelection;
|
|
}
|
|
|
|
set allowsEmptySelection(flag)
|
|
{
|
|
this._selectionController.allowsEmptySelection = flag;
|
|
}
|
|
|
|
get allowsMultipleSelection()
|
|
{
|
|
return this._selectionController.allowsMultipleSelection;
|
|
}
|
|
|
|
set allowsMultipleSelection(flag)
|
|
{
|
|
this._selectionController.allowsMultipleSelection = flag;
|
|
}
|
|
|
|
get selectedTreeElement()
|
|
{
|
|
return this.getCachedTreeElement(this._selectionController.lastSelectedItem);
|
|
}
|
|
|
|
set selectedTreeElement(treeElement)
|
|
{
|
|
if (treeElement)
|
|
this._selectionController.selectItem(this.objectForSelection(treeElement));
|
|
else
|
|
this._selectionController.deselectAll();
|
|
}
|
|
|
|
get selectedTreeElements()
|
|
{
|
|
if (this.allowsMultipleSelection) {
|
|
let treeElements = [];
|
|
for (let representedObject of this._selectionController.selectedItems)
|
|
treeElements.push(this.getCachedTreeElement(representedObject));
|
|
return treeElements;
|
|
}
|
|
|
|
let selectedTreeElement = this.selectedTreeElement;
|
|
if (selectedTreeElement)
|
|
return [selectedTreeElement];
|
|
|
|
return [];
|
|
}
|
|
|
|
get processingSelectionChange() { return this._processingSelectionChange; }
|
|
|
|
get hidden()
|
|
{
|
|
return this._hidden;
|
|
}
|
|
|
|
set hidden(x)
|
|
{
|
|
if (this._hidden === x)
|
|
return;
|
|
|
|
this._hidden = x;
|
|
this.element.hidden = this._hidden;
|
|
}
|
|
|
|
get compact()
|
|
{
|
|
return this._compact;
|
|
}
|
|
|
|
set compact(x)
|
|
{
|
|
if (this._compact === x)
|
|
return;
|
|
|
|
this._compact = x;
|
|
if (this._compact)
|
|
this.large = false;
|
|
|
|
this.element.classList.toggle("compact", this._compact);
|
|
}
|
|
|
|
get large()
|
|
{
|
|
return this._large;
|
|
}
|
|
|
|
set large(x)
|
|
{
|
|
if (this._large === x)
|
|
return;
|
|
|
|
this._large = x;
|
|
if (this._large)
|
|
this.compact = false;
|
|
|
|
this.element.classList.toggle("large", this._large);
|
|
}
|
|
|
|
get disclosureButtons()
|
|
{
|
|
return this._disclosureButtons;
|
|
}
|
|
|
|
set disclosureButtons(x)
|
|
{
|
|
if (this._disclosureButtons === x)
|
|
return;
|
|
|
|
this._disclosureButtons = x;
|
|
this.element.classList.toggle("hide-disclosure-buttons", !this._disclosureButtons);
|
|
}
|
|
|
|
get customIndent()
|
|
{
|
|
return this._customIndent;
|
|
}
|
|
|
|
set customIndent(x)
|
|
{
|
|
if (this._customIndent === x)
|
|
return;
|
|
|
|
this._customIndent = x;
|
|
this.element.classList.toggle(WI.TreeOutline.CustomIndentStyleClassName, this._customIndent);
|
|
}
|
|
|
|
get selectable() { return this._selectable; }
|
|
|
|
appendChild(child)
|
|
{
|
|
console.assert(child);
|
|
if (!child)
|
|
return;
|
|
|
|
var lastChild = this.children[this.children.length - 1];
|
|
if (lastChild) {
|
|
lastChild.nextSibling = child;
|
|
child.previousSibling = lastChild;
|
|
} else {
|
|
child.previousSibling = null;
|
|
child.nextSibling = null;
|
|
}
|
|
|
|
var isFirstChild = !this.children.length;
|
|
|
|
this.children.push(child);
|
|
this.hasChildren = true;
|
|
child.parent = this;
|
|
child.treeOutline = this.treeOutline;
|
|
child.treeOutline._rememberTreeElement(child);
|
|
|
|
var current = child.children[0];
|
|
while (current) {
|
|
current.treeOutline = this.treeOutline;
|
|
current.treeOutline._rememberTreeElement(current);
|
|
current = current.traverseNextTreeElement(false, child, true);
|
|
}
|
|
|
|
if (child.hasChildren && child.treeOutline._treeElementsExpandedState[child.identifier] !== undefined)
|
|
child.expanded = child.treeOutline._treeElementsExpandedState[child.identifier];
|
|
|
|
if (this._childrenListNode)
|
|
child._attach();
|
|
|
|
if (this.treeOutline)
|
|
this.treeOutline.dispatchEventToListeners(WI.TreeOutline.Event.ElementAdded, {element: child});
|
|
|
|
if (isFirstChild && this.expanded)
|
|
this.expand();
|
|
}
|
|
|
|
insertChild(child, index)
|
|
{
|
|
console.assert(child);
|
|
if (!child)
|
|
return;
|
|
|
|
var previousChild = index > 0 ? this.children[index - 1] : null;
|
|
if (previousChild) {
|
|
previousChild.nextSibling = child;
|
|
child.previousSibling = previousChild;
|
|
} else {
|
|
child.previousSibling = null;
|
|
}
|
|
|
|
var nextChild = this.children[index];
|
|
if (nextChild) {
|
|
nextChild.previousSibling = child;
|
|
child.nextSibling = nextChild;
|
|
} else {
|
|
child.nextSibling = null;
|
|
}
|
|
|
|
var isFirstChild = !this.children.length;
|
|
|
|
this.children.splice(index, 0, child);
|
|
this.hasChildren = true;
|
|
child.parent = this;
|
|
child.treeOutline = this.treeOutline;
|
|
child.treeOutline._rememberTreeElement(child);
|
|
|
|
var current = child.children[0];
|
|
while (current) {
|
|
current.treeOutline = this.treeOutline;
|
|
current.treeOutline._rememberTreeElement(current);
|
|
current = current.traverseNextTreeElement(false, child, true);
|
|
}
|
|
|
|
if (child.expandable && child.treeOutline._treeElementsExpandedState[child.identifier] !== undefined)
|
|
child.expanded = child.treeOutline._treeElementsExpandedState[child.identifier];
|
|
|
|
if (this._childrenListNode)
|
|
child._attach();
|
|
|
|
if (this.treeOutline)
|
|
this.treeOutline.dispatchEventToListeners(WI.TreeOutline.Event.ElementAdded, {element: child});
|
|
|
|
if (isFirstChild && this.expanded)
|
|
this.expand();
|
|
}
|
|
|
|
removeChildAtIndex(childIndex, suppressOnDeselect, suppressSelectSibling)
|
|
{
|
|
console.assert(childIndex >= 0 && childIndex < this.children.length);
|
|
if (childIndex < 0 || childIndex >= this.children.length)
|
|
return;
|
|
|
|
let child = this.children[childIndex];
|
|
let parent = child.parent;
|
|
|
|
let childOrDescendantWasSelected = child.deselect(suppressOnDeselect) || child.selfOrDescendant((descendant) => descendant.selected);
|
|
if (childOrDescendantWasSelected && !suppressSelectSibling) {
|
|
const omitFocus = true;
|
|
if (child.previousSibling)
|
|
child.previousSibling.select(omitFocus);
|
|
else if (child.nextSibling)
|
|
child.nextSibling.select(omitFocus);
|
|
else
|
|
parent.select(omitFocus);
|
|
}
|
|
|
|
let treeOutline = child.treeOutline;
|
|
if (treeOutline) {
|
|
treeOutline._forgetTreeElement(child);
|
|
treeOutline._forgetChildrenRecursive(child);
|
|
}
|
|
|
|
if (child.previousSibling)
|
|
child.previousSibling.nextSibling = child.nextSibling;
|
|
if (child.nextSibling)
|
|
child.nextSibling.previousSibling = child.previousSibling;
|
|
|
|
this.children.splice(childIndex, 1);
|
|
|
|
child._detach();
|
|
child.treeOutline = null;
|
|
child.parent = null;
|
|
child.nextSibling = null;
|
|
child.previousSibling = null;
|
|
|
|
if (treeOutline)
|
|
treeOutline.dispatchEventToListeners(WI.TreeOutline.Event.ElementRemoved, {element: child});
|
|
}
|
|
|
|
removeChild(child, suppressOnDeselect, suppressSelectSibling)
|
|
{
|
|
console.assert(child);
|
|
if (!child)
|
|
return;
|
|
|
|
var childIndex = this.children.indexOf(child);
|
|
console.assert(childIndex !== -1);
|
|
if (childIndex === -1)
|
|
return;
|
|
|
|
this.removeChildAtIndex(childIndex, suppressOnDeselect, suppressSelectSibling);
|
|
|
|
if (!this.children.length) {
|
|
if (this._listItemNode)
|
|
this._listItemNode.classList.remove("parent");
|
|
this.hasChildren = false;
|
|
}
|
|
}
|
|
|
|
removeChildren(suppressOnDeselect)
|
|
{
|
|
for (let child of this.children) {
|
|
child.deselect(suppressOnDeselect);
|
|
|
|
let treeOutline = child.treeOutline;
|
|
if (treeOutline) {
|
|
treeOutline._forgetTreeElement(child);
|
|
treeOutline._forgetChildrenRecursive(child);
|
|
}
|
|
|
|
child._detach();
|
|
child.treeOutline = null;
|
|
child.parent = null;
|
|
child.nextSibling = null;
|
|
child.previousSibling = null;
|
|
|
|
if (treeOutline)
|
|
treeOutline.dispatchEventToListeners(WI.TreeOutline.Event.ElementRemoved, {element: child});
|
|
}
|
|
|
|
this.children = [];
|
|
}
|
|
|
|
_rememberTreeElement(element)
|
|
{
|
|
if (!this._knownTreeElements[element.identifier])
|
|
this._knownTreeElements[element.identifier] = [];
|
|
|
|
var elements = this._knownTreeElements[element.identifier];
|
|
if (!elements.includes(element)) {
|
|
elements.push(element);
|
|
this._cachedNumberOfDescendants++;
|
|
}
|
|
|
|
if (this.virtualized)
|
|
this._virtualizedDebouncer.delayForFrame();
|
|
}
|
|
|
|
_forgetTreeElement(element)
|
|
{
|
|
if (this.selectedTreeElement === element) {
|
|
element.deselect(true);
|
|
this.selectedTreeElement = null;
|
|
}
|
|
|
|
if (this._knownTreeElements[element.identifier]) {
|
|
if (this._knownTreeElements[element.identifier].remove(element))
|
|
this._cachedNumberOfDescendants--;
|
|
}
|
|
|
|
if (this.virtualized)
|
|
this._virtualizedDebouncer.delayForFrame();
|
|
}
|
|
|
|
_forgetChildrenRecursive(parentElement)
|
|
{
|
|
var child = parentElement.children[0];
|
|
while (child) {
|
|
this._forgetTreeElement(child);
|
|
child = child.traverseNextTreeElement(false, parentElement, true);
|
|
}
|
|
}
|
|
|
|
getCachedTreeElement(representedObject)
|
|
{
|
|
if (!representedObject)
|
|
return null;
|
|
|
|
// SelectionController requires every selectable object to be unique.
|
|
// A TreeOutline subclass where multiple TreeElements may be associated
|
|
// with one represented object can override objectForSelection, and return
|
|
// a proxy object that is associated with a single TreeElement.
|
|
if (representedObject.__proxyObjectTreeElement)
|
|
return representedObject.__proxyObjectTreeElement;
|
|
|
|
if (representedObject.__treeElementIdentifier) {
|
|
// If this representedObject has a tree element identifier, and it is a known TreeElement
|
|
// in our tree we can just return that tree element.
|
|
var elements = this._knownTreeElements[representedObject.__treeElementIdentifier];
|
|
if (elements) {
|
|
for (var i = 0; i < elements.length; ++i)
|
|
if (elements[i].representedObject === representedObject)
|
|
return elements[i];
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
selfOrDescendant(predicate)
|
|
{
|
|
let treeElements = [this];
|
|
while (treeElements.length) {
|
|
let treeElement = treeElements.shift();
|
|
if (predicate(treeElement))
|
|
return treeElement;
|
|
|
|
treeElements.pushAll(treeElement.children);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
findTreeElement(representedObject, isAncestor, getParent)
|
|
{
|
|
if (!representedObject)
|
|
return null;
|
|
|
|
var cachedElement = this.getCachedTreeElement(representedObject);
|
|
if (cachedElement)
|
|
return cachedElement;
|
|
|
|
// The representedObject isn't known, so we start at the top of the tree and work down to find the first
|
|
// tree element that represents representedObject or one of its ancestors.
|
|
var item;
|
|
var found = false;
|
|
for (var i = 0; i < this.children.length; ++i) {
|
|
item = this.children[i];
|
|
if (item.representedObject === representedObject || (isAncestor && isAncestor(item.representedObject, representedObject))) {
|
|
found = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!found)
|
|
return null;
|
|
|
|
// Make sure the item that we found is connected to the root of the tree.
|
|
// Build up a list of representedObject's ancestors that aren't already in our tree.
|
|
var ancestors = [];
|
|
var currentObject = representedObject;
|
|
while (currentObject) {
|
|
ancestors.unshift(currentObject);
|
|
if (currentObject === item.representedObject)
|
|
break;
|
|
currentObject = getParent(currentObject);
|
|
}
|
|
|
|
// For each of those ancestors we populate them to fill in the tree.
|
|
for (var i = 0; i < ancestors.length; ++i) {
|
|
// Make sure we don't call findTreeElement with the same representedObject
|
|
// again, to prevent infinite recursion.
|
|
if (ancestors[i] === representedObject)
|
|
continue;
|
|
|
|
// FIXME: we could do something faster than findTreeElement since we will know the next
|
|
// ancestor exists in the tree.
|
|
item = this.findTreeElement(ancestors[i], isAncestor, getParent);
|
|
if (!item)
|
|
return null;
|
|
|
|
item.onpopulate();
|
|
}
|
|
|
|
return this.getCachedTreeElement(representedObject);
|
|
}
|
|
|
|
_treeElementDidChange(treeElement)
|
|
{
|
|
if (treeElement.treeOutline !== this)
|
|
return;
|
|
|
|
this.dispatchEventToListeners(WI.TreeOutline.Event.ElementDidChange, {element: treeElement});
|
|
}
|
|
|
|
treeElementFromNode(node)
|
|
{
|
|
var listNode = node.closest("ol, li");
|
|
if (listNode)
|
|
return listNode.parentTreeElement || listNode.treeElement;
|
|
return null;
|
|
}
|
|
|
|
treeElementFromPoint(x, y)
|
|
{
|
|
var node = this._childrenListNode.ownerDocument.elementFromPoint(x, y);
|
|
if (!node)
|
|
return null;
|
|
|
|
return this.treeElementFromNode(node);
|
|
}
|
|
|
|
_treeKeyDown(event)
|
|
{
|
|
if (WI.isBeingEdited(event.target))
|
|
return;
|
|
|
|
if (event.target !== this._childrenListNode && event.target.closest("." + WI.TreeOutline.ElementStyleClassName) !== this._childrenListNode)
|
|
return;
|
|
|
|
let isRTL = WI.resolveLayoutDirectionForElement(this.element) === WI.LayoutDirection.RTL;
|
|
let expandKeyIdentifier = isRTL ? "Left" : "Right";
|
|
let collapseKeyIdentifier = isRTL ? "Right" : "Left";
|
|
|
|
var handled = false;
|
|
var nextSelectedElement;
|
|
|
|
if (this.selectedTreeElement) {
|
|
if (event.keyIdentifier === collapseKeyIdentifier) {
|
|
if (this.selectedTreeElement.expanded) {
|
|
if (event.altKey)
|
|
this.selectedTreeElement.collapseRecursively();
|
|
else
|
|
this.selectedTreeElement.collapse();
|
|
handled = true;
|
|
} else if (this.selectedTreeElement.parent && !this.selectedTreeElement.parent.root) {
|
|
handled = true;
|
|
if (this.selectedTreeElement.parent.selectable) {
|
|
nextSelectedElement = this.selectedTreeElement.parent;
|
|
while (nextSelectedElement && !nextSelectedElement.selectable)
|
|
nextSelectedElement = nextSelectedElement.parent;
|
|
handled = nextSelectedElement ? true : false;
|
|
} else if (this.selectedTreeElement.parent)
|
|
this.selectedTreeElement.parent.collapse();
|
|
}
|
|
} else if (event.keyIdentifier === expandKeyIdentifier) {
|
|
if (!this.selectedTreeElement.revealed()) {
|
|
this.selectedTreeElement.reveal();
|
|
handled = true;
|
|
} else if (this.selectedTreeElement.expandable) {
|
|
handled = true;
|
|
if (this.selectedTreeElement.expanded) {
|
|
nextSelectedElement = this.selectedTreeElement.children[0];
|
|
while (nextSelectedElement && !nextSelectedElement.selectable)
|
|
nextSelectedElement = nextSelectedElement.nextSibling;
|
|
handled = nextSelectedElement ? true : false;
|
|
} else {
|
|
if (event.altKey)
|
|
this.selectedTreeElement.expandRecursively();
|
|
else
|
|
this.selectedTreeElement.expand();
|
|
}
|
|
}
|
|
} else if (event.keyCode === 8 /* Backspace */ || event.keyCode === 46 /* Delete */) {
|
|
for (let treeElement of this.selectedTreeElements) {
|
|
if (treeElement.ondelete && treeElement.ondelete())
|
|
handled = true;
|
|
}
|
|
if (!handled && this.treeOutline.ondelete)
|
|
handled = this.treeOutline.ondelete(this.selectedTreeElement);
|
|
} else if (isEnterKey(event)) {
|
|
if (this.selectedTreeElement.onenter)
|
|
handled = this.selectedTreeElement.onenter();
|
|
if (!handled && this.treeOutline.onenter)
|
|
handled = this.treeOutline.onenter(this.selectedTreeElement);
|
|
} else if (event.keyIdentifier === "U+0020" /* Space */) {
|
|
if (this.selectedTreeElement.onspace)
|
|
handled = this.selectedTreeElement.onspace();
|
|
if (!handled && this.treeOutline.onspace)
|
|
handled = this.treeOutline.onspace(this.selectedTreeElement);
|
|
}
|
|
}
|
|
|
|
if (!handled) {
|
|
this._itemWasSelectedByUser = true;
|
|
handled = this._selectionController.handleKeyDown(event);
|
|
this._itemWasSelectedByUser = false;
|
|
|
|
if (handled)
|
|
nextSelectedElement = this.selectedTreeElement;
|
|
}
|
|
|
|
if (nextSelectedElement) {
|
|
nextSelectedElement.reveal();
|
|
nextSelectedElement.select(false, true);
|
|
}
|
|
|
|
if (handled) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
}
|
|
}
|
|
|
|
expand()
|
|
{
|
|
// this is the root, do nothing
|
|
}
|
|
|
|
collapse()
|
|
{
|
|
// this is the root, do nothing
|
|
}
|
|
|
|
revealed()
|
|
{
|
|
return true;
|
|
}
|
|
|
|
reveal()
|
|
{
|
|
// this is the root, do nothing
|
|
}
|
|
|
|
select()
|
|
{
|
|
// this is the root, do nothing
|
|
}
|
|
|
|
revealAndSelect(omitFocus)
|
|
{
|
|
// this is the root, do nothing
|
|
}
|
|
|
|
selectTreeElements(treeElements)
|
|
{
|
|
if (!treeElements.length)
|
|
return;
|
|
|
|
if (treeElements.length === 1) {
|
|
this.selectedTreeElement = treeElements[0];
|
|
return;
|
|
}
|
|
|
|
console.assert(this.allowsMultipleSelection, "Cannot select TreeElements with multiple selection disabled.");
|
|
if (!this.allowsMultipleSelection)
|
|
return;
|
|
|
|
let selectableObjects = treeElements.map((treeElement) => this.objectForSelection(treeElement));
|
|
this._selectionController.selectItems(new Set(selectableObjects));
|
|
}
|
|
|
|
get virtualized()
|
|
{
|
|
return this._virtualizedScrollContainer && !isNaN(this._virtualizedTreeItemHeight);
|
|
}
|
|
|
|
registerScrollVirtualizer(scrollContainer, treeItemHeight)
|
|
{
|
|
console.assert(scrollContainer);
|
|
console.assert(!isNaN(treeItemHeight));
|
|
console.assert(!this.virtualized);
|
|
|
|
let boundUpdateVirtualizedElements = (focusedTreeElement) => {
|
|
this._updateVirtualizedElements(focusedTreeElement);
|
|
};
|
|
|
|
this._virtualizedDebouncer = new Debouncer(boundUpdateVirtualizedElements);
|
|
this._virtualizedVisibleTreeElements = new Set;
|
|
this._virtualizedAttachedTreeElements = new Set;
|
|
this._virtualizedScrollContainer = scrollContainer;
|
|
this._virtualizedTreeItemHeight = treeItemHeight;
|
|
this._virtualizedTopSpacer = document.createElement("div");
|
|
this._virtualizedBottomSpacer = document.createElement("div");
|
|
|
|
let throttler = new Throttler(boundUpdateVirtualizedElements, 1000 / 16);
|
|
this._virtualizedScrollContainer.addEventListener("scroll", (event) => {
|
|
throttler.fire();
|
|
});
|
|
|
|
this._updateVirtualizedElements();
|
|
}
|
|
|
|
get updateVirtualizedElementsDebouncer()
|
|
{
|
|
return this._virtualizedDebouncer;
|
|
}
|
|
|
|
// SelectionController delegate
|
|
|
|
selectionControllerSelectionDidChange(controller, deselectedItems, selectedItems)
|
|
{
|
|
this._processingSelectionChange = true;
|
|
|
|
for (let representedObject of deselectedItems) {
|
|
let treeElement = this.getCachedTreeElement(representedObject);
|
|
if (!treeElement)
|
|
continue;
|
|
|
|
if (treeElement.listItemElement)
|
|
treeElement.listItemElement.classList.remove("selected");
|
|
|
|
treeElement.deselect();
|
|
}
|
|
|
|
for (let representedObject of selectedItems) {
|
|
let treeElement = this.getCachedTreeElement(representedObject);
|
|
if (!treeElement)
|
|
continue;
|
|
|
|
if (treeElement.listItemElement)
|
|
treeElement.listItemElement.classList.add("selected");
|
|
|
|
const omitFocus = true;
|
|
treeElement.select(omitFocus);
|
|
}
|
|
|
|
this._dispatchSelectionDidChangeEvent();
|
|
|
|
this._processingSelectionChange = false;
|
|
}
|
|
|
|
selectionControllerFirstSelectableItem(controller)
|
|
{
|
|
let firstChild = this.children[0];
|
|
if (firstChild.selectable)
|
|
return firstChild.representedObject;
|
|
return this.selectionControllerNextSelectableItem(controller, firstChild.representedObject);
|
|
}
|
|
|
|
selectionControllerLastSelectableItem(controller)
|
|
{
|
|
let treeElement = this.children.lastValue;
|
|
while (treeElement.expanded && treeElement.children.length)
|
|
treeElement = treeElement.children.lastValue;
|
|
|
|
let item = this.objectForSelection(treeElement);
|
|
if (this.canSelectTreeElement(treeElement))
|
|
return item;
|
|
return this.selectionControllerPreviousSelectableItem(controller, item);
|
|
}
|
|
|
|
selectionControllerPreviousSelectableItem(controller, item)
|
|
{
|
|
let treeElement = this.getCachedTreeElement(item);
|
|
console.assert(treeElement, "Missing TreeElement for representedObject.", item);
|
|
if (!treeElement)
|
|
return null;
|
|
|
|
const skipUnrevealed = true;
|
|
const stayWithin = null;
|
|
const dontPopulate = true;
|
|
|
|
while (treeElement = treeElement.traversePreviousTreeElement(skipUnrevealed, stayWithin, dontPopulate)) {
|
|
if (this.canSelectTreeElement(treeElement))
|
|
return this.objectForSelection(treeElement);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
selectionControllerNextSelectableItem(controller, item)
|
|
{
|
|
let treeElement = this.getCachedTreeElement(item);
|
|
console.assert(treeElement, "Missing TreeElement for representedObject.", item);
|
|
if (!treeElement)
|
|
return null;
|
|
|
|
const skipUnrevealed = true;
|
|
const stayWithin = null;
|
|
const dontPopulate = true;
|
|
|
|
while (treeElement = treeElement.traverseNextTreeElement(skipUnrevealed, stayWithin, dontPopulate)) {
|
|
if (this.canSelectTreeElement(treeElement))
|
|
return this.objectForSelection(treeElement);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// Protected
|
|
|
|
canSelectTreeElement(treeElement)
|
|
{
|
|
// Can be overridden by subclasses.
|
|
|
|
return treeElement.selectable;
|
|
}
|
|
|
|
objectForSelection(treeElement)
|
|
{
|
|
return treeElement.representedObject;
|
|
}
|
|
|
|
selectTreeElementInternal(treeElement, suppressNotification = false, selectedByUser = false)
|
|
{
|
|
if (this._processingSelectionChange)
|
|
return;
|
|
|
|
this._itemWasSelectedByUser = selectedByUser;
|
|
this._suppressNextSelectionDidChangeEvent = suppressNotification;
|
|
|
|
if (this.allowsRepeatSelection && this.selectedTreeElement === treeElement) {
|
|
this._dispatchSelectionDidChangeEvent();
|
|
return;
|
|
}
|
|
|
|
this.selectedTreeElement = treeElement;
|
|
}
|
|
|
|
treeElementFromEvent(event)
|
|
{
|
|
// We can't take event.pageX to be our X coordinate, since the TreeElement
|
|
// could be indented, in which case we can't rely on its DOM element to be
|
|
// under the mouse.
|
|
// We choose this X coordinate based on the knowledge that our list
|
|
// items extend at least to the trailing edge of the outer <ol> container.
|
|
// In the no-word-wrap mode the outer <ol> may be wider than the tree container
|
|
// (and partially hidden), in which case we use the edge of its container.
|
|
|
|
let scrollContainer = this.element.parentElement;
|
|
if (scrollContainer.offsetWidth > this.element.offsetWidth)
|
|
scrollContainer = this.element;
|
|
|
|
// This adjustment is useful in order to find the inner-most tree element that
|
|
// lines up horizontally with the location of the event. If the mouse event
|
|
// happened in the space preceding a nested tree element (in the leading indentated
|
|
// space) we use this adjustment to get the nested tree element and not a tree element
|
|
// from a parent / outer tree outline / tree element.
|
|
//
|
|
// NOTE: This can fail if there is floating content over the trailing edge of
|
|
// the <li> content, since the element from point could hit that.
|
|
let isRTL = WI.resolvedLayoutDirection() === WI.LayoutDirection.RTL;
|
|
let trailingEdgeOffset = isRTL ? 36 : (scrollContainer.offsetWidth - 36);
|
|
let x = scrollContainer.totalOffsetLeft + trailingEdgeOffset;
|
|
let y = event.pageY;
|
|
|
|
// Our list items have 1-pixel cracks between them vertically. We avoid
|
|
// the cracks by checking slightly above and slightly below the mouse
|
|
// and seeing if we hit the same element each time.
|
|
let elementUnderMouse = this.treeElementFromPoint(x, y);
|
|
let elementAboveMouse = this.treeElementFromPoint(x, y - 2);
|
|
let element = null;
|
|
if (elementUnderMouse === elementAboveMouse)
|
|
element = elementUnderMouse;
|
|
else
|
|
element = this.treeElementFromPoint(x, y + 2);
|
|
|
|
return element;
|
|
}
|
|
|
|
populateContextMenu(contextMenu, event, treeElement)
|
|
{
|
|
treeElement.populateContextMenu(contextMenu, event);
|
|
}
|
|
|
|
// Private
|
|
|
|
static _generateStyleRulesIfNeeded()
|
|
{
|
|
if (WI.TreeOutline._styleElement)
|
|
return;
|
|
|
|
WI.TreeOutline._styleElement = document.createElement("style");
|
|
|
|
let maximumTreeDepth = 32;
|
|
let depthPadding = 10;
|
|
|
|
let styleText = "";
|
|
let childrenSubstring = "";
|
|
for (let i = 1; i <= maximumTreeDepth; ++i) {
|
|
// Keep all the elements at the same depth once the maximum is reached.
|
|
childrenSubstring += i === maximumTreeDepth ? " .children" : " > .children";
|
|
styleText += `.${WI.TreeOutline.ElementStyleClassName}:not(.${WI.TreeOutline.CustomIndentStyleClassName})${childrenSubstring} > .item { `;
|
|
styleText += `padding-inline-start: calc(var(--tree-outline-item-padding) + ${depthPadding * i}px);`;
|
|
styleText += ` }\n`;
|
|
}
|
|
|
|
WI.TreeOutline._styleElement.textContent = styleText;
|
|
|
|
document.head.appendChild(WI.TreeOutline._styleElement);
|
|
}
|
|
|
|
_updateVirtualizedElements(focusedTreeElement)
|
|
{
|
|
console.assert(this.virtualized);
|
|
|
|
this._virtualizedDebouncer.cancel();
|
|
|
|
function walk(parent, callback, count = 0) {
|
|
let shouldReturn = false;
|
|
for (let child of parent.children) {
|
|
if (!child.revealed(false))
|
|
continue;
|
|
|
|
shouldReturn = callback(child, count);
|
|
if (shouldReturn)
|
|
break;
|
|
|
|
++count;
|
|
if (child.expanded) {
|
|
let result = walk(child, callback, count);
|
|
count = result.count;
|
|
if (result.shouldReturn)
|
|
break;
|
|
}
|
|
}
|
|
return {count, shouldReturn};
|
|
}
|
|
|
|
function calculateOffsetFromContainer(node, target) {
|
|
let top = 0;
|
|
while (node !== target) {
|
|
top += node.offsetTop;
|
|
node = node.offsetParent;
|
|
if (!node)
|
|
return 0;
|
|
}
|
|
return top;
|
|
}
|
|
|
|
let offsetFromContainer = calculateOffsetFromContainer(this._virtualizedTopSpacer.parentNode ? this._virtualizedTopSpacer : this.element, this._virtualizedScrollContainer);
|
|
let numberVisible = Math.ceil(Math.max(0, this._virtualizedScrollContainer.offsetHeight - offsetFromContainer) / this._virtualizedTreeItemHeight);
|
|
let extraRows = Math.max(numberVisible * 5, 50);
|
|
let firstItem = Math.floor((this._virtualizedScrollContainer.scrollTop - offsetFromContainer) / this._virtualizedTreeItemHeight) - extraRows;
|
|
let lastItem = firstItem + numberVisible + (extraRows * 2);
|
|
|
|
let shouldScroll = false;
|
|
if (focusedTreeElement && focusedTreeElement.revealed(false)) {
|
|
let index = walk(this, (treeElement) => treeElement === focusedTreeElement).count;
|
|
if (index < firstItem) {
|
|
firstItem = index - extraRows;
|
|
lastItem = index + numberVisible + extraRows;
|
|
} else if (index > lastItem) {
|
|
firstItem = index - numberVisible - extraRows;
|
|
lastItem = index + extraRows;
|
|
}
|
|
|
|
// Only scroll if the `focusedTreeElement` is outside the visible items, not including
|
|
// the added buffer `extraRows`.
|
|
shouldScroll = (index < firstItem + extraRows) || (index > lastItem - extraRows);
|
|
}
|
|
|
|
console.assert(firstItem < lastItem);
|
|
|
|
let visibleTreeElements = new Set;
|
|
let treeElementsToAttach = new Set;
|
|
let treeElementsToDetach = new Set;
|
|
let totalItems = walk(this, (treeElement, count) => {
|
|
if (count >= firstItem && count <= lastItem) {
|
|
treeElementsToAttach.add(treeElement);
|
|
if (count >= firstItem + extraRows && count <= lastItem - extraRows)
|
|
visibleTreeElements.add(treeElement);
|
|
} else if (treeElement._listItemNode.parentNode)
|
|
treeElementsToDetach.add(treeElement);
|
|
|
|
return false;
|
|
}).count;
|
|
|
|
// Redraw if we are about to scroll.
|
|
if (!shouldScroll) {
|
|
// Redraw if there are a different number of items to show.
|
|
if (visibleTreeElements.size === this._virtualizedVisibleTreeElements.size) {
|
|
// Redraw if all of the previously centered `WI.TreeElement` are no longer centered.
|
|
if (visibleTreeElements.intersects(this._virtualizedVisibleTreeElements)) {
|
|
// Redraw if there is a `WI.TreeElement` that should be shown that isn't attached.
|
|
if (visibleTreeElements.isSubsetOf(this._virtualizedAttachedTreeElements))
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
this._virtualizedVisibleTreeElements = visibleTreeElements;
|
|
this._virtualizedAttachedTreeElements = treeElementsToAttach;
|
|
|
|
for (let treeElement of treeElementsToDetach)
|
|
treeElement._listItemNode.remove();
|
|
|
|
for (let treeElement of treeElementsToAttach) {
|
|
treeElement.parent._childrenListNode.appendChild(treeElement._listItemNode);
|
|
if (treeElement._childrenListNode)
|
|
treeElement.parent._childrenListNode.appendChild(treeElement._childrenListNode);
|
|
}
|
|
|
|
this._virtualizedTopSpacer.style.height = (Number.constrain(firstItem, 0, totalItems) * this._virtualizedTreeItemHeight) + "px";
|
|
if (this.element.previousElementSibling !== this._virtualizedTopSpacer)
|
|
this.element.parentNode.insertBefore(this._virtualizedTopSpacer, this.element);
|
|
|
|
this._virtualizedBottomSpacer.style.height = (Number.constrain(totalItems - lastItem, 0, totalItems) * this._virtualizedTreeItemHeight) + "px";
|
|
if (this.element.nextElementSibling !== this._virtualizedBottomSpacer)
|
|
this.element.parentNode.insertBefore(this._virtualizedBottomSpacer, this.element.nextElementSibling);
|
|
|
|
if (shouldScroll)
|
|
this._virtualizedScrollContainer.scrollTop = offsetFromContainer + ((firstItem + extraRows) * this._virtualizedTreeItemHeight);
|
|
}
|
|
|
|
_handleContextmenu(event)
|
|
{
|
|
let treeElement = this.treeElementFromEvent(event);
|
|
if (!treeElement)
|
|
return;
|
|
|
|
let contextMenu = WI.ContextMenu.createFromEvent(event);
|
|
this.populateContextMenu(contextMenu, event, treeElement);
|
|
}
|
|
|
|
_handleMouseDown(event)
|
|
{
|
|
let treeElement = this.treeElementFromEvent(event);
|
|
if (!treeElement || !treeElement.selectable)
|
|
return;
|
|
|
|
if (treeElement.isEventWithinDisclosureTriangle(event)) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
|
|
if (!treeElement.canSelectOnMouseDown(event))
|
|
return;
|
|
|
|
if (this.allowsRepeatSelection && treeElement.selected && this._selectionController.selectedItems.size === 1) {
|
|
// Special case for dispatching a selection event for an already selected
|
|
// item in single-selection mode.
|
|
this._itemWasSelectedByUser = true;
|
|
this._dispatchSelectionDidChangeEvent();
|
|
return;
|
|
}
|
|
|
|
this._itemWasSelectedByUser = true;
|
|
this._selectionController.handleItemMouseDown(this.objectForSelection(treeElement), event);
|
|
this._itemWasSelectedByUser = false;
|
|
|
|
treeElement.focus();
|
|
}
|
|
|
|
_dispatchSelectionDidChangeEvent()
|
|
{
|
|
let selectedByUser = this._itemWasSelectedByUser;
|
|
this._itemWasSelectedByUser = false;
|
|
|
|
if (this._suppressNextSelectionDidChangeEvent) {
|
|
this._suppressNextSelectionDidChangeEvent = false;
|
|
return;
|
|
}
|
|
|
|
this.dispatchEventToListeners(WI.TreeOutline.Event.SelectionDidChange, {selectedByUser});
|
|
}
|
|
};
|
|
|
|
WI.TreeOutline._styleElement = null;
|
|
|
|
WI.TreeOutline.ElementStyleClassName = "tree-outline";
|
|
WI.TreeOutline.CustomIndentStyleClassName = "custom-indent";
|
|
|
|
WI.TreeOutline.Event = {
|
|
ElementAdded: "element-added",
|
|
ElementDidChange: "element-did-change",
|
|
ElementRemoved: "element-removed",
|
|
ElementRevealed: "element-revealed",
|
|
ElementClicked: "element-clicked",
|
|
ElementDisclosureDidChanged: "element-disclosure-did-change",
|
|
ElementVisibilityDidChange: "element-visbility-did-change",
|
|
SelectionDidChange: "selection-did-change",
|
|
};
|
|
|
|
WI.TreeOutline._knownTreeElementNextIdentifier = 1;
|