@iasiv5/dsh-quota-watch 0.1.9 → 0.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/lib/client.js +230 -53
- package/package.json +1 -1
- package/src/client/drag.mjs +49 -0
- package/src/client/prefs.mjs +39 -15
- package/src/client.mjs +216 -49
package/README.md
CHANGED
|
@@ -6,11 +6,11 @@
|
|
|
6
6
|
|
|
7
7
|
## 功能
|
|
8
8
|
|
|
9
|
-
- **悬浮胶囊**(0.1.1
|
|
10
|
-
-
|
|
9
|
+
- **悬浮胶囊**(0.1.1 起为唯一浮动形态,圆球已退役):拖拽松手后磁性贴靠左右屏幕边缘、完整可见(距边 8px),记住停靠边与纵向位置,跨重启恢复;数据文本变宽、窗口缩放或转屏后自动重新贴边。拖拽跟手(保持抓取点,transform 渲染);触摸设备可直接拖拽(触摸阈值更宽防误拖)、长按 500ms 呼出菜单,点击热区扩展到 44px,自动避让 iPhone 刘海 / 小黑条(safe-area)。胶囊一行显示「GLM 13% · Copilot 24%」,已用超过 80% 转橙、超过 95% 转红,任一 provider 超 95% 时胶囊轻微脉动(尊重系统「减少动态效果」设置,开启时不脉动、贴边直落无动画);颜色使用 DSH 主题语义变量,自动适配浅色 / 深色主题。悬停提示与 aria-label 实时给出摘要。
|
|
10
|
+
- **面板**:点击胶囊展开浮动详情面板(半透明毛玻璃底,宽 `min(320px, 100vw−24px)`,靠边自动翻侧,滚动 / 缩放跟随;≤480px 窄屏自动改为底部弹层并避开 safe-area),头部带标题与 ✕,Esc 或点击面板外关闭。概览列两个 provider 行,点击进入详情:GLM 为今日 Tokens / 调用次数双大数字 + 五列对齐窗口网格(MCP(月)→ 5 小时 → 每月 → 每周固定排序,周窗口未下发时显示「每周额度 · ♾️(无限)」占位)+ 分模型用量行;Copilot 为可用额度、本期已用(credits 消耗 + 弱化百分比)、重置时间三行。接口未下发有效 `remaining` / `percent_remaining` 时该行不渲染,`credits_used` 绝不用于推算余额。
|
|
11
11
|
- **侧边栏卡片(可选,默认隐藏)**:胶囊是默认界面;需要侧栏卡片时在胶囊右键菜单开启「显示侧边栏卡片」(Web profile 侧栏底部区块存在时才可选)。卡片显示时提供摘要行 + 行点击开面板 + 行首 provider 名称点击立即探测。侧栏收起(Web 窄栏)或容器过窄(<160px,跨 profile 通用判据)时卡片自动隐藏,侧栏整体不存在(Desktop 收起态)同样自然缺席、无报错——入口始终由悬浮胶囊承载。
|
|
12
12
|
- **右键菜单**:显示 / 隐藏侧边栏卡片 · 立即刷新。菜单项随上下文增减(无侧栏卡片挂载点时只剩刷新项)。
|
|
13
|
-
- 凭据缺失的 provider 不渲染摘要行与胶囊分段(两者皆缺时悬浮胶囊与卡片一并隐藏);探测失败时保留最后一次成功数据并在行上加 ⚠ 过期标记(⚠
|
|
13
|
+
- 凭据缺失的 provider 不渲染摘要行与胶囊分段(两者皆缺时悬浮胶囊与卡片一并隐藏);探测失败时保留最后一次成功数据并在行上加 ⚠ 过期标记(⚠ 只出现在面板与卡片行内,不在胶囊上显示)。
|
|
14
14
|
- 宿主默认每 60 秒探测(窗口 + 用量 + Copilot);profile patch 可将 `pollIntervalSec` 设置在 30–3600 秒。浏览器页签可见时每 30 秒刷新视图,隐藏时暂停。
|
|
15
15
|
|
|
16
16
|
## 兼容与配置
|
|
@@ -47,4 +47,4 @@ dsh plugin --profile web add @iasiv5/dsh-quota-watch
|
|
|
47
47
|
|
|
48
48
|
## English summary
|
|
49
49
|
|
|
50
|
-
DSH Quota Watch is a personal DSH quota monitor built around a persistent floating capsule: a one-line `GLM x% · Copilot y%` summary that turns amber past 80% and red past 95
|
|
50
|
+
DSH Quota Watch is a personal DSH quota monitor built around a persistent floating capsule: a one-line `GLM x% · Copilot y%` summary that turns amber past 80% and red past 95%. Dragging it is grab-accurate (transform-based, rAF-batched) and the capsule magnetically docks to the nearest left/right screen edge after release — fully visible, with the docked edge and vertical offset remembered across restarts. Touch devices drag directly (wider slop against accidental drags), a 500ms long-press opens the context menu (iOS has no right-click), the hit area is padded to 44px and the dock respects `env(safe-area-inset-*)`. Clicking the capsule opens a floating glass panel whose overview reuses the classic 0.0.18 summary rows (label · bar · percent · extra · chevron) and whose detail pages replicate the 0.0.18 big numbers, window grid and per-model list; the panel closes via ✕, Esc or an outside click, and viewports ≤480px dock it to the bottom as a sheet. A sidebar card remains an opportunistic extra entry on web profiles, opt-in from the capsule's menu, and silently disappears whenever the sidebar does — collapsed web sidebars and the desktop profile are covered by the floating capsule alone. The right-click menu (long-press on touch) offers card visibility and an immediate provider re-probe. Credentials are resolved on every host-side probe, only normalized values reach the browser, no usage history is kept, and a Copilot balance is never inferred from credits used. Colors ride DSH semantic theme tokens with hardcoded fallbacks; all UI lives inside a shadow root (ADR 0001).
|
package/lib/client.js
CHANGED
|
@@ -82,35 +82,51 @@ function remainingToUsed(percentRemaining) {
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
// src/client/prefs.mjs
|
|
85
|
-
var
|
|
85
|
+
var FLOAT_DOCK_KEY = "dsh-quota-watch:float-dock";
|
|
86
86
|
var SURFACE_FLAGS_KEY = "dsh-quota-watch:surface-flags";
|
|
87
87
|
var MARGIN = 8;
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
const
|
|
88
|
+
var ZERO_INSETS = { left: 0, right: 0, top: 0, bottom: 0 };
|
|
89
|
+
function normalizeSize(size) {
|
|
90
|
+
if (typeof size === "number") return { width: size, height: size };
|
|
91
|
+
const width = typeof size?.width === "number" ? size.width : 0;
|
|
92
|
+
const height = typeof size?.height === "number" ? size.height : 0;
|
|
93
|
+
return { width, height };
|
|
94
|
+
}
|
|
95
|
+
function clampPoint(point, viewport, size = 0, insets = ZERO_INSETS) {
|
|
96
|
+
const { width, height } = normalizeSize(size);
|
|
97
|
+
const insetLeft = Number.isFinite(insets?.left) ? insets.left : 0;
|
|
98
|
+
const insetRight = Number.isFinite(insets?.right) ? insets.right : 0;
|
|
99
|
+
const insetTop = Number.isFinite(insets?.top) ? insets.top : 0;
|
|
100
|
+
const insetBottom = Number.isFinite(insets?.bottom) ? insets.bottom : 0;
|
|
101
|
+
const minX = MARGIN + insetLeft;
|
|
102
|
+
const minY = MARGIN + insetTop;
|
|
103
|
+
const maxX = Math.max(minX, viewport.width - insetRight - MARGIN - width);
|
|
104
|
+
const maxY = Math.max(minY, viewport.height - insetBottom - MARGIN - height);
|
|
93
105
|
return {
|
|
94
106
|
x: Math.min(Math.max(point.x, minX), maxX),
|
|
95
107
|
y: Math.min(Math.max(point.y, minY), maxY)
|
|
96
108
|
};
|
|
97
109
|
}
|
|
98
|
-
|
|
110
|
+
var DOCK_EDGES = /* @__PURE__ */ new Set(["left", "right"]);
|
|
111
|
+
function loadFloatDock(storage) {
|
|
99
112
|
try {
|
|
100
|
-
const raw = storage.getItem(
|
|
113
|
+
const raw = storage.getItem(FLOAT_DOCK_KEY);
|
|
101
114
|
if (typeof raw !== "string" || raw === "") return null;
|
|
102
115
|
const parsed = JSON.parse(raw);
|
|
103
|
-
if (typeof parsed !== "object" || parsed === null || !
|
|
116
|
+
if (typeof parsed !== "object" || parsed === null || !DOCK_EDGES.has(parsed.edge) || !Number.isFinite(parsed.offsetY)) {
|
|
104
117
|
return null;
|
|
105
118
|
}
|
|
106
|
-
return {
|
|
119
|
+
return { edge: parsed.edge, offsetY: parsed.offsetY };
|
|
107
120
|
} catch {
|
|
108
121
|
return null;
|
|
109
122
|
}
|
|
110
123
|
}
|
|
111
|
-
function
|
|
124
|
+
function saveFloatDock(storage, dock) {
|
|
125
|
+
if (typeof dock !== "object" || dock === null || !DOCK_EDGES.has(dock.edge) || !Number.isFinite(dock.offsetY)) {
|
|
126
|
+
throw new TypeError(`invalid float dock: ${JSON.stringify(dock)}`);
|
|
127
|
+
}
|
|
112
128
|
try {
|
|
113
|
-
storage.setItem(
|
|
129
|
+
storage.setItem(FLOAT_DOCK_KEY, JSON.stringify({ edge: dock.edge, offsetY: dock.offsetY }));
|
|
114
130
|
} catch {
|
|
115
131
|
}
|
|
116
132
|
}
|
|
@@ -134,14 +150,46 @@ function saveSurfaceFlags(storage, flags) {
|
|
|
134
150
|
}
|
|
135
151
|
}
|
|
136
152
|
|
|
153
|
+
// src/client/drag.mjs
|
|
154
|
+
var DRAG_SLOP_MOUSE = 6;
|
|
155
|
+
var DRAG_SLOP_TOUCH = 10;
|
|
156
|
+
var LONG_PRESS_MS = 500;
|
|
157
|
+
function dragSlop(pointerType) {
|
|
158
|
+
return pointerType === "touch" || pointerType === "pen" ? DRAG_SLOP_TOUCH : DRAG_SLOP_MOUSE;
|
|
159
|
+
}
|
|
160
|
+
function grabOffset(point, rect) {
|
|
161
|
+
return { dx: point.x - rect.left, dy: point.y - rect.top };
|
|
162
|
+
}
|
|
163
|
+
function pillTopLeft(point, grab) {
|
|
164
|
+
return { x: point.x - grab.dx, y: point.y - grab.dy };
|
|
165
|
+
}
|
|
166
|
+
function clampFrame(point, grab, size, viewport, insets) {
|
|
167
|
+
return clampPoint(pillTopLeft(point, grab), viewport, size, insets);
|
|
168
|
+
}
|
|
169
|
+
function releaseDock(point, grab, size, viewport, insets) {
|
|
170
|
+
const topLeft = pillTopLeft(point, grab);
|
|
171
|
+
const centerX = topLeft.x + size.width / 2;
|
|
172
|
+
const edge = centerX < viewport.width / 2 ? "left" : "right";
|
|
173
|
+
return { edge, offsetY: clampPoint(topLeft, viewport, size, insets).y };
|
|
174
|
+
}
|
|
175
|
+
function dockX(edge, width, viewport, insets) {
|
|
176
|
+
const insetLeft = Number.isFinite(insets?.left) ? insets.left : 0;
|
|
177
|
+
const insetRight = Number.isFinite(insets?.right) ? insets.right : 0;
|
|
178
|
+
return edge === "left" ? MARGIN + insetLeft : viewport.width - insetRight - MARGIN - width;
|
|
179
|
+
}
|
|
180
|
+
function parseInset(computedValue) {
|
|
181
|
+
const value = Number.parseFloat(computedValue);
|
|
182
|
+
return Number.isFinite(value) ? value : 0;
|
|
183
|
+
}
|
|
184
|
+
|
|
137
185
|
// src/client.mjs
|
|
138
186
|
var name = "quota-watch-client";
|
|
139
187
|
var inject = [];
|
|
140
188
|
var CARD_SELECTOR = "[data-dsh-quota-watch-card]";
|
|
141
189
|
var FLOAT_SELECTOR = "[data-dsh-quota-watch-float]";
|
|
142
190
|
var FETCH_TIMEOUT_MS = 15e3;
|
|
143
|
-
var
|
|
144
|
-
var
|
|
191
|
+
var FALLBACK_SIZE = { width: 38, height: 26 };
|
|
192
|
+
var ZERO_INSETS2 = { left: 0, right: 0, top: 0, bottom: 0 };
|
|
145
193
|
var CARD_MIN_WIDTH = 160;
|
|
146
194
|
var COPY = {
|
|
147
195
|
zh: {
|
|
@@ -238,16 +286,21 @@ var STYLE_TEXT = `
|
|
|
238
286
|
`;
|
|
239
287
|
var FLOAT_STYLE_TEXT = `
|
|
240
288
|
:host { position: fixed; z-index: 2147483000; }
|
|
241
|
-
.dqw-capsule { display: inline-flex; align-items: center; gap: 6px; height: 26px; padding: 0 10px; border: 1px solid var(--dsw-alias-border-secondary, rgba(128,128,128,.35)); border-radius: 999px; background: var(--dsw-alias-bg-base, rgba(128,128,128,.08)); background: color-mix(in srgb, var(--dsw-alias-bg-base, #1f1f1f) 86%, transparent); -webkit-backdrop-filter: blur(10px) saturate(1.2); backdrop-filter: blur(10px) saturate(1.2); box-shadow: 0 4px 14px rgba(0,0,0,.22); color: var(--dsw-alias-label-primary, inherit); font: inherit; font-size: 11px; line-height: 24px; cursor:
|
|
242
|
-
.dqw-capsule
|
|
289
|
+
.dqw-capsule { display: inline-flex; align-items: center; gap: 6px; height: 26px; padding: 0 10px; border: 1px solid var(--dsw-alias-border-secondary, rgba(128,128,128,.35)); border-radius: 999px; background: var(--dsw-alias-bg-base, rgba(128,128,128,.08)); background: color-mix(in srgb, var(--dsw-alias-bg-base, #1f1f1f) 86%, transparent); -webkit-backdrop-filter: blur(10px) saturate(1.2); backdrop-filter: blur(10px) saturate(1.2); box-shadow: 0 4px 14px rgba(0,0,0,.22); color: var(--dsw-alias-label-primary, inherit); font: inherit; font-size: 11px; line-height: 24px; cursor: grab; user-select: none; white-space: nowrap; position: relative; touch-action: none; -webkit-tap-highlight-color: transparent; -webkit-touch-callout: none; }
|
|
290
|
+
.dqw-capsule::before { content: ''; position: absolute; inset: -9px; }
|
|
291
|
+
.dqw-capsule:hover { border-color: var(--dsw-alias-border-primary, var(--dsw-alias-border-secondary, rgba(128,128,128,.35))); box-shadow: 0 6px 18px rgba(0,0,0,.28); }
|
|
292
|
+
.dqw-capsule:active { transform: scale(.98); }
|
|
243
293
|
.dqw-capsule:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary, #5b8def); outline-offset: 2px; }
|
|
244
294
|
.dqw-capsule-sep { opacity: .5; }
|
|
245
295
|
.dqw-capsule-glm, .dqw-capsule-copilot { font-variant-numeric: tabular-nums; font-weight: 600; }
|
|
246
296
|
.dqw-capsule-glm.warn, .dqw-capsule-copilot.warn { color: var(--dsw-alias-label-warning, #d29922); }
|
|
247
297
|
.dqw-capsule-glm.danger, .dqw-capsule-copilot.danger { color: var(--dsw-alias-label-danger, #c93c3c); }
|
|
248
298
|
.dqw-capsule[hidden] { display: none; }
|
|
299
|
+
.dqw-capsule--dragging { cursor: grabbing; -webkit-backdrop-filter: none; backdrop-filter: none; background: var(--dsw-alias-bg-elevated, var(--dsw-alias-bg-base, #1f1f1f)); will-change: transform; transition: none; }
|
|
249
300
|
@media (prefers-reduced-motion: no-preference) {
|
|
250
301
|
.dqw-capsule[data-alert] { animation: dqw-pulse 1.6s ease-in-out infinite; }
|
|
302
|
+
.dqw-capsule { transition: border-color .12s ease-out, box-shadow .12s ease-out; }
|
|
303
|
+
.dqw-capsule--snapping { transition: transform 150ms cubic-bezier(.2,.8,.2,1); }
|
|
251
304
|
}
|
|
252
305
|
@media print { :host { display: none !important; } }
|
|
253
306
|
@keyframes dqw-pulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.06); } }
|
|
@@ -257,6 +310,7 @@ var FLOAT_STYLE_TEXT = `
|
|
|
257
310
|
.dqw-menu-item:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.12)); }
|
|
258
311
|
.dqw-panel { position: fixed; z-index: 2147483000; min-width: 166px; max-width: min(320px, calc(100vw - 24px)); max-height: calc(100vh - 24px); overflow-y: auto; padding: 10px 12px; border: 1px solid var(--dsw-alias-border-secondary, rgba(128,128,128,.35)); border-radius: 10px; background: var(--dsw-alias-bg-elevated, var(--dsw-alias-bg-base, #1f1f1f)); background: color-mix(in srgb, var(--dsw-alias-bg-base, #1f1f1f) 86%, transparent); -webkit-backdrop-filter: blur(14px) saturate(1.3); backdrop-filter: blur(14px) saturate(1.3); box-shadow: 0 8px 24px rgba(0,0,0,.25); color: var(--dsw-alias-label-primary, inherit); display: flex; flex-direction: column; gap: 8px; font: inherit; }
|
|
259
312
|
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) { .dqw-panel { background: var(--dsw-alias-bg-elevated, var(--dsw-alias-bg-base, #1f1f1f)); } }
|
|
313
|
+
.dqw-panel--sheet { left:8px; right:8px; top:auto; bottom:calc(8px + env(safe-area-inset-bottom, 0px)); width:auto; max-width:none; max-height:calc(100dvh - 24px); }
|
|
260
314
|
.dqw-panel[hidden] { display: none; }
|
|
261
315
|
.dqw-panel *, .dqw-panel *::before, .dqw-panel *::after { box-sizing: border-box; }
|
|
262
316
|
.dqw-panel-header { display: flex; align-items: center; gap: 6px; min-width: 0; }
|
|
@@ -276,7 +330,7 @@ var FLOAT_STYLE_TEXT = `
|
|
|
276
330
|
.dqw-label { font-size: 11px; font-weight: 600; width: 52px; flex: none; }
|
|
277
331
|
.dqw-label[data-action="refresh"] { cursor: pointer; border-radius: 4px; }
|
|
278
332
|
.dqw-label[data-action="refresh"]:hover { opacity: .75; }
|
|
279
|
-
.dqw-bar { display: block; flex: 0 0 auto; width:
|
|
333
|
+
.dqw-bar { display: block; flex: 0 0 auto; width: 100px; height: 4px; overflow: hidden; border-radius: 4px; background: var(--dsw-alias-bg-tertiary, rgba(128,128,128,.2)); }
|
|
280
334
|
.dqw-bar-fill { display: block; height: 100%; border-radius: inherit; background: var(--dsw-alias-button-primary-fill, #5b8def); }
|
|
281
335
|
.dqw-bar-fill.warn { background: var(--dsw-alias-label-warning, #d29922); }
|
|
282
336
|
.dqw-bar-fill.danger { background: var(--dsw-alias-label-danger, #c93c3c); }
|
|
@@ -486,19 +540,41 @@ function mountQuotaCard({
|
|
|
486
540
|
floatHost.hidden = dataHidden;
|
|
487
541
|
};
|
|
488
542
|
doc.body.append(floatHost);
|
|
489
|
-
const
|
|
543
|
+
const measureSurface = () => {
|
|
544
|
+
const rect = surface.getBoundingClientRect();
|
|
545
|
+
return rect.width > 0 ? { width: rect.width, height: rect.height } : FALLBACK_SIZE;
|
|
546
|
+
};
|
|
547
|
+
const applyDock = () => {
|
|
490
548
|
const viewWidth = win.innerWidth ?? 1024;
|
|
491
549
|
const viewHeight = win.innerHeight ?? 768;
|
|
492
|
-
const
|
|
493
|
-
const
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
550
|
+
const viewport = { width: viewWidth, height: viewHeight };
|
|
551
|
+
const size = measureSurface();
|
|
552
|
+
const dock = loadFloatDock(win.localStorage) ?? {
|
|
553
|
+
edge: "right",
|
|
554
|
+
offsetY: viewHeight - MARGIN - size.height
|
|
555
|
+
};
|
|
556
|
+
const insets = readInsets();
|
|
557
|
+
const top = clampPoint({ x: 0, y: dock.offsetY }, viewport, size, insets).y;
|
|
558
|
+
floatHost.style.left = `${Math.round(dockX(dock.edge, size.width, viewport, insets))}px`;
|
|
559
|
+
floatHost.style.top = `${Math.round(top)}px`;
|
|
560
|
+
};
|
|
561
|
+
const safeAreaProbe = text(doc, "div", "", "");
|
|
562
|
+
safeAreaProbe.style.cssText = "position:fixed;visibility:hidden;pointer-events:none;width:0;height:0;overflow:hidden;padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);";
|
|
563
|
+
floatRoot.append(safeAreaProbe);
|
|
564
|
+
const readInsets = () => {
|
|
565
|
+
try {
|
|
566
|
+
const style2 = win.getComputedStyle(safeAreaProbe);
|
|
567
|
+
return {
|
|
568
|
+
top: parseInset(style2?.paddingTop),
|
|
569
|
+
right: parseInset(style2?.paddingRight),
|
|
570
|
+
bottom: parseInset(style2?.paddingBottom),
|
|
571
|
+
left: parseInset(style2?.paddingLeft)
|
|
572
|
+
};
|
|
573
|
+
} catch {
|
|
574
|
+
return ZERO_INSETS2;
|
|
575
|
+
}
|
|
500
576
|
};
|
|
501
|
-
|
|
577
|
+
applyDock();
|
|
502
578
|
floatRoot.append(panel);
|
|
503
579
|
const menu = doc.createElement("div");
|
|
504
580
|
menu.className = "dqw-menu";
|
|
@@ -507,52 +583,145 @@ function mountQuotaCard({
|
|
|
507
583
|
menu.hidden = true;
|
|
508
584
|
floatRoot.append(menu);
|
|
509
585
|
let suppressNextClick = false;
|
|
586
|
+
const scheduleFrame = (fn) => typeof win.requestAnimationFrame === "function" ? win.requestAnimationFrame.call(win, fn) : fn();
|
|
510
587
|
const attachDrag = (surfaceEl) => {
|
|
511
588
|
let dragging = false;
|
|
512
589
|
let moved = false;
|
|
513
590
|
let originX = 0;
|
|
514
591
|
let originY = 0;
|
|
592
|
+
let slop = DRAG_SLOP_MOUSE;
|
|
593
|
+
let grab = { dx: 0, dy: 0 };
|
|
594
|
+
let dragSize = FALLBACK_SIZE;
|
|
595
|
+
let restX = 0;
|
|
596
|
+
let restY = 0;
|
|
597
|
+
let lastPoint = null;
|
|
598
|
+
let longPressTimer;
|
|
599
|
+
const clearLongPress = () => {
|
|
600
|
+
if (longPressTimer !== void 0) {
|
|
601
|
+
win.clearTimeout(longPressTimer);
|
|
602
|
+
longPressTimer = void 0;
|
|
603
|
+
}
|
|
604
|
+
};
|
|
605
|
+
const bindGesture = () => {
|
|
606
|
+
doc.addEventListener("pointermove", onPointerMove);
|
|
607
|
+
doc.addEventListener("pointerup", onPointerUp);
|
|
608
|
+
doc.addEventListener("pointercancel", onPointerCancel);
|
|
609
|
+
};
|
|
610
|
+
const unbindGesture = () => {
|
|
611
|
+
doc.removeEventListener("pointermove", onPointerMove);
|
|
612
|
+
doc.removeEventListener("pointerup", onPointerUp);
|
|
613
|
+
doc.removeEventListener("pointercancel", onPointerCancel);
|
|
614
|
+
};
|
|
515
615
|
const onPointerDown = (event) => {
|
|
516
616
|
if (event.button !== 0) return;
|
|
517
617
|
dragging = true;
|
|
518
618
|
moved = false;
|
|
519
619
|
originX = event.clientX;
|
|
520
620
|
originY = event.clientY;
|
|
621
|
+
slop = dragSlop(event.pointerType);
|
|
622
|
+
clearLongPress();
|
|
623
|
+
if (slop === DRAG_SLOP_TOUCH) {
|
|
624
|
+
longPressTimer = win.setTimeout(() => {
|
|
625
|
+
longPressTimer = void 0;
|
|
626
|
+
if (!dragging || moved) return;
|
|
627
|
+
try {
|
|
628
|
+
win.navigator.vibrate?.(10);
|
|
629
|
+
} catch {
|
|
630
|
+
}
|
|
631
|
+
openMenu();
|
|
632
|
+
suppressNextClick = true;
|
|
633
|
+
}, LONG_PRESS_MS);
|
|
634
|
+
}
|
|
635
|
+
const rect = surfaceEl.getBoundingClientRect();
|
|
636
|
+
grab = grabOffset({ x: event.clientX, y: event.clientY }, rect);
|
|
637
|
+
dragSize = rect.width > 0 ? { width: rect.width, height: rect.height } : FALLBACK_SIZE;
|
|
638
|
+
restX = Number.parseFloat(floatHost.style.left) || 0;
|
|
639
|
+
restY = Number.parseFloat(floatHost.style.top) || 0;
|
|
521
640
|
surfaceEl.setPointerCapture?.(event.pointerId);
|
|
641
|
+
bindGesture();
|
|
522
642
|
};
|
|
523
643
|
const onPointerMove = (event) => {
|
|
524
644
|
if (!dragging) return;
|
|
525
|
-
if (!moved
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
645
|
+
if (!moved) {
|
|
646
|
+
if (Math.hypot(event.clientX - originX, event.clientY - originY) < slop) return;
|
|
647
|
+
moved = true;
|
|
648
|
+
clearLongPress();
|
|
649
|
+
doc.body.style.userSelect = "none";
|
|
650
|
+
surfaceEl.classList.add("dqw-capsule--dragging");
|
|
651
|
+
}
|
|
652
|
+
lastPoint = { x: event.clientX, y: event.clientY };
|
|
653
|
+
scheduleFrame(() => {
|
|
654
|
+
if (!dragging || !lastPoint || disposed) return;
|
|
655
|
+
const viewWidth = win.innerWidth ?? 1024;
|
|
656
|
+
const viewHeight = win.innerHeight ?? 768;
|
|
657
|
+
const topLeft = clampFrame(lastPoint, grab, dragSize, { width: viewWidth, height: viewHeight }, ZERO_INSETS2);
|
|
658
|
+
const dx = topLeft.x - restX;
|
|
659
|
+
const dy = topLeft.y - restY;
|
|
660
|
+
surfaceEl.style.transform = `translate3d(${Math.round(dx)}px, ${Math.round(dy)}px, 0) scale(1.03)`;
|
|
661
|
+
});
|
|
662
|
+
};
|
|
663
|
+
const finishDragRelease = (dock) => {
|
|
664
|
+
surfaceEl.classList.remove("dqw-capsule--dragging", "dqw-capsule--snapping");
|
|
665
|
+
surfaceEl.style.transform = "";
|
|
666
|
+
saveFloatDock(win.localStorage, dock);
|
|
667
|
+
applyDock();
|
|
668
|
+
suppressNextClick = true;
|
|
530
669
|
};
|
|
531
670
|
const onPointerUp = (event) => {
|
|
532
671
|
if (!dragging) return;
|
|
533
672
|
dragging = false;
|
|
673
|
+
clearLongPress();
|
|
674
|
+
unbindGesture();
|
|
534
675
|
doc.body.style.userSelect = "";
|
|
535
676
|
if (!moved) return;
|
|
536
677
|
const viewWidth = win.innerWidth ?? 1024;
|
|
537
678
|
const viewHeight = win.innerHeight ?? 768;
|
|
538
|
-
const
|
|
679
|
+
const insets = readInsets();
|
|
680
|
+
const viewport = { width: viewWidth, height: viewHeight };
|
|
681
|
+
const dock = releaseDock(
|
|
539
682
|
{ x: event.clientX, y: event.clientY },
|
|
540
|
-
|
|
541
|
-
|
|
683
|
+
grab,
|
|
684
|
+
dragSize,
|
|
685
|
+
viewport,
|
|
686
|
+
insets
|
|
542
687
|
);
|
|
543
|
-
floatHost.style.left = `${Math.round(point.x)}px`;
|
|
544
|
-
floatHost.style.top = `${Math.round(point.y)}px`;
|
|
545
|
-
saveFloatGeometry(win.localStorage, point);
|
|
546
688
|
suppressNextClick = true;
|
|
689
|
+
const motionAllowed = typeof win.matchMedia === "function" && win.matchMedia("(prefers-reduced-motion: no-preference)").matches;
|
|
690
|
+
if (motionAllowed) {
|
|
691
|
+
surfaceEl.classList.remove("dqw-capsule--dragging");
|
|
692
|
+
surfaceEl.classList.add("dqw-capsule--snapping");
|
|
693
|
+
const targetLeft = dockX(dock.edge, dragSize.width, viewport, insets);
|
|
694
|
+
surfaceEl.style.transform = `translate3d(${Math.round(targetLeft - restX)}px, ${Math.round(dock.offsetY - restY)}px, 0) scale(1)`;
|
|
695
|
+
let settled = false;
|
|
696
|
+
let snapTimer;
|
|
697
|
+
const settle = () => {
|
|
698
|
+
if (settled || disposed) return;
|
|
699
|
+
settled = true;
|
|
700
|
+
surfaceEl.removeEventListener("transitionend", settle);
|
|
701
|
+
win.clearTimeout(snapTimer);
|
|
702
|
+
finishDragRelease(dock);
|
|
703
|
+
};
|
|
704
|
+
surfaceEl.addEventListener("transitionend", settle);
|
|
705
|
+
snapTimer = win.setTimeout(settle, 200);
|
|
706
|
+
} else {
|
|
707
|
+
finishDragRelease(dock);
|
|
708
|
+
}
|
|
709
|
+
};
|
|
710
|
+
const onPointerCancel = () => {
|
|
711
|
+
if (!dragging) return;
|
|
712
|
+
dragging = false;
|
|
713
|
+
clearLongPress();
|
|
714
|
+
unbindGesture();
|
|
715
|
+
doc.body.style.userSelect = "";
|
|
716
|
+
surfaceEl.classList.remove("dqw-capsule--dragging", "dqw-capsule--snapping");
|
|
717
|
+
surfaceEl.style.transform = "";
|
|
547
718
|
};
|
|
548
719
|
surfaceEl.addEventListener("pointerdown", onPointerDown);
|
|
549
|
-
|
|
550
|
-
doc.addEventListener("pointerup", onPointerUp);
|
|
551
|
-
return () => {
|
|
720
|
+
const disposeDrag2 = () => {
|
|
552
721
|
surfaceEl.removeEventListener("pointerdown", onPointerDown);
|
|
553
|
-
|
|
554
|
-
doc.removeEventListener("pointerup", onPointerUp);
|
|
722
|
+
unbindGesture();
|
|
555
723
|
};
|
|
724
|
+
return disposeDrag2;
|
|
556
725
|
};
|
|
557
726
|
let snapshot;
|
|
558
727
|
let requestSequence = 0;
|
|
@@ -763,6 +932,13 @@ function mountQuotaCard({
|
|
|
763
932
|
if (panel.hidden) return;
|
|
764
933
|
const anchor = lastAnchor?.();
|
|
765
934
|
if (!anchor || !anchor.isConnected) return;
|
|
935
|
+
if ((win.innerWidth ?? 1024) <= 480) {
|
|
936
|
+
panel.classList.add("dqw-panel--sheet");
|
|
937
|
+
panel.style.left = "";
|
|
938
|
+
panel.style.top = "";
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
panel.classList.remove("dqw-panel--sheet");
|
|
766
942
|
const rect = anchor.getBoundingClientRect();
|
|
767
943
|
const viewWidth = win.innerWidth ?? 1024;
|
|
768
944
|
const viewHeight = win.innerHeight ?? 768;
|
|
@@ -840,6 +1016,7 @@ function mountQuotaCard({
|
|
|
840
1016
|
const label = summary === "" ? copy.title : locale === "zh" ? `${copy.title}\uFF1A${summary}` : `${copy.title}: ${summary}`;
|
|
841
1017
|
surface.setAttribute("aria-label", label);
|
|
842
1018
|
surface.title = label;
|
|
1019
|
+
applyDock();
|
|
843
1020
|
};
|
|
844
1021
|
const onSurfaceClick = () => {
|
|
845
1022
|
if (suppressNextClick) {
|
|
@@ -880,6 +1057,7 @@ function mountQuotaCard({
|
|
|
880
1057
|
};
|
|
881
1058
|
const onSurfaceContext = (event) => {
|
|
882
1059
|
event.preventDefault();
|
|
1060
|
+
if (!menu.hidden) return;
|
|
883
1061
|
openMenu();
|
|
884
1062
|
};
|
|
885
1063
|
const onMenuClick = (event) => {
|
|
@@ -1046,21 +1224,19 @@ function mountQuotaCard({
|
|
|
1046
1224
|
openKey = void 0;
|
|
1047
1225
|
renderAll();
|
|
1048
1226
|
};
|
|
1227
|
+
let scrollFrameQueued = false;
|
|
1049
1228
|
const onDocScroll = () => {
|
|
1050
|
-
|
|
1229
|
+
if (scrollFrameQueued || disposed) return;
|
|
1230
|
+
scrollFrameQueued = true;
|
|
1231
|
+
scheduleFrame(() => {
|
|
1232
|
+
scrollFrameQueued = false;
|
|
1233
|
+
if (!disposed) placePanel();
|
|
1234
|
+
});
|
|
1051
1235
|
};
|
|
1052
1236
|
const onWinResize = () => {
|
|
1053
1237
|
placePanel();
|
|
1054
1238
|
syncCardVisibility();
|
|
1055
|
-
|
|
1056
|
-
const viewHeight = win.innerHeight ?? 768;
|
|
1057
|
-
const x = parseFloat(floatHost.style.left);
|
|
1058
|
-
const y = parseFloat(floatHost.style.top);
|
|
1059
|
-
if (Number.isFinite(x) && Number.isFinite(y)) {
|
|
1060
|
-
const point = clampPoint({ x, y }, { width: viewWidth, height: viewHeight }, SURFACE_SIZE);
|
|
1061
|
-
floatHost.style.left = `${Math.round(point.x)}px`;
|
|
1062
|
-
floatHost.style.top = `${Math.round(point.y)}px`;
|
|
1063
|
-
}
|
|
1239
|
+
applyDock();
|
|
1064
1240
|
};
|
|
1065
1241
|
const onDocKeydown = (event) => {
|
|
1066
1242
|
if (event.key !== "Escape") return;
|
|
@@ -1112,6 +1288,7 @@ function mountQuotaCard({
|
|
|
1112
1288
|
surface.removeEventListener("contextmenu", onSurfaceContext);
|
|
1113
1289
|
menu.removeEventListener("click", onMenuClick);
|
|
1114
1290
|
disposeDrag();
|
|
1291
|
+
doc.body.style.userSelect = "";
|
|
1115
1292
|
container.remove();
|
|
1116
1293
|
panel.remove();
|
|
1117
1294
|
floatHost.remove();
|
package/package.json
CHANGED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// DOM-free drag state machine for @iasiv5/dsh-quota-watch (ADR 0002).
|
|
2
|
+
// Point/rect values come from the caller; nothing here touches the DOM, so the
|
|
3
|
+
// math stays unit-testable under jsdom.
|
|
4
|
+
|
|
5
|
+
import { MARGIN, clampPoint } from './prefs.mjs'
|
|
6
|
+
|
|
7
|
+
export const DRAG_SLOP_MOUSE = 6
|
|
8
|
+
export const DRAG_SLOP_TOUCH = 10
|
|
9
|
+
export const LONG_PRESS_MS = 500
|
|
10
|
+
|
|
11
|
+
/** Touch/pen gestures get the larger slop so taps win over drags. */
|
|
12
|
+
export function dragSlop(pointerType) {
|
|
13
|
+
return pointerType === 'touch' || pointerType === 'pen' ? DRAG_SLOP_TOUCH : DRAG_SLOP_MOUSE
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Offset between the pointer and the pill's top-left at grab time. */
|
|
17
|
+
export function grabOffset(point, rect) {
|
|
18
|
+
return { dx: point.x - rect.left, dy: point.y - rect.top }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function pillTopLeft(point, grab) {
|
|
22
|
+
return { x: point.x - grab.dx, y: point.y - grab.dy }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Live clamp for the pill top-left while the gesture is active. */
|
|
26
|
+
export function clampFrame(point, grab, size, viewport, insets) {
|
|
27
|
+
return clampPoint(pillTopLeft(point, grab), viewport, size, insets)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Rest dock for a release: nearest horizontal edge by pill center, vertical offset clamped. */
|
|
31
|
+
export function releaseDock(point, grab, size, viewport, insets) {
|
|
32
|
+
const topLeft = pillTopLeft(point, grab)
|
|
33
|
+
const centerX = topLeft.x + size.width / 2
|
|
34
|
+
const edge = centerX < viewport.width / 2 ? 'left' : 'right'
|
|
35
|
+
return { edge, offsetY: clampPoint(topLeft, viewport, size, insets).y }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Resting x for a docked pill: always derived, never stored. */
|
|
39
|
+
export function dockX(edge, width, viewport, insets) {
|
|
40
|
+
const insetLeft = Number.isFinite(insets?.left) ? insets.left : 0
|
|
41
|
+
const insetRight = Number.isFinite(insets?.right) ? insets.right : 0
|
|
42
|
+
return edge === 'left' ? MARGIN + insetLeft : viewport.width - insetRight - MARGIN - width
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Computed-style padding value ("20px" | "" | "auto") → numeric inset or 0. */
|
|
46
|
+
export function parseInset(computedValue) {
|
|
47
|
+
const value = Number.parseFloat(computedValue)
|
|
48
|
+
return Number.isFinite(value) ? value : 0
|
|
49
|
+
}
|
package/src/client/prefs.mjs
CHANGED
|
@@ -1,46 +1,70 @@
|
|
|
1
1
|
// Float-shell preferences for @iasiv5/dsh-quota-watch.
|
|
2
2
|
// Pure logic: no DOM. `storage` is a localStorage-like object (getItem/setItem).
|
|
3
3
|
|
|
4
|
-
export const
|
|
4
|
+
export const FLOAT_DOCK_KEY = 'dsh-quota-watch:float-dock'
|
|
5
5
|
export const SURFACE_FLAGS_KEY = 'dsh-quota-watch:surface-flags'
|
|
6
6
|
|
|
7
|
-
const MARGIN = 8
|
|
7
|
+
export const MARGIN = 8
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const
|
|
9
|
+
const ZERO_INSETS = { left: 0, right: 0, top: 0, bottom: 0 }
|
|
10
|
+
|
|
11
|
+
function normalizeSize(size) {
|
|
12
|
+
if (typeof size === 'number') return { width: size, height: size }
|
|
13
|
+
const width = typeof size?.width === 'number' ? size.width : 0
|
|
14
|
+
const height = typeof size?.height === 'number' ? size.height : 0
|
|
15
|
+
return { width, height }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function clampPoint(point, viewport, size = 0, insets = ZERO_INSETS) {
|
|
19
|
+
const { width, height } = normalizeSize(size)
|
|
20
|
+
const insetLeft = Number.isFinite(insets?.left) ? insets.left : 0
|
|
21
|
+
const insetRight = Number.isFinite(insets?.right) ? insets.right : 0
|
|
22
|
+
const insetTop = Number.isFinite(insets?.top) ? insets.top : 0
|
|
23
|
+
const insetBottom = Number.isFinite(insets?.bottom) ? insets.bottom : 0
|
|
24
|
+
const minX = MARGIN + insetLeft
|
|
25
|
+
const minY = MARGIN + insetTop
|
|
26
|
+
const maxX = Math.max(minX, viewport.width - insetRight - MARGIN - width)
|
|
27
|
+
const maxY = Math.max(minY, viewport.height - insetBottom - MARGIN - height)
|
|
14
28
|
return {
|
|
15
29
|
x: Math.min(Math.max(point.x, minX), maxX),
|
|
16
30
|
y: Math.min(Math.max(point.y, minY), maxY),
|
|
17
31
|
}
|
|
18
32
|
}
|
|
19
33
|
|
|
20
|
-
|
|
34
|
+
const DOCK_EDGES = new Set(['left', 'right'])
|
|
35
|
+
|
|
36
|
+
export function loadFloatDock(storage) {
|
|
21
37
|
try {
|
|
22
|
-
const raw = storage.getItem(
|
|
38
|
+
const raw = storage.getItem(FLOAT_DOCK_KEY)
|
|
23
39
|
if (typeof raw !== 'string' || raw === '') return null
|
|
24
40
|
const parsed = JSON.parse(raw)
|
|
25
41
|
if (
|
|
26
42
|
typeof parsed !== 'object' ||
|
|
27
43
|
parsed === null ||
|
|
28
|
-
!
|
|
29
|
-
!Number.isFinite(parsed.
|
|
44
|
+
!DOCK_EDGES.has(parsed.edge) ||
|
|
45
|
+
!Number.isFinite(parsed.offsetY)
|
|
30
46
|
) {
|
|
31
47
|
return null
|
|
32
48
|
}
|
|
33
|
-
return {
|
|
49
|
+
return { edge: parsed.edge, offsetY: parsed.offsetY }
|
|
34
50
|
} catch {
|
|
35
51
|
return null
|
|
36
52
|
}
|
|
37
53
|
}
|
|
38
54
|
|
|
39
|
-
export function
|
|
55
|
+
export function saveFloatDock(storage, dock) {
|
|
56
|
+
if (
|
|
57
|
+
typeof dock !== 'object' ||
|
|
58
|
+
dock === null ||
|
|
59
|
+
!DOCK_EDGES.has(dock.edge) ||
|
|
60
|
+
!Number.isFinite(dock.offsetY)
|
|
61
|
+
) {
|
|
62
|
+
throw new TypeError(`invalid float dock: ${JSON.stringify(dock)}`)
|
|
63
|
+
}
|
|
40
64
|
try {
|
|
41
|
-
storage.setItem(
|
|
65
|
+
storage.setItem(FLOAT_DOCK_KEY, JSON.stringify({ edge: dock.edge, offsetY: dock.offsetY }))
|
|
42
66
|
} catch {
|
|
43
|
-
/* storage unavailable;
|
|
67
|
+
/* storage unavailable; dock stays session-only */
|
|
44
68
|
}
|
|
45
69
|
}
|
|
46
70
|
|
package/src/client.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { CLIENT_POLL_INTERVAL_MS, CLIENT_ROUTES } from './shared.mjs'
|
|
2
2
|
import { remainingToUsed } from './core/adapters.mjs'
|
|
3
|
-
import { clampPoint,
|
|
3
|
+
import { MARGIN, clampPoint, loadFloatDock, loadSurfaceFlags, saveFloatDock, saveSurfaceFlags } from './client/prefs.mjs'
|
|
4
|
+
import { DRAG_SLOP_MOUSE, DRAG_SLOP_TOUCH, LONG_PRESS_MS, parseInset, clampFrame, dockX, dragSlop, grabOffset, releaseDock } from './client/drag.mjs'
|
|
4
5
|
|
|
5
6
|
export const name = 'quota-watch-client'
|
|
6
7
|
export const inject = []
|
|
@@ -8,8 +9,10 @@ export const inject = []
|
|
|
8
9
|
const CARD_SELECTOR = '[data-dsh-quota-watch-card]'
|
|
9
10
|
const FLOAT_SELECTOR = '[data-dsh-quota-watch-float]'
|
|
10
11
|
const FETCH_TIMEOUT_MS = 15_000
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
// Measured-size fallback for environments where getBoundingClientRect reports
|
|
13
|
+
// zeros (jsdom) or the capsule has not painted yet.
|
|
14
|
+
const FALLBACK_SIZE = { width: 38, height: 26 }
|
|
15
|
+
const ZERO_INSETS = { left: 0, right: 0, top: 0, bottom: 0 }
|
|
13
16
|
// Below this a mounted card would clip into unreadable text (collapsed web
|
|
14
17
|
// rails, squeezed desktop layouts) — hide it and let the capsule carry the
|
|
15
18
|
// entry. clientWidth 0 (no layout yet, jsdom) counts as "unknown", not narrow.
|
|
@@ -117,16 +120,21 @@ const STYLE_TEXT = `
|
|
|
117
120
|
// card shadow keeps its own copies for the sidebar rows.
|
|
118
121
|
const FLOAT_STYLE_TEXT = `
|
|
119
122
|
:host { position: fixed; z-index: 2147483000; }
|
|
120
|
-
.dqw-capsule { display: inline-flex; align-items: center; gap: 6px; height: 26px; padding: 0 10px; border: 1px solid var(--dsw-alias-border-secondary, rgba(128,128,128,.35)); border-radius: 999px; background: var(--dsw-alias-bg-base, rgba(128,128,128,.08)); background: color-mix(in srgb, var(--dsw-alias-bg-base, #1f1f1f) 86%, transparent); -webkit-backdrop-filter: blur(10px) saturate(1.2); backdrop-filter: blur(10px) saturate(1.2); box-shadow: 0 4px 14px rgba(0,0,0,.22); color: var(--dsw-alias-label-primary, inherit); font: inherit; font-size: 11px; line-height: 24px; cursor:
|
|
121
|
-
.dqw-capsule
|
|
123
|
+
.dqw-capsule { display: inline-flex; align-items: center; gap: 6px; height: 26px; padding: 0 10px; border: 1px solid var(--dsw-alias-border-secondary, rgba(128,128,128,.35)); border-radius: 999px; background: var(--dsw-alias-bg-base, rgba(128,128,128,.08)); background: color-mix(in srgb, var(--dsw-alias-bg-base, #1f1f1f) 86%, transparent); -webkit-backdrop-filter: blur(10px) saturate(1.2); backdrop-filter: blur(10px) saturate(1.2); box-shadow: 0 4px 14px rgba(0,0,0,.22); color: var(--dsw-alias-label-primary, inherit); font: inherit; font-size: 11px; line-height: 24px; cursor: grab; user-select: none; white-space: nowrap; position: relative; touch-action: none; -webkit-tap-highlight-color: transparent; -webkit-touch-callout: none; }
|
|
124
|
+
.dqw-capsule::before { content: ''; position: absolute; inset: -9px; }
|
|
125
|
+
.dqw-capsule:hover { border-color: var(--dsw-alias-border-primary, var(--dsw-alias-border-secondary, rgba(128,128,128,.35))); box-shadow: 0 6px 18px rgba(0,0,0,.28); }
|
|
126
|
+
.dqw-capsule:active { transform: scale(.98); }
|
|
122
127
|
.dqw-capsule:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary, #5b8def); outline-offset: 2px; }
|
|
123
128
|
.dqw-capsule-sep { opacity: .5; }
|
|
124
129
|
.dqw-capsule-glm, .dqw-capsule-copilot { font-variant-numeric: tabular-nums; font-weight: 600; }
|
|
125
130
|
.dqw-capsule-glm.warn, .dqw-capsule-copilot.warn { color: var(--dsw-alias-label-warning, #d29922); }
|
|
126
131
|
.dqw-capsule-glm.danger, .dqw-capsule-copilot.danger { color: var(--dsw-alias-label-danger, #c93c3c); }
|
|
127
132
|
.dqw-capsule[hidden] { display: none; }
|
|
133
|
+
.dqw-capsule--dragging { cursor: grabbing; -webkit-backdrop-filter: none; backdrop-filter: none; background: var(--dsw-alias-bg-elevated, var(--dsw-alias-bg-base, #1f1f1f)); will-change: transform; transition: none; }
|
|
128
134
|
@media (prefers-reduced-motion: no-preference) {
|
|
129
135
|
.dqw-capsule[data-alert] { animation: dqw-pulse 1.6s ease-in-out infinite; }
|
|
136
|
+
.dqw-capsule { transition: border-color .12s ease-out, box-shadow .12s ease-out; }
|
|
137
|
+
.dqw-capsule--snapping { transition: transform 150ms cubic-bezier(.2,.8,.2,1); }
|
|
130
138
|
}
|
|
131
139
|
@media print { :host { display: none !important; } }
|
|
132
140
|
@keyframes dqw-pulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.06); } }
|
|
@@ -136,6 +144,7 @@ const FLOAT_STYLE_TEXT = `
|
|
|
136
144
|
.dqw-menu-item:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.12)); }
|
|
137
145
|
.dqw-panel { position: fixed; z-index: 2147483000; min-width: 166px; max-width: min(320px, calc(100vw - 24px)); max-height: calc(100vh - 24px); overflow-y: auto; padding: 10px 12px; border: 1px solid var(--dsw-alias-border-secondary, rgba(128,128,128,.35)); border-radius: 10px; background: var(--dsw-alias-bg-elevated, var(--dsw-alias-bg-base, #1f1f1f)); background: color-mix(in srgb, var(--dsw-alias-bg-base, #1f1f1f) 86%, transparent); -webkit-backdrop-filter: blur(14px) saturate(1.3); backdrop-filter: blur(14px) saturate(1.3); box-shadow: 0 8px 24px rgba(0,0,0,.25); color: var(--dsw-alias-label-primary, inherit); display: flex; flex-direction: column; gap: 8px; font: inherit; }
|
|
138
146
|
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) { .dqw-panel { background: var(--dsw-alias-bg-elevated, var(--dsw-alias-bg-base, #1f1f1f)); } }
|
|
147
|
+
.dqw-panel--sheet { left:8px; right:8px; top:auto; bottom:calc(8px + env(safe-area-inset-bottom, 0px)); width:auto; max-width:none; max-height:calc(100dvh - 24px); }
|
|
139
148
|
.dqw-panel[hidden] { display: none; }
|
|
140
149
|
.dqw-panel *, .dqw-panel *::before, .dqw-panel *::after { box-sizing: border-box; }
|
|
141
150
|
.dqw-panel-header { display: flex; align-items: center; gap: 6px; min-width: 0; }
|
|
@@ -155,7 +164,7 @@ const FLOAT_STYLE_TEXT = `
|
|
|
155
164
|
.dqw-label { font-size: 11px; font-weight: 600; width: 52px; flex: none; }
|
|
156
165
|
.dqw-label[data-action="refresh"] { cursor: pointer; border-radius: 4px; }
|
|
157
166
|
.dqw-label[data-action="refresh"]:hover { opacity: .75; }
|
|
158
|
-
.dqw-bar { display: block; flex: 0 0 auto; width:
|
|
167
|
+
.dqw-bar { display: block; flex: 0 0 auto; width: 100px; height: 4px; overflow: hidden; border-radius: 4px; background: var(--dsw-alias-bg-tertiary, rgba(128,128,128,.2)); }
|
|
159
168
|
.dqw-bar-fill { display: block; height: 100%; border-radius: inherit; background: var(--dsw-alias-button-primary-fill, #5b8def); }
|
|
160
169
|
.dqw-bar-fill.warn { background: var(--dsw-alias-label-warning, #d29922); }
|
|
161
170
|
.dqw-bar-fill.danger { background: var(--dsw-alias-label-danger, #c93c3c); }
|
|
@@ -428,22 +437,50 @@ export function mountQuotaCard({
|
|
|
428
437
|
// fallbacks (broke the light theme). <body> is also transform-free, so
|
|
429
438
|
// position:fixed stays viewport-anchored.
|
|
430
439
|
doc.body.append(floatHost)
|
|
431
|
-
|
|
440
|
+
/** Measured capsule size; zeros (jsdom / unpainted) fall back to the box. */
|
|
441
|
+
const measureSurface = () => {
|
|
442
|
+
const rect = surface.getBoundingClientRect()
|
|
443
|
+
return rect.width > 0 ? { width: rect.width, height: rect.height } : FALLBACK_SIZE
|
|
444
|
+
}
|
|
445
|
+
/** Rest position = docked edge + persisted offsetY; x is always derived (ADR 0002). */
|
|
446
|
+
const applyDock = () => {
|
|
432
447
|
const viewWidth = win.innerWidth ?? 1024
|
|
433
448
|
const viewHeight = win.innerHeight ?? 768
|
|
434
|
-
const
|
|
435
|
-
const
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
floatHost.style.left = `${Math.round(point.x)}px`
|
|
441
|
-
floatHost.style.top = `${Math.round(point.y)}px`
|
|
449
|
+
const viewport = { width: viewWidth, height: viewHeight }
|
|
450
|
+
const size = measureSurface()
|
|
451
|
+
const dock = loadFloatDock(win.localStorage) ?? {
|
|
452
|
+
edge: 'right',
|
|
453
|
+
offsetY: viewHeight - MARGIN - size.height,
|
|
454
|
+
}
|
|
442
455
|
// Deliberately NOT persisted here: a window that starts small (Electron
|
|
443
|
-
// restore, split-screen) must not clobber the user's
|
|
444
|
-
//
|
|
456
|
+
// restore, split-screen) must not clobber the user's stored dock — storage
|
|
457
|
+
// updates on drag release only; this is the visual clamp.
|
|
458
|
+
const insets = readInsets()
|
|
459
|
+
const top = clampPoint({ x: 0, y: dock.offsetY }, viewport, size, insets).y
|
|
460
|
+
floatHost.style.left = `${Math.round(dockX(dock.edge, size.width, viewport, insets))}px`
|
|
461
|
+
floatHost.style.top = `${Math.round(top)}px`
|
|
462
|
+
}
|
|
463
|
+
// Safe-area probe: env() insets ride computed padding; jsdom reports '' which
|
|
464
|
+
// parseInset normalizes to 0, so tests without a stub see zero insets.
|
|
465
|
+
const safeAreaProbe = text(doc, 'div', '', '')
|
|
466
|
+
safeAreaProbe.style.cssText = 'position:fixed;visibility:hidden;pointer-events:none;width:0;height:0;overflow:hidden;padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);'
|
|
467
|
+
floatRoot.append(safeAreaProbe)
|
|
468
|
+
/** Live env(safe-area-inset-*) reading — re-probed on every use, so window
|
|
469
|
+
* chrome changes (rotation, URL-bar collapse) need no explicit invalidation. */
|
|
470
|
+
const readInsets = () => {
|
|
471
|
+
try {
|
|
472
|
+
const style = win.getComputedStyle(safeAreaProbe)
|
|
473
|
+
return {
|
|
474
|
+
top: parseInset(style?.paddingTop),
|
|
475
|
+
right: parseInset(style?.paddingRight),
|
|
476
|
+
bottom: parseInset(style?.paddingBottom),
|
|
477
|
+
left: parseInset(style?.paddingLeft),
|
|
478
|
+
}
|
|
479
|
+
} catch {
|
|
480
|
+
return ZERO_INSETS
|
|
481
|
+
}
|
|
445
482
|
}
|
|
446
|
-
|
|
483
|
+
applyDock()
|
|
447
484
|
// Panel lives inside the float shadow root from now on (契约总表「归属时序」).
|
|
448
485
|
floatRoot.append(panel)
|
|
449
486
|
const menu = doc.createElement('div')
|
|
@@ -454,55 +491,168 @@ export function mountQuotaCard({
|
|
|
454
491
|
floatRoot.append(menu)
|
|
455
492
|
|
|
456
493
|
let suppressNextClick = false
|
|
457
|
-
/**
|
|
458
|
-
*
|
|
459
|
-
*
|
|
494
|
+
/** One rAF (or a synchronous fallback when the host has none) — drag frames
|
|
495
|
+
* and scroll re-placement share this gate so a burst of events costs one
|
|
496
|
+
* layout read/write per frame. */
|
|
497
|
+
const scheduleFrame = (fn) => (
|
|
498
|
+
typeof win.requestAnimationFrame === 'function' ? win.requestAnimationFrame.call(win, fn) : fn()
|
|
499
|
+
)
|
|
500
|
+
/** Grab-offset + transform drag (ADR 0002): the pill keeps its grab point
|
|
501
|
+
* under the cursor, moves via transform during the gesture (rAF-batched,
|
|
502
|
+
* live-clamped to the measured size), and the dock is persisted on release —
|
|
503
|
+
* optionally through a short snap transition when motion is allowed.
|
|
504
|
+
* Move/up listen on `document` — pointer events always bubble there, in
|
|
505
|
+
* browsers and jsdom alike. Returns a dispose function. */
|
|
460
506
|
const attachDrag = (surfaceEl) => {
|
|
461
507
|
let dragging = false
|
|
462
508
|
let moved = false
|
|
463
509
|
let originX = 0
|
|
464
510
|
let originY = 0
|
|
511
|
+
let slop = DRAG_SLOP_MOUSE
|
|
512
|
+
let grab = { dx: 0, dy: 0 }
|
|
513
|
+
let dragSize = FALLBACK_SIZE
|
|
514
|
+
let restX = 0
|
|
515
|
+
let restY = 0
|
|
516
|
+
let lastPoint = null
|
|
517
|
+
let longPressTimer
|
|
518
|
+
const clearLongPress = () => {
|
|
519
|
+
if (longPressTimer !== undefined) {
|
|
520
|
+
win.clearTimeout(longPressTimer)
|
|
521
|
+
longPressTimer = undefined
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
/** Doc-level move/up/cancel are bound only while a gesture is active —
|
|
525
|
+
* the pill's resting state costs zero document-level listeners. */
|
|
526
|
+
const bindGesture = () => {
|
|
527
|
+
doc.addEventListener('pointermove', onPointerMove)
|
|
528
|
+
doc.addEventListener('pointerup', onPointerUp)
|
|
529
|
+
doc.addEventListener('pointercancel', onPointerCancel)
|
|
530
|
+
}
|
|
531
|
+
const unbindGesture = () => {
|
|
532
|
+
doc.removeEventListener('pointermove', onPointerMove)
|
|
533
|
+
doc.removeEventListener('pointerup', onPointerUp)
|
|
534
|
+
doc.removeEventListener('pointercancel', onPointerCancel)
|
|
535
|
+
}
|
|
465
536
|
const onPointerDown = (event) => {
|
|
466
537
|
if (event.button !== 0) return
|
|
467
538
|
dragging = true
|
|
468
539
|
moved = false
|
|
469
540
|
originX = event.clientX
|
|
470
541
|
originY = event.clientY
|
|
542
|
+
// Graded slop: touch/pen get 10px so taps win over drags (Task 6).
|
|
543
|
+
slop = dragSlop(event.pointerType)
|
|
544
|
+
// Long press opens the context menu on touch (iOS has no contextmenu);
|
|
545
|
+
// movement past the slop cancels it in favor of the drag.
|
|
546
|
+
clearLongPress()
|
|
547
|
+
if (slop === DRAG_SLOP_TOUCH) {
|
|
548
|
+
longPressTimer = win.setTimeout(() => {
|
|
549
|
+
longPressTimer = undefined
|
|
550
|
+
if (!dragging || moved) return
|
|
551
|
+
try { win.navigator.vibrate?.(10) } catch { /* vibration unavailable */ }
|
|
552
|
+
openMenu()
|
|
553
|
+
suppressNextClick = true
|
|
554
|
+
}, LONG_PRESS_MS)
|
|
555
|
+
}
|
|
556
|
+
const rect = surfaceEl.getBoundingClientRect()
|
|
557
|
+
grab = grabOffset({ x: event.clientX, y: event.clientY }, rect)
|
|
558
|
+
dragSize = rect.width > 0 ? { width: rect.width, height: rect.height } : FALLBACK_SIZE
|
|
559
|
+
restX = Number.parseFloat(floatHost.style.left) || 0
|
|
560
|
+
restY = Number.parseFloat(floatHost.style.top) || 0
|
|
471
561
|
surfaceEl.setPointerCapture?.(event.pointerId)
|
|
562
|
+
bindGesture()
|
|
472
563
|
}
|
|
473
564
|
const onPointerMove = (event) => {
|
|
474
565
|
if (!dragging) return
|
|
475
|
-
if (!moved
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
566
|
+
if (!moved) {
|
|
567
|
+
if (Math.hypot(event.clientX - originX, event.clientY - originY) < slop) return
|
|
568
|
+
moved = true
|
|
569
|
+
clearLongPress()
|
|
570
|
+
doc.body.style.userSelect = 'none'
|
|
571
|
+
surfaceEl.classList.add('dqw-capsule--dragging')
|
|
572
|
+
}
|
|
573
|
+
lastPoint = { x: event.clientX, y: event.clientY }
|
|
574
|
+
scheduleFrame(() => {
|
|
575
|
+
if (!dragging || !lastPoint || disposed) return
|
|
576
|
+
const viewWidth = win.innerWidth ?? 1024
|
|
577
|
+
const viewHeight = win.innerHeight ?? 768
|
|
578
|
+
const topLeft = clampFrame(lastPoint, grab, dragSize, { width: viewWidth, height: viewHeight }, ZERO_INSETS)
|
|
579
|
+
const dx = topLeft.x - restX
|
|
580
|
+
const dy = topLeft.y - restY
|
|
581
|
+
surfaceEl.style.transform = `translate3d(${Math.round(dx)}px, ${Math.round(dy)}px, 0) scale(1.03)`
|
|
582
|
+
})
|
|
583
|
+
}
|
|
584
|
+
const finishDragRelease = (dock) => {
|
|
585
|
+
surfaceEl.classList.remove('dqw-capsule--dragging', 'dqw-capsule--snapping')
|
|
586
|
+
surfaceEl.style.transform = ''
|
|
587
|
+
saveFloatDock(win.localStorage, dock)
|
|
588
|
+
applyDock()
|
|
589
|
+
suppressNextClick = true
|
|
480
590
|
}
|
|
481
591
|
const onPointerUp = (event) => {
|
|
482
592
|
if (!dragging) return
|
|
483
593
|
dragging = false
|
|
594
|
+
clearLongPress()
|
|
595
|
+
unbindGesture()
|
|
484
596
|
doc.body.style.userSelect = ''
|
|
485
597
|
if (!moved) return
|
|
486
598
|
const viewWidth = win.innerWidth ?? 1024
|
|
487
599
|
const viewHeight = win.innerHeight ?? 768
|
|
488
|
-
const
|
|
600
|
+
const insets = readInsets()
|
|
601
|
+
const viewport = { width: viewWidth, height: viewHeight }
|
|
602
|
+
const dock = releaseDock(
|
|
489
603
|
{ x: event.clientX, y: event.clientY },
|
|
490
|
-
|
|
491
|
-
|
|
604
|
+
grab,
|
|
605
|
+
dragSize,
|
|
606
|
+
viewport,
|
|
607
|
+
insets,
|
|
492
608
|
)
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
saveFloatGeometry(win.localStorage, point)
|
|
609
|
+
// The real click lands within ms of pointerup — suppression is armed
|
|
610
|
+
// synchronously here, never deferred to the animation's settle (E1-2).
|
|
496
611
|
suppressNextClick = true
|
|
612
|
+
// jsdom has no matchMedia: the typeof guard keeps the release working
|
|
613
|
+
// (direct commit) where the motion query cannot be asked at all.
|
|
614
|
+
const motionAllowed = typeof win.matchMedia === 'function'
|
|
615
|
+
&& win.matchMedia('(prefers-reduced-motion: no-preference)').matches
|
|
616
|
+
if (motionAllowed) {
|
|
617
|
+
surfaceEl.classList.remove('dqw-capsule--dragging')
|
|
618
|
+
surfaceEl.classList.add('dqw-capsule--snapping')
|
|
619
|
+
// Animate toward the NEW dock (delta from rest) — the host still sits
|
|
620
|
+
// at the old dock until settle commits it, so (0,0) would slide the
|
|
621
|
+
// pill backwards first and then teleport (E1-1).
|
|
622
|
+
const targetLeft = dockX(dock.edge, dragSize.width, viewport, insets)
|
|
623
|
+
surfaceEl.style.transform = `translate3d(${Math.round(targetLeft - restX)}px, ${Math.round(dock.offsetY - restY)}px, 0) scale(1)`
|
|
624
|
+
let settled = false
|
|
625
|
+
let snapTimer
|
|
626
|
+
const settle = () => {
|
|
627
|
+
if (settled || disposed) return
|
|
628
|
+
settled = true
|
|
629
|
+
surfaceEl.removeEventListener('transitionend', settle)
|
|
630
|
+
win.clearTimeout(snapTimer)
|
|
631
|
+
finishDragRelease(dock)
|
|
632
|
+
}
|
|
633
|
+
surfaceEl.addEventListener('transitionend', settle)
|
|
634
|
+
snapTimer = win.setTimeout(settle, 200)
|
|
635
|
+
} else {
|
|
636
|
+
finishDragRelease(dock)
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
/** A canceled pointer (scroll takeover, incoming call, palm) resets the
|
|
640
|
+
* gesture: no dock is persisted and the trailing click stays allowed. */
|
|
641
|
+
const onPointerCancel = () => {
|
|
642
|
+
if (!dragging) return
|
|
643
|
+
dragging = false
|
|
644
|
+
clearLongPress()
|
|
645
|
+
unbindGesture()
|
|
646
|
+
doc.body.style.userSelect = ''
|
|
647
|
+
surfaceEl.classList.remove('dqw-capsule--dragging', 'dqw-capsule--snapping')
|
|
648
|
+
surfaceEl.style.transform = ''
|
|
497
649
|
}
|
|
498
650
|
surfaceEl.addEventListener('pointerdown', onPointerDown)
|
|
499
|
-
|
|
500
|
-
doc.addEventListener('pointerup', onPointerUp)
|
|
501
|
-
return () => {
|
|
651
|
+
const disposeDrag = () => {
|
|
502
652
|
surfaceEl.removeEventListener('pointerdown', onPointerDown)
|
|
503
|
-
|
|
504
|
-
doc.removeEventListener('pointerup', onPointerUp)
|
|
653
|
+
unbindGesture()
|
|
505
654
|
}
|
|
655
|
+
return disposeDrag
|
|
506
656
|
}
|
|
507
657
|
let snapshot
|
|
508
658
|
let requestSequence = 0
|
|
@@ -735,6 +885,16 @@ export function mountQuotaCard({
|
|
|
735
885
|
if (panel.hidden) return
|
|
736
886
|
const anchor = lastAnchor?.()
|
|
737
887
|
if (!anchor || !anchor.isConnected) return
|
|
888
|
+
if ((win.innerWidth ?? 1024) <= 480) {
|
|
889
|
+
// Narrow viewport (phones): dock the panel to the bottom instead of
|
|
890
|
+
// squeezing it beside the capsule — the sheet is CSS-positioned, so the
|
|
891
|
+
// inline anchor coordinates are dropped.
|
|
892
|
+
panel.classList.add('dqw-panel--sheet')
|
|
893
|
+
panel.style.left = ''
|
|
894
|
+
panel.style.top = ''
|
|
895
|
+
return
|
|
896
|
+
}
|
|
897
|
+
panel.classList.remove('dqw-panel--sheet')
|
|
738
898
|
const rect = anchor.getBoundingClientRect()
|
|
739
899
|
const viewWidth = win.innerWidth ?? 1024
|
|
740
900
|
const viewHeight = win.innerHeight ?? 768
|
|
@@ -817,6 +977,9 @@ export function mountQuotaCard({
|
|
|
817
977
|
const label = summary === '' ? copy.title : locale === 'zh' ? `${copy.title}:${summary}` : `${copy.title}: ${summary}`
|
|
818
978
|
surface.setAttribute('aria-label', label)
|
|
819
979
|
surface.title = label
|
|
980
|
+
// Re-derive the docked x every render: the pill's width follows its text,
|
|
981
|
+
// and the stored dock only knows the edge, not the pixel (ADR 0002).
|
|
982
|
+
applyDock()
|
|
820
983
|
}
|
|
821
984
|
|
|
822
985
|
const onSurfaceClick = () => {
|
|
@@ -859,6 +1022,9 @@ export function mountQuotaCard({
|
|
|
859
1022
|
}
|
|
860
1023
|
const onSurfaceContext = (event) => {
|
|
861
1024
|
event.preventDefault()
|
|
1025
|
+
// Android fires the native contextmenu right after the long-press timer:
|
|
1026
|
+
// an already-open menu is kept as-is instead of being rebuilt.
|
|
1027
|
+
if (!menu.hidden) return
|
|
862
1028
|
openMenu()
|
|
863
1029
|
}
|
|
864
1030
|
const onMenuClick = (event) => {
|
|
@@ -1045,21 +1211,20 @@ export function mountQuotaCard({
|
|
|
1045
1211
|
openKey = undefined
|
|
1046
1212
|
renderAll()
|
|
1047
1213
|
}
|
|
1048
|
-
|
|
1214
|
+
let scrollFrameQueued = false
|
|
1215
|
+
const onDocScroll = () => {
|
|
1216
|
+
// Coalesce scroll bursts into one placement per frame (mobile inertia).
|
|
1217
|
+
if (scrollFrameQueued || disposed) return
|
|
1218
|
+
scrollFrameQueued = true
|
|
1219
|
+
scheduleFrame(() => {
|
|
1220
|
+
scrollFrameQueued = false
|
|
1221
|
+
if (!disposed) placePanel()
|
|
1222
|
+
})
|
|
1223
|
+
}
|
|
1049
1224
|
const onWinResize = () => {
|
|
1050
1225
|
placePanel()
|
|
1051
1226
|
syncCardVisibility()
|
|
1052
|
-
|
|
1053
|
-
// preferred coordinates — only the visual position is clamped here.
|
|
1054
|
-
const viewWidth = win.innerWidth ?? 1024
|
|
1055
|
-
const viewHeight = win.innerHeight ?? 768
|
|
1056
|
-
const x = parseFloat(floatHost.style.left)
|
|
1057
|
-
const y = parseFloat(floatHost.style.top)
|
|
1058
|
-
if (Number.isFinite(x) && Number.isFinite(y)) {
|
|
1059
|
-
const point = clampPoint({ x, y }, { width: viewWidth, height: viewHeight }, SURFACE_SIZE)
|
|
1060
|
-
floatHost.style.left = `${Math.round(point.x)}px`
|
|
1061
|
-
floatHost.style.top = `${Math.round(point.y)}px`
|
|
1062
|
-
}
|
|
1227
|
+
applyDock()
|
|
1063
1228
|
}
|
|
1064
1229
|
const onDocKeydown = (event) => {
|
|
1065
1230
|
if (event.key !== 'Escape') return
|
|
@@ -1112,6 +1277,8 @@ export function mountQuotaCard({
|
|
|
1112
1277
|
surface.removeEventListener('contextmenu', onSurfaceContext)
|
|
1113
1278
|
menu.removeEventListener('click', onMenuClick)
|
|
1114
1279
|
disposeDrag()
|
|
1280
|
+
// A dispose landing mid-gesture must not leave the body unselectable.
|
|
1281
|
+
doc.body.style.userSelect = ''
|
|
1115
1282
|
container.remove()
|
|
1116
1283
|
panel.remove()
|
|
1117
1284
|
floatHost.remove()
|