/* 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/. */ const link = document.createElement("link"); link.rel = "stylesheet"; link.href = "chrome://messenger/skin/pane-splitter.css"; document.head.appendChild(link); /** * 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. * * If the splitter is in a flexible sized container it can be resized with the * application with the "resize-with-window" option. If the splitter is in a * container with another flexible size element, the "resize-lock-ids" option * can be used to lock the size of other elements while resizing. */ class PaneSplitter extends HTMLHRElement { static observedAttributes = [ "resize-direction", "resize-id", "resize-with-window", "resize-lock-ids", "disabled", "id", ]; /** * Whether this splitter is currently being resized by another splitter. * * @type {boolean} */ #externalResizing = false; /** * The parent element currently used for resize-with-window listeners. * * @type {?HTMLElement} */ #resizeWithWindowParent = null; /** * Elements temporarily locked to their current size while this splitter is * resizing with the window. * * @type {Map} */ #lockedElements = new Map(); /** * Elements to keep locked while this splitter is being dragged. * * @type {Set} */ #lockedElementsToPreserve = new Set(); /** * The requestAnimationFrame handle for a pending lock retry. * * @type {number} */ #lockRetryRequest = 0; /** * The number of consecutive lock retry attempts. * * @type {number} */ #lockRetryCount = 0; /** * Whether a restored resize-with-window size is waiting for layout to settle. * * @type {boolean} */ #pendingResizeWithWindowRestore = false; /** * The window currently observed while waiting for a restored size to settle. * * @type {?Window} */ #resizeWithWindowRestoreWindow = null; /** * Whether the restored size should be forced when expanding. * * @type {boolean} */ #forceSizeOnNextExpand = false; /** * Whether a resize-with-window restore should be skipped while disabling * resize-with-window behavior. * * @type {boolean} */ #skipResizeWithWindowRestore = false; /** * The arrow key that began an in-progress keyboard resize. * * @type {?string} */ #keyResizeKey = null; connectedCallback() { this.addEventListener("mousedown", this); this.addEventListener("keydown", this); this.addEventListener("keyup", this); this.addEventListener("blur", this); this.setAttribute("role", "separator"); // Try and find the _resizeElement from the resize-id attribute. this._updateResizeElement(); this._updateStyling(); this.#updateAccessibility(); this.#updateResizeWithWindow(); } disconnectedCallback() { this.#removeResizeWithWindowListeners(); this.#updateLockElements({ lock: false }); } attributeChangedCallback(attrName) { switch (attrName) { case "resize-direction": this._updateResizeDirection(); break; case "resize-id": this._updateResizeElement(); break; case "resize-with-window": this.#updateResizeWithWindow(); break; case "resize-lock-ids": this.#updateLockElements({ lock: this.#resizeWithWindowActive && !this.isCollapsed, }); break; case "disabled": this.#updateAccessibility(); 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); } /** * The css property corresponding to the resize-direction. * * @type {"height"|"width"} */ get #resizeProperty() { return this.resizeDirection === "vertical" ? "height" : "width"; } /** * If the splitter should resize the element with the window * This corresponds to the "resize-with-window" attribute and defaults to * "false" when none is given. * * @type {boolean} */ get resizeWithWindow() { return this.hasAttribute("resize-with-window"); } set resizeWithWindow(val) { this.toggleAttribute("resize-with-window", val); } /** * Whether this splitter currently has a preferred size to preserve while * resizing with the window. * * @type {boolean} */ get #resizeWithWindowActive() { return this.resizeWithWindow && this[this.#resizeProperty] != null; } /** * Lock or unlock the size of elements specified by the resize-lock-ids * attribute. * * @param {object} options * @param {boolean} options.lock - Whether the elements should be locked. * @param {Set} [options.keepLockedElements] - Elements that * should remain locked when unlocking. * @param {boolean} [options.retry=true] - Whether to retry if an element * can't be locked yet. * @returns {boolean} Whether all elements were successfully locked. */ #updateLockElements({ lock, keepLockedElements = new Set(), retry = true }) { if (!this.#pendingResizeWithWindowRestore || !lock) { this.#cancelLockRetry(); } if (!lock) { this.#unlockElements({ keepLockedElements }); if (!keepLockedElements.size) { this.#lockedElementsToPreserve.clear(); } this.#lockRetryCount = 0; this.#pendingResizeWithWindowRestore = false; this.#removeResizeWithWindowRestoreListener(); return true; } const ids = this.getAttribute("resize-lock-ids") ?.split(",") .map(id => id.trim()) .filter(Boolean) ?? []; let needsRetry = false; const oldLockedElements = new Map(this.#lockedElements); const lockedElements = new Map(); const sizes = []; this.#lockedElements.clear(); const idSet = new Set(ids); for (const [element, property] of oldLockedElements) { if (property == this.#resizeProperty && idSet.has(element.id)) { continue; } element.style.removeProperty(property); oldLockedElements.delete(element); } for (const id of ids) { const element = this.ownerDocument.getElementById(id); if (!element) { needsRetry = true; continue; } const size = this.#getElementSize(element); if (!Number.isFinite(size) || size <= 0) { needsRetry = true; continue; } sizes.push([element, size]); } for (const [element, size] of sizes) { element.style.setProperty(this.#resizeProperty, `${size}px`, "important"); lockedElements.set(element, this.#resizeProperty); oldLockedElements.delete(element); } for (const [element, property] of oldLockedElements) { element.style.removeProperty(property); } this.#lockedElements = lockedElements; if (retry && needsRetry) { this.#scheduleLockRetry(); } if (!needsRetry) { this.#lockRetryCount = 0; } return !needsRetry; } /** * Lock other elements at their current size, then make this splitter's * element take future resize changes. * * @param {object} [options] * @param {boolean} [options.retry=true] - Whether to retry if an element * can't be locked yet. * @returns {boolean} Whether all elements were successfully locked. */ #enableResizeWithWindow({ retry = true } = {}) { const shouldResizeWithWindow = this.#resizeWithWindowActive && !this.isCollapsed; const locked = this.#updateLockElements({ lock: shouldResizeWithWindow, retry, }); if (locked && shouldResizeWithWindow) { this.#setSize("100%"); } return locked; } /** * Unlock the size of any elements locked by this splitter. * * @param {object} [options] * @param {Set} [options.keepLockedElements] - Elements that * should remain locked. */ #unlockElements({ keepLockedElements = new Set() } = {}) { for (const [element, property] of this.#lockedElements) { if (keepLockedElements.has(element)) { continue; } element.style.removeProperty(property); this.#lockedElements.delete(element); } } /** * Get lock targets that should stay locked while this splitter is dragged. * * @returns {Set} Elements that should stay locked. */ #getLockedElementsToPreserve() { const lockedElementsToPreserve = new Set(); if (this.resizeDirection != "horizontal") { return lockedElementsToPreserve; } const unlockedElement = this._beforeElement ? this.previousElementSibling : this.nextElementSibling; for (const element of this.#lockedElements.keys()) { if (element != unlockedElement) { lockedElementsToPreserve.add(element); } } return lockedElementsToPreserve; } /** * Retry locking elements after layout has had a chance to settle. */ #scheduleLockRetry() { const win = this.ownerDocument.documentGlobal; if (!win || !this.isConnected) { this.#lockRetryCount = 0; this.#pendingResizeWithWindowRestore = false; this.#removeResizeWithWindowRestoreListener(); return; } if (!this.#pendingResizeWithWindowRestore && this.#lockRetryCount >= 60) { this.#lockRetryCount = 0; return; } if (this.#lockRetryRequest) { return; } this.#lockRetryRequest = win.requestAnimationFrame(() => { this.#lockRetryRequest = 0; this.#lockRetryCount++; if (this.#pendingResizeWithWindowRestore) { this.#finishResizeWithWindowRestore(); return; } if (!this.#enableResizeWithWindow({ retry: false })) { this.#scheduleLockRetry(); } }); } /** * Cancel any pending retry of lock element sizing. */ #cancelLockRetry() { if (!this.#lockRetryRequest) { return; } this.ownerDocument.documentGlobal.cancelAnimationFrame( this.#lockRetryRequest ); this.#lockRetryRequest = 0; } #addResizeWithWindowRestoreListener() { const win = this.ownerDocument.documentGlobal; if (!win || this.#resizeWithWindowRestoreWindow == win) { return; } this.#removeResizeWithWindowRestoreListener(); win.addEventListener("resize", this); this.#resizeWithWindowRestoreWindow = win; } #removeResizeWithWindowRestoreListener() { if (!this.#resizeWithWindowRestoreWindow) { return; } this.#resizeWithWindowRestoreWindow.removeEventListener("resize", this); this.#resizeWithWindowRestoreWindow = null; } /** * Handles changes to the resize-with-window attribute. */ #updateResizeWithWindow() { const val = this.resizeWithWindow; if (!this.parentNode || !val) { this.#removeResizeWithWindowListeners(); this.#updateLockElements({ lock: false }); if (!val) { this._updateStyling(true); } return; } if (this.#resizeWithWindowParent != this.parentNode) { this.#removeResizeWithWindowListeners(); this.#resizeWithWindowParent = this.parentNode; this.#resizeWithWindowParent.addEventListener( "splitter-before-resize", this ); this.#resizeWithWindowParent.addEventListener( "splitter-resize-end", this ); this.ownerDocument.addEventListener("visibilitychange", this); } this._updateStyling(); if (this.isCollapsed) { this.#updateLockElements({ lock: false }); } else if (this.#resizeWithWindowActive) { this.#finishResizeWithWindowRestore(); } else { this.#updateLockElements({ lock: false }); } } #removeResizeWithWindowListeners() { this.#resizeWithWindowParent?.removeEventListener( "splitter-before-resize", this ); this.#resizeWithWindowParent?.removeEventListener( "splitter-resize-end", this ); this.ownerDocument.removeEventListener("visibilitychange", this); this.#resizeWithWindowParent = null; } _updateResizeDirection() { // The resize direction has changed. To be safe, make sure we're no longer // resizing. this.endResize(); this.#updateAccessibility(); const forceSize = this.resizeWithWindow && !this.isCollapsed && this[this.#resizeProperty] != null; this._updateStyling(forceSize); if (forceSize) { this.#finishResizeWithWindowRestore(); } else { this.#updateLockElements({ lock: this.#resizeWithWindowActive && !this.isCollapsed, }); } } _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"); } this.#updateResizeWithWindow(); } /** * 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. const 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(); this.#updateAccessibility(); this.#updateResizeWithWindow(); } _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) { this.#setDimension("width", width); } _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) { this.#setDimension("height", height); } /** * Update a preferred size dimension. * * @param {"width"|"height"} property - The property to update. * @param {?number|string} size - The preferred size. */ #setDimension(property, size) { size = this.#normalizeSize(size); const field = `_${property}`; if (size == this[field]) { return; } this[field] = size; this.#updateAccessibility(); const forceSize = this.resizeWithWindow && size != null; const isResizing = !!this._dragStartInfo; if (forceSize && this.isCollapsed) { this.#forceSizeOnNextExpand = true; } this._updateStyling(forceSize); if ( forceSize && !this.#skipResizeWithWindowRestore && !this.isCollapsed && !isResizing ) { this.#finishResizeWithWindowRestore(); } else if ( this.#resizeWithWindowActive && !this.isCollapsed && !isResizing ) { this.#enableResizeWithWindow(); } else if ( this.resizeWithWindow && property == this.#resizeProperty && size == null ) { this.#updateLockElements({ lock: false }); } } /** * Convert persisted or assigned size values to numbers. * * @param {?number|string} size - The size to normalize. * @returns {?number} */ #normalizeSize(size) { if (size === null || size === undefined || size === "") { return null; } const number = Number(size); return Number.isFinite(number) ? number : null; } /** * 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) { if (trySize != undefined) { this[this.#resizeProperty] = Math.round(trySize); } // Now that the width or height has been updated, fetch the size the // element actually took after CSS layout constraints were applied. this[this.#resizeProperty] = this.#getElementSize(this.resizeElement); this.#updateAccessibility(); } /** * Update the accessible separator state. */ #updateAccessibility() { const size = this.isCollapsed ? 0 : this.#getElementSize(this._resizeElement); const resizesOwnContainer = this.resizeElement == this.parentNode; this.tabIndex = this.resizeElement && !this.isDisabled && !resizesOwnContainer ? 0 : -1; // aria-orientation describes the separator's axis, not the resized axis. this.setAttribute( "aria-orientation", this.resizeDirection == "horizontal" ? "vertical" : "horizontal" ); if (this.isDisabled) { this.setAttribute("aria-disabled", "true"); } else { this.removeAttribute("aria-disabled"); } if (this._resizeElement?.id) { this.setAttribute("aria-controls", this._resizeElement.id); } else { this.removeAttribute("aria-controls"); } this.setAttribute("aria-valuemin", "0"); if (Number.isFinite(size)) { this.setAttribute("aria-valuenow", Math.round(size)); } else { this.removeAttribute("aria-valuenow"); } } /** * Get the actual size of an element, 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. * * @param {HTMLElement} element - the element to get the size of * @returns {number} - The border area size of the resizeElement. */ #getElementSize(element) { return element?.getBoundingClientRect()[this.#resizeProperty]; } /** * Collapses the controlled pane. */ collapse() { this.#toggleCollapse(true); } /** * Expands the controlled pane. */ expand() { this.#toggleCollapse(false); } /** * toggle the collapsed status of the pane. When expanding it returns to the * width or height it had when collapsed and fires a "splitter-expanded" * event. Collapsing does not affect the `width` or `height` properties and * fires a "splitter-collapsed" event. * * @param {boolean} collapse - If the element should be collapsed. */ #toggleCollapse(collapse) { if (collapse == this._isCollapsed) { return; } if (collapse && this.#resizeWithWindowActive) { this.#forceSizeOnNextExpand = true; } const forceSize = !collapse && this.#forceSizeOnNextExpand; this._isCollapsed = collapse; this._updateStyling(forceSize); this.#updateAccessibility(); if (forceSize) { this.#finishResizeWithWindowRestore(); } else if (this.#resizeWithWindowActive && !this.isCollapsed) { this.#enableResizeWithWindow(); } else { this.#updateLockElements({ lock: this.#resizeWithWindowActive && !this.isCollapsed, }); } this._updateDragCursor(); this.dispatchEvent( new CustomEvent(`splitter-${collapse ? "collapsed" : "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) { this.toggleAttribute("disabled", !!disabled); } _cssName = null; /** * Update styling to reflect the current state. * * @param {boolean} forceSize - Whether to force the updated size. */ _updateStyling(forceSize) { 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`, }; } const vertical = this.resizeDirection == "vertical"; const size = this.isCollapsed ? 0 : this[this.#resizeProperty]; if (size == null) { this.parentNode.style.removeProperty(this._cssName[this.#resizeProperty]); } this.parentNode.style.removeProperty( this._cssName[vertical ? "width" : "height"] ); if (typeof size === "number" || this.isCollapsed) { this.#setSize( this.isCollapsed || this._started || !this.resizeWithWindow || this.#externalResizing || forceSize ? `${size}px` : "100%" ); } this.resizeElement.classList.toggle( "collapsed-by-splitter", this.isCollapsed ); this.classList.toggle("splitter-collapsed", this.isCollapsed); this.classList.toggle("splitter-before", this._beforeElement); } /** * Set the size of the `resizeElement`. * * @param {string} size A size to set as the css variable value. */ #setSize(size) { if (this.resizeElement) { this.parentNode.style.setProperty( this._cssName[this.#resizeProperty], size ); } } handleEvent(event) { switch (event.type) { case "mousedown": this._onMouseDown(event); break; case "mousemove": this._onMouseMove(event); break; case "mouseup": this._onMouseUp(event); break; case "keydown": this._onKeyDown(event); break; case "keyup": case "blur": this._onKeyUpOrBlur(event); break; case "splitter-before-resize": case "splitter-resize-end": if ( event.target !== this && (this.#resizeWithWindowActive || this.#externalResizing) ) { if (event.type == "splitter-before-resize") { this.#lockedElementsToPreserve.clear(); } this.#externalResizing = event.type !== "splitter-resize-end"; this.#toggleWindowResizing(event.type === "splitter-resize-end"); } break; case "visibilitychange": if (this.#pendingResizeWithWindowRestore) { this.#lockRetryCount = 0; this.#scheduleLockRetry(); } break; case "resize": if (this.#pendingResizeWithWindowRestore) { this.#lockRetryCount = 0; this.#scheduleLockRetry(); } break; } } /** * Toggle the elements should currently resize with the window. * * @param {boolean} enable - Whether resizing with the window should be * enabled. */ #toggleWindowResizing(enable) { if (!enable) { this.#skipResizeWithWindowRestore = true; try { this[this.#resizeProperty] = this.#getElementSize(this._resizeElement); } finally { this.#skipResizeWithWindowRestore = false; } this._updateStyling(true); this.#updateLockElements({ lock: false, keepLockedElements: this.#lockedElementsToPreserve, }); } else { if (this[this.#resizeProperty] != null) { this.#finishResizeWithWindowRestore(); return; } this.#forceSizeOnNextExpand = false; if (this.isCollapsed) { this.#setSize("0px"); this.#updateLockElements({ lock: false }); } else { this.#enableResizeWithWindow(); } } } /** * Preserve a restored size while keeping resize-with-window behavior enabled. */ #finishResizeWithWindowRestore() { this.#forceSizeOnNextExpand = false; this.#unlockElements({ keepLockedElements: this.#lockedElementsToPreserve, }); this._updateStyling(true); this.#pendingResizeWithWindowRestore = true; this.#addResizeWithWindowRestoreListener(); // During startup, persisted pane sizes are applied while the document is // still loading and before the outer window has settled on its final size. if ( this.ownerDocument.readyState != "complete" && this.#lockRetryCount < 60 ) { this.#scheduleLockRetry(); return; } const size = this[this.#resizeProperty]; const actualSize = this.#getElementSize(this._resizeElement); if ( !this.isCollapsed && size != null && (!Number.isFinite(actualSize) || Math.abs(actualSize - size) > 1) && this.#lockRetryCount < 60 ) { this.#scheduleLockRetry(); return; } if ( !this.isCollapsed && size != null && (!Number.isFinite(actualSize) || Math.abs(actualSize - size) > 1) ) { return; } const lockSucceeded = this.#updateLockElements({ lock: !this.isCollapsed, retry: false, }); if (!lockSucceeded && this.#lockRetryCount < 60) { this.#scheduleLockRetry(); return; } if (!lockSucceeded) { return; } this.#pendingResizeWithWindowRestore = false; this.#cancelLockRetry(); this.#removeResizeWithWindowRestoreListener(); this.#lockRetryCount = 0; this.#lockedElementsToPreserve.clear(); this.#setSize(this.isCollapsed ? "0px" : "100%"); } /** * Handles mousedown events on the splitter element. * * @param {MouseEvent} event * @returns {void} */ _onMouseDown(event) { if (!this.resizeElement || this.isDisabled) { return; } if (event.buttons != 1) { return; } this.dispatchEvent( new CustomEvent("splitter-before-resize", { bubbles: true }) ); if (this.resizeWithWindow) { this.#updateLockElements({ lock: true, retry: false }); this.#lockedElementsToPreserve = this.#getLockedElementsToPreserve(); this.#toggleWindowResizing(false); } const vertical = this.resizeDirection == "vertical"; const collapseSize = Number(this.getAttribute(`collapse-${this.#resizeProperty}`)) || 0; const 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.#getElementSize(this._resizeElement), 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"); } /** * Handles keyboard resizing on the splitter. * * @param {KeyboardEvent} event */ _onKeyDown(event) { if ( !this.resizeElement || this.isDisabled || event.altKey || event.metaKey ) { return; } const vertical = this.resizeDirection == "vertical"; const arrowKeys = vertical ? ["ArrowUp", "ArrowDown"] : ["ArrowLeft", "ArrowRight"]; if (!arrowKeys.includes(event.key)) { return; } const ltrDir = this.parentNode.matches(":dir(ltr)"); const negative = vertical ? this._beforeElement : this._beforeElement == ltrDir; const coordinateDelta = event.key == "ArrowDown" || event.key == "ArrowRight" ? 1 : -1; const sizeDelta = coordinateDelta * (negative ? -1 : 1) * (event.shiftKey ? 50 : 10); const size = this.#getElementSize(this._resizeElement); if (!Number.isFinite(size)) { return; } event.preventDefault(); if (!this.#keyResizeKey) { this.#keyResizeKey = event.key; this.dispatchEvent( new CustomEvent("splitter-before-resize", { bubbles: true }) ); this.dispatchEvent( new CustomEvent("splitter-resizing", { bubbles: true }) ); } const maxSize = this.resizeWithWindow || this.parentNode == this.resizeElement ? null : this.#getElementSize(this.parentNode); this._updateSize( Math.max(0, Math.min(maxSize ?? Infinity, size + sizeDelta)) ); } /** * Completes keyboard resizing when its initiating key is released or the * splitter loses focus. * * @param {KeyboardEvent|FocusEvent} event */ _onKeyUpOrBlur(event) { if ( !this.#keyResizeKey || (event.type == "keyup" && event.key != this.#keyResizeKey) ) { return; } this.dispatchEvent( new CustomEvent("splitter-resize-end", { bubbles: true }) ); // Make sure our property corresponds to the actual final size. this._updateSize(); this.#keyResizeKey = null; this.dispatchEvent(new CustomEvent("splitter-resized", { bubbles: true })); } _updateDragCursor() { if (!this._dragStartInfo) { return; } let cursor; const { 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; /** * Handles mouse move events on the splitter, resizing and or * collapsing the resize-element as needed. * * @param {MouseEvent} event */ _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 }) ); } // Some splitters live inside the element they resize, e.g. table column // headers. In that layout the parent is not the available container. const maxSize = this.resizeWithWindow || this.parentNode == this.resizeElement ? null : this.#getElementSize(this.parentNode); 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); } size = Math.min(size, maxSize ?? size); this._updateSize(Math.max(0, size)); } /** * Handles mouseup on the splitter. * * @param {MouseEvent} event */ _onMouseUp(event) { event.preventDefault(); this.endResize(); } /** * Stop the resizing operation if it is currently active. * */ endResize() { if (!this._dragStartInfo) { return; } this.dispatchEvent( new CustomEvent("splitter-resize-end", { bubbles: true }) ); // Make sure our property corresponds to the actual final size. this._updateSize(); const didStart = this._started; delete this._dragStartInfo; delete this._started; if (this.resizeWithWindow) { this.#toggleWindowResizing(true); } window.removeEventListener("mousemove", this); window.removeEventListener("mouseup", this); document.documentElement.style.pointerEvents = null; document.documentElement.style.cursor = null; this.classList.remove("splitter-resizing"); if (didStart) { this.dispatchEvent( new CustomEvent("splitter-resized", { bubbles: true }) ); } } } customElements.define("pane-splitter", PaneSplitter, { extends: "hr" });