fix(island): 命中改为"光标位置兜底轮询",修复"点开再收起就再也点不开"

用户复现路径:点开灵动岛(展开)→ 收起为胶囊 → 再点就毫无反应。
根因:固定包围盒窗口默认整块穿透,命中只在渲染进程收到 mousemove 时才打开。
Windows 下 setIgnoreMouseEvents(true, { forward: true }) 的 mousemove 转发并不总是可靠,
一旦"展开 → 收起"后转发丢失,窗口就永远停在穿透状态 —— 看得到胶囊,却怎么点都没反应。

修法(双保险,命中不再依赖"必须先收到一次 mousemove"):
- 渲染进程把**岛体矩形**上报主进程(新 IPC island:set-hit-rect,随状态/外观/形变更新,
  彻底隐藏时上报 null);
- 主进程每 120ms 用 screen.getCursorScreenPoint() 与矩形比对校正命中(窗口不可见或无矩形
  则强制穿透),并在收到矩形时立即校正一次;
- 保留渲染进程 mousemove 的快速路径(即时响应),两者使用同一矩形,结论一致;
- 渲染进程仍记住最近指针位置,状态变化时按新几何重算命中。

冒烟新增/改造(新增可注入光标位置 setHitTestCursor,避免去挪用户物理鼠标):
- 「展开 → 收起 → 再次展开(用户复现路径)」:真实点击往返,并断言每个形态都上报了命中框;
- 「命中兜底:岛体矩形随形态更新」(展开 424x230 → 收起 268x44);
- 「彻底隐藏时撤销命中框(不吞桌面点击)」;
- 「空闲细缝态命中跟随光标」(细缝上可点、移开即穿透);
- 「岛外鼠标穿透」与「收起态点击可再次展开」改为注入光标位置做确定性断言。

验证:verify:desktop 65/65 + 离线复跑 59/59;打包便携版 65/65(packaged=true);
typecheck / lint / format 全绿。
This commit is contained in:
LoveChengke committed 2026-09-12 21:49:14 +08:00
1 parent 80a0efbcfe
commit d8b0eb05c2
6 files changed
+270 -49

No files matched your search

@@ -125,6 +125,12 @@ class IslandController {
private backgroundMaterial: string = 'none';
/** 当前是否接收鼠标(固定大包围盒窗口默认穿透,由渲染进程按命中动态打开) */
private interactive = false;
/** 渲染进程上报的岛体矩形(窗口内 CSS px):主进程据此做光标命中兜底轮询 */
private hitRect: { x: number; y: number; width: number; height: number } | null = null;
/** 光标命中兜底轮询定时器(见 syncHitFromCursor 注释) */
private hitTimer: ReturnType<typeof setInterval> | null = null;
/** 诊断/冒烟用:覆盖光标位置(屏幕坐标),null 表示使用真实光标 */
private cursorOverride: { x: number; y: number } | null = null;
private state: IslandState = {
mode: 'hidden',
@@ -195,6 +201,8 @@ class IslandController {
// 默认整块窗口不接收鼠标(避免大面积透明窗口吞掉桌面点击),
// 只有指针进入"岛"的可见区域时,渲染进程才通过 island:set-interactive 打开命中。
win.setIgnoreMouseEvents(true, { forward: true });
// 光标命中兜底轮询:即使 forward 的 mousemove 丢失,也能按光标位置自行校正命中
this.startHitPoll();
win.on('closed', () => {
this.win = null;
this.ready = false;
@@ -473,6 +481,7 @@ class IslandController {
destroy(): void {
this.clearTimers();
this.stopHitPoll();
this.cancelBoundsAnimation();
if (this.win && !this.win.isDestroyed()) this.win.destroy();
this.win = null;
@@ -811,6 +820,11 @@ class IslandController {
* 鼠标命中开关(照搬 WinIsland 的 `set_cursor_hittest`):
* 窗口是固定大包围盒,若不控制命中就会把整片桌面的点击都吃掉。
* 渲染进程做命中测试(指针是否在"岛"的可见区域/可交互控件内),再回调这里切换。
*
* 另外主进程还有一路**兜底**:`syncHitFromCursor()` 每 120ms 直接读光标坐标与渲染进程
* 上报的岛体矩形比对。原因是 Windows 下 `forward: true` 的 mousemove 转发并不总是可靠,
* 一旦"展开 → 收起"后转发丢失,窗口会永远停在穿透状态 —— 用户看到的就是
* "点开了再收起,就再也点不开了"。
*/
setInteractive(interactive: boolean): void {
if (this.interactive === interactive) return;
@@ -821,6 +835,66 @@ class IslandController {
this.win.setIgnoreMouseEvents(!interactive, { forward: true });
}
/** 渲染进程上报的岛体矩形(窗口内 CSS px,坐标系与窗口 DIP 一致) */
setHitRect(rect: { x: number; y: number; width: number; height: number } | null): void {
this.hitRect = rect && rect.width > 0 && rect.height > 0 ? { ...rect } : null;
// 立即校正一次,避免最长 120ms 的"点了没反应"窗口
this.syncHitFromCursor();
}
/** 当前岛体矩形(供冒烟验证) */
getHitRect(): { x: number; y: number; width: number; height: number } | null {
return this.hitRect ? { ...this.hitRect } : null;
}
/**
* 诊断 / 冒烟专用:覆盖"光标位置"(屏幕坐标),让命中兜底轮询按指定坐标校正。
*
* 真实场景用 `screen.getCursorScreenPoint()`;冒烟里不能去挪用户的物理鼠标,
* 因此提供一个显式注入点,把"光标在岛上 / 不在岛上"两种情况都变成可复现的断言。
*/
setHitTestCursor(point: { x: number; y: number } | null): void {
this.cursorOverride = point ? { ...point } : null;
this.syncHitFromCursor();
}
/** 按光标位置校正命中(不依赖渲染进程是否收到 mousemove) */
private syncHitFromCursor(): void {
if (!this.win || this.win.isDestroyed() || !this.win.isVisible()) {
this.setInteractive(false);
return;
}
const rect = this.hitRect;
if (!rect) {
this.setInteractive(false);
return;
}
const bounds = this.win.getBounds();
const point = this.cursorOverride ?? screen.getCursorScreenPoint();
const localX = point.x - bounds.x;
const localY = point.y - bounds.y;
const margin = 2;
const inside =
localX >= rect.x - margin &&
localX <= rect.x + rect.width + margin &&
localY >= rect.y - margin &&
localY <= rect.y + rect.height + margin;
this.setInteractive(inside);
}
private startHitPoll(): void {
if (this.hitTimer) return;
this.hitTimer = setInterval(() => this.syncHitFromCursor(), 120);
// 定时器不应拖住进程退出
this.hitTimer.unref?.();
}
private stopHitPoll(): void {
if (!this.hitTimer) return;
clearInterval(this.hitTimer);
this.hitTimer = null;
}
/** 当前是否接收鼠标(供冒烟验证) */
getInteractive(): boolean {
return this.interactive;
@@ -970,6 +1044,14 @@ export function registerIslandIpc(): void {
island.setInteractive(interactive === true);
});
// 渲染进程上报岛体矩形 → 主进程按光标位置做命中兜底(不依赖 mousemove 转发)
ipcMain.on(
'island:set-hit-rect',
(_event, rect: { x: number; y: number; width: number; height: number } | null) => {
island.setHitRect(rect ?? null);
},
);
ipcMain.on('island:action', (_event, payload: IslandActionPayload) => {
if (!payload?.action) return;
island.handleAction(payload);