FeatureCallout.sys.mjs (95985B)
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 const lazy = {}; 6 7 ChromeUtils.defineESModuleGetters(lazy, { 8 AboutWelcomeParent: "resource:///actors/AboutWelcomeParent.sys.mjs", 9 ASRouter: "resource:///modules/asrouter/ASRouter.sys.mjs", 10 CustomizableUI: 11 "moz-src:///browser/components/customizableui/CustomizableUI.sys.mjs", 12 PageEventManager: "resource:///modules/asrouter/PageEventManager.sys.mjs", 13 }); 14 15 const TRANSITION_MS = 500; 16 const CONTAINER_ID = "feature-callout"; 17 const CONTENT_BOX_ID = "multi-stage-message-root"; 18 const BUNDLE_SRC = 19 "chrome://browser/content/aboutwelcome/aboutwelcome.bundle.js"; 20 21 ChromeUtils.defineLazyGetter(lazy, "log", () => { 22 const { Logger } = ChromeUtils.importESModule( 23 "resource://messaging-system/lib/Logger.sys.mjs" 24 ); 25 return new Logger("FeatureCallout"); 26 }); 27 28 /** 29 * Feature Callout fetches messages relevant to a given source and displays them 30 * in the parent page pointing to the element they describe. 31 */ 32 export class FeatureCallout { 33 /** 34 * @typedef {object} FeatureCalloutOptions 35 * @property {Window} win window in which messages will be rendered. 36 * @property {{name: string, defaultValue?: string}} [pref] optional pref used 37 * to track progress through a given feature tour. for example: 38 * { 39 * name: "browser.pdfjs.feature-tour", 40 * defaultValue: '{ screen: "FEATURE_CALLOUT_1", complete: false }', 41 * } 42 * or { name: "browser.pdfjs.feature-tour" } (defaultValue is optional) 43 * @property {string} [location] string to pass as the page when requesting 44 * messages from ASRouter and sending telemetry. 45 * @property {string} context either "chrome" or "content". "chrome" is used 46 * when the callout is shown in the browser chrome, and "content" is used 47 * when the callout is shown in a content page like Firefox View. 48 * @property {MozBrowser} [browser] <browser> element responsible for the 49 * feature callout. for content pages, this is the browser element that the 50 * callout is being shown in. for chrome, this is the active browser. 51 * @property {Function} [listener] callback to be invoked on various callout 52 * events to keep the broker informed of the callout's state. 53 * @property {FeatureCalloutTheme} [theme] @see FeatureCallout.themePresets 54 */ 55 56 /** @param {FeatureCalloutOptions} options */ 57 constructor({ 58 win, 59 pref, 60 location, 61 context, 62 browser, 63 listener, 64 theme = {}, 65 } = {}) { 66 this.win = win; 67 this.doc = win.document; 68 this.browser = browser || this.win.docShell.chromeEventHandler; 69 this.config = null; 70 this.loadingConfig = false; 71 this.message = null; 72 if (pref?.name) { 73 this.pref = pref; 74 } 75 this._featureTourProgress = null; 76 this.currentScreen = null; 77 this.renderObserver = null; 78 this.savedFocus = null; 79 this.ready = false; 80 this._positionListenersRegistered = false; 81 this._panelConflictListenersRegistered = false; 82 this.AWSetup = false; 83 this.location = location; 84 this.context = context; 85 this.listener = listener; 86 this._initTheme(theme); 87 88 this._handlePrefChange = this._handlePrefChange.bind(this); 89 90 this.setupFeatureTourProgress(); 91 92 // When the window is focused, ensure tour is synced with tours in any other 93 // instances of the parent page. This does not apply when the Callout is 94 // shown in the browser chrome. 95 if (this.context !== "chrome") { 96 this.win.addEventListener("visibilitychange", this); 97 } 98 99 this.win.addEventListener("unload", this); 100 } 101 102 setupFeatureTourProgress() { 103 if (this.featureTourProgress) { 104 return; 105 } 106 if (this.pref?.name) { 107 this._handlePrefChange(null, null, this.pref.name); 108 Services.prefs.addObserver(this.pref.name, this._handlePrefChange); 109 } 110 } 111 112 teardownFeatureTourProgress() { 113 if (this.pref?.name) { 114 Services.prefs.removeObserver(this.pref.name, this._handlePrefChange); 115 } 116 this._featureTourProgress = null; 117 } 118 119 get featureTourProgress() { 120 return this._featureTourProgress; 121 } 122 123 /** 124 * Get the page event manager and instantiate it if necessary. Only used by 125 * _attachPageEventListeners, since we don't want to do this unnecessary work 126 * if a message with page event listeners hasn't loaded. Other consumers 127 * should use `this._pageEventManager?.property` instead. 128 */ 129 get _loadPageEventManager() { 130 if (!this._pageEventManager) { 131 this._pageEventManager = new lazy.PageEventManager(this.win); 132 } 133 return this._pageEventManager; 134 } 135 136 _addPositionListeners() { 137 if (!this._positionListenersRegistered) { 138 this.win.addEventListener("resize", this); 139 this._positionListenersRegistered = true; 140 } 141 } 142 143 _removePositionListeners() { 144 if (this._positionListenersRegistered) { 145 this.win.removeEventListener("resize", this); 146 this._positionListenersRegistered = false; 147 } 148 } 149 150 _addPanelConflictListeners() { 151 if (!this._panelConflictListenersRegistered) { 152 this.win.addEventListener("popupshowing", this); 153 this.win.gURLBar.controller.addListener(this); 154 this._panelConflictListenersRegistered = true; 155 } 156 } 157 158 _removePanelConflictListeners() { 159 if (this._panelConflictListenersRegistered) { 160 this.win.removeEventListener("popupshowing", this); 161 this.win.gURLBar.controller.removeListener(this); 162 this._panelConflictListenersRegistered = false; 163 } 164 } 165 166 /** 167 * Close the tour when the urlbar is opened in the chrome. Set up by 168 * gURLBar.controller.addListener in _addPanelConflictListeners. 169 */ 170 onViewOpen() { 171 this.endTour(); 172 } 173 174 _handlePrefChange(subject, topic, prefName) { 175 switch (prefName) { 176 case this.pref?.name: 177 try { 178 this._featureTourProgress = JSON.parse( 179 Services.prefs.getStringPref( 180 this.pref.name, 181 this.pref.defaultValue ?? null 182 ) 183 ); 184 } catch (error) { 185 this._featureTourProgress = null; 186 } 187 if (topic === "nsPref:changed") { 188 this._advanceOnTourPrefChange(); 189 } 190 break; 191 } 192 } 193 194 /** 195 * @typedef {object} AdvanceScreensOptions 196 * @property {boolean | "actionResult"} [behavior] Set to true to take effect 197 * immediately, or set to "actionResult" to only advance screens after the 198 * special message action has resolved successfully. "actionResult" requires 199 * `action.needsAwait` to be true. Defaults to true. 200 * @property {string} [id] The id of the screen to advance to. If both id and 201 * direction are provided (which they shouldn't be), the id takes priority. 202 * Either `id` or `direction` is required. Passing `%end%` ends the tour. 203 * @property {number} [direction] How many screens, and in which direction, to 204 * advance. Positive integers advance forward, negative integers advance 205 * backward. Must be an integer. If advancing by the specified number of 206 * screens would take you beyond the last screen, it will end the tour, just 207 * like if you used `dismiss: true`. If it's a negative integer that 208 * advances beyond the first screen, it will stop at the first screen. 209 */ 210 211 /** @param {AdvanceScreensOptions} options */ 212 _advanceScreens({ id, direction } = {}) { 213 if (!this.currentScreen) { 214 lazy.log.error( 215 `In ${this.location}: Cannot advance screens without a current screen.` 216 ); 217 return; 218 } 219 if ((!direction || !Number.isInteger(direction)) && !id) { 220 lazy.log.debug( 221 `In ${this.location}: Cannot advance screens without a valid direction or id.` 222 ); 223 return; 224 } 225 if (id === "%end%") { 226 // Special case for ending the tour. When `id` is `%end%`, we should end 227 // the tour and clear the current screen. 228 this.endTour(); 229 return; 230 } 231 let nextIndex = -1; // Default to -1 to indicate an invalid index. 232 let currentIndex = this.config.screens.findIndex( 233 screen => screen.id === this.currentScreen.id 234 ); 235 if (id) { 236 nextIndex = this.config.screens.findIndex(screen => screen.id === id); 237 if (nextIndex === -1) { 238 lazy.log.debug( 239 `In ${this.location}: Unable to find screen with id: ${id}` 240 ); 241 return; 242 } 243 if (nextIndex === currentIndex) { 244 lazy.log.debug( 245 `In ${this.location}: Already on screen with id: ${id}. Not advancing.` 246 ); 247 return; 248 } 249 } else { 250 // Calculate the next index based on the current screen and direction. 251 nextIndex = Math.max(0, currentIndex + direction); 252 } 253 if (nextIndex < 0) { 254 // Don't allow going before the first screen. 255 lazy.log.debug( 256 `In ${this.location}: Cannot advance before the first screen.` 257 ); 258 return; 259 } 260 if (nextIndex >= this.config.screens.length) { 261 // Allow ending the tour if we would go beyond the last screen. 262 this.endTour(); 263 return; 264 } 265 266 this.ready = false; 267 this._container?.classList.toggle( 268 "hidden", 269 this._container?.localName !== "panel" 270 ); 271 this._pageEventManager?.emit({ 272 type: "touradvance", 273 target: this._container, 274 }); 275 const onFadeOut = async () => { 276 this._container?.remove(); 277 this.renderObserver?.disconnect(); 278 this._removePositionListeners(); 279 this._removePanelConflictListeners(); 280 this.doc.querySelector(`[src="${BUNDLE_SRC}"]`)?.remove(); 281 if (this.message) { 282 const isMessageUnblocked = await lazy.ASRouter.isUnblockedMessage( 283 this.message 284 ); 285 if (!isMessageUnblocked) { 286 this.endTour(); 287 return; 288 } 289 } 290 let updated = await this._updateConfig(this.message, nextIndex); 291 if (!updated && !this.currentScreen) { 292 this.endTour(); 293 return; 294 } 295 let rendering = await this._renderCallout(); 296 if (!rendering) { 297 this.endTour(); 298 } 299 }; 300 if (this._container?.localName === "panel") { 301 this._container.removeEventListener("popuphiding", this); 302 const controller = new AbortController(); 303 this._container.addEventListener( 304 "popuphidden", 305 event => { 306 if (event.target === this._container) { 307 controller.abort(); 308 onFadeOut(); 309 } 310 }, 311 { signal: controller.signal } 312 ); 313 this._container.hidePopup(true); 314 } else { 315 this.win.setTimeout(onFadeOut, TRANSITION_MS); 316 } 317 } 318 319 _advanceOnTourPrefChange() { 320 if (this.doc.visibilityState === "hidden" || !this.featureTourProgress) { 321 return; 322 } 323 324 // If we have more than one screen, it means that we're displaying a feature 325 // tour, and transitions are handled based on the value of a tour progress 326 // pref. Otherwise, just show the feature callout. If a pref change results 327 // from an event in a Spotlight message, initialize the feature callout with 328 // the next message in the tour. 329 if ( 330 this.config?.screens.length === 1 || 331 this.currentScreen === "spotlight" 332 ) { 333 this.showFeatureCallout(); 334 return; 335 } 336 337 let prefVal = this.featureTourProgress; 338 // End the tour according to the tour progress pref or if the user disabled 339 // contextual feature recommendations. 340 if (prefVal.complete) { 341 this.endTour(); 342 } else if (prefVal.screen !== this.currentScreen?.id) { 343 // Pref changes only matter to us insofar as they let us advance an 344 // ongoing tour. If the tour was closed and the pref changed later, e.g. 345 // by editing the pref directly, we don't want to start up the tour again. 346 // This is more important in the chrome, which is always open. 347 if (this.context === "chrome" && !this.currentScreen) { 348 return; 349 } 350 this.ready = false; 351 this._container?.classList.toggle( 352 "hidden", 353 this._container?.localName !== "panel" 354 ); 355 this._pageEventManager?.emit({ 356 type: "touradvance", 357 target: this._container, 358 }); 359 const onFadeOut = async () => { 360 // If the initial message was deployed from outside by ASRouter as a 361 // result of a trigger, we can't continue it through _loadConfig, since 362 // that effectively requests a message with a `featureCalloutCheck` 363 // trigger. So we need to load up the same message again, merely 364 // changing the startScreen index. Just check that the next screen and 365 // the current screen are both within the message's screens array. 366 let nextMessage = null; 367 if ( 368 this.context === "chrome" && 369 this.message?.trigger.id !== "featureCalloutCheck" 370 ) { 371 if ( 372 this.config?.screens.some(s => s.id === this.currentScreen?.id) && 373 this.config.screens.some(s => s.id === prefVal.screen) 374 ) { 375 nextMessage = this.message; 376 } 377 } 378 this._container?.remove(); 379 this.renderObserver?.disconnect(); 380 this._removePositionListeners(); 381 this._removePanelConflictListeners(); 382 this.doc.querySelector(`[src="${BUNDLE_SRC}"]`)?.remove(); 383 if (nextMessage) { 384 const isMessageUnblocked = 385 await lazy.ASRouter.isUnblockedMessage(nextMessage); 386 if (!isMessageUnblocked) { 387 this.endTour(); 388 return; 389 } 390 } 391 let updated = await this._updateConfig(nextMessage); 392 if (!updated && !this.currentScreen) { 393 this.endTour(); 394 return; 395 } 396 let rendering = await this._renderCallout(); 397 if (!rendering) { 398 this.endTour(); 399 } 400 }; 401 if (this._container?.localName === "panel") { 402 this._container.removeEventListener("popuphiding", this); 403 const controller = new AbortController(); 404 this._container.addEventListener( 405 "popuphidden", 406 event => { 407 if (event.target === this._container) { 408 controller.abort(); 409 onFadeOut(); 410 } 411 }, 412 { signal: controller.signal } 413 ); 414 this._container.hidePopup(true); 415 } else { 416 this.win.setTimeout(onFadeOut, TRANSITION_MS); 417 } 418 } 419 } 420 421 handleEvent(event) { 422 switch (event.type) { 423 case "focus": { 424 if (!this._container) { 425 return; 426 } 427 // If focus has fired on the feature callout window itself, or on something 428 // contained in that window, ignore it, as we can't possibly place the focus 429 // on it after the callout is closd. 430 if ( 431 event.target === this._container || 432 (Node.isInstance(event.target) && 433 this._container.contains(event.target)) 434 ) { 435 return; 436 } 437 // Save this so that if the next focus event is re-entering the popup, 438 // then we'll put the focus back here where the user left it once we exit 439 // the feature callout series. 440 if (this.doc.activeElement) { 441 let element = this.doc.activeElement; 442 this.savedFocus = { 443 element, 444 focusVisible: element.matches(":focus-visible"), 445 }; 446 } else { 447 this.savedFocus = null; 448 } 449 break; 450 } 451 452 case "keypress": { 453 if (event.key !== "Escape") { 454 return; 455 } 456 if (!this._container) { 457 return; 458 } 459 this.win.AWSendEventTelemetry?.({ 460 event: "DISMISS", 461 event_context: { 462 source: `KEY_${event.key}`, 463 page: this.location, 464 }, 465 message_id: this.config?.id.toUpperCase(), 466 }); 467 this._dismiss(); 468 event.preventDefault(); 469 break; 470 } 471 472 case "visibilitychange": 473 this._advanceOnTourPrefChange(); 474 break; 475 476 case "resize": 477 case "toggle": 478 this.win.requestAnimationFrame(() => this._positionCallout()); 479 break; 480 481 case "popupshowing": 482 // If another panel is showing, close the tour. 483 if ( 484 event.target.ownerGlobal === this.win && 485 event.target !== this._container && 486 event.target.localName === "panel" && 487 event.target.id !== "ctrlTab-panel" && 488 event.target.getAttribute("noautohide") !== "true" 489 ) { 490 this.endTour(); 491 } 492 break; 493 494 case "popuphiding": 495 if (event.target === this._container) { 496 this.endTour(); 497 } 498 break; 499 500 case "unload": 501 try { 502 this.teardownFeatureTourProgress(); 503 } catch (error) {} 504 break; 505 506 default: 507 } 508 } 509 510 async _addCalloutLinkElements() { 511 for (const path of [ 512 "browser/newtab/onboarding.ftl", 513 "browser/spotlight.ftl", 514 "branding/brand.ftl", 515 "toolkit/branding/brandings.ftl", 516 "browser/newtab/asrouter.ftl", 517 "browser/featureCallout.ftl", 518 ]) { 519 this.win.MozXULElement.insertFTLIfNeeded(path); 520 } 521 522 const addChromeSheet = href => { 523 try { 524 this.win.windowUtils.loadSheetUsingURIString( 525 href, 526 Ci.nsIDOMWindowUtils.AUTHOR_SHEET 527 ); 528 } catch (error) { 529 // the sheet was probably already loaded. I don't think there's a way to 530 // check for this via JS, but the method checks and throws if it's 531 // already loaded, so we can just treat the error as expected. 532 } 533 }; 534 const addStylesheet = href => { 535 if (this.win.isChromeWindow) { 536 // for chrome, load the stylesheet using a special method to make sure 537 // it's loaded synchronously before the first paint & position. 538 return addChromeSheet(href); 539 } 540 if (this.doc.querySelector(`link[href="${href}"]`)) { 541 return null; 542 } 543 const link = this.doc.head.appendChild(this.doc.createElement("link")); 544 link.rel = "stylesheet"; 545 link.href = href; 546 return null; 547 }; 548 // Update styling to be compatible with about:welcome bundle 549 await addStylesheet( 550 "chrome://browser/content/aboutwelcome/aboutwelcome.css" 551 ); 552 } 553 554 /** 555 * @typedef { 556 * | "topleft" 557 * | "topright" 558 * | "bottomleft" 559 * | "bottomright" 560 * | "leftcenter" 561 * | "rightcenter" 562 * | "topcenter" 563 * | "bottomcenter" 564 * } PopupAttachmentPoint 565 * 566 * @see nsMenuPopupFrame 567 * 568 * Each attachment point corresponds to an attachment point on the edge of a 569 * frame. For example, "topleft" corresponds to the frame's top left corner, 570 * and "rightcenter" corresponds to the center of the right edge of the frame. 571 */ 572 573 /** 574 * @typedef {object} PanelPosition Specifies how the callout panel should be 575 * positioned relative to the anchor element, by providing which point on 576 * the callout should be aligned with which point on the anchor element. 577 * @property {PopupAttachmentPoint} anchor_attachment 578 * @property {PopupAttachmentPoint} callout_attachment 579 * @property {string} [panel_position_string] The attachments joined into a 580 * string, e.g. "bottomleft topright". Passed to XULPopupElement::openPopup. 581 * This is not provided by JSON, but generated from anchor_attachment and 582 * callout_attachment. 583 * @property {number} [offset_x] Offset in pixels to apply to the callout 584 * position in the horizontal direction. 585 * @property {number} [offset_y] The same in the vertical direction. 586 * 587 * This is used when you want the callout to be displayed as a <panel> 588 * element. A panel is critical when the callout is displayed in the browser 589 * chrome, anchored to an element whose position on the screen is dynamic, 590 * such as a button. When the anchor moves, the panel will automatically move 591 * with it. Also, when the elements are aligned so that the callout would 592 * extend beyond the edge of the screen, the panel will automatically flip 593 * itself to the other side of the anchor element. This requires specifying 594 * both an anchor attachment point and a callout attachment point. For 595 * example, to get the callout to appear under a button, with its arrow on the 596 * right side of the callout: 597 * { anchor_attachment: "bottomcenter", callout_attachment: "topright" } 598 */ 599 600 /** 601 * @typedef { 602 * | "top" 603 * | "bottom" 604 * | "end" 605 * | "start" 606 * | "top-end" 607 * | "top-start" 608 * | "top-center-arrow-end" 609 * | "top-center-arrow-start" 610 * } HTMLArrowPosition 611 * 612 * @see FeatureCallout._positionCallout() 613 * The position of the callout arrow relative to the callout container. Only 614 * used for HTML callouts, typically in content pages. If the position 615 * contains a dash, the value before the dash refers to which edge of the 616 * feature callout the arrow points from. The value after the dash describes 617 * where along that edge the arrow sits, with middle as the default. 618 */ 619 620 /** 621 * @typedef {object} PositionOverride CSS properties to override 622 * the callout's position relative to the anchor element. Although the 623 * callout is not actually a child of the anchor element, this allows 624 * absolute positioning of the callout relative to the anchor element. In 625 * other words, { top: "0px", left: "0px" } will position the callout in the 626 * top left corner of the anchor element, in the same way these properties 627 * would position a child element. 628 * @property {string} [top] 629 * @property {string} [left] 630 * @property {string} [right] 631 * @property {string} [bottom] 632 */ 633 634 /** 635 * @typedef {object} AutoFocusOptions For the optional autofocus feature. 636 * @property {string} [selector] A preferred CSS selector, if you want a 637 * specific element to be focused. If omitted, the default prioritization 638 * listed below will be used, based on `use_defaults`. 639 * Default prioritization: primary_button, secondary_button, additional_button 640 * (excluding pseudo-links), dismiss_button, <input>, any button. 641 * @property {boolean} [use_defaults] Whether to use the default element 642 * prioritization. If `selector` is provided and the element can't be found, 643 * and this is set to false, nothing will be selected. If `selector` is not 644 * provided, this must be true. Defaults to true. 645 */ 646 647 /** 648 * @typedef {object} Anchor 649 * @property {string} selector CSS selector for the anchor node. 650 * @property {Element} [element] The anchor node resolved from the selector. 651 * Not provided by JSON, but generated dynamically. 652 * @property {PanelPosition} [panel_position] Used to show the callout in a 653 * XUL panel. Only works in chrome documents, like the main browser window. 654 * @property {HTMLArrowPosition} [arrow_position] Used to show the callout in 655 * an HTML div container. Mutually exclusive with panel_position. 656 * @property {PositionOverride} [absolute_position] Only used for HTML 657 * callouts, i.e. when panel_position is not specified. Allows absolute 658 * positioning of the callout relative to the anchor element. 659 * @property {boolean} [hide_arrow] Whether to hide the arrow. 660 * @property {boolean} [no_open_on_anchor] Whether to set the [open] style on 661 * the anchor element when the callout is shown. False to set it, true to 662 * not set it. This only works for panel callouts. Not all elements have an 663 * [open] style. Buttons do, for example. It's usually similar to :active. 664 * @property {number} [arrow_width] The desired width of the arrow in a number 665 * of pixels. 33.94113 by default (this corresponds to 24px edges). 666 * @property {AutoFocusOptions} [autofocus] Options for the optional autofocus 667 * feature. Typically omitted, but if provided, an element inside the 668 * callout will be automatically focused when the callout appears. 669 */ 670 671 /** 672 * Return the first visible anchor element for the current screen. Screens can 673 * specify multiple anchors in an array, and the first one that is visible 674 * will be used. If none are visible, return null. 675 * 676 * @returns {Anchor|null} 677 */ 678 _getAnchor() { 679 /** @type {Anchor[]} */ 680 const anchors = Array.isArray(this.currentScreen?.anchors) 681 ? this.currentScreen.anchors 682 : []; 683 for (let anchor of anchors) { 684 if (!anchor || typeof anchor !== "object") { 685 lazy.log.debug( 686 `In ${this.location}: Invalid anchor config. Expected an object, got: ${anchor}` 687 ); 688 continue; 689 } 690 let { selector, arrow_position, panel_position } = anchor; 691 if (!selector) { 692 continue; // No selector provided. 693 } 694 if (panel_position) { 695 let panel_position_string = 696 this._getPanelPositionString(panel_position); 697 // if the positionString doesn't match the format we expect, don't 698 // render the callout. 699 if (!panel_position_string && !arrow_position) { 700 lazy.log.debug( 701 `In ${ 702 this.location 703 }: Invalid panel_position config. Expected an object with anchor_attachment and callout_attachment properties, got: ${JSON.stringify( 704 panel_position 705 )}` 706 ); 707 continue; 708 } 709 panel_position.panel_position_string = panel_position_string; 710 } 711 if ( 712 arrow_position && 713 !this._HTMLArrowPositions.includes(arrow_position) 714 ) { 715 lazy.log.debug( 716 `In ${ 717 this.location 718 }: Invalid arrow_position config. Expected one of ${JSON.stringify( 719 this._HTMLArrowPositions 720 )}, got: ${arrow_position}` 721 ); 722 continue; 723 } 724 725 const resolvedSelectorAndScope = this._resolveSelectorAndScope(selector); 726 // Attempt to resolve the selector into a usable DOM context. 727 // Handles special tokens like %triggerTab%, shadow DOM traversal (::%shadow%), etc. 728 // If resolution fails (e.g., element is not visible or overflows), returns null. 729 // Applies to both plain selectors and tokenized ones. 730 if (!resolvedSelectorAndScope) { 731 continue; 732 } 733 const { scope, selector: resolvedSelector } = resolvedSelectorAndScope; 734 let element = scope.querySelector(resolvedSelector); 735 736 // The element may not be a child of the scope, but the scope itself. For 737 // example, if we're anchoring directly to the trigger tab, our selector 738 // might look like `%triggerTab%[visuallyselected]`. In this case, 739 // querySelector() will return nothing, but matches() will return true. 740 if (!element && scope.matches?.(resolvedSelector)) { 741 element = scope; 742 } 743 if (!element) { 744 continue; // Element doesn't exist at all. 745 } 746 if (!this._isElementVisible(element)) { 747 continue; 748 } 749 if ( 750 this.context === "chrome" && 751 element.id && 752 selector.includes(`#${element.id}`) 753 ) { 754 let widget = lazy.CustomizableUI.getWidget(element.id); 755 if ( 756 widget && 757 (this.win.CustomizationHandler.isCustomizing() || 758 widget.areaType?.includes("panel")) 759 ) { 760 // The element is a customizable widget (a toolbar item, e.g. the 761 // reload button or the downloads button). Widgets can be in various 762 // areas, like the overflow panel or the customization palette. 763 // Widgets in the palette are present in the chrome's DOM during 764 // customization, but can't be used. 765 continue; 766 } 767 } 768 return { ...anchor, element }; 769 } 770 return null; 771 } 772 773 _isElementVisible(el) { 774 if ( 775 this.context === "chrome" && 776 typeof this.win.isElementVisible === "function" && 777 !this.win.isElementVisible(el) 778 ) { 779 return false; 780 } 781 782 const style = this.win.getComputedStyle(el); 783 return style?.visibility === "visible" && style?.display !== "none"; 784 } 785 786 /** 787 * Resolves selector tokens into a usable scope and selector. 788 * 789 * The selector string may contain custom tokens that are substituted and resolved 790 * against the appropriate DOM context. 791 * 792 * Supported custom tokens: 793 * - %triggerTab%: The <tab> element associated with the current browser. 794 * - %triggeredTabBookmark%: Bookmark item in the toolbar matching the current tab's URL or label. 795 * - ::%shadow%: Traverses nested shadow DOM boundaries. 796 * 797 * @param {string} selector 798 * @returns {{scope: Element, selector: string} | null} 799 */ 800 _resolveSelectorAndScope(selector) { 801 let scope = this.doc.documentElement; 802 let normalizedSelector = selector; 803 804 // %triggerTab% 805 if (this.browser && normalizedSelector.includes("%triggerTab%")) { 806 const triggerTab = this.browser.ownerGlobal.gBrowser?.getTabForBrowser( 807 this.browser 808 ); 809 if (!triggerTab) { 810 lazy.log.debug( 811 `In ${this.location}: Failed to resolve %triggerTab% in selector: ${selector}` 812 ); 813 return null; 814 } 815 scope = triggerTab; 816 normalizedSelector = normalizedSelector.replace("%triggerTab%", ":scope"); 817 } 818 819 // %triggeredTabBookmark% 820 if (normalizedSelector.includes("%triggeredTabBookmark%")) { 821 const gBrowser = this.browser?.ownerGlobal?.gBrowser; 822 const tab = gBrowser?.getTabForBrowser(this.browser); 823 const url = this.browser?.currentURI?.spec; 824 const label = tab?.label; 825 const toolbar = this.doc.getElementById("PersonalToolbar"); 826 const scrollbox = this.doc.getElementById("PlacesToolbarItems"); 827 828 // Early return if the toolbar is collapsed or missing context 829 if (!toolbar || toolbar.collapsed || !scrollbox || (!url && !label)) { 830 lazy.log.debug( 831 `In ${this.location}: Bookmarks toolbar is collapsed: ${selector}` 832 ); 833 return null; 834 } 835 836 // If a selector prefix is provided before the %triggeredTabBookmark% token, 837 // resolve it as the root scope. If there's a selector suffix after the token, 838 // it is appended to the resolved element using :scope as the base, 839 // e.g. `:root %triggeredTabBookmark% > image` becomes `:scope > image`. 840 const [preTokenSelector, postTokenSelector = ""] = 841 normalizedSelector.split("%triggeredTabBookmark%"); 842 const rootScope = preTokenSelector.trim() 843 ? this.doc.querySelector(preTokenSelector.trim()) 844 : this.doc; 845 846 const match = [ 847 ...rootScope.querySelectorAll("#PlacesToolbarItems .bookmark-item"), 848 ].find(el => { 849 const node = el._placesNode; 850 return ( 851 node && 852 (node.uri === url || 853 [this.browser.contentTitle, url].includes(node.title) || 854 el.getAttribute("label") === label) 855 ); 856 }); 857 858 if (!match) { 859 lazy.log.debug( 860 `In ${this.location}: No bookmark matched. Tab URL: ${url}, Tab Title: ${this.browser.contentTitle}, History Title: ${this._cachedHistoryTitle?.title}` 861 ); 862 return null; 863 } 864 865 if (!this._isElementVisible(match)) { 866 lazy.log.debug( 867 `In ${this.location}: Bookmark item is not visible or overflowed: ${selector}` 868 ); 869 return null; 870 } 871 872 scope = match; 873 normalizedSelector = `:scope${postTokenSelector}`; 874 } 875 876 // ::%shadow% 877 if (normalizedSelector.includes("::%shadow%")) { 878 let parts = normalizedSelector.split("::%shadow%"); 879 for (let i = 0; i < parts.length; i++) { 880 normalizedSelector = parts[i].trim(); 881 if (i === parts.length - 1) { 882 break; 883 } 884 let el = scope.querySelector(normalizedSelector); 885 if (!el) { 886 break; 887 } 888 if (el.shadowRoot) { 889 scope = el.shadowRoot; 890 } 891 } 892 } 893 894 // Attempts to resolve the final element using the normalized selector and 895 // scope. If querySelector fails (e.g. when the selector is ":scope"), fall 896 // back to checking whether the scope itself matches the selector. This is 897 // necessary for cases like "%triggeredTabBookmark%" or "%triggerTab%", 898 // where the target element is the scope. 899 let element = scope.querySelector(normalizedSelector); 900 if (!element && scope.matches?.(normalizedSelector)) { 901 element = scope; 902 } 903 if (!element || !this._isElementVisible(element)) { 904 lazy.log.debug( 905 `In ${this.location}: Selector failed to resolve or is not visible: ${normalizedSelector}` 906 ); 907 return null; 908 } 909 910 // Use the matched element as the anchor by returning it as scope, 911 // and ":scope" as the selector so it matches itself in _getAnchor(). 912 return { scope: element, selector: ":scope" }; 913 } 914 915 /** @see PopupAttachmentPoint */ 916 _popupAttachmentPoints = [ 917 "topleft", 918 "topright", 919 "bottomleft", 920 "bottomright", 921 "leftcenter", 922 "rightcenter", 923 "topcenter", 924 "bottomcenter", 925 ]; 926 927 /** 928 * Return a string representing the position of the panel relative to the 929 * anchor element. Passed to XULPopupElement::openPopup. The string is of the 930 * form "anchor_attachment callout_attachment". 931 * 932 * @param {PanelPosition} panelPosition 933 * @returns {string | null} A string like "bottomcenter topright", or null if 934 * the panelPosition object is invalid. 935 */ 936 _getPanelPositionString(panelPosition) { 937 const { anchor_attachment, callout_attachment } = panelPosition; 938 if ( 939 !this._popupAttachmentPoints.includes(anchor_attachment) || 940 !this._popupAttachmentPoints.includes(callout_attachment) 941 ) { 942 return null; 943 } 944 let positionString = `${anchor_attachment} ${callout_attachment}`; 945 return positionString; 946 } 947 948 /** 949 * Set/override methods on a panel element. Can be used to override methods on 950 * the custom element class, or to add additional methods. 951 * 952 * @param {MozPanel} panel The panel to set methods for 953 */ 954 _setPanelMethods(panel) { 955 // This method is optionally called by MozPanel::_setSideAttribute, though 956 // it does not exist on the class. 957 panel.setArrowPosition = function setArrowPosition(event) { 958 if (!this.hasAttribute("show-arrow")) { 959 return; 960 } 961 let { alignmentPosition, alignmentOffset, popupAlignment } = event; 962 let positionParts = alignmentPosition?.match( 963 /^(before|after|start|end)_(before|after|start|end)$/ 964 ); 965 if (!positionParts) { 966 return; 967 } 968 // Hide the arrow if the `flip` behavior has caused the panel to 969 // offset relative to its anchor, since the arrow would no longer 970 // point at the true anchor. This differs from an arrow that is 971 // intentionally hidden by the user in message. 972 if (this.getAttribute("hide-arrow") !== "permanent") { 973 if (alignmentOffset) { 974 this.setAttribute("hide-arrow", "temporary"); 975 } else { 976 this.removeAttribute("hide-arrow"); 977 } 978 } 979 let arrowPosition = "top"; 980 switch (positionParts[1]) { 981 case "start": 982 case "end": { 983 // Inline arrow, i.e. arrow is on one of the left/right edges. 984 let isRTL = 985 this.ownerGlobal.getComputedStyle(this).direction === "rtl"; 986 let isRight = isRTL ^ (positionParts[1] === "start"); 987 let side = isRight ? "end" : "start"; 988 arrowPosition = `inline-${side}`; 989 if (popupAlignment?.includes("center")) { 990 arrowPosition = `inline-${side}`; 991 } else if (positionParts[2] === "before") { 992 arrowPosition = `inline-${side}-top`; 993 } else if (positionParts[2] === "after") { 994 arrowPosition = `inline-${side}-bottom`; 995 } 996 break; 997 } 998 case "before": 999 case "after": { 1000 // Block arrow, i.e. arrow is on one of the top/bottom edges. 1001 let side = positionParts[1] === "before" ? "bottom" : "top"; 1002 arrowPosition = side; 1003 if (popupAlignment?.includes("center")) { 1004 arrowPosition = side; 1005 } else if (positionParts[2] === "end") { 1006 arrowPosition = `${side}-end`; 1007 } else if (positionParts[2] === "start") { 1008 arrowPosition = `${side}-start`; 1009 } 1010 break; 1011 } 1012 } 1013 this.setAttribute("arrow-position", arrowPosition); 1014 }; 1015 } 1016 1017 _createContainer() { 1018 const anchor = this._getAnchor(); 1019 // Don't render the callout if none of the anchors is visible. 1020 if (!anchor) { 1021 return false; 1022 } 1023 1024 const { autohide, ignorekeys, padding } = this.currentScreen.content; 1025 const { panel_position, hide_arrow, no_open_on_anchor, arrow_width } = 1026 anchor; 1027 const needsPanel = 1028 "MozXULElement" in this.win && !!panel_position?.panel_position_string; 1029 1030 if (this._container) { 1031 if (needsPanel ^ (this._container?.localName === "panel")) { 1032 this._container.remove(); 1033 } 1034 } 1035 1036 if (!this._container?.parentElement) { 1037 if (needsPanel) { 1038 let fragment = this.win.MozXULElement.parseXULToFragment(`<panel 1039 class="panel-no-padding" 1040 orient="vertical" 1041 noautofocus="true" 1042 flip="slide" 1043 type="arrow" 1044 consumeoutsideclicks="never" 1045 norolluponanchor="true" 1046 position="${panel_position.panel_position_string}" 1047 ${hide_arrow ? "" : 'show-arrow=""'} 1048 ${autohide ? "" : 'noautohide="true"'} 1049 ${ignorekeys ? 'ignorekeys="true"' : ""} 1050 ${no_open_on_anchor ? 'no-open-on-anchor=""' : ""} 1051 />`); 1052 this._container = fragment.firstElementChild; 1053 this._setPanelMethods(this._container); 1054 } else { 1055 this._container = this.doc.createElement("div"); 1056 this._container?.classList.add("hidden"); 1057 } 1058 this._container.classList.add("featureCallout"); 1059 if (hide_arrow) { 1060 this._container.setAttribute("hide-arrow", "permanent"); 1061 } else { 1062 this._container.removeAttribute("hide-arrow"); 1063 } 1064 this._container.id = CONTAINER_ID; 1065 this._container.setAttribute( 1066 "aria-describedby", 1067 "multi-stage-message-welcome-text" 1068 ); 1069 if (arrow_width) { 1070 this._container.style.setProperty("--arrow-width", `${arrow_width}px`); 1071 } else { 1072 this._container.style.removeProperty("--arrow-width"); 1073 } 1074 if (padding) { 1075 // This property used to accept a number value, either a number or a 1076 // string that is a number. It now accepts a standard CSS padding value 1077 // (e.g. "10px 12px" or "1em"), but we need to maintain backwards 1078 // compatibility with the old number value until there are no more uses 1079 // of it across experiments. 1080 if (CSS.supports("padding", padding)) { 1081 this._container.style.setProperty("--callout-padding", padding); 1082 } else { 1083 let cssValue = `${padding}px`; 1084 if (CSS.supports("padding", cssValue)) { 1085 this._container.style.setProperty("--callout-padding", cssValue); 1086 } else { 1087 this._container.style.removeProperty("--callout-padding"); 1088 } 1089 } 1090 } else { 1091 this._container.style.removeProperty("--callout-padding"); 1092 } 1093 let contentBox = this.doc.createElement("div"); 1094 contentBox.id = CONTENT_BOX_ID; 1095 contentBox.classList.add("onboardingContainer"); 1096 // This value is reported as the "page" in about:welcome telemetry 1097 contentBox.dataset.page = this.location; 1098 this._applyTheme(); 1099 if (needsPanel && this.win.isChromeWindow) { 1100 this.doc.getElementById("mainPopupSet").appendChild(this._container); 1101 } else { 1102 this.doc.body.prepend(this._container); 1103 } 1104 const makeArrow = classPrefix => { 1105 const arrowRotationBox = this.doc.createElement("div"); 1106 arrowRotationBox.classList.add("arrow-box", `${classPrefix}-arrow-box`); 1107 const arrow = this.doc.createElement("div"); 1108 arrow.classList.add("arrow", `${classPrefix}-arrow`); 1109 arrowRotationBox.appendChild(arrow); 1110 return arrowRotationBox; 1111 }; 1112 this._container.appendChild(makeArrow("shadow")); 1113 this._container.appendChild(contentBox); 1114 this._container.appendChild(makeArrow("background")); 1115 } 1116 return this._container; 1117 } 1118 1119 /** @see HTMLArrowPosition */ 1120 _HTMLArrowPositions = [ 1121 "top", 1122 "bottom", 1123 "end", 1124 "start", 1125 "top-end", 1126 "top-start", 1127 "top-center-arrow-end", 1128 "top-center-arrow-start", 1129 ]; 1130 1131 /** 1132 * Set callout's position relative to parent element 1133 */ 1134 _positionCallout() { 1135 const container = this._container; 1136 const anchor = this._getAnchor(); 1137 if (!container || !anchor) { 1138 this.endTour(); 1139 return; 1140 } 1141 const parentEl = anchor.element; 1142 const { doc } = this; 1143 const arrowPosition = anchor.arrow_position || "top"; 1144 const arrowWidth = anchor.arrow_width || 33.94113; 1145 const arrowHeight = arrowWidth / 2; 1146 const overlapAmount = 5; 1147 let overlap = overlapAmount - arrowHeight; 1148 // Is the document layout right to left? 1149 const RTL = this.doc.dir === "rtl"; 1150 const customPosition = anchor.absolute_position; 1151 1152 const getOffset = el => { 1153 const rect = el.getBoundingClientRect(); 1154 return { 1155 left: rect.left + this.win.scrollX, 1156 right: rect.right + this.win.scrollX, 1157 top: rect.top + this.win.scrollY, 1158 bottom: rect.bottom + this.win.scrollY, 1159 }; 1160 }; 1161 1162 const centerVertically = () => { 1163 let topOffset = 1164 (container.getBoundingClientRect().height - 1165 parentEl.getBoundingClientRect().height) / 1166 2; 1167 container.style.top = `${getOffset(parentEl).top - topOffset}px`; 1168 }; 1169 1170 /** 1171 * Horizontally align a top/bottom-positioned callout according to the 1172 * passed position. 1173 * 1174 * @param {string} position one of... 1175 * - "center": for use with top/bottom. arrow is in the center, and the 1176 * center of the callout aligns with the parent center. 1177 * - "center-arrow-start": for use with center-arrow-top-start. arrow is 1178 * on the start (left) side of the callout, and the callout is aligned 1179 * so that the arrow points to the center of the parent element. 1180 * - "center-arrow-end": for use with center-arrow-top-end. arrow is on 1181 * the end, and the arrow points to the center of the parent. 1182 * - "start": currently unused. align the callout's starting edge with the 1183 * parent's starting edge. 1184 * - "end": currently unused. same as start but for the ending edge. 1185 */ 1186 const alignHorizontally = position => { 1187 switch (position) { 1188 case "center": { 1189 const sideOffset = 1190 (parentEl.getBoundingClientRect().width - 1191 container.getBoundingClientRect().width) / 1192 2; 1193 const containerSide = RTL 1194 ? doc.documentElement.clientWidth - 1195 getOffset(parentEl).right + 1196 sideOffset 1197 : getOffset(parentEl).left + sideOffset; 1198 container.style[RTL ? "right" : "left"] = `${Math.max( 1199 containerSide, 1200 0 1201 )}px`; 1202 break; 1203 } 1204 case "end": 1205 case "start": { 1206 const containerSide = 1207 RTL ^ (position === "end") 1208 ? parentEl.getBoundingClientRect().left + 1209 parentEl.getBoundingClientRect().width - 1210 container.getBoundingClientRect().width 1211 : parentEl.getBoundingClientRect().left; 1212 container.style.left = `${Math.max(containerSide, 0)}px`; 1213 break; 1214 } 1215 case "center-arrow-end": 1216 case "center-arrow-start": { 1217 const parentRect = parentEl.getBoundingClientRect(); 1218 const containerWidth = container.getBoundingClientRect().width; 1219 const containerSide = 1220 RTL ^ position.endsWith("end") 1221 ? parentRect.left + 1222 parentRect.width / 2 + 1223 12 + 1224 arrowWidth / 2 - 1225 containerWidth 1226 : parentRect.left + parentRect.width / 2 - 12 - arrowWidth / 2; 1227 const maxContainerSide = 1228 doc.documentElement.clientWidth - containerWidth; 1229 container.style.left = `${Math.min( 1230 maxContainerSide, 1231 Math.max(containerSide, 0) 1232 )}px`; 1233 } 1234 } 1235 }; 1236 1237 // Remember not to use HTML-only properties/methods like offsetHeight. Try 1238 // to use getBoundingClientRect() instead, which is available on XUL 1239 // elements. This is necessary to support feature callout in chrome, which 1240 // is still largely XUL-based. 1241 const positioners = { 1242 // availableSpace should be the space between the edge of the page in the 1243 // assumed direction and the edge of the parent (with the callout being 1244 // intended to fit between those two edges) while needed space should be 1245 // the space necessary to fit the callout container. 1246 top: { 1247 availableSpace() { 1248 return ( 1249 doc.documentElement.clientHeight - 1250 getOffset(parentEl).top - 1251 parentEl.getBoundingClientRect().height 1252 ); 1253 }, 1254 neededSpace: container.getBoundingClientRect().height - overlap, 1255 position() { 1256 // Point to an element above the callout 1257 let containerTop = 1258 getOffset(parentEl).top + 1259 parentEl.getBoundingClientRect().height - 1260 overlap; 1261 container.style.top = `${Math.max(0, containerTop)}px`; 1262 alignHorizontally("center"); 1263 }, 1264 }, 1265 bottom: { 1266 availableSpace() { 1267 return getOffset(parentEl).top; 1268 }, 1269 neededSpace: container.getBoundingClientRect().height - overlap, 1270 position() { 1271 // Point to an element below the callout 1272 let containerTop = 1273 getOffset(parentEl).top - 1274 container.getBoundingClientRect().height + 1275 overlap; 1276 container.style.top = `${Math.max(0, containerTop)}px`; 1277 alignHorizontally("center"); 1278 }, 1279 }, 1280 right: { 1281 availableSpace() { 1282 return getOffset(parentEl).left; 1283 }, 1284 neededSpace: container.getBoundingClientRect().width - overlap, 1285 position() { 1286 // Point to an element to the right of the callout 1287 let containerLeft = 1288 getOffset(parentEl).left - 1289 container.getBoundingClientRect().width + 1290 overlap; 1291 container.style.left = `${Math.max(0, containerLeft)}px`; 1292 if ( 1293 container.getBoundingClientRect().height <= 1294 parentEl.getBoundingClientRect().height 1295 ) { 1296 container.style.top = `${getOffset(parentEl).top}px`; 1297 } else { 1298 centerVertically(); 1299 } 1300 }, 1301 }, 1302 left: { 1303 availableSpace() { 1304 return doc.documentElement.clientWidth - getOffset(parentEl).right; 1305 }, 1306 neededSpace: container.getBoundingClientRect().width - overlap, 1307 position() { 1308 // Point to an element to the left of the callout 1309 let containerLeft = 1310 getOffset(parentEl).left + 1311 parentEl.getBoundingClientRect().width - 1312 overlap; 1313 container.style.left = `${Math.max(0, containerLeft)}px`; 1314 if ( 1315 container.getBoundingClientRect().height <= 1316 parentEl.getBoundingClientRect().height 1317 ) { 1318 container.style.top = `${getOffset(parentEl).top}px`; 1319 } else { 1320 centerVertically(); 1321 } 1322 }, 1323 }, 1324 "top-start": { 1325 availableSpace() { 1326 return ( 1327 doc.documentElement.clientHeight - 1328 getOffset(parentEl).top - 1329 parentEl.getBoundingClientRect().height 1330 ); 1331 }, 1332 neededSpace: container.getBoundingClientRect().height - overlap, 1333 position() { 1334 // Point to an element above and at the start of the callout 1335 let containerTop = 1336 getOffset(parentEl).top + 1337 parentEl.getBoundingClientRect().height - 1338 overlap; 1339 container.style.top = `${Math.max(0, containerTop)}px`; 1340 alignHorizontally("start"); 1341 }, 1342 }, 1343 "top-end": { 1344 availableSpace() { 1345 return ( 1346 doc.documentElement.clientHeight - 1347 getOffset(parentEl).top - 1348 parentEl.getBoundingClientRect().height 1349 ); 1350 }, 1351 neededSpace: container.getBoundingClientRect().height - overlap, 1352 position() { 1353 // Point to an element above and at the end of the callout 1354 let containerTop = 1355 getOffset(parentEl).top + 1356 parentEl.getBoundingClientRect().height - 1357 overlap; 1358 container.style.top = `${Math.max(0, containerTop)}px`; 1359 alignHorizontally("end"); 1360 }, 1361 }, 1362 "top-center-arrow-start": { 1363 availableSpace() { 1364 return ( 1365 doc.documentElement.clientHeight - 1366 getOffset(parentEl).top - 1367 parentEl.getBoundingClientRect().height 1368 ); 1369 }, 1370 neededSpace: container.getBoundingClientRect().height - overlap, 1371 position() { 1372 // Point to an element above and at the start of the callout 1373 let containerTop = 1374 getOffset(parentEl).top + 1375 parentEl.getBoundingClientRect().height - 1376 overlap; 1377 container.style.top = `${Math.max(0, containerTop)}px`; 1378 alignHorizontally("center-arrow-start"); 1379 }, 1380 }, 1381 "top-center-arrow-end": { 1382 availableSpace() { 1383 return ( 1384 doc.documentElement.clientHeight - 1385 getOffset(parentEl).top - 1386 parentEl.getBoundingClientRect().height 1387 ); 1388 }, 1389 neededSpace: container.getBoundingClientRect().height - overlap, 1390 position() { 1391 // Point to an element above and at the end of the callout 1392 let containerTop = 1393 getOffset(parentEl).top + 1394 parentEl.getBoundingClientRect().height - 1395 overlap; 1396 container.style.top = `${Math.max(0, containerTop)}px`; 1397 alignHorizontally("center-arrow-end"); 1398 }, 1399 }, 1400 }; 1401 1402 const clearPosition = () => { 1403 Object.keys(positioners).forEach(position => { 1404 container.style[position] = "unset"; 1405 }); 1406 container.removeAttribute("arrow-position"); 1407 }; 1408 1409 const setArrowPosition = position => { 1410 let val; 1411 switch (position) { 1412 case "bottom": 1413 val = "bottom"; 1414 break; 1415 case "left": 1416 val = "inline-start"; 1417 break; 1418 case "right": 1419 val = "inline-end"; 1420 break; 1421 case "top-start": 1422 case "top-center-arrow-start": 1423 val = RTL ? "top-end" : "top-start"; 1424 break; 1425 case "top-end": 1426 case "top-center-arrow-end": 1427 val = RTL ? "top-start" : "top-end"; 1428 break; 1429 case "top": 1430 default: 1431 val = "top"; 1432 break; 1433 } 1434 1435 container.setAttribute("arrow-position", val); 1436 }; 1437 1438 const addValueToPixelValue = (value, pixelValue) => { 1439 return `${parseFloat(pixelValue) + value}px`; 1440 }; 1441 1442 const subtractPixelValueFromValue = (pixelValue, value) => { 1443 return `${value - parseFloat(pixelValue)}px`; 1444 }; 1445 1446 const overridePosition = () => { 1447 // We override _every_ positioner here, because we want to manually set 1448 // all container.style.positions in every positioner's "position" function 1449 // regardless of the actual arrow position 1450 1451 // Note: We override the position functions with new functions here, but 1452 // they don't actually get executed until the respective position 1453 // functions are called and this function is not executed unless the 1454 // message has a custom position property. 1455 1456 // We're positioning relative to a parent element's bounds, if that parent 1457 // element exists. 1458 1459 for (const position in positioners) { 1460 if (!Object.prototype.hasOwnProperty.call(positioners, position)) { 1461 continue; 1462 } 1463 1464 positioners[position].position = () => { 1465 if (customPosition.top) { 1466 container.style.top = addValueToPixelValue( 1467 parentEl.getBoundingClientRect().top, 1468 customPosition.top 1469 ); 1470 } 1471 1472 if (customPosition.left) { 1473 const leftPosition = addValueToPixelValue( 1474 parentEl.getBoundingClientRect().left, 1475 customPosition.left 1476 ); 1477 1478 if (RTL) { 1479 container.style.right = leftPosition; 1480 } else { 1481 container.style.left = leftPosition; 1482 } 1483 } 1484 1485 if (customPosition.right) { 1486 const rightPosition = subtractPixelValueFromValue( 1487 customPosition.right, 1488 parentEl.getBoundingClientRect().right - 1489 container.getBoundingClientRect().width 1490 ); 1491 1492 if (RTL) { 1493 container.style.right = rightPosition; 1494 } else { 1495 container.style.left = rightPosition; 1496 } 1497 } 1498 1499 if (customPosition.bottom) { 1500 container.style.top = subtractPixelValueFromValue( 1501 customPosition.bottom, 1502 parentEl.getBoundingClientRect().bottom - 1503 container.getBoundingClientRect().height 1504 ); 1505 } 1506 }; 1507 } 1508 }; 1509 1510 const calloutFits = position => { 1511 // Does callout element fit in this position relative 1512 // to the parent element without going off screen? 1513 1514 // Only consider which edge of the callout the arrow points from, 1515 // not the alignment of the arrow along the edge of the callout 1516 let [edgePosition] = position.split("-"); 1517 return ( 1518 positioners[edgePosition].availableSpace() > 1519 positioners[edgePosition].neededSpace 1520 ); 1521 }; 1522 1523 const choosePosition = () => { 1524 let position = arrowPosition; 1525 if (!this._HTMLArrowPositions.includes(position)) { 1526 // Configured arrow position is not valid 1527 position = null; 1528 } 1529 if (["start", "end"].includes(position)) { 1530 // position here is referencing the direction that the callout container 1531 // is pointing to, and therefore should be the _opposite_ side of the 1532 // arrow eg. if arrow is at the "end" in LTR layouts, the container is 1533 // pointing at an element to the right of itself, while in RTL layouts 1534 // it is pointing to the left of itself 1535 position = RTL ^ (position === "start") ? "left" : "right"; 1536 } 1537 // If we're overriding the position, we don't need to sort for available space 1538 if (customPosition || (position && calloutFits(position))) { 1539 return position; 1540 } 1541 let sortedPositions = ["top", "bottom", "left", "right"] 1542 .filter(p => p !== position) 1543 .filter(calloutFits) 1544 .sort((a, b) => { 1545 return ( 1546 positioners[b].availableSpace() - positioners[b].neededSpace > 1547 positioners[a].availableSpace() - positioners[a].neededSpace 1548 ); 1549 }); 1550 // If the callout doesn't fit in any position, use the configured one. 1551 // The callout will be adjusted to overlap the parent element so that 1552 // the former doesn't go off screen. 1553 return sortedPositions[0] || position; 1554 }; 1555 1556 clearPosition(container); 1557 1558 if (customPosition) { 1559 overridePosition(); 1560 } 1561 1562 let finalPosition = choosePosition(); 1563 if (finalPosition) { 1564 positioners[finalPosition].position(); 1565 setArrowPosition(finalPosition); 1566 } 1567 1568 container.classList.remove("hidden"); 1569 } 1570 1571 /** Expose top level functions expected by the aboutwelcome bundle. */ 1572 _setupWindowFunctions() { 1573 if (this.AWSetup) { 1574 return; 1575 } 1576 1577 const handleActorMessage = 1578 lazy.AboutWelcomeParent.prototype.onContentMessage.bind({}); 1579 const getActionHandler = name => data => 1580 handleActorMessage(`AWPage:${name}`, data, this.doc); 1581 1582 const telemetryMessageHandler = getActionHandler("TELEMETRY_EVENT"); 1583 const AWSendEventTelemetry = data => { 1584 if (this.config?.metrics !== "block") { 1585 return telemetryMessageHandler(data); 1586 } 1587 return null; 1588 }; 1589 this._windowFuncs = { 1590 AWGetFeatureConfig: () => this.config, 1591 AWGetSelectedTheme: getActionHandler("GET_SELECTED_THEME"), 1592 AWGetInstalledAddons: getActionHandler("GET_INSTALLED_ADDONS"), 1593 // Do not send telemetry if message config sets metrics as 'block'. 1594 AWSendEventTelemetry, 1595 AWSendToDeviceEmailsSupported: getActionHandler( 1596 "SEND_TO_DEVICE_EMAILS_SUPPORTED" 1597 ), 1598 AWSendToParent: (name, data) => getActionHandler(name)(data), 1599 AWFinish: () => this.endTour(), 1600 AWAdvanceScreens: options => this._advanceScreens(options), 1601 AWEvaluateScreenTargeting: getActionHandler("EVALUATE_SCREEN_TARGETING"), 1602 AWEvaluateAttributeTargeting: getActionHandler( 1603 "EVALUATE_ATTRIBUTE_TARGETING" 1604 ), 1605 }; 1606 for (const [name, func] of Object.entries(this._windowFuncs)) { 1607 this.win[name] = func; 1608 } 1609 1610 this.AWSetup = true; 1611 } 1612 1613 /** Clean up the functions defined above. */ 1614 _clearWindowFunctions() { 1615 if (this.AWSetup) { 1616 this.AWSetup = false; 1617 1618 for (const name of Object.keys(this._windowFuncs)) { 1619 delete this.win[name]; 1620 } 1621 } 1622 } 1623 1624 /** 1625 * Emit an event to the broker, if one is present. 1626 * 1627 * @param {string} name 1628 * @param {any} data 1629 */ 1630 _emitEvent(name, data) { 1631 this.listener?.(this.win, name, data); 1632 } 1633 1634 endTour(skipFadeOut = false) { 1635 // We don't want focus events that happen during teardown to affect 1636 // this.savedFocus 1637 this.win.removeEventListener("focus", this, { 1638 capture: true, 1639 passive: true, 1640 }); 1641 this.win.removeEventListener("keypress", this, { capture: true }); 1642 this._pageEventManager?.emit({ 1643 type: "tourend", 1644 target: this._container, 1645 }); 1646 this._container?.removeEventListener("popuphiding", this); 1647 this._pageEventManager?.clear(); 1648 1649 // Delete almost everything to get this ready to show a different message. 1650 this.teardownFeatureTourProgress(); 1651 this.pref = null; 1652 this.ready = false; 1653 this.message = null; 1654 this.content = null; 1655 this.currentScreen = null; 1656 // wait for fade out transition 1657 this._container?.classList.toggle( 1658 "hidden", 1659 this._container?.localName !== "panel" 1660 ); 1661 this._clearWindowFunctions(); 1662 const onFadeOut = () => { 1663 this._container?.remove(); 1664 this.renderObserver?.disconnect(); 1665 this._removePositionListeners(); 1666 this._removePanelConflictListeners(); 1667 this.doc.querySelector(`[src="${BUNDLE_SRC}"]`)?.remove(); 1668 // Put the focus back to the last place the user focused outside of the 1669 // featureCallout windows. 1670 if (this.savedFocus) { 1671 this.savedFocus.element.focus({ 1672 focusVisible: this.savedFocus.focusVisible, 1673 }); 1674 } 1675 this.savedFocus = null; 1676 this._emitEvent("end"); 1677 }; 1678 if (this._container?.localName === "panel") { 1679 const controller = new AbortController(); 1680 this._container.addEventListener( 1681 "popuphidden", 1682 event => { 1683 if (event.target === this._container) { 1684 controller.abort(); 1685 onFadeOut(); 1686 } 1687 }, 1688 { signal: controller.signal } 1689 ); 1690 this._container.hidePopup(!skipFadeOut); 1691 } else if (this._container) { 1692 this.win.setTimeout(onFadeOut, skipFadeOut ? 0 : TRANSITION_MS); 1693 } else { 1694 onFadeOut(); 1695 } 1696 } 1697 1698 _dismiss() { 1699 let action = 1700 this.currentScreen?.content.dismiss_action ?? 1701 this.currentScreen?.content.dismiss_button?.action; 1702 if (action?.type) { 1703 this.win.AWSendToParent("SPECIAL_ACTION", action); 1704 if (!action.dismiss) { 1705 return; 1706 } 1707 } 1708 this.endTour(); 1709 } 1710 1711 async _addScriptsAndRender() { 1712 const reactSrc = "chrome://global/content/vendor/react.js"; 1713 const domSrc = "chrome://global/content/vendor/react-dom.js"; 1714 // Add React script 1715 const getReactReady = () => { 1716 return new Promise(resolve => { 1717 let reactScript = this.doc.createElement("script"); 1718 reactScript.src = reactSrc; 1719 this.doc.head.appendChild(reactScript); 1720 reactScript.addEventListener("load", resolve); 1721 }); 1722 }; 1723 // Add ReactDom script 1724 const getDomReady = () => { 1725 return new Promise(resolve => { 1726 let domScript = this.doc.createElement("script"); 1727 domScript.src = domSrc; 1728 this.doc.head.appendChild(domScript); 1729 domScript.addEventListener("load", resolve); 1730 }); 1731 }; 1732 // Load React, then React Dom 1733 if (!this.doc.querySelector(`[src="${reactSrc}"]`)) { 1734 await getReactReady(); 1735 } 1736 if (!this.doc.querySelector(`[src="${domSrc}"]`)) { 1737 await getDomReady(); 1738 } 1739 // Load the bundle to render the content as configured. 1740 this.doc.querySelector(`[src="${BUNDLE_SRC}"]`)?.remove(); 1741 let bundleScript = this.doc.createElement("script"); 1742 bundleScript.src = BUNDLE_SRC; 1743 this.doc.head.appendChild(bundleScript); 1744 } 1745 1746 _observeRender(container) { 1747 this.renderObserver?.observe(container, { childList: true }); 1748 } 1749 1750 /** 1751 * Update the internal config with a new message. If a message is not 1752 * provided, try requesting one from ASRouter. The message content is stored 1753 * in this.config, which is returned by AWGetFeatureConfig. The aboutwelcome 1754 * bundle will use that function to get the content when it executes. 1755 * 1756 * @param {object} [message] ASRouter message. Omit to request a new one. 1757 * @param {number} [screenIndex] Index of the screen to render. 1758 * @returns {Promise<boolean>} true if a message is loaded, false if not. 1759 */ 1760 async _updateConfig(message, screenIndex) { 1761 if (this.loadingConfig) { 1762 return false; 1763 } 1764 1765 this.message = structuredClone(message || (await this._loadConfig())); 1766 1767 switch (this.message.template) { 1768 case "feature_callout": 1769 break; 1770 case "spotlight": 1771 // Deprecated: Special handling for spotlight messages, used as an 1772 // introduction to feature tours. 1773 this.currentScreen = "spotlight"; 1774 // fall through 1775 default: 1776 return false; 1777 } 1778 1779 this.config = this.message.content; 1780 1781 if (!this.config.screens) { 1782 lazy.log.error( 1783 `In ${ 1784 this.location 1785 }: Expected a message object with content.screens property, got: ${JSON.stringify( 1786 this.message 1787 )}` 1788 ); 1789 return false; 1790 } 1791 1792 // Set or override the default start screen. 1793 let overrideScreen = Number.isInteger(screenIndex); 1794 if (overrideScreen) { 1795 this.config.startScreen = screenIndex; 1796 } 1797 1798 let newScreen = this.config?.screens?.[this.config?.startScreen ?? 0]; 1799 1800 // If we have a feature tour in progress, try to set the start screen to 1801 // whichever screen is configured in the feature tour pref. 1802 if ( 1803 !overrideScreen && 1804 this.config?.tour_pref_name && 1805 this.config.tour_pref_name === this.pref?.name && 1806 this.featureTourProgress 1807 ) { 1808 const newIndex = this.config.screens.findIndex( 1809 screen => screen.id === this.featureTourProgress.screen 1810 ); 1811 if (newIndex !== -1) { 1812 newScreen = this.config.screens[newIndex]; 1813 if (newScreen?.id !== this.currentScreen?.id) { 1814 // This is how we tell the bundle to render the correct screen. 1815 this.config.startScreen = newIndex; 1816 } 1817 } 1818 } 1819 if (newScreen?.id === this.currentScreen?.id) { 1820 return false; 1821 } 1822 1823 this.currentScreen = newScreen; 1824 return true; 1825 } 1826 1827 /** 1828 * Request a message from ASRouter, targeting the `browser` and `page` values 1829 * passed to the constructor. 1830 * 1831 * @returns {Promise<object>} the requested message. 1832 */ 1833 async _loadConfig() { 1834 this.loadingConfig = true; 1835 await lazy.ASRouter.waitForInitialized; 1836 let result = await lazy.ASRouter.sendTriggerMessage({ 1837 browser: this.browser, 1838 // triggerId and triggerContext 1839 id: "featureCalloutCheck", 1840 context: { source: this.location }, 1841 }); 1842 this.loadingConfig = false; 1843 return result.message; 1844 } 1845 1846 /** 1847 * Try to render the callout in the current document. 1848 * 1849 * @returns {Promise<boolean>} whether the callout was rendered. 1850 */ 1851 async _renderCallout() { 1852 this._setupWindowFunctions(); 1853 await this._addCalloutLinkElements(); 1854 let container = this._createContainer(); 1855 if (container) { 1856 // This results in rendering the Feature Callout 1857 await this._addScriptsAndRender(); 1858 this._observeRender(container.querySelector(`#${CONTENT_BOX_ID}`)); 1859 if (container.localName === "div") { 1860 this._addPositionListeners(); 1861 } 1862 return true; 1863 } 1864 return false; 1865 } 1866 1867 /** 1868 * For each member of the screen's page_event_listeners array, add a listener. 1869 * 1870 * @param {Array<PageEventListenerConfig>} listeners 1871 * 1872 * @typedef {object} PageEventListenerConfig 1873 * @property {PageEventListenerParams} params Event listener parameters 1874 * @property {PageEventListenerAction} action Sent when the event fires 1875 * 1876 * @typedef {object} PageEventListenerParams See PageEventManager.sys.mjs 1877 * @property {string} type Event type string e.g. `click` 1878 * @property {string} [selectors] Target selector, e.g. `tag.class, #id[attr]` 1879 * @property {PageEventListenerOptions} [options] addEventListener options 1880 * 1881 * @typedef {object} PageEventListenerOptions 1882 * @property {boolean} [capture] Use event capturing phase 1883 * @property {boolean} [once] Remove listener after first event 1884 * @property {boolean} [preventDefault] Prevent default action 1885 * @property {number} [interval] Used only for `timeout` and `interval` event 1886 * types. These don't set up real event listeners, but instead invoke the 1887 * action on a timer. 1888 * @property {boolean} [every_window] Extend addEventListener to all windows. 1889 * Not compatible with `interval`. 1890 * 1891 * @typedef {object} PageEventListenerAction Action sent to AboutWelcomeParent 1892 * @property {string} [type] Action type, e.g. `OPEN_URL` 1893 * @property {object} [data] Extra data, properties depend on action type 1894 * @property {AdvanceScreensOptions} [advance_screens] Jump to a new screen 1895 * @property {boolean | "actionResult"} [dismiss] Dismiss callout 1896 * @property {boolean | "actionResult"} [reposition] Reposition callout 1897 * @property {boolean} [needsAwait] Wait for any special message actions 1898 * (given by the type property above) to resolve before advancing screens, 1899 * dismissing, or repositioning the callout, if those actions are set to 1900 * "actionResult". 1901 */ 1902 _attachPageEventListeners(listeners) { 1903 listeners?.forEach(({ params, action }) => 1904 this._loadPageEventManager[params.options?.once ? "once" : "on"]( 1905 params, 1906 event => { 1907 this._handlePageEventAction(action, event); 1908 if (params.options?.preventDefault) { 1909 event.preventDefault?.(); 1910 } 1911 } 1912 ) 1913 ); 1914 } 1915 1916 /** 1917 * Perform an action in response to a page event. 1918 * 1919 * @param {PageEventListenerAction} action 1920 * @param {Event} event Triggering event 1921 */ 1922 async _handlePageEventAction(action, event) { 1923 const page = this.location; 1924 const message_id = this.config?.id.toUpperCase(); 1925 const source = 1926 typeof event.target === "string" 1927 ? event.target 1928 : this._getUniqueElementIdentifier(event.target); 1929 let actionResult; 1930 if (action.type) { 1931 this.win.AWSendEventTelemetry?.({ 1932 event: "PAGE_EVENT", 1933 event_context: { 1934 action: action.type, 1935 reason: event.type?.toUpperCase(), 1936 source, 1937 page, 1938 }, 1939 message_id, 1940 }); 1941 let actionPromise = this.win.AWSendToParent("SPECIAL_ACTION", action); 1942 if (action.needsAwait) { 1943 actionResult = await actionPromise; 1944 } 1945 } 1946 1947 // `navigate` and `dismiss` can be true/false/undefined, or they can be a 1948 // string "actionResult" in which case we should use the actionResult 1949 // (boolean resolved by handleUserAction) 1950 const shouldDoBehavior = behavior => { 1951 if (behavior !== "actionResult") { 1952 return behavior; 1953 } 1954 if (action.needsAwait) { 1955 return actionResult; 1956 } 1957 lazy.log.warn( 1958 `In ${ 1959 this.location 1960 }: "actionResult" is only supported for actions with needsAwait, got: ${JSON.stringify(action)}` 1961 ); 1962 return false; 1963 }; 1964 1965 if (action.advance_screens) { 1966 if (shouldDoBehavior(action.advance_screens.behavior ?? true)) { 1967 this._advanceScreens?.(action.advance_screens); 1968 } 1969 } 1970 if (shouldDoBehavior(action.dismiss)) { 1971 this.win.AWSendEventTelemetry?.({ 1972 event: "DISMISS", 1973 event_context: { source: `PAGE_EVENT:${source}`, page }, 1974 message_id, 1975 }); 1976 this._dismiss(); 1977 } 1978 if (shouldDoBehavior(action.reposition)) { 1979 this.win.requestAnimationFrame(() => this._positionCallout()); 1980 } 1981 } 1982 1983 /** 1984 * For a given element, calculate a unique string that identifies it. 1985 * 1986 * @param {Element} target Element to calculate the selector for 1987 * @returns {string} Computed event target selector, e.g. `button#next` 1988 */ 1989 _getUniqueElementIdentifier(target) { 1990 let source; 1991 if (Element.isInstance(target)) { 1992 source = target.localName; 1993 if (target.className) { 1994 source += `.${[...target.classList].join(".")}`; 1995 } 1996 if (target.id) { 1997 source += `#${target.id}`; 1998 } 1999 if (target.attributes.length) { 2000 source += `${[...target.attributes] 2001 .filter(attr => ["is", "role", "open"].includes(attr.name)) 2002 .map(attr => `[${attr.name}="${attr.value}"]`) 2003 .join("")}`; 2004 } 2005 let doc = target.ownerDocument; 2006 if (doc.querySelectorAll(source).length > 1) { 2007 let uniqueAncestor = target.closest(`[id]:not(:scope, :root, body)`); 2008 if (uniqueAncestor) { 2009 source = `${this._getUniqueElementIdentifier( 2010 uniqueAncestor 2011 )} > ${source}`; 2012 } 2013 } 2014 if (doc !== this.doc) { 2015 let windowIndex = [ 2016 ...Services.wm.getEnumerator("navigator:browser"), 2017 ].indexOf(target.ownerGlobal); 2018 source = `window${windowIndex + 1}: ${source}`; 2019 } 2020 } 2021 return source; 2022 } 2023 2024 /** 2025 * Get the element that should be autofocused when the callout first opens. By 2026 * default, prioritize the primary button, then the secondary button, then any 2027 * additional button, excluding pseudo-links and the dismiss button. If no 2028 * button is found, focus the first input element. If no affirmative action is 2029 * found, focus the first button, which is probably the dismiss button. A 2030 * custom selector can also be provided to focus a specific element. 2031 * 2032 * @param {AutoFocusOptions} [options] 2033 * @returns {Element|null} The element to focus when the callout is shown. 2034 */ 2035 getAutoFocusElement({ selector, use_defaults = true } = {}) { 2036 if (!this._container) { 2037 return null; 2038 } 2039 if (selector) { 2040 let element = this._container.querySelector(selector); 2041 if (element) { 2042 return element; 2043 } 2044 } 2045 if (use_defaults) { 2046 return ( 2047 this._container.querySelector( 2048 ".primary:not(:disabled, [hidden], .text-link, .cta-link, .split-button)" 2049 ) || 2050 this._container.querySelector( 2051 ".secondary:not(:disabled, [hidden], .text-link, .cta-link, .split-button)" 2052 ) || 2053 this._container.querySelector( 2054 "button:not(:disabled, [hidden], .text-link, .cta-link, .dismiss-button, .split-button)" 2055 ) || 2056 this._container.querySelector("input:not(:disabled, [hidden])") || 2057 this._container.querySelector( 2058 "button:not(:disabled, [hidden], .text-link, .cta-link)" 2059 ) 2060 ); 2061 } 2062 return null; 2063 } 2064 2065 /** 2066 * Show a feature callout message, either by requesting one from ASRouter or 2067 * by showing a message passed as an argument. 2068 * 2069 * @param {object} [message] optional message to show instead of requesting one 2070 * @returns {Promise<boolean>} true if a message was shown 2071 */ 2072 async showFeatureCallout(message) { 2073 let updated = await this._updateConfig(message); 2074 2075 if (!updated || !this.config?.screens?.length) { 2076 return !!this.currentScreen; 2077 } 2078 2079 if (!this.renderObserver) { 2080 this.renderObserver = new this.win.MutationObserver(() => { 2081 // Check if the Feature Callout screen has loaded for the first time 2082 if (!this.ready && this._container.querySelector(".screen")) { 2083 const anchor = this._getAnchor(); 2084 const onRender = () => { 2085 this.ready = true; 2086 this._pageEventManager?.clear(); 2087 this._attachPageEventListeners( 2088 this.currentScreen?.content?.page_event_listeners 2089 ); 2090 if (anchor?.autofocus) { 2091 this.getAutoFocusElement(anchor.autofocus)?.focus(); 2092 } 2093 this.win.addEventListener("keypress", this, { capture: true }); 2094 if (this._container.localName === "div") { 2095 this.win.addEventListener("focus", this, { 2096 capture: true, // get the event before retargeting 2097 passive: true, 2098 }); 2099 this._positionCallout(); 2100 } else { 2101 this._container.classList.remove("hidden"); 2102 } 2103 }; 2104 if ( 2105 this._container.localName === "div" && 2106 this.doc.activeElement && 2107 !this.savedFocus 2108 ) { 2109 let element = this.doc.activeElement; 2110 this.savedFocus = { 2111 element, 2112 focusVisible: element.matches(":focus-visible"), 2113 }; 2114 } 2115 // Once the screen element is added to the DOM, wait for the 2116 // animation frame after next to ensure that _positionCallout 2117 // has access to the rendered screen with the correct height 2118 if (this._container.localName === "div") { 2119 this.win.requestAnimationFrame(() => { 2120 this.win.requestAnimationFrame(onRender); 2121 }); 2122 } else if (this._container.localName === "panel") { 2123 if (!anchor?.panel_position) { 2124 this.endTour(); 2125 return; 2126 } 2127 const { 2128 panel_position_string: position, 2129 offset_x: x, 2130 offset_y: y, 2131 } = anchor.panel_position; 2132 this._container.addEventListener("popupshown", onRender, { 2133 once: true, 2134 }); 2135 this._container.addEventListener("popuphiding", this); 2136 this._addPanelConflictListeners(); 2137 this._container.openPopup(anchor.element, { position, x, y }); 2138 } 2139 } 2140 }); 2141 } 2142 2143 this._pageEventManager?.clear(); 2144 this.ready = false; 2145 this._container?.remove(); 2146 this.renderObserver?.disconnect(); 2147 2148 let rendering = (await this._renderCallout()) && !!this.currentScreen; 2149 if (!rendering) { 2150 this.endTour(); 2151 } 2152 2153 if (this.message.template) { 2154 lazy.ASRouter.addImpression(this.message); 2155 } 2156 return rendering; 2157 } 2158 2159 /** 2160 * @typedef {object} FeatureCalloutTheme An object with a set of custom color 2161 * schemes and/or a preset key. If both are provided, the preset will be 2162 * applied first, then the custom themes will override the preset values. 2163 * @property {string} [preset] Key of {@link FeatureCallout.themePresets} 2164 * @property {ColorScheme} [light] Custom light scheme 2165 * @property {ColorScheme} [dark] Custom dark scheme 2166 * @property {ColorScheme} [hcm] Custom high contrast scheme 2167 * @property {ColorScheme} [all] Custom scheme that will be applied in all 2168 * cases, but overridden by the other schemes if they are present. This is 2169 * useful if the values are already controlled by the browser theme. 2170 * @property {boolean} [simulateContent] Set to true if the feature callout 2171 * exists in the browser chrome but is meant to be displayed over the 2172 * content area to appear as if it is part of the page. This will cause the 2173 * styles to use a media query targeting the content instead of the chrome, 2174 * so that if the browser theme doesn't match the content color scheme, the 2175 * callout will correctly follow the content scheme. This is currently used 2176 * for the feature callouts displayed over the PDF.js viewer. 2177 */ 2178 2179 /** 2180 * @typedef {object} ColorScheme An object with key-value pairs, with keys 2181 * from {@link FeatureCallout.themePropNames}, mapped to CSS color values 2182 */ 2183 2184 /** 2185 * Combine the preset and custom themes into a single object and store it. 2186 * 2187 * @param {FeatureCalloutTheme} theme 2188 */ 2189 _initTheme(theme) { 2190 /** @type {FeatureCalloutTheme} */ 2191 this.theme = Object.assign( 2192 {}, 2193 FeatureCallout.themePresets[theme.preset], 2194 theme 2195 ); 2196 } 2197 2198 /** 2199 * Apply all the theme colors to the feature callout's root element as CSS 2200 * custom properties in inline styles. These custom properties are consumed by 2201 * _feature-callout-theme.scss, which is bundled with the other styles that 2202 * are loaded by {@link FeatureCallout.prototype._addCalloutLinkElements}. 2203 */ 2204 _applyTheme() { 2205 if (this._container) { 2206 // This tells the stylesheets to use -moz-content-prefers-color-scheme 2207 // instead of prefers-color-scheme, in order to follow the content color 2208 // scheme instead of the chrome color scheme, in case of a mismatch when 2209 // the feature callout exists in the chrome but is meant to look like it's 2210 // part of the content of a page in a browser tab (like PDF.js). 2211 this._container.classList.toggle( 2212 "simulateContent", 2213 !!this.theme.simulateContent 2214 ); 2215 this._container.classList.toggle( 2216 "lwtNewtab", 2217 !!( 2218 this.theme.lwtNewtab !== false && 2219 this.theme.simulateContent && 2220 ["themed-content", "newtab"].includes(this.theme.preset) 2221 ) 2222 ); 2223 for (const type of ["light", "dark", "hcm"]) { 2224 const scheme = this.theme[type]; 2225 for (const name of FeatureCallout.themePropNames) { 2226 this._setThemeVariable( 2227 `--fc-${name}-${type}`, 2228 scheme?.[name] || this.theme.all?.[name] 2229 ); 2230 } 2231 } 2232 } 2233 } 2234 2235 /** 2236 * Set or remove a CSS custom property on the feature callout container 2237 * 2238 * @param {string} name Name of the CSS custom property 2239 * @param {string | void} [value] Value of the property, or omit to remove it 2240 */ 2241 _setThemeVariable(name, value) { 2242 if (value) { 2243 this._container.style.setProperty(name, value); 2244 } else { 2245 this._container.style.removeProperty(name); 2246 } 2247 } 2248 2249 /** A list of all the theme properties that can be set */ 2250 static themePropNames = [ 2251 "background", 2252 "color", 2253 "border", 2254 "accent-color", 2255 "button-background", 2256 "button-color", 2257 "button-border", 2258 "button-background-hover", 2259 "button-color-hover", 2260 "button-border-hover", 2261 "button-background-active", 2262 "button-color-active", 2263 "button-border-active", 2264 "primary-button-background", 2265 "primary-button-color", 2266 "primary-button-border", 2267 "primary-button-background-hover", 2268 "primary-button-color-hover", 2269 "primary-button-border-hover", 2270 "primary-button-background-active", 2271 "primary-button-color-active", 2272 "primary-button-border-active", 2273 "link-color", 2274 "link-color-hover", 2275 "link-color-active", 2276 "icon-success-color", 2277 "dismiss-button-background", 2278 "dismiss-button-background-hover", 2279 "dismiss-button-background-active", 2280 ]; 2281 2282 /** @type {{[key: string]: FeatureCalloutTheme}} */ 2283 static themePresets = { 2284 // For themed system pages like New Tab and Firefox View. Themed content 2285 // colors inherit from the user's theme through contentTheme.js. 2286 "themed-content": { 2287 all: { 2288 background: 2289 "var(--newtab-background-color, var(--background-color-canvas)) linear-gradient(var(--newtab-background-color-secondary), var(--newtab-background-color-secondary))", 2290 color: "var(--newtab-text-primary-color, var(--text-color))", 2291 border: 2292 "color-mix(in srgb, var(--newtab-background-color-secondary) 80%, #000)", 2293 "accent-color": "var(--button-background-color-primary)", 2294 "button-background": "color-mix(in srgb, transparent 93%, #000)", 2295 "button-color": "var(--newtab-text-primary-color, var(--text-color))", 2296 "button-border": "transparent", 2297 "button-background-hover": "color-mix(in srgb, transparent 88%, #000)", 2298 "button-color-hover": 2299 "var(--newtab-text-primary-color, var(--text-color))", 2300 "button-border-hover": "transparent", 2301 "button-background-active": "color-mix(in srgb, transparent 80%, #000)", 2302 "button-color-active": 2303 "var(--newtab-text-primary-color, var(--text-color))", 2304 "button-border-active": "transparent", 2305 "primary-button-background": "var(--button-background-color-primary)", 2306 "primary-button-color": "var(--button-text-color-primary)", 2307 "primary-button-border": "var(--button-border-color-primary)", 2308 "primary-button-background-hover": 2309 "var(--button-background-color-primary-hover)", 2310 "primary-button-color-hover": "var(--button-text-color-primary-hover)", 2311 "primary-button-border-hover": 2312 "var(--button-border-color-primary-hover)", 2313 "primary-button-background-active": 2314 "var(--button-background-color-primary-active)", 2315 "primary-button-color-active": 2316 "var(--button-text-color-primary-active)", 2317 "primary-button-border-active": 2318 "var(--button-border-color-primary-active)", 2319 "link-color": "LinkText", 2320 "link-color-hover": "LinkText", 2321 "link-color-active": "ActiveText", 2322 "link-color-visited": "VisitedText", 2323 "dismiss-button-background": 2324 "var(--newtab-background-color, var(--background-color-canvas)) linear-gradient(var(--newtab-background-color-secondary), var(--newtab-background-color-secondary))", 2325 "dismiss-button-background-hover": 2326 "var(--newtab-background-color, var(--background-color-canvas)) linear-gradient(color-mix(in srgb, currentColor 14%, var(--newtab-background-color-secondary)), color-mix(in srgb, currentColor 14%, var(--newtab-background-color-secondary)))", 2327 "dismiss-button-background-active": 2328 "var(--newtab-background-color, var(--background-color-canvas)) linear-gradient(color-mix(in srgb, currentColor 21%, var(--newtab-background-color-secondary)), color-mix(in srgb, currentColor 21%, var(--newtab-background-color-secondary)))", 2329 }, 2330 dark: { 2331 border: 2332 "color-mix(in srgb, var(--newtab-background-color-secondary) 80%, #FFF)", 2333 "button-background": "color-mix(in srgb, transparent 80%, #000)", 2334 "button-background-hover": "color-mix(in srgb, transparent 65%, #000)", 2335 "button-background-active": "color-mix(in srgb, transparent 55%, #000)", 2336 }, 2337 hcm: { 2338 background: "-moz-dialog", 2339 color: "-moz-dialogtext", 2340 border: "-moz-dialogtext", 2341 "accent-color": "LinkText", 2342 "button-background": "ButtonFace", 2343 "button-color": "ButtonText", 2344 "button-border": "ButtonText", 2345 "button-background-hover": "ButtonText", 2346 "button-color-hover": "ButtonFace", 2347 "button-border-hover": "ButtonText", 2348 "button-background-active": "ButtonText", 2349 "button-color-active": "ButtonFace", 2350 "button-border-active": "ButtonText", 2351 "dismiss-button-background": "-moz-dialog", 2352 "dismiss-button-background-hover": 2353 "color-mix(in srgb, currentColor 14%, SelectedItem)", 2354 "dismiss-button-background-active": 2355 "color-mix(in srgb, currentColor 21%, SelectedItem)", 2356 }, 2357 }, 2358 // PDF.js colors are from toolkit/components/pdfjs/content/web/viewer.css 2359 pdfjs: { 2360 all: { 2361 background: "#FFF", 2362 color: "rgb(12, 12, 13)", 2363 border: "#CFCFD8", 2364 "accent-color": "#0A84FF", 2365 "button-background": "rgb(215, 215, 219)", 2366 "button-color": "rgb(12, 12, 13)", 2367 "button-border": "transparent", 2368 "button-background-hover": "rgb(221, 222, 223)", 2369 "button-color-hover": "rgb(12, 12, 13)", 2370 "button-border-hover": "transparent", 2371 "button-background-active": "rgb(221, 222, 223)", 2372 "button-color-active": "rgb(12, 12, 13)", 2373 "button-border-active": "transparent", 2374 // use default primary button colors in _feature-callout-theme.scss 2375 "link-color": "LinkText", 2376 "link-color-hover": "LinkText", 2377 "link-color-active": "ActiveText", 2378 "link-color-visited": "VisitedText", 2379 "dismiss-button-background": "#FFF", 2380 "dismiss-button-background-hover": 2381 "color-mix(in srgb, currentColor 14%, #FFF)", 2382 "dismiss-button-background-active": 2383 "color-mix(in srgb, currentColor 21%, #FFF)", 2384 }, 2385 dark: { 2386 background: "#1C1B22", 2387 color: "#F9F9FA", 2388 border: "#3A3944", 2389 "button-background": "rgb(74, 74, 79)", 2390 "button-color": "#F9F9FA", 2391 "button-background-hover": "rgb(102, 102, 103)", 2392 "button-color-hover": "#F9F9FA", 2393 "button-background-active": "rgb(102, 102, 103)", 2394 "button-color-active": "#F9F9FA", 2395 "dismiss-button-background": "#1C1B22", 2396 "dismiss-button-background-hover": 2397 "color-mix(in srgb, currentColor 14%, #1C1B22)", 2398 "dismiss-button-background-active": 2399 "color-mix(in srgb, currentColor 21%, #1C1B22)", 2400 }, 2401 hcm: { 2402 background: "-moz-dialog", 2403 color: "-moz-dialogtext", 2404 border: "CanvasText", 2405 "accent-color": "Highlight", 2406 "button-background": "ButtonFace", 2407 "button-color": "ButtonText", 2408 "button-border": "ButtonText", 2409 "button-background-hover": "Highlight", 2410 "button-color-hover": "CanvasText", 2411 "button-border-hover": "Highlight", 2412 "button-background-active": "Highlight", 2413 "button-color-active": "CanvasText", 2414 "button-border-active": "Highlight", 2415 "dismiss-button-background": "-moz-dialog", 2416 "dismiss-button-background-hover": 2417 "color-mix(in srgb, currentColor 14%, SelectedItem)", 2418 "dismiss-button-background-active": 2419 "color-mix(in srgb, currentColor 21%, SelectedItem)", 2420 }, 2421 }, 2422 newtab: { 2423 all: { 2424 background: 2425 "var(--newtab-background-color, #F9F9FB) linear-gradient(var(--newtab-background-color-secondary, #FFF), var(--newtab-background-color-secondary, #FFF))", 2426 color: "var(--newtab-text-primary-color, WindowText)", 2427 border: 2428 "color-mix(in srgb, var(--newtab-background-color-secondary, #FFF) 80%, #000)", 2429 "accent-color": "#0061e0", 2430 "button-background": "color-mix(in srgb, transparent 93%, #000)", 2431 "button-color": "var(--newtab-text-primary-color, WindowText)", 2432 "button-border": "transparent", 2433 "button-background-hover": "color-mix(in srgb, transparent 88%, #000)", 2434 "button-color-hover": "var(--newtab-text-primary-color, WindowText)", 2435 "button-border-hover": "transparent", 2436 "button-background-active": "color-mix(in srgb, transparent 80%, #000)", 2437 "button-color-active": "var(--newtab-text-primary-color, WindowText)", 2438 "button-border-active": "transparent", 2439 // use default primary button colors in _feature-callout-theme.scss 2440 "link-color": "rgb(0, 97, 224)", 2441 "link-color-hover": "rgb(0, 97, 224)", 2442 "link-color-active": "color-mix(in srgb, rgb(0, 97, 224) 80%, #000)", 2443 "link-color-visited": "rgb(0, 97, 224)", 2444 "icon-success-color": "#2AC3A2", 2445 "dismiss-button-background": 2446 "var(--newtab-background-color, #F9F9FB) linear-gradient(var(--newtab-background-color-secondary, #FFF), var(--newtab-background-color-secondary, #FFF))", 2447 "dismiss-button-background-hover": 2448 "var(--newtab-background-color, #F9F9FB) linear-gradient(color-mix(in srgb, currentColor 14%, var(--newtab-background-color-secondary, #FFF)), color-mix(in srgb, currentColor 14%, var(--newtab-background-color-secondary, #FFF)))", 2449 "dismiss-button-background-active": 2450 "var(--newtab-background-color, #F9F9FB) linear-gradient(color-mix(in srgb, currentColor 21%, var(--newtab-background-color-secondary, #FFF)), color-mix(in srgb, currentColor 21%, var(--newtab-background-color-secondary, #FFF)))", 2451 }, 2452 dark: { 2453 "accent-color": "rgb(0, 221, 255)", 2454 background: 2455 "var(--newtab-background-color, #2B2A33) linear-gradient(var(--newtab-background-color-secondary, #42414D), var(--newtab-background-color-secondary, #42414D))", 2456 border: 2457 "color-mix(in srgb, var(--newtab-background-color-secondary, #42414D) 80%, #FFF)", 2458 "button-background": "color-mix(in srgb, transparent 80%, #000)", 2459 "button-background-hover": "color-mix(in srgb, transparent 65%, #000)", 2460 "button-background-active": "color-mix(in srgb, transparent 55%, #000)", 2461 "link-color": "rgb(0, 221, 255)", 2462 "link-color-hover": "rgb(0,221,255)", 2463 "link-color-active": "color-mix(in srgb, rgb(0, 221, 255) 60%, #FFF)", 2464 "link-color-visited": "rgb(0, 221, 255)", 2465 "icon-success-color": "#54FFBD", 2466 "dismiss-button-background": 2467 "var(--newtab-background-color, #2B2A33) linear-gradient(var(--newtab-background-color-secondary, #42414D), var(--newtab-background-color-secondary, #42414D))", 2468 "dismiss-button-background-hover": 2469 "var(--newtab-background-color, #2B2A33) linear-gradient(color-mix(in srgb, currentColor 14%, var(--newtab-background-color-secondary, #42414D)), color-mix(in srgb, currentColor 14%, var(--newtab-background-color-secondary, #42414D)))", 2470 "dismiss-button-background-active": 2471 "var(--newtab-background-color, #2B2A33) linear-gradient(color-mix(in srgb, currentColor 21%, var(--newtab-background-color-secondary, #42414D), color-mix(in srgb, currentColor 21%, var(--newtab-background-color-secondary, #42414D)))", 2472 }, 2473 hcm: { 2474 background: "-moz-dialog", 2475 color: "-moz-dialogtext", 2476 border: "-moz-dialogtext", 2477 "accent-color": "SelectedItem", 2478 "button-background": "ButtonFace", 2479 "button-color": "ButtonText", 2480 "button-border": "ButtonText", 2481 "button-background-hover": "ButtonText", 2482 "button-color-hover": "ButtonFace", 2483 "button-border-hover": "ButtonText", 2484 "button-background-active": "ButtonText", 2485 "button-color-active": "ButtonFace", 2486 "button-border-active": "ButtonText", 2487 "link-color": "LinkText", 2488 "link-color-hover": "LinkText", 2489 "link-color-active": "ActiveText", 2490 "link-color-visited": "VisitedText", 2491 "dismiss-button-background": "-moz-dialog", 2492 "dismiss-button-background-hover": 2493 "color-mix(in srgb, currentColor 14%, SelectedItem)", 2494 "dismiss-button-background-active": 2495 "color-mix(in srgb, currentColor 21%, SelectedItem)", 2496 }, 2497 }, 2498 // These colors are intended to inherit the user's theme properties from the 2499 // main chrome window, for callouts to be anchored to chrome elements. 2500 // Specific schemes aren't necessary since the theme and frontend 2501 // stylesheets handle these variables' values. 2502 chrome: { 2503 all: { 2504 // Use a gradient because it's possible (due to custom themes) that the 2505 // arrowpanel-background will be semi-transparent, causing the arrow to 2506 // show through the callout background. Put the Menu color behind the 2507 // arrowpanel-background. 2508 background: 2509 "Menu linear-gradient(var(--arrowpanel-background), var(--arrowpanel-background))", 2510 color: "var(--arrowpanel-color)", 2511 border: "var(--arrowpanel-border-color)", 2512 "accent-color": "var(--focus-outline-color)", 2513 // Button Background 2514 "button-background": "var(--button-background-color)", 2515 "button-background-hover": "var(--button-background-color-hover)", 2516 "button-background-active": "var(--button-background-color-active)", 2517 "button-background-disabled": "var(--button-background-color-disabled)", 2518 // Button Text 2519 "button-color": "var(--button-text-color)", 2520 "button-color-hover": "var(--button-text-color-hover)", 2521 "button-color-active": "var(--button-text-color-active)", 2522 // Button Border 2523 "button-border": "var(--button-border-color)", 2524 "button-border-color": "var(--button-border-color)", 2525 "button-border-hover": "var(--button-border-color-hover)", 2526 "button-border-active": "var(--button-border-color-active)", 2527 "button-border-disabled": "var(--button-border-color-disabled)", 2528 // Primary Button Background 2529 "primary-button-background": "var(--button-background-color-primary)", 2530 "primary-button-background-hover": 2531 "var(--button-background-color-primary-hover)", 2532 "primary-button-background-active": 2533 "var(--button-background-color-primary-active)", 2534 "primary-button-background-disabled": 2535 "var(--button-background-color-primary-disabled)", 2536 // Primary Button Color 2537 "primary-button-color": "var(--button-text-color-primary)", 2538 "primary-button-color-hover": "var(--button-text-color-primary)", 2539 "primary-button-color-active": "var(--button-text-color-primary)", 2540 "primary-button-color-disabled": "var(--button-text-color-primary)", 2541 // Primary Button Border 2542 "primary-button-border": "var(--button-border-color-primary)", 2543 "primary-button-border-hover": 2544 "var(--button-border-color-primary-hover)", 2545 "primary-button-border-active": 2546 "var(--button-border-color-primary-active)", 2547 "primary-button-border-disabled": 2548 "var(--button-border-color-primary-disabled)", 2549 // Links 2550 "link-color": "LinkText", 2551 "link-color-hover": "LinkText", 2552 "link-color-active": "ActiveText", 2553 "link-color-visited": "VisitedText", 2554 "icon-success-color": "var(--attention-dot-color)", 2555 // Dismiss Button 2556 "dismiss-button-background": 2557 "Menu linear-gradient(var(--arrowpanel-background), var(--arrowpanel-background))", 2558 "dismiss-button-background-hover": 2559 "Menu linear-gradient(color-mix(in srgb, currentColor 14%, var(--arrowpanel-background)))", 2560 "dismiss-button-background-active": 2561 "Menu linear-gradient(color-mix(in srgb, currentColor 21%, var(--arrowpanel-background)))", 2562 }, 2563 hcm: { 2564 background: "var(--arrowpanel-background)", 2565 "dismiss-button-background": "var(--arrowpanel-background)", 2566 "dismiss-button-background-hover": 2567 "color-mix(in srgb, currentColor 14%, SelectedItem)", 2568 "dismiss-button-background-active": 2569 "color-mix(in srgb, currentColor 21%, SelectedItem)", 2570 }, 2571 }, 2572 }; 2573 }