mirror of
https://github.com/mozilla/pdf.js.git
synced 2026-06-02 00:01:01 +02:00
Merge pull request #21309 from calixteman/issue21307
Fix 'Select all' after #20981
This commit is contained in:
commit
78cc2e3d38
@ -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) {
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user