fix: 框选所有退出路径恢复应用窗口; 轮询提前启动并同源采样按键与光标坐标

This commit is contained in:
yu 2026-11-03 10:28:24 +08:00
parent 817ea21c9a
commit 82765a7f19

View File

@ -714,23 +714,36 @@ function regionOverlayHtml(): string {
].join("");
}
interface MouseSample {
down: boolean;
/** 物理屏幕坐标(DPI 感知进程的 GetCursorPos) */
x: number;
y: number;
}
interface MouseButtonPoller {
/** 就绪且至少读到一次按键状态后为 true;超时/启动失败为 false,调用方回退到渲染层事件 */
/** 至少读到一次采样后为 true;超时/启动失败为 false,调用方回退到渲染层事件 */
ready: Promise<boolean>;
/** 当前物理左键是否按下;未就绪时返回 null */
isDown: () => boolean | null;
/** 最近一次采样;轮询进程退出后返回 null,调用方回退渲染层事件 */
sample: () => MouseSample | null;
stop: () => void;
}
// 覆盖层窗口可能被其他窗口盖住/失去焦点, 渲染层 mouseup/keydown 都可能收不到,
// 导致"画得出选区却永远完不成"。改用主进程轮询物理左键状态(GetAsyncKeyState),
// 与任何窗口的焦点、层级、事件路由完全解耦。
// 导致"画得出选区却永远完不成"。改用主进程轮询物理左键状态+光标位置
// (GetAsyncKeyState/GetCursorPos), 与任何窗口的焦点、层级、事件路由完全解耦。
// 按键状态与坐标来自同一次采样, 按下点不会因轮询间隔/PowerShell 冷启动延迟而漂移。
function spawnMouseButtonPoller(): MouseButtonPoller {
const script = [
"Add-Type -MemberDefinition '[DllImport(\"user32.dll\")] public static extern short GetAsyncKeyState(int vKey);' -Name K -Namespace WMouseButton | Out-Null",
// 必须 DPI 感知, 否则 GetCursorPos 返回的是被系统虚拟化(96dpi)后的坐标
"Add-Type -MemberDefinition '[DllImport(\"user32.dll\")] public static extern bool SetProcessDpiAwarenessContext(IntPtr value); [DllImport(\"user32.dll\")] public static extern short GetAsyncKeyState(int vKey); [DllImport(\"user32.dll\")] public static extern bool GetCursorPos(out System.Drawing.Point p);' -Name K -Namespace WMouseButton | Out-Null",
"Add-Type -AssemblyName System.Drawing | Out-Null",
"try { [void][WMouseButton.K]::SetProcessDpiAwarenessContext([IntPtr](-4)) } catch {}",
"while ($true) {",
" $down = ([WMouseButton.K]::GetAsyncKeyState(1) -band 0x8000) -ne 0",
" [Console]::Out.Write($(if ($down) {'1'} else {'0'}) + [char]10)",
" $p = New-Object System.Drawing.Point",
" [void][WMouseButton.K]::GetCursorPos([ref]$p)",
" [Console]::Out.Write([string]([int]$down) + ' ' + $p.X + ' ' + $p.Y + [char]10)",
" Start-Sleep -Milliseconds 40",
"}"
].join("\n");
@ -739,7 +752,8 @@ function spawnMouseButtonPoller(): MouseButtonPoller {
["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script],
{ stdio: ["ignore", "pipe", "ignore"], windowsHide: true }
);
let state: boolean | null = null;
let sample: MouseSample | null = null;
let dead = false;
let readyResolve: (value: boolean) => void = () => {};
const ready = new Promise<boolean>(resolve => { readyResolve = resolve; });
let buffer = "";
@ -749,16 +763,22 @@ function spawnMouseButtonPoller(): MouseButtonPoller {
while ((idx = buffer.indexOf("\n")) >= 0) {
const line = buffer.slice(0, idx).trim();
buffer = buffer.slice(idx + 1);
if (line === "1" || line === "0") {
state = line === "1";
readyResolve(true);
const parts = line.split(" ");
if (parts.length === 3 && (parts[0] === "0" || parts[0] === "1")) {
const x = Number(parts[1]);
const y = Number(parts[2]);
if (Number.isFinite(x) && Number.isFinite(y)) {
sample = { down: parts[0] === "1", x, y };
readyResolve(true);
}
}
}
});
child.on("error", () => readyResolve(false));
child.on("error", () => { dead = true; readyResolve(false); });
child.on("exit", () => { dead = true; });
return {
ready,
isDown: () => state,
sample: () => (dead ? null : sample),
stop: () => {
try { child.kill(); } catch {}
}
@ -768,6 +788,9 @@ function spawnMouseButtonPoller(): MouseButtonPoller {
export async function pickScreenRegion(): Promise<{ x: number; y: number; width: number; height: number; scale: number; imagePath: string }> {
const appWindows = BrowserWindow.getAllWindows().filter(w => !w.isDestroyed());
for (const w of appWindows) w.minimize();
// 轮询进程先于覆盖层启动: PowerShell 冷启动要 1~2s, 与覆盖层创建并行,
// 用户开始拖拽时轮询通常已就绪; 否则按下点要等轮询就绪才捕获(拖到一半才记起点, 选区尺寸错乱)
const poller = spawnMouseButtonPoller();
// 每个显示器各开一个覆盖层, 支持在任意副屏上框选。
// 注意: 构造参数里直接传副屏 bounds 在混合 DPI 下会按主屏 DPI 虚拟化导致尺寸不对,
// 必须先创建(隐藏)再 setBounds, 此时窗口已关联副屏 DPI
@ -788,12 +811,12 @@ export async function pickScreenRegion(): Promise<{ x: number; y: number; width:
return { win, display };
});
// 把焦点给光标所在屏的覆盖层, 保证键盘事件(Esc)优先送达
const cursorDisplay = displayForPoint(screen.getCursorScreenPoint().x, screen.getCursorScreenPoint().y);
const initialCursor = screen.getCursorScreenPoint();
const cursorDisplay = displayForPoint(initialCursor.x, initialCursor.y);
for (const { win, display } of overlays) {
if (!win.isDestroyed() && display.id === cursorDisplay.id) win.focus();
}
const poller = spawnMouseButtonPoller();
const pollerReady = await Promise.race([
poller.ready,
new Promise<boolean>(resolve => setTimeout(() => resolve(false), 2500))
@ -812,62 +835,66 @@ export async function pickScreenRegion(): Promise<{ x: number; y: number; width:
}
}
// 选区坐标不取自窗口 clientX/clientY(混合 DPI 下窗口坐标映射不可靠),
// 主进程用 getCursorScreenPoint 采样鼠标屏幕绝对 DIP 坐标;
// 按下/松开用主进程物理按键轮询判定, 渲染层状态只作回退
// 选区坐标不取自窗口 clientX/clientY(混合 DPI 下窗口坐标映射不可靠);
// 优先用轮询采样(物理坐标经 screenToDipPoint 换算), 轮询不可用时回退渲染层事件 + getCursorScreenPoint
let downPoint: { x: number; y: number } | null = null;
let sawDown = false;
let picked: { rect: { x: number; y: number; width: number; height: number }; display: (typeof displays)[number] } | null = null;
try {
while (Date.now() < deadline) {
await new Promise(r => setTimeout(r, 80));
if (cancelled) break;
// 聚合所有覆盖层状态: 任一屏按 Esc 取消; 按下/松开仅作轮询失败时的回退
let rendererDown = false;
let anyAlive = false;
for (const { win } of overlays) {
if (win.isDestroyed()) continue;
try {
const raw = await win.webContents.executeJavaScript("JSON.stringify({ down: !!window.__down, cancelled: !!window.__cancelled })", true);
const s = JSON.parse(raw) as { down: boolean; cancelled: boolean };
anyAlive = true;
rendererDown = rendererDown || s.down;
if (s.cancelled) cancelled = true;
} catch {}
try {
while (Date.now() < deadline) {
await new Promise(r => setTimeout(r, 80));
if (cancelled) break;
// 聚合所有覆盖层状态: 任一屏按 Esc 取消; 按下/松开仅作轮询失败时的回退
let rendererDown = false;
let anyAlive = false;
for (const { win } of overlays) {
if (win.isDestroyed()) continue;
try {
const raw = await win.webContents.executeJavaScript("JSON.stringify({ down: !!window.__down, cancelled: !!window.__cancelled })", true);
const s = JSON.parse(raw) as { down: boolean; cancelled: boolean };
anyAlive = true;
rendererDown = rendererDown || s.down;
if (s.cancelled) cancelled = true;
} catch {}
}
if (cancelled) break;
if (!anyAlive) continue;
const mouse = pollerReady ? poller.sample() : null;
// 按键与坐标同源采样; 轮询不可用时回退: 渲染层事件 + getCursorScreenPoint
const cursor = mouse
? screen.screenToDipPoint({ x: mouse.x, y: mouse.y })
: screen.getCursorScreenPoint();
const down = mouse ? mouse.down : rendererDown;
if (down && !downPoint) {
downPoint = { x: cursor.x, y: cursor.y };
sawDown = true;
console.log("[region-pick] down", downPoint, mouse ? "(polled)" : "(renderer)");
}
if (!down && sawDown && downPoint) {
const display = displayForPoint(downPoint.x, downPoint.y);
const rx0 = Math.min(downPoint.x, cursor.x);
const ry0 = Math.min(downPoint.y, cursor.y);
const rx1 = Math.max(downPoint.x, cursor.x);
const ry1 = Math.max(downPoint.y, cursor.y);
picked = { rect: { x: rx0, y: ry0, width: rx1 - rx0, height: ry1 - ry0 }, display };
console.log("[region-pick] up", picked.rect);
break;
}
}
if (cancelled) break;
if (!anyAlive) continue;
const cursor = screen.getCursorScreenPoint();
const polled = pollerReady ? poller.isDown() : null;
const down = polled !== null ? polled : rendererDown;
if (down && !downPoint) {
downPoint = { x: cursor.x, y: cursor.y };
sawDown = true;
console.log("[region-pick] down", downPoint);
}
if (!down && sawDown && downPoint) {
const display = displayForPoint(downPoint.x, downPoint.y);
const rx0 = Math.min(downPoint.x, cursor.x);
const ry0 = Math.min(downPoint.y, cursor.y);
const rx1 = Math.max(downPoint.x, cursor.x);
const ry1 = Math.max(downPoint.y, cursor.y);
picked = { rect: { x: rx0, y: ry0, width: rx1 - rx0, height: ry1 - ry0 }, display };
console.log("[region-pick] up", picked.rect);
break;
} finally {
// 循环的所有退出路径(取消/超时/异常)都要清理, 否则覆盖层永远留在屏幕上
poller.stop();
for (const accelerator of [...hotkeys].reverse()) {
try { globalShortcut.unregister(accelerator); } catch {}
}
for (const { win } of overlays) if (!win.isDestroyed()) win.destroy();
}
} finally {
// 所有退出路径(取消/超时/异常)都要清理, 否则覆盖层永远留在屏幕上
poller.stop();
for (const accelerator of [...hotkeys].reverse()) {
try { globalShortcut.unregister(accelerator); } catch {}
if (cancelled) throw new RuntimeError("pick_cancelled", "已取消框选");
if (!picked || picked.rect.width <= 2 || picked.rect.height <= 2) {
throw new RuntimeError("pick_cancelled", "框选区域过小或未完成,请拖拽出选区后松开");
}
for (const { win } of overlays) if (!win.isDestroyed()) win.destroy();
}
if (cancelled) throw new RuntimeError("pick_cancelled", "已取消框选");
if (!picked || picked.rect.width <= 2 || picked.rect.height <= 2) throw new RuntimeError("pick_cancelled", "未完成框选");
const { rect, display } = picked;
try {
const { rect, display } = picked;
// 关键顺序: 先销毁覆盖层、完成截图, 最后才恢复应用窗口——
// 之前是先恢复窗口再截图, 应用弹回盖住选区, 截到的全是应用窗口内容
await new Promise(r => setTimeout(r, 300));
@ -908,7 +935,9 @@ export async function pickScreenRegion(): Promise<{ x: number; y: number; width:
imagePath
};
} finally {
// 截图完成后(无论成败)恢复应用窗口
// 所有退出路径(取消/超时/异常/成功)都必须恢复应用窗口。
// 之前取消和"未完成框选"路径抛错时跳过了恢复, 应用一直最小化 → 用户看到的就是"卡住不能退出"。
// 成功路径 return 在 try 内, finally 在截图完成后才执行, 不会破坏截图顺序。
for (const w of appWindows) { if (!w.isDestroyed()) { w.show(); w.restore(); w.focus(); } }
}
}