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:
1 parent
80a0efbcfe
commit
d8b0eb05c2
6 files changed
+270
-49
No files matched your search
@@ -20,7 +20,7 @@
|
|||||||
| 1 | pnpm monorepo 脚手架、TypeScript / ESLint / Prettier / 环境变量 | ✅ 已完成 |
|
| 1 | pnpm monorepo 脚手架、TypeScript / ESLint / Prettier / 环境变量 | ✅ 已完成 |
|
||||||
| 2 | 后端:Prisma schema + 迁移 + 种子数据 + JWT 认证 + RBAC + 模块化 REST API + Socket.IO | ✅ 已完成 |
|
| 2 | 后端:Prisma schema + 迁移 + 种子数据 + JWT 认证 + RBAC + 模块化 REST API + Socket.IO | ✅ 已完成 |
|
||||||
| 3 | Web 管理端:登录、主布局、仪表盘、班级/学生/课表/作业/通知/成绩页面、Axios 封装、实时提示 | ✅ 已完成 |
|
| 3 | Web 管理端:登录、主布局、仪表盘、班级/学生/课表/作业/通知/成绩页面、Axios 封装、实时提示 | ✅ 已完成 |
|
||||||
| 4 | EXE 客户端:Electron 主进程/preload/渲染进程、登录、课表/作业/通知/成绩/设置、实时推送、IndexedDB 离线缓存 | ✅ 已完成(冒烟验证 62/62) |
|
| 4 | EXE 客户端:Electron 主进程/preload/渲染进程、登录、课表/作业/通知/成绩/设置、实时推送、IndexedDB 离线缓存 | ✅ 已完成(冒烟验证 65/65) |
|
||||||
| 5 | 三端联调脚本、打包命令、完整 README | ✅ 已完成(三套安装包 + 两套 UI 回归测试) |
|
| 5 | 三端联调脚本、打包命令、完整 README | ✅ 已完成(三套安装包 + 两套 UI 回归测试) |
|
||||||
| 6 | 测试账号与种子数据说明 | ✅ 已完成(见下文) |
|
| 6 | 测试账号与种子数据说明 | ✅ 已完成(见下文) |
|
||||||
| 7 | 生产化:服务端安装程序(内置 Node)、Web 端 PWA 可安装、Docker + Nginx 部署、生产加固与运维文档 | ✅ 已完成(见 [`docs/production.md`](docs/production.md)) |
|
| 7 | 生产化:服务端安装程序(内置 Node)、Web 端 PWA 可安装、Docker + Nginx 部署、生产加固与运维文档 | ✅ 已完成(见 [`docs/production.md`](docs/production.md)) |
|
||||||
@@ -173,13 +173,13 @@ pnpm verify:desktop
|
|||||||
# 另开一个终端先启动后端:pnpm dev:server
|
# 另开一个终端先启动后端:pnpm dev:server
|
||||||
pnpm verify:e2e # 后端 + REST + Socket.IO + RBAC + 上课时段拦截 + 紧急/普通叫人 + 导入 + 班级账号:136 项
|
pnpm verify:e2e # 后端 + REST + Socket.IO + RBAC + 上课时段拦截 + 紧急/普通叫人 + 导入 + 班级账号:136 项
|
||||||
pnpm verify:web # Web 管理端真实点击(含权限入口隐藏、手机适配、叫人入口 + 紧急/普通级别、紧急通知 + 成绩/时间配置导入弹窗):20 项
|
pnpm verify:web # Web 管理端真实点击(含权限入口隐藏、手机适配、叫人入口 + 紧急/普通级别、紧急通知 + 成绩/时间配置导入弹窗):20 项
|
||||||
pnpm verify:desktop # Electron 客户端(含灵动岛收起常驻/再次展开、圆角四角一致、开合不震动、紧急与普通叫人、真实链路、课表时间轴、个性化 10 项参数与托盘/退出无残留):62 项
|
pnpm verify:desktop # Electron 客户端(含灵动岛收起常驻/命中兜底/展开收起再展开、圆角四角一致、开合不震动、紧急与普通叫人、真实链路、课表时间轴、个性化 10 项参数与托盘/退出无残留):65 项
|
||||||
```
|
```
|
||||||
|
|
||||||
后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截、导入与失败回滚、班级账号代全班操作、老师端导入不越权**等 136 项,
|
后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截、导入与失败回滚、班级账号代全班操作、老师端导入不越权**等 136 项,
|
||||||
实测通知 37ms、作业 26ms、成绩 25ms 到达(要求 < 5 秒),紧急通知 409 拦截与二次确认后发布均通过;
|
实测通知 37ms、作业 26ms、成绩 25ms 到达(要求 < 5 秒),紧急通知 409 拦截与二次确认后发布均通过;
|
||||||
客户端脚本验证 preload 桥接、渲染进程、IndexedDB 读写、断网回退、**灵动岛四种状态切换**与联网集成
|
客户端脚本验证 preload 桥接、渲染进程、IndexedDB 读写、断网回退、**灵动岛四种状态切换**与联网集成
|
||||||
(**班级账号登录** + 四类数据 + Socket.IO),实测 62/62 通过;退出后再启动一次校验单实例锁与文件锁已释放(离线复跑 56/56)。
|
(**班级账号登录** + 四类数据 + Socket.IO),实测 65/65 通过;退出后再启动一次校验单实例锁与文件锁已释放(离线复跑 59/59)。
|
||||||
|
|
||||||
## 默认账号与种子数据
|
## 默认账号与种子数据
|
||||||
|
|
||||||
@@ -208,30 +208,30 @@ pnpm verify:desktop # Electron 客户端(含灵动岛收起常驻/再次
|
|||||||
|
|
||||||
## 常用命令
|
## 常用命令
|
||||||
|
|
||||||
| 命令 | 说明 |
|
| 命令 | 说明 |
|
||||||
| ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `pnpm dev` | 并行启动 shared(tsc watch) + 后端 + Web 端 |
|
| `pnpm dev` | 并行启动 shared(tsc watch) + 后端 + Web 端 |
|
||||||
| `pnpm dev:server` / `pnpm dev:web` | 只启动后端 / 只启动 Web 端 |
|
| `pnpm dev:server` / `pnpm dev:web` | 只启动后端 / 只启动 Web 端 |
|
||||||
| `pnpm dev:desktop` | 启动 EXE 客户端开发模式(Vite 5174 + Electron,热更新) |
|
| `pnpm dev:desktop` | 启动 EXE 客户端开发模式(Vite 5174 + Electron,热更新) |
|
||||||
| `pnpm build` | 构建 shared + 后端 + Web 端 + EXE 客户端 |
|
| `pnpm build` | 构建 shared + 后端 + Web 端 + EXE 客户端 |
|
||||||
| `pnpm build:desktop` | 仅构建 EXE 客户端(esbuild 主进程/preload + Vite 渲染进程) |
|
| `pnpm build:desktop` | 仅构建 EXE 客户端(esbuild 主进程/preload + Vite 渲染进程) |
|
||||||
| `pnpm icons` | 生成应用图标(PNG/ICO,用 Electron 渲染 SVG) |
|
| `pnpm icons` | 生成应用图标(PNG/ICO,用 Electron 渲染 SVG) |
|
||||||
| **`pnpm dist:server`** | **打包服务端 + Web 管理端**(免安装目录 + NSIS 安装程序,内置 Node) |
|
| **`pnpm dist:server`** | **打包服务端 + Web 管理端**(免安装目录 + NSIS 安装程序,内置 Node) |
|
||||||
| `pnpm dist:dir` | 打包客户端免安装目录 `release/win-unpacked`(含可执行文件,最快) |
|
| `pnpm dist:dir` | 打包客户端免安装目录 `release/win-unpacked`(含可执行文件,最快) |
|
||||||
| `pnpm dist:win` | 打包客户端 nsis 安装包 + portable 单文件 EXE |
|
| `pnpm dist:win` | 打包客户端 nsis 安装包 + portable 单文件 EXE |
|
||||||
| `pnpm dist:all` | 服务端安装程序 + 客户端安装程序一起打 |
|
| `pnpm dist:all` | 服务端安装程序 + 客户端安装程序一起打 |
|
||||||
| `pnpm verify:e2e` | 后端端到端验收(136 项,含角色权限矩阵、导入与班级账号,需服务端已启动) |
|
| `pnpm verify:e2e` | 后端端到端验收(136 项,含角色权限矩阵、导入与班级账号,需服务端已启动) |
|
||||||
| `pnpm verify:web` | Web 管理端 UI 真实点击测试(Electron 驱动,20 项,含权限入口隐藏、叫人紧急/普通与导入弹窗) |
|
| `pnpm verify:web` | Web 管理端 UI 真实点击测试(Electron 驱动,20 项,含权限入口隐藏、叫人紧急/普通与导入弹窗) |
|
||||||
| `pnpm verify:desktop` | EXE 客户端冒烟验证(62 项,含灵动岛圆角/收起常驻与再次展开/开合不震动/已读/作业/紧急与普通叫人、课表时间轴、个性化全参数) |
|
| `pnpm verify:desktop` | EXE 客户端冒烟验证(65 项,含灵动岛圆角/收起常驻/命中兜底/展开收起再展开/开合不震动/已读/作业/紧急与普通叫人、课表时间轴、个性化全参数) |
|
||||||
| `pnpm typecheck` | 全仓库类型检查(含 `vue-tsc`) |
|
| `pnpm typecheck` | 全仓库类型检查(含 `vue-tsc`) |
|
||||||
| `pnpm lint` / `pnpm lint:fix` | ESLint 检查 / 自动修复 |
|
| `pnpm lint` / `pnpm lint:fix` | ESLint 检查 / 自动修复 |
|
||||||
| `pnpm format` / `pnpm format:check` | Prettier 格式化 / 检查 |
|
| `pnpm format` / `pnpm format:check` | Prettier 格式化 / 检查 |
|
||||||
| `pnpm db:generate` | 生成 Prisma Client(输出到 `packages/server/src/generated/prisma`) |
|
| `pnpm db:generate` | 生成 Prisma Client(输出到 `packages/server/src/generated/prisma`) |
|
||||||
| `pnpm db:migrate` | 创建并应用迁移 |
|
| `pnpm db:migrate` | 创建并应用迁移 |
|
||||||
| `pnpm db:seed` | 写入种子数据 |
|
| `pnpm db:seed` | 写入种子数据 |
|
||||||
| `pnpm db:reset` | 重置数据库并重新执行 seed |
|
| `pnpm db:reset` | 重置数据库并重新执行 seed |
|
||||||
| `pnpm db:studio` | 打开 Prisma Studio |
|
| `pnpm db:studio` | 打开 Prisma Studio |
|
||||||
| `pnpm db:switch:mysql` / `db:switch:sqlite` | 切换数据库 provider |
|
| `pnpm db:switch:mysql` / `db:switch:sqlite` | 切换数据库 provider |
|
||||||
|
|
||||||
## Web 管理端(含手机小屏适配)
|
## Web 管理端(含手机小屏适配)
|
||||||
|
|
||||||
@@ -348,6 +348,13 @@ pnpm verify:desktop # Electron 客户端(含灵动岛收起常驻/再次
|
|||||||
> (dt 以帧为单位;换向只保留 35% 动量、单帧位移不超过剩余距离 20%),并额外加**零过冲钳制**(越过目标立即吸附)。
|
> (dt 以帧为单位;换向只保留 35% 动量、单帧位移不超过剩余距离 20%),并额外加**零过冲钳制**(越过目标立即吸附)。
|
||||||
> 鼠标命中照搬 `set_cursor_hittest`:窗口默认 `setIgnoreMouseEvents(true, { forward: true })` 整块穿透,
|
> 鼠标命中照搬 `set_cursor_hittest`:窗口默认 `setIgnoreMouseEvents(true, { forward: true })` 整块穿透,
|
||||||
> 渲染层做命中测试后只在指针进入岛体时打开命中——**大窗口不会吞掉桌面点击**(有专项断言)。
|
> 渲染层做命中测试后只在指针进入岛体时打开命中——**大窗口不会吞掉桌面点击**(有专项断言)。
|
||||||
|
>
|
||||||
|
> **双保险**:Windows 下 `forward: true` 的 mousemove 转发并不总是可靠,"展开 → 收起"后一旦转发丢失,
|
||||||
|
> 窗口会永远停在穿透状态(用户反馈的"点开再收起就再也点不开")。因此渲染进程会把**岛体矩形**上报主进程
|
||||||
|
> (`island:set-hit-rect`),主进程每 120ms 用 `screen.getCursorScreenPoint()` 与矩形比对校正命中——
|
||||||
|
> 命中不再依赖"必须先收到一次 mousemove"。冒烟通过 `setHitTestCursor()` 注入光标位置做确定性断言
|
||||||
|
> (细缝上可点 / 移开即穿透 / 展开收起再展开)。
|
||||||
|
>
|
||||||
> 由于窗口固定,Windows 那条"透明窗口最小高度约 36px"的限制不再作用于岛:空闲细缝可以真正做到 6px 宽。
|
> 由于窗口固定,Windows 那条"透明窗口最小高度约 36px"的限制不再作用于岛:空闲细缝可以真正做到 6px 宽。
|
||||||
|
|
||||||
| 场景 | 灵动岛行为 |
|
| 场景 | 灵动岛行为 |
|
||||||
@@ -550,7 +557,9 @@ pnpm dist:win # nsis 安装包 + portable 单文件(需联网下载 NSIS
|
|||||||
| **WinIsland 架构:窗口恒定 + 形状正确** | ✅ 逐帧窗口签名恒为 `476x282@482,-2`(只有 1 种);SVG 形状 bbox 与卡片框一致(±2px);**路径绕行 \|winding\| ≈ 2π**(凸、不自交,可抓出"角上往回折") |
|
| **WinIsland 架构:窗口恒定 + 形状正确** | ✅ 逐帧窗口签名恒为 `476x282@482,-2`(只有 1 种);SVG 形状 bbox 与卡片框一致(±2px);**路径绕行 \|winding\| ≈ 2π**(凸、不自交,可抓出"角上往回折") |
|
||||||
| **岛外鼠标穿透** | ✅ 指针在岛内 `interactive=true`、在岛外 `interactive=false`(固定大包围盒窗口不吞桌面点击) |
|
| **岛外鼠标穿透** | ✅ 指针在岛内 `interactive=true`、在岛外 `interactive=false`(固定大包围盒窗口不吞桌面点击) |
|
||||||
| **收起后胶囊常驻(可再次点开)** | ✅ 收起后等满 `TIMEOUTS.pill`(15s)超时:`mode=pill 可见=true`(有未处理通知就不消失);显式把命中置为穿透后,一次状态变化即恢复 `interactive=true`,点击后 `mode=expanded active=smoke-collapse-reopen` |
|
| **收起后胶囊常驻(可再次点开)** | ✅ 收起后等满 `TIMEOUTS.pill`(15s)超时:`mode=pill 可见=true`(有未处理通知就不消失);显式把命中置为穿透后,一次状态变化即恢复 `interactive=true`,点击后 `mode=expanded active=smoke-collapse-reopen` |
|
||||||
| **空闲细缝也可点开** | ✅ `mode=hidden 可见=true interactive=true 细缝=6x22`(细缝虽窄,指针在其上即可命中) |
|
| **空闲细缝也可点开** | ✅ 光标注入到细缝上 → `interactive=true`;移开后 `interactive=false`(`mode=hidden 可见=true 细缝=6x22`) |
|
||||||
|
| **展开 → 收起 → 再次展开(回归)** | ✅ 真实点击往返:`expanded(命中框 424x230)→ pill(命中框 268x44)→ expanded`(此前第二步之后会卡在穿透状态,点了没反应) |
|
||||||
|
| **命中兜底随形态更新** | ✅ 岛体矩形随形态上报主进程(展开 424x230 → 收起 268x44);彻底隐藏时撤销命中框(`mode=hidden 命中框=无`),保证看不见的窗口不吞桌面点击 |
|
||||||
| **紧急叫人(URGENT + 叫人)** | ✅ 上课中也立即展开:`mode=expanded kind=call reason=call 徽标=叫人 按钮=收到` |
|
| **紧急叫人(URGENT + 叫人)** | ✅ 上课中也立即展开:`mode=expanded kind=call reason=call 徽标=叫人 按钮=收到` |
|
||||||
| **普通叫人(HIGH + 叫人)** | ✅ 上课只进队列:`mode=hidden active≠smoke-call-normal queued=[…smoke-call-normal]`;下课后自动弹出 `active=smoke-call-normal kind=call` |
|
| **普通叫人(HIGH + 叫人)** | ✅ 上课只进队列:`mode=hidden active≠smoke-call-normal queued=[…smoke-call-normal]`;下课后自动弹出 `active=smoke-call-normal kind=call` |
|
||||||
| **WinIsland 排版:字号即时生效** | ✅ 13px → 19px:胶囊标题 `11.96→17.48`、胶囊副题 `9.62→14.06`、标题 `14.04→20.52`、正文 `12.35→18.05`、按钮 `9.88→14.44`,**倍率全部 1.462 = 19/13** |
|
| **WinIsland 排版:字号即时生效** | ✅ 13px → 19px:胶囊标题 `11.96→17.48`、胶囊副题 `9.62→14.06`、标题 `14.04→20.52`、正文 `12.35→18.05`、按钮 `9.88→14.44`,**倍率全部 1.462 = 19/13** |
|
||||||
@@ -565,7 +574,7 @@ pnpm dist:win # nsis 安装包 + portable 单文件(需联网下载 NSIS
|
|||||||
| **退出后无残留(文件锁 + 二次启动)** | ✅ 配置目录可删除(句柄已释放)→ 用同一配置目录二次启动成功并再次全绿 |
|
| **退出后无残留(文件锁 + 二次启动)** | ✅ 配置目录可删除(句柄已释放)→ 用同一配置目录二次启动成功并再次全绿 |
|
||||||
|
|
||||||
该验证对**开发产物与打包后的 EXE 都适用**(打包后用 `ELECTRON_SMOKE_RESULT=<file>` 写出 JSON 结果,
|
该验证对**开发产物与打包后的 EXE 都适用**(打包后用 `ELECTRON_SMOKE_RESULT=<file>` 写出 JSON 结果,
|
||||||
已实测 `packaged: true`、62/62 通过、退出码 0)。灵动岛各状态的窗口截图会写到
|
已实测 `packaged: true`、65/65 通过、退出码 0)。灵动岛各状态的窗口截图会写到
|
||||||
`ISLAND_SHOTS_DIR`(默认 `.cache/island-shots/`,仓库内留档目录 `docs/screenshots/island/`),
|
`ISLAND_SHOTS_DIR`(默认 `.cache/island-shots/`,仓库内留档目录 `docs/screenshots/island/`),
|
||||||
并对尺寸、宽高比、绘制内容(不透明像素/颜色种类)、紧急态红色像素与外溢红光做断言。
|
并对尺寸、宽高比、绘制内容(不透明像素/颜色种类)、紧急态红色像素与外溢红光做断言。
|
||||||
冒烟进程使用独立 userData(`ELECTRON_SMOKE_PROFILE`),因此**用户开着客户端也能跑验证**。
|
冒烟进程使用独立 userData(`ELECTRON_SMOKE_PROFILE`),因此**用户开着客户端也能跑验证**。
|
||||||
@@ -932,14 +941,14 @@ pnpm db:migrate && pnpm db:seed
|
|||||||
|
|
||||||
当前实测:
|
当前实测:
|
||||||
|
|
||||||
| 验证 | 结果 |
|
| 验证 | 结果 |
|
||||||
| ------------------------------------------------------------------------------------------------------------------ | ----------------------------------- |
|
| ------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- |
|
||||||
| `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **133/133** ✅ |
|
| `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **133/133** ✅ |
|
||||||
| `pnpm verify:web`(Web 管理端真实点击 + 权限入口隐藏 + 手机适配 + 叫人 + 成绩/时间配置导入 + PWA) | **19/19** ✅ |
|
| `pnpm verify:web`(Web 管理端真实点击 + 权限入口隐藏 + 手机适配 + 叫人 + 成绩/时间配置导入 + PWA) | **19/19** ✅ |
|
||||||
| `pnpm verify:desktop`(客户端冒烟 + WinIsland 架构/连续圆角/收起常驻与再次展开 + 个性化全参数 + 托盘与退出无残留) | **62/62** ✅ |
|
| `pnpm verify:desktop`(客户端冒烟 + WinIsland 架构/连续圆角/收起常驻 + 命中兜底与"展开收起再展开" + 个性化全参数 + 托盘与退出无残留) | **65/65** ✅ |
|
||||||
| `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ |
|
| `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ |
|
||||||
| 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ |
|
| 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ |
|
||||||
| Docker / Nginx 部署样例 | 文件已提供,本机无 Docker 未实测 ⚠️ |
|
| Docker / Nginx 部署样例 | 文件已提供,本机无 Docker 未实测 ⚠️ |
|
||||||
|
|
||||||
## 常见问题(本机环境已知坑)
|
## 常见问题(本机环境已知坑)
|
||||||
|
|
||||||
|
|||||||
@@ -301,6 +301,19 @@ const islandClass = computed(() => ({
|
|||||||
let lastInteractive: boolean | null = null;
|
let lastInteractive: boolean | null = null;
|
||||||
/** 最近一次已知的指针位置:岛收起/变大后用它重算命中,避免"鼠标没动就点不到" */
|
/** 最近一次已知的指针位置:岛收起/变大后用它重算命中,避免"鼠标没动就点不到" */
|
||||||
let lastPointer: { x: number; y: number } | 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 {
|
function updateInteractive(clientX: number, clientY: number): void {
|
||||||
lastPointer = { x: clientX, y: clientY };
|
lastPointer = { x: clientX, y: clientY };
|
||||||
@@ -327,9 +340,14 @@ function updateInteractive(clientX: number, clientY: number): void {
|
|||||||
* 只有在收到 mousemove 时才会打开命中。岛"收起 / 展开 / 换形态"时几何变了,
|
* 只有在收到 mousemove 时才会打开命中。岛"收起 / 展开 / 换形态"时几何变了,
|
||||||
* 但指针可能一动没动(没有新的 mousemove)—— 此时窗口会一直保持"穿透",
|
* 但指针可能一动没动(没有新的 mousemove)—— 此时窗口会一直保持"穿透",
|
||||||
* 表现就是**收起后怎么点都打不开**(用户反馈)。所以每次状态变化都用缓存的指针位置重算一次。
|
* 表现就是**收起后怎么点都打不开**(用户反馈)。所以每次状态变化都用缓存的指针位置重算一次。
|
||||||
|
*
|
||||||
|
* 另外这里会把当前岛体矩形上报主进程(`setHitRect`):即使 Windows 下 mousemove 转发丢失,
|
||||||
|
* 主进程也能按光标位置把命中校正回来 —— 这是"点开→收起→再也点不开"的兜底修复。
|
||||||
*/
|
*/
|
||||||
function refreshInteractive(): void {
|
function refreshInteractive(): void {
|
||||||
|
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);
|
||||||
if (fullyHidden) {
|
if (fullyHidden) {
|
||||||
if (lastInteractive !== false) {
|
if (lastInteractive !== false) {
|
||||||
lastInteractive = false;
|
lastInteractive = false;
|
||||||
|
|||||||
@@ -125,6 +125,12 @@ class IslandController {
|
|||||||
private backgroundMaterial: string = 'none';
|
private backgroundMaterial: string = 'none';
|
||||||
/** 当前是否接收鼠标(固定大包围盒窗口默认穿透,由渲染进程按命中动态打开) */
|
/** 当前是否接收鼠标(固定大包围盒窗口默认穿透,由渲染进程按命中动态打开) */
|
||||||
private interactive = false;
|
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 = {
|
private state: IslandState = {
|
||||||
mode: 'hidden',
|
mode: 'hidden',
|
||||||
@@ -195,6 +201,8 @@ class IslandController {
|
|||||||
// 默认整块窗口不接收鼠标(避免大面积透明窗口吞掉桌面点击),
|
// 默认整块窗口不接收鼠标(避免大面积透明窗口吞掉桌面点击),
|
||||||
// 只有指针进入"岛"的可见区域时,渲染进程才通过 island:set-interactive 打开命中。
|
// 只有指针进入"岛"的可见区域时,渲染进程才通过 island:set-interactive 打开命中。
|
||||||
win.setIgnoreMouseEvents(true, { forward: true });
|
win.setIgnoreMouseEvents(true, { forward: true });
|
||||||
|
// 光标命中兜底轮询:即使 forward 的 mousemove 丢失,也能按光标位置自行校正命中
|
||||||
|
this.startHitPoll();
|
||||||
win.on('closed', () => {
|
win.on('closed', () => {
|
||||||
this.win = null;
|
this.win = null;
|
||||||
this.ready = false;
|
this.ready = false;
|
||||||
@@ -473,6 +481,7 @@ class IslandController {
|
|||||||
|
|
||||||
destroy(): void {
|
destroy(): void {
|
||||||
this.clearTimers();
|
this.clearTimers();
|
||||||
|
this.stopHitPoll();
|
||||||
this.cancelBoundsAnimation();
|
this.cancelBoundsAnimation();
|
||||||
if (this.win && !this.win.isDestroyed()) this.win.destroy();
|
if (this.win && !this.win.isDestroyed()) this.win.destroy();
|
||||||
this.win = null;
|
this.win = null;
|
||||||
@@ -811,6 +820,11 @@ class IslandController {
|
|||||||
* 鼠标命中开关(照搬 WinIsland 的 `set_cursor_hittest`):
|
* 鼠标命中开关(照搬 WinIsland 的 `set_cursor_hittest`):
|
||||||
* 窗口是固定大包围盒,若不控制命中就会把整片桌面的点击都吃掉。
|
* 窗口是固定大包围盒,若不控制命中就会把整片桌面的点击都吃掉。
|
||||||
* 渲染进程做命中测试(指针是否在"岛"的可见区域/可交互控件内),再回调这里切换。
|
* 渲染进程做命中测试(指针是否在"岛"的可见区域/可交互控件内),再回调这里切换。
|
||||||
|
*
|
||||||
|
* 另外主进程还有一路**兜底**:`syncHitFromCursor()` 每 120ms 直接读光标坐标与渲染进程
|
||||||
|
* 上报的岛体矩形比对。原因是 Windows 下 `forward: true` 的 mousemove 转发并不总是可靠,
|
||||||
|
* 一旦"展开 → 收起"后转发丢失,窗口会永远停在穿透状态 —— 用户看到的就是
|
||||||
|
* "点开了再收起,就再也点不开了"。
|
||||||
*/
|
*/
|
||||||
setInteractive(interactive: boolean): void {
|
setInteractive(interactive: boolean): void {
|
||||||
if (this.interactive === interactive) return;
|
if (this.interactive === interactive) return;
|
||||||
@@ -821,6 +835,66 @@ class IslandController {
|
|||||||
this.win.setIgnoreMouseEvents(!interactive, { forward: true });
|
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 {
|
getInteractive(): boolean {
|
||||||
return this.interactive;
|
return this.interactive;
|
||||||
@@ -970,6 +1044,14 @@ export function registerIslandIpc(): void {
|
|||||||
island.setInteractive(interactive === true);
|
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) => {
|
ipcMain.on('island:action', (_event, payload: IslandActionPayload) => {
|
||||||
if (!payload?.action) return;
|
if (!payload?.action) return;
|
||||||
island.handleAction(payload);
|
island.handleAction(payload);
|
||||||
|
|||||||
@@ -977,6 +977,24 @@ 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(
|
||||||
@@ -1015,13 +1033,14 @@ async function runIslandChecks(
|
|||||||
collapseBefore.mode === 'pill' && lingered.mode === 'pill' && lingeredVisible,
|
collapseBefore.mode === 'pill' && lingered.mode === 'pill' && lingeredVisible,
|
||||||
`收起前=${collapseBefore.mode} 超时后=${lingered.mode} 可见=${lingeredVisible}`,
|
`收起前=${collapseBefore.mode} 超时后=${lingered.mode} 可见=${lingeredVisible}`,
|
||||||
);
|
);
|
||||||
// 命中:把窗口显式置为"穿透"(等价于收起瞬间窗口被重置),再做一次状态变化 ——
|
// 命中兜底:把窗口显式置为"穿透",再把光标位置注入到胶囊中心 ——
|
||||||
// 主进程会按当前形态重新下发命中,渲染进程也会按缓存指针重算,两者都必须把命中恢复。
|
// 主进程每 120ms 按光标校正一次命中,必须自己恢复(这正是"点开再收起后点不开"的兜底修复)。
|
||||||
island.setInteractive(false);
|
island.setInteractive(false);
|
||||||
await sleep(60);
|
await sleep(60);
|
||||||
const forcedThrough = island.getInteractive();
|
const forcedThrough = island.getInteractive();
|
||||||
island.handleAction({ action: 'collapse' });
|
const pillPoint = islandCardScreenPoint(0.5, 0.5);
|
||||||
await sleep(300);
|
island.setHitTestCursor(pillPoint);
|
||||||
|
await sleep(260);
|
||||||
const interactiveWhilePill = island.getInteractive();
|
const interactiveWhilePill = island.getInteractive();
|
||||||
await islandWindow?.webContents
|
await islandWindow?.webContents
|
||||||
.executeJavaScript(
|
.executeJavaScript(
|
||||||
@@ -1039,7 +1058,7 @@ async function runIslandChecks(
|
|||||||
interactiveWhilePill === true &&
|
interactiveWhilePill === true &&
|
||||||
reopened.mode === 'expanded' &&
|
reopened.mode === 'expanded' &&
|
||||||
reopened.active?.id === 'smoke-collapse-reopen',
|
reopened.active?.id === 'smoke-collapse-reopen',
|
||||||
`置穿透后=${forcedThrough} 状态变化后=${interactiveWhilePill} 点击后 mode=${reopened.mode} ` +
|
`置穿透后=${forcedThrough} 光标在胶囊上时=${interactiveWhilePill} 点击后 mode=${reopened.mode} ` +
|
||||||
`active=${reopened.active?.id ?? '-'}`,
|
`active=${reopened.active?.id ?? '-'}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1049,20 +1068,100 @@ async function runIslandChecks(
|
|||||||
await drainForCollapseCheck();
|
await drainForCollapseCheck();
|
||||||
await sleep(500);
|
await sleep(500);
|
||||||
const sliverState = island.getState();
|
const sliverState = island.getState();
|
||||||
await hoverCard();
|
island.setHitTestCursor(islandCardScreenPoint(0.5, 0.5));
|
||||||
await sleep(250);
|
await sleep(260);
|
||||||
const sliverInteractive = island.getInteractive();
|
const sliverInteractive = island.getInteractive();
|
||||||
const sliverVisible = islandWindow?.isVisible() ?? false;
|
const sliverVisible = islandWindow?.isVisible() ?? false;
|
||||||
|
// 光标移开细缝(屏幕左上角)→ 命中必须撤销,避免细缝窗口吞掉桌面点击
|
||||||
|
island.setHitTestCursor({ x: 1, y: 1 });
|
||||||
|
await sleep(260);
|
||||||
|
const sliverInteractiveOutside = island.getInteractive();
|
||||||
record(
|
record(
|
||||||
'空闲细缝态保持可交互(细缝很窄,指针停在岛上仍能点开)',
|
'空闲细缝态命中跟随光标(细缝上可点、移开即穿透)',
|
||||||
sliverState.mode === 'hidden' && sliverVisible && sliverInteractive === true,
|
sliverState.mode === 'hidden' &&
|
||||||
`mode=${sliverState.mode} 可见=${sliverVisible} interactive=${sliverInteractive} ` +
|
sliverVisible &&
|
||||||
`细缝=${sliverForInteractive.width}x${sliverForInteractive.height}`,
|
sliverInteractive === true &&
|
||||||
|
sliverInteractiveOutside === false,
|
||||||
|
`mode=${sliverState.mode} 可见=${sliverVisible} 光标在细缝上=${sliverInteractive} ` +
|
||||||
|
`光标移开=${sliverInteractiveOutside} 细缝=${sliverForInteractive.width}x${sliverForInteractive.height}`,
|
||||||
);
|
);
|
||||||
|
island.setHitTestCursor(null);
|
||||||
island.setAppearance({ ...island.getAppearance(), idleSliver: false });
|
island.setAppearance({ ...island.getAppearance(), idleSliver: false });
|
||||||
await drainForCollapseCheck();
|
await drainForCollapseCheck();
|
||||||
await sleep(300);
|
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) 截图留档的像素级断言:每张图必须有实际绘制内容、颜色丰富,且宽高比与
|
// 5) 截图留档的像素级断言:每张图必须有实际绘制内容、颜色丰富,且宽高比与
|
||||||
// 状态机配置的窗口尺寸一致(DPI 无关),紧急形态还必须出现红色描边/内部光晕像素。
|
// 状态机配置的窗口尺寸一致(DPI 无关),紧急形态还必须出现红色描边/内部光晕像素。
|
||||||
const shotDetails = islandShots.map((shot) => {
|
const shotDetails = islandShots.map((shot) => {
|
||||||
@@ -1524,6 +1623,9 @@ async function runIslandChecks(
|
|||||||
)
|
)
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
await sleep(180);
|
await sleep(180);
|
||||||
|
// 兜底轮询读的是主进程光标坐标:注入到岛体中心,等价于"指针停在岛内"
|
||||||
|
island.setHitTestCursor(islandCardScreenPoint(0.5, 0.5));
|
||||||
|
await sleep(260);
|
||||||
const interactiveInside = island.getInteractive();
|
const interactiveInside = island.getInteractive();
|
||||||
await islandWindow?.webContents
|
await islandWindow?.webContents
|
||||||
.executeJavaScript(
|
.executeJavaScript(
|
||||||
@@ -1533,8 +1635,11 @@ async function runIslandChecks(
|
|||||||
})()`,
|
})()`,
|
||||||
)
|
)
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
await sleep(180);
|
// 光标的权威判定在主进程(兜底轮询):这里把光标注入到屏幕左上角,等价于"指针离开岛体"
|
||||||
|
island.setHitTestCursor({ x: 1, y: 1 });
|
||||||
|
await sleep(260);
|
||||||
const interactiveOutside = island.getInteractive();
|
const interactiveOutside = island.getInteractive();
|
||||||
|
island.setHitTestCursor(null);
|
||||||
record(
|
record(
|
||||||
'岛外鼠标穿透(固定大窗口不吞桌面点击)',
|
'岛外鼠标穿透(固定大窗口不吞桌面点击)',
|
||||||
interactiveInside === true && interactiveOutside === false,
|
interactiveInside === true && interactiveOutside === false,
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export interface IslandBridge {
|
|||||||
getAppearance(): Promise<IslandAppearance>;
|
getAppearance(): Promise<IslandAppearance>;
|
||||||
/** 命中测试结果:指针是否在岛体上(决定窗口是否接收鼠标) */
|
/** 命中测试结果:指针是否在岛体上(决定窗口是否接收鼠标) */
|
||||||
setInteractive(interactive: boolean): void;
|
setInteractive(interactive: boolean): void;
|
||||||
|
/** 上报岛体矩形(窗口内 CSS px):主进程据此按光标位置兜底校正命中 */
|
||||||
|
setHitRect(rect: { x: number; y: number; width: number; height: number } | null): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bridge: IslandBridge = {
|
const bridge: IslandBridge = {
|
||||||
@@ -31,6 +33,9 @@ const bridge: IslandBridge = {
|
|||||||
setInteractive: (interactive) => {
|
setInteractive: (interactive) => {
|
||||||
ipcRenderer.send('island:set-interactive', interactive);
|
ipcRenderer.send('island:set-interactive', interactive);
|
||||||
},
|
},
|
||||||
|
setHitRect: (rect) => {
|
||||||
|
ipcRenderer.send('island:set-hit-rect', rect);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('island', bridge);
|
contextBridge.exposeInMainWorld('island', bridge);
|
||||||
@@ -75,6 +75,8 @@ export interface IslandRendererBridge {
|
|||||||
getState(): Promise<IslandState>;
|
getState(): Promise<IslandState>;
|
||||||
/** 命中测试结果:指针是否在岛体上(决定固定大窗口是否接收鼠标) */
|
/** 命中测试结果:指针是否在岛体上(决定固定大窗口是否接收鼠标) */
|
||||||
setInteractive(interactive: boolean): void;
|
setInteractive(interactive: boolean): void;
|
||||||
|
/** 上报岛体矩形(窗口内 CSS px):主进程据此按光标位置兜底校正命中 */
|
||||||
|
setHitRect(rect: { x: number; y: number; width: number; height: number } | null): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
Reference in new issue
Block a user