[Editor] Add a floating button close to the selected text to highlight it (bug 1867742)

For now keep this feature behind a pref in order to make some experiments before
deciding to enable it.
This commit is contained in:
Calixte Denizet 2024-02-26 19:12:34 +01:00
parent eb160726ee
commit b4267cd294
9 changed files with 233 additions and 21 deletions

View File

@ -72,6 +72,10 @@
"type": "boolean", "type": "boolean",
"default": false "default": false
}, },
"enableHighlightFloatingButton": {
"type": "boolean",
"default": false
},
"highlightEditorColors": { "highlightEditorColors": {
"type": "string", "type": "string",
"default": "yellow=#FFFF98,green=#53FFBC,blue=#80EBFF,pink=#FFCBE6,red=#FF4F5F" "default": "yellow=#FFFF98,green=#53FFBC,blue=#80EBFF,pink=#FFCBE6,red=#FF4F5F"

View File

@ -318,6 +318,8 @@ pdfjs-editor-stamp-button-label = Add or edit images
pdfjs-editor-highlight-button = pdfjs-editor-highlight-button =
.title = Highlight .title = Highlight
pdfjs-editor-highlight-button-label = Highlight pdfjs-editor-highlight-button-label = Highlight
pdfjs-highlight-floating-button =
.title = Highlight
## Remove button for the various kind of editor. ## Remove button for the various kind of editor.

View File

@ -135,4 +135,80 @@ class EditorToolbar {
} }
} }
export { EditorToolbar }; class HighlightToolbar {
#buttons = null;
#toolbar = null;
#uiManager;
constructor(uiManager) {
this.#uiManager = uiManager;
}
#render() {
const editToolbar = (this.#toolbar = document.createElement("div"));
editToolbar.className = "editToolbar";
editToolbar.setAttribute("role", "toolbar");
editToolbar.addEventListener("contextmenu", noContextMenu);
const buttons = (this.#buttons = document.createElement("div"));
buttons.className = "buttons";
editToolbar.append(buttons);
this.#addHighlightButton();
return editToolbar;
}
#getLastPoint(boxes, isLTR) {
let lastY = 0;
let lastX = 0;
for (const box of boxes) {
const y = box.y + box.height;
if (y < lastY) {
continue;
}
const x = box.x + (isLTR ? box.width : 0);
if (y > lastY) {
lastX = x;
lastY = y;
continue;
}
if (isLTR) {
if (x > lastX) {
lastX = x;
}
} else if (x < lastX) {
lastX = x;
}
}
return [isLTR ? 1 - lastX : lastX, lastY];
}
show(parent, boxes, isLTR) {
const [x, y] = this.#getLastPoint(boxes, isLTR);
const { style } = (this.#toolbar ||= this.#render());
parent.append(this.#toolbar);
style.insetInlineEnd = `${100 * x}%`;
style.top = `calc(${100 * y}% + var(--editor-toolbar-vert-offset))`;
}
hide() {
this.#toolbar.remove();
}
#addHighlightButton() {
const button = document.createElement("button");
button.className = "highlightButton";
button.tabIndex = 0;
button.setAttribute("data-l10n-id", `pdfjs-highlight-floating-button`);
button.addEventListener("contextmenu", noContextMenu);
button.addEventListener("click", () => {
this.#uiManager.highlightSelection("floating_button");
});
this.#buttons.append(button);
}
}
export { EditorToolbar, HighlightToolbar };

View File

@ -33,6 +33,7 @@ import {
getRGB, getRGB,
PixelsPerInch, PixelsPerInch,
} from "../display_utils.js"; } from "../display_utils.js";
import { HighlightToolbar } from "./toolbar.js";
function bindEvents(obj, element, names) { function bindEvents(obj, element, names) {
for (const name of names) { for (const name of names) {
@ -555,6 +556,8 @@ class AnnotationEditorUIManager {
#editorsToRescale = new Set(); #editorsToRescale = new Set();
#enableHighlightFloatingButton = false;
#filterFactory = null; #filterFactory = null;
#focusMainContainerTimeoutId = null; #focusMainContainerTimeoutId = null;
@ -563,6 +566,8 @@ class AnnotationEditorUIManager {
#highlightWhenShiftUp = false; #highlightWhenShiftUp = false;
#highlightToolbar = null;
#idManager = new IdManager(); #idManager = new IdManager();
#isEnabled = false; #isEnabled = false;
@ -771,6 +776,7 @@ class AnnotationEditorUIManager {
pdfDocument, pdfDocument,
pageColors, pageColors,
highlightColors, highlightColors,
enableHighlightFloatingButton,
mlManager mlManager
) { ) {
this.#container = container; this.#container = container;
@ -782,10 +788,12 @@ class AnnotationEditorUIManager {
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.#addSelectionListener();
this.#addKeyboardManager();
this.#annotationStorage = pdfDocument.annotationStorage; this.#annotationStorage = pdfDocument.annotationStorage;
this.#filterFactory = pdfDocument.filterFactory; this.#filterFactory = pdfDocument.filterFactory;
this.#pageColors = pageColors; this.#pageColors = pageColors;
this.#highlightColors = highlightColors || null; this.#highlightColors = highlightColors || null;
this.#enableHighlightFloatingButton = enableHighlightFloatingButton;
this.#mlManager = mlManager || null; this.#mlManager = mlManager || null;
this.viewParameters = { this.viewParameters = {
realScale: PixelsPerInch.PDF_TO_CSS_UNITS, realScale: PixelsPerInch.PDF_TO_CSS_UNITS,
@ -821,6 +829,8 @@ class AnnotationEditorUIManager {
this.#selectedEditors.clear(); this.#selectedEditors.clear();
this.#commandManager.destroy(); this.#commandManager.destroy();
this.#altTextManager?.destroy(); this.#altTextManager?.destroy();
this.#highlightToolbar?.hide();
this.#highlightToolbar = null;
if (this.#focusMainContainerTimeoutId) { if (this.#focusMainContainerTimeoutId) {
clearTimeout(this.#focusMainContainerTimeoutId); clearTimeout(this.#focusMainContainerTimeoutId);
this.#focusMainContainerTimeoutId = null; this.#focusMainContainerTimeoutId = null;
@ -946,6 +956,12 @@ class AnnotationEditorUIManager {
this.viewParameters.rotation = pagesRotation; this.viewParameters.rotation = pagesRotation;
} }
#getAnchorElementForSelection({ anchorNode }) {
return anchorNode.nodeType === Node.TEXT_NODE
? anchorNode.parentElement
: anchorNode;
}
highlightSelection(methodOfCreation = "") { highlightSelection(methodOfCreation = "") {
const selection = document.getSelection(); const selection = document.getSelection();
if (!selection || selection.isCollapsed) { if (!selection || selection.isCollapsed) {
@ -953,18 +969,19 @@ class AnnotationEditorUIManager {
} }
const { anchorNode, anchorOffset, focusNode, focusOffset } = selection; const { anchorNode, anchorOffset, focusNode, focusOffset } = selection;
const text = selection.toString(); const text = selection.toString();
const anchorElement = const anchorElement = this.#getAnchorElementForSelection(selection);
anchorNode.nodeType === Node.TEXT_NODE
? anchorNode.parentElement
: anchorNode;
const textLayer = anchorElement.closest(".textLayer"); const textLayer = anchorElement.closest(".textLayer");
const boxes = this.getSelectionBoxes(textLayer); const boxes = this.getSelectionBoxes(textLayer);
if (!boxes) {
return;
}
selection.empty(); selection.empty();
if (this.#mode === AnnotationEditorType.NONE) { if (this.#mode === AnnotationEditorType.NONE) {
this._eventBus.dispatch("showannotationeditorui", { this._eventBus.dispatch("showannotationeditorui", {
source: this, source: this,
mode: AnnotationEditorType.HIGHLIGHT, mode: AnnotationEditorType.HIGHLIGHT,
}); });
this.showAllEditors("highlight", true, /* updateButton = */ true);
} }
for (const layer of this.#allLayers.values()) { for (const layer of this.#allLayers.values()) {
if (layer.hasTextLayer(textLayer)) { if (layer.hasTextLayer(textLayer)) {
@ -982,6 +999,21 @@ class AnnotationEditorUIManager {
} }
} }
#displayHighlightToolbar() {
const selection = document.getSelection();
if (!selection || selection.isCollapsed) {
return;
}
const anchorElement = this.#getAnchorElementForSelection(selection);
const textLayer = anchorElement.closest(".textLayer");
const boxes = this.getSelectionBoxes(textLayer);
if (!boxes) {
return;
}
this.#highlightToolbar ||= new HighlightToolbar(this);
this.#highlightToolbar.show(textLayer, boxes, this.direction === "ltr");
}
/** /**
* Add an editor in the annotation storage. * Add an editor in the annotation storage.
* @param {AnnotationEditor} editor * @param {AnnotationEditor} editor
@ -1000,6 +1032,7 @@ class AnnotationEditorUIManager {
const selection = document.getSelection(); const selection = document.getSelection();
if (!selection || selection.isCollapsed) { if (!selection || selection.isCollapsed) {
if (this.#selectedTextNode) { if (this.#selectedTextNode) {
this.#highlightToolbar?.hide();
this.#selectedTextNode = null; this.#selectedTextNode = null;
this.#dispatchUpdateStates({ this.#dispatchUpdateStates({
hasSelectedText: false, hasSelectedText: false,
@ -1012,12 +1045,11 @@ class AnnotationEditorUIManager {
return; return;
} }
const anchorElement = const anchorElement = this.#getAnchorElementForSelection(selection);
anchorNode.nodeType === Node.TEXT_NODE const textLayer = anchorElement.closest(".textLayer");
? anchorNode.parentElement if (!textLayer) {
: anchorNode;
if (!anchorElement.closest(".textLayer")) {
if (this.#selectedTextNode) { if (this.#selectedTextNode) {
this.#highlightToolbar?.hide();
this.#selectedTextNode = null; this.#selectedTextNode = null;
this.#dispatchUpdateStates({ this.#dispatchUpdateStates({
hasSelectedText: false, hasSelectedText: false,
@ -1025,16 +1057,22 @@ class AnnotationEditorUIManager {
} }
return; return;
} }
this.#highlightToolbar?.hide();
this.#selectedTextNode = anchorNode; this.#selectedTextNode = anchorNode;
this.#dispatchUpdateStates({ this.#dispatchUpdateStates({
hasSelectedText: true, hasSelectedText: true,
}); });
if (this.#mode !== AnnotationEditorType.HIGHLIGHT) { if (
this.#mode !== AnnotationEditorType.HIGHLIGHT &&
this.#mode !== AnnotationEditorType.NONE
) {
return; return;
} }
if (this.#mode === AnnotationEditorType.HIGHLIGHT) {
this.showAllEditors("highlight", true, /* updateButton = */ true); this.showAllEditors("highlight", true, /* updateButton = */ true);
}
this.#highlightWhenShiftUp = this.isShiftKeyDown; this.#highlightWhenShiftUp = this.isShiftKeyDown;
if (!this.isShiftKeyDown) { if (!this.isShiftKeyDown) {
@ -1046,7 +1084,7 @@ class AnnotationEditorUIManager {
window.removeEventListener("pointerup", pointerup); window.removeEventListener("pointerup", pointerup);
window.removeEventListener("blur", pointerup); window.removeEventListener("blur", pointerup);
if (e.type === "pointerup") { if (e.type === "pointerup") {
this.highlightSelection("main_toolbar"); this.#onSelectEnd("main_toolbar");
} }
}; };
window.addEventListener("pointerup", pointerup); window.addEventListener("pointerup", pointerup);
@ -1054,6 +1092,14 @@ class AnnotationEditorUIManager {
} }
} }
#onSelectEnd(methodOfCreation = "") {
if (this.#mode === AnnotationEditorType.HIGHLIGHT) {
this.highlightSelection(methodOfCreation);
} else if (this.#enableHighlightFloatingButton) {
this.#displayHighlightToolbar();
}
}
#addSelectionListener() { #addSelectionListener() {
document.addEventListener("selectionchange", this.#boundSelectionChange); document.addEventListener("selectionchange", this.#boundSelectionChange);
} }
@ -1076,7 +1122,7 @@ class AnnotationEditorUIManager {
this.isShiftKeyDown = false; this.isShiftKeyDown = false;
if (this.#highlightWhenShiftUp) { if (this.#highlightWhenShiftUp) {
this.#highlightWhenShiftUp = false; this.#highlightWhenShiftUp = false;
this.highlightSelection("main_toolbar"); this.#onSelectEnd("main_toolbar");
} }
if (!this.hasSelection) { if (!this.hasSelection) {
return; return;
@ -1252,7 +1298,10 @@ class AnnotationEditorUIManager {
if (!this.isShiftKeyDown && event.key === "Shift") { if (!this.isShiftKeyDown && event.key === "Shift") {
this.isShiftKeyDown = true; this.isShiftKeyDown = true;
} }
if (!this.isEditorHandlingKeyboard) { if (
this.#mode !== AnnotationEditorType.NONE &&
!this.isEditorHandlingKeyboard
) {
AnnotationEditorUIManager._keyboardManager.exec(this, event); AnnotationEditorUIManager._keyboardManager.exec(this, event);
} }
} }
@ -1266,7 +1315,7 @@ class AnnotationEditorUIManager {
this.isShiftKeyDown = false; this.isShiftKeyDown = false;
if (this.#highlightWhenShiftUp) { if (this.#highlightWhenShiftUp) {
this.#highlightWhenShiftUp = false; this.#highlightWhenShiftUp = false;
this.highlightSelection("main_toolbar"); this.#onSelectEnd("main_toolbar");
} }
} }
} }
@ -1335,7 +1384,6 @@ class AnnotationEditorUIManager {
setEditingState(isEditing) { setEditingState(isEditing) {
if (isEditing) { if (isEditing) {
this.#addFocusManager(); this.#addFocusManager();
this.#addKeyboardManager();
this.#addCopyPasteListeners(); this.#addCopyPasteListeners();
this.#dispatchUpdateStates({ this.#dispatchUpdateStates({
isEditing: this.#mode !== AnnotationEditorType.NONE, isEditing: this.#mode !== AnnotationEditorType.NONE,
@ -1346,7 +1394,6 @@ class AnnotationEditorUIManager {
}); });
} else { } else {
this.#removeFocusManager(); this.#removeFocusManager();
this.#removeKeyboardManager();
this.#removeCopyPasteListeners(); this.#removeCopyPasteListeners();
this.#dispatchUpdateStates({ this.#dispatchUpdateStates({
isEditing: false, isEditing: false,

View File

@ -46,8 +46,11 @@ const getXY = (page, selector) =>
return `${bbox.x}::${bbox.y}`; return `${bbox.x}::${bbox.y}`;
}, selector); }, selector);
const getSpanRectFromText = (page, pageNumber, text) => const getSpanRectFromText = async (page, pageNumber, text) => {
page.evaluate( await page.waitForSelector(
`.page[data-page-number="${pageNumber}"] > .textLayer .endOfContent`
);
return page.evaluate(
(number, content) => { (number, content) => {
for (const el of document.querySelectorAll( for (const el of document.querySelectorAll(
`.page[data-page-number="${number}"] > .textLayer > span` `.page[data-page-number="${number}"] > .textLayer > span`
@ -62,6 +65,7 @@ const getSpanRectFromText = (page, pageNumber, text) =>
pageNumber, pageNumber,
text text
); );
};
describe("Highlight Editor", () => { describe("Highlight Editor", () => {
describe("Editor must be removed without exception", () => { describe("Editor must be removed without exception", () => {
@ -1510,4 +1514,46 @@ describe("Highlight Editor", () => {
); );
}); });
}); });
describe("Highlight from floating highlight button", () => {
let pages;
beforeAll(async () => {
pages = await loadAndWait(
"tracemonkey.pdf",
".annotationEditorLayer",
null,
null,
{ highlightEditorColors: "red=#AB0000" }
);
});
afterAll(async () => {
await closePages(pages);
});
it("must check that clicking on the highlight floating button triggers an highlight", 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, delay: 100 });
await page.waitForSelector(".textLayer .highlightButton");
await page.click(".textLayer .highlightButton");
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

@ -195,10 +195,12 @@
} }
.annotationEditorLayer .annotationEditorLayer
:is(.freeTextEditor, .inkEditor, .stampEditor, .highlightEditor) { :is(.freeTextEditor, .inkEditor, .stampEditor, .highlightEditor),
.textLayer {
.editToolbar { .editToolbar {
--editor-toolbar-delete-image: url(images/editor-toolbar-delete.svg); --editor-toolbar-delete-image: url(images/editor-toolbar-delete.svg);
--editor-toolbar-bg-color: #f0f0f4; --editor-toolbar-bg-color: #f0f0f4;
--editor-toolbar-highlight-image: url(images/toolbarButton-editorHighlight.svg);
--editor-toolbar-fg-color: #2e2e56; --editor-toolbar-fg-color: #2e2e56;
--editor-toolbar-border-color: #8f8f9d; --editor-toolbar-border-color: #8f8f9d;
--editor-toolbar-hover-border-color: var(--editor-toolbar-border-color); --editor-toolbar-hover-border-color: var(--editor-toolbar-border-color);
@ -284,6 +286,25 @@
margin-inline: 2px; margin-inline: 2px;
} }
.highlightButton {
width: var(--editor-toolbar-height);
&::before {
content: "";
mask-image: var(--editor-toolbar-highlight-image);
mask-repeat: no-repeat;
mask-position: center;
display: inline-block;
background-color: var(--editor-toolbar-fg-color);
width: 100%;
height: 100%;
}
&:hover::before {
background-color: var(--editor-toolbar-hover-fg-color);
}
}
.delete { .delete {
width: var(--editor-toolbar-height); width: var(--editor-toolbar-height);

View File

@ -424,6 +424,9 @@ const PDFViewerApplication = {
annotationMode: AppOptions.get("annotationMode"), annotationMode: AppOptions.get("annotationMode"),
annotationEditorMode, annotationEditorMode,
annotationEditorHighlightColors: AppOptions.get("highlightEditorColors"), annotationEditorHighlightColors: AppOptions.get("highlightEditorColors"),
enableHighlightFloatingButton: AppOptions.get(
"enableHighlightFloatingButton"
),
imageResourcesPath: AppOptions.get("imageResourcesPath"), imageResourcesPath: AppOptions.get("imageResourcesPath"),
enablePrintAutoRotate: AppOptions.get("enablePrintAutoRotate"), enablePrintAutoRotate: AppOptions.get("enablePrintAutoRotate"),
maxCanvasPixels: AppOptions.get("maxCanvasPixels"), maxCanvasPixels: AppOptions.get("maxCanvasPixels"),

View File

@ -143,6 +143,14 @@ const defaultOptions = {
value: typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING"), value: typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING"),
kind: OptionKind.VIEWER + OptionKind.PREFERENCE, kind: OptionKind.VIEWER + OptionKind.PREFERENCE,
}, },
enableHighlightFloatingButton: {
// We'll probably want to make some experiments before enabling this
// in Firefox release, but it has to be temporary.
// TODO: remove it when unnecessary.
/** @type {boolean} */
value: typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING"),
kind: OptionKind.VIEWER + OptionKind.PREFERENCE,
},
enableML: { enableML: {
/** @type {boolean} */ /** @type {boolean} */
value: false, value: false,

View File

@ -214,6 +214,8 @@ class PDFViewer {
#copyCallbackBound = null; #copyCallbackBound = null;
#enableHighlightFloatingButton = false;
#enablePermissions = false; #enablePermissions = false;
#mlManager = null; #mlManager = null;
@ -282,6 +284,8 @@ class PDFViewer {
options.annotationEditorMode ?? AnnotationEditorType.NONE; options.annotationEditorMode ?? AnnotationEditorType.NONE;
this.#annotationEditorHighlightColors = this.#annotationEditorHighlightColors =
options.annotationEditorHighlightColors || null; options.annotationEditorHighlightColors || null;
this.#enableHighlightFloatingButton =
options.enableHighlightFloatingButton === true;
this.imageResourcesPath = options.imageResourcesPath || ""; this.imageResourcesPath = options.imageResourcesPath || "";
this.enablePrintAutoRotate = options.enablePrintAutoRotate || false; this.enablePrintAutoRotate = options.enablePrintAutoRotate || false;
if (typeof PDFJSDev === "undefined" || PDFJSDev.test("GENERIC")) { if (typeof PDFJSDev === "undefined" || PDFJSDev.test("GENERIC")) {
@ -863,6 +867,7 @@ class PDFViewer {
pdfDocument, pdfDocument,
this.pageColors, this.pageColors,
this.#annotationEditorHighlightColors, this.#annotationEditorHighlightColors,
this.#enableHighlightFloatingButton,
this.#mlManager this.#mlManager
); );
this.eventBus.dispatch("annotationeditoruimanager", { this.eventBus.dispatch("annotationeditoruimanager", {