/*
 * 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.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();
    }

    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;

        if (child.deselect(suppressOnDeselect) && !suppressSelectSibling) {
            if (child.previousSibling)
                child.previousSibling.select(true, false);
            else if (child.nextSibling)
                child.nextSibling.select(true, false);
            else
                parent.select(true, false);
        }

        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)
                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.hasChildren) {
                    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;
