@qzsy/dsh-bridge 3.3.0 → 3.3.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # 更新日志
2
2
 
3
+ ## [v3.3.1] - 2026-11-02
4
+
5
+ ### 🐛 修复
6
+
7
+ - **全面屏安全区**:菜单按钮、内容区、抽屉都按 `safe-area-inset-*` 让位,刘海 / 状态栏 / 底部 home indicator 不再遮挡;横屏左右安全区同样生效。
8
+ - **首次加载闪动**:侧栏首帧先无动画隐藏,再启用 280ms 过渡,页面加载时不再看到侧栏从 0 滑出。
9
+
10
+ ### ✨ 改进
11
+
12
+ - **手势滑动**:支持左缘右滑打开、抽屉内左滑关闭;拖动时抽屉跟手、遮罩同步渐变,松手按距离/速度吸附。
13
+ - **手势防误触**:纵向滚动不触发抽屉;滑动后的合成点击会被吞掉,避免误开/误关会话。
14
+
15
+ ### 🧪 验证
16
+
17
+ - 模拟 47px 顶部 / 34px 底部安全区:按钮 y=57、内容 padding-top=99px,抽屉 padding 47/34 全部生效。
18
+ - 模拟触摸:拖动中 `translateX=-150px`、松手打开;抽屉内左滑关闭;纵向滑动不误开;9/9 通过。
19
+
20
+
3
21
  ## [v3.3.0] - 2026-11-02
4
22
 
5
23
  ### ✨ 新增
package/README.md CHANGED
@@ -47,6 +47,8 @@ dsh plugin --profile <name> add @qzsy/dsh-bridge
47
47
  窄屏(≤ 767px)访问 DSH 时,插件会自动把左侧栏改成抽屉,不需要额外配置:
48
48
 
49
49
  - 内容区占满屏宽,左上角「菜单」按钮打开会话 / 工作区 / 插件入口;
50
+ - 支持手势:左缘向右滑打开、抽屉内向左滑关闭,拖动时跟手,松手吸附;
51
+ - 全面屏自动避开安全区:刘海 / 状态栏 / 底部 home indicator 不遮挡内容;
50
52
  - 点遮罩、点会话行、按 Esc 或转回桌面宽度都会自动收起;
51
53
  - 桌面端保持 DSH 原生布局,不受影响。
52
54
 
package/client/client.js CHANGED
@@ -220,27 +220,62 @@ var MOBILE_MAX_WIDTH = 767;
220
220
  var MOBILE_DRAWER_CSS_ID = "qzsy-mobile-drawer-styles";
221
221
  var MOBILE_UI_FLAG = "data-qzsy-mobile-drawer";
222
222
  var OPEN_CLASS = "qzsy-drawer-open";
223
+ var DRAGGING_CLASS = "qzsy-drawer-dragging";
224
+ var HEADER_CLASS = "qzsy-mobile-header";
223
225
  var MENU_CLASS = "qzsy-mobile-menu-btn";
224
226
  var BACKDROP_CLASS = "qzsy-mobile-backdrop";
225
227
  var STYLE_PLUGIN = "@qzsy/dsh-bridge";
