style-inspector-menu.js (14236B)
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 { 8 VIEW_NODE_SELECTOR_TYPE, 9 VIEW_NODE_PROPERTY_TYPE, 10 VIEW_NODE_VALUE_TYPE, 11 VIEW_NODE_IMAGE_URL_TYPE, 12 VIEW_NODE_LOCATION_TYPE, 13 } = require("resource://devtools/client/inspector/shared/node-types.js"); 14 15 loader.lazyRequireGetter( 16 this, 17 "Menu", 18 "resource://devtools/client/framework/menu.js" 19 ); 20 loader.lazyRequireGetter( 21 this, 22 "MenuItem", 23 "resource://devtools/client/framework/menu-item.js" 24 ); 25 loader.lazyRequireGetter( 26 this, 27 "getRuleFromNode", 28 "resource://devtools/client/inspector/rules/utils/utils.js", 29 true 30 ); 31 loader.lazyRequireGetter( 32 this, 33 "clipboardHelper", 34 "resource://devtools/shared/platform/clipboard.js" 35 ); 36 37 const STYLE_INSPECTOR_PROPERTIES = 38 "devtools/shared/locales/styleinspector.properties"; 39 const { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); 40 const STYLE_INSPECTOR_L10N = new LocalizationHelper(STYLE_INSPECTOR_PROPERTIES); 41 42 const PREF_ORIG_SOURCES = "devtools.source-map.client-service.enabled"; 43 44 /** 45 * Style inspector context menu 46 */ 47 class StyleInspectorMenu { 48 /** 49 * @param {RuleView|ComputedView} view 50 * RuleView or ComputedView instance controlling this menu 51 * @param {object} options 52 * Option menu configuration 53 */ 54 constructor(view, { isRuleView = false } = {}) { 55 this.view = view; 56 this.inspector = this.view.inspector; 57 this.styleWindow = this.view.styleWindow || this.view.doc.defaultView; 58 this.isRuleView = isRuleView; 59 60 this._onCopy = this._onCopy.bind(this); 61 this._onCopyColor = this._onCopyColor.bind(this); 62 this._onCopyImageDataUrl = this._onCopyImageDataUrl.bind(this); 63 this._onCopyLocation = this._onCopyLocation.bind(this); 64 this._onCopyDeclaration = this._onCopyDeclaration.bind(this); 65 this._onCopyPropertyName = this._onCopyPropertyName.bind(this); 66 this._onCopyPropertyValue = this._onCopyPropertyValue.bind(this); 67 this._onCopyRule = this._onCopyRule.bind(this); 68 this._onCopySelector = this._onCopySelector.bind(this); 69 this._onCopyUrl = this._onCopyUrl.bind(this); 70 this._onSelectAll = this._onSelectAll.bind(this); 71 this._onToggleOrigSources = this._onToggleOrigSources.bind(this); 72 } 73 /** 74 * Display the style inspector context menu 75 */ 76 show(event) { 77 try { 78 this._openMenu({ 79 target: event.target, 80 screenX: event.screenX, 81 screenY: event.screenY, 82 }); 83 } catch (e) { 84 console.error(e); 85 } 86 } 87 88 _openMenu({ target, screenX = 0, screenY = 0 } = {}) { 89 this.currentTarget = target; 90 this.styleWindow.focus(); 91 92 const menu = new Menu(); 93 94 const menuitemCopy = new MenuItem({ 95 label: STYLE_INSPECTOR_L10N.getStr("styleinspector.contextmenu.copy"), 96 accesskey: STYLE_INSPECTOR_L10N.getStr( 97 "styleinspector.contextmenu.copy.accessKey" 98 ), 99 click: () => { 100 this._onCopy(); 101 }, 102 disabled: !this._hasTextSelected(), 103 }); 104 const menuitemCopyLocation = new MenuItem({ 105 label: STYLE_INSPECTOR_L10N.getStr( 106 "styleinspector.contextmenu.copyLocation" 107 ), 108 click: () => { 109 this._onCopyLocation(); 110 }, 111 visible: false, 112 }); 113 const menuitemCopyRule = new MenuItem({ 114 label: STYLE_INSPECTOR_L10N.getStr("styleinspector.contextmenu.copyRule"), 115 click: () => { 116 this._onCopyRule(); 117 }, 118 visible: 119 // `_onCopyRule` calls `getRuleFromNode`, which retrieves the target closest 120 // ancestor element with a data-rule-id attribute. If there's no such element, 121 // we're not inside a rule and we shouldn't display this context menu item. 122 this.isRuleView && !!target.closest(".ruleview-rule[data-rule-id]"), 123 }); 124 const copyColorAccessKey = "styleinspector.contextmenu.copyColor.accessKey"; 125 const menuitemCopyColor = new MenuItem({ 126 label: STYLE_INSPECTOR_L10N.getStr( 127 "styleinspector.contextmenu.copyColor" 128 ), 129 accesskey: STYLE_INSPECTOR_L10N.getStr(copyColorAccessKey), 130 click: () => { 131 this._onCopyColor(); 132 }, 133 visible: this._isColorPopup(), 134 }); 135 const copyUrlAccessKey = "styleinspector.contextmenu.copyUrl.accessKey"; 136 const menuitemCopyUrl = new MenuItem({ 137 label: STYLE_INSPECTOR_L10N.getStr("styleinspector.contextmenu.copyUrl"), 138 accesskey: STYLE_INSPECTOR_L10N.getStr(copyUrlAccessKey), 139 click: () => { 140 this._onCopyUrl(); 141 }, 142 visible: this._isImageUrl(), 143 }); 144 const copyImageAccessKey = 145 "styleinspector.contextmenu.copyImageDataUrl.accessKey"; 146 const menuitemCopyImageDataUrl = new MenuItem({ 147 label: STYLE_INSPECTOR_L10N.getStr( 148 "styleinspector.contextmenu.copyImageDataUrl" 149 ), 150 accesskey: STYLE_INSPECTOR_L10N.getStr(copyImageAccessKey), 151 click: () => { 152 this._onCopyImageDataUrl(); 153 }, 154 visible: this._isImageUrl(), 155 }); 156 const copyDeclarationLabel = "styleinspector.contextmenu.copyDeclaration"; 157 const menuitemCopyDeclaration = new MenuItem({ 158 label: STYLE_INSPECTOR_L10N.getStr(copyDeclarationLabel), 159 click: () => { 160 this._onCopyDeclaration(); 161 }, 162 visible: false, 163 }); 164 const menuitemCopyPropertyName = new MenuItem({ 165 label: STYLE_INSPECTOR_L10N.getStr( 166 "styleinspector.contextmenu.copyPropertyName" 167 ), 168 click: () => { 169 this._onCopyPropertyName(); 170 }, 171 visible: false, 172 }); 173 const menuitemCopyPropertyValue = new MenuItem({ 174 label: STYLE_INSPECTOR_L10N.getStr( 175 "styleinspector.contextmenu.copyPropertyValue" 176 ), 177 click: () => { 178 this._onCopyPropertyValue(); 179 }, 180 visible: false, 181 }); 182 const menuitemCopySelector = new MenuItem({ 183 label: STYLE_INSPECTOR_L10N.getStr( 184 "styleinspector.contextmenu.copySelector" 185 ), 186 click: () => { 187 this._onCopySelector(); 188 }, 189 visible: false, 190 }); 191 192 this._clickedNodeInfo = this._getClickedNodeInfo(); 193 if (this.isRuleView && this._clickedNodeInfo) { 194 switch (this._clickedNodeInfo.type) { 195 case VIEW_NODE_PROPERTY_TYPE: 196 menuitemCopyDeclaration.visible = true; 197 menuitemCopyPropertyName.visible = true; 198 break; 199 case VIEW_NODE_VALUE_TYPE: 200 menuitemCopyDeclaration.visible = true; 201 menuitemCopyPropertyValue.visible = true; 202 break; 203 case VIEW_NODE_SELECTOR_TYPE: 204 menuitemCopySelector.visible = true; 205 break; 206 case VIEW_NODE_LOCATION_TYPE: 207 menuitemCopyLocation.visible = true; 208 break; 209 } 210 } 211 212 menu.append(menuitemCopy); 213 menu.append(menuitemCopyLocation); 214 menu.append(menuitemCopyRule); 215 menu.append(menuitemCopyColor); 216 menu.append(menuitemCopyUrl); 217 menu.append(menuitemCopyImageDataUrl); 218 menu.append(menuitemCopyDeclaration); 219 menu.append(menuitemCopyPropertyName); 220 menu.append(menuitemCopyPropertyValue); 221 menu.append(menuitemCopySelector); 222 223 menu.append( 224 new MenuItem({ 225 type: "separator", 226 }) 227 ); 228 229 // Select All 230 const selectAllAccessKey = "styleinspector.contextmenu.selectAll.accessKey"; 231 const menuitemSelectAll = new MenuItem({ 232 label: STYLE_INSPECTOR_L10N.getStr( 233 "styleinspector.contextmenu.selectAll" 234 ), 235 accesskey: STYLE_INSPECTOR_L10N.getStr(selectAllAccessKey), 236 click: () => { 237 this._onSelectAll(); 238 }, 239 }); 240 menu.append(menuitemSelectAll); 241 242 menu.append( 243 new MenuItem({ 244 type: "separator", 245 }) 246 ); 247 248 // Add new rule 249 const addRuleAccessKey = "styleinspector.contextmenu.addNewRule.accessKey"; 250 const menuitemAddRule = new MenuItem({ 251 label: STYLE_INSPECTOR_L10N.getStr( 252 "styleinspector.contextmenu.addNewRule" 253 ), 254 accesskey: STYLE_INSPECTOR_L10N.getStr(addRuleAccessKey), 255 click: () => this.view.addNewRule(), 256 visible: this.isRuleView, 257 disabled: !this.isRuleView || !this.view.canAddNewRuleForSelectedNode(), 258 }); 259 menu.append(menuitemAddRule); 260 261 // Show Original Sources 262 const sourcesAccessKey = 263 "styleinspector.contextmenu.toggleOrigSources.accessKey"; 264 const menuitemSources = new MenuItem({ 265 label: STYLE_INSPECTOR_L10N.getStr( 266 "styleinspector.contextmenu.toggleOrigSources" 267 ), 268 accesskey: STYLE_INSPECTOR_L10N.getStr(sourcesAccessKey), 269 click: () => { 270 this._onToggleOrigSources(); 271 }, 272 type: "checkbox", 273 checked: Services.prefs.getBoolPref(PREF_ORIG_SOURCES), 274 }); 275 menu.append(menuitemSources); 276 277 menu.popup(screenX, screenY, this.inspector.toolbox.doc); 278 return menu; 279 } 280 281 _hasTextSelected() { 282 let hasTextSelected; 283 const selection = this.styleWindow.getSelection(); 284 285 const node = this._getClickedNode(); 286 if (node.nodeName == "input" || node.nodeName == "textarea") { 287 const { selectionStart, selectionEnd } = node; 288 hasTextSelected = 289 isFinite(selectionStart) && 290 isFinite(selectionEnd) && 291 selectionStart !== selectionEnd; 292 } else { 293 hasTextSelected = selection.toString() && !selection.isCollapsed; 294 } 295 296 return hasTextSelected; 297 } 298 299 /** 300 * Get the type of the currently clicked node 301 */ 302 _getClickedNodeInfo() { 303 const node = this._getClickedNode(); 304 return this.view.getNodeInfo(node); 305 } 306 307 /** 308 * A helper that determines if the popup was opened with a click to a color 309 * value and saves the color to this._colorToCopy. 310 * 311 * @return {boolean} 312 * true if click on color opened the popup, false otherwise. 313 */ 314 _isColorPopup() { 315 this._colorToCopy = ""; 316 317 const container = this._getClickedNode(); 318 if (!container) { 319 return false; 320 } 321 322 const colorNode = container.closest("[data-color]"); 323 if (!colorNode) { 324 return false; 325 } 326 327 this._colorToCopy = colorNode.dataset.color; 328 return true; 329 } 330 331 /** 332 * Check if the current node (clicked node) is an image URL 333 * 334 * @return {boolean} true if the node is an image url 335 */ 336 _isImageUrl() { 337 const nodeInfo = this._getClickedNodeInfo(); 338 if (!nodeInfo) { 339 return false; 340 } 341 return nodeInfo.type == VIEW_NODE_IMAGE_URL_TYPE; 342 } 343 344 /** 345 * Get the DOM Node container for the current target node. 346 * If the target node is a text node, return the parent node, otherwise return 347 * the target node itself. 348 * 349 * @return {DOMNode} 350 */ 351 _getClickedNode() { 352 const node = this.currentTarget; 353 354 if (!node) { 355 return null; 356 } 357 358 return node.nodeType === node.TEXT_NODE ? node.parentElement : node; 359 } 360 361 /** 362 * Select all text. 363 */ 364 _onSelectAll() { 365 const selection = this.styleWindow.getSelection(); 366 367 if (this.isRuleView) { 368 selection.selectAllChildren( 369 this.currentTarget.closest("#ruleview-container-focusable") 370 ); 371 } else { 372 selection.selectAllChildren(this.view.element); 373 } 374 } 375 376 /** 377 * Copy the most recently selected color value to clipboard. 378 */ 379 _onCopy() { 380 this.view.copySelection(this.currentTarget); 381 } 382 383 /** 384 * Copy the most recently selected color value to clipboard. 385 */ 386 _onCopyColor() { 387 clipboardHelper.copyString(this._colorToCopy); 388 } 389 390 /* 391 * Retrieve the url for the selected image and copy it to the clipboard 392 */ 393 _onCopyUrl() { 394 if (!this._clickedNodeInfo) { 395 return; 396 } 397 398 clipboardHelper.copyString(this._clickedNodeInfo.value.url); 399 } 400 401 /** 402 * Retrieve the image data for the selected image url and copy it to the 403 * clipboard 404 */ 405 async _onCopyImageDataUrl() { 406 if (!this._clickedNodeInfo) { 407 return; 408 } 409 410 let message; 411 try { 412 const inspectorFront = this.inspector.inspectorFront; 413 const imageUrl = this._clickedNodeInfo.value.url; 414 const data = await inspectorFront.getImageDataFromURL(imageUrl); 415 message = await data.data.string(); 416 } catch (e) { 417 message = STYLE_INSPECTOR_L10N.getStr( 418 "styleinspector.copyImageDataUrlError" 419 ); 420 } 421 422 clipboardHelper.copyString(message); 423 } 424 425 /** 426 * Copy the rule source location of the current clicked node. 427 */ 428 _onCopyLocation() { 429 if (!this._clickedNodeInfo) { 430 return; 431 } 432 433 clipboardHelper.copyString(this._clickedNodeInfo.value); 434 } 435 436 /** 437 * Copy the CSS declaration of the current clicked node. 438 */ 439 _onCopyDeclaration() { 440 if (!this._clickedNodeInfo) { 441 return; 442 } 443 444 const textProp = this._clickedNodeInfo.value.textProperty; 445 clipboardHelper.copyString(textProp.stringifyProperty()); 446 } 447 448 /** 449 * Copy the rule property name of the current clicked node. 450 */ 451 _onCopyPropertyName() { 452 if (!this._clickedNodeInfo) { 453 return; 454 } 455 456 clipboardHelper.copyString(this._clickedNodeInfo.value.property); 457 } 458 459 /** 460 * Copy the rule property value of the current clicked node. 461 */ 462 _onCopyPropertyValue() { 463 if (!this._clickedNodeInfo) { 464 return; 465 } 466 467 clipboardHelper.copyString(this._clickedNodeInfo.value.value); 468 } 469 470 /** 471 * Copy the rule of the current clicked node. 472 */ 473 _onCopyRule() { 474 const node = this._getClickedNode(); 475 const rule = getRuleFromNode(node, this.view._elementStyle); 476 if (!rule) { 477 console.error("Can't copy rule, no rule found for node", node); 478 return; 479 } 480 clipboardHelper.copyString(rule.stringifyRule()); 481 } 482 483 /** 484 * Copy the rule selector of the current clicked node. 485 */ 486 _onCopySelector() { 487 if (!this._clickedNodeInfo) { 488 return; 489 } 490 491 clipboardHelper.copyString(this._clickedNodeInfo.value); 492 } 493 494 /** 495 * Toggle the original sources pref. 496 */ 497 _onToggleOrigSources() { 498 const isEnabled = Services.prefs.getBoolPref(PREF_ORIG_SOURCES); 499 Services.prefs.setBoolPref(PREF_ORIG_SOURCES, !isEnabled); 500 } 501 502 destroy() { 503 this.currentTarget = null; 504 this.view = null; 505 this.inspector = null; 506 this.styleWindow = null; 507 } 508 } 509 510 module.exports = StyleInspectorMenu;