fix: 框选改为主进程轮询物理按键状态, 覆盖层最高层级置顶, 全路径清理

This commit is contained in:
yu 2026-11-03 10:15:41 +08:00
parent 257fdc7358
commit 817ea21c9a

View File

@ -1,4 +1,4 @@
import { execFile } from "node:child_process";
import { execFile, spawn } from "node:child_process";
import { promisify } from "node:util";
import fs from "node:fs";
@ -714,6 +714,57 @@ function regionOverlayHtml(): string {
].join("");
}
interface MouseButtonPoller {
/** 就绪且至少读到一次按键状态后为 true;超时/启动失败为 false,调用方回退到渲染层事件 */
ready: Promise<boolean>;
/** 当前物理左键是否按下;未就绪时返回 null */
isDown: () => boolean | null;
stop: () => void;
}
// 覆盖层窗口可能被其他窗口盖住/失去焦点, 渲染层 mouseup/keydown 都可能收不到,
// 导致"画得出选区却永远完不成"。改用主进程轮询物理左键状态(GetAsyncKeyState),
// 与任何窗口的焦点、层级、事件路由完全解耦。
function spawnMouseButtonPoller(): MouseButtonPoller {
const script = [
"Add-Type -MemberDefinition '[DllImport(\"user32.dll\")] public static extern short GetAsyncKeyState(int vKey);' -Name K -Namespace WMouseButton | Out-Null",
"while ($true) {",
" $down = ([WMouseButton.K]::GetAsyncKeyState(1) -band 0x8000) -ne 0",
" [Console]::Out.Write($(if ($down) {'1'} else {'0'}) + [char]10)",
" Start-Sleep -Milliseconds 40",
"}"
].join("\n");
const child = spawn(
"powershell.exe",
["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script],
{ stdio: ["ignore", "pipe", "ignore"], windowsHide: true }
);
let state: boolean | null = null;
let readyResolve: (value: boolean) => void = () => {};
const ready = new Promise<boolean>(resolve => { readyResolve = resolve; });
let buffer = "";
child.stdout?.on("data", (chunk: Buffer) => {
buffer += chunk.toString();
let idx: number;
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);
}
}
});
child.on("error", () => readyResolve(false));
return {
ready,
isDown: () => state,
stop: () => {
try { child.kill(); } catch {}
}
};
}
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();
@ -730,93 +781,136 @@ export async function pickScreenRegion(): Promise<{ x: number; y: number; width:
});
void win.loadURL(url);
win.setBounds({ x: display.bounds.x, y: display.bounds.y, width: display.bounds.width, height: display.bounds.height });
// 默认 alwaysOnTop 层级太低, 会被其他置顶/普通窗口盖住;
// screen-saver 层级压过任务栏和普通应用窗口
win.setAlwaysOnTop(true, "screen-saver");
win.show();
return { win, display };
});
const deadline = Date.now() + 60000;
// 把焦点给光标所在屏的覆盖层, 保证键盘事件(Esc)优先送达
const cursorDisplay = displayForPoint(screen.getCursorScreenPoint().x, screen.getCursorScreenPoint().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))
]);
if (!pollerReady) console.warn("[region-pick] 按键轮询未就绪, 回退渲染层事件");
const deadline = Date.now() + 120000;
let cancelled = false;
// Esc 全局兜底: 覆盖层失去键盘焦点时也能取消
const hotkeys: string[] = [];
for (const accelerator of ["Escape", "F9"]) {
try {
if (globalShortcut.register(accelerator, () => { cancelled = true; })) hotkeys.push(accelerator);
} catch (error) {
console.warn("[region-pick] hotkey register failed", accelerator, error);
}
}
// 选区坐标不取自窗口 clientX/clientY(混合 DPI 下窗口坐标映射不可靠),
// 渲染层只上报按下/松开状态, 主进程用 getCursorScreenPoint 采样鼠标屏幕绝对 DIP 坐标
// 主进程用 getCursorScreenPoint 采样鼠标屏幕绝对 DIP 坐标;
// 按下/松开用主进程物理按键轮询判定, 渲染层状态只作回退
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;
while (Date.now() < deadline) {
await new Promise(r => setTimeout(r, 100));
// 聚合所有覆盖层的状态: 任一屏按 Esc 取消, 任一屏的按下/松开都要识别
let down = false;
let wasDown = 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, wasDown: !!window.__wasDown, cancelled: !!window.__cancelled })", true);
const s = JSON.parse(raw) as { down: boolean; wasDown: boolean; cancelled: boolean };
anyAlive = true;
down = down || s.down;
wasDown = wasDown || s.wasDown;
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 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;
}
}
if (cancelled) break;
if (!anyAlive) continue;
const cursor = screen.getCursorScreenPoint();
if (down && !downPoint) { downPoint = { x: cursor.x, y: cursor.y }; sawDown = true; }
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 };
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();
}
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;
// 关键顺序: 先销毁覆盖层、完成截图, 最后才恢复应用窗口——
// 之前是先恢复窗口再截图, 应用弹回盖住选区, 截到的全是应用窗口内容
for (const { win } of overlays) if (!win.isDestroyed()) win.destroy();
await new Promise(r => setTimeout(r, 300));
const outDir = path.join(app.getPath("userData"), "region-picks");
fs.mkdirSync(outDir, { recursive: true });
const imagePath = path.join(outDir, `pick-region-${Date.now()}.png`);
// GDI CopyFromScreen 截不到 GPU 合成窗口(Electron 自身界面), 会截出全黑;
// 改用 desktopCapturer 走系统合成器截屏。rect 是屏幕绝对 DIP, 裁剪时换算到该屏相对坐标再乘 scaleFactor
const scale = display.scaleFactor || 1;
const sources = await desktopCapturer.getSources({
types: ["screen"],
thumbnailSize: { width: Math.round(display.bounds.width * scale), height: Math.round(display.bounds.height * scale) }
});
const physicalW = Math.round(display.bounds.width * scale);
// display_id 匹配 → 缩略图尺寸匹配 → 显示器数量一致时按下→右排序对位
const displaysByX = [...displays].sort((a, b) => a.bounds.x - b.bounds.x);
const source = sources.find(item => item.display_id === String(display.id))
|| sources.find(item => item.thumbnail.getSize().width === physicalW)
|| (sources.length === displays.length ? sources[displaysByX.findIndex(d => d.id === display.id)] : sources[0]);
if (!source || source.thumbnail.isEmpty()) {
throw new RuntimeError("pick_capture_failed", "框选截图失败:未获取到屏幕画面");
try {
// 关键顺序: 先销毁覆盖层、完成截图, 最后才恢复应用窗口——
// 之前是先恢复窗口再截图, 应用弹回盖住选区, 截到的全是应用窗口内容
await new Promise(r => setTimeout(r, 300));
const outDir = path.join(app.getPath("userData"), "region-picks");
fs.mkdirSync(outDir, { recursive: true });
const imagePath = path.join(outDir, `pick-region-${Date.now()}.png`);
// GDI CopyFromScreen 截不到 GPU 合成窗口(Electron 自身界面), 会截出全黑;
// 改用 desktopCapturer 走系统合成器截屏。rect 是屏幕绝对 DIP, 裁剪时换算到该屏相对坐标再乘 scaleFactor
const scale = display.scaleFactor || 1;
const sources = await desktopCapturer.getSources({
types: ["screen"],
thumbnailSize: { width: Math.round(display.bounds.width * scale), height: Math.round(display.bounds.height * scale) }
});
const physicalW = Math.round(display.bounds.width * scale);
// display_id 匹配 → 缩略图尺寸匹配 → 显示器数量一致时按下→右排序对位
const displaysByX = [...displays].sort((a, b) => a.bounds.x - b.bounds.x);
const source = sources.find(item => item.display_id === String(display.id))
|| sources.find(item => item.thumbnail.getSize().width === physicalW)
|| (sources.length === displays.length ? sources[displaysByX.findIndex(d => d.id === display.id)] : sources[0]);
if (!source || source.thumbnail.isEmpty()) {
throw new RuntimeError("pick_capture_failed", "框选截图失败:未获取到屏幕画面");
}
const thumbnail = source.thumbnail;
const actualScale = thumbnail.getSize().width / Math.max(1, display.bounds.width);
const relX = rect.x - display.bounds.x;
const relY = rect.y - display.bounds.y;
const cropX = Math.max(0, Math.min(thumbnail.getSize().width - 1, Math.round(relX * actualScale)));
const cropY = Math.max(0, Math.min(thumbnail.getSize().height - 1, Math.round(relY * actualScale)));
const cropW = Math.max(1, Math.min(thumbnail.getSize().width - cropX, Math.round(rect.width * actualScale)));
const cropH = Math.max(1, Math.min(thumbnail.getSize().height - cropY, Math.round(rect.height * actualScale)));
fs.writeFileSync(imagePath, thumbnail.crop({ x: cropX, y: cropY, width: cropW, height: cropH }).toPNG());
return {
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
scale,
imagePath
};
} finally {
// 截图完成后(无论成败)恢复应用窗口
for (const w of appWindows) { if (!w.isDestroyed()) { w.show(); w.restore(); w.focus(); } }
}
const thumbnail = source.thumbnail;
const actualScale = thumbnail.getSize().width / Math.max(1, display.bounds.width);
const relX = rect.x - display.bounds.x;
const relY = rect.y - display.bounds.y;
const cropX = Math.max(0, Math.min(thumbnail.getSize().width - 1, Math.round(relX * actualScale)));
const cropY = Math.max(0, Math.min(thumbnail.getSize().height - 1, Math.round(relY * actualScale)));
const cropW = Math.max(1, Math.min(thumbnail.getSize().width - cropX, Math.round(rect.width * actualScale)));
const cropH = Math.max(1, Math.min(thumbnail.getSize().height - cropY, Math.round(rect.height * actualScale)));
fs.writeFileSync(imagePath, thumbnail.crop({ x: cropX, y: cropY, width: cropW, height: cropH }).toPNG());
// 截图完成后才恢复应用窗口
for (const w of appWindows) { if (!w.isDestroyed()) { w.show(); w.restore(); w.focus(); } }
// 返回屏幕绝对 DIP 坐标(含负值/副屏偏移)+该屏缩放比, 运行时由此换算点击位置
return {
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
scale,
imagePath
};
}