dsh-notify-yimit 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +4 -3
- package/README.md +12 -2
- package/lib/index.js +105 -18
- package/lib/toast-host.ps1 +470 -15
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
A notification plugin for DeepSeek Harness: alerts you on **task completed / task failed / running (live activity) / awaiting approval / awaiting answer**.
|
|
18
18
|
|
|
19
19
|
- **Two notification styles**, switchable in Settings: **Windows system notifications** (native browser notifications) or **custom in-app notifications** (a toast at the bottom-right of the desktop, independent of the browser page); or turn everything **off** (default).
|
|
20
|
-
- **The notification title is the conversation title** (updated in place once the LLM generates it; the workspace directory name is never used). Toasts offer **Ignore**,
|
|
20
|
+
- **The notification title is the conversation title** (updated in place once the LLM generates it; the workspace directory name is never used). Toasts offer **Ignore**, on the web also **Jump to session**, and on the desktop also **Go to app** (see below).
|
|
21
21
|
- **One codebase for desktop and web**: the host detects its platform from the runtime and hides only browser-facing entry points — notification behavior is identical.
|
|
22
22
|
- **Settings-page integration**: DSH Settings → "Notifications" section with a master switch, notification style, toast count/duration, and per-type background/text colors (done = green, failed = red, running = blue, approval = yellow, question = purple).
|
|
23
23
|
|
|
@@ -38,11 +38,12 @@ A notification plugin for DeepSeek Harness: alerts you on **task completed / tas
|
|
|
38
38
|
- Custom notifications: **max simultaneously visible**, **display duration**, and a **per-type background/text color** list
|
|
39
39
|
(done = green, failed = red, running = blue, approval = yellow, question = purple; each customizable);
|
|
40
40
|
- The custom-settings block animates open/closed; system notifications offer one-click permission request.
|
|
41
|
-
- **Custom notification = desktop toast (independent of the browser)**: the host plugin spawns a **resident PowerShell + WPF host process** that pops borderless, always-on-top toasts at the **bottom-right** of the screen (multiple toasts stack upward without overlapping). Each toast has a title, body, and an **Ignore** button (**web only: plus a "Jump to session" button**; 10px rounded-rect buttons), colored per type from the config. Done/failed toasts auto-dismiss after the display duration; **running/approval/question toasts stay until their state ends** (turn end / decision made / answer given). Running content is **updated in place** (400ms throttle — no re-spawn, no flicker); approval/question are stateful and **not debounced** (multiple approvals/questions within 2s are never swallowed). Whether the browser page is open or minimized does not matter. Until the session title arrives (LLM-generated asynchronously — first-turn events precede the title event), toasts show a localized placeholder ("(Unnamed session)"); when the `session/title` event lands, **the titles of already-shown toasts are updated in place** — the workspace directory name is never used.
|
|
41
|
+
- **Custom notification = desktop toast (independent of the browser)**: the host plugin spawns a **resident PowerShell + WPF host process** that pops borderless, always-on-top toasts at the **bottom-right** of the screen (multiple toasts stack upward without overlapping). Each toast has a title, body, and an **Ignore** button (**web only: plus a "Jump to session" button**; **desktop only: plus a "Go to app" button**; 10px rounded-rect buttons), colored per type from the config. Done/failed toasts auto-dismiss after the display duration; **running/approval/question toasts stay until their state ends** (turn end / decision made / answer given). Running content is **updated in place** (400ms throttle — no re-spawn, no flicker); approval/question are stateful and **not debounced** (multiple approvals/questions within 2s are never swallowed). Whether the browser page is open or minimized does not matter. Until the session title arrives (LLM-generated asynchronously — first-turn events precede the title event), toasts show a localized placeholder ("(Unnamed session)"); when the `session/title` event lands, **the titles of already-shown toasts are updated in place** — the workspace directory name is never used.
|
|
42
42
|
- **Resident host architecture**: the plugin starts one `powershell` host process (`toast-host.ps1`) on load; WPF is loaded only once and every toast is created inside that process — toast creation latency drops from ~1s cold start to ~10ms. The host receives one JSON command per line on **stdin** (`show`/`text`/`title`/`move`/`close`/`shutdown`) and reports `pos`/`exit` on **stdout**; no per-toast process spawn and no ctl/pos file polling.
|
|
43
43
|
- **Jump to session (web only)**: clicking a system notification or the toast's "Jump to session" button opens the browser and navigates to the session (via the URL hash convention `#dsh-notify-yimit/session=<id>`, listened to by the client; optionally with a specific browser). **The desktop app does not show that button** — opening a browser from inside the desktop app is redundant.
|
|
44
|
+
- **Go to app (desktop only)**: the "Go to app" button (right of "Ignore") brings the running desktop window to the foreground. The **host process enumerates windows and activates it itself** (locating PIDs by the app executable path, picking that process's top-level window, then `ShowWindow(SW_RESTORE)` + `SetForegroundWindow`). It deliberately does **not** use the app's registered `dsh://open` protocol: the app only handles that URL in its **macOS `open-url`** event, while on Windows a protocol URL arrives as an argv argument and the app's `second-instance` handler neither reads argv nor checks the URL — that path is dead code on Windows. Window selection is tiered (visible + titled + unowned → titled + unowned → titled), so it still finds the main window when the app is **minimized to tray or covered**. The host is the foreground process at click time, so the "only the foreground process may steal focus" restriction does not apply. **Clicking it does not dismiss the toast** (unlike the other buttons); if no window can be located it silently does nothing.
|
|
44
45
|
- **Runtime-aware UI**: the host detects its platform from the runtime (Electron runtime = desktop, plain Node = web) and adapts the UI:
|
|
45
|
-
- desktop: toasts omit the "Jump to session" button; the settings page hides the jump browser picker and the "system notifications come from the browser / custom toasts sit at the bottom-right" explainer;
|
|
46
|
+
- desktop: toasts omit the "Jump to session" button and show "Go to app" instead; the settings page hides the jump browser picker and the "system notifications come from the browser / custom toasts sit at the bottom-right" explainer;
|
|
46
47
|
- web: everything stays visible.
|
|
47
48
|
- **System notifications**: native browser notifications; clicking one focuses the window and opens the session.
|
|
48
49
|
- **Requirements**: Windows (PowerShell 5.1+, built-in); custom desktop toasts need no extra dependencies.
|
package/README.md
CHANGED
|
@@ -45,7 +45,8 @@ DeepSeek Harness 通知插件:在 **任务完成 / 任务出错 / 运行中(实
|
|
|
45
45
|
- 自定义设置区块显隐带过渡动画;系统通知可一键申请浏览器通知权限。
|
|
46
46
|
- **自定义通知 = 桌面浮窗(不依赖浏览器)**:宿主插件通过**常驻 PowerShell + WPF 宿主进程**
|
|
47
47
|
在屏幕**右下角**弹出无边框置顶浮窗(多浮窗自动向上堆叠不重叠),标题、内容、**忽略**按钮
|
|
48
|
-
(**web
|
|
48
|
+
(**web 端另有「跳转会话」按钮**,**桌面端另有「返回应用」按钮**,按钮为圆角矩形 10px);
|
|
49
|
+
按通知类型使用对应配置的背景/文字色。完成/出错
|
|
49
50
|
按显示时长后自动消失;**运行中/待审批/待回答在状态结束前不会消失**(任务结束/审批决定/
|
|
50
51
|
回答完成后自动关闭)。运行中内容**原地更新文本**(400ms 节流,不重弹、不闪烁);
|
|
51
52
|
待审批/待回答为状态性通知,**不做防抖**(2s 内多条审批/提问不会被吞)。浏览器页面是否
|
|
@@ -59,9 +60,18 @@ DeepSeek Harness 通知插件:在 **任务完成 / 任务出错 / 运行中(实
|
|
|
59
60
|
- **跳转会话(仅 web 端)**:系统通知点击 / 自定义浮窗「跳转会话」按钮 → **打开浏览器**并定位到该会话
|
|
60
61
|
(通过 URL hash 约定 `#dsh-notify-yimit/session=<id>`,客户端监听后切换;可用所选浏览器打开)。
|
|
61
62
|
**桌面端(App)不显示该按钮**——在桌面应用里再打开浏览器属于多余动作。
|
|
63
|
+
- **返回应用(仅桌面端)**:浮窗「忽略」右侧的「返回应用」按钮把已运行的桌面窗口切到前台。
|
|
64
|
+
实现是**宿主进程自己枚举窗口并激活**(按应用可执行文件路径定位 PID,再挑出该进程的顶层窗口,
|
|
65
|
+
`ShowWindow(SW_RESTORE)` + `SetForegroundWindow`)。之所以不用应用已注册的 `dsh://open` 协议:
|
|
66
|
+
应用只在 **macOS 的 `open-url` 事件**里处理该 URL,Windows 上协议 URL 是作为 argv 交给新进程的,
|
|
67
|
+
而它的 `second-instance` 处理器不读 argv、也不校验 URL —— 那条路在 Windows 上是死代码。
|
|
68
|
+
窗口挑选分三级(可见+有标题+非 owner → 有标题+非 owner → 有标题),因此**最小化到托盘、
|
|
69
|
+
被其他窗口遮挡**时同样能找到主窗口。宿主被点击的那一刻本就是前台进程,所以不受
|
|
70
|
+
"仅前台进程可抢占前台"的限制。**点击后不关闭该通知**,浮窗保留(与其他按钮不同);
|
|
71
|
+
定位不到窗口时静默无动作。
|
|
62
72
|
- **运行环境自适应**:宿主按运行时自动判定平台(Electron 运行时 = 桌面端,普通 Node = web 端),
|
|
63
73
|
并据此调整界面:
|
|
64
|
-
-
|
|
74
|
+
- 桌面端:浮窗隐藏「跳转会话」按钮、改为显示「返回应用」按钮;设置页隐藏「跳转浏览器选择器」与"系统通知由浏览器弹出/
|
|
65
75
|
自定义为桌面右下角浮窗"那段说明;
|
|
66
76
|
- web 端:全部保留。
|
|
67
77
|
- **系统通知**:浏览器原生通知,点击通知即聚焦窗口并打开对应会话。
|
package/lib/index.js
CHANGED
|
@@ -96,11 +96,13 @@ const TOAST_LABELS = {
|
|
|
96
96
|
zh: {
|
|
97
97
|
ignore: '忽略',
|
|
98
98
|
jump: '跳转会话',
|
|
99
|
+
activate: '返回应用',
|
|
99
100
|
unnamed: '(未命名会话)'
|
|
100
101
|
},
|
|
101
102
|
en: {
|
|
102
103
|
ignore: 'Ignore',
|
|
103
104
|
jump: 'Open',
|
|
105
|
+
activate: 'Go to app',
|
|
104
106
|
unnamed: '(Unnamed session)'
|
|
105
107
|
}
|
|
106
108
|
}
|
|
@@ -114,6 +116,12 @@ const EDIT_TITLES = {
|
|
|
114
116
|
en: 'Notification preview'
|
|
115
117
|
}
|
|
116
118
|
|
|
119
|
+
/** 编辑模式基准线说明(中/英):基准线下方画 ↑,表示通知从该位置往上堆叠。 */
|
|
120
|
+
const EDIT_ANCHOR_LABELS = {
|
|
121
|
+
zh: '通知以该位置为基准,均在此线上方',
|
|
122
|
+
en: 'Toasts anchor here and stack upward'
|
|
123
|
+
}
|
|
124
|
+
|
|
117
125
|
const HEX_RE = /^#[0-9a-fA-F]{3,8}$/
|
|
118
126
|
const isHex = (v) => typeof v === 'string' && HEX_RE.test(v)
|
|
119
127
|
|
|
@@ -154,6 +162,14 @@ function normalizeConfig(input) {
|
|
|
154
162
|
left: Math.round(pos.left),
|
|
155
163
|
top: Math.round(pos.top)
|
|
156
164
|
}
|
|
165
|
+
// 兼容旧版已保存的坐标。旧版语义混乱:position.top 被当作"样板窗窗口顶边",
|
|
166
|
+
// 而通知也按"窗口顶边 = pos.top"落位;新版统一为"可见卡片底边 = pos.top"。
|
|
167
|
+
// 两者相差(卡片高 + GLOW),故对没有新标记的旧数据整体下移这么多。
|
|
168
|
+
// (此处拿不到真实卡片高,用默认值估;用户重新拖动一次即精确。)
|
|
169
|
+
if (srcToast.posAtBaseline !== true) {
|
|
170
|
+
out.toast.position.top = Math.round(out.toast.position.top + DEFAULT_WINDOW_H - TOAST_BOTTOM_GAP)
|
|
171
|
+
}
|
|
172
|
+
out.toast.posAtBaseline = true
|
|
157
173
|
}
|
|
158
174
|
|
|
159
175
|
const srcColors = src.colors !== null && typeof src.colors === 'object' && !Array.isArray(src.colors) ? src.colors :
|
|
@@ -287,8 +303,45 @@ function openConfig() {
|
|
|
287
303
|
const MAX_QUEUE = 500
|
|
288
304
|
const RUNNING_THROTTLE_MS = 400
|
|
289
305
|
const KIND_DEBOUNCE_MS = 2000
|
|
290
|
-
/** 通知间距默认值(px);实际使用 config.toast.gap(0–60,可调)。 */
|
|
306
|
+
/** 通知间距默认值(px,指"可见卡片边到边"的间距);实际使用 config.toast.gap(0–60,可调)。 */
|
|
291
307
|
const TOAST_GAP = 12
|
|
308
|
+
/**
|
|
309
|
+
* 每个通知窗口四周的透明辉光边距(px),必须与 toast-host.ps1 里
|
|
310
|
+
* New-ToastWindow 的 `$border.Margin = Thickness(12)` 保持一致。
|
|
311
|
+
*
|
|
312
|
+
* 实测(两条通知同锚点堆叠):卡片可见矩形 = 窗口内缩 GLOW,
|
|
313
|
+
* 即 窗口高 = 可见卡片高 + 2×GLOW。因此
|
|
314
|
+
* · 某条的"可见卡片高" = 窗口高 − 2×GLOW;
|
|
315
|
+
* · 堆叠间距直接用用户设置的 gap 即可 —— 因为相邻两条的"窗口间距离"
|
|
316
|
+
* 里已经含了 2×GLOW 的透明边距,而它们是被计入 offsetY 的可见高度吸收的。
|
|
317
|
+
* 关键是 gap 绝不能再减去 2×GLOW:那样 gap ≤ 24 时会算出 0 间距,
|
|
318
|
+
* 用户看到的默认 12px 就变成"卡片贴在一起"(实测复现过)。
|
|
319
|
+
*/
|
|
320
|
+
const TOAST_GLOW = 12
|
|
321
|
+
/** 默认右下角排布时,最新那条"可见卡片底边"距工作区底边的留白(px);须与宿主一致。 */
|
|
322
|
+
const TOAST_EDGE_PAD = 20
|
|
323
|
+
/**
|
|
324
|
+
* 通知窗口内"可见卡片底边"到**窗口底边**的距离(px),须与 toast-host.ps1 的
|
|
325
|
+
* $script:TOAST_BOTTOM_GAP 一致(实测 = 12,即窗口四周的辉光边距)。
|
|
326
|
+
* 底部锚定公式(注意含"窗口高",不能用固定值):
|
|
327
|
+
* 窗口顶边 = 锚点Y − (窗口高 − TOAST_BOTTOM_GAP) − offsetY
|
|
328
|
+
* ⇒ 卡片可见底边 = 锚点Y − offsetY,与内容高度无关。
|
|
329
|
+
*/
|
|
330
|
+
const TOAST_BOTTOM_GAP = 12
|
|
331
|
+
/**
|
|
332
|
+
* 迁移旧配置时估算用的"通知窗口总高"(px),≈ 卡片高 110 + 2×辉光 24。
|
|
333
|
+
* 真实高度由宿主回传,这里只在读旧配置时用作回落值。
|
|
334
|
+
*/
|
|
335
|
+
const DEFAULT_WINDOW_H = 134
|
|
336
|
+
/**
|
|
337
|
+
* 堆叠时相邻两条之间的"窗口间"间距。
|
|
338
|
+
*
|
|
339
|
+
* 直接用用户设置的 gap —— 不要再减去 2×TOAST_GLOW:
|
|
340
|
+
* offsetY 累加的是"可见卡片高 + 间距",可见卡片高已经把两侧透明辉光吸收掉了,
|
|
341
|
+
* 所以 gap 就是肉眼看到的卡片间距。若在此处再减 2×GLOW,gap ≤ 24 时会被夹成 0,
|
|
342
|
+
* 默认的 12px 就变成"卡片严丝合缝贴在一起"(实测复现过)。
|
|
343
|
+
*/
|
|
344
|
+
const stackGap = (gap) => Math.max(0, gap)
|
|
292
345
|
const TEXT_MAX = 300
|
|
293
346
|
|
|
294
347
|
function clip(text) {
|
|
@@ -532,12 +585,21 @@ export function apply(ctx) {
|
|
|
532
585
|
} else if (msg.type === 'geometry' && typeof msg.left === 'number' && typeof msg.top ===
|
|
533
586
|
'number') {
|
|
534
587
|
// 编辑模式:样板窗拖拽/缩放结束 → 写回配置,并应用到在屏浮窗。
|
|
588
|
+
//
|
|
589
|
+
// 坐标口径:宿主回传的 msg.top 就是"基准线(卡片可见底边)的屏幕 Y"。
|
|
590
|
+
// 宿主定位时用的是同一个口径($win.Top = pos.top − lineInWin),
|
|
591
|
+
// 所以"存下的值"与"重开时的落点"必然一致,不会漂移。
|
|
592
|
+
// 注意不要再自己加 msg.baselineTop:那是"基准线从窗口顶边算起的偏移",
|
|
593
|
+
// 只为调试/换算提供,加进去会重复计入一个卡片高。
|
|
535
594
|
const width = Math.max(260, Math.min(520, Math.round(Number(msg.width) || 340)))
|
|
536
595
|
const left = Math.round(Number(msg.left))
|
|
596
|
+
// position.top 一律存"基准线的屏幕 Y";posAtBaseline 标记供
|
|
597
|
+
// normalizeConfig 区分新旧坐标(旧数据语义不同,需迁移)。
|
|
537
598
|
const top = Math.round(Number(msg.top))
|
|
538
599
|
store.set({
|
|
539
600
|
toast: {
|
|
540
601
|
width,
|
|
602
|
+
posAtBaseline: true,
|
|
541
603
|
position: {
|
|
542
604
|
left,
|
|
543
605
|
top
|
|
@@ -617,19 +679,27 @@ export function apply(ctx) {
|
|
|
617
679
|
const cfg = store.get()
|
|
618
680
|
const pos = cfg.toast?.position ?? null
|
|
619
681
|
const gap = typeof cfg.toast?.gap === 'number' ? cfg.toast.gap : TOAST_GAP
|
|
682
|
+
// 窗口排布间距:扣除两侧透明辉光边距,使"可见卡片间距"真正等于用户设置的 gap。
|
|
683
|
+
const boxGap = stackGap(gap)
|
|
620
684
|
if (pos !== null) {
|
|
621
|
-
//
|
|
622
|
-
//
|
|
623
|
-
//
|
|
624
|
-
//
|
|
625
|
-
|
|
626
|
-
|
|
685
|
+
// 自定义位置:锚点(pos.top,即编辑模式那条基准线)对应"最新那条的可见卡片底边"。
|
|
686
|
+
// 这是底部锚定 —— 无论通知内容多长、卡片多高,底边都恒定落在基准线上,
|
|
687
|
+
// 只有顶边随内容浮动。
|
|
688
|
+
//
|
|
689
|
+
// 定位公式必须与宿主 New-ToastWindow 的 Loaded 完全一致,否则宿主先落位、
|
|
690
|
+
// 这里再校正,两者会互相打架(表现为浮窗抖动或被推错位):
|
|
691
|
+
// 窗口顶边 = pos.top − (窗口高 − TOAST_BOTTOM_GAP) − offsetY
|
|
692
|
+
// "窗口高"用每条自己回传的 meta.height —— 卡片高度随内容变化,写死会让
|
|
693
|
+
// 长内容的底边偏离锚点。offsetY = 该条之上已叠的高度(第 0 位为 0)。
|
|
694
|
+
// 横向同步 position.left,编辑调整位置后,在屏浮窗立即左右移动到位。
|
|
695
|
+
let offset = 0
|
|
627
696
|
for (let i = 0; i < toastOrder.length; i += 1) {
|
|
628
697
|
const rec = activeToasts.get(toastOrder[i])
|
|
629
698
|
if (rec === undefined) continue
|
|
630
|
-
const height = rec.meta?.height ??
|
|
631
|
-
const newTop =
|
|
632
|
-
|
|
699
|
+
const height = rec.meta?.height ?? DEFAULT_WINDOW_H
|
|
700
|
+
const newTop = Math.round(pos.top - (height - TOAST_BOTTOM_GAP) - offset)
|
|
701
|
+
// 累积"本条之上"的高度:可见卡片高(= 窗口高 − 2×辉光)+ gap
|
|
702
|
+
offset += Math.max(0, height - 2 * TOAST_GLOW) + boxGap
|
|
633
703
|
const needsTop = rec.meta !== null && newTop !== rec.meta.top
|
|
634
704
|
const needsLeft = rec.meta !== null && rec.meta.left !== pos.left
|
|
635
705
|
if (needsTop || needsLeft) {
|
|
@@ -645,19 +715,19 @@ export function apply(ctx) {
|
|
|
645
715
|
}
|
|
646
716
|
sendCmd(move)
|
|
647
717
|
}
|
|
648
|
-
prevTop = newTop
|
|
649
718
|
}
|
|
650
719
|
return
|
|
651
720
|
}
|
|
652
|
-
//
|
|
653
|
-
//
|
|
721
|
+
// 默认右下:最新那条的"可见卡片底边"距工作区底边 TOAST_EDGE_PAD,向上堆叠;
|
|
722
|
+
// 横向回默认贴边(left 'default'),覆盖"恢复默认位置/改宽度后需回到右下角"的场景。
|
|
723
|
+
// 与宿主默认分支同式:窗口顶边 = 工作区底边 − TOAST_EDGE_PAD − (窗口高 − TOAST_BOTTOM_GAP) − offsetY。
|
|
654
724
|
if (globalWorkBottom === null) return
|
|
655
|
-
let
|
|
725
|
+
let offset = 0
|
|
656
726
|
for (let i = 0; i < toastOrder.length; i += 1) {
|
|
657
727
|
const rec = activeToasts.get(toastOrder[i])
|
|
658
728
|
if (rec === undefined) continue
|
|
659
|
-
const height = rec.meta?.height ??
|
|
660
|
-
const newTop = Math.round(
|
|
729
|
+
const height = rec.meta?.height ?? DEFAULT_WINDOW_H
|
|
730
|
+
const newTop = Math.round(globalWorkBottom - TOAST_EDGE_PAD - (height - TOAST_BOTTOM_GAP) - offset)
|
|
661
731
|
if (rec.meta !== null && newTop !== rec.meta.top) {
|
|
662
732
|
rec.meta.top = newTop
|
|
663
733
|
sendCmd({
|
|
@@ -667,7 +737,7 @@ export function apply(ctx) {
|
|
|
667
737
|
left: 'default'
|
|
668
738
|
})
|
|
669
739
|
}
|
|
670
|
-
|
|
740
|
+
offset += Math.max(0, height - 2 * TOAST_GLOW) + boxGap
|
|
671
741
|
}
|
|
672
742
|
}
|
|
673
743
|
|
|
@@ -745,10 +815,18 @@ export function apply(ctx) {
|
|
|
745
815
|
}
|
|
746
816
|
const insertIndex = replaceIndex !== -1 ? replaceIndex : 0
|
|
747
817
|
const gap = typeof cfg.toast?.gap === 'number' ? cfg.toast.gap : TOAST_GAP
|
|
818
|
+
const boxGap = stackGap(gap)
|
|
819
|
+
// 宿主首帧落位用的偏移量,语义 = "该条之上已经叠了多少高度(px)"。
|
|
820
|
+
// 宿主按底部锚定定位:窗口顶边 = 锚点Y − 卡片可见高 − offsetY,
|
|
821
|
+
// 于是该条"可见卡片底边 = 锚点Y − offsetY"。所以:
|
|
822
|
+
// · 第 0 位(最下方那条)上方没有任何东西 → offsetY 必须为 0;
|
|
823
|
+
// · 其余位置 = Σ(上方各条的可见卡片高 + gap)。
|
|
824
|
+
// 每条只吃自己的高度,永远不需要预测新条自己的高度。
|
|
748
825
|
let offsetY = 0
|
|
749
826
|
for (let i = 0; i < insertIndex; i += 1) {
|
|
750
827
|
const r = activeToasts.get(toastOrder[i])
|
|
751
|
-
|
|
828
|
+
// 上方每一条贡献"可见卡片高 + gap";可见卡片高 = 窗口高 − 2×辉光。
|
|
829
|
+
offsetY += Math.max(0, (r?.meta?.height ?? 130) - 2 * TOAST_GLOW) + boxGap
|
|
752
830
|
}
|
|
753
831
|
|
|
754
832
|
const lang = cfg.locale === 'zh' ? 'zh' : 'en'
|
|
@@ -774,6 +852,13 @@ export function apply(ctx) {
|
|
|
774
852
|
jumpLabel: l.jump,
|
|
775
853
|
// 桌面端隐藏「跳转会话」按钮(该按钮会打开浏览器,在桌面端内多余)
|
|
776
854
|
jumpEnabled: isDesktopRuntime !== true,
|
|
855
|
+
// 「返回应用」只在桌面端有意义:把已在运行的桌面窗口切到前台。
|
|
856
|
+
// 实现是宿主侧枚举窗口 + SetForegroundWindow —— 不能用 dsh://open:
|
|
857
|
+
// 应用只在 macOS 的 open-url 事件里处理该 URL,Windows 上协议 URL 是以
|
|
858
|
+
// argv 交给新进程的,而 second-instance 处理器不读 argv,那条路是死代码。
|
|
859
|
+
// 因此把应用可执行文件路径一并下发,供宿主按进程定位窗口。
|
|
860
|
+
activateLabel: isDesktopRuntime === true ? l.activate : '',
|
|
861
|
+
appPath: isDesktopRuntime === true ? String(process.execPath ?? '') : '',
|
|
777
862
|
unnamedLabel: l.unnamed,
|
|
778
863
|
// 通知窗口样式:宽度 + 自定义位置(null = 默认右下贴边)
|
|
779
864
|
width: cfg.toast?.width ?? 340,
|
|
@@ -1162,6 +1247,8 @@ export function apply(ctx) {
|
|
|
1162
1247
|
sendCmd({
|
|
1163
1248
|
cmd: 'edit',
|
|
1164
1249
|
title: EDIT_TITLES[lang],
|
|
1250
|
+
// 基准线提示文案:告诉用户"通知以该位置为基准、均在此线上方"
|
|
1251
|
+
anchorLabel: EDIT_ANCHOR_LABELS[lang],
|
|
1165
1252
|
width: cfg.toast?.width ?? 340,
|
|
1166
1253
|
position: cfg.toast?.position ?? null,
|
|
1167
1254
|
// 新增:类型色卡数组,样板窗内做预览
|
package/lib/toast-host.ps1
CHANGED
|
@@ -59,23 +59,169 @@ function Add-GridRow($grid) {
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
# 编辑模式几何回传:样板窗当前 left/top/width/height(工作区相对坐标)。
|
|
62
|
+
# 样板窗额外回传 baselineTop = 基准线(卡片可见底边)在窗口内的 Y 偏移,
|
|
63
|
+
# 供前端把它换算成屏幕坐标存进配置。
|
|
62
64
|
function Send-Geometry($w) {
|
|
63
65
|
try {
|
|
64
66
|
$work = [System.Windows.SystemParameters]::WorkArea
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
top = [Math]::Round($w.Top - $work.Top)
|
|
69
|
-
width = [Math]::Round($w.ActualWidth)
|
|
70
|
-
height = [Math]::Round($w.ActualHeight)
|
|
67
|
+
$lineInWin = 0.0
|
|
68
|
+
if ($w.Tag -is [hashtable] -and $null -ne $w.Tag.lineInWin) {
|
|
69
|
+
$lineInWin = [double]$w.Tag.lineInWin
|
|
71
70
|
}
|
|
71
|
+
# ── 坐标口径必须与"重开时的定位口径"完全一致 ──
|
|
72
|
+
# 定位时:$win.Top = $pos.top − lineInWin(即 $pos.top 是基准线的屏幕 Y)。
|
|
73
|
+
# 因此这里回传的 top 也必须是**基准线的屏幕 Y**,而不是窗口顶边:
|
|
74
|
+
# top = 窗口顶边 + lineInWin
|
|
75
|
+
# 若回传窗口顶边,index.js 再把它当基准线存下来,就会出现
|
|
76
|
+
# "每进一次编辑模式基准线就往下漂一个带高"的问题(实测复现过)。
|
|
77
|
+
# baselineTop 仍回传"基准线从窗口顶边算起的偏移",供前端换算/调试。
|
|
78
|
+
$msg = @{
|
|
79
|
+
type = 'geometry'
|
|
80
|
+
left = [Math]::Round($w.Left - $work.Left)
|
|
81
|
+
top = [Math]::Round($w.Top - $work.Top + $lineInWin)
|
|
82
|
+
width = [Math]::Round($w.ActualWidth)
|
|
83
|
+
height = [Math]::Round($w.ActualHeight)
|
|
84
|
+
baselineTop = [Math]::Round($lineInWin)
|
|
85
|
+
}
|
|
86
|
+
Send-Report $msg
|
|
72
87
|
} catch { }
|
|
73
88
|
}
|
|
74
89
|
|
|
75
90
|
# ───────────────────────── 编辑模式样板窗 ─────────────────────────
|
|
76
91
|
|
|
92
|
+
# 基准线指示带固定高度(px):横线(2) + 3px 间距 + 箭头(19) + 2px 余量。
|
|
93
|
+
# 固定值而非 Auto:基准线到窗口底边的距离必须可预测,否则回传的基准线位置
|
|
94
|
+
# 会随字体度量浮动、与实际画的那条线对不上(编辑模式靠它反推窗口落点)。
|
|
95
|
+
$script:ANCHOR_BAND_H = 26
|
|
96
|
+
|
|
97
|
+
# 通知窗口内"可见卡片底边"到**窗口底边**的距离(px)。
|
|
98
|
+
# 实测地推:卡片底边 = 窗口顶边 + (窗口高 − 12);该 12 即 $border.Margin 的辉光边距。
|
|
99
|
+
# 注意它是"到窗口底边"的距离,不是"到窗口顶边"的偏移 —— 因此底部锚定的抬升量
|
|
100
|
+
# 必须写成 (窗口高 − TOAST_BOTTOM_GAP),随卡片高度自适应,绝不能写成固定值:
|
|
101
|
+
# 窗口顶边 = 锚点Y − (窗口高 − TOAST_BOTTOM_GAP) − offsetY
|
|
102
|
+
# 这样"卡片可见底边 = 锚点Y − offsetY",内容多长底边都钉在锚点上。
|
|
103
|
+
$script:TOAST_BOTTOM_GAP = 12
|
|
104
|
+
|
|
105
|
+
# 箭头用矢量多边形而非字体字形绘制:字形自带行距死区(上/下各数 px),
|
|
106
|
+
# 会把箭头推离横线、甚至溢出窗口被裁切;矢量图形能精确对齐到横线。
|
|
107
|
+
$script:ANCHOR_ARROW_W = 14
|
|
108
|
+
$script:ANCHOR_ARROW_H = 19
|
|
109
|
+
$script:ANCHOR_ARROW_GAP = 3
|
|
110
|
+
|
|
111
|
+
# 构造向上的空心箭头(chevron)多边形:顶点在正上方,两翼向下张开。
|
|
112
|
+
# 返回 PointCollection,坐标系原点在箭头外接框左上角。
|
|
113
|
+
function New-AnchorArrowPoints([double]$w, [double]$h) {
|
|
114
|
+
$hw = $w / 2.0
|
|
115
|
+
$th = [Math]::Max(2.0, $h * 0.45) # 两翼的竖直厚度
|
|
116
|
+
$tip = $hw * 0.62 # 翼尖相对中轴的横向偏移
|
|
117
|
+
$notch = $h * 0.42 # 内凹点深度(形成空心)
|
|
118
|
+
# 注意:PowerShell 中 `-` 与 `*` 的优先级容易误读,复合运算一律加括号。
|
|
119
|
+
$pts = New-Object System.Windows.Media.PointCollection
|
|
120
|
+
$pts.Add((New-Object System.Windows.Point($hw, 0))) # 顶点(贴住横线)
|
|
121
|
+
$pts.Add((New-Object System.Windows.Point($w, $th))) # 右翼外侧
|
|
122
|
+
$pts.Add((New-Object System.Windows.Point(($w - ($hw - $tip)), ($th + $tip)))) # 右翼尖端
|
|
123
|
+
$pts.Add((New-Object System.Windows.Point($hw, $notch))) # 内凹点
|
|
124
|
+
$pts.Add((New-Object System.Windows.Point(($hw - $tip), ($th + $tip)))) # 左翼尖端
|
|
125
|
+
$pts.Add((New-Object System.Windows.Point(0, $th))) # 左翼外侧
|
|
126
|
+
# 逗号运算符:PointCollection 实现了 IEnumerable,直接 return 会被 PowerShell
|
|
127
|
+
# 展开成 Object[],导致赋值给 Polygon.Points 失败。
|
|
128
|
+
return , $pts
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
# 基准线指示带(仅编辑模式):一条横线 + 居中 ↑,紧贴在卡片"可见底边"正下方,
|
|
132
|
+
# 用来告诉用户"通知以该位置为基准,自定义通知均在此线上方"。
|
|
133
|
+
#
|
|
134
|
+
# 几何约定(方案 A,务必与 New-EditSample 的边距算式保持一致):
|
|
135
|
+
# · 横线位于网格第 0 行,即带的顶边;↑ 叠放在同一格内(线画在箭头之上)。
|
|
136
|
+
# · 带高固定(常量 $script:ANCHOR_BAND_H):固定值而非 Auto,基准线到窗口底边
|
|
137
|
+
# 的距离才可预测,回传的基准线位置才不会与实际画的线不一致。
|
|
138
|
+
function New-AnchorBand([System.Windows.Media.Color]$accent, [string]$label, [double]$maxLabelWidth) {
|
|
139
|
+
$grid = New-Object System.Windows.Controls.Grid
|
|
140
|
+
$grid.Height = $script:ANCHOR_BAND_H
|
|
141
|
+
$grid.HorizontalAlignment = [System.Windows.HorizontalAlignment]::Stretch
|
|
142
|
+
# 必须 Top:若用 Center/Stretch 居中,线会被推到格子中间,离卡片底边多出一段距离。
|
|
143
|
+
$grid.VerticalAlignment = [System.Windows.VerticalAlignment]::Top
|
|
144
|
+
|
|
145
|
+
# ① 横线:强调色实线 + 辉光,两端小圆点形成"标尺"感
|
|
146
|
+
$line = New-Object System.Windows.Controls.Grid
|
|
147
|
+
$line.Height = 2
|
|
148
|
+
$line.VerticalAlignment = [System.Windows.VerticalAlignment]::Top
|
|
149
|
+
$line.Background = New-Object System.Windows.Media.SolidColorBrush($accent)
|
|
150
|
+
$line.Effect = New-GlowEffect $accent 0.55
|
|
151
|
+
foreach ($side in @('Left', 'Right')) {
|
|
152
|
+
$dot = New-Object System.Windows.Shapes.Ellipse
|
|
153
|
+
$dot.Width = 6
|
|
154
|
+
$dot.Height = 6
|
|
155
|
+
$dot.Fill = New-Object System.Windows.Media.SolidColorBrush($accent)
|
|
156
|
+
# 竖直居中于 2px 线上,视觉上成为线段两端的端点。
|
|
157
|
+
# 注意:枚举成员不能用 ::$side 这种写法展开(PowerShell 会当成属性访问而报错),
|
|
158
|
+
# 必须显式分支赋值。
|
|
159
|
+
if ($side -eq 'Left') {
|
|
160
|
+
$dot.HorizontalAlignment = [System.Windows.HorizontalAlignment]::Left
|
|
161
|
+
} else {
|
|
162
|
+
$dot.HorizontalAlignment = [System.Windows.HorizontalAlignment]::Right
|
|
163
|
+
}
|
|
164
|
+
$dot.Margin = New-Object System.Windows.Thickness(0, -2, 0, -2)
|
|
165
|
+
$line.Children.Add($dot) | Out-Null
|
|
166
|
+
}
|
|
167
|
+
$grid.Children.Add($line) | Out-Null
|
|
168
|
+
|
|
169
|
+
# ② ↑ + 可选文字:叠放在线之下(同一格)。
|
|
170
|
+
# 三列等分(各 1*):中列 = ↑,天然落在整条带的正中点;右列 = 文字。
|
|
171
|
+
# 这样"文字不会把 ↑ 推偏",且文字过宽时由自身的 TextTrimming 处理。
|
|
172
|
+
$cap = New-Object System.Windows.Controls.Grid
|
|
173
|
+
$cap.HorizontalAlignment = [System.Windows.HorizontalAlignment]::Stretch
|
|
174
|
+
$cap.VerticalAlignment = [System.Windows.VerticalAlignment]::Top
|
|
175
|
+
$cap.Margin = New-Object System.Windows.Thickness(0, 3, 0, 0)
|
|
176
|
+
foreach ($i in 1..3) {
|
|
177
|
+
$col = New-Object System.Windows.Controls.ColumnDefinition
|
|
178
|
+
$col.Width = New-Object System.Windows.GridLength(1, [System.Windows.GridUnitType]::Star)
|
|
179
|
+
$cap.ColumnDefinitions.Add($col) | Out-Null
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
$arrow = New-Object System.Windows.Shapes.Polygon
|
|
183
|
+
$arrow.Points = New-AnchorArrowPoints $script:ANCHOR_ARROW_W $script:ANCHOR_ARROW_H
|
|
184
|
+
$arrow.Fill = New-Object System.Windows.Media.SolidColorBrush($accent)
|
|
185
|
+
$arrow.Width = $script:ANCHOR_ARROW_W
|
|
186
|
+
$arrow.Height = $script:ANCHOR_ARROW_H
|
|
187
|
+
$arrow.Stretch = [System.Windows.Media.Stretch]::Fill
|
|
188
|
+
$arrow.Effect = New-GlowEffect $accent 0.45
|
|
189
|
+
# 顶点贴住横线,只留 $script:ANCHOR_ARROW_GAP 的呼吸量(矢量图形无字形死区)。
|
|
190
|
+
$arrow.Margin = New-Object System.Windows.Thickness(0, $script:ANCHOR_ARROW_GAP, 0, 0)
|
|
191
|
+
$arrow.VerticalAlignment = [System.Windows.VerticalAlignment]::Top
|
|
192
|
+
# 用一个撑满中列的容器来居中箭头:不依赖"列宽 = 箭头宽"这一巧合,
|
|
193
|
+
# 否则同列的文字一变宽,箭头就会被带偏。
|
|
194
|
+
$arrowBox = New-Object System.Windows.Controls.Border
|
|
195
|
+
$arrowBox.HorizontalAlignment = [System.Windows.HorizontalAlignment]::Stretch
|
|
196
|
+
$arrowBox.VerticalAlignment = [System.Windows.VerticalAlignment]::Top
|
|
197
|
+
$arrowBox.Child = $arrow
|
|
198
|
+
[System.Windows.Controls.Grid]::SetColumn($arrowBox, 1)
|
|
199
|
+
$cap.Children.Add($arrowBox) | Out-Null
|
|
200
|
+
|
|
201
|
+
if (-not [string]::IsNullOrEmpty($label)) {
|
|
202
|
+
$txt = New-Object System.Windows.Controls.TextBlock
|
|
203
|
+
$txt.Text = $label
|
|
204
|
+
$txt.Foreground = New-Object System.Windows.Media.SolidColorBrush($accent)
|
|
205
|
+
$txt.FontFamily = New-Object System.Windows.Media.FontFamily("Segoe UI, Microsoft YaHei UI")
|
|
206
|
+
$txt.FontSize = 11
|
|
207
|
+
$txt.Opacity = 0.92
|
|
208
|
+
$txt.VerticalAlignment = [System.Windows.VerticalAlignment]::Top
|
|
209
|
+
$txt.HorizontalAlignment = [System.Windows.HorizontalAlignment]::Left
|
|
210
|
+
$txt.Margin = New-Object System.Windows.Thickness(10, 1, 0, 0)
|
|
211
|
+
# 限宽 + 省略号:文案过长时按字符截断,不挤压 ↑、也不超出窗口。
|
|
212
|
+
$txt.TextTrimming = [System.Windows.TextTrimming]::CharacterEllipsis
|
|
213
|
+
$txt.MaxWidth = [Math]::Max(80, $maxLabelWidth)
|
|
214
|
+
[System.Windows.Controls.Grid]::SetColumn($txt, 2)
|
|
215
|
+
$cap.Children.Add($txt) | Out-Null
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
$grid.Children.Add($cap) | Out-Null
|
|
219
|
+
return $grid
|
|
220
|
+
}
|
|
221
|
+
|
|
77
222
|
# 编辑模式样板窗:拖拽移动 + 左右边缘圆点把手缩放;拖拽/缩放结束后回传 geometry。
|
|
78
|
-
# 强调色描边 + 辉光;内嵌 5 类型实时色卡预览(chips)
|
|
223
|
+
# 强调色描边 + 辉光;内嵌 5 类型实时色卡预览(chips);多语言提示;
|
|
224
|
+
# 卡片下方带"基准线 + ↑"指示带(方案 A)。
|
|
79
225
|
function New-EditSample($cmd) {
|
|
80
226
|
$bg = Parse-Color ([string]$cmd.bg) "#203a5c"
|
|
81
227
|
$fg = Parse-Color ([string]$cmd.fg) "#e8f0fb"
|
|
@@ -84,6 +230,25 @@ function New-EditSample($cmd) {
|
|
|
84
230
|
$fgBrush = New-Object System.Windows.Media.SolidColorBrush($fg)
|
|
85
231
|
$accentBrush = New-Object System.Windows.Media.SolidColorBrush($accent)
|
|
86
232
|
|
|
233
|
+
# ── 几何总纲(务必与手柄、带行的算式保持一致) ──
|
|
234
|
+
# $glowMargin 同时是:左右辉光呼吸量、卡片左/右/上边距、左右缩放把手的边距。
|
|
235
|
+
# · 卡片:左/右边距相等 → 始终居中;卡宽 = 窗口宽 − 2×$glowMargin。
|
|
236
|
+
# · 手柄:左右边距同样为 $glowMargin(宽 8px)→ 左右手柄"内沿"之间
|
|
237
|
+
# = 窗口宽 − 2×$glowMargin = 卡宽,即手柄与卡片严格等宽、等左右沿。
|
|
238
|
+
# · 下边距必须为 0:卡片因此一直铺到第 0 行的底边,而第 1 行(带行)紧随其后,
|
|
239
|
+
# 所以横线正好压在卡片可见底边上;卡片高度 = 行高 = 手柄高度。
|
|
240
|
+
# 若在这里留边距,卡片会在自己所在行内被顶上去,横线就会离开卡片底边。
|
|
241
|
+
# · 底部呼吸量由带行自身(透明)提供,无需再留边距。
|
|
242
|
+
$glowMargin = 8
|
|
243
|
+
$bandHeight = [double]$script:ANCHOR_BAND_H
|
|
244
|
+
$bottomMargin = 0
|
|
245
|
+
# 基准线(卡片可见底边)到**窗口底边**的距离 = 卡片下边距 + 带高。
|
|
246
|
+
# 实测:带(含自身下边距)紧贴第 0 行底部,故卡片可见底边 = 窗口高 − 该值,
|
|
247
|
+
# 也就是横线自身的 Y。这是"从底部算"的量。
|
|
248
|
+
# 注意:真正用于定位/回传的是"从窗口顶边算"的 lineInWin(在 Loaded 里实测),
|
|
249
|
+
# 两者基准不同,不可混用 —— 混用会导致每次重开编辑模式基准线漂移一个带高。
|
|
250
|
+
$baselineFromBottom = $bottomMargin + $bandHeight
|
|
251
|
+
|
|
87
252
|
$win = New-Object System.Windows.Window
|
|
88
253
|
$win.WindowStyle = [System.Windows.WindowStyle]::None
|
|
89
254
|
$win.AllowsTransparency = $true
|
|
@@ -99,7 +264,7 @@ function New-EditSample($cmd) {
|
|
|
99
264
|
$border.BorderBrush = $accentBrush
|
|
100
265
|
$border.BorderThickness = New-Object System.Windows.Thickness(1)
|
|
101
266
|
$border.CornerRadius = New-Object System.Windows.CornerRadius(14)
|
|
102
|
-
$border.Margin = New-Object System.Windows.Thickness(
|
|
267
|
+
$border.Margin = New-Object System.Windows.Thickness($glowMargin, $glowMargin, $glowMargin, $bottomMargin)
|
|
103
268
|
|
|
104
269
|
$root = New-Object System.Windows.Controls.StackPanel
|
|
105
270
|
$root.Margin = New-Object System.Windows.Thickness(16, 14, 16, 14)
|
|
@@ -175,6 +340,8 @@ function New-EditSample($cmd) {
|
|
|
175
340
|
$leftStrip.Cursor = [System.Windows.Input.Cursors]::SizeWE
|
|
176
341
|
$leftStrip.HorizontalAlignment = [System.Windows.HorizontalAlignment]::Left
|
|
177
342
|
$leftStrip.VerticalAlignment = [System.Windows.VerticalAlignment]::Stretch
|
|
343
|
+
# 把手竖直范围 = 卡片可见上下沿(下边距用带高,故把手不会压到基准线指示带上)。
|
|
344
|
+
$leftStrip.Margin = New-Object System.Windows.Thickness(0, $glowMargin, 0, $bottomMargin)
|
|
178
345
|
$leftStrip.Background = $edgeBrush
|
|
179
346
|
$leftStrip.CornerRadius = New-Object System.Windows.CornerRadius(14, 0, 0, 14)
|
|
180
347
|
$rightStrip = New-Object System.Windows.Controls.Border
|
|
@@ -182,6 +349,7 @@ function New-EditSample($cmd) {
|
|
|
182
349
|
$rightStrip.Cursor = [System.Windows.Input.Cursors]::SizeWE
|
|
183
350
|
$rightStrip.HorizontalAlignment = [System.Windows.HorizontalAlignment]::Right
|
|
184
351
|
$rightStrip.VerticalAlignment = [System.Windows.VerticalAlignment]::Stretch
|
|
352
|
+
$rightStrip.Margin = New-Object System.Windows.Thickness(0, $glowMargin, 0, $bottomMargin)
|
|
185
353
|
$rightStrip.Background = $edgeBrush
|
|
186
354
|
$rightStrip.CornerRadius = New-Object System.Windows.CornerRadius(0, 14, 14, 0)
|
|
187
355
|
|
|
@@ -206,24 +374,62 @@ function New-EditSample($cmd) {
|
|
|
206
374
|
Add-GripDots $leftStrip $fg
|
|
207
375
|
Add-GripDots $rightStrip $fg
|
|
208
376
|
|
|
209
|
-
# 外层 Grid:边框 + 左右缩放把手叠放(把手覆盖窗口左右边缘)
|
|
377
|
+
# 外层 Grid:边框 + 基准线指示带 + 左右缩放把手叠放(把手覆盖窗口左右边缘)
|
|
210
378
|
$outer = New-Object System.Windows.Controls.Grid
|
|
379
|
+
# 两条等分行:第 0 行卡片(占满剩余高度),第 1 行 = 基准线指示带。
|
|
380
|
+
$rowCard = New-Object System.Windows.Controls.RowDefinition
|
|
381
|
+
$rowCard.Height = New-Object System.Windows.GridLength(1, [System.Windows.GridUnitType]::Star)
|
|
382
|
+
$rowBand = New-Object System.Windows.Controls.RowDefinition
|
|
383
|
+
$rowBand.Height = [System.Windows.GridLength]::Auto
|
|
384
|
+
$outer.RowDefinitions.Add($rowCard) | Out-Null
|
|
385
|
+
$outer.RowDefinitions.Add($rowBand) | Out-Null
|
|
386
|
+
|
|
387
|
+
# 文案限宽:窗口宽度的一半多一点,过长按字符省略。
|
|
388
|
+
$band = New-AnchorBand $accent ([string]$cmd.anchorLabel) ([Math]::Max(80, $win.Width * 0.55))
|
|
389
|
+
[System.Windows.Controls.Grid]::SetRow($band, 1)
|
|
211
390
|
$outer.Children.Add($border) | Out-Null
|
|
391
|
+
$outer.Children.Add($band) | Out-Null
|
|
212
392
|
$outer.Children.Add($leftStrip) | Out-Null
|
|
213
393
|
$outer.Children.Add($rightStrip) | Out-Null
|
|
214
394
|
|
|
215
395
|
# 共享拖拽/缩放状态(事件回调看不到函数局部变量,放 Tag)
|
|
216
|
-
|
|
396
|
+
# lineInWin:基准线(卡片可见底边)从**窗口顶边**量起的 Y 偏移。
|
|
397
|
+
# 定位与回传都用它,保证"存下的坐标"与"重开时的落点"是同一个量。
|
|
398
|
+
$win.Tag = @{
|
|
399
|
+
resizing = $false
|
|
400
|
+
startX = 0.0
|
|
401
|
+
startWidth = 0.0
|
|
402
|
+
startLeft = 0.0
|
|
403
|
+
edge = 'right'
|
|
404
|
+
lineInWin = 0.0
|
|
405
|
+
}
|
|
217
406
|
|
|
218
407
|
$win.Add_Loaded({
|
|
219
408
|
$work = [System.Windows.SystemParameters]::WorkArea
|
|
220
409
|
$pos = $cmd.position
|
|
410
|
+
# ── 基准线自测(消除"从顶部算/从底部算"的混用) ──
|
|
411
|
+
# 先把窗口顶边临时归零,等布局落定后量出基准线在窗口内的真实 Y(从窗口顶边算起)。
|
|
412
|
+
# 之后定位与 Send-Geometry 回传都用这同一个量,于是"存下的坐标"与"重开的落点"
|
|
413
|
+
# 必然一致,不会再出现每进一次编辑模式就漂移一截的问题。
|
|
414
|
+
$win.Top = 0
|
|
415
|
+
$win.UpdateLayout()
|
|
416
|
+
$lineInWin = $border.TranslatePoint(
|
|
417
|
+
(New-Object System.Windows.Point(0, $border.ActualHeight)), $win).Y
|
|
418
|
+
if ($lineInWin -le 0) {
|
|
419
|
+
# 兜底:布局尚未就绪时用"窗口高 − 从底部算的距离"估算。
|
|
420
|
+
$lineInWin = $win.ActualHeight - $bottomMargin - $bandHeight
|
|
421
|
+
}
|
|
422
|
+
$win.Tag.lineInWin = $lineInWin
|
|
221
423
|
if ($null -ne $pos -and $null -ne $pos.left -and $null -ne $pos.top) {
|
|
222
424
|
$win.Left = [double]$pos.left + $work.Left
|
|
223
|
-
$win.Top = [double]$pos.top + $work.Top
|
|
425
|
+
$win.Top = [double]$pos.top + $work.Top - $lineInWin
|
|
224
426
|
} else {
|
|
225
|
-
|
|
226
|
-
|
|
427
|
+
# 编辑模式的样板窗贴住工作区右下角:窗口底边 = 工作区底边。
|
|
428
|
+
# (此处不加 20px 呼吸量 —— 用户明确要求样板窗能贴到屏幕最底部;
|
|
429
|
+
# 窗口底边即"基准线 + ↑"指示带所在行,基准线因此落在离屏幕底
|
|
430
|
+
# 一个带高的位置,通知向上堆叠时正好从屏幕底部往上排。)
|
|
431
|
+
$win.Left = $work.Right - $win.ActualWidth
|
|
432
|
+
$win.Top = $work.Bottom - $win.ActualHeight
|
|
227
433
|
}
|
|
228
434
|
Send-Geometry $win
|
|
229
435
|
}.GetNewClosure()) | Out-Null
|
|
@@ -313,6 +519,222 @@ $downI = New-Object System.Windows.Media.SolidColorBrush([System.Windows.Media.C
|
|
|
313
519
|
$hoverP = New-Object System.Windows.Media.SolidColorBrush([System.Windows.Media.Color]::FromRgb(196, 202, 208))
|
|
314
520
|
$hoverI = New-Object System.Windows.Media.SolidColorBrush([System.Windows.Media.Color]::FromRgb(44, 52, 62))
|
|
315
521
|
|
|
522
|
+
# 「返回应用」:把已在运行的 DeepSeek Harness 桌面窗口切到前台。
|
|
523
|
+
#
|
|
524
|
+
# 为什么不能靠 dsh://open:应用只在 macOS 的 `open-url` 事件里处理 `dsh://open`
|
|
525
|
+
# (Windows 上协议 URL 是作为 argv 交给新进程的,而它的 `second-instance` 处理器
|
|
526
|
+
# 不读 argv、也不校验 URL),所以那条路在 Windows 上是死代码。
|
|
527
|
+
#
|
|
528
|
+
# 因此改为直接枚举窗口并激活。合法性:用户点按钮的那一刻,前台窗口就是这个 WPF
|
|
529
|
+
# 浮窗(属于本宿主进程),所以宿主调用 SetForegroundWindow 不会被
|
|
530
|
+
# "只有前台进程才能抢占前台"的系统限制挡住。
|
|
531
|
+
$script:ToastWin32Ready = $false
|
|
532
|
+
try {
|
|
533
|
+
if (-not ('ToastWin32' -as [type])) {
|
|
534
|
+
Add-Type -TypeDefinition @'
|
|
535
|
+
using System;
|
|
536
|
+
using System.Runtime.InteropServices;
|
|
537
|
+
|
|
538
|
+
public class ToastWin32 {
|
|
539
|
+
public delegate bool EnumProc(IntPtr hWnd, IntPtr lParam);
|
|
540
|
+
|
|
541
|
+
[StructLayout(LayoutKind.Sequential)]
|
|
542
|
+
public struct RECT { public int Left, Top, Right, Bottom; }
|
|
543
|
+
|
|
544
|
+
[DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc cb, IntPtr p);
|
|
545
|
+
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h);
|
|
546
|
+
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);
|
|
547
|
+
[DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetWindowTextLength(IntPtr h);
|
|
548
|
+
[DllImport("user32.dll")] public static extern IntPtr GetShellWindow();
|
|
549
|
+
[DllImport("user32.dll")] public static extern IntPtr GetWindow(IntPtr h, uint cmd);
|
|
550
|
+
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r);
|
|
551
|
+
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd);
|
|
552
|
+
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
|
|
553
|
+
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
|
|
554
|
+
[DllImport("user32.dll")] public static extern bool AttachThreadInput(uint a, uint b, bool f);
|
|
555
|
+
[DllImport("user32.dll")] public static extern bool GetWindowPlacement(IntPtr h, ref WINDOWPLACEMENT wp);
|
|
556
|
+
[DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h);
|
|
557
|
+
[DllImport("user32.dll")] public static extern bool IsZoomed(IntPtr h);
|
|
558
|
+
|
|
559
|
+
[StructLayout(LayoutKind.Sequential)]
|
|
560
|
+
public struct POINT { public int X, Y; }
|
|
561
|
+
|
|
562
|
+
[StructLayout(LayoutKind.Sequential)]
|
|
563
|
+
public struct WINDOWPLACEMENT {
|
|
564
|
+
public int length;
|
|
565
|
+
public int flags;
|
|
566
|
+
public int showCmd;
|
|
567
|
+
public POINT ptMinPosition;
|
|
568
|
+
public POINT ptMaxPosition;
|
|
569
|
+
public RECT rcNormalPosition;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
private const uint GW_OWNER = 4;
|
|
573
|
+
private const int SW_SHOWNORMAL = 1;
|
|
574
|
+
private const int SW_SHOWMINIMIZED = 2;
|
|
575
|
+
private const int SW_SHOWMAXIMIZED = 3;
|
|
576
|
+
private const int SW_SHOW = 5;
|
|
577
|
+
private const int SW_RESTORE = 9;
|
|
578
|
+
/// WPF_RESTORETOMAXIMIZED:最小化前是最大化状态
|
|
579
|
+
private const int WPF_RESTORETOMAXIMIZED = 0x0002;
|
|
580
|
+
|
|
581
|
+
/// 找出 pid 名下最可能是"主窗口"的顶层窗口。
|
|
582
|
+
///
|
|
583
|
+
/// 实测要点(这台机器上枚举到 280 个窗口、其中 20 个可见):
|
|
584
|
+
/// 应用最小化到托盘 / 被隐藏时,它的主窗口会变成 **不可见**,甚至带上 owner
|
|
585
|
+
/// 关系。若像最初那样要求"可见 + 非 owner",这两条会把主窗口全部滤掉,
|
|
586
|
+
/// 结果就是"点了返回应用没反应"。因此这里分级挑选:
|
|
587
|
+
/// 1) 可见 + 有标题 + 非 owner(正常情况,最可靠)
|
|
588
|
+
/// 2) 有标题 + 非 owner(隐藏/托盘态)
|
|
589
|
+
/// 3) 有标题(连 owner 关系都能容忍)
|
|
590
|
+
/// 每级内部取面积最大者,Electron 的辅助窗口通常面积很小或没有标题。
|
|
591
|
+
public static IntPtr FindMainWindow(uint pid) {
|
|
592
|
+
IntPtr shell = GetShellWindow();
|
|
593
|
+
IntPtr[] best = new IntPtr[3];
|
|
594
|
+
long[] bestArea = new long[3] { -1, -1, -1 };
|
|
595
|
+
EnumWindows(delegate(IntPtr h, IntPtr p) {
|
|
596
|
+
uint wpid = 0;
|
|
597
|
+
GetWindowThreadProcessId(h, out wpid);
|
|
598
|
+
if (wpid != pid) return true;
|
|
599
|
+
if (h == shell) return true;
|
|
600
|
+
if (GetWindowTextLength(h) <= 0) return true;
|
|
601
|
+
|
|
602
|
+
bool visible = IsWindowVisible(h);
|
|
603
|
+
bool owned = GetWindow(h, GW_OWNER) != IntPtr.Zero;
|
|
604
|
+
RECT r;
|
|
605
|
+
if (!GetWindowRect(h, out r)) return true;
|
|
606
|
+
long area = (long)(r.Right - r.Left) * (long)(r.Bottom - r.Top);
|
|
607
|
+
|
|
608
|
+
int tier;
|
|
609
|
+
if (visible && !owned) tier = 0;
|
|
610
|
+
else if (!owned) tier = 1;
|
|
611
|
+
else tier = 2;
|
|
612
|
+
|
|
613
|
+
if (area > bestArea[tier]) { bestArea[tier] = area; best[tier] = h; }
|
|
614
|
+
return true;
|
|
615
|
+
}, IntPtr.Zero);
|
|
616
|
+
|
|
617
|
+
for (int i = 0; i < 3; i++) { if (best[i] != IntPtr.Zero) return best[i]; }
|
|
618
|
+
return IntPtr.Zero;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/// 把 hWnd 提到前台,并**保持窗口原有的尺寸/最大化状态**。
|
|
622
|
+
///
|
|
623
|
+
/// 关键:`ShowWindow(h, SW_RESTORE)` 会把"最小化**或**最大化"的窗口还原成
|
|
624
|
+
/// 原始(默认)尺寸 —— 这正是"窗口本来最大化、只是不在最上层,点返回应用后
|
|
625
|
+
/// 变成默认大小"的原因。所以这里必须先查询显示状态:
|
|
626
|
+
/// · 最小化 → SW_RESTORE(恢复原尺寸,若原先最大化会恢复成最大化)
|
|
627
|
+
/// · 最大化 → SW_SHOWMAXIMIZED
|
|
628
|
+
/// · 普通 → SW_SHOW(只显示/置顶,**不改尺寸**)
|
|
629
|
+
/// SetForegroundWindow 受"仅前台进程可抢占前台"限制,失败时按通行做法
|
|
630
|
+
/// 附加到当前前台线程的输入队列后重试一次。
|
|
631
|
+
public static bool Activate(IntPtr h) {
|
|
632
|
+
if (h == IntPtr.Zero) return false;
|
|
633
|
+
|
|
634
|
+
WINDOWPLACEMENT wp = new WINDOWPLACEMENT();
|
|
635
|
+
wp.length = Marshal.SizeOf(typeof(WINDOWPLACEMENT));
|
|
636
|
+
int show = SW_SHOW;
|
|
637
|
+
if (GetWindowPlacement(h, ref wp)) {
|
|
638
|
+
int cur = wp.showCmd;
|
|
639
|
+
if (cur == SW_SHOWMINIMIZED) {
|
|
640
|
+
show = (wp.flags & WPF_RESTORETOMAXIMIZED) != 0 ? SW_SHOWMAXIMIZED : SW_RESTORE;
|
|
641
|
+
} else if (cur == SW_SHOWMAXIMIZED) {
|
|
642
|
+
show = SW_SHOWMAXIMIZED;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
ShowWindow(h, show);
|
|
646
|
+
|
|
647
|
+
if (SetForegroundWindow(h)) return true;
|
|
648
|
+
IntPtr fg = GetForegroundWindow();
|
|
649
|
+
uint dummy = 0;
|
|
650
|
+
uint tidFg = fg == IntPtr.Zero ? 0u : GetWindowThreadProcessId(fg, out dummy);
|
|
651
|
+
uint tidMe = GetWindowThreadProcessId(GetShellWindow(), out dummy);
|
|
652
|
+
if (tidFg != 0u && tidFg != tidMe) {
|
|
653
|
+
if (AttachThreadInput(tidMe, tidFg, true)) {
|
|
654
|
+
bool ok = SetForegroundWindow(h);
|
|
655
|
+
AttachThreadInput(tidMe, tidFg, false);
|
|
656
|
+
if (ok) return true;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
return SetForegroundWindow(h);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
'@ -ErrorAction Stop
|
|
663
|
+
}
|
|
664
|
+
$script:ToastWin32Ready = $true
|
|
665
|
+
} catch {
|
|
666
|
+
# Win32 互操作不可用(极少见):activateLabel 会因此不下发,按钮不出现。
|
|
667
|
+
$script:ToastWin32Ready = $false
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function Write-ActLog([string]$msg) {
|
|
671
|
+
# 诊断日志:激活这一步涉及"按 exe 路径找进程 + 枚举窗口"两处环境相关行为,
|
|
672
|
+
# 出问题时按钮只会静默无反应,没有日志无法定位。
|
|
673
|
+
# 落点优先级:脚本所在目录(真实宿主 = lib/)→ DSH_HOME。
|
|
674
|
+
try {
|
|
675
|
+
$line = "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') $msg`r`n"
|
|
676
|
+
$enc = New-Object System.Text.UTF8Encoding($false)
|
|
677
|
+
$dirs = @()
|
|
678
|
+
if (-not [string]::IsNullOrEmpty($PSScriptRoot)) { $dirs += $PSScriptRoot }
|
|
679
|
+
if (-not [string]::IsNullOrEmpty($env:DSH_HOME)) { $dirs += $env:DSH_HOME }
|
|
680
|
+
foreach ($d in $dirs) {
|
|
681
|
+
try {
|
|
682
|
+
[System.IO.File]::AppendAllText((Join-Path $d 'activate-debug.log'), $line, $enc)
|
|
683
|
+
return
|
|
684
|
+
} catch { }
|
|
685
|
+
}
|
|
686
|
+
} catch { }
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
# 启动探针:宿主每次加载(含插件重载)都记一行门控状态。
|
|
690
|
+
# 必须放在 Write-ActLog 定义之后,否则这个调用会因函数未定义而静默失效。
|
|
691
|
+
try {
|
|
692
|
+
$probe = "boot: Win32Ready=$script:ToastWin32Ready"
|
|
693
|
+
try { $probe += " selfExe='$((Get-Process -Id $PID).Path)'" } catch { $probe += " selfExe=<取不到>" }
|
|
694
|
+
try { $probe += " DSH_HOME='$env:DSH_HOME'" } catch { }
|
|
695
|
+
try { $probe += " PSScriptRoot='$PSScriptRoot'" } catch { }
|
|
696
|
+
Write-ActLog $probe
|
|
697
|
+
} catch { }
|
|
698
|
+
|
|
699
|
+
function Invoke-ToastActivateApp([string]$appPath) {
|
|
700
|
+
try {
|
|
701
|
+
Write-ActLog "--- click ---"
|
|
702
|
+
Write-ActLog "Win32Ready=$script:ToastWin32Ready appPath='$appPath'"
|
|
703
|
+
if (-not $script:ToastWin32Ready) { Write-ActLog "中止: Win32 助手不可用"; return }
|
|
704
|
+
if ([string]::IsNullOrEmpty($appPath)) { Write-ActLog "中止: appPath 为空"; return }
|
|
705
|
+
$full = $null
|
|
706
|
+
try { $full = [System.IO.Path]::GetFullPath($appPath) } catch { Write-ActLog "中止: GetFullPath 失败"; return }
|
|
707
|
+
Write-ActLog "full='$full' exists=$(Test-Path -LiteralPath $full)"
|
|
708
|
+
if (-not (Test-Path -LiteralPath $full)) { Write-ActLog "中止: exe 路径不存在"; return }
|
|
709
|
+
|
|
710
|
+
# Electron 的主进程与各渲染进程都是同一个 exe,窗口可能挂在其中任意一个 PID 上,
|
|
711
|
+
# 所以把全部同 exe 的进程当作一组来挑窗口,而不是"谁先回答就用谁"。
|
|
712
|
+
$procs = @()
|
|
713
|
+
foreach ($p in (Get-Process -ErrorAction SilentlyContinue)) {
|
|
714
|
+
try { if ($p.Path -eq $full) { $procs += $p } } catch { }
|
|
715
|
+
}
|
|
716
|
+
Write-ActLog "按 exe 匹配=$($procs.Count) pids=$(($procs | ForEach-Object { $_.Id }) -join ',')"
|
|
717
|
+
if ($procs.Count -eq 0) {
|
|
718
|
+
# 兜底:按进程名匹配(某些情况下 Path 取不到)。
|
|
719
|
+
$nm = [System.IO.Path]::GetFileNameWithoutExtension($full)
|
|
720
|
+
$procs = @(Get-Process -Name $nm -ErrorAction SilentlyContinue)
|
|
721
|
+
Write-ActLog "按名 '$nm' 兜底=$($procs.Count) pids=$(($procs | ForEach-Object { $_.Id }) -join ',')"
|
|
722
|
+
}
|
|
723
|
+
foreach ($p in $procs) {
|
|
724
|
+
$h = [ToastWin32]::FindMainWindow([uint32]$p.Id)
|
|
725
|
+
Write-ActLog " pid=$($p.Id) hwnd=$h"
|
|
726
|
+
if ($h -ne [IntPtr]::Zero) {
|
|
727
|
+
$ok = [ToastWin32]::Activate($h)
|
|
728
|
+
Write-ActLog " Activate=$ok fg=$([ToastWin32]::GetForegroundWindow())"
|
|
729
|
+
if ($ok) { return }
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
Write-ActLog "结束: 未找到可激活窗口"
|
|
733
|
+
} catch {
|
|
734
|
+
Write-ActLog "异常: $($_.Exception.GetType().Name) :: $($_.Exception.Message)"
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
316
738
|
# 浮窗按钮:primary 时传入强调色画刷,hover/按压基于该色自动提亮/压暗;
|
|
317
739
|
# secondary 保持描边透明底样式。
|
|
318
740
|
function New-ToastButton([string]$label, [scriptblock]$onClick, [bool]$primary, [System.Windows.Media.SolidColorBrush]$fg, [System.Windows.Media.SolidColorBrush]$bg) {
|
|
@@ -500,6 +922,19 @@ function New-ToastWindow($cmd) {
|
|
|
500
922
|
$ignoreLabel = if ([string]::IsNullOrEmpty([string]$cmd.ignoreLabel)) { "Ignore" } else { [string]$cmd.ignoreLabel }
|
|
501
923
|
$ignoreBtn = New-ToastButton $ignoreLabel ({ Close-WithFade $win }.GetNewClosure()) $false $fgBrush $bgBrush
|
|
502
924
|
$btnRow.Children.Add($ignoreBtn) | Out-Null
|
|
925
|
+
# 「返回应用」:位于「忽略」右侧;仅桌面端出现(由 index.js 按 isDesktopRuntime 下发
|
|
926
|
+
# activateLabel)。点击后把应用窗口切到前台,但**不**关闭本条通知。
|
|
927
|
+
# 另外要求 Win32 互操作可用(否则无法激活窗口,宁可不显示这个按钮)。
|
|
928
|
+
$activateLabel = [string]$cmd.activateLabel
|
|
929
|
+
$appPath = [string]$cmd.appPath
|
|
930
|
+
if ($script:ToastWin32Ready -and -not [string]::IsNullOrEmpty($activateLabel) -and
|
|
931
|
+
-not [string]::IsNullOrEmpty($appPath)) {
|
|
932
|
+
$activateBtn = New-ToastButton $activateLabel ({
|
|
933
|
+
Invoke-ToastActivateApp $appPath
|
|
934
|
+
# 按要求保留通知,不做 Close-WithFade。
|
|
935
|
+
}.GetNewClosure()) $false $fgBrush $bgBrush
|
|
936
|
+
$btnRow.Children.Add($activateBtn) | Out-Null
|
|
937
|
+
}
|
|
503
938
|
# 缺省 true = 保留「跳转会话」;桌面端宿主会显式传 jumpEnabled=false 来隐藏它。
|
|
504
939
|
$jumpEnabled = $true
|
|
505
940
|
if ($cmd.PSObject.Properties.Name -contains 'jumpEnabled') { $jumpEnabled = [bool]$cmd.jumpEnabled }
|
|
@@ -555,12 +990,32 @@ function New-ToastWindow($cmd) {
|
|
|
555
990
|
$win.Add_Loaded({
|
|
556
991
|
$work = [System.Windows.SystemParameters]::WorkArea
|
|
557
992
|
$pos =$cmd.position
|
|
993
|
+
# ── 底部锚定定位 ──
|
|
994
|
+
# 目标:本条通知"可见卡片底边"落在锚点上(卡片多高都只影响顶边)。
|
|
995
|
+
#
|
|
996
|
+
# 抬升量由布局实测得出,而不是靠写死的常量:
|
|
997
|
+
# $border 就是那张可见卡片,先把窗口顶边临时放到 0,量出它相对窗口的下边缘
|
|
998
|
+
# ($borderBottomInWin),于是
|
|
999
|
+
# 窗口顶边 = 锚点Y − $borderBottomInWin − offsetY
|
|
1000
|
+
# 其中 offsetY = 该条之上已叠的高度(首条为 0)。
|
|
1001
|
+
# 先前用固定常量推算,卡片高度随内容变化时底边会偏离锚点(实测可差 12px 以上),
|
|
1002
|
+
# 所以这里改成"用布局真实值",与卡片高度彻底解耦。
|
|
1003
|
+
# 另:也不能写成 "$pos.top − offsetY"(那是把 pos.top 当窗口顶边的顶边锚定)。
|
|
1004
|
+
$win.Top = 0
|
|
1005
|
+
$win.UpdateLayout()
|
|
1006
|
+
$borderBottomInWin = $border.TranslatePoint(
|
|
1007
|
+
(New-Object System.Windows.Point(0, $border.ActualHeight)), $win).Y
|
|
1008
|
+
if ($borderBottomInWin -le 0) {
|
|
1009
|
+
# 兜底:布局尚未就绪时退回到"窗口高 − 常量"的估算。
|
|
1010
|
+
$borderBottomInWin = $win.ActualHeight - $script:TOAST_BOTTOM_GAP
|
|
1011
|
+
}
|
|
558
1012
|
if ($null -ne$pos -and $null -ne$pos.left -and $null -ne$pos.top) {
|
|
559
1013
|
$targetLeft = [double]$pos.left + $work.Left
|
|
560
|
-
$targetTop = [double]$pos.top + $work.Top - [int]$cmd.offsetY
|
|
1014
|
+
$targetTop = [double]$pos.top + $work.Top - $borderBottomInWin - [int]$cmd.offsetY
|
|
561
1015
|
} else {
|
|
1016
|
+
# 默认右下:可见卡片底边距工作区底边 20px(与 index.js 的 TOAST_EDGE_PAD 一致)。
|
|
562
1017
|
$targetLeft =$work.Right - $win.ActualWidth - 20
|
|
563
|
-
$targetTop =$work.Bottom -
|
|
1018
|
+
$targetTop =$work.Bottom - 20 - $borderBottomInWin - [int]$cmd.offsetY
|
|
564
1019
|
}
|
|
565
1020
|
$win.Left =$targetLeft
|
|
566
1021
|
$win.Top =$targetTop + 36
|
package/package.json
CHANGED