228
+ var EDGE_START_ZONE = 28;
229
+ var DRAG_THRESHOLD = 8;
226
230
  var MOBILE_DRAWER_CSS = `
227
- .qzsy-mobile-menu-btn,
231
+ :root {
232
+ --qzsy-safe-top: env(safe-area-inset-top, 0px);
233
+ --qzsy-safe-bottom: env(safe-area-inset-bottom, 0px);
234
+ --qzsy-safe-left: env(safe-area-inset-left, 0px);
235
+ --qzsy-safe-right: env(safe-area-inset-right, 0px);
236
+ }
237
+
238
+ .qzsy-mobile-header,
228
239
  .qzsy-mobile-backdrop {
229
240
  display: none;
230
241
  }
231
242
 
243
+ /* \u83DC\u5355\u6309\u94AE\u5BB9\u5668\uFF1A\u59CB\u7EC8\u5728\u5BBF\u4E3B\u4E4B\u4E0A\uFF1B\u5BB9\u5668\u81EA\u8EAB\u4E0D\u62E6\u622A\u70B9\u51FB */
244
+ .qzsy-mobile-header {
245
+ position: fixed;
246
+ top: 0;
247
+ left: 0;
248
+ right: 0;
249
+ z-index: 65;
250
+ box-sizing: border-box;
251
+ align-items: flex-end;
252
+ padding-top: 10px;
253
+ padding-top: calc(10px + var(--qzsy-safe-top, 0px));
254
+ padding-right: 10px;
255
+ padding-right: calc(10px + var(--qzsy-safe-right, 0px));
256
+ padding-bottom: 2px;
257
+ padding-left: 10px;
258
+ padding-left: calc(10px + var(--qzsy-safe-left, 0px));
259
+ pointer-events: none;
260
+ }
261
+
232
262
  @media (max-width: 767px) {
233
- /* 1. \u5DE6\u4FA7\u680F\u8131\u79BB grid\uFF0C\u5185\u5BB9\u5360\u6EE1\uFF1B\u9876\u90E8\u7559\u51FA 52px \u7ED9\u83DC\u5355\u6309\u94AE */
263
+ .qzsy-mobile-header { display: flex; }
264
+
265
+ /* 1. \u5DE6\u4FA7\u680F\u8131\u79BB grid\uFF0C\u5185\u5BB9\u5360\u6EE1\uFF1B\u9876\u90E8\u7ED9\u83DC\u5355\u6309\u94AE\u7559\u51FA\u5B89\u5168\u533A\u9AD8\u5EA6 */
234
266
  [data-slot="root"] > div[class*="_frame"] {
235
267
  grid-template-columns: 0px minmax(0px, 1fr) minmax(0px, 0px) !important;
236
- padding-top: 52px !important;
237
268
  box-sizing: border-box !important;
269
+ padding-top: 52px !important;
270
+ padding-top: calc(52px + var(--qzsy-safe-top, 0px)) !important;
271
+ padding-bottom: 0 !important;
272
+ padding-bottom: var(--qzsy-safe-bottom, 0px) !important;
238
273
  }
239
274
  div[class*="_centerCol"] { grid-column: 2 !important; }
240
275
  div[class*="_rightbarCol"] { grid-column: 3 !important; }
241
276
  div[class*="_overlayLayer"] { grid-column: 1 / -1 !important; }
242
277
 
243
- /* 2. \u539F\u751F\u4FA7\u680F\u6539\u4E3A fixed \u62BD\u5C49\uFF08\u9ED8\u8BA4\u85CF\u5728\u5C4F\u5E55\u5DE6\u4FA7\uFF09 */
278
+ /* 2. \u539F\u751F\u4FA7\u680F\u6539\u4E3A fixed \u62BD\u5C49\uFF08\u9ED8\u8BA4\u85CF\u5728\u5C4F\u5E55\u5DE6\u4FA7\uFF1B\u5185\u90E8\u907F\u5F00\u5B89\u5168\u533A\uFF09 */
244
279
  div[class*="_sidebarCol"] {
245
280
  position: fixed !important;
246
281
  top: 0 !important;
@@ -250,16 +285,26 @@ var MOBILE_DRAWER_CSS = `
250
285
  max-width: 84vw !important;
251
286
  height: 100vh !important;
252
287
  height: 100dvh !important;
288
+ box-sizing: border-box !important;
289
+ padding-top: 0 !important;
290
+ padding-top: var(--qzsy-safe-top, 0px) !important;
291
+ padding-bottom: 0 !important;
292
+ padding-bottom: var(--qzsy-safe-bottom, 0px) !important;
293
+ padding-left: 0 !important;
294
+ padding-left: var(--qzsy-safe-left, 0px) !important;
253
295
  z-index: 60 !important;
254
296
  background: var(--dsw-alias-bg-layer-1, #ffffff) !important;
255
297
  border-right: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.08)) !important;
256
298
  box-shadow: none !important;
257
299
  transform: translateX(-102%) !important;
258
- transition: transform 280ms cubic-bezier(0.16, 1, 0.3, 1) !important;
259
300
  overflow: hidden !important;
260
301
  overscroll-behavior: contain;
261
302
  will-change: transform;
262
303
  }
304
+ /* \u9996\u5E27\u5148\u5C31\u4F4D\u3001\u4E0D\u64AD\u653E\u5165\u573A\u52A8\u753B\uFF0C\u907F\u514D\u9875\u9762\u52A0\u8F7D\u65F6\u4FA7\u680F\u4ECE 0 \u6ED1\u51FA */
305
+ body.qzsy-drawer-ready div[class*="_sidebarCol"] {
306
+ transition: transform 280ms cubic-bezier(0.16, 1, 0.3, 1) !important;
307
+ }
263
308
  body.qzsy-drawer-open div[class*="_sidebarCol"] {
264
309
  transform: translateX(0) !important;
265
310
  box-shadow: 8px 0 32px rgba(0, 0, 0, 0.18) !important;
@@ -268,6 +313,17 @@ var MOBILE_DRAWER_CSS = `
268
313
  width: 100% !important;
269
314
  }
270
315
 
316
+ /* \u62D6\u52A8\u4E2D\uFF1A\u5173\u95ED\u8FC7\u6E21\uFF0Ctransform/\u906E\u7F69\u76F4\u63A5\u8DDF\u624B */
317
+ body.qzsy-drawer-dragging div[class*="_sidebarCol"] {
318
+ transition: none !important;
319
+ transform: translateX(var(--qzsy-drawer-x, -102%)) !important;
320
+ }
321
+ body.qzsy-drawer-dragging .qzsy-mobile-backdrop {
322
+ transition: none !important;
323
+ opacity: var(--qzsy-drawer-progress, 0) !important;
324
+ pointer-events: auto !important;
325
+ }
326
+
271
327
  /* 3. \u534A\u900F\u660E\u906E\u7F69\uFF1A\u70B9\u51FB\u6536\u8D77 */
272
328
  .qzsy-mobile-backdrop {
273
329
  display: block;
@@ -286,17 +342,13 @@ var MOBILE_DRAWER_CSS = `
286
342
  pointer-events: auto;
287
343
  }
