[Editor] Make sure that annotation positions in the DOM respect the visual order (bug 1992770)

This commit is contained in:
Calixte Denizet 2025-10-07 21:01:03 +02:00
parent d83cbb28a9
commit 41dea1e38b
5 changed files with 172 additions and 32 deletions

View File

@ -186,7 +186,8 @@ class AnnotationElement {
this.parent = parameters.parent; this.parent = parameters.parent;
if (isRenderable) { if (isRenderable) {
this.container = this._createContainer(ignoreBorder); this.contentElement = this.container =
this._createContainer(ignoreBorder);
} }
if (createQuadrilaterals) { if (createQuadrilaterals) {
this._createQuadrilaterals(); this._createQuadrilaterals();
@ -1008,6 +1009,7 @@ class LinkAnnotationElement extends AnnotationElement {
this.container.classList.add("linkAnnotation"); this.container.classList.add("linkAnnotation");
if (isBound) { if (isBound) {
this.contentElement = link;
this.container.append(link); this.container.append(link);
} }
@ -1517,6 +1519,7 @@ class TextWidgetAnnotationElement extends WidgetAnnotationElement {
element.hidden = true; element.hidden = true;
} }
GetElementsByNameSet.add(element); GetElementsByNameSet.add(element);
this.contentElement = element;
element.setAttribute("data-element-id", id); element.setAttribute("data-element-id", id);
element.disabled = this.data.readOnly; element.disabled = this.data.readOnly;
@ -3070,7 +3073,7 @@ class FreeTextAnnotationElement extends AnnotationElement {
this.container.classList.add("freeTextAnnotation"); this.container.classList.add("freeTextAnnotation");
if (this.textContent) { if (this.textContent) {
const content = document.createElement("div"); const content = (this.contentElement = document.createElement("div"));
content.classList.add("annotationTextContent"); content.classList.add("annotationTextContent");
content.setAttribute("role", "comment"); content.setAttribute("role", "comment");
for (const line of this.textContent) { for (const line of this.textContent) {
@ -3787,7 +3790,7 @@ class AnnotationLayer {
} }
async #appendElement(element, id, popupElements) { async #appendElement(element, id, popupElements) {
const contentElement = element.firstChild || element; const { contentElement, container } = element;
const annotationId = (contentElement.id = `${AnnotationPrefix}${id}`); const annotationId = (contentElement.id = `${AnnotationPrefix}${id}`);
const ariaAttributes = const ariaAttributes =
await this.#structTreeLayer?.getAriaAttributes(annotationId); await this.#structTreeLayer?.getAriaAttributes(annotationId);
@ -3799,17 +3802,30 @@ class AnnotationLayer {
if (popupElements) { if (popupElements) {
// Set the popup just after the first element associated with the popup. // Set the popup just after the first element associated with the popup.
popupElements.at(-1).container.after(element); popupElements.at(-1).container.after(container);
} else { } else {
this.div.append(element); this.#moveElementInDOM(container, contentElement);
}
}
#moveElementInDOM(container, contentElement) {
this.div.append(container);
this.#accessibilityManager?.moveElementInDOM( this.#accessibilityManager?.moveElementInDOM(
this.div, this.div,
element, container,
contentElement, contentElement,
/* isRemovable = */ false /* isRemovable = */ false,
); /* filter = */ node => node.nodeName === "SECTION",
/* inserter = */ (prevNode, node) => {
if (prevNode.nextElementSibling.nodeName === "BUTTON") {
// In case we have a comment button, insert after the button.
prevNode.nextElementSibling.after(node);
} else {
prevNode.after(node);
} }
} }
);
}
/** /**
* Render a new annotation layer with all annotation elements. * Render a new annotation layer with all annotation elements.
@ -3877,7 +3893,7 @@ class AnnotationLayer {
if (data.hidden) { if (data.hidden) {
rendered.style.visibility = "hidden"; rendered.style.visibility = "hidden";
} }
await this.#appendElement(rendered, data.id, elementParams.elements); await this.#appendElement(element, data.id, elementParams.elements);
element.extraPopupElement?.popup?.renderCommentButton(); element.extraPopupElement?.popup?.renderCommentButton();
if (element._isEditable) { if (element._isEditable) {
@ -3913,8 +3929,8 @@ class AnnotationLayer {
if (!element.isRenderable) { if (!element.isRenderable) {
continue; continue;
} }
const rendered = element.render(); element.render();
await this.#appendElement(rendered, data.id, null); await this.#appendElement(element, data.id, null);
} }
} }
@ -3999,18 +4015,32 @@ class AnnotationLayer {
linkService: this.#linkService, linkService: this.#linkService,
annotationStorage: this.#annotationStorage, annotationStorage: this.#annotationStorage,
}); });
const htmlElement = element.render(); const rendered = element.render();
div.append(htmlElement); rendered.id = `${AnnotationPrefix}${id}`;
this.#accessibilityManager?.moveElementInDOM( this.#moveElementInDOM(rendered, rendered);
div,
htmlElement,
htmlElement,
/* isRemovable = */ false
);
element.createOrUpdatePopup(); element.createOrUpdatePopup();
return element; return element;
} }
togglePointerEvents(enabled = false) {
this.div.classList.toggle("disabled", !enabled);
}
updateFakeAnnotations(editors) {
if (editors.length === 0) {
return;
}
// In order to ensure that the annotations are correctly moved in the DOM
// we need to make sure that this has been laid out.
window.requestAnimationFrame(() =>
setTimeout(() => {
for (const editor of editors) {
editor.updateFakeAnnotationElement(this);
}
}, 10)
);
}
/** /**
* @private * @private
*/ */

