diff options
Diffstat (limited to 'toolkit/components/printing/content')
-rw-r--r-- | toolkit/components/printing/content/print.css | 299 | ||||
-rw-r--r-- | toolkit/components/printing/content/print.html | 587 | ||||
-rw-r--r-- | toolkit/components/printing/content/print.js | 2847 | ||||
-rw-r--r-- | toolkit/components/printing/content/printPageSetup.js | 538 | ||||
-rw-r--r-- | toolkit/components/printing/content/printPageSetup.xhtml | 291 | ||||
-rw-r--r-- | toolkit/components/printing/content/printPagination.css | 141 | ||||
-rw-r--r-- | toolkit/components/printing/content/printPreviewPagination.js | 185 | ||||
-rw-r--r-- | toolkit/components/printing/content/printUtils.js | 822 | ||||
-rw-r--r-- | toolkit/components/printing/content/simplifyMode.css | 36 | ||||
-rw-r--r-- | toolkit/components/printing/content/toggle-group.css | 79 |
10 files changed, 5825 insertions, 0 deletions
diff --git a/toolkit/components/printing/content/print.css b/toolkit/components/printing/content/print.css new file mode 100644 index 0000000000..2788312576 --- /dev/null +++ b/toolkit/components/printing/content/print.css @@ -0,0 +1,299 @@ +/* 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/. */ + +html, body { + height: 100vh; +} + +body { + display: flex; + flex-direction: column; + justify-content: flex-start; + overflow: hidden; +} + +body[loading] #print { + visibility: hidden; +} + +*[hidden] { + display: none !important; +} + +.section-block { + margin: 16px; +} + +.section-block .block-label { + display: block; + margin-bottom: 8px; +} + +.row { + display: flex; + flex-direction: column; + width: 100%; + min-height: 1.8em; + margin-block: 2px; +} + +.row.cols-2 { + flex-direction: row; + align-items: center; +} + +#scale .row.cols-2, +#more-settings-options > .row.cols-2 { + /* The margin-block of the checkboxes/radiobuttons + already provide the needed vertical spacing. */ + margin-block: 0; +} + +hr { + margin-inline: 8px; + margin-block: 0; +} + +.header-container { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + flex: 0 1 auto; + padding: 8px 18px; +} +.header-container > h2 { + margin: 0; + font-size: 17px; + line-height: 1; +} + +#sheet-count { + font-size: 11px; + padding: 3px 8px; + margin: 0; +} + +#sheet-count[loading], +body[invalid] #sheet-count { + visibility: hidden; +} + +form#print { + display: flex; + flex: 1 1 auto; + flex-direction: column; + justify-content: flex-start; + overflow: hidden; + font: menu; +} + +select { + margin: 0; +} + +select:not([size], [multiple])[iconic] { + padding-inline-start: calc(8px + 16px + 4px); /* spacing before image + image width + spacing after image */ + background-size: auto 12px, 16px; + background-position: right 19px center, left 8px center; +} + +select:not([size], [multiple])[iconic]:dir(rtl) { + background-position-x: left 19px, right 8px; +} + +#printer-picker { + background-image: url("chrome://global/skin/icons/arrow-down-12.svg"), url("chrome://global/skin/icons/print.svg"); + width: 100%; +} + +#printer-picker[output="pdf"] { + background-image: url("chrome://global/skin/icons/arrow-down-12.svg"), url("chrome://global/skin/icons/page-portrait.svg"); +} + +input[type="number"], +input[type="text"] { + padding: 2px; + padding-inline-start: 8px; +} + +.toggle-group-label { + padding: 4px 8px; +} + +.body-container { + flex: 1 1 auto; + overflow: auto; +} + +.twisty > summary { + list-style: none; + display: flex; + cursor: pointer; + align-items: center; +} + +#more-settings > summary > .twisty { + background-image: url("chrome://global/skin/icons/arrow-down-12.svg"); + background-repeat: no-repeat; + background-position: center; + -moz-context-properties: fill; + fill: currentColor; + width: 12px; + height: 12px; +} + +#more-settings > summary > .label { + flex-grow: 1; +} + +#more-settings[open] > summary > .twisty { + background-image: url("chrome://global/skin/icons/arrow-up-12.svg"); +} + +#open-dialog-link { + display: flex; + justify-content: space-between; + align-items: center; +} + +#open-dialog-link::after { + display: block; + flex-shrink: 0; + content: ""; + background: url(chrome://global/skin/icons/open-in-new.svg) no-repeat center; + background-size: 12px; + -moz-context-properties: fill; + fill: currentColor; + width: 12px; + height: 12px; +} + +#open-dialog-link:dir(rtl)::after { + scale: -1 1; +} + +.footer-container { + flex: 0 1 auto; +} + +#print-progress { + background-image: url("chrome://global/skin/icons/loading.png"); + background-repeat: no-repeat; + background-size: 16px; + background-position: left center; + padding-inline-start: 20px; +} + +@media (min-resolution: 1.1dppx) { + #print-progress { + background-image: url("chrome://global/skin/icons/loading@2x.png"); + } +} + +#print-progress:dir(rtl) { + background-position-x: right; +} + +#custom-range { + margin-top: 4px; + margin-inline: 0; +} + +.vertical-margins, +.horizontal-margins { + display: flex; + gap: 20px; + margin-block: 5px; +} + +.margin-pair { + width: calc(50% - 10px); /* Half minus the gap */ +} + +.margin-input { + /* FIXME: Important is needed to override the photon-number styles below, + * probably should be refactored */ + width: 100% !important; + box-sizing: border-box; +} + +.margin-descriptor { + text-align: center; + display: block; + margin-top: 2px; +} + +.toggle-group #landscape + .toggle-group-label::before { + content: url("chrome://global/skin/icons/page-landscape.svg"); +} +.toggle-group #portrait + .toggle-group-label::before { + content: url("chrome://global/skin/icons/page-portrait.svg"); +} + +.error-message { + font-size: 12px; + color: var(--in-content-error-text-color, color-mix(in srgb, currentColor 40%, #C50042)); + margin-top: 4px; +} + +#percent-scale { + margin-inline-start: 4px; +} + +#percent-scale:disabled { + /* Let clicks on the disabled input select the radio button */ + pointer-events: none; +} + +input[type="number"].photon-number { + padding: 0; + padding-inline-start: 8px; + margin: 0; + height: 1.5em; + width: 4em; +} + +input[type="number"].photon-number::-moz-number-spin-box { + height: 100%; + max-height: 100%; + border-inline-start: 1px solid var(--in-content-box-border-color); + width: 1em; + border-start-end-radius: 4px; + border-end-end-radius: 4px; +} + +input[type="number"].photon-number:hover::-moz-number-spin-box { + border-color: var(--in-content-border-hover); +} + +input[type="number"].photon-number::-moz-number-spin-up, +input[type="number"].photon-number::-moz-number-spin-down { + border: 0; + border-radius: 0; + background-color: var(--in-content-button-background); + background-image: url("chrome://global/skin/icons/arrow-down.svg"); + background-repeat: no-repeat; + background-size: 8px; + background-position: center; + -moz-context-properties: fill; + fill: currentColor; + appearance: none; +} + +input[type="number"].photon-number::-moz-number-spin-up { + background-image: url("chrome://global/skin/icons/arrow-up.svg"); +} + +input[type="number"].photon-number::-moz-number-spin-up:hover, +input[type="number"].photon-number::-moz-number-spin-down:hover { + background-color: var(--in-content-button-background-hover); + color: var(--in-content-button-text-color-hover); +} + +input[type="checkbox"]:disabled + label, +input[type="radio"]:disabled + label, +input[type="radio"]:disabled + span > label { + opacity: 0.5; +} diff --git a/toolkit/components/printing/content/print.html b/toolkit/components/printing/content/print.html new file mode 100644 index 0000000000..62d214608c --- /dev/null +++ b/toolkit/components/printing/content/print.html @@ -0,0 +1,587 @@ +<!DOCTYPE html> +<!-- 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/. --> +<html> + <head> + <meta charset="utf-8" /> + <title data-l10n-id="printui-title"></title> + <meta + http-equiv="Content-Security-Policy" + content="default-src chrome:;img-src data:; object-src 'none'" + /> + + <link rel="localization" href="toolkit/printing/printUI.ftl" /> + + <link rel="stylesheet" href="chrome://global/skin/in-content/common.css" /> + <link rel="stylesheet" href="chrome://global/content/toggle-group.css" /> + <link rel="stylesheet" href="chrome://global/content/print.css" /> + <script defer src="chrome://global/content/print.js"></script> + <script + type="module" + src="chrome://global/content/elements/moz-button-group.mjs" + ></script> + </head> + + <body loading rendering> + <template id="page-range-template"> + <select + id="range-picker" + name="page-range-type" + data-l10n-id="printui-page-range-picker" + is="setting-select" + > + <option + value="all" + selected + data-l10n-id="printui-page-range-all" + ></option> + <option + value="current" + data-l10n-id="printui-page-range-current" + ></option> + <option value="odd" data-l10n-id="printui-page-range-odd"></option> + <option + value="even" + name="even" + data-l10n-id="printui-page-range-even" + ></option> + <option + value="custom" + data-l10n-id="printui-page-range-custom" + ></option> + </select> + <input + id="custom-range" + type="text" + disabled + hidden + data-l10n-id="printui-page-custom-range-input" + aria-errormessage="error-invalid-range error-invalid-start-range-overflow" + /> + <p + id="error-invalid-range" + hidden + data-l10n-id="printui-error-invalid-range" + class="error-message" + role="alert" + data-l10n-args='{ "numPages": 1 }' + ></p> + <p + id="error-invalid-start-range-overflow" + hidden + data-l10n-id="printui-error-invalid-start-overflow" + class="error-message" + role="alert" + ></p> + </template> + + <template id="orientation-template"> + <input + type="radio" + name="orientation" + id="portrait" + value="0" + checked + class="toggle-group-input" + /> + <label + for="portrait" + data-l10n-id="printui-portrait" + class="toggle-group-label toggle-group-label-iconic" + ></label> + <input + type="radio" + name="orientation" + id="landscape" + value="1" + class="toggle-group-input" + /> + <label + for="landscape" + data-l10n-id="printui-landscape" + class="toggle-group-label toggle-group-label-iconic" + ></label> + </template> + + <template id="twisty-summary-template"> + <span class="label"></span> + <span class="twisty"></span> + </template> + + <template id="scale-template"> + <div role="radiogroup" aria-labelledby="scale-label"> + <label class="row cols-2"> + <input + type="radio" + name="scale-choice" + id="fit-choice" + value="fit" + checked + /> + <span + data-l10n-id="printui-scale-fit-to-page-width" + class="col" + ></span> + </label> + <label class="row cols-2" for="percent-scale-choice"> + <input type="radio" name="scale-choice" id="percent-scale-choice" /> + <span class="col"> + <span + id="percent-scale-label" + data-l10n-id="printui-scale-pcent" + ></span> + <!-- Note that here and elsewhere, we're setting aria-errormessage + attributes to a list of all possible errors. The a11y APIs + will filter this down to visible items only. --> + <input + id="percent-scale" + class="photon-number" + is="setting-number" + min="10" + max="200" + step="1" + size="6" + aria-labelledby="percent-scale-label" + aria-errormessage="error-invalid-scale" + disabled + required + /> + </span> + </label> + <p + id="error-invalid-scale" + hidden + data-l10n-id="printui-error-invalid-scale" + class="error-message" + role="alert" + ></p> + </div> + </template> + + <template id="copy-template"> + <input + id="copies-count" + is="setting-number" + data-setting-name="numCopies" + min="1" + max="10000" + class="copy-count-input photon-number" + aria-errormessage="error-invalid-copies" + required + /> + <p + id="error-invalid-copies" + hidden + data-l10n-id="printui-error-invalid-copies" + class="error-message" + role="alert" + ></p> + </template> + + <template id="margins-template"> + <label + for="margins-picker" + class="block-label" + data-l10n-id="printui-margins" + ></label> + <select + is="margins-select" + id="margins-picker" + name="margin-type" + class="row" + data-setting-name="margins" + > + <option value="default" data-l10n-id="printui-margins-default"></option> + <option value="minimum" data-l10n-id="printui-margins-min"></option> + <option value="none" data-l10n-id="printui-margins-none"></option> + <option + value="custom" + data-unit-prefix-l10n-id="printui-margins-custom-" + ></option> + </select> + <div id="custom-margins" class="margin-group" role="group" hidden> + <div class="vertical-margins"> + <div class="margin-pair"> + <input + is="setting-number" + id="custom-margin-top" + class="margin-input photon-number" + aria-describedby="margins-custom-margin-top-desc" + min="0" + step="0.01" + required + /> + <label + for="custom-margin-top" + class="margin-descriptor" + data-l10n-id="printui-margins-custom-top" + ></label> + <label + hidden + id="margins-custom-margin-top-desc" + data-unit-prefix-l10n-id="printui-margins-custom-top-" + ></label> + </div> + <div class="margin-pair"> + <input + is="setting-number" + id="custom-margin-bottom" + class="margin-input photon-number" + aria-describedby="margins-custom-margin-bottom-desc" + min="0" + step="0.01" + required + /> + <label + for="custom-margin-bottom" + class="margin-descriptor" + data-l10n-id="printui-margins-custom-bottom" + ></label> + <label + hidden + id="margins-custom-margin-bottom-desc" + data-unit-prefix-l10n-id="printui-margins-custom-bottom-" + ></label> + </div> + </div> + <div class="horizontal-margins"> + <div class="margin-pair"> + <input + is="setting-number" + id="custom-margin-left" + class="margin-input photon-number" + aria-describedby="margins-custom-margin-left-desc" + min="0" + step="0.01" + required + /> + <label + for="custom-margin-left" + class="margin-descriptor" + data-l10n-id="printui-margins-custom-left" + ></label> + <label + hidden + id="margins-custom-margin-left-desc" + data-unit-prefix-l10n-id="printui-margins-custom-left-" + ></label> + </div> + <div class="margin-pair"> + <input + is="setting-number" + id="custom-margin-right" + class="margin-input photon-number" + aria-describedby="margins-custom-margin-right-desc" + min="0" + step="0.01" + required + /> + <label + for="custom-margin-right" + class="margin-descriptor" + data-l10n-id="printui-margins-custom-right" + ></label> + <label + hidden + id="margins-custom-margin-right-desc" + data-unit-prefix-l10n-id="printui-margins-custom-right-" + ></label> + </div> + </div> + <p + id="error-invalid-margin" + hidden + data-l10n-id="printui-error-invalid-margin" + class="error-message" + role="alert" + ></p> + </div> + </template> + + <header class="header-container" role="none"> + <h2 data-l10n-id="printui-title"></h2> + <div aria-live="off"> + <p + id="sheet-count" + is="page-count" + data-l10n-id="printui-sheets-count" + data-l10n-args='{ "sheetCount": 0 }' + loading + ></p> + </div> + </header> + + <hr /> + + <form id="print" is="print-form" aria-labelledby="page-header"> + <section class="body-container"> + <section id="destination" class="section-block"> + <label + for="printer-picker" + class="block-label" + data-l10n-id="printui-destination-label" + ></label> + <div class="printer-picker-wrapper"> + <select + is="destination-picker" + id="printer-picker" + data-setting-name="printerName" + iconic + ></select> + </div> + </section> + <section id="settings"> + <section id="copies" class="section-block"> + <label + for="copies-count" + class="block-label" + data-l10n-id="printui-copies-label" + ></label> + <copy-count-input></copy-count-input> + </section> + + <section id="orientation" class="section-block"> + <label + id="orientation-label" + class="block-label" + data-l10n-id="printui-orientation" + ></label> + <div + is="orientation-input" + class="toggle-group" + role="radiogroup" + aria-labelledby="orientation-label" + ></div> + </section> + + <section id="pages" class="section-block"> + <label + for="page-range-input" + class="block-label" + data-l10n-id="printui-page-range-label" + ></label> + <div + id="page-range-input" + is="page-range-input" + class="page-range-input row" + ></div> + </section> + + <section id="color-mode" class="section-block"> + <label + for="color-mode-picker" + class="block-label" + data-l10n-id="printui-color-mode-label" + ></label> + <select + is="color-mode-select" + id="color-mode-picker" + class="row" + data-setting-name="printInColor" + > + <option + value="color" + selected + data-l10n-id="printui-color-mode-color" + ></option> + <option value="bw" data-l10n-id="printui-color-mode-bw"></option> + </select> + </section> + + <hr /> + + <details id="more-settings" class="twisty"> + <summary + class="block-label section-block" + is="twisty-summary" + data-open-l10n-id="printui-less-settings" + data-closed-l10n-id="printui-more-settings" + ></summary> + + <section id="paper-size" class="section-block"> + <label + for="paper-size-picker" + class="block-label" + data-l10n-id="printui-paper-size-label" + ></label> + <select + is="paper-size-select" + id="paper-size-picker" + class="row" + data-setting-name="paperId" + ></select> + </section> + + <section id="scale" class="section-block"> + <label + id="scale-label" + class="block-label" + data-l10n-id="printui-scale" + ></label> + <scale-input></scale-input> + </section> + + <section id="pages-per-sheet" class="section-block"> + <label + id="pages-per-sheet-label" + for="pages-per-sheet-picker" + class="block-label" + data-l10n-id="printui-pages-per-sheet" + ></label> + <select + is="setting-select" + id="pages-per-sheet-picker" + class="row" + data-setting-name="numPagesPerSheet" + > + <option value="1">1</option> + <option value="2">2</option> + <option value="4">4</option> + <option value="6">6</option> + <option value="9">9</option> + <option value="16">16</option> + </select> + </section> + + <section id="margins" class="section-block"> + <div + id="margins-select" + is="margins-select" + class="margins-select row" + ></div> + </section> + + <section id="two-sided-printing" class="section-block"> + <label + class="block-label" + for="duplex-select" + data-l10n-id="printui-two-sided-printing" + ></label> + <select + is="setting-select" + id="duplex-select" + name="duplex-type" + class="row" + data-setting-name="printDuplex" + > + <option + value="off" + data-l10n-id="printui-two-sided-printing-off" + selected + ></option> + <option + value="long-edge" + data-l10n-id="printui-two-sided-printing-long-edge" + ></option> + <option + value="short-edge" + data-l10n-id="printui-two-sided-printing-short-edge" + ></option> + </select> + </section> + + <section id="source-version-section" class="section-block"> + <label + id="source-version-label" + data-l10n-id="printui-source-label" + ></label> + <div role="radiogroup" aria-labelledby="source-version-label"> + <label id="source-version-source" class="row cols-2"> + <input + is="setting-radio" + name="source-version" + value="source" + id="source-version-source-radio" + data-setting-name="sourceVersion" + checked + /> + <span data-l10n-id="printui-source-radio"></span> + </label> + <label id="source-version-selection" class="row cols-2"> + <input + is="setting-radio" + name="source-version" + value="selection" + id="source-version-selection-radio" + data-setting-name="sourceVersion" + /> + <span data-l10n-id="printui-selection-radio"></span> + </label> + <label id="source-version-simplified" class="row cols-2"> + <input + is="setting-radio" + name="source-version" + value="simplified" + id="source-version-simplified-radio" + data-setting-name="sourceVersion" + /> + <span data-l10n-id="printui-simplify-page-radio"></span> + </label> + </div> + </section> + + <section id="more-settings-options" class="section-block"> + <label class="block-label" data-l10n-id="printui-options"></label> + <label id="headers-footers" class="row cols-2"> + <input + is="setting-checkbox" + id="headers-footers-enabled" + data-setting-name="printFootersHeaders" + /> + <span data-l10n-id="printui-headers-footers-checkbox"></span> + </label> + <label id="backgrounds" class="row cols-2"> + <input + is="print-backgrounds" + id="backgrounds-enabled" + data-setting-name="printBackgrounds" + /> + <span data-l10n-id="printui-backgrounds-checkbox"></span> + </label> + </section> + </details> + </section> + + <section id="system-print" class="section-block"> + <a + href="#" + id="open-dialog-link" + data-l10n-id="printui-system-dialog-link" + ></a> + </section> + </section> + + <hr /> + + <footer class="footer-container" id="print-footer" role="none"> + <p + id="print-progress" + class="section-block" + data-l10n-id="printui-print-progress-indicator" + hidden + ></p> + <moz-button-group id="button-container" class="section-block"> + <button + id="print-button" + class="primary" + showfocus + name="print" + data-l10n-id="printui-primary-button" + is="print-button" + type="submit" + ></button> + <button + id="cancel-button" + name="cancel" + data-l10n-id="printui-cancel-button" + data-close-l10n-id="printui-close-button" + data-cancel-l10n-id="printui-cancel-button" + type="button" + is="cancel-button" + ></button> + </moz-button-group> + </footer> + </form> + </body> +</html> diff --git a/toolkit/components/printing/content/print.js b/toolkit/components/printing/content/print.js new file mode 100644 index 0000000000..45938431b5 --- /dev/null +++ b/toolkit/components/printing/content/print.js @@ -0,0 +1,2847 @@ +/* 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 { PrintUtils, Services, AppConstants } = + window.docShell.chromeEventHandler.ownerGlobal; + +ChromeUtils.defineESModuleGetters(this, { + DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs", + DownloadPaths: "resource://gre/modules/DownloadPaths.sys.mjs", +}); + +const PDF_JS_URI = "resource://pdf.js/web/viewer.html"; +const INPUT_DELAY_MS = Cu.isInAutomation ? 100 : 500; +const MM_PER_POINT = 25.4 / 72; +const INCHES_PER_POINT = 1 / 72; +const INCHES_PER_MM = 1 / 25.4; +const ourBrowser = window.docShell.chromeEventHandler; +const PSSVC = Cc["@mozilla.org/gfx/printsettings-service;1"].getService( + Ci.nsIPrintSettingsService +); + +var logger = (function () { + const getMaxLogLevel = () => + Services.prefs.getBoolPref("print.debug", false) ? "all" : "warn"; + + let { ConsoleAPI } = ChromeUtils.importESModule( + "resource://gre/modules/Console.sys.mjs" + ); + // Create a new instance of the ConsoleAPI so we can control the maxLogLevel with a pref. + let _logger = new ConsoleAPI({ + prefix: "printUI", + maxLogLevel: getMaxLogLevel(), + }); + + function onPrefChange() { + if (_logger) { + _logger.maxLogLevel = getMaxLogLevel(); + } + } + // Watch for pref changes and the maxLogLevel for the logger + Services.prefs.addObserver("print.debug", onPrefChange); + window.addEventListener("unload", () => { + Services.prefs.removeObserver("print.debug", onPrefChange); + }); + return _logger; +})(); + +function serializeSettings(settings, logPrefix) { + let re = /^(k[A-Z]|resolution)/; // accessing settings.resolution throws an exception? + let types = new Set(["string", "boolean", "number", "undefined"]); + let nameValues = {}; + for (let key in settings) { + try { + if (!re.test(key) && types.has(typeof settings[key])) { + nameValues[key] = settings[key]; + } + } catch (e) { + logger.warn("Exception accessing setting: ", key, e); + } + } + return JSON.stringify(nameValues, null, 2); +} + +let printPending = false; +let deferredTasks = []; +function createDeferredTask(fn, timeout) { + let task = new DeferredTask(fn, timeout); + deferredTasks.push(task); + return task; +} + +function cancelDeferredTasks() { + for (let task of deferredTasks) { + task.disarm(); + } + PrintEventHandler._updatePrintPreviewTask?.disarm(); + deferredTasks = []; +} + +document.addEventListener( + "DOMContentLoaded", + e => { + window._initialized = PrintEventHandler.init().catch(e => console.error(e)); + ourBrowser.setAttribute("flex", "0"); + ourBrowser.setAttribute("constrainpopups", "false"); + ourBrowser.classList.add("printSettingsBrowser"); + ourBrowser.closest(".dialogBox")?.classList.add("printDialogBox"); + }, + { once: true } +); + +window.addEventListener("dialogclosing", () => { + cancelDeferredTasks(); +}); + +window.addEventListener( + "unload", + e => { + document.textContent = ""; + }, + { once: true } +); + +var PrintEventHandler = { + settings: null, + defaultSettings: null, + allPaperSizes: {}, + previewIsEmpty: false, + _delayedChanges: {}, + _userChangedSettings: {}, + settingFlags: { + margins: Ci.nsIPrintSettings.kInitSaveMargins, + customMargins: Ci.nsIPrintSettings.kInitSaveMargins, + orientation: Ci.nsIPrintSettings.kInitSaveOrientation, + paperId: + Ci.nsIPrintSettings.kInitSavePaperSize | + Ci.nsIPrintSettings.kInitSaveUnwriteableMargins, + printInColor: Ci.nsIPrintSettings.kInitSaveInColor, + scaling: Ci.nsIPrintSettings.kInitSaveScaling, + shrinkToFit: Ci.nsIPrintSettings.kInitSaveShrinkToFit, + printDuplex: Ci.nsIPrintSettings.kInitSaveDuplex, + 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, + }, + + topContentTitle: null, + topCurrentURI: null, + activeContentTitle: null, + activeCurrentURI: null, + + get activeURI() { + return this.viewSettings.sourceVersion == "selection" + ? this.activeCurrentURI + : this.topCurrentURI; + }, + get activeTitle() { + return this.viewSettings.sourceVersion == "selection" + ? this.activeContentTitle + : this.topContentTitle; + }, + + // These settings do not have an associated pref value or flag, but + // changing them requires us to update the print preview. + _nonFlaggedUpdatePreviewSettings: new Set([ + "pageRanges", + "numPagesPerSheet", + "sourceVersion", + ]), + _noPreviewUpdateSettings: new Set(["numCopies", "printDuplex"]), + + async init() { + Services.telemetry.scalarAdd("printing.preview_opened_tm", 1); + + this.printPreviewEl = + ourBrowser.parentElement.querySelector("print-preview"); + + // Do not keep a reference to source browser, it may mutate after printing + // is initiated and the print preview clone must be a snapshot from the + // time that the print was started. + let sourceBrowsingContext = this.printPreviewEl.getSourceBrowsingContext(); + + let args = window.arguments[0]; + this.printFrameOnly = args.getProperty("printFrameOnly"); + this.printSelectionOnly = args.getProperty("printSelectionOnly"); + this.isArticle = args.getProperty("isArticle"); + this.hasSelection = await PrintUtils.checkForSelection( + sourceBrowsingContext + ); + + let sourcePrincipal = + sourceBrowsingContext.currentWindowGlobal.documentPrincipal; + let sourceIsPdf = + !sourcePrincipal.isNullPrincipal && sourcePrincipal.spec == PDF_JS_URI; + this.activeContentTitle = + sourceBrowsingContext.currentWindowContext.documentTitle; + this.activeCurrentURI = + sourceBrowsingContext.currentWindowContext.documentURI.spec; + let topWindowContext = sourceBrowsingContext.top.currentWindowContext; + this.topContentTitle = topWindowContext.documentTitle; + this.topCurrentURI = topWindowContext.documentURI.spec; + this.isReader = this.topCurrentURI.startsWith("about:reader"); + + let canSimplify = !this.isReader && this.isArticle; + if (!this.hasSelection && !canSimplify) { + document.getElementById("source-version-section").hidden = true; + } else { + document.getElementById("source-version-selection").hidden = + !this.hasSelection; + document.getElementById("source-version-simplified").hidden = + !canSimplify; + } + + // We don't need the sourceBrowsingContext anymore, get rid of it. + sourceBrowsingContext = undefined; + + this.printProgressIndicator = document.getElementById("print-progress"); + this.printForm = document.getElementById("print"); + if (sourceIsPdf) { + this.printForm.removeNonPdfSettings(); + } + + // Let the dialog appear before doing any potential main thread work. + await ourBrowser._dialogReady; + + // First check the available destinations to ensure we get settings for an + // accessible printer. + let destinations, + defaultSystemPrinter, + fallbackPaperList, + selectedPrinter, + printersByName; + try { + ({ + destinations, + defaultSystemPrinter, + fallbackPaperList, + selectedPrinter, + printersByName, + } = await this.getPrintDestinations()); + } catch (e) { + this.reportPrintingError("PRINT_DESTINATIONS"); + throw e; + } + PrintSettingsViewProxy.availablePrinters = printersByName; + PrintSettingsViewProxy.fallbackPaperList = fallbackPaperList; + PrintSettingsViewProxy.defaultSystemPrinter = defaultSystemPrinter; + PrintSettingsViewProxy._sourceVersion = + this.hasSelection && this.printSelectionOnly ? "selection" : "source"; + + logger.debug("availablePrinters: ", Object.keys(printersByName)); + logger.debug("defaultSystemPrinter: ", defaultSystemPrinter); + + document.addEventListener("print", async () => { + let cancelButton = document.getElementById("cancel-button"); + document.l10n.setAttributes( + cancelButton, + cancelButton.dataset.closeL10nId + ); + let didPrint = await this.print(); + if (!didPrint) { + // Re-enable elements of the form if the user cancels saving or + // if a deferred task rendered the page invalid. + this.printForm.enable(); + } + // Reset the cancel button regardless of the outcome. + document.l10n.setAttributes( + cancelButton, + cancelButton.dataset.cancelL10nId + ); + }); + this._createDelayedSettingsChangeTask(); + document.addEventListener("update-print-settings", e => { + this.handleSettingsChange(e.detail); + }); + document.addEventListener("cancel-print-settings", e => { + this._delayedSettingsChangeTask.disarm(); + for (let setting of Object.keys(e.detail)) { + delete this._delayedChanges[setting]; + } + }); + document.addEventListener("cancel-print", () => this.cancelPrint()); + document.addEventListener("open-system-dialog", async () => { + // This file in only used if pref print.always_print_silent is false, so + // no need to check that here. + + // Hide the dialog box before opening system dialog + // We cannot close the window yet because the browsing context for the + // print preview browser is needed to print the page. + let sourceBrowser = + this.printPreviewEl.getSourceBrowsingContext().top.embedderElement; + let dialogBoxManager = + PrintUtils.getTabDialogBox(sourceBrowser).getTabDialogManager(); + dialogBoxManager.hideDialog(sourceBrowser); + + // Use our settings to prepopulate the system dialog. + // The system print dialog won't recognize our internal save-to-pdf + // pseudo-printer. We need to pass it a settings object from any + // system recognized printer. + let settings = + this.settings.printerName == PrintUtils.SAVE_TO_PDF_PRINTER + ? PrintUtils.getPrintSettings(this.viewSettings.defaultSystemPrinter) + : this.settings.clone(); + // We set the title so that if the user chooses save-to-PDF from the + // system dialog the title will be used to generate the prepopulated + // filename in the file picker. + settings.title = this.activeTitle; + Services.telemetry.scalarAdd("printing.dialog_opened_via_preview_tm", 1); + const doPrint = await this._showPrintDialog( + window, + this.hasSelection, + settings + ); + if (!doPrint) { + Services.telemetry.scalarAdd( + "printing.dialog_via_preview_cancelled_tm", + 1 + ); + window.close(); + return; + } + await this.print(settings); + }); + + let originalError; + const printersByPriority = [ + selectedPrinter.value, + ...Object.getOwnPropertyNames(printersByName).filter( + name => name != selectedPrinter.value + ), + ]; + + // Try to update settings, falling back to any available printer + for (const printerName of printersByPriority) { + try { + let settingsToChange = await this.refreshSettings(printerName); + await this.updateSettings(settingsToChange, true); + originalError = null; + break; + } catch (e) { + if (!originalError) { + originalError = e; + // Report on how often fetching the last used printer settings fails. + this.reportPrintingError("PRINTER_SETTINGS_LAST_USED"); + } + } + } + + // Only throw original error if no fallback was possible + if (originalError) { + this.reportPrintingError("PRINTER_SETTINGS"); + throw originalError; + } + + let initialPreviewDone = this._updatePrintPreview(); + + // Use a DeferredTask for updating the preview. This will ensure that we + // only have one update running at a time. + this._createUpdatePrintPreviewTask(initialPreviewDone); + + document.dispatchEvent( + new CustomEvent("available-destinations", { + detail: destinations, + }) + ); + + document.dispatchEvent( + new CustomEvent("print-settings", { + detail: this.viewSettings, + }) + ); + + document.body.removeAttribute("loading"); + + await new Promise(resolve => window.requestAnimationFrame(resolve)); + + // Now that we're showing the form, select the destination select. + document.getElementById("printer-picker").focus({ focusVisible: true }); + + await initialPreviewDone; + }, + + async print(systemDialogSettings) { + // Disable the form when a print is in progress + this.printForm.disable(); + + if (Object.keys(this._delayedChanges).length) { + // Make sure any pending changes get saved. + let task = this._delayedSettingsChangeTask; + this._createDelayedSettingsChangeTask(); + await task.finalize(); + } + + if (this.settings.pageRanges.length) { + // Finish any running previews to verify the range is still valid. + let task = this._updatePrintPreviewTask; + this._createUpdatePrintPreviewTask(); + await task.finalize(); + } + + if (!this.printForm.checkValidity() || this.previewIsEmpty) { + return false; + } + + let settings = systemDialogSettings || this.settings; + + if (settings.printerName == PrintUtils.SAVE_TO_PDF_PRINTER) { + try { + settings.toFileName = await pickFileName( + this.activeTitle, + this.activeURI + ); + } catch (e) { + return false; + } + } + + await window._initialized; + + // This seems like it should be handled automatically but it isn't. + PSSVC.maybeSaveLastUsedPrinterNameToPrefs(settings.printerName); + + try { + // We'll provide our own progress indicator. + let l10nId = + settings.printerName == PrintUtils.SAVE_TO_PDF_PRINTER + ? "printui-print-progress-indicator-saving" + : "printui-print-progress-indicator"; + document.l10n.setAttributes(this.printProgressIndicator, l10nId); + this.printProgressIndicator.hidden = false; + let bc = this.printPreviewEl.currentBrowsingContext; + await this._doPrint(bc, settings); + } catch (e) { + console.error(e); + } + + if (settings.printerName == PrintUtils.SAVE_TO_PDF_PRINTER) { + // Clear the file name from the preference value since it may potentially + // contain sensitive information from the page title (Bug 1675965) + let prefName = + "print.printer_" + + settings.printerName.replace(/ /g, "_") + + ".print_to_filename"; + Services.prefs.clearUserPref(prefName); + } + + window.close(); + return true; + }, + + /** + * Prints the window. This method has been abstracted into a helper for + * testing purposes. + */ + _doPrint(aBrowsingContext, aSettings) { + return aBrowsingContext.print(aSettings); + }, + + cancelPrint() { + Services.telemetry.scalarAdd("printing.preview_cancelled_tm", 1); + window.close(); + }, + + async refreshSettings(printerName) { + this.currentPrinterName = printerName; + let currentPrinter; + try { + currentPrinter = await PrintSettingsViewProxy.resolvePropertiesForPrinter( + printerName + ); + } catch (e) { + this.reportPrintingError("PRINTER_PROPERTIES"); + throw e; + } + if (this.currentPrinterName != printerName) { + // Refresh settings could take a while, if the destination has changed + // then we don't want to update the settings after all. + return {}; + } + + this.settings = currentPrinter.settings; + this.defaultSettings = currentPrinter.defaultSettings; + + this.settings.printSelectionOnly = this.printSelectionOnly; + + logger.debug("currentPrinter name: ", printerName); + logger.debug("settings:", serializeSettings(this.settings)); + + // Some settings are only used by the UI + // assigning new values should update the underlying settings + this.viewSettings = new Proxy(this.settings, PrintSettingsViewProxy); + return this.getSettingsToUpdate(); + }, + + getSettingsToUpdate() { + // Get the previously-changed settings we want to try to use on this printer + let settingsToUpdate = Object.assign({}, this._userChangedSettings); + + // Ensure the color option is correct, if either of the supportsX flags are + // false then the user cannot change the value through the UI. + if (!this.viewSettings.supportsColor) { + settingsToUpdate.printInColor = false; + } else if (!this.viewSettings.supportsMonochrome) { + settingsToUpdate.printInColor = true; + } + + if (settingsToUpdate.sourceVersion == "simplified") { + if (this.viewSettings.printBackgrounds) { + // Remember that this was true before so it gets restored if the + // format is changed to something else. + this._userChangedSettings.printBackgrounds = true; + } + // Backgrounds are removed in simplified mode and this setting changes + // the output subtly to be less legible. + settingsToUpdate.printBackgrounds = false; + } + + if ( + settingsToUpdate.printInColor != this._userChangedSettings.printInColor + ) { + delete this._userChangedSettings.printInColor; + } + + // See if the paperId needs to change. + let paperId = settingsToUpdate.paperId || this.viewSettings.paperId; + + logger.debug("Using paperId: ", paperId); + logger.debug( + "Available paper sizes: ", + PrintSettingsViewProxy.availablePaperSizes + ); + let matchedPaper = + paperId && PrintSettingsViewProxy.availablePaperSizes[paperId]; + if (!matchedPaper) { + let paperWidth, paperHeight, paperSizeUnit; + if (settingsToUpdate.paperId) { + // The user changed paperId in this instance and session, + // We should have details on the paper size from the previous printer + paperId = settingsToUpdate.paperId; + let cachedPaperWrapper = this.allPaperSizes[paperId]; + // for the purposes of finding a best-size match, we'll use mm + paperWidth = cachedPaperWrapper.paper.width * MM_PER_POINT; + paperHeight = cachedPaperWrapper.paper.height * MM_PER_POINT; + paperSizeUnit = PrintEventHandler.settings.kPaperSizeMillimeters; + } else { + paperId = this.viewSettings.paperId; + logger.debug( + "No paperId or matchedPaper, get a new default from viewSettings:", + paperId + ); + paperWidth = this.viewSettings.paperWidth; + paperHeight = this.viewSettings.paperHeight; + paperSizeUnit = this.viewSettings.paperSizeUnit; + } + matchedPaper = PrintSettingsViewProxy.getBestPaperMatch( + paperWidth, + paperHeight, + paperSizeUnit + ); + } + if (!matchedPaper) { + // We didn't find a good match. Take the first paper size + matchedPaper = Object.values( + PrintSettingsViewProxy.availablePaperSizes + )[0]; + delete this._userChangedSettings.paperId; + } + if (matchedPaper.id !== paperId) { + // The exact paper id doesn't exist for this printer + logger.log( + `Requested paperId: "${paperId}" missing on this printer, using: ${matchedPaper.id} instead` + ); + delete this._userChangedSettings.paperId; + } + // Always write paper details back to settings + settingsToUpdate.paperId = matchedPaper.id; + + return settingsToUpdate; + }, + + _createDelayedSettingsChangeTask() { + this._delayedSettingsChangeTask = createDeferredTask(async () => { + if (Object.keys(this._delayedChanges).length) { + let changes = this._delayedChanges; + this._delayedChanges = {}; + await this.onUserSettingsChange(changes); + } + }, INPUT_DELAY_MS); + }, + + _createUpdatePrintPreviewTask(initialPreviewDone = null) { + this._updatePrintPreviewTask = new DeferredTask(async () => { + await initialPreviewDone; + await this._updatePrintPreview(); + document.dispatchEvent(new CustomEvent("preview-updated")); + }, 0); + }, + + _scheduleDelayedSettingsChange(changes) { + Object.assign(this._delayedChanges, changes); + this._delayedSettingsChangeTask.disarm(); + this._delayedSettingsChangeTask.arm(); + }, + + handleSettingsChange(changedSettings = {}) { + let delayedChanges = {}; + let instantChanges = {}; + for (let [setting, value] of Object.entries(changedSettings)) { + switch (setting) { + case "pageRanges": + case "scaling": + delayedChanges[setting] = value; + break; + case "customMargins": + delete this._delayedChanges.margins; + changedSettings.margins == "custom" + ? (delayedChanges[setting] = value) + : (instantChanges[setting] = value); + break; + default: + instantChanges[setting] = value; + break; + } + } + if (Object.keys(delayedChanges).length) { + this._scheduleDelayedSettingsChange(delayedChanges); + } + if (Object.keys(instantChanges).length) { + this.onUserSettingsChange(instantChanges); + } + }, + + async onUserSettingsChange(changedSettings = {}) { + let previewableChange = false; + for (let [setting, value] of Object.entries(changedSettings)) { + Services.telemetry.keyedScalarAdd( + "printing.settings_changed", + setting, + 1 + ); + // Update the list of user-changed settings, which we attempt to maintain + // across printer changes. + this._userChangedSettings[setting] = value; + if (!this._noPreviewUpdateSettings.has(setting)) { + previewableChange = true; + } + } + if (changedSettings.printerName) { + logger.debug( + "onUserSettingsChange, changing to printerName:", + changedSettings.printerName + ); + this.printForm.printerChanging = true; + this.printForm.disable(el => el.id != "printer-picker"); + let { printerName } = changedSettings; + // Treat a printerName change separately, because it involves a settings + // object switch and we don't want to set the new name on the old settings. + changedSettings = await this.refreshSettings(printerName); + if (printerName != this.currentPrinterName) { + // Don't continue this update if the printer changed again. + return; + } + this.printForm.printerChanging = false; + this.printForm.enable(); + } else { + changedSettings = this.getSettingsToUpdate(); + } + + let shouldPreviewUpdate = + (await this.updateSettings( + changedSettings, + !!changedSettings.printerName + )) && previewableChange; + + if (shouldPreviewUpdate && !printPending) { + // We do not need to arm the preview task if the user has already printed + // and finalized any deferred tasks. + this.updatePrintPreview(); + } + document.dispatchEvent( + new CustomEvent("print-settings", { + detail: this.viewSettings, + }) + ); + }, + + async updateSettings(changedSettings = {}, printerChanged = false) { + let updatePreviewWithoutFlag = false; + let flags = 0; + logger.debug("updateSettings ", changedSettings, printerChanged); + + if (printerChanged || changedSettings.paperId) { + // The paper's margin properties are async, + // so resolve those now before we update the settings + try { + let paperWrapper = await PrintSettingsViewProxy.fetchPaperMargins( + changedSettings.paperId || this.viewSettings.paperId + ); + + // See if we also need to change the custom margin values + + let paperHeightInInches = paperWrapper.paper.height * INCHES_PER_POINT; + let paperWidthInInches = paperWrapper.paper.width * INCHES_PER_POINT; + let height = + (changedSettings.orientation || this.viewSettings.orientation) == 0 + ? paperHeightInInches + : paperWidthInInches; + let width = + (changedSettings.orientation || this.viewSettings.orientation) == 0 + ? paperWidthInInches + : paperHeightInInches; + + function verticalMarginsInvalid(margins) { + return ( + parseFloat(margins.marginTop) + parseFloat(margins.marginBottom) > + height - + paperWrapper.unwriteableMarginTop - + paperWrapper.unwriteableMarginBottom + ); + } + + function horizontalMarginsInvalid(margins) { + return ( + parseFloat(margins.marginRight) + parseFloat(margins.marginLeft) > + width - + paperWrapper.unwriteableMarginRight - + paperWrapper.unwriteableMarginLeft + ); + } + + let unwriteableMarginsInvalid = false; + if ( + verticalMarginsInvalid(this.viewSettings.customMargins) || + this.viewSettings.customMargins.marginTop < 0 || + this.viewSettings.customMargins.marginBottom < 0 + ) { + let { marginTop, marginBottom } = this.viewSettings.defaultMargins; + if (verticalMarginsInvalid(this.viewSettings.defaultMargins)) { + let marginsNone = this.getMarginPresets("none"); + marginTop = marginsNone.marginTop; + marginBottom = marginsNone.marginBottom; + unwriteableMarginsInvalid = true; + } + changedSettings.marginTop = changedSettings.customMarginTop = + marginTop; + changedSettings.marginBottom = changedSettings.customMarginBottom = + marginBottom; + delete this._userChangedSettings.customMargins; + } + + if ( + horizontalMarginsInvalid(this.viewSettings.customMargins) || + this.viewSettings.customMargins.marginLeft < 0 || + this.viewSettings.customMargins.marginRight < 0 + ) { + let { marginLeft, marginRight } = this.viewSettings.defaultMargins; + if (horizontalMarginsInvalid(this.viewSettings.defaultMargins)) { + let marginsNone = this.getMarginPresets("none"); + marginLeft = marginsNone.marginLeft; + marginRight = marginsNone.marginRight; + unwriteableMarginsInvalid = true; + } + changedSettings.marginLeft = changedSettings.customMarginLeft = + marginLeft; + changedSettings.marginRight = changedSettings.customMarginRight = + marginRight; + delete this._userChangedSettings.customMargins; + } + + if (unwriteableMarginsInvalid) { + changedSettings.ignoreUnwriteableMargins = true; + } + } catch (e) { + this.reportPrintingError("PAPER_MARGINS"); + throw e; + } + } + + for (let [setting, value] of Object.entries(changedSettings)) { + // Always write paper changes back to settings as pref-derived values could be bad + if ( + this.viewSettings[setting] != value || + (printerChanged && setting == "paperId") + ) { + if (setting == "pageRanges") { + // The page range is kept as an array. If the user switches between all + // and custom with no specified range input (which is represented as an + // empty array), we do not want to send an update. + if (!this.viewSettings[setting].length && !value.length) { + continue; + } + } + this.viewSettings[setting] = value; + + if ( + setting in this.settingFlags && + setting in this._userChangedSettings + ) { + flags |= this.settingFlags[setting]; + } + updatePreviewWithoutFlag |= + this._nonFlaggedUpdatePreviewSettings.has(setting); + } + } + + let shouldPreviewUpdate = + flags || printerChanged || updatePreviewWithoutFlag; + logger.debug( + "updateSettings, calculated flags:", + flags, + "shouldPreviewUpdate:", + shouldPreviewUpdate + ); + if (flags) { + this.saveSettingsToPrefs(flags); + } + return shouldPreviewUpdate; + }, + + saveSettingsToPrefs(flags) { + PSSVC.maybeSavePrintSettingsToPrefs(this.settings, flags); + }, + + /** + * Queue a task to update the print preview. It will start immediately or when + * the in progress update completes. + */ + async updatePrintPreview() { + // Make sure the rendering state is set so we don't visibly update the + // sheet count with incomplete data. + this._updatePrintPreviewTask.arm(); + }, + + /** + * Creates a print preview or refreshes the preview with new settings when omitted. + * + * @return {Promise} Resolves when the preview has been updated. + */ + async _updatePrintPreview() { + let { settings } = this; + + const isFirstCall = !this.printInitiationTime; + if (isFirstCall) { + let params = new URLSearchParams(location.search); + this.printInitiationTime = parseInt( + params.get("printInitiationTime"), + 10 + ); + const elapsed = Date.now() - this.printInitiationTime; + Services.telemetry + .getHistogramById("PRINT_INIT_TO_PLATFORM_SENT_SETTINGS_MS") + .add(elapsed); + } + + let totalPageCount, sheetCount, isEmpty, orientation, pageWidth, pageHeight; + try { + // This resolves with a PrintPreviewSuccessInfo dictionary. + let { sourceVersion } = this.viewSettings; + let sourceURI = this.activeURI; + this._lastPrintPreviewSettings = settings; + ({ + totalPageCount, + sheetCount, + isEmpty, + orientation, + pageWidth, + pageHeight, + } = await this.printPreviewEl.printPreview(settings, { + sourceVersion, + sourceURI, + })); + } catch (e) { + this.reportPrintingError("PRINT_PREVIEW"); + console.error(e); + throw e; + } + + // If there is a set orientation, update the settings to use it. In this + // case, the document will already have used this orientation to create + // the print preview. + if (orientation != "unspecified") { + const kIPrintSettings = Ci.nsIPrintSettings; + settings.orientation = + orientation == "landscape" + ? kIPrintSettings.kLandscapeOrientation + : kIPrintSettings.kPortraitOrientation; + document.dispatchEvent(new CustomEvent("hide-orientation")); + } + + // If the page size is set, check whether we should use it as our paper size. + let isUsingPageRuleSizeAsPaperSize = + settings.usePageRuleSizeAsPaperSize && + pageWidth !== null && + pageHeight !== null; + if (isUsingPageRuleSizeAsPaperSize) { + // We canonically represent paper sizes using the width/height of a portrait-oriented sheet, + // with landscape-orientation applied as a supplemental rotation. + // If the page-size is landscape oriented, we flip the pageWidth / pageHeight here + // in order to pass a canonical representation into the paper-size settings. + if (orientation == "landscape") { + [pageHeight, pageWidth] = [pageWidth, pageHeight]; + } + + let matchedPaper = PrintSettingsViewProxy.getBestPaperMatch( + pageWidth, + pageHeight, + settings.kPaperSizeInches + ); + if (matchedPaper) { + settings.paperId = matchedPaper.id; + } + + settings.paperWidth = pageWidth; + settings.paperHeight = pageHeight; + settings.paperSizeUnit = settings.kPaperSizeInches; + document.dispatchEvent(new CustomEvent("hide-paper-size")); + } + + this.previewIsEmpty = isEmpty; + // If the preview is empty, we know our range is greater than the number of pages. + // We have to send a pageRange update to display a non-empty page. + if (this.previewIsEmpty) { + this.viewSettings.pageRanges = []; + this.updatePrintPreview(); + } + + document.dispatchEvent( + new CustomEvent("page-count", { + detail: { sheetCount, totalPages: totalPageCount }, + }) + ); + + if (isFirstCall) { + const elapsed = Date.now() - this.printInitiationTime; + Services.telemetry + .getHistogramById("PRINT_INIT_TO_PREVIEW_DOC_SHOWN_MS") + .add(elapsed); + } + }, + + async getPrintDestinations() { + const printerList = Cc["@mozilla.org/gfx/printerlist;1"].createInstance( + Ci.nsIPrinterList + ); + let printers; + + if (Cu.isInAutomation) { + printers = window._mockPrinters || []; + } else { + try { + printers = await printerList.printers; + } catch (e) { + this.reportPrintingError("PRINTER_LIST"); + throw e; + } + } + + let fallbackPaperList; + try { + fallbackPaperList = await printerList.fallbackPaperList; + } catch (e) { + this.reportPrintingError("FALLBACK_PAPER_LIST"); + throw e; + } + + let lastUsedPrinterName; + try { + lastUsedPrinterName = PSSVC.lastUsedPrinterName; + } catch (e) { + this.reportPrintingError("LAST_USED_PRINTER"); + throw e; + } + const defaultPrinterName = printerList.systemDefaultPrinterName; + const printersByName = {}; + + let lastUsedPrinter; + let defaultSystemPrinter; + + let saveToPdfPrinter = { + nameId: "printui-destination-pdf-label", + value: PrintUtils.SAVE_TO_PDF_PRINTER, + }; + printersByName[PrintUtils.SAVE_TO_PDF_PRINTER] = { + supportsColor: true, + supportsMonochrome: false, + name: PrintUtils.SAVE_TO_PDF_PRINTER, + }; + + if (lastUsedPrinterName == PrintUtils.SAVE_TO_PDF_PRINTER) { + lastUsedPrinter = saveToPdfPrinter; + } + + let destinations = [ + saveToPdfPrinter, + ...printers.map(printer => { + printer.QueryInterface(Ci.nsIPrinter); + const { name } = printer; + printersByName[printer.name] = { printer }; + const destination = { name, value: name }; + + if (name == lastUsedPrinterName) { + lastUsedPrinter = destination; + } + if (name == defaultPrinterName) { + defaultSystemPrinter = destination; + } + + return destination; + }), + ]; + + let selectedPrinter = + lastUsedPrinter || defaultSystemPrinter || saveToPdfPrinter; + + return { + destinations, + fallbackPaperList, + selectedPrinter, + printersByName, + defaultSystemPrinter, + }; + }, + + getMarginPresets(marginSize, paperWrapper) { + switch (marginSize) { + case "minimum": { + let marginSource = paperWrapper || this.defaultSettings; + return { + marginTop: marginSource.unwriteableMarginTop, + marginRight: marginSource.unwriteableMarginRight, + marginBottom: marginSource.unwriteableMarginBottom, + marginLeft: marginSource.unwriteableMarginLeft, + }; + } + case "none": + return { + marginTop: 0, + marginLeft: 0, + marginBottom: 0, + marginRight: 0, + }; + case "custom": + return { + marginTop: + PrintSettingsViewProxy._lastCustomMarginValues.marginTop ?? + this.settings.marginTop, + marginBottom: + PrintSettingsViewProxy._lastCustomMarginValues.marginBottom ?? + this.settings.marginBottom, + marginLeft: + PrintSettingsViewProxy._lastCustomMarginValues.marginLeft ?? + this.settings.marginLeft, + marginRight: + PrintSettingsViewProxy._lastCustomMarginValues.marginRight ?? + this.settings.marginRight, + }; + default: { + let minimum = this.getMarginPresets("minimum", paperWrapper); + return { + marginTop: !isNaN(minimum.marginTop) + ? Math.max(minimum.marginTop, this.defaultSettings.marginTop) + : this.defaultSettings.marginTop, + marginRight: !isNaN(minimum.marginRight) + ? Math.max(minimum.marginRight, this.defaultSettings.marginRight) + : this.defaultSettings.marginRight, + marginBottom: !isNaN(minimum.marginBottom) + ? Math.max(minimum.marginBottom, this.defaultSettings.marginBottom) + : this.defaultSettings.marginBottom, + marginLeft: !isNaN(minimum.marginLeft) + ? Math.max(minimum.marginLeft, this.defaultSettings.marginLeft) + : this.defaultSettings.marginLeft, + }; + } + } + }, + + reportPrintingError(aMessage) { + logger.debug("reportPrintingError:", aMessage); + Services.telemetry.keyedScalarAdd("printing.error", aMessage, 1); + }, + + /** + * Shows the system dialog. This method has been abstracted into a helper for + * testing purposes. The showPrintDialog() call blocks until the dialog is + * closed, so we mark it as async to allow us to reject from the test. + */ + async _showPrintDialog(aWindow, aHaveSelection, aSettings) { + return PrintUtils.handleSystemPrintDialog( + aWindow, + aHaveSelection, + aSettings + ); + }, +}; + +var 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", + }, + + // Custom margins are not saved by a pref, so we need to keep track of them + // in order to save the value. + _lastCustomMarginValues: { + marginTop: null, + marginBottom: null, + marginLeft: null, + marginRight: null, + }, + + // This list was taken from nsDeviceContextSpecWin.cpp which records telemetry on print target type + knownSaveToFilePrinters: new Set([ + "Microsoft Print to PDF", + "Adobe PDF", + "Bullzip PDF Printer", + "CutePDF Writer", + "doPDF", + "Foxit Reader PDF Printer", + "Nitro PDF Creator", + "novaPDF", + "PDF-XChange", + "PDF24 PDF", + "PDFCreator", + "PrimoPDF", + "Soda PDF", + "Solid PDF Creator", + "Universal Document Converter", + "Microsoft XPS Document Writer", + ]), + + getBestPaperMatch(paperWidth, paperHeight, paperSizeUnit) { + let paperSizes = Object.values(this.availablePaperSizes); + if (!(paperWidth && paperHeight)) { + return null; + } + // first try to match on the paper dimensions using the current units + let unitsPerPoint; + let altUnitsPerPoint; + if (paperSizeUnit == PrintEventHandler.settings.kPaperSizeMillimeters) { + unitsPerPoint = MM_PER_POINT; + altUnitsPerPoint = INCHES_PER_POINT; + } else { + unitsPerPoint = INCHES_PER_POINT; + altUnitsPerPoint = MM_PER_POINT; + } + // equality to 1pt. + const equal = (a, b) => Math.abs(a - b) < 1; + const findMatch = (widthPts, heightPts) => + paperSizes.find(paperWrapper => { + // the dimensions on the nsIPaper object are in points + let result = + equal(widthPts, paperWrapper.paper.width) && + equal(heightPts, paperWrapper.paper.height); + return result; + }); + // Look for a paper with matching dimensions, using the current printer's + // paper size unit, then the alternate unit + let matchedPaper = + findMatch(paperWidth / unitsPerPoint, paperHeight / unitsPerPoint) || + findMatch(paperWidth / altUnitsPerPoint, paperHeight / altUnitsPerPoint); + + if (matchedPaper) { + return matchedPaper; + } + return null; + }, + + async fetchPaperMargins(paperId) { + // resolve any async and computed properties we need on the paper + let paperWrapper = this.availablePaperSizes[paperId]; + if (!paperWrapper) { + throw new Error("Can't fetchPaperMargins: " + paperId); + } + if (paperWrapper._resolved) { + // We've already resolved and calculated these values + return paperWrapper; + } + let margins; + try { + margins = await paperWrapper.paper.unwriteableMargin; + } catch (e) { + this.reportPrintingError("UNWRITEABLE_MARGIN"); + throw e; + } + margins.QueryInterface(Ci.nsIPaperMargin); + + // margin dimensions are given on the paper in points, setting values need to be in inches + paperWrapper.unwriteableMarginTop = margins.top * INCHES_PER_POINT; + paperWrapper.unwriteableMarginRight = margins.right * INCHES_PER_POINT; + paperWrapper.unwriteableMarginBottom = margins.bottom * INCHES_PER_POINT; + paperWrapper.unwriteableMarginLeft = margins.left * INCHES_PER_POINT; + // No need to re-resolve static properties + paperWrapper._resolved = true; + return paperWrapper; + }, + + async resolvePropertiesForPrinter(printerName) { + // resolve any async properties we need on the printer + let printerInfo = this.availablePrinters[printerName]; + if (printerInfo._resolved) { + // Store a convenience reference + this.availablePaperSizes = printerInfo.availablePaperSizes; + return printerInfo; + } + + // Await the async printer data. + if (printerInfo.printer) { + let basePrinterInfo; + try { + [ + printerInfo.supportsDuplex, + printerInfo.supportsColor, + printerInfo.supportsMonochrome, + basePrinterInfo, + ] = await Promise.all([ + printerInfo.printer.supportsDuplex, + printerInfo.printer.supportsColor, + printerInfo.printer.supportsMonochrome, + printerInfo.printer.printerInfo, + ]); + } catch (e) { + this.reportPrintingError("PRINTER_SETTINGS"); + throw e; + } + basePrinterInfo.QueryInterface(Ci.nsIPrinterInfo); + basePrinterInfo.defaultSettings.QueryInterface(Ci.nsIPrintSettings); + + printerInfo.paperList = basePrinterInfo.paperList; + printerInfo.defaultSettings = basePrinterInfo.defaultSettings; + } else if (printerName == PrintUtils.SAVE_TO_PDF_PRINTER) { + // The Mozilla PDF pseudo-printer has no actual nsIPrinter implementation + printerInfo.defaultSettings = PSSVC.createNewPrintSettings(); + printerInfo.defaultSettings.printerName = printerName; + printerInfo.defaultSettings.toFileName = ""; + printerInfo.defaultSettings.outputFormat = + Ci.nsIPrintSettings.kOutputFormatPDF; + printerInfo.defaultSettings.outputDestination = + Ci.nsIPrintSettings.kOutputDestinationFile; + printerInfo.defaultSettings.usePageRuleSizeAsPaperSize = + Services.prefs.getBoolPref( + "print.save_as_pdf.use_page_rule_size_as_paper_size.enabled", + false + ); + printerInfo.paperList = this.fallbackPaperList; + } + printerInfo.settings = printerInfo.defaultSettings.clone(); + // Apply any previously persisted user values + // Don't apply kInitSavePrintToFile though, that should only be true for + // the PDF printer. + printerInfo.settings.outputDestination = + printerName == PrintUtils.SAVE_TO_PDF_PRINTER + ? Ci.nsIPrintSettings.kOutputDestinationFile + : Ci.nsIPrintSettings.kOutputDestinationPrinter; + let flags = + printerInfo.settings.kInitSaveAll ^ + printerInfo.settings.kInitSavePrintToFile; + PSSVC.initPrintSettingsFromPrefs(printerInfo.settings, true, flags); + // We set `isInitializedFromPrinter` to make sure that that's set on the + // SAVE_TO_PDF_PRINTER settings. The naming is poor, but that tells the + // platform code that the settings object is complete. + printerInfo.settings.isInitializedFromPrinter = true; + + printerInfo.settings.toFileName = ""; + + // prepare the available paper sizes for this printer + if (!printerInfo.paperList?.length) { + logger.warn( + "Printer has empty paperList: ", + printerInfo.printer.id, + "using fallbackPaperList" + ); + printerInfo.paperList = this.fallbackPaperList; + } + // don't trust the settings to provide valid paperSizeUnit values + let sizeUnit = + printerInfo.settings.paperSizeUnit == + printerInfo.settings.kPaperSizeMillimeters + ? printerInfo.settings.kPaperSizeMillimeters + : printerInfo.settings.kPaperSizeInches; + let papersById = (printerInfo.availablePaperSizes = {}); + // Store a convenience reference + this.availablePaperSizes = papersById; + + for (let paper of printerInfo.paperList) { + paper.QueryInterface(Ci.nsIPaper); + // Bug 1662239: I'm seeing multiple duplicate entries for each paper size + // so ensure we have one entry per name + if (!papersById[paper.id]) { + papersById[paper.id] = { + paper, + id: paper.id, + name: paper.name, + // XXXsfoster: Eventually we want to get the unit from the nsIPaper object + sizeUnit, + }; + } + } + // Update our cache of all the paper sizes by name + Object.assign(PrintEventHandler.allPaperSizes, papersById); + + // The printer properties don't change, mark this as resolved for next time + printerInfo._resolved = true; + logger.debug("Resolved printerInfo:", printerInfo); + return printerInfo; + }, + + get(target, name) { + switch (name) { + case "currentPaper": { + let paperId = this.get(target, "paperId"); + return paperId && this.availablePaperSizes[paperId]; + } + + case "marginPresets": + let paperWrapper = this.get(target, "currentPaper"); + return { + none: PrintEventHandler.getMarginPresets("none", paperWrapper), + minimum: PrintEventHandler.getMarginPresets("minimum", paperWrapper), + default: PrintEventHandler.getMarginPresets("default", paperWrapper), + custom: PrintEventHandler.getMarginPresets("custom", paperWrapper), + }; + + case "marginOptions": { + let allMarginPresets = this.get(target, "marginPresets"); + let uniqueMargins = new Set(); + let marginsEnabled = {}; + for (let name of ["none", "default", "minimum", "custom"]) { + let { marginTop, marginLeft, marginBottom, marginRight } = + allMarginPresets[name]; + let key = [marginTop, marginLeft, marginBottom, marginRight].join( + "," + ); + // Custom margins are initialized to default margins + marginsEnabled[name] = !uniqueMargins.has(key) || name == "custom"; + uniqueMargins.add(key); + } + return marginsEnabled; + } + + case "margins": + let marginSettings = { + marginTop: target.marginTop, + marginLeft: target.marginLeft, + marginBottom: target.marginBottom, + marginRight: target.marginRight, + }; + // see if they match the none, minimum, or default margin values + let allMarginPresets = this.get(target, "marginPresets"); + const marginsMatch = function (lhs, rhs) { + return Object.keys(marginSettings).every( + name => lhs[name].toFixed(2) == rhs[name].toFixed(2) + ); + }; + const potentialPresets = (function () { + let presets = []; + const minimumIsNone = marginsMatch( + allMarginPresets.none, + allMarginPresets.minimum + ); + // We only attempt to match the serialized values against the "none" + // preset if the unwriteable margins are being ignored or are zero. + if (target.ignoreUnwriteableMargins || minimumIsNone) { + presets.push("none"); + } + if (!minimumIsNone) { + presets.push("minimum"); + } + presets.push("default"); + return presets; + })(); + for (let presetName of potentialPresets) { + let marginPresets = allMarginPresets[presetName]; + if (marginsMatch(marginSettings, marginPresets)) { + return presetName; + } + } + + // Fall back to custom for other values + return "custom"; + + case "defaultMargins": + return PrintEventHandler.getMarginPresets( + "default", + this.get(target, "currentPaper") + ); + + case "customMargins": + return PrintEventHandler.getMarginPresets( + "custom", + this.get(target, "currentPaper") + ); + + case "paperSizes": + return Object.values(this.availablePaperSizes) + .sort((a, b) => a.name.localeCompare(b.name)) + .map(paper => { + return { + name: paper.name, + value: paper.id, + }; + }); + + case "supportsDuplex": + return this.availablePrinters[target.printerName].supportsDuplex; + + case "printDuplex": + switch (target.duplex) { + case Ci.nsIPrintSettings.kDuplexNone: + break; + case Ci.nsIPrintSettings.kDuplexFlipOnLongEdge: + return "long-edge"; + case Ci.nsIPrintSettings.kDuplexFlipOnShortEdge: + return "short-edge"; + default: + logger.warn("Unexpected duplex value: ", target.duplex); + } + return "off"; + 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 "supportsColor": + return this.availablePrinters[target.printerName].supportsColor; + + case "willSaveToFile": + return ( + target.outputFormat == Ci.nsIPrintSettings.kOutputFormatPDF || + this.knownSaveToFilePrinters.has(target.printerName) + ); + case "supportsMonochrome": + return this.availablePrinters[target.printerName].supportsMonochrome; + case "defaultSystemPrinter": + return ( + this.defaultSystemPrinter?.value || + Object.getOwnPropertyNames(this.availablePrinters).find( + name => name != PrintUtils.SAVE_TO_PDF_PRINTER + ) + ); + + case "numCopies": + return this.get(target, "willSaveToFile") ? 1 : target.numCopies; + case "sourceVersion": + return this._sourceVersion; + } + return target[name]; + }, + + set(target, name, value) { + switch (name) { + case "margins": + if (!["default", "minimum", "none", "custom"].includes(value)) { + logger.warn("Unexpected margin preset name: ", value); + value = "default"; + } + let paperWrapper = this.get(target, "currentPaper"); + let marginPresets = PrintEventHandler.getMarginPresets( + value, + paperWrapper + ); + for (let [settingName, presetValue] of Object.entries(marginPresets)) { + target[settingName] = presetValue; + } + target.honorPageRuleMargins = value == "default"; + target.ignoreUnwriteableMargins = value == "none"; + break; + + case "paperId": { + let paperId = value; + let paperWrapper = this.availablePaperSizes[paperId]; + // Dimensions on the paper object are in pts. + // We convert to the printer's specified unit when updating settings + let unitsPerPoint = + paperWrapper.sizeUnit == target.kPaperSizeMillimeters + ? MM_PER_POINT + : INCHES_PER_POINT; + // paperWidth and paperHeight are calculated values that we always treat as suspect and + // re-calculate whenever the paperId changes + target.paperSizeUnit = paperWrapper.sizeUnit; + target.paperWidth = paperWrapper.paper.width * unitsPerPoint; + target.paperHeight = paperWrapper.paper.height * unitsPerPoint; + // Unwriteable margins were pre-calculated from their async values when the paper size + // was selected. They are always in inches + target.unwriteableMarginTop = paperWrapper.unwriteableMarginTop; + target.unwriteableMarginRight = paperWrapper.unwriteableMarginRight; + target.unwriteableMarginBottom = paperWrapper.unwriteableMarginBottom; + target.unwriteableMarginLeft = paperWrapper.unwriteableMarginLeft; + target.paperId = paperWrapper.paper.id; + // pull new margin values for the new paper size + this.set(target, "margins", this.get(target, "margins")); + break; + } + + case "printerName": + // Can't set printerName, settings objects belong to a specific printer. + break; + + case "printBackgrounds": + target.printBGImages = value; + target.printBGColors = value; + break; + + case "printDuplex": { + let duplex = (function () { + switch (value) { + case "off": + break; + case "long-edge": + return Ci.nsIPrintSettings.kDuplexFlipOnLongEdge; + case "short-edge": + return Ci.nsIPrintSettings.kDuplexFlipOnShortEdge; + default: + logger.warn("Unexpected duplex name: ", value); + } + return Ci.nsIPrintSettings.kDuplexNone; + })(); + + target.duplex = duplex; + 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 "customMargins": + if (value != null) { + for (let [settingName, newVal] of Object.entries(value)) { + target[settingName] = newVal; + this._lastCustomMarginValues[settingName] = newVal; + } + } + break; + + case "customMarginTop": + case "customMarginBottom": + case "customMarginLeft": + case "customMarginRight": + let customMarginName = "margin" + name.substring(12); + this.set( + target, + "customMargins", + Object.assign({}, this.get(target, "customMargins"), { + [customMarginName]: value, + }) + ); + break; + + case "sourceVersion": + this._sourceVersion = value; + this.set(target, "printSelectionOnly", value == "selection"); + if (value == "simplified") { + this.set(target, "printBackgrounds", false); + } + break; + + default: + target[name] = value; + } + }, +}; + +/* + * Custom elements ---------------------------------------------------- + */ + +function PrintUIControlMixin(superClass) { + return class PrintUIControl extends superClass { + connectedCallback() { + this.setAttribute("autocomplete", "off"); + 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("input", this); + } + + render() {} + + update(settings) {} + + dispatchSettingsChange(changedSettings) { + this.dispatchEvent( + new CustomEvent("update-print-settings", { + bubbles: true, + detail: changedSettings, + }) + ); + } + + cancelSettingsChange(changedSettings) { + this.dispatchEvent( + new CustomEvent("cancel-print-settings", { + bubbles: true, + detail: changedSettings, + }) + ); + } + + handleEvent(event) {} + }; +} + +class PrintUIForm extends PrintUIControlMixin(HTMLFormElement) { + initialize() { + super.initialize(); + + this.addEventListener("submit", this); + this.addEventListener("click", this); + this.addEventListener("revalidate", this); + + this._printerDestination = this.querySelector("#destination"); + this.printButton = this.querySelector("#print-button"); + } + + removeNonPdfSettings() { + let selectors = ["#backgrounds", "#source-version-selection"]; + for (let selector of selectors) { + this.querySelector(selector).remove(); + } + let moreSettings = this.querySelector("#more-settings-options"); + if (moreSettings.children.length <= 1) { + moreSettings.remove(); + } + } + + requestPrint() { + this.requestSubmit(this.printButton); + } + + update(settings) { + // If there are no default system printers available and we are not on mac, + // we should hide the system dialog because it won't be populated with + // the correct settings. Mac and Gtk support save to pdf functionality + // in the native dialog, so it can be shown regardless. + this.querySelector("#system-print").hidden = + AppConstants.platform === "win" && !settings.defaultSystemPrinter; + + this.querySelector("#two-sided-printing").hidden = !settings.supportsDuplex; + } + + enable() { + let isValid = this.checkValidity(); + document.body.toggleAttribute("invalid", !isValid); + if (isValid) { + for (let element of this.elements) { + if (!element.hasAttribute("disallowed")) { + element.disabled = false; + } + } + // aria-describedby will usually cause the first value to be reported. + // Unfortunately, screen readers don't pick up description changes from + // dialogs, so we must use a live region. To avoid double reporting of + // the first value, we don't set aria-live initially. We only set it for + // subsequent updates. + // aria-live is set on the parent because sheetCount itself might be + // hidden and then shown, and updates are only reported for live + // regions that were already visible. + document + .querySelector("#sheet-count") + .parentNode.setAttribute("aria-live", "polite"); + } else { + // Find the invalid element + let invalidElement; + for (let element of this.elements) { + if (!element.checkValidity()) { + invalidElement = element; + break; + } + } + let section = invalidElement.closest(".section-block"); + document.body.toggleAttribute("invalid", !isValid); + // We're hiding the sheet count and aria-describedby includes the + // content of hidden elements, so remove aria-describedby. + document.body.removeAttribute("aria-describedby"); + 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") != this._printerDestination && + element.closest(".section-block") != section); + } + } + } + + disable(filterFn) { + for (let element of this.elements) { + if (filterFn && !filterFn(element)) { + continue; + } + element.disabled = element.name != "cancel"; + } + } + + 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(); + if (e.submitter.name == "print" && this.checkValidity()) { + this.dispatchEvent(new Event("print", { bubbles: true })); + } + } else if ( + (e.type == "input" || e.type == "revalidate") && + !this.printerChanging + ) { + this.enable(); + } + } +} +customElements.define("print-form", PrintUIForm, { extends: "form" }); + +class PrintSettingSelect extends PrintUIControlMixin(HTMLSelectElement) { + initialize() { + super.initialize(); + this.addEventListener("keypress", this); + } + + connectedCallback() { + this.settingName = this.dataset.settingName; + super.connectedCallback(); + } + + setOptions(optionValues = []) { + this.textContent = ""; + for (let optionData of optionValues) { + let opt = new Option( + optionData.name, + "value" in optionData ? optionData.value : optionData.name + ); + if (optionData.nameId) { + document.l10n.setAttributes(opt, optionData.nameId); + } + // option selectedness is set via update() and assignment to this.value + this.options.add(opt); + } + } + + update(settings) { + if (this.settingName) { + this.value = settings[this.settingName]; + } + } + + handleEvent(e) { + if (e.type == "input" && this.settingName) { + this.dispatchSettingsChange({ + [this.settingName]: e.target.value, + }); + } else if (e.type == "keypress") { + if ( + e.key == "Enter" && + (!e.metaKey || AppConstants.platform == "macosx") + ) { + this.form.requestPrint(); + } + } + } +} +customElements.define("setting-select", PrintSettingSelect, { + extends: "select", +}); + +class PrintSettingNumber extends PrintUIControlMixin(HTMLInputElement) { + initialize() { + super.initialize(); + this.addEventListener("beforeinput", e => this.preventWhitespaceEntry(e)); + this.addEventListener("paste", e => this.pasteWithoutWhitespace(e)); + } + + connectedCallback() { + this.type = "number"; + this.settingName = this.dataset.settingName; + super.connectedCallback(); + } + + update(settings) { + if (this.settingName) { + this.value = settings[this.settingName]; + } + } + + preventWhitespaceEntry(e) { + if (e.data && !e.data.trim().length) { + e.preventDefault(); + } + } + + pasteWithoutWhitespace(e) { + // Prevent original value from being pasted + e.preventDefault(); + + // Manually update input's value with sanitized clipboard data + let paste = (e.clipboardData || window.clipboardData) + .getData("text") + .trim(); + this.value = paste; + } + + handleEvent(e) { + switch (e.type) { + case "input": + if (this.settingName && this.checkValidity()) { + this.dispatchSettingsChange({ + [this.settingName]: this.value, + }); + } + break; + } + } +} +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 PrintSettingRadio extends PrintUIControlMixin(HTMLInputElement) { + connectedCallback() { + this.type = "radio"; + this.settingName = this.dataset.settingName; + super.connectedCallback(); + } + + update(settings) { + this.checked = settings[this.settingName] == this.value; + } + + handleEvent(e) { + this.dispatchSettingsChange({ + [this.settingName]: this.value, + }); + } +} +customElements.define("setting-radio", PrintSettingRadio, { + extends: "input", +}); + +class DestinationPicker extends PrintSettingSelect { + initialize() { + super.initialize(); + document.addEventListener("available-destinations", this); + } + + update(settings) { + super.update(settings); + let isPdf = settings.outputFormat == Ci.nsIPrintSettings.kOutputFormatPDF; + this.setAttribute("output", isPdf ? "pdf" : "paper"); + } + + handleEvent(e) { + super.handleEvent(e); + + if (e.type == "available-destinations") { + this.setOptions(e.detail); + } + } +} +customElements.define("destination-picker", DestinationPicker, { + extends: "select", +}); + +class ColorModePicker extends PrintSettingSelect { + update(settings) { + this.value = settings[this.settingName] ? "color" : "bw"; + let canSwitch = settings.supportsColor && settings.supportsMonochrome; + if (this.disablePicker != canSwitch) { + this.toggleAttribute("disallowed", !canSwitch); + this.disabled = !canSwitch; + } + this.disablePicker = canSwitch; + } + + handleEvent(e) { + if (e.type == "input") { + // turn our string value into the expected boolean + this.dispatchSettingsChange({ + [this.settingName]: this.value == "color", + }); + } + } +} +customElements.define("color-mode-select", ColorModePicker, { + extends: "select", +}); + +class PaperSizePicker extends PrintSettingSelect { + initialize() { + super.initialize(); + this._printerName = null; + this._section = this.closest(".section-block"); + document.addEventListener("hide-paper-size", this); + } + + update(settings) { + if (settings.printerName !== this._printerName) { + this._printerName = settings.printerName; + this.setOptions(settings.paperSizes); + } + this.value = settings.paperId; + + // Unhide the paper-size picker, if we've stopped using the page size as paper-size. + if (this._section.hidden && !settings.usePageRuleSizeAsPaperSize) { + this._section.hidden = false; + } + } + + handleEvent(e) { + super.handleEvent(e); + const { type } = e; + if (type == "hide-paper-size") { + this._section.hidden = true; + } + } +} +customElements.define("paper-size-select", PaperSizePicker, { + extends: "select", +}); + +class OrientationInput extends PrintUIControlMixin(HTMLElement) { + initialize() { + super.initialize(); + document.addEventListener("hide-orientation", this); + } + + get templateId() { + return "orientation-template"; + } + + update(settings) { + for (let input of this.querySelectorAll("input")) { + input.checked = settings.orientation == input.value; + } + } + + handleEvent(e) { + if (e.type == "hide-orientation") { + document.getElementById("orientation").hidden = true; + return; + } + this.dispatchSettingsChange({ + orientation: e.target.value, + }); + } +} +customElements.define("orientation-input", OrientationInput); + +class CopiesInput extends PrintUIControlMixin(HTMLElement) { + get templateId() { + return "copy-template"; + } + + initialize() { + super.initialize(); + this._copiesSection = this.closest(".section-block"); + this._copiesInput = this.querySelector("#copies-count"); + this._copiesError = this.querySelector("#error-invalid-copies"); + } + + update(settings) { + this._copiesSection.hidden = settings.willSaveToFile; + this._copiesError.hidden = true; + } + + handleEvent(e) { + this._copiesError.hidden = this._copiesInput.checkValidity(); + } +} +customElements.define("copy-count-input", CopiesInput); + +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"); + } + + updateScale() { + this.dispatchSettingsChange({ + scaling: Number(this._percentScale.value / 100), + }); + } + + update(settings) { + let { scaling, shrinkToFit, printerName } = settings; + this._shrinkToFitChoice.checked = shrinkToFit; + this._scaleChoice.checked = !shrinkToFit; + if (this.disableScale != shrinkToFit) { + this._percentScale.disabled = shrinkToFit; + this._percentScale.toggleAttribute("disallowed", shrinkToFit); + } + this.disableScale = shrinkToFit; + if (!this.printerName) { + this.printerName = printerName; + } + + // 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. + let isValid = this._percentScale.checkValidity(); + if ( + !this._percentScale.value || + (this._shrinkToFitChoice.checked && !isValid) || + (this.printerName != printerName && !isValid) + ) { + // Only allow whole numbers. 0.14 * 100 would have decimal places, etc. + this._percentScale.value = parseInt(scaling * 100, 10); + this.printerName = printerName; + if (!isValid) { + this.dispatchEvent(new Event("revalidate", { bubbles: true })); + this._scaleError.hidden = true; + } + } + } + + handleEvent(e) { + if (e.target == this._shrinkToFitChoice || e.target == this._scaleChoice) { + if (!this._percentScale.checkValidity()) { + this._percentScale.value = 100; + } + let scale = + e.target == this._shrinkToFitChoice + ? 1 + : Number(this._percentScale.value / 100); + this.dispatchSettingsChange({ + shrinkToFit: this._shrinkToFitChoice.checked, + scaling: scale, + }); + this._scaleError.hidden = true; + } else if (e.type == "input") { + if (this._percentScale.checkValidity()) { + this.updateScale(); + } + } + + window.clearTimeout(this.showErrorTimeoutId); + if (this._percentScale.validity.valid) { + this._scaleError.hidden = true; + } else { + this.cancelSettingsChange({ scaling: true }); + this.showErrorTimeoutId = window.setTimeout(() => { + this._scaleError.hidden = false; + }, INPUT_DELAY_MS); + } + } +} +customElements.define("scale-input", ScaleInput); + +class PageRangeInput extends PrintUIControlMixin(HTMLElement) { + initialize() { + super.initialize(); + + this._rangeInput = this.querySelector("#custom-range"); + this._rangeInput.title = ""; + this._rangePicker = this.querySelector("#range-picker"); + this._rangePickerEvenOption = this._rangePicker.namedItem("even"); + this._rangeError = this.querySelector("#error-invalid-range"); + this._startRangeOverflowError = this.querySelector( + "#error-invalid-start-range-overflow" + ); + + this._pagesSet = new Set(); + + this.addEventListener("keypress", this); + this.addEventListener("paste", this); + document.addEventListener("page-count", this); + } + + get templateId() { + return "page-range-template"; + } + + updatePageRange() { + let isCustom = this._rangePicker.value == "custom"; + let isCurrent = this._rangePicker.value == "current"; + + if (!isCurrent) { + this._currentPage = null; + } + + if (isCustom) { + this.validateRangeInput(); + } else if (isCurrent) { + this._currentPage = this._rangeInput.value = + this._currentPage || this.getCurrentVisiblePageNumber(); + this.validateRangeInput(); + } else { + this._pagesSet.clear(); + + if (this._rangePicker.value == "odd") { + for (let i = 1; i <= this._numPages; i += 2) { + this._pagesSet.add(i); + } + } else if (this._rangePicker.value == "even") { + for (let i = 2; i <= this._numPages; i += 2) { + this._pagesSet.add(i); + } + } + + if (!this._rangeInput.checkValidity()) { + this._rangeInput.setCustomValidity(""); + this._rangeInput.value = ""; + } + } + + this.dispatchEvent(new Event("revalidate", { bubbles: true })); + + document.l10n.setAttributes( + this._rangeError, + "printui-error-invalid-range", + { + numPages: this._numPages, + } + ); + + // If it's valid, update the page range and hide the error messages. + // Otherwise, set the appropriate error message + if (this._rangeInput.validity.valid || !isCustom) { + window.clearTimeout(this.showErrorTimeoutId); + this._startRangeOverflowError.hidden = this._rangeError.hidden = true; + } else { + this._rangeInput.focus(); + } + } + + dispatchPageRange(shouldCancel = true) { + window.clearTimeout(this.showErrorTimeoutId); + if ( + this._rangeInput.validity.valid || + this._rangePicker.value != "custom" + ) { + this.dispatchSettingsChange({ + pageRanges: this.formatPageRange(), + }); + } else { + if (shouldCancel) { + this.cancelSettingsChange({ pageRanges: true }); + } + this.showErrorTimeoutId = window.setTimeout(() => { + this._rangeError.hidden = + this._rangeInput.validationMessage != "invalid"; + this._startRangeOverflowError.hidden = + this._rangeInput.validationMessage != "startRangeOverflow"; + }, INPUT_DELAY_MS); + } + } + + // The platform expects pageRanges to be an array of + // ranges represented by ints. + // Ex: Printing pages 1-3 would return [1,3] + // Ex: Printing page 1 would return [1,1] + // Ex: Printing pages 1-2,4 would return [1,2,4,4] + formatPageRange() { + if ( + this._pagesSet.size == 0 || + (this._rangePicker.value == "custom" && this._rangeInput.value == "") || + this._rangePicker.value == "all" + ) { + // Show all pages. + return []; + } + let pages = Array.from(this._pagesSet).sort((a, b) => a - b); + + let formattedRanges = []; + let startRange = pages[0]; + let endRange = pages[0]; + formattedRanges.push(startRange); + + for (let i = 1; i < pages.length; i++) { + let currentPage = pages[i - 1]; + let nextPage = pages[i]; + if (nextPage > currentPage + 1) { + formattedRanges.push(endRange); + startRange = endRange = nextPage; + formattedRanges.push(startRange); + } else { + endRange = nextPage; + } + } + formattedRanges.push(endRange); + + return formattedRanges; + } + + update(settings) { + let { pageRanges, printerName } = settings; + + this.toggleAttribute("all-pages", !pageRanges.length); + if (!this.printerName) { + this.printerName = printerName; + } + + let isValid = this._rangeInput.checkValidity(); + + if (this.printerName != printerName && !isValid) { + this.printerName = printerName; + this._rangeInput.value = ""; + this.updatePageRange(); + this.dispatchPageRange(); + } + } + + handleKeypress(e) { + let char = String.fromCharCode(e.charCode); + let acceptedChar = char.match(/^[0-9,-]$/); + if (!acceptedChar && !char.match("\x00") && !e.ctrlKey && !e.metaKey) { + e.preventDefault(); + } + } + + handlePaste(e) { + let paste = (e.clipboardData || window.clipboardData) + .getData("text") + .trim(); + if (!paste.match(/^[0-9,-]*$/)) { + e.preventDefault(); + } + } + + // This method has been abstracted into a helper for testing purposes + _validateRangeInput(value, numPages) { + this._pagesSet.clear(); + var ranges = value.split(","); + + for (let range of ranges) { + let rangeParts = range.split("-"); + if (rangeParts.length > 2) { + this._rangeInput.setCustomValidity("invalid"); + this._rangeInput.title = ""; + this._pagesSet.clear(); + return; + } + let startRange = parseInt(rangeParts[0], 10); + let endRange = parseInt( + rangeParts.length == 2 ? rangeParts[1] : rangeParts[0], + 10 + ); + + if (isNaN(startRange) && isNaN(endRange)) { + continue; + } + + // If the startRange was not specified, then we infer this + // to be 1. + if (isNaN(startRange) && rangeParts[0] == "") { + startRange = 1; + } + // If the end range was not specified, then we infer this + // to be the total number of pages. + if (isNaN(endRange) && rangeParts[1] == "") { + endRange = numPages; + } + + // Check the range for errors + if (endRange < startRange) { + this._rangeInput.setCustomValidity("startRangeOverflow"); + this._pagesSet.clear(); + return; + } else if ( + startRange > numPages || + endRange > numPages || + startRange == 0 + ) { + this._rangeInput.setCustomValidity("invalid"); + this._rangeInput.title = ""; + this._pagesSet.clear(); + return; + } + + for (let i = startRange; i <= endRange; i++) { + this._pagesSet.add(i); + } + } + + this._rangeInput.setCustomValidity(""); + } + + validateRangeInput() { + let value = ["custom", "current"].includes(this._rangePicker.value) + ? this._rangeInput.value + : ""; + this._validateRangeInput(value, this._numPages); + } + + getCurrentVisiblePageNumber() { + let pageNum = parseInt( + PrintEventHandler.printPreviewEl.lastPreviewBrowser.getAttribute( + "current-page" + ) + ); + return isNaN(pageNum) ? 1 : pageNum; + } + + handleEvent(e) { + if (e.type == "keypress") { + if (e.target == this._rangeInput) { + this.handleKeypress(e); + } + return; + } + + if (e.type === "paste" && e.target == this._rangeInput) { + this.handlePaste(e); + return; + } + + if (e.type == "page-count") { + let { totalPages } = e.detail; + // This means we have already handled the page count event + // and do not need to dispatch another event. + if (this._numPages == totalPages) { + return; + } + + this._numPages = totalPages; + this._rangeInput.disabled = false; + this._rangePickerEvenOption.disabled = this._numPages < 2; + + let prevPages = Array.from(this._pagesSet); + this.updatePageRange(); + if ( + prevPages.length != this._pagesSet.size || + !prevPages.every(page => this._pagesSet.has(page)) + ) { + // If the calculated set of pages has changed then we need to dispatch + // a new pageRanges setting :( + // Ideally this would be resolved in the settings code since it should + // only happen for the "N-" case where pages N through the end of the + // document are in the range. + this.dispatchPageRange(false); + } + + return; + } + + if (e.target == this._rangePicker) { + this._rangeInput.hidden = e.target.value != "custom"; + this.updatePageRange(); + this.dispatchPageRange(); + if (!this._rangeInput.hidden) { + this._rangeInput.select(); + } + } else if (e.target == this._rangeInput) { + this._rangeInput.focus(); + if (this._numPages) { + this.updatePageRange(); + this.dispatchPageRange(); + } + } + } +} +customElements.define("page-range-input", PageRangeInput); + +class MarginsPicker extends PrintUIControlMixin(HTMLElement) { + initialize() { + super.initialize(); + + this._marginPicker = this.querySelector("#margins-picker"); + this._customTopMargin = this.querySelector("#custom-margin-top"); + this._customBottomMargin = this.querySelector("#custom-margin-bottom"); + this._customLeftMargin = this.querySelector("#custom-margin-left"); + this._customRightMargin = this.querySelector("#custom-margin-right"); + this._marginError = this.querySelector("#error-invalid-margin"); + this._sizeUnit = null; + this._toInchesMultiplier = 1; + } + + get templateId() { + return "margins-template"; + } + + updateCustomMargins() { + let newMargins = { + marginTop: this.toInchValue(this._customTopMargin.value), + marginBottom: this.toInchValue(this._customBottomMargin.value), + marginLeft: this.toInchValue(this._customLeftMargin.value), + marginRight: this.toInchValue(this._customRightMargin.value), + }; + + this.dispatchSettingsChange({ + margins: "custom", + customMargins: newMargins, + }); + this._marginError.hidden = true; + } + + updateMaxValues() { + let maxWidth = this.toCurrentUnitValue(this._maxWidth); + let maxHeight = this.toCurrentUnitValue(this._maxHeight); + this._customTopMargin.max = this.formatMaxAttr( + maxHeight - this._customBottomMargin.value + ); + this._customBottomMargin.max = this.formatMaxAttr( + maxHeight - this._customTopMargin.value + ); + this._customLeftMargin.max = this.formatMaxAttr( + maxWidth - this._customRightMargin.value + ); + this._customRightMargin.max = this.formatMaxAttr( + maxWidth - this._customLeftMargin.value + ); + } + + truncateTwoDecimals(val) { + if (val.split(".")[1].length > 2) { + let dotIndex = val.indexOf("."); + return val.slice(0, dotIndex + 3); + } + return val; + } + + formatMaxAttr(val) { + const strVal = val.toString(); + if (strVal.includes(".")) { + return this.truncateTwoDecimals(strVal); + } + return val; + } + + formatMargin(target) { + if (target.value.includes(".")) { + target.value = this.truncateTwoDecimals(target.value); + } + } + + toCurrentUnitValue(val) { + if (typeof val == "string") { + val = parseFloat(val); + } + return val / this._toInchesMultiplier; + } + + toInchValue(val) { + if (typeof val == "string") { + val = parseFloat(val); + } + return val * this._toInchesMultiplier; + } + + setAllMarginValues(settings) { + this._customTopMargin.value = this.toCurrentUnitValue( + settings.customMargins.marginTop + ).toFixed(2); + this._customBottomMargin.value = this.toCurrentUnitValue( + settings.customMargins.marginBottom + ).toFixed(2); + this._customLeftMargin.value = this.toCurrentUnitValue( + settings.customMargins.marginLeft + ).toFixed(2); + this._customRightMargin.value = this.toCurrentUnitValue( + settings.customMargins.marginRight + ).toFixed(2); + } + + update(settings) { + // Re-evaluate which margin options should be enabled whenever the printer or paper changes + this._toInchesMultiplier = + settings.paperSizeUnit == settings.kPaperSizeMillimeters + ? INCHES_PER_MM + : 1; + if ( + settings.paperId !== this._paperId || + settings.printerName !== this._printerName || + settings.orientation !== this._orientation + ) { + let enabledMargins = settings.marginOptions; + for (let option of this._marginPicker.options) { + option.hidden = !enabledMargins[option.value]; + } + this._paperId = settings.paperId; + this._printerName = settings.printerName; + this._orientation = settings.orientation; + + // Paper dimensions are in the paperSizeUnit. As the margin values are in inches + // we'll normalize to that when storing max dimensions + let height = + this._orientation == 0 ? settings.paperHeight : settings.paperWidth; + let width = + this._orientation == 0 ? settings.paperWidth : settings.paperHeight; + let heightInches = + Math.round(this._toInchesMultiplier * height * 100) / 100; + let widthInches = + Math.round(this._toInchesMultiplier * width * 100) / 100; + + this._maxHeight = + heightInches - + settings.unwriteableMarginTop - + settings.unwriteableMarginBottom; + this._maxWidth = + widthInches - + settings.unwriteableMarginLeft - + settings.unwriteableMarginRight; + + // The values in custom fields should be initialized to custom margin values + // and must be overriden if they are no longer valid. + this.setAllMarginValues(settings); + this.updateMaxValues(); + this.dispatchEvent(new Event("revalidate", { bubbles: true })); + this._marginError.hidden = true; + } + + if (settings.paperSizeUnit !== this._sizeUnit) { + this._sizeUnit = settings.paperSizeUnit; + let unitStr = + this._sizeUnit == settings.kPaperSizeMillimeters ? "mm" : "inches"; + for (let elem of this.querySelectorAll("[data-unit-prefix-l10n-id]")) { + let l10nId = elem.getAttribute("data-unit-prefix-l10n-id") + unitStr; + elem.setAttribute("data-l10n-id", l10nId); + } + } + + // We need to ensure we don't override the value if the value should be custom. + if (this._marginPicker.value != "custom") { + // Reset the custom margin values if they are not valid and revalidate the form + if ( + !this._customTopMargin.checkValidity() || + !this._customBottomMargin.checkValidity() || + !this._customLeftMargin.checkValidity() || + !this._customRightMargin.checkValidity() + ) { + window.clearTimeout(this.showErrorTimeoutId); + this.setAllMarginValues(settings); + this.updateMaxValues(); + this.dispatchEvent(new Event("revalidate", { bubbles: true })); + this._marginError.hidden = true; + } + if (settings.margins == "custom") { + // Ensure that we display the custom margin boxes + this.querySelector(".margin-group").hidden = false; + } + this._marginPicker.value = settings.margins; + } + } + + handleEvent(e) { + if (e.target == this._marginPicker) { + let customMargin = e.target.value == "custom"; + this.querySelector(".margin-group").hidden = !customMargin; + if (customMargin) { + // Update the custom margin values to ensure consistency + this.updateCustomMargins(); + return; + } + + this.dispatchSettingsChange({ + margins: e.target.value, + customMargins: null, + }); + } + + if ( + e.target == this._customTopMargin || + e.target == this._customBottomMargin || + e.target == this._customLeftMargin || + e.target == this._customRightMargin + ) { + if (e.target.checkValidity()) { + this.updateMaxValues(); + } + if ( + this._customTopMargin.validity.valid && + this._customBottomMargin.validity.valid && + this._customLeftMargin.validity.valid && + this._customRightMargin.validity.valid + ) { + this.formatMargin(e.target); + this.updateCustomMargins(); + } else if (e.target.validity.stepMismatch) { + // If this is the third digit after the decimal point, we should + // truncate the string. + this.formatMargin(e.target); + } + } + + window.clearTimeout(this.showErrorTimeoutId); + if ( + this._customTopMargin.validity.valid && + this._customBottomMargin.validity.valid && + this._customLeftMargin.validity.valid && + this._customRightMargin.validity.valid + ) { + this._marginError.hidden = true; + } else { + this.cancelSettingsChange({ customMargins: true, margins: true }); + this.showErrorTimeoutId = window.setTimeout(() => { + this._marginError.hidden = false; + }, INPUT_DELAY_MS); + } + } +} +customElements.define("margins-select", MarginsPicker); + +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); + let shouldOpen = Services.prefs.getBoolPref( + "print.more-settings.open", + false + ); + this.closest("details").open = shouldOpen; + this.updateSummary(shouldOpen); + } + + handleEvent(e) { + let willOpen = !this.isOpen; + Services.prefs.setBoolPref("print.more-settings.open", willOpen); + this.updateSummary(willOpen); + } + + updateSummary(open) { + 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.duplex = settings.duplex; + this.outputDestination = settings.outputDestination; + this.render(); + } + + render() { + if (!this.numCopies || !this.sheetCount) { + return; + } + + let sheetCount = this.sheetCount; + + // When printing to a printer (not to a file) update + // the sheet count to account for duplex printing. + if ( + this.outputDestination == Ci.nsIPrintSettings.kOutputDestinationPrinter && + this.duplex != Ci.nsIPrintSettings.kDuplexNone + ) { + sheetCount = Math.ceil(sheetCount / 2); + } + + sheetCount *= this.numCopies; + + document.l10n.setAttributes(this, "printui-sheets-count", { + sheetCount, + }); + + // The loading attribute must be removed on first render + if (this.hasAttribute("loading")) { + this.removeAttribute("loading"); + } + + if (this.id) { + // We're showing the sheet count, so let it describe the dialog. + document.body.setAttribute("aria-describedby", this.id); + } + } + + handleEvent(e) { + this.sheetCount = e.detail.sheetCount; + this.render(); + } +} +customElements.define("page-count", PageCount); + +class PrintBackgrounds extends PrintSettingCheckbox { + update(settings) { + super.update(settings); + let isSimplified = settings.sourceVersion == "simplified"; + this.disabled = isSimplified; + this.toggleAttribute("disallowed", isSimplified); + this.checked = !isSimplified && settings.printBackgrounds; + } +} +customElements.define("print-backgrounds", PrintBackgrounds, { + extends: "input", +}); + +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" }); + +class CancelButton extends HTMLButtonElement { + constructor() { + super(); + this.addEventListener("click", () => { + this.dispatchEvent(new Event("cancel-print", { bubbles: true })); + }); + } +} +customElements.define("cancel-button", CancelButton, { extends: "button" }); + +async function pickFileName(contentTitle, currentURI) { + 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 (contentTitle != "") { + filename = contentTitle; + } else { + let url = new URL(currentURI); + let path = decodeURIComponent(url.pathname); + path = path.replace(/\/$/, ""); + filename = path.split("/").pop(); + if (filename == "") { + filename = url.hostname; + } + } + if (!filename.endsWith(".pdf")) { + // macOS and linux don't set the extension based on the default extension. + // Windows won't add the extension a second time, fortunately. + // If it already ends with .pdf though, adding it again isn't needed. + filename += ".pdf"; + } + 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(); + + // Remove the file to reduce the likelihood of the user opening an empty or damaged fle when the + // preview is loading + await IOUtils.remove(picker.file.path); + } catch (e) { + throw new Error({ reason: retval == 0 ? "not_saved" : "not_replaced" }); + } + } + + return picker.file.path; +} diff --git a/toolkit/components/printing/content/printPageSetup.js b/toolkit/components/printing/content/printPageSetup.js new file mode 100644 index 0000000000..91f5786243 --- /dev/null +++ b/toolkit/components/printing/content/printPageSetup.js @@ -0,0 +1,538 @@ +// -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + +/* 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/. */ + +var gDialog; +var paramBlock; +var gPrintService = null; +var gPrintSettings = null; +var gStringBundle = null; +var gDoingMetric = false; + +var gPrintSettingsInterface = Ci.nsIPrintSettings; +var gDoDebug = false; + +// --------------------------------------------------- +function initDialog() { + gDialog = {}; + + gDialog.orientation = document.getElementById("orientation"); + gDialog.portrait = document.getElementById("portrait"); + gDialog.landscape = document.getElementById("landscape"); + + gDialog.printBG = document.getElementById("printBG"); + + gDialog.shrinkToFit = document.getElementById("shrinkToFit"); + + gDialog.marginGroup = document.getElementById("marginGroup"); + + gDialog.marginPage = document.getElementById("marginPage"); + gDialog.marginTop = document.getElementById("marginTop"); + gDialog.marginBottom = document.getElementById("marginBottom"); + gDialog.marginLeft = document.getElementById("marginLeft"); + gDialog.marginRight = document.getElementById("marginRight"); + + gDialog.topInput = document.getElementById("topInput"); + gDialog.bottomInput = document.getElementById("bottomInput"); + gDialog.leftInput = document.getElementById("leftInput"); + gDialog.rightInput = document.getElementById("rightInput"); + + gDialog.hLeftOption = document.getElementById("hLeftOption"); + gDialog.hCenterOption = document.getElementById("hCenterOption"); + gDialog.hRightOption = document.getElementById("hRightOption"); + + gDialog.fLeftOption = document.getElementById("fLeftOption"); + gDialog.fCenterOption = document.getElementById("fCenterOption"); + gDialog.fRightOption = document.getElementById("fRightOption"); + + gDialog.scalingLabel = document.getElementById("scalingInput"); + gDialog.scalingInput = document.getElementById("scalingInput"); + + gDialog.enabled = false; + + document.addEventListener("dialogaccept", onAccept); +} + +// --------------------------------------------------- +function isListOfPrinterFeaturesAvailable() { + return Services.prefs.getBoolPref( + "print.tmp.printerfeatures." + + gPrintSettings.printerName + + ".has_special_printerfeatures", + false + ); +} + +// --------------------------------------------------- +function checkDouble(element) { + element.value = element.value.replace(/[^.0-9]/g, ""); +} + +// Theoretical paper width/height. +var gPageWidth = 8.5; +var gPageHeight = 11.0; + +// --------------------------------------------------- +function setOrientation() { + var selection = gDialog.orientation.selectedItem; + + var style = "background-color:white;"; + if ( + (selection == gDialog.portrait && gPageWidth > gPageHeight) || + (selection == gDialog.landscape && gPageWidth < gPageHeight) + ) { + // Swap width/height. + var temp = gPageHeight; + gPageHeight = gPageWidth; + gPageWidth = temp; + } + var div = gDoingMetric ? 100 : 10; + style += + "width:" + + gPageWidth / div + + unitString() + + ";height:" + + gPageHeight / div + + unitString() + + ";"; + gDialog.marginPage.setAttribute("style", style); +} + +// --------------------------------------------------- +function unitString() { + return gPrintSettings.paperSizeUnit == + gPrintSettingsInterface.kPaperSizeInches + ? "in" + : "mm"; +} + +// --------------------------------------------------- +function checkMargin(value, max, other) { + // Don't draw this margin bigger than permitted. + return Math.min(value, max - other.value); +} + +// --------------------------------------------------- +function changeMargin(node) { + // Correct invalid input. + checkDouble(node); + + // Reset the margin height/width for this node. + var val = node.value; + var nodeToStyle; + var attr = "width"; + if (node == gDialog.topInput) { + nodeToStyle = gDialog.marginTop; + val = checkMargin(val, gPageHeight, gDialog.bottomInput); + attr = "height"; + } else if (node == gDialog.bottomInput) { + nodeToStyle = gDialog.marginBottom; + val = checkMargin(val, gPageHeight, gDialog.topInput); + attr = "height"; + } else if (node == gDialog.leftInput) { + nodeToStyle = gDialog.marginLeft; + val = checkMargin(val, gPageWidth, gDialog.rightInput); + } else { + nodeToStyle = gDialog.marginRight; + val = checkMargin(val, gPageWidth, gDialog.leftInput); + } + var style = attr + ":" + val / 10 + unitString() + ";"; + nodeToStyle.setAttribute("style", style); +} + +// --------------------------------------------------- +function changeMargins() { + changeMargin(gDialog.topInput); + changeMargin(gDialog.bottomInput); + changeMargin(gDialog.leftInput); + changeMargin(gDialog.rightInput); +} + +// --------------------------------------------------- +async function customize(node) { + // If selection is now "Custom..." then prompt user for custom setting. + if (node.value == 6) { + let [title, promptText] = await document.l10n.formatValues([ + { id: "custom-prompt-title" }, + { id: "custom-prompt-prompt" }, + ]); + var result = { value: node.custom }; + var ok = Services.prompt.prompt(window, title, promptText, result, null, { + value: false, + }); + if (ok) { + node.custom = result.value; + } + } +} + +// --------------------------------------------------- +function setHeaderFooter(node, value) { + node.value = hfValueToId(value); + if (node.value == 6) { + // Remember current Custom... value. + node.custom = value; + } else { + // Start with empty Custom... value. + node.custom = ""; + } +} + +var gHFValues = []; +gHFValues["&T"] = 1; +gHFValues["&U"] = 2; +gHFValues["&D"] = 3; +gHFValues["&P"] = 4; +gHFValues["&PT"] = 5; + +function hfValueToId(val) { + if (val in gHFValues) { + return gHFValues[val]; + } + if (val.length) { + return 6; // Custom... + } + return 0; // --blank-- +} + +function hfIdToValue(node) { + var result = ""; + switch (parseInt(node.value)) { + case 0: + break; + case 1: + result = "&T"; + break; + case 2: + result = "&U"; + break; + case 3: + result = "&D"; + break; + case 4: + result = "&P"; + break; + case 5: + result = "&PT"; + break; + case 6: + result = node.custom; + break; + } + return result; +} + +async function lastUsedPrinterNameOrDefault() { + let printerList = Cc["@mozilla.org/gfx/printerlist;1"].getService( + Ci.nsIPrinterList + ); + let lastUsedName = gPrintService.lastUsedPrinterName; + let printers = await printerList.printers; + for (let printer of printers) { + printer.QueryInterface(Ci.nsIPrinter); + if (printer.name == lastUsedName) { + return lastUsedName; + } + } + return printerList.systemDefaultPrinterName; +} + +async function setPrinterDefaultsForSelectedPrinter() { + if (gPrintSettings.printerName == "") { + gPrintSettings.printerName = await lastUsedPrinterNameOrDefault(); + } + + // First get any defaults from the printer + gPrintService.initPrintSettingsFromPrinter( + gPrintSettings.printerName, + gPrintSettings + ); + + // now augment them with any values from last time + gPrintService.initPrintSettingsFromPrefs( + gPrintSettings, + true, + gPrintSettingsInterface.kInitSaveAll + ); + + if (gDoDebug) { + dump( + "pagesetup/setPrinterDefaultsForSelectedPrinter: printerName='" + + gPrintSettings.printerName + + "', orientation='" + + gPrintSettings.orientation + + "'\n" + ); + } +} + +// --------------------------------------------------- +async function loadDialog() { + var print_orientation = 0; + var print_margin_top = 0.5; + var print_margin_left = 0.5; + var print_margin_bottom = 0.5; + var print_margin_right = 0.5; + + try { + gPrintService = Cc["@mozilla.org/gfx/printsettings-service;1"]; + if (gPrintService) { + gPrintService = gPrintService.getService(); + if (gPrintService) { + gPrintService = gPrintService.QueryInterface( + Ci.nsIPrintSettingsService + ); + } + } + } catch (ex) { + dump("loadDialog: ex=" + ex + "\n"); + } + + await setPrinterDefaultsForSelectedPrinter(); + + gDialog.printBG.checked = + gPrintSettings.printBGColors || gPrintSettings.printBGImages; + + gDialog.shrinkToFit.checked = gPrintSettings.shrinkToFit; + + gDialog.scalingLabel.disabled = gDialog.scalingInput.disabled = + gDialog.shrinkToFit.checked; + + if ( + gPrintSettings.paperSizeUnit == gPrintSettingsInterface.kPaperSizeInches + ) { + document.l10n.setAttributes( + gDialog.marginGroup, + "margin-group-label-inches" + ); + gDoingMetric = false; + } else { + document.l10n.setAttributes( + gDialog.marginGroup, + "margin-group-label-metric" + ); + // Also, set global page dimensions for A4 paper, in millimeters (assumes portrait at this point). + gPageWidth = 2100; + gPageHeight = 2970; + gDoingMetric = true; + } + + print_orientation = gPrintSettings.orientation; + print_margin_top = convertMarginInchesToUnits( + gPrintSettings.marginTop, + gDoingMetric + ); + print_margin_left = convertMarginInchesToUnits( + gPrintSettings.marginLeft, + gDoingMetric + ); + print_margin_right = convertMarginInchesToUnits( + gPrintSettings.marginRight, + gDoingMetric + ); + print_margin_bottom = convertMarginInchesToUnits( + gPrintSettings.marginBottom, + gDoingMetric + ); + + if (gDoDebug) { + dump("print_orientation " + print_orientation + "\n"); + + dump("print_margin_top " + print_margin_top + "\n"); + dump("print_margin_left " + print_margin_left + "\n"); + dump("print_margin_right " + print_margin_right + "\n"); + dump("print_margin_bottom " + print_margin_bottom + "\n"); + } + + if (print_orientation == gPrintSettingsInterface.kPortraitOrientation) { + gDialog.orientation.selectedItem = gDialog.portrait; + } else if ( + print_orientation == gPrintSettingsInterface.kLandscapeOrientation + ) { + gDialog.orientation.selectedItem = gDialog.landscape; + } + + // Set orientation the first time on a timeout so the dialog sizes to the + // maximum height specified in the .xul file. Otherwise, if the user switches + // from landscape to portrait, the content grows and the buttons are clipped. + setTimeout(setOrientation, 0); + + gDialog.topInput.value = print_margin_top.toFixed(1); + gDialog.bottomInput.value = print_margin_bottom.toFixed(1); + gDialog.leftInput.value = print_margin_left.toFixed(1); + gDialog.rightInput.value = print_margin_right.toFixed(1); + changeMargins(); + + setHeaderFooter(gDialog.hLeftOption, gPrintSettings.headerStrLeft); + setHeaderFooter(gDialog.hCenterOption, gPrintSettings.headerStrCenter); + setHeaderFooter(gDialog.hRightOption, gPrintSettings.headerStrRight); + + setHeaderFooter(gDialog.fLeftOption, gPrintSettings.footerStrLeft); + setHeaderFooter(gDialog.fCenterOption, gPrintSettings.footerStrCenter); + setHeaderFooter(gDialog.fRightOption, gPrintSettings.footerStrRight); + + gDialog.scalingInput.value = (gPrintSettings.scaling * 100).toFixed(0); + + // Enable/disable widgets based in the information whether the selected + // printer supports the matching feature or not + if (isListOfPrinterFeaturesAvailable()) { + if ( + Services.prefs.getBoolPref( + "print.tmp.printerfeatures." + + gPrintSettings.printerName + + ".can_change_orientation" + ) + ) { + gDialog.orientation.removeAttribute("disabled"); + } else { + gDialog.orientation.setAttribute("disabled", "true"); + } + } + + // Give initial focus to the orientation radio group. + // Done on a timeout due to to bug 103197. + setTimeout(function () { + gDialog.orientation.focus(); + }, 0); +} + +// --------------------------------------------------- +function onLoad() { + // Init gDialog. + initDialog(); + + if (window.arguments[0] != null) { + gPrintSettings = window.arguments[0].QueryInterface(Ci.nsIPrintSettings); + paramBlock = window.arguments[1].QueryInterface(Ci.nsIDialogParamBlock); + } else if (gDoDebug) { + alert("window.arguments[0] == null!"); + } + + // default return value is "cancel" + paramBlock.SetInt(0, 0); + + if (gPrintSettings) { + loadDialog(); + } else if (gDoDebug) { + alert("Could initialize gDialog, PrintSettings is null!"); + } +} + +function convertUnitsMarginToInches(aVal, aIsMetric) { + if (aIsMetric) { + return aVal / 25.4; + } + return aVal; +} + +function convertMarginInchesToUnits(aVal, aIsMetric) { + if (aIsMetric) { + return aVal * 25.4; + } + return aVal; +} + +// --------------------------------------------------- +function onAccept() { + if (gPrintSettings) { + if (gDialog.orientation.selectedItem == gDialog.portrait) { + gPrintSettings.orientation = gPrintSettingsInterface.kPortraitOrientation; + } else { + gPrintSettings.orientation = + gPrintSettingsInterface.kLandscapeOrientation; + } + + // save these out so they can be picked up by the device spec + gPrintSettings.marginTop = convertUnitsMarginToInches( + gDialog.topInput.value, + gDoingMetric + ); + gPrintSettings.marginLeft = convertUnitsMarginToInches( + gDialog.leftInput.value, + gDoingMetric + ); + gPrintSettings.marginBottom = convertUnitsMarginToInches( + gDialog.bottomInput.value, + gDoingMetric + ); + gPrintSettings.marginRight = convertUnitsMarginToInches( + gDialog.rightInput.value, + gDoingMetric + ); + + gPrintSettings.headerStrLeft = hfIdToValue(gDialog.hLeftOption); + gPrintSettings.headerStrCenter = hfIdToValue(gDialog.hCenterOption); + gPrintSettings.headerStrRight = hfIdToValue(gDialog.hRightOption); + + gPrintSettings.footerStrLeft = hfIdToValue(gDialog.fLeftOption); + gPrintSettings.footerStrCenter = hfIdToValue(gDialog.fCenterOption); + gPrintSettings.footerStrRight = hfIdToValue(gDialog.fRightOption); + + gPrintSettings.printBGColors = gDialog.printBG.checked; + gPrintSettings.printBGImages = gDialog.printBG.checked; + + gPrintSettings.shrinkToFit = gDialog.shrinkToFit.checked; + + var scaling = document.getElementById("scalingInput").value; + if (scaling < 10.0) { + scaling = 10.0; + } + if (scaling > 500.0) { + scaling = 500.0; + } + scaling /= 100.0; + gPrintSettings.scaling = scaling; + + if (gDoDebug) { + dump("******* Page Setup Accepting ******\n"); + dump("print_margin_top " + gDialog.topInput.value + "\n"); + dump("print_margin_left " + gDialog.leftInput.value + "\n"); + dump("print_margin_right " + gDialog.bottomInput.value + "\n"); + dump("print_margin_bottom " + gDialog.rightInput.value + "\n"); + } + } + + // set return value to "ok" + if (paramBlock) { + paramBlock.SetInt(0, 1); + } else { + dump("*** FATAL ERROR: No paramBlock\n"); + } + + var flags = + gPrintSettingsInterface.kInitSaveMargins | + gPrintSettingsInterface.kInitSaveHeaderLeft | + gPrintSettingsInterface.kInitSaveHeaderCenter | + gPrintSettingsInterface.kInitSaveHeaderRight | + gPrintSettingsInterface.kInitSaveFooterLeft | + gPrintSettingsInterface.kInitSaveFooterCenter | + gPrintSettingsInterface.kInitSaveFooterRight | + gPrintSettingsInterface.kInitSaveBGColors | + gPrintSettingsInterface.kInitSaveBGImages | + gPrintSettingsInterface.kInitSaveInColor | + gPrintSettingsInterface.kInitSaveReversed | + gPrintSettingsInterface.kInitSaveOrientation | + gPrintSettingsInterface.kInitSaveShrinkToFit | + gPrintSettingsInterface.kInitSaveScaling; + + // Note that this file is Windows only code, so this doesn't handle saving + // for other platforms. + // XXX Should we do this in nsPrintDialogServiceWin::ShowPageSetup (the code + // that invokes us), since ShowPageSetup is where we do the saving for the + // other platforms? + gPrintService.maybeSavePrintSettingsToPrefs(gPrintSettings, flags); +} + +// --------------------------------------------------- +function onCancel() { + // set return value to "cancel" + if (paramBlock) { + paramBlock.SetInt(0, 0); + } else { + dump("*** FATAL ERROR: No paramBlock\n"); + } + + return true; +} diff --git a/toolkit/components/printing/content/printPageSetup.xhtml b/toolkit/components/printing/content/printPageSetup.xhtml new file mode 100644 index 0000000000..235e8a474b --- /dev/null +++ b/toolkit/components/printing/content/printPageSetup.xhtml @@ -0,0 +1,291 @@ +<?xml version="1.0"?> +<!-- -*- Mode: HTML -*- --> + +<!-- 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/. --> + +<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?> +<?xml-stylesheet href="chrome://global/skin/in-content/common.css"?> +<?xml-stylesheet href="chrome://global/skin/printPageSetup.css" type="text/css"?> + +<window + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + onload="onLoad();" + oncancel="return onCancel();" + data-l10n-id="print-setup" + dialogroot="true" + persist="screenX screenY" + screenX="24" + screenY="24" +> + <dialog id="printPageSetupDialog"> + <linkset> + <html:link rel="localization" href="toolkit/printing/printDialogs.ftl" /> + </linkset> + + <script src="chrome://global/content/globalOverlay.js" /> + <script src="chrome://global/content/editMenuOverlay.js" /> + <script src="chrome://global/content/printPageSetup.js" /> + + <tabbox flex="1"> + <tabs> + <tab data-l10n-id="basic-tab" /> + <tab data-l10n-id="advanced-tab" /> + </tabs> + <tabpanels flex="1"> + <vbox> + <html:fieldset> + <html:legend + ><label data-l10n-id="format-group-label" + /></html:legend> + <vbox class="groupbox-body"> + <hbox align="center"> + <label control="orientation" data-l10n-id="orientation-label" /> + <radiogroup id="orientation" oncommand="setOrientation()"> + <hbox align="center"> + <radio + id="portrait" + class="portrait-page" + data-l10n-id="portrait" + /> + <radio + id="landscape" + class="landscape-page" + data-l10n-id="landscape" + /> + </hbox> + </radiogroup> + </hbox> + <separator /> + <hbox align="center"> + <label control="scalingInput" data-l10n-id="scale" /> + <html:input + id="scalingInput" + size="4" + oninput="checkDouble(this)" + /> + <label data-l10n-id="scale-percent" /> + <separator /> + <checkbox + id="shrinkToFit" + data-l10n-id="shrink-to-fit" + oncommand="gDialog.scalingInput.disabled = gDialog.scalingLabel.disabled = this.checked" + /> + </hbox> + </vbox> + </html:fieldset> + <html:fieldset> + <html:legend + ><label data-l10n-id="options-group-label" + /></html:legend> + <checkbox + id="printBG" + class="groupbox-body" + data-l10n-id="print-bg" + /> + </html:fieldset> + </vbox> + <vbox> + <html:fieldset> + <html:legend + ><label id="marginGroup" data-l10n-id="margin-group-label" + /></html:legend> + <vbox class="groupbox-body"> + <hbox align="center"> + <spacer flex="1" /> + <label control="topInput" data-l10n-id="margin-top" /> + <html:input + id="topInput" + size="5" + oninput="changeMargin(this)" + /> + <!-- This invisible label (with same content as the visible one!) is used + to ensure that the <input> is centered above the page. The same + technique is deployed for the bottom/left/right input fields, below. --> + <label + data-l10n-id="margin-top-invisible" + style="visibility: hidden" + /> + <spacer flex="1" /> + </hbox> + <hbox dir="ltr"> + <spacer flex="1" /> + <vbox> + <spacer flex="1" /> + <label control="leftInput" data-l10n-id="margin-left" /> + <html:input + id="leftInput" + size="5" + oninput="changeMargin(this)" + /> + <label + data-l10n-id="margin-left-invisible" + style="visibility: hidden" + /> + <spacer flex="1" /> + </vbox> + <!-- The "margin page" draws a simulated printout page with dashed lines + for the margins. The height/width style attributes of the marginTop, + marginBottom, marginLeft, and marginRight elements are set by + the JS code dynamically based on the user input. --> + <vbox id="marginPage" style="height: 29.7mm"> + <box id="marginTop" style="height: 0.05in" /> + <hbox flex="1" dir="ltr"> + <box id="marginLeft" style="width: 0.025in" /> + <box + style=" + border: 1px; + border-style: dashed; + border-color: gray; + " + flex="1" + /> + <box id="marginRight" style="width: 0.025in" /> + </hbox> + <box id="marginBottom" style="height: 0.05in" /> + </vbox> + <vbox> + <spacer flex="1" /> + <label control="rightInput" data-l10n-id="margin-right" /> + <html:input + id="rightInput" + size="5" + oninput="changeMargin(this)" + /> + <label + data-l10n-id="margin-right-invisible" + style="visibility: hidden" + /> + <spacer flex="1" /> + </vbox> + <spacer flex="1" /> + </hbox> + <hbox align="center"> + <spacer flex="1" /> + <label control="bottomInput" data-l10n-id="margin-bottom" /> + <html:input + id="bottomInput" + size="5" + oninput="changeMargin(this)" + /> + <label + data-l10n-id="margin-bottom-invisible" + style="visibility: hidden" + /> + <spacer flex="1" /> + </hbox> + </vbox> + </html:fieldset> + <html:fieldset> + <html:legend + ><label data-l10n-id="header-footer-label" + /></html:legend> + <box id="header-footer-grid" class="groupbox-body" dir="ltr"> + <menulist + id="hLeftOption" + oncommand="customize(this)" + data-l10n-id="header-left-tip" + > + <menupopup> + <menuitem value="0" data-l10n-id="hf-blank" /> + <menuitem value="1" data-l10n-id="hf-title" /> + <menuitem value="2" data-l10n-id="hf-url" /> + <menuitem value="3" data-l10n-id="hf-date-and-time" /> + <menuitem value="4" data-l10n-id="hf-page" /> + <menuitem value="5" data-l10n-id="hf-page-and-total" /> + <menuitem value="6" data-l10n-id="hf-custom" /> + </menupopup> + </menulist> + <menulist + id="hCenterOption" + oncommand="customize(this)" + data-l10n-id="header-center-tip" + > + <menupopup> + <menuitem value="0" data-l10n-id="hf-blank" /> + <menuitem value="1" data-l10n-id="hf-title" /> + <menuitem value="2" data-l10n-id="hf-url" /> + <menuitem value="3" data-l10n-id="hf-date-and-time" /> + <menuitem value="4" data-l10n-id="hf-page" /> + <menuitem value="5" data-l10n-id="hf-page-and-total" /> + <menuitem value="6" data-l10n-id="hf-custom" /> + </menupopup> + </menulist> + <menulist + id="hRightOption" + oncommand="customize(this)" + data-l10n-id="header-right-tip" + > + <menupopup> + <menuitem value="0" data-l10n-id="hf-blank" /> + <menuitem value="1" data-l10n-id="hf-title" /> + <menuitem value="2" data-l10n-id="hf-url" /> + <menuitem value="3" data-l10n-id="hf-date-and-time" /> + <menuitem value="4" data-l10n-id="hf-page" /> + <menuitem value="5" data-l10n-id="hf-page-and-total" /> + <menuitem value="6" data-l10n-id="hf-custom" /> + </menupopup> + </menulist> + <vbox align="center"> + <label data-l10n-id="hf-left-label" /> + </vbox> + <vbox align="center"> + <label data-l10n-id="hf-center-label" /> + </vbox> + <vbox align="center"> + <label data-l10n-id="hf-right-label" /> + </vbox> + <menulist + id="fLeftOption" + oncommand="customize(this)" + data-l10n-id="footer-left-tip" + > + <menupopup> + <menuitem value="0" data-l10n-id="hf-blank" /> + <menuitem value="1" data-l10n-id="hf-title" /> + <menuitem value="2" data-l10n-id="hf-url" /> + <menuitem value="3" data-l10n-id="hf-date-and-time" /> + <menuitem value="4" data-l10n-id="hf-page" /> + <menuitem value="5" data-l10n-id="hf-page-and-total" /> + <menuitem value="6" data-l10n-id="hf-custom" /> + </menupopup> + </menulist> + <menulist + id="fCenterOption" + oncommand="customize(this)" + data-l10n-id="footer-center-tip" + > + <menupopup> + <menuitem value="0" data-l10n-id="hf-blank" /> + <menuitem value="1" data-l10n-id="hf-title" /> + <menuitem value="2" data-l10n-id="hf-url" /> + <menuitem value="3" data-l10n-id="hf-date-and-time" /> + <menuitem value="4" data-l10n-id="hf-page" /> + <menuitem value="5" data-l10n-id="hf-page-and-total" /> + <menuitem value="6" data-l10n-id="hf-custom" /> + </menupopup> + </menulist> + <menulist + id="fRightOption" + oncommand="customize(this)" + data-l10n-id="footer-right-tip" + > + <menupopup> + <menuitem value="0" data-l10n-id="hf-blank" /> + <menuitem value="1" data-l10n-id="hf-title" /> + <menuitem value="2" data-l10n-id="hf-url" /> + <menuitem value="3" data-l10n-id="hf-date-and-time" /> + <menuitem value="4" data-l10n-id="hf-page" /> + <menuitem value="5" data-l10n-id="hf-page-and-total" /> + <menuitem value="6" data-l10n-id="hf-custom" /> + </menupopup> + </menulist> + </box> + </html:fieldset> + </vbox> + </tabpanels> + </tabbox> + </dialog> +</window> diff --git a/toolkit/components/printing/content/printPagination.css b/toolkit/components/printing/content/printPagination.css new file mode 100644 index 0000000000..5eb7b46924 --- /dev/null +++ b/toolkit/components/printing/content/printPagination.css @@ -0,0 +1,141 @@ +/* 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/. */ + +:host { + /* in-content/common.css variables */ + --blue-50: #0a84ff; + --grey-90-a10: rgba(12, 12, 13, 0.1); + --shadow-30: 0 4px 16px var(--grey-90-a10); + --border-active-shadow: var(--blue-50); +} + +:host { + display: block; + position: absolute; + bottom: 24px; + inset-inline-start: 50%; + translate: -50%; +} +:host(:-moz-locale-dir(rtl)) { + translate: 50%; +} + +.container { + margin-inline: auto; + align-items: center; + display: flex; + justify-content: center; + box-shadow: var(--shadow-30); + color: var(--toolbar-color); + background-color: var(--toolbar-bgcolor); + border-radius: 6px; + border-style: none; +} +.container::before { + content: ""; + display: block; + position: absolute; + inset: 0; + z-index: -1; + background-color: ButtonFace; + border-radius: 6px; +} + +.toolbarButton, +.toolbarCenter { + align-self: stretch; + flex: 0 0 auto; + padding: var(--toolbarbutton-outer-padding); + border: none; + border-inline-end: 1px solid ThreeDShadow; + border-block: 1px solid ThreeDShadow; + color: inherit; + background-color: transparent; + min-width: calc(2 * var(--toolbarbutton-inner-padding) + 16px); + min-height: calc(2 * var(--toolbarbutton-inner-padding) + 16px); +} +.startItem { + border-inline-start: 1px solid ThreeDShadow; + border-start-start-radius: 6px; + border-end-start-radius: 6px; +} +.endItem { + border-start-end-radius: 6px; + border-end-end-radius: 6px; +} + +.toolbarButton::after { + content: ""; + display: inline-block; + width: 16px; + height: 16px; + vertical-align: text-bottom; + text-align: center; + background-repeat: no-repeat; + background-position: center center; + background-size: 12px; + -moz-context-properties: fill, fill-opacity; + fill: var(--toolbarbutton-icon-fill); +} + +.toolbarButton:hover { + background-color: var(--toolbarbutton-hover-background); +} +.toolbarButton:hover:active { + background-color: var(--toolbarbutton-active-background); +} +.toolbarButton::-moz-focus-inner { + border: none; +} +.toolbarButton:focus { + z-index: 1; +} + +.toolbarButton:-moz-focusring { + outline: 2px solid var(--border-active-shadow); +} + +.toolbarCenter { + flex-shrink: 0; + /* 3 chars + (3px border + 1px padding) on both sides */ + min-width: calc(8px + 3ch); + padding: 0 32px; + display: flex; + align-items: center; + justify-content: center; +} + +#navigateHome::after, +#navigateEnd::after { + background-image: url("chrome://global/skin/icons/chevron.svg"); +} + +#navigatePrevious::after, +#navigateNext::after { + background-image: url("chrome://global/skin/icons/arrow-left.svg"); +} + +#navigatePrevious:-moz-locale-dir(rtl)::after, +#navigateNext:-moz-locale-dir(ltr)::after { + background-image: url("chrome://global/skin/icons/arrow-right.svg"); +} + +#navigateEnd:-moz-locale-dir(rtl)::after, +#navigateHome:-moz-locale-dir(ltr)::after { + transform: scaleX(-1); +} + +/* progressively hide the navigation buttons when the print preview is too narrow to fit */ +@media (max-width: 550px) { + #navigatePrevious, + #navigateNext, + #navigateEnd, + #navigateHome { + display: none; + } + .toolbarCenter { + border-inline-start: 1px solid ThreeDShadow; + border-radius: 6px; + } +} diff --git a/toolkit/components/printing/content/printPreviewPagination.js b/toolkit/components/printing/content/printPreviewPagination.js new file mode 100644 index 0000000000..c503f0a8cb --- /dev/null +++ b/toolkit/components/printing/content/printPreviewPagination.js @@ -0,0 +1,185 @@ +// This file is loaded into the browser window scope. +/* eslint-env mozilla/browser-window */ + +// -*- tab-width: 2; indent-tabs-mode: nil; js-indent-level: 2 -*- + +/* 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/. */ + +customElements.define( + "printpreview-pagination", + class PrintPreviewPagination extends HTMLElement { + static get markup() { + return ` + <html:link rel="stylesheet" href="chrome://global/content/printPagination.css" /> + <html:div class="container"> + <html:button id="navigateHome" class="toolbarButton startItem" data-l10n-id="printpreview-homearrow-button"></html:button> + <html:button id="navigatePrevious" class="toolbarButton" data-l10n-id="printpreview-previousarrow-button"></html:button> + <html:div class="toolbarCenter"><html:span id="sheetIndicator" data-l10n-id="printpreview-sheet-of-sheets" data-l10n-args='{ "sheetNum": 1, "sheetCount": 1 }'></html:span></html:div> + <html:button id="navigateNext" class="toolbarButton" data-l10n-id="printpreview-nextarrow-button"></html:button> + <html:button id="navigateEnd" class="toolbarButton endItem" data-l10n-id="printpreview-endarrow-button"></html:button> + </html:div> + `; + } + + static get defaultProperties() { + return { + currentPage: 1, + sheetCount: 1, + }; + } + + get previewBrowser() { + return this._previewBrowser; + } + + set previewBrowser(aBrowser) { + this._previewBrowser = aBrowser; + } + + observePreviewBrowser(browser) { + if (browser == this.previewBrowser || !this.isConnected) { + return; + } + this.previewBrowser = browser; + this.mutationObserver.disconnect(); + this.mutationObserver.observe(browser, { + attributes: ["current-page", "sheet-count"], + }); + this.updateFromBrowser(); + } + + connectedCallback() { + MozXULElement.insertFTLIfNeeded("toolkit/printing/printPreview.ftl"); + + const shadowRoot = this.attachShadow({ mode: "open" }); + document.l10n.connectRoot(shadowRoot); + + let fragment = MozXULElement.parseXULToFragment(this.constructor.markup); + this.shadowRoot.append(fragment); + + this.elements = { + sheetIndicator: shadowRoot.querySelector("#sheetIndicator"), + homeButton: shadowRoot.querySelector("#navigateHome"), + previousButton: shadowRoot.querySelector("#navigatePrevious"), + nextButton: shadowRoot.querySelector("#navigateNext"), + endButton: shadowRoot.querySelector("#navigateEnd"), + }; + + this.shadowRoot.addEventListener("click", this); + + this.mutationObserver = new MutationObserver(() => + this.updateFromBrowser() + ); + + // Initial render with some default values + // We'll be updated with real values when available + this.update(this.constructor.defaultProperties); + } + + disconnectedCallback() { + document.l10n.disconnectRoot(this.shadowRoot); + this.shadowRoot.textContent = ""; + this.mutationObserver?.disconnect(); + delete this.mutationObserver; + this.currentPreviewBrowserObserver?.disconnect(); + delete this.currentPreviewBrowserObserver; + } + + handleEvent(event) { + if (event.type == "click" && event.button != 0) { + return; + } + event.stopPropagation(); + + switch (event.target) { + case this.elements.homeButton: + this.navigate(0, 0, "home"); + break; + case this.elements.previousButton: + this.navigate(-1, 0, 0); + break; + case this.elements.nextButton: + this.navigate(1, 0, 0); + break; + case this.elements.endButton: + this.navigate(0, 0, "end"); + break; + } + } + + navigate(aDirection, aPageNum, aHomeOrEnd) { + const nsIWebBrowserPrint = Ci.nsIWebBrowserPrint; + let targetNum; + let navType; + // we use only one of aHomeOrEnd, aDirection, or aPageNum + if (aHomeOrEnd) { + // We're going to either the very first page ("home"), or the + // very last page ("end"). + if (aHomeOrEnd == "home") { + targetNum = 1; + navType = nsIWebBrowserPrint.PRINTPREVIEW_HOME; + } else { + targetNum = this.sheetCount; + navType = nsIWebBrowserPrint.PRINTPREVIEW_END; + } + } else if (aPageNum) { + // We're going to a specific page (aPageNum) + targetNum = Math.min(Math.max(1, aPageNum), this.sheetCount); + navType = nsIWebBrowserPrint.PRINTPREVIEW_GOTO_PAGENUM; + } else { + // aDirection is either +1 or -1, and allows us to increment + // or decrement our currently viewed page. + targetNum = Math.min( + Math.max(1, this.currentSheet + aDirection), + this.sheetCount + ); + navType = nsIWebBrowserPrint.PRINTPREVIEW_GOTO_PAGENUM; + } + + // Preemptively update our own state, rather than waiting for the message from the child process + // This allows subsequent clicks of next/back to advance 1 page per click if possible + // and keeps the UI feeling more responsive + this.update({ currentPage: targetNum }); + + this.previewBrowser.sendMessageToActor( + "Printing:Preview:Navigate", + { + navType, + pageNum: targetNum, + }, + "Printing" + ); + } + + update(data = {}) { + if (data.sheetCount) { + this.sheetCount = data.sheetCount; + } + if (data.currentPage) { + this.currentSheet = data.currentPage; + } + document.l10n.setAttributes( + this.elements.sheetIndicator, + this.elements.sheetIndicator.dataset.l10nId, + { + sheetNum: this.currentSheet, + sheetCount: this.sheetCount, + } + ); + } + + updateFromBrowser() { + let sheetCount = parseInt( + this.previewBrowser.getAttribute("sheet-count"), + 10 + ); + let currentPage = parseInt( + this.previewBrowser.getAttribute("current-page"), + 10 + ); + this.update({ sheetCount, currentPage }); + } + } +); diff --git a/toolkit/components/printing/content/printUtils.js b/toolkit/components/printing/content/printUtils.js new file mode 100644 index 0000000000..f1e3489fe7 --- /dev/null +++ b/toolkit/components/printing/content/printUtils.js @@ -0,0 +1,822 @@ +// This file is loaded into the browser window scope. +/* eslint-env mozilla/browser-window */ + +// -*- tab-width: 2; indent-tabs-mode: nil; js-indent-level: 2 -*- + +/* 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/. */ + +/** + * PrintUtils is a utility for front-end code to trigger common print + * operations (printing, show print preview, show page settings). + * + * Unfortunately, likely due to inconsistencies in how different operating + * systems do printing natively, our XPCOM-level printing interfaces + * are a bit confusing and the method by which we do something basic + * like printing a page is quite circuitous. + * + * To compound that, we need to support remote browsers, and that means + * kicking off the print jobs in the content process. This means we send + * messages back and forth to that process via the Printing actor. + * + * This also means that <xul:browser>'s that hope to use PrintUtils must have + * their type attribute set to "content". + * + * Messages sent: + * + * Printing:Preview:Enter + * This message is sent to put content into print preview mode. We pass + * the content window of the browser we're showing the preview of, and + * the target of the message is the browser that we'll be showing the + * preview in. + * + * Printing:Preview:Exit + * This message is sent to take content out of print preview mode. + */ + +XPCOMUtils.defineLazyPreferenceGetter( + this, + "SHOW_PAGE_SETUP_MENU", + "print.show_page_setup_menu", + false +); + +XPCOMUtils.defineLazyPreferenceGetter( + this, + "PRINT_ALWAYS_SILENT", + "print.always_print_silent", + false +); + +XPCOMUtils.defineLazyPreferenceGetter( + this, + "PREFER_SYSTEM_DIALOG", + "print.prefer_system_dialog", + false +); + +ChromeUtils.defineESModuleGetters(this, { + PromptUtils: "resource://gre/modules/PromptUtils.sys.mjs", +}); + +var PrintUtils = { + SAVE_TO_PDF_PRINTER: "Mozilla Save to PDF", + + get _bundle() { + delete this._bundle; + return (this._bundle = Services.strings.createBundle( + "chrome://global/locale/printing.properties" + )); + }, + + async checkForSelection(browsingContext) { + try { + let sourceActor = + browsingContext.currentWindowGlobal.getActor("PrintingSelection"); + // Need the await for the try to trigger... + return await sourceActor.sendQuery("PrintingSelection:HasSelection", {}); + } catch (e) { + console.error(e); + } + return false; + }, + + /** + * Updates the hidden state of the "Page Setup" menu items in the File menu, + * depending on the value of the `print.show_page_setup_menu` pref. + * Note: not all platforms have a "Page Setup" menu item (or Page Setup + * window). + */ + updatePrintSetupMenuHiddenState() { + let pageSetupMenuItem = document.getElementById("menu_printSetup"); + if (pageSetupMenuItem) { + pageSetupMenuItem.hidden = !SHOW_PAGE_SETUP_MENU; + } + }, + + /** + * Shows the page setup dialog, and saves any settings changed in + * that dialog if print.save_print_settings is set to true. + * + * @return true on success, false on failure + */ + showPageSetup() { + let printSettings = this.getPrintSettings(); + // If we come directly from the Page Setup menu, the hack in + // _enterPrintPreview will not have been invoked to set the last used + // printer name. For the reasons outlined at that hack, we want that set + // here too. + let PSSVC = Cc["@mozilla.org/gfx/printsettings-service;1"].getService( + Ci.nsIPrintSettingsService + ); + if (!PSSVC.lastUsedPrinterName) { + if (printSettings.printerName) { + PSSVC.maybeSaveLastUsedPrinterNameToPrefs(printSettings.printerName); + PSSVC.maybeSavePrintSettingsToPrefs( + printSettings, + Ci.nsIPrintSettings.kInitSaveAll + ); + } + } + try { + var PRINTDIALOGSVC = Cc[ + "@mozilla.org/widget/printdialog-service;1" + ].getService(Ci.nsIPrintDialogService); + PRINTDIALOGSVC.showPageSetupDialog(window, printSettings, null); + } catch (e) { + dump("showPageSetup " + e + "\n"); + return false; + } + return true; + }, + + /** + * This call exists in a separate method so it can be easily overridden where + * `gBrowser` doesn't exist (e.g. Thunderbird). + * + * @see getTabDialogBox in tabbrowser.js + */ + getTabDialogBox(sourceBrowser) { + return gBrowser.getTabDialogBox(sourceBrowser); + }, + + getPreviewBrowser(sourceBrowser) { + let dialogBox = this.getTabDialogBox(sourceBrowser); + for (let dialog of dialogBox.getTabDialogManager()._dialogs) { + let browser = dialog._box.querySelector(".printPreviewBrowser"); + if (browser) { + return browser; + } + } + return null; + }, + + /** + * Opens the tab modal version of the print UI for the current tab. + * + * @param aBrowsingContext + * The BrowsingContext of the window to print. + * @param aExistingPreviewBrowser + * An existing browser created for printing from window.print(). + * @param aPrintInitiationTime + * The time the print was initiated (typically by the user) as obtained + * from `Date.now()`. That is, the initiation time as the number of + * milliseconds since January 1, 1970. + * @param aPrintSelectionOnly + * Whether to print only the active selection of the given browsing + * context. + * @param aPrintFrameOnly + * Whether to print the selected frame only + * @return promise resolving when the dialog is open, rejected if the preview + * fails. + */ + _openTabModalPrint( + aBrowsingContext, + aOpenWindowInfo, + aPrintInitiationTime, + aPrintSelectionOnly, + aPrintFrameOnly + ) { + let sourceBrowser = aBrowsingContext.top.embedderElement; + let previewBrowser = this.getPreviewBrowser(sourceBrowser); + if (previewBrowser) { + // Don't open another dialog if we're already printing. + // + // XXX This can be racy can't it? getPreviewBrowser looks at browser that + // we set up after opening the dialog. But I guess worst case we just + // open two dialogs so... + throw new Error("Tab-modal print UI already open"); + } + + // Create the print preview dialog. + let args = PromptUtils.objectToPropBag({ + printSelectionOnly: !!aPrintSelectionOnly, + isArticle: sourceBrowser.isArticle, + printFrameOnly: !!aPrintFrameOnly, + }); + let dialogBox = this.getTabDialogBox(sourceBrowser); + let { closedPromise, dialog } = dialogBox.open( + `chrome://global/content/print.html?printInitiationTime=${aPrintInitiationTime}`, + { features: "resizable=no", sizeTo: "available" }, + args + ); + closedPromise.catch(e => { + console.error(e); + }); + + let settingsBrowser = dialog._frame; + let printPreview = new PrintPreview({ + sourceBrowsingContext: aBrowsingContext, + settingsBrowser, + topBrowsingContext: aBrowsingContext.top, + activeBrowsingContext: aBrowsingContext, + openWindowInfo: aOpenWindowInfo, + printFrameOnly: aPrintFrameOnly, + }); + // This will create the source browser in connectedCallback() if we sent + // openWindowInfo. Otherwise the browser will be null. + settingsBrowser.parentElement.insertBefore(printPreview, settingsBrowser); + return printPreview.sourceBrowser; + }, + + /** + * Initialize a print, this will open the tab modal UI if it is enabled or + * defer to the native dialog/silent print. + * + * @param aBrowsingContext + * The BrowsingContext of the window to print. + * Note that the browsing context could belong to a subframe of the + * tab that called window.print, or similar shenanigans. + * @param aOptions + * {windowDotPrintOpenWindowInfo} + * Non-null if this call comes from window.print(). + * This is the nsIOpenWindowInfo object that has to + * be passed down to createBrowser in order for the + * static clone that has been cretaed in the child + * process to be linked to the browser it creates + * in the parent process. + * {printSelectionOnly} Whether to print only the active selection of + * the given browsing context. + * {printFrameOnly} Whether to print the selected frame. + */ + startPrintWindow(aBrowsingContext, aOptions) { + const printInitiationTime = Date.now(); + + // At most, one of these is set. + let { printSelectionOnly, printFrameOnly, windowDotPrintOpenWindowInfo } = + aOptions || {}; + + if ( + windowDotPrintOpenWindowInfo && + !windowDotPrintOpenWindowInfo.isForWindowDotPrint + ) { + throw new Error("Only expect openWindowInfo for window.print()"); + } + + let browsingContext = aBrowsingContext; + if (printSelectionOnly) { + // Ensure that we use the window with focus/selection if the context menu + // (from which 'Print selection' was selected) happens to have been opened + // over a different frame. + let focusedBc = Services.focus.focusedContentBrowsingContext; + if ( + focusedBc && + focusedBc.top.embedderElement == browsingContext.top.embedderElement + ) { + browsingContext = focusedBc; + } + } + + if (!PRINT_ALWAYS_SILENT && !PREFER_SYSTEM_DIALOG) { + return this._openTabModalPrint( + browsingContext, + windowDotPrintOpenWindowInfo, + printInitiationTime, + printSelectionOnly, + printFrameOnly + ); + } + + const useSystemDialog = PREFER_SYSTEM_DIALOG && !PRINT_ALWAYS_SILENT; + + let browser = null; + if (windowDotPrintOpenWindowInfo) { + // When we're called by handleStaticCloneCreatedForPrint(), we must + // return this browser. + browser = this.createParentBrowserForStaticClone( + browsingContext, + windowDotPrintOpenWindowInfo + ); + browsingContext = browser.browsingContext; + } + + // This code is wrapped in an async function so that we can await the async + // functions that it calls. + async function makePrintSettingsAndInvokePrint() { + let settings = PrintUtils.getPrintSettings( + /*aPrinterName*/ "", + /*aDefaultsOnly*/ false, + /*aAllowPseudoPrinter*/ !useSystemDialog + ); + settings.printSelectionOnly = printSelectionOnly; + if ( + settings.outputDestination == + Ci.nsIPrintSettings.kOutputDestinationFile && + !settings.toFileName + ) { + // TODO(bug 1748004): We should consider generating the file name + // from the document's title as we do in print.js's pickFileName + // (including using DownloadPaths.sanitize!). + // For now, the following is for consistency with the behavior + // prior to bug 1669149 part 3. + let dest = undefined; + try { + dest = Services.dirsvc.get("CurWorkD", Ci.nsIFile).path; + } catch (e) {} + if (!dest) { + dest = Services.dirsvc.get("Home", Ci.nsIFile).path; + } + settings.toFileName = PathUtils.join(dest, "mozilla.pdf"); + } + + if (useSystemDialog) { + const hasSelection = await PrintUtils.checkForSelection( + browsingContext + ); + + // Prompt the user to choose a printer and make any desired print + // settings changes. + let doPrint = false; + try { + doPrint = await PrintUtils.handleSystemPrintDialog( + browsingContext.topChromeWindow, + hasSelection, + settings + ); + if (!doPrint) { + return; + } + } finally { + // Clean up browser if we aren't going to use it. + if (!doPrint && browser) { + browser.remove(); + } + } + } + + // At some point we should handle the Promise that this returns (at + // least report rejection to telemetry). + browsingContext.print(settings); + } + + // We need to return to the event loop before calling + // makePrintSettingsAndInvokePrint() if we were called for `window.print()`. + // That's because if that function synchronously calls `browser.remove()` + // or `browsingContext.print()` before we return `browser`, the nested + // event loop that is being spun in the content process under the + // OpenInternal call in nsGlobalWindowOuter::Print will still be active. + // In the case of `browser.remove()`, nsGlobalWindowOuter::Print would then + // get unhappy once OpenInternal does return since it will fail to return + // a BrowsingContext. In the case of `browsingContext.print()`, we would + // re-enter nsGlobalWindowOuter::Print under the nested event loop and + // printing would then fail since the outer nsGlobalWindowOuter::Print call + // wouldn't yet have created the static clone. + setTimeout(makePrintSettingsAndInvokePrint, 0); + + return browser; + }, + + togglePrintPreview(aBrowsingContext) { + let dialogBox = this.getTabDialogBox(aBrowsingContext.top.embedderElement); + let dialogs = dialogBox.getTabDialogManager().dialogs; + let previewDialog = dialogs.find(d => + d._box.querySelector(".printSettingsBrowser") + ); + if (previewDialog) { + previewDialog.close(); + return; + } + this.startPrintWindow(aBrowsingContext); + }, + + /** + * Called when a content process has created a new BrowsingContext for a + * static clone of a document that is to be printed, but we do NOT yet have a + * CanonicalBrowsingContext counterpart in the parent process. This only + * happens in the following cases: + * + * - content script invoked window.print() in the content process, or: + * - silent printing is enabled, and UI code previously invoked + * startPrintWindow which called BrowsingContext.print(), and we're now + * being called back by the content process to parent the static clone. + * + * In the latter case we only need to create the CanonicalBrowsingContext, + * link it to it's content process counterpart, and inserted it into + * the document tree; the print in the content process has already been + * initiated. + * + * In the former case we additionally need to check if we should open the + * tab modal print UI (if not silent printing), obtain a valid + * nsIPrintSettings object, and tell the content process to initiate the + * print with this settings object. + */ + handleStaticCloneCreatedForPrint(aOpenWindowInfo) { + let browsingContext = aOpenWindowInfo.parent; + if (aOpenWindowInfo.isForWindowDotPrint) { + return this.startPrintWindow(browsingContext, { + windowDotPrintOpenWindowInfo: aOpenWindowInfo, + }); + } + return this.createParentBrowserForStaticClone( + browsingContext, + aOpenWindowInfo + ); + }, + + createParentBrowserForStaticClone(aBrowsingContext, aOpenWindowInfo) { + // XXX This code is only called when silent printing, so we're really + // abusing PrintPreview here. See bug 1768020. + let printPreview = new PrintPreview({ + sourceBrowsingContext: aBrowsingContext, + openWindowInfo: aOpenWindowInfo, + }); + let browser = printPreview.createPreviewBrowser("source"); + document.documentElement.append(browser); + return browser; + }, + + // "private" methods and members. Don't use them. + + _getErrorCodeForNSResult(nsresult) { + const MSG_CODES = [ + "GFX_PRINTER_NO_PRINTER_AVAILABLE", + "GFX_PRINTER_NAME_NOT_FOUND", + "GFX_PRINTER_COULD_NOT_OPEN_FILE", + "GFX_PRINTER_STARTDOC", + "GFX_PRINTER_ENDDOC", + "GFX_PRINTER_STARTPAGE", + "GFX_PRINTER_DOC_IS_BUSY", + "ABORT", + "NOT_AVAILABLE", + "NOT_IMPLEMENTED", + "OUT_OF_MEMORY", + "UNEXPECTED", + ]; + + for (let code of MSG_CODES) { + let nsErrorResult = "NS_ERROR_" + code; + if (Cr[nsErrorResult] == nsresult) { + return code; + } + } + + // PERR_FAILURE is the catch-all error message if we've gotten one that + // we don't recognize. + return "FAILURE"; + }, + + _displayPrintingError(nsresult, isPrinting, browser) { + // The nsresults from a printing error are mapped to strings that have + // similar names to the errors themselves. For example, for error + // NS_ERROR_GFX_PRINTER_NO_PRINTER_AVAILABLE, the name of the string + // for the error message is: PERR_GFX_PRINTER_NO_PRINTER_AVAILABLE. What's + // more, if we're in the process of doing a print preview, it's possible + // that there are strings specific for print preview for these errors - + // if so, the names of those strings have _PP as a suffix. It's possible + // that no print preview specific strings exist, in which case it is fine + // to fall back to the original string name. + let msgName = "PERR_" + this._getErrorCodeForNSResult(nsresult); + let msg, title; + if (!isPrinting) { + // Try first with _PP suffix. + let ppMsgName = msgName + "_PP"; + try { + msg = this._bundle.GetStringFromName(ppMsgName); + } catch (e) { + // We allow localizers to not have the print preview error string, + // and just fall back to the printing error string. + } + } + + if (!msg) { + msg = this._bundle.GetStringFromName(msgName); + } + + title = this._bundle.GetStringFromName( + isPrinting + ? "print_error_dialog_title" + : "printpreview_error_dialog_title" + ); + + Services.prompt.asyncAlert( + browser.browsingContext, + Services.prompt.MODAL_TYPE_TAB, + title, + msg + ); + + Services.telemetry.keyedScalarAdd( + "printing.error", + this._getErrorCodeForNSResult(nsresult), + 1 + ); + }, + + getPrintSettings(aPrinterName, aDefaultsOnly, aAllowPseudoPrinter = true) { + var printSettings; + try { + var PSSVC = Cc["@mozilla.org/gfx/printsettings-service;1"].getService( + Ci.nsIPrintSettingsService + ); + + function isValidPrinterName(aPrinterName) { + return ( + aPrinterName && + (aAllowPseudoPrinter || + aPrinterName != PrintUtils.SAVE_TO_PDF_PRINTER) + ); + } + + // We must not try to print using an nsIPrintSettings without a printer + // name set. + const printerName = (function () { + if (isValidPrinterName(aPrinterName)) { + return aPrinterName; + } + if (isValidPrinterName(PSSVC.lastUsedPrinterName)) { + return PSSVC.lastUsedPrinterName; + } + return Cc["@mozilla.org/gfx/printerlist;1"].getService( + Ci.nsIPrinterList + ).systemDefaultPrinterName; + })(); + + printSettings = PSSVC.createNewPrintSettings(); + printSettings.printerName = printerName; + + // First get any defaults from the printer. We want to skip this for Save + // to PDF since it isn't a real printer and will throw. + if (printSettings.printerName != this.SAVE_TO_PDF_PRINTER) { + PSSVC.initPrintSettingsFromPrinter( + printSettings.printerName, + printSettings + ); + } + + if (!aDefaultsOnly) { + // Apply any settings that have been saved for this printer. + PSSVC.initPrintSettingsFromPrefs( + printSettings, + true, + printSettings.kInitSaveAll + ); + } + } catch (e) { + console.error("PrintUtils.getPrintSettings failed: ", e, "\n"); + } + return printSettings; + }, + + // Show the system print dialog, saving modified preferences. + // Returns true if the user clicked print (Not cancel). + async handleSystemPrintDialog(aWindow, aHasSelection, aSettings) { + // Prompt the user to choose a printer and make any desired print + // settings changes. + try { + const svc = Cc["@mozilla.org/widget/printdialog-service;1"].getService( + Ci.nsIPrintDialogService + ); + await svc.showPrintDialog(aWindow, aHasSelection, aSettings); + } catch (e) { + if (e.result == Cr.NS_ERROR_ABORT) { + return false; + } + throw e; + } + + // Update the saved last used printer name and print settings: + var PSSVC = Cc["@mozilla.org/gfx/printsettings-service;1"].getService( + Ci.nsIPrintSettingsService + ); + PSSVC.maybeSaveLastUsedPrinterNameToPrefs(aSettings.printerName); + PSSVC.maybeSavePrintSettingsToPrefs( + aSettings, + Ci.nsIPrintSettings.kPrintDialogPersistSettings + ); + return true; + }, +}; + +class PrintPreview extends MozElements.BaseControl { + constructor({ + sourceBrowsingContext, + settingsBrowser, + topBrowsingContext, + activeBrowsingContext, + openWindowInfo, + printFrameOnly, + }) { + super(); + this.sourceBrowsingContext = sourceBrowsingContext; + this.settingsBrowser = settingsBrowser; + this.topBrowsingContext = topBrowsingContext; + this.activeBrowsingContext = activeBrowsingContext; + this.openWindowInfo = openWindowInfo; + this.printFrameOnly = printFrameOnly; + + this.printSelectionOnly = false; + this.simplifyPage = false; + this.sourceBrowser = null; + this.selectionBrowser = null; + this.simplifiedBrowser = null; + this.lastPreviewBrowser = null; + } + + connectedCallback() { + if (this.childElementCount > 0) { + return; + } + this.setAttribute("flex", "1"); + this.append( + MozXULElement.parseXULToFragment(` + <stack class="previewStack" rendering="true" flex="1" previewtype="primary"> + <vbox class="previewRendering" flex="1"> + <h1 class="print-pending-label" data-l10n-id="printui-loading"></h1> + </vbox> + <html:printpreview-pagination class="printPreviewNavigation"></html:printpreview-pagination> + </stack> + `) + ); + this.stack = this.firstElementChild; + this.paginator = this.querySelector("printpreview-pagination"); + + if (this.openWindowInfo) { + // For window.print() we need a browser right away for the contents to be + // cloned into, create it now. + this.createPreviewBrowser("source"); + } + } + + disconnectedCallback() { + this.exitPrintPreview(); + } + + getSourceBrowsingContext() { + if (this.openWindowInfo) { + // If openWindowInfo is set this was for window.print() and the source + // contents have already been cloned into the preview browser. + return this.sourceBrowser.browsingContext; + } + return this.sourceBrowsingContext; + } + + get currentBrowsingContext() { + return this.lastPreviewBrowser.browsingContext; + } + + exitPrintPreview() { + this.sourceBrowser?.frameLoader?.exitPrintPreview(); + this.simplifiedBrowser?.frameLoader?.exitPrintPreview(); + this.selectionBrowser?.frameLoader?.exitPrintPreview(); + + this.textContent = ""; + } + + async printPreview(settings, { sourceVersion, sourceURI }) { + this.stack.setAttribute("rendering", true); + + let result = await this._printPreview(settings, { + sourceVersion, + sourceURI, + }); + + let browser = this.lastPreviewBrowser; + this.stack.setAttribute("previewtype", browser.getAttribute("previewtype")); + browser.setAttribute("sheet-count", result.sheetCount); + // The view resets to the top of the document on update bug 1686737. + browser.setAttribute("current-page", 1); + this.paginator.observePreviewBrowser(browser); + await document.l10n.translateElements([browser]); + + this.stack.removeAttribute("rendering"); + + return result; + } + + async _printPreview(settings, { sourceVersion, sourceURI }) { + let printSelectionOnly = sourceVersion == "selection"; + let simplifyPage = sourceVersion == "simplified"; + let selectionTypeBrowser; + let previewBrowser; + + // Select the existing preview browser elements, these could be null. + if (printSelectionOnly) { + selectionTypeBrowser = this.selectionBrowser; + previewBrowser = this.selectionBrowser; + } else { + selectionTypeBrowser = this.sourceBrowser; + previewBrowser = simplifyPage + ? this.simplifiedBrowser + : this.sourceBrowser; + } + + settings.docURL = sourceURI; + + if (previewBrowser) { + this.lastPreviewBrowser = previewBrowser; + if (this.openWindowInfo) { + // We only want to use openWindowInfo for the window.print() browser, + // we can get rid of it now. + this.openWindowInfo = null; + } + // This browser has been rendered already, just update it. + return previewBrowser.frameLoader.printPreview(settings, null); + } + + if (!selectionTypeBrowser) { + // Need to create a non-simplified browser. + selectionTypeBrowser = this.createPreviewBrowser( + simplifyPage ? "source" : sourceVersion + ); + let browsingContext = + printSelectionOnly || this.printFrameOnly + ? this.activeBrowsingContext + : this.topBrowsingContext; + let result = await selectionTypeBrowser.frameLoader.printPreview( + settings, + browsingContext + ); + // If this isn't simplified then we're done. + if (!simplifyPage) { + this.lastPreviewBrowser = selectionTypeBrowser; + return result; + } + } + + // We have the base selection/primary browser but need to simplify. + previewBrowser = this.createPreviewBrowser(sourceVersion); + await previewBrowser.browsingContext.currentWindowGlobal + .getActor("Printing") + .sendQuery("Printing:Preview:ParseDocument", { + URL: sourceURI, + windowID: + selectionTypeBrowser.browsingContext.currentWindowGlobal + .outerWindowId, + }); + + // We've parsed a simplified version into the preview browser. Convert that to + // a print preview as usual. + this.lastPreviewBrowser = previewBrowser; + return previewBrowser.frameLoader.printPreview( + settings, + previewBrowser.browsingContext + ); + } + + createPreviewBrowser(sourceVersion) { + let browser = document.createXULElement("browser"); + let browsingContext = + sourceVersion == "selection" || + this.printFrameOnly || + (sourceVersion == "source" && this.openWindowInfo) + ? this.sourceBrowsingContext + : this.sourceBrowsingContext.top; + if (sourceVersion == "source" && this.openWindowInfo) { + browser.openWindowInfo = this.openWindowInfo; + } else { + let userContextId = browsingContext.originAttributes.userContextId; + if (userContextId) { + browser.setAttribute("usercontextid", userContextId); + } + browser.setAttribute( + "initialBrowsingContextGroupId", + browsingContext.group.id + ); + } + browser.setAttribute("type", "content"); + let remoteType = browsingContext.currentRemoteType; + if (remoteType) { + browser.setAttribute("remoteType", remoteType); + browser.setAttribute("remote", "true"); + } + // When the print process finishes, we get closed by + // nsDocumentViewer::OnDonePrinting, or by the print preview code. + // + // When that happens, we should remove us from the DOM if connected. + browser.addEventListener("DOMWindowClose", function (e) { + if (this.isConnected) { + this.remove(); + } + e.stopPropagation(); + e.preventDefault(); + }); + + if (this.settingsBrowser) { + browser.addEventListener("contextmenu", function (e) { + e.preventDefault(); + }); + + browser.setAttribute("previewtype", sourceVersion); + browser.classList.add("printPreviewBrowser"); + browser.setAttribute("flex", "1"); + browser.setAttribute("printpreview", "true"); + browser.setAttribute("nodefaultsrc", "true"); + document.l10n.setAttributes(browser, "printui-preview-label"); + + this.stack.insertBefore(browser, this.paginator); + + if (sourceVersion == "source") { + this.sourceBrowser = browser; + } else if (sourceVersion == "selection") { + this.selectionBrowser = browser; + } else if (sourceVersion == "simplified") { + this.simplifiedBrowser = browser; + } + } else { + browser.style.visibility = "collapse"; + } + return browser; + } +} +customElements.define("print-preview", PrintPreview); diff --git a/toolkit/components/printing/content/simplifyMode.css b/toolkit/components/printing/content/simplifyMode.css new file mode 100644 index 0000000000..71e5d8fc1c --- /dev/null +++ b/toolkit/components/printing/content/simplifyMode.css @@ -0,0 +1,36 @@ +/* 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/. */ + +/* This file defines specific rules for print preview when using simplify mode. + * Some of these rules (styling for title and author on the header element) + * already exist in aboutReader.css, however, we decoupled it from the original + * file so we don't need to load a bunch of extra queries that will not take + * effect when using the simplify page checkbox. */ +:root { + --font-size: 16px; + --content-width: 30em; + --line-height: 1.6em; +} + +body { + padding: 0px; +} + +.container { + max-width: 100%; + font-family: Georgia, "Times New Roman", serif; +} + +.header > h1 { + font-size: 1.6em; + line-height: 1.25em; + margin: 30px 0; +} + +.header > .credits { + font-size: 0.9em; + line-height: 1.48em; + margin: 0 0 30px 0; + font-style: italic; +} diff --git a/toolkit/components/printing/content/toggle-group.css b/toolkit/components/printing/content/toggle-group.css new file mode 100644 index 0000000000..94922eb963 --- /dev/null +++ b/toolkit/components/printing/content/toggle-group.css @@ -0,0 +1,79 @@ +/* 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/. */ + + +/* + * A radiogroup styled to hide the radio buttons + * and present tab-like labels as buttons + */ + +.toggle-group { + display: inline-flex; +} + +.toggle-group-label { + display: inline-flex; + align-items: center; + margin: 0; + padding: 6px 10px; + background-color: var(--in-content-button-background); +} + +.toggle-group-input { + position: absolute; + inset-inline-start: -100px; +} + +.toggle-group-label-iconic::before { + width: 16px; + height: 16px; + margin-inline-end: 5px; + -moz-context-properties: fill; + fill: currentColor; +} + +.toggle-group-label:first-of-type { + border-start-start-radius: 4px; + border-end-start-radius: 4px; +} + +.toggle-group-label:last-of-type { + border-start-end-radius: 4px; + border-end-end-radius: 4px; +} + +.toggle-group-input:disabled + .toggle-group-label { + opacity: 0.4; +} + +.toggle-group-input:enabled + .toggle-group-label:hover { + background-color: var(--in-content-button-background-hover); + color: var(--in-content-button-text-color-hover); +} + +.toggle-group-input:enabled + .toggle-group-label:hover:active { + background-color: var(--in-content-button-background-active); + color: var(--in-content-button-text-color-active); +} + +.toggle-group-input:focus-visible + .toggle-group-label { + outline: var(--in-content-focus-outline); + outline-offset: var(--in-content-focus-outline-offset); + z-index: 1; +} + +.toggle-group-input:checked + .toggle-group-label { + background-color: var(--in-content-primary-button-background); + color: var(--in-content-primary-button-text-color); +} + +.toggle-group-input:enabled:checked + .toggle-group-label:hover { + background-color: var(--in-content-primary-button-background-hover); + color: var(--in-content-primary-button-text-color-hover); +} + +.toggle-group-input:enabled:checked + .toggle-group-label:hover:active { + background-color: var(--in-content-primary-button-background-active); + color: var(--in-content-primary-button-text-color-active); +} |