@qzsy/dsh-bridge 3.3.0 → 3.4.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/client/mobile.js CHANGED
@@ -1,42 +1,79 @@
1
1
  // 移动端抽屉导航:把宿主左侧栏(会话/工作区菜单)改成从左侧滑出的 sheet。
2
2
  //
3
- // 背景:DSH 宿主在窄屏下仍把左侧栏压成 56px 图标轨道,展开后会把内容区挤到
4
- // 110px(实测 390×844)。这里只做一件事:窄屏时内容占满,左侧栏变成带遮罩的
5
- // 抽屉,由顶部菜单按钮打开;桌面端完全不受影响。
3
+ // 设计目标(对齐 tmp/dsh-bridge 的移动端体验,但只保留本项目需要的部分):
4
+ // - 窄屏内容占满屏宽,左侧栏变成带遮罩的抽屉;
5
+ // - 支持全面屏安全区(刘海/状态栏/home indicator),菜单按钮和抽屉内容都不被遮挡;
6
+ // - 支持手势:左缘右滑打开、抽屉内左滑关闭,拖动时抽屉跟随手指,松手吸附;
7
+ // - 桌面端完全不受影响。
6
8
  //
7
9
  // 宿主 DOM 是 CSS Modules 哈希类名,本模块只依赖稳定结构/ARIA:
8
10
  // div[class*="_frame"] / _sidebarCol / _centerCol / _rightbarCol / _overlayLayer
9
11
  // button[aria-label*="打开侧边栏"](英文 Open sidebar)
10
12
  // div[class*="sessionRow"](会话行;projectRow 是工作区分组,不能误关)
11
- // 不写死任何宿主哈希,宿主升级换前缀时选择器仍有效。
12
13
  import { createTranslator } from './locales.mjs';
13
14
 
14
15
  export const MOBILE_MAX_WIDTH = 767;
15
16
  export const MOBILE_DRAWER_CSS_ID = 'qzsy-mobile-drawer-styles';
16
17
  export const MOBILE_UI_FLAG = 'data-qzsy-mobile-drawer';
17
18
  const OPEN_CLASS = 'qzsy-drawer-open';
19
+ const DRAGGING_CLASS = 'qzsy-drawer-dragging';
20
+ const HEADER_CLASS = 'qzsy-mobile-header';
18
21
  const MENU_CLASS = 'qzsy-mobile-menu-btn';
19
22
  const BACKDROP_CLASS = 'qzsy-mobile-backdrop';
20
23
  const STYLE_PLUGIN = '@qzsy/dsh-bridge';
24
+ const EDGE_START_ZONE = 28;
25
+ const DRAG_THRESHOLD = 8;
21
26
 
22
27
  export const MOBILE_DRAWER_CSS = `
23
- .qzsy-mobile-menu-btn,
24
- .qzsy-mobile-backdrop {
28
+ :root {
29
+ --qzsy-safe-top: env(safe-area-inset-top, 0px);
30
+ --qzsy-safe-bottom: env(safe-area-inset-bottom, 0px);
31
+ --qzsy-safe-left: env(safe-area-inset-left, 0px);
32
+ --qzsy-safe-right: env(safe-area-inset-right, 0px);
33
+ }
34
+
35
+ .qzsy-mobile-header,
36
+ .qzsy-mobile-backdrop,
37
+ .qzsy-model-sheet-backdrop {
25
38
  display: none;
26
39
  }
27
40
 
41
+ /* 菜单按钮容器:始终在宿主之上;容器自身不拦截点击 */
42
+ .qzsy-mobile-header {
43
+ position: fixed;
44
+ top: 0;
45
+ left: 0;
46
+ right: 0;
47
+ z-index: 65;
48
+ box-sizing: border-box;
49
+ align-items: flex-end;
50
+ padding-top: 10px;
51
+ padding-top: calc(10px + var(--qzsy-safe-top, 0px));
52
+ padding-right: 10px;
53
+ padding-right: calc(10px + var(--qzsy-safe-right, 0px));
54
+ padding-bottom: 2px;
55
+ padding-left: 10px;
56
+ padding-left: calc(10px + var(--qzsy-safe-left, 0px));
57
+ pointer-events: none;
58
+ }
59
+
28
60
  @media (max-width: 767px) {
29
- /* 1. 左侧栏脱离 grid,内容占满;顶部留出 52px 给菜单按钮 */
61
+ .qzsy-mobile-header { display: flex; }
62
+
63
+ /* 1. 左侧栏脱离 grid,内容占满;顶部给菜单按钮留出安全区高度 */
30
64
  [data-slot="root"] > div[class*="_frame"] {
31
65
  grid-template-columns: 0px minmax(0px, 1fr) minmax(0px, 0px) !important;
32
- padding-top: 52px !important;
33
66
  box-sizing: border-box !important;
67
+ padding-top: 52px !important;
68
+ padding-top: calc(52px + var(--qzsy-safe-top, 0px)) !important;
69
+ padding-bottom: 0 !important;
70
+ padding-bottom: var(--qzsy-safe-bottom, 0px) !important;
34
71
  }
35
72
  div[class*="_centerCol"] { grid-column: 2 !important; }
36
73
  div[class*="_rightbarCol"] { grid-column: 3 !important; }
37
74
  div[class*="_overlayLayer"] { grid-column: 1 / -1 !important; }
38
75
 
39
- /* 2. 原生侧栏改为 fixed 抽屉(默认藏在屏幕左侧) */
76
+ /* 2. 原生侧栏改为 fixed 抽屉(默认藏在屏幕左侧;内部避开安全区) */
40
77
  div[class*="_sidebarCol"] {
41
78
  position: fixed !important;
42
79
  top: 0 !important;
@@ -46,16 +83,26 @@ export const MOBILE_DRAWER_CSS = `
46
83
  max-width: 84vw !important;
