dsh-notify-yimit 0.1.3 → 0.1.4

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/lib/client.js CHANGED
@@ -40,7 +40,10 @@ window.__ModuleLoader__.load({
40
40
  '.dn-seg{display:flex;gap:2px;padding:2px;border-radius:10px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);max-width:520px}',
41
41
  '.dn-seg-btn{flex:1;font:inherit;font-size:13px;color:var(--dsw-alias-label-secondary);border:0;border-radius:8px;padding:6px 10px;cursor:pointer;background:transparent;transition:background .15s ease,color .15s ease;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
42
42
  '.dn-seg-btn:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}',
43
- '.dn-seg-btn[data-on="true"]{background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);box-shadow:var(--dsw-shadow-lv1)}',
43
+ // 选中态用主题主色 + 反色文字:浅色模式下与背景对比也足够清晰(此前浅色下
44
+ // 白底+深灰与容器太接近,深色正常)。
45
+ '.dn-seg-btn[data-on="true"]{background:var(--dsw-alias-state-business-primary);color:var(--dsw-alias-label-primary-inverted);box-shadow:var(--dsw-shadow-lv1)}',
46
+ '.dn-seg-btn[data-on="true"]:hover{background:var(--dsw-alias-state-business-primary);color:var(--dsw-alias-label-primary-inverted)}',
44
47
  '.dn-seg-btn:disabled{opacity:.45;cursor:not-allowed}',
45
48
  '.dn-seg-btn:disabled:hover{background:transparent;color:var(--dsw-alias-label-secondary)}',
46
49
  '.dn-collapse{display:grid;grid-template-rows:1fr;opacity:1;transition:grid-template-rows .24s ease,opacity .2s ease}',
@@ -49,14 +52,28 @@ window.__ModuleLoader__.load({
49
52
  '.dn-collapse>.dn-collapse-body>.dn-block{min-width:0}',
50
53
  '.dn-kind-list{display:flex;flex-direction:column;gap:8px}',
51
54
  '.dn-kind-row{display:flex;align-items:center;gap:12px;padding:8px 12px;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;background:var(--dsw-alias-bg-layer-1)}',
52
- '.dn-kind-swatch{flex:none;width:20px;height:20px;border-radius:6px;border:1px solid var(--dsw-alias-border-l2);box-shadow:inset 0 0 0 1px rgba(0,0,0,.12)}',
53
- '.dn-kind-name{flex:1;font-size:13px;font-weight:600;min-width:0}',
55
+ // 每类型的背景/文字/开关项右对齐(预览块留在左侧)
56
+ '.dn-kind-row>.dn-kind-field:first-of-type{margin-left:auto}',
57
+ // 样式预览:纯色块内嵌类型文字,始终用配置的背景/文字色(内联样式,不受主题影响)
58
+ '.dn-kind-preview{flex:none;min-width:72px;text-align:center;font-size:12px;font-weight:600;line-height:22px;padding:2px 10px;border-radius:6px;white-space:nowrap;box-shadow:inset 0 0 0 1px rgba(0,0,0,.08)}',
54
59
  '.dn-kind-field{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--dsw-alias-label-tertiary);flex:none}',
55
60
  '.dn-input{font:inherit;font-size:13px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:5px 10px;outline:none;max-width:120px}',
56
61
  '.dn-input:focus{border-color:var(--dsw-alias-state-business-primary)}',
57
62
  '.dn-input[type=color]{padding:2px 4px;width:48px;height:28px;cursor:pointer;max-width:none}',
58
63
  '.dn-input:disabled{opacity:.45;cursor:not-allowed}',
59
- '.dn-select{max-width:260px;cursor:pointer}',
64
+ // 浏览器选择器:原生 select 的展开列表为系统绘制、无法贴合主题,改为自绘下拉,
65
+ // 触发器与弹层都走 --dsw-* 变量,深浅色模式下与设置页风格一致。
66
+ '.dn-bselect{position:relative;width:280px;max-width:100%;flex:none}',
67
+ '.dn-bselect-trigger{display:flex;align-items:center;justify-content:space-between;gap:8px;width:100%;font:inherit;font-size:13px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:5px 10px;cursor:pointer;outline:none}',
68
+ '.dn-bselect-trigger:hover:not(:disabled){border-color:var(--dsw-alias-border-l2)}',
69
+ '.dn-bselect-trigger.open{border-color:var(--dsw-alias-state-business-primary)}',
70
+ '.dn-bselect-trigger:disabled{opacity:.45;cursor:not-allowed}',
71
+ '.dn-bselect-value{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
72
+ '.dn-bselect-arrow{flex:none;font-size:10px;color:var(--dsw-alias-label-tertiary)}',
73
+ '.dn-bselect-pop{position:absolute;top:calc(100% + 4px);left:0;right:0;z-index:100;background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-border-l1);border-radius:10px;box-shadow:var(--dsw-shadow-lv3);padding:4px;max-height:240px;overflow:auto}',
74
+ '.dn-bselect-opt{display:block;width:100%;text-align:left;font:inherit;font-size:13px;color:var(--dsw-alias-label-primary);background:transparent;border:0;border-radius:7px;padding:6px 10px;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
75
+ '.dn-bselect-opt:hover{background:var(--dsw-alias-interactive-bg-hover)}',
76
+ '.dn-bselect-opt.sel{color:var(--dsw-alias-state-business-primary);font-weight:600}',
60
77
  '.dn-color-preview{font-size:12px;color:var(--dsw-alias-label-tertiary);font-family:var(--dsw-font-mono)}',
61
78
  '.dn-btn{font:inherit;font-size:13px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-button-elevated-fill);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:6px 14px;cursor:pointer}',
62
79
  '.dn-btn:hover{background:var(--dsw-alias-interactive-bg-hover)}',
@@ -120,6 +137,12 @@ window.__ModuleLoader__.load({
120
137
  permissionDefault: '系统通知需要浏览器通知权限',
121
138
  requestPermission: '申请通知权限',
122
139
  triggerNote: '触发场景:任务完成、任务出错、运行中(实时内容)、等待审批、等待回答。通知标题为对话标题;点击通知或跳转按钮可打开对应会话。',
140
+ toastStyleTitle: '通知窗口样式',
141
+ toastGapLabel: '通知间距(px)',
142
+ enterEdit: '进入编辑模式',
143
+ endEdit: '完成编辑',
144
+ editHint: '拖动样板窗调整位置;拖拽窗口边缘调整宽度;完成后点击「完成编辑」保存。',
145
+ resetPosition: '恢复默认位置',
123
146
  saveHint: '所有设置修改后自动保存。',
124
147
  saved: '已保存',
125
148
  saveFailed: '保存失败:{message}',
@@ -160,6 +183,12 @@ window.__ModuleLoader__.load({
160
183
  permissionDefault: 'System notifications need browser notification permission',
161
184
  requestPermission: 'Request permission',
162
185
  triggerNote: 'Triggers: task completed, task failed, running (live activity), awaiting approval, awaiting answer. The title is the conversation title; clicking the notification or the jump button opens the session.',
186
+ toastStyleTitle: 'Toast window style',
187
+ toastGapLabel: 'Toast gap (px)',
188
+ enterEdit: 'Enter edit mode',
189
+ endEdit: 'Finish editing',
190
+ editHint: 'Drag the sample window to reposition; drag its edges to resize the width; click "Finish editing" to save.',
191
+ resetPosition: 'Reset to default position',
163
192
  saveHint: 'All settings changes are auto-saved.',
164
193
  saved: 'Saved',
165
194
  saveFailed: 'Save failed: {message}',
@@ -283,7 +312,8 @@ window.__ModuleLoader__.load({
283
312
  .colors) ? v.colors[key] : undefined
284
313
  return {
285
314
  bg: typeof entry?.bg === 'string' ? entry.bg : fallbackBg,
286
- fg: typeof entry?.fg === 'string' ? entry.fg : fallbackFg
315
+ fg: typeof entry?.fg === 'string' ? entry.fg : fallbackFg,
316
+ enabled: entry?.enabled !== false
287
317
  }
288
318
  }
289
319
  const topBg = typeof v.bgColor === 'string' ? v.bgColor : '#20272f'
@@ -297,6 +327,15 @@ window.__ModuleLoader__.load({
297
327
  bgColor: topBg,
298
328
  textColor: topFg,
299
329
  locale: v.locale === 'zh' || v.locale === 'en' ? v.locale : 'en',
330
+ toast: {
331
+ width: typeof v.toast?.width === 'number' ? v.toast.width : 340,
332
+ gap: typeof v.toast?.gap === 'number' ? v.toast.gap : 12,
333
+ position: v.toast !== null && typeof v.toast === 'object' && v.toast.position !== null &&
334
+ typeof v.toast.position === 'object' && !Array.isArray(v.toast.position) &&
335
+ typeof v.toast.position.left === 'number' && typeof v.toast.position.top === 'number'
336
+ ? { left: v.toast.position.left, top: v.toast.position.top }
337
+ : null,
338
+ },
300
339
  colors: {
301
340
  completed: kindColors('completed', topBg, topFg),
302
341
  error: kindColors('error', topBg, topFg),
@@ -433,6 +472,34 @@ window.__ModuleLoader__.load({
433
472
  typeSymbol: 'dsh-notify-yimit#BrowserList',
434
473
  schema: browsersCodec
435
474
  }
475
+ }, {
476
+ id: 'dsh-notify-yimit#notify/startEditMode',
477
+ service: 'notify',
478
+ namespace: 'notify',
479
+ method: 'startEditMode',
480
+ invocation: {
481
+ kind: 'direct'
482
+ },
483
+ parameters: [],
484
+ result: {
485
+ mode: 'strict',
486
+ typeSymbol: 'dsh-notify-yimit#AckResult',
487
+ schema: ackCodec
488
+ }
489
+ }, {
490
+ id: 'dsh-notify-yimit#notify/endEditMode',
491
+ service: 'notify',
492
+ namespace: 'notify',
493
+ method: 'endEditMode',
494
+ invocation: {
495
+ kind: 'direct'
496
+ },
497
+ parameters: [],
498
+ result: {
499
+ mode: 'strict',
500
+ typeSymbol: 'dsh-notify-yimit#AckResult',
501
+ schema: ackCodec
502
+ }
436
503
  }]
437
504
  }
438
505
 
@@ -585,6 +652,81 @@ window.__ModuleLoader__.load({
585
652
  }, entry[1])))
586
653
  }
587
654
 
655
+ /** 跳转浏览器选择器:原生 select 的展开列表无法贴合主题,自绘下拉(触发器 + 弹层)。 */
656
+ function BrowserSelect(props) {
657
+ const {
658
+ value,
659
+ options,
660
+ disabled,
661
+ onChange,
662
+ t
663
+ } = props
664
+ const [open, setOpen] = useState(false)
665
+ const wrapRef = useRef(null)
666
+ // 点击外部 / Esc 关闭弹层。
667
+ useEffect(() => {
668
+ if (!open) return
669
+ const onDown = (e) => {
670
+ if (wrapRef.current !== null && !wrapRef.current.contains(e.target)) setOpen(false)
671
+ }
672
+ const onKey = (e) => {
673
+ if (e.key === 'Escape') setOpen(false)
674
+ }
675
+ document.addEventListener('mousedown', onDown)
676
+ document.addEventListener('keydown', onKey)
677
+ return () => {
678
+ document.removeEventListener('mousedown', onDown)
679
+ document.removeEventListener('keydown', onKey)
680
+ }
681
+ }, [open])
682
+
683
+ const current = options.find((b) => b.path === value)
684
+ const pick = (path) => {
685
+ onChange(path)
686
+ setOpen(false)
687
+ }
688
+ return el('div', {
689
+ className: 'dn-bselect',
690
+ ref: wrapRef
691
+ },
692
+ el('button', {
693
+ type: 'button',
694
+ className: 'dn-bselect-trigger' + (open ? ' open' : ''),
695
+ disabled: disabled === true,
696
+ 'aria-haspopup': 'listbox',
697
+ 'aria-expanded': String(open),
698
+ onClick: () => {
699
+ if (disabled !== true) setOpen(!open)
700
+ },
701
+ },
702
+ el('span', {
703
+ className: 'dn-bselect-value'
704
+ }, current !== undefined ? current.name : t('browserDefault')),
705
+ el('span', {
706
+ className: 'dn-bselect-arrow'
707
+ }, open ? '▴' : '▾')),
708
+ open ? el('div', {
709
+ className: 'dn-bselect-pop',
710
+ role: 'listbox'
711
+ },
712
+ el('button', {
713
+ type: 'button',
714
+ role: 'option',
715
+ 'aria-selected': String(value === ''),
716
+ className: 'dn-bselect-opt' + (value === '' ? ' sel' : ''),
717
+ onClick: () => pick(''),
718
+ }, t('browserDefault')),
719
+ options.map((b) => el('button', {
720
+ type: 'button',
721
+ role: 'option',
722
+ key: b.id,
723
+ 'aria-selected': String(value === b.path),
724
+ className: 'dn-bselect-opt' + (value === b.path ? ' sel' : ''),
725
+ onClick: () => pick(b.path),
726
+ }, b.name)))
727
+ : null)
728
+ }
729
+
588
730
  const KIND_ORDER = ['completed', 'error', 'running', 'approval', 'question']
589
731
 
590
732
  function NotifySection(props) {
@@ -599,6 +741,15 @@ window.__ModuleLoader__.load({
599
741
  const [saving, setSaving] = useState(false)
600
742
  const [permission, setPermission] = useState(typeof Notification !== 'undefined' ? Notification
601
743
  .permission : 'unsupported')
744
+ // 通知窗口编辑模式(样板窗拖拽/缩放)
745
+ const [editing, setEditing] = useState(false)
746
+ // 最新值镜像:卸载清理时读取(避免空依赖闭包拿到陈旧值)。
747
+ const editingRef = useRef(false)
748
+ editingRef.current = editing
749
+ useEffect(() => () => {
750
+ // 离开通知设置页(组件卸载)时若仍在编辑模式,自动关闭样板窗。
751
+ if (editingRef.current) void api.endEditMode()
752
+ }, [])
602
753
 
603
754
  const saveTimerRef = useRef(null)
604
755
  const save = useCallback((patch) => {
@@ -653,18 +804,18 @@ window.__ModuleLoader__.load({
653
804
  const entry = config.colors?. [kind] ?? {}
654
805
  const bg = entry.bg || config.bgColor || '#20272f'
655
806
  const fg = entry.fg || config.textColor || '#e6edf3'
807
+ const enabled = entry.enabled !== false
656
808
  return el('div', {
657
809
  key: kind,
658
810
  className: 'dn-kind-row'
659
811
  },
812
+ // 样式预览:类型文字显示在配置色的纯色块内(内联样式,不受深色模式影响)
660
813
  el('span', {
661
- className: 'dn-kind-swatch',
814
+ className: 'dn-kind-preview',
662
815
  style: {
663
- background: bg
816
+ background: bg,
817
+ color: fg
664
818
  }
665
- }),
666
- el('span', {
667
- className: 'dn-kind-name'
668
819
  }, kindLabel(kind, t)),
669
820
  el('label', {
670
821
  className: 'dn-kind-field'
@@ -701,7 +852,28 @@ window.__ModuleLoader__.load({
701
852
  }
702
853
  }
703
854
  })
704
- }), t('colorFg')))
855
+ }), t('colorFg')),
856
+ // 每类型开关:控制该类型通知是否产生(默认全开)
857
+ el('label', {
858
+ className: 'dn-kind-field'
859
+ },
860
+ el('button', {
861
+ type: 'button',
862
+ role: 'switch',
863
+ 'aria-checked': enabled,
864
+ 'aria-label': kindLabel(kind, t),
865
+ className: 'dn-switch',
866
+ 'data-on': String(enabled),
867
+ disabled: controlsDisabled,
868
+ onClick: () => save({
869
+ colors: {
870
+ [kind]: {
871
+ ...entry,
872
+ enabled: !enabled
873
+ }
874
+ }
875
+ })
876
+ })))
705
877
  })
706
878
 
707
879
  const customOpen = config.mode === 'custom'
@@ -764,21 +936,15 @@ window.__ModuleLoader__.load({
764
936
  el('span', {
765
937
  className: 'dn-label'
766
938
  }, t('browserLabel')),
767
- el('select', {
768
- className: 'dn-input dn-select',
769
- value: config.browser || '',
770
- disabled: controlsDisabled,
771
- onChange: (e) => save({
772
- browser: e.target.value
773
- })
774
- },
775
- el('option', {
776
- value: ''
777
- }, t('browserDefault')),
778
- (state?.browsers ?? []).map((b) => el('option', {
779
- key: b.id,
780
- value: b.path
781
- }, b.name)))),
939
+ el(BrowserSelect, {
940
+ value: config.browser || '',
941
+ options: state?.browsers ?? [],
942
+ disabled: controlsDisabled,
943
+ onChange: (path) => save({
944
+ browser: path
945
+ }),
946
+ t
947
+ })),
782
948
  el('p', {
783
949
  className: 'dn-note'
784
950
  }, t('browserNote'))
@@ -868,7 +1034,70 @@ window.__ModuleLoader__.load({
868
1034
  }, t('colorsTitle')),
869
1035
  el('div', {
870
1036
  className: 'dn-kind-list'
871
- }, kindRows))
1037
+ }, kindRows),
1038
+ el('div', {
1039
+ className: 'dn-divider'
1040
+ }),
1041
+ el('p', {
1042
+ className: 'dn-title'
1043
+ }, t('toastStyleTitle')),
1044
+ el('div', {
1045
+ className: 'dn-row'
1046
+ },
1047
+ el('span', {
1048
+ className: 'dn-label'
1049
+ }, t('toastGapLabel')),
1050
+ el('input', {
1051
+ type: 'number',
1052
+ min: 0,
1053
+ max: 60,
1054
+ className: 'dn-input',
1055
+ value: config.toast?.gap ?? 12,
1056
+ disabled: controlsDisabled,
1057
+ onChange: (e) => {
1058
+ const g = Math.max(0, Math.min(60, Math.floor(Number(e
1059
+ .target.value) || 0)))
1060
+ save({
1061
+ toast: {
1062
+ ...(config.toast ?? {}),
1063
+ gap: g
1064
+ }
1065
+ })
1066
+ }
1067
+ })),
1068
+ el('div', {
1069
+ className: 'dn-row'
1070
+ },
1071
+ el('button', {
1072
+ className: 'dn-btn primary',
1073
+ disabled: controlsDisabled,
1074
+ onClick: () => {
1075
+ if (editing) {
1076
+ void api.endEditMode()
1077
+ setEditing(false)
1078
+ } else {
1079
+ setEditing(true)
1080
+ void api.startEditMode()
1081
+ }
1082
+ }
1083
+ }, editing ? t('endEdit') : t('enterEdit')),
1084
+ el('button', {
1085
+ className: 'dn-btn',
1086
+ disabled: controlsDisabled || !config.toast?.position,
1087
+ onClick: () => {
1088
+ save({
1089
+ toast: {
1090
+ ...(config.toast ?? {}),
1091
+ position: null
1092
+ }
1093
+ })
1094
+ // 编辑模式下:重开样板窗,让其在默认位置(右下)刷新显示。
1095
+ if (editing) void api.startEditMode()
1096
+ }
1097
+ }, t('resetPosition'))),
1098
+ el('p', {
1099
+ className: 'dn-note'
1100
+ }, t('editHint')))
872
1101
  )),
