[Editor] Add the possibility to create an highlight from the context menu when some text is selected (bug 1867739)

This commit is contained in:
Calixte Denizet 2024-02-21 18:36:07 +01:00
parent 72b8b29147
commit e1f6f5179f
7 changed files with 266 additions and 72 deletions

View File

@ -184,6 +184,10 @@ class AnnotationEditorLayer {
this.div.hidden = false; this.div.hidden = false;
} }
hasTextLayer(textLayer) {
return textLayer === this.#textLayer?.div;
}
addInkEditorIfNeeded(isCommitting) { addInkEditorIfNeeded(isCommitting) {
if (this.#uiManager.getMode() !== AnnotationEditorType.INK) { if (this.#uiManager.getMode() !== AnnotationEditorType.INK) {
// We don't want to add an ink editor if we're not in ink mode! // 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 * @param {PointerEvent} event
*/ */
pointerUpAfterSelection(event) { pointerUpAfterSelection(event) {
const selection = document.getSelection(); const boxes = this.#uiManager.getSelectionBoxes(this.#textLayer?.div);
if (selection.rangeCount === 0) { if (boxes) {
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) {
this.createAndAddNewEditor(event, false, { this.createAndAddNewEditor(event, false, {
boxes, boxes,
}); });
document.getSelection().empty();
} }
selection.empty();
} }
/** /**

View File

@ -551,6 +551,8 @@ class AnnotationEditorUIManager {
#focusMainContainerTimeoutId = null; #focusMainContainerTimeoutId = null;
#hasSelection = false;
#highlightColors = null; #highlightColors = null;
#idManager = new IdManager(); #idManager = new IdManager();
@ -569,6 +571,8 @@ class AnnotationEditorUIManager {
#selectedEditors = new Set(); #selectedEditors = new Set();
#selectedTextNode = null;
#pageColors = null; #pageColors = null;
#boundBlur = this.blur.bind(this); #boundBlur = this.blur.bind(this);
@ -591,6 +595,8 @@ class AnnotationEditorUIManager {
#boundOnScaleChanging = this.onScaleChanging.bind(this); #boundOnScaleChanging = this.onScaleChanging.bind(this);
#boundSelectionChange = this.#selectionChange.bind(this);
#boundOnRotationChanging = this.onRotationChanging.bind(this); #boundOnRotationChanging = this.onRotationChanging.bind(this);
#previousStates = { #previousStates = {
@ -599,6 +605,7 @@ class AnnotationEditorUIManager {
hasSomethingToUndo: false, hasSomethingToUndo: false,
hasSomethingToRedo: false, hasSomethingToRedo: false,
hasSelectedEditor: false, hasSelectedEditor: false,
hasSelectedText: false,
}; };
#translation = [0, 0]; #translation = [0, 0];
@ -762,6 +769,7 @@ class AnnotationEditorUIManager {
this._eventBus._on("pagechanging", this.#boundOnPageChanging); this._eventBus._on("pagechanging", this.#boundOnPageChanging);
this._eventBus._on("scalechanging", this.#boundOnScaleChanging); this._eventBus._on("scalechanging", this.#boundOnScaleChanging);
this._eventBus._on("rotationchanging", this.#boundOnRotationChanging); this._eventBus._on("rotationchanging", this.#boundOnRotationChanging);
this.#addSelectionListener();
this.#annotationStorage = pdfDocument.annotationStorage; this.#annotationStorage = pdfDocument.annotationStorage;
this.#filterFactory = pdfDocument.filterFactory; this.#filterFactory = pdfDocument.filterFactory;
this.#pageColors = pageColors; this.#pageColors = pageColors;
@ -799,6 +807,7 @@ class AnnotationEditorUIManager {
clearTimeout(this.#translationTimeoutId); clearTimeout(this.#translationTimeoutId);
this.#translationTimeoutId = null; this.#translationTimeoutId = null;
} }
this.#removeSelectionListener();
} }
async mlGuess(data) { async mlGuess(data) {
@ -905,6 +914,33 @@ class AnnotationEditorUIManager {
this.viewParameters.rotation = pagesRotation; 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. * Add an editor in the annotation storage.
* @param {AnnotationEditor} editor * @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() { #addFocusManager() {
window.addEventListener("focus", this.#boundFocus); window.addEventListener("focus", this.#boundFocus);
window.addEventListener("blur", this.#boundBlur); window.addEventListener("blur", this.#boundBlur);
@ -1127,7 +1209,11 @@ class AnnotationEditorUIManager {
* @param {Object} details * @param {Object} details
*/ */
onEditingAction(details) { onEditingAction(details) {
if (["undo", "redo", "delete", "selectAll"].includes(details.name)) { if (
["undo", "redo", "delete", "selectAll", "highlightSelection"].includes(
details.name
)
) {
this[details.name](); this[details.name]();
} }
} }
@ -1916,6 +2002,80 @@ class AnnotationEditorUIManager {
get imageManager() { get imageManager() {
return shadow(this, "imageManager", new 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 { export {

View File

@ -18,7 +18,7 @@ import { closePages, loadAndWait } from "./test_utils.mjs";
const waitForSelectionChange = (page, selection) => const waitForSelectionChange = (page, selection) =>
page.waitForFunction( page.waitForFunction(
// We need to replace EOL on Windows to make the test pass. // 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 selection
); );

View File

@ -2829,7 +2829,7 @@ describe("FreeText Editor", () => {
count: 3, count: 3,
}); });
const selection = await page.evaluate(() => const selection = await page.evaluate(() =>
window.getSelection().toString() document.getSelection().toString()
); );
expect(selection).withContext(`In ${browserName}`).toEqual(data); expect(selection).withContext(`In ${browserName}`).toEqual(data);

View File

@ -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");
})
);
});
});
}); });

View File

@ -188,7 +188,7 @@ class PDFPresentationMode {
// Text selection is disabled in Presentation Mode, thus it's not possible // 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") // for the user to deselect text that is selected (e.g. with "Select all")
// when entering Presentation Mode, hence we remove any active selection. // when entering Presentation Mode, hence we remove any active selection.
window.getSelection().removeAllRanges(); document.getSelection().empty();
} }
#exit() { #exit() {

View File

@ -126,6 +126,14 @@ class Toolbar {
); );
} }
eventBus._on("showannotationeditorui", ({ mode }) => {
switch (mode) {
case AnnotationEditorType.HIGHLIGHT:
options.editorHighlightButton.click();
break;
}
});
this.reset(); this.reset();
} }