47
84
  height: 100vh !important;
48
85
  height: 100dvh !important;
86
+ box-sizing: border-box !important;
87
+ padding-top: 0 !important;
88
+ padding-top: var(--qzsy-safe-top, 0px) !important;
89
+ padding-bottom: 0 !important;
90
+ padding-bottom: var(--qzsy-safe-bottom, 0px) !important;
91
+ padding-left: 0 !important;
92
+ padding-left: var(--qzsy-safe-left, 0px) !important;
49
93
  z-index: 60 !important;
50
94
  background: var(--dsw-alias-bg-layer-1, #ffffff) !important;
51
95
  border-right: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.08)) !important;
52
96
  box-shadow: none !important;
53
97
  transform: translateX(-102%) !important;
54
- transition: transform 280ms cubic-bezier(0.16, 1, 0.3, 1) !important;
55
98
  overflow: hidden !important;
56
99
  overscroll-behavior: contain;
57
100
  will-change: transform;
58
101
  }
102
+ /* 首帧先就位、不播放入场动画,避免页面加载时侧栏从 0 滑出 */
103
+ body.qzsy-drawer-ready div[class*="_sidebarCol"] {
104
+ transition: transform 280ms cubic-bezier(0.16, 1, 0.3, 1) !important;
105
+ }
59
106
  body.qzsy-drawer-open div[class*="_sidebarCol"] {
60
107
  transform: translateX(0) !important;
61
108
  box-shadow: 8px 0 32px rgba(0, 0, 0, 0.18) !important;
@@ -64,6 +111,17 @@ export const MOBILE_DRAWER_CSS = `
64
111
  width: 100% !important;
65
112
  }
66
113
 
114
+ /* 拖动中:关闭过渡,transform/遮罩直接跟手 */
115
+ body.qzsy-drawer-dragging div[class*="_sidebarCol"] {
116
+ transition: none !important;
117
+ transform: translateX(var(--qzsy-drawer-x, -102%)) !important;
118
+ }
119
+ body.qzsy-drawer-dragging .qzsy-mobile-backdrop {
120
+ transition: none !important;
121
+ opacity: var(--qzsy-drawer-progress, 0) !important;
122
+ pointer-events: auto !important;
123
+ }
124
+
67
125
  /* 3. 半透明遮罩:点击收起 */
68
126
  .qzsy-mobile-backdrop {
69
127
  display: block;
@@ -82,17 +140,13 @@ export const MOBILE_DRAWER_CSS = `
82
140
  pointer-events: auto;
83
141
  }
84
142
 
85
- /* 4. 顶部菜单按钮(位于 52px 留白条内,避免遮挡会话标题) */
143
+ /* 4. 菜单按钮:位于安全区下方的 52px 留白条内 */
86
144
  .qzsy-mobile-menu-btn {
87
145
  display: inline-flex;
88
- position: fixed;
89
- top: 10px;
90
- left: 10px;
91
- width: 40px;
92
- height: 40px;
93
- z-index: 65;
94
146
  align-items: center;
95
147
  justify-content: center;
148
+ width: 40px;
149
+ height: 40px;
96
150
  padding: 0;
97
151
  border: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.1));
98
152
  border-radius: 12px;
@@ -100,6 +154,7 @@ export const MOBILE_DRAWER_CSS = `
100
154
  color: var(--dsw-alias-label-primary, #111827);
101
155
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
102
156
  cursor: pointer;
157
+ pointer-events: auto;
103
158
  -webkit-tap-highlight-color: transparent;
104
159
  transition: opacity 180ms ease, transform 180ms ease;
105
160
  }
@@ -109,6 +164,109 @@ export const MOBILE_DRAWER_CSS = `
109
164
  opacity: 0;
110
165
  pointer-events: none;
111
166
  }