873
1102
  el('div', {
874
1103
  className: 'dn-divider'
@@ -965,6 +1194,18 @@ window.__ModuleLoader__.load({
965
1194
  } catch {
966
1195
  /* 静默 */ }
967
1196
  },
1197
+ startEditMode: async () => {
1198
+ try {
1199
+ await notify.startEditMode()
1200
+ } catch {
1201
+ /* 静默 */ }
1202
+ },
1203
+ endEditMode: async () => {
1204
+ try {
1205
+ await notify.endEditMode()
1206
+ } catch {
1207
+ /* 静默 */ }
1208
+ },
968
1209
  }
969
1210
 
970
1211
  // ===== 现在设置 __localeService 并使用 api =====
@@ -1125,4 +1366,4 @@ window.__ModuleLoader__.load({
1125
1366
  exports.inject = inject
1126
1367
  return module.exports
1127
1368
  },
1128
- })
1369
+ })
package/lib/index.js CHANGED
@@ -30,26 +30,38 @@ const DEFAULT_CONFIG = Object.freeze({
30
30
  bgColor: '#20272f',
31
31
  textColor: '#e6edf3',
32
32
  locale: 'en',
33
+ // 通知窗口样式:width 窗口宽度(编辑模式拖拽边缘调整);gap 通知间距(px);
34
+ // position = null 表示默认右下贴边,{left,top} 为任意屏幕位置(工作区相对坐标)。
35
+ toast: {
36
+ width: 340,
37
+ gap: 12,
38
+ position: null,
39
+ },
33
40
  colors: {
34
41
  completed: {
35
42
  bg: '#1f5138',
36
- fg: '#e6f4ec'
43
+ fg: '#e6f4ec',
44
+ enabled: true
37
45
  },
38
46
  error: {
39
47
  bg: '#5c1f24',
40
- fg: '#fbe9ea'
48
+ fg: '#fbe9ea',
49
+ enabled: true
41
50
  },
42
51
  running: {
43
52
  bg: '#203a5c',
44
- fg: '#e8f0fb'
53
+ fg: '#e8f0fb',
54
+ enabled: true
45
55
  },
46
56
  approval: {
47
57
  bg: '#4a3a12',
48
- fg: '#f7efd8'
58
+ fg: '#f7efd8',
59
+ enabled: true
49
60
  },
50
61
  question: {
51
62
  bg: '#3d2a55',
52
- fg: '#f0e8fa'
63
+ fg: '#f0e8fa',
64
+ enabled: true
53
65
  },
54
66
  },
55
67
  })
