diff --git a/src/main/desktop-pick.ts b/src/main/desktop-pick.ts index e184a41..59b7993 100644 --- a/src/main/desktop-pick.ts +++ b/src/main/desktop-pick.ts @@ -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; - /** 当前物理左键是否按下;未就绪时返回 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(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(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(); } } } }