/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, you can obtain one at http://mozilla.org/MPL/2.0/. */ { /** * A widget for adjusting the size of its {@link PaneSplitter#resizeElement}. * By default, the splitter will resize the height of the resizeElement, but * this can be changed using the "resize-direction" attribute. * * If dragged, the splitter will set a CSS variable on the parent element, * which is named from the id of the element plus "width" or "height" as * appropriate (e.g. --splitter-width). The variable should be used to set the * border-area width or height of the resizeElement. * * Often, you will want to naturally limit the size of the resizeElement to * prevent it exceeding its min or max size bounds, and to remain within the * available space of its container. One way to do this is to use a grid * layout on the container and size the resizeElement's row with * "minmax(auto, --splitter-height)", or similar for the column when adjusting * the width. * * This splitter element fires a "splitter-resizing" event as dragging begins, * and "splitter-resized" when it ends. * * The resizeElement can be collapsed and expanded. Whilst collapsed, the * "collapsed-by-splitter" class will be added to the resizeElement and the * "---width" or "---height" CSS variable, will be be set to "0px". * The "splitter-collapsed" and "splitter-expanded" events are fired as * appropriate. If the splitter has a "collapse-width" or "collapse-height" * attribute, collapsing and expanding happens automatically when below the * given size. */ class PaneSplitter extends HTMLHRElement { static observedAttributes = ["resize-direction", "resize-id", "id"]; connectedCallback() { this.addEventListener("mousedown", this); // Try and find the _resizeElement from the resize-id attribute. this._updateResizeElement(); this._updateStyling(); } attributeChangedCallback(name, oldValue, newValue) { switch (name) { case "resize-direction": this._updateResizeDirection(); break; case "resize-id": this._updateResizeElement(); break; case "id": this._updateStyling(); break; } } /** * The direction the splitter resizes the controlled element. Resizing * horizontally changes its width, whilst resizing vertically changes its * height. * * This corresponds to the "resize-direction" attribute and defaults to * "vertical" when none is given. * * @type {"vertical"|"horizontal"} */ get resizeDirection() { return this.getAttribute("resize-direction") ?? "vertical"; } set resizeDirection(val) { this.setAttribute("resize-direction", val); } _updateResizeDirection() { // The resize direction has changed. To be safe, make sure we're no longer // resizing. this.endResize(); this._updateStyling(); } _resizeElement = null; /** * The element that is being sized by the splitter. It must have a set id. * * If the "resize-id" attribute is set, it will be used to choose this * element by its id. * * @type {?HTMLElement} */ get resizeElement() { // Make sure the resizeElement is up to date. this._updateResizeElement(); return this._resizeElement; } set resizeElement(element) { if (!element?.id) { element = null; } this._updateResizeElement(element); // Set the resize-id attribute. // NOTE: This will trigger a second call to _updateResizeElement, but it // should end early because the resize-id matches the just set // _resizeElement. if (element) { this.setAttribute("resize-id", element.id); } else { this.removeAttribute("resize-id"); } } /** * Update the _resizeElement property. * * @param {?HTMLElement} [element] - The resizeElement to set, or leave * undefined to use the resize-id attribute to find the element. */ _updateResizeElement(element) { if (element == undefined) { // Use the resize-id to find the element. let resizeId = this.getAttribute("resize-id"); if (resizeId) { if (this._resizeElement?.id == resizeId) { // Avoid looking up the element since we already have it. return; } // Try and find the element. // NOTE: If we don't find the element now, then we still keep the same // resize-id attribute and we'll try again the next time this method // is called. element = this.ownerDocument.getElementById(resizeId); } else { element = null; } } if (element == this._resizeElement) { return; } // Make sure we stop resizing the current _resizeElement. this.endResize(); if (this._resizeElement) { // Clean up previous element. this._resizeElement.classList.remove("collapsed-by-splitter"); } this._resizeElement = element; this._beforeElement = element && !!( this.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_FOLLOWING ); // Are we already collapsed? this._isCollapsed = this._resizeElement?.classList.contains( "collapsed-by-splitter" ); this._updateStyling(); } _width = null; /** * The desired width of the resizeElement. This is used to set the * ---width CSS variable on the parent when the resizeDirection is * "horizontal" and the resizeElement is not collapsed. If its value is * null, the same CSS variable is removed from the parent instead. * * Note, this value is persistent across collapse states, so the width * before collapsing can be returned to on expansion. * * Use this value in persistent storage. * * @type {?number} */ get width() { return this._width; } set width(width) { if (width == this._width) { return; } this._width = width; this._updateStyling(); } _height = null; /** * The desired height of the resizeElement. This is used to set the * --height CSS variable on the parent when the resizeDirection is * "vertical" and the resizeElement is not collapsed. If its value is null, * the same CSS variable is removed from the parent instead. * * Note, this value is persistent across collapse states, so the height * before collapsing can be returned to on expansion. * * Use this value in persistent storage. * * @type {?number} */ get height() { return this._height; } set height(height) { if (height == this._height) { return; } this._height = height; this._updateStyling(); } /** * Update the width or height of the splitter, depending on its * resizeDirection. * * If a trySize is given, the width or height of the splitter will be set to * the given value, before being set to the actual size of the * resizeElement. This acts as an automatic bounding process, without * knowing the details of the layout and its constraints. * * If no trySize is given, then the width and height will be set to the * actual size of the resizeElement. * * @param {?number} [trySize] - The size to try and achieve. */ _updateSize(trySize) { let vertical = this.resizeDirection == "vertical"; if (trySize != undefined) { if (vertical) { this.height = trySize; } else { this.width = trySize; } } // Now that the width and height are updated, we fetch the size the // element actually took. let actual = this._getActualResizeSize(); if (vertical) { this.height = actual; } else { this.width = actual; } } /** * Get the actual size of the resizeElement, regardless of the current * width or height property values. This causes a reflow, and it gets * called on every mousemove event while dragging, so it's very expensive * but practically unavoidable. * * @returns {number} - The border area size of the resizeElement. */ _getActualResizeSize() { let resizeRect = this.resizeElement.getBoundingClientRect(); if (this.resizeDirection == "vertical") { return resizeRect.height; } return resizeRect.width; } /** * Collapses the controlled pane. A collapsed pane does not affect the * `width` or `height` properties. Fires a "splitter-collapsed" event. */ collapse() { if (this._isCollapsed) { return; } this._isCollapsed = true; this._updateStyling(); this._updateDragCursor(); this.dispatchEvent( new CustomEvent("splitter-collapsed", { bubbles: true }) ); } /** * Expands the controlled pane. It returns to the width or height it had * when collapsed. Fires a "splitter-expanded" event. */ expand() { if (!this._isCollapsed) { return; } this._isCollapsed = false; this._updateStyling(); this._updateDragCursor(); this.dispatchEvent( new CustomEvent("splitter-expanded", { bubbles: true }) ); } _isCollapsed = false; /** * If the controlled pane is collapsed. * * @type {boolean} */ get isCollapsed() { return this._isCollapsed; } set isCollapsed(collapsed) { if (collapsed) { this.collapse(); } else { this.expand(); } } /** * Collapse the splitter if it is expanded, or expand it if collapsed. */ toggleCollapsed() { this.isCollapsed = !this._isCollapsed; } /** * If the splitter is disabled. * * @type {boolean} */ get isDisabled() { return this.hasAttribute("disabled"); } set isDisabled(disabled) { if (disabled) { this.setAttribute("disabled", true); return; } this.removeAttribute("disabled"); } /** * Update styling to reflect the current state. */ _updateStyling() { if (!this.resizeElement || !this.parentNode || !this.id) { // Wait until we have a resizeElement, a parent and an id. return; } if (this.id != this._cssName?.basis) { // Clear the old names. if (this._cssName) { this.parentNode.style.removeProperty(this._cssName.width); this.parentNode.style.removeProperty(this._cssName.height); } this._cssName = { basis: this.id, height: `--${this.id}-height`, width: `--${this.id}-width`, }; } let vertical = this.resizeDirection == "vertical"; let height = this.isCollapsed ? 0 : this.height; if (!vertical || height == null) { // If we are resizing horizontally or the "height" property is set to // null, we remove the CSS height variable. The height of the element // is left to be determined by the CSS stylesheet rules. this.parentNode.style.removeProperty(this._cssName.height); } else { this.parentNode.style.setProperty(this._cssName.height, `${height}px`); } let width = this.isCollapsed ? 0 : this.width; if (vertical || width == null) { // If we are resizing vertically or the "width" property is set to // null, we remove the CSS width variable. The width of the element // is left to be determined by the CSS stylesheet rules. this.parentNode.style.removeProperty(this._cssName.width); } else { this.parentNode.style.setProperty(this._cssName.width, `${width}px`); } this.resizeElement.classList.toggle( "collapsed-by-splitter", this.isCollapsed ); this.classList.toggle("splitter-collapsed", this.isCollapsed); this.classList.toggle("splitter-before", this._beforeElement); } handleEvent(event) { switch (event.type) { case "mousedown": this._onMouseDown(event); break; case "mousemove": this._onMouseMove(event); break; case "mouseup": this._onMouseUp(event); break; } } _onMouseDown(event) { if (!this.resizeElement || this.isDisabled) { return; } if (event.buttons != 1) { return; } let vertical = this.resizeDirection == "vertical"; let collapseSize = Number( this.getAttribute(vertical ? "collapse-height" : "collapse-width") ) || 0; let ltrDir = this.parentNode.matches(":dir(ltr)"); this._dragStartInfo = { wasCollapsed: this.isCollapsed, // Whether this will resize vertically. vertical, pos: vertical ? event.clientY : event.clientX, // Whether decreasing X/Y should increase the size. negative: vertical ? this._beforeElement : this._beforeElement == ltrDir, size: this._getActualResizeSize(), collapseSize, }; event.preventDefault(); window.addEventListener("mousemove", this); window.addEventListener("mouseup", this); // Block all other pointer events whilst resizing. This ensures we don't // trigger any styling or other effects whilst resizing. This also ensures // that the MouseEvent's clientX and clientY will always be relative to // the current window, rather than some ancestor xul:browser's window. document.documentElement.style.pointerEvents = "none"; this._updateDragCursor(); this.classList.add("splitter-resizing"); } _updateDragCursor() { if (!this._dragStartInfo) { return; } let cursor; let { vertical, negative } = this._dragStartInfo; if (this.isCollapsed) { if (vertical) { cursor = negative ? "n-resize" : "s-resize"; } else { cursor = negative ? "w-resize" : "e-resize"; } } else { cursor = vertical ? "ns-resize" : "ew-resize"; } document.documentElement.style.cursor = cursor; } /** * If `mousemove` events will be ignored because the screen hasn't been * updated since the last one. * * @type {boolean} */ _mouseMoveBlocked = false; _onMouseMove(event) { if (event.buttons != 1) { // The button was released and we didn't get a mouseup event (e.g. // releasing the mouse above a disabled html:button), or the // button(s) pressed changed. Either way, stop dragging. this.endResize(); return; } event.preventDefault(); // Ensure the expensive part of this function runs no more than once // per frame. Doing it more frequently is just wasting CPU time. if (this._mouseMoveBlocked) { return; } this._mouseMoveBlocked = true; requestAnimationFrame(() => (this._mouseMoveBlocked = false)); let { wasCollapsed, vertical, negative, pos, size, collapseSize } = this._dragStartInfo; let delta = (vertical ? event.clientY : event.clientX) - pos; if (negative) { delta *= -1; } if (!this._started) { if (Math.abs(delta) < 3) { return; } this._started = true; this.dispatchEvent( new CustomEvent("splitter-resizing", { bubbles: true }) ); } size += delta; if (collapseSize) { let pastCollapseThreshold = size < collapseSize - 20; if (wasCollapsed) { if (!pastCollapseThreshold) { this._dragStartInfo.wasCollapsed = false; } pastCollapseThreshold = size < 20; } if (pastCollapseThreshold) { this.collapse(); return; } this.expand(); size = Math.max(size, collapseSize); } this._updateSize(Math.max(0, size)); } _onMouseUp(event) { event.preventDefault(); this.endResize(); } /** * Stop the resizing operation if it is currently active. */ endResize() { if (!this._dragStartInfo) { return; } let didStart = this._started; delete this._dragStartInfo; delete this._started; window.removeEventListener("mousemove", this); window.removeEventListener("mouseup", this); document.documentElement.style.pointerEvents = null; document.documentElement.style.cursor = null; this.classList.remove("splitter-resizing"); // Make sure our property corresponds to the actual final size. this._updateSize(); if (didStart) { this.dispatchEvent( new CustomEvent("splitter-resized", { bubbles: true }) ); } } } customElements.define("pane-splitter", PaneSplitter, { extends: "hr" }); }