dsh-movable-settings 0.0.0-stage → 1.1.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 +150 -2
- package/client.js +1052 -0
- package/cordis.patch.yml +3 -0
- package/icon.svg +7 -0
- package/index.js +10 -0
- package/locale/de.json +6 -0
- package/locale/en.json +6 -0
- package/locale/es.json +6 -0
- package/locale/fr.json +6 -0
- package/locale/ja.json +6 -0
- package/locale/pt.json +6 -0
- package/locale/ru.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +58 -4
package/client.js
ADDED
|
@@ -0,0 +1,1052 @@
|
|
|
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
|
+
const de = {
|
|
103
|
+
'action.reset': 'Position und Größe des Einstellungsdialogs zurücksetzen',
|
|
104
|
+
'action.reset.short': 'Position zurücksetzen',
|
|
105
|
+
'action.maximize': 'Einstellungsdialog maximieren (auch per Doppelklick auf die Titelleiste)',
|
|
106
|
+
'action.maximize.short': 'Maximieren',
|
|
107
|
+
'action.restore': 'Einstellungsdialog wiederherstellen (auch per Doppelklick auf die Titelleiste)',
|
|
108
|
+
'action.restore.short': 'Wiederherstellen',
|
|
109
|
+
'tooltip.dragMaximize': 'Ziehen zum Verschieben · Doppelklick zum Maximieren',
|
|
110
|
+
'tooltip.dragRestore': 'Ziehen zum Verschieben · Doppelklick zum Wiederherstellen',
|
|
111
|
+
};
|
|
112
|
+
const es = {
|
|
113
|
+
'action.reset': 'Restablecer la posición y el tamaño del diálogo de ajustes',
|
|
114
|
+
'action.reset.short': 'Restablecer posición',
|
|
115
|
+
'action.maximize': 'Maximizar el diálogo de ajustes (también con doble clic en la barra de título)',
|
|
116
|
+
'action.maximize.short': 'Maximizar',
|
|
117
|
+
'action.restore': 'Restaurar el diálogo de ajustes (también con doble clic en la barra de título)',
|
|
118
|
+
'action.restore.short': 'Restaurar',
|
|
119
|
+
'tooltip.dragMaximize': 'Arrastrar para mover · doble clic para maximizar',
|
|
120
|
+
'tooltip.dragRestore': 'Arrastrar para mover · doble clic para restaurar',
|
|
121
|
+
};
|
|
122
|
+
const fr = {
|
|
123
|
+
'action.reset': 'Réinitialiser la position et la taille de la boîte de dialogue des paramètres',
|
|
124
|
+
'action.reset.short': 'Réinitialiser la position',
|
|
125
|
+
'action.maximize': 'Agrandir la boîte de dialogue des paramètres (ou double-clic sur la barre de titre)',
|
|
126
|
+
'action.maximize.short': 'Agrandir',
|
|
127
|
+
'action.restore': 'Restaurer la boîte de dialogue des paramètres (ou double-clic sur la barre de titre)',
|
|
128
|
+
'action.restore.short': 'Restaurer',
|
|
129
|
+
'tooltip.dragMaximize': 'Glisser pour déplacer · double-clic pour agrandir',
|
|
130
|
+
'tooltip.dragRestore': 'Glisser pour déplacer · double-clic pour restaurer',
|
|
131
|
+
};
|
|
132
|
+
const ja = {
|
|
133
|
+
'action.reset': '設定ダイアログの位置とサイズをリセット',
|
|
134
|
+
'action.reset.short': '位置をリセット',
|
|
135
|
+
'action.maximize': '設定ダイアログを最大化(タイトルバーのダブルクリックでも可)',
|
|
136
|
+
'action.maximize.short': '最大化',
|
|
137
|
+
'action.restore': '設定ダイアログを元に戻す(タイトルバーのダブルクリックでも可)',
|
|
138
|
+
'action.restore.short': '元に戻す',
|
|
139
|
+
'tooltip.dragMaximize': 'ドラッグで移動 · ダブルクリックで最大化',
|
|
140
|
+
'tooltip.dragRestore': 'ドラッグで移動 · ダブルクリックで元に戻す',
|
|
141
|
+
};
|
|
142
|
+
const pt = {
|
|
143
|
+
'action.reset': 'Redefinir a posição e o tamanho da caixa de diálogo de configurações',
|
|
144
|
+
'action.reset.short': 'Redefinir posição',
|
|
145
|
+
'action.maximize': 'Maximizar a caixa de diálogo de configurações (ou clique duplo na barra de título)',
|
|
146
|
+
'action.maximize.short': 'Maximizar',
|
|
147
|
+
'action.restore': 'Restaurar a caixa de diálogo de configurações (ou clique duplo na barra de título)',
|
|
148
|
+
'action.restore.short': 'Restaurar',
|
|
149
|
+
'tooltip.dragMaximize': 'Arraste para mover · clique duplo para maximizar',
|
|
150
|
+
'tooltip.dragRestore': 'Arraste para mover · clique duplo para restaurar',
|
|
151
|
+
};
|
|
152
|
+
const ru = {
|
|
153
|
+
'action.reset': 'Сбросить положение и размер диалога настроек',
|
|
154
|
+
'action.reset.short': 'Сбросить положение',
|
|
155
|
+
'action.maximize': 'Развернуть диалог настроек (или дважды щёлкните по заголовку)',
|
|
156
|
+
'action.maximize.short': 'Развернуть',
|
|
157
|
+
'action.restore': 'Восстановить диалог настроек (или дважды щёлкните по заголовку)',
|
|
158
|
+
'action.restore.short': 'Восстановить',
|
|
159
|
+
'tooltip.dragMaximize': 'Перетащите, чтобы переместить · двойной щелчок — развернуть',
|
|
160
|
+
'tooltip.dragRestore': 'Перетащите, чтобы переместить · двойной щелчок — восстановить',
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
/** 兜底翻译:locale 服务未就绪时仍给出可读文案。 */
|
|
164
|
+
function fallbackTranslate(key) {
|
|
165
|
+
return Object.prototype.hasOwnProperty.call(zh, key) ? zh[key] : key;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** 包一层 locale 服务返回的 t:取不到或回落到 key 时用内置中文兜底。 */
|
|
169
|
+
function wrapTranslator(t) {
|
|
170
|
+
return function (key) {
|
|
171
|
+
try {
|
|
172
|
+
const value = t(key);
|
|
173
|
+
if (typeof value === 'string' && value !== '' && value !== key) return value;
|
|
174
|
+
} catch (error) {
|
|
175
|
+
/* 落到内置文案 */
|
|
176
|
+
}
|
|
177
|
+
return fallbackTranslate(key);
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ------------------------------------------------------------------ 纯几何
|
|
182
|
+
|
|
183
|
+
function clamp(value, lo, hi) {
|
|
184
|
+
const low = Math.min(lo, hi);
|
|
185
|
+
const high = Math.max(lo, hi);
|
|
186
|
+
return Math.min(Math.max(value, low), high);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function readVarPx(el, name, fallback) {
|
|
190
|
+
try {
|
|
191
|
+
const raw = window.getComputedStyle(el).getPropertyValue(name);
|
|
192
|
+
const value = Number.parseFloat(raw);
|
|
193
|
+
return Number.isFinite(value) ? value : fallback;
|
|
194
|
+
} catch (error) {
|
|
195
|
+
return fallback;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* 视口可用区:四边各留 max(24px, --dsh-frame-overlay-top, --dsh-frame-chrome-top)。
|
|
201
|
+
* 上游默认面板高度正是 `100vh - 2 * max(24px, var(--dsh-frame-overlay-top, 24px))`,
|
|
202
|
+
* 因此四边同留白在语义上与上游一致。
|
|
203
|
+
*/
|
|
204
|
+
function availableArea(el) {
|
|
205
|
+
const margin = Math.max(
|
|
206
|
+
FALLBACK_MARGIN,
|
|
207
|
+
readVarPx(el, '--dsh-frame-overlay-top', FALLBACK_MARGIN),
|
|
208
|
+
readVarPx(el, '--dsh-frame-chrome-top', 0),
|
|
209
|
+
);
|
|
210
|
+
const width = Math.max(0, window.innerWidth - margin * 2);
|
|
211
|
+
const height = Math.max(0, window.innerHeight - margin * 2);
|
|
212
|
+
return { left: margin, top: margin, right: margin + width, bottom: margin + height, width, height };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function fitGeometry(geo, avail) {
|
|
216
|
+
const minW = Math.min(MIN_W, avail.width);
|
|
217
|
+
const minH = Math.min(MIN_H, avail.height);
|
|
218
|
+
const w = clamp(geo.w, minW, Math.max(minW, avail.width));
|
|
219
|
+
const h = clamp(geo.h, minH, Math.max(minH, avail.height));
|
|
220
|
+
const x = clamp(geo.x, avail.left, Math.max(avail.left, avail.right - w));
|
|
221
|
+
const y = clamp(geo.y, avail.top, Math.max(avail.top, avail.bottom - h));
|
|
222
|
+
return { x: Math.round(x), y: Math.round(y), w: Math.round(w), h: Math.round(h) };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function isInteractive(node, root) {
|
|
226
|
+
let el = node && typeof node.matches === 'function' ? node : null;
|
|
227
|
+
while (el && el !== root) {
|
|
228
|
+
try {
|
|
229
|
+
if (el.matches(INTERACTIVE_SELECTOR)) return true;
|
|
230
|
+
} catch (error) {
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
el = el.parentElement;
|
|
234
|
+
}
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ------------------------------------------------------------------ 控制器
|
|
239
|
+
|
|
240
|
+
function createEnhancer() {
|
|
241
|
+
let active = false;
|
|
242
|
+
let styleEl = null;
|
|
243
|
+
let observer = null;
|
|
244
|
+
let frame = 0;
|
|
245
|
+
let warned = false;
|
|
246
|
+
let bound = null;
|
|
247
|
+
let originalStyle = null;
|
|
248
|
+
let layer = null;
|
|
249
|
+
let fallbackButton = null;
|
|
250
|
+
let geometry = null;
|
|
251
|
+
let restore = null;
|
|
252
|
+
let interaction = null;
|
|
253
|
+
let swallowClick = false;
|
|
254
|
+
/** 触发吞点的 pointerup 终点坐标;click 离它太远就说明是用户的新点击。 */
|
|
255
|
+
let swallowPoint = null;
|
|
256
|
+
let swallowTimer = 0;
|
|
257
|
+
let saveTimer = 0;
|
|
258
|
+
const handles = new Map();
|
|
259
|
+
/** 最大化状态的订阅者(settings.action 里的最大化/还原按钮)。 */
|
|
260
|
+
const subscribers = new Set();
|
|
261
|
+
/** 被本插件改过 title 的节点 → 原值(null 表示原本没有该属性)。 */
|
|
262
|
+
const titleBackups = new Map();
|
|
263
|
+
/** 本次实际被改写 title 的节点;还原时遍历它,保证不留残留。 */
|
|
264
|
+
const titleTouched = new Set();
|
|
265
|
+
/** 提示与按钮文案的翻译函数;locale 服务就绪后由外部替换。 */
|
|
266
|
+
let translator = fallbackTranslate;
|
|
267
|
+
|
|
268
|
+
// -------- 最大化状态与订阅
|
|
269
|
+
|
|
270
|
+
function isMaximized() {
|
|
271
|
+
return restore !== null;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function notifyState() {
|
|
275
|
+
const maximized = isMaximized();
|
|
276
|
+
subscribers.forEach(function (subscriber) {
|
|
277
|
+
try {
|
|
278
|
+
subscriber(maximized);
|
|
279
|
+
} catch (error) {
|
|
280
|
+
/* 单个订阅者出错不影响其它订阅者与插件本身 */
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
ensureTooltip();
|
|
284
|
+
updateLayer();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** 只注册;当前值由订阅者用 isMaximized() 自行取初值。 */
|
|
288
|
+
function subscribe(subscriber) {
|
|
289
|
+
if (typeof subscriber !== 'function') return function () {};
|
|
290
|
+
subscribers.add(subscriber);
|
|
291
|
+
return function () { subscribers.delete(subscriber); };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function dragTipText() {
|
|
295
|
+
return translator(isMaximized() ? 'tooltip.dragRestore' : 'tooltip.dragMaximize');
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* 拖动条带的可发现性提示:原生 title 气泡。
|
|
300
|
+
*
|
|
301
|
+
* title 会被浏览器逐元素继承,因此**只**把它挂在条带容器上:
|
|
302
|
+
* - `nav` 的第一个子元素(settings.header 标题容器)
|
|
303
|
+
* - `content` 的第一个子元素(54px 的 header 容器,含 actions 与 Close)
|
|
304
|
+
* 这两个容器内部的交互后代若自身没有 title,会被写成 `title=""` 阻断继承,
|
|
305
|
+
* 避免气泡落到上游控件上;nav 的列表区与 content 的 options 滚动区不在容器内,
|
|
306
|
+
* 天然不会出现提示。scan() 每次都会重做一遍(React 可能重建节点)。
|
|
307
|
+
*/
|
|
308
|
+
/**
|
|
309
|
+
* 条带结构:panel 的两个直接子元素(nav / content)各自的第一个元素子元素。
|
|
310
|
+
* 结构异常(缺列或缺标题/header 容器)时返回 null,调用方走静默降级。
|
|
311
|
+
*/
|
|
312
|
+
function stripStructureOf(panel) {
|
|
313
|
+
if (!panel) return null;
|
|
314
|
+
const nav = panel.firstElementChild;
|
|
315
|
+
const content = nav ? nav.nextElementSibling : null;
|
|
316
|
+
const titleHost = nav ? nav.firstElementChild : null;
|
|
317
|
+
const headerHost = content ? content.firstElementChild : null;
|
|
318
|
+
if (!titleHost || !headerHost) return null;
|
|
319
|
+
return { titleHost: titleHost, headerHost: headerHost };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function stripContainers() {
|
|
323
|
+
const structure = stripStructureOf(bound);
|
|
324
|
+
if (!structure) return [];
|
|
325
|
+
return [structure.titleHost, structure.headerHost];
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function rememberTitle(el) {
|
|
329
|
+
if (!el) return;
|
|
330
|
+
if (!titleBackups.has(el)) titleBackups.set(el, el.getAttribute('title'));
|
|
331
|
+
titleTouched.add(el);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function ensureTooltip() {
|
|
335
|
+
if (!bound) return;
|
|
336
|
+
const text = dragTipText();
|
|
337
|
+
stripContainers().forEach(function (container) {
|
|
338
|
+
rememberTitle(container);
|
|
339
|
+
if (container.getAttribute('title') !== text) container.setAttribute('title', text);
|
|
340
|
+
let descendants = [];
|
|
341
|
+
try {
|
|
342
|
+
descendants = container.querySelectorAll(INTERACTIVE_SELECTOR);
|
|
343
|
+
} catch (error) {
|
|
344
|
+
descendants = [];
|
|
345
|
+
}
|
|
346
|
+
Array.prototype.forEach.call(descendants, function (node) {
|
|
347
|
+
if (node.hasAttribute('title')) return;
|
|
348
|
+
rememberTitle(node);
|
|
349
|
+
node.setAttribute('title', '');
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* 还原所有被本插件改写过的 title。
|
|
356
|
+
* 遍历「实际改写集合」而不是备份表:即使某节点的原值记录不再可用(例如它中途离开过文档),
|
|
357
|
+
* 也一定会被 removeAttribute,不会残留我们写下的 `title=""` 或提示文案。
|
|
358
|
+
*/
|
|
359
|
+
function restoreTitles() {
|
|
360
|
+
titleTouched.forEach(function (el) {
|
|
361
|
+
try {
|
|
362
|
+
const previous = titleBackups.has(el) ? titleBackups.get(el) : null;
|
|
363
|
+
if (previous === null || previous === undefined) el.removeAttribute('title');
|
|
364
|
+
else el.setAttribute('title', previous);
|
|
365
|
+
} catch (error) {
|
|
366
|
+
/* 节点可能已随弹窗卸载 */
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
titleBackups.clear();
|
|
370
|
+
titleTouched.clear();
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// -------- 持久化
|
|
374
|
+
|
|
375
|
+
function sanitize(value) {
|
|
376
|
+
if (!value || typeof value !== 'object') return null;
|
|
377
|
+
const nums = ['x', 'y', 'w', 'h'].map(function (key) { return Number(value[key]); });
|
|
378
|
+
if (!nums.every(function (n) { return Number.isFinite(n); })) return null;
|
|
379
|
+
if (nums[2] <= 0 || nums[3] <= 0) return null;
|
|
380
|
+
return { x: nums[0], y: nums[1], w: nums[2], h: nums[3] };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function readStore() {
|
|
384
|
+
try {
|
|
385
|
+
const raw = window.localStorage.getItem(STORAGE_KEY);
|
|
386
|
+
if (!raw) return null;
|
|
387
|
+
const parsed = JSON.parse(raw);
|
|
388
|
+
if (!parsed || typeof parsed !== 'object') return null;
|
|
389
|
+
const stored = sanitize(parsed.geometry);
|
|
390
|
+
if (!stored) return null;
|
|
391
|
+
return { geometry: stored, restore: sanitize(parsed.restore) };
|
|
392
|
+
} catch (error) {
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function writeStore() {
|
|
398
|
+
if (!geometry) return;
|
|
399
|
+
try {
|
|
400
|
+
window.localStorage.setItem(STORAGE_KEY, JSON.stringify({
|
|
401
|
+
v: 1,
|
|
402
|
+
geometry: geometry,
|
|
403
|
+
restore: restore,
|
|
404
|
+
}));
|
|
405
|
+
} catch (error) {
|
|
406
|
+
/* 存储不可用时静默降级:本次会话内的拖动仍然有效。 */
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function clearStore() {
|
|
411
|
+
try {
|
|
412
|
+
window.localStorage.removeItem(STORAGE_KEY);
|
|
413
|
+
} catch (error) {
|
|
414
|
+
/* 同上。 */
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function scheduleSave() {
|
|
419
|
+
if (saveTimer) return;
|
|
420
|
+
saveTimer = window.setTimeout(function () {
|
|
421
|
+
saveTimer = 0;
|
|
422
|
+
writeStore();
|
|
423
|
+
}, SAVE_DEBOUNCE_MS);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// -------- 几何落盘
|
|
427
|
+
|
|
428
|
+
function paint() {
|
|
429
|
+
if (!bound || !geometry) return;
|
|
430
|
+
const style = bound.style;
|
|
431
|
+
style.position = 'absolute';
|
|
432
|
+
style.left = geometry.x + 'px';
|
|
433
|
+
style.top = geometry.y + 'px';
|
|
434
|
+
style.width = geometry.w + 'px';
|
|
435
|
+
style.height = geometry.h + 'px';
|
|
436
|
+
style.margin = '0';
|
|
437
|
+
style.maxWidth = 'none';
|
|
438
|
+
updateLayer();
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function restoreOriginalStyle() {
|
|
442
|
+
if (!bound) return;
|
|
443
|
+
if (originalStyle === null || originalStyle === undefined) bound.removeAttribute('style');
|
|
444
|
+
else bound.setAttribute('style', originalStyle);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** 从当前实际布局接管几何(首次打开、重置之后都走这里)。 */
|
|
448
|
+
function adoptCurrentRect() {
|
|
449
|
+
if (!bound) return false;
|
|
450
|
+
const rect = bound.getBoundingClientRect();
|
|
451
|
+
const avail = availableArea(bound);
|
|
452
|
+
geometry = fitGeometry({
|
|
453
|
+
x: rect.left,
|
|
454
|
+
y: rect.top,
|
|
455
|
+
w: rect.width || 800,
|
|
456
|
+
h: rect.height || 600,
|
|
457
|
+
}, avail);
|
|
458
|
+
return true;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// -------- 手柄层(自管理节点,绝不进入 React 管理的子列表)
|
|
462
|
+
|
|
463
|
+
function setHandleRect(dir, left, top, width, height) {
|
|
464
|
+
const handle = handles.get(dir);
|
|
465
|
+
if (!handle) return;
|
|
466
|
+
handle.style.left = Math.round(left) + 'px';
|
|
467
|
+
handle.style.top = Math.round(top) + 'px';
|
|
468
|
+
handle.style.width = Math.round(Math.max(1, width)) + 'px';
|
|
469
|
+
handle.style.height = Math.round(Math.max(1, height)) + 'px';
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function updateLayer() {
|
|
473
|
+
if (!layer) return;
|
|
474
|
+
if (!geometry) {
|
|
475
|
+
layer.style.display = 'none';
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
layer.style.display = '';
|
|
479
|
+
const x = geometry.x;
|
|
480
|
+
const y = geometry.y;
|
|
481
|
+
const w = geometry.w;
|
|
482
|
+
const h = geometry.h;
|
|
483
|
+
const pad = 8;
|
|
484
|
+
const half = HANDLE_SIZE / 2;
|
|
485
|
+
const over = 4;
|
|
486
|
+
setHandleRect('n', x + pad, y - half, w - pad * 2, HANDLE_SIZE);
|
|
487
|
+
setHandleRect('s', x + pad, y + h - half, w - pad * 2, HANDLE_SIZE);
|
|
488
|
+
setHandleRect('w', x - half, y + pad, HANDLE_SIZE, h - pad * 2);
|
|
489
|
+
setHandleRect('e', x + w - half, y + pad, HANDLE_SIZE, h - pad * 2);
|
|
490
|
+
setHandleRect('nw', x - over, y - over, CORNER_SIZE, CORNER_SIZE);
|
|
491
|
+
setHandleRect('ne', x + w - CORNER_SIZE + over, y - over, CORNER_SIZE, CORNER_SIZE);
|
|
492
|
+
setHandleRect('sw', x - over, y + h - CORNER_SIZE + over, CORNER_SIZE, CORNER_SIZE);
|
|
493
|
+
setHandleRect('se', x + w - CORNER_SIZE + over, y + h - CORNER_SIZE + over, CORNER_SIZE, CORNER_SIZE);
|
|
494
|
+
if (fallbackButton && !fallbackButton.hidden) {
|
|
495
|
+
const nav = bound && bound.firstElementChild;
|
|
496
|
+
const navWidth = nav ? nav.getBoundingClientRect().width : 188;
|
|
497
|
+
fallbackButton.style.left = Math.round(x + navWidth + 10) + 'px';
|
|
498
|
+
fallbackButton.style.top = Math.round(y + 13) + 'px';
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function buildLayer() {
|
|
503
|
+
if (layer) return;
|
|
504
|
+
layer = document.createElement('div');
|
|
505
|
+
layer.className = 'dsh-ms-layer';
|
|
506
|
+
layer.setAttribute('data-dsh-ms-layer', '');
|
|
507
|
+
DIRECTIONS.forEach(function (dir) {
|
|
508
|
+
const handle = document.createElement('div');
|
|
509
|
+
handle.className = 'dsh-ms-handle';
|
|
510
|
+
handle.setAttribute('data-dsh-ms-handle', dir);
|
|
511
|
+
handle.setAttribute('aria-hidden', 'true');
|
|
512
|
+
handle.addEventListener('pointerdown', function (event) { onHandlePointerDown(event, dir); });
|
|
513
|
+
handle.addEventListener('pointermove', onPointerMove);
|
|
514
|
+
handle.addEventListener('pointerup', onPointerUp);
|
|
515
|
+
handle.addEventListener('pointercancel', onPointerUp);
|
|
516
|
+
handles.set(dir, handle);
|
|
517
|
+
layer.appendChild(handle);
|
|
518
|
+
});
|
|
519
|
+
fallbackButton = document.createElement('button');
|
|
520
|
+
fallbackButton.type = 'button';
|
|
521
|
+
fallbackButton.className = 'dsh-ms-fallback';
|
|
522
|
+
fallbackButton.setAttribute('data-dsh-ms-fallback', '');
|
|
523
|
+
fallbackButton.hidden = true;
|
|
524
|
+
fallbackButton.addEventListener('pointerdown', function (event) { event.stopPropagation(); });
|
|
525
|
+
fallbackButton.addEventListener('click', function (event) {
|
|
526
|
+
event.stopPropagation();
|
|
527
|
+
reset();
|
|
528
|
+
});
|
|
529
|
+
layer.appendChild(fallbackButton);
|
|
530
|
+
document.body.appendChild(layer);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** 正规扩展点不可用时才显示自管理的浮层重置按钮。 */
|
|
534
|
+
function updateFallbackButton() {
|
|
535
|
+
if (!fallbackButton || !bound) return;
|
|
536
|
+
const inSlot = bound.querySelector('[data-dsh-ms-slot-reset]');
|
|
537
|
+
const shouldShow = inSlot === null;
|
|
538
|
+
if (fallbackButton.hidden !== !shouldShow) fallbackButton.hidden = !shouldShow;
|
|
539
|
+
updateLayer();
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// -------- 指针交互
|
|
543
|
+
|
|
544
|
+
function setInteractionCursor(dir) {
|
|
545
|
+
const html = document.documentElement;
|
|
546
|
+
if (dir === null) {
|
|
547
|
+
html.classList.add('dsh-ms-drag');
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
const cursors = {
|
|
551
|
+
n: 'ns-resize', s: 'ns-resize', w: 'ew-resize', e: 'ew-resize',
|
|
552
|
+
nw: 'nwse-resize', se: 'nwse-resize', ne: 'nesw-resize', sw: 'nesw-resize',
|
|
553
|
+
};
|
|
554
|
+
html.classList.add('dsh-ms-resizing');
|
|
555
|
+
html.style.setProperty('--dsh-ms-cursor', cursors[dir] || 'default');
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function clearInteractionCursor() {
|
|
559
|
+
const html = document.documentElement;
|
|
560
|
+
html.classList.remove('dsh-ms-drag');
|
|
561
|
+
html.classList.remove('dsh-ms-resizing');
|
|
562
|
+
html.style.removeProperty('--dsh-ms-cursor');
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function onPanelPointerDown(event) {
|
|
566
|
+
if (event.button !== 0 || !bound || interaction) return;
|
|
567
|
+
const rect = bound.getBoundingClientRect();
|
|
568
|
+
const offsetX = event.clientX - rect.left;
|
|
569
|
+
const offsetY = event.clientY - rect.top;
|
|
570
|
+
if (offsetX < 0 || offsetX > rect.width || offsetY < 0 || offsetY > DRAG_STRIP) return;
|
|
571
|
+
if (isInteractive(event.target, bound)) return;
|
|
572
|
+
if (!geometry && !adoptCurrentRect()) return;
|
|
573
|
+
event.stopPropagation();
|
|
574
|
+
interaction = {
|
|
575
|
+
kind: 'move', dir: null, pointerId: event.pointerId,
|
|
576
|
+
startX: event.clientX, startY: event.clientY,
|
|
577
|
+
origin: { x: geometry.x, y: geometry.y, w: geometry.w, h: geometry.h },
|
|
578
|
+
moved: false,
|
|
579
|
+
};
|
|
580
|
+
try { bound.setPointerCapture(event.pointerId); } catch (error) { /* 忽略:capture 只是优化 */ }
|
|
581
|
+
setInteractionCursor(null);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function onHandlePointerDown(event, dir) {
|
|
585
|
+
if (event.button !== 0 || !bound || interaction) return;
|
|
586
|
+
event.preventDefault();
|
|
587
|
+
event.stopPropagation();
|
|
588
|
+
if (!geometry && !adoptCurrentRect()) return;
|
|
589
|
+
paint();
|
|
590
|
+
interaction = {
|
|
591
|
+
kind: 'resize', dir: dir, pointerId: event.pointerId,
|
|
592
|
+
startX: event.clientX, startY: event.clientY,
|
|
593
|
+
origin: { x: geometry.x, y: geometry.y, w: geometry.w, h: geometry.h },
|
|
594
|
+
moved: false,
|
|
595
|
+
};
|
|
596
|
+
try { event.currentTarget.setPointerCapture(event.pointerId); } catch (error) { /* 同上 */ }
|
|
597
|
+
setInteractionCursor(dir);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function onPointerMove(event) {
|
|
601
|
+
if (!interaction || event.pointerId !== interaction.pointerId) return;
|
|
602
|
+
const dx = event.clientX - interaction.startX;
|
|
603
|
+
const dy = event.clientY - interaction.startY;
|
|
604
|
+
if (!interaction.moved && Math.abs(dx) < DRAG_THRESHOLD && Math.abs(dy) < DRAG_THRESHOLD) return;
|
|
605
|
+
interaction.moved = true;
|
|
606
|
+
if (event.cancelable !== false && typeof event.preventDefault === 'function') event.preventDefault();
|
|
607
|
+
if (!bound || !geometry) return;
|
|
608
|
+
const avail = availableArea(bound);
|
|
609
|
+
const origin = interaction.origin;
|
|
610
|
+
if (interaction.kind === 'move') {
|
|
611
|
+
geometry = fitGeometry({ x: origin.x + dx, y: origin.y + dy, w: origin.w, h: origin.h }, avail);
|
|
612
|
+
} else {
|
|
613
|
+
const dir = interaction.dir;
|
|
614
|
+
let left = origin.x;
|
|
615
|
+
let top = origin.y;
|
|
616
|
+
let right = origin.x + origin.w;
|
|
617
|
+
let bottom = origin.y + origin.h;
|
|
618
|
+
const minW = Math.min(MIN_W, avail.width);
|
|
619
|
+
const minH = Math.min(MIN_H, avail.height);
|
|
620
|
+
if (dir.indexOf('e') >= 0) right = clamp(origin.x + origin.w + dx, left + minW, avail.right);
|
|
621
|
+
if (dir.indexOf('w') >= 0) left = clamp(origin.x + dx, avail.left, Math.max(avail.left, right - minW));
|
|
622
|
+
if (dir.indexOf('s') >= 0) bottom = clamp(origin.y + origin.h + dy, top + minH, avail.bottom);
|
|
623
|
+
if (dir.indexOf('n') >= 0) top = clamp(origin.y + dy, avail.top, Math.max(avail.top, bottom - minH));
|
|
624
|
+
geometry = { x: left, y: top, w: right - left, h: bottom - top };
|
|
625
|
+
}
|
|
626
|
+
if (restore) {
|
|
627
|
+
restore = null;
|
|
628
|
+
notifyState();
|
|
629
|
+
}
|
|
630
|
+
paint();
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function onPointerUp(event) {
|
|
634
|
+
if (!interaction || event.pointerId !== interaction.pointerId) return;
|
|
635
|
+
const moved = interaction.moved;
|
|
636
|
+
try { event.currentTarget.releasePointerCapture(event.pointerId); } catch (error) { /* 忽略 */ }
|
|
637
|
+
interaction = null;
|
|
638
|
+
clearInteractionCursor();
|
|
639
|
+
if (!moved) return;
|
|
640
|
+
swallowClick = true;
|
|
641
|
+
swallowPoint = { x: event.clientX, y: event.clientY };
|
|
642
|
+
if (swallowTimer) window.clearTimeout(swallowTimer);
|
|
643
|
+
swallowTimer = window.setTimeout(function () {
|
|
644
|
+
swallowClick = false;
|
|
645
|
+
swallowPoint = null;
|
|
646
|
+
swallowTimer = 0;
|
|
647
|
+
}, SWALLOW_WINDOW_MS);
|
|
648
|
+
writeStore();
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function onPanelPointerMove(event) {
|
|
652
|
+
if (interaction) {
|
|
653
|
+
onPointerMove(event);
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
if (!bound || !geometry) return;
|
|
657
|
+
const rect = bound.getBoundingClientRect();
|
|
658
|
+
const offsetX = event.clientX - rect.left;
|
|
659
|
+
const offsetY = event.clientY - rect.top;
|
|
660
|
+
const onStrip = offsetX >= 0 && offsetX <= rect.width && offsetY >= 0 && offsetY <= DRAG_STRIP;
|
|
661
|
+
const next = onStrip && !isInteractive(event.target, bound) ? 'move' : '';
|
|
662
|
+
if (bound.style.cursor !== next) bound.style.cursor = next;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function onPanelPointerLeave() {
|
|
666
|
+
if (interaction || !bound) return;
|
|
667
|
+
if (bound.style.cursor !== '') bound.style.cursor = '';
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* 只吞「拖动结束瞬间、与抬手点几乎重合」的那一次残留 click(浏览器在 pointerup
|
|
672
|
+
* 之后补发、可能误触条带下方控件的噪声)。用户抬手后在新位置的正常点击距离较远,
|
|
673
|
+
* 一律放行 —— 判据「拖动/缩放结束后紧接着的第一次点击必须一次生效」。
|
|
674
|
+
*/
|
|
675
|
+
function isResidualClick(event) {
|
|
676
|
+
if (!swallowPoint) return false;
|
|
677
|
+
const x = Number(event.clientX);
|
|
678
|
+
const y = Number(event.clientY);
|
|
679
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) return false;
|
|
680
|
+
return Math.abs(x - swallowPoint.x) <= CLICK_SLOP && Math.abs(y - swallowPoint.y) <= CLICK_SLOP;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function onPanelClickCapture(event) {
|
|
684
|
+
if (!swallowClick) return;
|
|
685
|
+
swallowClick = false;
|
|
686
|
+
if (!isResidualClick(event)) {
|
|
687
|
+
swallowPoint = null;
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
swallowPoint = null;
|
|
691
|
+
event.stopPropagation();
|
|
692
|
+
if (typeof event.preventDefault === 'function') event.preventDefault();
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function toggleMaximize() {
|
|
696
|
+
if (!bound || !geometry) return;
|
|
697
|
+
const avail = availableArea(bound);
|
|
698
|
+
if (restore) {
|
|
699
|
+
geometry = fitGeometry(restore, avail);
|
|
700
|
+
restore = null;
|
|
701
|
+
} else {
|
|
702
|
+
restore = { x: geometry.x, y: geometry.y, w: geometry.w, h: geometry.h };
|
|
703
|
+
geometry = {
|
|
704
|
+
x: avail.left,
|
|
705
|
+
y: avail.top,
|
|
706
|
+
w: Math.max(1, avail.width),
|
|
707
|
+
h: Math.max(1, avail.height),
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
paint();
|
|
711
|
+
writeStore();
|
|
712
|
+
notifyState();
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function onPanelDblClick(event) {
|
|
716
|
+
if (event.button !== 0 || !bound || !geometry) return;
|
|
717
|
+
const rect = bound.getBoundingClientRect();
|
|
718
|
+
const offsetY = event.clientY - rect.top;
|
|
719
|
+
if (offsetY < 0 || offsetY > DRAG_STRIP) return;
|
|
720
|
+
if (isInteractive(event.target, bound)) return;
|
|
721
|
+
event.stopPropagation();
|
|
722
|
+
toggleMaximize();
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function onWindowResize() {
|
|
726
|
+
if (!bound || !geometry) return;
|
|
727
|
+
const avail = availableArea(bound);
|
|
728
|
+
if (restore) {
|
|
729
|
+
restore = fitGeometry(restore, avail);
|
|
730
|
+
geometry = {
|
|
731
|
+
x: avail.left,
|
|
732
|
+
y: avail.top,
|
|
733
|
+
w: Math.max(1, avail.width),
|
|
734
|
+
h: Math.max(1, avail.height),
|
|
735
|
+
};
|
|
736
|
+
} else {
|
|
737
|
+
geometry = fitGeometry(geometry, avail);
|
|
738
|
+
}
|
|
739
|
+
paint();
|
|
740
|
+
scheduleSave();
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// -------- 绑定 / 清理
|
|
744
|
+
|
|
745
|
+
function bind(panel) {
|
|
746
|
+
// 结构异常(缺 nav/content 或缺少标题/header 容器)时不接管:不改样式、不留节点。
|
|
747
|
+
if (!stripStructureOf(panel)) return;
|
|
748
|
+
bound = panel;
|
|
749
|
+
originalStyle = panel.getAttribute('style');
|
|
750
|
+
const stored = readStore();
|
|
751
|
+
const avail = availableArea(panel);
|
|
752
|
+
if (stored) {
|
|
753
|
+
if (stored.restore) {
|
|
754
|
+
restore = fitGeometry(stored.restore, avail);
|
|
755
|
+
geometry = {
|
|
756
|
+
x: avail.left,
|
|
757
|
+
y: avail.top,
|
|
758
|
+
w: Math.max(1, avail.width),
|
|
759
|
+
h: Math.max(1, avail.height),
|
|
760
|
+
};
|
|
761
|
+
} else {
|
|
762
|
+
restore = null;
|
|
763
|
+
geometry = fitGeometry(stored.geometry, avail);
|
|
764
|
+
}
|
|
765
|
+
} else {
|
|
766
|
+
restore = null;
|
|
767
|
+
adoptCurrentRect();
|
|
768
|
+
}
|
|
769
|
+
buildLayer();
|
|
770
|
+
paint();
|
|
771
|
+
panel.addEventListener('pointerdown', onPanelPointerDown);
|
|
772
|
+
panel.addEventListener('pointermove', onPanelPointerMove);
|
|
773
|
+
panel.addEventListener('pointerup', onPointerUp);
|
|
774
|
+
panel.addEventListener('pointercancel', onPointerUp);
|
|
775
|
+
panel.addEventListener('pointerleave', onPanelPointerLeave);
|
|
776
|
+
panel.addEventListener('dblclick', onPanelDblClick);
|
|
777
|
+
panel.addEventListener('click', onPanelClickCapture, true);
|
|
778
|
+
updateFallbackButton();
|
|
779
|
+
ensureTooltip();
|
|
780
|
+
notifyState();
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
function unbind() {
|
|
784
|
+
const panel = bound;
|
|
785
|
+
if (panel) {
|
|
786
|
+
panel.removeEventListener('pointerdown', onPanelPointerDown);
|
|
787
|
+
panel.removeEventListener('pointermove', onPanelPointerMove);
|
|
788
|
+
panel.removeEventListener('pointerup', onPointerUp);
|
|
789
|
+
panel.removeEventListener('pointercancel', onPointerUp);
|
|
790
|
+
panel.removeEventListener('pointerleave', onPanelPointerLeave);
|
|
791
|
+
panel.removeEventListener('dblclick', onPanelDblClick);
|
|
792
|
+
panel.removeEventListener('click', onPanelClickCapture, true);
|
|
793
|
+
try {
|
|
794
|
+
restoreOriginalStyle();
|
|
795
|
+
restoreTitles();
|
|
796
|
+
} catch (error) { /* 节点可能已随弹窗卸载 */ }
|
|
797
|
+
}
|
|
798
|
+
bound = null;
|
|
799
|
+
originalStyle = null;
|
|
800
|
+
geometry = null;
|
|
801
|
+
restore = null;
|
|
802
|
+
interaction = null;
|
|
803
|
+
if (swallowTimer) {
|
|
804
|
+
window.clearTimeout(swallowTimer);
|
|
805
|
+
swallowTimer = 0;
|
|
806
|
+
}
|
|
807
|
+
swallowClick = false;
|
|
808
|
+
swallowPoint = null;
|
|
809
|
+
clearInteractionCursor();
|
|
810
|
+
if (layer) {
|
|
811
|
+
layer.remove();
|
|
812
|
+
layer = null;
|
|
813
|
+
}
|
|
814
|
+
handles.clear();
|
|
815
|
+
fallbackButton = null;
|
|
816
|
+
notifyState();
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* 重置为上游默认的居中布局:清空持久化 → 撤掉本插件的内联覆盖 → 以原生
|
|
821
|
+
* 布局算出的矩形重新接管(视觉零跳变,手柄依旧可用)。
|
|
822
|
+
*/
|
|
823
|
+
function reset() {
|
|
824
|
+
clearStore();
|
|
825
|
+
restore = null;
|
|
826
|
+
geometry = null;
|
|
827
|
+
if (!bound) {
|
|
828
|
+
notifyState();
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
restoreOriginalStyle();
|
|
832
|
+
adoptCurrentRect();
|
|
833
|
+
paint();
|
|
834
|
+
notifyState();
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// -------- 发现面板
|
|
838
|
+
|
|
839
|
+
function schedule() {
|
|
840
|
+
if (!active || frame) return;
|
|
841
|
+
frame = window.requestAnimationFrame(function () {
|
|
842
|
+
frame = 0;
|
|
843
|
+
scan();
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
function scan() {
|
|
848
|
+
if (!active) return;
|
|
849
|
+
const panel = document.querySelector(PANEL_SELECTOR);
|
|
850
|
+
// 锚点命中但内部结构异常(缺 nav/content 或缺少标题/header 容器)同样走静默降级。
|
|
851
|
+
const usable = panel && stripStructureOf(panel) ? panel : null;
|
|
852
|
+
if (usable) {
|
|
853
|
+
if (usable !== bound) {
|
|
854
|
+
if (bound) unbind();
|
|
855
|
+
bind(usable);
|
|
856
|
+
} else {
|
|
857
|
+
updateFallbackButton();
|
|
858
|
+
ensureTooltip();
|
|
859
|
+
}
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
if (bound) unbind();
|
|
863
|
+
if (!warned && (panel || document.querySelector(ANCHOR_SELECTOR))) {
|
|
864
|
+
warned = true;
|
|
865
|
+
console.warn(
|
|
866
|
+
'[dsh-movable-settings] 设置弹窗的 DOM 结构与本插件预期不符(期望 ' + PANEL_SELECTOR +
|
|
867
|
+
' 且含 nav / content 两列各自的标题与 header 容器),已静默降级:本次不改动弹窗的任何样式与行为。',
|
|
868
|
+
);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
return {
|
|
873
|
+
start: function () {
|
|
874
|
+
if (active) return;
|
|
875
|
+
active = true;
|
|
876
|
+
styleEl = document.createElement('style');
|
|
877
|
+
styleEl.setAttribute('data-plugin', 'dsh-movable-settings');
|
|
878
|
+
styleEl.setAttribute('data-plugin-css', 'dsh-movable-settings/movable.css');
|
|
879
|
+
styleEl.textContent = CSS_TEXT;
|
|
880
|
+
document.head.appendChild(styleEl);
|
|
881
|
+
observer = new window.MutationObserver(schedule);
|
|
882
|
+
observer.observe(document.body, { childList: true, subtree: true });
|
|
883
|
+
window.addEventListener('resize', onWindowResize);
|
|
884
|
+
schedule();
|
|
885
|
+
},
|
|
886
|
+
dispose: function () {
|
|
887
|
+
if (!active) return;
|
|
888
|
+
active = false;
|
|
889
|
+
if (frame) {
|
|
890
|
+
window.cancelAnimationFrame(frame);
|
|
891
|
+
frame = 0;
|
|
892
|
+
}
|
|
893
|
+
if (observer) {
|
|
894
|
+
observer.disconnect();
|
|
895
|
+
observer = null;
|
|
896
|
+
}
|
|
897
|
+
window.removeEventListener('resize', onWindowResize);
|
|
898
|
+
unbind();
|
|
899
|
+
if (styleEl) {
|
|
900
|
+
styleEl.remove();
|
|
901
|
+
styleEl = null;
|
|
902
|
+
}
|
|
903
|
+
if (saveTimer) {
|
|
904
|
+
window.clearTimeout(saveTimer);
|
|
905
|
+
saveTimer = 0;
|
|
906
|
+
}
|
|
907
|
+
},
|
|
908
|
+
reset: reset,
|
|
909
|
+
/** 当前是否处于最大化状态(对外唯一判据)。 */
|
|
910
|
+
isMaximized: isMaximized,
|
|
911
|
+
/** 最大化 / 还原切换;按钮、双击与内部路径共用同一实现。 */
|
|
912
|
+
toggleMaximize: toggleMaximize,
|
|
913
|
+
/** 订阅最大化状态;返回退订函数。 */
|
|
914
|
+
subscribe: subscribe,
|
|
915
|
+
/** locale 服务就绪后注入翻译函数(未注入时用内置中文兜底)。 */
|
|
916
|
+
setTranslator: function (fn) {
|
|
917
|
+
if (typeof fn !== 'function') return;
|
|
918
|
+
translator = wrapTranslator(fn);
|
|
919
|
+
ensureTooltip();
|
|
920
|
+
},
|
|
921
|
+
/** 仅供测试与诊断:当前是否绑定到面板。 */
|
|
922
|
+
isBound: function () { return bound !== null; },
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// ------------------------------------------------------------------ header 按钮
|
|
927
|
+
|
|
928
|
+
/** 内联 SVG 图标:全部用 currentColor 与主题 token,不引入任何上游 Client 包。 */
|
|
929
|
+
function actionIcon(kind) {
|
|
930
|
+
const path = kind === 'maximize'
|
|
931
|
+
? h('rect', { x: 3, y: 3, width: 10, height: 10, rx: 1.5 })
|
|
932
|
+
: kind === 'restore'
|
|
933
|
+
? h('g', null,
|
|
934
|
+
h('rect', { x: 3, y: 6.5, width: 6.5, height: 6.5, rx: 1.5 }),
|
|
935
|
+
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' }))
|
|
936
|
+
: h('g', null,
|
|
937
|
+
h('path', { d: 'M3.2 7.5a5 5 0 1 1 1.6 3.7' }),
|
|
938
|
+
h('path', { d: 'M3 12.2V8.6h3.6' }));
|
|
939
|
+
return h('svg', {
|
|
940
|
+
width: 14,
|
|
941
|
+
height: 14,
|
|
942
|
+
viewBox: '0 0 16 16',
|
|
943
|
+
fill: 'none',
|
|
944
|
+
stroke: 'currentColor',
|
|
945
|
+
strokeWidth: 1.3,
|
|
946
|
+
strokeLinecap: 'round',
|
|
947
|
+
strokeLinejoin: 'round',
|
|
948
|
+
'aria-hidden': 'true',
|
|
949
|
+
}, path);
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
/**
|
|
953
|
+
* `settings.action`(list slot,位于内容列 header、Close 之前)里的最大化 / 还原按钮。
|
|
954
|
+
* 图标与文案完全由 enhancer 的订阅状态驱动,任何改变最大化状态的路径都会同步。
|
|
955
|
+
*/
|
|
956
|
+
function createMaximizeAction(enhancer) {
|
|
957
|
+
return function MaximizeAction(props) {
|
|
958
|
+
const t = props && typeof props.t === 'function' ? props.t : function (key) { return key; };
|
|
959
|
+
const [maximized, setMaximized] = React.useState(function () { return enhancer.isMaximized(); });
|
|
960
|
+
React.useEffect(function () { return enhancer.subscribe(setMaximized); }, []);
|
|
961
|
+
const key = maximized ? 'action.restore' : 'action.maximize';
|
|
962
|
+
const shortKey = maximized ? 'action.restore.short' : 'action.maximize.short';
|
|
963
|
+
const label = t(key);
|
|
964
|
+
return h('button', {
|
|
965
|
+
type: 'button',
|
|
966
|
+
className: 'dsh-ms-action',
|
|
967
|
+
'data-dsh-ms-slot-maximize': '',
|
|
968
|
+
title: label,
|
|
969
|
+
'aria-label': label,
|
|
970
|
+
'aria-pressed': maximized ? 'true' : 'false',
|
|
971
|
+
onClick: function (event) {
|
|
972
|
+
event.stopPropagation();
|
|
973
|
+
enhancer.toggleMaximize();
|
|
974
|
+
},
|
|
975
|
+
onPointerDown: function (event) { event.stopPropagation(); },
|
|
976
|
+
}, actionIcon(maximized ? 'restore' : 'maximize'), h('span', null, t(shortKey)));
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
function createResetAction(enhancer) {
|
|
981
|
+
/**
|
|
982
|
+
* `settings.action` 里的重置按钮。走正规扩展点,绝不 appendChild 进 React 管理的子列表。
|
|
983
|
+
*/
|
|
984
|
+
return function ResetAction(props) {
|
|
985
|
+
const t = props && typeof props.t === 'function' ? props.t : function (key) { return key; };
|
|
986
|
+
return h('button', {
|
|
987
|
+
type: 'button',
|
|
988
|
+
className: 'dsh-ms-action',
|
|
989
|
+
'data-dsh-ms-slot-reset': '',
|
|
990
|
+
title: t('action.reset'),
|
|
991
|
+
'aria-label': t('action.reset'),
|
|
992
|
+
onClick: function (event) {
|
|
993
|
+
event.stopPropagation();
|
|
994
|
+
enhancer.reset();
|
|
995
|
+
},
|
|
996
|
+
onPointerDown: function (event) { event.stopPropagation(); },
|
|
997
|
+
}, actionIcon('reset'), h('span', null, t('action.reset.short')));
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
return {
|
|
1002
|
+
apply(ctx) {
|
|
1003
|
+
const enhancer = createEnhancer();
|
|
1004
|
+
ctx.effect(function () {
|
|
1005
|
+
enhancer.start();
|
|
1006
|
+
return function () { enhancer.dispose(); };
|
|
1007
|
+
}, 'dsh-movable-settings: movable settings panel');
|
|
1008
|
+
|
|
1009
|
+
const MaximizeAction = createMaximizeAction(enhancer);
|
|
1010
|
+
const ResetAction = createResetAction(enhancer);
|
|
1011
|
+
const registerAction = function (scope) {
|
|
1012
|
+
if (!scope || typeof scope.effect !== 'function' || !scope.slots || !scope.locale) return;
|
|
1013
|
+
scope.effect(function () {
|
|
1014
|
+
return scope.locale.register(NS, { zh, en, de, es, fr, ja, pt, ru });
|
|
1015
|
+
}, 'dsh-movable-settings: dictionary');
|
|
1016
|
+
if (typeof scope.locale.bind === 'function') enhancer.setTranslator(scope.locale.bind(NS));
|
|
1017
|
+
scope.slots.inject('settings.action', function () {
|
|
1018
|
+
return scope.slots.register({
|
|
1019
|
+
name: 'settings.action',
|
|
1020
|
+
id: 'movable-settings-maximize',
|
|
1021
|
+
order: 19,
|
|
1022
|
+
locale: NS,
|
|
1023
|
+
}, MaximizeAction);
|
|
1024
|
+
});
|
|
1025
|
+
scope.slots.inject('settings.action', function () {
|
|
1026
|
+
return scope.slots.register({
|
|
1027
|
+
name: 'settings.action',
|
|
1028
|
+
id: 'movable-settings-reset',
|
|
1029
|
+
order: 20,
|
|
1030
|
+
locale: NS,
|
|
1031
|
+
}, ResetAction);
|
|
1032
|
+
});
|
|
1033
|
+
};
|
|
1034
|
+
if (typeof ctx.inject === 'function') {
|
|
1035
|
+
ctx.inject(['slots', 'locale'], function (scope) {
|
|
1036
|
+
try {
|
|
1037
|
+
registerAction(scope);
|
|
1038
|
+
} catch (error) {
|
|
1039
|
+
console.warn('[dsh-movable-settings] header 按钮注册失败,已退化为浮层入口:', error);
|
|
1040
|
+
}
|
|
1041
|
+
});
|
|
1042
|
+
} else {
|
|
1043
|
+
try {
|
|
1044
|
+
registerAction(ctx);
|
|
1045
|
+
} catch (error) {
|
|
1046
|
+
console.warn('[dsh-movable-settings] 重置按钮注册失败,已退化为浮层入口:', error);
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
},
|
|
1050
|
+
};
|
|
1051
|
+
},
|
|
1052
|
+
});
|