Bug 1741736 - Add overlay to Screenshots component implementation. r=sfoster,fluent-reviewers

Differential Revision: https://phabricator.services.mozilla.com/D132832
This commit is contained in:
Niklas Baumgardner 2022-01-14 14:36:49 +00:00
parent a207ad062a
commit bb01085481
12 changed files with 563 additions and 14 deletions

View File

@ -7,6 +7,14 @@
var EXPORTED_SYMBOLS = ["ScreenshotsComponentChild"]; var EXPORTED_SYMBOLS = ["ScreenshotsComponentChild"];
const { XPCOMUtils } = ChromeUtils.import(
"resource://gre/modules/XPCOMUtils.jsm"
);
XPCOMUtils.defineLazyModuleGetters(this, {
ScreenshotsOverlayChild: "resource:///modules/ScreenshotsOverlayChild.jsm",
});
class ScreenshotsComponentChild extends JSWindowActorChild { class ScreenshotsComponentChild extends JSWindowActorChild {
receiveMessage(message) { receiveMessage(message) {
switch (message.name) { switch (message.name) {
@ -22,6 +30,13 @@ class ScreenshotsComponentChild extends JSWindowActorChild {
return null; return null;
} }
/**
* Send a request to cancel the screenshot to the parent process
*/
requestCancelScreenshot() {
this.sendAsyncMessage("Screenshots:CancelScreenshot", null);
}
/** /**
* Resolves when the document is ready to have an overlay injected into it. * Resolves when the document is ready to have an overlay injected into it.
* *
@ -64,16 +79,35 @@ class ScreenshotsComponentChild extends JSWindowActorChild {
* @returns {Boolean} true when document is ready and the overlay is shown * @returns {Boolean} true when document is ready and the overlay is shown
* otherwise false * otherwise false
*/ */
async startScreenshotsOverlay(details = {}) { async startScreenshotsOverlay() {
try { try {
await this.documentIsReady(); await this.documentIsReady();
} catch (ex) { } catch (ex) {
console.warn(`ScreenshotsComponentChild: ${ex.message}`); console.warn(`ScreenshotsComponentChild: ${ex.message}`);
return false; return false;
} }
await this.documentIsReady();
let overlay =
this._overlay ||
(this._overlay = new ScreenshotsOverlayChild.AnonymousContentOverlay(
this.document,
this
));
this.document.addEventListener("keydown", this.handler);
overlay.initialize();
return true; return true;
} }
/**
* Function to handle the escape key press to cancel screenshots overlay
* @param event The keydown event
*/
handler = event => {
if (event.key === "Escape") {
this.requestCancelScreenshot();
}
};
/** /**
* Remove the screenshots overlay. * Remove the screenshots overlay.
* *
@ -81,7 +115,8 @@ class ScreenshotsComponentChild extends JSWindowActorChild {
* true when the overlay has been removed otherwise false * true when the overlay has been removed otherwise false
*/ */
endScreenshotsOverlay() { endScreenshotsOverlay() {
// this function will be implemented soon this.document.removeEventListener("keydown", this.handler);
this._overlay?.tearDown();
return true; return true;
} }

View File

@ -172,6 +172,7 @@
norolluponanchor="true" norolluponanchor="true"
consumeoutsideclicks="never" consumeoutsideclicks="never"
level="parent" level="parent"
noautohide="true"
tabspecific="true"> tabspecific="true">
<screenshots-buttons></screenshots-buttons> <screenshots-buttons></screenshots-buttons>
</panel> </panel>

View File

@ -273,10 +273,6 @@ var whitelist = [
// (The references to these files are dynamically generated, so the test can't // (The references to these files are dynamically generated, so the test can't
// find the references) // find the references)
{ file: "chrome://browser/content/screenshots/copied-notification.svg" }, { file: "chrome://browser/content/screenshots/copied-notification.svg" },
{
file:
"chrome://browser/content/screenshots/icon-welcome-face-without-eyes.svg",
},
{ file: "resource://app/modules/SnapshotSelector.jsm" }, { file: "resource://app/modules/SnapshotSelector.jsm" },

View File

@ -666,6 +666,9 @@ let JSWINDOWACTORS = {
}, },
ScreenshotsComponent: { ScreenshotsComponent: {
parent: {
moduleURI: "resource:///modules/ScreenshotsUtils.jsm",
},
child: { child: {
moduleURI: "resource:///actors/ScreenshotsComponentChild.jsm", moduleURI: "resource:///actors/ScreenshotsComponentChild.jsm",
}, },

View File

@ -0,0 +1,291 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
/**
* The Screenshots overlay is inserted into the document's
* canvasFrame anonymous content container (see dom/webidl/Document.webidl).
*
* This container gets cleared automatically when the document navigates.
*
* Since the overlay markup is inserted in the canvasFrame using
* insertAnonymousContent, this means that it can be modified using the API
* described in AnonymousContent.webidl.
*
* Any mutation of this content must be via the AnonymousContent API.
* This is similar in design to [devtools' highlighters](https://firefox-source-docs.mozilla.org/devtools/tools/highlighters.html#inserting-content-in-the-page),
* though as Screenshots doesnt need to work on XUL documents, or allow multiple kinds of
* highlight/overlay our case is a little simpler.
*
* To retrieve the AnonymousContent instance, use the `content` getter.
*/
var EXPORTED_SYMBOLS = ["ScreenshotsOverlayChild"];
const { XPCOMUtils } = ChromeUtils.import(
"resource://gre/modules/XPCOMUtils.jsm"
);
XPCOMUtils.defineLazyGetter(this, "overlayLocalization", () => {
return new Localization(["browser/screenshotsOverlay.ftl"], true);
});
const STYLESHEET_URL =
"chrome://browser/content/screenshots/overlay/overlay.css";
const HTML_NS = "http://www.w3.org/1999/xhtml";
class AnonymousContentOverlay {
constructor(contentDocument, screenshotsChild) {
this.listeners = new Map();
this.elements = new Map();
this.screenshotsChild = screenshotsChild;
this.contentDocument = contentDocument;
// aliased for easier diffs/maintenance of the event management code borrowed from devtools highlighters
this.pageListenerTarget = contentDocument.ownerGlobal;
this.overlayFragment = null;
this.overlayId = "screenshots-overlay-container";
this._initialized = false;
}
get content() {
if (!this._content || Cu.isDeadWrapper(this._content)) {
return null;
}
return this._content;
}
async initialize() {
if (this._initialized) {
return;
}
let document = this.contentDocument;
let window = document.ownerGlobal;
// Inject stylesheet
if (!this.overlayFragment) {
try {
window.windowUtils.loadSheetUsingURIString(
STYLESHEET_URL,
window.windowUtils.AGENT_SHEET
);
} catch {
// The method fails if the url is already loaded.
}
}
// Inject markup for the overlay UI
this.overlayFragment = this.overlayFragment
? this.overlayFragment
: this.buildOverlay();
this._content = document.insertAnonymousContent(
this.overlayFragment.children[0]
);
this.addEventListenerForElement(
"screenshots-cancel-button",
"click",
(event, targetId) => {
this.screenshotsChild.requestCancelScreenshot();
}
);
// TODO:
// * Define & hook up drag handles for custom region selection
this._initialized = true;
}
tearDown() {
if (this._content) {
this._removeAllListeners();
try {
this.contentDocument.removeAnonymousContent(this._content);
} catch (e) {
// If the current window isn't the one the content was inserted into, this
// will fail, but that's fine.
}
}
this._initialized = false;
}
createElem(elemName, props = {}, className = "") {
let elem = this.contentDocument.createElementNS(HTML_NS, elemName);
if (props) {
for (let [name, value] of Object.entries(props)) {
elem[name] = value;
}
}
if (className) {
elem.className = className;
}
// register this element so we can target events at it
if (elem.id) {
this.elements.set(elem.id, elem);
}
return elem;
}
buildOverlay() {
let [cancel, instrustions] = overlayLocalization.formatMessagesSync([
{ id: "screenshots-overlay-cancel-button" },
{ id: "screenshots-overlay-instructions" },
]);
const htmlString = `
<div id="${this.overlayId}">
<div class="fixed-container">
<div class="face-container">
<div class="eye left"><div class="eyeball"></div></div>
<div class="eye right"><div class="eyeball"></div></div>
<div class="face"></div>
</div>
<div class="preview-instructions" data-l10n-id="screenshots-instructions">${instrustions.value}</div>
<div class="cancel-shot" id="screenshots-cancel-button" data-l10n-id="screenshots-overlay-cancel-button">${cancel.value}</div>
</div>
</div`;
const parser = new this.contentDocument.ownerGlobal.DOMParser();
const tmpDoc = parser.parseFromString(htmlString, "text/html");
const fragment = this.contentDocument.createDocumentFragment();
fragment.appendChild(tmpDoc.body.children[0]);
return fragment;
}
// The event tooling is borrowed directly from devtools' highlighters (CanvasFrameAnonymousContentHelper)
/**
* Add an event listener to one of the elements inserted in the canvasFrame
* native anonymous container.
* Like other methods in this helper, this requires the ID of the element to
* be passed in.
*
* Note that if the content page navigates, the event listeners won't be
* added again.
*
* Also note that unlike traditional DOM events, the events handled by
* listeners added here will propagate through the document only through
* bubbling phase, so the useCapture parameter isn't supported.
* It is possible however to call e.stopPropagation() to stop the bubbling.
*
* IMPORTANT: the chrome-only canvasFrame insertion API takes great care of
* not leaking references to inserted elements to chrome JS code. That's
* because otherwise, chrome JS code could freely modify native anon elements
* inside the canvasFrame and probably change things that are assumed not to
* change by the C++ code managing this frame.
* See https://wiki.mozilla.org/DevTools/Highlighter#The_AnonymousContent_API
* Unfortunately, the inserted nodes are still available via
* event.originalTarget, and that's what the event handler here uses to check
* that the event actually occured on the right element, but that also means
* consumers of this code would be able to access the inserted elements.
* Therefore, the originalTarget property will be nullified before the event
* is passed to your handler.
*
* IMPL DETAIL: A single event listener is added per event types only, at
* browser level and if the event originalTarget is found to have the provided
* ID, the callback is executed (and then IDs of parent nodes of the
* originalTarget are checked too).
*
* @param {String} id
* @param {String} type
* @param {Function} handler
*/
addEventListenerForElement(id, type, handler) {
if (typeof id !== "string") {
throw new Error(
"Expected a string ID in addEventListenerForElement but got: " + id
);
}
// If no one is listening for this type of event yet, add one listener.
if (!this.listeners.has(type)) {
const target = this.pageListenerTarget;
target.addEventListener(type, this, true);
// Each type entry in the map is a map of ids:handlers.
this.listeners.set(type, new Map());
}
const listeners = this.listeners.get(type);
listeners.set(id, handler);
}
/**
* Remove an event listener from one of the elements inserted in the
* canvasFrame native anonymous container.
* @param {String} id
* @param {String} type
*/
removeEventListenerForElement(id, type) {
const listeners = this.listeners.get(type);
if (!listeners) {
return;
}
listeners.delete(id);
// If no one is listening for event type anymore, remove the listener.
if (!listeners.size) {
const target = this.pageListenerTarget;
target.removeEventListener(type, this, true);
}
}
handleEvent(event) {
const listeners = this.listeners.get(event.type);
if (!listeners) {
return;
}
// Hide the originalTarget property to avoid exposing references to native
// anonymous elements. See addEventListenerForElement's comment.
let isPropagationStopped = false;
const eventProxy = new Proxy(event, {
get: (obj, name) => {
if (name === "originalTarget") {
return null;
} else if (name === "stopPropagation") {
return () => {
isPropagationStopped = true;
};
}
return obj[name];
},
});
// Start at originalTarget, bubble through ancestors and call handlers when
// needed.
let node = event.originalTarget;
while (node) {
let nodeId = node.id;
if (nodeId) {
const handler = listeners.get(node.id);
if (handler) {
handler(eventProxy, nodeId);
if (isPropagationStopped) {
break;
}
}
if (nodeId == this.overlayId) {
break;
}
}
node = node.parentNode;
}
}
_removeAllListeners() {
if (this.pageListenerTarget) {
const target = this.pageListenerTarget;
for (const [type] of this.listeners) {
target.removeEventListener(type, this, true);
}
}
this.listeners.clear();
}
}
var ScreenshotsOverlayChild = {
AnonymousContentOverlay,
};

View File

@ -4,7 +4,7 @@
"use strict"; "use strict";
var EXPORTED_SYMBOLS = ["ScreenshotsUtils"]; var EXPORTED_SYMBOLS = ["ScreenshotsUtils", "ScreenshotsComponentParent"];
const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
@ -12,6 +12,16 @@ const PanelPosition = "bottomright topright";
const PanelOffsetX = -33; const PanelOffsetX = -33;
const PanelOffsetY = -8; const PanelOffsetY = -8;
class ScreenshotsComponentParent extends JSWindowActorParent {
receiveMessage(message) {
switch (message.name) {
case "Screenshots:CancelScreenshot":
let browser = message.target.browsingContext.topFrameElement;
ScreenshotsUtils.closePanel(browser);
}
}
}
var ScreenshotsUtils = { var ScreenshotsUtils = {
initialized: false, initialized: false,
initialize() { initialize() {
@ -57,9 +67,8 @@ var ScreenshotsUtils = {
} }
break; break;
case "screenshots-take-screenshot": case "screenshots-take-screenshot":
// need to toggle because panel button was clicked // need to close the preview because screenshot was taken
// and we need to hide the buttons this.closePanel(browser);
this.togglePreview(browser);
// init UI as a tab dialog box // init UI as a tab dialog box
let dialogBox = gBrowser.getTabDialogBox(browser); let dialogBox = gBrowser.getTabDialogBox(browser);
@ -103,8 +112,31 @@ var ScreenshotsUtils = {
return actor; return actor;
}, },
/** /**
* If the buttons panel exists and the panel is open we will hipe the panel * Open the panel buttons and call child actor to open the overlay
* popup and hide the screenshot overlay. * @param browser The current browser
*/
openPanel(browser) {
let actor = this.getActor(browser);
actor.sendQuery("Screenshots:ShowOverlay");
this.createOrDisplayButtons(browser);
},
/**
* Close the panel and call child actor to close the overlay
* @param browser The current browser
*/
closePanel(browser) {
let buttonsPanel = browser.ownerDocument.querySelector(
"#screenshotsPagePanel"
);
if (buttonsPanel && buttonsPanel.state !== "closed") {
buttonsPanel.hidePopup();
}
let actor = this.getActor(browser);
actor.sendQuery("Screenshots:HideOverlay");
},
/**
* If the buttons panel exists and is open we will hide both the panel
* popup and the overlay.
* Otherwise create or display the buttons. * Otherwise create or display the buttons.
* @param browser The current browser. * @param browser The current browser.
*/ */
@ -117,6 +149,8 @@ var ScreenshotsUtils = {
let actor = this.getActor(browser); let actor = this.getActor(browser);
return actor.sendQuery("Screenshots:HideOverlay"); return actor.sendQuery("Screenshots:HideOverlay");
} }
let actor = this.getActor(browser);
actor.sendQuery("Screenshots:ShowOverlay");
return this.createOrDisplayButtons(browser); return this.createOrDisplayButtons(browser);
}, },
/** /**
@ -173,10 +207,9 @@ var ScreenshotsUtils = {
template.replaceWith(clone); template.replaceWith(clone);
buttonsPanel = doc.querySelector("#screenshotsPagePanel"); buttonsPanel = doc.querySelector("#screenshotsPagePanel");
} }
let anchor = doc.querySelector("#navigator-toolbox"); let anchor = doc.querySelector("#navigator-toolbox");
buttonsPanel.openPopup(anchor, PanelPosition, PanelOffsetX, PanelOffsetY); buttonsPanel.openPopup(anchor, PanelPosition, PanelOffsetX, PanelOffsetY);
let actor = this.getActor(browser);
return actor.sendQuery("Screenshots:ShowOverlay");
}, },
/** /**
* Gets the full page bounds from the screenshots child actor. * Gets the full page bounds from the screenshots child actor.

View File

@ -16,3 +16,7 @@ browser.jar:
content/browser/screenshots/screenshots-buttons.css (screenshots-buttons.css) content/browser/screenshots/screenshots-buttons.css (screenshots-buttons.css)
content/browser/screenshots/screenshots.css (content/screenshots.css) content/browser/screenshots/screenshots.css (content/screenshots.css)
content/browser/screenshots/screenshots.html (content/screenshots.html) content/browser/screenshots/screenshots.html (content/screenshots.html)
content/browser/screenshots/overlay/ (overlay/**)
% content screenshots-overlay %overlay/

View File

@ -5,6 +5,7 @@
# file, You can obtain one at http://mozilla.org/MPL/2.0/. # file, You can obtain one at http://mozilla.org/MPL/2.0/.
EXTRA_JS_MODULES += [ EXTRA_JS_MODULES += [
"ScreenshotsOverlayChild.jsm",
"ScreenshotsUtils.jsm", "ScreenshotsUtils.jsm",
] ]

View File

@ -0,0 +1,126 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
:-moz-native-anonymous #screenshots-overlay-container {
/*
Content CSS applying to the html element can impact the overlay.
To avoid that, possible cases have been set to initial.
*/
text-transform: initial;
text-indent: initial;
letter-spacing: initial;
word-spacing: initial;
color: initial;
direction: initial;
writing-mode: initial;
}
/**
* Overlay content is position: fixed as we need to allow for the possiblily
* of the document scrolling or changing size while the overlay is visible
*/
:-moz-native-anonymous #screenshots-overlay-container {
position: fixed;
top: 0; left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.7);
pointer-events: auto;
cursor: crosshair;
}
:-moz-native-anonymous #screenshots-overlay-container[hidden] {
display: none;
}
:-moz-native-anonymous #screenshots-overlay-container[dragging] {
cursor: grabbing;
}
:-moz-native-anonymous #screenshots-cancel-button {
background-color: transparent;
width: fit-content;
cursor: pointer;
outline: none;
border-radius: 3px;
border: 1px #9b9b9b solid;
color: #fff;
cursor: pointer;
font-family: -apple-system, BlinkMacSystemFont, "segoe ui", "helvetica neue", helvetica, ubuntu, roboto, noto, arial, sans-serif;
font-size: 16px;
margin-top: 40px;
padding: 10px 25px;
}
:-moz-native-anonymous .fixed-container {
align-items: center;
display: flex;
flex-direction: column;
height: 100vh;
justify-content: center;
inset-inline-start: 0;
margin: 0;
padding: 0;
position: fixed;
top: 0;
width: 100%;
}
:-moz-native-anonymous .face-container {
position: relative;
width: 64px;
height: 64px;
}
:-moz-native-anonymous .face {
width: 62px;
height: 62px;
display: block;
background-image: url("chrome://browser/content/screenshots/icon-welcome-face-without-eyes.svg");
}
:-moz-native-anonymous .eye {
background-color: #fff;
width: 10px;
height: 14px;
position: absolute;
border-radius: 100%;
overflow: hidden;
inset-inline-start: 16px;
top: 19px;
}
:-moz-native-anonymous .eyeball {
position: absolute;
width: 6px;
height: 6px;
background-color: #000;
border-radius: 50%;
inset-inline-start: 2px;
top: 4px;
z-index: 10;
}
:-moz-native-anonymous .left {
margin-inline-start: 0;
}
:-moz-native-anonymous .right {
margin-inline-start: 20px;
}
:-moz-native-anonymous .preview-instructions {
display: flex;
align-items: center;
justify-content: center;
animation: pulse 125mm cubic-bezier(0.07, 0.95, 0, 1);
color: #fff;
font-family: -apple-system, BlinkMacSystemFont, "segoe ui", "helvetica neue", helvetica, ubuntu, roboto, noto, arial, sans-serif;
font-size: 24px;
line-height: 32px;
text-align: center;
padding-top: 20px;
width: 400px;
user-select: none;
}