288
344
 
289
- /* 4. \u9876\u90E8\u83DC\u5355\u6309\u94AE\uFF08\u4F4D\u4E8E 52px \u7559\u767D\u6761\u5185\uFF0C\u907F\u514D\u906E\u6321\u4F1A\u8BDD\u6807\u9898\uFF09 */
345
+ /* 4. \u83DC\u5355\u6309\u94AE\uFF1A\u4F4D\u4E8E\u5B89\u5168\u533A\u4E0B\u65B9\u7684 52px \u7559\u767D\u6761\u5185 */
290
346
  .qzsy-mobile-menu-btn {
291
347
  display: inline-flex;
292
- position: fixed;
293
- top: 10px;
294
- left: 10px;
295
- width: 40px;
296
- height: 40px;
297
- z-index: 65;
298
348
  align-items: center;
299
349
  justify-content: center;
350
+ width: 40px;
351
+ height: 40px;
300
352
  padding: 0;
301
353
  border: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.1));
302
354
  border-radius: 12px;
@@ -304,6 +356,7 @@ var MOBILE_DRAWER_CSS = `
304
356
  color: var(--dsw-alias-label-primary, #111827);
305
357
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
306
358
  cursor: pointer;
359
+ pointer-events: auto;
307
360
  -webkit-tap-highlight-color: transparent;
308
361
  transition: opacity 180ms ease, transform 180ms ease;
309
362
  }
@@ -336,6 +389,10 @@ function injectStyles() {
336
389
  }
337
390
  if (style.textContent !== MOBILE_DRAWER_CSS) style.textContent = MOBILE_DRAWER_CSS;
338
391
  }
392
+ function drawerWidth() {
393
+ if (typeof window === "undefined") return 300;
394
+ return Math.min(300, Math.round(window.innerWidth * 0.84));
395
+ }
339
396
  function setupMobileDrawer() {
340
397
  if (typeof document === "undefined" || typeof window === "undefined") return () => {
341
398
  };
@@ -346,16 +403,24 @@ function setupMobileDrawer() {
346
403
  let menuBtn = null;
347
404
  let observer = null;
348
405
  let disposed = false;
406
+ let gesture = null;
407
+ let suppressClickUntil = 0;
349
408
  const ensureElements = () => {
350
409
  if (disposed || !document.body) return;
351
410
  injectStyles();
352
- let btn = document.querySelector(`.${MENU_CLASS}`);
411
+ let header = document.querySelector(`.${HEADER_CLASS}`);
412
+ if (!header) {
413
+ header = document.createElement("div");
414
+ header.className = HEADER_CLASS;
415
+ document.body.appendChild(header);
416
+ }
417
+ let btn = header.querySelector(`.${MENU_CLASS}`);
353
418
  if (!btn) {
354
419
  btn = document.createElement("button");
355
420
  btn.type = "button";
356
421
  btn.className = MENU_CLASS;
357
422
  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>';
358
- document.body.appendChild(btn);
423
+ header.appendChild(btn);
359
424
  }
360
425
  let mask = document.querySelector(`.${BACKDROP_CLASS}`);
361
426
  if (!mask) {
@@ -386,6 +451,15 @@ function setupMobileDrawer() {
386
451
  }
387
452
  }
388
453
  };
454
+ const setDragProgress = (x, width) => {
455
+ document.body.style.setProperty("--qzsy-drawer-x", `${x}px`);
456
+ document.body.style.setProperty("--qzsy-drawer-progress", String(Math.max(0, Math.min(1, 1 - Math.abs(x) / width))));
457
+ };
458
+ const clearDrag = () => {
459
+ document.body.classList.remove(DRAGGING_CLASS);
460
+ document.body.style.removeProperty("--qzsy-drawer-x");
461
+ document.body.style.removeProperty("--qzsy-drawer-progress");
462
+ };
389
463
  const openDrawer = () => {
390
464
  if (disposed || !isMobileViewport() || !document.body) return;
391
465
  ensureElements();
@@ -411,6 +485,11 @@ function setupMobileDrawer() {
411
485
  }, 60);
412
486
  };
413
487
  const onClick = (event) => {
488
+ if (Date.now() < suppressClickUntil) {
489
+ event.preventDefault();
490
+ event.stopPropagation();
491
+ return;
492
+ }
414
493
  const target = event.target;
415
494
  if (!isElement(target)) return;
416
495
  if (target.closest(`.${MENU_CLASS}`)) {
@@ -447,10 +526,106 @@ function setupMobileDrawer() {
447
526
  if (event.key === "Escape" && isOpen()) closeDrawer();
448
527
  };
449
528
  const onResize = () => {
450
- if (!isMobileViewport() && isOpen()) closeDrawer({ restoreFocus: false });
529
+ if (!isMobileViewport() && isOpen()) {
530
+ gesture = null;
531
+ clearDrag();
532
+ closeDrawer({ restoreFocus: false });
533
+ }
451
534
  };
452
535
  const onVisibilityChange = () => {
453
- if (document.visibilityState === "hidden" && isOpen()) closeDrawer({ restoreFocus: false });
536
+ if (document.visibilityState === "hidden" && isOpen()) {
537
+ gesture = null;
538
+ clearDrag();
539
+ closeDrawer({ restoreFocus: false });
540
+ }
541
+ };
542
+ const onTouchStart = (event) => {
543
+ if (disposed || !isMobileViewport() || event.touches?.length !== 1) return;
544
+ const touch = event.touches[0];
545
+ const sidebar = document.querySelector('div[class*="_sidebarCol"]');
546
+ if (!sidebar) return;
547
+ const open = isOpen();
548
+ if (!open) {
549
+ if (touch.clientX > EDGE_START_ZONE) return;
550
+ ensureSidebarExpanded();
551
+ gesture = {
552
+ mode: "open",
553
+ startX: touch.clientX,
554
+ startY: touch.clientY,
555
+ startT: Date.now(),
556
+ width: drawerWidth(),
557
+ active: false,
558
+ dx: 0
559
+ };
560
+ return;
561
+ }
562
+ if (!sidebar.contains(event.target)) return;
563
+ if (event.target.closest?.('input, textarea, select, [contenteditable="true"]')) return;
564
+ gesture = {
565
+ mode: "close",
566
+ startX: touch.clientX,
567
+ startY: touch.clientY,
568
+ startT: Date.now(),
569
+ width: drawerWidth(),
570
+ active: false,
571
+ dx: 0
572
+ };
573
+ };
574
+ const onTouchMove = (event) => {
575
+ if (!gesture || disposed) return;
576
+ const touch = event.touches?.[0];
577
+ if (!touch) return;
578
+ const dx = touch.clientX - gesture.startX;
579
+ const dy = touch.clientY - gesture.startY;
580
+ if (!gesture.active) {
581
+ if (Math.abs(dx) < DRAG_THRESHOLD) return;
582
+ if (Math.abs(dx) < Math.abs(dy) * 1.2) {
583
+ gesture = null;
584
+ return;
585
+ }
586
+ if (gesture.mode === "open" && dx <= 0) {
587
+ gesture = null;
588
+ return;
589
+ }
590
+ if (gesture.mode === "close" && dx >= 0) {
591
+ gesture = null;
592
+ return;
593
+ }
594
+ gesture.active = true;
595
+ suppressClickUntil = Date.now() + 450;
596
+ document.body.classList.add(DRAGGING_CLASS);
597
+ }
598
+ const width = Math.max(1, gesture.width);
599
+ let x;
600
+ if (gesture.mode === "open") x = Math.max(-width, Math.min(0, -width + dx));
601
+ else x = Math.max(-width, Math.min(0, dx));
602
+ gesture.dx = dx;
603
+ setDragProgress(x, width);
604
+ };
605
+ const finishGesture = (touch) => {
606
+ if (!gesture) return;
607
+ const current = gesture;
608
+ gesture = null;
609
+ if (!current.active) return;
610
+ const endX = touch ? touch.clientX : current.startX + current.dx;
611
+ const dx = endX - current.startX;
612
+ const elapsed = Math.max(1, Date.now() - current.startT);
613
+ const velocity = dx / elapsed;
614
+ const threshold = Math.min(90, Math.max(60, current.width * 0.3));
615
+ const openNow = current.mode === "open" ? dx > threshold || velocity > 0.45 : !(dx < -Math.max(50, threshold * 0.7) || velocity < -0.35);
616
+ document.body.classList.toggle(OPEN_CLASS, openNow);
617
+ if (openNow) ensureSidebarExpanded();
618
+ syncAria();
619
+ suppressClickUntil = Date.now() + 250;
620
+ window.requestAnimationFrame(clearDrag);
621
+ };
622
+ const onTouchEnd = (event) => {
623
+ if (!gesture) return;
624
+ finishGesture(event.changedTouches?.[0]);
625
+ };
626
+ const onTouchCancel = () => {
627
+ if (!gesture) return;
628
+ finishGesture(null);
454
629
  };
455
630
  injectStyles();
456
631
  ensureElements();
@@ -458,9 +633,16 @@ function setupMobileDrawer() {
458
633
  document.addEventListener("keydown", onKeyDown, true);
459
634
  document.addEventListener("visibilitychange", onVisibilityChange);
460
635
  window.addEventListener("resize", onResize);
636
+ window.addEventListener("touchstart", onTouchStart, { passive: true });
637
+ window.addEventListener("touchmove", onTouchMove, { passive: true });
638
+ window.addEventListener("touchend", onTouchEnd, { passive: true });
639
+ window.addEventListener("touchcancel", onTouchCancel, { passive: true });
640
+ window.requestAnimationFrame(() => {
641
+ if (!disposed && document.body) document.body.classList.add("qzsy-drawer-ready");
642
+ });
461
643
  if (typeof MutationObserver === "function" && document.body) {
462
644
  observer = new MutationObserver(() => {
463
- if (!document.querySelector(`.${MENU_CLASS}`) || !document.querySelector(`.${BACKDROP_CLASS}`) || !document.getElementById(MOBILE_DRAWER_CSS_ID)) {
645
+ if (!document.querySelector(`.${HEADER_CLASS}`) || !document.querySelector(`.${MENU_CLASS}`) || !document.querySelector(`.${BACKDROP_CLASS}`) || !document.getElementById(MOBILE_DRAWER_CSS_ID)) {
464
646
  ensureElements();
465
647
  }
466
648
  });
@@ -469,6 +651,7 @@ function setupMobileDrawer() {
469
651
  syncAria();
470
652
  return () => {
471
653
  disposed = true;
654
+ gesture = null;
472
655
  try {
473
656
  observer?.disconnect();
474
657
  } catch {
@@ -477,8 +660,14 @@ function setupMobileDrawer() {
477
660
  document.removeEventListener("keydown", onKeyDown, true);
478
661
  document.removeEventListener("visibilitychange", onVisibilityChange);
479
662
  window.removeEventListener("resize", onResize);
663
+ window.removeEventListener("touchstart", onTouchStart);
664
+ window.removeEventListener("touchmove", onTouchMove);
665
+ window.removeEventListener("touchend", onTouchEnd);
666
+ window.removeEventListener("touchcancel", onTouchCancel);
667
+ clearDrag();
480
668
  document.body?.classList.remove(OPEN_CLASS);
481
- document.querySelector(`.${MENU_CLASS}`)?.remove();
669
+ document.body?.classList.remove("qzsy-drawer-ready");
670
+ document.querySelector(`.${HEADER_CLASS}`)?.remove();
482
671
  document.querySelector(`.${BACKDROP_CLASS}`)?.remove();
483
672
  document.getElementById(MOBILE_DRAWER_CSS_ID)?.remove();
484
673
  root.removeAttribute(MOBILE_UI_FLAG);
package/client/mobile.js CHANGED
@@ -1,42 +1,78 @@
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,
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,
24
36
  .qzsy-mobile-backdrop {
25
37
  display: none;
26
38
  }
27
39
 
40
+ /* 菜单按钮容器:始终在宿主之上;容器自身不拦截点击 */
41
+ .qzsy-mobile-header {
42
+ position: fixed;
43
+ top: 0;
44
+ left: 0;
45
+ right: 0;
46
+ z-index: 65;
47
+ box-sizing: border-box;
48
+ align-items: flex-end;
49
+ padding-top: 10px;
50
+ padding-top: calc(10px + var(--qzsy-safe-top, 0px));
51
+ padding-right: 10px;
52
+ padding-right: calc(10px + var(--qzsy-safe-right, 0px));
53
+ padding-bottom: 2px;
54
+ padding-left: 10px;
55
+ padding-left: calc(10px + var(--qzsy-safe-left, 0px));
56
+ pointer-events: none;
57
+ }
58
+
28
59
  @media (max-width: 767px) {
29
- /* 1. 左侧栏脱离 grid,内容占满;顶部留出 52px 给菜单按钮 */
60
+ .qzsy-mobile-header { display: flex; }
61
+
62
+ /* 1. 左侧栏脱离 grid,内容占满;顶部给菜单按钮留出安全区高度 */
30
63
  [data-slot="root"] > div[class*="_frame"] {
31
64
  grid-template-columns: 0px minmax(0px, 1fr) minmax(0px, 0px) !important;
32
- padding-top: 52px !important;
33
65
  box-sizing: border-box !important;
66
+ padding-top: 52px !important;
67
+ padding-top: calc(52px + var(--qzsy-safe-top, 0px)) !important;
68
+ padding-bottom: 0 !important;
69
+ padding-bottom: var(--qzsy-safe-bottom, 0px) !important;
34
70
  }
35
71
  div[class*="_centerCol"] { grid-column: 2 !important; }
36
72
  div[class*="_rightbarCol"] { grid-column: 3 !important; }
37
73
  div[class*="_overlayLayer"] { grid-column: 1 / -1 !important; }
38
74
 
39
- /* 2. 原生侧栏改为 fixed 抽屉(默认藏在屏幕左侧) */
75
+ /* 2. 原生侧栏改为 fixed 抽屉(默认藏在屏幕左侧;内部避开安全区) */
40
76
  div[class*="_sidebarCol"] {
41
77
  position: fixed !important;
42
78
  top: 0 !important;
@@ -46,16 +82,26 @@ export const MOBILE_DRAWER_CSS = `
46
82
  max-width: 84vw !important;
