dsh-plugin-table-zoom 0.1.0 → 0.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,44 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.1] - 2026-08-22
4
+
5
+ - **每次打开自适应,不再继承任何布局**:移除尺寸/位置/缩放的跨次记忆
6
+ (不再读写 localStorage),浮窗每次打开都按表格自然宽度定宽
7
+ (下限 320;窄表收缩不撑满、**超级大表铺满视口可用宽度**)、居中、
8
+ 缩放 100%;手动拖拽(改大小/移动/缩放)只影响当次打开,关掉再开即回到自适应。
9
+ - **修复:自适应此前从未生效**——旧实现返回值错误(无记忆时返回估算宽 960
10
+ 而非 0)导致自适应分支不执行,小表仍撑满大窗口。
11
+ - **缩放不再跨次继承**:Ctrl+滚轮缩放比例不写入 localStorage,
12
+ 每次打开浮窗都从 100% 开始(标题栏百分比同步复位)。
13
+ - 测试:冒烟调整/新增 3 项(自适应宽度:窄表收缩/宽表封顶/空表兜底;
14
+ 每次打开不继承上次尺寸;openPopup 走自适应下限),
15
+ 共 selfcheck 12 项 + 冒烟 12 项。
16
+
17
+ ## [0.3.0] - 2026-08-22
18
+
19
+ - **浮窗可拖动**:不再强制居中——按住标题栏(按钮除外)拖动浮窗位置,
20
+ 自动限制在视口内不越出页面;位置与尺寸一并记忆到 localStorage,下次打开沿用。
21
+ - **Ctrl+滚轮缩放**:浮窗内按住 Ctrl 滚动滚轮,表格字体 60%–250% 缩放
22
+ (步进 10%),标题栏实时显示百分比;缩放比例随布局记忆。
23
+ - **滚动条更清晰**:正文滚动条加深加粗(WebKit 伪元素 + Firefox
24
+ `scrollbar-color`),浅色主题下也可见。
25
+ - 布局记录统一为 `{w,h,x,y,zoom}`(兼容旧 `{w,h}` 记录)。
26
+ - 测试:冒烟新增 2 项(标题栏拖拽移动 / Ctrl+滚轮缩放),
27
+ 共 selfcheck 12 项 + 冒烟 11 项。
28
+
29
+ ## [0.2.0] - 2026-08-22
30
+
31
+ - **浮窗可拖拽改尺寸**:右下角手柄调整大小(最小 320×200,上限视口减留白),
32
+ 尺寸记忆到 localStorage,下次打开自动复用;视口变化时自动收窄防溢出。
33
+ - **溢出可拖拽平移**:表格超出浮窗时,按住正文左右/上下拖动即滚动
34
+ (grab/grabbing 光标,越过 4px 阈值才视为拖动以免干扰文本选择;
35
+ 触屏保留原生滚动;拖完松手不会误关浮窗)。
36
+ - **列宽与聊天内一致**:`th/td` 增加 `max-width:min(30vw,320px);min-width:100px`,
37
+ 超宽列在浮窗内收缩换行,表格整体不再超出浮窗大小。
38
+ - 关闭浮窗时统一清理挂载的 window 监听器。
39
+ - 测试:冒烟新增 3 项(改尺寸手柄拖拽/尺寸记忆复用/溢出拖拽平移),
40
+ 共 selfcheck 12 项 + 冒烟 9 项。
41
+
3
42
  ## [0.1.0] - 2026-08-21
4
43
 
5
44
  - 新功能:**聊天表格浮窗**——聊天里 markdown 表格太长(≥9 行含表头)或
package/README.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # dsh-plugin-table-zoom
2
2
 
