dsh-plugin-image-tools 0.4.1 → 0.6.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/README.md +37 -0
- package/lib/client.js +501 -34
- package/package.json +2 -2
- package/scripts/smoke-client.mjs +55 -0
package/README.md
CHANGED
|
@@ -93,6 +93,15 @@ cordis.patch.yml bundle 补丁(挂载行)
|
|
|
93
93
|
docs/ 效果图(README 展示用)
|
|
94
94
|
```
|
|
95
95
|
|
|
96
|
+
## 配置
|
|
97
|
+
|
|
98
|
+
无需任何配置,安装即用:
|
|
99
|
+
|
|
100
|
+
- 不读取环境变量,不需要 API Key / token,不写配置文件;
|
|
101
|
+
- 图片来源三种形态(本地路径 / http(s) URL / base64 data URI)直接可用,无白名单配置;
|
|
102
|
+
- 图片内联预览走同源字节路由(loopback),无外部服务依赖;
|
|
103
|
+
- 包自带 `cordis.patch.yml` 挂载行,经 `dsh.profile.bundles` 自动应用,无需手动改配置。
|
|
104
|
+
|
|
96
105
|
## 安装
|
|
97
106
|
|
|
98
107
|
```powershell
|
|
@@ -102,6 +111,15 @@ dsh plugin --profile web add dsh-plugin-image-tools
|
|
|
102
111
|
dsh plugin --profile web add github:Pasumao/dsh-plugin-image-tools
|
|
103
112
|
```
|
|
104
113
|
|
|
114
|
+
源码安装(本地开发 / 调试):
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
git clone https://github.com/Pasumao/dsh-plugin-image-tools.git
|
|
118
|
+
cd dsh-plugin-image-tools
|
|
119
|
+
npm install
|
|
120
|
+
# 以 link: 方式挂载进 profile
|
|
121
|
+
```
|
|
122
|
+
|
|
105
123
|
装完重启 dsh(launcher),然后刷新浏览器页面。包自带 `cordis.patch.yml` 挂载行,
|
|
106
124
|
经 `dsh.profile.bundles` 自动应用(与 dsh-notify 同机制),无需手动改配置。
|
|
107
125
|
|
|
@@ -163,6 +181,25 @@ dsh plugin --profile web add github:Pasumao/dsh-plugin-image-tools
|
|
|
163
181
|
- 内嵌图片的 markdown URL 是绝对地址(`http://host:port`,由服务端监听配置推导);
|
|
164
182
|
若 GUI 经过反向代理/换端口访问,历史消息里的图片地址可能失效(同选择卡的限制)。
|
|
165
183
|
|
|
184
|
+
## 相关插件
|
|
185
|
+
|
|
186
|
+
本插件属于 **Pasumao 的 dsh 插件生态**,同系列已发布插件可搭配使用:
|
|
187
|
+
|
|
188
|
+
| 插件(npm) | GitHub | 说明 |
|
|
189
|
+
|---|---|---|
|
|
190
|
+
| [dsh-notify](https://www.npmjs.com/package/dsh-notify) | [GitHub 仓库](https://github.com/Pasumao/dsh-plugin-notify) | Windows 原生通知 + 系统托盘 |
|
|
191
|
+
| [dsh-plugin-choice-refresh](https://www.npmjs.com/package/dsh-plugin-choice-refresh) | [GitHub 仓库](https://github.com/Pasumao/dsh-plugin-choice-refresh) | 选择增强:重新生成选项 / 更多选项 |
|
|
192
|
+
| [dsh-plugin-dev-kb](https://www.npmjs.com/package/dsh-plugin-dev-kb) | [GitHub 仓库](https://github.com/Pasumao/dsh-plugin-dev-kb) | 插件开发知识库(官方文档完整镜像 + 技能) |
|
|
193
|
+
| [dsh-plugin-table-zoom](https://www.npmjs.com/package/dsh-plugin-table-zoom) | [GitHub 仓库](https://github.com/Pasumao/dsh-plugin-table-zoom) | 聊天长表格浮窗查看 + 一键复制 Markdown |
|
|
194
|
+
| [dsh-plugin-workbench](https://www.npmjs.com/package/dsh-plugin-workbench) | [GitHub 仓库](https://github.com/Pasumao/dsh-plugin-workbench) | VS Code 风格文件浏览器 + 可编辑预览 |
|
|
195
|
+
|
|
196
|
+
> 本系列其余插件见 [Pasumao · dsh 插件](https://github.com/Pasumao);觉得好用欢迎到 GitHub 点 ⭐。
|
|
197
|
+
|
|
198
|
+
## AI 生成声明
|
|
199
|
+
|
|
200
|
+
代码与文档由 AI 辅助生成(DeepSeek Harness),均经人工审查与实机验证
|
|
201
|
+
(`npm run smoke`:selfcheck + 假 ctx 服务端全链路 + 假客户端渲染)。
|
|
202
|
+
|
|
166
203
|
## 许可证
|
|
167
204
|
|
|
168
205
|
[MIT](./LICENSE)
|
package/lib/client.js
CHANGED
|
@@ -70,15 +70,21 @@ window.__ModuleLoader__.load({
|
|
|
70
70
|
'.dshpick-btn-outline:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}',
|
|
71
71
|
'.dshpick-btn-primary{background:var(--dsw-alias-interactive-bg-active);color:var(--dsw-alias-label-primary-inverted)}',
|
|
72
72
|
'.dshpick-btn-primary:hover:not(:disabled){filter:brightness(1.08)}',
|
|
73
|
-
'.dshpick-lightbox{position:fixed;inset:0;z-index:9999;background:rgba(8,10,18,.86);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;padding:52px 24px 28px;cursor:zoom-out}',
|
|
74
|
-
'.dshpick-lightboxClose{position:absolute;top:16px;right:18px;width:34px;height:34px;border:1px solid var(--dsw-alias-border-l2-darkmode-thin);border-radius:999px;background:rgba(255,255,255,.08);color:#fff;cursor:pointer;place-items:center;display:grid;font-size:16px;line-height:1;padding:0}',
|
|
73
|
+
'.dshpick-lightbox{position:fixed;inset:0;z-index:9999;background:rgba(8,10,18,.86);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;padding:52px 24px 28px;cursor:zoom-out;overflow:hidden}',
|
|
74
|
+
'.dshpick-lightboxClose{position:absolute;top:16px;right:18px;width:34px;height:34px;border:1px solid var(--dsw-alias-border-l2-darkmode-thin);border-radius:999px;background:rgba(255,255,255,.08);color:#fff;cursor:pointer;place-items:center;display:grid;font-size:16px;line-height:1;padding:0;z-index:2}',
|
|
75
75
|
'.dshpick-lightboxClose:hover{background:rgba(255,255,255,.18)}',
|
|
76
|
-
'.dshpick-lightboxFigure{margin:0;max-width:min(92vw,1100px);display:flex;flex-direction:column;align-items:center;gap:10px}',
|
|
77
|
-
'.dshpick-lightboxFigure
|
|
76
|
+
'.dshpick-lightboxFigure{margin:0;max-width:min(92vw,1100px);display:flex;flex-direction:column;align-items:center;gap:10px;transform-origin:0 0;will-change:transform;cursor:zoom-in}',
|
|
77
|
+
'.dshpick-lightboxFigure.dshpick-grabbing{cursor:grabbing;user-select:none}',
|
|
78
|
+
'.dshpick-lightboxFigure img{max-width:100%;max-height:82vh;object-fit:contain;border-radius:8px;background:#fff;box-shadow:0 8px 40px rgba(0,0,0,.5);cursor:inherit}',
|
|
79
|
+
'.dshpick-zoomBar{position:absolute;left:50%;bottom:14px;transform:translateX(-50%);z-index:2;display:flex;align-items:center;gap:2px;padding:4px 6px;border:1px solid var(--dsw-alias-border-l2-darkmode-thin);border-radius:999px;background:rgba(8,10,18,.72);box-shadow:0 4px 16px rgba(0,0,0,.35)}',
|
|
80
|
+
'.dshpick-zoomBtn{width:28px;height:28px;border:none;border-radius:999px;background:transparent;color:#fff;cursor:pointer;display:grid;place-items:center;padding:0;font-size:15px;line-height:1}',
|
|
81
|
+
'.dshpick-zoomBtn:hover{background:rgba(255,255,255,.16)}',
|
|
82
|
+
'.dshpick-zoomPct{min-width:46px;text-align:center;color:#fff;font-size:12px;line-height:18px;font-variant-numeric:tabular-nums}',
|
|
78
83
|
'.dshpick-lightboxCap{color:var(--dsw-alias-label-primary);font-size:13px;line-height:18px;text-align:center;overflow-wrap:anywhere}',
|
|
79
84
|
'.dshpick-lightboxCap small{display:block;color:var(--dsw-alias-label-secondary);font-size:11px;line-height:15px;margin-top:2px}',
|
|
80
85
|
'.dshimg-inline{max-width:min(100%,560px);max-height:420px;width:auto;height:auto;border-radius:14px;border:1px solid var(--dsw-alias-border-l2-darkmode-thin);background:var(--dsw-alias-interactive-bg-hover);margin:8px 0;display:block;cursor:zoom-in;object-fit:contain}',
|
|
81
86
|
'.dshimg-inline[data-failed]{opacity:.45;cursor:default;filter:grayscale(1)}',
|
|
87
|
+
'.dshimg-zoom{cursor:zoom-in}',
|
|
82
88
|
'@media (width<=720px){.dshpick-card{border-radius:16px}.dshpick-cards{grid-template-columns:repeat(auto-fill,minmax(104px,1fr))}.dshpick-lightbox{padding:44px 12px 20px}}'
|
|
83
89
|
].join('');
|
|
84
90
|
var tagId = 'dsh-plugin-image-tools/pick.module.css';
|
|
@@ -287,6 +293,8 @@ window.__ModuleLoader__.load({
|
|
|
287
293
|
* 图片放大查看层。zoom 为 null 时渲染 null。
|
|
288
294
|
* 打开期间:锁定 body 滚动、焦点移到关闭按钮、Esc 关闭;
|
|
289
295
|
* 关闭时恢复滚动并把焦点还给触发按钮(触发按钮 ref 由父级持有,这里不处理)。
|
|
296
|
+
* 支持滚轮/按钮缩放、双击切换 1x/2.5x、放大后拖拽平移(与命令式
|
|
297
|
+
* openImageZoom 同一套缩放参数与样式)。
|
|
290
298
|
* @param {object} props - { zoom: { src, label, description? }|null, onClose, t }。
|
|
291
299
|
*/
|
|
292
300
|
function Lightbox(props) {
|
|
@@ -294,23 +302,146 @@ window.__ModuleLoader__.load({
|
|
|
294
302
|
var onClose = props.onClose;
|
|
295
303
|
var t = props.t;
|
|
296
304
|
var closeRef = react.useRef(null);
|
|
305
|
+
var overlayRef = react.useRef(null);
|
|
306
|
+
var figureRef = react.useRef(null);
|
|
307
|
+
var imgRef = react.useRef(null);
|
|
308
|
+
var stateRef = react.useRef({ scale: 1, tx: 0, ty: 0 });
|
|
309
|
+
var pctState = react.useState('100%');
|
|
310
|
+
var pct = pctState[0];
|
|
311
|
+
var setPct = pctState[1];
|
|
312
|
+
var labels = react.useMemo(zoomLabels, []);
|
|
313
|
+
var apiRef = react.useRef({ zoomBy: function () {}, zoomTo: function () {} });
|
|
314
|
+
|
|
315
|
+
function viewSize() {
|
|
316
|
+
return { viewW: window.innerWidth, viewH: window.innerHeight };
|
|
317
|
+
}
|
|
318
|
+
function currentBase() {
|
|
319
|
+
var img = imgRef.current;
|
|
320
|
+
var nw = img === null ? 0 : img.naturalWidth;
|
|
321
|
+
var nh = img === null ? 0 : img.naturalHeight;
|
|
322
|
+
if (!nw || !nh) return null;
|
|
323
|
+
var view = viewSize();
|
|
324
|
+
return fitBaseSize(nw, nh, view.viewW, view.viewH);
|
|
325
|
+
}
|
|
326
|
+
function applyZoom() {
|
|
327
|
+
var state = stateRef.current;
|
|
328
|
+
var figure = figureRef.current;
|
|
329
|
+
if (figure === null) return;
|
|
330
|
+
var base = currentBase();
|
|
331
|
+
if (base === null) {
|
|
332
|
+
figure.style.transform = '';
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
var pan = clampPan(state.tx, state.ty, state.scale, base, viewSize());
|
|
336
|
+
state.tx = pan.tx;
|
|
337
|
+
state.ty = pan.ty;
|
|
338
|
+
figure.style.transform = (state.scale === 1 && state.tx === 0 && state.ty === 0)
|
|
339
|
+
? ''
|
|
340
|
+
: 'translate(' + state.tx + 'px,' + state.ty + 'px) scale(' + state.scale + ')';
|
|
341
|
+
setPct(Math.round(state.scale * 100) + '%');
|
|
342
|
+
}
|
|
343
|
+
function zoomBy(factor, clientX, clientY) {
|
|
344
|
+
var state = stateRef.current;
|
|
345
|
+
var base = currentBase();
|
|
346
|
+
if (base === null) return;
|
|
347
|
+
var view = viewSize();
|
|
348
|
+
var nextScale = clampZoom(state.scale * factor);
|
|
349
|
+
if (nextScale === state.scale) return;
|
|
350
|
+
var bx = (view.viewW - base.w0) / 2;
|
|
351
|
+
var by = (view.viewH - base.h0) / 2;
|
|
352
|
+
var cx, cy;
|
|
353
|
+
if (clientX === void 0) {
|
|
354
|
+
cx = state.tx + (base.w0 * state.scale) / 2;
|
|
355
|
+
cy = state.ty + (base.h0 * state.scale) / 2;
|
|
356
|
+
} else {
|
|
357
|
+
cx = clientX - bx;
|
|
358
|
+
cy = clientY - by;
|
|
359
|
+
}
|
|
360
|
+
var next = zoomTranslate(state.tx, state.ty, state.scale, nextScale, cx, cy);
|
|
361
|
+
state.tx = next.tx;
|
|
362
|
+
state.ty = next.ty;
|
|
363
|
+
state.scale = nextScale;
|
|
364
|
+
applyZoom();
|
|
365
|
+
}
|
|
366
|
+
function zoomTo(target) {
|
|
367
|
+
zoomBy(target / stateRef.current.scale);
|
|
368
|
+
}
|
|
369
|
+
apiRef.current = { zoomBy: zoomBy, zoomTo: zoomTo };
|
|
370
|
+
|
|
297
371
|
react.useEffect(function () {
|
|
298
372
|
if (zoom === null) return;
|
|
299
373
|
var previous = document.body.style.overflow;
|
|
300
374
|
document.body.style.overflow = 'hidden';
|
|
301
375
|
var focusTarget = closeRef.current;
|
|
302
376
|
if (focusTarget !== null && typeof focusTarget.focus === 'function') focusTarget.focus();
|
|
377
|
+
var overlay = overlayRef.current;
|
|
378
|
+
var figure = figureRef.current;
|
|
379
|
+
function onWheel(event) {
|
|
380
|
+
if (event.cancelable) event.preventDefault();
|
|
381
|
+
apiRef.current.zoomBy(wheelZoomFactor(event.deltaY), event.clientX, event.clientY);
|
|
382
|
+
}
|
|
383
|
+
var drag = null;
|
|
384
|
+
function onPointerDown(event) {
|
|
385
|
+
var state = stateRef.current;
|
|
386
|
+
if (state.scale <= 1.01) return;
|
|
387
|
+
drag = { x: event.clientX, y: event.clientY, tx: state.tx, ty: state.ty };
|
|
388
|
+
if (figure !== null) figure.classList.add('dshpick-grabbing');
|
|
389
|
+
if (figure !== null && typeof figure.setPointerCapture === 'function') figure.setPointerCapture(event.pointerId);
|
|
390
|
+
event.preventDefault();
|
|
391
|
+
}
|
|
392
|
+
function onPointerMove(event) {
|
|
393
|
+
var state = stateRef.current;
|
|
394
|
+
if (drag === null || figure === null) return;
|
|
395
|
+
state.tx = drag.tx + (event.clientX - drag.x);
|
|
396
|
+
state.ty = drag.ty + (event.clientY - drag.y);
|
|
397
|
+
applyZoom();
|
|
398
|
+
}
|
|
399
|
+
function onPointerUp() {
|
|
400
|
+
if (drag === null) return;
|
|
401
|
+
drag = null;
|
|
402
|
+
if (figure !== null) figure.classList.remove('dshpick-grabbing');
|
|
403
|
+
}
|
|
404
|
+
function onDoubleClick(event) {
|
|
405
|
+
var state = stateRef.current;
|
|
406
|
+
event.preventDefault();
|
|
407
|
+
if (state.scale > 1.01) apiRef.current.zoomTo(1);
|
|
408
|
+
else apiRef.current.zoomBy(2.5, event.clientX, event.clientY);
|
|
409
|
+
}
|
|
303
410
|
function onKeyDown(event) {
|
|
304
411
|
if (event.key === 'Escape') onClose();
|
|
412
|
+
else if (event.key === '+' || event.key === '=') { event.preventDefault(); apiRef.current.zoomBy(1.25); }
|
|
413
|
+
else if (event.key === '-' || event.key === '_') { event.preventDefault(); apiRef.current.zoomBy(1 / 1.25); }
|
|
414
|
+
else if (event.key === '0') { event.preventDefault(); apiRef.current.zoomTo(1); }
|
|
305
415
|
}
|
|
306
416
|
document.addEventListener('keydown', onKeyDown);
|
|
417
|
+
if (overlay !== null) overlay.addEventListener('wheel', onWheel, { passive: false });
|
|
418
|
+
if (figure !== null) {
|
|
419
|
+
figure.addEventListener('pointerdown', onPointerDown);
|
|
420
|
+
figure.addEventListener('pointermove', onPointerMove);
|
|
421
|
+
figure.addEventListener('pointerup', onPointerUp);
|
|
422
|
+
figure.addEventListener('pointercancel', onPointerUp);
|
|
423
|
+
figure.addEventListener('dblclick', onDoubleClick);
|
|
424
|
+
}
|
|
425
|
+
// 视口变化(含 Ctrl+滚轮引起的浏览器页面缩放)时重新推导基准并重新钳制平移
|
|
426
|
+
window.addEventListener('resize', applyZoom);
|
|
307
427
|
return function () {
|
|
308
428
|
document.body.style.overflow = previous;
|
|
309
429
|
document.removeEventListener('keydown', onKeyDown);
|
|
430
|
+
window.removeEventListener('resize', applyZoom);
|
|
431
|
+
if (overlay !== null) overlay.removeEventListener('wheel', onWheel);
|
|
432
|
+
if (figure !== null) {
|
|
433
|
+
figure.removeEventListener('pointerdown', onPointerDown);
|
|
434
|
+
figure.removeEventListener('pointermove', onPointerMove);
|
|
435
|
+
figure.removeEventListener('pointerup', onPointerUp);
|
|
436
|
+
figure.removeEventListener('pointercancel', onPointerUp);
|
|
437
|
+
figure.removeEventListener('dblclick', onDoubleClick);
|
|
438
|
+
}
|
|
310
439
|
};
|
|
311
|
-
}, [zoom, onClose]);
|
|
440
|
+
}, [zoom, onClose, setPct]);
|
|
441
|
+
|
|
312
442
|
if (zoom === null) return null;
|
|
313
443
|
return jsxs('div', {
|
|
444
|
+
ref: overlayRef,
|
|
314
445
|
className: 'dshpick-lightbox',
|
|
315
446
|
role: 'dialog',
|
|
316
447
|
'aria-modal': 'true',
|
|
@@ -327,10 +458,11 @@ window.__ModuleLoader__.load({
|
|
|
327
458
|
children: '\u2715'
|
|
328
459
|
}),
|
|
329
460
|
jsxs('figure', {
|
|
461
|
+
ref: figureRef,
|
|
330
462
|
className: 'dshpick-lightboxFigure',
|
|
331
463
|
onClick: function (event) { event.stopPropagation(); },
|
|
332
464
|
children: [
|
|
333
|
-
jsx('img', { src: zoom.src, alt: zoom.label }),
|
|
465
|
+
jsx('img', { ref: imgRef, src: zoom.src, alt: zoom.label }),
|
|
334
466
|
jsxs('figcaption', {
|
|
335
467
|
className: 'dshpick-lightboxCap',
|
|
336
468
|
children: [
|
|
@@ -339,20 +471,56 @@ window.__ModuleLoader__.load({
|
|
|
339
471
|
]
|
|
340
472
|
})
|
|
341
473
|
]
|
|
474
|
+
}),
|
|
475
|
+
jsxs('div', {
|
|
476
|
+
className: 'dshpick-zoomBar',
|
|
477
|
+
onClick: function (event) { event.stopPropagation(); },
|
|
478
|
+
children: [
|
|
479
|
+
jsx('button', {
|
|
480
|
+
type: 'button',
|
|
481
|
+
className: 'dshpick-zoomBtn',
|
|
482
|
+
'aria-label': labels.out,
|
|
483
|
+
title: labels.out,
|
|
484
|
+
onClick: function () { apiRef.current.zoomBy(1 / 1.25); },
|
|
485
|
+
children: '\u2212'
|
|
486
|
+
}),
|
|
487
|
+
jsx('span', { className: 'dshpick-zoomPct', children: pct }),
|
|
488
|
+
jsx('button', {
|
|
489
|
+
type: 'button',
|
|
490
|
+
className: 'dshpick-zoomBtn',
|
|
491
|
+
'aria-label': labels.in,
|
|
492
|
+
title: labels.in,
|
|
493
|
+
onClick: function () { apiRef.current.zoomBy(1.25); },
|
|
494
|
+
children: '+'
|
|
495
|
+
}),
|
|
496
|
+
jsx('button', {
|
|
497
|
+
type: 'button',
|
|
498
|
+
className: 'dshpick-zoomBtn',
|
|
499
|
+
'aria-label': labels.reset,
|
|
500
|
+
title: labels.reset,
|
|
501
|
+
onClick: function () { apiRef.current.zoomTo(1); },
|
|
502
|
+
children: '\u21BA'
|
|
503
|
+
})
|
|
504
|
+
]
|
|
342
505
|
})
|
|
343
506
|
]
|
|
344
507
|
});
|
|
345
508
|
}
|
|
346
509
|
|
|
347
510
|
// ------------------------------------------------------------------
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
//
|
|
351
|
-
//
|
|
511
|
+
// 聊天栏图片放大增强:聊天栏里显示的图片都支持点击放大——包括插件自己的
|
|
512
|
+
// show/attachment 图片(markdown 里是绝对 URL,注意不能用 src^= 前缀匹配),
|
|
513
|
+
// 以及模型回复 markdown 渲染出的任意 http(s) 图片。核心渲染器原生显示
|
|
514
|
+
// <img>,这里通过 MutationObserver 发现图片做渐进增强(加样式类/悬停提示),
|
|
515
|
+
// 点击统一走 document 级事件委托(isZoomableChatImage 判定),放大复用
|
|
516
|
+
// .dshpick-lightbox 样式(命令式 DOM,不依赖 React)。
|
|
352
517
|
// 纯 DOM 单节点变更:不插入/移除 React 管理的结构,重渲染时安全。
|
|
353
518
|
// ------------------------------------------------------------------
|
|
354
519
|
var IMAGE_SHOW_PREFIX = '/dsh-plugin-image-tools/show/';
|
|
355
520
|
var IMAGE_TOKEN_PREFIX = 'dshimg:';
|
|
521
|
+
var ATTACHMENT_PREFIX = '/dsh-plugin-image-tools/attachment/';
|
|
522
|
+
/** 聊天消息列容器(dsh-client-ui-conversation ChatView 的稳定语义属性)。 */
|
|
523
|
+
var CHAT_FLOW_SELECTOR = '[data-chat-flow]';
|
|
356
524
|
var ZOOM_CLOSE_LABEL = (typeof document !== 'undefined' && (document.documentElement.lang || '').toLowerCase().indexOf('zh') === 0)
|
|
357
525
|
? '\u5173\u95ed'
|
|
358
526
|
: 'Close';
|
|
@@ -362,6 +530,11 @@ window.__ModuleLoader__.load({
|
|
|
362
530
|
return typeof src === 'string' && src.indexOf(IMAGE_SHOW_PREFIX) >= 0;
|
|
363
531
|
}
|
|
364
532
|
|
|
533
|
+
/** 是否插件图片服务路由的 src(show 内嵌或 attachment 回显,含绝对 URL)。 */
|
|
534
|
+
function isPluginImageSrc(src) {
|
|
535
|
+
return typeof src === 'string' && (src.indexOf(IMAGE_SHOW_PREFIX) >= 0 || src.indexOf(ATTACHMENT_PREFIX) >= 0);
|
|
536
|
+
}
|
|
537
|
+
|
|
365
538
|
/** 从文本里提取 dshimg: 占位符的 attachmentId(纯函数,供冒烟测试)。 */
|
|
366
539
|
function extractImageTokenIds(text) {
|
|
367
540
|
if (typeof text !== 'string') return [];
|
|
@@ -382,7 +555,8 @@ window.__ModuleLoader__.load({
|
|
|
382
555
|
var scannedTexts = typeof WeakMap === 'undefined' ? null : new WeakMap();
|
|
383
556
|
|
|
384
557
|
/**
|
|
385
|
-
* 把文本节点里的 dshimg:<id>
|
|
558
|
+
* 把文本节点里的 dshimg:<id> 占位符替换为图片元素(盲模型收图回显)。
|
|
559
|
+
* 点击放大交给 document 级委托处理,这里只建图并做加载失败降级。
|
|
386
560
|
* 只做文本节点级替换,不触碰 React 管理的元素结构。
|
|
387
561
|
*/
|
|
388
562
|
function upgradeBubbleTexts() {
|
|
@@ -408,14 +582,11 @@ window.__ModuleLoader__.load({
|
|
|
408
582
|
if (m.index > cursor) frag.appendChild(document.createTextNode(value.slice(cursor, m.index)));
|
|
409
583
|
var img = document.createElement('img');
|
|
410
584
|
img.className = 'dshimg-inline dshimg-received';
|
|
411
|
-
img.
|
|
585
|
+
img.dataset.dshimg = '1';
|
|
586
|
+
img.src = ATTACHMENT_PREFIX + m[1];
|
|
412
587
|
img.alt = '\u7528\u6237\u53d1\u6765\u7684\u56fe\u7247';
|
|
588
|
+
img.title = '\u7528\u6237\u53d1\u6765\u7684\u56fe\u7247\uff08\u70b9\u51fb\u653e\u5927\uff09';
|
|
413
589
|
img.loading = 'lazy';
|
|
414
|
-
img.addEventListener('click', function (event) {
|
|
415
|
-
event.preventDefault();
|
|
416
|
-
event.stopPropagation();
|
|
417
|
-
openImageZoom(img.src, '\u7528\u6237\u53d1\u6765\u7684\u56fe\u7247\uff08\u70b9\u51fb\u653e\u5927\uff09');
|
|
418
|
-
});
|
|
419
590
|
img.addEventListener('error', function () { img.dataset.failed = '1'; });
|
|
420
591
|
frag.appendChild(img);
|
|
421
592
|
cursor = m.index + m[0].length;
|
|
@@ -425,11 +596,109 @@ window.__ModuleLoader__.load({
|
|
|
425
596
|
}
|
|
426
597
|
}
|
|
427
598
|
|
|
428
|
-
|
|
599
|
+
// ------------------------------------------------------------------
|
|
600
|
+
// 放大层内的图片缩放:滚轮/按钮/双击缩放 + 拖拽平移(纯函数,供冒烟测试)。
|
|
601
|
+
// 模型:图像由 flex 居中,transform-origin 0 0,屏幕位置 = 布局左上角 + 平移
|
|
602
|
+
// + 局部坐标 × 缩放;基准 base 在 scale=1 且无 transform 时测量一次。
|
|
603
|
+
// ------------------------------------------------------------------
|
|
604
|
+
/** 缩放范围 [0.25x, 12x](相对适配视口的基准尺寸)。 */
|
|
605
|
+
function clampZoom(scale) {
|
|
606
|
+
return Math.min(12, Math.max(0.25, scale));
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** 滚轮缩放因子:deltaY<0 放大;指数平滑,每格约 1.2 倍。 */
|
|
610
|
+
function wheelZoomFactor(deltaY) {
|
|
611
|
+
return Math.exp(-deltaY * 0.0018);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* 光标中心缩放:缩放后仍让光标下的图像点保持不动。
|
|
616
|
+
* @param {number} tx - 当前平移 x(相对图像布局左上角)。
|
|
617
|
+
* @param {number} ty - 当前平移 y。
|
|
618
|
+
* @param {number} scale - 当前缩放。
|
|
619
|
+
* @param {number} nextScale - 目标缩放。
|
|
620
|
+
* @param {number} cx - 光标相对图像左上角的 x。
|
|
621
|
+
* @param {number} cy - 光标相对图像左上角的 y。
|
|
622
|
+
* @returns {{tx:number,ty:number}} 新平移。
|
|
623
|
+
*/
|
|
624
|
+
function zoomTranslate(tx, ty, scale, nextScale, cx, cy) {
|
|
625
|
+
var px = (cx - tx) / scale;
|
|
626
|
+
var py = (cy - ty) / scale;
|
|
627
|
+
return { tx: cx - px * nextScale, ty: cy - py * nextScale };
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* 平移钳制(图像由 flex 居中、transform-origin 0 0;基准尺寸由 fitBaseSize
|
|
632
|
+
* 从图片自然尺寸 + 视口推导,不依赖一次性 DOM 实测——实测会在图片未加载或
|
|
633
|
+
* 视口变化后失效,导致平移被钳死、拖不到图片右侧):
|
|
634
|
+
* 图像小于视口时保持居中(t=0);放大到超出视口后限制平移,保证图像始终
|
|
635
|
+
* 覆盖视口(左右/上下边缘都能拖到,不会整张拖出屏幕)。
|
|
636
|
+
* @param {number} tx - 当前平移 x。
|
|
637
|
+
* @param {number} ty - 当前平移 y。
|
|
638
|
+
* @param {number} scale - 当前缩放。
|
|
639
|
+
* @param {object} base - 基准尺寸 { w0, h0 }(scale=1 时图像适配视口的尺寸)。
|
|
640
|
+
* @param {object} view - 视口 { viewW, viewH }。
|
|
641
|
+
* @returns {{tx:number,ty:number}} 钳制后的平移。
|
|
642
|
+
*/
|
|
643
|
+
function clampPan(tx, ty, scale, base, view) {
|
|
644
|
+
var w = base.w0 * scale;
|
|
645
|
+
var h = base.h0 * scale;
|
|
646
|
+
var bx = (view.viewW - base.w0) / 2;
|
|
647
|
+
var by = (view.viewH - base.h0) / 2;
|
|
648
|
+
var minTx = Math.min(0, view.viewW - bx - w);
|
|
649
|
+
var maxTx = Math.max(0, -bx);
|
|
650
|
+
var minTy = Math.min(0, view.viewH - by - h);
|
|
651
|
+
var maxTy = Math.max(0, -by);
|
|
652
|
+
return {
|
|
653
|
+
tx: Math.min(maxTx, Math.max(minTx, tx)),
|
|
654
|
+
ty: Math.min(maxTy, Math.max(minTy, ty))
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* 图片适配视口的基准尺寸(与 .dshpick-lightboxFigure img 的 CSS 约束一致:
|
|
660
|
+
* 最大宽 min(92vw, 1100px)、最大高 82vh,等比缩放)。
|
|
661
|
+
* @param {number} naturalW - 图片自然宽。
|
|
662
|
+
* @param {number} naturalH - 图片自然高。
|
|
663
|
+
* @param {number} viewW - 视口宽。
|
|
664
|
+
* @param {number} viewH - 视口高。
|
|
665
|
+
* @returns {{w0:number,h0:number}} scale=1 时的基准尺寸。
|
|
666
|
+
*/
|
|
667
|
+
function fitBaseSize(naturalW, naturalH, viewW, viewH) {
|
|
668
|
+
var maxW = Math.min(0.92 * viewW, 1100);
|
|
669
|
+
var maxH = 0.82 * viewH;
|
|
670
|
+
var w = naturalW;
|
|
671
|
+
var h = naturalH;
|
|
672
|
+
if (w > maxW) {
|
|
673
|
+
h = h * maxW / w;
|
|
674
|
+
w = maxW;
|
|
675
|
+
}
|
|
676
|
+
if (h > maxH) {
|
|
677
|
+
w = w * maxH / h;
|
|
678
|
+
h = maxH;
|
|
679
|
+
}
|
|
680
|
+
return { w0: w, h0: h };
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/** 放大层缩放控制按钮文案(按页面语言)。 */
|
|
684
|
+
function zoomLabels() {
|
|
685
|
+
var zh = typeof document !== 'undefined' && (document.documentElement.lang || '').toLowerCase().indexOf('zh') === 0;
|
|
686
|
+
return zh
|
|
687
|
+
? { in: '放大', out: '缩小', reset: '重置为 100%' }
|
|
688
|
+
: { in: 'Zoom in', out: 'Zoom out', reset: 'Reset to 100%' };
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* 打开命令式 Lightbox 放大查看(复用选择卡的 .dshpick-lightbox 样式)。
|
|
693
|
+
* 支持:滚轮缩放(普通滚轮即可,光标中心)、−/+/重置按钮、双击切换 1x/2.5x、
|
|
694
|
+
* 放大后拖拽平移、快捷键 +/-/0/Esc。Ctrl+滚轮是浏览器级页面缩放,网页无法
|
|
695
|
+
* 拦截;事件到达页面时这里也会同步缩放图片并尽力 preventDefault。
|
|
696
|
+
*/
|
|
429
697
|
function openImageZoom(src, caption) {
|
|
430
698
|
if (typeof document === 'undefined') return;
|
|
431
699
|
var existing = document.querySelector('.dshpick-lightbox[data-dshimg-zoom]');
|
|
432
700
|
if (existing !== null) existing.remove();
|
|
701
|
+
var labels = zoomLabels();
|
|
433
702
|
var overlay = document.createElement('div');
|
|
434
703
|
overlay.className = 'dshpick-lightbox';
|
|
435
704
|
overlay.dataset.dshimgZoom = '1';
|
|
@@ -453,46 +722,230 @@ window.__ModuleLoader__.load({
|
|
|
453
722
|
}
|
|
454
723
|
overlay.appendChild(close);
|
|
455
724
|
overlay.appendChild(figure);
|
|
725
|
+
// 缩放控制条:− / 百分比 / + / 重置
|
|
726
|
+
var pctEl = document.createElement('span');
|
|
727
|
+
pctEl.className = 'dshpick-zoomPct';
|
|
728
|
+
pctEl.textContent = '100%';
|
|
729
|
+
function zoomBtn(text, label, onClick) {
|
|
730
|
+
var button = document.createElement('button');
|
|
731
|
+
button.type = 'button';
|
|
732
|
+
button.className = 'dshpick-zoomBtn';
|
|
733
|
+
button.textContent = text;
|
|
734
|
+
button.setAttribute('aria-label', label);
|
|
735
|
+
button.title = label;
|
|
736
|
+
button.addEventListener('click', function (event) {
|
|
737
|
+
event.stopPropagation();
|
|
738
|
+
onClick();
|
|
739
|
+
});
|
|
740
|
+
return button;
|
|
741
|
+
}
|
|
742
|
+
var bar = document.createElement('div');
|
|
743
|
+
bar.className = 'dshpick-zoomBar';
|
|
744
|
+
bar.appendChild(zoomBtn('\u2212', labels.out, function () { zoomBy(1 / 1.25); }));
|
|
745
|
+
bar.appendChild(pctEl);
|
|
746
|
+
bar.appendChild(zoomBtn('+', labels.in, function () { zoomBy(1.25); }));
|
|
747
|
+
bar.appendChild(zoomBtn('\u21BA', labels.reset, function () { zoomTo(1); }));
|
|
748
|
+
bar.addEventListener('click', function (event) { event.stopPropagation(); });
|
|
749
|
+
overlay.appendChild(bar);
|
|
750
|
+
|
|
751
|
+
// 缩放状态:基准尺寸每次从图片自然尺寸 + 当前视口实时推导(fitBaseSize),
|
|
752
|
+
// 不做一次性 DOM 实测——实测在图片未加载/视口变化后会失效,导致平移被
|
|
753
|
+
// 钳死、拖不到图片右侧。
|
|
754
|
+
var scale = 1;
|
|
755
|
+
var tx = 0;
|
|
756
|
+
var ty = 0;
|
|
757
|
+
function viewSize() {
|
|
758
|
+
return { viewW: window.innerWidth, viewH: window.innerHeight };
|
|
759
|
+
}
|
|
760
|
+
function currentBase() {
|
|
761
|
+
var nw = img.naturalWidth;
|
|
762
|
+
var nh = img.naturalHeight;
|
|
763
|
+
if (!nw || !nh) return null;
|
|
764
|
+
var view = viewSize();
|
|
765
|
+
return fitBaseSize(nw, nh, view.viewW, view.viewH);
|
|
766
|
+
}
|
|
767
|
+
function applyZoom() {
|
|
768
|
+
var base = currentBase();
|
|
769
|
+
if (base === null) {
|
|
770
|
+
figure.style.transform = '';
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
var pan = clampPan(tx, ty, scale, base, viewSize());
|
|
774
|
+
tx = pan.tx;
|
|
775
|
+
ty = pan.ty;
|
|
776
|
+
figure.style.transform = (scale === 1 && tx === 0 && ty === 0)
|
|
777
|
+
? ''
|
|
778
|
+
: 'translate(' + tx + 'px,' + ty + 'px) scale(' + scale + ')';
|
|
779
|
+
pctEl.textContent = Math.round(scale * 100) + '%';
|
|
780
|
+
}
|
|
781
|
+
function zoomBy(factor, clientX, clientY) {
|
|
782
|
+
var base = currentBase();
|
|
783
|
+
if (base === null) return;
|
|
784
|
+
var view = viewSize();
|
|
785
|
+
var nextScale = clampZoom(scale * factor);
|
|
786
|
+
if (nextScale === scale) return;
|
|
787
|
+
var bx = (view.viewW - base.w0) / 2;
|
|
788
|
+
var by = (view.viewH - base.h0) / 2;
|
|
789
|
+
var cx, cy;
|
|
790
|
+
if (clientX === void 0) {
|
|
791
|
+
cx = tx + (base.w0 * scale) / 2;
|
|
792
|
+
cy = ty + (base.h0 * scale) / 2;
|
|
793
|
+
} else {
|
|
794
|
+
cx = clientX - bx;
|
|
795
|
+
cy = clientY - by;
|
|
796
|
+
}
|
|
797
|
+
var next = zoomTranslate(tx, ty, scale, nextScale, cx, cy);
|
|
798
|
+
tx = next.tx;
|
|
799
|
+
ty = next.ty;
|
|
800
|
+
scale = nextScale;
|
|
801
|
+
applyZoom();
|
|
802
|
+
}
|
|
803
|
+
function zoomTo(target) {
|
|
804
|
+
zoomBy(target / scale);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// 滚轮缩放(普通滚轮;Ctrl+滚轮事件到达页面时也缩放图片)
|
|
808
|
+
function onWheel(event) {
|
|
809
|
+
if (event.cancelable) event.preventDefault();
|
|
810
|
+
zoomBy(wheelZoomFactor(event.deltaY), event.clientX, event.clientY);
|
|
811
|
+
}
|
|
812
|
+
// 放大后拖拽平移
|
|
813
|
+
var drag = null;
|
|
814
|
+
function onPointerDown(event) {
|
|
815
|
+
if (scale <= 1.01) return;
|
|
816
|
+
drag = { x: event.clientX, y: event.clientY, tx: tx, ty: ty };
|
|
817
|
+
figure.classList.add('dshpick-grabbing');
|
|
818
|
+
if (typeof figure.setPointerCapture === 'function') figure.setPointerCapture(event.pointerId);
|
|
819
|
+
event.preventDefault();
|
|
820
|
+
}
|
|
821
|
+
function onPointerMove(event) {
|
|
822
|
+
if (drag === null) return;
|
|
823
|
+
tx = drag.tx + (event.clientX - drag.x);
|
|
824
|
+
ty = drag.ty + (event.clientY - drag.y);
|
|
825
|
+
applyZoom();
|
|
826
|
+
}
|
|
827
|
+
function onPointerUp() {
|
|
828
|
+
if (drag === null) return;
|
|
829
|
+
drag = null;
|
|
830
|
+
figure.classList.remove('dshpick-grabbing');
|
|
831
|
+
}
|
|
832
|
+
function onDoubleClick(event) {
|
|
833
|
+
event.preventDefault();
|
|
834
|
+
if (scale > 1.01) zoomTo(1);
|
|
835
|
+
else zoomBy(2.5, event.clientX, event.clientY);
|
|
836
|
+
}
|
|
837
|
+
|
|
456
838
|
var previous = document.body.style.overflow;
|
|
457
839
|
document.body.style.overflow = 'hidden';
|
|
458
840
|
var closeZoom = function () {
|
|
459
841
|
overlay.remove();
|
|
460
842
|
document.body.style.overflow = previous;
|
|
461
843
|
document.removeEventListener('keydown', onKey);
|
|
844
|
+
window.removeEventListener('resize', applyZoom);
|
|
462
845
|
};
|
|
463
846
|
var onKey = function (event) {
|
|
464
847
|
if (event.key === 'Escape') closeZoom();
|
|
848
|
+
else if (event.key === '+' || event.key === '=') { event.preventDefault(); zoomBy(1.25); }
|
|
849
|
+
else if (event.key === '-' || event.key === '_') { event.preventDefault(); zoomBy(1 / 1.25); }
|
|
850
|
+
else if (event.key === '0') { event.preventDefault(); zoomTo(1); }
|
|
465
851
|
};
|
|
466
852
|
overlay.addEventListener('click', closeZoom);
|
|
467
853
|
figure.addEventListener('click', function (event) { event.stopPropagation(); });
|
|
468
854
|
close.addEventListener('click', function (event) { event.stopPropagation(); closeZoom(); });
|
|
855
|
+
overlay.addEventListener('wheel', onWheel, { passive: false });
|
|
856
|
+
figure.addEventListener('pointerdown', onPointerDown);
|
|
857
|
+
figure.addEventListener('pointermove', onPointerMove);
|
|
858
|
+
figure.addEventListener('pointerup', onPointerUp);
|
|
859
|
+
figure.addEventListener('pointercancel', onPointerUp);
|
|
860
|
+
figure.addEventListener('dblclick', onDoubleClick);
|
|
469
861
|
document.addEventListener('keydown', onKey);
|
|
862
|
+
// 视口变化(含 Ctrl+滚轮引起的浏览器页面缩放)时重新推导基准并重新钳制平移
|
|
863
|
+
window.addEventListener('resize', applyZoom);
|
|
470
864
|
document.body.appendChild(overlay);
|
|
471
865
|
close.focus();
|
|
472
866
|
}
|
|
473
867
|
|
|
474
|
-
/**
|
|
868
|
+
/** 是否位于插件自己的放大/选择 UI 内(这些图有各自的查看方式)。 */
|
|
869
|
+
function inZoomExcludedZone(img) {
|
|
870
|
+
if (typeof img.closest !== 'function') return false;
|
|
871
|
+
return img.closest('.dshpick-lightbox, .dshpick-card, .dshpick-cards, .dshpick-thumb') !== null;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/** 是否位于交互控件内(按钮/链接等已有自己的点击语义,如附件缩略图自带 lightbox)。 */
|
|
875
|
+
function isInteractiveContext(img) {
|
|
876
|
+
if (typeof img.closest !== 'function') return false;
|
|
877
|
+
return img.closest('button, a, [role="button"], [role="link"], [role="menuitem"]') !== null;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/** 内容图尺寸启发:渲染宽 ≥ 40px 或自然宽 ≥ 160px(跳过图标/头像等小图)。 */
|
|
881
|
+
function isContentSized(img) {
|
|
882
|
+
var rect = typeof img.getBoundingClientRect === 'function' ? img.getBoundingClientRect() : null;
|
|
883
|
+
var rendered = rect !== null && rect.width > 0 ? rect.width : (img.clientWidth || 0);
|
|
884
|
+
return rendered >= 40 || (img.naturalWidth || 0) >= 160;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/**
|
|
888
|
+
* 判定一张图片是否应触发放大:
|
|
889
|
+
* 插件图片(show/attachment 路由)无条件放大;
|
|
890
|
+
* 其他图片要求位于聊天栏 [data-chat-flow] 内、不在交互控件/放大 UI 里、
|
|
891
|
+
* 且达到内容图尺寸阈值。
|
|
892
|
+
*/
|
|
893
|
+
function isZoomableChatImage(img) {
|
|
894
|
+
if (img === null || typeof img !== 'object') return false;
|
|
895
|
+
var src = img.getAttribute('src') || img.currentSrc || '';
|
|
896
|
+
if (isPluginImageSrc(src)) return !inZoomExcludedZone(img);
|
|
897
|
+
if (typeof img.closest !== 'function' || img.closest(CHAT_FLOW_SELECTOR) === null) return false;
|
|
898
|
+
if (inZoomExcludedZone(img) || isInteractiveContext(img)) return false;
|
|
899
|
+
if (img.dataset !== void 0 && img.dataset.failed !== void 0) return false;
|
|
900
|
+
return isContentSized(img);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* document 级点击委托:聊天栏内点击内容图片 → 放大查看。
|
|
905
|
+
* React 17+ 事件挂在 root 容器上、先于本委托执行,因此交互控件内的
|
|
906
|
+
* 图片(按钮/链接)保持各自语义(如附件缩略图的原生 lightbox),
|
|
907
|
+
* 这里只兜底纯图片。
|
|
908
|
+
* @returns {Function|null} 停止函数;无 DOM 环境返回 null。
|
|
909
|
+
*/
|
|
910
|
+
function startClickDelegation() {
|
|
911
|
+
if (typeof document === 'undefined') return null;
|
|
912
|
+
function onDocumentClick(event) {
|
|
913
|
+
var target = event.target;
|
|
914
|
+
if (target === null || target === undefined || typeof target.tagName !== 'string' || target.tagName.toLowerCase() !== 'img') return;
|
|
915
|
+
if (typeof target.closest === 'function' && target.closest('.dshpick-lightbox') !== null) return;
|
|
916
|
+
if (!isZoomableChatImage(target)) return;
|
|
917
|
+
event.preventDefault();
|
|
918
|
+
event.stopPropagation();
|
|
919
|
+
var src = target.currentSrc || target.getAttribute('src') || '';
|
|
920
|
+
var caption = (target.getAttribute('alt') || '').trim();
|
|
921
|
+
openImageZoom(src, caption);
|
|
922
|
+
}
|
|
923
|
+
document.addEventListener('click', onDocumentClick);
|
|
924
|
+
return function () { document.removeEventListener('click', onDocumentClick); };
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/** 升级单个图片的视觉提示:插件图片套用内嵌样式,聊天内容图加放大光标。 */
|
|
475
928
|
function upgradeInlineImage(img) {
|
|
476
929
|
if (img === null || typeof img !== 'object' || img.dataset === void 0) return;
|
|
477
930
|
if (img.dataset.dshimg === '1') return;
|
|
478
|
-
|
|
479
|
-
img.dataset.dshimg = '1';
|
|
480
|
-
img.classList.add('dshimg-inline');
|
|
931
|
+
var src = img.getAttribute('src') || '';
|
|
481
932
|
var caption = (img.getAttribute('alt') || '').trim();
|
|
933
|
+
if (isPluginImageSrc(src)) {
|
|
934
|
+
img.dataset.dshimg = '1';
|
|
935
|
+
img.classList.add('dshimg-inline');
|
|
936
|
+
if (caption !== '') img.title = caption;
|
|
937
|
+
img.addEventListener('error', function () { img.dataset.failed = '1'; });
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
if (!isZoomableChatImage(img)) return;
|
|
941
|
+
img.dataset.dshimg = '1';
|
|
942
|
+
img.classList.add('dshimg-zoom');
|
|
482
943
|
if (caption !== '') img.title = caption;
|
|
483
|
-
var src = img.getAttribute('src') || '';
|
|
484
|
-
if (!(isShowImageSrc(src) || src.indexOf('/dsh-plugin-image-tools/attachment/') >= 0)) return;
|
|
485
|
-
img.addEventListener('click', function (event) {
|
|
486
|
-
event.preventDefault();
|
|
487
|
-
event.stopPropagation();
|
|
488
|
-
openImageZoom(src, caption);
|
|
489
|
-
});
|
|
490
|
-
img.addEventListener('error', function () { img.dataset.failed = '1'; });
|
|
491
944
|
}
|
|
492
945
|
|
|
493
946
|
/**
|
|
494
|
-
*
|
|
495
|
-
*
|
|
947
|
+
* 启动聊天栏图片放大增强:MutationObserver 扫描(rAF 合帧)加样式提示
|
|
948
|
+
* (聊天栏内所有 img + 插件图片路由),并挂上 document 级点击委托。
|
|
496
949
|
* @returns {Function|null} 停止函数;无 DOM 环境返回 null。
|
|
497
950
|
*/
|
|
498
951
|
function startInlineEnhancer() {
|
|
@@ -500,7 +953,9 @@ window.__ModuleLoader__.load({
|
|
|
500
953
|
var pending = false;
|
|
501
954
|
var scan = function () {
|
|
502
955
|
pending = false;
|
|
503
|
-
var images = document.querySelectorAll(
|
|
956
|
+
var images = document.querySelectorAll(
|
|
957
|
+
CHAT_FLOW_SELECTOR + ' img, img[src*="' + IMAGE_SHOW_PREFIX + '"], img[src*="' + ATTACHMENT_PREFIX + '"]'
|
|
958
|
+
);
|
|
504
959
|
for (var i = 0; i < images.length; i++) upgradeInlineImage(images[i]);
|
|
505
960
|
upgradeBubbleTexts();
|
|
506
961
|
};
|
|
@@ -511,7 +966,11 @@ window.__ModuleLoader__.load({
|
|
|
511
966
|
});
|
|
512
967
|
observer.observe(document.body, { childList: true, subtree: true });
|
|
513
968
|
scan();
|
|
514
|
-
|
|
969
|
+
var stopDelegation = startClickDelegation();
|
|
970
|
+
return function () {
|
|
971
|
+
observer.disconnect();
|
|
972
|
+
if (stopDelegation !== null) stopDelegation();
|
|
973
|
+
};
|
|
515
974
|
}
|
|
516
975
|
|
|
517
976
|
// ------------------------------------------------------------------
|
|
@@ -921,9 +1380,17 @@ window.__ModuleLoader__.load({
|
|
|
921
1380
|
exports.Lightbox = Lightbox;
|
|
922
1381
|
exports.IMAGE_SHOW_PREFIX = IMAGE_SHOW_PREFIX;
|
|
923
1382
|
exports.isShowImageSrc = isShowImageSrc;
|
|
1383
|
+
exports.isPluginImageSrc = isPluginImageSrc;
|
|
1384
|
+
exports.isZoomableChatImage = isZoomableChatImage;
|
|
924
1385
|
exports.extractImageTokenIds = extractImageTokenIds;
|
|
925
1386
|
exports.upgradeInlineImage = upgradeInlineImage;
|
|
926
1387
|
exports.startInlineEnhancer = startInlineEnhancer;
|
|
1388
|
+
exports.startClickDelegation = startClickDelegation;
|
|
1389
|
+
exports.clampZoom = clampZoom;
|
|
1390
|
+
exports.wheelZoomFactor = wheelZoomFactor;
|
|
1391
|
+
exports.zoomTranslate = zoomTranslate;
|
|
1392
|
+
exports.clampPan = clampPan;
|
|
1393
|
+
exports.fitBaseSize = fitBaseSize;
|
|
927
1394
|
return module.exports;
|
|
928
1395
|
}
|
|
929
1396
|
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-image-tools",
|
|
3
|
-
"description": "DSH 图片插件:ask_user_choice 图片/图文混合选项(Web GUI 渲染图片选择卡,可放大查看)+ show_images
|
|
4
|
-
"version": "0.
|
|
3
|
+
"description": "DSH 图片插件:ask_user_choice 图片/图文混合选项(Web GUI 渲染图片选择卡,可放大查看)+ show_images 在回复中内嵌图片(图片与文字混排)+ 聊天栏所有图片点击放大,放大层支持滚轮/按钮缩放与拖拽平移。图片来源支持本地路径 / http(s) URL / base64 data URI。纯插件实现,不改核心包。",
|
|
4
|
+
"version": "0.6.1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|
package/scripts/smoke-client.mjs
CHANGED
|
@@ -59,6 +59,61 @@ assert.ok(mod.isShowImageSrc('http://127.0.0.1:3080/dsh-plugin-image-tools/show/
|
|
|
59
59
|
assert.ok(!mod.isShowImageSrc('/dsh-plugin-image-tools/abc/0'), '选择卡路由不应被识别为内嵌图')
|
|
60
60
|
assert.ok(!mod.isShowImageSrc('https://example.com/a.png'), '外部图片不应被识别')
|
|
61
61
|
assert.equal(mod.startInlineEnhancer(), null, '无 DOM 环境下不应启动观察器')
|
|
62
|
+
assert.equal(mod.startClickDelegation(), null, '无 DOM 环境下不应挂点击委托')
|
|
63
|
+
|
|
64
|
+
// 5a) 聊天栏图片放大判定(纯逻辑,fake DOM 对象)
|
|
65
|
+
const fakeClosest = (insideFlow, inButton, inPick) => (sel) => {
|
|
66
|
+
if (sel === '[data-chat-flow]') return insideFlow ? {} : null
|
|
67
|
+
if (sel === 'button, a, [role="button"], [role="link"], [role="menuitem"]') return inButton ? {} : null
|
|
68
|
+
if (sel === '.dshpick-lightbox, .dshpick-card, .dshpick-cards, .dshpick-thumb') return inPick ? {} : null
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
const fakeImg = (opts) => ({
|
|
72
|
+
getAttribute: (k) => (k === 'src' ? (opts.src ?? '') : null),
|
|
73
|
+
currentSrc: opts.currentSrc ?? '',
|
|
74
|
+
closest: fakeClosest(opts.insideFlow ?? false, opts.inButton ?? false, opts.inPick ?? false),
|
|
75
|
+
getBoundingClientRect: () => ({ width: opts.w ?? 320, height: opts.h ?? 240 }),
|
|
76
|
+
naturalWidth: opts.nw ?? 640,
|
|
77
|
+
dataset: opts.dataset ?? {},
|
|
78
|
+
})
|
|
79
|
+
assert.equal(mod.isPluginImageSrc('/dsh-plugin-image-tools/show/a/0'), true, 'show 路由应判定为插件图')
|
|
80
|
+
assert.equal(mod.isPluginImageSrc('http://127.0.0.1:3080/dsh-plugin-image-tools/attachment/att-123'), true, 'attachment 绝对 URL 应判定为插件图')
|
|
81
|
+
assert.equal(mod.isPluginImageSrc('/dsh-plugin-image-tools/pick-1/0'), false, '选择卡路由不是内嵌插件图')
|
|
82
|
+
assert.equal(mod.isZoomableChatImage(fakeImg({ insideFlow: true })), true, '聊天栏内内容图应可放大')
|
|
83
|
+
assert.equal(mod.isZoomableChatImage(fakeImg({ insideFlow: true, w: 16, h: 16, nw: 16 })), false, '聊天栏内小图标不应放大')
|
|
84
|
+
assert.equal(mod.isZoomableChatImage(fakeImg({ insideFlow: true, inButton: true })), false, '按钮内图片应放行给原生语义')
|
|
85
|
+
assert.equal(mod.isZoomableChatImage(fakeImg({ insideFlow: true, inPick: true })), false, '选择卡内图片应由卡片自己的放大处理')
|
|
86
|
+
assert.equal(mod.isZoomableChatImage(fakeImg({ insideFlow: false, src: 'https://example.com/a.png' })), false, '聊天栏外的图片不放大')
|
|
87
|
+
assert.equal(mod.isZoomableChatImage(fakeImg({ insideFlow: true, src: '/dsh-plugin-image-tools/show/x/0' })), true, '插件图无条件放大')
|
|
88
|
+
assert.equal(mod.isZoomableChatImage(null), false, 'null 不应放大')
|
|
89
|
+
|
|
90
|
+
// 5c) 放大层缩放纯函数
|
|
91
|
+
assert.equal(mod.clampZoom(0.1), 0.25, '缩放下限 0.25x')
|
|
92
|
+
assert.equal(mod.clampZoom(20), 12, '缩放上限 12x')
|
|
93
|
+
assert.equal(mod.clampZoom(2), 2, '范围内原样')
|
|
94
|
+
assert.ok(mod.wheelZoomFactor(-100) > 1, '向上滚应放大')
|
|
95
|
+
assert.ok(mod.wheelZoomFactor(100) < 1, '向下滚应缩小')
|
|
96
|
+
// 光标中心缩放:缩放前后光标下的图像点屏幕位置不变
|
|
97
|
+
const zt = mod.zoomTranslate(0, 0, 1, 2, 100, 50)
|
|
98
|
+
assert.ok(Math.abs((100 * 2 + zt.tx) - 100) < 1e-9, '光标中心缩放后 x 不动')
|
|
99
|
+
assert.ok(Math.abs((50 * 2 + zt.ty) - 50) < 1e-9, '光标中心缩放后 y 不动')
|
|
100
|
+
// 平移钳制:scale=1 保持 flex 居中(t=0);超出视口后限位、图像始终覆盖视口
|
|
101
|
+
// (左右/上下边缘都能拖到——这是修复"放大后拖不到右边"的关键约束)
|
|
102
|
+
const zoomBase = { w0: 200, h0: 100 }
|
|
103
|
+
const zoomView = { viewW: 1000, viewH: 800 }
|
|
104
|
+
assert.deepEqual(mod.clampPan(50, 50, 1, zoomBase, zoomView), { tx: 0, ty: 0 }, 'scale=1 保持居中')
|
|
105
|
+
assert.deepEqual(mod.clampPan(50, 50, 4, zoomBase, zoomView), { tx: 0, ty: 0 }, '4x 仍小于视口 → 居中')
|
|
106
|
+
assert.deepEqual(mod.clampPan(-10000, 0, 8, zoomBase, zoomView), { tx: -1000, ty: 0 }, '8x 超出视口 → 限位到左边缘')
|
|
107
|
+
assert.deepEqual(mod.clampPan(99999, -99999, 8, zoomBase, zoomView), { tx: 0, ty: -350 }, '8x 超出视口 → 限位到右/上边缘')
|
|
108
|
+
// 基准尺寸推导:与 .dshpick-lightboxFigure img 的 CSS 约束一致(max-width min(92vw,1100) / max-height 82vh)
|
|
109
|
+
const fit = mod.fitBaseSize(2000, 1000, 1000, 800)
|
|
110
|
+
assert.ok(Math.abs(fit.w0 - 920) < 1e-9 && Math.abs(fit.h0 - 460) < 1e-9, '宽图按 92vw 等比缩放')
|
|
111
|
+
const fit2 = mod.fitBaseSize(800, 2000, 1000, 800)
|
|
112
|
+
assert.ok(Math.abs(fit2.h0 - 656) < 1e-9 && Math.abs(fit2.w0 - 262.4) < 1e-9, '高图按 82vh 等比缩放')
|
|
113
|
+
const fit3 = mod.fitBaseSize(500, 300, 1000, 800)
|
|
114
|
+
assert.deepEqual(fit3, { w0: 500, h0: 300 }, '小于约束时保持自然尺寸')
|
|
115
|
+
const fit4 = mod.fitBaseSize(3000, 3000, 400, 300)
|
|
116
|
+
assert.ok(fit4.w0 <= 368 + 1e-9 && fit4.h0 <= 246 + 1e-9, '小视口下同时受宽高约束')
|
|
62
117
|
|
|
63
118
|
// 5b) 盲模型收图占位符 token 提取
|
|
64
119
|
assert.deepEqual(mod.extractImageTokenIds('📷 图片 dshimg:att-aaa111bbb222 和 dshimg:att-ccc333ddd444'), ['att-aaa111bbb222', 'att-ccc333ddd444'])
|