From d1e172458fea9f94d044620bd7fd6ab70ba03d5b Mon Sep 17 00:00:00 2001 From: Calixte Denizet Date: Thu, 15 Jun 2023 11:59:59 +0200 Subject: [PATCH] [api-minor] Make the popup independent of their associated annotations - it'll help to be able to move popups on screen to let the user read the text - popups won't inherit some properties from their parent: - the popup can be misrendered if for example the parent has a clip-path property. - add an outline to the popup when the parent is focused. - hide a popup when it's clicked. --- src/core/annotation.js | 31 +- src/display/annotation_layer.js | 652 ++++++++++++-------- src/display/editor/freetext.js | 4 +- test/annotation_layer_builder_overrides.css | 8 +- test/driver.js | 6 +- test/integration/annotation_spec.js | 2 +- test/unit/annotation_spec.js | 4 +- web/annotation_layer_builder.css | 36 +- web/annotation_layer_builder.js | 5 +- 9 files changed, 469 insertions(+), 279 deletions(-) diff --git a/src/core/annotation.js b/src/core/annotation.js index 18fc96589..06efc2c2a 100644 --- a/src/core/annotation.js +++ b/src/core/annotation.js @@ -1318,6 +1318,7 @@ class MarkupAnnotation extends Annotation { this.data.replyType = rt instanceof Name ? rt.name : AnnotationReplyType.REPLY; } + let popupRef = null; if (this.data.replyType === AnnotationReplyType.GROUP) { // Subordinate annotations in a group should inherit @@ -1344,7 +1345,7 @@ class MarkupAnnotation extends Annotation { this.data.modificationDate = this.modificationDate; } - this.data.hasPopup = parent.has("Popup"); + popupRef = parent.getRaw("Popup"); if (!parent.has("C")) { // Fall back to the default background color. @@ -1359,7 +1360,7 @@ class MarkupAnnotation extends Annotation { this.setCreationDate(dict.get("CreationDate")); this.data.creationDate = this.creationDate; - this.data.hasPopup = dict.has("Popup"); + popupRef = dict.getRaw("Popup"); if (!dict.has("C")) { // Fall back to the default background color. @@ -1367,6 +1368,8 @@ class MarkupAnnotation extends Annotation { } } + this.data.popupRef = popupRef instanceof Ref ? popupRef.toString() : null; + if (dict.has("RC")) { this.data.richText = XFAFactory.getRichTextAsHtml(dict.get("RC")); } @@ -3496,6 +3499,12 @@ class PopupAnnotation extends Annotation { const { dict } = params; this.data.annotationType = AnnotationType.POPUP; + if ( + this.data.rect[0] === this.data.rect[2] || + this.data.rect[1] === this.data.rect[3] + ) { + this.data.rect = null; + } let parentItem = dict.get("Parent"); if (!parentItem) { @@ -3503,17 +3512,11 @@ class PopupAnnotation extends Annotation { return; } - const parentSubtype = parentItem.get("Subtype"); - this.data.parentType = - parentSubtype instanceof Name ? parentSubtype.name : null; - const rawParent = dict.getRaw("Parent"); - this.data.parentId = rawParent instanceof Ref ? rawParent.toString() : null; - const parentRect = parentItem.getArray("Rect"); if (Array.isArray(parentRect) && parentRect.length === 4) { this.data.parentRect = Util.normalizeRect(parentRect); } else { - this.data.parentRect = [0, 0, 0, 0]; + this.data.parentRect = null; } const rt = parentItem.get("RT"); @@ -3557,6 +3560,8 @@ class PopupAnnotation extends Annotation { if (parentItem.has("RC")) { this.data.richText = XFAFactory.getRichTextAsHtml(parentItem.get("RC")); } + + this.data.open = !!dict.get("Open"); } } @@ -4220,7 +4225,7 @@ class HighlightAnnotation extends MarkupAnnotation { }); } } else { - this.data.hasPopup = false; + this.data.popupRef = null; } } } @@ -4258,7 +4263,7 @@ class UnderlineAnnotation extends MarkupAnnotation { }); } } else { - this.data.hasPopup = false; + this.data.popupRef = null; } } } @@ -4302,7 +4307,7 @@ class SquigglyAnnotation extends MarkupAnnotation { }); } } else { - this.data.hasPopup = false; + this.data.popupRef = null; } } } @@ -4341,7 +4346,7 @@ class StrikeOutAnnotation extends MarkupAnnotation { }); } } else { - this.data.hasPopup = false; + this.data.popupRef = null; } } } diff --git a/src/display/annotation_layer.js b/src/display/annotation_layer.js index 725451a92..8c81be108 100644 --- a/src/display/annotation_layer.js +++ b/src/display/annotation_layer.js @@ -56,8 +56,6 @@ function getRectDims(rect) { * @typedef {Object} AnnotationElementParameters * @property {Object} data * @property {HTMLDivElement} layer - * @property {PDFPageProxy} page - * @property {PageViewport} viewport * @property {IPDFLinkService} linkService * @property {IDownloadManager} downloadManager * @property {AnnotationStorage} [annotationStorage] @@ -168,8 +166,6 @@ class AnnotationElement { this.isRenderable = isRenderable; this.data = parameters.data; this.layer = parameters.layer; - this.page = parameters.page; - this.viewport = parameters.viewport; this.linkService = parameters.linkService; this.downloadManager = parameters.downloadManager; this.imageResourcesPath = parameters.imageResourcesPath; @@ -179,6 +175,7 @@ class AnnotationElement { this.enableScripting = parameters.enableScripting; this.hasJSActions = parameters.hasJSActions; this._fieldObjects = parameters.fieldObjects; + this.parent = parameters.parent; if (isRenderable) { this.container = this._createContainer(ignoreBorder); @@ -196,17 +193,40 @@ class AnnotationElement { * @memberof AnnotationElement * @returns {HTMLElement} A section element. */ - _createContainer(ignoreBorder = false) { - const { data, page, viewport } = this; + _createContainer(ignoreBorder) { + const { + data, + parent: { page, viewport }, + } = this; const container = document.createElement("section"); container.setAttribute("data-annotation-id", data.id); + // The accessibility manager will move the annotation in the DOM in + // order to match the visual ordering. + // But if an annotation is above an other one, then we must draw it + // after the other one whatever the order is in the DOM, hence the + // use of the z-index. + container.style.zIndex = this.parent.zIndex++; + + if (this.data.popupRef) { + container.setAttribute("aria-haspopup", true); + } + if (data.noRotate) { container.classList.add("norotate"); } const { pageWidth, pageHeight, pageX, pageY } = viewport.rawDims; + + if (!data.rect || this instanceof PopupAnnotationElement) { + const { rotation } = data; + if (!data.hasOwnCanvas && rotation !== 0) { + this.setRotation(rotation, container); + } + return container; + } + const { width, height } = getRectDims(data.rect); // Do *not* modify `data.rect`, since that will corrupt the annotation @@ -284,7 +304,7 @@ class AnnotationElement { } setRotation(angle, container = this.container) { - const { pageWidth, pageHeight } = this.viewport.rawDims; + const { pageWidth, pageHeight } = this.parent.viewport.rawDims; const { width, height } = getRectDims(this.data.rect); let elementWidth, elementHeight; @@ -428,6 +448,7 @@ class AnnotationElement { const quadrilaterals = []; const savedRect = this.data.rect; + let firstQuadRect = null; for (const quadPoint of this.data.quadPoints) { this.data.rect = [ quadPoint[2].x, @@ -436,8 +457,10 @@ class AnnotationElement { quadPoint[1].y, ]; quadrilaterals.push(this._createContainer(ignoreBorder)); + firstQuadRect ||= this.data.rect; } this.data.rect = savedRect; + this.firstQuadRect = firstQuadRect; return quadrilaterals; } @@ -447,40 +470,28 @@ class AnnotationElement { * are of a type that works with popups (such as Highlight annotations). * * @private - * @param {HTMLDivElement|HTMLImageElement|null} trigger - * @param {Object} data * @memberof AnnotationElement */ - _createPopup(trigger, data) { - let container = this.container; - if (this.quadrilaterals) { - trigger ||= this.quadrilaterals; - container = this.quadrilaterals[0]; - } + _createPopup() { + const { container, data } = this; + container.setAttribute("aria-haspopup", true); - // If no trigger element is specified, create it. - if (!trigger) { - trigger = document.createElement("div"); - trigger.classList.add("popupTriggerArea"); - container.append(trigger); - } - - const popupElement = new PopupElement({ - container, - trigger, - color: data.color, - titleObj: data.titleObj, - modificationDate: data.modificationDate, - contentsObj: data.contentsObj, - richText: data.richText, - hideWrapper: true, + const popup = new PopupAnnotationElement({ + data: { + color: data.color, + titleObj: data.titleObj, + modificationDate: data.modificationDate, + contentsObj: data.contentsObj, + richText: data.richText, + parentRect: this.firstQuadRect || data.rect, + borderStyle: 0, + id: `popup_${data.id}`, + rotation: data.rotation, + }, + parent: this.parent, + elements: [this], }); - const popup = popupElement.render(); - - // Position the popup next to the annotation's container. - popup.style.left = "100%"; - - container.append(popup); + this.parent.div.append(popup.render()); } /** @@ -573,6 +584,10 @@ class AnnotationElement { this.container.hidden = true; } } + + getElementsToTriggerPopup() { + return this.quadrilaterals || this.container; + } } class LinkAnnotationElement extends AnnotationElement { @@ -864,7 +879,7 @@ class LinkAnnotationElement extends AnnotationElement { class TextAnnotationElement extends AnnotationElement { constructor(parameters) { const isRenderable = !!( - parameters.data.hasPopup || + parameters.data.popupRef || parameters.data.titleObj?.str || parameters.data.contentsObj?.str || parameters.data.richText?.str @@ -885,8 +900,8 @@ class TextAnnotationElement extends AnnotationElement { image.dataset.l10nId = "text_annotation_type"; image.dataset.l10nArgs = JSON.stringify({ type: this.data.name }); - if (!this.data.hasPopup) { - this._createPopup(image, this.data); + if (!this.data.popupRef) { + this._createPopup(); } this.container.append(image); @@ -1832,157 +1847,241 @@ class ChoiceWidgetAnnotationElement extends WidgetAnnotationElement { } class PopupAnnotationElement extends AnnotationElement { - // Do not render popup annotations for parent elements with these types as - // they create the popups themselves (because of custom trigger divs). - static IGNORE_TYPES = new Set([ - "Line", - "Square", - "Circle", - "PolyLine", - "Polygon", - "Ink", - ]); - constructor(parameters) { - const { data } = parameters; - const isRenderable = - !PopupAnnotationElement.IGNORE_TYPES.has(data.parentType) && - !!(data.titleObj?.str || data.contentsObj?.str || data.richText?.str); + const { data, elements } = parameters; + const isRenderable = !!( + data.titleObj?.str || + data.contentsObj?.str || + data.richText?.str + ); super(parameters, { isRenderable }); + this.elements = elements; } render() { this.container.classList.add("popupAnnotation"); - const parentElements = this.layer.querySelectorAll( - `[data-annotation-id="${this.data.parentId}"]` - ); - if (parentElements.length === 0) { - return this.container; - } - - const popup = new PopupElement({ + // eslint-disable-next-line no-new + new PopupElement({ container: this.container, - trigger: Array.from(parentElements), color: this.data.color, titleObj: this.data.titleObj, modificationDate: this.data.modificationDate, contentsObj: this.data.contentsObj, richText: this.data.richText, + rect: this.data.rect, + parentRect: this.data.parentRect || null, + parent: this.parent, + elements: this.elements, + open: this.data.open, }); + this.container.setAttribute( + "aria-controls", + this.elements.map(e => e.data.id).join(",") + ); - // Position the popup next to the parent annotation's container. - // PDF viewers ignore a popup annotation's rectangle. - const page = this.page; - const rect = Util.normalizeRect([ - this.data.parentRect[0], - page.view[3] - this.data.parentRect[1] + page.view[1], - this.data.parentRect[2], - page.view[3] - this.data.parentRect[3] + page.view[1], - ]); - const popupLeft = - rect[0] + this.data.parentRect[2] - this.data.parentRect[0]; - const popupTop = rect[1]; - - const { pageWidth, pageHeight, pageX, pageY } = this.viewport.rawDims; - - this.container.style.left = `${(100 * (popupLeft - pageX)) / pageWidth}%`; - this.container.style.top = `${(100 * (popupTop - pageY)) / pageHeight}%`; - - this.container.append(popup.render()); return this.container; } } class PopupElement { - constructor(parameters) { - this.container = parameters.container; - this.trigger = parameters.trigger; - this.color = parameters.color; - this.titleObj = parameters.titleObj; - this.modificationDate = parameters.modificationDate; - this.contentsObj = parameters.contentsObj; - this.richText = parameters.richText; - this.hideWrapper = parameters.hideWrapper || false; + #dateTimePromise = null; - this.pinned = false; + #boundHide = this.#hide.bind(this); + + #boundShow = this.#show.bind(this); + + #boundToggle = this.#toggle.bind(this); + + #color = null; + + #container = null; + + #contentsObj = null; + + #elements = null; + + #parent = null; + + #parentRect = null; + + #pinned = false; + + #popup = null; + + #rect = null; + + #richText = null; + + #titleObj = null; + + constructor({ + container, + color, + elements, + titleObj, + modificationDate, + contentsObj, + richText, + parent, + rect, + parentRect, + open, + }) { + this.#container = container; + this.#titleObj = titleObj; + this.#contentsObj = contentsObj; + this.#richText = richText; + this.#parent = parent; + this.#color = color; + this.#rect = rect; + this.#parentRect = parentRect; + this.#elements = elements; + + const dateObject = PDFDateString.toDateObject(modificationDate); + if (dateObject) { + // The modification date is shown in the popup instead of the creation + // date if it is available and can be parsed correctly, which is + // consistent with other viewers such as Adobe Acrobat. + this.#dateTimePromise = parent.l10n.get( + "annotation_date_string", + { + date: dateObject.toLocaleDateString(), + time: dateObject.toLocaleTimeString(), + }, + "{{date}}, {{time}}" + ); + } + + this.trigger = elements.flatMap(e => e.getElementsToTriggerPopup()); + // Attach the event listeners to the trigger element. + for (const element of this.trigger) { + element.addEventListener("click", this.#boundToggle); + element.addEventListener("mouseenter", this.#boundShow); + element.addEventListener("mouseleave", this.#boundHide); + if (typeof PDFJSDev !== "undefined" && PDFJSDev.test("TESTING")) { + element.classList.add("popupTriggerArea"); + } + } + + this.#container.hidden = true; + if (open) { + this.#toggle(); + } + + if (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) { + // Since the popup is lazily created, we need to ensure that it'll be + // created and displayed during reference tests. + this.#parent.popupShow.push(async () => { + if (this.#container.hidden) { + this.#show(); + } + if (this.#dateTimePromise) { + await this.#dateTimePromise; + } + }); + } } render() { - const BACKGROUND_ENLIGHT = 0.7; - - const wrapper = document.createElement("div"); - wrapper.classList.add("popupWrapper"); - - // For Popup annotations we hide the entire section because it contains - // only the popup. However, for Text annotations without a separate Popup - // annotation, we cannot hide the entire container as the image would - // disappear too. In that special case, hiding the wrapper suffices. - this.hideElement = this.hideWrapper ? wrapper : this.container; - this.hideElement.hidden = true; - - const popup = document.createElement("div"); - popup.classList.add("popup"); - - const color = this.color; - if (color) { - // Enlighten the color. - const r = BACKGROUND_ENLIGHT * (255 - color[0]) + color[0]; - const g = BACKGROUND_ENLIGHT * (255 - color[1]) + color[1]; - const b = BACKGROUND_ENLIGHT * (255 - color[2]) + color[2]; - popup.style.backgroundColor = Util.makeHexColor(r | 0, g | 0, b | 0); + if (this.#popup) { + return; } - const title = document.createElement("h1"); - title.dir = this.titleObj.dir; - title.textContent = this.titleObj.str; - popup.append(title); + const { + page: { view }, + viewport: { + rawDims: { pageWidth, pageHeight, pageX, pageY }, + }, + } = this.#parent; + const popup = (this.#popup = document.createElement("div")); + popup.className = "popup"; - // The modification date is shown in the popup instead of the creation - // date if it is available and can be parsed correctly, which is - // consistent with other viewers such as Adobe Acrobat. - const dateObject = PDFDateString.toDateObject(this.modificationDate); - if (dateObject) { + if (this.#color) { + const baseColor = (popup.style.outlineColor = Util.makeHexColor( + ...this.#color + )); + if ( + (typeof PDFJSDev !== "undefined" && PDFJSDev.test("MOZCENTRAL")) || + CSS.supports("background-color", "color-mix(in srgb, red 30%, white)") + ) { + popup.style.backgroundColor = `color-mix(in srgb, ${baseColor} 30%, white)`; + } else { + // color-mix isn't supported in some browsers hence this version. + // See https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/color-mix#browser_compatibility + // TODO: Use color-mix when it's supported everywhere. + // Enlighten the color. + const BACKGROUND_ENLIGHT = 0.7; + popup.style.backgroundColor = Util.makeHexColor( + ...this.#color.map(c => + Math.floor(BACKGROUND_ENLIGHT * (255 - c) + c) + ) + ); + } + } + + const header = document.createElement("span"); + header.className = "header"; + const title = document.createElement("h1"); + header.append(title); + ({ dir: title.dir, str: title.textContent } = this.#titleObj); + popup.append(header); + + if (this.#dateTimePromise) { const modificationDate = document.createElement("span"); modificationDate.classList.add("popupDate"); - modificationDate.textContent = "{{date}}, {{time}}"; - modificationDate.dataset.l10nId = "annotation_date_string"; - modificationDate.dataset.l10nArgs = JSON.stringify({ - date: dateObject.toLocaleDateString(), - time: dateObject.toLocaleTimeString(), + this.#dateTimePromise.then(localized => { + modificationDate.textContent = localized; }); - popup.append(modificationDate); + header.append(modificationDate); } + const contentsObj = this.#contentsObj; + const richText = this.#richText; if ( - this.richText?.str && - (!this.contentsObj?.str || this.contentsObj.str === this.richText.str) + richText?.str && + (!contentsObj?.str || contentsObj.str === richText.str) ) { XfaLayer.render({ - xfaHtml: this.richText.html, + xfaHtml: richText.html, intent: "richText", div: popup, }); popup.lastChild.classList.add("richText", "popupContent"); } else { - const contents = this._formatContents(this.contentsObj); + const contents = this._formatContents(contentsObj); popup.append(contents); } - if (!Array.isArray(this.trigger)) { - this.trigger = [this.trigger]; + let useParentRect = !!this.#parentRect; + let rect = useParentRect ? this.#parentRect : this.#rect; + for (const element of this.#elements) { + if (!rect || Util.intersect(element.data.rect, rect) !== null) { + rect = element.data.rect; + useParentRect = true; + break; + } } - // Attach the event listeners to the trigger element. - for (const element of this.trigger) { - element.addEventListener("click", this._toggle.bind(this)); - element.addEventListener("mouseover", this._show.bind(this, false)); - element.addEventListener("mouseout", this._hide.bind(this, false)); - } - popup.addEventListener("click", this._hide.bind(this, true)); + const normalizedRect = Util.normalizeRect([ + rect[0], + view[3] - rect[1] + view[1], + rect[2], + view[3] - rect[3] + view[1], + ]); - wrapper.append(popup); - return wrapper; + const HORIZONTAL_SPACE_AFTER_ANNOTATION = 5; + const parentWidth = useParentRect + ? rect[2] - rect[0] + HORIZONTAL_SPACE_AFTER_ANNOTATION + : 0; + const popupLeft = normalizedRect[0] + parentWidth; + const popupTop = normalizedRect[1]; + + const { style } = this.#container; + style.left = `${(100 * (popupLeft - pageX)) / pageWidth}%`; + style.top = `${(100 * (popupTop - pageY)) / pageHeight}%`; + + this.#container.append(popup); } /** @@ -2010,59 +2109,52 @@ class PopupElement { /** * Toggle the visibility of the popup. - * - * @private - * @memberof PopupElement */ - _toggle() { - if (this.pinned) { - this._hide(true); + #toggle() { + this.#pinned = !this.#pinned; + if (this.#pinned) { + this.#show(); + this.#container.addEventListener("click", this.#boundToggle); } else { - this._show(true); + this.#hide(); + this.#container.removeEventListener("click", this.#boundToggle); } } /** * Show the popup. - * - * @private - * @param {boolean} pin - * @memberof PopupElement */ - _show(pin = false) { - if (pin) { - this.pinned = true; + #show() { + if (!this.#popup) { + this.render(); } - if (this.hideElement.hidden) { - this.hideElement.hidden = false; - this.container.style.zIndex = - parseInt(this.container.style.zIndex) + 1000; + if (this.#container.hidden) { + this.#container.hidden = false; + this.#container.style.zIndex = + parseInt(this.#container.style.zIndex) + 1000; + } else if (this.#pinned) { + this.#container.classList.add("focused"); } } /** * Hide the popup. - * - * @private - * @param {boolean} unpin - * @memberof PopupElement */ - _hide(unpin = true) { - if (unpin) { - this.pinned = false; - } - if (!this.hideElement.hidden && !this.pinned) { - this.hideElement.hidden = true; - this.container.style.zIndex = - parseInt(this.container.style.zIndex) - 1000; + #hide() { + this.#container.classList.remove("focused"); + if (this.#pinned) { + return; } + this.#container.hidden = true; + this.#container.style.zIndex = + parseInt(this.#container.style.zIndex) - 1000; } } class FreeTextAnnotationElement extends AnnotationElement { constructor(parameters) { const isRenderable = !!( - parameters.data.hasPopup || + parameters.data.popupRef || parameters.data.titleObj?.str || parameters.data.contentsObj?.str || parameters.data.richText?.str @@ -2087,17 +2179,19 @@ class FreeTextAnnotationElement extends AnnotationElement { this.container.append(content); } - if (!this.data.hasPopup) { - this._createPopup(null, this.data); + if (!this.data.popupRef) { + this._createPopup(); } return this.container; } } class LineAnnotationElement extends AnnotationElement { + #line = null; + constructor(parameters) { const isRenderable = !!( - parameters.data.hasPopup || + parameters.data.popupRef || parameters.data.titleObj?.str || parameters.data.contentsObj?.str || parameters.data.richText?.str @@ -2121,7 +2215,7 @@ class LineAnnotationElement extends AnnotationElement { // PDF coordinates are calculated from a bottom left origin, so transform // the line coordinates to a top left origin for the SVG element. - const line = this.svgFactory.createElement("svg:line"); + const line = (this.#line = this.svgFactory.createElement("svg:line")); line.setAttribute("x1", data.rect[2] - data.lineCoordinates[0]); line.setAttribute("y1", data.rect[3] - data.lineCoordinates[1]); line.setAttribute("x2", data.rect[2] - data.lineCoordinates[2]); @@ -2137,16 +2231,24 @@ class LineAnnotationElement extends AnnotationElement { // Create the popup ourselves so that we can bind it to the line instead // of to the entire container (which is the default). - this._createPopup(line, data); + if (!data.popupRef) { + this._createPopup(); + } return this.container; } + + getElementsToTriggerPopup() { + return this.#line; + } } class SquareAnnotationElement extends AnnotationElement { + #square = null; + constructor(parameters) { const isRenderable = !!( - parameters.data.hasPopup || + parameters.data.popupRef || parameters.data.titleObj?.str || parameters.data.contentsObj?.str || parameters.data.richText?.str @@ -2172,7 +2274,7 @@ class SquareAnnotationElement extends AnnotationElement { // the borders outside the square by default. This behavior cannot be // changed programmatically, so correct for that here. const borderWidth = data.borderStyle.width; - const square = this.svgFactory.createElement("svg:rect"); + const square = (this.#square = this.svgFactory.createElement("svg:rect")); square.setAttribute("x", borderWidth / 2); square.setAttribute("y", borderWidth / 2); square.setAttribute("width", width - borderWidth); @@ -2188,16 +2290,24 @@ class SquareAnnotationElement extends AnnotationElement { // Create the popup ourselves so that we can bind it to the square instead // of to the entire container (which is the default). - this._createPopup(square, data); + if (!data.popupRef) { + this._createPopup(); + } return this.container; } + + getElementsToTriggerPopup() { + return this.#square; + } } class CircleAnnotationElement extends AnnotationElement { + #circle = null; + constructor(parameters) { const isRenderable = !!( - parameters.data.hasPopup || + parameters.data.popupRef || parameters.data.titleObj?.str || parameters.data.contentsObj?.str || parameters.data.richText?.str @@ -2223,7 +2333,8 @@ class CircleAnnotationElement extends AnnotationElement { // the borders outside the circle by default. This behavior cannot be // changed programmatically, so correct for that here. const borderWidth = data.borderStyle.width; - const circle = this.svgFactory.createElement("svg:ellipse"); + const circle = (this.#circle = + this.svgFactory.createElement("svg:ellipse")); circle.setAttribute("cx", width / 2); circle.setAttribute("cy", height / 2); circle.setAttribute("rx", width / 2 - borderWidth / 2); @@ -2239,16 +2350,24 @@ class CircleAnnotationElement extends AnnotationElement { // Create the popup ourselves so that we can bind it to the circle instead // of to the entire container (which is the default). - this._createPopup(circle, data); + if (!data.popupRef) { + this._createPopup(); + } return this.container; } + + getElementsToTriggerPopup() { + return this.#circle; + } } class PolylineAnnotationElement extends AnnotationElement { + #polyline = null; + constructor(parameters) { const isRenderable = !!( - parameters.data.hasPopup || + parameters.data.popupRef || parameters.data.titleObj?.str || parameters.data.contentsObj?.str || parameters.data.richText?.str @@ -2285,7 +2404,9 @@ class PolylineAnnotationElement extends AnnotationElement { } points = points.join(" "); - const polyline = this.svgFactory.createElement(this.svgElementName); + const polyline = (this.#polyline = this.svgFactory.createElement( + this.svgElementName + )); polyline.setAttribute("points", points); // Ensure that the 'stroke-width' is always non-zero, since otherwise it // won't be possible to open/close the popup (note e.g. issue 11122). @@ -2298,10 +2419,16 @@ class PolylineAnnotationElement extends AnnotationElement { // Create the popup ourselves so that we can bind it to the polyline // instead of to the entire container (which is the default). - this._createPopup(polyline, data); + if (!data.popupRef) { + this._createPopup(polyline, data); + } return this.container; } + + getElementsToTriggerPopup() { + return this.#polyline; + } } class PolygonAnnotationElement extends PolylineAnnotationElement { @@ -2317,7 +2444,7 @@ class PolygonAnnotationElement extends PolylineAnnotationElement { class CaretAnnotationElement extends AnnotationElement { constructor(parameters) { const isRenderable = !!( - parameters.data.hasPopup || + parameters.data.popupRef || parameters.data.titleObj?.str || parameters.data.contentsObj?.str || parameters.data.richText?.str @@ -2328,17 +2455,19 @@ class CaretAnnotationElement extends AnnotationElement { render() { this.container.classList.add("caretAnnotation"); - if (!this.data.hasPopup) { - this._createPopup(null, this.data); + if (!this.data.popupRef) { + this._createPopup(); } return this.container; } } class InkAnnotationElement extends AnnotationElement { + #polylines = []; + constructor(parameters) { const isRenderable = !!( - parameters.data.hasPopup || + parameters.data.popupRef || parameters.data.titleObj?.str || parameters.data.contentsObj?.str || parameters.data.richText?.str @@ -2380,6 +2509,7 @@ class InkAnnotationElement extends AnnotationElement { points = points.join(" "); const polyline = this.svgFactory.createElement(this.svgElementName); + this.#polylines.push(polyline); polyline.setAttribute("points", points); // Ensure that the 'stroke-width' is always non-zero, since otherwise it // won't be possible to open/close the popup (note e.g. issue 11122). @@ -2389,7 +2519,9 @@ class InkAnnotationElement extends AnnotationElement { // Create the popup ourselves so that we can bind it to the polyline // instead of to the entire container (which is the default). - this._createPopup(polyline, data); + if (!data.popupRef) { + this._createPopup(polyline, data); + } svg.append(polyline); } @@ -2397,12 +2529,16 @@ class InkAnnotationElement extends AnnotationElement { this.container.append(svg); return this.container; } + + getElementsToTriggerPopup() { + return this.#polylines; + } } class HighlightAnnotationElement extends AnnotationElement { constructor(parameters) { const isRenderable = !!( - parameters.data.hasPopup || + parameters.data.popupRef || parameters.data.titleObj?.str || parameters.data.contentsObj?.str || parameters.data.richText?.str @@ -2415,8 +2551,8 @@ class HighlightAnnotationElement extends AnnotationElement { } render() { - if (!this.data.hasPopup) { - this._createPopup(null, this.data); + if (!this.data.popupRef) { + this._createPopup(); } if (this.quadrilaterals) { @@ -2431,7 +2567,7 @@ class HighlightAnnotationElement extends AnnotationElement { class UnderlineAnnotationElement extends AnnotationElement { constructor(parameters) { const isRenderable = !!( - parameters.data.hasPopup || + parameters.data.popupRef || parameters.data.titleObj?.str || parameters.data.contentsObj?.str || parameters.data.richText?.str @@ -2444,8 +2580,8 @@ class UnderlineAnnotationElement extends AnnotationElement { } render() { - if (!this.data.hasPopup) { - this._createPopup(null, this.data); + if (!this.data.popupRef) { + this._createPopup(); } if (this.quadrilaterals) { @@ -2460,7 +2596,7 @@ class UnderlineAnnotationElement extends AnnotationElement { class SquigglyAnnotationElement extends AnnotationElement { constructor(parameters) { const isRenderable = !!( - parameters.data.hasPopup || + parameters.data.popupRef || parameters.data.titleObj?.str || parameters.data.contentsObj?.str || parameters.data.richText?.str @@ -2473,8 +2609,8 @@ class SquigglyAnnotationElement extends AnnotationElement { } render() { - if (!this.data.hasPopup) { - this._createPopup(null, this.data); + if (!this.data.popupRef) { + this._createPopup(); } if (this.quadrilaterals) { @@ -2489,7 +2625,7 @@ class SquigglyAnnotationElement extends AnnotationElement { class StrikeOutAnnotationElement extends AnnotationElement { constructor(parameters) { const isRenderable = !!( - parameters.data.hasPopup || + parameters.data.popupRef || parameters.data.titleObj?.str || parameters.data.contentsObj?.str || parameters.data.richText?.str @@ -2502,8 +2638,8 @@ class StrikeOutAnnotationElement extends AnnotationElement { } render() { - if (!this.data.hasPopup) { - this._createPopup(null, this.data); + if (!this.data.popupRef) { + this._createPopup(); } if (this.quadrilaterals) { @@ -2518,7 +2654,7 @@ class StrikeOutAnnotationElement extends AnnotationElement { class StampAnnotationElement extends AnnotationElement { constructor(parameters) { const isRenderable = !!( - parameters.data.hasPopup || + parameters.data.popupRef || parameters.data.titleObj?.str || parameters.data.contentsObj?.str || parameters.data.richText?.str @@ -2529,14 +2665,16 @@ class StampAnnotationElement extends AnnotationElement { render() { this.container.classList.add("stampAnnotation"); - if (!this.data.hasPopup) { - this._createPopup(null, this.data); + if (!this.data.popupRef) { + this._createPopup(); } return this.container; } } class FileAttachmentAnnotationElement extends AnnotationElement { + #trigger = null; + constructor(parameters) { super(parameters, { isRenderable: true }); @@ -2570,20 +2708,25 @@ class FileAttachmentAnnotationElement extends AnnotationElement { } trigger.classList.add("popupTriggerArea"); trigger.addEventListener("dblclick", this._download.bind(this)); + this.#trigger = trigger; if ( - !this.data.hasPopup && + !this.data.popupRef && (this.data.titleObj?.str || this.data.contentsObj?.str || this.data.richText) ) { - this._createPopup(trigger, this.data); + this._createPopup(); } this.container.append(trigger); return this.container; } + getElementsToTriggerPopup() { + return this.#trigger; + } + /** * Download the file attachment associated with this annotation. * @@ -2627,23 +2770,44 @@ class AnnotationLayer { #annotationCanvasMap = null; - #div = null; - #editableAnnotations = new Map(); - constructor({ div, accessibilityManager, annotationCanvasMap }) { - this.#div = div; + constructor({ + div, + accessibilityManager, + annotationCanvasMap, + l10n, + page, + viewport, + }) { + this.div = div; this.#accessibilityManager = accessibilityManager; this.#annotationCanvasMap = annotationCanvasMap; + this.l10n = l10n; + this.page = page; + this.viewport = viewport; + this.zIndex = 0; + + if (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) { + // For testing purposes. + Object.defineProperty(this, "showPopups", { + value: async () => { + for (const show of this.popupShow) { + await show(); + } + }, + }); + this.popupShow = []; + } } #appendElement(element, id) { const contentElement = element.firstChild || element; contentElement.id = `${AnnotationPrefix}${id}`; - this.#div.append(element); + this.div.append(element); this.#accessibilityManager?.moveElementInDOM( - this.#div, + this.div, element, contentElement, /* isRemovable = */ false @@ -2657,15 +2821,14 @@ class AnnotationLayer { * @memberof AnnotationLayer */ render(params) { - const { annotations, viewport } = params; - const layer = this.#div; - setLayerDimensions(layer, viewport); + const { annotations } = params; + const layer = this.div; + setLayerDimensions(layer, this.viewport); + const popupToElements = new Map(); const elementParams = { data: null, layer, - page: params.page, - viewport, linkService: params.linkService, downloadManager: params.downloadManager, imageResourcesPath: params.imageResourcesPath || "", @@ -2675,18 +2838,27 @@ class AnnotationLayer { enableScripting: params.enableScripting === true, hasJSActions: params.hasJSActions, fieldObjects: params.fieldObjects, + parent: this, + elements: null, }; - let zIndex = 0; for (const data of annotations) { if (data.noHTML) { continue; } - if (data.annotationType !== AnnotationType.POPUP) { + const isPopupAnnotation = data.annotationType === AnnotationType.POPUP; + if (!isPopupAnnotation) { const { width, height } = getRectDims(data.rect); if (width <= 0 || height <= 0) { continue; // Ignore empty annotations. } + } else { + const elements = popupToElements.get(data.id); + if (!elements) { + // Ignore popup annotations without a corresponding annotation. + continue; + } + elementParams.elements = elements; } elementParams.data = data; const element = AnnotationElementFactory.create(elementParams); @@ -2695,6 +2867,15 @@ class AnnotationLayer { continue; } + if (!isPopupAnnotation && data.popupRef) { + const elements = popupToElements.get(data.popupRef); + if (!elements) { + popupToElements.set(data.popupRef, [element]); + } else { + elements.push(element); + } + } + if (element.annotationEditorType > 0) { this.#editableAnnotations.set(element.data.id, element); } @@ -2705,24 +2886,10 @@ class AnnotationLayer { } if (Array.isArray(rendered)) { for (const renderedElement of rendered) { - renderedElement.style.zIndex = zIndex++; this.#appendElement(renderedElement, data.id); } } else { - // The accessibility manager will move the annotation in the DOM in - // order to match the visual ordering. - // But if an annotation is above an other one, then we must draw it - // after the other one whatever the order is in the DOM, hence the - // use of the z-index. - rendered.style.zIndex = zIndex++; - - if (element instanceof PopupAnnotationElement) { - // Popup annotation elements should not be on top of other - // annotation elements to prevent interfering with mouse events. - layer.prepend(rendered); - } else { - this.#appendElement(rendered, data.id); - } + this.#appendElement(rendered, data.id); } } @@ -2736,7 +2903,8 @@ class AnnotationLayer { * @memberof AnnotationLayer */ update({ viewport }) { - const layer = this.#div; + const layer = this.div; + this.viewport = viewport; setLayerDimensions(layer, { rotation: viewport.rotation }); this.#setAnnotationCanvasMap(); @@ -2747,7 +2915,7 @@ class AnnotationLayer { if (!this.#annotationCanvasMap) { return; } - const layer = this.#div; + const layer = this.div; for (const [id, canvas] of this.#annotationCanvasMap) { const element = layer.querySelector(`[data-annotation-id="${id}"]`); if (!element) { diff --git a/src/display/editor/freetext.js b/src/display/editor/freetext.js index 53439912f..f7e7c4fbf 100644 --- a/src/display/editor/freetext.js +++ b/src/display/editor/freetext.js @@ -537,7 +537,9 @@ class FreeTextEditor extends AnnotationEditor { id, }, textContent, - page: { pageNumber }, + parent: { + page: { pageNumber }, + }, } = data; if (!textContent || textContent.length === 0) { // Empty annotation. diff --git a/test/annotation_layer_builder_overrides.css b/test/annotation_layer_builder_overrides.css index f882fded4..4efeb8dbe 100644 --- a/test/annotation_layer_builder_overrides.css +++ b/test/annotation_layer_builder_overrides.css @@ -30,14 +30,18 @@ } .annotationLayer :is(.linkAnnotation, .buttonWidgetAnnotation.pushButton) > a, -.annotationLayer .popupTriggerArea { +.annotationLayer .popupTriggerArea::after, +.annotationLayer .fileAttachmentAnnotation .popupTriggerArea { opacity: 0.2; background: rgba(255, 255, 0, 1); box-shadow: 0 2px 10px rgba(255, 255, 0, 1); } -.annotationLayer :is(.popupAnnotation, .popupWrapper) { +.annotationLayer .popupTriggerArea::after { display: block; + width: 100%; + height: 100%; + content: ""; } .annotationLayer .popup :is(h1, p) { diff --git a/test/driver.js b/test/driver.js index ab52b13fb..b2ce75bc5 100644 --- a/test/driver.js +++ b/test/driver.js @@ -223,9 +223,7 @@ class Rasterize { // Rendering annotation layer as HTML. const parameters = { - viewport: annotationViewport, annotations, - page, linkService: new SimpleLinkService(), imageResourcesPath, renderForms, @@ -233,8 +231,12 @@ class Rasterize { const annotationLayer = new AnnotationLayer({ div, annotationCanvasMap: annotationImageMap, + page, + l10n, + viewport: annotationViewport, }); annotationLayer.render(parameters); + await annotationLayer.showPopups(); await l10n.translate(div); // Inline SVG images from text annotations. diff --git a/test/integration/annotation_spec.js b/test/integration/annotation_spec.js index 74f73e414..b082841ce 100644 --- a/test/integration/annotation_spec.js +++ b/test/integration/annotation_spec.js @@ -21,7 +21,7 @@ const { } = require("./test_utils.js"); describe("Annotation highlight", () => { - describe("annotation-highlight.pdf", () => { + fdescribe("annotation-highlight.pdf", () => { let pages; beforeAll(async () => { diff --git a/test/unit/annotation_spec.js b/test/unit/annotation_spec.js index dee019222..a8cee1143 100644 --- a/test/unit/annotation_spec.js +++ b/test/unit/annotation_spec.js @@ -623,7 +623,7 @@ describe("annotation", function () { expect(data.creationDate).toEqual("D:20180423"); expect(data.modificationDate).toEqual("D:20190423"); expect(data.color).toEqual(new Uint8ClampedArray([0, 0, 255])); - expect(data.hasPopup).toEqual(true); + expect(data.popupRef).toEqual("820R"); }); it("should parse IRT/RT for a reply type", async function () { @@ -678,7 +678,7 @@ describe("annotation", function () { expect(data.creationDate).toEqual("D:20180523"); expect(data.modificationDate).toEqual("D:20190523"); expect(data.color).toEqual(new Uint8ClampedArray([102, 102, 102])); - expect(data.hasPopup).toEqual(false); + expect(data.popupRef).toEqual(null); }); }); diff --git a/web/annotation_layer_builder.css b/web/annotation_layer_builder.css index 2bfec4965..f22194d46 100644 --- a/web/annotation_layer_builder.css +++ b/web/annotation_layer_builder.css @@ -42,6 +42,10 @@ .annotationLayer .linkAnnotation:hover { backdrop-filter: invert(100%); } + + .annotationLayer .popupAnnotation.focused .popup { + outline: calc(3px * var(--scale-factor)) solid Highlight !important; + } } .annotationLayer { @@ -240,32 +244,27 @@ appearance: none; } -.annotationLayer .popupTriggerArea { +.annotationLayer .fileAttachmentAnnotation .popupTriggerArea { height: 100%; width: 100%; } -.annotationLayer .fileAttachmentAnnotation .popupTriggerArea { - position: absolute; -} - -.annotationLayer .popupWrapper { +.annotationLayer .popupAnnotation { position: absolute; font-size: calc(9px * var(--scale-factor)); - width: 100%; - min-width: calc(180px * var(--scale-factor)); pointer-events: none; + width: max-content; + max-width: 45%; + height: auto; } .annotationLayer .popup { - position: absolute; - max-width: calc(180px * var(--scale-factor)); background-color: rgba(255, 255, 153, 1); box-shadow: 0 calc(2px * var(--scale-factor)) calc(5px * var(--scale-factor)) rgba(136, 136, 136, 1); border-radius: calc(2px * var(--scale-factor)); + outline: 1.5px solid rgb(255, 255, 74); padding: calc(6px * var(--scale-factor)); - margin-left: calc(5px * var(--scale-factor)); cursor: pointer; font: message-box; white-space: normal; @@ -273,17 +272,26 @@ pointer-events: auto; } -.annotationLayer .popup > * { +.annotationLayer .popupAnnotation.focused .popup { + outline-width: 3px; +} + +.annotationLayer .popup * { font-size: calc(9px * var(--scale-factor)); } -.annotationLayer .popup h1 { +.annotationLayer .popup > .header { display: inline-block; } -.annotationLayer .popupDate { +.annotationLayer .popup > .header h1 { + display: inline; +} + +.annotationLayer .popup > .header .popupDate { display: inline-block; margin-left: calc(5px * var(--scale-factor)); + width: fit-content; } .annotationLayer .popupContent { diff --git a/web/annotation_layer_builder.js b/web/annotation_layer_builder.js index a0fb1ce52..1edcf6f94 100644 --- a/web/annotation_layer_builder.js +++ b/web/annotation_layer_builder.js @@ -129,12 +129,13 @@ class AnnotationLayerBuilder { div, accessibilityManager: this._accessibilityManager, annotationCanvasMap: this._annotationCanvasMap, + l10n: this.l10n, + page: this.pdfPage, + viewport: viewport.clone({ dontFlip: true }), }); this.annotationLayer.render({ - viewport: viewport.clone({ dontFlip: true }), annotations, - page: this.pdfPage, imageResourcesPath: this.imageResourcesPath, renderForms: this.renderForms, linkService: this.linkService,