dsh-plugin-table-zoom 0.1.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/CHANGELOG.md ADDED
@@ -0,0 +1,15 @@
1
+ # Changelog
2
+
3
+ ## [0.1.0] - 2026-08-21
4
+
5
+ - 新功能:**聊天表格浮窗**——聊天里 markdown 表格太长(≥9 行含表头)或
6
+ 横向溢出(超宽)看不完时,表格下方出现「⛶ 浮窗查看」按钮,点击弹出
7
+ 独立可滚动的小浮窗完整显示表格,并支持一键复制为 Markdown。
8
+ - 长表判定:行数 ≥ `MIN_ROWS`(9,含表头)或横向溢出 > 2px;短表不打扰。
9
+ - 浮窗展示原表格的克隆,聊天里的表格保持不变;关闭按钮 / Esc / 点击遮罩
10
+ 均可关闭,打开期间锁定页面滚动。
11
+ - 纯前端 DOM 增强(MutationObserver + rAF 合帧扫描),零运行时依赖,
12
+ 不注册工具、无服务端逻辑;服务端半边为空实现,以自带 bundle patch
13
+ 挂载进 profile(与 dsh-notify / dsh-plugin-image-tools 同款机制)。
14
+ - 测试:selfcheck 12 项纯函数用例 + 假 DOM 端到端冒烟 6 项
15
+ (按钮注入/幂等/短表跳过/浮窗开合/滚动还原/剪贴板复制)。
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-plugin-table-zoom contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,81 @@
1
+ # dsh-plugin-table-zoom
2
+
3
+ DeepSeek Harness (dsh) Web GUI 聊天表格浮窗插件。
4
+
5
+ 聊天里模型输出的 markdown 表格经常**很长(行数多)或很宽(横向溢出)**,
6
+ 在对话流里一次看不完:要上下滚动聊天、左右拖动才能看完。本插件在长表上方
7
+ 自动注入一个小按钮「⛶ 浮窗查看」,点击后弹出**独立可滚动的小浮窗**完整显示
8
+ 表格,并支持**一键复制为 Markdown**。
9
+
10
+ 纯前端 DOM 增强:不改核心包、不注册工具、无服务端逻辑、零运行时依赖。
11
+
12
+ ## 效果
13
+
14
+ - 长表(≥ 9 行含表头,或横向溢出超过 2px)表格下方出现右对齐的「⛶ 浮窗查看」按钮;
15
+ - 短表不打扰;
16
+ - 点击按钮弹出浮窗:标题显示「表格 · N 行 × M 列」,正文可独立滚动(横竖都行),
17
+ 关闭按钮 / Esc / 点击遮罩均可关闭,打开期间锁定聊天页滚动;
18
+ - 浮窗头部「复制为 Markdown」按钮一键复制整表(单元格内联换行折叠、管道符转义);
19
+ - 浮窗里展示的是**原表格的克隆**,聊天里的表格保持不变,也不会被重复增强。
20
+
21
+ ## 安装
22
+
23
+ 本插件是纯客户端插件,需作为 bundle 挂载进 dsh 的 Web profile
24
+ (与 dsh-notify / dsh-plugin-image-tools 同款机制)。
25
+
26
+ 1. 安装依赖(在 profile 目录,例如 `~/.dsh/profiles/web`):
27
+
28
+ ```bash
29
+ npm install dsh-plugin-table-zoom
30
+ # 或从 GitHub 直装
31
+ npm install github:Pasumao/dsh-plugin-table-zoom
32
+ ```
33
+
34
+ 2. 在 profile 的 `package.json` 的 `dsh.profile.bundles` 里加入该插件:
35
+
36
+ ```jsonc
37
+ "dsh": {
38
+ "profile": {
39
+ "bundles": [
40
+ /* ...已有的 bundle... */,
41
+ "dsh-plugin-table-zoom"
42
+ ]
43
+ }
44
+ }
45
+ ```
46
+
47
+ 3. 在 profile 目录执行 `pnpm install`(或 `npm install`);
48
+ 4. **重启 `dsh web`**(launcher 重新拉起),新插件才会进入浏览器 bundle。
49
+
50
+ > 若以本地目录开发调试,可用 `link:` 依赖替换第 1 步:
51
+ > `"dsh-plugin-table-zoom": "link:D:/path/to/dsh-plugin-table-zoom"`。
52
+
53
+ ## 工作原理
54
+
55
+ - 服务端半边(`lib/index.js`):空实现。插件以「自带 bundle patch」方式挂载进
56
+ profile,客户端半边由 `dsh.client` 声明经 `/plugins/dsh-plugin-table-zoom/client.js`
57
+ 送达浏览器(与 dsh-notify / dsh-plugin-image-tools 同款机制)。
58
+ - 客户端半边(`lib/client.js`):MutationObserver 观察 `document.body`,
59
+ rAF 合帧扫描 markdown 表格(核心渲染器把表格包在类名含 `tableScroll` 的
60
+ 容器里),对长表在容器后注入按钮行;按钮点击后命令式创建浮窗(复用
61
+ image-tools 的 lightbox 模式:纯 DOM 单节点变更,不插入/移除 React 管理的
62
+ 结构,重渲染时安全)。
63
+
64
+ ## 开发
65
+
66
+ ```sh
67
+ npm run selfcheck # 纯函数离线测试(识别/长表判定/Markdown 序列化)
68
+ npm run smoke # selfcheck + 假 DOM 端到端冒烟(按钮注入/浮窗开合/复制)
69
+ npm run pack # 打包
70
+ ```
71
+
72
+ ## 兼容性
73
+
74
+ - 目标:DeepSeek Harness Web GUI(dsh web),皮肤与主题 CSS 变量自适应;
75
+ - 依赖浏览器 `MutationObserver` / `requestAnimationFrame`,无 DOM 环境自动禁用;
76
+ - 不影响其他插件:只认 `tableScroll` 容器内的表格,跳过浮窗与 image-tools
77
+ lightbox 内的表格。
78
+
79
+ ## License
80
+
81
+ MIT
@@ -0,0 +1,7 @@
1
+ # dsh-plugin-table-zoom bundle patch: 挂载一行,交给 profile 的
2
+ # dsh.profile.bundles(与 dsh-notify / dsh-plugin-image-tools 同款机制)自动应用。
3
+ # `name` 必须等于 package.json 的 `name`(loader 按该名从 profile 的
4
+ # node_modules 解析插件模块)。
5
+ - insert:
6
+ - id: dsh-plugin-table-zoom
7
+ name: dsh-plugin-table-zoom
package/lib/client.js ADDED
@@ -0,0 +1,379 @@
1
+ window.__ModuleLoader__.load({
2
+ id: 'dsh-plugin-table-zoom',
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
7
+
8
+ // ------------------------------------------------------------------
9
+ // 样式:一次注入 <style>,类名 dstz-*,全部走主题 CSS 变量。
10
+ // ------------------------------------------------------------------
11
+ var CSS = [
12
+ '.dstz-row{display:flex;justify-content:flex-end;margin-top:2px}',
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
+ '.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
+ '.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)}',
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}',
20
+ '.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
+ '.dstz-title small{color:var(--dsw-alias-label-tertiary);font-size:12px;font-weight:400;margin-left:8px}',
22
+ '.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}',
24
+ '.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)}',
27
+ '.dstz-table tr:last-child td{border-bottom:none}',
28
+ '.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
+ '.dstz-iconButton:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}',
30
+ '.dstz-iconButton:focus-visible{outline:2px solid var(--dsw-alias-interactive-bg-active);outline-offset:1px}',
31
+ '.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
+ '.dstz-copyBtn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}',
33
+ '.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}}'
35
+ ].join('');
36
+ var tagId = 'dsh-plugin-table-zoom/zoom.module.css';
37
+ if (typeof document !== 'undefined' && document.querySelector('style[data-plugin-css=' + JSON.stringify(tagId) + ']') === null) {
38
+ var tag = document.createElement('style');
39
+ tag.dataset.plugin = 'dsh-plugin-table-zoom';
40
+ tag.dataset.pluginCss = tagId;
41
+ tag.textContent = CSS;
42
+ document.head.appendChild(tag);
43
+ }
44
+
45
+ // ------------------------------------------------------------------
46
+ // 常量与文案
47
+ // ------------------------------------------------------------------
48
+ /** 行数(含表头)达到该值即视为“长表”,注入浮窗按钮。 */
49
+ var MIN_ROWS = 9;
50
+ /** 超过该像素视为横向溢出(表太宽),也注入按钮。 */
51
+ var OVERFLOW_PX = 2;
52
+ /** markdown 表格容器类名片段(核心渲染器 tableScroll,CSS Module 哈希前缀)。 */
53
+ var TABLE_WRAP_HINT = 'tableScroll';
54
+
55
+ /** 页面语言是否中文(决定按钮/浮窗文案)。 */
56
+ function isZh() {
57
+ return typeof document !== 'undefined' && (document.documentElement.lang || '').toLowerCase().indexOf('zh') === 0;
58
+ }
59
+ var zh = isZh();
60
+ var LABELS = {
61
+ open: zh ? '\u6d6e\u7a97\u67e5\u770b' : 'Open in popup',
62
+ title: zh ? '\u8868\u683c' : 'Table',
63
+ rowsCols: zh ? '\u884c \u00d7 \u5217' : 'rows \u00d7 cols',
64
+ copy: zh ? '\u590d\u5236\u4e3a Markdown' : 'Copy as Markdown',
65
+ copied: zh ? '\u5df2\u590d\u5236' : 'Copied',
66
+ copyFailed: zh ? '\u590d\u5236\u5931\u8d25' : 'Copy failed',
67
+ close: zh ? '\u5173\u95ed' : 'Close'
68
+ };
69
+
70
+ // ------------------------------------------------------------------
71
+ // 纯函数(导出供 selfcheck / smoke 测试)
72
+ // ------------------------------------------------------------------
73
+
74
+ /**
75
+ * 判断一个 <table> 是否位于 markdown 表格容器内(核心渲染器把表格包在
76
+ * `.tableScroll` 样式的 div 里,类名为 CSS Module 哈希,用属性包含匹配)。
77
+ * @param {object} table - <table> 元素(或测试用假对象,须有 closest 方法)。
78
+ * @returns {boolean}
79
+ */
80
+ function isMarkdownTable(table) {
81
+ if (table === null || typeof table !== 'object' || typeof table.closest !== 'function') return false;
82
+ return table.closest('[class*="' + TABLE_WRAP_HINT + '"]') !== null;
83
+ }
84
+
85
+ /**
86
+ * 判断表格是否“长”(行数多或横向溢出)而值得注入浮窗按钮。
87
+ * @param {object} table - <table> 元素(须有 rows 数组/类数组,rows[0].cells)。
88
+ * @param {object} wrap - 表格容器(须有 scrollWidth / clientWidth)。
89
+ * @returns {boolean}
90
+ */
91
+ function isLongTable(table, wrap) {
92
+ if (table === null || typeof table !== 'object') return false;
93
+ var rows = table.rows;
94
+ var rowCount = rows === null || rows === void 0 ? 0 : rows.length;
95
+ if (rowCount >= MIN_ROWS) return true;
96
+ if (wrap !== null && typeof wrap === 'object') {
97
+ var sw = typeof wrap.scrollWidth === 'number' ? wrap.scrollWidth : 0;
98
+ var cw = typeof wrap.clientWidth === 'number' ? wrap.clientWidth : 0;
99
+ if (sw > cw + OVERFLOW_PX) return true;
100
+ }
101
+ return false;
102
+ }
103
+
104
+ /**
105
+ * 把 <table> 序列化为 Markdown 文本(单元格内联换行/管道转义),
106
+ * 供「复制为 Markdown」使用。纯函数,可离线测试。
107
+ * @param {object} table - <table> 元素(须有 rows,rows[i].cells,cell.textContent)。
108
+ * @returns {string}
109
+ */
110
+ function tableToMarkdown(table) {
111
+ if (table === null || typeof table !== 'object' || table.rows === null || table.rows === void 0) return '';
112
+ var rows = [];
113
+ for (var r = 0; r < table.rows.length; r++) {
114
+ var tr = table.rows[r];
115
+ var cells = tr.cells === null || tr.cells === void 0 ? [] : tr.cells;
116
+ var row = [];
117
+ for (var c = 0; c < cells.length; c++) {
118
+ var text = cells[c].textContent === null || cells[c].textContent === void 0 ? '' : String(cells[c].textContent);
119
+ row.push(text.replace(/\s*\n\s*/g, ' ').trim().replace(/\|/g, '\\|'));
120
+ }
121
+ rows.push(row);
122
+ }
123
+ if (rows.length === 0) return '';
124
+ var width = 0;
125
+ for (var i = 0; i < rows.length; i++) width = Math.max(width, rows[i].length);
126
+ var lines = [];
127
+ var pad = function (row) {
128
+ var out = [];
129
+ for (var j = 0; j < width; j++) out.push(j < row.length ? row[j] : '');
130
+ return out;
131
+ };
132
+ var line = function (row) { return '| ' + pad(row).join(' | ') + ' |'; };
133
+ lines.push(line(rows[0]));
134
+ var sep = [];
135
+ for (var k = 0; k < width; k++) sep.push('---');
136
+ lines.push('| ' + sep.join(' | ') + ' |');
137
+ for (var m = 1; m < rows.length; m++) lines.push(line(rows[m]));
138
+ return lines.join('\n');
139
+ }
140
+
141
+ // ------------------------------------------------------------------
142
+ // 浮窗:命令式 DOM(复用 image-tools lightbox 模式,不触碰 React 结构)
143
+ // ------------------------------------------------------------------
144
+ var activePopup = null;
145
+ var lastTrigger = null;
146
+
147
+ /** 关闭当前浮窗并还原页面滚动。 */
148
+ function closePopup() {
149
+ if (activePopup === null) return;
150
+ var popup = activePopup;
151
+ activePopup = null;
152
+ if (typeof popup._onKey === 'function') document.removeEventListener('keydown', popup._onKey);
153
+ popup.remove();
154
+ document.body.style.overflow = lastTrigger !== null && lastTrigger.bodyOverflow !== void 0 ? lastTrigger.bodyOverflow : '';
155
+ lastTrigger = null;
156
+ }
157
+
158
+ /** 复制文本到剪贴板(clipboard API,失败时 execCommand 兜底)。@returns {Promise<boolean>} */
159
+ function copyText(text) {
160
+ if (typeof navigator !== 'undefined' && navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
161
+ return navigator.clipboard.writeText(text).then(function () { return true; }, function () { return legacyCopy(text); });
162
+ }
163
+ return Promise.resolve(legacyCopy(text));
164
+ }
165
+
166
+ /** 旧式复制兜底(textarea + execCommand)。 */
167
+ function legacyCopy(text) {
168
+ if (typeof document === 'undefined') return false;
169
+ try {
170
+ var ta = document.createElement('textarea');
171
+ ta.value = text;
172
+ ta.setAttribute('readonly', '');
173
+ ta.style.position = 'fixed';
174
+ ta.style.top = '-9999px';
175
+ document.body.appendChild(ta);
176
+ ta.select();
177
+ var ok = document.execCommand('copy');
178
+ ta.remove();
179
+ return ok;
180
+ } catch (e) {
181
+ return false;
182
+ }
183
+ }
184
+
185
+ /**
186
+ * 打开表格浮窗:可滚动容器 + 表格克隆 + 头部(标题/行列数/复制/关闭)。
187
+ * 克隆而不是移动原表格,聊天里的表格保持不变。
188
+ * @param {object} table - 原始 <table> 元素。
189
+ */
190
+ function openPopup(table) {
191
+ if (typeof document === 'undefined' || table === null || typeof table !== 'object') return;
192
+ closePopup();
193
+
194
+ var overlay = document.createElement('div');
195
+ overlay.className = 'dstz-popup';
196
+ overlay.setAttribute('role', 'dialog');
197
+ overlay.setAttribute('aria-modal', 'true');
198
+ overlay.setAttribute('aria-label', LABELS.title);
199
+
200
+ var panel = document.createElement('div');
201
+ panel.className = 'dstz-panel';
202
+
203
+ var header = document.createElement('header');
204
+ header.className = 'dstz-header';
205
+ var title = document.createElement('h3');
206
+ title.className = 'dstz-title';
207
+ title.textContent = LABELS.title;
208
+ var rowCount = table.rows !== null && table.rows !== void 0 ? table.rows.length : 0;
209
+ var colCount = rowCount > 0 && table.rows[0].cells !== void 0 ? table.rows[0].cells.length : 0;
210
+ var small = document.createElement('small');
211
+ small.textContent = rowCount + ' ' + LABELS.rowsCols + ' ' + colCount;
212
+ title.appendChild(small);
213
+
214
+ var actions = document.createElement('div');
215
+ actions.className = 'dstz-headerActions';
216
+
217
+ var copyBtn = document.createElement('button');
218
+ copyBtn.type = 'button';
219
+ copyBtn.className = 'dstz-copyBtn';
220
+ copyBtn.textContent = LABELS.copy;
221
+ copyBtn.addEventListener('click', function () {
222
+ copyBtn.disabled = true;
223
+ copyText(tableToMarkdown(table)).then(function (ok) {
224
+ copyBtn.textContent = ok ? LABELS.copied : LABELS.copyFailed;
225
+ window.setTimeout(function () {
226
+ copyBtn.textContent = LABELS.copy;
227
+ copyBtn.disabled = false;
228
+ }, 1600);
229
+ });
230
+ });
231
+
232
+ var closeBtn = document.createElement('button');
233
+ closeBtn.type = 'button';
234
+ closeBtn.className = 'dstz-iconButton';
235
+ closeBtn.setAttribute('aria-label', LABELS.close);
236
+ closeBtn.title = LABELS.close;
237
+ closeBtn.textContent = '\u2715';
238
+ closeBtn.addEventListener('click', closePopup);
239
+
240
+ actions.appendChild(copyBtn);
241
+ actions.appendChild(closeBtn);
242
+ header.appendChild(title);
243
+ header.appendChild(actions);
244
+
245
+ var body = document.createElement('div');
246
+ body.className = 'dstz-body';
247
+ var clone = table.cloneNode(true);
248
+ clone.className = 'dstz-table';
249
+ body.appendChild(clone);
250
+
251
+ panel.appendChild(header);
252
+ panel.appendChild(body);
253
+ overlay.appendChild(panel);
254
+
255
+ lastTrigger = { bodyOverflow: document.body.style.overflow };
256
+ document.body.style.overflow = 'hidden';
257
+
258
+ var onKey = function (event) {
259
+ if (event.key === 'Escape') closePopup();
260
+ };
261
+ overlay.addEventListener('click', function (event) {
262
+ if (event.target === overlay) closePopup();
263
+ });
264
+ document.addEventListener('keydown', onKey);
265
+ overlay._onKey = onKey;
266
+
267
+ activePopup = overlay;
268
+ document.body.appendChild(overlay);
269
+ closeBtn.focus();
270
+ }
271
+
272
+ // ------------------------------------------------------------------
273
+ // 扫描与按钮注入
274
+ // ------------------------------------------------------------------
275
+
276
+ /** 为单个表格注入「浮窗查看」按钮(容器后插入一行,右对齐)。 */
277
+ function upgradeTable(table) {
278
+ if (typeof document === 'undefined') return null;
279
+ if (!isMarkdownTable(table)) return null;
280
+ // 跳过浮窗内部(克隆表)与图片 lightbox 内的表格
281
+ if (typeof table.closest === 'function' && table.closest('.dstz-popup') !== null) return null;
282
+ if (typeof table.closest === 'function' && table.closest('.dshpick-lightbox') !== null) return null;
283
+
284
+ var wrap = table.closest('[class*="' + TABLE_WRAP_HINT + '"]');
285
+ if (wrap === null || wrap === void 0) return null;
286
+ if (!isLongTable(table, wrap)) return null;
287
+
288
+ // 该表格容器后紧跟的兄弟已经是按钮行则不再重复插入
289
+ if (wrap.nextElementSibling !== null && wrap.nextElementSibling.classList !== void 0
290
+ && wrap.nextElementSibling.classList.contains('dstz-row')) {
291
+ if (table.dataset !== void 0) table.dataset.dstz = '1';
292
+ return null;
293
+ }
294
+
295
+ var parent = wrap.parentNode;
296
+ if (parent === null || parent === void 0) return null;
297
+
298
+ var row = document.createElement('div');
299
+ row.className = 'dstz-row';
300
+ var btn = document.createElement('button');
301
+ btn.type = 'button';
302
+ btn.className = 'dstz-btn';
303
+ btn.setAttribute('aria-label', LABELS.open);
304
+ btn.title = LABELS.open;
305
+ var icon = document.createElement('span');
306
+ icon.setAttribute('aria-hidden', 'true');
307
+ icon.textContent = '\u26F6';
308
+ var label = document.createElement('span');
309
+ label.textContent = LABELS.open;
310
+ btn.appendChild(icon);
311
+ btn.appendChild(label);
312
+ btn.addEventListener('click', function () { openPopup(table); });
313
+ row.appendChild(btn);
314
+
315
+ if (wrap.nextSibling !== null) {
316
+ parent.insertBefore(row, wrap.nextSibling);
317
+ } else {
318
+ parent.appendChild(row);
319
+ }
320
+ if (table.dataset !== void 0) table.dataset.dstz = '1';
321
+ return row;
322
+ }
323
+
324
+ /** 全量扫描:对每个候选表格执行 upgradeTable。 */
325
+ function upgradeTables() {
326
+ if (typeof document === 'undefined') return;
327
+ var tables = document.querySelectorAll('table');
328
+ for (var i = 0; i < tables.length; i++) upgradeTable(tables[i]);
329
+ }
330
+
331
+ /**
332
+ * 启动表格增强:MutationObserver 观察 body,rAF 合帧扫描。
333
+ * @returns {Function|null} 停止函数;无 DOM 环境返回 null。
334
+ */
335
+ function startEnhancer() {
336
+ if (typeof document === 'undefined' || typeof MutationObserver === 'undefined' || typeof requestAnimationFrame === 'undefined') return null;
337
+ var pending = false;
338
+ var scan = function () {
339
+ pending = false;
340
+ upgradeTables();
341
+ };
342
+ var observer = new MutationObserver(function () {
343
+ if (pending) return;
344
+ pending = true;
345
+ requestAnimationFrame(scan);
346
+ });
347
+ observer.observe(document.body, { childList: true, subtree: true });
348
+ scan();
349
+ return function () {
350
+ observer.disconnect();
351
+ closePopup();
352
+ };
353
+ }
354
+
355
+ // ------------------------------------------------------------------
356
+ // 插件入口
357
+ // ------------------------------------------------------------------
358
+ function apply(ctx) {
359
+ ctx.effect(function () {
360
+ return startEnhancer();
361
+ }, 'dsh-plugin-table-zoom: table popup enhancer');
362
+ }
363
+
364
+ exports.apply = apply;
365
+ exports.inject = [];
366
+ // 供自检/冒烟测试复用:纯函数与可测入口。
367
+ exports.MIN_ROWS = MIN_ROWS;
368
+ exports.isMarkdownTable = isMarkdownTable;
369
+ exports.isLongTable = isLongTable;
370
+ exports.tableToMarkdown = tableToMarkdown;
371
+ exports.openPopup = openPopup;
372
+ exports.closePopup = closePopup;
373
+ exports.copyText = copyText;
374
+ exports.upgradeTable = upgradeTable;
375
+ exports.upgradeTables = upgradeTables;
376
+ exports.startEnhancer = startEnhancer;
377
+ return module.exports;
378
+ }
379
+ });
package/lib/index.js ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * dsh-plugin-table-zoom — 聊天表格浮窗插件(服务端半边)
3
+ *
4
+ * 功能:聊天里 markdown 表格太长(行数多 / 横向超宽)导致一次看不完时,
5
+ * 在表格上方自动出现一个小按钮「⛶ 浮窗查看」;点击后弹出可滚动的小浮窗,
6
+ * 完整显示表格(独立于聊天滚动),并支持一键复制为 Markdown。
7
+ *
8
+ * 实现完全在客户端半边(lib/client.js):
9
+ * MutationObserver 扫描 DOM 中的 markdown 表格(`.tableScroll` 容器内的
10
+ * `<table>`),对长表注入按钮;按钮点击后命令式创建浮窗(复用 image-tools
11
+ * 的 lightbox 模式,纯 DOM 单节点变更,不触碰 React 管理的结构)。
12
+ * 服务端半边因此为空实现:插件以「自带 bundle patch」方式挂载进 profile,
13
+ * 客户端半边由 dsh.client 声明经 /plugins/<id>/client.js 送达浏览器。
14
+ *
15
+ * @module dsh-plugin-table-zoom
16
+ */
17
+
18
+ export const name = 'dsh-plugin-table-zoom'
19
+
20
+ /** 服务端不依赖任何宿主服务(所有逻辑在客户端半边)。 */
21
+ export const inject = []
22
+
23
+ /** 插件入口:空实现。详见文件头注释。 */
24
+ export function apply() {
25
+ // 无服务端注册项:浮窗协议是纯客户端交互(DOM 增强)。
26
+ }
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "dsh-plugin-table-zoom",
3
+ "description": "DSH 聊天表格浮窗插件:聊天里 markdown 表格太长(行多/超宽)看不完时,表格上方自动出现「浮窗查看」按钮,点击弹出可滚动的小浮窗完整显示表格,并支持一键复制为 Markdown。纯前端 DOM 增强,不改核心包、不注册工具、无服务端逻辑。",
4
+ "version": "0.1.0",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": "./lib/index.js",
9
+ "./client": "./lib/client.js",
10
+ "./package.json": "./package.json"
11
+ },
12
+ "files": [
13
+ "lib",
14
+ "scripts",
15
+ "cordis.patch.yml",
16
+ "CHANGELOG.md",
17
+ "README.md",
18
+ "LICENSE",
19
+ "设计说明.md"
20
+ ],
21
+ "scripts": {
22
+ "selfcheck": "node scripts/selfcheck.mjs",
23
+ "smoke": "node scripts/selfcheck.mjs && node scripts/smoke-client.mjs",
24
+ "pack": "npm pack",
25
+ "prepublishOnly": "npm run smoke"
26
+ },
27
+ "peerDependencies": {
28
+ "@deepseek-ai/cordis": "^4.0.1"
29
+ },
30
+ "engines": {
31
+ "node": ">=18"
32
+ },
33
+ "keywords": [
34
+ "dsh",
35
+ "dsh-plugin",
36
+ "deepseek",
37
+ "harness",
38
+ "cordis",
39
+ "plugin",
40
+ "table",
41
+ "markdown",
42
+ "popup",
43
+ "zoom",
44
+ "float"
45
+ ],
46
+ "repository": {
47
+ "type": "git",
48
+ "url": "git+https://github.com/Pasumao/dsh-plugin-table-zoom.git"
49
+ },
50
+ "homepage": "https://github.com/Pasumao/dsh-plugin-table-zoom#readme",
51
+ "bugs": {
52
+ "url": "https://github.com/Pasumao/dsh-plugin-table-zoom/issues"
53
+ },
54
+ "license": "MIT",
55
+ "dsh": {
56
+ "bundle": {
57
+ "patch": "./cordis.patch.yml"
58
+ },
59
+ "client": {
60
+ "inject": [],
61
+ "platform": "web"
62
+ }
63
+ }
64
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * dsh-plugin-table-zoom 自检:对 lib/client.js 导出的纯函数做离线冒烟测试。
3
+ * 运行:node scripts/selfcheck.mjs
4
+ */
5
+ import { readFileSync } from 'node:fs'
6
+ import { dirname, join } from 'node:path'
7
+ import { fileURLToPath } from 'node:url'
8
+ import { strict as assert } from 'node:assert'
9
+
10
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
11
+
12
+ // 1) 模拟浏览器模块加载器并物化 factory(本插件不依赖 react,无需 shim require)
13
+ let spec = null
14
+ globalThis.window = {
15
+ __ModuleLoader__: { load: (value) => { spec = value } },
16
+ }
17
+ const code = readFileSync(join(ROOT, 'lib', 'client.js'), 'utf8')
18
+ new Function(code)()
19
+ assert.ok(spec !== null, 'module loader not invoked')
20
+ assert.equal(spec.id, 'dsh-plugin-table-zoom')
21
+
22
+ const mod = spec.factory((name) => { throw new Error(`unexpected require: ${name}`) })
23
+ assert.equal(typeof mod.apply, 'function')
24
+ assert.deepEqual(mod.inject, [])
25
+ assert.equal(typeof mod.isMarkdownTable, 'function')
26
+ assert.equal(typeof mod.isLongTable, 'function')
27
+ assert.equal(typeof mod.tableToMarkdown, 'function')
28
+ assert.equal(typeof mod.openPopup, 'function')
29
+ assert.equal(typeof mod.closePopup, 'function')
30
+ assert.equal(typeof mod.copyText, 'function')
31
+
32
+ let passed = 0
33
+ function ok(name, fn) {
34
+ fn()
35
+ passed += 1
36
+ console.log(` ok ${name}`)
37
+ }
38
+
39
+ console.log('[dsh-plugin-table-zoom] selfcheck')
40
+
41
+ // --- isMarkdownTable:识别 markdown 表格容器(类名哈希含 tableScroll) ---
42
+ ok('isMarkdownTable 命中 tableScroll 容器', () => {
43
+ const table = { closest: (sel) => sel.includes('tableScroll') ? {} : null }
44
+ assert.equal(mod.isMarkdownTable(table), true)
45
+ })
46
+ ok('isMarkdownTable 拒绝普通表格', () => {
47
+ const table = { closest: () => null }
48
+ assert.equal(mod.isMarkdownTable(table), false)
49
+ })
50
+ ok('isMarkdownTable 拒绝 null / 无 closest', () => {
51
+ assert.equal(mod.isMarkdownTable(null), false)
52
+ assert.equal(mod.isMarkdownTable({}), false)
53
+ })
54
+
55
+ // --- isLongTable:行数多或横向溢出 ---
56
+ ok('isLongTable 行数达标', () => {
57
+ const table = { rows: new Array(mod.MIN_ROWS).fill({}) }
58
+ assert.equal(mod.isLongTable(table, { scrollWidth: 100, clientWidth: 100 }), true)
59
+ })
60
+ ok('isLongTable 行数少但横向溢出', () => {
61
+ const table = { rows: [{}, {}] }
62
+ assert.equal(mod.isLongTable(table, { scrollWidth: 500, clientWidth: 300 }), true)
63
+ })
64
+ ok('isLongTable 都未达标', () => {
65
+ const table = { rows: [{}, {}] }
66
+ assert.equal(mod.isLongTable(table, { scrollWidth: 100, clientWidth: 100 }), false)
67
+ })
68
+ ok('isLongTable 缺 wrap 时只看行数', () => {
69
+ const table = { rows: new Array(mod.MIN_ROWS).fill({}) }
70
+ assert.equal(mod.isLongTable(table, null), true)
71
+ assert.equal(mod.isLongTable({ rows: [{}] }, null), false)
72
+ })
73
+
74
+ // --- tableToMarkdown:单元格转义 / 内联换行 / 对齐 ---
75
+ ok('tableToMarkdown 基础表格', () => {
76
+ const table = {
77
+ rows: [
78
+ { cells: [{ textContent: '名称' }, { textContent: '数值' }] },
79
+ { cells: [{ textContent: '甲' }, { textContent: '12' }] },
80
+ { cells: [{ textContent: '乙' }, { textContent: '34' }] },
81
+ ],
82
+ }
83
+ assert.equal(
84
+ mod.tableToMarkdown(table),
85
+ '| 名称 | 数值 |\n| --- | --- |\n| 甲 | 12 |\n| 乙 | 34 |',
86
+ )
87
+ })
88
+ ok('tableToMarkdown 转义管道符', () => {
89
+ const table = {
90
+ rows: [
91
+ { cells: [{ textContent: 'A|B' }] },
92
+ { cells: [{ textContent: 'C' }] },
93
+ ],
94
+ }
95
+ const out = mod.tableToMarkdown(table)
96
+ assert.ok(out.includes('A\\|B'), out)
97
+ })
98
+ ok('tableToMarkdown 折叠内联换行', () => {
99
+ const table = {
100
+ rows: [
101
+ { cells: [{ textContent: '多\n行' }] },
102
+ ],
103
+ }
104
+ assert.ok(mod.tableToMarkdown(table).includes('多 行'))
105
+ })
106
+ ok('tableToMarkdown 补齐短行', () => {
107
+ const table = {
108
+ rows: [
109
+ { cells: [{ textContent: 'a' }, { textContent: 'b' }] },
110
+ { cells: [{ textContent: 'c' }] },
111
+ ],
112
+ }
113
+ const out = mod.tableToMarkdown(table)
114
+ assert.ok(out.includes('| c | |'), out)
115
+ })
116
+ ok('tableToMarkdown 空表', () => {
117
+ assert.equal(mod.tableToMarkdown({ rows: [] }), '')
118
+ assert.equal(mod.tableToMarkdown(null), '')
119
+ })
120
+
121
+ console.log(`[dsh-plugin-table-zoom] selfcheck: ${passed} passed`)
@@ -0,0 +1,254 @@
1
+ /**
2
+ * dsh-plugin-table-zoom 客户端冒烟测试:
3
+ * 用 Node 模拟浏览器模块加载器执行 lib/client.js,再用一个最小假 DOM
4
+ * 验证端到端行为:
5
+ * 1. 模块加载、apply/inject 导出正常;
6
+ * 2. upgradeTable 在长表容器后注入「浮窗查看」按钮行,短表不注入;
7
+ * 3. openPopup 创建浮窗(标题含行列数、表格克隆、关闭按钮),
8
+ * closePopup 关闭并还原 body 滚动;
9
+ * 4. 表格克隆在浮窗内不会被再次增强(跳过 .dstz-popup)。
10
+ *
11
+ * 运行:node scripts/smoke-client.mjs
12
+ */
13
+ import { readFileSync } from 'node:fs'
14
+ import { dirname, join } from 'node:path'
15
+ import { fileURLToPath } from 'node:url'
16
+ import { strict as assert } from 'node:assert'
17
+
18
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // 最小假 DOM(够 upgradeTable / openPopup / closePopup 使用)
22
+ // ---------------------------------------------------------------------------
23
+ function makeEl(tag) {
24
+ const el = {
25
+ tagName: tag.toUpperCase(),
26
+ children: [],
27
+ parentNode: null,
28
+ className: '',
29
+ dataset: {},
30
+ style: {},
31
+ _text: '',
32
+ attributes: {},
33
+ listeners: {},
34
+ _onKey: null,
35
+ setAttribute(k, v) { this.attributes[k] = String(v) },
36
+ getAttribute(k) { return this.attributes[k] ?? null },
37
+ appendChild(child) {
38
+ this.children.push(child)
39
+ child.parentNode = this
40
+ return child
41
+ },
42
+ insertBefore(child, ref) {
43
+ const idx = this.children.indexOf(ref)
44
+ if (idx < 0) this.children.push(child)
45
+ else this.children.splice(idx, 0, child)
46
+ child.parentNode = this
47
+ return child
48
+ },
49
+ remove() {
50
+ if (this.parentNode) {
51
+ const idx = this.parentNode.children.indexOf(this)
52
+ if (idx >= 0) this.parentNode.children.splice(idx, 1)
53
+ this.parentNode = null
54
+ }
55
+ },
56
+ addEventListener(type, fn) { (this.listeners[type] ??= []).push(fn) },
57
+ removeEventListener(type, fn) {
58
+ const arr = this.listeners[type]
59
+ if (!arr) return
60
+ const idx = arr.indexOf(fn)
61
+ if (idx >= 0) arr.splice(idx, 1)
62
+ },
63
+ dispatch(type, event = {}) {
64
+ const arr = this.listeners[type]
65
+ if (!arr) return
66
+ for (const fn of [...arr]) fn({ target: this, stopPropagation() {}, ...event })
67
+ },
68
+ focus() { this._focused = true },
69
+ cloneNode(deep) {
70
+ const copy = makeEl(this.tagName)
71
+ copy.className = this.className
72
+ copy._text = this._text
73
+ copy.dataset = { ...this.dataset }
74
+ if (deep) {
75
+ copy.children = this.children.map((c) => c.cloneNode(true))
76
+ for (const c of copy.children) c.parentNode = copy
77
+ }
78
+ return copy
79
+ },
80
+ querySelector(sel) {
81
+ for (const c of this.children) {
82
+ if (c.matches && c.matches(sel)) return c
83
+ const hit = c.querySelector ? c.querySelector(sel) : null
84
+ if (hit) return hit
85
+ }
86
+ return null
87
+ },
88
+ matches(sel) {
89
+ if (sel.startsWith('.')) return this.className.split(/\s+/).includes(sel.slice(1))
90
+ return false
91
+ },
92
+ }
93
+ Object.defineProperty(el, 'textContent', {
94
+ get() {
95
+ return this._text + this.children.map((c) => c.textContent).join('')
96
+ },
97
+ set(v) { this._text = String(v) },
98
+ })
99
+ Object.defineProperty(el, 'nextElementSibling', {
100
+ get() {
101
+ if (!this.parentNode) return null
102
+ const siblings = this.parentNode.children
103
+ const idx = siblings.indexOf(this)
104
+ if (idx < 0) return null
105
+ return siblings[idx + 1] ?? null
106
+ },
107
+ })
108
+ Object.defineProperty(el, 'classList', {
109
+ value: {
110
+ contains: (c) => el.className.split(/\s+/).includes(c),
111
+ add: (...cs) => { for (const c of cs) if (!el.classList.contains(c)) el.className += (el.className ? ' ' : '') + c },
112
+ remove: (...cs) => {
113
+ const keep = el.className.split(/\s+/).filter((c) => !cs.includes(c))
114
+ el.className = keep.join(' ')
115
+ },
116
+ },
117
+ })
118
+ return el
119
+ }
120
+
121
+ const documentEl = {
122
+ documentElement: { lang: 'zh-CN' },
123
+ head: makeEl('head'),
124
+ body: makeEl('body'),
125
+ createElement: (tag) => makeEl(tag),
126
+ querySelector: () => null,
127
+ querySelectorAll: () => [],
128
+ addEventListener() {},
129
+ removeEventListener() {},
130
+ }
131
+ globalThis.document = documentEl
132
+ globalThis.window = { setTimeout: (fn) => fn() }
133
+ Object.defineProperty(globalThis, 'navigator', {
134
+ value: { clipboard: { writeText: async (t) => { globalThis.__copied = t } } },
135
+ configurable: true,
136
+ })
137
+ globalThis.requestAnimationFrame = (fn) => fn()
138
+
139
+ let passed = 0
140
+ function ok(name, fn) {
141
+ fn()
142
+ passed += 1
143
+ console.log(` ok ${name}`)
144
+ }
145
+
146
+ console.log('[dsh-plugin-table-zoom] smoke-client')
147
+
148
+ // 1) 模块加载与导出
149
+ let spec = null
150
+ globalThis.window.__ModuleLoader__ = { load: (value) => { spec = value } }
151
+ const code = readFileSync(join(ROOT, 'lib', 'client.js'), 'utf8')
152
+ new Function(code)()
153
+ assert.ok(spec !== null, 'module loader not invoked')
154
+ assert.equal(spec.id, 'dsh-plugin-table-zoom')
155
+ const mod = spec.factory((name) => { throw new Error(`unexpected require: ${name}`) })
156
+ assert.equal(typeof mod.apply, 'function')
157
+ assert.equal(typeof mod.upgradeTable, 'function')
158
+ assert.equal(typeof mod.upgradeTables, 'function')
159
+ assert.equal(typeof mod.openPopup, 'function')
160
+ assert.equal(typeof mod.closePopup, 'function')
161
+ assert.equal(typeof mod.startEnhancer, 'function')
162
+ assert.equal(typeof mod.tableToMarkdown, 'function')
163
+
164
+ // 2) 长表注入按钮行
165
+ ok('upgradeTable 为长表注入按钮行', () => {
166
+ const parent = makeEl('div')
167
+ const wrap = makeEl('div')
168
+ wrap.className = 'x_tableScroll_y'
169
+ wrap.scrollWidth = 100
170
+ wrap.clientWidth = 100
171
+ const table = makeEl('table')
172
+ table.closest = (sel) => sel.includes('tableScroll') ? wrap : null
173
+ table.rows = new Array(mod.MIN_ROWS).fill({ cells: [] })
174
+ parent.appendChild(wrap)
175
+ const row = mod.upgradeTable(table)
176
+ assert.ok(row !== null)
177
+ assert.ok(row.classList.contains('dstz-row'))
178
+ assert.equal(row.children[0].className, 'dstz-btn')
179
+ assert.equal(table.dataset.dstz, '1')
180
+ // 幂等:再次调用不重复插入
181
+ const again = mod.upgradeTable(table)
182
+ assert.equal(again, null)
183
+ assert.equal(parent.children.filter((c) => c.classList.contains('dstz-row')).length, 1)
184
+ })
185
+
186
+ ok('upgradeTable 跳过短表', () => {
187
+ const parent = makeEl('div')
188
+ const wrap = makeEl('div')
189
+ wrap.className = 'x_tableScroll_y'
190
+ wrap.scrollWidth = 100
191
+ wrap.clientWidth = 100
192
+ const table = makeEl('table')
193
+ table.closest = (sel) => sel.includes('tableScroll') ? wrap : null
194
+ table.rows = [{ cells: [] }, { cells: [] }]
195
+ parent.appendChild(wrap)
196
+ assert.equal(mod.upgradeTable(table), null)
197
+ })
198
+
199
+ ok('upgradeTable 跳过浮窗内克隆表', () => {
200
+ const popup = makeEl('div')
201
+ popup.className = 'dstz-popup'
202
+ const wrap = makeEl('div')
203
+ wrap.className = 'x_tableScroll_y'
204
+ const table = makeEl('table')
205
+ table.closest = (sel) => {
206
+ if (sel.includes('tableScroll')) return wrap
207
+ if (sel.startsWith('.dstz-popup')) return popup
208
+ return null
209
+ }
210
+ table.rows = new Array(mod.MIN_ROWS).fill({ cells: [] })
211
+ popup.appendChild(wrap)
212
+ assert.equal(mod.upgradeTable(table), null)
213
+ })
214
+
215
+ // 3) 浮窗开合
216
+ ok('openPopup 创建浮窗并克隆表格', () => {
217
+ const table = makeEl('table')
218
+ const row1 = makeEl('tr'); row1.cells = [{ textContent: '列A' }, { textContent: '列B' }]
219
+ const row2 = makeEl('tr'); row2.cells = [{ textContent: '1' }, { textContent: '2' }]
220
+ table.rows = [row1, row2]
221
+ table.appendChild(row1)
222
+ table.appendChild(row2)
223
+ mod.openPopup(table)
224
+ const popup = documentEl.body.children.find((c) => c.className === 'dstz-popup')
225
+ assert.ok(popup !== undefined, 'popup not appended')
226
+ const panel = popup.children[0]
227
+ assert.equal(panel.className, 'dstz-panel')
228
+ const titleText = panel.children[0].textContent
229
+ assert.ok(titleText.includes('2'), 'title should include row count: ' + titleText)
230
+ const clone = panel.children[1].children[0]
231
+ assert.equal(clone.className, 'dstz-table')
232
+ assert.equal(clone.children.length, 2, 'clone should carry both rows')
233
+ assert.equal(documentEl.body.style.overflow, 'hidden', 'body scroll locked')
234
+ })
235
+
236
+ ok('closePopup 关闭浮窗并还原滚动', () => {
237
+ mod.closePopup() // 清掉上一条用例遗留的浮窗
238
+ documentEl.body.style.overflow = 'auto'
239
+ const table = makeEl('table')
240
+ table.rows = []
241
+ mod.openPopup(table)
242
+ assert.equal(documentEl.body.style.overflow, 'hidden')
243
+ mod.closePopup()
244
+ assert.equal(documentEl.body.children.filter((c) => c.className === 'dstz-popup').length, 0)
245
+ assert.equal(documentEl.body.style.overflow, 'auto')
246
+ })
247
+
248
+ // 4) 复制为 Markdown(走 navigator.clipboard 假实现)
249
+ ok('copyText 写入剪贴板', async () => {
250
+ await mod.copyText('| a |\n| --- |')
251
+ assert.equal(globalThis.__copied, '| a |\n| --- |')
252
+ })
253
+
254
+ console.log(`[dsh-plugin-table-zoom] smoke-client: ${passed} passed`)
@@ -0,0 +1,53 @@
1
+ # dsh-plugin-table-zoom 设计说明
2
+
3
+ ## 目标
4
+
5
+ 给 dsh Web GUI 聊天增加一项能力:模型输出的 markdown 表格太长/太宽一次看不完时,
6
+ 表格下方出现「浮窗查看」按钮,点击弹出独立可滚动的小浮窗完整显示表格,并可一键
7
+ 复制为 Markdown。
8
+
9
+ ## 现状(原生链路)
10
+
11
+ ```
12
+ 模型文本(markdown 表格)
13
+ → 核心渲染器 MarkdownText(micromark 直接 mdast→React)
14
+ → renderTable:<div class="…tableScroll"><table>…</table></div>
15
+ → 长表在对话流里:整条消息很高,表格行数多时上下滚动才能看完;
16
+ 表很宽时 tableScroll 容器横向滚动,但视野受限。
17
+ ```
18
+
19
+ ## 关键约束
20
+
21
+ - 核心 MarkdownText 是 frozen 静态模块(`dsh-client-ui-primitives`),**无法包装
22
+ MarkdownText**,消息渲染也没有 content 级 slot。因此按钮注入走「DOM 渐进增强」
23
+ 而不是改渲染器——与 dsh-plugin-image-tools 的回复内嵌图片增强同一模式:
24
+ `MutationObserver` + `requestAnimationFrame` 合帧扫描,命令式 DOM 单节点变更,
25
+ 不插入/移除 React 管理的结构,重渲染时安全。
26
+ - 表格容器类名是 CSS Module 哈希(如 `_tableScroll_1nba0_174`),**不能用固定类名
27
+ 匹配**,改用属性包含匹配 `[class*="tableScroll"]`(核心渲染器恒定使用该片段)。
28
+ - 浮窗展示的是 `table.cloneNode(true)` 克隆,不是移动原表格:聊天里的表格保持
29
+ 不变,且克隆表位于 `.dstz-popup` 内,扫描器会跳过,不会被重复增强。
30
+ - 短表不打扰:行数 ≥ 9(含表头)或横向溢出 > 2px 才注入按钮。
31
+
32
+ ## 实现结构
33
+
34
+ ```
35
+ lib/index.js 服务端半边:空实现(纯客户端插件,自带 bundle patch 挂载)
36
+ lib/client.js 客户端半边:样式注入 + 纯函数(识别/长表判定/Markdown 序列化)
37
+ + 按钮注入 + 浮窗(开合/滚动锁定/复制)
38
+ scripts/selfcheck.mjs 纯函数离线测试
39
+ scripts/smoke-client.mjs 假 DOM 端到端冒烟(按钮注入/浮窗开合/复制)
40
+ cordis.patch.yml 挂载一行,交 profile 的 dsh.profile.bundles 自动应用
41
+ ```
42
+
43
+ ## 浮窗行为
44
+
45
+ - 打开:锁定 body 滚动;Esc / 关闭按钮 / 点击遮罩关闭并还原滚动;
46
+ - 头部:标题「表格 · N 行 × M 列」+「复制为 Markdown」+ 关闭按钮;
47
+ - 正文:独立滚动(横竖),表格克隆带 `dstz-table` 类,样式全部走主题 CSS 变量。
48
+
49
+ ## 边界与不做的事
50
+
51
+ - 只认 `tableScroll` 容器内的表格;浮窗内克隆表与 image-tools lightbox 内的表格跳过;
52
+ - 不提供服务端工具/路由/配置;无 React 依赖(零运行时依赖);
53
+ - 复制为 Markdown 优先 clipboard API,失败时 execCommand 兜底。