diff --git a/src/display/editor/annotation_editor_layer.js b/src/display/editor/annotation_editor_layer.js index 72f705d29..4c3d8fb09 100644 --- a/src/display/editor/annotation_editor_layer.js +++ b/src/display/editor/annotation_editor_layer.js @@ -184,6 +184,10 @@ class AnnotationEditorLayer { this.div.hidden = false; } + hasTextLayer(textLayer) { + return textLayer === this.#textLayer?.div; + } + addInkEditorIfNeeded(isCommitting) { if (this.#uiManager.getMode() !== AnnotationEditorType.INK) { // We don't want to add an ink editor if we're not in ink mode! @@ -721,78 +725,13 @@ class AnnotationEditorLayer { * @param {PointerEvent} event */ pointerUpAfterSelection(event) { - const selection = document.getSelection(); - if (selection.rangeCount === 0) { - return; - } - const range = selection.getRangeAt(0); - if (range.collapsed) { - return; - } - - if (!this.#textLayer?.div.contains(range.commonAncestorContainer)) { - return; - } - - const { - x: layerX, - y: layerY, - width: parentWidth, - height: parentHeight, - } = this.#textLayer.div.getBoundingClientRect(); - const bboxes = range.getClientRects(); - - // We must rotate the boxes because we want to have them in the non-rotated - // page coordinates. - let rotator; - switch (this.viewport.rotation) { - case 90: - rotator = (x, y, w, h) => ({ - x: (y - layerY) / parentHeight, - y: 1 - (x + w - layerX) / parentWidth, - width: h / parentHeight, - height: w / parentWidth, - }); - break; - case 180: - rotator = (x, y, w, h) => ({ - x: 1 - (x + w - layerX) / parentWidth, - y: 1 - (y + h - layerY) / parentHeight, - width: w / parentWidth, - height: h / parentHeight, - }); - break; - case 270: - rotator = (x, y, w, h) => ({ - x: 1 - (y + h - layerY) / parentHeight, - y: (x - layerX) / parentWidth, - width: h / parentHeight, - height: w / parentWidth, - }); - break; - default: - rotator = (x, y, w, h) => ({ - x: (x - layerX) / parentWidth, - y: (y - layerY) / parentHeight, - width: w / parentWidth, - height: h / parentHeight, - }); - break; - } - - const boxes = []; - for (const { x, y, width, height } of bboxes) { - if (width === 0 || height === 0) { - continue; - } - boxes.push(rotator(x, y, width, height)); - } - if (boxes.length !== 0) { + const boxes = this.#uiManager.getSelectionBoxes(this.#textLayer?.div); + if (boxes) { this.createAndAddNewEditor(event, false, { boxes, }); + document.getSelection().empty(); } - selection.empty(); } /** diff --git a/src/display/editor/tools.js b/src/display/editor/tools.js index e6ee222c5..09c9b14f3 100644 --- a/src/display/editor/tools.js +++ b/src/display/editor/tools.js @@ -551,6 +551,8 @@ class AnnotationEditorUIManager { #focusMainContainerTimeoutId = null; + #hasSelection = false; + #highlightColors = null; #idManager = new IdManager(); @@ -569,6 +571,8 @@ class AnnotationEditorUIManager { #selectedEditors = new Set(); + #selectedTextNode = null; + #pageColors = null; #boundBlur = this.blur.bind(this); @@ -591,6 +595,8 @@ class AnnotationEditorUIManager { #boundOnScaleChanging = this.onScaleChanging.bind(this); + #boundSelectionChange = this.#selectionChange.bind(this); + #boundOnRotationChanging = this.onRotationChanging.bind(this); #previousStates = { @@ -599,6 +605,7 @@ class AnnotationEditorUIManager { hasSomethingToUndo: false, hasSomethingToRedo: false, hasSelectedEditor: false, + hasSelectedText: false, }; #translation = [0, 0]; @@ -762,6 +769,7 @@ class AnnotationEditorUIManager { this._eventBus._on("pagechanging", this.#boundOnPageChanging); this._eventBus._on("scalechanging", this.#boundOnScaleChanging); this._eventBus._on("rotationchanging", this.#boundOnRotationChanging); + this.#addSelectionListener(); this.#annotationStorage = pdfDocument.annotationStorage; this.#filterFactory = pdfDocument.filterFactory; this.#pageColors = pageColors; @@ -799,6 +807,7 @@ class AnnotationEditorUIManager { clearTimeout(this.#translationTimeoutId); this.#translationTimeoutId = null; } + this.#removeSelectionListener(); } async mlGuess(data) { @@ -905,6 +914,33 @@ class AnnotationEditorUIManager { this.viewParameters.rotation = pagesRotation; } + highlightSelection() { + const selection = document.getSelection(); + if (!selection || selection.isCollapsed) { + return; + } + const { anchorNode } = selection; + const anchorElement = + anchorNode.nodeType === Node.TEXT_NODE + ? anchorNode.parentElement + : anchorNode; + const textLayer = anchorElement.closest(".textLayer"); + const boxes = this.getSelectionBoxes(textLayer); + selection.empty(); + if (this.#mode === AnnotationEditorType.NONE) { + this._eventBus.dispatch("showannotationeditorui", { + source: this, + mode: AnnotationEditorType.HIGHLIGHT, + }); + } + for (const layer of this.#allLayers.values()) { + if (layer.hasTextLayer(textLayer)) { + layer.createAndAddNewEditor({ x: 0, y: 0 }, false, { boxes }); + break; + } + } + } + /** * Add an editor in the annotation storage. * @param {AnnotationEditor} editor @@ -919,6 +955,52 @@ class AnnotationEditorUIManager { } } + #selectionChange() { + const selection = document.getSelection(); + if (!selection || selection.isCollapsed) { + if (this.#hasSelection) { + this.#hasSelection = false; + this.#selectedTextNode = null; + this.#dispatchUpdateStates({ + hasSelectedText: false, + }); + } + return; + } + const { anchorNode } = selection; + if (anchorNode === this.#selectedTextNode) { + return; + } + + const anchorElement = + anchorNode.nodeType === Node.TEXT_NODE + ? anchorNode.parentElement + : anchorNode; + if (!anchorElement.closest(".textLayer")) { + if (this.#hasSelection) { + this.#hasSelection = false; + this.#selectedTextNode = null; + this.#dispatchUpdateStates({ + hasSelectedText: false, + }); + } + return; + } + this.#hasSelection = true; + this.#selectedTextNode = anchorNode; + this.#dispatchUpdateStates({ + hasSelectedText: true, + }); + } + + #addSelectionListener() { + document.addEventListener("selectionchange", this.#boundSelectionChange); + } + + #removeSelectionListener() { + document.removeEventListener("selectionchange", this.#boundSelectionChange); + } + #addFocusManager() { window.addEventListener("focus", this.#boundFocus); window.addEventListener("blur", this.#boundBlur); @@ -1127,7 +1209,11 @@ class AnnotationEditorUIManager { * @param {Object} details */ onEditingAction(details) { - if (["undo", "redo", "delete", "selectAll"].includes(details.name)) { + if ( + ["undo", "redo", "delete", "selectAll", "highlightSelection"].includes( + details.name + ) + ) { this[details.name](); } } @@ -1916,6 +2002,80 @@ class AnnotationEditorUIManager { get imageManager() { return shadow(this, "imageManager", new ImageManager()); } + + getSelectionBoxes(textLayer) { + if (!textLayer) { + return null; + } + const selection = document.getSelection(); + for (let i = 0, ii = selection.rangeCount; i < ii; i++) { + if ( + !textLayer.contains(selection.getRangeAt(i).commonAncestorContainer) + ) { + return null; + } + } + + const { + x: layerX, + y: layerY, + width: parentWidth, + height: parentHeight, + } = textLayer.getBoundingClientRect(); + + // We must rotate the boxes because we want to have them in the non-rotated + // page coordinates. + let rotator; + switch (textLayer.getAttribute("data-main-rotation")) { + case "90": + rotator = (x, y, w, h) => ({ + x: (y - layerY) / parentHeight, + y: 1 - (x + w - layerX) / parentWidth, + width: h / parentHeight, + height: w / parentWidth, + }); + break; + case "180": + rotator = (x, y, w, h) => ({ + x: 1 - (x + w - layerX) / parentWidth, + y: 1 - (y + h - layerY) / parentHeight, + width: w / parentWidth, + height: h / parentHeight, + }); + break; + case "270": + rotator = (x, y, w, h) => ({ + x: 1 - (y + h - layerY) / parentHeight, + y: (x - layerX) / parentWidth, + width: h / parentHeight, + height: w / parentWidth, + }); + break; + default: + rotator = (x, y, w, h) => ({ + x: (x - layerX) / parentWidth, + y: (y - layerY) / parentHeight, + width: w / parentWidth, + height: h / parentHeight, + }); + break; + } + + const boxes = []; + for (let i = 0, ii = selection.rangeCount; i < ii; i++) { + const range = selection.getRangeAt(i); + if (range.collapsed) { + continue; + } + for (const { x, y, width, height } of range.getClientRects()) { + if (width === 0 || height === 0) { + continue; + } + boxes.push(rotator(x, y, width, height)); + } + } + return boxes.length === 0 ? null : boxes; + } } export { diff --git a/test/integration/caret_browsing_spec.mjs b/test/integration/caret_browsing_spec.mjs index ba20f1e02..c7c591cc3 100644 --- a/test/integration/caret_browsing_spec.mjs +++ b/test/integration/caret_browsing_spec.mjs @@ -18,7 +18,7 @@ import { closePages, loadAndWait } from "./test_utils.mjs"; const waitForSelectionChange = (page, selection) => page.waitForFunction( // We need to replace EOL on Windows to make the test pass. - sel => window.getSelection().toString().replaceAll("\r\n", "\n") === sel, + sel => document.getSelection().toString().replaceAll("\r\n", "\n") === sel, {}, selection ); diff --git a/test/integration/freetext_editor_spec.mjs b/test/integration/freetext_editor_spec.mjs index f3cff4cea..15c7d9496 100644 --- a/test/integration/freetext_editor_spec.mjs +++ b/test/integration/freetext_editor_spec.mjs @@ -2829,7 +2829,7 @@ describe("FreeText Editor", () => { count: 3, }); const selection = await page.evaluate(() => - window.getSelection().toString() + document.getSelection().toString() ); expect(selection).withContext(`In ${browserName}`).toEqual(data); diff --git a/test/integration/highlight_editor_spec.mjs b/test/integration/highlight_editor_spec.mjs index 522f37bbd..984d54b0c 100644 --- a/test/integration/highlight_editor_spec.mjs +++ b/test/integration/highlight_editor_spec.mjs @@ -943,4 +943,91 @@ describe("Highlight Editor", () => { ); }); }); + + describe("Send a message when some text is selected", () => { + let pages; + + beforeAll(async () => { + pages = await loadAndWait( + "tracemonkey.pdf", + `.page[data-page-number = "1"] .endOfContent`, + null, + async page => { + await page.waitForFunction(async () => { + await window.PDFViewerApplication.initializedPromise; + return true; + }); + await page.evaluate(() => { + window.editingEvents = []; + window.PDFViewerApplication.eventBus.on( + "annotationeditorstateschanged", + ({ details }) => { + window.editingEvents.push(details); + } + ); + }); + }, + { highlightEditorColors: "red=#AB0000" } + ); + }); + + afterAll(async () => { + await closePages(pages); + }); + + it("must check that a message is sent on selection", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + const rect = await getSpanRectFromText(page, 1, "Abstract"); + const x = rect.x + rect.width / 2; + const y = rect.y + rect.height / 2; + await page.mouse.click(x, y, { count: 2 }); + await page.waitForFunction(() => window.editingEvents.length > 0); + + let editingEvent = await page.evaluate(() => { + const e = window.editingEvents[0]; + window.editingEvents.length = 0; + return e; + }); + expect(editingEvent.isEditing) + .withContext(`In ${browserName}`) + .toBe(false); + expect(editingEvent.hasSelectedText) + .withContext(`In ${browserName}`) + .toBe(true); + + // Click somewhere to unselect the current selection. + await page.mouse.click(rect.x + rect.width + 10, y, { count: 1 }); + await page.waitForFunction(() => window.editingEvents.length > 0); + editingEvent = await page.evaluate(() => { + const e = window.editingEvents[0]; + window.editingEvents.length = 0; + return e; + }); + expect(editingEvent.hasSelectedText) + .withContext(`In ${browserName}`) + .toBe(false); + + await page.mouse.click(x, y, { count: 2 }); + await page.waitForFunction(() => window.editingEvents.length > 0); + + await page.evaluate(() => { + window.PDFViewerApplication.eventBus.dispatch("editingaction", { + name: "highlightSelection", + }); + }); + + await page.waitForSelector(getEditorSelector(0)); + const usedColor = await page.evaluate(() => { + const highlight = document.querySelector( + `.page[data-page-number = "1"] .canvasWrapper > svg.highlight` + ); + return highlight.getAttribute("fill"); + }); + + expect(usedColor).withContext(`In ${browserName}`).toEqual("#AB0000"); + }) + ); + }); + }); }); diff --git a/web/pdf_presentation_mode.js b/web/pdf_presentation_mode.js index 49cc15be8..e916693a9 100644 --- a/web/pdf_presentation_mode.js +++ b/web/pdf_presentation_mode.js @@ -188,7 +188,7 @@ class PDFPresentationMode { // Text selection is disabled in Presentation Mode, thus it's not possible // for the user to deselect text that is selected (e.g. with "Select all") // when entering Presentation Mode, hence we remove any active selection. - window.getSelection().removeAllRanges(); + document.getSelection().empty(); } #exit() { diff --git a/web/toolbar.js b/web/toolbar.js index 45570687f..d59f30556 100644 --- a/web/toolbar.js +++ b/web/toolbar.js @@ -126,6 +126,14 @@ class Toolbar { ); } + eventBus._on("showannotationeditorui", ({ mode }) => { + switch (mode) { + case AnnotationEditorType.HIGHLIGHT: + options.editorHighlightButton.click(); + break; + } + }); + this.reset(); }