167
+
168
+ /* 5. 模型切换器:把宿主桌面 popover 改成移动端底部 sheet */
169
+ @keyframes qzsy-model-sheet-in {
170
+ from { transform: translateY(100%); opacity: 0.6; }
171
+ to { transform: translateY(0); opacity: 1; }
172
+ }
173
+
174
+ .qzsy-model-sheet-backdrop {
175
+ display: block;
176
+ position: fixed;
177
+ inset: 0;
178
+ z-index: 1190;
179
+ background: rgba(0, 0, 0, 0.4);
180
+ opacity: 0;
181
+ pointer-events: none;
182
+ transition: opacity 240ms ease;
183
+ touch-action: none;
184
+ -webkit-tap-highlight-color: transparent;
185
+ }
186
+ body.qzsy-model-sheet-open .qzsy-model-sheet-backdrop {
187
+ opacity: 1;
188
+ pointer-events: auto;
189
+ }
190
+
191
+ [data-qzsy-model-sheet] {
192
+ position: fixed !important;
193
+ top: auto !important;
194
+ left: 0 !important;
195
+ right: 0 !important;
196
+ bottom: 0 !important;
197
+ width: 100vw !important;
198
+ max-width: 100vw !important;
199
+ height: auto !important;
200
+ max-height: 82vh !important;
201
+ max-height: 82dvh !important;
202
+ margin: 0 !important;
203
+ padding: 18px 0 0 !important;
204
+ border: 0 !important;
205
+ border-radius: 18px 18px 0 0 !important;
206
+ background: var(--dsw-alias-bg-layer-1, #ffffff) !important;
207
+ box-shadow: 0 -12px 40px rgba(0, 0, 0, 0.18) !important;
208
+ box-sizing: border-box !important;
209
+ display: flex !important;
210
+ flex-direction: column !important;
211
+ overflow: hidden !important;
212
+ z-index: 1200 !important;
213
+ transform-origin: bottom center !important;
214
+ animation: qzsy-model-sheet-in 260ms cubic-bezier(0.16, 1, 0.3, 1) !important;
215
+ will-change: transform;
216
+ }
217
+ [data-qzsy-model-sheet]::before {
218
+ content: '';
219
+ position: absolute;
220
+ top: 7px;
221
+ left: 50%;
222
+ width: 36px;
223
+ height: 4px;
224
+ margin-left: -18px;
225
+ border-radius: 999px;
226
+ background: rgba(0, 0, 0, 0.18);
227
+ pointer-events: none;
228
+ }
229
+ [data-qzsy-model-sheet] > div,
230
+ [data-qzsy-model-sheet] div[role="menu"] {
231
+ width: 100% !important;
232
+ max-width: 100% !important;
233
+ border: 0 !important;
234
+ border-radius: 0 !important;
235
+ background: transparent !important;
236
+ box-shadow: none !important;
237
+ box-sizing: border-box !important;
238
+ }
239
+ [data-qzsy-model-sheet] div[role="menu"] {
240
+ max-height: calc(82vh - 40px) !important;
241
+ max-height: calc(82dvh - 40px) !important;
242
+ overflow-y: auto !important;
243
+ overscroll-behavior: contain !important;
244
+ padding: 8px 12px calc(14px + var(--qzsy-safe-bottom, 0px)) !important;
245
+ }
246
+ [data-qzsy-model-sheet] input {
247
+ width: 100% !important;
248
+ box-sizing: border-box !important;
249
+ }
250
+ [data-qzsy-model-sheet] button[role="menuitem"],
251
+ [data-qzsy-model-sheet] [role="option"] {
252
+ width: 100% !important;
253
+ box-sizing: border-box !important;
254
+ }
255
+ [data-qzsy-model-sheet].qzsy-model-sheet-closing {
256
+ transform: translateY(100%) !important;
257
+ opacity: 0 !important;
258
+ transition: transform 220ms ease, opacity 220ms ease !important;
259
+ animation: none !important;
260
+ }
261
+ body.qzsy-model-sheet-dragging [data-qzsy-model-sheet] {
262
+ transform: translateY(var(--qzsy-model-sheet-y, 0px)) !important;
263
+ transition: none !important;
264
+ animation: none !important;
265
+ }
266
+ body.qzsy-model-sheet-dragging .qzsy-model-sheet-backdrop {
267
+ opacity: var(--qzsy-model-sheet-progress, 1) !important;
268
+ transition: none !important;
269
+ }
112
270
  }
