diff options
Diffstat (limited to 'toolkit/components/printing/content/print.js')
-rw-r--r-- | toolkit/components/printing/content/print.js | 2562 |
1 files changed, 2562 insertions, 0 deletions
diff --git a/toolkit/components/printing/content/print.js b/toolkit/components/printing/content/print.js new file mode 100644 index 0000000000..826893a0ec --- /dev/null +++ b/toolkit/components/printing/content/print.js @@ -0,0 +1,2562 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const { + gBrowser, + PrintUtils, + Services, + AppConstants, +} = window.docShell.chromeEventHandler.ownerGlobal; + +ChromeUtils.defineModuleGetter( + this, + "DownloadPaths", + "resource://gre/modules/DownloadPaths.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "DeferredTask", + "resource://gre/modules/DeferredTask.jsm" +); + +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 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.import("resource://gre/modules/Console.jsm"); + // 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 nameValues; +} + +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(); + ourBrowser.setAttribute("flex", "0"); + ourBrowser.classList.add("printSettingsBrowser"); + ourBrowser.closest(".dialogBox")?.classList.add("printDialogBox"); + }, + { once: true } +); + +window.addEventListener("dialogclosing", () => { + PrintEventHandler.unload(); + cancelDeferredTasks(); +}); + +window.addEventListener( + "unload", + e => { + document.textContent = ""; + }, + { once: true } +); + +var PrintEventHandler = { + settings: null, + defaultSettings: null, + allPaperSizes: {}, + previewIsEmpty: false, + _delayedChanges: {}, + _hasRenderedSelectionPreview: false, + _hasRenderedPrimaryPreview: false, + _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, + }, + originalSourceContentTitle: null, + originalSourceCurrentURI: null, + previewBrowser: null, + selectionPreviewBrowser: null, + currentPreviewBrowser: null, + + // 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", + "printSelectionOnly", + ]), + _noPreviewUpdateSettings: new Set(["numCopies", "printDuplex"]), + + async init() { + Services.telemetry.scalarAdd("printing.preview_opened_tm", 1); + + // 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.getSourceBrowsingContext(); + ({ + previewBrowser: this.previewBrowser, + selectionPreviewBrowser: this.selectionPreviewBrowser, + } = PrintUtils.createPreviewBrowsers(sourceBrowsingContext, ourBrowser)); + + let args = window.arguments[0]; + this.printSelectionOnly = args.getProperty("printSelectionOnly"); + this.hasSelection = + args.getProperty("hasSelection") && this.selectionPreviewBrowser; + this.printFrameOnly = args.getProperty("printFrameOnly"); + // Get the temporary browser that will previously have been created for the + // platform code to generate the static clone printing doc into if this + // print is for a window.print() call. In that case we steal the browser's + // docshell to get the static clone, then discard it. + let existingBrowser = args.getProperty("previewBrowser"); + if (existingBrowser) { + sourceBrowsingContext = existingBrowser.browsingContext; + this.previewBrowser.swapDocShells(existingBrowser); + existingBrowser.remove(); + } + document.querySelector("#print-selection-container").hidden = !this + .hasSelection; + + let sourcePrincipal = + sourceBrowsingContext.currentWindowGlobal.documentPrincipal; + let sourceIsPdf = + !sourcePrincipal.isNullPrincipal && sourcePrincipal.spec == PDF_JS_URI; + this.originalSourceContentTitle = + sourceBrowsingContext.currentWindowContext.documentTitle; + this.originalSourceCurrentURI = + sourceBrowsingContext.currentWindowContext.documentURI.spec; + + this.sourceWindowId = this.printFrameOnly + ? sourceBrowsingContext.currentWindowGlobal.outerWindowId + : sourceBrowsingContext.top.embedderElement.browsingContext + .currentWindowGlobal.outerWindowId; + this.selectionWindowId = + sourceBrowsingContext.currentWindowGlobal.outerWindowId; + + // 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; + + 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.getSourceBrowsingContext().top.embedderElement; + let dialogBoxManager = gBrowser + .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(); + settings.showPrintProgress = false; + // 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.previewBrowser.browsingContext.embedderElement.contentTitle; + const PRINTPROMPTSVC = Cc[ + "@mozilla.org/embedcomp/printingprompt-service;1" + ].getService(Ci.nsIPrintingPromptService); + try { + Services.telemetry.scalarAdd( + "printing.dialog_opened_via_preview_tm", + 1 + ); + await this._showPrintDialog(PRINTPROMPTSVC, window, settings); + } catch (e) { + if (e.result == Cr.NS_ERROR_ABORT) { + Services.telemetry.scalarAdd( + "printing.dialog_via_preview_cancelled_tm", + 1 + ); + window.close(); + return; // user cancelled + } + throw e; + } + await this.print(settings); + }); + + let settingsToChange = await this.refreshSettings(selectedPrinter.value); + await this.updateSettings(settingsToChange, true); + + 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, + }) + ); + + await document.l10n.translateElements([this.previewBrowser]); + if (this.selectionPreviewBrowser) { + await document.l10n.translateElements([this.selectionPreviewBrowser]); + } + + document.body.removeAttribute("loading"); + + await new Promise(resolve => window.requestAnimationFrame(resolve)); + + // Now that we're showing the form, select the destination select. + window.focus(); + let fm = Services.focus; + fm.setFocus(document.getElementById("printer-picker"), fm.FLAG_SHOWRING); + + await initialPreviewDone; + }, + + unload() { + this.previewBrowser.frameLoader.exitPrintPreview(); + if (this.selectionPreviewBrowser) { + this.selectionPreviewBrowser.frameLoader.exitPrintPreview(); + } + }, + + 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.originalSourceContentTitle, + this.originalSourceCurrentURI + ); + } catch (e) { + return false; + } + } + + await window._initialized; + + // This seems like it should be handled automatically but it isn't. + Services.prefs.setStringPref("print_printer", settings.printerName); + + try { + // We'll provide our own progress indicator. + this.settings.showPrintProgress = false; + 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.currentPreviewBrowser.browsingContext; + await this._doPrint(bc, settings); + } catch (e) { + Cu.reportError(e); + } + + window.close(); + return true; + }, + + 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.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; + 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; + + if ( + parseFloat(this.viewSettings.customMargins.marginTop) + + parseFloat(this.viewSettings.customMargins.marginBottom) > + height - + paperWrapper.unwriteableMarginTop - + paperWrapper.unwriteableMarginBottom || + this.viewSettings.customMargins.marginTop < 0 || + this.viewSettings.customMargins.marginBottom < 0 + ) { + let { marginTop, marginBottom } = this.viewSettings.defaultMargins; + changedSettings.marginTop = changedSettings.customMarginTop = marginTop; + changedSettings.marginBottom = changedSettings.customMarginBottom = marginBottom; + delete this._userChangedSettings.customMargins; + } + + if ( + parseFloat(this.viewSettings.customMargins.marginRight) + + parseFloat(this.viewSettings.customMargins.marginLeft) > + width - + paperWrapper.unwriteableMarginRight - + paperWrapper.unwriteableMarginLeft || + this.viewSettings.customMargins.marginLeft < 0 || + this.viewSettings.customMargins.marginRight < 0 + ) { + let { marginLeft, marginRight } = this.viewSettings.defaultMargins; + changedSettings.marginLeft = changedSettings.customMarginLeft = marginLeft; + changedSettings.marginRight = changedSettings.customMarginRight = marginRight; + delete this._userChangedSettings.customMargins; + } + } 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.savePrintSettingsToPrefs(this.settings, true, 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._showRenderingIndicator(); + 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; + let { printSelectionOnly } = this.viewSettings; + if (!this.selectionPreviewBrowser) { + printSelectionOnly = false; + } + + // We never want the progress dialog to show + settings.showPrintProgress = false; + + this._showRenderingIndicator(); + + let sourceWinId; + + // If it's the first time loading this type of browser, get the stored window id. + if (printSelectionOnly && !this._hasRenderedSelectionPreview) { + sourceWinId = this.selectionWindowId; + this._hasRenderedSelectionPreview = true; + } else if (!printSelectionOnly && !this._hasRenderedPrimaryPreview) { + sourceWinId = this.sourceWindowId; + this._hasRenderedPrimaryPreview = true; + } + + this.previewBrowser.parentElement.setAttribute( + "previewtype", + printSelectionOnly ? "selection" : "primary" + ); + + this.currentPreviewBrowser = printSelectionOnly + ? this.selectionPreviewBrowser + : this.previewBrowser; + + 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; + try { + // This resolves with a PrintPreviewSuccessInfo dictionary. + ({ + totalPageCount, + sheetCount, + isEmpty, + } = await this.currentPreviewBrowser.frameLoader.printPreview( + settings, + sourceWinId + )); + } catch (e) { + this.reportPrintingError("PRINT_PREVIEW"); + throw e; + } + + 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(); + } + + // Update the settings print options on whether there is a selection. + settings.isPrintSelectionRBEnabled = this.hasSelection; + + document.dispatchEvent( + new CustomEvent("page-count", { + detail: { sheetCount, totalPages: totalPageCount }, + }) + ); + this.currentPreviewBrowser.setAttribute("sheet-count", sheetCount); + + this._hideRenderingIndicator(); + + if (isFirstCall) { + const elapsed = Date.now() - this.printInitiationTime; + Services.telemetry + .getHistogramById("PRINT_INIT_TO_PREVIEW_DOC_SHOWN_MS") + .add(elapsed); + } + }, + + _showRenderingIndicator() { + let stack = this.previewBrowser.parentElement; + stack.setAttribute("rendering", true); + document.body.setAttribute("rendering", true); + }, + + _hideRenderingIndicator() { + let stack = this.previewBrowser.parentElement; + stack.removeAttribute("rendering"); + document.body.removeAttribute("rendering"); + }, + + getSourceBrowsingContext() { + let params = new URLSearchParams(location.search); + let browsingContextId = params.get("browsingContextId"); + if (!browsingContextId) { + return null; + } + return BrowsingContext.get(browsingContextId); + }, + + async getPrintDestinations() { + const printerList = Cc["@mozilla.org/gfx/printerlist;1"].createInstance( + Ci.nsIPrinterList + ); + let printers; + + if (Cu.isInAutomation) { + printers = await Promise.resolve(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, paper) { + switch (marginSize) { + case "minimum": + return { + marginTop: paper.unwriteableMarginTop, + marginLeft: paper.unwriteableMarginLeft, + marginBottom: paper.unwriteableMarginBottom, + marginRight: paper.unwriteableMarginRight, + }; + 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", paper); + 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) { + Services.telemetry.keyedScalarAdd("printing.error", aMessage, 1); + }, + + /** + * Prints the window. This method has been abstracted into a helper for + * testing purposes. + */ + _doPrint(aBrowsingContext, aSettings) { + return aBrowsingContext.top.embedderElement.print( + aBrowsingContext.currentWindowGlobal.outerWindowId, + aSettings + ); + }, + + /** + * 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(aPrintingPromptService, aWindow, aSettings) { + return aPrintingPromptService.showPrintDialog(aWindow, 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.newPrintSettings; + printerInfo.defaultSettings.printerName = printerName; + printerInfo.defaultSettings.toFileName = ""; + printerInfo.defaultSettings.outputFormat = + Ci.nsIPrintSettings.kOutputFormatPDF; + printerInfo.defaultSettings.printToFile = true; + printerInfo.paperList = this.fallbackPaperList; + } + printerInfo.settings = printerInfo.defaultSettings.clone(); + // Apply any previously persisted user values + let flags = printerInfo.settings.kInitSaveAll; + if (printerName == PrintUtils.SAVE_TO_PDF_PRINTER) { + // Don't apply potentially-bad printToFile setting that may be in some user's prefs. + flags ^= 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; + 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"); + for (let presetName of ["none", "minimum", "default"]) { + let marginPresets = allMarginPresets[presetName]; + if ( + Object.keys(marginSettings).every( + name => + marginSettings[name].toFixed(2) == + marginPresets[name].toFixed(2) + ) + ) { + 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": + return target.duplex; + + 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; + } + 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; + } + 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": + target.duplex = value + ? Ci.nsIPrintSettings.kDuplexHorizontal + : Ci.nsIPrintSettings.kSimplex; + 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; + + 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"); + if (AppConstants.platform != "win") { + // Move the Print button to the end if this isn't Windows. + this.printButton.parentElement.append(this.printButton); + } + this.querySelector("#pages-per-sheet").hidden = !Services.prefs.getBoolPref( + "print.pages_per_sheet.enabled", + false + ); + } + + removeNonPdfSettings() { + let selectors = [ + "#margins", + "#headers-footers", + "#backgrounds", + "#print-selection-container", + ]; + 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("#copies").hidden = settings.willSaveToFile; + + 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("keypress", e => this.handleKeypress(e)); + this.addEventListener("paste", e => this.handlePaste(e)); + } + + connectedCallback() { + this.type = "number"; + this.settingName = this.dataset.settingName; + super.connectedCallback(); + } + + update(settings) { + if (this.settingName) { + this.value = settings[this.settingName]; + } + } + + handleKeypress(e) { + let char = String.fromCharCode(e.charCode); + let acceptedChar = e.target.step.includes(".") + ? char.match(/^[0-9.]$/) + : 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(); + let acceptedChars = e.target.step.includes(".") + ? paste.match(/^[0-9.]*$/) + : paste.match(/^[0-9]*$/); + if (!acceptedChars) { + e.preventDefault(); + } + } + + handleEvent(e) { + switch (e.type) { + case "paste": + this.handlePaste(); + break; + case "keypress": + this.handleKeypress(); + break; + 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 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; + } + + update(settings) { + if (settings.printerName !== this._printerName) { + this._printerName = settings.printerName; + this.setOptions(settings.paperSizes); + } + this.value = settings.paperId; + } +} +customElements.define("paper-size-select", PaperSizePicker, { + extends: "select", +}); + +class OrientationInput extends PrintUIControlMixin(HTMLElement) { + get templateId() { + return "orientation-template"; + } + + update(settings) { + for (let input of this.querySelectorAll("input")) { + input.checked = settings.orientation == input.value; + } + } + + handleEvent(e) { + this.dispatchSettingsChange({ + orientation: e.target.value, + }); + } +} +customElements.define("orientation-input", OrientationInput); + +class 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._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 isAll = this._rangePicker.value == "all"; + if (isAll) { + this._pagesSet.clear(); + if (!this._rangeInput.checkValidity()) { + this._rangeInput.setCustomValidity(""); + this._rangeInput.value = ""; + } + } else { + this.validateRangeInput(); + } + + 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 || isAll) { + 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 == "all") { + 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._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 = this._rangePicker.value == "all" ? "" : this._rangeInput.value; + this._validateRangeInput(value, this._numPages); + } + + 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; + + 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 == "all"; + this.updatePageRange(); + this.dispatchPageRange(); + } 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"); + } + + get templateId() { + return "margins-template"; + } + + updateCustomMargins() { + let newMargins = { + marginTop: this._customTopMargin.value, + marginBottom: this._customBottomMargin.value, + marginLeft: this._customLeftMargin.value, + marginRight: this._customRightMargin.value, + }; + + this.dispatchSettingsChange({ + margins: "custom", + customMargins: newMargins, + }); + this._marginError.hidden = true; + } + + updateMaxValues() { + this._customTopMargin.max = + this._maxHeight - this._customBottomMargin.value; + this._customBottomMargin.max = + this._maxHeight - this._customTopMargin.value; + this._customLeftMargin.max = this._maxWidth - this._customRightMargin.value; + this._customRightMargin.max = this._maxWidth - this._customLeftMargin.value; + } + + formatMargin(target) { + if (target.value.includes(".")) { + if (target.value.split(".")[1].length > 2) { + let dotIndex = target.value.indexOf("."); + target.value = target.value.slice(0, dotIndex + 3); + } + } + } + + setAllMarginValues(settings) { + this._customTopMargin.value = parseFloat( + settings.customMargins.marginTop + ).toFixed(2); + this._customBottomMargin.value = parseFloat( + settings.customMargins.marginBottom + ).toFixed(2); + this._customLeftMargin.value = parseFloat( + settings.customMargins.marginLeft + ).toFixed(2); + this._customRightMargin.value = parseFloat( + settings.customMargins.marginRight + ).toFixed(2); + } + + update(settings) { + // Re-evaluate which margin options should be enabled whenever the printer or paper changes + 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; + + let height = + this._orientation == 0 ? settings.paperHeight : settings.paperWidth; + let width = + this._orientation == 0 ? settings.paperWidth : settings.paperHeight; + + this._maxHeight = + height - + settings.unwriteableMarginTop - + settings.unwriteableMarginBottom; + this._maxWidth = + width - + settings.unwriteableMarginLeft - + settings.unwriteableMarginRight; + + this._defaultPresets = settings.defaultMargins; + // 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; + } + + // 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); + this.updateSummary(); + } + + handleEvent(e) { + let willOpen = !this.isOpen; + this.updateSummary(willOpen); + } + + updateSummary(open = false) { + document.l10n.setAttributes( + this.label, + open + ? this.getAttribute("data-open-l10n-id") + : this.getAttribute("data-closed-l10n-id") + ); + } +} +customElements.define("twisty-summary", TwistySummary); + +class PageCount extends PrintUIControlMixin(HTMLElement) { + initialize() { + super.initialize(); + document.addEventListener("page-count", this); + } + + update(settings) { + this.numCopies = settings.numCopies; + this.render(); + } + + render() { + if (!this.numCopies || !this.sheetCount) { + return; + } + document.l10n.setAttributes(this, "printui-sheets-count", { + sheetCount: this.sheetCount * this.numCopies, + }); + + // 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 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; +} |