summaryrefslogtreecommitdiffstats
path: root/devtools/client/debugger/src/components/Editor/Preview
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 17:32:43 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 17:32:43 +0000
commit6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch)
treea68f146d7fa01f0134297619fbe7e33db084e0aa /devtools/client/debugger/src/components/Editor/Preview
parentInitial commit. (diff)
downloadthunderbird-upstream.tar.xz
thunderbird-upstream.zip
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/client/debugger/src/components/Editor/Preview')
-rw-r--r--devtools/client/debugger/src/components/Editor/Preview/ExceptionPopup.js164
-rw-r--r--devtools/client/debugger/src/components/Editor/Preview/Popup.css209
-rw-r--r--devtools/client/debugger/src/components/Editor/Preview/Popup.js382
-rw-r--r--devtools/client/debugger/src/components/Editor/Preview/index.js136
-rw-r--r--devtools/client/debugger/src/components/Editor/Preview/moz.build12
-rw-r--r--devtools/client/debugger/src/components/Editor/Preview/tests/Popup.spec.js107
6 files changed, 1010 insertions, 0 deletions
diff --git a/devtools/client/debugger/src/components/Editor/Preview/ExceptionPopup.js b/devtools/client/debugger/src/components/Editor/Preview/ExceptionPopup.js
new file mode 100644
index 0000000000..624a78fb8b
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Preview/ExceptionPopup.js
@@ -0,0 +1,164 @@
+/* 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/>. */
+
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+import { connect } from "../../../utils/connect";
+
+import Reps from "devtools/client/shared/components/reps/index";
+const {
+ REPS: { StringRep },
+} = Reps;
+
+import actions from "../../../actions";
+
+import { getThreadContext } from "../../../selectors";
+
+import AccessibleImage from "../../shared/AccessibleImage";
+
+const DevToolsUtils = require("devtools/shared/DevToolsUtils");
+const classnames = require("devtools/client/shared/classnames.js");
+
+const POPUP_SELECTOR = ".preview-popup.exception-popup";
+const ANONYMOUS_FN_NAME = "<anonymous>";
+
+// The exception popup works in two modes:
+// a. when the stacktrace is closed the exception popup
+// gets closed when the mouse leaves the popup.
+// b. when the stacktrace is opened the exception popup
+// gets closed only by clicking outside the popup.
+class ExceptionPopup extends Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ isStacktraceExpanded: false,
+ };
+ }
+
+ static get propTypes() {
+ return {
+ clearPreview: PropTypes.func.isRequired,
+ cx: PropTypes.object.isRequired,
+ mouseout: PropTypes.func.isRequired,
+ selectSourceURL: PropTypes.func.isRequired,
+ exception: PropTypes.object.isRequired,
+ };
+ }
+
+ updateTopWindow() {
+ // The ChromeWindow is used when the stacktrace is expanded to capture all clicks
+ // outside the popup so the popup can be closed only by clicking outside of it.
+ if (this.topWindow) {
+ this.topWindow.removeEventListener(
+ "mousedown",
+ this.onTopWindowClick,
+ true
+ );
+ this.topWindow = null;
+ }
+ this.topWindow = DevToolsUtils.getTopWindow(window.parent);
+ this.topWindow.addEventListener("mousedown", this.onTopWindowClick, true);
+ }
+
+ onTopWindowClick = e => {
+ const { cx, clearPreview } = this.props;
+
+ // When the stactrace is expaned the exception popup gets closed
+ // only by clicking ouside the popup.
+ if (!e.target.closest(POPUP_SELECTOR)) {
+ clearPreview(cx);
+ }
+ };
+
+ onExceptionMessageClick() {
+ const isStacktraceExpanded = this.state.isStacktraceExpanded;
+
+ this.updateTopWindow();
+ this.setState({ isStacktraceExpanded: !isStacktraceExpanded });
+ }
+
+ buildStackFrame(frame) {
+ const { cx, selectSourceURL } = this.props;
+ const { filename, lineNumber } = frame;
+ const functionName = frame.functionName || ANONYMOUS_FN_NAME;
+
+ return (
+ <div
+ className="frame"
+ onClick={() => selectSourceURL(cx, filename, { line: lineNumber })}
+ >
+ <span className="title">{functionName}</span>
+ <span className="location">
+ <span className="filename">{filename}</span>:
+ <span className="line">{lineNumber}</span>
+ </span>
+ </div>
+ );
+ }
+
+ renderStacktrace(stacktrace) {
+ const isStacktraceExpanded = this.state.isStacktraceExpanded;
+
+ if (stacktrace.length && isStacktraceExpanded) {
+ return (
+ <div className="exception-stacktrace">
+ {stacktrace.map(frame => this.buildStackFrame(frame))}
+ </div>
+ );
+ }
+ return null;
+ }
+
+ renderArrowIcon(stacktrace) {
+ if (stacktrace.length) {
+ return (
+ <AccessibleImage
+ className={classnames("arrow", {
+ expanded: this.state.isStacktraceExpanded,
+ })}
+ />
+ );
+ }
+ return null;
+ }
+
+ render() {
+ const {
+ exception: { stacktrace, errorMessage },
+ mouseout,
+ } = this.props;
+
+ return (
+ <div
+ className="preview-popup exception-popup"
+ dir="ltr"
+ onMouseLeave={() => mouseout(true, this.state.isStacktraceExpanded)}
+ >
+ <div
+ className="exception-message"
+ onClick={() => this.onExceptionMessageClick()}
+ >
+ {this.renderArrowIcon(stacktrace)}
+ {StringRep.rep({
+ object: errorMessage,
+ useQuotes: false,
+ className: "exception-text",
+ })}
+ </div>
+ {this.renderStacktrace(stacktrace)}
+ </div>
+ );
+ }
+}
+
+const mapStateToProps = state => ({
+ cx: getThreadContext(state),
+});
+
+const mapDispatchToProps = {
+ selectSourceURL: actions.selectSourceURL,
+ clearPreview: actions.clearPreview,
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(ExceptionPopup);
diff --git a/devtools/client/debugger/src/components/Editor/Preview/Popup.css b/devtools/client/debugger/src/components/Editor/Preview/Popup.css
new file mode 100644
index 0000000000..3e578becf1
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Preview/Popup.css
@@ -0,0 +1,209 @@
+/* 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/>. */
+
+.popover .preview-popup {
+ background: var(--theme-body-background);
+ width: 350px;
+ border: 1px solid var(--theme-splitter-color);
+ padding: 10px;
+ height: auto;
+ overflow: auto;
+ box-shadow: 1px 2px 3px var(--popup-shadow-color);
+}
+
+.preview-popup .tree {
+ /* Setting a fixed line height to avoid issues in custom formatters changing
+ * the line height like the CLJS DevTools */
+ line-height: 15px;
+}
+
+.gap svg {
+ pointer-events: none;
+}
+
+.gap polygon {
+ pointer-events: auto;
+}
+
+.theme-dark .popover .preview-popup {
+ box-shadow: 1px 2px 3px var(--popup-shadow-color);
+}
+
+.popover .preview-popup .header-container {
+ width: 100%;
+ line-height: 15px;
+ display: flex;
+ flex-direction: row;
+ margin-bottom: 5px;
+}
+
+.popover .preview-popup .logo {
+ width: 20px;
+ margin-right: 5px;
+}
+
+.popover .preview-popup .header-container h3 {
+ margin: 0;
+ margin-bottom: 5px;
+ font-weight: normal;
+ font-size: 14px;
+ line-height: 20px;
+ margin-left: 4px;
+}
+
+.popover .preview-popup .header .link {
+ align-self: flex-end;
+ color: var(--theme-highlight-blue);
+ text-decoration: underline;
+}
+
+.popover .preview-popup .object-node {
+ padding-inline-start: 0px;
+}
+
+.preview-token:hover {
+ cursor: default;
+}
+
+.preview-token,
+.debug-expression.preview-token {
+ background-color: var(--theme-highlight-yellow);
+}
+
+.theme-dark .preview-token,
+.theme-dark .debug-expression.preview-token {
+ background-color: #743884;
+}
+
+.theme-dark .cm-s-mozilla .preview-token,
+.theme-dark .cm-s-mozilla .debug-expression.preview-token {
+ color: #e7ebee;
+}
+
+.popover .preview-popup .function-signature {
+ padding-top: 10px;
+}
+
+.theme-dark .popover .preview-popup {
+ border-color: var(--theme-body-color);
+}
+
+.tooltip {
+ position: fixed;
+ z-index: 100;
+}
+
+.tooltip .preview-popup {
+ background: var(--theme-toolbar-background);
+ max-width: inherit;
+ border: 1px solid var(--theme-splitter-color);
+ box-shadow: 1px 2px 4px 1px var(--theme-toolbar-background-alt);
+ padding: 5px;
+ height: auto;
+ min-height: inherit;
+ max-height: 200px;
+ overflow: auto;
+}
+
+.theme-dark .tooltip .preview-popup {
+ border-color: var(--theme-body-color);
+}
+
+.tooltip .gap {
+ height: 4px;
+ padding-top: 0px;
+}
+
+.add-to-expression-bar {
+ border: 1px solid var(--theme-splitter-color);
+ border-top: none;
+ display: -webkit-box;
+ display: -ms-flexbox;
+ display: flex;
+ -webkit-box-align: center;
+ -ms-flex-align: center;
+ align-items: center;
+ font-size: 14px;
+ line-height: 30px;
+ background: var(--theme-toolbar-background);
+ color: var(--theme-text-color-inactive);
+ padding: 0 4px;
+}
+
+.add-to-expression-bar .prompt {
+ width: 1em;
+}
+
+.add-to-expression-bar .expression-to-save-label {
+ width: calc(100% - 4em);
+}
+
+.add-to-expression-bar .expression-to-save-button {
+ font-size: 14px;
+ color: var(--theme-comment);
+}
+
+/* Exception popup */
+.exception-popup .exception-text {
+ color: var(--red-70);
+}
+
+.theme-dark .exception-popup .exception-text {
+ color: var(--red-20);
+}
+
+.exception-popup .exception-message {
+ display: flex;
+ align-items: center;
+}
+
+.exception-message .arrow {
+ margin-inline-end: 4px;
+}
+
+.exception-popup .exception-stacktrace {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ grid-column-gap: 8px;
+ padding-inline: 2px 3px;
+ line-height: var(--theme-code-line-height);
+}
+
+.exception-stacktrace .frame {
+ display: contents;
+ cursor: pointer;
+}
+
+.exception-stacktrace .title {
+ grid-column: 1/2;
+ color: var(--grey-90);
+}
+
+.theme-dark .exception-stacktrace .title {
+ color: white;
+}
+
+.exception-stacktrace .location {
+ grid-column: -1/-2;
+ color: var(--theme-highlight-purple);
+ direction: rtl;
+ text-align: end;
+ white-space: nowrap;
+ /* Force the location to be on one line and crop at start if wider then max-width */
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 350px;
+}
+
+.theme-dark .exception-stacktrace .location {
+ color: var(--blue-40);
+}
+
+.exception-stacktrace .line {
+ color: var(--theme-highlight-blue);
+}
+
+.theme-dark .exception-stacktrace .line {
+ color: hsl(210, 40%, 60%);
+}
diff --git a/devtools/client/debugger/src/components/Editor/Preview/Popup.js b/devtools/client/debugger/src/components/Editor/Preview/Popup.js
new file mode 100644
index 0000000000..3097d3c945
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Preview/Popup.js
@@ -0,0 +1,382 @@
+/* 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/>. */
+
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+import { connect } from "../../../utils/connect";
+
+import Reps from "devtools/client/shared/components/reps/index";
+const {
+ REPS: { Rep },
+ MODE,
+ objectInspector,
+} = Reps;
+
+const { ObjectInspector, utils } = objectInspector;
+
+const {
+ node: { nodeIsPrimitive, nodeIsFunction, nodeIsObject },
+} = utils;
+
+import ExceptionPopup from "./ExceptionPopup";
+
+import actions from "../../../actions";
+import { getThreadContext } from "../../../selectors";
+import Popover from "../../shared/Popover";
+import PreviewFunction from "../../shared/PreviewFunction";
+
+import "./Popup.css";
+
+export class Popup extends Component {
+ constructor(props) {
+ super(props);
+ }
+
+ static get propTypes() {
+ return {
+ clearPreview: PropTypes.func.isRequired,
+ cx: PropTypes.object.isRequired,
+ editorRef: PropTypes.object.isRequired,
+ highlightDomElement: PropTypes.func.isRequired,
+ openElementInInspector: PropTypes.func.isRequired,
+ openLink: PropTypes.func.isRequired,
+ preview: PropTypes.object.isRequired,
+ selectSourceURL: PropTypes.func.isRequired,
+ unHighlightDomElement: PropTypes.func.isRequired,
+ };
+ }
+
+ componentDidMount() {
+ this.addHighlightToToken();
+ }
+
+ componentWillUnmount() {
+ this.removeHighlightFromToken();
+ }
+
+ addHighlightToToken() {
+ const { target } = this.props.preview;
+ if (target) {
+ target.classList.add("preview-token");
+ addHighlightToTargetSiblings(target, this.props);
+ }
+ }
+
+ removeHighlightFromToken() {
+ const { target } = this.props.preview;
+ if (target) {
+ target.classList.remove("preview-token");
+ removeHighlightForTargetSiblings(target);
+ }
+ }
+
+ calculateMaxHeight = () => {
+ const { editorRef } = this.props;
+ if (!editorRef) {
+ return "auto";
+ }
+
+ const { height, top } = editorRef.getBoundingClientRect();
+ const maxHeight = height + top;
+ if (maxHeight < 250) {
+ return maxHeight;
+ }
+
+ return 250;
+ };
+
+ createElement(element) {
+ return document.createElement(element);
+ }
+
+ renderFunctionPreview() {
+ const {
+ cx,
+ selectSourceURL,
+ preview: { resultGrip },
+ } = this.props;
+
+ if (!resultGrip) {
+ return null;
+ }
+
+ const { location } = resultGrip;
+
+ return (
+ <div
+ className="preview-popup"
+ onClick={() =>
+ location &&
+ selectSourceURL(cx, location.url, {
+ line: location.line,
+ })
+ }
+ >
+ <PreviewFunction func={resultGrip} />
+ </div>
+ );
+ }
+
+ renderObjectPreview() {
+ const {
+ preview: { root, properties },
+ openLink,
+ openElementInInspector,
+ highlightDomElement,
+ unHighlightDomElement,
+ } = this.props;
+
+ const usesCustomFormatter =
+ root?.contents?.value?.useCustomFormatter ?? false;
+
+ if (!properties.length) {
+ return (
+ <div className="preview-popup">
+ <span className="label">{L10N.getStr("preview.noProperties")}</span>
+ </div>
+ );
+ }
+
+ const roots = usesCustomFormatter ? [root] : properties;
+
+ return (
+ <div
+ className="preview-popup"
+ style={{ maxHeight: this.calculateMaxHeight() }}
+ >
+ <ObjectInspector
+ roots={roots}
+ autoExpandDepth={0}
+ autoReleaseObjectActors={false}
+ mode={usesCustomFormatter ? MODE.LONG : null}
+ disableWrap={true}
+ focusable={false}
+ openLink={openLink}
+ createElement={this.createElement}
+ onDOMNodeClick={grip => openElementInInspector(grip)}
+ onInspectIconClick={grip => openElementInInspector(grip)}
+ onDOMNodeMouseOver={grip => highlightDomElement(grip)}
+ onDOMNodeMouseOut={grip => unHighlightDomElement(grip)}
+ mayUseCustomFormatter={true}
+ />
+ </div>
+ );
+ }
+
+ renderSimplePreview() {
+ const {
+ openLink,
+ preview: { resultGrip },
+ } = this.props;
+ return (
+ <div className="preview-popup">
+ {Rep({
+ object: resultGrip,
+ mode: MODE.LONG,
+ openLink,
+ })}
+ </div>
+ );
+ }
+
+ renderExceptionPreview(exception) {
+ return (
+ <ExceptionPopup
+ exception={exception}
+ mouseout={this.onMouseOutException}
+ />
+ );
+ }
+
+ renderPreview() {
+ // We don't have to check and
+ // return on `false`, `""`, `0`, `undefined` etc,
+ // these falsy simple typed value because we want to
+ // do `renderSimplePreview` on these values below.
+ const {
+ preview: { root, exception },
+ } = this.props;
+
+ if (nodeIsFunction(root)) {
+ return this.renderFunctionPreview();
+ }
+
+ if (nodeIsObject(root)) {
+ return <div>{this.renderObjectPreview()}</div>;
+ }
+
+ if (exception) {
+ return this.renderExceptionPreview(exception);
+ }
+
+ return this.renderSimplePreview();
+ }
+
+ getPreviewType() {
+ const {
+ preview: { root, properties, exception },
+ } = this.props;
+ if (
+ exception ||
+ nodeIsPrimitive(root) ||
+ nodeIsFunction(root) ||
+ !Array.isArray(properties) ||
+ properties.length === 0
+ ) {
+ return "tooltip";
+ }
+
+ return "popover";
+ }
+
+ onMouseOut = () => {
+ const { clearPreview, cx } = this.props;
+
+ clearPreview(cx);
+ };
+
+ onMouseOutException = (shouldClearOnMouseout, isExceptionStactraceOpen) => {
+ // onMouseOutException can be called:
+ // a. when the mouse leaves Popover element
+ // b. when the mouse leaves ExceptionPopup element
+ // We want to prevent closing the popup when the stacktrace
+ // is expanded and the mouse leaves either the Popover element
+ // or the ExceptionPopup element.
+ const { clearPreview, cx } = this.props;
+
+ if (shouldClearOnMouseout) {
+ this.isExceptionStactraceOpen = isExceptionStactraceOpen;
+ }
+
+ if (!this.isExceptionStactraceOpen) {
+ clearPreview(cx);
+ }
+ };
+
+ render() {
+ const {
+ preview: { cursorPos, resultGrip, exception },
+ editorRef,
+ } = this.props;
+
+ if (
+ !exception &&
+ (typeof resultGrip == "undefined" || resultGrip?.optimizedOut)
+ ) {
+ return null;
+ }
+
+ const type = this.getPreviewType();
+ return (
+ <Popover
+ targetPosition={cursorPos}
+ type={type}
+ editorRef={editorRef}
+ target={this.props.preview.target}
+ mouseout={exception ? this.onMouseOutException : this.onMouseOut}
+ >
+ {this.renderPreview()}
+ </Popover>
+ );
+ }
+}
+
+export function addHighlightToTargetSiblings(target, props) {
+ // This function searches for related tokens that should also be highlighted when previewed.
+ // Here is the process:
+ // It conducts a search on the target's next siblings and then another search for the previous siblings.
+ // If a sibling is not an element node (nodeType === 1), the highlight is not added and the search is short-circuited.
+ // If the element sibling is the same token type as the target, and is also found in the preview expression, the highlight class is added.
+
+ const tokenType = target.classList.item(0);
+ const previewExpression = props.preview.expression;
+
+ if (
+ tokenType &&
+ previewExpression &&
+ target.innerHTML !== previewExpression
+ ) {
+ let nextSibling = target.nextSibling;
+ let nextElementSibling = target.nextElementSibling;
+
+ // Note: Declaring previous/next ELEMENT siblings as well because
+ // properties like innerHTML can't be checked on nextSibling
+ // without creating a flow error even if the node is an element type.
+ while (
+ nextSibling &&
+ nextElementSibling &&
+ nextSibling.nodeType === 1 &&
+ nextElementSibling.className.includes(tokenType) &&
+ previewExpression.includes(nextElementSibling.innerHTML)
+ ) {
+ // All checks passed, add highlight and continue the search.
+ nextElementSibling.classList.add("preview-token");
+
+ nextSibling = nextSibling.nextSibling;
+ nextElementSibling = nextElementSibling.nextElementSibling;
+ }
+
+ let previousSibling = target.previousSibling;
+ let previousElementSibling = target.previousElementSibling;
+
+ while (
+ previousSibling &&
+ previousElementSibling &&
+ previousSibling.nodeType === 1 &&
+ previousElementSibling.className.includes(tokenType) &&
+ previewExpression.includes(previousElementSibling.innerHTML)
+ ) {
+ // All checks passed, add highlight and continue the search.
+ previousElementSibling.classList.add("preview-token");
+
+ previousSibling = previousSibling.previousSibling;
+ previousElementSibling = previousElementSibling.previousElementSibling;
+ }
+ }
+}
+
+export function removeHighlightForTargetSiblings(target) {
+ // Look at target's previous and next token siblings.
+ // If they also have the highlight class 'preview-token',
+ // remove that class.
+ let nextSibling = target.nextElementSibling;
+ while (nextSibling && nextSibling.className.includes("preview-token")) {
+ nextSibling.classList.remove("preview-token");
+ nextSibling = nextSibling.nextElementSibling;
+ }
+ let previousSibling = target.previousElementSibling;
+ while (
+ previousSibling &&
+ previousSibling.className.includes("preview-token")
+ ) {
+ previousSibling.classList.remove("preview-token");
+ previousSibling = previousSibling.previousElementSibling;
+ }
+}
+
+const mapStateToProps = state => ({
+ cx: getThreadContext(state),
+});
+
+const {
+ addExpression,
+ selectSourceURL,
+ openLink,
+ openElementInInspectorCommand,
+ highlightDomElement,
+ unHighlightDomElement,
+ clearPreview,
+} = actions;
+
+const mapDispatchToProps = {
+ addExpression,
+ selectSourceURL,
+ openLink,
+ openElementInInspector: openElementInInspectorCommand,
+ highlightDomElement,
+ unHighlightDomElement,
+ clearPreview,
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(Popup);
diff --git a/devtools/client/debugger/src/components/Editor/Preview/index.js b/devtools/client/debugger/src/components/Editor/Preview/index.js
new file mode 100644
index 0000000000..0e2c70c557
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Preview/index.js
@@ -0,0 +1,136 @@
+/* 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/>. */
+
+import PropTypes from "prop-types";
+import React, { PureComponent } from "react";
+import { connect } from "../../../utils/connect";
+
+import Popup from "./Popup";
+
+import {
+ getPreview,
+ getThreadContext,
+ getCurrentThread,
+ getHighlightedCalls,
+ getIsCurrentThreadPaused,
+} from "../../../selectors";
+import actions from "../../../actions";
+
+const EXCEPTION_MARKER = "mark-text-exception";
+
+class Preview extends PureComponent {
+ target = null;
+ constructor(props) {
+ super(props);
+ this.state = { selecting: false };
+ }
+
+ static get propTypes() {
+ return {
+ clearPreview: PropTypes.func.isRequired,
+ cx: PropTypes.object.isRequired,
+ editor: PropTypes.object.isRequired,
+ editorRef: PropTypes.object.isRequired,
+ highlightedCalls: PropTypes.array,
+ isPaused: PropTypes.bool.isRequired,
+ preview: PropTypes.object,
+ setExceptionPreview: PropTypes.func.isRequired,
+ updatePreview: PropTypes.func.isRequired,
+ };
+ }
+
+ componentDidMount() {
+ this.updateListeners();
+ }
+
+ componentWillUnmount() {
+ const { codeMirror } = this.props.editor;
+ const codeMirrorWrapper = codeMirror.getWrapperElement();
+
+ codeMirror.off("tokenenter", this.onTokenEnter);
+ codeMirror.off("scroll", this.onScroll);
+ codeMirrorWrapper.removeEventListener("mouseup", this.onMouseUp);
+ codeMirrorWrapper.removeEventListener("mousedown", this.onMouseDown);
+ }
+
+ updateListeners(prevProps) {
+ const { codeMirror } = this.props.editor;
+ const codeMirrorWrapper = codeMirror.getWrapperElement();
+ codeMirror.on("tokenenter", this.onTokenEnter);
+ codeMirror.on("scroll", this.onScroll);
+ codeMirrorWrapper.addEventListener("mouseup", this.onMouseUp);
+ codeMirrorWrapper.addEventListener("mousedown", this.onMouseDown);
+ }
+
+ onTokenEnter = ({ target, tokenPos }) => {
+ const { cx, editor, updatePreview, highlightedCalls, setExceptionPreview } =
+ this.props;
+
+ const isTargetException = target.classList.contains(EXCEPTION_MARKER);
+
+ if (isTargetException) {
+ setExceptionPreview(cx, target, tokenPos, editor.codeMirror);
+ return;
+ }
+
+ if (
+ this.props.isPaused &&
+ !this.state.selecting &&
+ highlightedCalls === null &&
+ !isTargetException
+ ) {
+ updatePreview(cx, target, tokenPos, editor.codeMirror);
+ }
+ };
+
+ onMouseUp = () => {
+ if (this.props.isPaused) {
+ this.setState({ selecting: false });
+ }
+ };
+
+ onMouseDown = () => {
+ if (this.props.isPaused) {
+ this.setState({ selecting: true });
+ }
+ };
+
+ onScroll = () => {
+ if (this.props.isPaused) {
+ this.props.clearPreview(this.props.cx);
+ }
+ };
+
+ render() {
+ const { preview } = this.props;
+ if (!preview || this.state.selecting) {
+ return null;
+ }
+
+ return (
+ <Popup
+ preview={preview}
+ editor={this.props.editor}
+ editorRef={this.props.editorRef}
+ />
+ );
+ }
+}
+
+const mapStateToProps = state => {
+ const thread = getCurrentThread(state);
+ return {
+ highlightedCalls: getHighlightedCalls(state, thread),
+ cx: getThreadContext(state),
+ preview: getPreview(state),
+ isPaused: getIsCurrentThreadPaused(state),
+ };
+};
+
+export default connect(mapStateToProps, {
+ clearPreview: actions.clearPreview,
+ addExpression: actions.addExpression,
+ updatePreview: actions.updatePreview,
+ setExceptionPreview: actions.setExceptionPreview,
+})(Preview);
diff --git a/devtools/client/debugger/src/components/Editor/Preview/moz.build b/devtools/client/debugger/src/components/Editor/Preview/moz.build
new file mode 100644
index 0000000000..362faadc42
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Preview/moz.build
@@ -0,0 +1,12 @@
+# 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/.
+
+DIRS += []
+
+CompiledModules(
+ "ExceptionPopup.js",
+ "index.js",
+ "Popup.js",
+)
diff --git a/devtools/client/debugger/src/components/Editor/Preview/tests/Popup.spec.js b/devtools/client/debugger/src/components/Editor/Preview/tests/Popup.spec.js
new file mode 100644
index 0000000000..8c58fe9c63
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Preview/tests/Popup.spec.js
@@ -0,0 +1,107 @@
+/* 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/>. */
+
+import {
+ addHighlightToTargetSiblings,
+ removeHighlightForTargetSiblings,
+} from "../Popup";
+
+describe("addHighlightToTargetSiblings", () => {
+ it("should add preview highlight class to related target siblings", async () => {
+ const div = document.createElement("div");
+ const divChildren = ["a", "divided", "token"];
+ divChildren.forEach(function (span) {
+ const child = document.createElement("span");
+ const text = document.createTextNode(span);
+ child.appendChild(text);
+ child.classList.add("cm-property");
+ div.appendChild(child);
+ });
+
+ const target = div.children[1];
+ const props = {
+ preview: {
+ expression: "adividedtoken",
+ },
+ };
+
+ addHighlightToTargetSiblings(target, props);
+
+ const previous = target.previousElementSibling;
+ if (previous && previous.className) {
+ expect(previous.className.includes("preview-token")).toEqual(true);
+ }
+
+ const next = target.nextElementSibling;
+ if (next && next.className) {
+ expect(next.className.includes("preview-token")).toEqual(true);
+ }
+ });
+
+ it("should not add preview highlight class to target's related siblings after non-element nodes", () => {
+ const div = document.createElement("div");
+
+ const elementBeforePeriod = document.createElement("span");
+ elementBeforePeriod.innerHTML = "object";
+ elementBeforePeriod.classList.add("cm-property");
+ div.appendChild(elementBeforePeriod);
+
+ const period = document.createTextNode(".");
+ div.appendChild(period);
+
+ const target = document.createElement("span");
+ target.innerHTML = "property";
+ target.classList.add("cm-property");
+ div.appendChild(target);
+
+ const anotherPeriod = document.createTextNode(".");
+ div.appendChild(anotherPeriod);
+
+ const elementAfterPeriod = document.createElement("span");
+ elementAfterPeriod.innerHTML = "anotherProperty";
+ elementAfterPeriod.classList.add("cm-property");
+ div.appendChild(elementAfterPeriod);
+
+ const props = {
+ preview: {
+ expression: "object.property.anotherproperty",
+ },
+ };
+ addHighlightToTargetSiblings(target, props);
+
+ expect(elementBeforePeriod.className.includes("preview-token")).toEqual(
+ false
+ );
+ expect(elementAfterPeriod.className.includes("preview-token")).toEqual(
+ false
+ );
+ });
+});
+
+describe("removeHighlightForTargetSiblings", () => {
+ it("should remove preview highlight class from target's related siblings", async () => {
+ const div = document.createElement("div");
+ const divChildren = ["a", "divided", "token"];
+ divChildren.forEach(function (span) {
+ const child = document.createElement("span");
+ const text = document.createTextNode(span);
+ child.appendChild(text);
+ child.classList.add("preview-token");
+ div.appendChild(child);
+ });
+ const target = div.children[1];
+
+ removeHighlightForTargetSiblings(target);
+
+ const previous = target.previousElementSibling;
+ if (previous && previous.className) {
+ expect(previous.className.includes("preview-token")).toEqual(false);
+ }
+
+ const next = target.nextElementSibling;
+ if (next && next.className) {
+ expect(next.className.includes("preview-token")).toEqual(false);
+ }
+ });
+});