[Editor] Add the possibility to create an highlight from the context menu when some text is selected (bug 1867739)
This commit is contained in:
parent
72b8b29147
commit
e1f6f5179f
@ -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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -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 {
|
||||||
|
@ -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
|
||||||
);
|
);
|
||||||
|
@ -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);
|
||||||
|
@ -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");
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -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() {
|
||||||
|
@ -126,6 +126,14 @@ class Toolbar {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
eventBus._on("showannotationeditorui", ({ mode }) => {
|
||||||
|
switch (mode) {
|
||||||
|
case AnnotationEditorType.HIGHLIGHT:
|
||||||
|
options.editorHighlightButton.click();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
this.reset();
|
this.reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user