Source code
Revision control
Copy as Markdown
Other Tools
/* 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
"use strict";
const EventEmitter = require("resource://devtools/shared/event-emitter.js");
const { debounce } = require("resource://devtools/shared/debounce.js");
/**
* 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
* 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, clip-path and offset-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.setAttribute("aria-pressed", false);
}
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(".inspector-shapeswatch");
if (this.swatch) {
this.swatch.setAttribute("aria-pressed", true);
}
}
/**
* 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 = ".inspector-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;