ConditionalPanel.js (13548B)
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, { PureComponent } from "devtools/client/shared/vendor/react"; 6 import { 7 div, 8 input, 9 button, 10 form, 11 label, 12 } from "devtools/client/shared/vendor/react-dom-factories"; 13 import ReactDOM from "devtools/client/shared/vendor/react-dom"; 14 import PropTypes from "devtools/client/shared/vendor/react-prop-types"; 15 import { connect } from "devtools/client/shared/vendor/react-redux"; 16 import { toEditorLine } from "../../utils/editor/index"; 17 import { createEditor } from "../../utils/editor/create-editor"; 18 import actions from "../../actions/index"; 19 20 import { 21 getClosestBreakpoint, 22 getConditionalPanelLocation, 23 getLogPointStatus, 24 } from "../../selectors/index"; 25 26 const classnames = require("resource://devtools/client/shared/classnames.js"); 27 28 export class ConditionalPanel extends PureComponent { 29 cbPanel; 30 input; 31 codeMirror; 32 panelNode; 33 scrollParent; 34 35 constructor() { 36 super(); 37 this.cbPanel = null; 38 this.breakpointPanelEditor = null; 39 this.formRef = React.createRef(); 40 } 41 42 static get propTypes() { 43 return { 44 breakpoint: PropTypes.object, 45 closeConditionalPanel: PropTypes.func.isRequired, 46 editor: PropTypes.object.isRequired, 47 location: PropTypes.any.isRequired, 48 log: PropTypes.bool.isRequired, 49 openConditionalPanel: PropTypes.func.isRequired, 50 setBreakpointOptions: PropTypes.func.isRequired, 51 selectedSource: PropTypes.object.isRequired, 52 }; 53 } 54 55 removeBreakpointPanelEditor() { 56 if (this.breakpointPanelEditor) { 57 this.breakpointPanelEditor.destroy(); 58 } 59 this.breakpointPanelEditor = null; 60 } 61 62 keepFocusOnInput() { 63 if (this.input) { 64 this.input.focus(); 65 } else if (this.breakpointPanelEditor) { 66 if (!this.breakpointPanelEditor.isDestroyed()) { 67 this.breakpointPanelEditor.focus(); 68 } 69 } 70 } 71 72 onFormSubmit = e => { 73 if (e && e.preventDefault) { 74 e.preventDefault(); 75 } 76 const formData = new FormData(this.formRef.current); 77 const showStacktrace = formData.get("showStacktrace") === "on"; 78 79 if ( 80 !this.breakpointPanelEditor || 81 this.breakpointPanelEditor.isDestroyed() 82 ) { 83 return; 84 } 85 const expression = this.breakpointPanelEditor.getText(null); 86 this.saveAndClose(expression, showStacktrace); 87 }; 88 89 onPanelBlur = e => { 90 // if the focus is outside of the conditional panel, 91 // close/hide the conditional panel 92 if ( 93 e.relatedTarget && 94 e.relatedTarget.closest(".conditional-breakpoint-panel-container") 95 ) { 96 return; 97 } 98 this.props.closeConditionalPanel(); 99 }; 100 101 /** 102 * Set the breakpoint/logpoint if expression isn't empty, and close the panel. 103 * 104 * @param {string} expression: The expression that will be used for setting the 105 * conditional breakpoint/logpoint 106 * @param {boolean} showStacktrace: Whether to show the stacktrace for the logpoint 107 */ 108 saveAndClose = (expression = null, showStacktrace = false) => { 109 if (typeof expression === "string") { 110 const trimmedExpression = expression.trim(); 111 if (trimmedExpression) { 112 this.setBreakpoint(trimmedExpression, showStacktrace); 113 } else if (this.props.breakpoint) { 114 // if the user was editing the condition/log of an existing breakpoint, 115 // we remove the condition/log. 116 this.setBreakpoint(null); 117 } 118 } 119 120 this.props.closeConditionalPanel(); 121 }; 122 123 /** 124 * Handle inline editor keydown event 125 * 126 * @param {Event} e: The keydown event 127 */ 128 onKey = e => { 129 if (e.key === "Enter" && !e.shiftKey) { 130 e.preventDefault(); 131 this.formRef.current.requestSubmit(); 132 } else if (e.key === "Escape") { 133 this.props.closeConditionalPanel(); 134 } 135 }; 136 137 /** 138 * Handle inline editor blur event 139 * 140 * @param {Event} e: The blur event 141 */ 142 onBlur = e => { 143 let explicitOriginalTarget = e?.explicitOriginalTarget; 144 // The explicit original target can be a text node, in such case retrieve its parent 145 // element so we can use `closest` on it. 146 if (explicitOriginalTarget && !Element.isInstance(explicitOriginalTarget)) { 147 explicitOriginalTarget = explicitOriginalTarget.parentElement; 148 } 149 150 if ( 151 // if there is no event 152 // or if the focus is the conditional panel 153 // do not close the conditional panel 154 !e || 155 (explicitOriginalTarget && 156 explicitOriginalTarget.closest( 157 ".conditional-breakpoint-panel-container" 158 )) 159 ) { 160 return; 161 } 162 163 this.props.closeConditionalPanel(); 164 }; 165 166 setBreakpoint(value, showStacktrace) { 167 const { log, breakpoint } = this.props; 168 // If breakpoint is `pending`, props will not contain a breakpoint. 169 // If source is a URL without location, breakpoint will contain no generatedLocation. 170 const location = 171 breakpoint && breakpoint.generatedLocation 172 ? breakpoint.generatedLocation 173 : this.props.location; 174 const options = breakpoint ? breakpoint.options : {}; 175 const type = log ? "logValue" : "condition"; 176 return this.props.setBreakpointOptions(location, { 177 ...options, 178 [type]: value, 179 showStacktrace, 180 }); 181 } 182 183 clearConditionalPanel() { 184 if (this.cbPanel) { 185 this.cbPanel.clear(); 186 this.cbPanel = null; 187 } 188 if (this.scrollParent) { 189 this.scrollParent.removeEventListener("scroll", this.repositionOnScroll); 190 } 191 } 192 193 repositionOnScroll = () => { 194 if (this.panelNode && this.scrollParent) { 195 const { scrollLeft } = this.scrollParent; 196 this.panelNode.style.transform = `translateX(${scrollLeft}px)`; 197 } 198 }; 199 200 showConditionalPanel(prevProps) { 201 const { location, log, editor, breakpoint, selectedSource } = this.props; 202 203 if (!selectedSource || !location) { 204 this.removeBreakpointPanelEditor(); 205 return; 206 } 207 // When breakpoint is removed 208 if (prevProps?.breakpoint && !breakpoint) { 209 editor.removeLineContentMarker(editor.markerTypes.CONDITIONAL_BP_MARKER); 210 this.removeBreakpointPanelEditor(); 211 return; 212 } 213 if (selectedSource.id !== location.source.id) { 214 editor.removeLineContentMarker(editor.markerTypes.CONDITIONAL_BP_MARKER); 215 this.removeBreakpointPanelEditor(); 216 return; 217 } 218 const line = toEditorLine(location.source, location.line || 0); 219 editor.setLineContentMarker({ 220 id: editor.markerTypes.CONDITIONAL_BP_MARKER, 221 lines: [{ line }], 222 renderAsBlock: true, 223 createLineElementNode: () => { 224 // Create a Codemirror editor for the breakpoint panel 225 226 const onEnterKeyMapConfig = { 227 preventDefault: true, 228 stopPropagation: true, 229 run: () => this.formRef.current.requestSubmit(), 230 }; 231 232 const breakpointPanelEditor = createEditor({ 233 cm6: true, 234 readOnly: false, 235 lineNumbers: false, 236 lineWrapping: true, 237 placeholder: L10N.getStr( 238 log 239 ? "editor.conditionalPanel.logPoint.placeholder2" 240 : "editor.conditionalPanel.placeholder2" 241 ), 242 keyMap: [ 243 { 244 key: "Enter", 245 ...onEnterKeyMapConfig, 246 }, 247 { 248 key: "Mod-Enter", 249 ...onEnterKeyMapConfig, 250 }, 251 { 252 key: "Escape", 253 preventDefault: true, 254 stopPropagation: true, 255 run: () => this.props.closeConditionalPanel(), 256 }, 257 ], 258 }); 259 260 this.breakpointPanelEditor = breakpointPanelEditor; 261 return this.renderConditionalPanel(this.props, breakpointPanelEditor); 262 }, 263 }); 264 } 265 266 // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 267 UNSAFE_componentWillMount() { 268 this.showConditionalPanel(); 269 } 270 271 componentDidUpdate(prevProps) { 272 this.showConditionalPanel(prevProps); 273 this.keepFocusOnInput(); 274 } 275 276 componentWillUnmount() { 277 // This is called if CodeMirror is re-initializing itself before the 278 // user closes the conditional panel. Clear the widget, and re-render it 279 // as soon as this component gets remounted 280 const { editor } = this.props; 281 editor.removeLineContentMarker(editor.markerTypes.CONDITIONAL_BP_MARKER); 282 this.removeBreakpointPanelEditor(); 283 } 284 285 componentDidMount() { 286 if (this.formRef && this.formRef.current) { 287 const checkbox = this.formRef.current.querySelector("#showStacktrace"); 288 if (checkbox) { 289 checkbox.checked = this.props.breakpoint?.options?.showStacktrace; 290 } 291 } 292 } 293 294 renderToWidget(props) { 295 if (this.cbPanel) { 296 this.clearConditionalPanel(); 297 } 298 const { location, editor } = props; 299 if (!location) { 300 return; 301 } 302 303 const editorLine = toEditorLine(location.source, location.line || 0); 304 this.cbPanel = editor.codeMirror.addLineWidget( 305 editorLine, 306 this.renderConditionalPanel(props, editor), 307 { 308 coverGutter: true, 309 noHScroll: true, 310 } 311 ); 312 313 if (this.input) { 314 let parent = this.input.parentNode; 315 while (parent) { 316 if ( 317 HTMLElement.isInstance(parent) && 318 parent.classList.contains("CodeMirror-scroll") 319 ) { 320 this.scrollParent = parent; 321 break; 322 } 323 parent = parent.parentNode; 324 } 325 326 if (this.scrollParent) { 327 this.scrollParent.addEventListener("scroll", this.repositionOnScroll); 328 this.repositionOnScroll(); 329 } 330 } 331 } 332 333 setupAndAppendInlineEditor = (el, editor) => { 334 editor.appendToLocalElement(el); 335 editor.on("blur", e => this.onBlur(e)); 336 337 editor.setText(this.getDefaultValue()); 338 editor.focus(); 339 editor.selectAll(); 340 }; 341 342 getDefaultValue() { 343 const { breakpoint, log } = this.props; 344 const options = breakpoint?.options || {}; 345 const value = log ? options.logValue : options.condition; 346 return value || ""; 347 } 348 349 renderConditionalPanel(props, editor) { 350 const { log } = props; 351 const panel = document.createElement("div"); 352 const isWindows = Services.appinfo.OS.startsWith("WINNT"); 353 354 const isCreating = !this.props.breakpoint; 355 356 const saveButton = button( 357 { 358 type: "submit", 359 id: "save-logpoint", 360 className: "devtools-button conditional-breakpoint-panel-save-button", 361 }, 362 L10N.getStr( 363 isCreating 364 ? "editor.conditionalPanel.create" 365 : "editor.conditionalPanel.update" 366 ) 367 ); 368 369 const cancelButton = button( 370 { 371 type: "button", 372 className: "devtools-button conditional-breakpoint-panel-cancel-button", 373 onClick: () => this.props.closeConditionalPanel(), 374 }, 375 L10N.getStr("editor.conditionalPanel.cancel") 376 ); 377 378 // CodeMirror6 can't have margin on a block widget, so we need to wrap the actual 379 // panel inside a container which won't have any margin 380 const reactElPanel = div( 381 { 382 className: "conditional-breakpoint-panel-container", 383 onBlur: this.onPanelBlur, 384 tabIndex: -1, 385 }, 386 form( 387 { 388 className: classnames("conditional-breakpoint-panel", { 389 "log-point": log, 390 }), 391 onSubmit: this.onFormSubmit, 392 ref: this.formRef, 393 }, 394 div( 395 { 396 className: "input-container", 397 }, 398 div( 399 { 400 className: "prompt", 401 }, 402 "ยป" 403 ), 404 div({ 405 className: "inline-codemirror-container", 406 ref: el => this.setupAndAppendInlineEditor(el, editor), 407 }) 408 ), 409 div( 410 { 411 className: "conditional-breakpoint-panel-controls", 412 }, 413 log 414 ? label( 415 { 416 className: "conditional-breakpoint-panel-checkbox-label", 417 htmlFor: "showStacktrace", 418 }, 419 input({ 420 type: "checkbox", 421 id: "showStacktrace", 422 name: "showStacktrace", 423 defaultChecked: 424 this.props.breakpoint?.options?.showStacktrace, 425 "aria-label": L10N.getStr( 426 "editor.conditionalPanel.logPoint.showStacktrace" 427 ), 428 }), 429 L10N.getStr("editor.conditionalPanel.logPoint.showStacktrace") 430 ) 431 : null, 432 div( 433 { 434 className: "conditional-breakpoint-panel-buttons", 435 }, 436 isWindows ? saveButton : cancelButton, 437 isWindows ? cancelButton : saveButton 438 ) 439 ) 440 ) 441 ); 442 ReactDOM.render(reactElPanel, panel); 443 return panel; 444 } 445 446 render() { 447 return null; 448 } 449 } 450 451 const mapStateToProps = state => { 452 const location = getConditionalPanelLocation(state); 453 454 if (!location) { 455 return {}; 456 } 457 458 const breakpoint = getClosestBreakpoint(state, location); 459 460 return { 461 breakpoint, 462 location, 463 log: getLogPointStatus(state), 464 }; 465 }; 466 467 const { setBreakpointOptions, openConditionalPanel, closeConditionalPanel } = 468 actions; 469 470 const mapDispatchToProps = { 471 setBreakpointOptions, 472 openConditionalPanel, 473 closeConditionalPanel, 474 }; 475 476 export default connect(mapStateToProps, mapDispatchToProps)(ConditionalPanel);