mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20246bef72 | ||
|
|
3c48d3162e | ||
|
|
5aa1cd6cbe | ||
|
|
88e9e1280c | ||
|
|
b777c790af | ||
|
|
6b13f46d1d | ||
|
|
026c0262cb | ||
|
|
a6d2d67401 | ||
|
|
dcacd84b08 | ||
|
|
777520ded7 | ||
|
|
f4c7d7ba62 | ||
|
|
a48da724ee | ||
|
|
0530b0d2f5 | ||
|
|
e2d4ac2431 | ||
|
|
24f5d63cfa | ||
|
|
2df406eed3 | ||
|
|
5efef66a58 | ||
|
|
9ebbd08b4b | ||
|
|
934ba9d8f9 | ||
|
|
144344d1c8 | ||
|
|
65ed0a0326 | ||
|
|
288a68b831 | ||
|
|
abfbac75ea | ||
|
|
2ec34b7968 | ||
|
|
e104d47dc1 |
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user