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

@@ -301,6 +301,19 @@ const islandClass = computed(() => ({
let lastInteractive: boolean | null = null;
/** 最近一次已知的指针位置:岛收起/变大后用它重算命中,避免"鼠标没动就点不到" */
let lastPointer: { x: number; y: number } | null = null;
/** 最近一次上报给主进程的岛体矩形(去重,避免形变期间刷屏 IPC) */
let lastHitRectKey: string | null = null;
/** 把岛体矩形上报给主进程:主进程按光标位置兜底校正命中(不依赖 mousemove 转发) */
function reportHitRect(rect: DOMRect | null): void {
const payload = rect ? { x: rect.left, y: rect.top, width: rect.width, height: rect.height } : null;
const key = payload
? `${Math.round(payload.x)},${Math.round(payload.y)},${Math.round(payload.width)},${Math.round(payload.height)}`
: 'none';
if (key === lastHitRectKey) return;
lastHitRectKey = key;
bridge?.setHitRect?.(payload);
}
function updateInteractive(clientX: number, clientY: number): void {
lastPointer = { x: clientX, y: clientY };
@@ -327,9 +340,14 @@ function updateInteractive(clientX: number, clientY: number): void {
* 只有在收到 mousemove 时才会打开命中。岛"收起 / 展开 / 换形态"时几何变了,
* 但指针可能一动没动(没有新的 mousemove)—— 此时窗口会一直保持"穿透",
* 表现就是**收起后怎么点都打不开**(用户反馈)。所以每次状态变化都用缓存的指针位置重算一次。
*
* 另外这里会把当前岛体矩形上报主进程(`setHitRect`):即使 Windows 下 mousemove 转发丢失,
* 主进程也能按光标位置把命中校正回来 —— 这是"点开→收起→再也点不开"的兜底修复。
*/
function refreshInteractive(): void {
const node = document.querySelector('.island-card') as HTMLElement | null;
const fullyHidden = state.value?.mode === 'hidden' && !appearance.value.idleSliver;
reportHitRect(node && !fullyHidden ? node.getBoundingClientRect() : null);
if (fullyHidden) {
if (lastInteractive !== false) {
lastInteractive = false;
@@ -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);
+117 -12
View File
@@ -977,6 +977,24 @@ async function runIslandChecks(
await sleep(120);
}
};
/**
* 岛体矩形上的相对点(fx/fy ∈ [0,1])→ 屏幕坐标。
* 主进程的命中兜底轮询读的是屏幕坐标,冒烟里用注入点代替"挪动用户鼠标"。
*/
const islandCardScreenPoint = (fx: number, fy: number): { x: number; y: number } => {
const bounds = island.getWindowBounds();
const rect = island.getHitRect();
if (!rect) {
return {
x: bounds.x + Math.round(bounds.width / 2),
y: bounds.y + Math.round(bounds.height / 2),
};
}
return {
x: Math.round(bounds.x + rect.x + rect.width * fx),
y: Math.round(bounds.y + rect.y + rect.height * fy),
};
};
/** 合成"指针移到卡片上":命中测试由渲染进程按指针位置决定(真实场景由系统转发 mousemove) */
const hoverCard = async (): Promise<boolean> =>
Boolean(
@@ -1015,13 +1033,14 @@ async function runIslandChecks(
collapseBefore.mode === 'pill' && lingered.mode === 'pill' && lingeredVisible,
`收起前=${collapseBefore.mode} 超时后=${lingered.mode} 可见=${lingeredVisible}`,
);
// 命中:把窗口显式置为"穿透"(等价于收起瞬间窗口被重置),再做一次状态变化 ——
// 主进程会按当前形态重新下发命中,渲染进程也会按缓存指针重算,两者都必须把命中恢复。
// 命中兜底:把窗口显式置为"穿透",再把光标位置注入到胶囊中心 ——
// 主进程每 120ms 按光标校正一次命中,必须自己恢复(这正是"点开再收起后点不开"的兜底修复)。
island.setInteractive(false);
await sleep(60);
const forcedThrough = island.getInteractive();
island.handleAction({ action: 'collapse' });
await sleep(300);
const pillPoint = islandCardScreenPoint(0.5, 0.5);
island.setHitTestCursor(pillPoint);
await sleep(260);
const interactiveWhilePill = island.getInteractive();
await islandWindow?.webContents
.executeJavaScript(
@@ -1039,7 +1058,7 @@ async function runIslandChecks(
interactiveWhilePill === true &&
reopened.mode === 'expanded' &&
reopened.active?.id === 'smoke-collapse-reopen',
`置穿透后=${forcedThrough} 状态变化后=${interactiveWhilePill} 点击后 mode=${reopened.mode} ` +
`置穿透后=${forcedThrough} 光标在胶囊上时=${interactiveWhilePill} 点击后 mode=${reopened.mode} ` +
`active=${reopened.active?.id ?? '-'}`,
);
@@ -1049,20 +1068,100 @@ async function runIslandChecks(
await drainForCollapseCheck();
await sleep(500);
const sliverState = island.getState();
await hoverCard();
await sleep(250);
island.setHitTestCursor(islandCardScreenPoint(0.5, 0.5));
await sleep(260);
const sliverInteractive = island.getInteractive();
const sliverVisible = islandWindow?.isVisible() ?? false;
// 光标移开细缝(屏幕左上角)→ 命中必须撤销,避免细缝窗口吞掉桌面点击
island.setHitTestCursor({ x: 1, y: 1 });
await sleep(260);
const sliverInteractiveOutside = island.getInteractive();
record(
'空闲细缝态保持可交互(细缝很窄,指针停在岛上仍能点开)',
sliverState.mode === 'hidden' && sliverVisible && sliverInteractive === true,
`mode=${sliverState.mode} 可见=${sliverVisible} interactive=${sliverInteractive} ` +
`细缝=${sliverForInteractive.width}x${sliverForInteractive.height}`,
'空闲细缝态命中跟随光标(细缝上可点、移开即穿透)',
sliverState.mode === 'hidden' &&
sliverVisible &&
sliverInteractive === true &&
sliverInteractiveOutside === false,
`mode=${sliverState.mode} 可见=${sliverVisible} 光标在细缝上=${sliverInteractive} ` +
`光标移开=${sliverInteractiveOutside} 细缝=${sliverForInteractive.width}x${sliverForInteractive.height}`,
);
island.setHitTestCursor(null);
island.setAppearance({ ...island.getAppearance(), idleSliver: false });
await drainForCollapseCheck();
await sleep(300);
// 4.10) 用户复现路径:"点开灵动岛再收起就无法再次打开了"
// 之前是一条"展开 → 收起 → 再也点不开"的死路(窗口停在穿透状态)。
// 这里用真实 DOM 点击走完 展开 → 收起 → 再次展开,并断言每个形态都有命中框上报。
const clickIsland = async (selector = '.island-card'): Promise<boolean> =>
Boolean(
await islandWindow?.webContents
.executeJavaScript(
`(() => {
const node = document.querySelector(${JSON.stringify(selector)});
if (!node) return false;
node.click();
return true;
})()`,
)
.catch(() => false),
);
const rectText = (rect: { width: number; height: number } | null): string =>
rect ? `${Math.round(rect.width)}x${Math.round(rect.height)}` : '无';
await drainForCollapseCheck();
await sleep(300);
island.pushNotification(makeNotification('smoke-reopen-roundtrip', 'NORMAL', '展开收起再展开'), {
inClass: false,
});
await sleep(500);
const clickedFirst = await clickIsland();
await sleep(700);
const afterFirstOpen = island.getState();
const hitRectExpanded = island.getHitRect();
const clickedCollapse = await clickIsland('.island-card.expanded .body');
await sleep(700);
const afterCollapse = island.getState();
const hitRectPill = island.getHitRect();
const clickedSecond = await clickIsland();
await sleep(750);
const afterSecondOpen = island.getState();
record(
'展开 → 收起 → 再次展开(用户复现路径)',
clickedFirst &&
clickedCollapse &&
clickedSecond &&
afterFirstOpen.mode === 'expanded' &&
afterCollapse.mode === 'pill' &&
afterSecondOpen.mode === 'expanded' &&
afterSecondOpen.active?.id === 'smoke-reopen-roundtrip' &&
hitRectExpanded !== null &&
hitRectPill !== null,
`${afterFirstOpen.mode}(命中框 ${rectText(hitRectExpanded)}) → ${afterCollapse.mode}` +
`(命中框 ${rectText(hitRectPill)}) → ${afterSecondOpen.mode}` +
` active=${afterSecondOpen.active?.id ?? '-'}`,
);
// 主进程的命中兜底轮询:岛体矩形必须随形态更新(指针交给系统,这里只断言"依据"是对的)
await island.handleAction({ action: 'collapse' });
await sleep(500);
const hitRectCollapsed = island.getHitRect();
record(
'命中兜底:岛体矩形随形态更新(供主进程按光标校正)',
hitRectCollapsed !== null &&
Math.abs(hitRectCollapsed.width - ISLAND_SIZES.pill.width) <= 2 &&
hitRectExpanded !== null &&
hitRectExpanded.width > hitRectCollapsed.width,
`展开 ${rectText(hitRectExpanded)} → 收起 ${rectText(hitRectCollapsed)}(期望 ${ISLAND_SIZES.pill.width})`,
);
// 彻底隐藏(无细缝)时必须撤销命中框,避免"看不见的窗口吞桌面点击"
await drainForCollapseCheck();
await sleep(600);
const hitRectHidden = island.getHitRect();
record(
'彻底隐藏时撤销命中框(不吞桌面点击)',
island.getState().mode === 'hidden' && hitRectHidden === null,
`mode=${island.getState().mode} 命中框=${rectText(hitRectHidden)}`,
);
// 5) 截图留档的像素级断言:每张图必须有实际绘制内容、颜色丰富,且宽高比与
// 状态机配置的窗口尺寸一致(DPI 无关),紧急形态还必须出现红色描边/内部光晕像素。
const shotDetails = islandShots.map((shot) => {
@@ -1524,6 +1623,9 @@ async function runIslandChecks(
)
.catch(() => undefined);
await sleep(180);
// 兜底轮询读的是主进程光标坐标:注入到岛体中心,等价于"指针停在岛内"
island.setHitTestCursor(islandCardScreenPoint(0.5, 0.5));
await sleep(260);
const interactiveInside = island.getInteractive();
await islandWindow?.webContents
.executeJavaScript(
@@ -1533,8 +1635,11 @@ async function runIslandChecks(
})()`,
)
.catch(() => undefined);
await sleep(180);
// 光标的权威判定在主进程(兜底轮询):这里把光标注入到屏幕左上角,等价于"指针离开岛体"
island.setHitTestCursor({ x: 1, y: 1 });
await sleep(260);
const interactiveOutside = island.getInteractive();
island.setHitTestCursor(null);
record(
'岛外鼠标穿透(固定大窗口不吞桌面点击)',
interactiveInside === true && interactiveOutside === false,
@@ -14,6 +14,8 @@ export interface IslandBridge {
getAppearance(): Promise<IslandAppearance>;
/** 命中测试结果:指针是否在岛体上(决定窗口是否接收鼠标) */
setInteractive(interactive: boolean): void;
/** 上报岛体矩形(窗口内 CSS px):主进程据此按光标位置兜底校正命中 */
setHitRect(rect: { x: number; y: number; width: number; height: number } | null): void;
}
const bridge: IslandBridge = {
@@ -31,6 +33,9 @@ const bridge: IslandBridge = {
setInteractive: (interactive) => {
ipcRenderer.send('island:set-interactive', interactive);
},
setHitRect: (rect) => {
ipcRenderer.send('island:set-hit-rect', rect);
},
};
contextBridge.exposeInMainWorld('island', bridge);
+2
View File
@@ -75,6 +75,8 @@ export interface IslandRendererBridge {
getState(): Promise<IslandState>;
/** 命中测试结果:指针是否在岛体上(决定固定大窗口是否接收鼠标) */
setInteractive(interactive: boolean): void;
/** 上报岛体矩形(窗口内 CSS px):主进程据此按光标位置兜底校正命中 */
setHitRect(rect: { x: number; y: number; width: number; height: number } | null): void;
}
declare global {