[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 }); 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. * Get an id for an editor.
* @returns {string} * @returns {string}

View File

@ -16,7 +16,9 @@
import { noContextMenu } from "../display_utils.js"; import { noContextMenu } from "../display_utils.js";
class Comment { class Comment {
#commentButton = null; #commentStandaloneButton = null;
#commentToolbarButton = null;
#commentWasFromKeyBoard = false; #commentWasFromKeyBoard = false;
@ -32,16 +34,40 @@ class Comment {
constructor(editor) { constructor(editor) {
this.#editor = 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()) { if (!this.#editor._uiManager.hasCommentManager()) {
return null; return null;
} }
const comment = (this.#commentButton = document.createElement("button"));
comment.className = "comment";
comment.tabIndex = "0"; comment.tabIndex = "0";
comment.setAttribute("data-l10n-id", "pdfjs-editor-edit-comment-button"); comment.setAttribute("data-l10n-id", "pdfjs-editor-edit-comment-button");
@ -57,7 +83,11 @@ class Comment {
const onClick = event => { const onClick = event => {
event.preventDefault(); event.preventDefault();
if (comment === this.#commentToolbarButton) {
this.edit(); this.edit();
} else {
this.#editor._uiManager.toggleComment(this.#editor);
}
}; };
comment.addEventListener("click", onClick, { capture: true, signal }); comment.addEventListener("click", onClick, { capture: true, signal });
comment.addEventListener( comment.addEventListener(
@ -86,10 +116,12 @@ class Comment {
} }
finish() { finish() {
if (!this.#commentButton) { if (!this.#commentToolbarButton) {
return; return;
} }
this.#commentButton.focus({ focusVisible: this.#commentWasFromKeyBoard }); this.#commentToolbarButton.focus({
focusVisible: this.#commentWasFromKeyBoard,
});
this.#commentWasFromKeyBoard = false; this.#commentWasFromKeyBoard = false;
} }
@ -132,18 +164,13 @@ class Comment {
this.data = text; this.data = text;
} }
toggle(enabled = false) {
if (!this.#commentButton) {
return;
}
this.#commentButton.disabled = !enabled;
}
shown() {} shown() {}
destroy() { destroy() {
this.#commentButton?.remove(); this.#commentToolbarButton?.remove();
this.#commentButton = null; this.#commentToolbarButton = null;
this.#commentStandaloneButton?.remove();
this.#commentStandaloneButton = null;
this.#text = ""; this.#text = "";
this.#date = null; this.#date = null;
this.#editor = null; this.#editor = null;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -896,6 +896,7 @@ class AnnotationEditorUIManager {
this.isShiftKeyDown = false; this.isShiftKeyDown = false;
this._editorUndoBar = editorUndoBar || null; this._editorUndoBar = editorUndoBar || null;
this._supportsPinchToZoom = supportsPinchToZoom !== false; this._supportsPinchToZoom = supportsPinchToZoom !== false;
commentManager?.setSidebarUiManager(this);
if (typeof PDFJSDev !== "undefined" && PDFJSDev.test("TESTING")) { if (typeof PDFJSDev !== "undefined" && PDFJSDev.test("TESTING")) {
Object.defineProperty(this, "reset", { Object.defineProperty(this, "reset", {
@ -1071,6 +1072,27 @@ class AnnotationEditorUIManager {
this.#commentManager?.open(this, editor, position); 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) { getSignature(editor) {
this.#signatureManager?.getSignature({ uiManager: this, editor }); this.#signatureManager?.getSignature({ uiManager: this, editor });
} }
@ -1799,6 +1821,9 @@ class AnnotationEditorUIManager {
if (this.#mode === AnnotationEditorType.POPUP) { if (this.#mode === AnnotationEditorType.POPUP) {
this.#commentManager?.hideSidebar(); this.#commentManager?.hideSidebar();
for (const editor of this.#allEditors.values()) {
editor.removeStandaloneCommentButton();
}
} }
this.#mode = mode; this.#mode = mode;
@ -1815,13 +1840,6 @@ class AnnotationEditorUIManager {
if (mode === AnnotationEditorType.SIGNATURE) { if (mode === AnnotationEditorType.SIGNATURE) {
await this.#signatureManager?.loadSignatures(); 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); this.setEditingState(true);
await this.#enableAll(); await this.#enableAll();
@ -1829,6 +1847,40 @@ class AnnotationEditorUIManager {
for (const layer of this.#allLayers.values()) { for (const layer of this.#allLayers.values()) {
layer.updateMode(mode); 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 (!editId) {
if (isFromKeyboard) { if (isFromKeyboard) {
this.addNewEditorFromKeyboard(); this.addNewEditorFromKeyboard();

View File

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

View File

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