fune/toolkit/components/printing/content/print.js
Emma Malysz f68ef97340 Bug 1656057, implement form validation and disable form for an invalid event r=mstriemer,sfoster,fluent-reviewers,flod
Disables all elements in the form except for those within the invalid section.
Hides the form until printers become available.

Differential Revision: https://phabricator.services.mozilla.com/D86989
2020-08-17 23:10:40 +00:00

801 lines
22 KiB
JavaScript

/* 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/. */
const {
gBrowser,
PrintUtils,
Services,
} = window.docShell.chromeEventHandler.ownerGlobal;
ChromeUtils.defineModuleGetter(
this,
"DownloadPaths",
"resource://gre/modules/DownloadPaths.jsm"
);
const INVALID_INPUT_DELAY_MS = 500;
document.addEventListener(
"DOMContentLoaded",
e => {
PrintEventHandler.init();
},
{ once: true }
);
window.addEventListener(
"unload",
e => {
document.textContent = "";
},
{ once: true }
);
var PrintEventHandler = {
async init() {
this.sourceBrowser = this.getSourceBrowser();
this.previewBrowser = this.getPreviewBrowser();
document.addEventListener("print", e => this.print({ silent: true }));
document.addEventListener("update-print-settings", e =>
this.updateSettings(e.detail)
);
document.addEventListener("cancel-print", () => this.cancelPrint());
document.addEventListener("open-system-dialog", () =>
this.print({ silent: false })
);
this.settingFlags = {
orientation: Ci.nsIPrintSettings.kInitSaveOrientation,
printerName: Ci.nsIPrintSettings.kInitSavePrinterName,
scaling: Ci.nsIPrintSettings.kInitSaveScaling,
shrinkToFit: Ci.nsIPrintSettings.kInitSaveShrinkToFit,
printFootersHeaders:
Ci.nsIPrintSettings.kInitSaveHeaderLeft |
Ci.nsIPrintSettings.kInitSaveHeaderCenter |
Ci.nsIPrintSettings.kInitSaveHeaderRight |
Ci.nsIPrintSettings.kInitSaveFooterLeft |
Ci.nsIPrintSettings.kInitSaveFooterCenter |
Ci.nsIPrintSettings.kInitSaveFooterRight,
printBackgrounds:
Ci.nsIPrintSettings.kInitSaveBGColors |
Ci.nsIPrintSettings.kInitSaveBGImages,
};
// First check the available destinations to ensure we get settings for an
// accessible printer.
let { destinations, selectedPrinter } = await this.getPrintDestinations();
// Find the settings for the printer we'll select initially.
this.settings = PrintUtils.getPrintSettings(selectedPrinter.value);
// Wrap the settings with our view model to simplify the UI.
this.viewSettings = new Proxy(this.settings, PrintSettingsViewProxy);
// Set the printer name through the view model to ensure the PDF flags are
// set correctly.
this.viewSettings.printerName = this.settings.printerName;
this.updatePrintPreview();
document.dispatchEvent(
new CustomEvent("available-destinations", {
detail: destinations,
})
);
document.dispatchEvent(
new CustomEvent("print-settings", {
detail: this.viewSettings,
})
);
document.body.removeAttribute("loading");
},
async print({ silent } = {}) {
let settings = this.settings;
settings.printSilent = silent;
if (settings.printerName == PrintUtils.SAVE_TO_PDF_PRINTER) {
try {
settings.toFileName = await pickFileName(this.sourceBrowser, settings);
} catch (e) {
// Don't care why just yet.
return;
}
}
if (silent) {
// This seems like it should be handled automatically but it isn't.
Services.prefs.setStringPref("print_printer", settings.printerName);
}
PrintUtils.printWindow(this.previewBrowser.browsingContext, settings);
},
cancelPrint() {
window.close();
},
updateSettings(changedSettings = {}) {
let isChanged = false;
let flags = 0;
for (let [setting, value] of Object.entries(changedSettings)) {
if (this.viewSettings[setting] != value) {
this.viewSettings[setting] = value;
if (setting in this.settingFlags) {
flags |= this.settingFlags[setting];
}
isChanged = true;
Services.telemetry.keyedScalarAdd(
"printing.settings_changed",
setting,
1
);
}
}
if (isChanged) {
let PSSVC = Cc["@mozilla.org/gfx/printsettings-service;1"].getService(
Ci.nsIPrintSettingsService
);
if (flags) {
PSSVC.savePrintSettingsToPrefs(this.settings, true, flags);
this.updatePrintPreview();
}
document.dispatchEvent(
new CustomEvent("print-settings", {
detail: this.viewSettings,
})
);
}
},
async updatePrintPreview() {
if (this._previewUpdatingPromise) {
if (!this._queuedPreviewUpdatePromise) {
this._queuedPreviewUpdatePromise = this._previewUpdatingPromise.then(
() => this._updatePrintPreview()
);
}
// else there's already an update queued.
} else {
this._previewUpdatingPromise = this._updatePrintPreview();
}
},
async _updatePrintPreview() {
let numPages = await PrintUtils.updatePrintPreview(
this.sourceBrowser,
this.previewBrowser,
this.settings
);
document.dispatchEvent(
new CustomEvent("page-count", { detail: { numPages } })
);
if (this._queuedPreviewUpdatePromise) {
// Now that we're done, the queued update (if there is one) will start.
this._previewUpdatingPromise = this._queuedPreviewUpdatePromise;
this._queuedPreviewUpdatePromise = null;
} else {
// Nothing queued so throw our promise away.
this._previewUpdatingPromise = null;
}
},
getSourceBrowser() {
let params = new URLSearchParams(location.search);
let browsingContextId = params.get("browsingContextId");
if (!browsingContextId) {
return null;
}
let browsingContext = BrowsingContext.get(browsingContextId);
if (!browsingContext) {
return null;
}
return browsingContext.embedderElement;
},
getPreviewBrowser() {
let container = gBrowser.getBrowserContainer(this.sourceBrowser);
return container.querySelector(".printPreviewBrowser");
},
async getPrintDestinations() {
const printerList = Cc["@mozilla.org/gfx/printerlist;1"].createInstance(
Ci.nsIPrinterList
);
const lastUsedPrinterName = PrintUtils._getLastUsedPrinterName();
const defaultPrinterName = printerList.systemDefaultPrinterName;
const printers = await printerList.printers;
let lastUsedPrinter;
let defaultPrinter;
let saveToPdfPrinter = {
nameId: "printui-destination-pdf-label",
value: PrintUtils.SAVE_TO_PDF_PRINTER,
};
let destinations = [
saveToPdfPrinter,
...printers.map(printer => {
printer.QueryInterface(Ci.nsIPrinter);
const { name } = printer;
const destination = { name, value: name };
if (name == lastUsedPrinterName) {
lastUsedPrinter = destination;
}
if (name == defaultPrinterName) {
defaultPrinter = destination;
}
return destination;
}),
];
let selectedPrinter = lastUsedPrinter || defaultPrinter || saveToPdfPrinter;
return { destinations, selectedPrinter };
},
};
const PrintSettingsViewProxy = {
get defaultHeadersAndFooterValues() {
const defaultBranch = Services.prefs.getDefaultBranch("");
let settingValues = {};
for (let [name, pref] of Object.entries(this.headerFooterSettingsPrefs)) {
settingValues[name] = defaultBranch.getStringPref(pref);
}
// We only need to retrieve these defaults once and they will not change
Object.defineProperty(this, "defaultHeadersAndFooterValues", {
value: settingValues,
});
return settingValues;
},
headerFooterSettingsPrefs: {
footerStrCenter: "print.print_footercenter",
footerStrLeft: "print.print_footerleft",
footerStrRight: "print.print_footerright",
headerStrCenter: "print.print_headercenter",
headerStrLeft: "print.print_headerleft",
headerStrRight: "print.print_headerright",
},
get(target, name) {
switch (name) {
case "printBackgrounds":
return target.printBGImages || target.printBGColors;
case "printFootersHeaders":
// if any of the footer and headers settings have a non-empty string value
// we consider that "enabled"
return Object.keys(this.headerFooterSettingsPrefs).some(
name => !!target[name]
);
case "printAllOrCustomRange":
return target.printRange == Ci.nsIPrintSettings.kRangeAllPages
? "all"
: "custom";
}
return target[name];
},
set(target, name, value) {
switch (name) {
case "printBackgrounds":
target.printBGImages = value;
target.printBGColors = value;
break;
case "printFootersHeaders":
// To disable header & footers, set them all to empty.
// To enable, restore default values for each of the header & footer settings.
for (let [settingName, defaultValue] of Object.entries(
this.defaultHeadersAndFooterValues
)) {
target[settingName] = value ? defaultValue : "";
}
break;
case "printAllOrCustomRange":
target.printRange =
value == "all"
? Ci.nsIPrintSettings.kRangeAllPages
: Ci.nsIPrintSettings.kRangeSpecifiedPageRange;
// TODO: There's also kRangeSelection, which should come into play
// once we have a text box where the user can specify a range
break;
case "printerName":
target.printerName = value;
target.toFileName = "";
if (value == PrintUtils.SAVE_TO_PDF_PRINTER) {
target.outputFormat = Ci.nsIPrintSettings.kOutputFormatPDF;
target.printToFile = true;
} else {
target.outputFormat = Ci.nsIPrintSettings.kOutputFormatNative;
target.printToFile = false;
}
break;
default:
target[name] = value;
}
},
};
/*
* Custom elements ----------------------------------------------------
*/
function PrintUIControlMixin(superClass) {
return class PrintUIControl extends superClass {
connectedCallback() {
this.initialize();
this.render();
}
initialize() {
if (this._initialized) {
return;
}
this._initialized = true;
if (this.templateId) {
let template = this.ownerDocument.getElementById(this.templateId);
let templateContent = template.content;
this.appendChild(templateContent.cloneNode(true));
}
document.addEventListener("print-settings", ({ detail: settings }) => {
this.update(settings);
});
this.addEventListener("change", this);
}
render() {}
update(settings) {}
dispatchSettingsChange(changedSettings) {
this.dispatchEvent(
new CustomEvent("update-print-settings", {
bubbles: true,
detail: changedSettings,
})
);
}
handleEvent(event) {}
};
}
class DestinationPicker extends PrintUIControlMixin(HTMLSelectElement) {
initialize() {
super.initialize();
document.addEventListener("available-destinations", this);
}
setOptions(optionValues = []) {
this._options = optionValues;
this.textContent = "";
for (let optionData of this._options) {
let opt = new Option(
optionData.name,
"value" in optionData ? optionData.value : optionData.name
);
if (optionData.nameId) {
document.l10n.setAttributes(opt, optionData.nameId);
}
this.options.add(opt);
}
}
update(settings) {
let isPdf = settings.outputFormat == Ci.nsIPrintSettings.kOutputFormatPDF;
this.setAttribute("output", isPdf ? "pdf" : "paper");
this.value = settings.printerName;
}
handleEvent(e) {
if (e.type == "change") {
this.dispatchSettingsChange({
printerName: e.target.value,
});
}
if (e.type == "available-destinations") {
this.setOptions(e.detail);
this.required = true;
}
}
}
customElements.define("destination-picker", DestinationPicker, {
extends: "select",
});
class OrientationInput extends PrintUIControlMixin(HTMLElement) {
get templateId() {
return "orientation-template";
}
update(settings) {
for (let input of this.querySelectorAll("input")) {
input.checked = settings.orientation == input.value;
}
}
handleEvent(e) {
this.dispatchSettingsChange({
orientation: e.target.value,
});
}
}
customElements.define("orientation-input", OrientationInput);
class PhotonNumber extends HTMLElement {
connectedCallback() {
this.attachShadow({ mode: "open" });
let slot = document.createElement("slot");
this.upButton = this.makeButton("up");
this.downButton = this.makeButton("down");
let styles = document.createElement("link");
styles.rel = "stylesheet";
styles.href = "chrome://global/content/photon-number.css";
let buttons = document.createElement("div");
buttons.append(this.upButton, this.downButton);
let wrapper = document.createElement("span");
wrapper.classList.add("wrapper");
wrapper.append(slot, buttons);
this.shadowRoot.append(wrapper, styles);
}
makeButton(direction) {
let button = document.createElement("button");
button.setAttribute("step", direction);
button.tabIndex = "-1";
button.addEventListener("click", this);
button.addEventListener("mousedown", this);
return button;
}
get input() {
return this.querySelector("input[type=number]");
}
handleEvent(e) {
if (e.type == "mousedown") {
// Prevent mousedown pulling focus from the input when the spinner is
// clicked, this was causing a focus style flicker on macOS.
e.preventDefault();
} else if (e.type == "click") {
// TODO: You can hold down on a regular spinner to make it count up/down.
let step = e.originalTarget.getAttribute("step");
switch (step) {
case "up":
this.input.stepUp();
this.input.focus();
break;
case "down":
this.input.stepDown();
this.input.focus();
break;
}
}
}
}
customElements.define("photon-number", PhotonNumber);
class CopiesInput extends PrintUIControlMixin(HTMLInputElement) {
update(settings) {
this.value = settings.numCopies;
}
handleEvent(e) {
this.dispatchSettingsChange({
numCopies: e.target.value,
});
}
}
customElements.define("copy-count-input", CopiesInput, {
extends: "input",
});
class PrintUIForm extends PrintUIControlMixin(HTMLFormElement) {
initialize() {
super.initialize();
this.addEventListener("submit", this);
this.addEventListener("click", this);
this.addEventListener("input", this);
}
handleEvent(e) {
if (e.target.id == "open-dialog-link") {
this.dispatchEvent(new Event("open-system-dialog", { bubbles: true }));
return;
}
if (e.type == "submit") {
e.preventDefault();
switch (e.submitter.name) {
case "print":
if (!this.checkValidity()) {
return;
}
this.dispatchEvent(new Event("print", { bubbles: true }));
break;
case "cancel":
this.dispatchEvent(new Event("cancel-print", { bubbles: true }));
break;
}
} else if (e.type == "input") {
let isValid = this.checkValidity();
let section = e.target.closest(".section-block");
for (let element of this.elements) {
// If we're valid, enable all inputs.
// Otherwise, disable the valid inputs other than the cancel button and the elements
// in the invalid section.
element.disabled =
element.hasAttribute("disallowed") ||
(!isValid &&
element.validity.valid &&
element.name != "cancel" &&
element.closest(".section-block") != section);
}
}
}
}
customElements.define("print-form", PrintUIForm, { extends: "form" });
class ScaleInput extends PrintUIControlMixin(HTMLElement) {
get templateId() {
return "scale-template";
}
initialize() {
super.initialize();
this._percentScale = this.querySelector("#percent-scale");
this._shrinkToFitChoice = this.querySelector("#fit-choice");
this._scaleChoice = this.querySelector("#percent-scale-choice");
this._scaleError = this.querySelector("#error-invalid-scale");
this._percentScale.addEventListener("input", this);
this.addEventListener("input", this);
}
update(settings) {
let { scaling, shrinkToFit } = settings;
this._shrinkToFitChoice.checked = shrinkToFit;
this._scaleChoice.checked = !shrinkToFit;
this._percentScale.disabled = shrinkToFit;
this._percentScale.toggleAttribute("disallowed", shrinkToFit);
// If the user had an invalid input and switches back to "fit to page",
// we repopulate the scale field with the stored, valid scaling value.
if (!this._percentScale.value || this._shrinkToFitChoice.checked) {
// Only allow whole numbers. 0.14 * 100 would have decimal places, etc.
this._percentScale.value = parseInt(scaling * 100, 10);
}
}
handleEvent(e) {
if (e.target == this._shrinkToFitChoice || e.target == this._scaleChoice) {
this.dispatchSettingsChange({
shrinkToFit: this._shrinkToFitChoice.checked,
});
this._scaleError.hidden = true;
return;
}
window.clearTimeout(this.invalidTimeoutId);
if (this._percentScale.checkValidity() && e.type == "input") {
this.invalidTimeoutId = window.setTimeout(() => {
this.dispatchSettingsChange({
scaling: Number(this._percentScale.value / 100),
});
}, INVALID_INPUT_DELAY_MS);
}
this._scaleError.hidden = this._percentScale.validity.valid;
}
}
customElements.define("scale-input", ScaleInput);
class PageRangeInput extends PrintUIControlMixin(HTMLElement) {
get templateId() {
return "page-range-template";
}
update(settings) {
let rangePicker = this.querySelector("#range-picker");
rangePicker.value = settings.printAllOrCustomRange;
}
handleEvent(e) {
this.dispatchSettingsChange({
printAllOrCustomRange: e.target.value,
});
}
}
customElements.define("page-range-input", PageRangeInput);
class PrintSettingNumber extends PrintUIControlMixin(HTMLInputElement) {
connectedCallback() {
this.type = "number";
this.settingName = this.dataset.settingName;
super.connectedCallback();
}
update(settings) {
this.value = settings[this.settingName];
}
handleEvent(e) {
this.dispatchSettingsChange({
[this.settingName]: this.value,
});
}
}
customElements.define("setting-number", PrintSettingNumber, {
extends: "input",
});
class PrintSettingCheckbox extends PrintUIControlMixin(HTMLInputElement) {
connectedCallback() {
this.type = "checkbox";
this.settingName = this.dataset.settingName;
super.connectedCallback();
}
update(settings) {
this.checked = settings[this.settingName];
}
handleEvent(e) {
this.dispatchSettingsChange({
[this.settingName]: this.checked,
});
}
}
customElements.define("setting-checkbox", PrintSettingCheckbox, {
extends: "input",
});
class TwistySummary extends PrintUIControlMixin(HTMLElement) {
get isOpen() {
return this.closest("details")?.hasAttribute("open");
}
get templateId() {
return "twisty-summary-template";
}
initialize() {
if (this._initialized) {
return;
}
super.initialize();
this.label = this.querySelector(".label");
this.addEventListener("click", this);
this.updateSummary();
}
handleEvent(e) {
let willOpen = !this.isOpen;
this.updateSummary(willOpen);
}
updateSummary(open = false) {
document.l10n.setAttributes(
this.label,
open
? this.getAttribute("data-open-l10n-id")
: this.getAttribute("data-closed-l10n-id")
);
}
}
customElements.define("twisty-summary", TwistySummary);
class PageCount extends PrintUIControlMixin(HTMLElement) {
initialize() {
super.initialize();
document.addEventListener("page-count", this);
}
update(settings) {
this.numCopies = settings.numCopies;
this.render();
}
render() {
if (!this.numCopies || !this.numPages) {
return;
}
document.l10n.setAttributes(this, "printui-sheets-count", {
sheetCount: this.numPages * this.numCopies,
});
this.removeAttribute("loading");
}
handleEvent(e) {
let { numPages } = e.detail;
this.numPages = numPages;
this.render();
}
}
customElements.define("page-count", PageCount);
class PrintButton extends PrintUIControlMixin(HTMLButtonElement) {
update(settings) {
let l10nId =
settings.printerName == PrintUtils.SAVE_TO_PDF_PRINTER
? "printui-primary-button-save"
: "printui-primary-button";
document.l10n.setAttributes(this, l10nId);
}
}
customElements.define("print-button", PrintButton, { extends: "button" });
async function pickFileName(sourceBrowser, pageSettings) {
let picker = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
let [title] = await document.l10n.formatMessages([
{ id: "printui-save-to-pdf-title" },
]);
title = title.value;
let filename;
if (sourceBrowser.contentTitle != "") {
filename = sourceBrowser.contentTitle;
} else {
let url = new URL(sourceBrowser.currentURI.spec);
let path = decodeURIComponent(url.pathname);
path = path.replace(/\/$/, "");
filename = path.split("/").pop();
if (filename == "") {
filename = url.hostname;
}
}
filename = DownloadPaths.sanitize(filename);
picker.init(
window.docShell.chromeEventHandler.ownerGlobal,
title,
Ci.nsIFilePicker.modeSave
);
picker.appendFilter("PDF", "*.pdf");
picker.defaultExtension = "pdf";
picker.defaultString = filename;
let retval = await new Promise(resolve => picker.open(resolve));
if (retval == 1) {
throw new Error({ reason: "cancelled" });
} else {
// OK clicked (retval == 0) or replace confirmed (retval == 2)
// Workaround: When trying to replace an existing file that is open in another application (i.e. a locked file),
// the print progress listener is never called. This workaround ensures that a correct status is always returned.
try {
let fstream = Cc[
"@mozilla.org/network/file-output-stream;1"
].createInstance(Ci.nsIFileOutputStream);
fstream.init(picker.file, 0x2a, 0o666, 0); // ioflags = write|create|truncate, file permissions = rw-rw-rw-
fstream.close();
} catch (e) {
throw new Error({ reason: retval == 0 ? "not_saved" : "not_replaced" });
}
}
return picker.file.path;
}