fix: 框选所有退出路径恢复应用窗口; 轮询提前启动并同源采样按键与光标坐标
This commit is contained in:
parent
817ea21c9a
commit
82765a7f19
@ -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(); } }
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user