diff --git a/extensions/chromium/preferences_schema.json b/extensions/chromium/preferences_schema.json index 974b00730..91e6666a5 100644 --- a/extensions/chromium/preferences_schema.json +++ b/extensions/chromium/preferences_schema.json @@ -71,6 +71,10 @@ "type": "string", "default": "" }, + "commentLearnMoreUrl": { + "type": "string", + "default": "" + }, "enableSignatureEditor": { "type": "boolean", "default": false diff --git a/l10n/en-US/viewer.ftl b/l10n/en-US/viewer.ftl index df44ab339..6c431c3b5 100644 --- a/l10n/en-US/viewer.ftl +++ b/l10n/en-US/viewer.ftl @@ -414,7 +414,8 @@ pdfjs-editor-comments-sidebar-close-button = pdfjs-editor-comments-sidebar-close-button-label = Close the sidebar # Instructional copy to add a comment by selecting text or an annotations. -pdfjs-editor-comments-sidebar-no-comments = Add a comment by selecting text or an annotation. +pdfjs-editor-comments-sidebar-no-comments1 = See something noteworthy? Highlight it and leave a comment. +pdfjs-editor-comments-sidebar-no-comments-link = Learn more ## Alt-text dialog @@ -670,21 +671,28 @@ pdfjs-editor-edit-signature-dialog-title = Edit description pdfjs-editor-edit-signature-update-button = Update +## Comment popup + +pdfjs-editor-edit-comment-popup-button-label = Edit comment +pdfjs-editor-edit-comment-popup-button = + .title = Edit comment +pdfjs-editor-delete-comment-popup-button-label = Remove comment +pdfjs-editor-delete-comment-popup-button = + .title = Remove comment + ## Edit a comment dialog -pdfjs-editor-edit-comment-actions-button-label = Actions -pdfjs-editor-edit-comment-actions-button = - .title = Actions -pdfjs-editor-edit-comment-close-button-label = Close -pdfjs-editor-edit-comment-close-button = - .title = Close -pdfjs-editor-edit-comment-actions-edit-button-label = Edit -pdfjs-editor-edit-comment-actions-delete-button-label = Delete -pdfjs-editor-edit-comment-manager-text-input = - .placeholder = Enter your comment +# An existing comment is edited +pdfjs-editor-edit-comment-dialog-title-when-editing = Edit comment -pdfjs-editor-edit-comment-manager-cancel-button = Cancel -pdfjs-editor-edit-comment-manager-save-button = Save +# No existing comment +pdfjs-editor-edit-comment-dialog-title-when-adding = Add comment + +pdfjs-editor-edit-comment-dialog-text-input = + .placeholder = Start typing… + +pdfjs-editor-edit-comment-dialog-cancel-button = Cancel +pdfjs-editor-edit-comment-dialog-save-button = Save ## Edit a comment button in the editor toolbar diff --git a/src/display/editor/annotation_editor_layer.js b/src/display/editor/annotation_editor_layer.js index 3df2e3a02..fe01b9156 100644 --- a/src/display/editor/annotation_editor_layer.js +++ b/src/display/editor/annotation_editor_layer.js @@ -334,7 +334,7 @@ class AnnotationEditorLayer { if (editor?.annotationElementId === null) { e.stopPropagation(); e.preventDefault(); - editor.dblclick(); + editor.dblclick(e); } }, { signal, capture: true } diff --git a/src/display/editor/comment.js b/src/display/editor/comment.js index 6bfc468dd..4845c6479 100644 --- a/src/display/editor/comment.js +++ b/src/display/editor/comment.js @@ -13,7 +13,7 @@ * limitations under the License. */ -import { noContextMenu } from "../display_utils.js"; +import { noContextMenu, stopEvent } from "../display_utils.js"; class Comment { #commentStandaloneButton = null; @@ -34,6 +34,8 @@ class Comment { #deleted = false; + #popupPosition = null; + constructor(editor) { this.#editor = editor; } @@ -42,7 +44,7 @@ class Comment { const button = (this.#commentToolbarButton = document.createElement("button")); button.className = "comment"; - return this.#render(button); + return this.#render(button, false); } renderForStandalone() { @@ -66,16 +68,87 @@ class Comment { } } - return this.#render(button); + return this.#render(button, true); } - #render(comment) { + onUpdatedColor() { + if (!this.#commentStandaloneButton) { + return; + } + const color = this.#editor.commentButtonColor; + if (color) { + this.#commentStandaloneButton.style.backgroundColor = color; + } + this.#editor._uiManager.updatePopupColor(this.#editor); + } + + get commentButtonWidth() { + return ( + (this.#commentStandaloneButton?.getBoundingClientRect().width ?? 0) / + this.#editor.parent.boundingClientRect.width + ); + } + + get commentPopupPositionInLayer() { + if (this.#popupPosition) { + return this.#popupPosition; + } + if (!this.#commentStandaloneButton) { + return null; + } + const { x, y, height } = + this.#commentStandaloneButton.getBoundingClientRect(); + const { + x: parentX, + y: parentY, + width: parentWidth, + height: parentHeight, + } = this.#editor.parent.boundingClientRect; + const OFFSET_UNDER_BUTTON = 2; + return [ + (x - parentX) / parentWidth, + (y + height + OFFSET_UNDER_BUTTON - parentY) / parentHeight, + ]; + } + + set commentPopupPositionInLayer(pos) { + this.#popupPosition = pos; + } + + removeStandaloneCommentButton() { + this.#commentStandaloneButton?.remove(); + this.#commentStandaloneButton = null; + } + + removeToolbarCommentButton() { + this.#commentToolbarButton?.remove(); + this.#commentToolbarButton = null; + } + + setCommentButtonStates({ selected, hasPopup }) { + if (!this.#commentStandaloneButton) { + return; + } + this.#commentStandaloneButton.classList.toggle("selected", selected); + this.#commentStandaloneButton.ariaExpanded = hasPopup; + } + + #render(comment, isStandalone) { if (!this.#editor._uiManager.hasCommentManager()) { return null; } comment.tabIndex = "0"; - comment.setAttribute("data-l10n-id", "pdfjs-editor-edit-comment-button"); + comment.ariaHasPopup = "dialog"; + + if (isStandalone) { + comment.ariaControls = "commentPopup"; + } else { + comment.ariaControlsElements = [ + this.#editor._uiManager.getCommentDialogElement(), + ]; + comment.setAttribute("data-l10n-id", "pdfjs-editor-edit-comment-button"); + } const signal = this.#editor._uiManager._signal; if (!(signal instanceof AbortSignal) || signal.aborted) { @@ -83,6 +156,30 @@ class Comment { } comment.addEventListener("contextmenu", noContextMenu, { signal }); + if (isStandalone) { + comment.addEventListener( + "focusin", + e => { + this.#editor._focusEventsAllowed = false; + stopEvent(e); + }, + { + capture: true, + signal, + } + ); + comment.addEventListener( + "focusout", + e => { + this.#editor._focusEventsAllowed = true; + stopEvent(e); + }, + { + capture: true, + signal, + } + ); + } comment.addEventListener("pointerdown", event => event.stopPropagation(), { signal, }); @@ -92,7 +189,7 @@ class Comment { if (comment === this.#commentToolbarButton) { this.edit(); } else { - this.#editor._uiManager.toggleComment(this.#editor); + this.#editor.toggleComment(/* isSelected = */ true); } }; comment.addEventListener("click", onClick, { capture: true, signal }); @@ -107,18 +204,55 @@ class Comment { { signal } ); + comment.addEventListener( + "pointerenter", + () => { + this.#editor.toggleComment( + /* isSelected = */ false, + /* visibility = */ true + ); + }, + { signal } + ); + comment.addEventListener( + "pointerleave", + () => { + this.#editor.toggleComment( + /* isSelected = */ false, + /* visibility = */ false + ); + }, + { signal } + ); + return comment; } - edit() { - const { bottom, left, right } = this.#editor.getClientDimensions(); - const position = { top: bottom }; - if (this.#editor._uiManager.direction === "ltr") { - position.right = right; + edit(options) { + const position = this.commentPopupPositionInLayer; + let posX, posY; + if (position) { + [posX, posY] = position; } else { - position.left = left; + // The position is in the editor coordinates. + [posX, posY] = this.#editor.commentButtonPosition; + const { width, height, x, y } = this.#editor; + posX = x + posX * width; + posY = y + posY * height; } - this.#editor._uiManager.editComment(this.#editor, position); + const parentDimensions = this.#editor.parent.boundingClientRect; + const { + x: parentX, + y: parentY, + width: parentWidth, + height: parentHeight, + } = parentDimensions; + this.#editor._uiManager.editComment( + this.#editor, + parentX + posX * parentWidth, + parentY + posY * parentHeight, + { ...options, parentDimensions } + ); } finish() { diff --git a/src/display/editor/draw.js b/src/display/editor/draw.js index 2ef564076..b36bb4f6d 100644 --- a/src/display/editor/draw.js +++ b/src/display/editor/draw.js @@ -98,6 +98,12 @@ class DrawingEditor extends AnnotationEditor { this._addOutlines(params); } + /** @inheritdoc */ + onUpdatedColor() { + this._colorPicker?.update(this.color); + super.onUpdatedColor(); + } + _addOutlines(params) { if (params.drawOutlines) { this.#createDrawOutlines(params); @@ -243,7 +249,7 @@ class DrawingEditor extends AnnotationEditor { options.toSVGProperties() ); if (type === this.colorType) { - this._colorPicker?.update(val); + this.onUpdatedColor(); } }; this.addCommands({ diff --git a/src/display/editor/editor.js b/src/display/editor/editor.js index 15635c8d8..db0d3af29 100644 --- a/src/display/editor/editor.js +++ b/src/display/editor/editor.js @@ -22,19 +22,13 @@ import { ColorManager, KeyboardManager, } from "./tools.js"; -import { - applyOpacity, - CSSConstants, - findContrastColor, - noContextMenu, - stopEvent, -} from "../display_utils.js"; import { FeatureTest, MathClamp, shadow, unreachable, } from "../../shared/util.js"; +import { noContextMenu, stopEvent } from "../display_utils.js"; import { AltText } from "./alt_text.js"; import { Comment } from "./comment.js"; import { EditorToolbar } from "./toolbar.js"; @@ -1098,28 +1092,28 @@ class AnnotationEditor { await this._editToolbar.addButton(name, tool); } } - this._editToolbar.addButton("comment", this.addCommentButton()); + if (!this.hasComment) { + this._editToolbar.addButton("comment", this.addCommentButton()); + } this._editToolbar.addButton("delete"); return this._editToolbar; } addCommentButtonInToolbar() { - if (!this._editToolbar) { - return; - } - this._editToolbar.addButtonBefore( + this._editToolbar?.addButtonBefore( "comment", this.addCommentButton(), ".deleteButton" ); } + removeCommentButtonFromToolbar() { + this._editToolbar?.removeButton("comment"); + } + removeEditToolbar() { - if (!this._editToolbar) { - return; - } - this._editToolbar.remove(); + this._editToolbar?.remove(); this._editToolbar = null; // We destroy the alt text but we don't null it because we want to be able @@ -1195,8 +1189,11 @@ class AnnotationEditor { } addStandaloneCommentButton() { - this.#comment ||= new Comment(this); if (this.#commentStandaloneButton) { + this.#commentStandaloneButton.classList.remove("hidden"); + return; + } + if (!this.hasComment) { return; } this.#commentStandaloneButton = this.#comment.renderForStandalone(); @@ -1204,12 +1201,12 @@ class AnnotationEditor { } removeStandaloneCommentButton() { - this.#commentStandaloneButton?.remove(); + this.#comment.removeStandaloneCommentButton(); this.#commentStandaloneButton = null; } - get commentColor() { - return null; + hideStandaloneCommentButton() { + this.#commentStandaloneButton?.classList.add("hidden"); } get comment() { @@ -1221,7 +1218,8 @@ class AnnotationEditor { richText, date, deleted, - color: this.commentColor, + color: this.getNonHCMColor(), + opacity: this.opacity ?? 1, }; } @@ -1229,17 +1227,18 @@ class AnnotationEditor { this.#comment ||= new Comment(this); this.#comment.data = text; if (this.hasComment) { + this.removeCommentButtonFromToolbar(); this.addStandaloneCommentButton(); + this._uiManager.updateComment(this); } else { this.addCommentButtonInToolbar(); this.removeStandaloneCommentButton(); + this._uiManager.removeComment(this); } } setCommentData({ comment, richText }) { - if (!this.#comment) { - this.#comment = new Comment(this); - } + this.#comment ||= new Comment(this); this.#comment.setInitialText(comment, richText); } @@ -1253,14 +1252,20 @@ class AnnotationEditor { ); } - async editComment() { - if (!this.#comment) { - this.#comment = new Comment(this); - } - this.#comment.edit(); + async editComment(options) { + this.#comment ||= new Comment(this); + this.#comment.edit(options); } - showComment() {} + toggleComment(isSelected, visibility = undefined) { + if (this.hasComment) { + this._uiManager.toggleComment(this, isSelected, visibility); + } + } + + setSelectedCommentButton(selected) { + this.#comment.setSelectedButton(selected); + } addComment(serialized) { if (this.hasEditedComment) { @@ -1280,6 +1285,10 @@ class AnnotationEditor { } } + get parentBoundingClientRect() { + return this.parent.boundingClientRect; + } + /** * Render this editor in a div. * @returns {HTMLDivElement | null} @@ -1327,6 +1336,7 @@ class AnnotationEditor { }); } + this.addStandaloneCommentButton(); this._uiManager._editorUndoBar?.hide(); return div; @@ -1467,6 +1477,11 @@ class AnnotationEditor { e => { if (!hasDraggingStarted) { hasDraggingStarted = true; + this._uiManager.toggleComment( + this, + /* isSelected = */ true, + /* visibility = */ false + ); this._onStartDragging(); } const { clientX: x, clientY: y, pointerId } = e; @@ -1632,9 +1647,25 @@ class AnnotationEditor { return this.getRect(0, 0); } + getNonHCMColor() { + return ( + this.color && + AnnotationEditor._colorManager.convert( + this._uiManager.getNonHCMColor(this.color) + ) + ); + } + + /** + * The color has been changed. + */ + onUpdatedColor() { + this.#comment?.onUpdatedColor(); + } + getData() { const { - comment: { text: str, date, deleted, richText }, + comment: { text: str, color, date, opacity, deleted, richText }, uid: id, pageIndex, creationDate, @@ -1649,6 +1680,8 @@ class AnnotationEditor { creationDate, modificationDate: date || modificationDate, popupRef: !deleted, + color, + opacity, }; } @@ -1903,18 +1936,32 @@ class AnnotationEditor { } get commentButtonColor() { - if (!this.color) { - return null; - } - const [r, g, b] = AnnotationEditor._colorManager.convert( - this._uiManager.getNonHCMColor(this.color) - ); - return findContrastColor( - applyOpacity(r, g, b, this.opacity), - CSSConstants.commentForegroundColor + return this._uiManager.makeCommentColor( + this.getNonHCMColor(), + this.opacity ); } + get commentPopupPosition() { + return this.#comment.commentPopupPositionInLayer; + } + + set commentPopupPosition(pos) { + this.#comment.commentPopupPositionInLayer = pos; + } + + get commentButtonWidth() { + return this.#comment.commentButtonWidth; + } + + get elementBeforePopup() { + return this.div; + } + + setCommentButtonStates(options) { + this.#comment.setCommentButtonStates(options); + } + /** * onkeydown callback. * @param {KeyboardEvent} event @@ -2047,6 +2094,7 @@ class AnnotationEditor { */ select() { if (this.isSelected && this._editToolbar) { + this._editToolbar.show(); return; } this.isSelected = true; @@ -2086,6 +2134,13 @@ class AnnotationEditor { } this._editToolbar?.hide(); this.#altText?.toggleAltTextBadge(true); + if (this.hasComment) { + this._uiManager.toggleComment( + this, + /* isSelected = */ false, + /* visibility = */ false + ); + } } /** @@ -2133,6 +2188,10 @@ class AnnotationEditor { * @param {MouseEvent} event */ dblclick(event) { + if (event.target.nodeName === "BUTTON") { + // Avoid entering in edit mode when clicking on the comment button. + return; + } this.enterInEditMode(); this.parent.updateToolbar({ mode: this.constructor._editorType, diff --git a/src/display/editor/freetext.js b/src/display/editor/freetext.js index 9cdc2005f..595b3cdbd 100644 --- a/src/display/editor/freetext.js +++ b/src/display/editor/freetext.js @@ -236,14 +236,21 @@ class FreeTextEditor extends AnnotationEditor { }); } + /** @inheritdoc */ + onUpdatedColor() { + this.editorDiv.style.color = this.color; + this._colorPicker?.update(this.color); + super.onUpdatedColor(); + } + /** * Update the color and make this action undoable. * @param {string} color */ #updateColor(color) { const setColor = col => { - this.color = this.editorDiv.style.color = col; - this._colorPicker?.update(col); + this.color = col; + this.onUpdatedColor(); }; const savedColor = this.color; this.addCommands({ diff --git a/src/display/editor/highlight.js b/src/display/editor/highlight.js index f9a0560d9..43f0e56cc 100644 --- a/src/display/editor/highlight.js +++ b/src/display/editor/highlight.js @@ -348,6 +348,18 @@ class HighlightEditor extends AnnotationEditor { ]; } + /** @inheritdoc */ + onUpdatedColor() { + this.parent?.drawLayer.updateProperties(this.#id, { + root: { + fill: this.color, + "fill-opacity": this.opacity, + }, + }); + this.#colorPicker?.updateColor(this.color); + super.onUpdatedColor(); + } + /** * Update the color and make this action undoable. * @param {string} color @@ -356,13 +368,7 @@ class HighlightEditor extends AnnotationEditor { const setColorAndOpacity = (col, opa) => { this.color = col; this.opacity = opa; - this.parent?.drawLayer.updateProperties(this.#id, { - root: { - fill: col, - "fill-opacity": opa, - }, - }); - this.#colorPicker?.updateColor(col); + this.onUpdatedColor(); }; const savedColor = this.color; const savedOpacity = this.opacity; diff --git a/src/display/editor/toolbar.js b/src/display/editor/toolbar.js index 2ad428144..b5564b663 100644 --- a/src/display/editor/toolbar.js +++ b/src/display/editor/toolbar.js @@ -28,6 +28,8 @@ class EditorToolbar { #comment = null; + #commentButtonDivider = null; + #signatureDescriptionButton = null; static #l10nRemove = null; @@ -167,11 +169,12 @@ class EditorToolbar { return; } this.#addListenersToElement(button); + const divider = (this.#commentButtonDivider = this.#divider); if (!beforeElement) { - this.#buttons.append(button, this.#divider); + this.#buttons.append(button, divider); } else { this.#buttons.insertBefore(button, beforeElement); - this.#buttons.insertBefore(this.#divider, beforeElement); + this.#buttons.insertBefore(divider, beforeElement); } this.#comment = comment; comment.toolbar = this; @@ -194,6 +197,17 @@ class EditorToolbar { this.#buttons.append(button, this.#divider); } + removeButton(name) { + switch (name) { + case "comment": + this.#comment?.removeToolbarCommentButton(); + this.#comment = null; + this.#commentButtonDivider?.remove(); + this.#commentButtonDivider = null; + break; + } + } + async addButton(name, tool) { switch (name) { case "colorPicker": diff --git a/src/display/editor/tools.js b/src/display/editor/tools.js index 4cc92e8db..93a91f8d0 100644 --- a/src/display/editor/tools.js +++ b/src/display/editor/tools.js @@ -1068,14 +1068,40 @@ class AnnotationEditorUIManager { return !!this.#commentManager; } - editComment(editor, position) { - this.#commentManager?.open(this, editor, position); + editComment(editor, posX, posY, options) { + this.#commentManager?.showDialog(this, editor, posX, posY, options); } - showComment(pageIndex, uid) { + selectComment(pageIndex, uid) { const layer = this.#allLayers.get(pageIndex); const editor = layer?.getEditorByUID(uid); - editor?.showComment(); + editor?.toggleComment(/* isSelected */ true, /* visibility */ true); + } + + updateComment(editor) { + this.#commentManager?.updateComment(editor.getData()); + } + + updatePopupColor(editor) { + this.#commentManager?.updatePopupColor(editor); + } + + removeComment(editor) { + this.#commentManager?.removeComments([editor.uid]); + } + + toggleComment(editor, isSelected, visibility = undefined) { + this.#commentManager?.toggleCommentPopup(editor, isSelected, visibility); + } + + makeCommentColor(color, opacity) { + return ( + (color && this.#commentManager?.makeCommentColor(color, opacity)) || null + ); + } + + getCommentDialogElement() { + return this.#commentManager?.dialogElement || null; } async waitForEditorsRendered(pageNumber) { @@ -1821,22 +1847,28 @@ class AnnotationEditorUIManager { if (this.#mode === AnnotationEditorType.POPUP) { this.#commentManager?.hideSidebar(); - for (const editor of this.#allEditors.values()) { - editor.removeStandaloneCommentButton(); - } } + this.#commentManager?.destroyPopup(); this.#mode = mode; if (mode === AnnotationEditorType.NONE) { this.setEditingState(false); this.#disableAll(); + for (const editor of this.#allEditors.values()) { + editor.hideStandaloneCommentButton(); + } this._editorUndoBar?.hide(); + this.toggleComment(/* editor = */ null); this.#updateModeCapability.resolve(); return; } + for (const editor of this.#allEditors.values()) { + editor.addStandaloneCommentButton(); + } + if (mode === AnnotationEditorType.SIGNATURE) { await this.#signatureManager?.loadSignatures(); } @@ -1862,7 +1894,6 @@ class AnnotationEditorUIManager { } if (hasComment && !deleted) { allComments.push(editor.getData()); - editor.addStandaloneCommentButton(); } } for (const annotation of this.#allEditableAnnotations) { @@ -1891,7 +1922,7 @@ class AnnotationEditorUIManager { } for (const editor of this.#allEditors.values()) { - if (editor.annotationElementId === editId || editor.id === editId) { + if (editor.uid === editId) { this.setSelected(editor); if (editComment) { editor.editComment(); diff --git a/web/annotation_editor_layer_builder.css b/web/annotation_editor_layer_builder.css index 99a817901..d93ffb724 100644 --- a/web/annotation_editor_layer_builder.css +++ b/web/annotation_editor_layer_builder.css @@ -142,7 +142,13 @@ pointer-events: none; &.highlightEditing - :is(.freeTextEditor, .inkEditor, .stampEditor, .signatureEditor) { + :is( + .freeTextEditor, + .inkEditor, + .stampEditor, + .signatureEditor, + .commentPopup + ) { pointer-events: auto; } } diff --git a/web/app.js b/web/app.js index af4ce3d5b..d3f752bd1 100644 --- a/web/app.js +++ b/web/app.js @@ -491,11 +491,16 @@ const PDFViewerApplication = { eventBus ) : null; + + const ltr = appConfig.viewerContainer + ? getComputedStyle(appConfig.viewerContainer).direction === "ltr" + : true; const commentManager = AppOptions.get("enableComment") && appConfig.editCommentDialog ? new CommentManager( appConfig.editCommentDialog, { + learnMoreUrl: AppOptions.get("commentLearnMoreUrl"), sidebar: appConfig.annotationEditorParams?.editorCommentsSidebar || null, commentsList: @@ -515,7 +520,8 @@ const PDFViewerApplication = { }, eventBus, linkService, - overlayManager + overlayManager, + ltr ) : null; diff --git a/web/app_options.js b/web/app_options.js index 254b5cbb6..8d675df5a 100644 --- a/web/app_options.js +++ b/web/app_options.js @@ -173,6 +173,14 @@ const defaultOptions = { value: 200, kind: OptionKind.VIEWER + OptionKind.PREFERENCE, }, + commentLearnMoreUrl: { + /** @type {string} */ + value: + typeof PDFJSDev === "undefined" || PDFJSDev.test("MOZCENTRAL") + ? "https://support.mozilla.org/1/firefox/%VERSION%/%OS%/%LOCALE%/pdf-comment" + : "", + kind: OptionKind.VIEWER + OptionKind.PREFERENCE, + }, cursorToolOnLoad: { /** @type {number} */ value: 0, diff --git a/web/comment_manager.css b/web/comment_manager.css index 0dc4dad7b..c9ea7675b 100644 --- a/web/comment_manager.css +++ b/web/comment_manager.css @@ -13,75 +13,23 @@ * limitations under the License. */ +.commentPopup, +#commentManagerDialog { + width: 360px; + max-width: 100%; + min-width: 200px; + position: absolute; + padding: 8px 16px 16px; + margin: 0; + box-sizing: border-box; + + border-radius: 8px; +} + #commentManagerDialog { --comment-actions-button-icon: url(images/comment-actionsButton.svg); --comment-close-button-icon: url(images/comment-closeButton.svg); - --default-dialog-bg-color: #ffff98; - --dialog-base-color: var(--default-dialog-bg-color); - --dialog-bg-color: color-mix(in srgb, var(--dialog-base-color), white 30%); - --dialog-border-color: var(--dialog-base-color); - - --menuitem-bg-color: transparent; - --menuitem-fg-color: black; - --menuitem-hover-bg-color: #3383e7; - --menuitem-hover-fg-color: white; - - --comment-text-input-bg: white; - --comment-text-input-fg: black; - --comment-text-input-border: #0060df; - --comment-focus-outline-color: #0060df; - - --hover-filter: brightness(0.9); - --text-primary-color: #15141a; - - --button-secondary-bg-color: #f0f0f4; - --button-secondary-active-bg-color: color-mix( - in srgb, - var(--button-secondary-bg-color), - black 14% - ); - --button-secondary-hover-bg-color: color-mix( - in srgb, - var(--button-secondary-bg-color), - black 7% - ); - - --button-primary-bg-color: #0060df; - --button-primary-fg-color: #fbfbfe; - --button-primary-active-bg-color: #0050c0; - --button-primary-hover-bg-color: #0250bb; - - --menu-bg-color: rgb(253 250 244); - --menu-button-border-color: transparent; - --menu-button-focus-outline-color: var(--comment-text-input-border); - - @media screen and (forced-colors: active) { - --hover-filter: none; - --text-primary-color: CanvasText; - --button-secondary-bg-color: HighlightText; - --button-secondary-active-bg-color: HighlightText; - --button-secondary-hover-bg-color: HighlightText; - --button-primary-bg-color: ButtonText; - --button-primary-fg-color: HighlightText; - --button-primary-active-bg-color: SelectedItem; - --button-primary-hover-bg-color: SelectedItem; - - --menu-button-border-color: Canvas; - --menu-button-focus-outline-color: CanvasText; - } - - width: 308px; - padding: 8px 16px 16px; - overflow: visible; - position: absolute; - margin: 0; - - border-radius: 4px; - border: 1px solid var(--dialog-border-color); - background: var(--dialog-bg-color); - box-shadow: 0 2px 14px 0 rgb(58 57 68 / 0.2); - .mainContainer { width: 100%; height: auto; @@ -97,156 +45,20 @@ #commentManagerToolbar { width: 100%; + height: 32px; display: flex; - justify-content: flex-end; + justify-content: flex-start; align-items: flex-start; gap: 8px; align-self: stretch; cursor: move; - - > button { - color-scheme: light; - width: 24px; - height: 24px; - padding: 0; - border: none; - - cursor: pointer; - - &::before { - content: ""; - display: inline-block; - width: 100%; - height: 100%; - mask-repeat: no-repeat; - mask-position: center; - } - - &#commentActionsButton::before { - mask-image: var(--comment-actions-button-icon); - } - - &#commentCloseButton::before { - mask-image: var(--comment-close-button-icon); - } - - > span { - display: inline-block; - width: 0; - height: 0; - overflow: hidden; - } - } - - menu { - width: max-content; - min-width: 90px; - display: flex; - flex-direction: column; - align-items: center; - gap: 1px; - padding: 5px 6px; - cursor: auto; - z-index: 1; - margin: 0; - - position: absolute; - top: 8px; - right: -6.5px; - - border-radius: 6px; - border: 0.5px solid #b4b4b6; - background-color: var(--menu-bg-color); - box-shadow: - 1px -1px 0 0 #fff inset, - -1px 1px 0 0 #fff inset, - -1px -1px 0 0 #fff inset, - 1px 1px 0 0 #fff inset, - 0 0 15px 0 rgb(0 0 0 / 0.25); - - button { - background-color: var(--menuitem-bg-color); - width: 100%; - height: 24px; - padding: 0; - box-sizing: border-box; - display: flex; - border: 2px solid var(--menu-button-border-color); - color: var(--menuitem-fg-color); - - &:hover { - background-color: var(--menuitem-hover-bg-color); - color: var(--menuitem-hover-fg-color); - } - - &:is(:focus-visible, :focus) { - outline: none; - border: 2px solid var(--menu-button-focus-outline-color); - } - - &:disabled { - opacity: 0.5; - pointer-events: none; - } - - span { - align-content: center; - width: 100%; - max-width: min-content; - padding-inline: 8px; - color: inherit; - text-align: start; - font: menu; - font-size: 15px; - font-weight: 400; - line-height: normal; - } - } - } } #commentManagerTextInput { width: 100%; min-height: 132px; - resize: none; - box-sizing: border-box; margin-bottom: 12px; - - border-radius: 4px; - border: 2px solid var(--comment-text-input-border); - background-color: var(--comment-text-input-bg); - color: var(--comment-text-input-fg); - } - - #commentManagerTextView { - width: 100%; - height: max-content; - resize: none; - box-sizing: border-box; - margin-bottom: 12px; - - border: none; - background-color: transparent; - color: var(--comment-text-input-fg); - } - - .dialogButtonsGroup { - gap: 8px; - - #commentManagerSaveButton:disabled { - background-color: color-mix( - in srgb, - var(--button-primary-disabled-bg-color), - transparent 50% - ); - border-color: color-mix( - in srgb, - var(--button-primary-disabled-border-color), - transparent 50% - ); - opacity: 1; - } } } } @@ -279,12 +91,14 @@ @media screen and (forced-colors: active) { --comment-button-bg: Canvas; - --comment-button-fg: CanvasText; - --comment-button-hover-bg: Highlight; - --comment-button-hover-fg: ButtonFace; - --comment-button-active-bg: Highlight; - --comment-button-active-fg: ButtonFace; + --comment-button-fg: ButtonText; + --comment-button-hover-bg: Canvas; + --comment-button-hover-fg: Highlight; + --comment-button-active-bg: Canvas; + --comment-button-active-fg: Highlight; --comment-button-border-color: ButtonBorder; + --comment-button-active-border-color: ButtonBorder; + --comment-button-hover-border-color: Highlight; --comment-button-box-shadow: none; --comment-button-focus-outline-color: CanvasText; --comment-button-selected-bg: ButtonBorder; @@ -358,36 +172,54 @@ } } -.comment.sidebar { +#editorCommentsSidebar, +.commentPopup { --comment-close-button-icon: url(images/comment-closeButton.svg); + --comment-popup-edit-button-icon: url(images/comment-popup-editButton.svg); + --comment-popup-delete-button-icon: url(images/editor-toolbar-delete.svg); --comment-date-fg-color: light-dark( rgb(21 20 26 / 0.69), rgb(251 251 254 / 0.69) ); --comment-bg-color: light-dark(#f9f9fb, #1c1b22); - --comment-hover-bg-color: light-dark( - rgb(21 20 26 / 0.14), - rgb(251 251 254 / 0.14) - ); - --comment-active-bg-color: light-dark( - rgb(21 20 26 / 0.21), - rgb(251 251 254 / 0.21) - ); + --comment-hover-bg-color: light-dark(#e0e0e6, #2c2b33); + --comment-active-bg-color: light-dark(#d1d1d9, #3a3944); + --comment-hover-brightness: 0.89; + --comment-hover-filter: brightness(var(--comment-hover-brightness)); + --comment-active-brightness: 0.825; + --comment-active-filter: brightness(var(--comment-active-brightness)); --comment-border-color: light-dark(#f0f0f4, #52525e); --comment-focus-outline-color: light-dark(#0062fa, #00cadb); --comment-fg-color: light-dark(#15141a, #fbfbfe); --comment-count-bg-color: light-dark(#e2f7ff, #00317e); --comment-indicator-active-fg-color: light-dark(#0041a4, #a6ecf4); + --comment-indicator-active-filter: brightness( + calc(1 / var(--comment-active-brightness)) + ); --comment-indicator-focus-fg-color: light-dark(#5b5b66, #fbfbfe); --comment-indicator-hover-fg-color: light-dark(#0053cb, #61dce9); + --comment-indicator-hover-filter: brightness( + calc(1 / var(--comment-hover-brightness)) + ); --comment-indicator-selected-fg-color: light-dark(#0062fa, #00cadb); + --button-comment-bg: transparent; + --button-comment-color: var(--main-color); + --button-comment-active-bg: light-dark(#cfcfd8, #5b5b66); + --button-comment-active-border: none; + --button-comment-active-color: var(--button-comment-color); + --button-comment-border: none; + --button-comment-hover-bg: light-dark(#e0e0e6, #52525e); + --button-comment-hover-color: var(--button-comment-color); + @media screen and (forced-colors: active) { --comment-date-fg-color: CanvasText; --comment-bg-color: Canvas; --comment-hover-bg-color: SelectedItemText; + --comment-hover-filter: none; --comment-active-bg-color: SelectedItemText; + --comment-active-filter: none; --comment-border-color: CanvasText; --comment-fg-color: CanvasText; --comment-count-bg-color: Canvas; @@ -395,8 +227,17 @@ --comment-indicator-focus-fg-color: CanvasText; --comment-indicator-hover-fg-color: CanvasText; --comment-indicator-selected-fg-color: SelectedItem; + --button-comment-bg: HighlightText; + --button-comment-color: ButtonText; + --button-comment-active-bg: ButtonText; + --button-comment-active-color: HighlightText; + --button-comment-border: 1px solid ButtonText; + --button-comment-hover-bg: Highlight; + --button-comment-hover-color: HighlightText; } +} +#editorCommentsSidebar { display: flex; width: 239px; height: auto; @@ -449,6 +290,7 @@ width: 32px; height: 32px; padding: 8px; + border-radius: 4px; border: none; background: none; cursor: pointer; @@ -472,6 +314,10 @@ background-color: var(--comment-active-bg-color); } + &:focus-visible { + outline: var(--focus-ring-outline); + } + > span { display: inline-block; width: 0; @@ -483,18 +329,18 @@ #editorCommentsSidebarListContainer { overflow: scroll; + width: 100%; #editorCommentsSidebarList { display: flex; width: auto; - padding: 1px 16px 0; + padding: 4px 16px; gap: 10px; flex: 1 0 0; align-self: stretch; align-items: flex-start; flex-direction: column; list-style-type: none; - overflow: scroll; .sidebarComment { display: flex; @@ -511,20 +357,28 @@ &:not(.noComments) { &:hover { - background-color: var(--comment-hover-bg-color); + @media screen and (forced-colors: active) { + background-color: var(--comment-hover-bg-color); + } + filter: var(--comment-hover-filter); time::after { display: inline-block; background-color: var(--comment-indicator-hover-fg-color); + filter: var(--comment-indicator-hover-filter); } } &:active { - background-color: var(--comment-active-bg-color); + @media screen and (forced-colors: active) { + background-color: var(--comment-active-bg-color); + } + filter: var(--comment-active-filter); time::after { display: inline-block; background-color: var(--comment-indicator-active-fg-color); + filter: var(--comment-indicator-active-filter); } } @@ -565,12 +419,34 @@ -webkit-line-clamp: 2; overflow: hidden; overflow-wrap: break-word; + + .richText { + --total-scale-factor: 1.5; + } } - &.noComments .sidebarCommentText { - max-height: fit-content; - -webkit-line-clamp: unset; - user-select: none; + &.noComments { + .sidebarCommentText { + max-height: fit-content; + -webkit-line-clamp: unset; + user-select: none; + } + + a { + font: menu; + font-style: normal; + font-weight: 400; + line-height: normal; + font-size: 15px; + width: 100%; + height: auto; + overflow-wrap: break-word; + margin-block-start: 15px; + + &:focus-visible { + outline: var(--focus-ring-outline); + } + } } time { @@ -600,3 +476,163 @@ } } } + +.commentPopup { + color-scheme: light dark; + + --divider-color: light-dark(#cfcfd8, #3a3944); + --comment-shadow: + 0 0.5px 2px 0 light-dark(rgb(0 0 0 / 0.05), rgb(0 0 0 / 0.2)), + 0 4px 16px 0 light-dark(rgb(0 0 0 / 0.1), rgb(0 0 0 / 0.4)); + + @media screen and (forced-colors: active) { + --divider-color: CanvasText; + --comment-shadow: none; + } + + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 12px; + z-index: 100001; /* above selected annotation editor */ + pointer-events: auto; + + border: 0.5px solid var(--comment-border-color); + background: var(--comment-bg-color); + box-shadow: var(--comment-shadow); + + &:focus-visible { + outline: none; + } + + &.dragging { + cursor: move !important; + + * { + cursor: move !important; + } + + button { + pointer-events: none !important; + } + } + + &:not(.selected) .commentPopupButtons { + visibility: hidden !important; + } + + hr { + width: 100%; + height: 1px; + border: none; + border-top: 1px solid var(--divider-color); + margin: 0; + padding: 0; + } + + .commentPopupTop { + display: flex; + width: 100%; + height: auto; + padding-bottom: 4px; + justify-content: space-between; + align-items: center; + align-self: stretch; + cursor: move; + user-select: none; + + .commentPopupTime { + font: menu; + font-style: normal; + font-weight: 400; + line-height: normal; + font-size: 13px; + color: var(--comment-date-fg-color); + } + + .commentPopupButtons { + display: flex; + align-items: center; + gap: 2px; + cursor: default; + + > button { + width: 32px; + height: 32px; + padding: 8px; + border: none; + border-radius: 4px; + background-color: var(--button-comment-bg); + color: var(--button-comment-color); + + &:hover { + background-color: var(--button-comment-hover-bg); + } + + &:active { + border: var(--button-comment-active-border); + background-color: var(--button-comment-active-bg); + color: var(--button-comment-active-color); + + &::before { + background-color: var(--button-comment-active-color); + } + } + + &:focus-visible { + background-color: var(--button-comment-hover-bg); + outline: 2px solid var(--comment-focus-outline-color); + outline-offset: 0; + } + + &::before { + content: ""; + display: inline-block; + width: 100%; + height: 100%; + mask-repeat: no-repeat; + mask-position: center; + } + + &.commentPopupEdit::before { + mask-image: var(--comment-popup-edit-button-icon); + } + + &.commentPopupDelete::before { + mask-image: var(--comment-popup-delete-button-icon); + } + } + } + } + + .commentPopupText { + width: 100%; + height: auto; + + font: menu; + font-style: normal; + font-weight: 400; + line-height: normal; + font-size: 15px; + color: var(--comment-fg-color); + } +} + +.commentPopupText, +.sidebarCommentText .richText { + margin-block: 0; + + p:first-of-type { + margin-block: 0; + } + + > * { + white-space: pre-wrap; + font-size: max(15px, calc(10px * var(--total-scale-factor))); + overflow-wrap: break-word; + } + + span { + color: var(--comment-fg-color) !important; + } +} diff --git a/web/comment_manager.js b/web/comment_manager.js index ce1e4906b..6e322b168 100644 --- a/web/comment_manager.js +++ b/web/comment_manager.js @@ -15,163 +15,47 @@ import { AnnotationEditorType, + applyOpacity, CSSConstants, findContrastColor, - getRGB, noContextMenu, PDFDateString, + renderRichText, shadow, stopEvent, + Util, } from "pdfjs-lib"; import { binarySearchFirstItem } from "./ui_utils.js"; class CommentManager { - #actions; - - #currentEditor; - #dialog; - #deleteMenuItem; - - #editMenuItem; - - #overlayManager; - - #previousText = ""; - - #commentText = ""; - - #menu; - - #textInput; - - #textView; - - #saveButton; + #popup; #sidebar; - #uiManager; - - #prevDragX = Infinity; - - #prevDragY = Infinity; - - #dialogX = 0; - - #dialogY = 0; - - #menuAC = null; - constructor( - { - dialog, - toolbar, - actions, - menu, - editMenuItem, - deleteMenuItem, - closeButton, - textInput, - textView, - cancelButton, - saveButton, - }, + commentDialog, sidebar, eventBus, linkService, - overlayManager + overlayManager, + ltr ) { - this.#actions = actions; - this.#dialog = dialog; - this.#editMenuItem = editMenuItem; - this.#deleteMenuItem = deleteMenuItem; - this.#menu = menu; - this.#sidebar = new CommentSidebar(sidebar, eventBus, linkService); - this.#textInput = textInput; - this.#textView = textView; - this.#overlayManager = overlayManager; - this.#saveButton = saveButton; - - const finishBound = this.#finish.bind(this); - dialog.addEventListener("close", finishBound); - dialog.addEventListener("contextmenu", e => { - if (e.target !== this.#textInput) { - e.preventDefault(); - } + const dateFormat = new Intl.DateTimeFormat(undefined, { + dateStyle: "long", }); - cancelButton.addEventListener("click", finishBound); - closeButton.addEventListener("click", finishBound); - saveButton.addEventListener("click", this.#save.bind(this)); - - this.#makeMenu(); - editMenuItem.addEventListener("click", () => { - this.#closeMenu(); - this.#edit(); - }); - deleteMenuItem.addEventListener("click", () => { - this.#closeMenu(); - this.#textInput.value = ""; - this.#currentEditor.comment = null; - this.#save(); - }); - - textInput.addEventListener("input", () => { - saveButton.disabled = textInput.value === this.#previousText; - this.#deleteMenuItem.disabled = textInput.value === ""; - }); - textView.addEventListener("dblclick", () => { - this.#edit(); - }); - - // Make the dialog draggable. - let pointerMoveAC; - const cancelDrag = () => { - this.#prevDragX = this.#prevDragY = Infinity; - this.#dialog.classList.remove("dragging"); - pointerMoveAC?.abort(); - pointerMoveAC = null; - }; - toolbar.addEventListener("pointerdown", e => { - const { target, clientX, clientY } = e; - if (target !== toolbar) { - return; - } - this.#closeMenu(); - this.#prevDragX = clientX; - this.#prevDragY = clientY; - pointerMoveAC = new AbortController(); - const { signal } = pointerMoveAC; - dialog.classList.add("dragging"); - window.addEventListener( - "pointermove", - ev => { - if (this.#prevDragX !== Infinity) { - const { clientX: x, clientY: y } = ev; - this.#setPosition( - this.#dialogX + x - this.#prevDragX, - this.#dialogY + y - this.#prevDragY - ); - this.#prevDragX = x; - this.#prevDragY = y; - stopEvent(ev); - } - }, - { signal } - ); - window.addEventListener("blur", cancelDrag, { signal }); - stopEvent(e); - }); - dialog.addEventListener("pointerup", e => { - if (this.#prevDragX === Infinity) { - return; // Not dragging. - } - cancelDrag(); - stopEvent(e); - }); - - overlayManager.register(dialog); + this.dialogElement = commentDialog.dialog; + this.#dialog = new CommentDialog(commentDialog, overlayManager, ltr); + this.#popup = new CommentPopup(dateFormat, ltr, this.dialogElement); + this.#sidebar = new CommentSidebar( + sidebar, + eventBus, + linkService, + this.#popup, + dateFormat + ); + this.#popup.sidebar = this.#sidebar; } setSidebarUiManager(uiManager) { @@ -198,224 +82,44 @@ class CommentManager { this.#sidebar.addComment(annotation); } - #closeMenu() { - if (!this.#menuAC) { - return; - } - const menu = this.#menu; - menu.classList.toggle("hidden", true); - this.#actions.ariaExpanded = "false"; - this.#menuAC.abort(); - this.#menuAC = null; - if (menu.contains(document.activeElement)) { - // If the menu is closed while focused, focus the actions button. - setTimeout(() => { - if (!this.#dialog.contains(document.activeElement)) { - this.#actions.focus(); - } - }, 0); - } + updateComment(annotation) { + this.#sidebar.updateComment(annotation); } - #renderActionsButton(visible) { - this.#actions.classList.toggle("hidden", !visible); - } - - #makeMenu() { - this.#actions.addEventListener("click", e => { - const closeMenu = this.#closeMenu.bind(this); - if (this.#menuAC) { - closeMenu(); - return; - } - - const menu = this.#menu; - menu.classList.toggle("hidden", false); - this.#actions.ariaExpanded = "true"; - this.#menuAC = new AbortController(); - const { signal } = this.#menuAC; - window.addEventListener( - "pointerdown", - ({ target }) => { - if (target !== this.#actions && !menu.contains(target)) { - closeMenu(); - } - }, - { signal } - ); - window.addEventListener("blur", closeMenu, { signal }); - this.#actions.addEventListener( - "keydown", - ({ key }) => { - switch (key) { - case "ArrowDown": - case "Home": - menu.firstElementChild.focus(); - stopEvent(e); - break; - case "ArrowUp": - case "End": - menu.lastElementChild.focus(); - stopEvent(e); - break; - case "Escape": - closeMenu(); - stopEvent(e); - } - }, - { signal } - ); - }); - - const keyboardListener = e => { - const { key, target } = e; - const menu = this.#menu; - switch (key) { - case "Escape": - this.#closeMenu(); - stopEvent(e); - break; - case "ArrowDown": - case "Tab": - (target.nextElementSibling || menu.firstElementChild).focus(); - stopEvent(e); - break; - case "ArrowUp": - case "ShiftTab": - (target.previousElementSibling || menu.lastElementChild).focus(); - stopEvent(e); - break; - case "Home": - menu.firstElementChild.focus(); - stopEvent(e); - break; - case "End": - menu.lastElementChild.focus(); - stopEvent(e); - break; - } - }; - for (const menuItem of this.#menu.children) { - if (menuItem.classList.contains("hidden")) { - continue; // Skip hidden menu items. - } - menuItem.addEventListener("keydown", keyboardListener); - menuItem.addEventListener("contextmenu", noContextMenu); + toggleCommentPopup(editor, isSelected, visibility) { + if (isSelected) { + this.selectComment(editor.uid); } - this.#menu.addEventListener("contextmenu", noContextMenu); + this.#popup.toggle(editor, isSelected, visibility); } - async open(uiManager, editor, position) { - if (editor) { - this.#uiManager = uiManager; - this.#currentEditor = editor; - } - const { - comment: { text, color }, - } = editor; - this.#dialog.style.setProperty( - "--dialog-base-color", - this.#lightenColor(color) || "var(--default-dialog-bg-color)" - ); - this.#commentText = text || ""; - if (!text) { - this.#renderActionsButton(false); - this.#edit(); - } else { - this.#renderActionsButton(true); - this.#setText(text); - this.#textInput.classList.toggle("hidden", true); - this.#textView.classList.toggle("hidden", false); - this.#editMenuItem.disabled = this.#deleteMenuItem.disabled = false; - } - this.#uiManager.removeEditListeners(); - this.#saveButton.disabled = true; - - const x = - position.right !== undefined - ? position.right - this._dialogWidth - : position.left; - const y = position.top; - this.#setPosition(x, y, /* isInitial */ true); - - await this.#overlayManager.open(this.#dialog); + destroyPopup() { + this.#popup.destroy(); } - async #save() { - this.#currentEditor.comment = this.#textInput.value; - this.#finish(); + updatePopupColor(editor) { + this.#popup.updateColor(editor); } - get _dialogWidth() { - const dialog = this.#dialog; - const { style } = dialog; - style.opacity = "0"; - style.display = "block"; - const width = dialog.getBoundingClientRect().width; - style.opacity = style.display = ""; - return shadow(this, "_dialogWidth", width); + showDialog(uiManager, editor, posX, posY, options) { + return this.#dialog.open(uiManager, editor, posX, posY, options); } - #lightenColor(color) { - if (!color) { - return null; // No color provided. - } + makeCommentColor(color, opacity) { + return CommentManager._makeCommentColor(color, opacity); + } + + static _makeCommentColor(color, opacity) { return findContrastColor( - getRGB(color), + applyOpacity(...color, opacity ?? 1), CSSConstants.commentForegroundColor ); } - #setText(text) { - const textView = this.#textView; - for (const line of text.split("\n")) { - const span = document.createElement("span"); - span.textContent = line; - textView.append(span, document.createElement("br")); - } - } - - #setPosition(x, y, isInitial = false) { - this.#dialogX = x; - this.#dialogY = y; - const { style } = this.#dialog; - style.left = `${x}px`; - style.top = isInitial - ? `calc(${y}px + var(--editor-toolbar-vert-offset))` - : `${y}px`; - } - - #edit() { - const textInput = this.#textInput; - const textView = this.#textView; - if (textView.childElementCount > 0) { - const height = parseFloat(getComputedStyle(textView).height); - textInput.value = this.#previousText = this.#commentText; - textInput.style.height = `${height + 20}px`; - } else { - textInput.value = this.#previousText = this.#commentText; - } - - textInput.classList.toggle("hidden", false); - textView.classList.toggle("hidden", true); - this.#editMenuItem.disabled = true; - setTimeout(() => textInput.focus(), 0); - } - - #finish() { - this.#textView.replaceChildren(); - this.#textInput.value = this.#previousText = this.#commentText = ""; - this.#overlayManager.closeIfActive(this.#dialog); - this.#textInput.style.height = ""; - this.#uiManager?.addEditListeners(); - this.#uiManager = null; - this.#currentEditor = null; - } - destroy() { - this.#uiManager = null; - this.#finish(); + this.#dialog.destroy(); this.#sidebar.hide(); + this.#popup.destroy(); } } @@ -434,10 +138,16 @@ class CommentSidebar { #commentCount; + #dateFormat; + #sidebarTitle; + #learnMoreUrl; + #linkService; + #popup; + #elementsToAnnotations = null; #idsToElements = null; @@ -446,6 +156,7 @@ class CommentSidebar { constructor( { + learnMoreUrl, sidebar, commentsList, commentCount, @@ -454,14 +165,19 @@ class CommentSidebar { commentToolbarButton, }, eventBus, - linkService + linkService, + popup, + dateFormat ) { this.#sidebar = sidebar; this.#sidebarTitle = sidebarTitle; this.#commentsList = commentsList; this.#commentCount = commentCount; + this.#learnMoreUrl = learnMoreUrl; this.#linkService = linkService; this.#closeButton = closeButton; + this.#popup = popup; + this.#dateFormat = dateFormat; closeButton.addEventListener("click", () => { eventBus.dispatch("switchannotationeditormode", { @@ -469,7 +185,7 @@ class CommentSidebar { mode: AnnotationEditorType.NONE, }); }); - commentToolbarButton.addEventListener("keydown", e => { + const keyDownCallback = e => { if (e.key === "ArrowDown" || e.key === "Home" || e.key === "F6") { this.#commentsList.firstElementChild.focus(); stopEvent(e); @@ -477,7 +193,9 @@ class CommentSidebar { this.#commentsList.lastElementChild.focus(); stopEvent(e); } - }); + }; + commentToolbarButton.addEventListener("keydown", keyDownCallback); + sidebar.addEventListener("keydown", keyDownCallback); this.#sidebar.hidden = true; } @@ -513,7 +231,7 @@ class CommentSidebar { } removeComments(ids) { - if (ids.length === 0) { + if (ids.length === 0 || !this.#idsToElements) { return; } if ( @@ -538,11 +256,60 @@ class CommentSidebar { } } - #removeComment(id) { + updateComment(annotation) { + if (!this.#idsToElements) { + return; + } + const { + id, + creationDate, + modificationDate, + richText, + contentsObj, + popupRef, + } = annotation; + + if (!popupRef || (!richText && !contentsObj?.str)) { + this.#removeComment(id); + } + const element = this.#idsToElements.get(id); if (!element) { return; } + const prevAnnotation = this.#elementsToAnnotations.get(element); + let index = binarySearchFirstItem( + this.#annotations, + a => this.#sortComments(a, prevAnnotation) >= 0 + ); + if (index >= this.#annotations.length) { + return; + } + + this.#setDate(element.firstChild, modificationDate || creationDate); + this.#setText(element.lastChild, richText, contentsObj); + + this.#annotations.splice(index, 1); + index = binarySearchFirstItem( + this.#annotations, + a => this.#sortComments(a, annotation) >= 0 + ); + this.#annotations.splice(index, 0, annotation); + if (index >= this.#commentsList.children.length) { + this.#commentsList.append(element); + } else { + this.#commentsList.insertBefore( + element, + this.#commentsList.children[index] + ); + } + } + + #removeComment(id) { + const element = this.#idsToElements?.get(id); + if (!element) { + return; + } const annotation = this.#elementsToAnnotations.get(element); const index = binarySearchFirstItem( this.#annotations, @@ -566,14 +333,21 @@ class CommentSidebar { } selectComment(element, id = null) { + if (!this.#idsToElements) { + return; + } + const hasNoElement = !element; element ||= this.#idsToElements.get(id); for (const el of this.#commentsList.children) { el.classList.toggle("selected", el === element); } + if (hasNoElement) { + element?.scrollIntoView({ behavior: "instant", block: "center" }); + } } addComment(annotation) { - if (this.#idsToElements.has(annotation.id)) { + if (this.#idsToElements?.has(annotation.id)) { return; } const { popupRef, contentsObj } = annotation; @@ -618,41 +392,72 @@ class CommentSidebar { #createZeroCommentElement() { const commentItem = document.createElement("li"); commentItem.classList.add("sidebarComment", "noComments"); - commentItem.role = "button"; const textDiv = document.createElement("div"); textDiv.className = "sidebarCommentText"; textDiv.setAttribute( "data-l10n-id", - "pdfjs-editor-comments-sidebar-no-comments" + "pdfjs-editor-comments-sidebar-no-comments1" ); - commentItem.addEventListener("keydown", this.#boundCommentKeydown); commentItem.append(textDiv); + if (this.#learnMoreUrl) { + const a = document.createElement("a"); + a.setAttribute( + "data-l10n-id", + "pdfjs-editor-comments-sidebar-no-comments-link" + ); + a.href = this.#learnMoreUrl; + a.target = "_blank"; + a.rel = "noopener noreferrer"; + commentItem.append(a); + } return commentItem; } + #setDate(element, date) { + date = PDFDateString.toDateObject(date); + element.dateTime = date.toISOString(); + element.textContent = this.#dateFormat.format(date); + } + + #setText(element, richText, contentsObj) { + element.replaceChildren(); + const html = + richText?.str && (!contentsObj?.str || richText.str === contentsObj.str) + ? richText.html + : contentsObj?.str; + renderRichText( + { + html, + dir: contentsObj?.dir || "auto", + className: "richText", + }, + element + ); + } + #createCommentElement(annotation) { const { id, creationDate, modificationDate, - contentsObj: { str: text }, + richText, + contentsObj, + color, + opacity, } = annotation; const commentItem = document.createElement("li"); commentItem.role = "button"; commentItem.className = "sidebarComment"; commentItem.tabIndex = -1; - + commentItem.style.backgroundColor = + (color && CommentManager._makeCommentColor(color, opacity)) || ""; const dateDiv = document.createElement("time"); - const date = PDFDateString.toDateObject(modificationDate || creationDate); - dateDiv.dateTime = date.toISOString(); - const dateFormat = new Intl.DateTimeFormat(undefined, { - dateStyle: "long", - }); - dateDiv.textContent = dateFormat.format(date); + this.#setDate(dateDiv, modificationDate || creationDate); const textDiv = document.createElement("div"); textDiv.className = "sidebarCommentText"; - textDiv.textContent = text; + this.#setText(textDiv, richText, contentsObj); + commentItem.append(dateDiv, textDiv); commentItem.addEventListener("click", this.#boundCommentClick); commentItem.addEventListener("keydown", this.#boundCommentKeydown); @@ -670,19 +475,17 @@ class CommentSidebar { if (!annotation) { return; } + this.#popup._hide(); const { id, pageIndex, rect } = annotation; - const SPACE_ABOVE_ANNOTATION = 10; const pageNumber = pageIndex + 1; const pageVisiblePromise = this.#uiManager?.waitForEditorsRendered(pageNumber); - this.#linkService?.goToXY( - pageNumber, - rect[0], - rect[3] + SPACE_ABOVE_ANNOTATION - ); + this.#linkService?.goToXY(pageNumber, rect[0], rect[3], { + center: "both", + }); this.selectComment(currentTarget); await pageVisiblePromise; - this.#uiManager?.showComment(pageIndex, id); + this.#uiManager?.selectComment(pageIndex, id); } #commentKeydown(e) { @@ -742,4 +545,523 @@ class CommentSidebar { } } +class CommentDialog { + #dialog; + + #editor; + + #overlayManager; + + #previousText = ""; + + #commentText = ""; + + #textInput; + + #title; + + #saveButton; + + #uiManager; + + #prevDragX = 0; + + #prevDragY = 0; + + #dialogX = 0; + + #dialogY = 0; + + #isLTR; + + constructor( + { dialog, toolbar, title, textInput, cancelButton, saveButton }, + overlayManager, + ltr + ) { + this.#dialog = dialog; + this.#textInput = textInput; + this.#overlayManager = overlayManager; + this.#saveButton = saveButton; + this.#title = title; + this.#isLTR = ltr; + + const finishBound = this.#finish.bind(this); + dialog.addEventListener("close", finishBound); + dialog.addEventListener("contextmenu", e => { + if (e.target !== this.#textInput) { + e.preventDefault(); + } + }); + cancelButton.addEventListener("click", finishBound); + saveButton.addEventListener("click", this.#save.bind(this)); + + textInput.addEventListener("input", () => { + saveButton.disabled = textInput.value === this.#previousText; + }); + + // Make the dialog draggable. + let pointerMoveAC; + const cancelDrag = () => { + dialog.classList.remove("dragging"); + pointerMoveAC?.abort(); + pointerMoveAC = null; + }; + toolbar.addEventListener("pointerdown", e => { + if (pointerMoveAC) { + cancelDrag(); + return; + } + const { clientX, clientY } = e; + stopEvent(e); + this.#prevDragX = clientX; + this.#prevDragY = clientY; + pointerMoveAC = new AbortController(); + const { signal } = pointerMoveAC; + dialog.classList.add("dragging"); + window.addEventListener( + "pointermove", + ev => { + if (!pointerMoveAC) { + return; + } + const { clientX: x, clientY: y } = ev; + this.#setPosition( + this.#dialogX + x - this.#prevDragX, + this.#dialogY + y - this.#prevDragY + ); + this.#prevDragX = x; + this.#prevDragY = y; + stopEvent(ev); + }, + { signal } + ); + window.addEventListener("blur", cancelDrag, { signal }); + window.addEventListener( + "pointerup", + ev => { + if (pointerMoveAC) { + cancelDrag(); + stopEvent(ev); + } + }, + { signal } + ); + }); + + overlayManager.register(dialog); + } + + async open(uiManager, editor, posX, posY, options) { + if (editor) { + this.#uiManager = uiManager; + this.#editor = editor; + } + const { + contentsObj: { str }, + color, + opacity, + } = editor.getData(); + const { style: dialogStyle } = this.#dialog; + if (color) { + dialogStyle.backgroundColor = CommentManager._makeCommentColor( + color, + opacity + ); + dialogStyle.borderColor = Util.makeHexColor(...color); + } else { + dialogStyle.backgroundColor = dialogStyle.borderColor = ""; + } + this.#commentText = str || ""; + const textInput = this.#textInput; + textInput.value = this.#previousText = this.#commentText; + this.#title.setAttribute( + "data-l10n-id", + str + ? "pdfjs-editor-edit-comment-dialog-title-when-editing" + : "pdfjs-editor-edit-comment-dialog-title-when-adding" + ); + if (options?.height) { + textInput.style.height = `${options.height}px`; + } + this.#uiManager?.removeEditListeners(); + this.#saveButton.disabled = true; + const parentDimensions = options?.parentDimensions; + if ( + parentDimensions && + ((this.#isLTR && + posX + this._dialogWidth > + parentDimensions.x + parentDimensions.width) || + (!this.#isLTR && posX - this._dialogWidth < parentDimensions.x)) + ) { + const buttonWidth = this.#editor.commentButtonWidth; + posX -= this._dialogWidth - buttonWidth * parentDimensions.width; + } + + this.#setPosition(posX, posY); + + await this.#overlayManager.open(this.#dialog); + textInput.focus(); + } + + async #save() { + this.#editor.comment = this.#textInput.value; + this.#finish(); + } + + get _dialogWidth() { + const dialog = this.#dialog; + const { style } = dialog; + style.opacity = "0"; + style.display = "block"; + const width = dialog.getBoundingClientRect().width; + style.opacity = style.display = ""; + return shadow(this, "_dialogWidth", width); + } + + #setPosition(x, y) { + this.#dialogX = x; + this.#dialogY = y; + const { style } = this.#dialog; + style.left = `${x}px`; + style.top = `${y}px`; + } + + #finish() { + this.#textInput.value = this.#previousText = this.#commentText = ""; + this.#overlayManager.closeIfActive(this.#dialog); + this.#textInput.style.height = ""; + this.#uiManager?.addEditListeners(); + this.#uiManager = null; + this.#editor = null; + } + + destroy() { + this.#uiManager = null; + this.#finish(); + } +} + +class CommentPopup { + #commentDialog; + + #dateFormat; + + #editor = null; + + #isLTR; + + #container = null; + + #text = null; + + #time = null; + + #prevDragX = 0; + + #prevDragY = 0; + + #posX = 0; + + #posY = 0; + + #previousFocusedElement = null; + + #selected = false; + + #visible = false; + + constructor(dateFormat, ltr, commentDialog) { + this.#dateFormat = dateFormat; + this.#isLTR = ltr; + this.#commentDialog = commentDialog; + this.sidebar = null; + } + + get _popupWidth() { + const container = this.#createPopup(); + const { style } = container; + style.opacity = "0"; + style.display = "block"; + document.body.append(container); + const width = container.getBoundingClientRect().width; + container.remove(); + style.opacity = style.display = ""; + return shadow(this, "_popupWidth", width); + } + + #createPopup() { + if (this.#container) { + return this.#container; + } + const container = (this.#container = document.createElement("div")); + container.className = "commentPopup"; + container.id = "commentPopup"; + container.tabIndex = -1; + container.role = "dialog"; + container.ariaModal = "false"; + container.addEventListener("contextmenu", noContextMenu); + container.addEventListener("keydown", e => { + if (e.key === "Escape") { + this.toggle(this.#editor, true, false); + this.#previousFocusedElement?.focus(); + stopEvent(e); + } + }); + container.addEventListener("click", () => { + container.focus(); + }); + + const top = document.createElement("div"); + top.className = "commentPopupTop"; + const time = (this.#time = document.createElement("time")); + time.className = "commentPopupTime"; + + const buttons = document.createElement("div"); + buttons.className = "commentPopupButtons"; + const edit = document.createElement("button"); + edit.classList.add("commentPopupEdit", "toolbarButton"); + edit.tabIndex = 0; + edit.setAttribute("data-l10n-id", "pdfjs-editor-edit-comment-popup-button"); + edit.ariaHasPopup = "dialog"; + edit.ariaControlsElements = [this.#commentDialog]; + const editLabel = document.createElement("span"); + editLabel.setAttribute( + "data-l10n-id", + "pdfjs-editor-edit-comment-popup-button-label" + ); + edit.append(editLabel); + edit.addEventListener("click", () => { + const editor = this.#editor; + const height = parseFloat(getComputedStyle(this.#text).height); + this.toggle(editor, /* isSelected */ true, /* visibility */ false); + editor.editComment({ + height, + }); + }); + edit.addEventListener("contextmenu", noContextMenu); + + const del = document.createElement("button"); + del.classList.add("commentPopupDelete", "toolbarButton"); + del.tabIndex = 0; + del.setAttribute( + "data-l10n-id", + "pdfjs-editor-delete-comment-popup-button" + ); + const delLabel = document.createElement("span"); + delLabel.setAttribute( + "data-l10n-id", + "pdfjs-editor-delete-comment-popup-button-label" + ); + del.append(delLabel); + del.addEventListener("click", () => { + this.#editor.comment = null; + this.destroy(); + }); + del.addEventListener("contextmenu", noContextMenu); + buttons.append(edit, del); + + top.append(time, buttons); + + const separator = document.createElement("hr"); + + const text = (this.#text = document.createElement("div")); + text.className = "commentPopupText"; + container.append(top, separator, text); + + // Make the dialog draggable. + let pointerMoveAC; + const cancelDrag = () => { + container.classList.remove("dragging"); + pointerMoveAC?.abort(); + pointerMoveAC = null; + }; + top.addEventListener("pointerdown", e => { + if (pointerMoveAC) { + cancelDrag(); + return; + } + const { target, clientX, clientY } = e; + if (buttons.contains(target)) { + return; + } + stopEvent(e); + const { width: parentWidth, height: parentHeight } = + this.#editor.parentBoundingClientRect; + this.#prevDragX = clientX; + this.#prevDragY = clientY; + pointerMoveAC = new AbortController(); + const { signal } = pointerMoveAC; + container.classList.add("dragging"); + window.addEventListener( + "pointermove", + ev => { + if (!pointerMoveAC) { + return; // Not dragging. + } + const { clientX: x, clientY: y } = ev; + this.#setPosition( + this.#posX + (x - this.#prevDragX) / parentWidth, + this.#posY + (y - this.#prevDragY) / parentHeight, + /* isDragging = */ true + ); + this.#prevDragX = x; + this.#prevDragY = y; + stopEvent(ev); + }, + { signal } + ); + window.addEventListener("blur", cancelDrag, { signal }); + window.addEventListener( + "pointerup", + ev => { + if (pointerMoveAC) { + cancelDrag(); + stopEvent(ev); + } + }, + { signal } + ); + }); + + return container; + } + + updateColor(editor) { + if (this.#editor !== editor || !this.#visible) { + return; + } + const { color, opacity } = editor.getData(); + this.#container.style.backgroundColor = + (color && CommentManager._makeCommentColor(color, opacity)) || ""; + } + + _hide(editor) { + const container = this.#createPopup(); + + container.classList.toggle("hidden", true); + container.classList.toggle("selected", false); + (editor || this.#editor)?.setCommentButtonStates({ + selected: false, + hasPopup: false, + }); + this.#editor = null; + this.#selected = false; + this.#visible = false; + this.#text.replaceChildren(); + this.sidebar.selectComment(null); + } + + toggle(editor, isSelected, visibility = undefined) { + if (!editor) { + this.destroy(); + return; + } + + if (isSelected) { + visibility ??= + this.#editor === editor ? !this.#selected || !this.#visible : true; + } else { + if (this.#selected) { + return; + } + visibility ??= !this.#visible; + } + + if (!visibility) { + this._hide(editor); + return; + } + + this.#visible = true; + if (this.#editor !== editor) { + this.#editor?.setCommentButtonStates({ + selected: false, + hasPopup: false, + }); + } + + const container = this.#createPopup(); + container.classList.toggle("hidden", false); + container.classList.toggle("selected", isSelected); + this.#selected = isSelected; + this.#editor = editor; + editor.setCommentButtonStates({ + selected: isSelected, + hasPopup: true, + }); + + const { + contentsObj, + richText, + creationDate, + modificationDate, + color, + opacity, + } = editor.getData(); + container.style.backgroundColor = + (color && CommentManager._makeCommentColor(color, opacity)) || ""; + this.#text.replaceChildren(); + const html = + richText?.str && (!contentsObj?.str || richText.str === contentsObj.str) + ? richText.html + : contentsObj?.str; + if (html) { + renderRichText( + { + html, + dir: contentsObj?.dir || "auto", + className: "richText", + }, + this.#text + ); + } + this.#time.textContent = this.#dateFormat.format( + PDFDateString.toDateObject(modificationDate || creationDate) + ); + this.#setPosition(...editor.commentPopupPosition); + editor.elementBeforePopup.after(container); + container.addEventListener( + "focus", + ({ relatedTarget }) => { + this.#previousFocusedElement = relatedTarget; + }, + { once: true } + ); + if (isSelected) { + setTimeout(() => container.focus(), 0); + } + } + + #setPosition(x, y, isDragging = false) { + if (isDragging) { + this.#editor.commentPopupPosition = [x, y]; + } else { + const widthRatio = + this._popupWidth / this.#editor.parentBoundingClientRect.width; + if ( + (this.#isLTR && x + widthRatio > 1) || + (!this.#isLTR && x - widthRatio >= 0) + ) { + const buttonWidth = this.#editor.commentButtonWidth; + x -= widthRatio - buttonWidth; + } + } + this.#posX = x; + this.#posY = y; + const { style } = this.#container; + style.left = `${100 * x}%`; + style.top = `${100 * y}%`; + } + + destroy() { + this._hide(); + this.#container?.remove(); + this.#container = this.#text = this.#time = null; + this.#prevDragX = this.#prevDragY = Infinity; + this.#posX = this.#posY = 0; + this.#previousFocusedElement = null; + } +} + export { CommentManager }; diff --git a/web/images/comment-popup-editButton.svg b/web/images/comment-popup-editButton.svg new file mode 100644 index 000000000..e49742287 --- /dev/null +++ b/web/images/comment-popup-editButton.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/web/pdf_viewer.js b/web/pdf_viewer.js index 4333d4c55..2ec0dcf91 100644 --- a/web/pdf_viewer.js +++ b/web/pdf_viewer.js @@ -1057,6 +1057,7 @@ class PDFViewer { enableHWA: this.#enableHWA, enableAutoLinking: this.#enableAutoLinking, minDurationToUpdateCanvas: this.#minDurationToUpdateCanvas, + commentManager: this.#commentManager, }); this._pages.push(pageView); } diff --git a/web/viewer.html b/web/viewer.html index 085fb7d42..daa1fc148 100644 --- a/web/viewer.html +++ b/web/viewer.html @@ -249,7 +249,7 @@ See https://github.com/adobe-type-tools/cmap-resources -