[Editor] Add editors with a comment in the sidebar

and add a button close to the editor.
Clicking on the button will display a popup with the comment but it's for a next patch.
This commit is contained in:
Calixte Denizet 2025-08-30 18:56:21 +02:00
parent 9855d85fb5
commit 2a459857ce
10 changed files with 264 additions and 49 deletions

View File

@ -643,6 +643,15 @@ class AnnotationEditorLayer {
this.addCommands({ cmd, undo, mustExec: false });
}
getEditorByUID(uid) {
for (const editor of this.#editors.values()) {
if (editor.uid === uid) {
return editor;
}
}
return null;
}
/**
* Get an id for an editor.
* @returns {string}

View File

@ -16,7 +16,9 @@
import { noContextMenu } from "../display_utils.js";
class Comment {
#commentButton = null;
#commentStandaloneButton = null;
#commentToolbarButton = null;
#commentWasFromKeyBoard = false;
@ -32,16 +34,40 @@ class Comment {
constructor(editor) {
this.#editor = editor;
this.toolbar = null;
}
render() {
renderForToolbar() {
const button = (this.#commentToolbarButton =
document.createElement("button"));
button.className = "comment";
return this.#render(button);
}
renderForStandalone() {
const button = (this.#commentStandaloneButton =
document.createElement("button"));
button.className = "annotationCommentButton";
const position = this.#editor.commentButtonPosition;
if (position) {
const { style } = button;
style.insetInlineEnd = `calc(${
100 *
(this.#editor._uiManager.direction === "ltr"
? 1 - position[0]
: position[0])
}% - var(--comment-button-dim))`;
style.top = `calc(${100 * position[1]}% - var(--comment-button-dim))`;
}
return this.#render(button);
}
#render(comment) {
if (!this.#editor._uiManager.hasCommentManager()) {
return null;
}
const comment = (this.#commentButton = document.createElement("button"));
comment.className = "comment";
comment.tabIndex = "0";
comment.setAttribute("data-l10n-id", "pdfjs-editor-edit-comment-button");
@ -57,7 +83,11 @@ class Comment {
const onClick = event => {
event.preventDefault();
this.edit();
if (comment === this.#commentToolbarButton) {
this.edit();
} else {
this.#editor._uiManager.toggleComment(this.#editor);
}
};
comment.addEventListener("click", onClick, { capture: true, signal });
comment.addEventListener(
@ -86,10 +116,12 @@ class Comment {
}
finish() {
if (!this.#commentButton) {
if (!this.#commentToolbarButton) {
return;
}
this.#commentButton.focus({ focusVisible: this.#commentWasFromKeyBoard });
this.#commentToolbarButton.focus({
focusVisible: this.#commentWasFromKeyBoard,
});
this.#commentWasFromKeyBoard = false;
}
@ -132,18 +164,13 @@ class Comment {
this.data = text;
}
toggle(enabled = false) {
if (!this.#commentButton) {
return;
}
this.#commentButton.disabled = !enabled;
}
shown() {}
destroy() {
this.#commentButton?.remove();
this.#commentButton = null;
this.#commentToolbarButton?.remove();
this.#commentToolbarButton = null;
this.#commentStandaloneButton?.remove();
this.#commentStandaloneButton = null;
this.#text = "";
this.#date = null;
this.#editor = null;

View File

@ -480,6 +480,7 @@ class FreeDrawOutline extends Outline {
this.#scaleFactor = scaleFactor;
this.#innerMargin = innerMargin;
this.#isLTR = isLTR;
this.firstPoint = [NaN, NaN];
this.lastPoint = [NaN, NaN];
this.#computeMinMax(isLTR);
@ -560,9 +561,12 @@ class FreeDrawOutline extends Outline {
let lastX = outline[4];
let lastY = outline[5];
const minMax = [lastX, lastY, lastX, lastY];
let firstPointX = lastX;
let firstPointY = lastY;
let lastPointX = lastX;
let lastPointY = lastY;
const ltrCallback = isLTR ? Math.max : Math.min;
const bezierBbox = new Float32Array(4);
for (let i = 6, ii = outline.length; i < ii; i += 6) {
const x = outline[i + 4],
@ -571,6 +575,12 @@ class FreeDrawOutline extends Outline {
if (isNaN(outline[i])) {
Util.pointBoundingBox(x, y, minMax);
if (firstPointY > y) {
firstPointX = x;
firstPointY = y;
} else if (firstPointY === y) {
firstPointX = ltrCallback(firstPointX, x);
}
if (lastPointY < y) {
lastPointX = x;
lastPointY = y;
@ -578,16 +588,34 @@ class FreeDrawOutline extends Outline {
lastPointX = ltrCallback(lastPointX, x);
}
} else {
const bbox = [Infinity, Infinity, -Infinity, -Infinity];
Util.bezierBoundingBox(lastX, lastY, ...outline.slice(i, i + 6), bbox);
bezierBbox[0] = bezierBbox[1] = Infinity;
bezierBbox[2] = bezierBbox[3] = -Infinity;
Util.bezierBoundingBox(
lastX,
lastY,
...outline.slice(i, i + 6),
bezierBbox
);
Util.rectBoundingBox(...bbox, minMax);
Util.rectBoundingBox(
bezierBbox[0],
bezierBbox[1],
bezierBbox[2],
bezierBbox[3],
minMax
);
if (lastPointY < bbox[3]) {
lastPointX = bbox[2];
lastPointY = bbox[3];
} else if (lastPointY === bbox[3]) {
lastPointX = ltrCallback(lastPointX, bbox[2]);
if (firstPointY > bezierBbox[1]) {
firstPointX = bezierBbox[0];
firstPointY = bezierBbox[1];
} else if (firstPointY === bezierBbox[1]) {
firstPointX = ltrCallback(firstPointX, bezierBbox[0]);
}
if (lastPointY < bezierBbox[3]) {
lastPointX = bezierBbox[2];
lastPointY = bezierBbox[3];
} else if (lastPointY === bezierBbox[3]) {
lastPointX = ltrCallback(lastPointX, bezierBbox[2]);
}
}
lastX = x;
@ -599,6 +627,7 @@ class FreeDrawOutline extends Outline {
bbox[1] = minMax[1] - this.#innerMargin;
bbox[2] = minMax[2] - minMax[0] + 2 * this.#innerMargin;
bbox[3] = minMax[3] - minMax[1] + 2 * this.#innerMargin;
this.firstPoint = [firstPointX, firstPointY];
this.lastPoint = [lastPointX, lastPointY];
}

View File

@ -20,6 +20,8 @@ import { Util } from "../../../shared/util.js";
class HighlightOutliner {
#box;
#firstPoint;
#lastPoint;
#verticalEdges = [];
@ -63,12 +65,30 @@ class HighlightOutliner {
const bboxHeight = minMax[3] - minMax[1] + 2 * innerMargin;
const shiftedMinX = minMax[0] - innerMargin;
const shiftedMinY = minMax[1] - innerMargin;
let firstPointX = isLTR ? -Infinity : Infinity;
let firstPointY = Infinity;
const lastEdge = this.#verticalEdges.at(isLTR ? -1 : -2);
const lastPoint = [lastEdge[0], lastEdge[2]];
// Convert the coordinates of the edges into box coordinates.
for (const edge of this.#verticalEdges) {
const [x, y1, y2] = edge;
const [x, y1, y2, left] = edge;
if (!left && isLTR) {
if (y1 < firstPointY) {
firstPointY = y1;
firstPointX = x;
} else if (y1 === firstPointY) {
firstPointX = Math.max(firstPointX, x);
}
} else if (left && !isLTR) {
if (y1 < firstPointY) {
firstPointY = y1;
firstPointX = x;
} else if (y1 === firstPointY) {
firstPointX = Math.min(firstPointX, x);
}
}
edge[0] = (x - shiftedMinX) / bboxWidth;
edge[1] = (y1 - shiftedMinY) / bboxHeight;
edge[2] = (y2 - shiftedMinY) / bboxHeight;
@ -80,6 +100,7 @@ class HighlightOutliner {
bboxWidth,
bboxHeight,
]);
this.#firstPoint = [firstPointX, firstPointY];
this.#lastPoint = lastPoint;
}
@ -170,7 +191,12 @@ class HighlightOutliner {
}
outline.push(lastPointX, lastPointY);
}
return new HighlightOutline(outlines, this.#box, this.#lastPoint);
return new HighlightOutline(
outlines,
this.#box,
this.#firstPoint,
this.#lastPoint
);
}
#binarySearch(y) {
@ -264,10 +290,11 @@ class HighlightOutline extends Outline {
#outlines;
constructor(outlines, box, lastPoint) {
constructor(outlines, box, firstPoint, lastPoint) {
super();
this.#outlines = outlines;
this.#box = box;
this.firstPoint = firstPoint;
this.lastPoint = lastPoint;
}

View File

@ -55,6 +55,8 @@ class AnnotationEditor {
#comment = null;
#commentStandaloneButton = null;
#disabled = false;
#dragPointerId = null;
@ -184,6 +186,7 @@ class AnnotationEditor {
this._initialOptions.isCentered = parameters.isCentered;
this._structTreeParentId = null;
this.annotationElementId = parameters.annotationElementId || null;
this.creationDate = new Date();
const {
rotation,
@ -313,6 +316,10 @@ class AnnotationEditor {
this.div?.classList.toggle("draggable", value);
}
get uid() {
return this.annotationElementId || this.id;
}
/**
* @returns {boolean} true if the editor handles the Enter key itself.
*/
@ -1166,10 +1173,21 @@ class AnnotationEditor {
}
addCommentButton() {
if (this.#comment) {
return this.#comment;
return (this.#comment ||= new Comment(this));
}
addStandaloneCommentButton() {
this.#comment ||= new Comment(this);
if (this.#commentStandaloneButton) {
return;
}
return (this.#comment = new Comment(this));
this.#commentStandaloneButton = this.#comment.renderForStandalone();
this.div.append(this.#commentStandaloneButton);
}
removeStandaloneCommentButton() {
this.#commentStandaloneButton?.remove();
this.#commentStandaloneButton = null;
}
get commentColor() {
@ -1204,6 +1222,10 @@ class AnnotationEditor {
return this.#comment?.hasBeenEdited();
}
get hasComment() {
return !!this.#comment && !this.#comment.isDeleted();
}
async editComment() {
if (!this.#comment) {
this.#comment = new Comment(this);
@ -1211,6 +1233,8 @@ class AnnotationEditor {
this.#comment.edit();
}
showComment() {}
addComment(serialized) {
if (this.hasEditedComment) {
const DEFAULT_POPUP_WIDTH = 180;
@ -1581,6 +1605,17 @@ class AnnotationEditor {
return this.getRect(0, 0);
}
getData() {
return {
id: this.uid,
pageIndex: this.pageIndex,
rect: this.getPDFRect(),
contentsObj: { str: this.comment.text },
creationDate: this.creationDate,
popupRef: !this.#comment.isDeleted(),
};
}
/**
* Executed once this editor has been rendered.
* @param {boolean} focus - true if the editor should be focused.
@ -1814,6 +1849,14 @@ class AnnotationEditor {
return null;
}
/**
* Get the position of the comment button.
* @returns {Array<number>|null}
*/
get commentButtonPosition() {
return this._uiManager.direction === "ltr" ? [1, 0] : [0, 0];
}
/**
* onkeydown callback.
* @param {KeyboardEvent} event

View File

@ -60,6 +60,8 @@ class HighlightEditor extends AnnotationEditor {
#isFreeHighlight = false;
#firstPoint = null;
#lastPoint = null;
#opacity;
@ -177,7 +179,11 @@ class HighlightEditor extends AnnotationEditor {
this.#focusOutlines = outlinerForOutline.getOutlines();
// The last point is in the pages coordinate system.
const { lastPoint } = this.#focusOutlines;
const { firstPoint, lastPoint } = this.#focusOutlines;
this.#firstPoint = [
(firstPoint[0] - this.x) / this.width,
(firstPoint[1] - this.y) / this.height,
];
this.#lastPoint = [
(lastPoint[0] - this.x) / this.width,
(lastPoint[1] - this.y) / this.height,
@ -268,7 +274,11 @@ class HighlightEditor extends AnnotationEditor {
}
}
const { lastPoint } = this.#focusOutlines;
const { firstPoint, lastPoint } = this.#focusOutlines;
this.#firstPoint = [
(firstPoint[0] - x) / width,
(firstPoint[1] - y) / height,
];
this.#lastPoint = [(lastPoint[0] - x) / width, (lastPoint[1] - y) / height];
}
@ -299,6 +309,11 @@ class HighlightEditor extends AnnotationEditor {
return this.#lastPoint;
}
/** @inheritdoc */
get commentButtonPosition() {
return this.#firstPoint;
}
/** @inheritdoc */
updateParams(type, value) {
switch (type) {

View File

@ -162,7 +162,7 @@ class EditorToolbar {
if (this.#comment) {
return;
}
const button = comment.render();
const button = comment.renderForToolbar();
if (!button) {
return;
}

View File

@ -896,6 +896,7 @@ class AnnotationEditorUIManager {
this.isShiftKeyDown = false;
this._editorUndoBar = editorUndoBar || null;
this._supportsPinchToZoom = supportsPinchToZoom !== false;
commentManager?.setSidebarUiManager(this);
if (typeof PDFJSDev !== "undefined" && PDFJSDev.test("TESTING")) {
Object.defineProperty(this, "reset", {
@ -1071,6 +1072,27 @@ class AnnotationEditorUIManager {
this.#commentManager?.open(this, editor, position);
}
showComment(pageIndex, uid) {
const layer = this.#allLayers.get(pageIndex);
const editor = layer?.getEditorByUID(uid);
editor?.showComment();
}
async waitForPageRendered(pageNumber) {
if (this.#allLayers.has(pageNumber - 1)) {
return;
}
const { resolve, promise } = Promise.withResolvers();
const onPageRendered = evt => {
if (evt.pageNumber === pageNumber) {
this._eventBus._off("annotationeditorlayerrendered", onPageRendered);
resolve();
}
};
this._eventBus.on("annotationeditorlayerrendered", onPageRendered);
await promise;
}
getSignature(editor) {
this.#signatureManager?.getSignature({ uiManager: this, editor });
}
@ -1799,6 +1821,9 @@ class AnnotationEditorUIManager {
if (this.#mode === AnnotationEditorType.POPUP) {
this.#commentManager?.hideSidebar();
for (const editor of this.#allEditors.values()) {
editor.removeStandaloneCommentButton();
}
}
this.#mode = mode;
@ -1815,13 +1840,6 @@ class AnnotationEditorUIManager {
if (mode === AnnotationEditorType.SIGNATURE) {
await this.#signatureManager?.loadSignatures();
}
if (mode === AnnotationEditorType.POPUP) {
this.#allEditableAnnotations ||=
await this.#pdfDocument.getAnnotationsByType(
new Set(this.#editorTypes.map(editorClass => editorClass._editorType))
);
this.#commentManager?.showSidebar(this.#allEditableAnnotations);
}
this.setEditingState(true);
await this.#enableAll();
@ -1829,6 +1847,40 @@ class AnnotationEditorUIManager {
for (const layer of this.#allLayers.values()) {
layer.updateMode(mode);
}
if (mode === AnnotationEditorType.POPUP) {
this.#allEditableAnnotations ||=
await this.#pdfDocument.getAnnotationsByType(
new Set(this.#editorTypes.map(editorClass => editorClass._editorType))
);
const elementIds = new Set();
const allComments = [];
for (const editor of this.#allEditors.values()) {
const { annotationElementId, hasComment, deleted } = editor;
if (annotationElementId) {
elementIds.add(annotationElementId);
}
if (hasComment && !deleted) {
allComments.push(editor.getData());
editor.addStandaloneCommentButton();
}
}
for (const annotation of this.#allEditableAnnotations) {
const { id, popupRef, contentsObj } = annotation;
if (
popupRef &&
contentsObj?.str &&
!elementIds.has(id) &&
!this.#deletedAnnotationsElementIds.has(id)
) {
// The annotation exists in the PDF and has a comment but there
// is no editor for it (anymore).
allComments.push(annotation);
}
}
this.#commentManager?.showSidebar(allComments);
}
if (!editId) {
if (isFromKeyboard) {
this.addNewEditorFromKeyboard();

View File

@ -251,7 +251,7 @@
}
}
.annotationLayer.disabled .annotationCommentButton {
.annotationLayer.disabled :is(.annotationCommentButton) {
display: none;
}

View File

@ -173,6 +173,10 @@ class CommentManager {
overlayManager.register(dialog);
}
setSidebarUiManager(uiManager) {
this.#sidebar.setUIManager(uiManager);
}
showSidebar(annotations) {
this.#sidebar.show(annotations);
}
@ -435,6 +439,8 @@ class CommentSidebar {
#idsToElements = null;
#uiManager = null;
constructor(
{
sidebar,
@ -472,12 +478,14 @@ class CommentSidebar {
this.#sidebar.hidden = true;
}
setUIManager(uiManager) {
this.#uiManager = uiManager;
}
show(annotations) {
this.#elementsToAnnotations = new WeakMap();
this.#idsToElements = new Map();
this.#annotations = annotations = annotations.filter(
a => a.popupRef && a.contentsObj?.str
);
this.#annotations = annotations;
annotations.sort(this.#sortComments.bind(this));
if (annotations.length !== 0) {
const fragment = document.createDocumentFragment();
@ -621,6 +629,7 @@ class CommentSidebar {
#createCommentElement(annotation) {
const {
id,
creationDate,
modificationDate,
contentsObj: { str: text },
@ -646,11 +655,11 @@ class CommentSidebar {
commentItem.addEventListener("keydown", this.#boundCommentKeydown);
this.#elementsToAnnotations.set(commentItem, annotation);
this.#idsToElements.set(annotation.id, commentItem);
this.#idsToElements.set(id, commentItem);
return commentItem;
}
#commentClick({ currentTarget }) {
async #commentClick({ currentTarget }) {
if (currentTarget.classList.contains("selected")) {
return;
}
@ -658,14 +667,18 @@ class CommentSidebar {
if (!annotation) {
return;
}
const { pageIndex, rect } = annotation;
const { id, pageIndex, rect } = annotation;
const SPACE_ABOVE_ANNOTATION = 10;
const pageNumber = pageIndex + 1;
const pageVisiblePromise = this.#uiManager?.waitForPageRendered(pageNumber);
this.#linkService?.goToXY(
pageIndex + 1,
pageNumber,
rect[0],
rect[3] + SPACE_ABOVE_ANNOTATION
);
this.selectComment(currentTarget);
await pageVisiblePromise;
this.#uiManager?.showComment(pageIndex, id);
}
#commentKeydown(e) {