View File

@ -242,7 +242,7 @@ class AnnotationEditorLayer {
} }
toggleAnnotationLayerPointerEvents(enabled = false) { toggleAnnotationLayerPointerEvents(enabled = false) {
this.#annotationLayer?.div.classList.toggle("disabled", !enabled); this.#annotationLayer?.togglePointerEvents(enabled);
} }
get #allEditorsIterator() { get #allEditorsIterator() {
@ -354,13 +354,14 @@ class AnnotationEditorLayer {
} }
const annotationLayer = this.#annotationLayer; const annotationLayer = this.#annotationLayer;
const needFakeAnnotation = [];
if (annotationLayer) { if (annotationLayer) {
const changedAnnotations = new Map(); const changedAnnotations = new Map();
const resetAnnotations = new Map(); const resetAnnotations = new Map();
for (const editor of this.#allEditorsIterator) { for (const editor of this.#allEditorsIterator) {
editor.disableEditing(); editor.disableEditing();
if (!editor.annotationElementId) { if (!editor.annotationElementId) {
editor.updateFakeAnnotationElement(annotationLayer); needFakeAnnotation.push(editor);
continue; continue;
} }
if (editor.serialize() !== null) { if (editor.serialize() !== null) {
@ -411,6 +412,7 @@ class AnnotationEditorLayer {
} }
this.disableTextSelection(); this.disableTextSelection();
this.toggleAnnotationLayerPointerEvents(true); this.toggleAnnotationLayerPointerEvents(true);
annotationLayer?.updateFakeAnnotations(needFakeAnnotation);
this.#isDisabling = false; this.#isDisabling = false;
} }

View File

@ -95,7 +95,7 @@ describe("Annotation highlight", () => {
pages.map(async ([browserName, page]) => { pages.map(async ([browserName, page]) => {
for (const i of [23, 22, 14]) { for (const i of [23, 22, 14]) {
await page.click(getAnnotationSelector(`${i}R`)); await page.click(getAnnotationSelector(`${i}R`));
await page.waitForSelector(`#pdfjs_internal_id_${i}R:focus`); await page.waitForSelector(`#pdfjs_internal_id_${i}R:focus-within`);
} }
}) })
); );
@ -878,4 +878,46 @@ a dynamic compiler for JavaScript based on our`);
}); });
}); });
}); });
describe("Annotation order in the DOM", () => {
let pages;
beforeEach(async () => {
pages = await loadAndWait(
"comments.pdf",
".page[data-page-number='1'] .annotationLayer #pdfjs_internal_id_661R"
);
});
afterEach(async () => {
await closePages(pages);
});
it("must check that annotations are in the visual order", async () => {
await Promise.all(
pages.map(async ([browserName, page]) => {
const sectionIds = await page.evaluate(() =>
[
...document.querySelectorAll(
".page[data-page-number='1'] .annotationLayer > section:not(.popupAnnotation)"
),
].map(el => el.id.split("_").pop())
);
expect(sectionIds)
.withContext(`In ${browserName}`)
.toEqual([
"612R",
"693R",
"687R",
"690R",
"713R",
"673R",
"613R",
"680R",
"661R",
]);
})
);
});
});
}); });

View File

@ -737,4 +737,65 @@ describe("Comment", () => {
); );
}); });
}); });
describe("Annotations order in reading mode", () => {
let pages;
beforeEach(async () => {
pages = await loadAndWait(
"comments.pdf",
".annotationEditorLayer",
"page-fit",
null,
{ enableComment: true }
);
});
afterEach(async () => {
await closePages(pages);
});
it("must check that the annotations are in the right order", async () => {
await Promise.all(
pages.map(async ([browserName, page]) => {
await switchToHighlight(page);
await highlightSpan(
page,
1,
"method provides cheap inter-procedural type specialization, and an"
);
await editComment(page, getEditorSelector(9), "Hello world!");
await highlightSpan(page, 1, "Andreas Gal");
await editComment(page, getEditorSelector(10), "Hello world!");
await switchToHighlight(page, /* disable = */ true);
await page.waitForSelector(
".annotationLayer section:nth-child(4).editorAnnotation"
);
const sectionIds = await page.evaluate(() =>
[
...document.querySelectorAll(
".page[data-page-number='1'] .annotationLayer > section:not(.popupAnnotation)"
),
].map(el => el.id.split("_").pop())
);
expect(sectionIds).withContext(`In ${browserName}`).toEqual([
"612R",
"693R",
"10", // shortcut for pdfjs_internal_id_pdfjs_internal_editor_10
"687R",
"690R",
"713R",
"9", // shortcut for pdfjs_internal_id_pdfjs_internal_editor_9
"673R",
"613R",
"680R",
"661R",
]);
})
);
});
});
}); });

View File

@ -222,7 +222,14 @@ class TextAccessibilityManager {
* @param {HTMLDivElement} element * @param {HTMLDivElement} element
* @returns {string|null} The id in the struct tree if any. * @returns {string|null} The id in the struct tree if any.
*/ */
moveElementInDOM(container, element, contentElement, isRemovable) { moveElementInDOM(
container,
element,
contentElement,
isRemovable,
filter,
inserter
) {
const id = this.addPointerInTextLayer(contentElement, isRemovable); const id = this.addPointerInTextLayer(contentElement, isRemovable);
if (!container.hasChildNodes()) { if (!container.hasChildNodes()) {
@ -231,25 +238,23 @@ class TextAccessibilityManager {
} }
const children = Array.from(container.childNodes).filter( const children = Array.from(container.childNodes).filter(
node => node !== element node => node !== element && (!filter || filter(node))
); );
if (children.length === 0) { if (children.length === 0) {
return id; return id;
} }
const elementToCompare = contentElement || element;
const index = binarySearchFirstItem( const index = binarySearchFirstItem(
children, children,
node => node =>
TextAccessibilityManager.#compareElementPositions( TextAccessibilityManager.#compareElementPositions(element, node) < 0
elementToCompare,
node
) < 0
); );
if (index === 0) { if (index === 0) {
children[0].before(element); children[0].before(element);
} else if (inserter) {
inserter(children[index - 1], element);
} else { } else {
children[index - 1].after(element); children[index - 1].after(element);
} }