@@ -99,6 +111,12 @@ const TOAST_LABELS = {
99
111
  /** 标题未生成时的占位文本(中/英):session/title 到达后原地替换。 */
100
112
  const UNTITLED_NAMES = new Set([TOAST_LABELS.zh.unnamed, TOAST_LABELS.en.unnamed])
101
113
 
114
+ /** 编辑模式样板窗标题(中/英)。 */
115
+ const EDIT_TITLES = {
116
+ zh: '通知样式预览',
117
+ en: 'Notification preview'
118
+ }
119
+
102
120
  const HEX_RE = /^#[0-9a-fA-F]{3,8}$/
103
121
  const isHex = (v) => typeof v === 'string' && HEX_RE.test(v)
104
122
 
@@ -118,6 +136,22 @@ function normalizeConfig(input) {
118
136
  if (isHex(src.textColor)) out.textColor = src.textColor
119
137
  if (src.locale === 'zh' || src.locale === 'en') out.locale = src.locale
120
138
 
139
+ // 通知窗口样式:width 260–520;gap 0–60;position 缺省 null(默认右下贴边),合法 {left,top} 才采纳。
140
+ out.toast = { width: 340, gap: 12, position: null }
141
+ const srcToast = src.toast !== null && typeof src.toast === 'object' && !Array.isArray(src.toast) ? src.toast : {}
142
+ if (typeof srcToast.width === 'number' && Number.isFinite(srcToast.width)) {
143
+ out.toast.width = Math.max(260, Math.min(520, Math.round(srcToast.width)))
144
+ }
145
+ if (typeof srcToast.gap === 'number' && Number.isFinite(srcToast.gap)) {
146
+ out.toast.gap = Math.max(0, Math.min(60, Math.round(srcToast.gap)))
147
+ }
148
+ const pos = srcToast.position
149
+ if (pos !== null && typeof pos === 'object' && !Array.isArray(pos) &&
150
+ typeof pos.left === 'number' && Number.isFinite(pos.left) &&
151
+ typeof pos.top === 'number' && Number.isFinite(pos.top)) {
152
+ out.toast.position = { left: Math.round(pos.left), top: Math.round(pos.top) }
153
+ }
154
+
121
155
  const srcColors = src.colors !== null && typeof src.colors === 'object' && !Array.isArray(src.colors) ? src.colors :
122
156
  {}
123
157
  out.colors = {}
@@ -127,6 +161,7 @@ function normalizeConfig(input) {
127
161
  out.colors[kind] = {
128
162
  bg: isHex(entry?.bg) ? entry.bg : def.bg,
129
163
  fg: isHex(entry?.fg) ? entry.fg : def.fg,
164
+ enabled: entry?.enabled !== false,
130
165
  }
131
166
  }
132
167
  return out
@@ -139,7 +174,8 @@ function migrateLegacyColors(input) {
139
174
  const colors = {}
140
175
  for (const kind of KIND_KEYS) colors[kind] = {
141
176
  bg: input.bgColor,
142
- fg: input.textColor
177
+ fg: input.textColor,
178
+ enabled: true
143
179
  }
144
180
  return {
145
181
  ...input,
@@ -162,6 +198,13 @@ function mergeConfigPatch(base, patch) {
162
198
  }
163
199
  out.colors = next
164
200
  }
201
+ if (patch !== null && typeof patch === 'object' && patch.toast !== null && typeof patch.toast === 'object' && !
202
+ Array.isArray(patch.toast)) {
203
+ out.toast = {
204
+ ...(base.toast !== null && typeof base.toast === 'object' && !Array.isArray(base.toast) ? base.toast : {}),
205
+ ...patch.toast
206
+ }
207
+ }
165
208
  return out
166
209
  }
167
210
 
@@ -223,6 +266,7 @@ function openConfig() {
223
266
  const MAX_QUEUE = 500
224
267
  const RUNNING_THROTTLE_MS = 400
225
268
  const KIND_DEBOUNCE_MS = 2000
269
+ /** 通知间距默认值(px);实际使用 config.toast.gap(0–60,可调)。 */
226
270
  const TOAST_GAP = 12
227
271
  const TEXT_MAX = 300
228
272
 
@@ -445,6 +489,21 @@ export function apply(ctx) {
445
489
  if (idx !== -1) toastOrder.splice(idx, 1)
446
490
  reflowToasts()
447
491
  }
492
+ } else if (msg.type === 'geometry' && typeof msg.left === 'number' && typeof msg.top === 'number') {
493
+ // 编辑模式:样板窗拖拽/缩放结束 → 写回配置,并应用到在屏浮窗。
494
+ const width = Math.max(260, Math.min(520, Math.round(Number(msg.width) || 340)))
495
+ const left = Math.round(Number(msg.left))
496
+ const top = Math.round(Number(msg.top))
497
+ store.set({
498
+ toast: {
499
+ width,
500
+ position: { left, top }
501
+ }
502
+ })
503
+ for (const rec of activeToasts.values()) {
504
+ sendCmd({ cmd: 'size', key: rec.key, width })
505
+ }
506
+ reflowToasts()
448
507
  }
449
508
  }
450
509
  })
