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();
+ });
+ }
+ );
+});