3
+ ![npm version](https://img.shields.io/npm/v/dsh-plugin-table-zoom)
4
+ ![License](https://img.shields.io/github/license/Pasumao/dsh-plugin-table-zoom)
5
+ ![AI Assisted](https://img.shields.io/badge/AI-Assisted-8A2BE2)
6
+
3
7
  DeepSeek Harness (dsh) Web GUI 聊天表格浮窗插件。
4
8
 
5
9
  聊天里模型输出的 markdown 表格经常**很长(行数多)或很宽(横向溢出)**,
@@ -9,14 +13,35 @@ DeepSeek Harness (dsh) Web GUI 聊天表格浮窗插件。
9
13
 
10
14
  纯前端 DOM 增强:不改核心包、不注册工具、无服务端逻辑、零运行时依赖。
11
15
 
12
- ## 效果
16
+ ## 功能
13
17
 
14
18
  - 长表(≥ 9 行含表头,或横向溢出超过 2px)表格下方出现右对齐的「⛶ 浮窗查看」按钮;
15
19
  - 短表不打扰;
16
20
  - 点击按钮弹出浮窗:标题显示「表格 · N 行 × M 列」,正文可独立滚动(横竖都行),
17
21
  关闭按钮 / Esc / 点击遮罩均可关闭,打开期间锁定聊天页滚动;
18
22
  - 浮窗头部「复制为 Markdown」按钮一键复制整表(单元格内联换行折叠、管道符转义);
19
- - 浮窗里展示的是**原表格的克隆**,聊天里的表格保持不变,也不会被重复增强。
23
+ - 浮窗里展示的是**原表格的克隆**,聊天里的表格保持不变,也不会被重复增强;
24
+ - **拖拽改尺寸**:浮窗右下角手柄可自由调整大小(最小 320×200,最大到视口留白),
25
+ 只影响当次打开;
26
+ - **每次打开自适应**:浮窗不记忆任何布局——宽度按表格内容自适应(窄表不撑满、
27
+ 超级大表直接铺满视口可用宽度,下限 320)、位置居中、缩放 100%;关掉再开即回到自适应;
28
+ - **拖拽移动**:按住标题栏可把浮窗拖到任意位置(自动限制在页面内,不会拖出屏幕),
29
+ 位置只影响当次打开;
30
+ - **Ctrl+滚轮缩放**:浮窗内按住 Ctrl 滚动滚轮,表格字体 60%–250% 缩放
31
+ (步进 10%),标题栏实时显示百分比;**缩放不记忆,每次打开回到 100%**;
32
+ - **拖拽平移**:表格超宽/超高时,按住正文任意位置即可左右/上下拖动滚动
33
+ (grab 光标提示;越过 4px 阈值才视为拖动,不干扰文本选择;触屏走原生滚动);
34
+ - **滚动条清晰**:正文滚动条加深加粗,浅色主题下也能看清;
35
+ - **列宽适配**:列宽上限与聊天内一致(`min(30vw, 320px)`),超宽列自动换行收缩,
36
+ 表格整体不再撑破浮窗。
37
+
38
+ ## 配置
39
+
40
+ 无需任何配置,安装即用:
41
+
42
+ - 不读取环境变量,不需要 API Key / token,不写配置文件;
43
+ - 长表判定阈值(≥ 9 行 / 横向溢出 2px)为内置默认值,无需调整;
44
+ - 样式跟随 DSH 主题 CSS 变量自适应,不引入独立主题配置。
20
45
 
21
46
  ## 安装
22
47
 
@@ -47,6 +72,15 @@ DeepSeek Harness (dsh) Web GUI 聊天表格浮窗插件。
47
72
  3. 在 profile 目录执行 `pnpm install`(或 `npm install`);
48
73
  4. **重启 `dsh web`**(launcher 重新拉起),新插件才会进入浏览器 bundle。
49
74
 
75
+ 源码安装(本地开发 / 调试):
76
+
77
+ ```bash
78
+ git clone https://github.com/Pasumao/dsh-plugin-table-zoom.git
79
+ cd dsh-plugin-table-zoom
80
+ npm install
81
+ # 以 link: 依赖挂载进 profile,见下方说明
82
+ ```
83
+
50
84
  > 若以本地目录开发调试,可用 `link:` 依赖替换第 1 步:
51
85
  > `"dsh-plugin-table-zoom": "link:D:/path/to/dsh-plugin-table-zoom"`。
52
86
 
@@ -76,6 +110,11 @@ npm run pack # 打包
76
110
  - 不影响其他插件:只认 `tableScroll` 容器内的表格,跳过浮窗与 image-tools
77
111
  lightbox 内的表格。
78
112
 
113
+ ## AI 生成声明
114
+
115
+ 代码与文档由 AI 辅助生成(DeepSeek Harness),均经人工审查与实机验证
116
+ (`npm run smoke`:selfcheck + 假 DOM 端到端冒烟)。
117
+
79
118
  ## License
80
119
 
81
120
  MIT
package/lib/client.js CHANGED
@@ -13,17 +13,28 @@ window.__ModuleLoader__.load({
13
13
  '.dstz-btn{appearance:none;cursor:pointer;display:inline-flex;align-items:center;gap:5px;border:1px solid var(--dsw-alias-border-l2-darkmode-thin);background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary);border-radius:999px;font-size:12px;line-height:18px;padding:3px 10px;font-family:inherit;white-space:nowrap}',
14
14
  '.dstz-btn:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-interactive-bg-active);background:var(--dsw-alias-interactive-bg-hover)}',
15
15
  '.dstz-btn:focus-visible{outline:2px solid var(--dsw-alias-interactive-bg-active);outline-offset:1px}',
16
- '.dstz-popup{position:fixed;inset:0;z-index:9999;background:rgba(8,10,18,.55);display:flex;align-items:center;justify-content:center;padding:24px}',
17
- '.dstz-panel{box-sizing:border-box;background:var(--dsw-specific-input-major);border:1px solid var(--dsw-alias-border-l2-darkmode-thin);border-radius:16px;box-shadow:var(--dsw-shadow-lv2);color:var(--dsw-alias-label-primary);width:min(92vw,960px);max-height:min(84vh,720px);flex-direction:column;display:flex;overflow:hidden;--dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2);--dsh-scrollbar-thumb-hover:var(--dsw-alias-scrollbar-hover-l2)}',
16
+ '.dstz-popup{position:fixed;inset:0;z-index:9999;background:rgba(8,10,18,.55)}',
17
+ '.dstz-panel{box-sizing:border-box;background:var(--dsw-specific-input-major);border:1px solid var(--dsw-alias-border-l2-darkmode-thin);border-radius:16px;box-shadow:var(--dsw-shadow-lv2);color:var(--dsw-alias-label-primary);width:min(92vw,960px);max-height:min(84vh,720px);flex-direction:column;display:flex;overflow:hidden;position:fixed}',
18
18
  '.dstz-panel,.dstz-panel *{box-sizing:border-box}',
19
- '.dstz-header{flex-shrink:0;justify-content:space-between;align-items:center;gap:12px;border-bottom:1px solid var(--dsw-alias-border-l2);padding:10px 12px 10px 18px;display:flex}',
19
+ '.dstz-panel.dstz-resizing,.dstz-panel.dstz-resizing *{user-select:none;-webkit-user-select:none}',
20
+ '.dstz-header{flex-shrink:0;justify-content:space-between;align-items:center;gap:12px;border-bottom:1px solid var(--dsw-alias-border-l2);padding:10px 12px 10px 18px;display:flex;cursor:move;touch-action:none}',
21
+ '.dstz-header.dstz-dragging{cursor:grabbing;user-select:none;-webkit-user-select:none}',
22
+ '.dstz-header button{cursor:pointer}',
20
23
  '.dstz-title{margin:0;font-size:14px;font-weight:500;line-height:20px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
21
24
  '.dstz-title small{color:var(--dsw-alias-label-tertiary);font-size:12px;font-weight:400;margin-left:8px}',
22
25
  '.dstz-headerActions{flex-shrink:0;align-items:center;gap:8px;display:flex}',
23
- '.dstz-body{overscroll-behavior:contain;flex:auto;min-height:0;padding:14px 18px 18px;overflow:auto}',
26
+ '.dstz-body{overscroll-behavior:contain;flex:auto;min-height:0;padding:14px 18px 18px;overflow-x:auto;overflow-y:auto;scrollbar-width:thin;scrollbar-color:var(--dsw-alias-scrollbar-hover-l2, rgba(148,163,184,.65)) transparent}',
27
+ '.dstz-body::-webkit-scrollbar{width:9px;height:9px}',
28
+ '.dstz-body::-webkit-scrollbar-thumb{background:var(--dsw-alias-scrollbar-hover-l2, rgba(148,163,184,.65));border-radius:7px;border:2px solid var(--dsw-specific-input-major);background-clip:padding-box}',
29
+ '.dstz-body::-webkit-scrollbar-thumb:hover{background:var(--dsw-alias-label-secondary, rgba(148,163,184,.95))}',
30
+ '.dstz-body::-webkit-scrollbar-track{background:transparent}',
31
+ '.dstz-body::-webkit-scrollbar-corner{background:transparent}',
32
+ '.dstz-body.dstz-grabbable{cursor:grab}',
33
+ '.dstz-body.dstz-panning{cursor:grabbing;user-select:none;-webkit-user-select:none}',
24
34
  '.dstz-table{border-collapse:collapse;width:max-content;max-width:max-content;color:var(--dsw-alias-label-primary)}',
25
- '.dstz-table th{text-align:start;padding:8px 14px;border-bottom:1px solid var(--dsw-alias-border-l3);font:var(--dsw-font-markdown-table-head);white-space:nowrap}',
26
- '.dstz-table td{padding:8px 14px;border-bottom:1px solid var(--dsw-alias-border-l2);font:var(--dsw-font-markdown-table)}',
35
+ // 列宽上限与核心渲染器(tableScroll)一致:超宽列在浮窗内收缩换行,整体不溢出
36
+ '.dstz-table th{text-align:start;padding:8px 14px;border-bottom:1px solid var(--dsw-alias-border-l3);font:var(--dsw-font-markdown-table-head);max-width:min(30vw,320px);min-width:100px}',
37
+ '.dstz-table td{padding:8px 14px;border-bottom:1px solid var(--dsw-alias-border-l2);font:var(--dsw-font-markdown-table);max-width:min(30vw,320px);min-width:100px}',
27
38
  '.dstz-table tr:last-child td{border-bottom:none}',
28
39
  '.dstz-iconButton{width:28px;height:28px;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;border-radius:999px;place-items:center;display:grid;font-size:15px;line-height:1;padding:0}',
29
40
  '.dstz-iconButton:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}',
@@ -31,7 +42,11 @@ window.__ModuleLoader__.load({
31
42
  '.dstz-copyBtn{appearance:none;cursor:pointer;border-radius:999px;border:1px solid var(--dsw-alias-border-l2-darkmode-thin);background:0 0;color:var(--dsw-alias-label-primary);font-size:12px;line-height:18px;padding:3px 10px;font-family:inherit;white-space:nowrap}',
32
43
  '.dstz-copyBtn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}',
33
44
  '.dstz-copyBtn:disabled{cursor:default;opacity:.55}',
34
- '@media (width<=720px){.dstz-popup{padding:12px}.dstz-panel{width:min(96vw,960px);max-height:88vh}.dstz-body{padding:10px 12px 12px}}'
45
+ // 拖拽改尺寸:右下角手柄(悬停显示,不占滚动条位置)
46
+ '.dstz-resizeSE{position:absolute;right:2px;bottom:2px;width:22px;height:22px;cursor:nwse-resize;z-index:4;touch-action:none}',
47
+ '.dstz-resizeSE::after{content:"";position:absolute;right:5px;bottom:5px;width:9px;height:9px;border-right:2px solid var(--dsw-alias-label-tertiary);border-bottom:2px solid var(--dsw-alias-label-tertiary);border-bottom-right-radius:2px;pointer-events:none}',
48
+ '.dstz-resizeSE:hover::after,.dstz-panel.dstz-resizing .dstz-resizeSE::after{border-color:var(--dsw-alias-label-primary)}',
49
+ '@media (width<=720px){.dstz-panel{width:min(96vw,960px);max-height:88vh}.dstz-body{padding:10px 12px 12px}}'
35
50
  ].join('');
36
51
  var tagId = 'dsh-plugin-table-zoom/zoom.module.css';
37
52
  if (typeof document !== 'undefined' && document.querySelector('style[data-plugin-css=' + JSON.stringify(tagId) + ']') === null) {
@@ -150,6 +165,11 @@ window.__ModuleLoader__.load({
150
165
  var popup = activePopup;
151
166
  activePopup = null;
152
167
  if (typeof popup._onKey === 'function') document.removeEventListener('keydown', popup._onKey);
168
+ if (Array.isArray(popup._cleanups)) {
169
+ for (var i = 0; i < popup._cleanups.length; i++) {
170
+ try { popup._cleanups[i](); } catch (e) { /* ignore */ }
171
+ }
172
+ }
153
173
  popup.remove();
154
174
  document.body.style.overflow = lastTrigger !== null && lastTrigger.bodyOverflow !== void 0 ? lastTrigger.bodyOverflow : '';
155
175
  lastTrigger = null;
@@ -182,6 +202,241 @@ window.__ModuleLoader__.load({
182
202
  }
183
203
  }
184
204
 
205
+ // ------------------------------------------------------------------
206
+ // 浮窗布局:拖拽改大小 + 拖标题栏移动 + Ctrl+滚轮缩放
207
+ // 不记忆任何布局:每次打开都按表格内容自适应宽度并居中,手动拖拽只影响当次。
208
+ // ------------------------------------------------------------------
209
+
210
+ var MIN_POPUP_W = 320;
211
+ var MIN_POPUP_H = 200;
212
+ /** 浮窗距视口边缘的最小留白。 */
213
+ var POPUP_MARGIN = 48;
214
+ /** Ctrl+滚轮缩放范围与步进。 */
215
+ var ZOOM_MIN = 0.6;
216
+ var ZOOM_MAX = 2.5;
217
+ var ZOOM_STEP = 0.1;
218
+
219
+ /** 把 v 限制在 [lo, hi]。 */
220
+ function clamp(v, lo, hi) {
221
+ return Math.min(Math.max(v, lo), hi);
222
+ }
223
+
224
+ /** 当前视口宽(无 DOM 环境兜底 1280)。 */
225
+ function viewportW() {
226
+ if (typeof window !== 'undefined' && typeof window.innerWidth === 'number' && window.innerWidth > 0) return window.innerWidth;
227
+ if (typeof document !== 'undefined' && document.documentElement !== null && typeof document.documentElement.clientWidth === 'number' && document.documentElement.clientWidth > 0) return document.documentElement.clientWidth;
228
+ return 1280;
229
+ }
230
+
231
+ /** 当前视口高(无 DOM 环境兜底 800)。 */
232
+ function viewportH() {
233
+ if (typeof window !== 'undefined' && typeof window.innerHeight === 'number' && window.innerHeight > 0) return window.innerHeight;
234
+ if (typeof document !== 'undefined' && document.documentElement !== null && typeof document.documentElement.clientHeight === 'number' && document.documentElement.clientHeight > 0) return document.documentElement.clientHeight;
235
+ return 800;
236
+ }
237
+
238
+ /**
239
+ * 每次打开都按表格自然宽度自适应面板宽度
240
+ * (正文左右 padding 36 + 面板边框 2;下限 320,上限为视口减去留白——
241
+ * 超级大表直接铺满最大可用宽度)。
242
+ */
243
+ function applyAdaptiveWidth(panel, clone) {
244
+ var tableW = typeof clone.offsetWidth === 'number' && clone.offsetWidth > 0 ? clone.offsetWidth : 0;
245
+ var maxW = viewportW() - POPUP_MARGIN;
246
+ var w = clamp(tableW + 38, MIN_POPUP_W, Math.max(MIN_POPUP_W, maxW));
247
+ panel.style.width = w + 'px';
248
+ }
249
+
250
+ /** 把面板按实际尺寸重新居中(自适应改宽后调用;测不到尺寸时跳过)。 */
251
+ function centerPanel(panel) {
252
+ var w = panel.offsetWidth;
253
+ var h = panel.offsetHeight;
254
+ if (typeof w !== 'number' || w <= 0 || typeof h !== 'number' || h <= 0) return;
255
+ panel.style.left = Math.round((viewportW() - w) / 2) + 'px';
256
+ panel.style.top = Math.round((viewportH() - h) / 2) + 'px';
257
+ }
258
+
259
+ /** 把面板位置限制在视口内(完全可见,不越出页面)。 */
260
+ function clampPanelPosition(panel) {
261
+ var rect = null;
262
+ try { rect = panel.getBoundingClientRect(); } catch (err) { rect = null; }
263
+ if (rect === null || typeof rect !== 'object') return;
264
+ if (typeof rect.left !== 'number' || typeof rect.top !== 'number') return;
265
+ var x = clamp(rect.left, 0, Math.max(0, viewportW() - rect.width));
266
+ var y = clamp(rect.top, 0, Math.max(0, viewportH() - rect.height));
267
+ panel.style.left = x + 'px';
268
+ panel.style.top = y + 'px';
269
+ }
270
+
271
+ /** 把面板移到 (x, y) 并限制在视口内。 */
272
+ function setPanelPosition(panel, x, y) {
273
+ var rect = null;
274
+ try { rect = panel.getBoundingClientRect(); } catch (err) { rect = null; }
275
+ if (rect === null || typeof rect !== 'object') return;
276
+ x = clamp(x, 0, Math.max(0, viewportW() - rect.width));
277
+ y = clamp(y, 0, Math.max(0, viewportH() - rect.height));
278
+ panel.style.left = x + 'px';
279
+ panel.style.top = y + 'px';
280
+ }
281
+
282
+ /** 内容溢出时给正文加 grab 光标提示(可拖拽平移)。 */
283
+ function refreshGrabbable(body) {
284
+ var overX = body.scrollWidth > body.clientWidth + 1;
285
+ var overY = body.scrollHeight > body.clientHeight + 1;
286
+ if (overX || overY) body.classList.add('dstz-grabbable');
287
+ else body.classList.remove('dstz-grabbable');
288
+ }
289
+
290
+ /** 命中可交互元素(链接/按钮/输入框等)时让给默认行为,不启动拖拽。 */
291
+ function isInteractive(el) {
292
+ if (el === null || typeof el !== 'object' || typeof el.closest !== 'function') return false;
293
+ return el.closest('a,button,input,textarea,select,[contenteditable="true"],[role="button"]') !== null;
294
+ }
295
+
296
+ /**
297
+ * 给面板挂载「拖右下角改尺寸」:指针捕获 + _suppressClick 防止拖完
298
+ * 松手落在遮罩上误触发关闭。尺寸只影响当次打开,不持久化。
299
+ * @param {object} panel - .dstz-panel 元素。
300
+ * @param {object} overlay - .dstz-popup 元素(承载 _suppressClick)。
301
+ */
302
+ function attachResize(panel, overlay) {
303
+ var handle = document.createElement('div');
304
+ handle.className = 'dstz-resizeSE';
305
+ handle.setAttribute('aria-hidden', 'true');
306
+ var state = null;
307
+ var begin = function (e) {
308
+ if (e.button !== 0) return;
309
+ if (typeof e.preventDefault === 'function') e.preventDefault();
310
+ if (typeof e.stopPropagation === 'function') e.stopPropagation();
311
+ var rect = null;
312
+ try { rect = panel.getBoundingClientRect(); } catch (err) { rect = null; }
313
+ if (rect === null || typeof rect !== 'object') return;
314
+ state = { x: e.clientX, y: e.clientY, w: rect.width, h: rect.height };
315
+ panel.style.maxHeight = 'none';
316
+ panel.classList.add('dstz-resizing');
317
+ overlay._suppressClick = true;
318
+ if (e.currentTarget !== null && e.currentTarget !== void 0 && typeof e.currentTarget.setPointerCapture === 'function') {
319
+ try { e.currentTarget.setPointerCapture(e.pointerId); } catch (err) { /* ignore */ }
320
+ }
321
+ };
322
+ var move = function (e) {
323
+ if (state === null) return;
324
+ var dx = e.clientX - state.x;
325
+ var dy = e.clientY - state.y;
326
+ var w = Math.min(Math.max(state.w + dx, MIN_POPUP_W), Math.max(MIN_POPUP_W, viewportW() - POPUP_MARGIN));
327
+ var h = Math.min(Math.max(state.h + dy, MIN_POPUP_H), Math.max(MIN_POPUP_H, viewportH() - POPUP_MARGIN));
328
+ panel.style.width = w + 'px';
329
+ panel.style.height = h + 'px';
330
+ if (typeof e.preventDefault === 'function') e.preventDefault();
331
+ };
332
+ var end = function () {
333
+ if (state === null) return;
334
+ state = null;
335
+ panel.classList.remove('dstz-resizing');
336
+ clampPanelPosition(panel);
337
+ window.setTimeout(function () { overlay._suppressClick = false; }, 0);
338
+ };
339
+ handle.addEventListener('pointerdown', begin);
340
+ handle.addEventListener('pointermove', move);
341
+ handle.addEventListener('pointerup', end);
342
+ handle.addEventListener('pointercancel', end);
343
+ panel.appendChild(handle);
344
+ }
345
+
346
+ /**
347
+ * 给标题栏挂载「拖拽移动」:按住标题栏(按钮除外)拖动面板位置,
348
+ * 位置限制在视口内(不越出页面);位置不记忆,下次打开重新居中。
349
+ * @param {object} panel - .dstz-panel 元素(children[0] 为标题栏)。
350
+ * @param {object} overlay - .dstz-popup 元素(承载 _suppressClick)。
351
+ */
352
+ function attachMove(panel, overlay) {
353
+ var header = panel.children[0];
354
+ if (header === null || header === void 0) return;
355
+ var state = null;
356
+ header.addEventListener('pointerdown', function (e) {
357
+ if (e.button !== 0) return;
358
+ if (isInteractive(e.target)) return;
359
+ var rect = null;
360
+ try { rect = panel.getBoundingClientRect(); } catch (err) { rect = null; }
361
+ if (rect === null || typeof rect !== 'object') return;
362
+ state = { x: e.clientX, y: e.clientY, lx: rect.left, ty: rect.top, moved: false };
363
+ if (typeof header.setPointerCapture === 'function') {
364
+ try { header.setPointerCapture(e.pointerId); } catch (err) { /* ignore */ }
365
+ }
366
+ });
367
+ header.addEventListener('pointermove', function (e) {
368
+ if (state === null) return;
369
+ var dx = e.clientX - state.x;
370
+ var dy = e.clientY - state.y;
371
+ if (!state.moved && Math.abs(dx) + Math.abs(dy) < 3) return;
372
+ if (!state.moved) {
373
+ state.moved = true;
374
+ header.classList.add('dstz-dragging');
375
+ overlay._suppressClick = true;
376
+ }
377
+ setPanelPosition(panel, state.lx + dx, state.ty + dy);
378
+ if (typeof e.preventDefault === 'function') e.preventDefault();
379
+ });
380
+ var end = function () {
381
+ if (state === null) return;
382
+ var wasMoved = state.moved;
383
+ state = null;
384
+ header.classList.remove('dstz-dragging');
385
+ if (wasMoved) window.setTimeout(function () { overlay._suppressClick = false; }, 0);
386
+ };
387
+ header.addEventListener('pointerup', end);
388
+ header.addEventListener('pointercancel', end);
389
+ }
390
+
391
+ /**
392
+ * 给正文挂载「拖拽平移」:内容溢出时按住表格左右/上下拖动即可滚动
393
+ * (鼠标/触控笔;触屏保留原生滚动)。越过 4px 阈值才视为拖动,
394
+ * 避免干扰文本选择;拖动结束松手落在遮罩上不会误关浮窗。
395
+ * @param {object} body - .dstz-body 元素。
396
+ * @param {object} overlay - .dstz-popup 元素(承载 _suppressClick)。
397
+ */
398
+ function attachPan(body, overlay) {
399
+ var state = null;
400
+ body.addEventListener('pointerdown', function (e) {
401
+ if (e.button !== 0) return;
402
+ if (e.pointerType !== undefined && e.pointerType !== 'mouse' && e.pointerType !== 'pen') return;
403
+ if (isInteractive(e.target)) return;
404
+ var overX = body.scrollWidth > body.clientWidth + 1;
405
+ var overY = body.scrollHeight > body.clientHeight + 1;
406
+ if (!overX && !overY) return;
407
+ state = { x: e.clientX, y: e.clientY, sl: body.scrollLeft, st: body.scrollTop, moved: false };
408
+ if (typeof body.setPointerCapture === 'function') {
409
+ try { body.setPointerCapture(e.pointerId); } catch (err) { /* ignore */ }
410
+ }
411
+ });
412
+ body.addEventListener('pointermove', function (e) {
413
+ if (state === null) return;
414
+ var dx = e.clientX - state.x;
415
+ var dy = e.clientY - state.y;
416
+ if (!state.moved && Math.abs(dx) + Math.abs(dy) < 4) return;
417
+ if (!state.moved) {
418
+ state.moved = true;
419
+ body.classList.add('dstz-panning');
420
+ overlay._suppressClick = true;
421
+ if (typeof document !== 'undefined' && typeof document.getSelection === 'function') {
422
+ try { document.getSelection().removeAllRanges(); } catch (err) { /* ignore */ }
423
+ }
424
+ }
425
+ body.scrollLeft = state.sl - dx;
426
+ body.scrollTop = state.st - dy;
427
+ if (typeof e.preventDefault === 'function') e.preventDefault();
428
+ });
429
+ var end = function () {
430
+ if (state === null) return;
431
+ var wasMoved = state.moved;
432
+ state = null;
433
+ body.classList.remove('dstz-panning');
434
+ if (wasMoved) window.setTimeout(function () { overlay._suppressClick = false; }, 0);
435
+ };
436
+ body.addEventListener('pointerup', end);
437
+ body.addEventListener('pointercancel', end);
438
+ }
439
+
185
440
  /**
186
441
  * 打开表格浮窗:可滚动容器 + 表格克隆 + 头部(标题/行列数/复制/关闭)。
187
442
  * 克隆而不是移动原表格,聊天里的表格保持不变。
@@ -252,6 +507,49 @@ window.__ModuleLoader__.load({
252
507
  panel.appendChild(body);
253
508
  overlay.appendChild(panel);
254
509
 
510
+ // 拖拽改尺寸 + 拖标题栏移动 + 内容溢出拖拽平移 + Ctrl+滚轮缩放
511
+ attachResize(panel, overlay);
512
+ attachMove(panel, overlay);
513
+ attachPan(body, overlay);
514
+
515
+ var zoom = 1;
516
+ var applyZoom = function (z) {
517
+ zoom = clamp(z, ZOOM_MIN, ZOOM_MAX);
518
+ clone.style.zoom = String(zoom);
519
+ small.textContent = rowCount + ' ' + LABELS.rowsCols + ' ' + colCount
520
+ + (Math.abs(zoom - 1) > 0.001 ? ' \u00b7 ' + Math.round(zoom * 100) + '%' : '');
521
+ };
522
+ applyZoom(1); // 不继承上次的缩放,每次都从 100% 开始
523
+ refreshGrabbable(body);
524
+
525
+ // Ctrl+滚轮:缩放表格字体(阻止浏览器整页缩放;缩放不写入 localStorage)
526
+ var onWheel = function (e) {
527
+ if (!e.ctrlKey) return;
528
+ if (typeof e.preventDefault === 'function') e.preventDefault();
529
+ applyZoom(e.deltaY < 0 ? zoom + ZOOM_STEP : zoom - ZOOM_STEP);
530
+ };
531
+ panel.addEventListener('wheel', onWheel, { passive: false });
532
+
533
+ var onWindowResize = function () {
534
+ var maxW = viewportW() - POPUP_MARGIN;
535
+ var maxH = viewportH() - POPUP_MARGIN;
536
+ var w = panel.offsetWidth;
537
+ var h = panel.offsetHeight;
538
+ if (w > maxW || h > maxH) {
539
+ panel.style.width = Math.min(w, maxW) + 'px';
540
+ panel.style.height = Math.min(h, maxH) + 'px';
541
+ }
542
+ clampPanelPosition(panel);
543
+ refreshGrabbable(body);
544
+ };
545
+ overlay._cleanups = [];
546
+ if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
547
+ window.addEventListener('resize', onWindowResize);
548
+ overlay._cleanups.push(function () {
549
+ window.removeEventListener('resize', onWindowResize);
550
+ });
551
+ }
552
+
255
553
  lastTrigger = { bodyOverflow: document.body.style.overflow };
256
554
  document.body.style.overflow = 'hidden';
257
555
 
@@ -259,6 +557,11 @@ window.__ModuleLoader__.load({
259
557
  if (event.key === 'Escape') closePopup();
260
558
  };
261
559
  overlay.addEventListener('click', function (event) {
560
+ // 拖拽(改尺寸/平移)后松手落在遮罩上会产生一次 click,需吞掉避免误关
561
+ if (overlay._suppressClick) {
562
+ overlay._suppressClick = false;
563
+ return;
564
+ }
262
565
  if (event.target === overlay) closePopup();
263
566
  });
264
567
  document.addEventListener('keydown', onKey);
@@ -266,6 +569,10 @@ window.__ModuleLoader__.load({
266
569
 
267
570
  activePopup = overlay;
268
571
  document.body.appendChild(overlay);
572
+ // 每次打开都按表格自然宽度自适应,并按实际尺寸重新居中(不继承上次的尺寸/位置)
573
+ applyAdaptiveWidth(panel, clone);
574
+ centerPanel(panel);
575
+ refreshGrabbable(body);
269
576
  closeBtn.focus();
270
577
  }
271
578
 
@@ -374,6 +681,12 @@ window.__ModuleLoader__.load({
374
681
  exports.upgradeTable = upgradeTable;
375
682
  exports.upgradeTables = upgradeTables;
376
683
  exports.startEnhancer = startEnhancer;
684
+ exports.attachResize = attachResize;
685
+ exports.attachMove = attachMove;
686
+ exports.attachPan = attachPan;
687
+ exports.applyAdaptiveWidth = applyAdaptiveWidth;
688
+ exports.clampPanelPosition = clampPanelPosition;
689
+ exports.refreshGrabbable = refreshGrabbable;
377
690
  return module.exports;
378
691
  }
379
692
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-plugin-table-zoom",
3
3
  "description": "DSH 聊天表格浮窗插件:聊天里 markdown 表格太长(行多/超宽)看不完时,表格上方自动出现「浮窗查看」按钮,点击弹出可滚动的小浮窗完整显示表格,并支持一键复制为 Markdown。纯前端 DOM 增强,不改核心包、不注册工具、无服务端逻辑。",
4
- "version": "0.1.0",
4
+ "version": "0.3.1",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -6,7 +6,10 @@
6
6
  * 2. upgradeTable 在长表容器后注入「浮窗查看」按钮行,短表不注入;
7
7
  * 3. openPopup 创建浮窗(标题含行列数、表格克隆、关闭按钮),
8
8
  * closePopup 关闭并还原 body 滚动;
9
- * 4. 表格克隆在浮窗内不会被再次增强(跳过 .dstz-popup)。
9
+ * 4. 表格克隆在浮窗内不会被再次增强(跳过 .dstz-popup);
10
+ * 5. 右下角改尺寸手柄可拖拽改面板大小;内容溢出时正文可拖拽平移;
11
+ * 标题栏可拖拽移动浮窗(不越出视口);Ctrl+滚轮缩放表格字体;
12
+ * 每次打开都按内容自适应宽度并居中(不继承上次的尺寸/位置/缩放)。
10
13
  *
11
14
  * 运行:node scripts/smoke-client.mjs
12
15
  */
@@ -251,4 +254,148 @@ ok('copyText 写入剪贴板', async () => {
251
254
  assert.equal(globalThis.__copied, '| a |\n| --- |')
252
255
  })
253
256
 
257
+ // 5) 浮窗改尺寸 + 自适应 + 拖拽平移(无布局记忆:每次打开都自适应并居中)
258
+
259
+ ok('openPopup 挂载右下角改尺寸手柄并可拖拽', () => {
260
+ mod.closePopup()
261
+ const table = makeEl('table')
262
+ table.rows = []
263
+ mod.openPopup(table)
264
+ const popup = documentEl.body.children.find((c) => c.className === 'dstz-popup')
265
+ const panel = popup.children[0]
266
+ const handle = panel.children.find((c) => c.className === 'dstz-resizeSE')
267
+ assert.ok(handle !== undefined, 'resize handle not mounted')
268
+ // 假 DOM 无真实布局:显式给一个起始尺寸,验证拖拽按 delta 改大小
269
+ panel.style.width = '800px'
270
+ panel.style.height = '400px'
271
+ panel.getBoundingClientRect = () => ({
272
+ width: parseInt(panel.style.width, 10) || 800,
273
+ height: parseInt(panel.style.height, 10) || 400,
274
+ })
275
+ handle.dispatch('pointerdown', { button: 0, clientX: 100, clientY: 100, pointerId: 1 })
276
+ handle.dispatch('pointermove', { clientX: 260, clientY: 160 })
277
+ assert.equal(panel.style.width, '960px')
278
+ assert.equal(panel.style.height, '460px')
279
+ assert.equal(panel.style.maxHeight, 'none')
280
+ assert.ok(popup._suppressClick === true, 'drag should suppress overlay close')
281
+ handle.dispatch('pointerup', {})
282
+ assert.ok(popup._suppressClick === false, 'suppress flag reset after drag')
283
+ mod.closePopup()
284
+ })
285
+
286
+ ok('每次打开不继承上次尺寸,始终按内容自适应', () => {
287
+ mod.closePopup()
288
+ const table = makeEl('table')
289
+ table.rows = []
290
+ mod.openPopup(table)
291
+ let popup = documentEl.body.children.find((c) => c.className === 'dstz-popup')
292
+ let panel = popup.children[0]
293
+ assert.equal(panel.style.width, '320px', '首次打开走自适应(假 DOM 无布局 → 下限 320)')
294
+ // 模拟当次手动改大
295
+ panel.style.width = '800px'
296
+ panel.style.height = '500px'
297
+ mod.closePopup()
298
+ // 重新打开:应回到自适应宽度,而不是继承 800px
299
+ mod.openPopup(table)
300
+ popup = documentEl.body.children.find((c) => c.className === 'dstz-popup')
301
+ panel = popup.children[0]
302
+ assert.equal(panel.style.width, '320px', '再次打开仍自适应,不继承上次尺寸')
303
+ assert.notEqual(panel.style.height, '500px')
304
+ mod.closePopup()
305
+ })
306
+
307
+ ok('溢出时正文可拖拽平移', () => {
308
+ mod.closePopup()
309
+ const table = makeEl('table')
310
+ table.rows = []
311
+ mod.openPopup(table)
312
+ const popup = documentEl.body.children.find((c) => c.className === 'dstz-popup')
313
+ const body = popup.children[0].children[1]
314
+ body.scrollWidth = 1200
315
+ body.clientWidth = 600
316
+ body.scrollHeight = 400
317
+ body.clientHeight = 300
318
+ body.scrollLeft = 300
319
+ body.scrollTop = 100
320
+ mod.refreshGrabbable(body)
321
+ assert.ok(body.classList.contains('dstz-grabbable'), 'overflow should show grab cursor')
322
+ body.dispatch('pointerdown', { button: 0, clientX: 10, clientY: 10, pointerId: 1 })
323
+ body.dispatch('pointermove', { clientX: 110, clientY: 60 })
324
+ assert.ok(body.classList.contains('dstz-panning'), 'panning class while dragging')
325
+ assert.equal(body.scrollLeft, 200)
326
+ assert.equal(body.scrollTop, 50)
327
+ assert.ok(popup._suppressClick === true, 'pan should suppress overlay close')
328
+ body.dispatch('pointerup', {})
329
+ assert.ok(!body.classList.contains('dstz-panning'), 'panning class cleared on release')
330
+ assert.ok(popup._suppressClick === false, 'suppress flag reset after pan')
331
+ mod.closePopup()
332
+ })
333
+
334
+ ok('标题栏可拖拽移动浮窗(不越出视口)', () => {
335
+ mod.closePopup()
336
+ const table = makeEl('table')
337
+ table.rows = []
338
+ mod.openPopup(table)
339
+ const popup = documentEl.body.children.find((c) => c.className === 'dstz-popup')
340
+ const panel = popup.children[0]
341
+ const header = panel.children[0]
342
+ assert.equal(panel.style.width, '320px', '打开即自适应(假 DOM 无布局 → 下限 320)')
343
+ // 假 DOM 无真实布局:显式给起始位置,验证拖拽按 delta 移动
344
+ panel.style.left = '160px'
345
+ panel.style.top = '40px'
346
+ panel.getBoundingClientRect = () => ({
347
+ width: parseInt(panel.style.width, 10) || 800,
348
+ height: parseInt(panel.style.height, 10) || 400,
349
+ left: parseInt(panel.style.left, 10) || 300,
350
+ top: parseInt(panel.style.top, 10) || 200,
351
+ })
352
+ const startLeft = parseInt(panel.style.left, 10)
353
+ const startTop = parseInt(panel.style.top, 10)
354
+ header.dispatch('pointerdown', { button: 0, clientX: 10, clientY: 10, pointerId: 1 })
355
+ header.dispatch('pointermove', { clientX: 50, clientY: 30 })
356
+ assert.ok(header.classList.contains('dstz-dragging'), 'dragging class while moving')
357
+ assert.equal(parseInt(panel.style.left, 10), startLeft + 40, 'left follows drag delta')
358
+ assert.equal(parseInt(panel.style.top, 10), startTop + 20, 'top follows drag delta')
359
+ assert.ok(popup._suppressClick === true, 'move should suppress overlay close')
360
+ header.dispatch('pointerup', {})
361
+ assert.ok(!header.classList.contains('dstz-dragging'), 'dragging class cleared on release')
362
+ assert.ok(popup._suppressClick === false, 'suppress flag reset after move')
363
+ mod.closePopup()
364
+ })
365
+
366
+ ok('Ctrl+滚轮缩放表格字体', () => {
367
+ mod.closePopup()
368
+ const table = makeEl('table')
369
+ table.rows = [{ cells: [] }, { cells: [] }]
370
+ mod.openPopup(table)
371
+ const popup = documentEl.body.children.find((c) => c.className === 'dstz-popup')
372
+ const panel = popup.children[0]
373
+ const clone = panel.children[1].children[0]
374
+ const small = panel.children[0].children[0].children[0]
375
+ assert.equal(clone.style.zoom, '1')
376
+ assert.ok(!small.textContent.includes('%'), 'zoom 1 时不显示百分比')
377
+ panel.dispatch('wheel', { ctrlKey: true, deltaY: -100 })
378
+ assert.equal(clone.style.zoom, '1.1')
379
+ assert.ok(small.textContent.includes('110%'), '标题显示缩放百分比')
380
+ panel.dispatch('wheel', { ctrlKey: true, deltaY: 100 })
381
+ assert.equal(clone.style.zoom, '1')
382
+ assert.ok(!small.textContent.includes('%'), '回到 100% 后百分比消失')
383
+ mod.closePopup()
384
+ })
385
+
386
+ ok('applyAdaptiveWidth 按表格自然宽度自适应(窄表收缩、宽表铺满视口)', () => {
387
+ const panel = makeEl('div')
388
+ const clone = makeEl('table')
389
+ clone.offsetWidth = 500
390
+ mod.applyAdaptiveWidth(panel, clone)
391
+ assert.equal(panel.style.width, '538px')
392
+ // 超级大表:铺满视口可用宽度(假 DOM 视口 1280 - 留白 48 = 1232)
393
+ clone.offsetWidth = 3000
394
+ mod.applyAdaptiveWidth(panel, clone)
395
+ assert.equal(panel.style.width, '1232px')
396
+ clone.offsetWidth = 0
397
+ mod.applyAdaptiveWidth(panel, clone)
398
+ assert.equal(panel.style.width, '320px')
399
+ })
400
+
254
401
  console.log(`[dsh-plugin-table-zoom] smoke-client: ${passed} passed`)
@@ -44,10 +44,28 @@ cordis.patch.yml 挂载一行,交 profile 的 dsh.profile.bundles 自
44
44
 
45
45
  - 打开:锁定 body 滚动;Esc / 关闭按钮 / 点击遮罩关闭并还原滚动;
46
46
  - 头部:标题「表格 · N 行 × M 列」+「复制为 Markdown」+ 关闭按钮;
47
- - 正文:独立滚动(横竖),表格克隆带 `dstz-table` 类,样式全部走主题 CSS 变量。
47
+ - 正文:独立滚动(横竖),表格克隆带 `dstz-table` 类,样式全部走主题 CSS 变量;
48
+ - **改尺寸**:右下角有拖拽手柄,按住可自由调整浮窗大小(最小 320×200,
49
+ 上限为视口减去遮罩留白);
50
+ - **每次打开自适应**:浮窗**不记忆任何布局**——尺寸按表格自然宽度自适应
51
+ (下限 320,窄表不撑满、超级大表铺满视口可用宽度)、位置居中、缩放 100%;
52
+ 手动拖拽(改大小/移动/缩放)只影响当次打开,关掉再开即回到自适应;
53
+ - **拖拽移动**:按住标题栏(按钮除外)可拖动浮窗位置,位置限制在视口内、
54
+ 不越出页面;
55
+ - **Ctrl+滚轮缩放**:浮窗内按住 Ctrl 滚动滚轮可放大/缩小表格字体
56
+ (60%–250%,步进 10%),标题栏实时显示百分比;缩放不记忆,
57
+ 每次打开都从 100% 开始;
58
+ - **滚动条**:正文滚动条加深加粗(WebKit 伪元素 + Firefox `scrollbar-color`),
59
+ 浅色主题下也清晰可见;
60
+ - **拖拽平移**:表格内容溢出正文时,鼠标/触控笔按住表格即可左右/上下拖动
61
+ 滚动(grab/grabbing 光标提示);越过 4px 阈值才视为拖动,避免干扰文本
62
+ 选择;触屏保留浏览器原生滚动;拖完松手落在遮罩上不会误关浮窗;
63
+ - **列宽上限**:与核心渲染器 `tableScroll` 一致(`max-width:min(30vw,320px)`、
64
+ `min-width:100px`),超宽列在浮窗内收缩换行,表整体不再超出浮窗。
48
65
 
49
66
  ## 边界与不做的事
50
67
 
51
68
  - 只认 `tableScroll` 容器内的表格;浮窗内克隆表与 image-tools lightbox 内的表格跳过;
52
69
  - 不提供服务端工具/路由/配置;无 React 依赖(零运行时依赖);
53
- - 复制为 Markdown 优先 clipboard API,失败时 execCommand 兜底。
70
+ - 复制为 Markdown 优先 clipboard API,失败时 execCommand 兜底;
71
+ - 不写 localStorage:浮窗布局(尺寸/位置/缩放)不跨次记忆,每次打开都自适应。