Popover.js (8146B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ 4 5 import React, { Component } from "devtools/client/shared/vendor/react"; 6 import { div } from "devtools/client/shared/vendor/react-dom-factories"; 7 import PropTypes from "devtools/client/shared/vendor/react-prop-types"; 8 import BracketArrow from "./BracketArrow"; 9 10 const classnames = require("resource://devtools/client/shared/classnames.js"); 11 12 class Popover extends Component { 13 state = { 14 coords: { 15 left: 0, 16 top: 0, 17 orientation: "down", 18 targetMid: { x: 0, y: 0 }, 19 }, 20 }; 21 firstRender = true; 22 23 static defaultProps = { 24 type: "popover", 25 }; 26 27 static get propTypes() { 28 return { 29 children: PropTypes.node.isRequired, 30 editorRef: PropTypes.object.isRequired, 31 mouseout: PropTypes.func.isRequired, 32 target: PropTypes.object.isRequired, 33 targetPosition: PropTypes.object.isRequired, 34 type: PropTypes.string.isRequired, 35 }; 36 } 37 38 componentDidMount() { 39 const { type } = this.props; 40 this.gapHeight = this.$gap.getBoundingClientRect().height; 41 const coords = 42 type == "popover" ? this.getPopoverCoords() : this.getTooltipCoords(); 43 44 if (coords) { 45 this.setState({ coords }); 46 } 47 48 this.firstRender = false; 49 this.startTimer(); 50 } 51 52 componentDidUpdate(prevProps) { 53 // We have to update `coords` when the Popover type changes 54 if ( 55 prevProps.type != this.props.type || 56 prevProps.target !== this.props.target 57 ) { 58 const coords = 59 this.props.type == "popover" 60 ? this.getPopoverCoords() 61 : this.getTooltipCoords(); 62 63 if (coords) { 64 this.setState({ coords }); 65 } 66 } 67 } 68 69 componentWillUnmount() { 70 if (this.timerId) { 71 clearTimeout(this.timerId); 72 } 73 } 74 75 startTimer() { 76 this.timerId = setTimeout(this.onTimeout, 0); 77 } 78 79 onTimeout = () => { 80 const isHoveredOnGap = this.$gap && this.$gap.matches(":hover"); 81 const isHoveredOnPopover = this.$popover && this.$popover.matches(":hover"); 82 const isHoveredOnTooltip = this.$tooltip && this.$tooltip.matches(":hover"); 83 const isHoveredOnTarget = this.props.target.matches(":hover"); 84 85 // Don't clear the current preview if mouse is hovered on: 86 // - popover or tooltip (depending on the preview type we either have a PopOver or a Tooltip) 87 // - target, which is the highlighted token in CodeMirror 88 if (isHoveredOnPopover || isHoveredOnTooltip || isHoveredOnTarget) { 89 this.timerId = setTimeout(this.onTimeout, 0); 90 return; 91 } 92 93 // If we are only hovering the "gap", i.e. the extra space where the arrow pointing 94 // to the highlighted token is, hide the popup with an extra timeout 95 if (isHoveredOnGap) { 96 this.timerId = setTimeout(this.onTimeout, 200); 97 return; 98 } 99 100 this.props.mouseout(); 101 }; 102 103 calculateLeft(target, editor, popover, orientation) { 104 const estimatedLeft = target.left; 105 const estimatedRight = estimatedLeft + popover.width; 106 const isOverflowingRight = estimatedRight > editor.right; 107 if (orientation === "right") { 108 return target.left + target.width; 109 } 110 if (isOverflowingRight) { 111 const adjustedLeft = editor.right - popover.width - 8; 112 return adjustedLeft; 113 } 114 return estimatedLeft; 115 } 116 117 calculateTopForRightOrientation = (target, editor, popover) => { 118 if (popover.height <= editor.height) { 119 const rightOrientationTop = target.top - popover.height / 2; 120 if (rightOrientationTop < editor.top) { 121 return editor.top - target.height; 122 } 123 const rightOrientationBottom = rightOrientationTop + popover.height; 124 if (rightOrientationBottom > editor.bottom) { 125 return editor.bottom + target.height - popover.height + this.gapHeight; 126 } 127 return rightOrientationTop; 128 } 129 return editor.top - target.height; 130 }; 131 132 calculateOrientation(target, editor, popover) { 133 const estimatedBottom = target.bottom + popover.height; 134 if (editor.bottom > estimatedBottom) { 135 return "down"; 136 } 137 const upOrientationTop = target.top - popover.height; 138 if (upOrientationTop > editor.top) { 139 return "up"; 140 } 141 142 return "right"; 143 } 144 145 calculateTop = (target, editor, popover, orientation) => { 146 if (orientation === "down") { 147 return target.bottom; 148 } 149 if (orientation === "up") { 150 return target.top - popover.height; 151 } 152 153 return this.calculateTopForRightOrientation(target, editor, popover); 154 }; 155 156 getPopoverCoords() { 157 if (!this.$popover || !this.props.editorRef) { 158 return null; 159 } 160 161 const popover = this.$popover; 162 const editor = this.props.editorRef; 163 const popoverRect = popover.getBoundingClientRect(); 164 const editorRect = editor.getBoundingClientRect(); 165 const targetRect = this.props.targetPosition; 166 const orientation = this.calculateOrientation( 167 targetRect, 168 editorRect, 169 popoverRect 170 ); 171 const top = this.calculateTop( 172 targetRect, 173 editorRect, 174 popoverRect, 175 orientation 176 ); 177 const popoverLeft = this.calculateLeft( 178 targetRect, 179 editorRect, 180 popoverRect, 181 orientation 182 ); 183 let targetMid; 184 if (orientation === "right") { 185 targetMid = { 186 x: -14, 187 y: targetRect.top - top - 2, 188 }; 189 } else { 190 targetMid = { 191 x: targetRect.left - popoverLeft + targetRect.width / 2 - 8, 192 y: 0, 193 }; 194 } 195 196 return { 197 left: popoverLeft, 198 top, 199 orientation, 200 targetMid, 201 }; 202 } 203 204 getTooltipCoords() { 205 if (!this.$tooltip || !this.props.editorRef) { 206 return null; 207 } 208 const tooltip = this.$tooltip; 209 const editor = this.props.editorRef; 210 const tooltipRect = tooltip.getBoundingClientRect(); 211 const editorRect = editor.getBoundingClientRect(); 212 const targetRect = this.props.targetPosition; 213 const left = this.calculateLeft(targetRect, editorRect, tooltipRect); 214 const enoughRoomForTooltipAbove = 215 targetRect.top - editorRect.top > tooltipRect.height; 216 const top = enoughRoomForTooltipAbove 217 ? targetRect.top - tooltipRect.height 218 : targetRect.bottom; 219 220 return { 221 left, 222 top, 223 orientation: enoughRoomForTooltipAbove ? "up" : "down", 224 targetMid: { x: 0, y: 0 }, 225 }; 226 } 227 228 getChildren() { 229 const { children } = this.props; 230 const { coords } = this.state; 231 const gap = this.getGap(); 232 233 return coords.orientation === "up" ? [children, gap] : [gap, children]; 234 } 235 236 getGap() { 237 return div({ 238 className: "gap", 239 key: "gap", 240 ref: a => (this.$gap = a), 241 }); 242 } 243 244 getPopoverArrow(orientation, left, top) { 245 let arrowProps = {}; 246 247 if (orientation === "up") { 248 arrowProps = { orientation: "down", bottom: 10, left }; 249 } else if (orientation === "down") { 250 arrowProps = { orientation: "up", top: -2, left }; 251 } else { 252 arrowProps = { orientation: "left", top, left: -4 }; 253 } 254 return React.createElement(BracketArrow, arrowProps); 255 } 256 257 renderPopover() { 258 const { top, left, orientation, targetMid } = this.state.coords; 259 const arrow = this.getPopoverArrow(orientation, targetMid.x, targetMid.y); 260 return div( 261 { 262 className: classnames("popover", `orientation-${orientation}`, { 263 up: orientation === "up", 264 }), 265 style: { 266 top, 267 left, 268 }, 269 ref: c => (this.$popover = c), 270 }, 271 arrow, 272 this.getChildren() 273 ); 274 } 275 276 renderTooltip() { 277 const { top, left, orientation } = this.state.coords; 278 return div( 279 { 280 className: `tooltip orientation-${orientation}`, 281 style: { 282 top, 283 left, 284 }, 285 ref: c => (this.$tooltip = c), 286 }, 287 this.getChildren() 288 ); 289 } 290 291 render() { 292 const { type } = this.props; 293 294 if (type === "tooltip") { 295 return this.renderTooltip(); 296 } 297 298 return this.renderPopover(); 299 } 300 } 301 302 export default Popover;