@@ -507,22 +566,53 @@ export function apply(ctx) {
507
566
  }
508
567
 
509
568
  function reflowToasts() {
569
+ const cfg = store.get()
570
+ const pos = cfg.toast?.position ?? null
571
+ const gap = typeof cfg.toast?.gap === 'number' ? cfg.toast.gap : TOAST_GAP
572
+ if (pos !== null) {
573
+ // 自定义位置:position.top 是"第一个(最新,最底部)浮窗的顶边",向上堆叠。
574
+ // 与宿主 Loaded 的 targetTop = pos.top - offsetY 语义一致,否则重排会把
575
+ // 浮窗再上移一个自身高度(先正确、随即上移的 bug)。横向同步 position.left,
576
+ // 编辑调整位置后,在屏浮窗立即左右移动到位。
577
+ let prevTop = pos.top
578
+ let first = true
579
+ for (let i = 0; i < toastOrder.length; i += 1) {
580
+ const rec = activeToasts.get(toastOrder[i])
581
+ if (rec === undefined) continue
582
+ const height = rec.meta?.height ?? 130
583
+ const newTop = first ? prevTop : Math.round(prevTop - gap - height)
584
+ first = false
585
+ const needsTop = rec.meta !== null && newTop !== rec.meta.top
586
+ const needsLeft = rec.meta !== null && rec.meta.left !== pos.left
587
+ if (needsTop || needsLeft) {
588
+ const move = { cmd: 'move', key: rec.key, top: newTop }
589
+ if (needsTop) rec.meta.top = newTop
590
+ if (needsLeft) { move.left = pos.left; rec.meta.left = pos.left }
591
+ sendCmd(move)
592
+ }
593
+ prevTop = newTop
594
+ }
595
+ return
596
+ }
597
+ // 默认右下:底边对齐(工作区底边 - 20),向上堆叠;横向回默认贴边(left 'default'),
598
+ // 覆盖"恢复默认位置/改宽度后需回到右下角"的场景。
510
599
  if (globalWorkBottom === null) return
511
- let anchor = globalWorkBottom - 20
600
+ let cursor = globalWorkBottom - 20
512
601
  for (let i = 0; i < toastOrder.length; i += 1) {
513
602
  const rec = activeToasts.get(toastOrder[i])
514
603
  if (rec === undefined) continue
515
604
  const height = rec.meta?.height ?? 130
516
- const newTop = Math.round(anchor - height)
605
+ const newTop = Math.round(cursor - height)
517
606
  if (rec.meta !== null && newTop !== rec.meta.top) {
518
607
  rec.meta.top = newTop
519
608
  sendCmd({
520
609
  cmd: 'move',
521
610
  key: rec.key,
522
- top: newTop
611
+ top: newTop,
612
+ left: 'default'
523
613
  })
524
614
  }
525
- anchor = newTop - TOAST_GAP
615
+ cursor = newTop - gap
526
616
  }
527
617
  }
