Fix text selection stuck in pan mode on touch-capable devices

This commit is contained in:
Anthony Stirling
2026-08-25 13:45:11 +01:00
parent 49c1e75ced
commit e03d7e37e2
12 changed files with 524 additions and 50 deletions
@@ -21,11 +21,7 @@ import { Scroller, ScrollPluginPackage } from "@embedpdf/plugin-scroll/react";
import { DocumentManagerPluginPackage } from "@embedpdf/plugin-document-manager/react";
import { RenderPluginPackage } from "@embedpdf/plugin-render/react";
import { ZoomPluginPackage, ZoomMode } from "@embedpdf/plugin-zoom/react";
import {
InteractionManagerPluginPackage,
PagePointerProvider,
GlobalPointerProvider,
} from "@embedpdf/plugin-interaction-manager/react";
import { InteractionManagerPluginPackage } from "@embedpdf/plugin-interaction-manager/react";
import {
SelectionLayer,
SelectionPluginPackage,
@@ -35,6 +31,11 @@ import {
TilingPluginPackage,
} from "@embedpdf/plugin-tiling/react";
import { PanPluginPackage } from "@embedpdf/plugin-pan/react";
import { VIEWER_PAN_CONFIG } from "@app/components/viewer/viewerPanConfig";
import {
ViewerGlobalPointerProvider,
ViewerPagePointerProvider,
} from "@app/components/viewer/ViewerPointerProviders";
import { SpreadPluginPackage, SpreadMode } from "@embedpdf/plugin-spread/react";
import { SearchPluginPackage } from "@embedpdf/plugin-search/react";
import { ThumbnailPluginPackage } from "@embedpdf/plugin-thumbnail/react";
@@ -386,12 +387,7 @@ export function LocalEmbedPDF({
drawBlackBoxes: false,
}),
// Register pan plugin (depends on Viewport, InteractionManager).
// Keep the default mode ("never"). Do NOT set defaultMode: "mobile" - the pan
// react layer makes pan the default interaction on any touch-capable device
// (navigator.maxTouchPoints > 0), e.g. Windows touchscreen laptops, which then
// permanently locks the viewer in pan mode and blocks all text selection.
createPluginRegistration(PanPluginPackage),
createPluginRegistration(PanPluginPackage, VIEWER_PAN_CONFIG),
// Register zoom plugin with configuration
createPluginRegistration(ZoomPluginPackage, {
@@ -1057,7 +1053,7 @@ export function LocalEmbedPDF({
>
{(documentId) => (
<>
<GlobalPointerProvider documentId={documentId}>
<ViewerGlobalPointerProvider documentId={documentId}>
<Viewport
documentId={documentId}
style={{
@@ -1083,7 +1079,7 @@ export function LocalEmbedPDF({
documentId={documentId}
pageIndex={pageIndex}
>
<PagePointerProvider
<ViewerPagePointerProvider
documentId={documentId}
pageIndex={pageIndex}
>
@@ -1246,13 +1242,13 @@ export function LocalEmbedPDF({
/>
)}
</ViewerPageContainer>
</PagePointerProvider>
</ViewerPagePointerProvider>
</Rotate>
);
}}
/>
</Viewport>
</GlobalPointerProvider>
</ViewerGlobalPointerProvider>
{enableAnnotations && (
<CommentAuthorProvider displayName={commentAuthorName}>
<CommentsSidebar
@@ -29,9 +29,6 @@ function PanAPIBridgeInner({ documentId }: { documentId: string }) {
panRef.current = pan;
}, [pan]);
// Track previous isPanning value to detect changes
const prevIsPanningRef = useRef<boolean>(isPanning);
useEffect(() => {
const currentPan = panRef.current;
if (currentPan) {
@@ -52,24 +49,10 @@ function PanAPIBridgeInner({ documentId }: { documentId: string }) {
toggle: () => {
currentPan.togglePan();
},
makePanDefault: () => {
// v2.5.0: makePanDefault may not exist, enable pan as fallback
if (
"makePanDefault" in currentPan &&
typeof (currentPan as any).makePanDefault === "function"
) {
(currentPan as any).makePanDefault();
} else {
currentPan.enablePan();
}
},
},
});
if (prevIsPanningRef.current !== isPanning) {
prevIsPanningRef.current = isPanning;
triggerImmediatePanUpdate(isPanning);
}
triggerImmediatePanUpdate(isPanning);
}
return () => {
@@ -77,5 +60,11 @@ function PanAPIBridgeInner({ documentId }: { documentId: string }) {
};
}, [isPanning, registerBridge, triggerImmediatePanUpdate]);
useEffect(() => {
return () => {
triggerImmediatePanUpdate(false);
};
}, [triggerImmediatePanUpdate]);
return null;
}
@@ -43,6 +43,16 @@ function RedactionAPIBridgeInner({ documentId }: { documentId: string }) {
};
}, [setBridgeReady]);
useEffect(() => {
return () => {
try {
redactionProvides?.endRedact();
} catch {
/* document already torn down */
}
};
}, [redactionProvides]);
// Sync EmbedPDF state to our context
useEffect(() => {
if (state) {
@@ -0,0 +1,61 @@
import { useEffect, useState, type ReactNode } from "react";
import {
GlobalPointerProvider,
PagePointerProvider,
useInteractionManagerCapability,
} from "@embedpdf/plugin-interaction-manager/react";
const POINTER_MODE = "pointerMode";
function useActiveInteractionMode(documentId: string): string | null {
const { provides: interactionManager } = useInteractionManagerCapability();
const [mode, setMode] = useState<string | null>(null);
useEffect(() => {
if (!interactionManager) return;
const scope = interactionManager.forDocument(documentId);
setMode(scope.getActiveInteractionMode()?.id ?? null);
return scope.onModeChange((next) => setMode(next));
}, [interactionManager, documentId]);
return mode;
}
export function ViewerGlobalPointerProvider({
documentId,
children,
}: {
documentId: string;
children: ReactNode;
}) {
const mode = useActiveInteractionMode(documentId);
return (
<GlobalPointerProvider
documentId={documentId}
data-viewer-touch-scroll={mode === POINTER_MODE ? "on" : "off"}
>
{children}
</GlobalPointerProvider>
);
}
export function ViewerPagePointerProvider({
documentId,
pageIndex,
children,
}: {
documentId: string;
pageIndex: number;
children: ReactNode;
}) {
return (
<PagePointerProvider
documentId={documentId}
pageIndex={pageIndex}
className="pdf-page-pointer-layer"
>
{children}
</PagePointerProvider>
);
}
@@ -137,7 +137,6 @@ export function useViewerWorkbenchBarButtons(
setIsRulerActive?.(true);
if (isPanning) {
viewer.panActions.disablePan();
setIsPanning(false);
}
}, [isPanning, setIsRulerActive, startScaleCalibration, viewer.panActions]);
@@ -247,11 +246,7 @@ export function useViewerWorkbenchBarButtons(
!isPanning && pendingCount > 0 && redactionActiveType !== null,
onClick: () => {
viewer.panActions.togglePan();
setIsPanning((prev) => {
const next = !prev;
if (next && isRulerActive) setIsRulerActive?.(false);
return next;
});
if (!isPanning && isRulerActive) setIsRulerActive?.(false);
},
},
{
@@ -267,7 +262,6 @@ export function useViewerWorkbenchBarButtons(
setIsRulerActive?.(next);
if (next && isPanning) {
viewer.panActions.disablePan();
setIsPanning(false);
}
},
},
@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { PanPluginPackage } from "@embedpdf/plugin-pan";
import { VIEWER_PAN_CONFIG } from "@app/components/viewer/viewerPanConfig";
describe("viewer pan plugin config", () => {
it("resolves defaultMode to never so pan never becomes the interaction default", () => {
const resolved = {
...PanPluginPackage.manifest.defaultConfig,
...VIEWER_PAN_CONFIG,
};
expect(resolved.defaultMode).toBe("never");
});
});
@@ -0,0 +1,5 @@
import type { PanPluginConfig } from "@embedpdf/plugin-pan";
export const VIEWER_PAN_CONFIG: PanPluginConfig = {
defaultMode: "never",
};
@@ -55,7 +55,6 @@ export interface PanAPIWrapper {
enable: () => void;
disable: () => void;
toggle: () => void;
makePanDefault: () => void;
}
export interface SelectionAPIWrapper {
@@ -602,6 +602,10 @@
background-color: var(--p-gray-500) !important;
}
[data-viewer-touch-scroll="on"] .pdf-page-pointer-layer {
touch-action: pan-y pinch-zoom !important;
}
/* Override the flat multiply blend for a clean, modern semi-transparent overlay */
.pdf-selection-layer > div:first-child {
mix-blend-mode: normal !important;
@@ -0,0 +1,83 @@
import path from "path";
import { test, expect } from "@app/tests/helpers/stub-test-base";
const SAMPLE_PDF = path.join(
import.meta.dirname,
"../test-fixtures/sample.pdf",
);
const SELECTION_RECTS = ".pdf-selection-layer > div:first-child > div";
async function loadViewer(page: import("@playwright/test").Page) {
await page.goto("/editor");
await page.locator('input[type="file"]').first().setInputFiles(SAMPLE_PDF);
const firstPage = page.locator('[data-page-index="0"]').first();
await expect(firstPage).toBeVisible({ timeout: 30_000 });
await expect(firstPage.locator(".pdf-selection-layer")).toBeAttached({
timeout: 15_000,
});
await page.waitForTimeout(2_000);
return firstPage;
}
function viewerMode(page: import("@playwright/test").Page) {
return page.evaluate(() => {
const scope = document.querySelector<HTMLElement>(
"[data-viewer-touch-scroll]",
);
const pageEl = document.querySelector<HTMLElement>('[data-page-index="0"]');
return {
touchScroll: scope?.getAttribute("data-viewer-touch-scroll") ?? null,
cursor: pageEl?.parentElement
? getComputedStyle(pageEl.parentElement).cursor
: null,
};
});
}
test("exiting redaction restores text selection", async ({ page }) => {
test.setTimeout(120_000);
const firstPage = await loadViewer(page);
expect(await viewerMode(page)).toEqual({
touchScroll: "on",
cursor: "auto",
});
const redactButton = page.getByRole("button", { name: /redact/i }).first();
await expect(redactButton).toBeVisible({ timeout: 10_000 });
await redactButton.click();
await expect
.poll(async () => (await viewerMode(page)).cursor, { timeout: 15_000 })
.toBe("crosshair");
expect((await viewerMode(page)).touchScroll).toBe("off");
const exitButton = page
.getByRole("button", { name: /exit redaction mode/i })
.first();
if (await exitButton.isVisible().catch(() => false)) {
await exitButton.click();
} else {
await redactButton.click();
}
await expect
.poll(async () => (await viewerMode(page)).touchScroll, { timeout: 15_000 })
.toBe("on");
expect((await viewerMode(page)).cursor).toBe("auto");
const box = await firstPage.boundingBox();
if (!box) throw new Error("Page wrapper has no bounding box");
const y = box.y + box.height * 0.105;
await page.mouse.move(box.x + box.width * 0.15, y);
await page.mouse.down();
await page.mouse.move(box.x + box.width * 0.6, y, { steps: 15 });
await page.mouse.up();
await expect(firstPage.locator(SELECTION_RECTS).first()).toBeAttached({
timeout: 5_000,
});
});
@@ -334,15 +334,12 @@ test("text selection still works after toggling the pan tool off again", async (
const firstPage = await loadSampleAndOpenViewer(page);
// Toggling pan on then off should return the active mode to pointerMode.
const panButton = page
.locator('[aria-label="Pan"], [aria-label*="and tool" i]')
.first();
if (await panButton.count()) {
await panButton.click();
await page.waitForTimeout(200);
await panButton.click();
await page.waitForTimeout(200);
}
const panButton = page.getByRole("button", { name: "Pan Mode" }).first();
await expect(panButton).toBeVisible({ timeout: 10_000 });
await panButton.click();
await page.waitForTimeout(200);
await panButton.click();
await page.waitForTimeout(200);
await dragSelectAcrossPage(page, firstPage);
@@ -0,0 +1,322 @@
import path from "path";
import { test, expect } from "@app/tests/helpers/stub-test-base";
const FIXTURES_DIR = path.join(import.meta.dirname, "../test-fixtures");
const SAMPLE_PDF = path.join(FIXTURES_DIR, "sample.pdf");
const MULTIPAGE_PDF = path.join(FIXTURES_DIR, "annotations_out_of_order.pdf");
const SELECTION_RECTS = ".pdf-selection-layer > div:first-child > div";
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
Object.defineProperty(navigator, "maxTouchPoints", {
get: () => 10,
configurable: true,
});
});
});
async function loadViewer(
page: import("@playwright/test").Page,
pdf: string = SAMPLE_PDF,
) {
await page.goto("/editor");
await page.locator('input[type="file"]').first().setInputFiles(pdf);
const workspaceTab = page.getByRole("tab", { name: "Workspace" });
if (await workspaceTab.isVisible().catch(() => false)) {
await workspaceTab.click();
}
const firstPage = page.locator('[data-page-index="0"]').first();
await expect(firstPage).toBeVisible({ timeout: 30_000 });
await expect(firstPage.locator(".pdf-selection-layer")).toBeAttached({
timeout: 15_000,
});
await page.waitForTimeout(2_000);
return firstPage;
}
async function dragSelectAcrossPage(
page: import("@playwright/test").Page,
firstPage: import("@playwright/test").Locator,
) {
const box = await firstPage.boundingBox();
if (!box) throw new Error("Page wrapper has no bounding box");
const y = box.y + box.height * 0.18;
await page.mouse.move(box.x + box.width * 0.15, y);
await page.mouse.down();
await page.mouse.move(box.x + box.width * 0.6, y, { steps: 15 });
await page.mouse.up();
}
function grabCursorCount(page: import("@playwright/test").Page) {
return page.evaluate(
() =>
Array.from(document.querySelectorAll<HTMLElement>("div")).filter(
(el) => el.style.cursor === "grab",
).length,
);
}
test("reports a touchscreen so the regression can actually occur", async ({
page,
}) => {
await page.goto("/editor");
expect(await page.evaluate(() => navigator.maxTouchPoints)).toBeGreaterThan(
0,
);
});
test("viewer does not start in pan mode on a touchscreen device", async ({
page,
}) => {
test.setTimeout(60_000);
await loadViewer(page);
expect(await grabCursorCount(page)).toBe(0);
});
test("viewer does not start in pan mode when only ontouchstart is exposed", async ({
page,
}) => {
test.setTimeout(60_000);
await page.addInitScript(() => {
Object.defineProperty(navigator, "maxTouchPoints", {
get: () => 0,
configurable: true,
});
Object.defineProperty(window, "ontouchstart", {
value: null,
configurable: true,
});
});
const firstPage = await loadViewer(page);
expect(
await page.evaluate(() => ({
mtp: navigator.maxTouchPoints,
ots: "ontouchstart" in window,
})),
).toEqual({ mtp: 0, ots: true });
expect(await grabCursorCount(page)).toBe(0);
await dragSelectAcrossPage(page, firstPage);
await expect(firstPage.locator(SELECTION_RECTS).first()).toBeAttached({
timeout: 5_000,
});
});
test("text is selectable on a touchscreen laptop without touching pan", async ({
page,
}) => {
test.setTimeout(60_000);
const firstPage = await loadViewer(page);
await dragSelectAcrossPage(page, firstPage);
const selectionRects = firstPage.locator(SELECTION_RECTS);
await expect(selectionRects.first()).toBeAttached({ timeout: 5_000 });
expect(await selectionRects.count()).toBeGreaterThan(0);
});
test("hovering text gives an I-beam, not the pan hand", async ({ page }) => {
test.setTimeout(60_000);
const firstPage = await loadViewer(page);
const box = await firstPage.boundingBox();
if (!box) throw new Error("Page wrapper has no bounding box");
await page.mouse.move(box.x + 5, box.y + 5);
await page.waitForTimeout(100);
await page.mouse.move(box.x + box.width * 0.21, box.y + box.height * 0.105, {
steps: 5,
});
await page.waitForTimeout(500);
const cursor = await firstPage.evaluate((el) => {
const parent = (el as HTMLElement).parentElement;
return parent ? getComputedStyle(parent).cursor : "no-parent";
});
expect(cursor).toMatch(/^(text|vertical-text)$/);
});
test("right-click on a word offers Copy", async ({ page }) => {
test.setTimeout(60_000);
const firstPage = await loadViewer(page);
const box = await firstPage.boundingBox();
if (!box) throw new Error("Page wrapper has no bounding box");
await page.mouse.click(box.x + box.width * 0.21, box.y + box.height * 0.105, {
button: "right",
});
await expect(firstPage.locator(SELECTION_RECTS).first()).toBeAttached({
timeout: 5_000,
});
await expect(page.getByRole("button", { name: "Copy" }).first()).toBeVisible({
timeout: 5_000,
});
});
test("pan toggle turns pan on and back off on a touchscreen laptop", async ({
page,
}) => {
test.setTimeout(120_000);
const firstPage = await loadViewer(page);
const panButton = page.getByRole("button", { name: "Pan Mode" }).first();
await expect(panButton).toBeVisible({ timeout: 10_000 });
expect(await grabCursorCount(page)).toBe(0);
await panButton.click();
await expect
.poll(() => grabCursorCount(page), { timeout: 5_000 })
.toBeGreaterThan(0);
await panButton.click();
await expect.poll(() => grabCursorCount(page), { timeout: 5_000 }).toBe(0);
await dragSelectAcrossPage(page, firstPage);
await expect(firstPage.locator(SELECTION_RECTS).first()).toBeAttached({
timeout: 5_000,
});
});
test("pan button state is not left stale after switching files", async ({
page,
}) => {
test.setTimeout(120_000);
await loadViewer(page);
const panButton = page.getByRole("button", { name: "Pan Mode" }).first();
await panButton.click();
await expect
.poll(() => grabCursorCount(page), { timeout: 5_000 })
.toBeGreaterThan(0);
await page.locator('input[type="file"]').first().setInputFiles(MULTIPAGE_PDF);
await expect
.poll(() => page.locator("[data-page-index]").count(), { timeout: 30_000 })
.toBe(3);
await page.waitForTimeout(1_000);
await expect.poll(() => grabCursorCount(page), { timeout: 5_000 }).toBe(0);
await expect(panButton).not.toHaveAttribute("aria-pressed", "true");
await panButton.click();
await expect
.poll(() => grabCursorCount(page), { timeout: 5_000 })
.toBeGreaterThan(0);
});
test.describe("touch-primary device", () => {
test.use({
viewport: { width: 394, height: 915 },
isMobile: true,
hasTouch: true,
deviceScaleFactor: 3,
});
test.skip(
({ browserName }) => browserName !== "chromium",
"isMobile emulation and CDP touch injection are chromium-only",
);
test("finger can both scroll the document and select text", async ({
page,
}) => {
test.setTimeout(120_000);
expect(
await page.evaluate(() => matchMedia("(pointer: coarse)").matches),
).toBe(true);
const firstPage = await loadViewer(page, MULTIPAGE_PDF);
expect(await grabCursorCount(page)).toBe(0);
expect(
await page.evaluate(() => {
const el = document.querySelector<HTMLElement>(
".pdf-page-pointer-layer",
);
return el ? getComputedStyle(el).touchAction : null;
}),
).toBe("pan-y pinch-zoom");
const cdp = await page.context().newCDPSession(page);
const touchDrag = async (x: number, y: number, dx: number, dy: number) => {
await cdp.send("Input.dispatchTouchEvent", {
type: "touchStart",
touchPoints: [{ x, y }],
});
for (let i = 1; i <= 12; i++) {
await cdp.send("Input.dispatchTouchEvent", {
type: "touchMove",
touchPoints: [{ x: x + (dx * i) / 12, y: y + (dy * i) / 12 }],
});
await page.waitForTimeout(16);
}
await cdp.send("Input.dispatchTouchEvent", {
type: "touchEnd",
touchPoints: [],
});
await page.waitForTimeout(400);
};
const scrollTop = () =>
page.evaluate(() => {
let el: HTMLElement | null = document.querySelector(
'[data-page-index="0"]',
);
while (el) {
if (
el.scrollHeight > el.clientHeight + 5 &&
/auto|scroll/.test(getComputedStyle(el).overflowY)
) {
(window as unknown as { __sc?: HTMLElement }).__sc = el;
return el.scrollTop;
}
el = el.parentElement;
}
return null;
});
await scrollTop();
await page.evaluate(() => {
const el = (window as unknown as { __sc?: HTMLElement }).__sc;
if (el) el.scrollTop = 0;
});
await page.waitForTimeout(300);
await expect
.poll(
async () => {
for (const yFraction of [0.11, 0.18, 0.14]) {
const box = (await firstPage.boundingBox())!;
await touchDrag(
box.x + box.width * 0.12,
box.y + box.height * yFraction,
box.width * 0.6,
0,
);
const count = await firstPage.locator(SELECTION_RECTS).count();
if (count > 0) return count;
}
return 0;
},
{ timeout: 45_000 },
)
.toBeGreaterThan(0);
const before = await scrollTop();
const box = (await firstPage.boundingBox())!;
await touchDrag(box.x + box.width / 2, box.y + box.height * 0.6, 0, -250);
const after = await scrollTop();
expect(after ?? 0).toBeGreaterThan(before ?? 0);
});
});