47
83
  height: 100vh !important;
48
84
  height: 100dvh !important;
85
+ box-sizing: border-box !important;
86
+ padding-top: 0 !important;
87
+ padding-top: var(--qzsy-safe-top, 0px) !important;
88
+ padding-bottom: 0 !important;
89
+ padding-bottom: var(--qzsy-safe-bottom, 0px) !important;
90
+ padding-left: 0 !important;
91
+ padding-left: var(--qzsy-safe-left, 0px) !important;
49
92
  z-index: 60 !important;
50
93
  background: var(--dsw-alias-bg-layer-1, #ffffff) !important;
51
94
  border-right: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.08)) !important;
52
95
  box-shadow: none !important;
53
96
  transform: translateX(-102%) !important;
54
- transition: transform 280ms cubic-bezier(0.16, 1, 0.3, 1) !important;
55
97
  overflow: hidden !important;
56
98
  overscroll-behavior: contain;
57
99
  will-change: transform;
58
100
  }
101
+ /* 首帧先就位、不播放入场动画,避免页面加载时侧栏从 0 滑出 */
102
+ body.qzsy-drawer-ready div[class*="_sidebarCol"] {
103
+ transition: transform 280ms cubic-bezier(0.16, 1, 0.3, 1) !important;
104
+ }
59
105
  body.qzsy-drawer-open div[class*="_sidebarCol"] {
60
106
  transform: translateX(0) !important;
61
107
  box-shadow: 8px 0 32px rgba(0, 0, 0, 0.18) !important;
@@ -64,6 +110,17 @@ export const MOBILE_DRAWER_CSS = `
64
110
  width: 100% !important;
65
111
  }
66
112
 
113
+ /* 拖动中:关闭过渡,transform/遮罩直接跟手 */
114
+ body.qzsy-drawer-dragging div[class*="_sidebarCol"] {
115
+ transition: none !important;
116
+ transform: translateX(var(--qzsy-drawer-x, -102%)) !important;
117
+ }
118
+ body.qzsy-drawer-dragging .qzsy-mobile-backdrop {
119
+ transition: none !important;
120
+ opacity: var(--qzsy-drawer-progress, 0) !important;
121
+ pointer-events: auto !important;
122
+ }
123
+
67
124
  /* 3. 半透明遮罩:点击收起 */
68
125
  .qzsy-mobile-backdrop {
69
126
  display: block;
@@ -82,17 +139,13 @@ export const MOBILE_DRAWER_CSS = `
82
139
  pointer-events: auto;
83
140
  }
