diff options
Diffstat (limited to 'devtools/client/shared/components/splitter')
6 files changed, 731 insertions, 0 deletions
diff --git a/devtools/client/shared/components/splitter/Draggable.js b/devtools/client/shared/components/splitter/Draggable.js new file mode 100644 index 0000000000..3d18e49c34 --- /dev/null +++ b/devtools/client/shared/components/splitter/Draggable.js @@ -0,0 +1,106 @@ +/* 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/. */ + +"use strict"; + +const { + createRef, + Component, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); + +class Draggable extends Component { + static get propTypes() { + return { + onMove: PropTypes.func.isRequired, + onDoubleClick: PropTypes.func, + onStart: PropTypes.func, + onStop: PropTypes.func, + style: PropTypes.object, + title: PropTypes.string, + className: PropTypes.string, + }; + } + + constructor(props) { + super(props); + + this.draggableEl = createRef(); + + this.startDragging = this.startDragging.bind(this); + this.stopDragging = this.stopDragging.bind(this); + this.onDoubleClick = this.onDoubleClick.bind(this); + this.onMove = this.onMove.bind(this); + + this.mouseX = 0; + this.mouseY = 0; + } + startDragging(ev) { + const xDiff = Math.abs(this.mouseX - ev.clientX); + const yDiff = Math.abs(this.mouseY - ev.clientY); + + // This allows for double-click. + if (this.props.onDoubleClick && xDiff + yDiff <= 1) { + return; + } + this.mouseX = ev.clientX; + this.mouseY = ev.clientY; + + if (this.isDragging) { + return; + } + this.isDragging = true; + ev.preventDefault(); + + this.draggableEl.current.addEventListener("mousemove", this.onMove); + this.draggableEl.current.setPointerCapture(ev.pointerId); + + this.props.onStart && this.props.onStart(); + } + + onDoubleClick() { + if (this.props.onDoubleClick) { + this.props.onDoubleClick(); + } + } + + onMove(ev) { + if (!this.isDragging) { + return; + } + + ev.preventDefault(); + // Use viewport coordinates so, moving mouse over iframes + // doesn't mangle (relative) coordinates. + this.props.onMove(ev.clientX, ev.clientY); + } + + stopDragging(ev) { + if (!this.isDragging) { + return; + } + this.isDragging = false; + ev.preventDefault(); + + this.draggableEl.current.removeEventListener("mousemove", this.onMove); + this.draggableEl.current.releasePointerCapture(ev.pointerId); + this.props.onStop && this.props.onStop(); + } + + render() { + return dom.div({ + ref: this.draggableEl, + role: "presentation", + style: this.props.style, + title: this.props.title, + className: this.props.className, + onMouseDown: this.startDragging, + onMouseUp: this.stopDragging, + onDoubleClick: this.onDoubleClick, + }); + } +} + +module.exports = Draggable; diff --git a/devtools/client/shared/components/splitter/GridElementResizer.css b/devtools/client/shared/components/splitter/GridElementResizer.css new file mode 100644 index 0000000000..dfa69592e9 --- /dev/null +++ b/devtools/client/shared/components/splitter/GridElementResizer.css @@ -0,0 +1,32 @@ +/* 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/. */ + +.grid-element-width-resizer { + /* The space we'll have on each side of the "splitter border" */ + --inline-inset: 3px; + /* We use the --inline-inset value that we multiply by 2 and add 1px to center the splitter */ + width: calc(1px + (2 * var(--inline-inset))); + position: relative; + cursor: ew-resize; + z-index: 10; +} + +.grid-element-width-resizer.start { + justify-self: start; + inset-inline-start: calc(-1 * var(--inline-inset)); +} + +.grid-element-width-resizer.end { + justify-self: end; + inset-inline-start: var(--inline-inset); +} + +.dragging, +.dragging * { + /* When resizing, we keep the "resize" cursor on every element we might hover */ + cursor: ew-resize !important; + /* This prevents to trigger some :hover style and is better for performance + * when resizing */ + pointer-events: none !important; +} diff --git a/devtools/client/shared/components/splitter/GridElementWidthResizer.js b/devtools/client/shared/components/splitter/GridElementWidthResizer.js new file mode 100644 index 0000000000..c6ab6f3e14 --- /dev/null +++ b/devtools/client/shared/components/splitter/GridElementWidthResizer.js @@ -0,0 +1,138 @@ +/* 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/. */ + +"use strict"; + +const { + Component, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const Draggable = createFactory( + require("resource://devtools/client/shared/components/splitter/Draggable.js") +); + +class GridElementWidthResizer extends Component { + static get propTypes() { + return { + getControlledElementNode: PropTypes.func.isRequired, + enabled: PropTypes.bool, + position: PropTypes.string.isRequired, + className: PropTypes.string, + onResizeEnd: PropTypes.func, + }; + } + + constructor(props) { + super(props); + + this.onStartMove = this.onStartMove.bind(this); + this.onStopMove = this.onStopMove.bind(this); + this.onMove = this.onMove.bind(this); + this.state = { + dragging: false, + isRTLElement: false, + defaultCursor: null, + defaultWidth: null, + }; + } + + componentDidUpdate(prevProps) { + if (prevProps.enabled === true && this.props.enabled === false) { + this.onStopMove(); + const controlledElementNode = this.props.getControlledElementNode(); + controlledElementNode.style.width = this.state.defaultWidth; + } + } + + // Dragging Events + + /** + * Set 'resizing' cursor on entire document during splitter dragging. + * This avoids cursor-flickering that happens when the mouse leaves + * the splitter bar area (happens frequently). + */ + onStartMove() { + const controlledElementNode = this.props.getControlledElementNode(); + if (!controlledElementNode) { + return; + } + + const doc = controlledElementNode.ownerDocument; + const defaultCursor = doc.documentElement.style.cursor; + const defaultWidth = doc.documentElement.style.width; + doc.documentElement.style.cursor = "ew-resize"; + doc.firstElementChild.classList.add("dragging"); + + this.setState({ + dragging: true, + isRTLElement: + controlledElementNode.ownerDocument.defaultView.getComputedStyle( + controlledElementNode + ).direction === "rtl", + defaultCursor, + defaultWidth, + }); + } + + onStopMove() { + const controlledElementNode = this.props.getControlledElementNode(); + if (!this.state.dragging || !controlledElementNode) { + return; + } + const doc = controlledElementNode.ownerDocument; + doc.documentElement.style.cursor = this.state.defaultCursor; + doc.firstElementChild.classList.remove("dragging"); + + this.setState({ + dragging: false, + }); + + if (this.props.onResizeEnd) { + const { width } = controlledElementNode.getBoundingClientRect(); + this.props.onResizeEnd(width); + } + } + + /** + * Adjust size of the controlled panel. + */ + onMove(x) { + const controlledElementNode = this.props.getControlledElementNode(); + if (!this.state.dragging || !controlledElementNode) { + return; + } + const nodeBounds = controlledElementNode.getBoundingClientRect(); + const { isRTLElement } = this.state; + const { position } = this.props; + + const size = + (isRTLElement && position === "end") || + (!isRTLElement && position === "start") + ? nodeBounds.width + (nodeBounds.left - x) + : x - nodeBounds.left; + + controlledElementNode.style.width = `${size}px`; + } + + render() { + if (!this.props.enabled) { + return null; + } + + const classNames = ["grid-element-width-resizer", this.props.position]; + if (this.props.className) { + classNames.push(this.props.className); + } + + return Draggable({ + className: classNames.join(" "), + onStart: this.onStartMove, + onStop: this.onStopMove, + onMove: this.onMove, + }); + } +} + +module.exports = GridElementWidthResizer; diff --git a/devtools/client/shared/components/splitter/SplitBox.css b/devtools/client/shared/components/splitter/SplitBox.css new file mode 100644 index 0000000000..6028619e7b --- /dev/null +++ b/devtools/client/shared/components/splitter/SplitBox.css @@ -0,0 +1,93 @@ +/* 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/. */ + +.split-box { + display: flex; + flex: 1; + min-width: 0; + height: 100%; + width: 100%; +} + +.split-box.vert { + flex-direction: row; +} + +.split-box.horz { + flex-direction: column; +} + +.split-box > .uncontrolled { + display: flex; + flex: 1; + min-width: 0; + overflow: auto; +} + +.split-box > .controlled { + display: flex; + overflow: auto; +} + +.split-box > .splitter { + background-image: none; + border: 0; + border-style: solid; + border-color: transparent; + background-color: var(--theme-splitter-color); + background-clip: content-box; + position: relative; + + box-sizing: border-box; + + /* Positive z-index positions the splitter on top of its siblings and makes + it clickable on both sides. */ + z-index: 1; +} + +.split-box.vert > .splitter { + min-width: var(--devtools-vertical-splitter-min-width); + + border-inline-start-width: var(--devtools-splitter-inline-start-width); + border-inline-end-width: var(--devtools-splitter-inline-end-width); + + margin-inline-start: calc(-1 * var(--devtools-splitter-inline-start-width) - 1px); + margin-inline-end: calc(-1 * var(--devtools-splitter-inline-end-width)); + + cursor: ew-resize; +} + +.split-box.horz > .splitter { + /* Emphasize the horizontal splitter width and color */ + min-height: var(--devtools-emphasized-horizontal-splitter-min-height); + + background-color: var(--theme-emphasized-splitter-color); + + border-top-width: var(--devtools-splitter-top-width); + border-bottom-width: var(--devtools-splitter-bottom-width); + + margin-top: calc(-1 * var(--devtools-splitter-top-width) - 1px); + margin-bottom: calc(-1 * var(--devtools-splitter-bottom-width)); + + cursor: ns-resize; +} + +/* Emphasized splitter has the hover style. */ +.split-box.horz > .splitter:hover { + background-color: var(--theme-emphasized-splitter-color-hover); +} + +.split-box.disabled { + pointer-events: none; +} + +/** + * Make sure splitter panels are not processing any mouse + * events. This is good for performance during splitter + * bar dragging. + */ +.split-box.dragging > .controlled, +.split-box.dragging > .uncontrolled { + pointer-events: none; +} diff --git a/devtools/client/shared/components/splitter/SplitBox.js b/devtools/client/shared/components/splitter/SplitBox.js new file mode 100644 index 0000000000..2bf0fdb74d --- /dev/null +++ b/devtools/client/shared/components/splitter/SplitBox.js @@ -0,0 +1,351 @@ +/* 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/. */ + +"use strict"; + +const { + Component, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); + +const Draggable = createFactory( + require("resource://devtools/client/shared/components/splitter/Draggable.js") +); + +/** + * This component represents a Splitter. The splitter supports vertical + * as well as horizontal mode. + */ +class SplitBox extends Component { + static get propTypes() { + return { + // Custom class name. You can use more names separated by a space. + className: PropTypes.string, + // Initial size of controlled panel. + initialSize: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + // Initial width of controlled panel. + initialWidth: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + // Initial height of controlled panel. + initialHeight: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + // Left/top panel + startPanel: PropTypes.any, + // Left/top panel collapse state. + startPanelCollapsed: PropTypes.bool, + // Min panel size. + minSize: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + // Max panel size. + maxSize: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + // Right/bottom panel + endPanel: PropTypes.any, + // Right/bottom panel collapse state. + endPanelCollapsed: PropTypes.bool, + // True if the right/bottom panel should be controlled. + endPanelControl: PropTypes.bool, + // Size of the splitter handle bar. + splitterSize: PropTypes.number, + // True if the splitter bar is vertical (default is vertical). + vert: PropTypes.bool, + // Style object. + style: PropTypes.object, + // Call when controlled panel was resized. + onControlledPanelResized: PropTypes.func, + // Optional callback when splitbox resize stops + onResizeEnd: PropTypes.func, + // Retrieve DOM reference to the start panel element + onSelectContainerElement: PropTypes.any, + }; + } + + static get defaultProps() { + return { + splitterSize: 5, + vert: true, + endPanelControl: false, + }; + } + + constructor(props) { + super(props); + + /** + * The state stores whether or not the end panel should be controlled, the current + * orientation (vertical or horizontal), the splitter size, and the current size + * (width/height). All these values can change during the component's life time. + */ + this.state = { + // True if the right/bottom panel should be controlled. + endPanelControl: props.endPanelControl, + // True if the splitter bar is vertical (default is vertical). + vert: props.vert, + // Size of the splitter handle bar. + splitterSize: props.splitterSize, + // Width of controlled panel. + width: props.initialWidth || props.initialSize, + // Height of controlled panel. + height: props.initialHeight || props.initialSize, + }; + + this.onStartMove = this.onStartMove.bind(this); + this.onStopMove = this.onStopMove.bind(this); + this.onMove = this.onMove.bind(this); + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillReceiveProps(nextProps) { + const { endPanelControl, splitterSize, vert } = nextProps; + + if (endPanelControl != this.props.endPanelControl) { + this.setState({ endPanelControl }); + } + + if (splitterSize != this.props.splitterSize) { + this.setState({ splitterSize }); + } + + if (vert !== this.props.vert) { + this.setState({ vert }); + } + } + + shouldComponentUpdate(nextProps, nextState) { + return ( + nextState.width != this.state.width || + nextState.endPanelControl != this.props.endPanelControl || + nextState.height != this.state.height || + nextState.vert != this.state.vert || + nextState.splitterSize != this.state.splitterSize || + nextProps.startPanel != this.props.startPanel || + nextProps.endPanel != this.props.endPanel || + nextProps.minSize != this.props.minSize || + nextProps.maxSize != this.props.maxSize + ); + } + + componentDidUpdate(prevProps, prevState) { + if ( + this.props.onControlledPanelResized && + (prevState.width !== this.state.width || + prevState.height !== this.state.height) + ) { + this.props.onControlledPanelResized(this.state.width, this.state.height); + } + } + + // Dragging Events + + /** + * Set 'resizing' cursor on entire document during splitter dragging. + * This avoids cursor-flickering that happens when the mouse leaves + * the splitter bar area (happens frequently). + */ + onStartMove() { + const doc = this.splitBox.ownerDocument; + const defaultCursor = doc.documentElement.style.cursor; + doc.documentElement.style.cursor = this.state.vert + ? "ew-resize" + : "ns-resize"; + + this.splitBox.classList.add("dragging"); + + this.setState({ + defaultCursor, + }); + } + + onStopMove() { + const doc = this.splitBox.ownerDocument; + doc.documentElement.style.cursor = this.state.defaultCursor; + + this.splitBox.classList.remove("dragging"); + + if (this.props.onResizeEnd) { + this.props.onResizeEnd( + this.state.vert ? this.state.width : this.state.height + ); + } + } + + /** + * Adjust size of the controlled panel. Depending on the current + * orientation we either remember the width or height of + * the splitter box. + */ + onMove(x, y) { + const nodeBounds = this.splitBox.getBoundingClientRect(); + + let size; + let { endPanelControl, vert } = this.state; + + if (vert) { + // Use the document owning the SplitBox to detect rtl. The global document might be + // the one bound to the toolbox shared BrowserRequire, which is irrelevant here. + const doc = this.splitBox.ownerDocument; + + // Switch the control flag in case of RTL. Note that RTL + // has impact on vertical splitter only. + if (doc.dir === "rtl") { + endPanelControl = !endPanelControl; + } + + size = endPanelControl + ? nodeBounds.left + nodeBounds.width - x + : x - nodeBounds.left; + + this.setState({ + width: this.getConstrainedSizeInPx(size, nodeBounds.width), + }); + } else { + size = endPanelControl + ? nodeBounds.top + nodeBounds.height - y + : y - nodeBounds.top; + + this.setState({ + height: this.getConstrainedSizeInPx(size, nodeBounds.height), + }); + } + } + + /** + * Calculates the constrained size taking into account the minimum width or + * height passed via this.props.minSize. + * + * @param {Number} requestedSize + * The requested size + * @param {Number} splitBoxWidthOrHeight + * The width or height of the splitBox + * + * @return {Number} + * The constrained size + */ + getConstrainedSizeInPx(requestedSize, splitBoxWidthOrHeight) { + let minSize = this.props.minSize + ""; + + if (minSize.endsWith("%")) { + minSize = (parseFloat(minSize) / 100) * splitBoxWidthOrHeight; + } else if (minSize.endsWith("px")) { + minSize = parseFloat(minSize); + } + return Math.max(requestedSize, minSize); + } + + // Rendering + + // eslint-disable-next-line complexity + render() { + const { endPanelControl, splitterSize, vert } = this.state; + const { + startPanel, + startPanelCollapsed, + endPanel, + endPanelCollapsed, + minSize, + maxSize, + onSelectContainerElement, + } = this.props; + + const style = Object.assign( + { + // Set the size of the controlled panel (height or width depending on the + // current state). This can be used to help with styling of dependent + // panels. + "--split-box-controlled-panel-size": `${ + vert ? this.state.width : this.state.height + }`, + }, + this.props.style + ); + + // Calculate class names list. + let classNames = ["split-box"]; + classNames.push(vert ? "vert" : "horz"); + if (this.props.className) { + classNames = classNames.concat(this.props.className.split(" ")); + } + + let leftPanelStyle; + let rightPanelStyle; + + // Set proper size for panels depending on the current state. + if (vert) { + leftPanelStyle = { + maxWidth: endPanelControl ? null : maxSize, + minWidth: endPanelControl ? null : minSize, + width: endPanelControl ? null : this.state.width, + }; + rightPanelStyle = { + maxWidth: endPanelControl ? maxSize : null, + minWidth: endPanelControl ? minSize : null, + width: endPanelControl ? this.state.width : null, + }; + } else { + leftPanelStyle = { + maxHeight: endPanelControl ? null : maxSize, + minHeight: endPanelControl ? null : minSize, + height: endPanelControl ? null : this.state.height, + }; + rightPanelStyle = { + maxHeight: endPanelControl ? maxSize : null, + minHeight: endPanelControl ? minSize : null, + height: endPanelControl ? this.state.height : null, + }; + } + + // Calculate splitter size + const splitterStyle = { + flex: "0 0 " + splitterSize + "px", + }; + + return dom.div( + { + className: classNames.join(" "), + ref: div => { + this.splitBox = div; + }, + style, + }, + startPanel && !startPanelCollapsed + ? dom.div( + { + className: endPanelControl ? "uncontrolled" : "controlled", + style: leftPanelStyle, + role: "presentation", + ref: div => { + this.startPanelContainer = div; + if (onSelectContainerElement) { + onSelectContainerElement(div); + } + }, + }, + startPanel + ) + : null, + splitterSize > 0 + ? Draggable({ + className: "splitter", + style: splitterStyle, + onStart: this.onStartMove, + onStop: this.onStopMove, + onMove: this.onMove, + }) + : null, + endPanel && !endPanelCollapsed + ? dom.div( + { + className: endPanelControl ? "controlled" : "uncontrolled", + style: rightPanelStyle, + role: "presentation", + ref: div => { + this.endPanelContainer = div; + }, + }, + endPanel + ) + : null + ); + } +} + +module.exports = SplitBox; diff --git a/devtools/client/shared/components/splitter/moz.build b/devtools/client/shared/components/splitter/moz.build new file mode 100644 index 0000000000..4abe762b34 --- /dev/null +++ b/devtools/client/shared/components/splitter/moz.build @@ -0,0 +1,11 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +DevToolsModules( + "Draggable.js", + "GridElementWidthResizer.js", + "SplitBox.js", +) |