diff --git a/plugins/spotlight/.gitignore b/plugins/spotlight/.gitignore new file mode 100644 index 00000000..03de1200 --- /dev/null +++ b/plugins/spotlight/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +.env +.env.* +.DS_Store +Thumbs.db +*.log diff --git a/plugins/spotlight/CHANGELOG.md b/plugins/spotlight/CHANGELOG.md new file mode 100644 index 00000000..6ac32560 --- /dev/null +++ b/plugins/spotlight/CHANGELOG.md @@ -0,0 +1,15 @@ +# Changelog + +## 1.0.0 + +### Added + +- 聚光灯核心效果:全屏变暗,圆形亮区跟随鼠标指针平滑移动(rAF 插值 + 合成器位移) +- 入场动画:黑幕从屏幕四角向鼠标位置收拢,退出时淡出 +- 多显示器支持:每块屏幕独立遮罩窗口,光圈跨屏平滑跟随,兼容不同分辨率 / DPI 缩放 +- 设置页:光圈大小(60–600px)、背景暗度(30%–95%)、边缘柔化(0–150px)实时预览调节 +- 开关项:点击屏幕任意处退出、多屏覆盖 +- 快捷键引导:一键跳转 ZTools 全局快捷键设置页(redirectHotKeySetting) +- 退出方式:Esc / 点击 / 切换窗口(Alt+Tab)自动退出,多窗口 localStorage 令牌联动 +- 显示器诊断:设置页展示检测到的屏幕数量、分辨率、坐标与缩放比 +- macOS 适配:遮罩窗口在所有桌面 Space 及全屏 App 之上可见 diff --git a/plugins/spotlight/README.md b/plugins/spotlight/README.md new file mode 100644 index 00000000..04b6a263 --- /dev/null +++ b/plugins/spotlight/README.md @@ -0,0 +1,77 @@ +# 聚光灯 (Spotlight) + +一款 ZTools 插件:按下快捷键,全屏瞬间变暗,只有鼠标指针周围的一圈保持明亮,并跟随鼠标平滑移动。适用于演示讲解、录屏教学、远程协作时指示光标位置,让观众的视线聚焦在你想表达的地方。 + +## 功能特性 + +- 🔦 **一键聚光**:全屏变暗,圆形光圈实时跟随鼠标指针 +- 🌊 **平滑跟随**:基于 `requestAnimationFrame` 的插值动画,光圈移动顺滑无卡顿 +- 🖥️ **多显示器支持**:遮罩窗口精确覆盖鼠标所在的屏幕 +- ⚙️ **可视化设置页**:光圈大小、背景暗度、边缘柔化自由调节,内置实时预览 +- ⌨️ **自定义快捷键**:设置页录制组合键,配合 ZTools 全局快捷键全局唤起 +- 💾 **设置自动保存**:所有配置通过 `dbStorage` 持久化,即时生效 + +## 安装 + +1. 下载或克隆本仓库 +2. 打开 ZTools → 插件中心 → 开发者模式(本地插件) +3. 添加本插件所在目录(包含 `plugin.json` 的目录) + +## 使用 + +| 操作 | 方式 | +| --- | --- | +| 开启聚光灯 | 搜索框输入 `聚光灯` / `spotlight` / `jgd`,或使用绑定的全局快捷键 | +| 打开设置页 | 搜索框输入 `聚光灯设置` / `聚光灯配置` | +| 退出聚光灯 | 按 `Esc`、点击屏幕任意处(可在设置中关闭)、或切换窗口(Alt+Tab) | + +### 绑定全局快捷键 + +点击设置页的「去绑定全局快捷键」按钮(自动跳转 ZTools 快捷键设置页),为本插件的「聚光灯」功能录入组合键(如 `Ctrl + Alt + S`)即可。 + +## 设置项 + +| 设置 | 范围 | 默认值 | 说明 | +| --- | --- | --- | --- | +| 光圈大小 | 60 – 600 px | 180 px | 亮圆区域的半径 | +| 背景暗度 | 30% – 95% | 75% | 光圈之外区域的压暗程度 | +| 边缘柔化 | 0 – 150 px | 60 px | 光圈边缘的羽化过渡宽度 | +| 点击退出 | 开 / 关 | 开 | 开启后点击屏幕任意处退出聚光灯 | +| 多屏覆盖 | 开 / 关 | 开 | 开启后黑幕横跨所有显示器,光圈跨屏跟随鼠标 | + +## 技术实现 + +- 纯 HTML / CSS / JavaScript,无构建步骤,无第三方依赖 +- 遮罩窗口由 `ztools.createBrowserWindow` 创建:全屏、透明、无边框、置顶、跳过任务栏 +- 多屏支持:每块显示器创建一个独立遮罩窗口,各自精确覆盖自身 bounds(规避混合 DPI / 不同分辨率下单窗口跨屏被系统裁剪的问题);窗口间通过 localStorage 会话令牌联动退出,通过焦点心跳识别用户切走 +- 光圈渲染:圆形元素 + 超大扩散 `box-shadow`(`0 0 {柔化}px 300vmax rgba(0,0,0,{暗度})`),圆外遮暗、圆内透亮 +- 位置跟踪:遮罩窗口 `mousemove` 事件为主通道,`ztools.getCursorScreenPoint()` 轮询为兜底 +- 平滑算法:每帧 `pos += (target - pos) × 0.3` 线性插值,位移仅更新 `transform`,由浏览器合成器处理 + +## 文件结构 + +``` +spotlight/ +├── plugin.json 插件配置(功能入口定义) +├── index.html 设置页面(UI) +├── app.js 主页面逻辑:设置存取 + 聚光灯激活控制 +├── overlay.html 全屏遮罩窗口页面 +├── overlay.js 遮罩窗口逻辑:光圈渲染 + 平滑跟随 + 退出 +├── preload.js preload 脚本(补充原生信息) +└── logo.png 插件图标 +``` + +## 发布 + +本插件符合 ZTools 插件规范(preload 未压缩混淆)。发布流程: + +```bash +git init && git add . && git commit -m "Add plugin spotlight v1.0.0" +ztools publish +``` + +详见 [ZTools 开发者文档](https://ztoolscenter.github.io/ZTools-doc/getting-started.html)。 + +## License + +MIT diff --git a/plugins/spotlight/app.js b/plugins/spotlight/app.js new file mode 100644 index 00000000..7062a1e1 --- /dev/null +++ b/plugins/spotlight/app.js @@ -0,0 +1,427 @@ +/** + * 聚光灯插件 - 主页面逻辑(设置页 + 聚光灯激活控制) + * + * 两个功能入口(plugin.json features): + * - spotlight → 直接开启聚光灯(隐藏主窗口,创建全屏遮罩窗口) + * - spotlight-settings → 打开本设置页 + */ + +(function () { + 'use strict'; + + var SETTINGS_KEY = 'spotlight-settings'; + + var DEFAULTS = { + radius: 180, // 光圈半径 px + opacity: 75, // 背景暗度 % + softness: 60, // 边缘柔化 px + clickExit: true, // 点击退出 + allScreens: true // 多屏覆盖(每块屏幕一个遮罩窗口) + }; + + var spotWins = []; // 当前聚光灯遮罩窗口(多屏时每块屏幕一个) + var monitorTimer = null; // 遮罩窗口存活监视器 + var lastActivateAt = 0; // 上次激活时间(用于去重,onPluginEnter/onPluginReady 可能重复触发) + + /* ---------------- 设置存取 ---------------- */ + + function loadSettings() { + var saved = null; + try { + saved = window.ztools && window.ztools.dbStorage.getItem(SETTINGS_KEY); + } catch (e) { /* ignore */ } + var s = {}; + for (var k in DEFAULTS) s[k] = DEFAULTS[k]; + if (saved && typeof saved === 'object') { + for (var k2 in DEFAULTS) { + if (saved[k2] !== undefined) s[k2] = saved[k2]; + } + } + return s; + } + + function saveSettings(s) { + try { + window.ztools && window.ztools.dbStorage.setItem(SETTINGS_KEY, s); + } catch (e) { /* ignore */ } + } + + /* ---------------- 开启聚光灯 ---------------- */ + + function activateSpotlight() { + if (!window.ztools) return; + + // 去重:onPluginEnter / onPluginReady 可能先后触发同一进入事件 + var now = Date.now(); + if (now - lastActivateAt < 800) return; + lastActivateAt = now; + + var s = loadSettings(); + + // 已开启则先关闭旧的(重复按快捷键 = 重新开启/刷新) + if (spotWins.length) { + destroyAllOverlays(); + } + + // 取鼠标当前位置,找到所在显示器 + var point = null; + try { point = window.ztools.getCursorScreenPoint(); } catch (e) {} + var display = null; + try { + display = point + ? window.ztools.getDisplayNearestPoint(point) + : window.ztools.getPrimaryDisplay(); + } catch (e) {} + var primaryBounds = (display && display.bounds) || { x: 0, y: 0, width: screen.width, height: screen.height }; + + // 确定要覆盖的显示器列表: + // - 多屏覆盖开启 → 所有显示器(每块屏幕一个独立遮罩窗口,各自精确覆盖自身 bounds, + // 避免混合 DPI / 不同分辨率下单个大窗口被系统裁剪的问题) + // - 关闭 → 仅鼠标所在显示器 + var boundsList = []; + if (s.allScreens) { + try { + var displays = window.ztools.getAllDisplays(); + if (displays && displays.length) { + displays.forEach(function (d) { if (d && d.bounds) boundsList.push(d.bounds); }); + } + } catch (e) { /* 获取失败时退回单屏 */ } + } + if (!boundsList.length) boundsList.push(primaryBounds); + + // 本次会话 ID:多窗口之间通过 localStorage 令牌联动退出 + var sid = 's' + Date.now(); + + function createOverlays() { + var cursorWin = null; + var failCount = 0; + + boundsList.forEach(function (b) { + // 通过 URL query 把初始参数交给遮罩窗口(窗口坐标原点 + 设置项 + 会话 ID) + var isCursorScreen = point && + point.x >= b.x && point.x < b.x + b.width && + point.y >= b.y && point.y < b.y + b.height; + var q = 'ox=' + b.x + '&oy=' + b.y + + '&r=' + s.radius + '&o=' + s.opacity + '&soft=' + s.softness + + '&ce=' + (s.clickExit ? 1 : 0) + + '&sid=' + sid + + (isCursorScreen ? '&tip=1' : '') + + (point ? '&cx=' + point.x + '&cy=' + point.y : ''); + + var win = null; + try { + win = window.ztools.createBrowserWindow('overlay.html?' + q, { + x: b.x, + y: b.y, + width: b.width, + height: b.height, + frame: false, + transparent: true, + resizable: false, + movable: false, + minimizable: false, + maximizable: false, + fullscreenable: false, + alwaysOnTop: true, + skipTaskbar: true, + show: false, + backgroundColor: '#00000000', + webPreferences: { + preload: 'preload.js', + backgroundThrottling: false + } + }); + } catch (e) { win = null; } + + // createBrowserWindow 失败时可能返回 null 或 Error 对象 + if (!win || typeof win.show !== 'function') { + failCount++; + return; + } + spotWins.push(win); + + // 注意:不使用创建回调来显示窗口。 + // ZTools 主进程通过 window.ztools.__event__.createBrowserWindowCallback 单一全局槽位 + // 触发回调,连续创建多个窗口时回调会相互覆盖,只有最后一个生效。 + // 窗口背景是透明的,立即显示不会有视觉闪烁。 + try { + if (typeof win.showInactive === 'function') win.showInactive(); + else win.show(); + } catch (e) { try { win.show(); } catch (e2) {} } + try { win.setAlwaysOnTop(true, 'screen-saver'); } catch (e) {} + // macOS:让遮罩在所有桌面 Space 及全屏 App 之上可见(Windows 下为无操作) + try { + if (typeof win.setVisibleOnAllWorkspaces === 'function') { + win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); + } + } catch (e) {} + + if (isCursorScreen) cursorWin = win; + }); + + // 最后统一聚焦鼠标所在屏幕的窗口,确保 Esc 退出立即可用 + if (cursorWin) { + try { cursorWin.focus(); } catch (e) {} + } + + if (!spotWins.length) { + try { window.ztools.showNotification('聚光灯窗口创建失败'); } catch (e) {} + return; + } + if (failCount > 0) { + try { + window.ztools.showNotification('有 ' + failCount + ' 块屏幕的遮罩创建失败(共 ' + boundsList.length + ' 块)'); + } catch (e) {} + } + + // 监视遮罩窗口:任何一个被外部关闭时,兜底销毁其余窗口; + // 全部关闭后退出插件到后台——下次打开 ZTools 回到搜索页,而不是停留在本插件页面 + monitorTimer = setInterval(function () { + if (!spotWins.length) { clearInterval(monitorTimer); return; } + var alive = 0; + var anyGone = false; + spotWins.forEach(function (w) { + try { + if (w.isDestroyed()) anyGone = true; + else alive++; + } catch (e) { alive++; /* 代理不支持时视为存活 */ } + }); + if (anyGone && alive > 0) destroyAllOverlays(); + if (alive === 0 || anyGone) { + spotWins = []; + clearInterval(monitorTimer); + try { window.ztools.outPlugin(); } catch (e) {} + } + }, 500); + } + + // 关键顺序:先隐藏主窗口,再创建遮罩窗口。 + // 避免遮罩加载期间 ZTools 主窗口停留在屏幕上造成"先弹出再关闭"的闪烁感。 + var hideResult = null; + try { hideResult = window.ztools.hideMainWindow(); } catch (e) {} + if (hideResult && typeof hideResult.then === 'function') { + hideResult.then(createOverlays, createOverlays); + } else { + createOverlays(); + } + } + + function destroyAllOverlays() { + spotWins.forEach(function (w) { + try { w.destroy(); } catch (e) { try { w.close(); } catch (e2) {} } + }); + spotWins = []; + } + + /* ---------------- 进入插件 ---------------- */ + + function handleEnter(param) { + var code = param && param.code; + if (code === 'spotlight') { + document.body.classList.add('activating'); + activateSpotlight(); + } else { + // 设置页:务必移除 activating 隐藏类—— + // 插件页面在开启聚光灯后仍在后台存活,类名会残留导致设置页显示为空白 + document.body.classList.remove('activating'); + refreshDisplayInfo(); // 每次进入设置页刷新显示器诊断(函数声明会提升,此处可安全调用) + } + } + + if (window.ztools) { + window.ztools.onPluginEnter(handleEnter); + if (window.ztools.onPluginReady) window.ztools.onPluginReady(handleEnter); + } else { + // 非 ZTools 环境(如直接浏览器打开调试)也保证设置页可见 + document.body.classList.remove('activating'); + } + + // 聚光灯开启后插件页在后台存活,activating 隐藏类会残留; + // 当 ZTools 主窗口重新显示、本页面重新获得焦点/可见时,恢复设置页显示 + function restorePage() { + document.body.classList.remove('activating'); + } + window.addEventListener('focus', restorePage); + document.addEventListener('visibilitychange', function () { + if (!document.hidden) restorePage(); + }); + + /* ---------------- 设置页 UI ---------------- */ + + var settings = loadSettings(); + + var $ = function (id) { return document.getElementById(id); }; + var toastTimer = null; + function toast() { + var el = $('toast'); + el.classList.add('show'); + clearTimeout(toastTimer); + toastTimer = setTimeout(function () { el.classList.remove('show'); }, 900); + } + function persist() { + saveSettings(settings); + toast(); + } + + function paintRange(input) { + var p = (input.value - input.min) / (input.max - input.min) * 100; + input.style.setProperty('--p', p + '%'); + } + + // --- 滑杆 --- + function bindSlider(id, valId, key, fmt) { + var input = $(id), val = $(valId); + input.value = settings[key]; + val.textContent = fmt(settings[key]); + paintRange(input); + input.addEventListener('input', function () { + settings[key] = parseInt(input.value, 10); + val.textContent = fmt(settings[key]); + paintRange(input); + persist(); + }); + } + bindSlider('radius', 'radiusVal', 'radius', function (v) { return v + ' px'; }); + bindSlider('opacity', 'opacityVal', 'opacity', function (v) { return v + '%'; }); + bindSlider('softness', 'softnessVal', 'softness', function (v) { return v + ' px'; }); + + // --- 开关 --- + function bindSwitch(id, key) { + var el = $(id); + el.checked = !!settings[key]; + el.addEventListener('change', function () { + settings[key] = el.checked; + persist(); + }); + } + bindSwitch('clickExit', 'clickExit'); + bindSwitch('allScreens', 'allScreens'); + + // --- 立即开启 --- + $('testBtn').addEventListener('click', activateSpotlight); + + // --- 跳转 ZTools 全局快捷键设置 --- + $('bindBtn').addEventListener('click', function () { + try { + if (window.ztools && window.ztools.redirectHotKeySetting) { + window.ztools.redirectHotKeySetting('聚光灯', true); + } else { + window.ztools && window.ztools.showNotification('当前 ZTools 版本不支持自动跳转,请手动到 设置 → 全局快捷键 绑定'); + } + } catch (e) {} + }); + + // --- 显示器诊断 --- + function refreshDisplayInfo() { + var el = $('displayInfo'); + if (!el) return; + if (!window.ztools || !window.ztools.getAllDisplays) { + el.textContent = '当前环境不支持显示器检测'; + return; + } + try { + var displays = window.ztools.getAllDisplays() || []; + if (!displays.length) { + el.textContent = '未检测到任何显示器(getAllDisplays 返回空)'; + return; + } + var lines = displays.map(function (d, i) { + var b = d.bounds || {}; + return '屏 ' + (i + 1) + ':' + b.width + '×' + b.height + + ' @ (' + b.x + ', ' + b.y + ')' + + (d.scaleFactor ? ' · 缩放 ' + d.scaleFactor + 'x' : ''); + }); + el.textContent = '共检测到 ' + displays.length + ' 块显示器 — ' + lines.join(' | '); + } catch (e) { + el.textContent = '显示器检测出错:' + e.message; + } + } + refreshDisplayInfo(); + + /* ---------------- 实时预览 ---------------- */ + + var preview = $('preview'); + var pSpot = $('pSpot'); + var pTarget = { x: 160, y: 120 }; + var pPos = { x: 160, y: 120 }; + + function applyPreviewSpot() { + var r = settings.radius; + var op = settings.opacity / 100; + var soft = settings.softness; + pSpot.style.width = r * 2 + 'px'; + pSpot.style.height = r * 2 + 'px'; + // 大扩散 box-shadow 形成“圆形亮区 + 四周变暗”,blur 提供柔化边缘 + pSpot.style.boxShadow = '0 0 ' + soft + 'px 3000px rgba(0, 0, 0, ' + op + ')'; + } + + preview.addEventListener('mousemove', function (e) { + var rect = preview.getBoundingClientRect(); + pTarget.x = e.clientX - rect.left; + pTarget.y = e.clientY - rect.top; + }); + + // 鼠标进入预览区时,重播“黑幕从四角向鼠标收拢”的入场动画 + var OPEN_MS = 300; + var pOpenStart = null; + preview.addEventListener('mouseenter', function (e) { + var rect = preview.getBoundingClientRect(); + pTarget.x = e.clientX - rect.left; + pTarget.y = e.clientY - rect.top; + pPos.x = pTarget.x; + pPos.y = pTarget.y; + pOpenStart = performance.now(); + }); + + function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); } + + function farthestCornerDist(x, y, w, h) { + return Math.max( + Math.sqrt(x * x + y * y), + Math.sqrt((w - x) * (w - x) + y * y), + Math.sqrt(x * x + (h - y) * (h - y)), + Math.sqrt((w - x) * (w - x) + (h - y) * (h - y)) + ); + } + + (function previewLoop() { + // 与真实遮罩相同的平滑跟随算法(线性插值) + pPos.x += (pTarget.x - pPos.x) * 0.28; + pPos.y += (pTarget.y - pPos.y) * 0.28; + + // 入场收拢:亮洞半径从“覆盖整个预览区”收缩到设定半径 + var rEff = settings.radius; + if (pOpenStart !== null) { + var t = (performance.now() - pOpenStart) / OPEN_MS; + if (t >= 1) { + pOpenStart = null; + } else { + var rect = preview.getBoundingClientRect(); + var extra = farthestCornerDist(pPos.x, pPos.y, rect.width, rect.height) + settings.softness; + rEff = settings.radius + (1 - easeOutCubic(t)) * extra; + } + } + + pSpot.style.width = rEff * 2 + 'px'; + pSpot.style.height = rEff * 2 + 'px'; + pSpot.style.transform = 'translate3d(' + (pPos.x - rEff) + 'px, ' + (pPos.y - rEff) + 'px, 0)'; + requestAnimationFrame(previewLoop); + })(); + + applyPreviewSpot(); + ['radius', 'opacity', 'softness'].forEach(function (id) { + $(id).addEventListener('input', applyPreviewSpot); + }); + + // 兜底:若 600ms 内未收到任何进入事件(非常规打开方式),恢复设置页可见 + setTimeout(function () { + if (Date.now() - lastActivateAt > 800) { + document.body.classList.remove('activating'); + } + }, 600); + + // 主窗口高度适配 + if (window.ztools) { + try { window.ztools.setExpendHeight(770); } catch (e) {} + } +})(); diff --git a/plugins/spotlight/index.html b/plugins/spotlight/index.html new file mode 100644 index 00000000..c6228c90 --- /dev/null +++ b/plugins/spotlight/index.html @@ -0,0 +1,236 @@ + + + + + + +聚光灯设置 + + + +
+
+ 聚光灯 +
+