84
141
 
85
- /* 4. 顶部菜单按钮(位于 52px 留白条内,避免遮挡会话标题) */
142
+ /* 4. 菜单按钮:位于安全区下方的 52px 留白条内 */
86
143
  .qzsy-mobile-menu-btn {
87
144
  display: inline-flex;
88
- position: fixed;
89
- top: 10px;
90
- left: 10px;
91
- width: 40px;
92
- height: 40px;
93
- z-index: 65;
94
145
  align-items: center;
95
146
  justify-content: center;
147
+ width: 40px;
148
+ height: 40px;
96
149
  padding: 0;
97
150
  border: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.1));
98
151
  border-radius: 12px;
@@ -100,6 +153,7 @@ export const MOBILE_DRAWER_CSS = `
100
153
  color: var(--dsw-alias-label-primary, #111827);
101
154
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
102
155
  cursor: pointer;
156
+ pointer-events: auto;
103
157
  -webkit-tap-highlight-color: transparent;
104
158
  transition: opacity 180ms ease, transform 180ms ease;
105
159
  }
@@ -137,6 +191,11 @@ function injectStyles() {
137
191
  if (style.textContent !== MOBILE_DRAWER_CSS) style.textContent = MOBILE_DRAWER_CSS;
138
192
  }
139
193
 
194
+ function drawerWidth() {
195
+ if (typeof window === 'undefined') return 300;
196
+ return Math.min(300, Math.round(window.innerWidth * 0.84));
197
+ }
198
+
140
199
  /**
141
200
  * 安装移动端抽屉导航。无 DOM 环境(单测/SSR)安全退化为 no-op。
142
201
  * 返回 disposer;宿主若支持 ctx.effect,可注册为插件副作用。
@@ -150,18 +209,26 @@ export function setupMobileDrawer() {
150
209
  let menuBtn = null;
151
210
  let observer = null;
152
211
  let disposed = false;
212
+ let gesture = null;
213
+ let suppressClickUntil = 0;
153
214
 
154
215
  const ensureElements = () => {
155
216
  if (disposed || !document.body) return;
156
217
  injectStyles();
157
218
 
158
- let btn = document.querySelector(`.${MENU_CLASS}`);
219
+ let header = document.querySelector(`.${HEADER_CLASS}`);
220
+ if (!header) {
221
+ header = document.createElement('div');
222
+ header.className = HEADER_CLASS;
223
+ document.body.appendChild(header);
224
+ }
225
+ let btn = header.querySelector(`.${MENU_CLASS}`);
159
226
  if (!btn) {
160
227
  btn = document.createElement('button');
161
228
  btn.type = 'button';
162
229
  btn.className = MENU_CLASS;
163
230
  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);
231
+ header.appendChild(btn);
165
232
  }
166
233
  let mask = document.querySelector(`.${BACKDROP_CLASS}`);
167
234
  if (!mask) {
@@ -194,6 +261,17 @@ export function setupMobileDrawer() {
194
261
  }
195
262
  };
196
263
 
264
+ const setDragProgress = (x, width) => {
265
+ document.body.style.setProperty('--qzsy-drawer-x', `${x}px`);
266
+ document.body.style.setProperty('--qzsy-drawer-progress', String(Math.max(0, Math.min(1, 1 - Math.abs(x) / width))));
267
+ };
268
+
269
+ const clearDrag = () => {
270
+ document.body.classList.remove(DRAGGING_CLASS);
271
+ document.body.style.removeProperty('--qzsy-drawer-x');
272
+ document.body.style.removeProperty('--qzsy-drawer-progress');
273
+ };
274
+
197
275
  const openDrawer = () => {
198
276
  if (disposed || !isMobileViewport() || !document.body) return;
199
277
  ensureElements();
@@ -219,6 +297,11 @@ export function setupMobileDrawer() {
219
297
  };
220
298
 
221
299
  const onClick = (event) => {
300
+ if (Date.now() < suppressClickUntil) {
301
+ event.preventDefault();
302
+ event.stopPropagation();
303
+ return;
304
+ }
222
305
  const target = event.target;
223
306
  if (!isElement(target)) return;
224
307
 
@@ -265,11 +348,115 @@ export function setupMobileDrawer() {
265
348
  };
266
349
 
267
350
  const onResize = () => {
268
- if (!isMobileViewport() && isOpen()) closeDrawer({ restoreFocus: false });
351
+ if (!isMobileViewport() && isOpen()) {
352
+ gesture = null;
353
+ clearDrag();
354
+ closeDrawer({ restoreFocus: false });
355
+ }
269
356
  };
270
357
 
271
358
  const onVisibilityChange = () => {
272
- if (document.visibilityState === 'hidden' && isOpen()) closeDrawer({ restoreFocus: false });
359
+ if (document.visibilityState === 'hidden' && isOpen()) {
360
+ gesture = null;
361
+ clearDrag();
362
+ closeDrawer({ restoreFocus: false });
363
+ }
364
+ };
365
+
366
+ // ---- 手势:左缘右滑打开;抽屉内左滑关闭;拖动跟手 ----
367
+ const onTouchStart = (event) => {
368
+ if (disposed || !isMobileViewport() || event.touches?.length !== 1) return;
369
+ const touch = event.touches[0];
370
+ const sidebar = document.querySelector('div[class*="_sidebarCol"]');
371
+ if (!sidebar) return;
372
+ const open = isOpen();
373
+
374
+ if (!open) {
375
+ if (touch.clientX > EDGE_START_ZONE) return;
376
+ // 先把宿主侧栏展开,保证拖动时抽屉里有完整会话列表
377
+ ensureSidebarExpanded();
378
+ gesture = {
379
+ mode: 'open',
380
+ startX: touch.clientX,
381
+ startY: touch.clientY,
382
+ startT: Date.now(),
383
+ width: drawerWidth(),
384
+ active: false,
385
+ dx: 0,
386
+ };
387
+ return;
388
+ }
389
+
390
+ if (!sidebar.contains(event.target)) return;
391
+ if (event.target.closest?.('input, textarea, select, [contenteditable="true"]')) return;
392
+ gesture = {
393
+ mode: 'close',
394
+ startX: touch.clientX,
395
+ startY: touch.clientY,
396
+ startT: Date.now(),
397
+ width: drawerWidth(),
398
+ active: false,
399
+ dx: 0,
400
+ };
401
+ };
402
+
403
+ const onTouchMove = (event) => {
404
+ if (!gesture || disposed) return;
405
+ const touch = event.touches?.[0];
406
+ if (!touch) return;
407
+ const dx = touch.clientX - gesture.startX;
408
+ const dy = touch.clientY - gesture.startY;
409
+
410
+ if (!gesture.active) {
411
+ if (Math.abs(dx) < DRAG_THRESHOLD) return;
412
+ // 纵向为主:交还给宿主滚动,本次不拦截
413
+ if (Math.abs(dx) < Math.abs(dy) * 1.2) { gesture = null; return; }
414
+ if (gesture.mode === 'open' && dx <= 0) { gesture = null; return; }
415
+ if (gesture.mode === 'close' && dx >= 0) { gesture = null; return; }
416
+ gesture.active = true;
417
+ suppressClickUntil = Date.now() + 450;
418
+ document.body.classList.add(DRAGGING_CLASS);
419
+ }
420
+
421
+ const width = Math.max(1, gesture.width);
422
+ let x;
423
+ if (gesture.mode === 'open') x = Math.max(-width, Math.min(0, -width + dx));
424
+ else x = Math.max(-width, Math.min(0, dx));
425
+ gesture.dx = dx;
426
+ setDragProgress(x, width);
427
+ };
428
+
429
+ const finishGesture = (touch) => {
430
+ if (!gesture) return;
431
+ const current = gesture;
432
+ gesture = null;
433
+ if (!current.active) return;
434
+
435
+ const endX = touch ? touch.clientX : current.startX + current.dx;
436
+ const dx = endX - current.startX;
437
+ const elapsed = Math.max(1, Date.now() - current.startT);
438
+ const velocity = dx / elapsed;
439
+ const threshold = Math.min(90, Math.max(60, current.width * 0.3));
440
+ const openNow = current.mode === 'open'
441
+ ? dx > threshold || velocity > 0.45
442
+ : !(dx < -Math.max(50, threshold * 0.7) || velocity < -0.35);
443
+
444
+ document.body.classList.toggle(OPEN_CLASS, openNow);
445
+ if (openNow) ensureSidebarExpanded();
446
+ syncAria();
447
+ suppressClickUntil = Date.now() + 250;
448
+ // 等一帧让浏览器接住当前拖动位置,再撤掉拖动样式做吸附动画
449
+ window.requestAnimationFrame(clearDrag);
450
+ };
451
+
452
+ const onTouchEnd = (event) => {
453
+ if (!gesture) return;
454
+ finishGesture(event.changedTouches?.[0]);
455
+ };
456
+
457
+ const onTouchCancel = () => {
458
+ if (!gesture) return;
459
+ finishGesture(null);
273
460
  };
274
461
 
275
462
  injectStyles();
@@ -278,10 +465,19 @@ export function setupMobileDrawer() {
278
465
  document.addEventListener('keydown', onKeyDown, true);
279
466
  document.addEventListener('visibilitychange', onVisibilityChange);
280
467
  window.addEventListener('resize', onResize);
468
+ window.addEventListener('touchstart', onTouchStart, { passive: true });
469
+ window.addEventListener('touchmove', onTouchMove, { passive: true });
470
+ window.addEventListener('touchend', onTouchEnd, { passive: true });
471
+ window.addEventListener('touchcancel', onTouchCancel, { passive: true });
472
+
473
+ // 等侧栏先无动画地藏到屏幕外,再启用过渡(否则首次加载会看到 280ms 滑出)
474
+ window.requestAnimationFrame(() => {
475
+ if (!disposed && document.body) document.body.classList.add('qzsy-drawer-ready');
476
+ });
281
477
 
282
478
  if (typeof MutationObserver === 'function' && document.body) {
283
479
  observer = new MutationObserver(() => {
284
- if (!document.querySelector(`.${MENU_CLASS}`) || !document.querySelector(`.${BACKDROP_CLASS}`) || !document.getElementById(MOBILE_DRAWER_CSS_ID)) {
480
+ if (!document.querySelector(`.${HEADER_CLASS}`) || !document.querySelector(`.${MENU_CLASS}`) || !document.querySelector(`.${BACKDROP_CLASS}`) || !document.getElementById(MOBILE_DRAWER_CSS_ID)) {
285
481
  ensureElements();
286
482
  }
287
483
  });
@@ -292,13 +488,20 @@ export function setupMobileDrawer() {
292
488
 
293
489
  return () => {
294
490
  disposed = true;
491
+ gesture = null;
295
492
  try { observer?.disconnect(); } catch { /* 忽略 */ }
296
493
  document.removeEventListener('click', onClick, true);
297
494
  document.removeEventListener('keydown', onKeyDown, true);
298
495
  document.removeEventListener('visibilitychange', onVisibilityChange);
299
496
  window.removeEventListener('resize', onResize);
497
+ window.removeEventListener('touchstart', onTouchStart);
498
+ window.removeEventListener('touchmove', onTouchMove);
499
+ window.removeEventListener('touchend', onTouchEnd);
500
+ window.removeEventListener('touchcancel', onTouchCancel);
501
+ clearDrag();
300
502
  document.body?.classList.remove(OPEN_CLASS);
301
- document.querySelector(`.${MENU_CLASS}`)?.remove();
503
+ document.body?.classList.remove('qzsy-drawer-ready');
504
+ document.querySelector(`.${HEADER_CLASS}`)?.remove();
302
505
  document.querySelector(`.${BACKDROP_CLASS}`)?.remove();
303
506
  document.getElementById(MOBILE_DRAWER_CSS_ID)?.remove();
304
507
  root.removeAttribute(MOBILE_UI_FLAG);
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@qzsy/dsh-bridge",
3
- "version": "3.3.0",
3
+ "version": "3.3.1",
4
4
  "description": "DSH 远程隧道云官方客户端插件:一条命令把本机 DeepSeek Harness 接入云端隧道(设备码绑定、短期令牌、本地门禁代理、HTTP/SSE/WebSocket 反向隧道)。",
5
- "releaseNotes": "【v3.3.0】移动端抽屉导航\\n• 手机访问时左侧栏改为从左侧滑出的 sheet,内容区占满屏幕,不再被 56px 图标栏挤窄\\n• 左上角新增菜单按钮:打开会话/工作区/插件入口,点遮罩、会话、Esc 或转回桌面自动收起\\n• 抽屉带 52px 顶部安全留白与 280ms 缓动,桌面端完全不受影响\\n• 宿主重渲染移除节点时自动补回",
5
+ "releaseNotes": "【v3.3.1】移动端安全区与手势修复\\n• 全面屏:菜单按钮/内容/抽屉按 safe-area-inset 让位,刘海、状态栏、home indicator 不再遮挡\\n• 手势:左缘右滑打开、抽屉内左滑关闭;拖动跟手、遮罩渐变,松手按距离/速度吸附\\n• 纵向滚动不误触抽屉;滑动后的合成点击会被吞掉\\n• 首次加载不再播放侧栏滑出动画",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",
8
8
  "exports": {