Compare commits

...
Author SHA1 Message Date
Anthony Stirling 20246bef72 Merge remote-tracking branch 'origin/main' into sweep/pr6735 2026-08-26 08:12:16 +01:00
Anthony Stirling 3c48d3162e Merge remote-tracking branch 'origin/main' into sweep/pr6735 2026-08-26 07:13:25 +01:00
Anthony Stirling 5aa1cd6cbe fix(viewer): keep native touch scrolling while pointer mode is default 2026-08-13 12:56:39 +01:00
Anthony Stirling 88e9e1280c fix(viewer): stop the pan plugin defaulting to pan mode on touch devices 2026-08-13 10:15:58 +01:00
Anthony Stirling b777c790af Merge remote-tracking branch 'origin/main' into fix/viewer-pan-mode-text-selection-lock 2026-08-13 09:50:28 +01:00
Frooodle 6b13f46d1d Merge branch 'main' into fix/viewer-pan-mode-text-selection-lock 2026-08-05 09:29:38 +01:00
Frooodle 026c0262cb Merge branch 'main' into fix/viewer-pan-mode-text-selection-lock 2026-08-02 00:28:32 +01:00
Frooodle a6d2d67401 Merge branch 'main' into fix/viewer-pan-mode-text-selection-lock 2026-07-30 12:12:20 +01:00
Frooodle dcacd84b08 Merge branch 'main' into fix/viewer-pan-mode-text-selection-lock 2026-07-29 16:04:06 +01:00
Frooodle 777520ded7 Merge branch 'main' into fix/viewer-pan-mode-text-selection-lock 2026-07-29 08:30:40 +01:00
Frooodle f4c7d7ba62 Merge branch 'main' into fix/viewer-pan-mode-text-selection-lock 2026-07-24 11:25:25 +01:00
Frooodle a48da724ee Merge branch 'main' into fix/viewer-pan-mode-text-selection-lock 2026-07-13 09:27:52 +01:00
Frooodle 0530b0d2f5 merge main 2026-07-10 10:39:59 +01:00
Frooodle e2d4ac2431 merge main 2026-07-06 18:45:18 +01:00
Frooodle 24f5d63cfa merge main 2026-07-02 09:36:30 +01:00
Frooodle 2df406eed3 merge main 2026-07-01 08:43:52 +01:00
Frooodle 5efef66a58 merge main 2026-06-30 08:25:42 +01:00
Frooodle 9ebbd08b4b merge main 2026-06-29 11:19:25 +01:00
Frooodle 934ba9d8f9 merge main 2026-06-27 08:18:33 +01:00
Frooodle 144344d1c8 merge main 2026-06-25 13:57:27 +01:00
Frooodle 65ed0a0326 merge main 2026-06-25 09:32:39 +01:00
Frooodle 288a68b831 merge main 2026-06-24 10:19:25 +01:00
Frooodle abfbac75ea Merge remote-tracking branch 'origin/main' into fix/viewer-pan-mode-text-selection-lock 2026-06-23 15:22:47 +01:00
Anthony Stirling 2ec34b7968 Merge branch 'main' into fix/viewer-pan-mode-text-selection-lock 2026-06-19 17:02:02 +01:00
Anthony Stirling e104d47dc1 fix(viewer): always restore text selection when pan is toggled off 2026-06-19 15:01:22 +01:00
4 changed files with 191 additions and 34 deletions
@@ -23,6 +23,7 @@ import { RenderPluginPackage } from "@embedpdf/plugin-render/react";
import { ZoomPluginPackage, ZoomMode } from "@embedpdf/plugin-zoom/react";
import {
InteractionManagerPluginPackage,
InteractionManagerPlugin,
PagePointerProvider,
GlobalPointerProvider,
} from "@embedpdf/plugin-interaction-manager/react";
@@ -386,12 +387,9 @@ 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),
// "never" is required: the plugin's own defaultConfig is "mobile", which makes
// pan the default mode on touch devices and locks text selection (#5175).
createPluginRegistration(PanPluginPackage, { defaultMode: "never" }),
// Register zoom plugin with configuration
createPluginRegistration(ZoomPluginPackage, {
@@ -521,6 +519,19 @@ export function LocalEmbedPDF({
engine={engine}
plugins={plugins}
onInitialized={async (registry: PluginRegistry) => {
// pointerMode is a read/select mode, so the browser must keep touch:
// without this it claims raw touch and touch-action:none kills scroll.
const interactionApi = registry
.getPlugin<InteractionManagerPlugin>("interaction-manager")
?.provides?.();
interactionApi?.registerMode({
id: "pointerMode",
scope: "page",
exclusive: false,
cursor: "auto",
wantsRawTouch: false,
});
// v2.0: Use registry.getPlugin() to access plugin APIs
const annotationPlugin = registry.getPlugin("annotation");
if (!annotationPlugin || !annotationPlugin.provides) return;
@@ -1,5 +1,6 @@
import { useEffect, useRef } from "react";
import { usePan } from "@embedpdf/plugin-pan/react";
import { useInteractionManagerCapability } from "@embedpdf/plugin-interaction-manager/react";
import { useViewer } from "@app/contexts/ViewerContext";
import { useActiveDocumentId } from "@app/components/viewer/useActiveDocumentId";
import { useDocumentReady } from "@app/components/viewer/hooks/useDocumentReady";
@@ -21,13 +22,18 @@ export function PanAPIBridge() {
function PanAPIBridgeInner({ documentId }: { documentId: string }) {
const { provides: pan, isPanning } = usePan(documentId);
const { provides: imCapability } = useInteractionManagerCapability();
const { registerBridge, triggerImmediatePanUpdate } = useViewer();
// Keep pan ref updated to avoid re-running effect when object reference changes
// Keep refs updated to avoid re-running effect when object references change
const panRef = useRef(pan);
useEffect(() => {
panRef.current = pan;
}, [pan]);
const imRef = useRef(imCapability);
useEffect(() => {
imRef.current = imCapability;
}, [imCapability]);
// Track previous isPanning value to detect changes
const prevIsPanningRef = useRef<boolean>(isPanning);
@@ -39,6 +45,17 @@ function PanAPIBridgeInner({ documentId }: { documentId: string }) {
isPanning,
};
// Pan off must always land in selection (pointerMode), not the default mode -
// if pan ever became the default, disablePan/togglePan couldn't escape it (#5175).
const goToPointerMode = () => {
const im = imRef.current;
if (im) {
im.forDocument(documentId).activate("pointerMode");
} else {
currentPan.disablePan();
}
};
// Register this bridge with ViewerContext
registerBridge("pan", {
state: newState,
@@ -47,22 +64,20 @@ function PanAPIBridgeInner({ documentId }: { documentId: string }) {
currentPan.enablePan();
},
disable: () => {
currentPan.disablePan();
goToPointerMode();
},
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();
if (isPanning) {
goToPointerMode();
} else {
currentPan.enablePan();
}
},
makePanDefault: () => {
// Never make pan the default mode (that is what locks the viewer in
// #5175). Just enable pan for the current interaction.
currentPan.enablePan();
},
},
});
@@ -75,7 +90,7 @@ function PanAPIBridgeInner({ documentId }: { documentId: string }) {
return () => {
registerBridge("pan", null);
};
}, [isPanning, registerBridge, triggerImmediatePanUpdate]);
}, [isPanning, registerBridge, triggerImmediatePanUpdate, documentId]);
return null;
}
@@ -246,12 +246,10 @@ export function useViewerWorkbenchBarButtons(
disabled:
!isPanning && pendingCount > 0 && redactionActiveType !== null,
onClick: () => {
// Don't optimistically flip isPanning - it must reflect the real EmbedPDF
// mode so the button can't show "off" while still stuck in pan (#5175).
viewer.panActions.togglePan();
setIsPanning((prev) => {
const next = !prev;
if (next && isRulerActive) setIsRulerActive?.(false);
return next;
});
if (!isPanning && isRulerActive) setIsRulerActive?.(false);
},
},
{
@@ -327,23 +327,23 @@ test("Ctrl+A works without first hovering the viewer", async ({ page }) => {
expect(nativeSelectionLength).toBe(0);
});
async function togglePanOnThenOff(page: import("@playwright/test").Page) {
const panButton = page.locator('[aria-label="Pan Mode"]').first();
await expect(panButton).toBeVisible({ timeout: 10_000 });
await panButton.click();
await page.waitForTimeout(200);
await panButton.click();
await page.waitForTimeout(200);
}
test("text selection still works after toggling the pan tool off again", async ({
page,
}) => {
test.setTimeout(60_000);
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);
}
// Toggling pan on then off must land back in pointerMode, not the default mode.
await togglePanOnThenOff(page);
await dragSelectAcrossPage(page, firstPage);
const selectionRects = firstPage.locator(
@@ -351,3 +351,136 @@ test("text selection still works after toggling the pan tool off again", async (
);
await expect(selectionRects.first()).toBeAttached({ timeout: 5_000 });
});
// hasTouch is required: the pan plugin's defaultConfig is "mobile", so only a
// touch-capable context reproduces the pan lock that kills selection (#5175).
test.describe("pan mode on a touch-capable device", () => {
test.use({ hasTouch: true });
// Playwright's Firefox stops dispatching PointerEvents for the mouse once
// hasTouch is on, so no pointer-driven interaction can be exercised there.
const skipPointerOnFirefox = (browserName: string) =>
test.skip(
browserName === "firefox",
"Playwright Firefox emits no PointerEvents for the mouse when hasTouch is set",
);
test("the viewer does not open locked in pan mode", async ({
page,
browserName,
}) => {
test.setTimeout(60_000);
skipPointerOnFirefox(browserName);
const firstPage = await loadSampleAndOpenViewer(page);
await dragSelectAcrossPage(page, firstPage);
const selectionRects = firstPage.locator(
".pdf-selection-layer > div:first-child > div",
);
await expect(selectionRects.first()).toBeAttached({ timeout: 5_000 });
});
test("text selection still works after toggling the pan tool off again", async ({
page,
browserName,
}) => {
test.setTimeout(60_000);
skipPointerOnFirefox(browserName);
const firstPage = await loadSampleAndOpenViewer(page);
await togglePanOnThenOff(page);
await dragSelectAcrossPage(page, firstPage);
const selectionRects = firstPage.locator(
".pdf-selection-layer > div:first-child > div",
);
await expect(selectionRects.first()).toBeAttached({ timeout: 5_000 });
});
// Guards the other half of the trade: leaving the viewer in pointerMode must
// not make the interaction manager claim raw touch and block native scrolling.
test("a finger swipe still scrolls the document", async ({
page,
browserName,
}) => {
test.setTimeout(60_000);
// Multi-page so the viewport always overflows, whatever the window size.
await page
.locator('input[type="file"]')
.first()
.setInputFiles(MULTIPAGE_PDF);
const firstPage = page.locator('[data-page-index="0"]').first();
await expect(firstPage).toBeVisible({ timeout: 30_000 });
await page.waitForTimeout(2_000);
// touch-action:none anywhere between the page and its scroll container
// stops the browser panning that container with a finger.
const chain = await page.evaluate(() => {
let cur = document.querySelector(
'[data-page-index="0"]',
) as HTMLElement | null;
const touchActions: string[] = [];
while (cur) {
const style = getComputedStyle(cur);
touchActions.push(style.touchAction);
if (style.overflowY === "auto" || style.overflowY === "scroll") {
return { touchActions, foundScroller: true };
}
cur = cur.parentElement;
}
return { touchActions, foundScroller: false };
});
expect(chain.foundScroller).toBe(true);
expect(chain.touchActions).not.toContain("none");
// Chromium is the only engine Playwright lets us inject trusted touches into.
if (browserName !== "chromium") return;
const readScroll = () =>
page.evaluate(() => {
let cur = document.querySelector(
'[data-page-index="0"]',
) as HTMLElement | null;
while (cur) {
const style = getComputedStyle(cur);
if (style.overflowY === "auto" || style.overflowY === "scroll") {
return {
top: cur.scrollTop,
overflows: cur.scrollHeight > cur.clientHeight,
};
}
cur = cur.parentElement;
}
return { top: -1, overflows: false };
});
const before = await readScroll();
expect(before.overflows).toBe(true);
const box = await firstPage.boundingBox();
if (!box) throw new Error("Page wrapper has no bounding box");
const x = box.x + box.width * 0.5;
const yStart = box.y + box.height * 0.5;
const cdp = await page.context().newCDPSession(page);
await cdp.send("Input.dispatchTouchEvent", {
type: "touchStart",
touchPoints: [{ x, y: yStart }],
});
for (let step = 1; step <= 10; step++) {
await cdp.send("Input.dispatchTouchEvent", {
type: "touchMove",
touchPoints: [{ x, y: yStart - step * 20 }],
});
await page.waitForTimeout(16);
}
await cdp.send("Input.dispatchTouchEvent", {
type: "touchEnd",
touchPoints: [],
});
await page.waitForTimeout(1_000);
expect((await readScroll()).top).toBeGreaterThan(before.top);
});
});