diff --git a/test/integration/comment_spec.mjs b/test/integration/comment_spec.mjs index ed67f006a..ef9de39d8 100644 --- a/test/integration/comment_spec.mjs +++ b/test/integration/comment_spec.mjs @@ -21,6 +21,8 @@ import { getEditorSelector, getRect, highlightSpan, + kbModifierDown, + kbModifierUp, loadAndWait, scrollIntoView, selectEditor, @@ -545,6 +547,59 @@ describe("Comment", () => { ); }); + it("must check that the comment sidebar is resizable with the keyboard", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + await switchToComment(page); + + const sidebarSelector = "#editorCommentParamsToolbar"; + const handle = await createPromise(page, resolve => { + document + .getElementById("editorCommentsSidebarResizer") + .addEventListener("focus", () => resolve(), { once: true }); + }); + await page.focus(`${sidebarSelector} #editorCommentsSidebarResizer`); + await awaitPromise(handle); + + // Use Ctrl+ArrowLeft/Right to resize the sidebar. + for (const extraWidth of [10, -10]) { + const rect = await getRect(page, sidebarSelector); + const arrowKey = extraWidth > 0 ? "ArrowLeft" : "ArrowRight"; + for (let i = 0; i < Math.abs(extraWidth); i++) { + await kbModifierDown(page); + await page.keyboard.press(arrowKey); + await kbModifierUp(page); + } + + const rectAfter = await getRect(page, sidebarSelector); + expect(Math.abs(rectAfter.width - (rect.width + 10 * extraWidth))) + .withContext(`In ${browserName}`) + .toBeLessThanOrEqual(1); + expect(Math.abs(rectAfter.x - (rect.x - 10 * extraWidth))) + .withContext(`In ${browserName}`) + .toBeLessThanOrEqual(1); + } + + // Use ArrowLeft/Right to resize the sidebar. + for (const extraWidth of [10, -10]) { + const rect = await getRect(page, sidebarSelector); + const arrowKey = extraWidth > 0 ? "ArrowLeft" : "ArrowRight"; + for (let i = 0; i < Math.abs(extraWidth); i++) { + await page.keyboard.press(arrowKey); + } + + const rectAfter = await getRect(page, sidebarSelector); + expect(Math.abs(rectAfter.width - (rect.width + extraWidth))) + .withContext(`In ${browserName}`) + .toBeLessThanOrEqual(1); + expect(Math.abs(rectAfter.x - (rect.x - extraWidth))) + .withContext(`In ${browserName}`) + .toBeLessThanOrEqual(1); + } + }) + ); + }); + it("must check that comments are in chronological order", async () => { await Promise.all( pages.map(async ([browserName, page]) => { diff --git a/web/comment_manager.js b/web/comment_manager.js index eaff727bd..e273f3d6c 100644 --- a/web/comment_manager.js +++ b/web/comment_manager.js @@ -27,6 +27,7 @@ import { Util, } from "pdfjs-lib"; import { binarySearchFirstItem } from "./ui_utils.js"; +import { Sidebar } from "./sidebar.js"; class CommentManager { #dialog; @@ -141,7 +142,7 @@ class CommentManager { } } -class CommentSidebar { +class CommentSidebar extends Sidebar { #annotations = null; #eventBus; @@ -150,8 +151,6 @@ class CommentSidebar { #boundCommentKeydown = this.#commentKeydown.bind(this); - #sidebar; - #closeButton; #commentsList; @@ -174,16 +173,6 @@ class CommentSidebar { #uiManager = null; - #minWidth = 0; - - #maxWidth = 0; - - #initialWidth = 0; - - #width = 0; - - #ltr; - constructor( { learnMoreUrl, @@ -201,7 +190,11 @@ class CommentSidebar { dateFormat, ltr ) { - this.#sidebar = sidebar; + super( + { sidebar, resizer: sidebarResizer, toggleButton: commentToolbarButton }, + ltr, + /* isResizerOnTheLeft = */ true + ); this.#sidebarTitle = sidebarTitle; this.#commentsList = commentsList; this.#commentCount = commentCount; @@ -210,17 +203,8 @@ class CommentSidebar { this.#closeButton = closeButton; this.#popup = popup; this.#dateFormat = dateFormat; - this.#ltr = ltr; this.#eventBus = eventBus; - const style = window.getComputedStyle(sidebar); - this.#minWidth = parseFloat(style.getPropertyValue("--sidebar-min-width")); - this.#maxWidth = parseFloat(style.getPropertyValue("--sidebar-max-width")); - this.#initialWidth = this.#width = parseFloat( - style.getPropertyValue("--sidebar-width") - ); - - this.#makeSidebarResizable(sidebarResizer); closeButton.addEventListener("click", () => { eventBus.dispatch("switchannotationeditormode", { source: this, @@ -238,64 +222,6 @@ class CommentSidebar { }; commentToolbarButton.addEventListener("keydown", keyDownCallback); sidebar.addEventListener("keydown", keyDownCallback); - this.#sidebar.hidden = true; - } - - #makeSidebarResizable(resizer) { - let pointerMoveAC; - const cancelResize = () => { - this.#width = MathClamp(this.#width, this.#minWidth, this.#maxWidth); - this.#sidebar.classList.remove("resizing"); - pointerMoveAC?.abort(); - pointerMoveAC = null; - }; - resizer.addEventListener("pointerdown", e => { - if (pointerMoveAC) { - cancelResize(); - return; - } - const { clientX } = e; - stopEvent(e); - let prevX = clientX; - pointerMoveAC = new AbortController(); - const { signal } = pointerMoveAC; - const sign = this.#ltr ? -1 : 1; - const sidebar = this.#sidebar; - const sidebarStyle = sidebar.style; - sidebar.classList.add("resizing"); - const parentStyle = sidebar.parentElement.style; - parentStyle.minWidth = 0; - window.addEventListener("contextmenu", noContextMenu, { signal }); - window.addEventListener( - "pointermove", - ev => { - if (!pointerMoveAC) { - return; - } - stopEvent(ev); - const { clientX: x } = ev; - const newWidth = (this.#width += sign * (x - prevX)); - prevX = x; - if (newWidth > this.#maxWidth || newWidth < this.#minWidth) { - return; - } - sidebarStyle.width = `${newWidth.toFixed(3)}px`; - parentStyle.insetInlineStart = `${(this.#initialWidth - newWidth).toFixed(3)}px`; - }, - { signal, capture: true } - ); - window.addEventListener("blur", cancelResize, { signal }); - window.addEventListener( - "pointerup", - ev => { - if (pointerMoveAC) { - cancelResize(); - stopEvent(ev); - } - }, - { signal } - ); - }); } setUIManager(uiManager) { @@ -318,7 +244,7 @@ class CommentSidebar { } else { this.#setCommentsCount(); } - this.#sidebar.hidden = false; + this._sidebar.hidden = false; this.#eventBus.dispatch("reporttelemetry", { source: this, details: { @@ -329,7 +255,7 @@ class CommentSidebar { } hide() { - this.#sidebar.hidden = true; + this._sidebar.hidden = true; this.#commentsList.replaceChildren(); this.#elementsToAnnotations = null; this.#idsToElements = null; @@ -356,7 +282,7 @@ class CommentSidebar { if (!element) { return; } - this.#sidebar.scrollTop = element.offsetTop - this.#sidebar.offsetTop; + this._sidebar.scrollTop = element.offsetTop - this._sidebar.offsetTop; for (const el of this.#commentsList.children) { el.classList.toggle("selected", el === element); } diff --git a/web/sidebar.css b/web/sidebar.css index ffecf8832..c6d881c15 100644 --- a/web/sidebar.css +++ b/web/sidebar.css @@ -22,6 +22,7 @@ --sidebar-box-shadow: 0 0.25px 0.75px light-dark(rgb(0 0 0 / 0.05), rgb(0 0 0 / 0.2)), 0 2px 6px 0 light-dark(rgb(0 0 0 / 0.1), rgb(0 0 0 / 0.4)); + --sidebar-backdrop-filter: none; --sidebar-border-radius: 8px; --sidebar-padding: 5px; --sidebar-min-width: 180px; @@ -46,6 +47,7 @@ width: var(--sidebar-width); min-width: var(--sidebar-min-width); max-width: var(--sidebar-max-width); + backdrop-filter: var(--sidebar-backdrop-filter); .sidebarResizer { width: var(--resizer-width); @@ -64,6 +66,10 @@ &:hover { background-color: var(--resizer-hover-bg-color); } + &:focus-visible { + background-color: var(--resizer-hover-bg-color); + outline: none; + } } &.resizing { diff --git a/web/sidebar.js b/web/sidebar.js new file mode 100644 index 000000000..67cf7dbb8 --- /dev/null +++ b/web/sidebar.js @@ -0,0 +1,180 @@ +/* Copyright 2025 Mozilla Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { MathClamp, noContextMenu, stopEvent } from "pdfjs-lib"; + +/** + * Viewer control to display a sidebar with resizer functionality. + */ +class Sidebar { + #minWidth = 0; + + #maxWidth = 0; + + #initialWidth = 0; + + #width = 0; + + #coefficient; + + #visible = false; + + /** + * @typedef {Object} SidebarElements + * @property {HTMLElement} sidebar - The sidebar element. + * @property {HTMLElement} resizer - The sidebar resizer element. + * @property {HTMLElement} toggleButton - The button used to toggle the + * sidebar. + */ + + /** + * Create a sidebar with resizer functionality. + * @param {SidebarElements} sidebarElements + * @param {boolean} ltr + * @param {boolean} isResizerOnTheLeft + */ + constructor({ sidebar, resizer, toggleButton }, ltr, isResizerOnTheLeft) { + this._sidebar = sidebar; + this.#coefficient = ltr === isResizerOnTheLeft ? -1 : 1; + + const style = window.getComputedStyle(sidebar); + this.#minWidth = parseFloat(style.getPropertyValue("--sidebar-min-width")); + this.#maxWidth = parseFloat(style.getPropertyValue("--sidebar-max-width")); + this.#initialWidth = this.#width = parseFloat( + style.getPropertyValue("--sidebar-width") + ); + + this.#makeSidebarResizable(resizer, isResizerOnTheLeft); + toggleButton.addEventListener("click", this.toggle.bind(this)); + sidebar.hidden = true; + } + + #makeSidebarResizable(resizer, isResizerOnTheLeft) { + resizer.ariaValueMin = this.#minWidth; + resizer.ariaValueMax = this.#maxWidth; + resizer.ariaValueNow = this.#width; + + let pointerMoveAC; + const cancelResize = () => { + this.#width = MathClamp(this.#width, this.#minWidth, this.#maxWidth); + this._sidebar.classList.remove("resizing"); + pointerMoveAC?.abort(); + pointerMoveAC = null; + }; + resizer.addEventListener("pointerdown", e => { + if (pointerMoveAC) { + cancelResize(); + return; + } + const { clientX } = e; + stopEvent(e); + let prevX = clientX; + pointerMoveAC = new AbortController(); + const { signal } = pointerMoveAC; + const sidebar = this._sidebar; + const sidebarStyle = sidebar.style; + sidebar.classList.add("resizing"); + const parentStyle = sidebar.parentElement.style; + parentStyle.minWidth = 0; + window.addEventListener("contextmenu", noContextMenu, { signal }); + window.addEventListener( + "pointermove", + ev => { + if (!pointerMoveAC) { + return; + } + stopEvent(ev); + const { clientX: x } = ev; + this.#setNewWidth( + x - prevX, + parentStyle, + resizer, + sidebarStyle, + isResizerOnTheLeft, + /* isFromKeyboard */ false + ); + prevX = x; + }, + { signal, capture: true } + ); + window.addEventListener("blur", cancelResize, { signal }); + window.addEventListener( + "pointerup", + ev => { + if (pointerMoveAC) { + cancelResize(); + stopEvent(ev); + } + }, + { signal } + ); + }); + resizer.addEventListener("keydown", e => { + const { key } = e; + const isArrowLeft = key === "ArrowLeft"; + if (isArrowLeft || key === "ArrowRight") { + const base = e.ctrlKey || e.metaKey ? 10 : 1; + const dx = base * (isArrowLeft ? -1 : 1); + this.#setNewWidth( + dx, + this._sidebar.parentElement.style, + resizer, + this._sidebar.style, + isResizerOnTheLeft, + /* isFromKeyboard */ true + ); + stopEvent(e); + } + }); + } + + #setNewWidth( + dx, + parentStyle, + resizer, + sidebarStyle, + isResizerOnTheLeft, + isFromKeyboard + ) { + let newWidth = this.#width + this.#coefficient * dx; + if (!isFromKeyboard) { + this.#width = newWidth; + } + if ( + (newWidth > this.#maxWidth || newWidth < this.#minWidth) && + (this.#width === this.#maxWidth || this.#width === this.#minWidth) + ) { + return; + } + newWidth = MathClamp(newWidth, this.#minWidth, this.#maxWidth); + if (isFromKeyboard) { + this.#width = newWidth; + } + resizer.ariaValueNow = Math.round(newWidth); + sidebarStyle.width = `${newWidth.toFixed(3)}px`; + if (isResizerOnTheLeft) { + parentStyle.insetInlineStart = `${(this.#initialWidth - newWidth).toFixed(3)}px`; + } + } + + /** + * Toggle the sidebar's visibility. + */ + toggle() { + this._sidebar.hidden = !(this.#visible = !this.#visible); + } +} + +export { Sidebar }; diff --git a/web/viewer.html b/web/viewer.html index e56f02441..e373314df 100644 --- a/web/viewer.html +++ b/web/viewer.html @@ -335,7 +335,7 @@ See https://github.com/adobe-type-tools/cmap-resources