Files
julian.kiedaischandClaude Opus 5.5 44b3f3ccb2 feat: F5.6d a drag is one change of the document; the others see it through the presence
Moving a device, moving or resizing a documentation item went into the
shared document at every pointer move: 59 updates a second, each leaving
about 18 bytes of history (M4 on test-filius). Now the editor keeps the drag
while it lasts, shows it itself and tells the others through the awareness
(moving, at most every 50 ms, the end at once), and writes it once when it
is let go. A drag the browser cancels or a second finger turns into a pinch
is written where it is; WebKit sends a second pointerdown for the first
finger when another comes, which had lost the drag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 07:17:38 +00:00

103 lines
5.2 KiB
TypeScript

// Fingers on the screen, in Chromium and in WebKit alike (plan §7.2, M7): a
// tap through Playwright's touchscreen; a press, a drag and two fingers
// through each engine's own touch input — Chromium's DevTools protocol
// (Input.dispatchTouchEvent), and WebKit's page protocol, whose command has
// the same name and points but which Playwright does not hand out: it is
// reached through the in-process connection (Playwright runs its server in
// the test's process). That seam belongs to Playwright's insides; the
// version is pinned (package.json), and a newer one may move it.
import type { CDPSession, Page } from '@playwright/test';
export interface Point {
readonly x: number;
readonly y: number;
}
type TouchType = 'touchStart' | 'touchMove' | 'touchEnd' | 'touchCancel';
export interface Fingers {
tap(at: Point): Promise<void>;
/** One finger held where it is, then lifted (a long press: 700 ms). */
press(at: Point, milliseconds?: number): Promise<void>;
/** One finger moved from one point to another in steps. */
drag(from: Point, to: Point, steps?: number): Promise<void>;
/** Two fingers either side of a middle, moved from one distance apart to another. */
pinch(middle: Point, from: number, to: number, steps?: number): Promise<void>;
/** One finger moved from one point to another, then a second one put down beside it and both moved apart. */
dragThenPinch(from: Point, to: Point): Promise<void>;
/** One finger moved from one point to another, then the touch cancelled (the browser took it). */
dragThenCancel(from: Point, to: Point): Promise<void>;
}
interface WebKitSession {
send(method: string, params: unknown): Promise<unknown>;
}
/** WebKit's page session: Playwright's own object behind the page, its page proxy's session. */
function webKitSession(page: Page): WebKitSession {
const connection = (page as unknown as { _connection: { toImpl?: (object: unknown) => { delegate?: unknown; _delegate?: unknown } } })._connection;
const server = connection.toImpl?.(page);
const delegate = (server?.delegate ?? server?._delegate) as { _pageProxySession?: WebKitSession } | undefined;
if (delegate?._pageProxySession === undefined) throw new Error("WebKit's page session is out of reach: Playwright's insides have changed");
return delegate._pageProxySession;
}
export async function fingers(page: Page): Promise<Fingers> {
const engine = page.context().browser()?.browserType().name();
let send: (type: TouchType, points: readonly Point[]) => Promise<unknown>;
if (engine === 'webkit') {
const session = webKitSession(page);
// WebKit ends a touch with the fingers lifted, not with none.
let last: readonly Point[] = [];
send = (type, points) => {
const touched = type === 'touchEnd' || type === 'touchCancel' ? last : points;
if (type !== 'touchEnd' && type !== 'touchCancel') last = points;
return session.send('Input.dispatchTouchEvent', { type, touchPoints: touched.map(({ x, y }, id) => ({ x: Math.round(x), y: Math.round(y), id })) });
};
} else {
const cdp: CDPSession = await page.context().newCDPSession(page);
send = (type, points) => cdp.send('Input.dispatchTouchEvent', { type, touchPoints: points.map(({ x, y }, id) => ({ x, y, id })) });
}
const steps = (count: number) => Array.from({ length: count }, (_, index) => (index + 1) / count);
return {
tap: (at) => page.touchscreen.tap(at.x, at.y),
async press(at, milliseconds = 700) {
await send('touchStart', [at]);
await page.waitForTimeout(milliseconds);
await send('touchEnd', []);
},
async drag(from, to, count = 8) {
await send('touchStart', [from]);
for (const share of steps(count)) await send('touchMove', [{ x: from.x + (to.x - from.x) * share, y: from.y + (to.y - from.y) * share }]);
// Held still before it is lifted, as a finger that puts something down: lifted on the move, Chromium
// takes it for a fling, and the next tap only stops the fling (no click).
await page.waitForTimeout(150);
await send('touchEnd', []);
},
async pinch(middle, from, to, count = 4) {
const pair = (distance: number): Point[] => [
{ x: middle.x - distance, y: middle.y },
{ x: middle.x + distance, y: middle.y },
];
await send('touchStart', pair(from));
for (const share of steps(count)) await send('touchMove', pair(from + (to - from) * share));
await page.waitForTimeout(150);
await send('touchEnd', []);
},
async dragThenPinch(from, to) {
await send('touchStart', [from]);
for (const share of steps(8)) await send('touchMove', [{ x: from.x + (to.x - from.x) * share, y: from.y + (to.y - from.y) * share }]);
await send('touchStart', [to, { x: to.x + 60, y: to.y }]);
for (const share of steps(4)) await send('touchMove', [{ x: to.x - 20 * share, y: to.y }, { x: to.x + 60 + 20 * share, y: to.y }]);
await page.waitForTimeout(150);
await send('touchEnd', []);
},
async dragThenCancel(from, to) {
await send('touchStart', [from]);
for (const share of steps(8)) await send('touchMove', [{ x: from.x + (to.x - from.x) * share, y: from.y + (to.y - from.y) * share }]);
await send('touchCancel', []);
},
};
}