528
618
 
@@ -582,10 +672,11 @@ export function apply(ctx) {
582
672
  toastOrder.splice(replaceIndex, 0, key)
583
673
  }
584
674
  const insertIndex = replaceIndex !== -1 ? replaceIndex : 0
675
+ const gap = typeof cfg.toast?.gap === 'number' ? cfg.toast.gap : TOAST_GAP
585
676
  let offsetY = 0
586
677
  for (let i = 0; i < insertIndex; i += 1) {
587
678
  const r = activeToasts.get(toastOrder[i])
588
- offsetY += (r?.meta?.height ?? 130) + TOAST_GAP
679
+ offsetY += (r?.meta?.height ?? 130) + gap
589
680
  }
590
681
 
591
682
  const lang = cfg.locale === 'zh' ? 'zh' : 'en'
@@ -608,6 +699,9 @@ export function apply(ctx) {
608
699
  ignoreLabel: l.ignore,
609
700
  jumpLabel: l.jump,
610
701
  unnamedLabel: l.unnamed,
702
+ // 通知窗口样式:宽度 + 自定义位置(null = 默认右下贴边)
703
+ width: cfg.toast?.width ?? 340,
704
+ position: cfg.toast?.position ?? null,
611
705
  }
612
706
  if (typeof cfg.browser === 'string' && cfg.browser.length > 0) payload.browserPath = cfg.browser
