class-list.js (8773B)
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 9 // This serves as a local cache for the classes applied to each of the node we care about 10 // here. 11 // The map is indexed by NodeFront. Any time a new node is selected in the inspector, an 12 // entry is added here, indexed by the corresponding NodeFront. 13 // The value for each entry is an array of each of the class this node has. Items of this 14 // array are objects like: { name, isApplied } where the name is the class itself, and 15 // isApplied is a Boolean indicating if the class is applied on the node or not. 16 const CLASSES = new WeakMap(); 17 18 /** 19 * Manages the list classes per DOM elements we care about. 20 * The actual list is stored in the CLASSES const, indexed by NodeFront objects. 21 * The responsibility of this class is to be the source of truth for anyone who wants to 22 * know which classes a given NodeFront has, and which of these are enabled and which are 23 * disabled. 24 * It also reacts to DOM mutations so the list of classes is up to date with what is in 25 * the DOM. 26 * It can also be used to enable/disable a given class, or add classes. 27 * 28 * @param {Inspector} inspector 29 * The current inspector instance. 30 */ 31 class ClassList { 32 constructor(inspector) { 33 EventEmitter.decorate(this); 34 35 this.inspector = inspector; 36 37 this.onMutations = this.onMutations.bind(this); 38 this.inspector.on("markupmutation", this.onMutations); 39 40 this.classListProxyNode = this.inspector.panelDoc.createElement("div"); 41 this.previewClasses = []; 42 this.unresolvedStateChanges = []; 43 } 44 45 destroy() { 46 this.inspector.off("markupmutation", this.onMutations); 47 this.inspector = null; 48 this.classListProxyNode = null; 49 } 50 51 /** 52 * The current node selection (which only returns if the node is an ELEMENT_NODE type 53 * since that's the only type this model can work with.) 54 */ 55 get currentNode() { 56 if ( 57 this.inspector.selection.isElementNode() && 58 !this.inspector.selection.isPseudoElementNode() 59 ) { 60 return this.inspector.selection.nodeFront; 61 } 62 return null; 63 } 64 65 /** 66 * The class states for the current node selection. See the documentation of the CLASSES 67 * constant. 68 */ 69 get currentClasses() { 70 if (!this.currentNode) { 71 return []; 72 } 73 74 if (!CLASSES.has(this.currentNode)) { 75 // Use the proxy node to get a clean list of classes. 76 this.classListProxyNode.className = this.currentNode.className; 77 const nodeClasses = [...new Set([...this.classListProxyNode.classList])] 78 .filter( 79 className => 80 !this.previewClasses.some( 81 previewClass => 82 previewClass.className === className && 83 !previewClass.wasAppliedOnNode 84 ) 85 ) 86 .map(name => { 87 return { name, isApplied: true }; 88 }); 89 90 CLASSES.set(this.currentNode, nodeClasses); 91 } 92 93 return CLASSES.get(this.currentNode); 94 } 95 96 /** 97 * Same as currentClasses, but returns it in the form of a className string, where only 98 * enabled classes are added. 99 */ 100 get currentClassesPreview() { 101 const currentClasses = this.currentClasses 102 .filter(({ isApplied }) => isApplied) 103 .map(({ name }) => name); 104 const previewClasses = this.previewClasses 105 .filter(previewClass => !currentClasses.includes(previewClass.className)) 106 .filter(item => item !== "") 107 .map(({ className }) => className); 108 109 return currentClasses.concat(previewClasses).join(" ").trim(); 110 } 111 112 /** 113 * Set the state for a given class on the current node. 114 * 115 * @param {string} name 116 * The class which state should be changed. 117 * @param {boolean} isApplied 118 * True if the class should be enabled, false otherwise. 119 * @return {Promise} Resolves when the change has been made in the DOM. 120 */ 121 setClassState(name, isApplied) { 122 // Do the change in our local model. 123 const nodeClasses = this.currentClasses; 124 nodeClasses.find(({ name: cName }) => cName === name).isApplied = isApplied; 125 126 return this.applyClassState(); 127 } 128 129 /** 130 * Add several classes to the current node at once. 131 * 132 * @param {string} classNameString 133 * The string that contains all classes. 134 * @return {Promise} Resolves when the change has been made in the DOM. 135 */ 136 addClassName(classNameString) { 137 this.classListProxyNode.className = classNameString; 138 this.eraseClassPreview(); 139 return Promise.all( 140 [...new Set([...this.classListProxyNode.classList])].map(name => { 141 return this.addClass(name); 142 }) 143 ); 144 } 145 146 /** 147 * Add a class to the current node at once. 148 * 149 * @param {string} name 150 * The class to be added. 151 * @return {Promise} Resolves when the change has been made in the DOM. 152 */ 153 addClass(name) { 154 // Avoid adding the same class again. 155 if (this.currentClasses.some(({ name: cName }) => cName === name)) { 156 return Promise.resolve(); 157 } 158 159 // Change the local model, so we retain the state of the existing classes. 160 this.currentClasses.push({ name, isApplied: true }); 161 162 return this.applyClassState(); 163 } 164 165 /** 166 * Used internally by other functions like addClass or setClassState. Actually applies 167 * the class change to the DOM. 168 * 169 * @return {Promise} Resolves when the change has been made in the DOM. 170 */ 171 applyClassState() { 172 // If there is no valid inspector selection, bail out silently. No need to report an 173 // error here. 174 if (!this.currentNode) { 175 return Promise.resolve(); 176 } 177 178 // Remember which node & className we applied until their mutation event is received, so we 179 // can filter out dom mutations that are caused by us in onMutations, even in situations when 180 // a new change is applied before that the event of the previous one has been received yet 181 this.unresolvedStateChanges.push({ 182 node: this.currentNode, 183 className: this.currentClassesPreview, 184 }); 185 186 // Apply the change to the node. 187 const mod = this.currentNode.startModifyingAttributes(); 188 mod.setAttribute("class", this.currentClassesPreview); 189 return mod.apply(); 190 } 191 192 onMutations(mutations) { 193 for (const { type, target, attributeName } of mutations) { 194 // Only care if this mutation is for the class attribute. 195 if (type !== "attributes" || attributeName !== "class") { 196 continue; 197 } 198 199 const isMutationForOurChange = this.unresolvedStateChanges.some( 200 previousStateChange => 201 previousStateChange.node === target && 202 previousStateChange.className === target.className 203 ); 204 205 if (!isMutationForOurChange) { 206 CLASSES.delete(target); 207 if (target === this.currentNode) { 208 this.emit("current-node-class-changed"); 209 } 210 } else { 211 this.removeResolvedStateChanged(target, target.className); 212 } 213 } 214 } 215 216 /** 217 * Get the available classNames in the document where the current selected node lives: 218 * - the one already used on elements of the document 219 * - the one defined in Stylesheets of the document 220 * 221 * @param {string} filter: A string the classNames should start with (an insensitive 222 * case matching will be done). 223 * @returns {Promise<Array<string>>} A promise that resolves with an array of strings 224 * matching the passed filter. 225 */ 226 getClassNames(filter) { 227 return this.currentNode.inspectorFront.pageStyle.getAttributesInOwnerDocument( 228 filter, 229 "class", 230 this.currentNode 231 ); 232 } 233 234 previewClass(inputClasses) { 235 if ( 236 this.previewClasses 237 .map(previewClass => previewClass.className) 238 .join(" ") !== inputClasses 239 ) { 240 this.previewClasses = []; 241 inputClasses.split(" ").forEach(className => { 242 this.previewClasses.push({ 243 className, 244 wasAppliedOnNode: this.isClassAlreadyApplied(className), 245 }); 246 }); 247 this.applyClassState(); 248 } 249 } 250 251 eraseClassPreview() { 252 this.previewClass(""); 253 } 254 255 removeResolvedStateChanged(currentNode, currentClassesPreview) { 256 this.unresolvedStateChanges.splice( 257 0, 258 this.unresolvedStateChanges.findIndex( 259 previousState => 260 previousState.node === currentNode && 261 previousState.className === currentClassesPreview 262 ) + 1 263 ); 264 } 265 266 isClassAlreadyApplied(className) { 267 return this.currentClasses.some(({ name }) => name === className); 268 } 269 } 270 271 module.exports = ClassList;