dsh-image-blur 0.2.6
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 +163 -0
- package/client.js +1186 -0
- package/cordis.patch.yml +13 -0
- package/docs/preview-blurred.jpg +0 -0
- package/docs/preview-shown.jpg +0 -0
- package/icon.svg +26 -0
- package/index.js +15 -0
- package/locale/en.json +23 -0
- package/locale/zh.json +23 -0
- package/package.json +57 -0
package/client.js
ADDED
|
@@ -0,0 +1,1186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* client.js — 浏览器半边:让 DSH 渲染出来的图片「默认模糊」,图片右上角挂一颗
|
|
3
|
+
* 「显示 / 模糊」按钮,点了「显示」才看清(NSFW 那种模式)。
|
|
4
|
+
*
|
|
5
|
+
* 为什么是「盯 <img>」而不是「换某个槽位」——依据 dsh-qwen-paint 的 README
|
|
6
|
+
* 「图片是怎么显示在聊天框里的(原理,改之前务必看)」那一节:
|
|
7
|
+
*
|
|
8
|
+
* ① 正文 Markdown 的 ``:客户端 `fileMediaUrl()` 把本地绝对路径
|
|
9
|
+
* 重写成 `<base>/api/file?path=…`,打到内核已认证的 `/api/file` 路由取字节,
|
|
10
|
+
* 渲染成**真 <img>**。这是官方设计的行内显示路径。
|
|
11
|
+
* ② 客户端自定义 `toolview`(`draw_image` / `read_image` 那种卡片)里,插件自己
|
|
12
|
+
* 写 `<img src="api/file?path=…">`,也是**真 <img>**。
|
|
13
|
+
*
|
|
14
|
+
* 两条路最后都落在同一批 DOM 节点上,所以本插件不做任何「替换渲染器」的动作:
|
|
15
|
+
*
|
|
16
|
+
* - 模糊 = 给 <img> 加一个我们自己拥有的属性 `data-dsh-imgblur`,样式表按属性下
|
|
17
|
+
* 发 `filter: blur(...)`。**不动 DOM 结构、不移动节点、不碰 className/style**,
|
|
18
|
+
* 于是 React 重渲染(含流式输出时的每帧重渲染)不会因为多出来的包裹层报
|
|
19
|
+
* `removeChild` 崩掉,图片文件本身也一个字节都不改。
|
|
20
|
+
* - 按钮 = 在 `shell.overlay`(frame 级穿透浮层,replaceRisk: none)里渲染一层
|
|
21
|
+
* 自己的浮层,给每张被模糊的图绝对定位一颗 chip;位置每帧按
|
|
22
|
+
* `getBoundingClientRect()` 跟随滚动、窗口缩放与图片加载。
|
|
23
|
+
* - 开关 = 在 `settings.general.item`(通用设置里的加行槽位)加一行:总开关 +
|
|
24
|
+
* 模糊强度,写 localStorage,改完立刻生效。
|
|
25
|
+
*
|
|
26
|
+
* 图片「显示」过的状态按 src 记住(同一个 src 的所有实例一起显示),所以切会话、
|
|
27
|
+
* 重渲染、再滚回来看都不会莫名其妙又糊上。
|
|
28
|
+
*
|
|
29
|
+
* 作用域(0.2.0 起):**只有会话正文里的图片参与**。判据是我们自己插进会话流
|
|
30
|
+
* (`conversation.chat.turnTail`)和输入框下方(`conversation.composer.dock`)的两个
|
|
31
|
+
* 零尺寸锚点 —— 取它们的最近公共祖先当作用域容器。侧栏、右侧面板、插件市场、
|
|
32
|
+
* 设置页、别的插件的工具面板都在容器之外,一律不碰;并且判定改用**渲染尺寸**
|
|
33
|
+
* (插件图标原图再大,显示成 20px 也不参与)。
|
|
34
|
+
*/
|
|
35
|
+
window.__ModuleLoader__.load({
|
|
36
|
+
id: 'dsh-image-blur',
|
|
37
|
+
factory(require) {
|
|
38
|
+
const React = require('react');
|
|
39
|
+
const h = React.createElement;
|
|
40
|
+
const { useRef, useState, useEffect } = React;
|
|
41
|
+
|
|
42
|
+
const NS = 'dsh-image-blur';
|
|
43
|
+
const ATTR = 'data-dsh-imgblur';
|
|
44
|
+
|
|
45
|
+
// ── 文案(本机兜底用;能拿到 locale 服务时以语言包为准)────────────────
|
|
46
|
+
const TEXT = {
|
|
47
|
+
zh: {
|
|
48
|
+
'chip.show': '显示',
|
|
49
|
+
'chip.blur': '模糊',
|
|
50
|
+
'chip.showTitle': '这张图片已模糊,点击显示',
|
|
51
|
+
'chip.blurTitle': '重新模糊这张图片',
|
|
52
|
+
'chip.showAria': '显示这张被模糊的图片',
|
|
53
|
+
'chip.blurAria': '重新模糊这张图片',
|
|
54
|
+
'settings.title': '图片模糊',
|
|
55
|
+
'settings.desc': '只糊会话正文里的图片:右上角点「显示」才看清,点「模糊」盖回去。侧栏、右侧面板、插件页面与图标一概不动,图片文件本身不变。',
|
|
56
|
+
'settings.offDesc': '已关闭:图片按原样显示,不出现「显示 / 模糊」按钮。',
|
|
57
|
+
'settings.strength': '强度',
|
|
58
|
+
strengthLight: '轻',
|
|
59
|
+
strengthNormal: '中',
|
|
60
|
+
strengthStrong: '强',
|
|
61
|
+
},
|
|
62
|
+
en: {
|
|
63
|
+
'chip.show': 'Show',
|
|
64
|
+
'chip.blur': 'Blur',
|
|
65
|
+
'chip.showTitle': 'This image is blurred — click to show it',
|
|
66
|
+
'chip.blurTitle': 'Blur this image again',
|
|
67
|
+
'chip.showAria': 'Show this blurred image',
|
|
68
|
+
'chip.blurAria': 'Blur this image again',
|
|
69
|
+
'settings.title': 'Image blur',
|
|
70
|
+
'settings.desc': 'Blurs images in the conversation body only; click “Show” in the corner to reveal, “Blur” to cover it again. Side panels, plugin pages and icons stay untouched.',
|
|
71
|
+
'settings.offDesc': 'Off: images render as-is and no Show / Blur button appears.',
|
|
72
|
+
'settings.strength': 'Strength',
|
|
73
|
+
strengthLight: 'Low',
|
|
74
|
+
strengthNormal: 'Medium',
|
|
75
|
+
strengthStrong: 'High',
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
let localeCtx = null;
|
|
80
|
+
let translate = null;
|
|
81
|
+
|
|
82
|
+
function activeLang() {
|
|
83
|
+
try {
|
|
84
|
+
const loc = localeCtx && localeCtx.get ? localeCtx.get('locale') : null;
|
|
85
|
+
const snap = loc && typeof loc.getLocale === 'function' ? loc.getLocale() : null;
|
|
86
|
+
const id = String((snap && snap.active) || '');
|
|
87
|
+
if (id) return /^zh/i.test(id) ? 'zh' : 'en';
|
|
88
|
+
} catch (err) {
|
|
89
|
+
/* 语言服务不可用:走浏览器语言 */
|
|
90
|
+
}
|
|
91
|
+
const navi = String((typeof navigator !== 'undefined' && navigator.language) || 'en');
|
|
92
|
+
return /^zh/i.test(navi) ? 'zh' : 'en';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function t(key) {
|
|
96
|
+
try {
|
|
97
|
+
const out = translate ? translate(key) : key;
|
|
98
|
+
if (out && out !== key) return out;
|
|
99
|
+
} catch (err) {
|
|
100
|
+
/* 没注册成功就用本机字典 */
|
|
101
|
+
}
|
|
102
|
+
const lang = activeLang();
|
|
103
|
+
const dict = TEXT[lang] || TEXT.en;
|
|
104
|
+
return dict[key] != null ? dict[key] : TEXT.en[key] != null ? TEXT.en[key] : key;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── 设置:localStorage,改完即时生效 ────────────────────────────────────
|
|
108
|
+
const LS_KEY = 'dsh-image-blur:settings:v1';
|
|
109
|
+
const STRENGTH = { light: 10, normal: 20, strong: 36 };
|
|
110
|
+
const STRENGTH_KEYS = ['light', 'normal', 'strong'];
|
|
111
|
+
const settings = { enabled: true, strength: 'normal', minPx: 120 };
|
|
112
|
+
const subs = new Set();
|
|
113
|
+
|
|
114
|
+
function loadSettings() {
|
|
115
|
+
try {
|
|
116
|
+
const raw = window.localStorage ? window.localStorage.getItem(LS_KEY) : null;
|
|
117
|
+
if (!raw) return;
|
|
118
|
+
const parsed = JSON.parse(raw);
|
|
119
|
+
if (!parsed || typeof parsed !== 'object') return;
|
|
120
|
+
if (typeof parsed.enabled === 'boolean') settings.enabled = parsed.enabled;
|
|
121
|
+
if (STRENGTH[parsed.strength]) settings.strength = parsed.strength;
|
|
122
|
+
if (typeof parsed.minPx === 'number' && parsed.minPx >= 32) settings.minPx = parsed.minPx;
|
|
123
|
+
} catch (err) {
|
|
124
|
+
/* 隐私模式 / 配额满:用默认值,功能照常 */
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function saveSettings() {
|
|
129
|
+
try {
|
|
130
|
+
if (window.localStorage) window.localStorage.setItem(LS_KEY, JSON.stringify(settings));
|
|
131
|
+
} catch (err) {
|
|
132
|
+
/* 存不了就只在本会话生效 */
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function pushCssVars() {
|
|
137
|
+
const root = document.documentElement;
|
|
138
|
+
if (!root || !root.style || typeof root.style.setProperty !== 'function') return;
|
|
139
|
+
root.style.setProperty('--dsh-imgblur-radius', (STRENGTH[settings.strength] || 20) + 'px');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function applySettings(patch) {
|
|
143
|
+
if (!patch || typeof patch !== 'object') return;
|
|
144
|
+
const structural = 'enabled' in patch || 'minPx' in patch;
|
|
145
|
+
if (typeof patch.enabled === 'boolean') settings.enabled = patch.enabled;
|
|
146
|
+
if (STRENGTH[patch.strength]) settings.strength = patch.strength;
|
|
147
|
+
if (typeof patch.minPx === 'number' && patch.minPx >= 32) settings.minPx = patch.minPx;
|
|
148
|
+
saveSettings();
|
|
149
|
+
pushCssVars();
|
|
150
|
+
// 只换强度不必拆了重来(会闪一下),改开关/阈值才需要重新扫描。
|
|
151
|
+
if (structural) engine.refresh();
|
|
152
|
+
subs.forEach((fn) => {
|
|
153
|
+
try {
|
|
154
|
+
fn();
|
|
155
|
+
} catch (err) {
|
|
156
|
+
/* 单个订阅者出错不影响别人 */
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── 样式 ───────────────────────────────────────────────────────────────
|
|
162
|
+
const CSS = [
|
|
163
|
+
'/* 模糊由我们自己的属性驱动:不碰 className / style,React 重渲染也不会打架 */',
|
|
164
|
+
'/* filter:blur 会往元素外溢,观感上「模糊范围比图片大」;inset(0 round …) 把',
|
|
165
|
+
' 滤镜输出裁回元素自己的圆角矩形,圆角用元素自身 computed border-radius 喂进来。 */',
|
|
166
|
+
'img[' + ATTR + ']{',
|
|
167
|
+
' clip-path: inset(0 round var(--dsh-imgblur-clip, 0px));',
|
|
168
|
+
' -webkit-clip-path: inset(0 round var(--dsh-imgblur-clip, 0px));',
|
|
169
|
+
'}',
|
|
170
|
+
'/* 预糊兜底:会话容器被我们打上 scope 之后,里面「还没被 JS 登记」的图片也先糊住,',
|
|
171
|
+
' 这样切会话 / 新渲染的第一帧不会露出裸图;够不上的(图标等)会被打上 off 立刻恢复。 */',
|
|
172
|
+
'[data-dsh-imgblur-scope] img:not([' + ATTR + ']):not([data-dsh-imgblur-off="1"]):not([data-dsh-imgblur-ignore="1"]){',
|
|
173
|
+
' filter: blur(var(--dsh-imgblur-radius, 20px)) saturate(.55) brightness(.94) !important;',
|
|
174
|
+
' clip-path: inset(0 round var(--dsh-imgblur-clip, 0px));',
|
|
175
|
+
' -webkit-clip-path: inset(0 round var(--dsh-imgblur-clip, 0px));',
|
|
176
|
+
'}',
|
|
177
|
+
'img[' + ATTR + '="hidden"]{',
|
|
178
|
+
' filter: blur(var(--dsh-imgblur-radius, 20px)) saturate(.55) brightness(.94) !important;',
|
|
179
|
+
' transition: filter 180ms ease;',
|
|
180
|
+
' cursor: zoom-in;',
|
|
181
|
+
'}',
|
|
182
|
+
'img[' + ATTR + '="shown"]{',
|
|
183
|
+
' filter: none !important;',
|
|
184
|
+
' transition: filter 180ms ease;',
|
|
185
|
+
' cursor: zoom-out;',
|
|
186
|
+
'}',
|
|
187
|
+
'.dsh-imgblur-layer{position:fixed;inset:0;pointer-events:none;z-index:30;}',
|
|
188
|
+
'.dsh-imgblur-anchor{position:absolute;left:0;top:0;width:0;height:0;overflow:hidden;pointer-events:none;}',
|
|
189
|
+
'.dsh-imgblur-chip{',
|
|
190
|
+
' position:absolute;left:0;top:0;pointer-events:auto;display:inline-flex;align-items:center;gap:5px;',
|
|
191
|
+
' height:24px;margin:0;padding:0 9px;border-radius:999px;',
|
|
192
|
+
' border:1px solid var(--dsw-alias-border-l1);',
|
|
193
|
+
' background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);',
|
|
194
|
+
' font-family:inherit;font-size:12px;font-weight:500;line-height:1;letter-spacing:.02em;white-space:nowrap;',
|
|
195
|
+
' cursor:pointer;-webkit-user-select:none;user-select:none;',
|
|
196
|
+
' box-shadow:0 2px 10px rgba(0,0,0,.22);opacity:1;will-change:transform;',
|
|
197
|
+
' transition:opacity 140ms ease,background-color 140ms ease,border-color 140ms ease;',
|
|
198
|
+
'}',
|
|
199
|
+
'.dsh-imgblur-chip:hover{background:var(--dsw-alias-bg-overlay);border-color:var(--dsw-alias-border-l2);}',
|
|
200
|
+
'.dsh-imgblur-chip:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px;}',
|
|
201
|
+
'.dsh-imgblur-chip svg{display:block;opacity:.85;}',
|
|
202
|
+
'.dsh-imgblur-chip[data-shown="1"]{opacity:0;}',
|
|
203
|
+
'.dsh-imgblur-chip[data-shown="1"][data-hot="1"],.dsh-imgblur-chip[data-shown="1"]:hover{opacity:1;}',
|
|
204
|
+
'/* 缩略图(多图附件的紧凑格子)上换成只有眼睛的小圆按钮,别把图盖满 */',
|
|
205
|
+
'.dsh-imgblur-chip[data-compact="1"]{width:24px;height:22px;padding:0;justify-content:center;gap:0;}',
|
|
206
|
+
'.dsh-imgblur-chip[data-compact="1"] span{display:none;}',
|
|
207
|
+
'.dsh-imgblur-row{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:12px 0;border-top:1px solid var(--dsw-alias-border-l1);}',
|
|
208
|
+
'.dsh-imgblur-col{min-width:0;display:flex;flex-direction:column;gap:3px;}',
|
|
209
|
+
'.dsh-imgblur-name{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:500;}',
|
|
210
|
+
'.dsh-imgblur-desc{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.5;}',
|
|
211
|
+
'.dsh-imgblur-ctrls{display:flex;align-items:center;gap:10px;flex:none;}',
|
|
212
|
+
'.dsh-imgblur-seg{display:inline-flex;align-items:center;gap:2px;padding:2px;border-radius:8px;',
|
|
213
|
+
' background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);}',
|
|
214
|
+
'.dsh-imgblur-seg-label{color:var(--dsw-alias-label-secondary);font-size:11px;padding:0 6px 0 4px;}',
|
|
215
|
+
'.dsh-imgblur-seg-btn{appearance:none;border:0;background:transparent;color:var(--dsw-alias-label-secondary);',
|
|
216
|
+
' font-family:inherit;font-size:12px;line-height:1;padding:5px 8px;border-radius:6px;cursor:pointer;}',
|
|
217
|
+
'.dsh-imgblur-seg-btn:hover:not(:disabled){color:var(--dsw-alias-label-primary);}',
|
|
218
|
+
'.dsh-imgblur-seg-btn.is-on{background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);box-shadow:0 1px 2px rgba(0,0,0,.12);}',
|
|
219
|
+
'.dsh-imgblur-seg-btn:disabled{opacity:.45;cursor:default;}',
|
|
220
|
+
'.dsh-imgblur-switch{position:relative;flex:none;width:36px;height:20px;padding:0;border-radius:999px;',
|
|
221
|
+
' border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);cursor:pointer;',
|
|
222
|
+
' transition:background-color 160ms ease,border-color 160ms ease;}',
|
|
223
|
+
'.dsh-imgblur-switch.is-on{background:var(--dsw-alias-brand-primary);border-color:var(--dsw-alias-brand-primary);}',
|
|
224
|
+
'.dsh-imgblur-knob{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;',
|
|
225
|
+
' background:var(--dsw-alias-label-secondary);transition:transform 160ms ease,background-color 160ms ease;}',
|
|
226
|
+
'.dsh-imgblur-switch.is-on .dsh-imgblur-knob{transform:translateX(16px);background:var(--dsw-alias-bg-base);}',
|
|
227
|
+
].join('\n');
|
|
228
|
+
|
|
229
|
+
// 样式表自己插一个 <style>:行为确定、可标可删,且不依赖某个内建是否可用。
|
|
230
|
+
function installCss(css) {
|
|
231
|
+
const el = document.createElement('style');
|
|
232
|
+
el.setAttribute('data-dsh-image-blur', '');
|
|
233
|
+
el.textContent = css;
|
|
234
|
+
(document.head || document.documentElement).appendChild(el);
|
|
235
|
+
return function disposeStyle() {
|
|
236
|
+
try {
|
|
237
|
+
el.remove();
|
|
238
|
+
} catch (err) {
|
|
239
|
+
/* ignore */
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ── 会话作用域:只有「对话正文」里的图片才参与 ─────────────────────────
|
|
245
|
+
// 判据既不用宿主的 class(会变),也不靠几何猜测,而是**我们自己插进会话里的
|
|
246
|
+
// 两个零尺寸锚点**:
|
|
247
|
+
// ① `conversation.chat.turnTail`(每轮一个,落在消息流内部)
|
|
248
|
+
// ② `conversation.composer.dock`(输入框下方,随会话常驻)
|
|
249
|
+
// 二者的最近公共祖先 = 会话正文容器(消息流 + 输入区都在里面)。
|
|
250
|
+
// 侧栏、右侧面板、插件市场、设置页、别的插件自绘的工具面板都在它之外 —— 不参与。
|
|
251
|
+
const anchors = { dock: new Set(), flow: new Set() };
|
|
252
|
+
const scopeCache = { at: 0, node: null };
|
|
253
|
+
|
|
254
|
+
function ancestry(node) {
|
|
255
|
+
const chain = [];
|
|
256
|
+
let el = node;
|
|
257
|
+
while (el) {
|
|
258
|
+
chain.push(el);
|
|
259
|
+
el = el.parentElement;
|
|
260
|
+
}
|
|
261
|
+
return chain;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// 最近公共祖先:chain 是 [自己 … html],第一个公共节点就是最深的那个。
|
|
265
|
+
function lowestCommonAncestor(nodes) {
|
|
266
|
+
const live = nodes.filter((n) => n && n.isConnected);
|
|
267
|
+
if (live.length < 2) return null;
|
|
268
|
+
const chains = live.map(ancestry);
|
|
269
|
+
for (const candidate of chains[0]) {
|
|
270
|
+
let shared = true;
|
|
271
|
+
for (let i = 1; i < chains.length; i += 1) {
|
|
272
|
+
if (chains[i].indexOf(candidate) === -1) {
|
|
273
|
+
shared = false;
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (shared) return candidate;
|
|
278
|
+
}
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function firstLive(set) {
|
|
283
|
+
for (const el of set) {
|
|
284
|
+
if (el && el.isConnected) return el;
|
|
285
|
+
}
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// 退路一:从锚点往上找第一个真正在滚动的容器(消息列表就是滚动的)。
|
|
290
|
+
function scrollScope(anchor) {
|
|
291
|
+
let el = anchor.parentElement;
|
|
292
|
+
for (let i = 0; el && i < 14; i += 1) {
|
|
293
|
+
try {
|
|
294
|
+
const s = window.getComputedStyle(el);
|
|
295
|
+
if (s && /(auto|scroll|overlay)/.test(String(s.overflowY || ''))) return el;
|
|
296
|
+
} catch (err) {
|
|
297
|
+
/* 跨域 iframe 之类,忽略 */
|
|
298
|
+
}
|
|
299
|
+
el = el.parentElement;
|
|
300
|
+
}
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// 退路二:往上找到第一个「里面真的有图」的祖先(输入框下面是消息流所在的那层)。
|
|
305
|
+
function fallbackScope(anchor) {
|
|
306
|
+
let el = anchor.parentElement;
|
|
307
|
+
for (let i = 0; el && i < 8; i += 1) {
|
|
308
|
+
try {
|
|
309
|
+
if (el.querySelector && el.querySelector('img')) return el;
|
|
310
|
+
} catch (err) {
|
|
311
|
+
/* ignore */
|
|
312
|
+
}
|
|
313
|
+
el = el.parentElement;
|
|
314
|
+
}
|
|
315
|
+
return anchor.parentElement || null;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function conversationScope() {
|
|
319
|
+
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
320
|
+
const cached = scopeCache.node;
|
|
321
|
+
if (cached && cached.isConnected && now - scopeCache.at < 400) return cached;
|
|
322
|
+
|
|
323
|
+
const flow = firstLive(anchors.flow);
|
|
324
|
+
const dock = firstLive(anchors.dock);
|
|
325
|
+
let node = flow && dock ? lowestCommonAncestor([flow, dock]) : null;
|
|
326
|
+
if (node === document.body || node === document.documentElement) node = null;
|
|
327
|
+
// 兜一层:公共祖先万一窜到应用根(`body > #root` 那一层)就不认,退回更紧的判据。
|
|
328
|
+
const appRoot = document.body ? document.body.firstElementChild : null;
|
|
329
|
+
if (node && appRoot && node === appRoot) node = null;
|
|
330
|
+
if (!node && flow) node = scrollScope(flow);
|
|
331
|
+
if (!node && dock) node = fallbackScope(dock);
|
|
332
|
+
if (!node && flow) node = fallbackScope(flow);
|
|
333
|
+
if (!node && cached && cached.isConnected) node = cached; // 守住上一次的有效作用域,避免抖动
|
|
334
|
+
|
|
335
|
+
scopeCache.node = node || null;
|
|
336
|
+
scopeCache.at = now;
|
|
337
|
+
if (scopeCache.node) markScope(scopeCache.node);
|
|
338
|
+
return scopeCache.node;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function resetScope() {
|
|
342
|
+
scopeCache.node = null;
|
|
343
|
+
scopeCache.at = 0;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// 会话容器的 scope 标记:CSS 靠它做「预糊」。只在节点真的换了的时候改属性。
|
|
347
|
+
let scopeMarked = null;
|
|
348
|
+
function markScope(node) {
|
|
349
|
+
const next = node && node.setAttribute ? node : null;
|
|
350
|
+
if (next === scopeMarked) return;
|
|
351
|
+
if (scopeMarked && scopeMarked.removeAttribute) {
|
|
352
|
+
try {
|
|
353
|
+
scopeMarked.removeAttribute('data-dsh-imgblur-scope');
|
|
354
|
+
} catch (err) {
|
|
355
|
+
/* ignore */
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
scopeMarked = next;
|
|
359
|
+
if (scopeMarked) {
|
|
360
|
+
try {
|
|
361
|
+
scopeMarked.setAttribute('data-dsh-imgblur-scope', '1');
|
|
362
|
+
} catch (err) {
|
|
363
|
+
/* ignore */
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ── 引擎:观察 <img>、下发属性、摆放按钮 ───────────────────────────────
|
|
369
|
+
// 显示状态按 src 记在 window 上:HMR / 重载之后不必重新点一遍。
|
|
370
|
+
if (!(window.__dshImageBlurRevealed instanceof Map)) window.__dshImageBlurRevealed = new Map();
|
|
371
|
+
|
|
372
|
+
const engine = {
|
|
373
|
+
started: false,
|
|
374
|
+
layer: null,
|
|
375
|
+
cssOff: null,
|
|
376
|
+
mo: null,
|
|
377
|
+
rafId: 0,
|
|
378
|
+
scanTimer: 0,
|
|
379
|
+
sweepTimer: 0,
|
|
380
|
+
tracked: new Map(),
|
|
381
|
+
revealed: window.__dshImageBlurRevealed,
|
|
382
|
+
|
|
383
|
+
start() {
|
|
384
|
+
if (this.started) return;
|
|
385
|
+
// 单例仲裁:HMR 会留下上一份实例,先把它的监听、样式、按钮收干净。
|
|
386
|
+
try {
|
|
387
|
+
if (typeof window.__dshImageBlurStop === 'function') window.__dshImageBlurStop();
|
|
388
|
+
} catch (err) {
|
|
389
|
+
/* 上一份已经不在了 */
|
|
390
|
+
}
|
|
391
|
+
this.started = true;
|
|
392
|
+
loadSettings();
|
|
393
|
+
pushCssVars();
|
|
394
|
+
this.cssOff = installCss(CSS);
|
|
395
|
+
document.addEventListener('pointerdown', onDocPointerDown, true);
|
|
396
|
+
document.addEventListener('click', onDocClick, true);
|
|
397
|
+
document.addEventListener('pointerover', onDocPointerOver, true);
|
|
398
|
+
document.addEventListener('pointerout', onDocPointerOut, true);
|
|
399
|
+
document.addEventListener('load', onDocLoad, true);
|
|
400
|
+
try {
|
|
401
|
+
// MutationObserver 的回调是微任务,跑在浏览器绘制之前:新图片插进 DOM 的
|
|
402
|
+
// 同一个任务里就把属性打上,所以第一帧就是糊的(切会话不再闪裸图)。
|
|
403
|
+
this.mo = new MutationObserver((records) => {
|
|
404
|
+
try {
|
|
405
|
+
this.onMutations(records);
|
|
406
|
+
} catch (err) {
|
|
407
|
+
this.queueScan();
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
this.mo.observe(document.documentElement, {
|
|
411
|
+
childList: true,
|
|
412
|
+
subtree: true,
|
|
413
|
+
attributes: true,
|
|
414
|
+
attributeFilter: ['src'],
|
|
415
|
+
});
|
|
416
|
+
} catch (err) {
|
|
417
|
+
this.mo = null;
|
|
418
|
+
}
|
|
419
|
+
window.__dshImageBlurStop = () => engine.stop();
|
|
420
|
+
this.queueScan();
|
|
421
|
+
// 兜底巡检:懒加载 / 从 display:none 里刚出来的图不会触发 mutation,
|
|
422
|
+
// 1.5 秒扫一遍 document.images 把它们捡起来(页面不可见时不做)。
|
|
423
|
+
this.sweepTimer = setInterval(() => {
|
|
424
|
+
if (document.hidden) return;
|
|
425
|
+
try {
|
|
426
|
+
this.scan();
|
|
427
|
+
this.refreshClippers();
|
|
428
|
+
} catch (err) {
|
|
429
|
+
/* 下一轮再来 */
|
|
430
|
+
}
|
|
431
|
+
this.kick();
|
|
432
|
+
}, 1500);
|
|
433
|
+
},
|
|
434
|
+
|
|
435
|
+
stop() {
|
|
436
|
+
if (!this.started) return;
|
|
437
|
+
this.started = false;
|
|
438
|
+
try {
|
|
439
|
+
if (this.mo) this.mo.disconnect();
|
|
440
|
+
} catch (err) {
|
|
441
|
+
/* ignore */
|
|
442
|
+
}
|
|
443
|
+
this.mo = null;
|
|
444
|
+
document.removeEventListener('pointerdown', onDocPointerDown, true);
|
|
445
|
+
document.removeEventListener('click', onDocClick, true);
|
|
446
|
+
document.removeEventListener('pointerover', onDocPointerOver, true);
|
|
447
|
+
document.removeEventListener('pointerout', onDocPointerOut, true);
|
|
448
|
+
document.removeEventListener('load', onDocLoad, true);
|
|
449
|
+
if (this.scanTimer) {
|
|
450
|
+
clearTimeout(this.scanTimer);
|
|
451
|
+
this.scanTimer = 0;
|
|
452
|
+
}
|
|
453
|
+
if (this.sweepTimer) {
|
|
454
|
+
clearInterval(this.sweepTimer);
|
|
455
|
+
this.sweepTimer = 0;
|
|
456
|
+
}
|
|
457
|
+
if (this.rafId) {
|
|
458
|
+
cancelAnimationFrame(this.rafId);
|
|
459
|
+
this.rafId = 0;
|
|
460
|
+
}
|
|
461
|
+
Array.from(this.tracked.keys()).forEach((img) => this.drop(img));
|
|
462
|
+
try {
|
|
463
|
+
if (this.cssOff) this.cssOff();
|
|
464
|
+
} catch (err) {
|
|
465
|
+
/* ignore */
|
|
466
|
+
}
|
|
467
|
+
this.cssOff = null;
|
|
468
|
+
this.layer = null;
|
|
469
|
+
markScope(null);
|
|
470
|
+
if (window.__dshImageBlurStop) window.__dshImageBlurStop = null;
|
|
471
|
+
},
|
|
472
|
+
|
|
473
|
+
// 图片是否值得管:必须落在会话正文容器里,且**按渲染尺寸**够大。
|
|
474
|
+
// 用渲染尺寸而不是 naturalWidth,是因为插件图标常常是 512px 的原图缩到
|
|
475
|
+
// 20–48px 显示 —— 按原图判会把整个界面糊成一片。
|
|
476
|
+
eligible(img) {
|
|
477
|
+
if (!img || img.tagName !== 'IMG') return false;
|
|
478
|
+
if (img.closest && img.closest('[data-dsh-imgblur-skip]')) return false;
|
|
479
|
+
if (img.getAttribute('data-dsh-imgblur-ignore') === '1') return false;
|
|
480
|
+
const scope = conversationScope();
|
|
481
|
+
if (!scope || typeof scope.contains !== 'function' || !scope.contains(img)) return false;
|
|
482
|
+
if (!img.complete || (img.naturalWidth || 0) < 24) return false; // 未解码 / 1px 占位图
|
|
483
|
+
const w = img.clientWidth || 0;
|
|
484
|
+
const h = img.clientHeight || 0;
|
|
485
|
+
if (w < 28 || h < 28) return false;
|
|
486
|
+
// 两条判据取「或」:
|
|
487
|
+
// ① 原图本身就是照片 / 渲染图(≥200×200)且显示得不小于一枚缩略图(≥56×56)
|
|
488
|
+
// —— 多图附件会被压成紧凑格子(64–96px),只看显示尺寸会把它们漏掉;
|
|
489
|
+
// ② 显示得够大(≥minPx 宽、≥48 高)—— 正文里的大图走这条。
|
|
490
|
+
// 图标:原图再大,显示通常也只有 20–48px,两条都不满足(56 这道门槛就是拿来挡图标的)。
|
|
491
|
+
const photoLike =
|
|
492
|
+
(img.naturalWidth || 0) >= 200 && (img.naturalHeight || 0) >= 200 && w >= 56 && h >= 56;
|
|
493
|
+
return photoLike || (w >= settings.minPx && h >= 48);
|
|
494
|
+
},
|
|
495
|
+
|
|
496
|
+
// 取消 CSS 预糊。只在「已经解码且量到尺寸」之后才下结论:正在加载的图如果被
|
|
497
|
+
// 提前打上 off,加载完成前就是裸的,之后又补上模糊 —— 那就是一次可见的闪。
|
|
498
|
+
markOff(img) {
|
|
499
|
+
if (!img || !img.complete || !img.clientWidth || !img.clientHeight) return;
|
|
500
|
+
try {
|
|
501
|
+
img.setAttribute('data-dsh-imgblur-off', '1');
|
|
502
|
+
} catch (err) {
|
|
503
|
+
/* ignore */
|
|
504
|
+
}
|
|
505
|
+
},
|
|
506
|
+
|
|
507
|
+
// 取当前作用域;缓存里的容器已经不在文档里(切了会话)就丢掉重算一次。
|
|
508
|
+
currentScope() {
|
|
509
|
+
let scope = conversationScope();
|
|
510
|
+
if (!scope || !scope.isConnected) {
|
|
511
|
+
resetScope();
|
|
512
|
+
scope = conversationScope();
|
|
513
|
+
}
|
|
514
|
+
return scope && typeof scope.contains === 'function' ? scope : null;
|
|
515
|
+
},
|
|
516
|
+
|
|
517
|
+
// 已经被登记的图如果跑出作用域(换会话、切视图、组件被挪走),立刻撤掉。
|
|
518
|
+
revalidate() {
|
|
519
|
+
const scope = conversationScope();
|
|
520
|
+
const stale = [];
|
|
521
|
+
this.tracked.forEach((entry, img) => {
|
|
522
|
+
if (!scope || typeof scope.contains !== 'function' || !scope.contains(img)) stale.push(img);
|
|
523
|
+
});
|
|
524
|
+
stale.forEach((img) => this.drop(img));
|
|
525
|
+
},
|
|
526
|
+
|
|
527
|
+
scan() {
|
|
528
|
+
if (!settings.enabled) {
|
|
529
|
+
markScope(null); // 关掉开关时连 CSS 预糊一起停
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
this.revalidate();
|
|
533
|
+
const scope = this.currentScope();
|
|
534
|
+
let imgs = [];
|
|
535
|
+
try {
|
|
536
|
+
imgs = Array.from(document.images || []);
|
|
537
|
+
} catch (err) {
|
|
538
|
+
imgs = [];
|
|
539
|
+
}
|
|
540
|
+
for (const img of imgs) {
|
|
541
|
+
if (this.tracked.has(img)) continue;
|
|
542
|
+
if (!scope || typeof scope.contains !== 'function' || !scope.contains(img)) continue;
|
|
543
|
+
if (this.eligible(img)) {
|
|
544
|
+
this.track(img);
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
// 在会话里但不够格(图标、状态点):打上 off 取消 CSS 预糊,保持原样。
|
|
548
|
+
this.markOff(img);
|
|
549
|
+
}
|
|
550
|
+
},
|
|
551
|
+
|
|
552
|
+
// 新插入的图片走这条路:同步登记(MutationObserver 回调是微任务,早于绘制),
|
|
553
|
+
// 于是第一帧就是模糊的,不会先露裸图。
|
|
554
|
+
trackNow(img) {
|
|
555
|
+
if (!settings.enabled) return;
|
|
556
|
+
if (!img || img.tagName !== 'IMG' || this.tracked.has(img)) return;
|
|
557
|
+
let scope = conversationScope();
|
|
558
|
+
// 刚切会话时缓存里可能还是上一个会话的容器,装不下这张新图 —— 丢掉缓存重算一次。
|
|
559
|
+
if (!scope || !scope.isConnected) {
|
|
560
|
+
resetScope();
|
|
561
|
+
scope = conversationScope();
|
|
562
|
+
}
|
|
563
|
+
if (!scope || typeof scope.contains !== 'function') {
|
|
564
|
+
this.queueScan();
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
if (!scope.contains(img)) return;
|
|
568
|
+
if (this.eligible(img)) {
|
|
569
|
+
this.track(img);
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
this.markOff(img);
|
|
573
|
+
},
|
|
574
|
+
|
|
575
|
+
onMutations(records) {
|
|
576
|
+
let touched = false;
|
|
577
|
+
for (let i = 0; i < records.length; i += 1) {
|
|
578
|
+
const rec = records[i];
|
|
579
|
+
if (rec.type !== 'childList') continue;
|
|
580
|
+
const added = rec.addedNodes;
|
|
581
|
+
for (let j = 0; j < added.length; j += 1) {
|
|
582
|
+
const node = added[j];
|
|
583
|
+
if (!node || node.nodeType !== 1) continue;
|
|
584
|
+
if (node.tagName === 'IMG') {
|
|
585
|
+
this.trackNow(node);
|
|
586
|
+
touched = true;
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
if (node.querySelectorAll) {
|
|
590
|
+
const imgs = node.querySelectorAll('img');
|
|
591
|
+
for (let k = 0; k < imgs.length; k += 1) {
|
|
592
|
+
this.trackNow(imgs[k]);
|
|
593
|
+
touched = true;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
if (touched) {
|
|
599
|
+
try {
|
|
600
|
+
this.layout();
|
|
601
|
+
} catch (err) {
|
|
602
|
+
/* ignore */
|
|
603
|
+
}
|
|
604
|
+
this.kick();
|
|
605
|
+
}
|
|
606
|
+
// 其余变化(文本流式、属性改)仍走节流全量扫描。
|
|
607
|
+
this.queueScan();
|
|
608
|
+
},
|
|
609
|
+
|
|
610
|
+
// 图片外面每一层「会裁剪的祖先」(消息列表就是滚动容器)。chip 只在图片
|
|
611
|
+
// 真正露出来的那块区域里画 —— 图滚出对话框时按钮跟着消失,不会飘到框外面。
|
|
612
|
+
clippersFor(img) {
|
|
613
|
+
const out = [];
|
|
614
|
+
let el = img.parentElement;
|
|
615
|
+
for (let i = 0; el && i < 16 && out.length < 4; i += 1) {
|
|
616
|
+
try {
|
|
617
|
+
const cs = window.getComputedStyle(el);
|
|
618
|
+
const ov = String(cs.overflowY || '') + ' ' + String(cs.overflowX || '');
|
|
619
|
+
if (/(hidden|auto|scroll|overlay|clip)/.test(ov)) out.push(el);
|
|
620
|
+
} catch (err) {
|
|
621
|
+
/* ignore */
|
|
622
|
+
}
|
|
623
|
+
el = el.parentElement;
|
|
624
|
+
}
|
|
625
|
+
return out;
|
|
626
|
+
},
|
|
627
|
+
|
|
628
|
+
// 容器可能晚于图片出现(用户消息先本地回显、再变成正式消息节点),
|
|
629
|
+
// 所以每秒一次把已登记图片的裁剪祖先重算一遍,免得用着用着失准。
|
|
630
|
+
refreshClippers() {
|
|
631
|
+
this.tracked.forEach((entry, img) => {
|
|
632
|
+
if (!img || !img.isConnected) return;
|
|
633
|
+
try {
|
|
634
|
+
entry.clippers = this.clippersFor(img);
|
|
635
|
+
entry.clipAt = 0;
|
|
636
|
+
// 图片被放大/缩小时(窗口缩放、附件从缩略图变成大图)顺手切换紧凑按钮。
|
|
637
|
+
const small = (img.clientWidth || 0) < 140 || (img.clientHeight || 0) < 90;
|
|
638
|
+
if (entry.chip && entry.compact !== small) {
|
|
639
|
+
entry.compact = small;
|
|
640
|
+
entry.chip.dataset.compact = small ? '1' : '0';
|
|
641
|
+
entry.width = entry.chip.offsetWidth || 0;
|
|
642
|
+
}
|
|
643
|
+
} catch (err) {
|
|
644
|
+
/* ignore */
|
|
645
|
+
}
|
|
646
|
+
});
|
|
647
|
+
},
|
|
648
|
+
|
|
649
|
+
track(img) {
|
|
650
|
+
const src = img.currentSrc || img.src || '';
|
|
651
|
+
const shown = this.revealed.has(src);
|
|
652
|
+
const entry = { src, shown, chip: null, label: null, width: 0, clippers: this.clippersFor(img) };
|
|
653
|
+
this.tracked.set(img, entry);
|
|
654
|
+
try {
|
|
655
|
+
img.removeAttribute('data-dsh-imgblur-off');
|
|
656
|
+
img.setAttribute(ATTR, shown ? 'shown' : 'hidden');
|
|
657
|
+
// 把元素自己的圆角喂给 clip-path,裁掉裁剪框后不会把圆角图片切成直角。
|
|
658
|
+
const cs = window.getComputedStyle(img);
|
|
659
|
+
const radius = (cs && cs.borderRadius) || '0px';
|
|
660
|
+
if (radius && radius !== '0px') img.style.setProperty('--dsh-imgblur-clip', radius);
|
|
661
|
+
} catch (err) {
|
|
662
|
+
/* ignore */
|
|
663
|
+
}
|
|
664
|
+
this.ensureChip(img, entry);
|
|
665
|
+
this.kick();
|
|
666
|
+
},
|
|
667
|
+
|
|
668
|
+
drop(img) {
|
|
669
|
+
const entry = this.tracked.get(img);
|
|
670
|
+
this.tracked.delete(img);
|
|
671
|
+
try {
|
|
672
|
+
img.removeAttribute(ATTR);
|
|
673
|
+
img.style.removeProperty('--dsh-imgblur-clip');
|
|
674
|
+
} catch (err) {
|
|
675
|
+
/* ignore */
|
|
676
|
+
}
|
|
677
|
+
if (entry && entry.chip) {
|
|
678
|
+
try {
|
|
679
|
+
entry.chip.remove();
|
|
680
|
+
} catch (err) {
|
|
681
|
+
/* ignore */
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
},
|
|
685
|
+
|
|
686
|
+
ensureChip(img, entry) {
|
|
687
|
+
if (!this.layer || !entry || entry.chip) return;
|
|
688
|
+
const chip = document.createElement('button');
|
|
689
|
+
chip.type = 'button';
|
|
690
|
+
chip.className = 'dsh-imgblur-chip';
|
|
691
|
+
chip.dataset.shown = entry.shown ? '1' : '0';
|
|
692
|
+
// 小图用「只有眼睛」的紧凑按钮,不然一颗「显示」就把缩略图盖住一半。
|
|
693
|
+
const small = (img.clientWidth || 0) < 140 || (img.clientHeight || 0) < 90;
|
|
694
|
+
chip.dataset.compact = small ? '1' : '0';
|
|
695
|
+
entry.compact = small;
|
|
696
|
+
chip.title = t(entry.shown ? 'chip.blurTitle' : 'chip.showTitle');
|
|
697
|
+
chip.setAttribute('aria-label', t(entry.shown ? 'chip.blurAria' : 'chip.showAria'));
|
|
698
|
+
|
|
699
|
+
const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
700
|
+
icon.setAttribute('viewBox', '0 0 16 16');
|
|
701
|
+
icon.setAttribute('width', '12');
|
|
702
|
+
icon.setAttribute('height', '12');
|
|
703
|
+
icon.setAttribute('aria-hidden', 'true');
|
|
704
|
+
icon.setAttribute('fill', 'none');
|
|
705
|
+
icon.setAttribute('stroke', 'currentColor');
|
|
706
|
+
icon.setAttribute('stroke-width', '1.4');
|
|
707
|
+
icon.setAttribute('stroke-linecap', 'round');
|
|
708
|
+
icon.setAttribute('stroke-linejoin', 'round');
|
|
709
|
+
const eye = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
710
|
+
eye.setAttribute('d', 'M1.4 8S3.8 3.9 8 3.9 14.6 8 14.6 8 12.2 12.1 8 12.1 1.4 8 1.4 8Z');
|
|
711
|
+
const pupil = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
|
712
|
+
pupil.setAttribute('cx', '8');
|
|
713
|
+
pupil.setAttribute('cy', '8');
|
|
714
|
+
pupil.setAttribute('r', '1.7');
|
|
715
|
+
icon.appendChild(eye);
|
|
716
|
+
icon.appendChild(pupil);
|
|
717
|
+
|
|
718
|
+
const label = document.createElement('span');
|
|
719
|
+
label.textContent = t(entry.shown ? 'chip.blur' : 'chip.show');
|
|
720
|
+
|
|
721
|
+
chip.appendChild(icon);
|
|
722
|
+
chip.appendChild(label);
|
|
723
|
+
chip.addEventListener('pointerdown', (ev) => {
|
|
724
|
+
ev.stopPropagation();
|
|
725
|
+
});
|
|
726
|
+
chip.addEventListener('click', (ev) => {
|
|
727
|
+
ev.preventDefault();
|
|
728
|
+
ev.stopPropagation();
|
|
729
|
+
const live = this.tracked.get(img);
|
|
730
|
+
this.setShown(img, !(live && live.shown));
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
entry.chip = chip;
|
|
734
|
+
entry.label = label;
|
|
735
|
+
this.layer.appendChild(chip);
|
|
736
|
+
try {
|
|
737
|
+
// chip 宽度量一次就缓存,滚动时每帧只读 img 矩形、不再触发重排。
|
|
738
|
+
entry.width = chip.offsetWidth || 0;
|
|
739
|
+
} catch (err) {
|
|
740
|
+
entry.width = 0;
|
|
741
|
+
}
|
|
742
|
+
},
|
|
743
|
+
|
|
744
|
+
setShown(img, shown) {
|
|
745
|
+
const entry = this.tracked.get(img);
|
|
746
|
+
const src = entry ? entry.src : img.currentSrc || img.src || '';
|
|
747
|
+
if (shown) {
|
|
748
|
+
this.revealed.set(src, true);
|
|
749
|
+
if (this.revealed.size > 800) {
|
|
750
|
+
const first = this.revealed.keys().next();
|
|
751
|
+
if (!first.done) this.revealed.delete(first.value);
|
|
752
|
+
}
|
|
753
|
+
} else {
|
|
754
|
+
this.revealed.delete(src);
|
|
755
|
+
}
|
|
756
|
+
this.tracked.forEach((other, el) => {
|
|
757
|
+
if (other.src !== src) return;
|
|
758
|
+
other.shown = shown;
|
|
759
|
+
try {
|
|
760
|
+
el.setAttribute(ATTR, shown ? 'shown' : 'hidden');
|
|
761
|
+
} catch (err) {
|
|
762
|
+
/* ignore */
|
|
763
|
+
}
|
|
764
|
+
if (other.chip) {
|
|
765
|
+
other.chip.dataset.shown = shown ? '1' : '0';
|
|
766
|
+
other.chip.title = t(shown ? 'chip.blurTitle' : 'chip.showTitle');
|
|
767
|
+
other.chip.setAttribute('aria-label', t(shown ? 'chip.blurAria' : 'chip.showAria'));
|
|
768
|
+
}
|
|
769
|
+
if (other.label) other.label.textContent = t(shown ? 'chip.blur' : 'chip.show');
|
|
770
|
+
if (other.chip) {
|
|
771
|
+
try {
|
|
772
|
+
other.width = other.chip.offsetWidth || 0;
|
|
773
|
+
} catch (err) {
|
|
774
|
+
/* ignore */
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
});
|
|
778
|
+
},
|
|
779
|
+
|
|
780
|
+
// 设置变了:全部拆掉重新来(关掉开关时这一步就是「原样还原」)。
|
|
781
|
+
refresh() {
|
|
782
|
+
Array.from(this.tracked.keys()).forEach((img) => this.drop(img));
|
|
783
|
+
this.scan();
|
|
784
|
+
this.kick();
|
|
785
|
+
},
|
|
786
|
+
|
|
787
|
+
kick() {
|
|
788
|
+
if (this.rafId) return;
|
|
789
|
+
if (!this.tracked.size) return;
|
|
790
|
+
this.rafId = requestAnimationFrame(() => {
|
|
791
|
+
this.rafId = 0;
|
|
792
|
+
if (!this.tracked.size) return;
|
|
793
|
+
this.layout();
|
|
794
|
+
this.kick();
|
|
795
|
+
});
|
|
796
|
+
},
|
|
797
|
+
|
|
798
|
+
layout() {
|
|
799
|
+
const layer = this.layer;
|
|
800
|
+
if (!layer || typeof layer.getBoundingClientRect !== 'function') return;
|
|
801
|
+
const lr = layer.getBoundingClientRect();
|
|
802
|
+
const vw = window.innerWidth || 0;
|
|
803
|
+
const vh = window.innerHeight || 0;
|
|
804
|
+
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
805
|
+
// 同一帧里多个 chip 常常共用同一个滚动容器:它的矩形只量一次。
|
|
806
|
+
const clipRects = new Map();
|
|
807
|
+
function rectOf(el) {
|
|
808
|
+
let r = clipRects.get(el);
|
|
809
|
+
if (!r) {
|
|
810
|
+
r = el.getBoundingClientRect();
|
|
811
|
+
clipRects.set(el, r);
|
|
812
|
+
}
|
|
813
|
+
return r;
|
|
814
|
+
}
|
|
815
|
+
// 先读完所有 img 的矩形(以及把每张图裁到「真正可见的那块」),再统一写 transform:
|
|
816
|
+
// 读写分开,滚动时不会因为「读-写-读-写」反复触发重排。
|
|
817
|
+
const reads = [];
|
|
818
|
+
const dead = [];
|
|
819
|
+
this.tracked.forEach((entry, img) => {
|
|
820
|
+
if (!img || !img.isConnected) {
|
|
821
|
+
dead.push(img);
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
const chip = entry.chip;
|
|
825
|
+
if (!chip) return;
|
|
826
|
+
const r = img.getBoundingClientRect();
|
|
827
|
+
// 裁切祖先可能是登记之后才出现的(用户消息从本地回显变成正式消息、
|
|
828
|
+
// 卡片先画骨架再套滚动容器),所以为空时每秒补扫一次。
|
|
829
|
+
if (!entry.clippers || !entry.clippers.length) {
|
|
830
|
+
if (!entry.clipAt || now - entry.clipAt > 1000) {
|
|
831
|
+
entry.clippers = this.clippersFor(img);
|
|
832
|
+
entry.clipAt = now;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
let top = Math.max(r.top, 0);
|
|
836
|
+
let bottom = Math.min(r.bottom, vh);
|
|
837
|
+
let left = Math.max(r.left, 0);
|
|
838
|
+
let right = Math.min(r.right, vw);
|
|
839
|
+
const clippers = entry.clippers || [];
|
|
840
|
+
for (let i = 0; i < clippers.length; i += 1) {
|
|
841
|
+
const c = clippers[i];
|
|
842
|
+
if (!c || !c.isConnected) continue;
|
|
843
|
+
const cr = rectOf(c);
|
|
844
|
+
if (cr.top > top) top = cr.top;
|
|
845
|
+
if (cr.bottom < bottom) bottom = cr.bottom;
|
|
846
|
+
if (cr.left > left) left = cr.left;
|
|
847
|
+
if (cr.right < right) right = cr.right;
|
|
848
|
+
}
|
|
849
|
+
// 兜底命中测试:拿图片右上角内侧一个点(避开 chip 自己的盒子)做
|
|
850
|
+
// elementFromPoint。只要那个点被裁掉 / 被别的层盖住,命中的就不是这张图
|
|
851
|
+
// 或它所在的那层包装,于是按钮一起收起 —— 不依赖「猜哪些祖先是裁剪容器」。
|
|
852
|
+
let hit = false;
|
|
853
|
+
const px = r.right - 4;
|
|
854
|
+
const py = r.top + 40;
|
|
855
|
+
if (px > 0 && px < vw && py > 0 && py < vh) {
|
|
856
|
+
let el = null;
|
|
857
|
+
try {
|
|
858
|
+
el = document.elementFromPoint(px, py);
|
|
859
|
+
} catch (err) {
|
|
860
|
+
el = null;
|
|
861
|
+
}
|
|
862
|
+
// 图片本身、图里的节点、或它往上 3 层包装里命中的元素都算「还看得见」
|
|
863
|
+
// (缩略图常给 <img> 挂 pointer-events:none,命中会是包住它的那层)。
|
|
864
|
+
let box = img.parentElement;
|
|
865
|
+
for (let i = 0; box && i < 3 && !hit; i += 1) {
|
|
866
|
+
hit = el === box || (box.contains ? box.contains(el) : false);
|
|
867
|
+
box = box.parentElement;
|
|
868
|
+
}
|
|
869
|
+
if (!hit && el === img) hit = true;
|
|
870
|
+
}
|
|
871
|
+
reads.push({
|
|
872
|
+
entry: entry,
|
|
873
|
+
chip: chip,
|
|
874
|
+
r: r,
|
|
875
|
+
top: top,
|
|
876
|
+
bottom: bottom,
|
|
877
|
+
left: left,
|
|
878
|
+
right: right,
|
|
879
|
+
hit: hit,
|
|
880
|
+
});
|
|
881
|
+
});
|
|
882
|
+
for (const item of reads) {
|
|
883
|
+
const r = item.r;
|
|
884
|
+
const chip = item.chip;
|
|
885
|
+
// 可见区太小(图几乎滚没了 / 被折叠)就不画
|
|
886
|
+
if (item.right - item.left < 26 || item.bottom - item.top < 20) {
|
|
887
|
+
chip.style.display = 'none';
|
|
888
|
+
continue;
|
|
889
|
+
}
|
|
890
|
+
// 图片那个角已经被裁掉 / 盖住:按钮跟着消失,不留在对话框外面。
|
|
891
|
+
if (!item.hit) {
|
|
892
|
+
chip.style.display = 'none';
|
|
893
|
+
continue;
|
|
894
|
+
}
|
|
895
|
+
const w = item.entry.width || 58;
|
|
896
|
+
const x = Math.min(Math.max(r.right - w - 8, 4), Math.max(vw - w - 4, 4));
|
|
897
|
+
const y = Math.min(Math.max(r.top + 8, 4), Math.max(vh - 32, 4));
|
|
898
|
+
// chip 还必须完整落在「图片可见区 ∩ 窗口」里。
|
|
899
|
+
const inside =
|
|
900
|
+
x >= item.left - 1 &&
|
|
901
|
+
x + w <= item.right + 1 &&
|
|
902
|
+
y >= item.top - 1 &&
|
|
903
|
+
y + 24 <= item.bottom + 1;
|
|
904
|
+
if (!inside) {
|
|
905
|
+
chip.style.display = 'none';
|
|
906
|
+
continue;
|
|
907
|
+
}
|
|
908
|
+
chip.style.display = '';
|
|
909
|
+
chip.style.transform =
|
|
910
|
+
'translate3d(' + Math.round(x - lr.left) + 'px,' + Math.round(y - lr.top) + 'px,0)';
|
|
911
|
+
}
|
|
912
|
+
dead.forEach((img) => this.drop(img));
|
|
913
|
+
},
|
|
914
|
+
|
|
915
|
+
queueScan() {
|
|
916
|
+
if (this.scanTimer) return;
|
|
917
|
+
this.scanTimer = setTimeout(() => {
|
|
918
|
+
this.scanTimer = 0;
|
|
919
|
+
try {
|
|
920
|
+
this.scan();
|
|
921
|
+
this.layout();
|
|
922
|
+
} catch (err) {
|
|
923
|
+
/* 一帧失败不影响后续 */
|
|
924
|
+
}
|
|
925
|
+
this.kick();
|
|
926
|
+
}, 60);
|
|
927
|
+
},
|
|
928
|
+
|
|
929
|
+
attachLayer(layer) {
|
|
930
|
+
this.layer = layer;
|
|
931
|
+
// 浮层可能在引擎启动之后才挂载:给已经登记的图片重做 chip。
|
|
932
|
+
this.tracked.forEach((entry, img) => {
|
|
933
|
+
if (entry.chip) {
|
|
934
|
+
try {
|
|
935
|
+
entry.chip.remove();
|
|
936
|
+
} catch (err) {
|
|
937
|
+
/* ignore */
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
entry.chip = null;
|
|
941
|
+
entry.label = null;
|
|
942
|
+
this.ensureChip(img, entry);
|
|
943
|
+
});
|
|
944
|
+
this.queueScan();
|
|
945
|
+
this.kick();
|
|
946
|
+
return () => {
|
|
947
|
+
if (this.layer === layer) this.layer = null;
|
|
948
|
+
this.tracked.forEach((entry) => {
|
|
949
|
+
if (entry.chip) {
|
|
950
|
+
try {
|
|
951
|
+
entry.chip.remove();
|
|
952
|
+
} catch (err) {
|
|
953
|
+
/* ignore */
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
entry.chip = null;
|
|
957
|
+
entry.label = null;
|
|
958
|
+
});
|
|
959
|
+
};
|
|
960
|
+
},
|
|
961
|
+
};
|
|
962
|
+
|
|
963
|
+
// ── 全局事件(都挂在 document 的捕获阶段,先于宿主自己的处理)───────────
|
|
964
|
+
const swallow = new WeakMap();
|
|
965
|
+
|
|
966
|
+
function imageFrom(ev) {
|
|
967
|
+
const target = ev && ev.target;
|
|
968
|
+
const el = target && target.nodeType === 1 ? target : target && target.parentElement;
|
|
969
|
+
if (!el || typeof el.closest !== 'function') return null;
|
|
970
|
+
return el.closest('img');
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
function blurredFrom(ev) {
|
|
974
|
+
const img = imageFrom(ev);
|
|
975
|
+
if (!img || !engine.tracked.has(img)) return null;
|
|
976
|
+
return img.getAttribute(ATTR) === 'hidden' ? img : null;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
function onDocPointerDown(ev) {
|
|
980
|
+
const img = blurredFrom(ev);
|
|
981
|
+
if (!img) return;
|
|
982
|
+
// 按下就揭开,并且不把这次按下交给宿主(否则会同时弹看图层)。
|
|
983
|
+
swallow.set(img, typeof performance !== 'undefined' ? performance.now() : Date.now());
|
|
984
|
+
engine.setShown(img, true);
|
|
985
|
+
ev.stopPropagation();
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
function onDocClick(ev) {
|
|
989
|
+
const img = imageFrom(ev);
|
|
990
|
+
if (!img) return;
|
|
991
|
+
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
992
|
+
const stamp = swallow.get(img);
|
|
993
|
+
if (stamp && now - stamp < 600) {
|
|
994
|
+
swallow.delete(img);
|
|
995
|
+
ev.preventDefault();
|
|
996
|
+
ev.stopPropagation();
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
999
|
+
const blurred = blurredFrom(ev);
|
|
1000
|
+
if (blurred) {
|
|
1001
|
+
ev.preventDefault();
|
|
1002
|
+
ev.stopPropagation();
|
|
1003
|
+
engine.setShown(blurred, true);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
function onDocPointerOver(ev) {
|
|
1008
|
+
const img = imageFrom(ev);
|
|
1009
|
+
if (!img) return;
|
|
1010
|
+
const entry = engine.tracked.get(img);
|
|
1011
|
+
if (entry && entry.chip) entry.chip.dataset.hot = '1';
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
function onDocPointerOut(ev) {
|
|
1015
|
+
const img = imageFrom(ev);
|
|
1016
|
+
if (!img) return;
|
|
1017
|
+
const entry = engine.tracked.get(img);
|
|
1018
|
+
if (entry && entry.chip) delete entry.chip.dataset.hot;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
function onDocLoad(ev) {
|
|
1022
|
+
const target = ev && ev.target;
|
|
1023
|
+
if (!target || target.tagName !== 'IMG') return;
|
|
1024
|
+
engine.queueScan();
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
// ── 槽位组件 ───────────────────────────────────────────────────────────
|
|
1028
|
+
// 锚点必须用 layout effect 登记:它同步跑在 commit 之后、绘制之前,
|
|
1029
|
+
// 于是 MutationObserver 回调(微任务,也早于绘制)里已经能算出作用域,
|
|
1030
|
+
// 新会话的第一帧就是糊的。用普通 useEffect 会晚一帧,正是「先露一下再糊」。
|
|
1031
|
+
const useIsoLayoutEffect = React.useLayoutEffect || useEffect;
|
|
1032
|
+
|
|
1033
|
+
// 零尺寸锚点:只用来「定位会话正文容器」,不画任何东西、不占任何空间。
|
|
1034
|
+
function useAnchor(bucket) {
|
|
1035
|
+
const ref = useRef(null);
|
|
1036
|
+
useIsoLayoutEffect(() => {
|
|
1037
|
+
const el = ref.current;
|
|
1038
|
+
if (!el) return undefined;
|
|
1039
|
+
bucket.add(el);
|
|
1040
|
+
resetScope();
|
|
1041
|
+
engine.queueScan();
|
|
1042
|
+
return () => {
|
|
1043
|
+
bucket.delete(el);
|
|
1044
|
+
resetScope();
|
|
1045
|
+
engine.queueScan();
|
|
1046
|
+
};
|
|
1047
|
+
}, []);
|
|
1048
|
+
return h('span', { ref, className: 'dsh-imgblur-anchor', 'aria-hidden': 'true' });
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
function DockAnchor() {
|
|
1052
|
+
return useAnchor(anchors.dock);
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
function FlowAnchor() {
|
|
1056
|
+
return useAnchor(anchors.flow);
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
function BlurLayer() {
|
|
1060
|
+
const ref = useRef(null);
|
|
1061
|
+
useIsoLayoutEffect(() => {
|
|
1062
|
+
const el = ref.current;
|
|
1063
|
+
if (!el) return undefined;
|
|
1064
|
+
return engine.attachLayer(el);
|
|
1065
|
+
}, []);
|
|
1066
|
+
return h('div', { ref, className: 'dsh-imgblur-layer' });
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
function StrengthSegment() {
|
|
1070
|
+
return h(
|
|
1071
|
+
'div',
|
|
1072
|
+
{ className: 'dsh-imgblur-seg', role: 'group', 'aria-label': t('settings.strength') },
|
|
1073
|
+
h('span', { className: 'dsh-imgblur-seg-label' }, t('settings.strength')),
|
|
1074
|
+
STRENGTH_KEYS.map((key) =>
|
|
1075
|
+
h(
|
|
1076
|
+
'button',
|
|
1077
|
+
{
|
|
1078
|
+
key,
|
|
1079
|
+
type: 'button',
|
|
1080
|
+
className: 'dsh-imgblur-seg-btn' + (settings.strength === key ? ' is-on' : ''),
|
|
1081
|
+
'aria-pressed': settings.strength === key ? 'true' : 'false',
|
|
1082
|
+
disabled: !settings.enabled,
|
|
1083
|
+
onClick: () => applySettings({ strength: key }),
|
|
1084
|
+
},
|
|
1085
|
+
t(key === 'light' ? 'strengthLight' : key === 'normal' ? 'strengthNormal' : 'strengthStrong'),
|
|
1086
|
+
),
|
|
1087
|
+
),
|
|
1088
|
+
);
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
function SettingsRow() {
|
|
1092
|
+
const [, bump] = useState(0);
|
|
1093
|
+
useEffect(() => {
|
|
1094
|
+
const fn = () => bump((n) => (n + 1) % 1000000);
|
|
1095
|
+
subs.add(fn);
|
|
1096
|
+
return () => {
|
|
1097
|
+
subs.delete(fn);
|
|
1098
|
+
};
|
|
1099
|
+
}, []);
|
|
1100
|
+
const on = settings.enabled;
|
|
1101
|
+
return h(
|
|
1102
|
+
'div',
|
|
1103
|
+
{ className: 'dsh-imgblur-row' },
|
|
1104
|
+
h(
|
|
1105
|
+
'div',
|
|
1106
|
+
{ className: 'dsh-imgblur-col' },
|
|
1107
|
+
h('div', { className: 'dsh-imgblur-name' }, t('settings.title')),
|
|
1108
|
+
h('div', { className: 'dsh-imgblur-desc' }, t(on ? 'settings.desc' : 'settings.offDesc')),
|
|
1109
|
+
),
|
|
1110
|
+
h(
|
|
1111
|
+
'div',
|
|
1112
|
+
{ className: 'dsh-imgblur-ctrls' },
|
|
1113
|
+
h(StrengthSegment, null),
|
|
1114
|
+
h(
|
|
1115
|
+
'button',
|
|
1116
|
+
{
|
|
1117
|
+
type: 'button',
|
|
1118
|
+
role: 'switch',
|
|
1119
|
+
'aria-checked': on ? 'true' : 'false',
|
|
1120
|
+
'aria-label': t('settings.title'),
|
|
1121
|
+
className: 'dsh-imgblur-switch' + (on ? ' is-on' : ''),
|
|
1122
|
+
onClick: () => applySettings({ enabled: !on }),
|
|
1123
|
+
},
|
|
1124
|
+
h('span', { className: 'dsh-imgblur-knob' }),
|
|
1125
|
+
),
|
|
1126
|
+
),
|
|
1127
|
+
);
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
// ── 插件本体 ───────────────────────────────────────────────────────────
|
|
1131
|
+
return {
|
|
1132
|
+
inject: ['slots'],
|
|
1133
|
+
apply(ctx) {
|
|
1134
|
+
localeCtx = ctx;
|
|
1135
|
+
// 语言包:注册成功就用 locale 服务,注册不上(老宿主)就用本机字典。
|
|
1136
|
+
try {
|
|
1137
|
+
const loc = ctx.get ? ctx.get('locale') : null;
|
|
1138
|
+
if (loc && typeof loc.register === 'function') {
|
|
1139
|
+
['zh-CN', 'zh', 'en-US', 'en'].forEach((id) => {
|
|
1140
|
+
ctx.effect(() => {
|
|
1141
|
+
try {
|
|
1142
|
+
return loc.register(NS, id, TEXT[id.indexOf('zh') === 0 ? 'zh' : 'en']);
|
|
1143
|
+
} catch (err) {
|
|
1144
|
+
return () => {};
|
|
1145
|
+
}
|
|
1146
|
+
}, 'image-blur-locale-' + id);
|
|
1147
|
+
});
|
|
1148
|
+
if (typeof loc.bind === 'function') translate = loc.bind(NS);
|
|
1149
|
+
}
|
|
1150
|
+
} catch (err) {
|
|
1151
|
+
translate = null;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
// 引擎常驻:CSS + 观察者只装一次(apply 万一被调两次也不会重复)。
|
|
1155
|
+
try {
|
|
1156
|
+
engine.start();
|
|
1157
|
+
} catch (err) {
|
|
1158
|
+
/* 引擎起不来也不能让整个 Client 半边炸掉 */
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
ctx.effect(() => {
|
|
1162
|
+
try {
|
|
1163
|
+
// 注意:必须包一层,直接返回 engine.stop 会丢 this。
|
|
1164
|
+
return () => engine.stop();
|
|
1165
|
+
} catch (err) {
|
|
1166
|
+
return () => {};
|
|
1167
|
+
}
|
|
1168
|
+
}, 'image-blur-engine');
|
|
1169
|
+
|
|
1170
|
+
ctx.slots.inject('shell.overlay', () =>
|
|
1171
|
+
ctx.slots.register({ name: 'shell.overlay', id: 'image-blur-pills', order: 30 }, BlurLayer),
|
|
1172
|
+
);
|
|
1173
|
+
// 作用域锚点:一个落在消息流里,一个落在输入框下方,二者公共祖先 = 会话正文。
|
|
1174
|
+
ctx.slots.inject('conversation.chat.turnTail', () =>
|
|
1175
|
+
ctx.slots.register({ name: 'conversation.chat.turnTail', id: 'image-blur-anchor-flow', order: 990 }, FlowAnchor),
|
|
1176
|
+
);
|
|
1177
|
+
ctx.slots.inject('conversation.composer.dock', () =>
|
|
1178
|
+
ctx.slots.register({ name: 'conversation.composer.dock', id: 'image-blur-anchor-dock', order: 990 }, DockAnchor),
|
|
1179
|
+
);
|
|
1180
|
+
ctx.slots.inject('settings.general.item', () =>
|
|
1181
|
+
ctx.slots.register({ name: 'settings.general.item', id: 'image-blur', order: 21 }, SettingsRow),
|
|
1182
|
+
);
|
|
1183
|
+
},
|
|
1184
|
+
};
|
|
1185
|
+
},
|
|
1186
|
+
});
|