|
11 | 11 | import type { BrowserTheme } from '@sim/browser-protocol' |
12 | 12 | import { createLogger } from '@sim/logger' |
13 | 13 | import { getErrorMessage } from '@sim/utils/errors' |
14 | | -import { interruptibleSleep, sleep } from '@sim/utils/helpers' |
| 14 | +import { interruptibleSleep } from '@sim/utils/helpers' |
15 | 15 | import { isRecordLike } from '@sim/utils/object' |
16 | 16 | import type { NativeImage, WebContents, WebFrameMain } from 'electron' |
17 | 17 |
|
@@ -948,6 +948,91 @@ export async function moveMouse(contents: WebContents, x: number, y: number): Pr |
948 | 948 | }) |
949 | 949 | } |
950 | 950 |
|
| 951 | +/** A point in CSS viewport pixels. */ |
| 952 | +export interface ViewportPoint { |
| 953 | + x: number |
| 954 | + y: number |
| 955 | +} |
| 956 | + |
| 957 | +/** The points a pointer passes through on its way, and how long the whole movement takes. */ |
| 958 | +export interface PointerPath { |
| 959 | + via: ViewportPoint[] |
| 960 | + /** Total movement time; null keeps the default brisk pace. */ |
| 961 | + durationMs: number | null |
| 962 | +} |
| 963 | + |
| 964 | +export const DIRECT_PATH: PointerPath = { via: [], durationMs: null } |
| 965 | + |
| 966 | +const DEFAULT_PATH_STEPS = 12 |
| 967 | +const DEFAULT_PATH_STEP_MS = 20 |
| 968 | +/** One display frame, so a timed movement looks continuous to animation-driven pages. */ |
| 969 | +const TIMED_PATH_STEP_MS = 16 |
| 970 | + |
| 971 | +/** |
| 972 | + * The moves from `from` through `path.via` to `to`, and the pause after each. A direct path |
| 973 | + * keeps the default 12 moves 20 ms apart. A path with via points or a duration moves one frame |
| 974 | + * at a time, shares the steps across segments by length, and lands exactly on every via point. |
| 975 | + */ |
| 976 | +export function pointerPathSteps( |
| 977 | + from: ViewportPoint, |
| 978 | + path: PointerPath, |
| 979 | + to: ViewportPoint |
| 980 | +): { points: ViewportPoint[]; stepDelayMs: number } { |
| 981 | + const lerp = (a: ViewportPoint, b: ViewportPoint, t: number): ViewportPoint => ({ |
| 982 | + x: a.x + (b.x - a.x) * t, |
| 983 | + y: a.y + (b.y - a.y) * t, |
| 984 | + }) |
| 985 | + if (path.via.length === 0 && path.durationMs === null) { |
| 986 | + const points: ViewportPoint[] = [] |
| 987 | + for (let step = 1; step <= DEFAULT_PATH_STEPS; step++) { |
| 988 | + points.push(lerp(from, to, step / DEFAULT_PATH_STEPS)) |
| 989 | + } |
| 990 | + return { points, stepDelayMs: DEFAULT_PATH_STEP_MS } |
| 991 | + } |
| 992 | + const vertices = [from, ...path.via, to] |
| 993 | + const durationMs = path.durationMs ?? DEFAULT_PATH_STEPS * DEFAULT_PATH_STEP_MS |
| 994 | + const lengths = vertices |
| 995 | + .slice(1) |
| 996 | + .map((vertex, index) => Math.hypot(vertex.x - vertices[index].x, vertex.y - vertices[index].y)) |
| 997 | + const totalLength = lengths.reduce((sum, length) => sum + length, 0) |
| 998 | + const totalSteps = Math.max(lengths.length, Math.round(durationMs / TIMED_PATH_STEP_MS)) |
| 999 | + const points: ViewportPoint[] = [] |
| 1000 | + lengths.forEach((length, index) => { |
| 1001 | + const share = totalLength > 0 ? length / totalLength : 1 / lengths.length |
| 1002 | + const segmentSteps = Math.max(1, Math.round(totalSteps * share)) |
| 1003 | + for (let step = 1; step <= segmentSteps; step++) { |
| 1004 | + points.push(lerp(vertices[index], vertices[index + 1], step / segmentSteps)) |
| 1005 | + } |
| 1006 | + }) |
| 1007 | + return { points, stepDelayMs: durationMs / points.length } |
| 1008 | +} |
| 1009 | + |
| 1010 | +/** |
| 1011 | + * Moves the pointer with no button pressed through `path.via` to `to`, starting from the first |
| 1012 | + * via point (or `to` itself for a direct move), for hover effects that follow the cursor. |
| 1013 | + */ |
| 1014 | +export async function movePointer( |
| 1015 | + contents: WebContents, |
| 1016 | + path: PointerPath, |
| 1017 | + to: ViewportPoint, |
| 1018 | + signal?: AbortSignal |
| 1019 | +): Promise<void> { |
| 1020 | + const [start, ...rest] = [...path.via, to] |
| 1021 | + signal?.throwIfAborted() |
| 1022 | + await moveMouse(contents, start.x, start.y) |
| 1023 | + if (rest.length === 0) return |
| 1024 | + const { points, stepDelayMs } = pointerPathSteps( |
| 1025 | + start, |
| 1026 | + { via: rest.slice(0, -1), durationMs: path.durationMs }, |
| 1027 | + to |
| 1028 | + ) |
| 1029 | + for (const point of points) { |
| 1030 | + await interruptibleSleep(stepDelayMs, signal) |
| 1031 | + signal?.throwIfAborted() |
| 1032 | + await moveMouse(contents, point.x, point.y) |
| 1033 | + } |
| 1034 | +} |
| 1035 | + |
951 | 1036 | /** One trusted click gesture: which button, how many presses, and held modifiers. */ |
952 | 1037 | export interface PointerClick { |
953 | 1038 | button: 'left' | 'right' | 'middle' |
@@ -1075,11 +1160,13 @@ export async function clickAt( |
1075 | 1160 | */ |
1076 | 1161 | export async function dragPointer( |
1077 | 1162 | contents: WebContents, |
1078 | | - from: { x: number; y: number }, |
1079 | | - to: { x: number; y: number }, |
1080 | | - steps = 12, |
1081 | | - stepDelayMs = 20 |
| 1163 | + from: ViewportPoint, |
| 1164 | + to: ViewportPoint, |
| 1165 | + path: PointerPath = DIRECT_PATH, |
| 1166 | + signal?: AbortSignal |
1082 | 1167 | ): Promise<{ nativeDragIntercepted: boolean }> { |
| 1168 | + signal?.throwIfAborted() |
| 1169 | + const { points, stepDelayMs } = pointerPathSteps(from, path, to) |
1083 | 1170 | const interception: DragInterception = { intercepted: false, data: null } |
1084 | 1171 | dragInterceptionsByContents.set(contents, interception) |
1085 | 1172 | let interceptEnabled = false |
@@ -1124,17 +1211,19 @@ export async function dragPointer( |
1124 | 1211 | }) |
1125 | 1212 | // Small first nudge so libraries with a start threshold (commonly 3-8px) |
1126 | 1213 | // register the drag before the pointer sweeps across the page. |
1127 | | - await dragMove(from.x + Math.sign(to.x - from.x || 1) * 4, from.y + 2) |
1128 | | - await sleep(stepDelayMs) |
1129 | | - const stepCount = Math.max(2, steps) |
1130 | | - for (let step = 1; step <= stepCount; step++) { |
1131 | | - const progress = step / stepCount |
1132 | | - await dragMove(from.x + (to.x - from.x) * progress, from.y + (to.y - from.y) * progress) |
1133 | | - await sleep(stepDelayMs) |
| 1214 | + const heading = points[0] ?? to |
| 1215 | + await dragMove(from.x + Math.sign(heading.x - from.x || 1) * 4, from.y + 2) |
| 1216 | + await interruptibleSleep(stepDelayMs, signal) |
| 1217 | + for (const point of points) { |
| 1218 | + signal?.throwIfAborted() |
| 1219 | + await dragMove(point.x, point.y) |
| 1220 | + await interruptibleSleep(stepDelayMs, signal) |
1134 | 1221 | } |
| 1222 | + signal?.throwIfAborted() |
1135 | 1223 | // Hold over the target so drop zones running enter/over animations settle |
1136 | 1224 | // before the release lands. |
1137 | | - await sleep(120) |
| 1225 | + await interruptibleSleep(120, signal) |
| 1226 | + signal?.throwIfAborted() |
1138 | 1227 | if (interception.intercepted && interception.data) { |
1139 | 1228 | await sendInput(contents, 'Input.dispatchDragEvent', { |
1140 | 1229 | type: 'drop', |
|
0 commit comments