613
707
 
@@ -673,6 +767,8 @@ export function apply(ctx) {
673
767
  if (st === undefined) return
674
768
  const at = Date.now()
675
769
  const cfg = store.get()
770
+ // 每类型开关:该类型被关闭时完全不产生通知(桌面浮窗与系统通知都不发)。
771
+ if (cfg.colors?.[kind]?.enabled === false) return
676
772
  const desktop = cfg.enabled === true && cfg.mode === 'custom'
677
773
  const lang = cfg.locale === 'zh' ? 'zh' : 'en'
678
774
  const l = TOAST_LABELS[lang]
@@ -933,7 +1029,19 @@ export function apply(ctx) {
933
1029
  }
934
1030
  },
935
1031
  updateConfig(patch) {
936
- return store.set(patch)
1032
+ const prev = store.get()
1033
+ const next = store.set(patch)
1034
+ // toast 几何(位置/宽度/间距)变更:立即应用到在屏浮窗,无需等待下一个浮窗。
1035
+ const pToast = patch !== null && typeof patch === 'object' && !Array.isArray(patch) ? patch.toast : undefined
1036
+ if (pToast !== null && typeof pToast === 'object' && !Array.isArray(pToast)) {
1037
+ if (typeof pToast.width === 'number' && pToast.width !== prev.toast?.width) {
1038
+ for (const rec of activeToasts.values()) {
1039
+ sendCmd({ cmd: 'size', key: rec.key, width: next.toast.width })
1040
+ }
1041
+ }
1042
+ reflowToasts()
1043
+ }
1044
+ return next
937
1045
  },
938
1046
  ackEvents(ids) {
939
1047
  if (Array.isArray(ids)) {
@@ -955,6 +1063,23 @@ export function apply(ctx) {
955
1063
  browsers: detectBrowsers()
956
1064
  }
957
1065
  },
1066
+ /** 进入通知窗口编辑模式:宿主弹样板窗(可拖拽/缩放),几何回传后写回配置。 */
1067
+ startEditMode() {
1068
+ const cfg = store.get()
1069
+ const lang = cfg.locale === 'zh' ? 'zh' : 'en'
1070
+ sendCmd({
1071
+ cmd: 'edit',
1072
+ title: EDIT_TITLES[lang],
1073
+ width: cfg.toast?.width ?? 340,
1074
+ position: cfg.toast?.position ?? null,
1075
+ })
1076
+ return { ok: true }
1077
+ },
1078
+ /** 退出编辑模式:样板窗淡出消失。 */
1079
+ endEditMode() {
1080
+ sendCmd({ cmd: 'edit-end' })
1081
+ return { ok: true }
1082
+ },
958
1083
  }
959
1084
 
960
1085
  Object.defineProperty(service, 'typertRemote', {