fix(island): 修好"收起后再次点击灵动岛没反应"(假失焦缩回 + 命中状态互相覆盖)
根因一(假失焦):展开时 setFocusable(true)+focus() 会引发一次与用户无关的失焦 (实测 70~520ms 后到达)。原来的失焦处理器在 500ms 宽限期之后就把任何 blur 当成 "用户点了别处",于是刚展开的岛立刻缩回胶囊 —— 看起来就是"点了没反应"。 现在只在**指针确实离开岛体**时才认为用户点了别处(新增 isCursorOnIsland)。 根因二(命中抖动):渲染进程与主进程各自下发 setInteractive(true/false),而两边几何 逐帧不一致(弹簧形变 + 岛体矩形上报限流),窗口就在"接收鼠标 / 穿透"之间几毫秒翻转一次, 点击撞上穿透态即"点不动"(日志实测 true/false 成对翻转)。现在命中判定单一权威: 渲染进程只负责"立刻打开命中",关闭一律由主进程按真实光标轮询(120ms→60ms)决定; 同时去掉"窗口一可见就整块 476×282 包围盒接收鼠标"(会在屏幕上方留一块看不见却点不动的 区域)、淡出期间不接收鼠标、岛体矩形上报限流 100→50ms。 验证(真机真实点击,非合成事件):SetCursorPos + mouse_event 连续 4 轮 "点开 → 点卡片收起 → 再点胶囊"全部正常,展开后保持展开不再被假失焦缩回 (修复前 0.5~2s 内必缩回);pnpm verify:desktop 78/78(新增回归用例 「假失焦(指针仍在岛上)不回缩:展开后必须保持展开」)。 顺带修掉既有测试缺陷:通知菜单项带未读徽标时整项 textContent 变成 "2通知", 用 startsWith 匹配会误报"菜单项缺失",改为按文案层 span 精确匹配。
This commit is contained in:
1 parent
70250151d0
commit
a7b0e9c2b3
6 files changed
+172
-54
No files matched your search
@@ -341,7 +341,12 @@ let lastPointer: { x: number; y: number } | null = null;
|
|||||||
/** 最近一次上报给主进程的岛体矩形(去重 + 限流,避免形变期间刷屏 IPC 拖慢渲染) */
|
/** 最近一次上报给主进程的岛体矩形(去重 + 限流,避免形变期间刷屏 IPC 拖慢渲染) */
|
||||||
let lastHitRectKey: string | null = null;
|
let lastHitRectKey: string | null = null;
|
||||||
let lastHitRectAt = 0;
|
let lastHitRectAt = 0;
|
||||||
const HIT_RECT_MIN_INTERVAL_MS = 100;
|
/**
|
||||||
|
* 岛体矩形上报的最小间隔:主进程按它做命中判定,限流太大就会在形变结束后留下
|
||||||
|
* 一段"矩形比实际卡片大"的死区(那段时间窗口会吃掉看不见区域的点击)。
|
||||||
|
* 50ms 既不会刷爆 IPC,也能让死区短到无感。
|
||||||
|
*/
|
||||||
|
const HIT_RECT_MIN_INTERVAL_MS = 50;
|
||||||
let hitRectTrailingTimer: number | null = null;
|
let hitRectTrailingTimer: number | null = null;
|
||||||
|
|
||||||
/** 把岛体矩形上报给主进程:主进程按光标位置兜底校正命中(不依赖 mousemove 转发) */
|
/** 把岛体矩形上报给主进程:主进程按光标位置兜底校正命中(不依赖 mousemove 转发) */
|
||||||
@@ -378,6 +383,14 @@ function reportHitRect(rect: DOMRect | null): void {
|
|||||||
}, HIT_RECT_MIN_INTERVAL_MS);
|
}, HIT_RECT_MIN_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 命中测试:把"指针在岛上"告诉主进程(照搬 WinIsland 的 set_cursor_hittest 思路)。
|
||||||
|
*
|
||||||
|
* **只上报 true**:窗口关闭命中一律由主进程按真实光标位置(60ms 轮询)决定。
|
||||||
|
* 为什么不能两边都下发:渲染进程与主进程的几何不可能逐帧一致(弹簧形变 + 岛体矩形上报限流),
|
||||||
|
* 两边都下发 true/false 时会互相覆盖,窗口就在"接收鼠标 / 穿透"之间来回抖动 ——
|
||||||
|
* 用户点下去时窗口恰好处于穿透态,表现就是**点了没反应**(实测日志里 true/false 每几毫秒翻转一次)。
|
||||||
|
*/
|
||||||
function updateInteractive(clientX: number, clientY: number): void {
|
function updateInteractive(clientX: number, clientY: number): void {
|
||||||
lastPointer = { x: clientX, y: clientY };
|
lastPointer = { x: clientX, y: clientY };
|
||||||
const node = document.querySelector('.island-card') as HTMLElement | null;
|
const node = document.querySelector('.island-card') as HTMLElement | null;
|
||||||
@@ -391,9 +404,15 @@ function updateInteractive(clientX: number, clientY: number): void {
|
|||||||
clientY >= rect.top - margin &&
|
clientY >= rect.top - margin &&
|
||||||
clientY <= rect.bottom + margin;
|
clientY <= rect.bottom + margin;
|
||||||
}
|
}
|
||||||
if (inside === lastInteractive) return;
|
if (!inside) {
|
||||||
lastInteractive = inside;
|
// 离开岛体不做任何下发:交给主进程轮询关闭;这里只复位本地标记,
|
||||||
bridge?.setInteractive?.(inside);
|
// 保证下次指针进入时能立刻重新打开命中(不必等下一次轮询)。
|
||||||
|
lastInteractive = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (lastInteractive === true) return;
|
||||||
|
lastInteractive = true;
|
||||||
|
bridge?.setInteractive?.(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -406,16 +425,15 @@ function updateInteractive(clientX: number, clientY: number): void {
|
|||||||
*
|
*
|
||||||
* 另外这里会把当前岛体矩形上报主进程(`setHitRect`):即使 Windows 下 mousemove 转发丢失,
|
* 另外这里会把当前岛体矩形上报主进程(`setHitRect`):即使 Windows 下 mousemove 转发丢失,
|
||||||
* 主进程也能按光标位置把命中校正回来 —— 这是"点开→收起→再也点不开"的兜底修复。
|
* 主进程也能按光标位置把命中校正回来 —— 这是"点开→收起→再也点不开"的兜底修复。
|
||||||
|
* 注意:关闭命中(穿透)不由这里下发,见 updateInteractive 的说明。
|
||||||
*/
|
*/
|
||||||
function refreshInteractive(): void {
|
function refreshInteractive(): void {
|
||||||
const node = document.querySelector('.island-card') as HTMLElement | null;
|
const node = document.querySelector('.island-card') as HTMLElement | null;
|
||||||
const fullyHidden = state.value?.mode === 'hidden' && !appearance.value.idleSliver;
|
const fullyHidden = state.value?.mode === 'hidden' && !appearance.value.idleSliver;
|
||||||
reportHitRect(node && !fullyHidden ? node.getBoundingClientRect() : null);
|
reportHitRect(node && !fullyHidden ? node.getBoundingClientRect() : null);
|
||||||
if (fullyHidden) {
|
if (fullyHidden) {
|
||||||
if (lastInteractive !== false) {
|
// 完全隐藏:本地复位即可(不下发关闭,窗口隐藏后由主进程轮询兜底关掉命中)
|
||||||
lastInteractive = false;
|
lastInteractive = false;
|
||||||
bridge?.setInteractive?.(false);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!lastPointer) return;
|
if (!lastPointer) return;
|
||||||
|
|||||||
@@ -224,9 +224,11 @@ class IslandController {
|
|||||||
* 点击屏幕任意位置收起:展开态临时允许窗口获得焦点,
|
* 点击屏幕任意位置收起:展开态临时允许窗口获得焦点,
|
||||||
* 用户点到别处(桌面、浏览器、其他应用)时窗口失焦 → 回缩为胶囊。
|
* 用户点到别处(桌面、浏览器、其他应用)时窗口失焦 → 回缩为胶囊。
|
||||||
*
|
*
|
||||||
* 加一个宽限期(`BLUR_GRACE_MS`):刚弹出/刚激活的瞬间,系统可能因为换前台窗口、
|
* 但 `win.focus()` 在 Windows 上并不可靠:展开时 `setFocusable(true) + focus()`
|
||||||
* 抢焦点或截图等操作立刻产生一次 blur,此时收起会让"刚弹出的通知一闪就没了"。
|
* 常常先给焦点、再被系统收回,产生一次与用户操作无关的 blur(实测 70ms~520ms 后到达)。
|
||||||
* 只在激活满 500ms 后才把 blur 当作"用户点击了别处"。
|
* 它落在 `BLUR_GRACE_MS` 宽限期内会被忽略,落在之后就会把刚展开的岛缩回胶囊 ——
|
||||||
|
* 用户看到的就是「点了没反应 / 点开又立刻缩回去」。
|
||||||
|
* 因此这里再用**指针位置**兜一层:指针还在岛上就绝不收起(详见 isCursorOnIsland)。
|
||||||
*/
|
*/
|
||||||
win.on('blur', () => {
|
win.on('blur', () => {
|
||||||
// 冒烟里可以临时关掉"失焦收起",避免测试过程被无关的焦点变化打断(默认开启)
|
// 冒烟里可以临时关掉"失焦收起",避免测试过程被无关的焦点变化打断(默认开启)
|
||||||
@@ -235,6 +237,12 @@ class IslandController {
|
|||||||
// 设置页预览:示例岛要继续留在屏幕上(用户在调滑块时主窗口一直是焦点)
|
// 设置页预览:示例岛要继续留在屏幕上(用户在调滑块时主窗口一直是焦点)
|
||||||
if (this.previewId && this.state.active?.id === this.previewId) return;
|
if (this.previewId && this.state.active?.id === this.previewId) return;
|
||||||
const sinceActivated = Date.now() - this.state.updatedAt;
|
const sinceActivated = Date.now() - this.state.updatedAt;
|
||||||
|
if (this.isCursorOnIsland()) {
|
||||||
|
logger.info(
|
||||||
|
`灵动岛:忽略失焦(指针仍在岛上,判定为展开时的激活抖动)距上次状态=${sinceActivated}ms`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (sinceActivated < BLUR_GRACE_MS) {
|
if (sinceActivated < BLUR_GRACE_MS) {
|
||||||
logger.info(`灵动岛:忽略激活后 ${sinceActivated}ms 内的失焦(宽限期内不收起)`);
|
logger.info(`灵动岛:忽略激活后 ${sinceActivated}ms 内的失焦(宽限期内不收起)`);
|
||||||
return;
|
return;
|
||||||
@@ -436,6 +444,10 @@ class IslandController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleAction(payload: IslandActionPayload): void {
|
handleAction(payload: IslandActionPayload): void {
|
||||||
|
// 渲染进程(胶囊/卡片/按钮)点进来的动作:日志是排查"点了没反应"的关键证据
|
||||||
|
logger.info(
|
||||||
|
`灵动岛:收到用户动作 ${payload.action}${payload.id ? ` id=${payload.id}` : ''}(当前 ${this.state.mode})`,
|
||||||
|
);
|
||||||
switch (payload.action) {
|
switch (payload.action) {
|
||||||
case 'expand': {
|
case 'expand': {
|
||||||
// 上课时段:普通通知(含普通叫人)一律不显示;但紧急通知 / 紧急叫人本来就是必须
|
// 上课时段:普通通知(含普通叫人)一律不显示;但紧急通知 / 紧急叫人本来就是必须
|
||||||
@@ -558,6 +570,7 @@ class IslandController {
|
|||||||
hideImmediately(): void {
|
hideImmediately(): void {
|
||||||
this.clearTimers();
|
this.clearTimers();
|
||||||
this.cancelBoundsAnimation();
|
this.cancelBoundsAnimation();
|
||||||
|
this.setInteractive(false, '立即隐藏');
|
||||||
this.explicitShowId = null;
|
this.explicitShowId = null;
|
||||||
this.fadeToken += 1;
|
this.fadeToken += 1;
|
||||||
const changed = this.state.mode !== 'hidden' || this.state.reason !== null;
|
const changed = this.state.mode !== 'hidden' || this.state.reason !== null;
|
||||||
@@ -743,25 +756,28 @@ class IslandController {
|
|||||||
if (this.state.mode === 'hidden') {
|
if (this.state.mode === 'hidden') {
|
||||||
// 空闲"细缝"(参考 WinIsland):打开后空闲不再完全隐藏,而是留一条很窄的圆角柱;
|
// 空闲"细缝"(参考 WinIsland):打开后空闲不再完全隐藏,而是留一条很窄的圆角柱;
|
||||||
// 关闭时保持原行为(完全淡出并隐藏)。
|
// 关闭时保持原行为(完全淡出并隐藏)。
|
||||||
|
this.setInteractive(false, '隐藏态');
|
||||||
if (this.appearance.idleSliver) {
|
if (this.appearance.idleSliver) {
|
||||||
this.cancelFade();
|
this.cancelFade();
|
||||||
this.setInteractive(false);
|
|
||||||
this.win.setOpacity(this.appearance.opacity);
|
this.win.setOpacity(this.appearance.opacity);
|
||||||
if (!this.win.isVisible()) this.win.showInactive();
|
if (!this.win.isVisible()) this.win.showInactive();
|
||||||
|
// 细缝也要"可见即可点":按真实光标位置决定是否接收鼠标
|
||||||
|
this.syncHitFromCursor();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.fadeOut();
|
this.fadeOut();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setInteractive(true);
|
|
||||||
if (!this.win.isVisible()) {
|
if (!this.win.isVisible()) {
|
||||||
// 窗口位置由固定包围盒决定(applyWindowLayout),这里只负责显示 + 透明度
|
// 窗口位置由固定包围盒决定(applyWindowLayout),这里只负责显示 + 透明度
|
||||||
this.cancelFade();
|
this.cancelFade();
|
||||||
this.win.setOpacity(this.appearance.opacity);
|
this.win.setOpacity(this.appearance.opacity);
|
||||||
this.win.showInactive();
|
this.win.showInactive();
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
// 命中一律按"真实光标是否落在岛上"决定:**绝不**整块固定包围盒吃鼠标,
|
||||||
|
// 否则岛一旦弹出,屏幕上方就会有一块看不见却点不动的区域。
|
||||||
|
this.syncHitFromCursor();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -814,7 +830,12 @@ class IslandController {
|
|||||||
this.fadeToken += 1;
|
this.fadeToken += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 展开态允许聚焦(用于"点击屏幕任意处收起"),其余时间不抢焦点 */
|
/**
|
||||||
|
* 展开态允许聚焦(用于"点击屏幕任意处收起"),其余时间不抢焦点。
|
||||||
|
*
|
||||||
|
* 注意:这个开关本身就是 Windows 上那次"假失焦"的来源(见 blur 处理器注释),
|
||||||
|
* 因此收起时的判断不能只依赖 blur,必须结合指针位置。
|
||||||
|
*/
|
||||||
private applyFocusable(): void {
|
private applyFocusable(): void {
|
||||||
if (!this.win || this.win.isDestroyed()) return;
|
if (!this.win || this.win.isDestroyed()) return;
|
||||||
const shouldFocus = this.state.mode === 'expanded';
|
const shouldFocus = this.state.mode === 'expanded';
|
||||||
@@ -937,9 +958,15 @@ class IslandController {
|
|||||||
* 一旦"展开 → 收起"后转发丢失,窗口会永远停在穿透状态 —— 用户看到的就是
|
* 一旦"展开 → 收起"后转发丢失,窗口会永远停在穿透状态 —— 用户看到的就是
|
||||||
* "点开了再收起,就再也点不开了"。
|
* "点开了再收起,就再也点不开了"。
|
||||||
*/
|
*/
|
||||||
setInteractive(interactive: boolean): void {
|
setInteractive(interactive: boolean, source = '未知'): void {
|
||||||
if (this.interactive === interactive) return;
|
if (this.interactive === interactive) return;
|
||||||
this.interactive = interactive;
|
this.interactive = interactive;
|
||||||
|
logger.info(
|
||||||
|
`灵动岛:鼠标命中=${interactive}(来自${source})mode=${this.state.mode} ` +
|
||||||
|
`窗口可见=${this.win?.isVisible() ?? false} 岛体矩形=${
|
||||||
|
this.hitRect ? `${Math.round(this.hitRect.width)}x${Math.round(this.hitRect.height)}` : '无'
|
||||||
|
}`,
|
||||||
|
);
|
||||||
if (!this.win || this.win.isDestroyed()) return;
|
if (!this.win || this.win.isDestroyed()) return;
|
||||||
// forward: true 让窗口在"忽略鼠标"时仍把 mousemove 转发给渲染进程,
|
// forward: true 让窗口在"忽略鼠标"时仍把 mousemove 转发给渲染进程,
|
||||||
// 这样渲染进程才能发现指针进入岛体并重新打开命中。
|
// 这样渲染进程才能发现指针进入岛体并重新打开命中。
|
||||||
@@ -973,33 +1000,63 @@ class IslandController {
|
|||||||
this.syncHitFromCursor();
|
this.syncHitFromCursor();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 光标相对窗口左上角的本地坐标(CSS px / DIP,与渲染进程上报的岛体矩形同一坐标系)。
|
||||||
|
* 冒烟里用 `setHitTestCursor` 注入,真实场景读系统光标。
|
||||||
|
*/
|
||||||
|
private cursorLocal(): { x: number; y: number } | null {
|
||||||
|
if (!this.win || this.win.isDestroyed()) return null;
|
||||||
|
const bounds = this.win.getBounds();
|
||||||
|
const point = this.cursorOverride ?? screen.getCursorScreenPoint();
|
||||||
|
return { x: point.x - bounds.x, y: point.y - bounds.y };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 本地坐标是否落在岛体矩形内(含 2px 容差) */
|
||||||
|
private isInsideHitRect(local: { x: number; y: number } | null): boolean {
|
||||||
|
const rect = this.hitRect;
|
||||||
|
if (!local || !rect) return false;
|
||||||
|
const margin = 2;
|
||||||
|
return (
|
||||||
|
local.x >= rect.x - margin &&
|
||||||
|
local.x <= rect.x + rect.width + margin &&
|
||||||
|
local.y >= rect.y - margin &&
|
||||||
|
local.y <= rect.y + rect.height + margin
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 指针当前是否落在岛体(卡片)上。
|
||||||
|
*
|
||||||
|
* 用途:区分"用户点了屏幕别处"和"展开自身引发的激活抖动" —— 两者都会让窗口失焦,
|
||||||
|
* 但只有前者会把指针留在岛外。岛是置顶窗口,用户点到岛上时点击必然落在岛上,
|
||||||
|
* 因此"指针还在岛上却收到 blur"一定是系统/程序造成的假失焦,此时绝不能收起
|
||||||
|
* (否则表现为「点了没反应 / 点开又立刻缩回胶囊」)。
|
||||||
|
*/
|
||||||
|
private isCursorOnIsland(): boolean {
|
||||||
|
if (!this.win || this.win.isDestroyed() || !this.win.isVisible()) return false;
|
||||||
|
return this.isInsideHitRect(this.cursorLocal());
|
||||||
|
}
|
||||||
|
|
||||||
/** 按光标位置校正命中(不依赖渲染进程是否收到 mousemove) */
|
/** 按光标位置校正命中(不依赖渲染进程是否收到 mousemove) */
|
||||||
private syncHitFromCursor(): void {
|
private syncHitFromCursor(): void {
|
||||||
if (!this.win || this.win.isDestroyed() || !this.win.isVisible()) {
|
if (!this.win || this.win.isDestroyed() || !this.win.isVisible()) {
|
||||||
this.setInteractive(false);
|
this.setInteractive(false, '窗口不可见');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const rect = this.hitRect;
|
// 完全隐藏(含淡出过程中):一律不再接收鼠标,避免窗口在消失的几百毫秒里吞掉桌面点击。
|
||||||
if (!rect) {
|
// 空闲细缝态(idleSliver)是"可见即可点",不走这条。
|
||||||
this.setInteractive(false);
|
if (this.state.mode === 'hidden' && !this.appearance.idleSliver) {
|
||||||
|
this.setInteractive(false, '隐藏态');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const bounds = this.win.getBounds();
|
this.setInteractive(this.isInsideHitRect(this.cursorLocal()), '光标轮询');
|
||||||
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 {
|
private startHitPoll(): void {
|
||||||
if (this.hitTimer) return;
|
if (this.hitTimer) return;
|
||||||
this.hitTimer = setInterval(() => this.syncHitFromCursor(), 120);
|
// 60ms:渲染进程的 mousemove 转发在 Windows 上并不可靠,这里是命中的**权威来源**,
|
||||||
|
// 间隔必须足够小,否则"指针移上胶囊后立刻点击"会因窗口还没接收鼠标而点空(用户反馈过)。
|
||||||
|
this.hitTimer = setInterval(() => this.syncHitFromCursor(), 60);
|
||||||
// 定时器不应拖住进程退出
|
// 定时器不应拖住进程退出
|
||||||
this.hitTimer.unref?.();
|
this.hitTimer.unref?.();
|
||||||
}
|
}
|
||||||
@@ -1162,9 +1219,12 @@ export function registerIslandIpc(): void {
|
|||||||
|
|
||||||
ipcMain.handle('island:get-appearance', () => island.getAppearance());
|
ipcMain.handle('island:get-appearance', () => island.getAppearance());
|
||||||
|
|
||||||
// 渲染进程命中测试结果 → 切换窗口是否接收鼠标
|
// 渲染进程命中测试结果 → **只用于立刻打开命中**(比 60ms 轮询更快)。
|
||||||
|
// 关闭命中一律由主进程按真实光标决定:两边都下发 true/false 会互相覆盖,
|
||||||
|
// 窗口在"接收/穿透"之间抖动,用户点下去时可能正好穿透(表现为"点了没反应")。
|
||||||
ipcMain.on('island:set-interactive', (_event, interactive: boolean) => {
|
ipcMain.on('island:set-interactive', (_event, interactive: boolean) => {
|
||||||
island.setInteractive(interactive === true);
|
if (interactive !== true) return;
|
||||||
|
island.setInteractive(true, '渲染进程');
|
||||||
});
|
});
|
||||||
|
|
||||||
// 渲染进程上报岛体矩形 → 主进程按光标位置做命中兜底(不依赖 mousemove 转发)
|
// 渲染进程上报岛体矩形 → 主进程按光标位置做命中兜底(不依赖 mousemove 转发)
|
||||||
|
|||||||
@@ -535,6 +535,25 @@ function makeNotification(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 岛体矩形上的相对点(fx/fy ∈ [0,1])→ 屏幕坐标。
|
||||||
|
* 主进程的命中兜底轮询读的是屏幕坐标,冒烟里用注入点代替"挪动用户鼠标"。
|
||||||
|
*/
|
||||||
|
function 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 灵动岛行为验证(对应产品需求):
|
* 灵动岛行为验证(对应产品需求):
|
||||||
* 1. 上课时间段收到普通通知 → 不显示灵动岛(窗口真正隐藏)并暂存
|
* 1. 上课时间段收到普通通知 → 不显示灵动岛(窗口真正隐藏)并暂存
|
||||||
@@ -900,6 +919,9 @@ async function runIslandChecks(
|
|||||||
island.handleAction({ action: 'expand' });
|
island.handleAction({ action: 'expand' });
|
||||||
await sleep(700);
|
await sleep(700);
|
||||||
const focusable = islandWindow?.isFocusable() ?? false;
|
const focusable = islandWindow?.isFocusable() ?? false;
|
||||||
|
// "点到别处"的另一半含义是**指针不在岛上**:这里把注入光标放到屏幕左上角(远离岛体),
|
||||||
|
// 否则会被下面 4.5.1 的"指针还在岛上 = 假失焦"保护逻辑判为无效失焦。
|
||||||
|
island.setHitTestCursor({ x: 1, y: 1 });
|
||||||
// 程序化触发失焦:等价于用户点到别处(冒烟窗口本身不显示,无法真的点击桌面)
|
// 程序化触发失焦:等价于用户点到别处(冒烟窗口本身不显示,无法真的点击桌面)
|
||||||
island.setBlurCollapseEnabled(true); // 这一条专门验证"失焦收起",先打开该行为
|
island.setBlurCollapseEnabled(true); // 这一条专门验证"失焦收起",先打开该行为
|
||||||
islandWindow?.emit('blur');
|
islandWindow?.emit('blur');
|
||||||
@@ -910,6 +932,33 @@ async function runIslandChecks(
|
|||||||
focusable && islandWindow?.isFocusable() === false && afterBlur.mode === 'pill',
|
focusable && islandWindow?.isFocusable() === false && afterBlur.mode === 'pill',
|
||||||
`展开时 focusable=${focusable} 失焦后 mode=${afterBlur.mode} focusable=${islandWindow?.isFocusable() ?? '-'}`,
|
`展开时 focusable=${focusable} 失焦后 mode=${afterBlur.mode} focusable=${islandWindow?.isFocusable() ?? '-'}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 4.5.1) 展开动作自身引发的**假失焦**不能把刚展开的岛缩回胶囊。
|
||||||
|
// 用户复现:"收起后再次点击灵动岛没反应" —— 点开胶囊后约 0.5s 被系统收回焦点,
|
||||||
|
// 岛立刻缩回胶囊,看起来就像"点了没反应"。此时指针必然还在岛上,必须忽略该失焦。
|
||||||
|
island.pushNotification(makeNotification('smoke-phantom-blur', 'NORMAL', '假失焦回归'), {
|
||||||
|
inClass: false,
|
||||||
|
});
|
||||||
|
await sleep(400);
|
||||||
|
island.handleAction({ action: 'expand' });
|
||||||
|
await sleep(300);
|
||||||
|
island.setHitTestCursor(islandCardScreenPoint(0.5, 0.5)); // 指针停在岛上(用户刚点的位置)
|
||||||
|
await sleep(160);
|
||||||
|
islandWindow?.emit('blur'); // 模拟展开后到达的那次假失焦
|
||||||
|
await sleep(500);
|
||||||
|
const afterPhantomBlur = island.getState();
|
||||||
|
const phantomStillInteractive = island.getInteractive();
|
||||||
|
record(
|
||||||
|
'假失焦(指针仍在岛上)不回缩:展开后必须保持展开',
|
||||||
|
afterPhantomBlur.mode === 'expanded' &&
|
||||||
|
afterPhantomBlur.active?.id === 'smoke-phantom-blur' &&
|
||||||
|
phantomStillInteractive === true,
|
||||||
|
`mode=${afterPhantomBlur.mode} active=${afterPhantomBlur.active?.id ?? '-'} 命中=${phantomStillInteractive}`,
|
||||||
|
);
|
||||||
|
island.setHitTestCursor(null);
|
||||||
|
// 清掉这一条通知(队列为空 → 岛自行隐藏),避免影响后续用例的"空闲态"断言
|
||||||
|
island.handleAction({ action: 'dismiss' });
|
||||||
|
await sleep(400);
|
||||||
// 其余用例关掉"失焦收起":避免无关的焦点变化(系统抢焦点等)把展开态收回,干扰断言
|
// 其余用例关掉"失焦收起":避免无关的焦点变化(系统抢焦点等)把展开态收回,干扰断言
|
||||||
island.setBlurCollapseEnabled(false);
|
island.setBlurCollapseEnabled(false);
|
||||||
|
|
||||||
@@ -1149,24 +1198,6 @@ async function runIslandChecks(
|
|||||||
await sleep(120);
|
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) */
|
/** 合成"指针移到卡片上":命中测试由渲染进程按指针位置决定(真实场景由系统转发 mousemove) */
|
||||||
const hoverCard = async (): Promise<boolean> =>
|
const hoverCard = async (): Promise<boolean> =>
|
||||||
Boolean(
|
Boolean(
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export interface IslandBridge {
|
|||||||
/** 订阅个性化外观(设置页改动后实时生效) */
|
/** 订阅个性化外观(设置页改动后实时生效) */
|
||||||
onAppearance(handler: (appearance: IslandAppearance) => void): void;
|
onAppearance(handler: (appearance: IslandAppearance) => void): void;
|
||||||
getAppearance(): Promise<IslandAppearance>;
|
getAppearance(): Promise<IslandAppearance>;
|
||||||
/** 命中测试结果:指针是否在岛体上(决定窗口是否接收鼠标) */
|
/** 命中提示:指针在岛上 → 让窗口接收鼠标(只上报 true,关闭由主进程轮询决定) */
|
||||||
setInteractive(interactive: boolean): void;
|
setInteractive(interactive: boolean): void;
|
||||||
/** 上报岛体矩形(窗口内 CSS px):主进程据此按光标位置兜底校正命中 */
|
/** 上报岛体矩形(窗口内 CSS px):主进程据此按光标位置兜底校正命中 */
|
||||||
setHitRect(rect: { x: number; y: number; width: number; height: number } | null): void;
|
setHitRect(rect: { x: number; y: number; width: number; height: number } | null): void;
|
||||||
|
|||||||
+6
-1
@@ -85,9 +85,14 @@ const EXPECTED_MENU: Array<{ label: string; path: string }> = [
|
|||||||
export async function layoutNavigationSelfTest(): Promise<SmokeCheckResult> {
|
export async function layoutNavigationSelfTest(): Promise<SmokeCheckResult> {
|
||||||
const { router } = await import('../router/index.js');
|
const { router } = await import('../router/index.js');
|
||||||
|
|
||||||
|
// 按**文案那一层 span 的完整文本**匹配,而不是整项 textContent:
|
||||||
|
// "通知"项里还有一个未读红点徽标(sup),整项 textContent 会变成 "2通知",
|
||||||
|
// 用 startsWith 匹配就会误报"菜单项缺失"(历史失败原因)。
|
||||||
const findMenuItem = (label: string): HTMLElement | undefined =>
|
const findMenuItem = (label: string): HTMLElement | undefined =>
|
||||||
(Array.from(document.querySelectorAll('.el-menu-item')) as HTMLElement[]).find((element) =>
|
(Array.from(document.querySelectorAll('.el-menu-item')) as HTMLElement[]).find((element) =>
|
||||||
(element.textContent ?? '').trim().startsWith(label),
|
Array.from(element.querySelectorAll('span')).some(
|
||||||
|
(span) => (span.textContent ?? '').trim() === label,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!findMenuItem('课表')) {
|
if (!findMenuItem('课表')) {
|
||||||
|
|||||||
+5
-1
@@ -89,7 +89,11 @@ export interface IslandRendererBridge {
|
|||||||
getAppearance(): Promise<IslandAppearance>;
|
getAppearance(): Promise<IslandAppearance>;
|
||||||
sendAction(action: 'expand' | 'collapse' | 'dismiss' | 'mark-read' | 'open-app', id?: string): void;
|
sendAction(action: 'expand' | 'collapse' | 'dismiss' | 'mark-read' | 'open-app', id?: string): void;
|
||||||
getState(): Promise<IslandState>;
|
getState(): Promise<IslandState>;
|
||||||
/** 命中测试结果:指针是否在岛体上(决定固定大窗口是否接收鼠标) */
|
/**
|
||||||
|
* 命中提示:指针在岛体上时应让固定大窗口接收鼠标(比主进程 60ms 轮询更快)。
|
||||||
|
* **只会上报 true**:关闭命中(穿透)一律由主进程按真实光标决定,
|
||||||
|
* 两边都下发会让窗口在"接收/穿透"之间抖动(用户反馈的"点了没反应")。
|
||||||
|
*/
|
||||||
setInteractive(interactive: boolean): void;
|
setInteractive(interactive: boolean): void;
|
||||||
/** 上报岛体矩形(窗口内 CSS px):主进程据此按光标位置兜底校正命中 */
|
/** 上报岛体矩形(窗口内 CSS px):主进程据此按光标位置兜底校正命中 */
|
||||||
setHitRect(rect: { x: number; y: number; width: number; height: number } | null): void;
|
setHitRect(rect: { x: number; y: number; width: number; height: number } | null): void;
|
||||||
|
|||||||
Reference in new issue
Block a user