Merge pull request #21309 from calixteman/issue21307

Fix 'Select all' after #20981
This commit is contained in:
calixteman 2026-05-21 18:11:56 +02:00 committed by GitHub
commit 78cc2e3d38
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 217 additions and 31 deletions

View File

@ -203,6 +203,9 @@ class DrawLayer {
/** @type {Object | null} */
#pageColors = null;
/** @type {MutationObserver | null} */
#textLayerObserver = null;
#toUpdate = new Map();
static #id = 0;
@ -248,6 +251,28 @@ class DrawLayer {
DrawLayer.#textLayers.set(textLayer, { drawLayer: this });
DrawLayer.#textLayerSet.add(textLayer);
this.#textLayer = textLayer;
this.#textLayerObserver = new MutationObserver(records => {
if (
!this.#parent ||
!this.#textLayer?.isConnected ||
!DrawLayer.#hasSelection()
) {
return;
}
for (const { addedNodes } of records) {
for (const node of addedNodes) {
if (
node.nodeType === Node.ELEMENT_NODE &&
node.classList.contains("endOfContent")
) {
DrawLayer.#selectionChange();
return;
}
}
}
});
this.#textLayerObserver.observe(textLayer, { childList: true });
if (DrawLayer.#selectionChangeAC === null) {
DrawLayer.#selectionChangeAC = new AbortController();
const { signal } = DrawLayer.#selectionChangeAC;
@ -288,6 +313,12 @@ class DrawLayer {
setParent(parent) {
if (!this.#parent) {
this.#parent = parent;
// A new text layer just became live (e.g. its page was scrolled into
// view). If a selection already exists, redraw overlays so that the
// selection extends into this newly-rendered text layer.
if (this.#textLayer?.isConnected && DrawLayer.#hasSelection()) {
DrawLayer.#selectionChange();
}
return;
}
@ -321,6 +352,25 @@ class DrawLayer {
textLayerData.path = null;
}
/**
* @returns {boolean}
* Whether there is a non-collapsed document selection.
*/
static #hasSelection() {
const selection = document.getSelection();
return !!selection && !selection.isCollapsed;
}
/**
* @returns {Array<Element>}
* Connected text layers sorted in document order.
*/
static #getOrderedTextLayers() {
return [...this.#textLayerSet]
.filter(textLayer => textLayer.isConnected)
.sort(compareTextLayers);
}
/**
* Handle `selectionchange` to update the selection display for text layers.
* We want to display the selection in a separate layer on top of the text
@ -341,6 +391,7 @@ class DrawLayer {
}
/** @type {WeakMap<Node, SelectionRotator>} */
const rotators = new WeakMap();
const orderedTextLayers = this.#getOrderedTextLayers();
/** @type {Array<[Range, Element]>} */
const ranges = [];
for (let i = 0, ii = selection.rangeCount; i < ii; i++) {
@ -390,10 +441,30 @@ class DrawLayer {
}
}
if (!startTextLayer || !endTextLayer) {
// Any remaining partial/outside range can be ignored.
const activeTextLayers = orderedTextLayers.filter(textLayer =>
range.intersectsNode(textLayer)
);
if (activeTextLayers.length === 0) {
continue;
}
// If a boundary is outside any text layer, use the selected live text
// layers as the range edges. This handles Select All, whose DOM range can
// span ancestors of the text layers.
let boundarySubstituted = false;
if (!startTextLayer) {
startTextLayer = activeTextLayers[0];
startContainer = startTextLayer;
startOffset = 0;
boundarySubstituted = true;
}
if (!endTextLayer) {
endTextLayer = activeTextLayers.at(-1);
endContainer = endTextLayer;
endOffset = endTextLayer.childNodes.length;
boundarySubstituted = true;
}
if (endContainer.nodeType === Node.ELEMENT_NODE) {
if (endContainer.classList.contains("endOfContent")) {
const previousNode = endContainer.previousSibling;
@ -435,39 +506,15 @@ class DrawLayer {
startOffset = normalizedStart.offset;
}
if (startTextLayer === endTextLayer) {
if (
startTextLayer === endTextLayer &&
!boundarySubstituted &&
activeTextLayers.includes(startTextLayer)
) {
ranges.push([range, startTextLayer]);
continue;
}
/** @type {Array<Element>} */
let activeTextLayers = [];
const orderedTextLayers = [...this.#textLayerSet].sort(compareTextLayers);
const startIndex = orderedTextLayers.indexOf(startTextLayer);
const endIndex = orderedTextLayers.indexOf(endTextLayer);
if (startIndex !== -1 && endIndex !== -1) {
const [minIndex, maxIndex] =
startIndex < endIndex
? [startIndex, endIndex]
: [endIndex, startIndex];
activeTextLayers = orderedTextLayers.slice(minIndex, maxIndex + 1);
} else {
// Fallback if a layer is no longer tracked for any reason.
for (const textLayer of this.#textLayerSet) {
if (range.intersectsNode(textLayer)) {
activeTextLayers.push(textLayer);
}
}
if (!activeTextLayers.includes(startTextLayer)) {
activeTextLayers.push(startTextLayer);
}
if (!activeTextLayers.includes(endTextLayer)) {
activeTextLayers.push(endTextLayer);
}
activeTextLayers.sort(compareTextLayers);
}
for (const textLayer of activeTextLayers) {
const firstNode = textLayer.firstChild;
if (!firstNode) {
@ -793,6 +840,8 @@ class DrawLayer {
}
this.#mapping.clear();
this.#toUpdate.clear();
this.#textLayerObserver?.disconnect();
this.#textLayerObserver = null;
if (this.#textLayer) {
const data = DrawLayer.#textLayers.get(this.#textLayer);
if (data?.drawLayer === this) {

View File

@ -21,6 +21,7 @@ import {
closePages,
closeSinglePage,
getSpanRectFromText,
kbSelectAll,
loadAndWait,
waitForEvent,
} from "./test_utils.mjs";
@ -1118,6 +1119,142 @@ describe("Text layer", () => {
.toHaveRoughlySelected(/frequently .* We call such a s/s);
});
});
describe("with select-all (Ctrl+A)", () => {
/** @type {Array<[string, Page]>} */
let pages;
/**
* Return the set of page numbers that have a non-empty selection
* overlay path in their draw layer.
*
* @param {Page} page
* @returns {Promise<Array<number>>}
*/
async function pagesWithDrawnSelection(page) {
return page.evaluate(() => {
const numbers = new Set();
for (const path of document.querySelectorAll(
".page .canvasWrapper .selection svg path"
)) {
if (path.getAttribute("d")?.trim()) {
const n = path.closest(".page")?.dataset.pageNumber;
if (n) {
numbers.add(Number(n));
}
}
}
return [...numbers].sort((a, b) => a - b);
});
}
beforeEach(async () => {
pages = await loadAndWait(
"tracemonkey.pdf",
`.page[data-page-number = "1"] .endOfContent`
);
});
afterEach(async () => {
await closePages(pages);
});
it("draws a selection overlay on currently-rendered pages", async () => {
await Promise.all(
pages.map(async ([browserName, page]) => {
// Wait for at least two text layers to be rendered so the
// overlay can be expected on multiple pages. The number of
// pages rendered up-front at the default zoom can vary
// depending on the viewport size on CI.
await page.waitForFunction(
() =>
document.querySelectorAll(".textLayer .endOfContent").length >=
2
);
await waitForEvent({
page,
eventName: "selectionchange",
action: () => kbSelectAll(page),
});
expect(await hasDrawnSelection(page))
.withContext(`In ${browserName}`)
.toBeTrue();
// Several text layers are rendered at the default zoom and
// each one should now carry a selection overlay.
const drawn = await pagesWithDrawnSelection(page);
expect(drawn.length)
.withContext(
`In ${browserName}, pages with selection overlay: ` +
`${drawn.join(",")}`
)
.toBeGreaterThan(1);
expect(drawn[0])
.withContext(`In ${browserName}, first selected page`)
.toBe(1);
})
);
});
it("extends the overlay onto pages rendered after scroll", async () => {
await Promise.all(
pages.map(async ([browserName, page]) => {
await waitForEvent({
page,
eventName: "selectionchange",
action: () => kbSelectAll(page),
});
const initial = await pagesWithDrawnSelection(page);
expect(initial.length)
.withContext(`In ${browserName}, initial pages with overlay`)
.toBeGreaterThan(0);
// Pick the first page that hasn't been rendered with a
// selection overlay yet, scroll to it, and verify the overlay
// gets drawn on it once its draw layer is parented.
const lastInitial = initial.at(-1);
const targetPage = lastInitial + 1;
await page.evaluate(n => {
const pageDiv = document.querySelector(
`.page[data-page-number="${n}"]`
);
pageDiv.scrollIntoView({ block: "center" });
}, targetPage);
await page.waitForSelector(
`.page[data-page-number="${targetPage}"] .textLayer .endOfContent`,
{ timeout: 0 }
);
// After the new page is rendered, its draw layer becomes
// "live" (`setParent` is called) and the selection overlay
// must be extended onto it without requiring a new
// `selectionchange` event.
await page.waitForFunction(
n => {
const path = document.querySelector(
`.page[data-page-number="${n}"] .canvasWrapper .selection svg path`
);
return !!path?.getAttribute("d")?.trim();
},
{ timeout: 0 },
targetPage
);
const afterScroll = await pagesWithDrawnSelection(page);
expect(afterScroll)
.withContext(
`In ${browserName}, target page ${targetPage} has overlay`
)
.toContain(targetPage);
})
);
});
});
});
describe("when the browser enforces a minimum font size", () => {