/* 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 . */ import React, { PureComponent } from "devtools/client/shared/vendor/react"; import { div, input, button, form, label, } from "devtools/client/shared/vendor/react-dom-factories"; import ReactDOM from "devtools/client/shared/vendor/react-dom"; import PropTypes from "devtools/client/shared/vendor/react-prop-types"; import { connect } from "devtools/client/shared/vendor/react-redux"; import { toEditorLine } from "../../utils/editor/index"; import { createEditor } from "../../utils/editor/create-editor"; import actions from "../../actions/index"; import { getClosestBreakpoint, getConditionalPanelLocation, getLogPointStatus, } from "../../selectors/index"; const classnames = require("resource://devtools/client/shared/classnames.js"); export class ConditionalPanel extends PureComponent { cbPanel; input; codeMirror; panelNode; scrollParent; constructor() { super(); this.cbPanel = null; this.breakpointPanelEditor = null; this.formRef = React.createRef(); } static get propTypes() { return { breakpoint: PropTypes.object, closeConditionalPanel: PropTypes.func.isRequired, editor: PropTypes.object.isRequired, location: PropTypes.any.isRequired, log: PropTypes.bool.isRequired, openConditionalPanel: PropTypes.func.isRequired, setBreakpointOptions: PropTypes.func.isRequired, selectedSource: PropTypes.object.isRequired, }; } removeBreakpointPanelEditor() { if (this.breakpointPanelEditor) { this.breakpointPanelEditor.destroy(); } this.breakpointPanelEditor = null; } keepFocusOnInput() { if (this.input) { this.input.focus(); } else if (this.breakpointPanelEditor) { if (!this.breakpointPanelEditor.isDestroyed()) { this.breakpointPanelEditor.focus(); } } } onFormSubmit = e => { if (e && e.preventDefault) { e.preventDefault(); } const formData = new FormData(this.formRef.current); const showStacktrace = formData.get("showStacktrace") === "on"; if ( !this.breakpointPanelEditor || this.breakpointPanelEditor.isDestroyed() ) { return; } const expression = this.breakpointPanelEditor.getText(null); this.saveAndClose(expression, showStacktrace); }; onPanelBlur = e => { // if the focus is outside of the conditional panel, // close/hide the conditional panel if ( e.relatedTarget && e.relatedTarget.closest(".conditional-breakpoint-panel-container") ) { return; } this.props.closeConditionalPanel(); }; /** * Set the breakpoint/logpoint if expression isn't empty, and close the panel. * * @param {string} expression: The expression that will be used for setting the * conditional breakpoint/logpoint * @param {boolean} showStacktrace: Whether to show the stacktrace for the logpoint */ saveAndClose = (expression = null, showStacktrace = false) => { if (typeof expression === "string") { const trimmedExpression = expression.trim(); if (trimmedExpression) { this.setBreakpoint(trimmedExpression, showStacktrace); } else if (this.props.breakpoint) { // if the user was editing the condition/log of an existing breakpoint, // we remove the condition/log. this.setBreakpoint(null); } } this.props.closeConditionalPanel(); }; /** * Handle inline editor keydown event * * @param {Event} e: The keydown event */ onKey = e => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); this.formRef.current.requestSubmit(); } else if (e.key === "Escape") { this.props.closeConditionalPanel(); } }; /** * Handle inline editor blur event * * @param {Event} e: The blur event */ onBlur = e => { let explicitOriginalTarget = e?.explicitOriginalTarget; // The explicit original target can be a text node, in such case retrieve its parent // element so we can use `closest` on it. if (explicitOriginalTarget && !Element.isInstance(explicitOriginalTarget)) { explicitOriginalTarget = explicitOriginalTarget.parentElement; } if ( // if there is no event // or if the focus is the conditional panel // do not close the conditional panel !e || (explicitOriginalTarget && explicitOriginalTarget.closest( ".conditional-breakpoint-panel-container" )) ) { return; } this.props.closeConditionalPanel(); }; setBreakpoint(value, showStacktrace) { const { log, breakpoint } = this.props; // If breakpoint is `pending`, props will not contain a breakpoint. // If source is a URL without location, breakpoint will contain no generatedLocation. const location = breakpoint && breakpoint.generatedLocation ? breakpoint.generatedLocation : this.props.location; const options = breakpoint ? breakpoint.options : {}; const type = log ? "logValue" : "condition"; return this.props.setBreakpointOptions(location, { ...options, [type]: value, showStacktrace, }); } clearConditionalPanel() { if (this.cbPanel) { this.cbPanel.clear(); this.cbPanel = null; } if (this.scrollParent) { this.scrollParent.removeEventListener("scroll", this.repositionOnScroll); } } repositionOnScroll = () => { if (this.panelNode && this.scrollParent) { const { scrollLeft } = this.scrollParent; this.panelNode.style.transform = `translateX(${scrollLeft}px)`; } }; showConditionalPanel(prevProps) { const { location, log, editor, breakpoint, selectedSource } = this.props; if (!selectedSource || !location) { this.removeBreakpointPanelEditor(); return; } // When breakpoint is removed if (prevProps?.breakpoint && !breakpoint) { editor.removeLineContentMarker(editor.markerTypes.CONDITIONAL_BP_MARKER); this.removeBreakpointPanelEditor(); return; } if (selectedSource.id !== location.source.id) { editor.removeLineContentMarker(editor.markerTypes.CONDITIONAL_BP_MARKER); this.removeBreakpointPanelEditor(); return; } const line = toEditorLine(location.source, location.line || 0); editor.setLineContentMarker({ id: editor.markerTypes.CONDITIONAL_BP_MARKER, lines: [{ line }], renderAsBlock: true, createLineElementNode: () => { // Create a Codemirror editor for the breakpoint panel const onEnterKeyMapConfig = { preventDefault: true, stopPropagation: true, run: () => this.formRef.current.requestSubmit(), }; const breakpointPanelEditor = createEditor({ cm6: true, readOnly: false, lineNumbers: false, lineWrapping: true, placeholder: L10N.getStr( log ? "editor.conditionalPanel.logPoint.placeholder2" : "editor.conditionalPanel.placeholder2" ), keyMap: [ { key: "Enter", ...onEnterKeyMapConfig, }, { key: "Mod-Enter", ...onEnterKeyMapConfig, }, { key: "Escape", preventDefault: true, stopPropagation: true, run: () => this.props.closeConditionalPanel(), }, ], }); this.breakpointPanelEditor = breakpointPanelEditor; return this.renderConditionalPanel(this.props, breakpointPanelEditor); }, }); } // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 UNSAFE_componentWillMount() { this.showConditionalPanel(); } componentDidUpdate(prevProps) { this.showConditionalPanel(prevProps); this.keepFocusOnInput(); } componentWillUnmount() { // This is called if CodeMirror is re-initializing itself before the // user closes the conditional panel. Clear the widget, and re-render it // as soon as this component gets remounted const { editor } = this.props; editor.removeLineContentMarker(editor.markerTypes.CONDITIONAL_BP_MARKER); this.removeBreakpointPanelEditor(); } componentDidMount() { if (this.formRef && this.formRef.current) { const checkbox = this.formRef.current.querySelector("#showStacktrace"); if (checkbox) { checkbox.checked = this.props.breakpoint?.options?.showStacktrace; } } } renderToWidget(props) { if (this.cbPanel) { this.clearConditionalPanel(); } const { location, editor } = props; if (!location) { return; } const editorLine = toEditorLine(location.source, location.line || 0); this.cbPanel = editor.codeMirror.addLineWidget( editorLine, this.renderConditionalPanel(props, editor), { coverGutter: true, noHScroll: true, } ); if (this.input) { let parent = this.input.parentNode; while (parent) { if ( HTMLElement.isInstance(parent) && parent.classList.contains("CodeMirror-scroll") ) { this.scrollParent = parent; break; } parent = parent.parentNode; } if (this.scrollParent) { this.scrollParent.addEventListener("scroll", this.repositionOnScroll); this.repositionOnScroll(); } } } setupAndAppendInlineEditor = (el, editor) => { editor.appendToLocalElement(el); editor.on("blur", e => this.onBlur(e)); editor.setText(this.getDefaultValue()); editor.focus(); editor.selectAll(); }; getDefaultValue() { const { breakpoint, log } = this.props; const options = breakpoint?.options || {}; const value = log ? options.logValue : options.condition; return value || ""; } renderConditionalPanel(props, editor) { const { log } = props; const panel = document.createElement("div"); const isWindows = Services.appinfo.OS.startsWith("WINNT"); const isCreating = !this.props.breakpoint; const saveButton = button( { type: "submit", id: "save-logpoint", className: "devtools-button conditional-breakpoint-panel-save-button", }, L10N.getStr( isCreating ? "editor.conditionalPanel.create" : "editor.conditionalPanel.update" ) ); const cancelButton = button( { type: "button", className: "devtools-button conditional-breakpoint-panel-cancel-button", onClick: () => this.props.closeConditionalPanel(), }, L10N.getStr("editor.conditionalPanel.cancel") ); // CodeMirror6 can't have margin on a block widget, so we need to wrap the actual // panel inside a container which won't have any margin const reactElPanel = div( { className: "conditional-breakpoint-panel-container", onBlur: this.onPanelBlur, tabIndex: -1, }, form( { className: classnames("conditional-breakpoint-panel", { "log-point": log, }), onSubmit: this.onFormSubmit, ref: this.formRef, }, div( { className: "input-container", }, div( { className: "prompt", }, "ยป" ), div({ className: "inline-codemirror-container", ref: el => this.setupAndAppendInlineEditor(el, editor), }) ), div( { className: "conditional-breakpoint-panel-controls", }, log ? label( { className: "conditional-breakpoint-panel-checkbox-label", htmlFor: "showStacktrace", }, input({ type: "checkbox", id: "showStacktrace", name: "showStacktrace", defaultChecked: this.props.breakpoint?.options?.showStacktrace, "aria-label": L10N.getStr( "editor.conditionalPanel.logPoint.showStacktrace" ), }), L10N.getStr("editor.conditionalPanel.logPoint.showStacktrace") ) : null, div( { className: "conditional-breakpoint-panel-buttons", }, isWindows ? saveButton : cancelButton, isWindows ? cancelButton : saveButton ) ) ) ); ReactDOM.render(reactElPanel, panel); return panel; } render() { return null; } } const mapStateToProps = state => { const location = getConditionalPanelLocation(state); if (!location) { return {}; } const breakpoint = getClosestBreakpoint(state, location); return { breakpoint, location, log: getLogPointStatus(state), }; }; const { setBreakpointOptions, openConditionalPanel, closeConditionalPanel } = actions; const mapDispatchToProps = { setBreakpointOptions, openConditionalPanel, closeConditionalPanel, }; export default connect(mapStateToProps, mapDispatchToProps)(ConditionalPanel);