diff --git a/browser/actors/ContextMenuChild.jsm b/browser/actors/ContextMenuChild.jsm index aaf3104feda3..2a53a2ae2a78 100644 --- a/browser/actors/ContextMenuChild.jsm +++ b/browser/actors/ContextMenuChild.jsm @@ -893,6 +893,7 @@ class ContextMenuChild extends JSWindowActorChild { context.onSpellcheckable = false; context.onTextInput = false; context.onVideo = false; + context.inPDFEditor = false; // Remember the node and its owner document that was clicked // This may be modifed before sending to nsContextMenu @@ -918,6 +919,10 @@ class ContextMenuChild extends JSWindowActorChild { context.inPDFViewer = context.target.ownerDocument.nodePrincipal.originNoSuffix == "resource://pdf.js"; + if (context.inPDFViewer) { + context.pdfEditorStates = context.target.ownerDocument.editorStates; + context.inPDFEditor = !!context.pdfEditorStates?.isEditing; + } // Check if we are in a synthetic document (stand alone image, video, etc.). context.inSyntheticDoc = context.target.ownerDocument.mozSyntheticDocument; diff --git a/browser/base/content/browser-context.inc b/browser/base/content/browser-context.inc index 1041c882dbd7..af2fd42e62c7 100644 --- a/browser/base/content/browser-context.inc +++ b/browser/base/content/browser-context.inc @@ -310,6 +310,31 @@ data-l10n-id="main-context-menu-print-selection" oncommand="gContextMenu.printSelection();"/> + + + + + + + + + + + diff --git a/browser/base/content/nsContextMenu.js b/browser/base/content/nsContextMenu.js index 3d1c6370afa0..b7c2fa234696 100644 --- a/browser/base/content/nsContextMenu.js +++ b/browser/base/content/nsContextMenu.js @@ -193,6 +193,7 @@ class nsContextMenu { this.isDesignMode = context.isDesignMode; this.inFrame = context.inFrame; this.inPDFViewer = context.inPDFViewer; + this.inPDFEditor = context.inPDFEditor; this.inSrcdocFrame = context.inSrcdocFrame; this.inSyntheticDoc = context.inSyntheticDoc; this.inTabBrowser = context.inTabBrowser; @@ -226,6 +227,8 @@ class nsContextMenu { this.onTextInput = context.onTextInput; this.onVideo = context.onVideo; + this.pdfEditorStates = context.pdfEditorStates; + this.target = context.target; this.targetIdentifier = context.targetIdentifier; @@ -338,6 +341,7 @@ class nsContextMenu { this.initViewSourceItems(); this.initScreenshotItem(); this.initPasswordControlItems(); + this.initPDFItems(); this.showHideSeparators(aXulMenu); if (!aXulMenu.showHideSeparators) { @@ -350,6 +354,60 @@ class nsContextMenu { } } + initPDFItems() { + for (const id of [ + "context-pdfjs-undo", + "context-pdfjs-redo", + "context-sep-pdfjs-redo", + "context-pdfjs-cut", + "context-pdfjs-copy", + "context-pdfjs-paste", + "context-pdfjs-delete", + "context-pdfjs-selectall", + "context-sep-pdfjs-selectall", + ]) { + this.showItem(id, this.inPDFEditor); + } + + if (!this.inPDFEditor) { + return; + } + + const { + isEmpty, + hasEmptyClipboard, + hasSomethingToUndo, + hasSomethingToRedo, + hasSelectedEditor, + } = this.pdfEditorStates; + + this.setItemAttr("context-pdfjs-undo", "disabled", !hasSomethingToUndo); + this.setItemAttr("context-pdfjs-redo", "disabled", !hasSomethingToRedo); + this.setItemAttr( + "context-sep-pdfjs-redo", + "disabled", + !hasSomethingToUndo && !hasSomethingToRedo + ); + this.setItemAttr( + "context-pdfjs-cut", + "disabled", + isEmpty || !hasSelectedEditor + ); + this.setItemAttr( + "context-pdfjs-copy", + "disabled", + isEmpty || !hasSelectedEditor + ); + this.setItemAttr("context-pdfjs-paste", "disabled", hasEmptyClipboard); + this.setItemAttr( + "context-pdfjs-delete", + "disabled", + isEmpty || !hasSelectedEditor + ); + this.setItemAttr("context-pdfjs-selectall", "disabled", isEmpty); + this.setItemAttr("context-sep-pdfjs-selectall", "disabled", isEmpty); + } + initOpenItems() { var isMailtoInternal = false; if (this.onMailtoLink) { @@ -551,7 +609,9 @@ class nsContextMenu { // (or is in a frame), or a canvas. If this isn't an image, check // if there is a background image. let showViewImage = - (this.onImage && (!this.inSyntheticDoc || this.inFrame)) || this.onCanvas; + ((this.onImage && (!this.inSyntheticDoc || this.inFrame)) || + this.onCanvas) && + !this.inPDFViewer; let showBGImage = this.hasBGImage && !this.hasMultipleBGImages && @@ -567,7 +627,10 @@ class nsContextMenu { this.showItem("context-viewimage", showViewImage || showBGImage); // Save image depends on having loaded its content. - this.showItem("context-saveimage", this.onLoadedImage || this.onCanvas); + this.showItem( + "context-saveimage", + (this.onLoadedImage || this.onCanvas) && !this.inPDFEditor + ); // Copy image contents depends on whether we're on an image. // Note: the element doesn't exist on all platforms, but showItem() takes @@ -879,7 +942,8 @@ class nsContextMenu { this.onImage || this.onVideo || this.onAudio || - this.inSyntheticDoc + this.inSyntheticDoc || + this.inPDFEditor ) || this.isDesignMode ); @@ -1402,6 +1466,10 @@ class nsContextMenu { } } + pdfJSCmd(name) { + this.browser.sendMessageToActor("PDFJS:Editing", { name }, "Pdfjs"); + } + // View Partial Source viewPartialSource() { let { browser } = this; diff --git a/toolkit/components/pdfjs/content/PdfStreamConverter.jsm b/toolkit/components/pdfjs/content/PdfStreamConverter.jsm index 1fb47b2696cd..311cbebbe951 100644 --- a/toolkit/components/pdfjs/content/PdfStreamConverter.jsm +++ b/toolkit/components/pdfjs/content/PdfStreamConverter.jsm @@ -605,6 +605,31 @@ class ChromeActions { } return result; } + + /** + * Set the different editor states in order to be able to update the context + * menu. + * @param {Object} details + */ + updateEditorStates({ details }) { + const doc = this.domWindow.document; + if (!doc.editorStates) { + doc.editorStates = { + isEditing: false, + isEmpty: true, + hasEmptyClipboard: true, + hasSomethingToUndo: false, + hasSomethingToRedo: false, + hasSelectedEditor: false, + }; + } + const { editorStates } = doc; + for (const [key, value] of Object.entries(details)) { + if (typeof value === "boolean" && key in editorStates) { + editorStates[key] = value; + } + } + } } /** diff --git a/toolkit/components/pdfjs/content/PdfjsChild.jsm b/toolkit/components/pdfjs/content/PdfjsChild.jsm index a6f946905702..6d16f6b88820 100644 --- a/toolkit/components/pdfjs/content/PdfjsChild.jsm +++ b/toolkit/components/pdfjs/content/PdfjsChild.jsm @@ -39,6 +39,10 @@ class PdfjsChild extends JSWindowActorChild { break; } + case "PDFJS:Editing": + let data = Cu.cloneInto(msg.data, this.contentWindow); + this.dispatchEvent("editingaction", data); + break; case "PDFJS:ZoomIn": case "PDFJS:ZoomOut": case "PDFJS:ZoomReset": diff --git a/toolkit/components/pdfjs/test/browser.ini b/toolkit/components/pdfjs/test/browser.ini index 1dcf7eef98ed..d12888d08536 100644 --- a/toolkit/components/pdfjs/test/browser.ini +++ b/toolkit/components/pdfjs/test/browser.ini @@ -40,3 +40,4 @@ support-files = [browser_pdfjs_views.js] [browser_pdfjs_zoom.js] skip-if = (verify && debug && (os == 'win')) +[browser_pdfjs_editing_contextmenu.js] diff --git a/toolkit/components/pdfjs/test/browser_pdfjs_editing_contextmenu.js b/toolkit/components/pdfjs/test/browser_pdfjs_editing_contextmenu.js new file mode 100644 index 000000000000..328689d95153 --- /dev/null +++ b/toolkit/components/pdfjs/test/browser_pdfjs_editing_contextmenu.js @@ -0,0 +1,431 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const RELATIVE_DIR = "toolkit/components/pdfjs/test/"; +const TESTROOT = "http://example.com/browser/" + RELATIVE_DIR; + +// This is a modified version from browser_contextmenuFillLogins.js. +async function openContextMenuAt(browser, x, y) { + const doc = browser.ownerDocument; + const contextMenu = doc.getElementById("contentAreaContextMenu"); + + const contextMenuShownPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + + // Synthesize a contextmenu event to actually open the context menu. + await BrowserTestUtils.synthesizeMouseAtPoint( + x, + y, + { + type: "contextmenu", + button: 2, + }, + browser + ); + + await contextMenuShownPromise; + return contextMenu; +} + +/** + * The text layer contains some spans with the text of the pdf. + * @param {Object} browser + * @param {string} text + * @returns {Object} the bbox of the span containing the text. + */ +async function getSpanBox(browser, text) { + return SpecialPowers.spawn(browser, [text], async function(text) { + const { ContentTaskUtils } = ChromeUtils.import( + "resource://testing-common/ContentTaskUtils.jsm" + ); + const { document } = content; + + await ContentTaskUtils.waitForCondition( + () => !!document.querySelector(".textLayer .endOfContent"), + "The text layer must be displayed" + ); + + let targetSpan = null; + for (const span of document.querySelectorAll( + `.textLayer span[role="presentation"]` + )) { + if (span.innerText.includes(text)) { + targetSpan = span; + break; + } + } + + Assert.ok(targetSpan, `document must have a span containing '${text}'`); + + const { x, y, width, height } = targetSpan.getBoundingClientRect(); + return { x, y, width, height }; + }); +} + +/** + * Open a context menu and get the pdfjs entries + * @param {Object} browser + * @param {Object} box + * @returns {Map} the pdfjs menu entries. + */ +async function getContextMenuItems(browser, box) { + const { x, y, width, height } = box; + const menuitems = [ + "context-pdfjs-undo", + "context-pdfjs-redo", + "context-sep-pdfjs-redo", + "context-pdfjs-cut", + "context-pdfjs-copy", + "context-pdfjs-paste", + "context-pdfjs-delete", + "context-pdfjs-selectall", + "context-sep-pdfjs-selectall", + ]; + + await openContextMenuAt(browser, x + width / 2, y + height / 2); + + const results = new Map(); + const doc = browser.ownerDocument; + for (const menuitem of menuitems) { + const item = doc.getElementById(menuitem); + results.set(menuitem, item || null); + } + + return results; +} + +/** + * Open a context menu on the element corresponding to the given selector + * and returs the pdfjs menu entries. + * @param {Object} browser + * @param {string} selector + * @returns {Map} the pdfjs menu entries. + */ +async function getContextMenuItemsOn(browser, selector) { + const box = await SpecialPowers.spawn(browser, [selector], async function( + selector + ) { + const element = content.document.querySelector(selector); + const { x, y, width, height } = element.getBoundingClientRect(); + return { x, y, width, height }; + }); + return getContextMenuItems(browser, box); +} + +/** + * Hide the context menu. + * @param {Object} browser + */ +async function hideContextMenu(browser) { + const doc = browser.ownerDocument; + const contextMenu = doc.getElementById("contentAreaContextMenu"); + + const popupHiddenPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popuphidden" + ); + contextMenu.hidePopup(); + await popupHiddenPromise; +} + +/** + * Enable an editor (Ink, FreeText, ...). + * @param {Object} browser + * @param {string} name + */ +async function enableEditor(browser, name) { + await SpecialPowers.spawn(browser, [name], async function(name) { + const button = content.document.querySelector(`#editor${name}`); + button.click(); + }); +} + +/** + * Click at the given coordinates. + * @param {Object} browser + * @param {number} x + * @param {number} y + */ +async function clickAt(browser, x, y) { + BrowserTestUtils.synthesizeMouseAtPoint( + x, + y, + { + type: "mousedown", + button: 0, + }, + browser + ); + BrowserTestUtils.synthesizeMouseAtPoint( + x, + y, + { + type: "mouseup", + button: 0, + }, + browser + ); +} + +/** + * Click on the element corresponding to the given selector. + * @param {Object} browser + * @param {string} selector + */ +async function clickOn(browser, selector) { + const [x, y] = await SpecialPowers.spawn(browser, [selector], async function( + selector + ) { + const element = content.document.querySelector(selector); + const { x, y, width, height } = element.getBoundingClientRect(); + return [x + width / 2, y + height / 2]; + }); + await clickAt(browser, x, y); +} + +/** + * Write some text using the keyboard. + * @param {Object} browser + * @param {string} text + */ +async function write(browser, text) { + await SpecialPowers.spawn(browser, [text], async function(text) { + const { ContentTaskUtils } = ChromeUtils.import( + "resource://testing-common/ContentTaskUtils.jsm" + ); + const EventUtils = ContentTaskUtils.getEventUtils(content); + + for (const char of text.split("")) { + EventUtils.synthesizeKey(char, {}, content); + } + }); +} + +/** + * Add a FreeText annotation and write some text inside. + * @param {Object} browser + * @param {string} text + * @param {Object} box + */ +async function addFreeText(browser, text, box) { + const { x, y, width, height } = box; + await clickAt(browser, x + 0.1 * width, y + 0.5 * height); + await write(browser, text); + await clickAt(browser, x + 0.1 * width, y + 2 * height); +} + +/** + * Count the number of elements corresponding to the given selector. + * @param {Object} browser + * @param {string} selector + * @returns + */ +async function countElements(browser, selector) { + return SpecialPowers.spawn(browser, [selector], async function(selector) { + const { document } = content; + return document.querySelectorAll(selector).length; + }); +} + +/** + * Asserts that the enabled pdfjs menuitems are the expected ones. + * @param {Map} menuitems + * @param {Array} expected + */ +function assertMenuitems(menuitems, expected) { + Assert.deepEqual( + [...menuitems.values()] + .filter( + elmt => + !elmt.id.includes("-sep-") && + !elmt.hidden && + elmt.getAttribute("disabled") === "false" + ) + .map(elmt => elmt.id), + expected + ); +} + +// Text copy, paste, undo, redo, delete and select all in using the context +// menu. +add_task(async function test() { + let mimeService = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService); + let handlerInfo = mimeService.getFromTypeAndExtension( + "application/pdf", + "pdf" + ); + + // Make sure pdf.js is the default handler. + is( + handlerInfo.alwaysAskBeforeHandling, + false, + "pdf handler defaults to always-ask is false" + ); + is( + handlerInfo.preferredAction, + Ci.nsIHandlerInfo.handleInternally, + "pdf handler defaults to internal" + ); + + info("Pref action: " + handlerInfo.preferredAction); + + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:blank" }, + async function(browser) { + await SpecialPowers.pushPrefEnv({ + set: [["pdfjs.annotationEditorMode", 0]], + }); + + // check that PDF is opened with internal viewer + await waitForPdfJS(browser, TESTROOT + "file_pdfjs_test.pdf"); + + const spanBox = await getSpanBox(browser, "and found references"); + let menuitems = await getContextMenuItems(browser, spanBox); + + // Nothing have been edited, hence the context menu doesn't contain any + // pdf entries. + Assert.ok( + [...menuitems.values()].every(elmt => elmt.hidden), + "No visible pdf menuitem" + ); + await hideContextMenu(browser); + + await enableEditor(browser, "FreeText"); + await addFreeText(browser, "hello", spanBox); + + Assert.equal(await countElements(browser, ".freeTextEditor"), 1); + + menuitems = await getContextMenuItems(browser, spanBox); + assertMenuitems(menuitems, [ + "context-pdfjs-undo", // Last created editor is undoable + "context-pdfjs-selectall", // and selectable. + ]); + // Undo. + menuitems.get("context-pdfjs-undo").click(); + + await hideContextMenu(browser); + + Assert.equal( + await countElements(browser, ".freeTextEditor"), + 0, + "The FreeText editor must have been removed" + ); + + menuitems = await getContextMenuItems(browser, spanBox); + + // The editor removed thanks to "undo" is now redoable + assertMenuitems(menuitems, ["context-pdfjs-redo"]); + menuitems.get("context-pdfjs-redo").click(); + + await hideContextMenu(browser); + + Assert.equal( + await countElements(browser, ".freeTextEditor"), + 1, + "The FreeText editor must have been added back" + ); + + await clickOn(browser, "#pdfjs_internal_editor_0"); + menuitems = await getContextMenuItemsOn( + browser, + "#pdfjs_internal_editor_0" + ); + + assertMenuitems(menuitems, [ + "context-pdfjs-undo", + "context-pdfjs-cut", + "context-pdfjs-copy", + "context-pdfjs-delete", + "context-pdfjs-selectall", + ]); + + menuitems.get("context-pdfjs-cut").click(); + await hideContextMenu(browser); + + Assert.equal( + await countElements(browser, ".freeTextEditor"), + 0, + "The FreeText editor must have been cut" + ); + + menuitems = await getContextMenuItems(browser, spanBox); + assertMenuitems(menuitems, ["context-pdfjs-undo", "context-pdfjs-paste"]); + + menuitems.get("context-pdfjs-paste").click(); + await hideContextMenu(browser); + + Assert.equal( + await countElements(browser, ".freeTextEditor"), + 1, + "The FreeText editor must have been pasted" + ); + + await clickOn(browser, "#pdfjs_internal_editor_2"); + menuitems = await getContextMenuItemsOn( + browser, + "#pdfjs_internal_editor_2" + ); + + assertMenuitems(menuitems, [ + "context-pdfjs-undo", + "context-pdfjs-cut", + "context-pdfjs-copy", + "context-pdfjs-paste", + "context-pdfjs-delete", + "context-pdfjs-selectall", + ]); + + menuitems.get("context-pdfjs-delete").click(); + await hideContextMenu(browser); + + Assert.equal( + await countElements(browser, ".freeTextEditor"), + 0, + "The FreeText editor must have been deleted" + ); + + menuitems = await getContextMenuItems(browser, spanBox); + menuitems.get("context-pdfjs-paste").click(); + await hideContextMenu(browser); + + Assert.equal( + await countElements(browser, ".freeTextEditor"), + 1, + "The FreeText editor must have been pasted" + ); + + await clickOn(browser, "#pdfjs_internal_editor_3"); + menuitems = await getContextMenuItemsOn( + browser, + "#pdfjs_internal_editor_3" + ); + menuitems.get("context-pdfjs-copy").click(); + menuitems.get("context-pdfjs-paste").click(); + await hideContextMenu(browser); + + Assert.equal( + await countElements(browser, ".freeTextEditor"), + 2, + "The FreeText editor must have been pasted" + ); + + menuitems = await getContextMenuItems(browser, spanBox); + menuitems.get("context-pdfjs-selectall").click(); + menuitems.get("context-pdfjs-delete").click(); + await hideContextMenu(browser); + + Assert.equal( + await countElements(browser, ".freeTextEditor"), + 0, + "All the FreeText editors must have been deleted" + ); + + await SpecialPowers.spawn(browser, [], async function() { + var viewer = content.wrappedJSObject.PDFViewerApplication; + await viewer.close(); + }); + } + ); +});