From 0f909879275e8aacbe5c794aac1c0af2c9e0e1bb Mon Sep 17 00:00:00 2001 From: Calixte Denizet Date: Wed, 20 May 2026 19:30:22 +0200 Subject: [PATCH] Fix 'Select all' after #20981 --- src/display/draw_layer.js | 111 ++++++++++++++++------ test/integration/text_layer_spec.mjs | 137 +++++++++++++++++++++++++++ 2 files changed, 217 insertions(+), 31 deletions(-) diff --git a/src/display/draw_layer.js b/src/display/draw_layer.js index 1c1aa8411..5cca03892 100644 --- a/src/display/draw_layer.js +++ b/src/display/draw_layer.js @@ -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} + * 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} */ 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} */ - 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) { diff --git a/test/integration/text_layer_spec.mjs b/test/integration/text_layer_spec.mjs index 61cea04c9..102438691 100644 --- a/test/integration/text_layer_spec.mjs +++ b/test/integration/text_layer_spec.mjs @@ -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>} + */ + 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", () => {