ShapesInContextEditor.js (12103B)
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 "use strict"; 6 7 const EventEmitter = require("resource://devtools/shared/event-emitter.js"); 8 const { debounce } = require("resource://devtools/shared/debounce.js"); 9 10 /** 11 * The ShapesInContextEditor: 12 * - communicates with the ShapesHighlighter actor from the server; 13 * - listens to events for shape change and hover point coming from the shape-highlighter; 14 * - writes shape value changes to the CSS declaration it was triggered from; 15 * - synchronises highlighting coordinate points on mouse over between the shapes 16 * highlighter and the shape value shown in the Rule view. 17 * 18 * It is instantiated once in HighlightersOverlay by calls to .getInContextEditor(). 19 */ 20 class ShapesInContextEditor { 21 constructor(highlighter, inspector, state) { 22 EventEmitter.decorate(this); 23 24 this.inspector = inspector; 25 this.highlighter = highlighter; 26 // Refence to the NodeFront currently being highlighted. 27 this.highlighterTargetNode = null; 28 this.highligherEventHandlers = {}; 29 this.highligherEventHandlers["shape-change"] = this.onShapeChange; 30 this.highligherEventHandlers["shape-hover-on"] = this.onShapeHover; 31 this.highligherEventHandlers["shape-hover-off"] = this.onShapeHover; 32 // Mode for shapes highlighter: shape-outside or clip-path. Used to discern 33 // when toggling the highlighter on the same node for different CSS properties. 34 this.mode = null; 35 // Reference to Rule view used to listen for changes 36 this.ruleView = this.inspector.getPanel("ruleview").view; 37 // Reference of |state| from HighlightersOverlay. 38 this.state = state; 39 // Reference to DOM node of the toggle icon for shapes highlighter. 40 this.swatch = null; 41 42 // Commit triggers expensive DOM changes in TextPropertyEditor.update() 43 // so we debounce it. 44 this.commit = debounce(this.commit, 200, this); 45 this.onHighlighterEvent = this.onHighlighterEvent.bind(this); 46 this.onNodeFrontChanged = this.onNodeFrontChanged.bind(this); 47 this.onShapeValueUpdated = this.onShapeValueUpdated.bind(this); 48 this.onRuleViewChanged = this.onRuleViewChanged.bind(this); 49 50 this.highlighter.on("highlighter-event", this.onHighlighterEvent); 51 this.ruleView.on("ruleview-changed", this.onRuleViewChanged); 52 } 53 54 /** 55 * Get the reference to the TextProperty where shape changes should be written. 56 * 57 * We can't rely on the TextProperty to be consistent while changing the value of an 58 * inline style because the fix for Bug 1467076 forces a full rebuild of TextProperties 59 * for the inline style's mock-CSS Rule in the Rule view. 60 * 61 * On |toggle()|, we store the target TextProperty index, property name and parent rule. 62 * Here, we use that index and property name to attempt to re-identify the correct 63 * TextProperty in the rule. 64 * 65 * @return {TextProperty|null} 66 */ 67 get textProperty() { 68 if (!this.rule || !this.rule.textProps) { 69 return null; 70 } 71 72 const textProp = this.rule.textProps[this.textPropIndex]; 73 return textProp && textProp.name === this.textPropName ? textProp : null; 74 } 75 76 /** 77 * Called when the element style changes from the Rule view. 78 * If the TextProperty we're acting on isn't enabled anymore or overridden, 79 * turn off the shapes highlighter. 80 */ 81 async onRuleViewChanged() { 82 if ( 83 this.textProperty && 84 (!this.textProperty.enabled || this.textProperty.overridden) 85 ) { 86 await this.hide(); 87 } 88 } 89 90 /** 91 * Toggle the shapes highlighter for the given element. 92 * 93 * @param {NodeFront} node 94 * The NodeFront of the element with a shape to highlight. 95 * @param {object} options 96 * Object used for passing options to the shapes highlighter. 97 */ 98 async toggle(node, options, prop) { 99 // Same target node, same mode -> hide and exit OR switch to toggle transform mode. 100 if (node == this.highlighterTargetNode && this.mode === options.mode) { 101 if (!options.transformMode) { 102 await this.hide(); 103 return; 104 } 105 106 options.transformMode = !this.state.shapes.options.transformMode; 107 } 108 109 // Same target node, dfferent modes -> toggle between shape-outside, clip-path and offset-path. 110 // Hide highlighter for previous property, but continue and show for other property. 111 if (node == this.highlighterTargetNode && this.mode !== options.mode) { 112 await this.hide(); 113 } 114 115 // Save the target TextProperty's parent rule, index and property name for later 116 // re-identification of the TextProperty. @see |get textProperty()|. 117 this.rule = prop.rule; 118 this.textPropIndex = this.rule.textProps.indexOf(prop); 119 this.textPropName = prop.name; 120 121 this.findSwatch(); 122 await this.show(node, options); 123 } 124 125 /** 126 * Show the shapes highlighter for the given element. 127 * 128 * @param {NodeFront} node 129 * The NodeFront of the element with a shape to highlight. 130 * @param {object} options 131 * Object used for passing options to the shapes highlighter. 132 */ 133 async show(node, options) { 134 const isShown = await this.highlighter.show(node, options); 135 if (!isShown) { 136 return; 137 } 138 139 this.inspector.selection.on("detached-front", this.onNodeFrontChanged); 140 this.inspector.selection.on("new-node-front", this.onNodeFrontChanged); 141 this.ruleView.on("property-value-updated", this.onShapeValueUpdated); 142 this.highlighterTargetNode = node; 143 this.mode = options.mode; 144 this.emit("show", { node, options }); 145 } 146 147 /** 148 * Hide the shapes highlighter. 149 */ 150 async hide() { 151 try { 152 await this.highlighter.hide(); 153 } catch (err) { 154 // silent error 155 } 156 157 // Stop if the panel has been destroyed during the call to hide. 158 if (this.destroyed) { 159 return; 160 } 161 162 if (this.swatch) { 163 this.swatch.setAttribute("aria-pressed", false); 164 } 165 this.swatch = null; 166 this.rule = null; 167 this.textPropIndex = -1; 168 this.textPropName = null; 169 170 this.emit("hide", { node: this.highlighterTargetNode }); 171 this.inspector.selection.off("detached-front", this.onNodeFrontChanged); 172 this.inspector.selection.off("new-node-front", this.onNodeFrontChanged); 173 this.ruleView.off("property-value-updated", this.onShapeValueUpdated); 174 this.highlighterTargetNode = null; 175 } 176 177 /** 178 * Identify the swatch (aka toggle icon) DOM node from the TextPropertyEditor of the 179 * TextProperty we're working with. Whenever the TextPropertyEditor is updated (i.e. 180 * when committing the shape value to the Rule view), it rebuilds its DOM and the old 181 * swatch reference becomes invalid. Call this method to identify the current swatch. 182 */ 183 findSwatch() { 184 if (!this.textProperty) { 185 return; 186 } 187 188 const valueSpan = this.textProperty.editor.valueSpan; 189 this.swatch = valueSpan.querySelector(".inspector-shapeswatch"); 190 if (this.swatch) { 191 this.swatch.setAttribute("aria-pressed", true); 192 } 193 } 194 195 /** 196 * Handle events emitted by the highlighter. 197 * Find any callback assigned to the event type and call it with the given data object. 198 * 199 * @param {object} data 200 * The data object sent in the event. 201 */ 202 onHighlighterEvent(data) { 203 const handler = this.highligherEventHandlers[data.type]; 204 if (!handler || typeof handler !== "function") { 205 return; 206 } 207 handler.call(this, data); 208 this.inspector.highlighters.emit("highlighter-event-handled"); 209 } 210 211 /** 212 * Clean up when node selection changes because Rule view and TextPropertyEditor 213 * instances are not automatically destroyed when selection changes. 214 */ 215 async onNodeFrontChanged() { 216 try { 217 await this.hide(); 218 } catch (err) { 219 // Silent error. 220 } 221 } 222 223 /** 224 * Handler for "shape-change" event from the shapes highlighter. 225 * 226 * @param {object} data 227 * Data associated with the "shape-change" event. 228 * Contains: 229 * - {String} value: the new shape value. 230 * - {String} type: the event type ("shape-change"). 231 */ 232 onShapeChange(data) { 233 this.preview(data.value); 234 this.commit(data.value); 235 } 236 237 /** 238 * Handler for "shape-hover-on" and "shape-hover-off" events from the shapes highlighter. 239 * Called when the mouse moves over or off of a coordinate point inside the shapes 240 * highlighter. Marks/unmarks the corresponding coordinate node in the shape value 241 * from the Rule view. 242 * 243 * @param {object} data 244 * Data associated with the "shape-hover" event. 245 * Contains: 246 * - {String|null} point: coordinate to highlight or null if nothing to highlight 247 * - {String} type: the event type ("shape-hover-on" or "shape-hover-on"). 248 */ 249 onShapeHover(data) { 250 const shapeValueEl = this.swatch && this.swatch.nextSibling; 251 if (!shapeValueEl) { 252 return; 253 } 254 255 const pointSelector = ".inspector-shape-point"; 256 // First, unmark all highlighted coordinate nodes from Rule view 257 for (const node of shapeValueEl.querySelectorAll( 258 `${pointSelector}.active` 259 )) { 260 node.classList.remove("active"); 261 } 262 263 // Exit if there's no coordinate to highlight. 264 if (typeof data.point !== "string") { 265 return; 266 } 267 268 const point = data.point.includes(",") 269 ? data.point.split(",")[0] 270 : data.point; 271 272 /** 273 * Build selector for coordinate nodes in shape value that must be highlighted. 274 * Coordinate values for inset() use class names instead of data attributes because 275 * a single node may represent multiple coordinates in shorthand notation. 276 * Example: inset(50px); The node wrapping 50px represents all four inset coordinates. 277 */ 278 const INSET_POINT_TYPES = ["top", "right", "bottom", "left"]; 279 const selector = INSET_POINT_TYPES.includes(point) 280 ? `${pointSelector}.${point}` 281 : `${pointSelector}[data-point='${point}']`; 282 283 for (const node of shapeValueEl.querySelectorAll(selector)) { 284 node.classList.add("active"); 285 } 286 } 287 288 /** 289 * Handler for "property-value-updated" event triggered by the Rule view. 290 * Called after the shape value has been written to the element's style and the Rule 291 * view updated. Emits an event on HighlightersOverlay that is expected by 292 * tests in order to check if the shape value has been correctly applied. 293 */ 294 async onShapeValueUpdated() { 295 if (this.textProperty) { 296 // When TextPropertyEditor updates, it replaces the previous swatch DOM node. 297 // Find and store the new one. 298 this.findSwatch(); 299 this.inspector.highlighters.emit("shapes-highlighter-changes-applied"); 300 } else { 301 await this.hide(); 302 } 303 } 304 305 /** 306 * Preview a shape value on the element without committing the changes to the Rule view. 307 * 308 * @param {string} value 309 * The shape value to set the current property to 310 */ 311 preview(value) { 312 if (!this.textProperty) { 313 return; 314 } 315 // Update the element's style to see live results. 316 this.textProperty.rule.previewPropertyValue(this.textProperty, value); 317 // Update the text of CSS value in the Rule view. This makes it inert. 318 // When commit() is called, the value is reparsed and its DOM structure rebuilt. 319 this.swatch.nextSibling.textContent = value; 320 } 321 322 /** 323 * Commit a shape value change which triggers an expensive operation that rebuilds 324 * part of the DOM of the TextPropertyEditor. Called in a debounced manner; see 325 * constructor. 326 * 327 * @param {string} value 328 * The shape value for the current property 329 */ 330 commit(value) { 331 if (!this.textProperty) { 332 return; 333 } 334 335 this.textProperty.setValue(value); 336 } 337 338 destroy() { 339 this.highlighter.off("highlighter-event", this.onHighlighterEvent); 340 this.ruleView.off("ruleview-changed", this.onRuleViewChanged); 341 this.highligherEventHandlers = {}; 342 343 this.destroyed = true; 344 } 345 } 346 347 module.exports = ShapesInContextEditor;