View File

@ -7,6 +7,7 @@ prefs =
extensions.screenshots.disabled=false extensions.screenshots.disabled=false
screenshots.browser.component.enabled=true screenshots.browser.component.enabled=true
[browser_screenshots_test_escape.js]
[browser_screenshots_test_full_page.js] [browser_screenshots_test_full_page.js]
skip-if = (!debug && os == 'win' && os_version == '6.1') # Bug 1746281 skip-if = (!debug && os == 'win' && os_version == '6.1') # Bug 1746281
[browser_screenshots_test_toggle_pref.js] [browser_screenshots_test_toggle_pref.js]

View File

@ -0,0 +1,52 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
add_task(async function test_fullpageScreenshot() {
CustomizableUI.addWidgetToArea(
"screenshot-button",
CustomizableUI.AREA_NAVBAR
);
let screenshotBtn = document.getElementById("screenshot-button");
Assert.ok(screenshotBtn, "The screenshots button was added to the nav bar");
await BrowserTestUtils.withNewTab(
{
gBrowser,
url: TEST_PAGE,
},
async browser => {
let helper = new ScreenshotsHelper(browser);
let contentInfo = await helper.getContentDimensions();
ok(contentInfo, "Got dimensions back from the content");
// click toolbar button so panel shows
helper.triggerUIFromToolbar();
let panel = gBrowser.selectedBrowser.ownerDocument.querySelector(
"#screenshotsPagePanel"
);
await BrowserTestUtils.waitForMutationCondition(
panel,
{ attributes: true },
() => {
return BrowserTestUtils.is_visible(panel);
}
);
ok(BrowserTestUtils.is_visible(panel), "Panel buttons are visible");
EventUtils.synthesizeKey("KEY_Escape");
await BrowserTestUtils.waitForMutationCondition(
panel,
{ attributes: true },
() => {
return BrowserTestUtils.is_hidden(panel);
}
);
ok(BrowserTestUtils.is_hidden(panel), "Panel buttons are hidden");
}
);
});

View File

@ -0,0 +1,6 @@
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
screenshots-overlay-cancel-button = Cancel
screenshots-overlay-instructions = Drag or click on the page to select a region. Press ESC to cancel.