聚光灯

+

全屏变暗,光圈跟随鼠标指针,随时聚焦注意力

+
+
+ +
+

快捷键

+
+ + +
+

+ 点击「去绑定全局快捷键」跳转 ZTools 快捷键设置页,为本插件的「聚光灯」功能录入组合键(如 + Ctrl+Alt+S)即可全局唤起。 + 开启聚光灯后,按 Esc 或点击屏幕任意处退出。 +

+
+ +
+

光圈与明暗

+
+ + + 180 px +
+
+ + + 75% +
+
+ + + 60 px +
+
+ 点击屏幕任意处退出聚光灯 + +
+
+ 多屏覆盖(黑幕横跨所有显示器,光圈跨屏跟随) + +
+
+ +
+

实时预览

+
+
+
+
+
+
+
+
+
+
移动鼠标预览效果
+
+
+ +
+

显示器诊断

+

读取中…

+
+ + +
+ +
已保存
+ + + + diff --git a/plugins/spotlight/logo.png b/plugins/spotlight/logo.png new file mode 100644 index 00000000..7a7a419f Binary files /dev/null and b/plugins/spotlight/logo.png differ diff --git a/plugins/spotlight/overlay.html b/plugins/spotlight/overlay.html new file mode 100644 index 00000000..9e4380e9 --- /dev/null +++ b/plugins/spotlight/overlay.html @@ -0,0 +1,55 @@ + + + + + +spotlight-overlay + + + +
+
+
移动鼠标指示位置 · Esc 或点击退出
+
+ + + diff --git a/plugins/spotlight/overlay.js b/plugins/spotlight/overlay.js new file mode 100644 index 00000000..9eb56f7e --- /dev/null +++ b/plugins/spotlight/overlay.js @@ -0,0 +1,221 @@ +/** + * 聚光灯遮罩窗口逻辑(多屏版:每块屏幕一个独立遮罩窗口) + * + * 本窗口由插件主页面通过 ztools.createBrowserWindow 创建, + * 初始参数经 URL query 传入: + * ox, oy - 窗口左上角在屏幕中的 DIP 坐标(用于把全局鼠标坐标换算为窗口内坐标) + * r - 光圈半径 px + * o - 背景暗度 0-100 + * soft - 边缘柔化 px + * ce - 1/0 是否允许点击退出 + * cx, cy - 开启瞬间的鼠标屏幕坐标(可选) + * sid - 本次聚光灯会话 ID(多窗口联动退出用) + * tip - 1 时在本窗口显示底部提示条(仅鼠标所在屏幕) + * + * 多窗口联动(localStorage 令牌): + * - 任意窗口退出时写入 spotlight-close:{sid},其余窗口轮询到后跟随关闭 + * - 聚焦窗口持续写焦点心跳 spotlight-fhb:{sid};某窗口失焦 300ms 后 + * 若全局焦点心跳已停(用户 Alt+Tab 离开),则触发整体退出。 + * 窗口间焦点转移时心跳不断,不会误退出。 + */ + +(function () { + 'use strict'; + + var params = new URLSearchParams(location.search); + function num(key, def) { + var v = parseFloat(params.get(key)); + return isNaN(v) ? def : v; + } + + var ORIGIN = { x: num('ox', 0), y: num('oy', 0) }; + var RADIUS = Math.max(20, num('r', 180)); + var OPACITY = Math.min(0.98, Math.max(0, num('o', 75) / 100)); + var SOFTNESS = Math.max(0, num('soft', 60)); + var CLICK_EXIT = params.get('ce') !== '0'; + var SID = params.get('sid') || 'default'; + var SHOW_TIP = params.get('tip') === '1'; + + var CLOSE_KEY = 'spotlight-close:' + SID; + var FOCUS_KEY = 'spotlight-fhb:' + SID; + + function lsGet(key) { + try { return localStorage.getItem(key); } catch (e) { return null; } + } + function lsSet(key, value) { + try { localStorage.setItem(key, value); } catch (e) {} + } + + var spot = document.getElementById('spot'); + var tip = document.getElementById('tip'); + + /* ---------- 光圈外观 ---------- */ + + spot.style.width = RADIUS * 2 + 'px'; + spot.style.height = RADIUS * 2 + 'px'; + // 300vmax 的扩散足以覆盖任意尺寸的屏幕 + spot.style.boxShadow = '0 0 ' + SOFTNESS + 'px 300vmax rgba(0, 0, 0, ' + OPACITY + ')'; + + /* ---------- 位置跟踪(全局坐标轮询为主,鼠标事件为辅) ---------- */ + + // target 是本窗口内的坐标;鼠标不在本屏时为负值或超出窗口尺寸, + // 光圈会平滑地从边缘滑出,本屏保持全暗 + var target = { x: window.innerWidth / 2, y: window.innerHeight / 2 }; + var pos = { x: target.x, y: target.y }; + + // 初始位置:优先使用父窗口传来的开启瞬间鼠标坐标 + if (params.get('cx') !== null && params.get('cy') !== null) { + target.x = num('cx', target.x) - ORIGIN.x; + target.y = num('cy', target.y) - ORIGIN.y; + pos.x = target.x; + pos.y = target.y; + } + + // 通道一:轮询全局鼠标坐标(跨屏跟踪的关键,ZTools 会向创建的窗口注入 ztools) + var canPoll = !!(window.ztools && window.ztools.getCursorScreenPoint); + function pollCursor() { + if (!canPoll) return; + try { + var p = window.ztools.getCursorScreenPoint(); + if (p && typeof p.x === 'number') { + target.x = p.x - ORIGIN.x; + target.y = p.y - ORIGIN.y; + + // 焦点跟随鼠标:光标进入本屏且聚光灯仍持有焦点(焦点心跳新鲜)时, + // 让本窗口获得焦点,保证 Esc 在任意屏幕都能退出; + // 心跳已停说明用户切走了,不抢焦点(由失焦逻辑统一退出) + var inside = + target.x >= 0 && target.x < window.innerWidth && + target.y >= 0 && target.y < window.innerHeight; + if (inside && !(document.hasFocus && document.hasFocus())) { + var lastBeat = parseInt(lsGet(FOCUS_KEY) || '0', 10); + if (Date.now() - lastBeat < 600) { + try { window.focus(); } catch (e) {} + } + } + } + } catch (e) { /* 忽略单次失败 */ } + } + + // 通道二:本窗口捕获的鼠标事件(事件驱动,更顺滑;仅光标在本屏时触发) + document.addEventListener('mousemove', function (e) { + target.x = e.clientX; + target.y = e.clientY; + }); + + /* ---------- 平滑跟随渲染循环 ---------- */ + + var LERP = 0.3; // 每帧向目标靠近 30%,形成顺滑的跟随感 + + /* + * 入场动画:黑幕从四角向鼠标位置收拢。 + * 原理:亮洞(光斑 div)的半径从“足以覆盖全屏”收缩到设定半径, + * box-shadow 的暗色边缘便从屏幕四角向光标处收拢。 + * 鼠标不在本屏的窗口:亮洞收缩到屏幕外,最终整屏变暗。 + */ + var OPEN_MS = 300; + var openStart = null; + + function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); } + + // 当前光斑中心到窗口四角的距离最大值(保证动画开始时阴影完全在屏幕外) + function farthestCornerDist(x, y) { + var w = window.innerWidth, h = window.innerHeight; + return Math.max( + Math.sqrt(x * x + y * y), + Math.sqrt((w - x) * (w - x) + y * y), + Math.sqrt(x * x + (h - y) * (h - y)), + Math.sqrt((w - x) * (w - x) + (h - y) * (h - y)) + ); + } + + function frame() { + pollCursor(); + pos.x += (target.x - pos.x) * LERP; + pos.y += (target.y - pos.y) * LERP; + + var rEff = RADIUS; + if (openStart !== null) { + var t = (performance.now() - openStart) / OPEN_MS; + if (t >= 1) { + openStart = null; + spot.style.width = RADIUS * 2 + 'px'; + spot.style.height = RADIUS * 2 + 'px'; + } else { + var extra = farthestCornerDist(pos.x, pos.y) + SOFTNESS + 20; + rEff = RADIUS + (1 - easeOutCubic(t)) * extra; + spot.style.width = rEff * 2 + 'px'; + spot.style.height = rEff * 2 + 'px'; + } + } + + spot.style.transform = + 'translate3d(' + (pos.x - rEff) + 'px, ' + (pos.y - rEff) + 'px, 0)'; + requestAnimationFrame(frame); + } + requestAnimationFrame(frame); + + // 入场:启动收拢动画 + 短暂显示提示条(仅鼠标所在屏幕) + requestAnimationFrame(function () { + openStart = performance.now(); + if (SHOW_TIP) { + tip.classList.add('show'); + setTimeout(function () { tip.classList.remove('show'); }, 2600); + } + }); + + /* ---------- 多窗口联动与退出 ---------- */ + + var closed = false; + var bornAt = Date.now(); + + function closeSpotlight(broadcast) { + if (closed) return; + closed = true; + if (broadcast) lsSet(CLOSE_KEY, String(Date.now())); // 通知其余遮罩窗口一起退出 + // 通知父窗口(若环境支持) + try { + if (window.ztools && window.ztools.sendToParent) { + window.ztools.sendToParent('spotlight:close'); + } + } catch (e) {} + // 黑幕淡出后关闭窗口 + spot.style.transition = 'opacity 120ms ease'; + spot.style.opacity = '0'; + tip.classList.remove('show'); + clearInterval(cascadeTimer); + clearInterval(focusBeatTimer); + setTimeout(function () { window.close(); }, 120); + } + + // 联动:其余窗口关闭时跟随关闭 + var cascadeTimer = setInterval(function () { + if (lsGet(CLOSE_KEY) !== null) closeSpotlight(false); + }, 200); + + // 焦点心跳:聚焦窗口每 200ms 上报一次;失焦窗口据此判断用户是否已离开聚光灯 + var focusBeatTimer = setInterval(function () { + if (document.hasFocus && document.hasFocus()) { + lsSet(FOCUS_KEY, String(Date.now())); + } + }, 200); + + window.addEventListener('blur', function () { + // 启动初期窗口间焦点交接属正常,宽限 800ms + if (Date.now() - bornAt < 800) return; + setTimeout(function () { + if (closed) return; + var lastBeat = parseInt(lsGet(FOCUS_KEY) || '0', 10); + // 没有任何遮罩窗口持有焦点(用户 Alt+Tab 离开)→ 整体退出 + if (Date.now() - lastBeat > 500) closeSpotlight(true); + }, 300); + }); + + document.addEventListener('keydown', function (e) { + if (e.key === 'Escape') closeSpotlight(true); + }); + + if (CLICK_EXIT) { + document.addEventListener('mousedown', function () { closeSpotlight(true); }); + } +})(); diff --git a/plugins/spotlight/plugin.json b/plugins/spotlight/plugin.json new file mode 100644 index 00000000..75fe0751 --- /dev/null +++ b/plugins/spotlight/plugin.json @@ -0,0 +1,23 @@ +{ + "name": "spotlight", + "title": "聚光灯", + "description": "屏幕聚光灯:一键全屏变暗,光圈平滑跟随鼠标,演示录屏时聚焦视线", + "author": "Reaosen", + "version": "1.0.0", + "main": "index.html", + "logo": "logo.png", + "preload": "preload.js", + "features": [ + { + "code": "spotlight", + "explain": "开启聚光灯(全屏变暗,光圈跟随鼠标)", + "cmds": ["聚光灯", "spotlight", "jgd"], + "mainHide": true + }, + { + "code": "spotlight-settings", + "explain": "聚光灯设置(快捷键 / 光圈大小 / 明暗 / 柔化)", + "cmds": ["聚光灯设置", "spotlight设置", "聚光灯配置", "jgd设置"] + } + ] +} diff --git a/plugins/spotlight/preload.js b/plugins/spotlight/preload.js new file mode 100644 index 00000000..0277f987 --- /dev/null +++ b/plugins/spotlight/preload.js @@ -0,0 +1,13 @@ +/** + * 聚光灯插件 preload + * ZTools 会自动向页面注入 window.ztools,这里只补充少量原生信息。 + * 注意:本文件遵循 ZTools 规范,保持清晰可读,不做压缩混淆。 + */ + +window.spotlightPreload = { + platform: process.platform, + versions: { + electron: process.versions.electron, + node: process.versions.node + } +};