summaryrefslogtreecommitdiffstats
path: root/devtools/client/shared/widgets/ShapesInContextEditor.js
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/shared/widgets/ShapesInContextEditor.js')
-rw-r--r--devtools/client/shared/widgets/ShapesInContextEditor.js347
1 files changed, 347 insertions, 0 deletions
diff --git a/devtools/client/shared/widgets/ShapesInContextEditor.js b/devtools/client/shared/widgets/ShapesInContextEditor.js
new file mode 100644
index 0000000000..6ee4eae5da
--- /dev/null
+++ b/devtools/client/shared/widgets/ShapesInContextEditor.js
@@ -0,0 +1,347 @@
+/* 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 EventEmitter = require("devtools/shared/event-emitter");
+const { debounce } = require("devtools/shared/debounce");
+
+/**
+ * The ShapesInContextEditor:
+ * - communicates with the ShapesHighlighter actor from the server;
+ * - listens to events for shape change and hover point coming from the shape-highlighter;
+ * - writes shape value changes to the CSS declaration it was triggered from;
+ * - synchronises highlighting coordinate points on mouse over between the shapes
+ * highlighter and the shape value shown in the Rule view.
+ *
+ * It is instantiated once in HighlightersOverlay by calls to .getInContextEditor().
+ */
+class ShapesInContextEditor {
+ constructor(highlighter, inspector, state) {
+ EventEmitter.decorate(this);
+
+ this.inspector = inspector;
+ this.highlighter = highlighter;
+ // Refence to the NodeFront currently being highlighted.
+ this.highlighterTargetNode = null;
+ this.highligherEventHandlers = {};
+ this.highligherEventHandlers["shape-change"] = this.onShapeChange;
+ this.highligherEventHandlers["shape-hover-on"] = this.onShapeHover;
+ this.highligherEventHandlers["shape-hover-off"] = this.onShapeHover;
+ // Mode for shapes highlighter: shape-outside or clip-path. Used to discern
+ // when toggling the highlighter on the same node for different CSS properties.
+ this.mode = null;
+ // Reference to Rule view used to listen for changes
+ this.ruleView = this.inspector.getPanel("ruleview").view;
+ // Reference of |state| from HighlightersOverlay.
+ this.state = state;
+ // Reference to DOM node of the toggle icon for shapes highlighter.
+ this.swatch = null;
+
+ // Commit triggers expensive DOM changes in TextPropertyEditor.update()
+ // so we debounce it.
+ this.commit = debounce(this.commit, 200, this);
+ this.onHighlighterEvent = this.onHighlighterEvent.bind(this);
+ this.onNodeFrontChanged = this.onNodeFrontChanged.bind(this);
+ this.onShapeValueUpdated = this.onShapeValueUpdated.bind(this);
+ this.onRuleViewChanged = this.onRuleViewChanged.bind(this);
+
+ this.highlighter.on("highlighter-event", this.onHighlighterEvent);
+ this.ruleView.on("ruleview-changed", this.onRuleViewChanged);
+ }
+
+ /**
+ * Get the reference to the TextProperty where shape changes should be written.
+ *
+ * We can't rely on the TextProperty to be consistent while changing the value of an
+ * inline style because the fix for Bug 1467076 forces a full rebuild of TextProperties
+ * for the inline style's mock-CSS Rule in the Rule view.
+ *
+ * On |toggle()|, we store the target TextProperty index, property name and parent rule.
+ * Here, we use that index and property name to attempt to re-identify the correct
+ * TextProperty in the rule.
+ *
+ * @return {TextProperty|null}
+ */
+ get textProperty() {
+ if (!this.rule || !this.rule.textProps) {
+ return null;
+ }
+
+ const textProp = this.rule.textProps[this.textPropIndex];
+ return textProp && textProp.name === this.textPropName ? textProp : null;
+ }
+
+ /**
+ * Called when the element style changes from the Rule view.
+ * If the TextProperty we're acting on isn't enabled anymore or overridden,
+ * turn off the shapes highlighter.
+ */
+ async onRuleViewChanged() {
+ if (
+ this.textProperty &&
+ (!this.textProperty.enabled || this.textProperty.overridden)
+ ) {
+ await this.hide();
+ }
+ }
+
+ /**
+ * Toggle the shapes highlighter for the given element.
+ *
+ * @param {NodeFront} node
+ * The NodeFront of the element with a shape to highlight.
+ * @param {Object} options
+ * Object used for passing options to the shapes highlighter.
+ */
+ async toggle(node, options, prop) {
+ // Same target node, same mode -> hide and exit OR switch to toggle transform mode.
+ if (node == this.highlighterTargetNode && this.mode === options.mode) {
+ if (!options.transformMode) {
+ await this.hide();
+ return;
+ }
+
+ options.transformMode = !this.state.shapes.options.transformMode;
+ }
+
+ // Same target node, dfferent modes -> toggle between shape-outside and clip-path.
+ // Hide highlighter for previous property, but continue and show for other property.
+ if (node == this.highlighterTargetNode && this.mode !== options.mode) {
+ await this.hide();
+ }
+
+ // Save the target TextProperty's parent rule, index and property name for later
+ // re-identification of the TextProperty. @see |get textProperty()|.
+ this.rule = prop.rule;
+ this.textPropIndex = this.rule.textProps.indexOf(prop);
+ this.textPropName = prop.name;
+
+ this.findSwatch();
+ await this.show(node, options);
+ }
+
+ /**
+ * Show the shapes highlighter for the given element.
+ *
+ * @param {NodeFront} node
+ * The NodeFront of the element with a shape to highlight.
+ * @param {Object} options
+ * Object used for passing options to the shapes highlighter.
+ */
+ async show(node, options) {
+ const isShown = await this.highlighter.show(node, options);
+ if (!isShown) {
+ return;
+ }
+
+ this.inspector.selection.on("detached-front", this.onNodeFrontChanged);
+ this.inspector.selection.on("new-node-front", this.onNodeFrontChanged);
+ this.ruleView.on("property-value-updated", this.onShapeValueUpdated);
+ this.highlighterTargetNode = node;
+ this.mode = options.mode;
+ this.emit("show", { node, options });
+ }
+
+ /**
+ * Hide the shapes highlighter.
+ */
+ async hide() {
+ try {
+ await this.highlighter.hide();
+ } catch (err) {
+ // silent error
+ }
+
+ // Stop if the panel has been destroyed during the call to hide.
+ if (this.destroyed) {
+ return;
+ }
+
+ if (this.swatch) {
+ this.swatch.classList.remove("active");
+ }
+ this.swatch = null;
+ this.rule = null;
+ this.textPropIndex = -1;
+ this.textPropName = null;
+
+ this.emit("hide", { node: this.highlighterTargetNode });
+ this.inspector.selection.off("detached-front", this.onNodeFrontChanged);
+ this.inspector.selection.off("new-node-front", this.onNodeFrontChanged);
+ this.ruleView.off("property-value-updated", this.onShapeValueUpdated);
+ this.highlighterTargetNode = null;
+ }
+
+ /**
+ * Identify the swatch (aka toggle icon) DOM node from the TextPropertyEditor of the
+ * TextProperty we're working with. Whenever the TextPropertyEditor is updated (i.e.
+ * when committing the shape value to the Rule view), it rebuilds its DOM and the old
+ * swatch reference becomes invalid. Call this method to identify the current swatch.
+ */
+ findSwatch() {
+ if (!this.textProperty) {
+ return;
+ }
+
+ const valueSpan = this.textProperty.editor.valueSpan;
+ this.swatch = valueSpan.querySelector(".ruleview-shapeswatch");
+ if (this.swatch) {
+ this.swatch.classList.add("active");
+ }
+ }
+
+ /**
+ * Handle events emitted by the highlighter.
+ * Find any callback assigned to the event type and call it with the given data object.
+ *
+ * @param {Object} data
+ * The data object sent in the event.
+ */
+ onHighlighterEvent(data) {
+ const handler = this.highligherEventHandlers[data.type];
+ if (!handler || typeof handler !== "function") {
+ return;
+ }
+ handler.call(this, data);
+ this.inspector.highlighters.emit("highlighter-event-handled");
+ }
+
+ /**
+ * Clean up when node selection changes because Rule view and TextPropertyEditor
+ * instances are not automatically destroyed when selection changes.
+ */
+ async onNodeFrontChanged() {
+ try {
+ await this.hide();
+ } catch (err) {
+ // Silent error.
+ }
+ }
+
+ /**
+ * Handler for "shape-change" event from the shapes highlighter.
+ *
+ * @param {Object} data
+ * Data associated with the "shape-change" event.
+ * Contains:
+ * - {String} value: the new shape value.
+ * - {String} type: the event type ("shape-change").
+ */
+ onShapeChange(data) {
+ this.preview(data.value);
+ this.commit(data.value);
+ }
+
+ /**
+ * Handler for "shape-hover-on" and "shape-hover-off" events from the shapes highlighter.
+ * Called when the mouse moves over or off of a coordinate point inside the shapes
+ * highlighter. Marks/unmarks the corresponding coordinate node in the shape value
+ * from the Rule view.
+ *
+ * @param {Object} data
+ * Data associated with the "shape-hover" event.
+ * Contains:
+ * - {String|null} point: coordinate to highlight or null if nothing to highlight
+ * - {String} type: the event type ("shape-hover-on" or "shape-hover-on").
+ */
+ onShapeHover(data) {
+ const shapeValueEl = this.swatch && this.swatch.nextSibling;
+ if (!shapeValueEl) {
+ return;
+ }
+
+ const pointSelector = ".ruleview-shape-point";
+ // First, unmark all highlighted coordinate nodes from Rule view
+ for (const node of shapeValueEl.querySelectorAll(
+ `${pointSelector}.active`
+ )) {
+ node.classList.remove("active");
+ }
+
+ // Exit if there's no coordinate to highlight.
+ if (typeof data.point !== "string") {
+ return;
+ }
+
+ const point = data.point.includes(",")
+ ? data.point.split(",")[0]
+ : data.point;
+
+ /**
+ * Build selector for coordinate nodes in shape value that must be highlighted.
+ * Coordinate values for inset() use class names instead of data attributes because
+ * a single node may represent multiple coordinates in shorthand notation.
+ * Example: inset(50px); The node wrapping 50px represents all four inset coordinates.
+ */
+ const INSET_POINT_TYPES = ["top", "right", "bottom", "left"];
+ const selector = INSET_POINT_TYPES.includes(point)
+ ? `${pointSelector}.${point}`
+ : `${pointSelector}[data-point='${point}']`;
+
+ for (const node of shapeValueEl.querySelectorAll(selector)) {
+ node.classList.add("active");
+ }
+ }
+
+ /**
+ * Handler for "property-value-updated" event triggered by the Rule view.
+ * Called after the shape value has been written to the element's style and the Rule
+ * view updated. Emits an event on HighlightersOverlay that is expected by
+ * tests in order to check if the shape value has been correctly applied.
+ */
+ async onShapeValueUpdated() {
+ if (this.textProperty) {
+ // When TextPropertyEditor updates, it replaces the previous swatch DOM node.
+ // Find and store the new one.
+ this.findSwatch();
+ this.inspector.highlighters.emit("shapes-highlighter-changes-applied");
+ } else {
+ await this.hide();
+ }
+ }
+
+ /**
+ * Preview a shape value on the element without committing the changes to the Rule view.
+ *
+ * @param {String} value
+ * The shape value to set the current property to
+ */
+ preview(value) {
+ if (!this.textProperty) {
+ return;
+ }
+ // Update the element's style to see live results.
+ this.textProperty.rule.previewPropertyValue(this.textProperty, value);
+ // Update the text of CSS value in the Rule view. This makes it inert.
+ // When commit() is called, the value is reparsed and its DOM structure rebuilt.
+ this.swatch.nextSibling.textContent = value;
+ }
+
+ /**
+ * Commit a shape value change which triggers an expensive operation that rebuilds
+ * part of the DOM of the TextPropertyEditor. Called in a debounced manner; see
+ * constructor.
+ *
+ * @param {String} value
+ * The shape value for the current property
+ */
+ commit(value) {
+ if (!this.textProperty) {
+ return;
+ }
+
+ this.textProperty.setValue(value);
+ }
+
+ destroy() {
+ this.highlighter.off("highlighter-event", this.onHighlighterEvent);
+ this.ruleView.off("ruleview-changed", this.onRuleViewChanged);
+ this.highligherEventHandlers = {};
+
+ this.destroyed = true;
+ }
+}
+
+module.exports = ShapesInContextEditor;