mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Fix text selection stuck in pan mode on touch-capable devices
This commit is contained in:
@@ -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;
|
||||
|
||||
+83
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user