@qzsy/dsh-bridge 3.2.0 → 3.3.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.
@@ -0,0 +1,306 @@
1
+ // 移动端抽屉导航:把宿主左侧栏(会话/工作区菜单)改成从左侧滑出的 sheet。
2
+ //
3
+ // 背景:DSH 宿主在窄屏下仍把左侧栏压成 56px 图标轨道,展开后会把内容区挤到
4
+ // 110px(实测 390×844)。这里只做一件事:窄屏时内容占满,左侧栏变成带遮罩的
5
+ // 抽屉,由顶部菜单按钮打开;桌面端完全不受影响。
6
+ //
7
+ // 宿主 DOM 是 CSS Modules 哈希类名,本模块只依赖稳定结构/ARIA:
8
+ // div[class*="_frame"] / _sidebarCol / _centerCol / _rightbarCol / _overlayLayer
9
+ // button[aria-label*="打开侧边栏"](英文 Open sidebar)
10
+ // div[class*="sessionRow"](会话行;projectRow 是工作区分组,不能误关)
11
+ // 不写死任何宿主哈希,宿主升级换前缀时选择器仍有效。
12
+ import { createTranslator } from './locales.mjs';
13
+
14
+ export const MOBILE_MAX_WIDTH = 767;
15
+ export const MOBILE_DRAWER_CSS_ID = 'qzsy-mobile-drawer-styles';
16
+ export const MOBILE_UI_FLAG = 'data-qzsy-mobile-drawer';
17
+ const OPEN_CLASS = 'qzsy-drawer-open';
18
+ const MENU_CLASS = 'qzsy-mobile-menu-btn';
19
+ const BACKDROP_CLASS = 'qzsy-mobile-backdrop';
20
+ const STYLE_PLUGIN = '@qzsy/dsh-bridge';
21
+
22
+ export const MOBILE_DRAWER_CSS = `
23
+ .qzsy-mobile-menu-btn,
24
+ .qzsy-mobile-backdrop {
25
+ display: none;
26
+ }
27
+
28
+ @media (max-width: 767px) {
29
+ /* 1. 左侧栏脱离 grid,内容占满;顶部留出 52px 给菜单按钮 */
30
+ [data-slot="root"] > div[class*="_frame"] {
31
+ grid-template-columns: 0px minmax(0px, 1fr) minmax(0px, 0px) !important;
32
+ padding-top: 52px !important;
33
+ box-sizing: border-box !important;
34
+ }
35
+ div[class*="_centerCol"] { grid-column: 2 !important; }
36
+ div[class*="_rightbarCol"] { grid-column: 3 !important; }
37
+ div[class*="_overlayLayer"] { grid-column: 1 / -1 !important; }
38
+
39
+ /* 2. 原生侧栏改为 fixed 抽屉(默认藏在屏幕左侧) */
40
+ div[class*="_sidebarCol"] {
41
+ position: fixed !important;
42
+ top: 0 !important;
43
+ left: 0 !important;
44
+ bottom: 0 !important;
45
+ width: min(300px, 84vw) !important;
46
+ max-width: 84vw !important;
47
+ height: 100vh !important;
48
+ height: 100dvh !important;
49
+ z-index: 60 !important;
50
+ background: var(--dsw-alias-bg-layer-1, #ffffff) !important;
51
+ border-right: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.08)) !important;
52
+ box-shadow: none !important;
53
+ transform: translateX(-102%) !important;
54
+ transition: transform 280ms cubic-bezier(0.16, 1, 0.3, 1) !important;
55
+ overflow: hidden !important;
56
+ overscroll-behavior: contain;
57
+ will-change: transform;
58
+ }
59
+ body.qzsy-drawer-open div[class*="_sidebarCol"] {
60
+ transform: translateX(0) !important;
61
+ box-shadow: 8px 0 32px rgba(0, 0, 0, 0.18) !important;
62
+ }
63
+ body.qzsy-drawer-open div[class*="_sidebarCol"] > div > div[class*="_root"] {
64
+ width: 100% !important;
65
+ }
66
+
67
+ /* 3. 半透明遮罩:点击收起 */
68
+ .qzsy-mobile-backdrop {
69
+ display: block;
70
+ position: fixed;
71
+ inset: 0;
72
+ z-index: 55;
73
+ background: rgba(0, 0, 0, 0.42);
74
+ opacity: 0;
75
+ pointer-events: none;
76
+ transition: opacity 280ms ease;
77
+ touch-action: none;
78
+ -webkit-tap-highlight-color: transparent;
79
+ }
80
+ body.qzsy-drawer-open .qzsy-mobile-backdrop {
81
+ opacity: 1;
82
+ pointer-events: auto;
83
+ }
84
+
85
+ /* 4. 顶部菜单按钮(位于 52px 留白条内,避免遮挡会话标题) */
86
+ .qzsy-mobile-menu-btn {
87
+ display: inline-flex;
88
+ position: fixed;
89
+ top: 10px;
90
+ left: 10px;
91
+ width: 40px;
92
+ height: 40px;
93
+ z-index: 65;
94
+ align-items: center;
95
+ justify-content: center;
96
+ padding: 0;
97
+ border: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.1));
98
+ border-radius: 12px;
99
+ background: var(--dsw-alias-bg-layer-1, #ffffff);
100
+ color: var(--dsw-alias-label-primary, #111827);
101
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
102
+ cursor: pointer;
103
+ -webkit-tap-highlight-color: transparent;
104
+ transition: opacity 180ms ease, transform 180ms ease;
105
+ }
106
+ .qzsy-mobile-menu-btn:active { transform: scale(0.94); }
107
+ .qzsy-mobile-menu-btn svg { display: block; }
108
+ body.qzsy-drawer-open .qzsy-mobile-menu-btn {
109
+ opacity: 0;
110
+ pointer-events: none;
111
+ }
112
+ }
113
+ `;
114
+
115
+ function isElement(value) {
116
+ return Boolean(value) && value.nodeType === 1;
117
+ }
118
+
119
+ function isMobileViewport() {
120
+ return typeof window !== 'undefined' && Number(window.innerWidth) <= MOBILE_MAX_WIDTH;
121
+ }
122
+
123
+ function menuLabel(open) {
124
+ const t = createTranslator({});
125
+ return open ? t('menuClose') : t('menuOpen');
126
+ }
127
+
128
+ function injectStyles() {
129
+ let style = document.getElementById(MOBILE_DRAWER_CSS_ID);
130
+ if (!style) {
131
+ style = document.createElement('style');
132
+ style.id = MOBILE_DRAWER_CSS_ID;
133
+ style.dataset.plugin = STYLE_PLUGIN;
134
+ style.dataset.pluginCss = `${STYLE_PLUGIN}/mobile`;
135
+ document.head.appendChild(style);
136
+ }
137
+ if (style.textContent !== MOBILE_DRAWER_CSS) style.textContent = MOBILE_DRAWER_CSS;
138
+ }
139
+
140
+ /**
141
+ * 安装移动端抽屉导航。无 DOM 环境(单测/SSR)安全退化为 no-op。
142
+ * 返回 disposer;宿主若支持 ctx.effect,可注册为插件副作用。
143
+ */
144
+ export function setupMobileDrawer() {
145
+ if (typeof document === 'undefined' || typeof window === 'undefined') return () => {};
146
+ const root = document.documentElement;
147
+ if (!root || root.getAttribute(MOBILE_UI_FLAG) === '1') return () => {};
148
+ root.setAttribute(MOBILE_UI_FLAG, '1');
149
+
150
+ let menuBtn = null;
151
+ let observer = null;
152
+ let disposed = false;
153
+
154
+ const ensureElements = () => {
155
+ if (disposed || !document.body) return;
156
+ injectStyles();
157
+
158
+ let btn = document.querySelector(`.${MENU_CLASS}`);
159
+ if (!btn) {
160
+ btn = document.createElement('button');
161
+ btn.type = 'button';
162
+ btn.className = MENU_CLASS;
163
+ 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);
165
+ }
166
+ let mask = document.querySelector(`.${BACKDROP_CLASS}`);
167
+ if (!mask) {
168
+ mask = document.createElement('div');
169
+ mask.className = BACKDROP_CLASS;
170
+ mask.setAttribute('aria-hidden', 'true');
171
+ document.body.appendChild(mask);
172
+ }
173
+ menuBtn = btn;
174
+ syncAria();
175
+ };
176
+
177
+ const isOpen = () => Boolean(document.body?.classList.contains(OPEN_CLASS));
178
+
179
+ const syncAria = () => {
180
+ if (!menuBtn) return;
181
+ const label = menuLabel(isOpen());
182
+ if (menuBtn.getAttribute('aria-label') !== label) menuBtn.setAttribute('aria-label', label);
183
+ if (menuBtn.getAttribute('title') !== label) menuBtn.setAttribute('title', label);
184
+ menuBtn.setAttribute('aria-expanded', isOpen() ? 'true' : 'false');
185
+ };
186
+
187
+ /** 宿主侧栏收起时先点它自己的「打开侧边栏」,让抽屉里有会话列表。 */
188
+ const ensureSidebarExpanded = () => {
189
+ const expand = document.querySelector(
190
+ 'button[aria-label*="打开侧边栏"], button[title*="打开侧边栏"], button[aria-label*="Open sidebar"], button[title*="Open sidebar"], div[class*="collapsed"] button[class*="toggle"]',
191
+ );
192
+ if (expand) {
193
+ try { expand.click(); } catch { /* 宿主没挂载完时忽略,下次打开再试 */ }
194
+ }
195
+ };
196
+
197
+ const openDrawer = () => {
198
+ if (disposed || !isMobileViewport() || !document.body) return;
199
+ ensureElements();
200
+ ensureSidebarExpanded();
201
+ document.body.classList.add(OPEN_CLASS);
202
+ syncAria();
203
+ };
204
+
205
+ const closeDrawer = ({ restoreFocus = true } = {}) => {
206
+ if (!document.body) return;
207
+ const wasOpen = isOpen();
208
+ document.body.classList.remove(OPEN_CLASS);
209
+ syncAria();
210
+ if (wasOpen && restoreFocus) {
211
+ try { menuBtn?.focus?.({ preventScroll: true }); } catch { /* 忽略焦点异常 */ }
212
+ }
213
+ };
214
+
215
+ const closeSoon = () => {
216
+ window.setTimeout(() => {
217
+ if (!disposed && isOpen()) closeDrawer({ restoreFocus: false });
218
+ }, 60);
219
+ };
220
+
221
+ const onClick = (event) => {
222
+ const target = event.target;
223
+ if (!isElement(target)) return;
224
+
225
+ if (target.closest(`.${MENU_CLASS}`)) {
226
+ event.preventDefault();
227
+ if (isOpen()) closeDrawer();
228
+ else openDrawer();
229
+ return;
230
+ }
231
+ if (!isOpen()) return;
232
+
233
+ if (target.closest(`.${BACKDROP_CLASS}`)) {
234
+ closeDrawer();
235
+ return;
236
+ }
237
+
238
+ const sidebar = document.querySelector('div[class*="_sidebarCol"]');
239
+ if (!sidebar || !sidebar.contains(target)) return;
240
+ if (target.closest('input, textarea, select, [contenteditable="true"]')) return;
241
+
242
+ // 会话行点击后收起抽屉;行内的操作按钮(归档/置顶等)不收起
243
+ const sessionRow = target.closest('div[class*="sessionRow"]');
244
+ if (sessionRow && !target.closest('button, [role="button"]')) {
245
+ closeSoon();
246
+ return;
247
+ }
248
+
249
+ const action = target.closest('button, [role="button"], a');
250
+ if (!action) return;
251
+ const label = `${action.getAttribute('aria-label') || ''} ${action.getAttribute('title') || ''} ${action.innerText || ''}`;
252
+ if (/新建会话|New session|收起侧边栏|Collapse sidebar/.test(label)) {
253
+ closeSoon();
254
+ return;
255
+ }
256
+ // 顶部一级菜单(插件/自动化任务/命令面板/…)打开新面板后收起抽屉;
257
+ // 「更多」往往是在抽屉内展开二级菜单,保持打开。
258
+ if (action.closest('button[class*="panelRow"]') && !/更多|More/.test(label)) {
259
+ closeSoon();
260
+ }
261
+ };
262
+
263
+ const onKeyDown = (event) => {
264
+ if (event.key === 'Escape' && isOpen()) closeDrawer();
265
+ };
266
+
267
+ const onResize = () => {
268
+ if (!isMobileViewport() && isOpen()) closeDrawer({ restoreFocus: false });
269
+ };
270
+
271
+ const onVisibilityChange = () => {
272
+ if (document.visibilityState === 'hidden' && isOpen()) closeDrawer({ restoreFocus: false });
273
+ };
274
+
275
+ injectStyles();
276
+ ensureElements();
277
+ document.addEventListener('click', onClick, true);
278
+ document.addEventListener('keydown', onKeyDown, true);
279
+ document.addEventListener('visibilitychange', onVisibilityChange);
280
+ window.addEventListener('resize', onResize);
281
+
282
+ if (typeof MutationObserver === 'function' && document.body) {
283
+ observer = new MutationObserver(() => {
284
+ if (!document.querySelector(`.${MENU_CLASS}`) || !document.querySelector(`.${BACKDROP_CLASS}`) || !document.getElementById(MOBILE_DRAWER_CSS_ID)) {
285
+ ensureElements();
286
+ }
287
+ });
288
+ observer.observe(document.body, { childList: true });
289
+ }
290
+
291
+ syncAria();
292
+
293
+ return () => {
294
+ disposed = true;
295
+ try { observer?.disconnect(); } catch { /* 忽略 */ }
296
+ document.removeEventListener('click', onClick, true);
297
+ document.removeEventListener('keydown', onKeyDown, true);
298
+ document.removeEventListener('visibilitychange', onVisibilityChange);
299
+ window.removeEventListener('resize', onResize);
300
+ document.body?.classList.remove(OPEN_CLASS);
301
+ document.querySelector(`.${MENU_CLASS}`)?.remove();
302
+ document.querySelector(`.${BACKDROP_CLASS}`)?.remove();
303
+ document.getElementById(MOBILE_DRAWER_CSS_ID)?.remove();
304
+ root.removeAttribute(MOBILE_UI_FLAG);
305
+ };
306
+ }