fix: 框选坐标改由主进程采样光标绝对位置\n\n- 混合 DPI 下窗口 clientX/clientY 与屏幕位置映射不可靠, 导致选区偏离鼠标实际位置\n- 渲染层只上报按下/松开状态, 主进程轮询 getCursorScreenPoint 取屏幕绝对 DIP 坐标计算选区\n- 选区正确性不再依赖覆盖层窗口几何, 裁剪与返回值均基于绝对坐标

This commit is contained in:
yu 2026-11-03 09:46:36 +08:00
parent 0641c5b91b
commit 5e6b7cd878

View File

@ -706,10 +706,10 @@ function regionOverlayHtml(): string {
"<div id=sel></div><div id=tip>拖拽框选区域,松开鼠标完成;Esc 取消</div>",
"<script>",
"var sel = document.getElementById('sel'); var start = null;",
"document.addEventListener('mousedown', function(e) { start = [e.clientX, e.clientY]; sel.style.display = 'block'; sel.style.left = e.clientX + 'px'; sel.style.top = e.clientY + 'px'; sel.style.width = '0px'; sel.style.height = '0px'; });",
"document.addEventListener('mousedown', function(e) { start = [e.clientX, e.clientY]; window.__down = true; window.__wasDown = true; sel.style.display = 'block'; sel.style.left = e.clientX + 'px'; sel.style.top = e.clientY + 'px'; sel.style.width = '0px'; sel.style.height = '0px'; });",
"document.addEventListener('mousemove', function(e) { if (!start) return; sel.style.left = Math.min(start[0], e.clientX) + 'px'; sel.style.top = Math.min(start[1], e.clientY) + 'px'; sel.style.width = Math.abs(e.clientX - start[0]) + 'px'; sel.style.height = Math.abs(e.clientY - start[1]) + 'px'; });",
"document.addEventListener('mouseup', function(e) { if (!start) return; window.__region = { x: Math.min(start[0], e.clientX), y: Math.min(start[1], e.clientY), width: Math.abs(e.clientX - start[0]), height: Math.abs(e.clientY - start[1]) }; window.__done = true; });",
"document.addEventListener('keydown', function(e) { if (e.key === 'Escape') { window.__cancelled = true; window.__done = true; } });",
"document.addEventListener('mouseup', function() { if (!start) return; start = null; window.__down = false; });",
"document.addEventListener('keydown', function(e) { if (e.key === 'Escape') { window.__cancelled = true; } });",
"</script></body></html>"
].join("");
}
@ -735,34 +735,47 @@ export async function pickScreenRegion(): Promise<{ x: number; y: number; width:
});
const deadline = Date.now() + 60000;
let cancelled = false;
let picked: { region: { x: number; y: number; width: number; height: number }; display: (typeof displays)[number] } | null = null;
// 选区坐标不取自窗口 clientX/clientY(混合 DPI 下窗口坐标映射不可靠),
// 渲染层只上报按下/松开状态, 主进程用 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, 250));
for (const { win, display } of overlays) {
await new Promise(r => setTimeout(r, 100));
let state: { down: boolean; wasDown: boolean; cancelled: boolean } | null = null;
for (const { win } of overlays) {
if (win.isDestroyed()) continue;
try {
const state = await win.webContents.executeJavaScript("JSON.stringify({ done: window.__done === true, cancelled: window.__cancelled === true, region: window.__region || null })", true);
const parsed = JSON.parse(state) as { done: boolean; cancelled: boolean; region: { x: number; y: number; width: number; height: number } | null };
if (parsed.cancelled) { cancelled = true; break; }
if (parsed.done && parsed.region && parsed.region.width > 2 && parsed.region.height > 2) {
picked = { region: parsed.region, display };
break;
}
const raw = await win.webContents.executeJavaScript("JSON.stringify({ down: !!window.__down, wasDown: !!window.__wasDown, cancelled: !!window.__cancelled })", true);
state = JSON.parse(raw) as { down: boolean; wasDown: boolean; cancelled: boolean };
break;
} catch {}
}
if (cancelled || picked) break;
if (!state) continue;
if (state.cancelled) { cancelled = true; break; }
const cursor = screen.getCursorScreenPoint();
if (state.down && !downPoint) { downPoint = { x: cursor.x, y: cursor.y }; sawDown = true; }
if (!state.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;
}
}
for (const w of appWindows) { if (!w.isDestroyed()) { w.show(); w.restore(); w.focus(); } }
for (const { win } of overlays) if (!win.isDestroyed()) win.destroy();
if (cancelled) throw new RuntimeError("pick_cancelled", "已取消框选");
if (!picked) throw new RuntimeError("pick_cancelled", "未完成框选");
const { region, display } = picked;
if (!picked || picked.rect.width <= 2 || picked.rect.height <= 2) throw new RuntimeError("pick_cancelled", "未完成框选");
const { rect, display } = picked;
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 走系统合成器截屏。覆盖层坐标是本屏 DIP, 裁剪时按 scaleFactor 换算物理像素
// 改用 desktopCapturer 走系统合成器截屏。rect 是屏幕绝对 DIP, 裁剪时换算到该屏相对坐标再乘 scaleFactor
const scale = display.scaleFactor || 1;
const sources = await desktopCapturer.getSources({
types: ["screen"],
@ -776,17 +789,19 @@ export async function pickScreenRegion(): Promise<{ x: number; y: number; width:
}
const thumbnail = source.thumbnail;
const actualScale = thumbnail.getSize().width / Math.max(1, display.bounds.width);
const cropX = Math.max(0, Math.min(thumbnail.getSize().width - 1, Math.round(region.x * actualScale)));
const cropY = Math.max(0, Math.min(thumbnail.getSize().height - 1, Math.round(region.y * actualScale)));
const cropW = Math.max(1, Math.min(thumbnail.getSize().width - cropX, Math.round(region.width * actualScale)));
const cropH = Math.max(1, Math.min(thumbnail.getSize().height - cropY, Math.round(region.height * actualScale)));
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());
// 返回屏幕绝对 DIP 坐标(含负值/副屏偏移)+该屏缩放比, 运行时由此换算点击位置
return {
x: display.bounds.x + region.x,
y: display.bounds.y + region.y,
width: region.width,
height: region.height,
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
scale,
imagePath
};