dsh-movable-settings 0.0.0-stage → 1.0.0
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/LICENSE +21 -0
- package/README.md +146 -2
- package/client.js +992 -0
- package/cordis.patch.yml +3 -0
- package/icon.svg +7 -0
- package/index.js +10 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +58 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ricardo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,147 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-movable-settings
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/dsh-movable-settings)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
|
|
6
|
+
把 DSH Web GUI 内置的「设置」弹窗从固定居中、不可调整,变成**可拖动、可八向缩放、可双击最大化**,并记住上次位置与尺寸的浮层。
|
|
7
|
+
|
|
8
|
+
Turn the built-in **Settings** dialog of the DSH Web GUI into a floating window you can drag, resize in 8 directions and maximize by double-click — with its geometry remembered.
|
|
9
|
+
|
|
10
|
+
> 面向 DSH `web` profile:增强对象是 `@deepseek-ai/dsh-client-ui-settings-general` 经 `createPortal` 渲染到 `body` 的设置 modal。
|
|
11
|
+
> Targets the DSH `web` profile: it enhances the settings modal rendered by `@deepseek-ai/dsh-client-ui-settings-general`.
|
|
12
|
+
|
|
13
|
+
## 安装 / Installation
|
|
14
|
+
|
|
15
|
+
在 DSH 里安装,四种方式任选。Install inside DSH — pick any of the four.
|
|
16
|
+
|
|
17
|
+
**方式一:让会话里的 Agent 装(推荐)** — Ask the agent in any DSH session:
|
|
18
|
+
|
|
19
|
+
> 帮我安装 dsh-movable-settings 插件
|
|
20
|
+
|
|
21
|
+
Agent 会替你执行 / the agent runs:
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
plugin_manager action=install_bundle target=dsh-movable-settings
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
**方式二:`dsh` 命令行** — With the `dsh` CLI:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
dsh plugin --profile web add dsh-movable-settings
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
> `dsh plugin` 把剩余参数转发给 profile 目录里的 pnpm,因此它只负责**装包**,不会把包登记进 profile 的 bundle 层(`~/.dsh/profiles/web/package.json` 的 `dsh.profile.bundles`)。装完请在 DSH 的「设置 → 插件」里启用它,或改用方式一 / 方式三由插件管理器一步到位。
|
|
34
|
+
> `dsh plugin` merely forwards the arguments to pnpm inside the profile directory — it installs the package but does **not** register it in `dsh.profile.bundles`, so enable it under DSH's Settings → Plugins afterwards, or use method 1 / 3 to let the plugin manager do both steps.
|
|
35
|
+
|
|
36
|
+
**方式三:自己调用 plugin_manager 工具(与方式一等价)** — Call the tool yourself:
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
plugin_manager action=install_bundle target=dsh-movable-settings
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
**方式四:本地目录(开发版 / 尚未发布)** — From a local checkout (development build):
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
plugin_manager action=install_bundle target=<本目录的绝对路径 / absolute path to this directory>
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
安装完成后**刷新一次页面**:client 半区的新代码只在页面重新加载后生效(除非同时跑着 `pnpm run dev:web` 的 HMR)。
|
|
49
|
+
|
|
50
|
+
After installing, **refresh the page** — the client half only picks up new code on reload (unless the `pnpm run dev:web` HMR watcher is running).
|
|
51
|
+
|
|
52
|
+
插件按 profile 安装,装好后该 profile 下的**每个会话**都会生效。
|
|
53
|
+
Plugins are installed per profile, so every session in that profile gets it.
|
|
54
|
+
|
|
55
|
+
## 行为 / Behavior
|
|
56
|
+
|
|
57
|
+
| 交互 Interaction | 位置 Where | 结果 Effect |
|
|
58
|
+
| --- | --- | --- |
|
|
59
|
+
| 拖动 Drag | 面板顶部约 54px 的非交互条带(导航标题区 + 内容列 header 空白处) | 指针变 `move`,面板实时跟随;命中按钮 / 输入框 / 链接 / slot 内容时不启动拖动;悬停条带显示原生 `title` 气泡「拖动移动 · 双击最大化 / 还原」 |
|
|
60
|
+
| 缩放 Resize | 四边 + 四角共 8 个手柄(悬停时淡淡显色) | 最小 480×360,且任意时刻不超出视口可用区 |
|
|
61
|
+
| 双击 Double-click | 同拖动条带 Same strip | 最大化到视口可用区;再次双击还原到最大化前的几何 |
|
|
62
|
+
| 最大化 / 还原 Maximize / Restore | 内容列 header 的「最大化」按钮(`settings.action` 槽位,重置按钮左侧,图标 + 文案随状态切换) | 与双击完全等价,是最大化能力的可见入口(`title` / `aria-label` 点明「也可双击标题栏」) |
|
|
63
|
+
| 重置 Reset | 内容列 header 的「重置位置」按钮 | 清空记忆并回到上游默认的居中布局 |
|
|
64
|
+
|
|
65
|
+
按钮顺序 / Button order:`最大化`(order 19) → `重置位置`(order 20) → 上游的 `打开配置文件` / `Close`。
|
|
66
|
+
|
|
67
|
+
拖动 / 缩放结束的瞬间,只有**与抬手点重合**(≤4px、120ms 内)的残留 click 会被吞掉;抬手后在新位置的第一次点击一律一次生效 —— 关闭按钮、输入框、下拉都照常触发与聚焦。
|
|
68
|
+
|
|
69
|
+
`title` 气泡只挂在两个条带容器上:`nav` 的第一个子元素(标题容器)与 `content` 的第一个子元素(54px header)。容器内没有自带 `title` 的交互后代会被写成 `title=""` 阻断继承,因此内容列 `options` 滚动区、`nav` 列表区与 header 里的上游控件都不会弹出这个提示。
|
|
70
|
+
|
|
71
|
+
视口可用区 = 视口四边各留 `max(24px, --dsh-frame-overlay-top, --dsh-frame-chrome-top)`,与上游默认面板高度 `min(800px, calc(100vh - 2 * max(24px, var(--dsh-frame-overlay-top, 24px))))` 的留白语义一致。窗口 resize 后会重新钳制,标题栏、关闭按钮与缩放手柄始终留在视口内可触达。
|
|
72
|
+
|
|
73
|
+
## 设计约束 / Design constraints
|
|
74
|
+
|
|
75
|
+
- **纯客户端 DOM 增强 / Pure client-side DOM enhancement**:不遮蔽、不重写任何 shipped slot,不接管键盘事件与 `useModalLayer` 的焦点陷阱;只把面板改成相对 overlay 的绝对定位,并附加一个自管理的交互层。
|
|
76
|
+
- **锚点只用语义属性 / Semantic anchors only**:`div[data-shortcut-modal="settings"][role="dialog"][aria-modal="true"]`。构建期 hash 类名(`VOzbGW_*`)会随上游构建变化,一律不依赖。
|
|
77
|
+
- **交互层不进 React 树 / Interaction layer stays out of React**:8 个手柄与兜底按钮挂在插件自建的 `body` 子节点(`position: fixed`)上,绝不 `appendChild` 进 React 管理的子列表。
|
|
78
|
+
- **静默降级 / Silent degradation**:锚点不匹配,或锚点命中但内部缺少 `nav` / `content` 两列及其标题 / header 容器时,插件都不改任何原样式、不留多余节点、不抛异常,仅输出一条 `console.warn`。
|
|
79
|
+
- **主题一致 / Theme-consistent**:颜色 / 圆角 / 边框只用 `--dsw-alias-*`、`--dsw-radius-*` 主题 token。
|
|
80
|
+
- **提示不误报 / No misleading tooltips**:拖动气泡只出现在能力真正生效的条带容器上,并对容器内无自带 `title` 的交互后代写入 `title=""` 阻断继承;`unbind` / `dispose` 时按原值逐一还原所有被改过的节点(实际改写集合 `titleTouched` + 原值表 `titleBackups`),节点中途脱离文档再复用也不会残留 `title=""`。
|
|
81
|
+
- **完全可回收 / Fully reversible**:`dispose` 时移除注入的 `<style>`、observer、所有监听器、交互层节点与内联覆盖样式。
|
|
82
|
+
|
|
83
|
+
## 文件 / Files
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
package.json 清单(dsh.bundle.patch + dsh.client)
|
|
87
|
+
cordis.patch.yml Loader 补丁,插入 id=movable-settings 的插件行
|
|
88
|
+
index.js Host 半区(空壳:能力全在 Client 半区)
|
|
89
|
+
client.js 核心实现(DOM 增强 + settings.action 最大化/重置按钮)
|
|
90
|
+
locale/zh.json 插件显示名与描述(中文)
|
|
91
|
+
locale/en.json 插件显示名与描述(英文)
|
|
92
|
+
icon.svg 插件图标
|
|
93
|
+
LICENSE MIT 许可证
|
|
94
|
+
test/dom-stub.mjs 最小 DOM 桩
|
|
95
|
+
test/enhancer.test.mjs 行为测试(node:test)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## 验证 / Verification
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
npm run check # node --check index.js client.js
|
|
102
|
+
npm test # 49 项行为与清单断言(node:test)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
等价的细项 / equivalent commands:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
node --check client.js
|
|
109
|
+
node --check index.js
|
|
110
|
+
node -e "JSON.parse(require('fs').readFileSync('package.json','utf8'))"
|
|
111
|
+
node --test test/enhancer.test.mjs
|
|
112
|
+
node --test test/manifest.test.mjs
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## 真机手测 / Manual smoke test
|
|
116
|
+
|
|
117
|
+
浏览器侧无法自动化的部分,按此逐条走一遍(打开设置:`Ctrl/Cmd+Alt+,` 或侧栏齿轮):
|
|
118
|
+
|
|
119
|
+
1. **拖动**:拖动导航标题栏,面板应跟随指针移动,指针为 `move`,松手后停在新位置。
|
|
120
|
+
2. **缩放**:指针移到面板边缘 / 角落,出现细手柄,拖动改宽高;拖到极小不应小于 480×360。
|
|
121
|
+
3. **双击最大化**:双击标题栏最大化,再双击还原。悬停标题栏应出现原生气泡「拖动移动 · 双击最大化 / 还原」;悬停设置内容区或 header 里的「打开配置文件」按钮时**不应**出现该气泡。
|
|
122
|
+
4. **按钮**:点内容列 header 的「最大化」按钮,与双击等价;最大化后它应变成「还原」并换图标,点它回到原几何。
|
|
123
|
+
5. **记忆**:关闭再打开设置弹窗,以及整页刷新,位置与尺寸应保持。
|
|
124
|
+
6. **钳制**:把浏览器窗口缩到很小,面板应被钳制,标题栏 / 关闭按钮 / 手柄都还能点到。
|
|
125
|
+
7. **重置**:点「重置位置」回到居中默认布局,且「最大化」按钮同步回到未最大化语义。
|
|
126
|
+
8. **回归**:关闭按钮、点击遮罩、Esc 仍能关闭;面板内滚动、Tab 焦点陷阱、设置项切换正常;拖动后不会误触面板内的按钮。
|
|
127
|
+
9. **卸载**:在「设置 → 插件」里禁用本插件,弹窗应立刻恢复原始行为,页面无残留样式、手柄与 `title`。
|
|
128
|
+
|
|
129
|
+
## 发布(维护者) / Publishing
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
npm login # 首次发布前 / first time only
|
|
133
|
+
npm publish # 包名 dsh-movable-settings 未被占用,清单已移除 private
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
发布后即可 `plugin_manager action=install_bundle target=dsh-movable-settings`。
|
|
137
|
+
|
|
138
|
+
## 已知边界 / Known limits
|
|
139
|
+
|
|
140
|
+
- 上游若连 `data-shortcut-modal="settings"` 属性一并改名,插件无从识别该弹窗,会完全静默(不改动、不告警);这属于不可探测的结构变更。
|
|
141
|
+
- 缩放手柄覆盖在面板外缘 6px 上,Windows 经典滚动条(约 15px 宽)的最外侧几个像素会被手柄占用,其余滚动条区域仍可点击。
|
|
142
|
+
- 条形气泡的落点靠结构定位(`nav` / `content` 的第一个子元素);上游若调整子元素顺序,气泡位置会偏,但拖动 / 缩放 / 最大化能力不受影响。
|
|
143
|
+
- 本仓库的自动化测试跑在最小 DOM 桩上,只覆盖插件自身的逻辑;浏览器渲染与视觉一致性仍需真机核验。
|
|
144
|
+
|
|
145
|
+
## 许可证 / License
|
|
146
|
+
|
|
147
|
+
[MIT](LICENSE) © 2026 ricardo
|
package/client.js
ADDED
|
@@ -0,0 +1,992 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-movable-settings —— 让内置「设置」弹窗可拖动 / 八向缩放 / 双击最大化 / 记忆几何。
|
|
3
|
+
*
|
|
4
|
+
* 设计要点(与目标判据一一对应):
|
|
5
|
+
* - 纯客户端 DOM 增强:不遮蔽、不重写任何 shipped slot,不接管键盘与焦点陷阱;
|
|
6
|
+
* 只把面板的定位方式改为相对 overlay 的绝对定位并附加自己的交互层。
|
|
7
|
+
* - 锚点只用语义属性 `[data-shortcut-modal="settings"][role="dialog"][aria-modal="true"]`,
|
|
8
|
+
* 绝不依赖上游构建期生成的 hash 类名(它们随上游构建变化)。
|
|
9
|
+
* - 上游结构不匹配时静默降级:不改原样式、不留多余节点、不抛异常,只留一条 console.warn。
|
|
10
|
+
* - 所有注入的 <style>、监听器、observer、临时节点与内联样式都在 dispose 时回收。
|
|
11
|
+
*/
|
|
12
|
+
window.__ModuleLoader__.load({
|
|
13
|
+
id: 'dsh-movable-settings',
|
|
14
|
+
factory(require) {
|
|
15
|
+
const React = require('react');
|
|
16
|
+
const h = React.createElement;
|
|
17
|
+
|
|
18
|
+
/** 插件命名空间:locale 字典键与可选 slot 条目共用。 */
|
|
19
|
+
const NS = 'dsh-movable-settings';
|
|
20
|
+
/** 几何持久化键(带插件命名空间与版本,便于将来迁移)。 */
|
|
21
|
+
const STORAGE_KEY = 'dsh-movable-settings:v1';
|
|
22
|
+
/** 面板最小尺寸(可用区比它还小时按可用区收缩)。 */
|
|
23
|
+
const MIN_W = 480;
|
|
24
|
+
const MIN_H = 360;
|
|
25
|
+
/** 视口四周留白下限:与上游 `max(24px, --dsh-frame-overlay-top)` 语义一致。 */
|
|
26
|
+
const FALLBACK_MARGIN = 24;
|
|
27
|
+
/** 可拖动条带高度:nav 标题区(22 + 24)与 content header(54)的交集。 */
|
|
28
|
+
const DRAG_STRIP = 54;
|
|
29
|
+
/** 位移超过该阈值才算拖动/缩放,避免吞掉单击与双击。 */
|
|
30
|
+
const DRAG_THRESHOLD = 3;
|
|
31
|
+
const SAVE_DEBOUNCE_MS = 200;
|
|
32
|
+
/** 拖动结束后「同一位置残留 click」的判定半径(px):超过它的一律是用户的新点击,必须放行。 */
|
|
33
|
+
const CLICK_SLOP = 4;
|
|
34
|
+
/** 残留 click 的兜底时间窗(ms)。 */
|
|
35
|
+
const SWALLOW_WINDOW_MS = 120;
|
|
36
|
+
|
|
37
|
+
const PANEL_SELECTOR = 'div[data-shortcut-modal="settings"][role="dialog"][aria-modal="true"]';
|
|
38
|
+
const ANCHOR_SELECTOR = '[data-shortcut-modal="settings"]';
|
|
39
|
+
/** 命中即不启动拖动:按钮 / 表单控件 / 链接 / slot 内容。 */
|
|
40
|
+
const INTERACTIVE_SELECTOR = [
|
|
41
|
+
'button',
|
|
42
|
+
'a[href]',
|
|
43
|
+
'input',
|
|
44
|
+
'select',
|
|
45
|
+
'textarea',
|
|
46
|
+
'label',
|
|
47
|
+
'summary',
|
|
48
|
+
'[role="button"]',
|
|
49
|
+
'[role="link"]',
|
|
50
|
+
'[role="checkbox"]',
|
|
51
|
+
'[role="switch"]',
|
|
52
|
+
'[contenteditable="true"]',
|
|
53
|
+
'[data-slot]',
|
|
54
|
+
'[data-dsh-ms-slot-reset]',
|
|
55
|
+
].join(',');
|
|
56
|
+
|
|
57
|
+
const EDGE_DIRECTIONS = ['n', 's', 'w', 'e'];
|
|
58
|
+
const CORNER_DIRECTIONS = ['nw', 'ne', 'sw', 'se'];
|
|
59
|
+
const DIRECTIONS = EDGE_DIRECTIONS.concat(CORNER_DIRECTIONS);
|
|
60
|
+
const HANDLE_SIZE = 6;
|
|
61
|
+
const CORNER_SIZE = 14;
|
|
62
|
+
|
|
63
|
+
const CSS_TEXT = [
|
|
64
|
+
'.dsh-ms-layer{position:fixed;inset:0;pointer-events:none;z-index:2147482000}',
|
|
65
|
+
'.dsh-ms-handle{position:absolute;pointer-events:auto;touch-action:none;background:transparent;border-radius:3px;transition:background-color .12s ease}',
|
|
66
|
+
'.dsh-ms-handle:hover{background:color-mix(in srgb,var(--dsw-alias-label-primary) 20%,transparent)}',
|
|
67
|
+
'.dsh-ms-handle[data-dsh-ms-handle="n"],.dsh-ms-handle[data-dsh-ms-handle="s"]{cursor:ns-resize}',
|
|
68
|
+
'.dsh-ms-handle[data-dsh-ms-handle="w"],.dsh-ms-handle[data-dsh-ms-handle="e"]{cursor:ew-resize}',
|
|
69
|
+
'.dsh-ms-handle[data-dsh-ms-handle="nw"],.dsh-ms-handle[data-dsh-ms-handle="se"]{cursor:nwse-resize}',
|
|
70
|
+
'.dsh-ms-handle[data-dsh-ms-handle="ne"],.dsh-ms-handle[data-dsh-ms-handle="sw"]{cursor:nesw-resize}',
|
|
71
|
+
'html.dsh-ms-drag,html.dsh-ms-drag *{cursor:grabbing !important}',
|
|
72
|
+
'html.dsh-ms-drag,html.dsh-ms-drag *{user-select:none}',
|
|
73
|
+
'html.dsh-ms-resizing,html.dsh-ms-resizing *{cursor:var(--dsh-ms-cursor,default) !important}',
|
|
74
|
+
'html.dsh-ms-resizing,html.dsh-ms-resizing *{user-select:none}',
|
|
75
|
+
'.dsh-ms-action{box-sizing:border-box;display:inline-flex;align-items:center;gap:6px;height:28px;padding:0 10px;font-family:inherit;font-size:12px;font-weight:500;line-height:18px;color:var(--dsw-alias-label-primary);background:transparent;border:1px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-sm);cursor:pointer;white-space:nowrap}',
|
|
76
|
+
'.dsh-ms-action svg{display:block;flex:none}',
|
|
77
|
+
'.dsh-ms-action:hover{background:var(--dsw-alias-interactive-bg-hover)}',
|
|
78
|
+
'.dsh-ms-fallback{box-sizing:border-box;position:absolute;pointer-events:auto;display:inline-flex;align-items:center;height:28px;padding:0 10px;font-family:inherit;font-size:12px;font-weight:500;line-height:18px;color:var(--dsw-alias-label-secondary);background:color-mix(in srgb,var(--dsw-alias-bg-layer-2) 86%,transparent);border:1px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-sm);cursor:pointer;white-space:nowrap;opacity:.78}',
|
|
79
|
+
'.dsh-ms-fallback:hover{opacity:1;background:var(--dsw-alias-interactive-bg-hover)}',
|
|
80
|
+
].join('\n');
|
|
81
|
+
|
|
82
|
+
const zh = {
|
|
83
|
+
'action.reset': '重置设置弹窗的位置与尺寸',
|
|
84
|
+
'action.reset.short': '重置位置',
|
|
85
|
+
'action.maximize': '最大化设置弹窗(也可双击标题栏)',
|
|
86
|
+
'action.maximize.short': '最大化',
|
|
87
|
+
'action.restore': '还原设置弹窗(也可双击标题栏)',
|
|
88
|
+
'action.restore.short': '还原',
|
|
89
|
+
'tooltip.dragMaximize': '拖动移动 · 双击最大化',
|
|
90
|
+
'tooltip.dragRestore': '拖动移动 · 双击还原',
|
|
91
|
+
};
|
|
92
|
+
const en = {
|
|
93
|
+
'action.reset': 'Reset settings dialog position and size',
|
|
94
|
+
'action.reset.short': 'Reset position',
|
|
95
|
+
'action.maximize': 'Maximize the settings dialog (or double-click its title bar)',
|
|
96
|
+
'action.maximize.short': 'Maximize',
|
|
97
|
+
'action.restore': 'Restore the settings dialog (or double-click its title bar)',
|
|
98
|
+
'action.restore.short': 'Restore',
|
|
99
|
+
'tooltip.dragMaximize': 'Drag to move · double-click to maximize',
|
|
100
|
+
'tooltip.dragRestore': 'Drag to move · double-click to restore',
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/** 兜底翻译:locale 服务未就绪时仍给出可读文案。 */
|
|
104
|
+
function fallbackTranslate(key) {
|
|
105
|
+
return Object.prototype.hasOwnProperty.call(zh, key) ? zh[key] : key;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 包一层 locale 服务返回的 t:取不到或回落到 key 时用内置中文兜底。 */
|
|
109
|
+
function wrapTranslator(t) {
|
|
110
|
+
return function (key) {
|
|
111
|
+
try {
|
|
112
|
+
const value = t(key);
|
|
113
|
+
if (typeof value === 'string' && value !== '' && value !== key) return value;
|
|
114
|
+
} catch (error) {
|
|
115
|
+
/* 落到内置文案 */
|
|
116
|
+
}
|
|
117
|
+
return fallbackTranslate(key);
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ------------------------------------------------------------------ 纯几何
|
|
122
|
+
|
|
123
|
+
function clamp(value, lo, hi) {
|
|
124
|
+
const low = Math.min(lo, hi);
|
|
125
|
+
const high = Math.max(lo, hi);
|
|
126
|
+
return Math.min(Math.max(value, low), high);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function readVarPx(el, name, fallback) {
|
|
130
|
+
try {
|
|
131
|
+
const raw = window.getComputedStyle(el).getPropertyValue(name);
|
|
132
|
+
const value = Number.parseFloat(raw);
|
|
133
|
+
return Number.isFinite(value) ? value : fallback;
|
|
134
|
+
} catch (error) {
|
|
135
|
+
return fallback;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* 视口可用区:四边各留 max(24px, --dsh-frame-overlay-top, --dsh-frame-chrome-top)。
|
|
141
|
+
* 上游默认面板高度正是 `100vh - 2 * max(24px, var(--dsh-frame-overlay-top, 24px))`,
|
|
142
|
+
* 因此四边同留白在语义上与上游一致。
|
|
143
|
+
*/
|
|
144
|
+
function availableArea(el) {
|
|
145
|
+
const margin = Math.max(
|
|
146
|
+
FALLBACK_MARGIN,
|
|
147
|
+
readVarPx(el, '--dsh-frame-overlay-top', FALLBACK_MARGIN),
|
|
148
|
+
readVarPx(el, '--dsh-frame-chrome-top', 0),
|
|
149
|
+
);
|
|
150
|
+
const width = Math.max(0, window.innerWidth - margin * 2);
|
|
151
|
+
const height = Math.max(0, window.innerHeight - margin * 2);
|
|
152
|
+
return { left: margin, top: margin, right: margin + width, bottom: margin + height, width, height };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function fitGeometry(geo, avail) {
|
|
156
|
+
const minW = Math.min(MIN_W, avail.width);
|
|
157
|
+
const minH = Math.min(MIN_H, avail.height);
|
|
158
|
+
const w = clamp(geo.w, minW, Math.max(minW, avail.width));
|
|
159
|
+
const h = clamp(geo.h, minH, Math.max(minH, avail.height));
|
|
160
|
+
const x = clamp(geo.x, avail.left, Math.max(avail.left, avail.right - w));
|
|
161
|
+
const y = clamp(geo.y, avail.top, Math.max(avail.top, avail.bottom - h));
|
|
162
|
+
return { x: Math.round(x), y: Math.round(y), w: Math.round(w), h: Math.round(h) };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function isInteractive(node, root) {
|
|
166
|
+
let el = node && typeof node.matches === 'function' ? node : null;
|
|
167
|
+
while (el && el !== root) {
|
|
168
|
+
try {
|
|
169
|
+
if (el.matches(INTERACTIVE_SELECTOR)) return true;
|
|
170
|
+
} catch (error) {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
el = el.parentElement;
|
|
174
|
+
}
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ------------------------------------------------------------------ 控制器
|
|
179
|
+
|
|
180
|
+
function createEnhancer() {
|
|
181
|
+
let active = false;
|
|
182
|
+
let styleEl = null;
|
|
183
|
+
let observer = null;
|
|
184
|
+
let frame = 0;
|
|
185
|
+
let warned = false;
|
|
186
|
+
let bound = null;
|
|
187
|
+
let originalStyle = null;
|
|
188
|
+
let layer = null;
|
|
189
|
+
let fallbackButton = null;
|
|
190
|
+
let geometry = null;
|
|
191
|
+
let restore = null;
|
|
192
|
+
let interaction = null;
|
|
193
|
+
let swallowClick = false;
|
|
194
|
+
/** 触发吞点的 pointerup 终点坐标;click 离它太远就说明是用户的新点击。 */
|
|
195
|
+
let swallowPoint = null;
|
|
196
|
+
let swallowTimer = 0;
|
|
197
|
+
let saveTimer = 0;
|
|
198
|
+
const handles = new Map();
|
|
199
|
+
/** 最大化状态的订阅者(settings.action 里的最大化/还原按钮)。 */
|
|
200
|
+
const subscribers = new Set();
|
|
201
|
+
/** 被本插件改过 title 的节点 → 原值(null 表示原本没有该属性)。 */
|
|
202
|
+
const titleBackups = new Map();
|
|
203
|
+
/** 本次实际被改写 title 的节点;还原时遍历它,保证不留残留。 */
|
|
204
|
+
const titleTouched = new Set();
|
|
205
|
+
/** 提示与按钮文案的翻译函数;locale 服务就绪后由外部替换。 */
|
|
206
|
+
let translator = fallbackTranslate;
|
|
207
|
+
|
|
208
|
+
// -------- 最大化状态与订阅
|
|
209
|
+
|
|
210
|
+
function isMaximized() {
|
|
211
|
+
return restore !== null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function notifyState() {
|
|
215
|
+
const maximized = isMaximized();
|
|
216
|
+
subscribers.forEach(function (subscriber) {
|
|
217
|
+
try {
|
|
218
|
+
subscriber(maximized);
|
|
219
|
+
} catch (error) {
|
|
220
|
+
/* 单个订阅者出错不影响其它订阅者与插件本身 */
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
ensureTooltip();
|
|
224
|
+
updateLayer();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** 只注册;当前值由订阅者用 isMaximized() 自行取初值。 */
|
|
228
|
+
function subscribe(subscriber) {
|
|
229
|
+
if (typeof subscriber !== 'function') return function () {};
|
|
230
|
+
subscribers.add(subscriber);
|
|
231
|
+
return function () { subscribers.delete(subscriber); };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function dragTipText() {
|
|
235
|
+
return translator(isMaximized() ? 'tooltip.dragRestore' : 'tooltip.dragMaximize');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* 拖动条带的可发现性提示:原生 title 气泡。
|
|
240
|
+
*
|
|
241
|
+
* title 会被浏览器逐元素继承,因此**只**把它挂在条带容器上:
|
|
242
|
+
* - `nav` 的第一个子元素(settings.header 标题容器)
|
|
243
|
+
* - `content` 的第一个子元素(54px 的 header 容器,含 actions 与 Close)
|
|
244
|
+
* 这两个容器内部的交互后代若自身没有 title,会被写成 `title=""` 阻断继承,
|
|
245
|
+
* 避免气泡落到上游控件上;nav 的列表区与 content 的 options 滚动区不在容器内,
|
|
246
|
+
* 天然不会出现提示。scan() 每次都会重做一遍(React 可能重建节点)。
|
|
247
|
+
*/
|
|
248
|
+
/**
|
|
249
|
+
* 条带结构:panel 的两个直接子元素(nav / content)各自的第一个元素子元素。
|
|
250
|
+
* 结构异常(缺列或缺标题/header 容器)时返回 null,调用方走静默降级。
|
|
251
|
+
*/
|
|
252
|
+
function stripStructureOf(panel) {
|
|
253
|
+
if (!panel) return null;
|
|
254
|
+
const nav = panel.firstElementChild;
|
|
255
|
+
const content = nav ? nav.nextElementSibling : null;
|
|
256
|
+
const titleHost = nav ? nav.firstElementChild : null;
|
|
257
|
+
const headerHost = content ? content.firstElementChild : null;
|
|
258
|
+
if (!titleHost || !headerHost) return null;
|
|
259
|
+
return { titleHost: titleHost, headerHost: headerHost };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function stripContainers() {
|
|
263
|
+
const structure = stripStructureOf(bound);
|
|
264
|
+
if (!structure) return [];
|
|
265
|
+
return [structure.titleHost, structure.headerHost];
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function rememberTitle(el) {
|
|
269
|
+
if (!el) return;
|
|
270
|
+
if (!titleBackups.has(el)) titleBackups.set(el, el.getAttribute('title'));
|
|
271
|
+
titleTouched.add(el);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function ensureTooltip() {
|
|
275
|
+
if (!bound) return;
|
|
276
|
+
const text = dragTipText();
|
|
277
|
+
stripContainers().forEach(function (container) {
|
|
278
|
+
rememberTitle(container);
|
|
279
|
+
if (container.getAttribute('title') !== text) container.setAttribute('title', text);
|
|
280
|
+
let descendants = [];
|
|
281
|
+
try {
|
|
282
|
+
descendants = container.querySelectorAll(INTERACTIVE_SELECTOR);
|
|
283
|
+
} catch (error) {
|
|
284
|
+
descendants = [];
|
|
285
|
+
}
|
|
286
|
+
Array.prototype.forEach.call(descendants, function (node) {
|
|
287
|
+
if (node.hasAttribute('title')) return;
|
|
288
|
+
rememberTitle(node);
|
|
289
|
+
node.setAttribute('title', '');
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* 还原所有被本插件改写过的 title。
|
|
296
|
+
* 遍历「实际改写集合」而不是备份表:即使某节点的原值记录不再可用(例如它中途离开过文档),
|
|
297
|
+
* 也一定会被 removeAttribute,不会残留我们写下的 `title=""` 或提示文案。
|
|
298
|
+
*/
|
|
299
|
+
function restoreTitles() {
|
|
300
|
+
titleTouched.forEach(function (el) {
|
|
301
|
+
try {
|
|
302
|
+
const previous = titleBackups.has(el) ? titleBackups.get(el) : null;
|
|
303
|
+
if (previous === null || previous === undefined) el.removeAttribute('title');
|
|
304
|
+
else el.setAttribute('title', previous);
|
|
305
|
+
} catch (error) {
|
|
306
|
+
/* 节点可能已随弹窗卸载 */
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
titleBackups.clear();
|
|
310
|
+
titleTouched.clear();
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// -------- 持久化
|
|
314
|
+
|
|
315
|
+
function sanitize(value) {
|
|
316
|
+
if (!value || typeof value !== 'object') return null;
|
|
317
|
+
const nums = ['x', 'y', 'w', 'h'].map(function (key) { return Number(value[key]); });
|
|
318
|
+
if (!nums.every(function (n) { return Number.isFinite(n); })) return null;
|
|
319
|
+
if (nums[2] <= 0 || nums[3] <= 0) return null;
|
|
320
|
+
return { x: nums[0], y: nums[1], w: nums[2], h: nums[3] };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function readStore() {
|
|
324
|
+
try {
|
|
325
|
+
const raw = window.localStorage.getItem(STORAGE_KEY);
|
|
326
|
+
if (!raw) return null;
|
|
327
|
+
const parsed = JSON.parse(raw);
|
|
328
|
+
if (!parsed || typeof parsed !== 'object') return null;
|
|
329
|
+
const stored = sanitize(parsed.geometry);
|
|
330
|
+
if (!stored) return null;
|
|
331
|
+
return { geometry: stored, restore: sanitize(parsed.restore) };
|
|
332
|
+
} catch (error) {
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function writeStore() {
|
|
338
|
+
if (!geometry) return;
|
|
339
|
+
try {
|
|
340
|
+
window.localStorage.setItem(STORAGE_KEY, JSON.stringify({
|
|
341
|
+
v: 1,
|
|
342
|
+
geometry: geometry,
|
|
343
|
+
restore: restore,
|
|
344
|
+
}));
|
|
345
|
+
} catch (error) {
|
|
346
|
+
/* 存储不可用时静默降级:本次会话内的拖动仍然有效。 */
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function clearStore() {
|
|
351
|
+
try {
|
|
352
|
+
window.localStorage.removeItem(STORAGE_KEY);
|
|
353
|
+
} catch (error) {
|
|
354
|
+
/* 同上。 */
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function scheduleSave() {
|
|
359
|
+
if (saveTimer) return;
|
|
360
|
+
saveTimer = window.setTimeout(function () {
|
|
361
|
+
saveTimer = 0;
|
|
362
|
+
writeStore();
|
|
363
|
+
}, SAVE_DEBOUNCE_MS);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// -------- 几何落盘
|
|
367
|
+
|
|
368
|
+
function paint() {
|
|
369
|
+
if (!bound || !geometry) return;
|
|
370
|
+
const style = bound.style;
|
|
371
|
+
style.position = 'absolute';
|
|
372
|
+
style.left = geometry.x + 'px';
|
|
373
|
+
style.top = geometry.y + 'px';
|
|
374
|
+
style.width = geometry.w + 'px';
|
|
375
|
+
style.height = geometry.h + 'px';
|
|
376
|
+
style.margin = '0';
|
|
377
|
+
style.maxWidth = 'none';
|
|
378
|
+
updateLayer();
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function restoreOriginalStyle() {
|
|
382
|
+
if (!bound) return;
|
|
383
|
+
if (originalStyle === null || originalStyle === undefined) bound.removeAttribute('style');
|
|
384
|
+
else bound.setAttribute('style', originalStyle);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** 从当前实际布局接管几何(首次打开、重置之后都走这里)。 */
|
|
388
|
+
function adoptCurrentRect() {
|
|
389
|
+
if (!bound) return false;
|
|
390
|
+
const rect = bound.getBoundingClientRect();
|
|
391
|
+
const avail = availableArea(bound);
|
|
392
|
+
geometry = fitGeometry({
|
|
393
|
+
x: rect.left,
|
|
394
|
+
y: rect.top,
|
|
395
|
+
w: rect.width || 800,
|
|
396
|
+
h: rect.height || 600,
|
|
397
|
+
}, avail);
|
|
398
|
+
return true;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// -------- 手柄层(自管理节点,绝不进入 React 管理的子列表)
|
|
402
|
+
|
|
403
|
+
function setHandleRect(dir, left, top, width, height) {
|
|
404
|
+
const handle = handles.get(dir);
|
|
405
|
+
if (!handle) return;
|
|
406
|
+
handle.style.left = Math.round(left) + 'px';
|
|
407
|
+
handle.style.top = Math.round(top) + 'px';
|
|
408
|
+
handle.style.width = Math.round(Math.max(1, width)) + 'px';
|
|
409
|
+
handle.style.height = Math.round(Math.max(1, height)) + 'px';
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function updateLayer() {
|
|
413
|
+
if (!layer) return;
|
|
414
|
+
if (!geometry) {
|
|
415
|
+
layer.style.display = 'none';
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
layer.style.display = '';
|
|
419
|
+
const x = geometry.x;
|
|
420
|
+
const y = geometry.y;
|
|
421
|
+
const w = geometry.w;
|
|
422
|
+
const h = geometry.h;
|
|
423
|
+
const pad = 8;
|
|
424
|
+
const half = HANDLE_SIZE / 2;
|
|
425
|
+
const over = 4;
|
|
426
|
+
setHandleRect('n', x + pad, y - half, w - pad * 2, HANDLE_SIZE);
|
|
427
|
+
setHandleRect('s', x + pad, y + h - half, w - pad * 2, HANDLE_SIZE);
|
|
428
|
+
setHandleRect('w', x - half, y + pad, HANDLE_SIZE, h - pad * 2);
|
|
429
|
+
setHandleRect('e', x + w - half, y + pad, HANDLE_SIZE, h - pad * 2);
|
|
430
|
+
setHandleRect('nw', x - over, y - over, CORNER_SIZE, CORNER_SIZE);
|
|
431
|
+
setHandleRect('ne', x + w - CORNER_SIZE + over, y - over, CORNER_SIZE, CORNER_SIZE);
|
|
432
|
+
setHandleRect('sw', x - over, y + h - CORNER_SIZE + over, CORNER_SIZE, CORNER_SIZE);
|
|
433
|
+
setHandleRect('se', x + w - CORNER_SIZE + over, y + h - CORNER_SIZE + over, CORNER_SIZE, CORNER_SIZE);
|
|
434
|
+
if (fallbackButton && !fallbackButton.hidden) {
|
|
435
|
+
const nav = bound && bound.firstElementChild;
|
|
436
|
+
const navWidth = nav ? nav.getBoundingClientRect().width : 188;
|
|
437
|
+
fallbackButton.style.left = Math.round(x + navWidth + 10) + 'px';
|
|
438
|
+
fallbackButton.style.top = Math.round(y + 13) + 'px';
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function buildLayer() {
|
|
443
|
+
if (layer) return;
|
|
444
|
+
layer = document.createElement('div');
|
|
445
|
+
layer.className = 'dsh-ms-layer';
|
|
446
|
+
layer.setAttribute('data-dsh-ms-layer', '');
|
|
447
|
+
DIRECTIONS.forEach(function (dir) {
|
|
448
|
+
const handle = document.createElement('div');
|
|
449
|
+
handle.className = 'dsh-ms-handle';
|
|
450
|
+
handle.setAttribute('data-dsh-ms-handle', dir);
|
|
451
|
+
handle.setAttribute('aria-hidden', 'true');
|
|
452
|
+
handle.addEventListener('pointerdown', function (event) { onHandlePointerDown(event, dir); });
|
|
453
|
+
handle.addEventListener('pointermove', onPointerMove);
|
|
454
|
+
handle.addEventListener('pointerup', onPointerUp);
|
|
455
|
+
handle.addEventListener('pointercancel', onPointerUp);
|
|
456
|
+
handles.set(dir, handle);
|
|
457
|
+
layer.appendChild(handle);
|
|
458
|
+
});
|
|
459
|
+
fallbackButton = document.createElement('button');
|
|
460
|
+
fallbackButton.type = 'button';
|
|
461
|
+
fallbackButton.className = 'dsh-ms-fallback';
|
|
462
|
+
fallbackButton.setAttribute('data-dsh-ms-fallback', '');
|
|
463
|
+
fallbackButton.hidden = true;
|
|
464
|
+
fallbackButton.addEventListener('pointerdown', function (event) { event.stopPropagation(); });
|
|
465
|
+
fallbackButton.addEventListener('click', function (event) {
|
|
466
|
+
event.stopPropagation();
|
|
467
|
+
reset();
|
|
468
|
+
});
|
|
469
|
+
layer.appendChild(fallbackButton);
|
|
470
|
+
document.body.appendChild(layer);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** 正规扩展点不可用时才显示自管理的浮层重置按钮。 */
|
|
474
|
+
function updateFallbackButton() {
|
|
475
|
+
if (!fallbackButton || !bound) return;
|
|
476
|
+
const inSlot = bound.querySelector('[data-dsh-ms-slot-reset]');
|
|
477
|
+
const shouldShow = inSlot === null;
|
|
478
|
+
if (fallbackButton.hidden !== !shouldShow) fallbackButton.hidden = !shouldShow;
|
|
479
|
+
updateLayer();
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// -------- 指针交互
|
|
483
|
+
|
|
484
|
+
function setInteractionCursor(dir) {
|
|
485
|
+
const html = document.documentElement;
|
|
486
|
+
if (dir === null) {
|
|
487
|
+
html.classList.add('dsh-ms-drag');
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
const cursors = {
|
|
491
|
+
n: 'ns-resize', s: 'ns-resize', w: 'ew-resize', e: 'ew-resize',
|
|
492
|
+
nw: 'nwse-resize', se: 'nwse-resize', ne: 'nesw-resize', sw: 'nesw-resize',
|
|
493
|
+
};
|
|
494
|
+
html.classList.add('dsh-ms-resizing');
|
|
495
|
+
html.style.setProperty('--dsh-ms-cursor', cursors[dir] || 'default');
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function clearInteractionCursor() {
|
|
499
|
+
const html = document.documentElement;
|
|
500
|
+
html.classList.remove('dsh-ms-drag');
|
|
501
|
+
html.classList.remove('dsh-ms-resizing');
|
|
502
|
+
html.style.removeProperty('--dsh-ms-cursor');
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function onPanelPointerDown(event) {
|
|
506
|
+
if (event.button !== 0 || !bound || interaction) return;
|
|
507
|
+
const rect = bound.getBoundingClientRect();
|
|
508
|
+
const offsetX = event.clientX - rect.left;
|
|
509
|
+
const offsetY = event.clientY - rect.top;
|
|
510
|
+
if (offsetX < 0 || offsetX > rect.width || offsetY < 0 || offsetY > DRAG_STRIP) return;
|
|
511
|
+
if (isInteractive(event.target, bound)) return;
|
|
512
|
+
if (!geometry && !adoptCurrentRect()) return;
|
|
513
|
+
event.stopPropagation();
|
|
514
|
+
interaction = {
|
|
515
|
+
kind: 'move', dir: null, pointerId: event.pointerId,
|
|
516
|
+
startX: event.clientX, startY: event.clientY,
|
|
517
|
+
origin: { x: geometry.x, y: geometry.y, w: geometry.w, h: geometry.h },
|
|
518
|
+
moved: false,
|
|
519
|
+
};
|
|
520
|
+
try { bound.setPointerCapture(event.pointerId); } catch (error) { /* 忽略:capture 只是优化 */ }
|
|
521
|
+
setInteractionCursor(null);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function onHandlePointerDown(event, dir) {
|
|
525
|
+
if (event.button !== 0 || !bound || interaction) return;
|
|
526
|
+
event.preventDefault();
|
|
527
|
+
event.stopPropagation();
|
|
528
|
+
if (!geometry && !adoptCurrentRect()) return;
|
|
529
|
+
paint();
|
|
530
|
+
interaction = {
|
|
531
|
+
kind: 'resize', dir: dir, pointerId: event.pointerId,
|
|
532
|
+
startX: event.clientX, startY: event.clientY,
|
|
533
|
+
origin: { x: geometry.x, y: geometry.y, w: geometry.w, h: geometry.h },
|
|
534
|
+
moved: false,
|
|
535
|
+
};
|
|
536
|
+
try { event.currentTarget.setPointerCapture(event.pointerId); } catch (error) { /* 同上 */ }
|
|
537
|
+
setInteractionCursor(dir);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function onPointerMove(event) {
|
|
541
|
+
if (!interaction || event.pointerId !== interaction.pointerId) return;
|
|
542
|
+
const dx = event.clientX - interaction.startX;
|
|
543
|
+
const dy = event.clientY - interaction.startY;
|
|
544
|
+
if (!interaction.moved && Math.abs(dx) < DRAG_THRESHOLD && Math.abs(dy) < DRAG_THRESHOLD) return;
|
|
545
|
+
interaction.moved = true;
|
|
546
|
+
if (event.cancelable !== false && typeof event.preventDefault === 'function') event.preventDefault();
|
|
547
|
+
if (!bound || !geometry) return;
|
|
548
|
+
const avail = availableArea(bound);
|
|
549
|
+
const origin = interaction.origin;
|
|
550
|
+
if (interaction.kind === 'move') {
|
|
551
|
+
geometry = fitGeometry({ x: origin.x + dx, y: origin.y + dy, w: origin.w, h: origin.h }, avail);
|
|
552
|
+
} else {
|
|
553
|
+
const dir = interaction.dir;
|
|
554
|
+
let left = origin.x;
|
|
555
|
+
let top = origin.y;
|
|
556
|
+
let right = origin.x + origin.w;
|
|
557
|
+
let bottom = origin.y + origin.h;
|
|
558
|
+
const minW = Math.min(MIN_W, avail.width);
|
|
559
|
+
const minH = Math.min(MIN_H, avail.height);
|
|
560
|
+
if (dir.indexOf('e') >= 0) right = clamp(origin.x + origin.w + dx, left + minW, avail.right);
|
|
561
|
+
if (dir.indexOf('w') >= 0) left = clamp(origin.x + dx, avail.left, Math.max(avail.left, right - minW));
|
|
562
|
+
if (dir.indexOf('s') >= 0) bottom = clamp(origin.y + origin.h + dy, top + minH, avail.bottom);
|
|
563
|
+
if (dir.indexOf('n') >= 0) top = clamp(origin.y + dy, avail.top, Math.max(avail.top, bottom - minH));
|
|
564
|
+
geometry = { x: left, y: top, w: right - left, h: bottom - top };
|
|
565
|
+
}
|
|
566
|
+
if (restore) {
|
|
567
|
+
restore = null;
|
|
568
|
+
notifyState();
|
|
569
|
+
}
|
|
570
|
+
paint();
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function onPointerUp(event) {
|
|
574
|
+
if (!interaction || event.pointerId !== interaction.pointerId) return;
|
|
575
|
+
const moved = interaction.moved;
|
|
576
|
+
try { event.currentTarget.releasePointerCapture(event.pointerId); } catch (error) { /* 忽略 */ }
|
|
577
|
+
interaction = null;
|
|
578
|
+
clearInteractionCursor();
|
|
579
|
+
if (!moved) return;
|
|
580
|
+
swallowClick = true;
|
|
581
|
+
swallowPoint = { x: event.clientX, y: event.clientY };
|
|
582
|
+
if (swallowTimer) window.clearTimeout(swallowTimer);
|
|
583
|
+
swallowTimer = window.setTimeout(function () {
|
|
584
|
+
swallowClick = false;
|
|
585
|
+
swallowPoint = null;
|
|
586
|
+
swallowTimer = 0;
|
|
587
|
+
}, SWALLOW_WINDOW_MS);
|
|
588
|
+
writeStore();
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function onPanelPointerMove(event) {
|
|
592
|
+
if (interaction) {
|
|
593
|
+
onPointerMove(event);
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
if (!bound || !geometry) return;
|
|
597
|
+
const rect = bound.getBoundingClientRect();
|
|
598
|
+
const offsetX = event.clientX - rect.left;
|
|
599
|
+
const offsetY = event.clientY - rect.top;
|
|
600
|
+
const onStrip = offsetX >= 0 && offsetX <= rect.width && offsetY >= 0 && offsetY <= DRAG_STRIP;
|
|
601
|
+
const next = onStrip && !isInteractive(event.target, bound) ? 'move' : '';
|
|
602
|
+
if (bound.style.cursor !== next) bound.style.cursor = next;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function onPanelPointerLeave() {
|
|
606
|
+
if (interaction || !bound) return;
|
|
607
|
+
if (bound.style.cursor !== '') bound.style.cursor = '';
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* 只吞「拖动结束瞬间、与抬手点几乎重合」的那一次残留 click(浏览器在 pointerup
|
|
612
|
+
* 之后补发、可能误触条带下方控件的噪声)。用户抬手后在新位置的正常点击距离较远,
|
|
613
|
+
* 一律放行 —— 判据「拖动/缩放结束后紧接着的第一次点击必须一次生效」。
|
|
614
|
+
*/
|
|
615
|
+
function isResidualClick(event) {
|
|
616
|
+
if (!swallowPoint) return false;
|
|
617
|
+
const x = Number(event.clientX);
|
|
618
|
+
const y = Number(event.clientY);
|
|
619
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) return false;
|
|
620
|
+
return Math.abs(x - swallowPoint.x) <= CLICK_SLOP && Math.abs(y - swallowPoint.y) <= CLICK_SLOP;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function onPanelClickCapture(event) {
|
|
624
|
+
if (!swallowClick) return;
|
|
625
|
+
swallowClick = false;
|
|
626
|
+
if (!isResidualClick(event)) {
|
|
627
|
+
swallowPoint = null;
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
swallowPoint = null;
|
|
631
|
+
event.stopPropagation();
|
|
632
|
+
if (typeof event.preventDefault === 'function') event.preventDefault();
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function toggleMaximize() {
|
|
636
|
+
if (!bound || !geometry) return;
|
|
637
|
+
const avail = availableArea(bound);
|
|
638
|
+
if (restore) {
|
|
639
|
+
geometry = fitGeometry(restore, avail);
|
|
640
|
+
restore = null;
|
|
641
|
+
} else {
|
|
642
|
+
restore = { x: geometry.x, y: geometry.y, w: geometry.w, h: geometry.h };
|
|
643
|
+
geometry = {
|
|
644
|
+
x: avail.left,
|
|
645
|
+
y: avail.top,
|
|
646
|
+
w: Math.max(1, avail.width),
|
|
647
|
+
h: Math.max(1, avail.height),
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
paint();
|
|
651
|
+
writeStore();
|
|
652
|
+
notifyState();
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function onPanelDblClick(event) {
|
|
656
|
+
if (event.button !== 0 || !bound || !geometry) return;
|
|
657
|
+
const rect = bound.getBoundingClientRect();
|
|
658
|
+
const offsetY = event.clientY - rect.top;
|
|
659
|
+
if (offsetY < 0 || offsetY > DRAG_STRIP) return;
|
|
660
|
+
if (isInteractive(event.target, bound)) return;
|
|
661
|
+
event.stopPropagation();
|
|
662
|
+
toggleMaximize();
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function onWindowResize() {
|
|
666
|
+
if (!bound || !geometry) return;
|
|
667
|
+
const avail = availableArea(bound);
|
|
668
|
+
if (restore) {
|
|
669
|
+
restore = fitGeometry(restore, avail);
|
|
670
|
+
geometry = {
|
|
671
|
+
x: avail.left,
|
|
672
|
+
y: avail.top,
|
|
673
|
+
w: Math.max(1, avail.width),
|
|
674
|
+
h: Math.max(1, avail.height),
|
|
675
|
+
};
|
|
676
|
+
} else {
|
|
677
|
+
geometry = fitGeometry(geometry, avail);
|
|
678
|
+
}
|
|
679
|
+
paint();
|
|
680
|
+
scheduleSave();
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// -------- 绑定 / 清理
|
|
684
|
+
|
|
685
|
+
function bind(panel) {
|
|
686
|
+
// 结构异常(缺 nav/content 或缺少标题/header 容器)时不接管:不改样式、不留节点。
|
|
687
|
+
if (!stripStructureOf(panel)) return;
|
|
688
|
+
bound = panel;
|
|
689
|
+
originalStyle = panel.getAttribute('style');
|
|
690
|
+
const stored = readStore();
|
|
691
|
+
const avail = availableArea(panel);
|
|
692
|
+
if (stored) {
|
|
693
|
+
if (stored.restore) {
|
|
694
|
+
restore = fitGeometry(stored.restore, avail);
|
|
695
|
+
geometry = {
|
|
696
|
+
x: avail.left,
|
|
697
|
+
y: avail.top,
|
|
698
|
+
w: Math.max(1, avail.width),
|
|
699
|
+
h: Math.max(1, avail.height),
|
|
700
|
+
};
|
|
701
|
+
} else {
|
|
702
|
+
restore = null;
|
|
703
|
+
geometry = fitGeometry(stored.geometry, avail);
|
|
704
|
+
}
|
|
705
|
+
} else {
|
|
706
|
+
restore = null;
|
|
707
|
+
adoptCurrentRect();
|
|
708
|
+
}
|
|
709
|
+
buildLayer();
|
|
710
|
+
paint();
|
|
711
|
+
panel.addEventListener('pointerdown', onPanelPointerDown);
|
|
712
|
+
panel.addEventListener('pointermove', onPanelPointerMove);
|
|
713
|
+
panel.addEventListener('pointerup', onPointerUp);
|
|
714
|
+
panel.addEventListener('pointercancel', onPointerUp);
|
|
715
|
+
panel.addEventListener('pointerleave', onPanelPointerLeave);
|
|
716
|
+
panel.addEventListener('dblclick', onPanelDblClick);
|
|
717
|
+
panel.addEventListener('click', onPanelClickCapture, true);
|
|
718
|
+
updateFallbackButton();
|
|
719
|
+
ensureTooltip();
|
|
720
|
+
notifyState();
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function unbind() {
|
|
724
|
+
const panel = bound;
|
|
725
|
+
if (panel) {
|
|
726
|
+
panel.removeEventListener('pointerdown', onPanelPointerDown);
|
|
727
|
+
panel.removeEventListener('pointermove', onPanelPointerMove);
|
|
728
|
+
panel.removeEventListener('pointerup', onPointerUp);
|
|
729
|
+
panel.removeEventListener('pointercancel', onPointerUp);
|
|
730
|
+
panel.removeEventListener('pointerleave', onPanelPointerLeave);
|
|
731
|
+
panel.removeEventListener('dblclick', onPanelDblClick);
|
|
732
|
+
panel.removeEventListener('click', onPanelClickCapture, true);
|
|
733
|
+
try {
|
|
734
|
+
restoreOriginalStyle();
|
|
735
|
+
restoreTitles();
|
|
736
|
+
} catch (error) { /* 节点可能已随弹窗卸载 */ }
|
|
737
|
+
}
|
|
738
|
+
bound = null;
|
|
739
|
+
originalStyle = null;
|
|
740
|
+
geometry = null;
|
|
741
|
+
restore = null;
|
|
742
|
+
interaction = null;
|
|
743
|
+
if (swallowTimer) {
|
|
744
|
+
window.clearTimeout(swallowTimer);
|
|
745
|
+
swallowTimer = 0;
|
|
746
|
+
}
|
|
747
|
+
swallowClick = false;
|
|
748
|
+
swallowPoint = null;
|
|
749
|
+
clearInteractionCursor();
|
|
750
|
+
if (layer) {
|
|
751
|
+
layer.remove();
|
|
752
|
+
layer = null;
|
|
753
|
+
}
|
|
754
|
+
handles.clear();
|
|
755
|
+
fallbackButton = null;
|
|
756
|
+
notifyState();
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* 重置为上游默认的居中布局:清空持久化 → 撤掉本插件的内联覆盖 → 以原生
|
|
761
|
+
* 布局算出的矩形重新接管(视觉零跳变,手柄依旧可用)。
|
|
762
|
+
*/
|
|
763
|
+
function reset() {
|
|
764
|
+
clearStore();
|
|
765
|
+
restore = null;
|
|
766
|
+
geometry = null;
|
|
767
|
+
if (!bound) {
|
|
768
|
+
notifyState();
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
restoreOriginalStyle();
|
|
772
|
+
adoptCurrentRect();
|
|
773
|
+
paint();
|
|
774
|
+
notifyState();
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// -------- 发现面板
|
|
778
|
+
|
|
779
|
+
function schedule() {
|
|
780
|
+
if (!active || frame) return;
|
|
781
|
+
frame = window.requestAnimationFrame(function () {
|
|
782
|
+
frame = 0;
|
|
783
|
+
scan();
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function scan() {
|
|
788
|
+
if (!active) return;
|
|
789
|
+
const panel = document.querySelector(PANEL_SELECTOR);
|
|
790
|
+
// 锚点命中但内部结构异常(缺 nav/content 或缺少标题/header 容器)同样走静默降级。
|
|
791
|
+
const usable = panel && stripStructureOf(panel) ? panel : null;
|
|
792
|
+
if (usable) {
|
|
793
|
+
if (usable !== bound) {
|
|
794
|
+
if (bound) unbind();
|
|
795
|
+
bind(usable);
|
|
796
|
+
} else {
|
|
797
|
+
updateFallbackButton();
|
|
798
|
+
ensureTooltip();
|
|
799
|
+
}
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
if (bound) unbind();
|
|
803
|
+
if (!warned && (panel || document.querySelector(ANCHOR_SELECTOR))) {
|
|
804
|
+
warned = true;
|
|
805
|
+
console.warn(
|
|
806
|
+
'[dsh-movable-settings] 设置弹窗的 DOM 结构与本插件预期不符(期望 ' + PANEL_SELECTOR +
|
|
807
|
+
' 且含 nav / content 两列各自的标题与 header 容器),已静默降级:本次不改动弹窗的任何样式与行为。',
|
|
808
|
+
);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
return {
|
|
813
|
+
start: function () {
|
|
814
|
+
if (active) return;
|
|
815
|
+
active = true;
|
|
816
|
+
styleEl = document.createElement('style');
|
|
817
|
+
styleEl.setAttribute('data-plugin', 'dsh-movable-settings');
|
|
818
|
+
styleEl.setAttribute('data-plugin-css', 'dsh-movable-settings/movable.css');
|
|
819
|
+
styleEl.textContent = CSS_TEXT;
|
|
820
|
+
document.head.appendChild(styleEl);
|
|
821
|
+
observer = new window.MutationObserver(schedule);
|
|
822
|
+
observer.observe(document.body, { childList: true, subtree: true });
|
|
823
|
+
window.addEventListener('resize', onWindowResize);
|
|
824
|
+
schedule();
|
|
825
|
+
},
|
|
826
|
+
dispose: function () {
|
|
827
|
+
if (!active) return;
|
|
828
|
+
active = false;
|
|
829
|
+
if (frame) {
|
|
830
|
+
window.cancelAnimationFrame(frame);
|
|
831
|
+
frame = 0;
|
|
832
|
+
}
|
|
833
|
+
if (observer) {
|
|
834
|
+
observer.disconnect();
|
|
835
|
+
observer = null;
|
|
836
|
+
}
|
|
837
|
+
window.removeEventListener('resize', onWindowResize);
|
|
838
|
+
unbind();
|
|
839
|
+
if (styleEl) {
|
|
840
|
+
styleEl.remove();
|
|
841
|
+
styleEl = null;
|
|
842
|
+
}
|
|
843
|
+
if (saveTimer) {
|
|
844
|
+
window.clearTimeout(saveTimer);
|
|
845
|
+
saveTimer = 0;
|
|
846
|
+
}
|
|
847
|
+
},
|
|
848
|
+
reset: reset,
|
|
849
|
+
/** 当前是否处于最大化状态(对外唯一判据)。 */
|
|
850
|
+
isMaximized: isMaximized,
|
|
851
|
+
/** 最大化 / 还原切换;按钮、双击与内部路径共用同一实现。 */
|
|
852
|
+
toggleMaximize: toggleMaximize,
|
|
853
|
+
/** 订阅最大化状态;返回退订函数。 */
|
|
854
|
+
subscribe: subscribe,
|
|
855
|
+
/** locale 服务就绪后注入翻译函数(未注入时用内置中文兜底)。 */
|
|
856
|
+
setTranslator: function (fn) {
|
|
857
|
+
if (typeof fn !== 'function') return;
|
|
858
|
+
translator = wrapTranslator(fn);
|
|
859
|
+
ensureTooltip();
|
|
860
|
+
},
|
|
861
|
+
/** 仅供测试与诊断:当前是否绑定到面板。 */
|
|
862
|
+
isBound: function () { return bound !== null; },
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// ------------------------------------------------------------------ header 按钮
|
|
867
|
+
|
|
868
|
+
/** 内联 SVG 图标:全部用 currentColor 与主题 token,不引入任何上游 Client 包。 */
|
|
869
|
+
function actionIcon(kind) {
|
|
870
|
+
const path = kind === 'maximize'
|
|
871
|
+
? h('rect', { x: 3, y: 3, width: 10, height: 10, rx: 1.5 })
|
|
872
|
+
: kind === 'restore'
|
|
873
|
+
? h('g', null,
|
|
874
|
+
h('rect', { x: 3, y: 6.5, width: 6.5, height: 6.5, rx: 1.5 }),
|
|
875
|
+
h('path', { d: 'M6.5 6.5V4.5a1.5 1.5 0 0 1 1.5-1.5h4.5a1.5 1.5 0 0 1 1.5 1.5V9a1.5 1.5 0 0 1-1.5 1.5h-2' }))
|
|
876
|
+
: h('g', null,
|
|
877
|
+
h('path', { d: 'M3.2 7.5a5 5 0 1 1 1.6 3.7' }),
|
|
878
|
+
h('path', { d: 'M3 12.2V8.6h3.6' }));
|
|
879
|
+
return h('svg', {
|
|
880
|
+
width: 14,
|
|
881
|
+
height: 14,
|
|
882
|
+
viewBox: '0 0 16 16',
|
|
883
|
+
fill: 'none',
|
|
884
|
+
stroke: 'currentColor',
|
|
885
|
+
strokeWidth: 1.3,
|
|
886
|
+
strokeLinecap: 'round',
|
|
887
|
+
strokeLinejoin: 'round',
|
|
888
|
+
'aria-hidden': 'true',
|
|
889
|
+
}, path);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* `settings.action`(list slot,位于内容列 header、Close 之前)里的最大化 / 还原按钮。
|
|
894
|
+
* 图标与文案完全由 enhancer 的订阅状态驱动,任何改变最大化状态的路径都会同步。
|
|
895
|
+
*/
|
|
896
|
+
function createMaximizeAction(enhancer) {
|
|
897
|
+
return function MaximizeAction(props) {
|
|
898
|
+
const t = props && typeof props.t === 'function' ? props.t : function (key) { return key; };
|
|
899
|
+
const [maximized, setMaximized] = React.useState(function () { return enhancer.isMaximized(); });
|
|
900
|
+
React.useEffect(function () { return enhancer.subscribe(setMaximized); }, []);
|
|
901
|
+
const key = maximized ? 'action.restore' : 'action.maximize';
|
|
902
|
+
const shortKey = maximized ? 'action.restore.short' : 'action.maximize.short';
|
|
903
|
+
const label = t(key);
|
|
904
|
+
return h('button', {
|
|
905
|
+
type: 'button',
|
|
906
|
+
className: 'dsh-ms-action',
|
|
907
|
+
'data-dsh-ms-slot-maximize': '',
|
|
908
|
+
title: label,
|
|
909
|
+
'aria-label': label,
|
|
910
|
+
'aria-pressed': maximized ? 'true' : 'false',
|
|
911
|
+
onClick: function (event) {
|
|
912
|
+
event.stopPropagation();
|
|
913
|
+
enhancer.toggleMaximize();
|
|
914
|
+
},
|
|
915
|
+
onPointerDown: function (event) { event.stopPropagation(); },
|
|
916
|
+
}, actionIcon(maximized ? 'restore' : 'maximize'), h('span', null, t(shortKey)));
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
function createResetAction(enhancer) {
|
|
921
|
+
/**
|
|
922
|
+
* `settings.action` 里的重置按钮。走正规扩展点,绝不 appendChild 进 React 管理的子列表。
|
|
923
|
+
*/
|
|
924
|
+
return function ResetAction(props) {
|
|
925
|
+
const t = props && typeof props.t === 'function' ? props.t : function (key) { return key; };
|
|
926
|
+
return h('button', {
|
|
927
|
+
type: 'button',
|
|
928
|
+
className: 'dsh-ms-action',
|
|
929
|
+
'data-dsh-ms-slot-reset': '',
|
|
930
|
+
title: t('action.reset'),
|
|
931
|
+
'aria-label': t('action.reset'),
|
|
932
|
+
onClick: function (event) {
|
|
933
|
+
event.stopPropagation();
|
|
934
|
+
enhancer.reset();
|
|
935
|
+
},
|
|
936
|
+
onPointerDown: function (event) { event.stopPropagation(); },
|
|
937
|
+
}, actionIcon('reset'), h('span', null, t('action.reset.short')));
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
return {
|
|
942
|
+
apply(ctx) {
|
|
943
|
+
const enhancer = createEnhancer();
|
|
944
|
+
ctx.effect(function () {
|
|
945
|
+
enhancer.start();
|
|
946
|
+
return function () { enhancer.dispose(); };
|
|
947
|
+
}, 'dsh-movable-settings: movable settings panel');
|
|
948
|
+
|
|
949
|
+
const MaximizeAction = createMaximizeAction(enhancer);
|
|
950
|
+
const ResetAction = createResetAction(enhancer);
|
|
951
|
+
const registerAction = function (scope) {
|
|
952
|
+
if (!scope || typeof scope.effect !== 'function' || !scope.slots || !scope.locale) return;
|
|
953
|
+
scope.effect(function () {
|
|
954
|
+
return scope.locale.register(NS, { zh: zh, en: en });
|
|
955
|
+
}, 'dsh-movable-settings: dictionary');
|
|
956
|
+
if (typeof scope.locale.bind === 'function') enhancer.setTranslator(scope.locale.bind(NS));
|
|
957
|
+
scope.slots.inject('settings.action', function () {
|
|
958
|
+
return scope.slots.register({
|
|
959
|
+
name: 'settings.action',
|
|
960
|
+
id: 'movable-settings-maximize',
|
|
961
|
+
order: 19,
|
|
962
|
+
locale: NS,
|
|
963
|
+
}, MaximizeAction);
|
|
964
|
+
});
|
|
965
|
+
scope.slots.inject('settings.action', function () {
|
|
966
|
+
return scope.slots.register({
|
|
967
|
+
name: 'settings.action',
|
|
968
|
+
id: 'movable-settings-reset',
|
|
969
|
+
order: 20,
|
|
970
|
+
locale: NS,
|
|
971
|
+
}, ResetAction);
|
|
972
|
+
});
|
|
973
|
+
};
|
|
974
|
+
if (typeof ctx.inject === 'function') {
|
|
975
|
+
ctx.inject(['slots', 'locale'], function (scope) {
|
|
976
|
+
try {
|
|
977
|
+
registerAction(scope);
|
|
978
|
+
} catch (error) {
|
|
979
|
+
console.warn('[dsh-movable-settings] header 按钮注册失败,已退化为浮层入口:', error);
|
|
980
|
+
}
|
|
981
|
+
});
|
|
982
|
+
} else {
|
|
983
|
+
try {
|
|
984
|
+
registerAction(ctx);
|
|
985
|
+
} catch (error) {
|
|
986
|
+
console.warn('[dsh-movable-settings] 重置按钮注册失败,已退化为浮层入口:', error);
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
},
|
|
990
|
+
};
|
|
991
|
+
},
|
|
992
|
+
});
|
package/cordis.patch.yml
ADDED
package/icon.svg
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Movable settings dialog">
|
|
2
|
+
<rect x="6" y="10" width="44" height="36" rx="6" fill="none" stroke="#247bbf" stroke-width="4"/>
|
|
3
|
+
<path d="M6 20h44" stroke="#247bbf" stroke-width="4" stroke-linecap="round"/>
|
|
4
|
+
<circle cx="13.5" cy="15" r="1.8" fill="#247bbf"/>
|
|
5
|
+
<circle cx="20" cy="15" r="1.8" fill="#247bbf"/>
|
|
6
|
+
<path d="M46 34l12 12M58 34v12H46" fill="none" stroke="#247bbf" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
|
7
|
+
</svg>
|
package/index.js
ADDED
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,60 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-movable-settings",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Make the DSH settings dialog movable, resizable and maximizable, remembering its geometry.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "index.js",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/zangxx66/dsh-movable-settings.git",
|
|
11
|
+
"directory": "plugins/dsh-movable-settings"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/zangxx66/dsh-movable-settings#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/zangxx66/dsh-movable-settings/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"dsh",
|
|
19
|
+
"dsh-plugin",
|
|
20
|
+
"deepseek-harness",
|
|
21
|
+
"settings",
|
|
22
|
+
"dialog",
|
|
23
|
+
"floating-window",
|
|
24
|
+
"draggable",
|
|
25
|
+
"resizable",
|
|
26
|
+
"ui"
|
|
27
|
+
],
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=22"
|
|
30
|
+
},
|
|
31
|
+
"exports": {
|
|
32
|
+
".": "./index.js",
|
|
33
|
+
"./client": "./client.js",
|
|
34
|
+
"./locale/*.json": "./locale/*.json",
|
|
35
|
+
"./icon": "./icon.svg",
|
|
36
|
+
"./package.json": "./package.json"
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"index.js",
|
|
40
|
+
"client.js",
|
|
41
|
+
"cordis.patch.yml",
|
|
42
|
+
"locale/*.json",
|
|
43
|
+
"icon.svg",
|
|
44
|
+
"README.md",
|
|
45
|
+
"LICENSE"
|
|
46
|
+
],
|
|
47
|
+
"scripts": {
|
|
48
|
+
"check": "node --check index.js && node --check client.js",
|
|
49
|
+
"test": "node --test test/enhancer.test.mjs test/manifest.test.mjs"
|
|
50
|
+
},
|
|
51
|
+
"dsh": {
|
|
52
|
+
"bundle": {
|
|
53
|
+
"patch": "./cordis.patch.yml"
|
|
54
|
+
},
|
|
55
|
+
"client": {
|
|
56
|
+
"platform": "web",
|
|
57
|
+
"immediately": true
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|