113
271
  `;
114
272
 
@@ -137,6 +295,11 @@ function injectStyles() {
137
295
  if (style.textContent !== MOBILE_DRAWER_CSS) style.textContent = MOBILE_DRAWER_CSS;
138
296
  }
139
297
 
298
+ function drawerWidth() {
299
+ if (typeof window === 'undefined') return 300;
300
+ return Math.min(300, Math.round(window.innerWidth * 0.84));
301
+ }
302
+
140
303
  /**
141
304
  * 安装移动端抽屉导航。无 DOM 环境(单测/SSR)安全退化为 no-op。
142
305
  * 返回 disposer;宿主若支持 ctx.effect,可注册为插件副作用。
@@ -150,18 +313,31 @@ export function setupMobileDrawer() {
150
313
  let menuBtn = null;
151
314
  let observer = null;
152
315
  let disposed = false;
316
+ let gesture = null;
317
+ let suppressClickUntil = 0;
318
+ let modelPortal = null;
319
+ let modelBackdrop = null;
320
+ let modelChainUntil = 0;
321
+ let modelDrag = null;
322
+ let modelDragHandlers = null;
153
323
 
154
324
  const ensureElements = () => {
155
325
  if (disposed || !document.body) return;
156
326
  injectStyles();
157
327
 
158
- let btn = document.querySelector(`.${MENU_CLASS}`);
328
+ let header = document.querySelector(`.${HEADER_CLASS}`);
329
+ if (!header) {
330
+ header = document.createElement('div');
331
+ header.className = HEADER_CLASS;
332
+ document.body.appendChild(header);
333
+ }
334
+ let btn = header.querySelector(`.${MENU_CLASS}`);
159
335
  if (!btn) {
160
336
  btn = document.createElement('button');
161
337
  btn.type = 'button';
162
338
  btn.className = MENU_CLASS;
163
339
  btn.innerHTML = '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><line x1="3" y1="8" x2="21" y2="8"></line><line x1="3" y1="15" x2="15" y2="15"></line></svg>';
164
- document.body.appendChild(btn);
340
+ header.appendChild(btn);
165
341
  }
166
342
  let mask = document.querySelector(`.${BACKDROP_CLASS}`);
167
343
  if (!mask) {
@@ -194,6 +370,17 @@ export function setupMobileDrawer() {
194
370
  }
195
371
  };
196
372
 
373
+ const setDragProgress = (x, width) => {
374
+ document.body.style.setProperty('--qzsy-drawer-x', `${x}px`);
375
+ document.body.style.setProperty('--qzsy-drawer-progress', String(Math.max(0, Math.min(1, 1 - Math.abs(x) / width))));
376
+ };
377
+
378
+ const clearDrag = () => {
379
+ document.body.classList.remove(DRAGGING_CLASS);
380
+ document.body.style.removeProperty('--qzsy-drawer-x');
381
+ document.body.style.removeProperty('--qzsy-drawer-progress');
382
+ };
383
+
197
384
  const openDrawer = () => {
198
385
  if (disposed || !isMobileViewport() || !document.body) return;
199
386
  ensureElements();
@@ -218,7 +405,234 @@ export function setupMobileDrawer() {
218
405
  }, 60);
219
406
  };
220
407
 
408
+ // ---- 模型切换器:宿主桌面 popover → 移动端底部 sheet ----
409
+ const bodyPortalOf = (node) => {
410
+ let el = node;
411
+ while (el && el.parentElement && el.parentElement !== document.body) el = el.parentElement;
412
+ return el && el.parentElement === document.body ? el : null;
413
+ };
414
+
415
+ const visiblePortal = (el) => {
416
+ if (!el || !el.isConnected) return false;
417
+ const s = getComputedStyle(el);
418
+ if (s.display === 'none' || s.visibility === 'hidden') return false;
419
+ const r = el.getBoundingClientRect();
420
+ return r.width > 0 && r.height > 0;
421
+ };
422
+
423
+ const isModelTrigger = (target) => {
424
+ const el = target?.closest?.('button, [role="menuitem"], [role="button"], a');
425
+ if (!el) return false;
426
+ const label = `${el.getAttribute('aria-label') || ''} ${el.getAttribute('title') || ''} ${el.innerText || ''}`;
427
+ return /模型|Model|model/.test(label);
428
+ };
429
+
430
+ const findModelPortal = () => {
431
+ if (!isMobileViewport()) return null;
432
+ const search = document.querySelector(
433
+ 'input[placeholder*="搜索模型"], input[placeholder*="Search model"], input[aria-label*="搜索模型"], input[aria-label*="Search model"]',
434
+ );
435
+ if (search) {
436
+ const menu = search.closest('[role="menu"]') || search.closest('[class*="_menu"]');
437
+ const portal = bodyPortalOf(menu || search);
438
+ if (portal && visiblePortal(portal)) {
439
+ const s = getComputedStyle(portal);
440
+ if ((s.position === 'fixed' || s.position === 'absolute') && portal.getBoundingClientRect().width > 120) return portal;
441
+ }
442
+ }
443
+ // 无搜索框的宿主(如移动端版本)用「模型触发链 + 菜单行数」兜底
444
+ if (Date.now() > modelChainUntil) return null;
445
+ const candidates = [];
446
+ for (const el of document.body.children) {
447
+ if (!(el instanceof HTMLElement)) continue;
448
+ if (el === modelBackdrop || el.classList.contains(HEADER_CLASS) || el.classList.contains(BACKDROP_CLASS)) continue;
449
+ if (!visiblePortal(el)) continue;
450
+ const s = getComputedStyle(el);
451
+ if (s.position !== 'fixed' && s.position !== 'absolute') continue;
452
+ const menu = el.querySelector('[role="menu"], [role="listbox"]');
453
+ if (!menu) continue;
454
+ const text = menu.innerText || '';
455
+ const rows = menu.querySelectorAll('[role="menuitem"], [role="option"], button, a, [tabindex]').length;
456
+ const hasSearch = Boolean(el.querySelector('input[placeholder*="搜索模型"], input[placeholder*="Search model"]'));
457
+ // 排除「模型 / 推理等级」两行的第一层 popover
458
+ if (/推理等级|Reasoning effort|推理强度/i.test(text) && rows <= 2) continue;
459
+ // 已经是全宽底部 sheet 的宿主菜单不再重复改造
460
+ const rect = el.getBoundingClientRect();
461
+ const alreadySheet = rect.width >= window.innerWidth - 2 && Math.abs(rect.bottom - window.innerHeight) < 2 && rect.height > 120;
462
+ if (alreadySheet && !hasSearch) continue;
463
+ if (hasSearch || rows > 2) candidates.push({ el, rows, hasSearch });
464
+ }
465
+ candidates.sort((a, b) => Number(b.hasSearch) - Number(a.hasSearch) || b.rows - a.rows);
466
+ return candidates[0]?.el || null;
467
+ };
468
+
469
+ const hostBackdropPresent = () => {
470
+ for (const el of document.body.children) {
471
+ if (!(el instanceof HTMLElement)) continue;
472
+ if (el === modelBackdrop || el.classList.contains(HEADER_CLASS) || el.classList.contains(BACKDROP_CLASS)) continue;
473
+ const s = getComputedStyle(el);
474
+ if (s.position !== 'fixed') continue;
475
+ const r = el.getBoundingClientRect();
476
+ if (r.width < window.innerWidth - 4 || r.height < window.innerHeight - 4) continue;
477
+ if (Number(s.opacity) < 0.1) continue;
478
+ const bg = s.backgroundColor || '';
479
+ if (!/^rgba?\(/.test(bg) || /rgba\(0,\s*0,\s*0,\s*0\)/.test(bg)) continue;
480
+ if (el.querySelector('[role="menu"]')) continue;
481
+ return true;
482
+ }
483
+ return false;
484
+ };
485
+
486
+ const requestHostModelClose = (portal) => {
487
+ if (disposed || !portal || !portal.isConnected) return;
488
+ const PointerCtor = typeof window.PointerEvent === 'function' ? window.PointerEvent : window.MouseEvent;
489
+ try { document.body.dispatchEvent(new PointerCtor('pointerdown', { bubbles: true, cancelable: true })); } catch { /* 忽略 */ }
490
+ try { document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); } catch { /* 忽略 */ }
491
+ window.setTimeout(() => {
492
+ if (disposed || !portal.isConnected) return;
493
+ const trigger = document.querySelector('button[aria-haspopup="menu"][aria-expanded="true"][aria-label*="选择模型"], button[aria-haspopup="menu"][aria-expanded="true"][aria-label*="Select model"]');
494
+ try { trigger?.click(); } catch { /* 忽略 */ }
495
+ window.setTimeout(() => {
496
+ if (!disposed && portal.isConnected && modelPortal === portal) clearModelSheet();
497
+ }, 450);
498
+ }, 120);
499
+ };
500
+
501
+ const closeModelSheetNow = () => {
502
+ const portal = modelPortal;
503
+ if (!portal) return;
504
+ document.body.classList.remove('qzsy-model-sheet-open');
505
+ document.body.style.removeProperty('--qzsy-model-sheet-y');
506
+ document.body.style.removeProperty('--qzsy-model-sheet-progress');
507
+ requestHostModelClose(portal);
508
+ };
509
+
510
+ const closeModelSheetAnimated = () => {
511
+ const portal = modelPortal;
512
+ if (!portal) return;
513
+ portal.classList.add('qzsy-model-sheet-closing');
514
+ document.body.classList.remove('qzsy-model-sheet-open');
515
+ window.setTimeout(() => requestHostModelClose(portal), 180);
516
+ };
517
+
518
+ const ensureModelBackdrop = () => {
519
+ if (modelBackdrop || hostBackdropPresent()) return;
520
+ modelBackdrop = document.createElement('div');
521
+ modelBackdrop.className = 'qzsy-model-sheet-backdrop';
522
+ modelBackdrop.setAttribute('aria-hidden', 'true');
523
+ modelBackdrop.addEventListener('pointerdown', (event) => event.stopPropagation(), true);
524
+ modelBackdrop.addEventListener('click', (event) => {
525
+ event.preventDefault();
526
+ event.stopPropagation();
527
+ closeModelSheetNow();
528
+ });
529
+ document.body.appendChild(modelBackdrop);
530
+ };
531
+
532
+ const detachModelDrag = () => {
533
+ if (!modelPortal || !modelDragHandlers) return;
534
+ modelPortal.removeEventListener('touchstart', modelDragHandlers.onStart);
535
+ modelPortal.removeEventListener('touchmove', modelDragHandlers.onMove);
536
+ modelPortal.removeEventListener('touchend', modelDragHandlers.onEnd);
537
+ modelPortal.removeEventListener('touchcancel', modelDragHandlers.onEnd);
538
+ modelDragHandlers = null;
539
+ };
540
+
541
+ const attachModelDrag = (portal) => {
542
+ if (!portal || modelDragHandlers) return;
543
+ const onStart = (event) => {
544
+ if (event.touches?.length !== 1) return;
545
+ const touch = event.touches[0];
546
+ const rect = portal.getBoundingClientRect();
547
+ if (touch.clientY - rect.top > 30) return;
548
+ if (event.target.closest?.('input, textarea, select, button, a, [contenteditable="true"]')) return;
549
+ modelDrag = { startY: touch.clientY, startT: Date.now(), dy: 0, active: false, height: rect.height };
550
+ };
551
+ const onMove = (event) => {
552
+ if (!modelDrag) return;
553
+ const touch = event.touches?.[0];
554
+ if (!touch) return;
555
+ const dy = touch.clientY - modelDrag.startY;
556
+ if (!modelDrag.active) {
557
+ if (dy < 6) return;
558
+ modelDrag.active = true;
559
+ document.body.classList.add('qzsy-model-sheet-dragging');
560
+ }
561
+ modelDrag.dy = Math.max(0, dy);
562
+ document.body.style.setProperty('--qzsy-model-sheet-y', `${modelDrag.dy}px`);
563
+ document.body.style.setProperty('--qzsy-model-sheet-progress', String(Math.max(0, 1 - modelDrag.dy / Math.max(1, modelDrag.height))));
564
+ if (event.cancelable) event.preventDefault();
565
+ };
566
+ const onEnd = () => {
567
+ if (!modelDrag) return;
568
+ const drag = modelDrag;
569
+ modelDrag = null;
570
+ if (!drag.active) return;
571
+ document.body.classList.remove('qzsy-model-sheet-dragging');
572
+ document.body.style.removeProperty('--qzsy-model-sheet-y');
573
+ document.body.style.removeProperty('--qzsy-model-sheet-progress');
574
+ const velocity = drag.dy / Math.max(1, Date.now() - drag.startT);
575
+ if (drag.dy > 110 || velocity > 0.5) closeModelSheetAnimated();
576
+ };
577
+ portal.addEventListener('touchstart', onStart, { passive: true });
578
+ portal.addEventListener('touchmove', onMove, { passive: false });
579
+ portal.addEventListener('touchend', onEnd, { passive: true });
580
+ portal.addEventListener('touchcancel', onEnd, { passive: true });
581
+ modelDragHandlers = { onStart, onMove, onEnd };
582
+ };
583
+
584
+ const clearModelSheet = () => {
585
+ if (modelPortal) {
586
+ detachModelDrag();
587
+ modelPortal.removeAttribute('data-qzsy-model-sheet');
588
+ modelPortal.classList.remove('qzsy-model-sheet', 'qzsy-model-sheet-closing');
589
+ }
590
+ modelPortal = null;
591
+ modelDrag = null;
592
+ document.body?.classList.remove('qzsy-model-sheet-open', 'qzsy-model-sheet-dragging');
593
+ document.body?.style.removeProperty('--qzsy-model-sheet-y');
594
+ document.body?.style.removeProperty('--qzsy-model-sheet-progress');
595
+ if (modelBackdrop) {
596
+ modelBackdrop.remove();
597
+ modelBackdrop = null;
598
+ }
599
+ };
600
+
601
+ const applyModelSheet = (portal) => {
602
+ if (modelPortal === portal) return;
603
+ clearModelSheet();
604
+ modelPortal = portal;
605
+ portal.setAttribute('data-qzsy-model-sheet', '');
606
+ portal.classList.add('qzsy-model-sheet');
607
+ attachModelDrag(portal);
608
+ ensureModelBackdrop();
609
+ window.requestAnimationFrame(() => {
610
+ if (!disposed && modelPortal === portal) document.body.classList.add('qzsy-model-sheet-open');
611
+ });
612
+ };
613
+
614
+ const syncModelSheet = () => {
615
+ if (disposed) return;
616
+ if (!isMobileViewport()) {
617
+ clearModelSheet();
618
+ return;
619
+ }
620
+ if (modelPortal && !visiblePortal(modelPortal)) clearModelSheet();
621
+ const found = findModelPortal();
622
+ if (!found) {
623
+ if (modelPortal?.isConnected) return; // 已应用,等待宿主关闭后移除
624
+ if (modelPortal) clearModelSheet();
625
+ return;
626
+ }
627
+ if (found !== modelPortal) applyModelSheet(found);
628
+ };
629
+
221
630
  const onClick = (event) => {
631
+ if (Date.now() < suppressClickUntil) {
632
+ event.preventDefault();
633
+ event.stopPropagation();
634
+ return;
635
+ }
222
636
  const target = event.target;
223
637
  if (!isElement(target)) return;
224
638
 
@@ -228,6 +642,14 @@ export function setupMobileDrawer() {
228
642
  else openDrawer();
229
643
  return;
230
644
  }
645
+
646
+ // 模型切换器:记录触发链,等宿主把二级菜单 portal 挂到 body 后再改造成 sheet
647
+ if (isModelTrigger(target)) {
648
+ modelChainUntil = Date.now() + 6000;
649
+ window.setTimeout(syncModelSheet, 30);
650
+ window.setTimeout(syncModelSheet, 180);
651
+ }
652
+
231
653
  if (!isOpen()) return;
232
654
 
233
655
  if (target.closest(`.${BACKDROP_CLASS}`)) {
@@ -265,11 +687,117 @@ export function setupMobileDrawer() {
265
687
  };
266
688
 
267
689
  const onResize = () => {
268
- if (!isMobileViewport() && isOpen()) closeDrawer({ restoreFocus: false });
690
+ if (!isMobileViewport() && isOpen()) {
691
+ gesture = null;
692
+ clearDrag();
693
+ closeDrawer({ restoreFocus: false });
694
+ }
695
+ syncModelSheet();
269
696
  };
270
697
 
271
698
  const onVisibilityChange = () => {
272
- if (document.visibilityState === 'hidden' && isOpen()) closeDrawer({ restoreFocus: false });
699
+ if (document.visibilityState === 'hidden' && isOpen()) {
700
+ gesture = null;
701
+ clearDrag();
702
+ closeDrawer({ restoreFocus: false });
703
+ }
704
+ if (document.visibilityState === 'visible') syncModelSheet();
705
+ };
706
+
707
+ // ---- 手势:左缘右滑打开;抽屉内左滑关闭;拖动跟手 ----
708
+ const onTouchStart = (event) => {
709
+ if (modelPortal || disposed || !isMobileViewport() || event.touches?.length !== 1) return;
710
+ const touch = event.touches[0];
711
+ const sidebar = document.querySelector('div[class*="_sidebarCol"]');
712
+ if (!sidebar) return;
713
+ const open = isOpen();
714
+
715
+ if (!open) {
716
+ if (touch.clientX > EDGE_START_ZONE) return;
717
+ // 先把宿主侧栏展开,保证拖动时抽屉里有完整会话列表
718
+ ensureSidebarExpanded();
719
+ gesture = {
720
+ mode: 'open',
721
+ startX: touch.clientX,
722
+ startY: touch.clientY,
723
+ startT: Date.now(),
724
+ width: drawerWidth(),
725
+ active: false,
726
+ dx: 0,
727
+ };
728
+ return;
729
+ }
730
+
731
+ if (!sidebar.contains(event.target)) return;
732
+ if (event.target.closest?.('input, textarea, select, [contenteditable="true"]')) return;
733
+ gesture = {
734
+ mode: 'close',
735
+ startX: touch.clientX,
736
+ startY: touch.clientY,
737
+ startT: Date.now(),
738
+ width: drawerWidth(),
739
+ active: false,
740
+ dx: 0,
741
+ };
742
+ };
743
+
744
+ const onTouchMove = (event) => {
745
+ if (modelPortal || !gesture || disposed) return;
746
+ const touch = event.touches?.[0];
747
+ if (!touch) return;
748
+ const dx = touch.clientX - gesture.startX;
749
+ const dy = touch.clientY - gesture.startY;
750
+
751
+ if (!gesture.active) {
752
+ if (Math.abs(dx) < DRAG_THRESHOLD) return;
753
+ // 纵向为主:交还给宿主滚动,本次不拦截
754
+ if (Math.abs(dx) < Math.abs(dy) * 1.2) { gesture = null; return; }
755
+ if (gesture.mode === 'open' && dx <= 0) { gesture = null; return; }
756
+ if (gesture.mode === 'close' && dx >= 0) { gesture = null; return; }
757
+ gesture.active = true;
758
+ suppressClickUntil = Date.now() + 450;
759
+ document.body.classList.add(DRAGGING_CLASS);
760
+ }
761
+
762
+ const width = Math.max(1, gesture.width);
763
+ let x;
764
+ if (gesture.mode === 'open') x = Math.max(-width, Math.min(0, -width + dx));
765
+ else x = Math.max(-width, Math.min(0, dx));
766
+ gesture.dx = dx;
767
+ setDragProgress(x, width);
768
+ };
769
+
770
+ const finishGesture = (touch) => {
771
+ if (!gesture) return;
772
+ const current = gesture;
773
+ gesture = null;
774
+ if (!current.active) return;
775
+
776
+ const endX = touch ? touch.clientX : current.startX + current.dx;
777
+ const dx = endX - current.startX;
778
+ const elapsed = Math.max(1, Date.now() - current.startT);
779
+ const velocity = dx / elapsed;
780
+ const threshold = Math.min(90, Math.max(60, current.width * 0.3));
781
+ const openNow = current.mode === 'open'
782
+ ? dx > threshold || velocity > 0.45
783
+ : !(dx < -Math.max(50, threshold * 0.7) || velocity < -0.35);
784
+
785
+ document.body.classList.toggle(OPEN_CLASS, openNow);
786
+ if (openNow) ensureSidebarExpanded();
787
+ syncAria();
788
+ suppressClickUntil = Date.now() + 250;
789
+ // 等一帧让浏览器接住当前拖动位置,再撤掉拖动样式做吸附动画
790
+ window.requestAnimationFrame(clearDrag);
791
+ };
792
+
793
+ const onTouchEnd = (event) => {
794
+ if (modelPortal || !gesture) return;
795
+ finishGesture(event.changedTouches?.[0]);
796
+ };
797
+
798
+ const onTouchCancel = () => {
799
+ if (modelPortal || !gesture) return;
800
+ finishGesture(null);
273
801
  };
274
802
 
275
803
  injectStyles();
@@ -278,27 +806,46 @@ export function setupMobileDrawer() {
278
806
  document.addEventListener('keydown', onKeyDown, true);
279
807
  document.addEventListener('visibilitychange', onVisibilityChange);
280
808
  window.addEventListener('resize', onResize);
809
+ window.addEventListener('touchstart', onTouchStart, { passive: true });
810
+ window.addEventListener('touchmove', onTouchMove, { passive: true });
811
+ window.addEventListener('touchend', onTouchEnd, { passive: true });
812
+ window.addEventListener('touchcancel', onTouchCancel, { passive: true });
813
+
814
+ // 等侧栏先无动画地藏到屏幕外,再启用过渡(否则首次加载会看到 280ms 滑出)
815
+ window.requestAnimationFrame(() => {
816
+ if (!disposed && document.body) document.body.classList.add('qzsy-drawer-ready');
817
+ });
281
818
 
282
819
  if (typeof MutationObserver === 'function' && document.body) {
283
820
  observer = new MutationObserver(() => {
284
- if (!document.querySelector(`.${MENU_CLASS}`) || !document.querySelector(`.${BACKDROP_CLASS}`) || !document.getElementById(MOBILE_DRAWER_CSS_ID)) {
821
+ if (!document.querySelector(`.${HEADER_CLASS}`) || !document.querySelector(`.${MENU_CLASS}`) || !document.querySelector(`.${BACKDROP_CLASS}`) || !document.getElementById(MOBILE_DRAWER_CSS_ID)) {
285
822
  ensureElements();
286
823
  }
824
+ syncModelSheet();
287
825
  });
288
826
  observer.observe(document.body, { childList: true });
289
827
  }
290
828
 
829
+ syncModelSheet();
291
830
  syncAria();
292
831
 
293
832
  return () => {
294
833
  disposed = true;
834
+ gesture = null;
295
835
  try { observer?.disconnect(); } catch { /* 忽略 */ }
296
836
  document.removeEventListener('click', onClick, true);
297
837
  document.removeEventListener('keydown', onKeyDown, true);
298
838
  document.removeEventListener('visibilitychange', onVisibilityChange);
299
839
  window.removeEventListener('resize', onResize);
840
+ window.removeEventListener('touchstart', onTouchStart);
841
+ window.removeEventListener('touchmove', onTouchMove);
842
+ window.removeEventListener('touchend', onTouchEnd);
843
+ window.removeEventListener('touchcancel', onTouchCancel);
844
+ clearModelSheet();
845
+ clearDrag();
300
846
  document.body?.classList.remove(OPEN_CLASS);
301
- document.querySelector(`.${MENU_CLASS}`)?.remove();
847
+ document.body?.classList.remove('qzsy-drawer-ready');
848
+ document.querySelector(`.${HEADER_CLASS}`)?.remove();
302
849
  document.querySelector(`.${BACKDROP_CLASS}`)?.remove();
303
850
  document.getElementById(MOBILE_DRAWER_CSS_ID)?.remove();
304
851
  root.removeAttribute(MOBILE_UI_FLAG);