dsh-design-studio 0.0.0-stage → 0.3.2

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/lib/picker.js ADDED
@@ -0,0 +1,1403 @@
1
+ /**
2
+ * 注入到预览页面里的脚本(浏览器里跑,不是 Node)。
3
+ *
4
+ * - 和右侧栏通信:只认外层 DSH 页面发来的消息(event.source === parent 且 origin 对得上)
5
+ * - 点选:鼠标移到哪高亮到哪,点一下选中元素;拖一下框出一块区域
6
+ * - 选中后把「位置、HTML、关键样式、组件源码位置(拿得到的话)」发回右侧栏,选中的地方留个虚线框
7
+ * - 改动高亮:刷新 / 热更新前后各记一份「指纹」,改了哪里就在哪里闪一下
8
+ * - 生成动画:模型刚做出来的一屏,元素从上到下依次浮现,一道扫描光带过去
9
+ * - 画布里缩小显示时,右侧栏告诉缩放比例,选取框、标签按比例放大,看得清
10
+ * - 设计变量:页面 :root 上的 CSS 变量(配色、字号、间距、圆角 …)连同出处报给右侧栏,显示成规范面板
11
+ * - 导出图片:把页面画成 PNG(样式逐个元素内联进 SVG foreignObject,图片、字体转成 data URL)交给右侧栏
12
+ * - 深色模式:页面带了深色样式就能一键切过去看
13
+ * - 本地文件预览:目录里有改动就通知右侧栏,右侧栏在后台载好新的一帧再换上来(不白屏);
14
+ * 单独在浏览器里打开时自己整页刷新。前后保住滚动位置
15
+ *
16
+ * 所有界面都画在一个 shadow root 里,不受页面样式影响,也不污染页面。
17
+ */
18
+ (() => {
19
+ if (window.__dshDesignPicker) return
20
+ window.__dshDesignPicker = true
21
+
22
+ const cfg = window.__DSH_DESIGN__ || {}
23
+ // 只和 DSH 说话:外层的源得是服务端给的那几个(桌面版 dsh-app://app、DSH 网页端)。
24
+ // 别的网站把预览嵌进去:当没有外层(不发消息、也不听它的),页面照常显示
25
+ const allowed = Array.isArray(cfg.parents) ? cfg.parents.filter((o) => typeof o === 'string' && o && o !== '*' && o !== 'null') : []
26
+ const anc = location.ancestorOrigins && location.ancestorOrigins.length ? location.ancestorOrigins[0] : ''
27
+ // 「源:*」= 这个地址的任意端口(DSH 声明的对外域名没写端口时)
28
+ const fitsParent = (o) => o === anc || (o.endsWith(':*') && anc.startsWith(o.slice(0, -1)) && /^\d+$/.test(anc.slice(o.length - 1)))
29
+ const parentOrigins = window.parent === window ? [] : anc ? (allowed.some(fitsParent) ? [anc] : []) : allowed.filter((o) => !o.endsWith(':*'))
30
+ const parentWin = parentOrigins.length ? window.parent : null
31
+ const SEED_KEY = '__dsh_design_seed__'
32
+
33
+ function post(msg) {
34
+ if (!parentWin) return
35
+ // 指定收件的源:外层不是这个源就收不到(拿不到 ancestorOrigins 时每个认的源各发一次,只有对得上的那个收得到)
36
+ for (const o of parentOrigins) {
37
+ try { parentWin.postMessage({ dshDesign: 1, ...msg }, o) } catch { /* 外层不在了 */ }
38
+ }
39
+ }
40
+
41
+ // ── 描述一个元素 ──────────────────────────────────────────────────────────
42
+
43
+ const esc = (s) => (window.CSS && CSS.escape ? CSS.escape(s) : String(s).replace(/[^\w-]/g, '\\$&'))
44
+ const GOOD_CLASS = /^[A-Za-z_][\w-]*$/
45
+ // 状态类、哈希类(css-modules / styled-components 的随机后缀)不进选择器
46
+ const NOISY_CLASS = /^(is-|has-)|(active|hover|focus|selected|open)$|^(css|sc|jsx|svelte|emotion)-[\w-]{4,}$|_[a-z0-9]{5,}$/i
47
+
48
+ function nameOf(el) {
49
+ let s = el.tagName.toLowerCase()
50
+ if (el.id && GOOD_CLASS.test(el.id)) return s + '#' + el.id
51
+ const cls = [...el.classList].filter((c) => GOOD_CLASS.test(c) && !NOISY_CLASS.test(c)).slice(0, 2)
52
+ if (cls.length) s += '.' + cls.join('.')
53
+ return s
54
+ }
55
+
56
+ /** 尽量短、又能唯一定位的 CSS 路径;最多往上 6 层 */
57
+ function cssPath(el) {
58
+ const parts = []
59
+ for (let e = el; e && e.nodeType === 1 && e !== document.documentElement; e = e.parentElement) {
60
+ if (e.id && GOOD_CLASS.test(e.id) && document.querySelectorAll('#' + esc(e.id)).length === 1) {
61
+ parts.unshift('#' + esc(e.id))
62
+ break
63
+ }
64
+ let s = e.tagName.toLowerCase()
65
+ const cls = [...e.classList].filter((c) => GOOD_CLASS.test(c) && !NOISY_CLASS.test(c)).slice(0, 2)
66
+ if (cls.length) s += '.' + cls.map(esc).join('.')
67
+ const parent = e.parentElement
68
+ if (parent) {
69
+ const same = [...parent.children].filter((x) => x.tagName === e.tagName)
70
+ if (same.length > 1) s += `:nth-of-type(${same.indexOf(e) + 1})`
71
+ }
72
+ parts.unshift(s)
73
+ if (e.tagName === 'BODY' || parts.length >= 5) break
74
+ }
75
+ if (parts.length > 1 && parts[0] === 'body') parts.shift()
76
+ return parts.join(' > ')
77
+ }
78
+
79
+ const clip = (s, n) => (s.length > n ? s.slice(0, n) + '…' : s)
80
+ const textOf = (el) => clip((el.innerText || el.textContent || '').replace(/\s+/g, ' ').trim(), 80)
81
+
82
+ /** 元素的 HTML:太长就只留开头标签 + 子元素个数,长属性值(data URI、svg path)截断 */
83
+ function htmlOf(el, max = 240) {
84
+ const shallow = el.cloneNode(false)
85
+ for (const a of [...shallow.attributes]) {
86
+ if (a.value.length > 80) shallow.setAttribute(a.name, a.value.slice(0, 80) + '…')
87
+ }
88
+ const full = el.outerHTML.replace(/\s+/g, ' ')
89
+ if (full.length <= max) return full
90
+ const open = shallow.outerHTML.replace(/<\/[^>]+>$/, '')
91
+ const kids = el.children.length
92
+ const txt = textOf(el)
93
+ const inner = kids ? ` …${kids} 个子元素… ` : clip(txt, 60)
94
+ return open + inner + `</${el.tagName.toLowerCase()}>`
95
+ }
96
+
97
+ const NONE = new Set(['none', 'normal', 'auto', '0px', 'rgba(0, 0, 0, 0)', 'transparent', 'static'])
98
+
99
+ /** 改样式时最常用到的几项,默认值不写 */
100
+ function stylesOf(el) {
101
+ const cs = getComputedStyle(el)
102
+ const out = {}
103
+ const pick = (k, label = k) => {
104
+ const v = cs.getPropertyValue(k)
105
+ if (v && !NONE.has(v)) out[label] = clip(v, 120)
106
+ }
107
+ pick('display')
108
+ pick('color')
109
+ pick('background-color')
110
+ if (cs.backgroundImage !== 'none') out['background-image'] = clip(cs.backgroundImage, 120)
111
+ pick('font-size')
112
+ pick('font-weight')
113
+ out['font-family'] = clip(cs.fontFamily.split(',')[0].replace(/["']/g, '').trim(), 40)
114
+ pick('line-height')
115
+ pick('padding')
116
+ pick('margin')
117
+ pick('border-radius')
118
+ if (parseFloat(cs.borderTopWidth) > 0) out.border = `${cs.borderTopWidth} ${cs.borderTopStyle} ${cs.borderTopColor}`
119
+ if (/flex|grid/.test(cs.display)) pick('gap')
120
+ pick('box-shadow')
121
+ pick('position')
122
+ return out
123
+ }
124
+
125
+ /** 组件和源码位置:React(开发版)、Vue、Svelte 能拿到就给 */
126
+ function sourceOf(el) {
127
+ try {
128
+ const fiberKey = Object.keys(el).find((k) => k.startsWith('__reactFiber$'))
129
+ if (fiberKey) {
130
+ let f = el[fiberKey]
131
+ let file = ''
132
+ let comp = ''
133
+ for (let i = 0; f && i < 40; i++, f = f.return) {
134
+ if (!file && f._debugSource) file = `${f._debugSource.fileName}:${f._debugSource.lineNumber}`
135
+ if (!comp && typeof f.type === 'function') comp = f.type.displayName || f.type.name || ''
136
+ if (file && comp) break
137
+ }
138
+ if (comp || file) return { framework: 'React', component: comp, file }
139
+ }
140
+ for (let e = el; e && e.nodeType === 1; e = e.parentElement) {
141
+ const v = e.__vueParentComponent
142
+ if (v && v.type) return { framework: 'Vue', component: v.type.name || v.type.__name || '', file: v.type.__file || '' }
143
+ const s = e.__svelte_meta
144
+ if (s && s.loc) return { framework: 'Svelte', component: '', file: `${s.loc.file}:${s.loc.line}` }
145
+ }
146
+ } catch { /* 拿不到就算了 */ }
147
+ return null
148
+ }
149
+
150
+ function rectOf(el) {
151
+ const r = el.getBoundingClientRect()
152
+ return { x: Math.round(r.left + scrollX), y: Math.round(r.top + scrollY), w: Math.round(r.width), h: Math.round(r.height) }
153
+ }
154
+
155
+ function describe(el) {
156
+ return {
157
+ name: nameOf(el),
158
+ selector: cssPath(el),
159
+ text: textOf(el),
160
+ html: htmlOf(el),
161
+ rect: rectOf(el),
162
+ styles: stylesOf(el),
163
+ source: sourceOf(el),
164
+ }
165
+ }
166
+
167
+ /** 框选:挑出大部分落在框里的元素,只要最外层的,最多 10 个 */
168
+ function elementsIn(box) {
169
+ const picked = []
170
+ const all = document.body ? document.body.getElementsByTagName('*') : []
171
+ const limit = Math.min(all.length, 6000)
172
+ for (let i = 0; i < limit; i++) {
173
+ const el = all[i]
174
+ if (el === host || picked.some((p) => p.contains(el))) continue
175
+ const r = el.getBoundingClientRect()
176
+ if (r.width < 2 || r.height < 2) continue
177
+ const ix = Math.max(0, Math.min(r.right, box.right) - Math.max(r.left, box.left))
178
+ const iy = Math.max(0, Math.min(r.bottom, box.bottom) - Math.max(r.top, box.top))
179
+ if (ix * iy >= r.width * r.height * 0.8) {
180
+ picked.push(el)
181
+ if (picked.length >= 10) break
182
+ }
183
+ }
184
+ if (!picked.length) {
185
+ const c = under((box.left + box.right) / 2, (box.top + box.bottom) / 2)
186
+ if (c) picked.push(c)
187
+ }
188
+ return picked
189
+ }
190
+
191
+ // ── 选取界面(shadow root 里) ─────────────────────────────────────────────
192
+
193
+ let host = null
194
+ let ui = null
195
+ let picking = false
196
+
197
+ const STYLE = `
198
+ :host{all:initial}
199
+ .layer{position:fixed;inset:0;z-index:2147483646;cursor:crosshair}
200
+ .box,.tag,.hint{z-index:2147483647}
201
+ .box{position:fixed;pointer-events:none;border:calc(1.5px * var(--k, 1)) solid #4d6bfe;background:rgba(77,107,254,.08);border-radius:3px;
202
+ transition:left .08s ease,top .08s ease,width .08s ease,height .08s ease;display:none}
203
+ .box.region{border-style:dashed;transition:none}
204
+ .box.done{animation:pulse .9s ease-out 1}
205
+ @keyframes pulse{0%{box-shadow:0 0 0 0 rgba(77,107,254,.45)}100%{box-shadow:0 0 0 10px rgba(77,107,254,0)}}
206
+ .tag{position:fixed;pointer-events:none;display:none;padding:2px 7px;border-radius:5px;background:#4d6bfe;color:#fff;
207
+ font:500 11px/16px -apple-system,system-ui,sans-serif;white-space:nowrap;max-width:60vw;overflow:hidden;text-overflow:ellipsis;
208
+ transform:scale(var(--k, 1));transform-origin:0 100%}
209
+ .tag.below{transform-origin:0 0}
210
+ .tag i{font-style:normal;opacity:.75;margin-left:6px}
211
+ .hint{position:fixed;left:50%;top:10px;transform:translateX(-50%);pointer-events:none;padding:6px 12px;border-radius:999px;
212
+ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-ui,sans-serif;white-space:nowrap;
213
+ box-shadow:0 4px 16px rgba(0,0,0,.25);backdrop-filter:blur(8px)}
214
+ `
215
+
216
+ // 画布里缩小显示时的放大倍数(1 / 缩放比例),选取框、标签、高亮都按它放大
217
+ let K = 1
218
+ function setK(k) {
219
+ K = Math.max(1, Math.min(6, Number(k) || 1))
220
+ if (host) host.style.setProperty('--k', K)
221
+ if (fx) fx.host.style.setProperty('--k', K)
222
+ if (editUi) editUi.host.style.setProperty('--k', K)
223
+ placePin()
224
+ }
225
+
226
+ function mount(hint) {
227
+ if (host) return
228
+ host = document.createElement('dsh-design-picker')
229
+ host.style.setProperty('--k', K)
230
+ const root = host.attachShadow({ mode: 'closed' })
231
+ root.innerHTML = `<style>${STYLE}</style><div class="layer"></div><div class="box"></div><div class="tag"></div><div class="hint">点选一个元素,或拖出一块区域 · Esc 取消</div>`
232
+ ui = {
233
+ layer: root.querySelector('.layer'),
234
+ box: root.querySelector('.box'),
235
+ tag: root.querySelector('.tag'),
236
+ hint: root.querySelector('.hint'),
237
+ }
238
+ if (!hint) ui.hint.style.display = 'none'
239
+ document.documentElement.appendChild(host)
240
+ ui.layer.addEventListener('mousedown', onDown, true)
241
+ ui.layer.addEventListener('mousemove', onMove, true)
242
+ ui.layer.addEventListener('mouseup', onUp, true)
243
+ ui.layer.addEventListener('mouseleave', () => hide(), true)
244
+ }
245
+
246
+ function unmount() {
247
+ if (host) host.remove()
248
+ host = null
249
+ ui = null
250
+ }
251
+
252
+ /** 指针下面的页面元素(跳过我们自己的层) */
253
+ function under(x, y) {
254
+ const list = document.elementsFromPoint(x, y)
255
+ for (const el of list) {
256
+ if (el === host || el === document.documentElement) continue
257
+ return el
258
+ }
259
+ return null
260
+ }
261
+
262
+ function place(r, cls) {
263
+ ui.box.className = 'box' + (cls ? ' ' + cls : '')
264
+ Object.assign(ui.box.style, { display: 'block', left: r.left + 'px', top: r.top + 'px', width: r.width + 'px', height: r.height + 'px' })
265
+ }
266
+
267
+ function label(r, text, sub) {
268
+ ui.tag.innerHTML = ''
269
+ ui.tag.append(text)
270
+ if (sub) {
271
+ const i = document.createElement('i')
272
+ i.textContent = sub
273
+ ui.tag.append(i)
274
+ }
275
+ ui.tag.style.display = 'block'
276
+ // 标签按左下角放大:盒子顶 = 框顶 - 22,看上去底边贴着框;上面放不下就放到框下面
277
+ const fits = r.top - 2 - 20 * K > 4
278
+ ui.tag.classList.toggle('below', !fits)
279
+ ui.tag.style.left = Math.max(4, Math.min(r.left, innerWidth - 200 * K)) + 'px'
280
+ ui.tag.style.top = (fits ? r.top - 22 : Math.min(innerHeight - 22 * K, r.bottom + 4)) + 'px'
281
+ }
282
+
283
+ function hide() {
284
+ if (!ui) return
285
+ ui.box.style.display = 'none'
286
+ ui.tag.style.display = 'none'
287
+ }
288
+
289
+ let down = null
290
+ let dragging = false
291
+
292
+ function onDown(e) {
293
+ if (e.button !== 0) return
294
+ e.preventDefault()
295
+ e.stopPropagation()
296
+ down = { x: e.clientX, y: e.clientY }
297
+ dragging = false
298
+ }
299
+
300
+ function regionBox(e) {
301
+ const left = Math.min(down.x, e.clientX)
302
+ const top = Math.min(down.y, e.clientY)
303
+ return { left, top, right: Math.max(down.x, e.clientX), bottom: Math.max(down.y, e.clientY), width: Math.abs(e.clientX - down.x), height: Math.abs(e.clientY - down.y) }
304
+ }
305
+
306
+ function onMove(e) {
307
+ e.stopPropagation()
308
+ if (down && (dragging || Math.hypot(e.clientX - down.x, e.clientY - down.y) > 6)) {
309
+ dragging = true
310
+ const b = regionBox(e)
311
+ place(b, 'region')
312
+ label(b, '区域', `${Math.round(b.width)}×${Math.round(b.height)}`)
313
+ return
314
+ }
315
+ const el = under(e.clientX, e.clientY)
316
+ if (!el) return hide()
317
+ const r = el.getBoundingClientRect()
318
+ place(r)
319
+ label(r, nameOf(el), `${Math.round(r.width)}×${Math.round(r.height)}`)
320
+ }
321
+
322
+ function onUp(e) {
323
+ if (!down) return
324
+ e.preventDefault()
325
+ e.stopPropagation()
326
+ const wasDrag = dragging
327
+ const b = wasDrag ? regionBox(e) : null
328
+ down = null
329
+ dragging = false
330
+ let payload
331
+ if (wasDrag && b.width > 6 && b.height > 6) {
332
+ const els = elementsIn(b)
333
+ payload = {
334
+ kind: 'region',
335
+ rect: { x: Math.round(b.left + scrollX), y: Math.round(b.top + scrollY), w: Math.round(b.width), h: Math.round(b.height) },
336
+ elements: els.map((el) => ({ name: nameOf(el), selector: cssPath(el), text: textOf(el), source: sourceOf(el) })),
337
+ }
338
+ place(b, 'region done')
339
+ pin({ left: b.left + scrollX, top: b.top + scrollY, width: b.width, height: b.height }, null, '区域')
340
+ } else {
341
+ const el = under(e.clientX, e.clientY)
342
+ if (!el) return
343
+ payload = { kind: 'element', ...describe(el) }
344
+ place(el.getBoundingClientRect(), 'done')
345
+ pin(null, el, nameOf(el))
346
+ }
347
+ stop(false)
348
+ post({ type: 'picked', payload: { ...payload, href: location.href, title: document.title, viewport: { w: innerWidth, h: innerHeight } } })
349
+ }
350
+
351
+ function onKey(e) {
352
+ if (e.key === 'Escape' && picking) {
353
+ e.preventDefault()
354
+ stop(true)
355
+ }
356
+ }
357
+
358
+ function start(hint = true) {
359
+ if (picking) return
360
+ picking = true
361
+ unpin()
362
+ mount(hint)
363
+ addEventListener('keydown', onKey, true)
364
+ }
365
+
366
+ /** cancelled: 用户按了 Esc / 外面点了取消;false 时留一下选中框再收 */
367
+ function stop(cancelled) {
368
+ if (!picking) return
369
+ picking = false
370
+ removeEventListener('keydown', onKey, true)
371
+ if (cancelled) {
372
+ unmount()
373
+ post({ type: 'cancelled' })
374
+ return
375
+ }
376
+ if (ui) {
377
+ ui.layer.style.pointerEvents = 'none'
378
+ ui.layer.style.cursor = 'auto'
379
+ ui.hint.style.display = 'none'
380
+ ui.tag.style.display = 'none'
381
+ }
382
+ const h = host
383
+ setTimeout(() => { if (host === h) unmount() }, 900)
384
+ }
385
+
386
+ // ── 改动高亮 ──────────────────────────────────────────────────────────────
387
+ //
388
+ // 「指纹」= 每个元素的路径 → 它自己的哈希(标签、类名、自己的文字、几项算出来的样式)。
389
+ // 新旧两份一比:哈希在旧的里面一个都找不到的元素就是改过的 / 新加的;只留最外层,最多 12 个。
390
+ // 宽高不进指纹(一处改动会牵动一串祖先的尺寸);状态类、inline style 也不进(轮播、动画会一直变)。
391
+
392
+ const SIG_SKIP = new Set(['SCRIPT', 'STYLE', 'LINK', 'META', 'NOSCRIPT', 'TEMPLATE', 'BR', 'TITLE', 'HEAD', 'DSH-DESIGN-PICKER', 'DSH-DESIGN-FX', 'DSH-DESIGN-EDIT'])
393
+ const SIG_PROPS = ['color', 'background-color', 'background-image', 'font-size', 'font-weight', 'font-style', 'font-family', 'text-align',
394
+ 'text-decoration-line', 'letter-spacing', 'border-radius', 'border-top-width', 'border-top-color', 'padding-top', 'padding-left',
395
+ 'margin-top', 'display', 'gap', 'box-shadow']
396
+ const MAX_SIG = 2500
397
+
398
+ function hashStr(s) {
399
+ let h = 5381
400
+ for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0
401
+ return (h >>> 0).toString(36)
402
+ }
403
+
404
+ function ownText(el) {
405
+ let s = ''
406
+ for (const n of el.childNodes) if (n.nodeType === 3) s += n.nodeValue
407
+ return s.replace(/\s+/g, ' ').trim().slice(0, 200)
408
+ }
409
+
410
+ function sigOf(el) {
411
+ const cs = getComputedStyle(el)
412
+ let s = el.tagName + '|' + [...el.classList].filter((c) => !NOISY_CLASS.test(c)).join('.') + '|' + ownText(el)
413
+ const src = el.getAttribute('src')
414
+ if (src) s += '|' + src.slice(0, 200)
415
+ for (const k of SIG_PROPS) s += '|' + cs.getPropertyValue(k)
416
+ return hashStr(s)
417
+ }
418
+
419
+ /** { map: 路径 → 哈希, els: 路径 → 元素 } */
420
+ function snapshot() {
421
+ const map = {}
422
+ const els = {}
423
+ let n = 0
424
+ const walk = (el, path) => {
425
+ const counts = {}
426
+ for (const c of el.children) {
427
+ if (n >= MAX_SIG) return
428
+ if (SIG_SKIP.has(c.tagName)) continue
429
+ const i = (counts[c.tagName] = (counts[c.tagName] || 0) + 1)
430
+ const p = path + '/' + c.tagName + i
431
+ map[p] = sigOf(c)
432
+ els[p] = c
433
+ n++
434
+ if (c.tagName.toLowerCase() !== 'svg') walk(c, p)
435
+ }
436
+ }
437
+ if (document.body) walk(document.body, '')
438
+ return { map, els }
439
+ }
440
+
441
+ /** 和旧指纹比:{ count, whole, els }(whole = 整页都换了,不逐个闪) */
442
+ function compare(oldMap, cur) {
443
+ const seen = new Set(Object.values(oldMap))
444
+ const changed = new Set()
445
+ for (const [p, h] of Object.entries(cur.map)) if (oldMap[p] !== h && !seen.has(h)) changed.add(p)
446
+ const total = Object.keys(cur.map).length
447
+ const outer = []
448
+ for (const p of changed) {
449
+ let a = p
450
+ let nested = false
451
+ for (let i = a.lastIndexOf('/'); i > 0; i = a.lastIndexOf('/')) {
452
+ a = a.slice(0, i)
453
+ if (changed.has(a)) { nested = true; break }
454
+ }
455
+ if (!nested) outer.push(p)
456
+ }
457
+ const whole = changed.size > Math.max(30, total * 0.5)
458
+ const els = whole ? [] : outer.map((p) => cur.els[p]).filter((el) => {
459
+ const r = el.getBoundingClientRect()
460
+ return r.width >= 2 && r.height >= 2
461
+ }).slice(0, 12)
462
+ return { count: outer.length, whole, els }
463
+ }
464
+
465
+ // 高亮和选中框画在另一个 shadow root 里,按页面坐标放(跟着页面滚)
466
+ const FX_STYLE = `
467
+ :host{all:initial}
468
+ .hl{position:absolute;pointer-events:none;border-radius:6px;opacity:0;
469
+ box-shadow:0 0 0 calc(2px * var(--k, 1)) #4d6bfe,0 0 calc(22px * var(--k, 1)) calc(4px * var(--k, 1)) rgba(77,107,254,.38);background:rgba(77,107,254,.07);
470
+ animation:hl 2.6s cubic-bezier(.2,.7,.2,1) forwards}
471
+ .hl i{position:absolute;inset:0;border-radius:inherit;overflow:hidden}
472
+ .hl i::after{content:"";position:absolute;inset:0;transform:translateX(-110%);
473
+ background:linear-gradient(100deg,transparent 25%,rgba(255,255,255,.65) 50%,transparent 75%);animation:sweep 1.1s .18s ease-out forwards}
474
+ .hl span,.pin span{position:absolute;left:-2px;top:-23px;padding:1px 8px;border-radius:5px;background:#4d6bfe;color:#fff;
475
+ font:500 11px/19px -apple-system,BlinkMacSystemFont,"PingFang SC",system-ui,sans-serif;white-space:nowrap;
476
+ box-shadow:0 4px 12px rgba(77,107,254,.35);transform:scale(var(--k, 1));transform-origin:0 100%}
477
+ .hl.below span,.pin.below span{top:auto;bottom:-23px;transform-origin:0 0}
478
+ @keyframes hl{0%{opacity:0;transform:scale(1.035)}9%{opacity:1;transform:scale(1)}78%{opacity:1}100%{opacity:0}}
479
+ @keyframes sweep{to{transform:translateX(110%)}}
480
+ .pin{position:absolute;pointer-events:none;border:calc(1.5px * var(--k, 1)) dashed #4d6bfe;border-radius:5px;background:rgba(77,107,254,.05);
481
+ animation:pin-in .25s ease-out}
482
+ .pin span{opacity:.92}
483
+ @keyframes pin-in{from{opacity:0}}
484
+ .scan{position:fixed;left:0;right:0;top:0;height:38vh;pointer-events:none;opacity:0;
485
+ background:linear-gradient(180deg,rgba(77,107,254,0),rgba(77,107,254,.06) 55%,rgba(124,140,255,.22) 92%,rgba(77,107,254,0));
486
+ animation:scan var(--dur, 1.2s) cubic-bezier(.45,0,.25,1) forwards}
487
+ .scan::after{content:"";position:absolute;left:0;right:0;bottom:8%;height:2px;
488
+ background:linear-gradient(90deg,transparent,#8ea2ff 20%,#c4b5fd 50%,#5eead4 80%,transparent);box-shadow:0 0 14px 3px rgba(124,140,255,.55)}
489
+ @keyframes scan{0%{opacity:0;transform:translateY(-38vh)}12%{opacity:1}82%{opacity:1}100%{opacity:0;transform:translateY(100vh)}}
490
+ `
491
+ let fx = null
492
+
493
+ function fxRoot() {
494
+ if (fx && fx.host.isConnected) return fx
495
+ const h = document.createElement('dsh-design-fx')
496
+ h.style.cssText = 'position:absolute;left:0;top:0;width:0;height:0;z-index:2147483645;pointer-events:none'
497
+ h.style.setProperty('--k', K)
498
+ const root = h.attachShadow({ mode: 'closed' })
499
+ root.innerHTML = `<style>${FX_STYLE}</style>`
500
+ document.documentElement.appendChild(h)
501
+ fx = { host: h, root }
502
+ return fx
503
+ }
504
+
505
+ const PX = /^\d+(\.\d+)?px$/
506
+ function frame(node, r, pad, radius) {
507
+ Object.assign(node.style, {
508
+ left: r.left - pad + 'px',
509
+ top: r.top - pad + 'px',
510
+ width: r.width + pad * 2 + 'px',
511
+ height: r.height + pad * 2 + 'px',
512
+ })
513
+ if (radius && PX.test(radius) && radius !== '0px') node.style.borderRadius = `${parseFloat(radius) + pad}px`
514
+ if (r.top < 28 * K) node.classList.add('below')
515
+ }
516
+
517
+ const docRect = (el) => {
518
+ const r = el.getBoundingClientRect()
519
+ return { left: r.left + scrollX, top: r.top + scrollY, width: r.width, height: r.height }
520
+ }
521
+
522
+ function flash(els) {
523
+ const { root } = fxRoot()
524
+ els.forEach((el, i) => {
525
+ const b = document.createElement('div')
526
+ b.className = 'hl'
527
+ b.append(document.createElement('i'))
528
+ frame(b, docRect(el), 3, getComputedStyle(el).borderTopLeftRadius)
529
+ b.style.animationDelay = i * 80 + 'ms'
530
+ if (i === 0) {
531
+ const t = document.createElement('span')
532
+ t.textContent = els.length > 1 ? `已更新 · ${els.length} 处` : '已更新'
533
+ b.append(t)
534
+ }
535
+ root.append(b)
536
+ setTimeout(() => b.remove(), 2800 + i * 80)
537
+ })
538
+ }
539
+
540
+ const inView = (el) => {
541
+ const r = el.getBoundingClientRect()
542
+ return r.bottom > 0 && r.right > 0 && r.top < innerHeight && r.left < innerWidth
543
+ }
544
+
545
+ /** 闪一下;改的地方都不在屏幕里就先滚过去 */
546
+ function play(els) {
547
+ if (!els.length) return
548
+ if (els.some(inView)) return flash(els)
549
+ try { els[0].scrollIntoView({ behavior: 'smooth', block: 'center' }) } catch { els[0].scrollIntoView() }
550
+ setTimeout(() => flash(els), 420)
551
+ }
552
+
553
+ // ── 生成动画:元素按从上到下的顺序浮现,扫描光带一起往下走 ─────────────────
554
+ //
555
+ // 只动屏幕里(和下面一点)的元素;「原子」(没有子元素 / 有自己的文字 / 图片按钮这类)上浮 + 去模糊,
556
+ // 有底色边框的容器(卡片)先淡入。用 Web Animations 的 fill: backwards,播完不留任何痕迹。
557
+
558
+ const ATOMS = new Set(['IMG', 'SVG', 'VIDEO', 'CANVAS', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'HR', 'PICTURE', 'IFRAME'])
559
+ const MAX_BUILD = 160
560
+
561
+ function buildIn() {
562
+ if (!document.body || typeof document.body.animate !== 'function') return
563
+ const vh = innerHeight
564
+ const vw = innerWidth
565
+ const items = []
566
+ const walk = (el) => {
567
+ for (const c of el.children) {
568
+ if (items.length >= MAX_BUILD) return
569
+ if (SIG_SKIP.has(c.tagName)) continue
570
+ const r = c.getBoundingClientRect()
571
+ if (r.width < 1 || r.height < 1) { walk(c); continue }
572
+ if (r.bottom < 0 || r.top > vh * 1.4) continue
573
+ const cs = getComputedStyle(c)
574
+ if (cs.visibility === 'hidden' || cs.opacity === '0') continue
575
+ const atom = ATOMS.has(c.tagName.toUpperCase()) || !c.children.length || ownText(c)
576
+ if (atom) { items.push({ el: c, r, atom: true }); continue }
577
+ const surface = r.width < vw * 0.96 && (cs.backgroundColor !== 'rgba(0, 0, 0, 0)' || cs.backgroundImage !== 'none' ||
578
+ parseFloat(cs.borderTopWidth) > 0 || cs.boxShadow !== 'none')
579
+ if (surface) items.push({ el: c, r, atom: false })
580
+ walk(c)
581
+ }
582
+ }
583
+ walk(document.body)
584
+ if (!items.length) return
585
+ const span = Math.max(vh, 1)
586
+ const DUR = 1300
587
+ const ease = 'cubic-bezier(.2,.8,.2,1)'
588
+ for (const it of items) {
589
+ const y = Math.min(1.4, Math.max(0, it.r.top / span))
590
+ const delay = Math.round((y / 1.4) * DUR * 0.78 + (Math.max(0, it.r.left) / vw) * 90)
591
+ try {
592
+ if (it.atom) {
593
+ it.el.animate([{ opacity: 0, translate: '0 12px', filter: 'blur(6px)' }, { opacity: 1, translate: '0 0', filter: 'blur(0)' }],
594
+ { duration: 620, delay: delay + 70, easing: ease, fill: 'backwards' })
595
+ } else {
596
+ it.el.animate([{ opacity: 0, scale: '0.97' }, { opacity: 1, scale: '1' }], { duration: 560, delay, easing: ease, fill: 'backwards' })
597
+ }
598
+ } catch { /* 个别元素动不了就跳过 */ }
599
+ }
600
+ const { root } = fxRoot()
601
+ const scan = document.createElement('div')
602
+ scan.className = 'scan'
603
+ scan.style.setProperty('--dur', DUR + 260 + 'ms')
604
+ root.append(scan)
605
+ setTimeout(() => scan.remove(), DUR + 400)
606
+ setTimeout(() => { if (linkSig) resendLinks() }, DUR + 800) // 动画时按钮挪了一点,播完按最终位置补报
607
+ }
608
+
609
+ // 选中后留一个虚线框,直到下次选择 / 页面刷新
610
+ let pinned = null
611
+
612
+ function pin(rect, el, text) {
613
+ unpin()
614
+ const { root } = fxRoot()
615
+ const b = document.createElement('div')
616
+ b.className = 'pin'
617
+ const t = document.createElement('span')
618
+ t.textContent = '已选 · ' + text
619
+ b.append(t)
620
+ root.append(b)
621
+ pinned = { node: b, el, rect }
622
+ placePin()
623
+ }
624
+
625
+ function placePin() {
626
+ if (!pinned) return
627
+ const r = pinned.el ? (pinned.el.isConnected ? docRect(pinned.el) : null) : pinned.rect
628
+ if (!r) return unpin()
629
+ frame(pinned.node, r, 2, pinned.el ? getComputedStyle(pinned.el).borderTopLeftRadius : '')
630
+ }
631
+
632
+ function unpin() {
633
+ if (pinned) pinned.node.remove()
634
+ pinned = null
635
+ }
636
+
637
+ addEventListener('resize', placePin)
638
+
639
+ // ── 刷新、就绪(和右侧栏的握手) ──────────────────────────────────────────
640
+ //
641
+ // 右侧栏里:页面载完 → hello → 右侧栏回 init(上一帧的滚动位置和指纹)→ 恢复滚动、算出改了哪
642
+ // → ready → 右侧栏把这一帧淡入换上来 → reveal → 闪改动。
643
+ // 还没换上来的那一帧(shown=false)不理会改动通知,右侧栏会再载一帧。
644
+
645
+ let shown = !parentWin
646
+ let booted = false
647
+ let pending = null
648
+ let busy = false
649
+
650
+ const seedNow = () => ({ href: location.href, scroll: { x: scrollX, y: scrollY }, sig: snapshot().map })
651
+
652
+ function boot(seed) {
653
+ if (booted) return
654
+ booted = true
655
+ const same = seed && seed.href === location.href
656
+ if (same && seed.scroll) scrollTo(seed.scroll.x, seed.scroll.y)
657
+ pending = same && seed.sig ? compare(seed.sig, snapshot()) : null
658
+ const meta = document.querySelector('meta[name="dsh-design"]')
659
+ post({
660
+ type: 'ready',
661
+ title: document.title,
662
+ href: location.href,
663
+ meta: meta ? String(meta.getAttribute('content') || '').slice(0, 80) : '',
664
+ tokens: tokensOf(),
665
+ links: firstLinks(),
666
+ dark: hasDark(),
667
+ changes: pending ? { count: pending.count, whole: pending.whole } : null,
668
+ })
669
+ if (shown) reveal()
670
+ }
671
+
672
+ // ── 设计变量::root / html 上的 CSS 自定义属性(取算出来的值),带出处样式表 ─────
673
+ const MAX_TOKENS = 80
674
+
675
+ function tokensOf() {
676
+ const out = []
677
+ const seen = new Set()
678
+ const cs = getComputedStyle(document.documentElement)
679
+ for (const sheet of document.styleSheets) {
680
+ let rules
681
+ try { rules = sheet.cssRules } catch { continue } // 别的源的样式表读不了
682
+ const href = sheet.href || location.href
683
+ for (const r of rules) {
684
+ if (out.length >= MAX_TOKENS) return out
685
+ if (!r.style || !/(^|,)\s*(:root|html)\s*(,|$)/.test(r.selectorText || '')) continue
686
+ for (let i = 0; i < r.style.length && out.length < MAX_TOKENS; i++) {
687
+ const name = r.style[i]
688
+ if (!name.startsWith('--') || seen.has(name)) continue
689
+ seen.add(name)
690
+ const value = (cs.getPropertyValue(name) || r.style.getPropertyValue(name)).trim()
691
+ if (value) out.push({ name, value: clip(value, 160), href })
692
+ }
693
+ }
694
+ }
695
+ return out
696
+ }
697
+
698
+ // ── 直接改字:点一下文字就地改,回车写回文件 ─────────────────────────────────
699
+ //
700
+ // 改的是指针下面那一个文字节点(临时包一层可编辑的 span)。写回源码靠「这段文字 + 它是第几次出现」:
701
+ // 按文档顺序数前面的文字节点里这段文字出现过几次,服务端在 HTML 的文字部分里数到同一处替换。
702
+
703
+ const normWs = (x) => String(x || '').replace(/[\s\u00a0]+/g, ' ').trim()
704
+ const NO_TEXT = /^(SCRIPT|STYLE|TEXTAREA|NOSCRIPT|TEMPLATE|TITLE)$/
705
+ let textEdit = false
706
+ let editUi = null
707
+ let editing = null // { span, node, before, nth }
708
+
709
+ function countIn(hay, needle) {
710
+ if (!needle) return 0
711
+ let n = 0
712
+ for (let i = hay.indexOf(needle); i >= 0; i = hay.indexOf(needle, i + needle.length)) n++
713
+ return n
714
+ }
715
+
716
+ /** 这个文字节点前面(文档顺序),同样的文字一共出现过几次 */
717
+ function nthOf(node, want) {
718
+ let n = 0
719
+ const walker = document.createTreeWalker(document.documentElement, NodeFilter.SHOW_TEXT)
720
+ for (let t = walker.nextNode(); t; t = walker.nextNode()) {
721
+ if (t === node) return n
722
+ const p = t.parentElement
723
+ if (!p || NO_TEXT.test(p.tagName)) continue
724
+ n += countIn(normWs(t.data), want)
725
+ }
726
+ return n
727
+ }
728
+
729
+ /** 指针下面的文字节点(有字的) */
730
+ function textAt(x, y) {
731
+ let node = null
732
+ if (document.caretRangeFromPoint) {
733
+ const r = document.caretRangeFromPoint(x, y)
734
+ node = r && r.startContainer
735
+ } else if (document.caretPositionFromPoint) {
736
+ const c = document.caretPositionFromPoint(x, y)
737
+ node = c && c.offsetNode
738
+ }
739
+ if (!node || node.nodeType !== 3 || !normWs(node.data)) return null
740
+ const p = node.parentElement
741
+ if (!p || NO_TEXT.test(p.tagName) || (editUi && editUi.host.contains(p))) return null
742
+ // 指针确实落在这段字上(不是行尾的空白处)
743
+ const range = document.createRange()
744
+ range.selectNodeContents(node)
745
+ const hit = [...range.getClientRects()].some((rc) => x >= rc.left - 2 && x <= rc.right + 2 && y >= rc.top - 2 && y <= rc.bottom + 2)
746
+ return hit ? node : null
747
+ }
748
+
749
+ function editMount(hint) {
750
+ if (editUi) return
751
+ const hostEl = document.createElement('dsh-design-edit')
752
+ hostEl.style.setProperty('--k', K)
753
+ const root = hostEl.attachShadow({ mode: 'closed' })
754
+ root.innerHTML = `<style>:host{all:initial}
755
+ .box{position:fixed;z-index:2147483646;pointer-events:none;display:none;border-radius:3px;border:calc(1.5px * var(--k, 1)) dashed #4d6bfe;background:rgba(77,107,254,.06)}
756
+ .hint{position:fixed;left:50%;top:10px;transform:translateX(-50%) scale(var(--k, 1));transform-origin:50% 0;z-index:2147483647;pointer-events:none;padding:6px 12px;border-radius:999px;
757
+ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-ui,sans-serif;white-space:nowrap;box-shadow:0 4px 16px rgba(0,0,0,.25)}</style>
758
+ <div class="box"></div><div class="hint">点文字直接改 · 回车保存 · Esc 退出</div>`
759
+ editUi = { host: hostEl, box: root.querySelector('.box'), hint: root.querySelector('.hint') }
760
+ if (!hint) editUi.hint.style.display = 'none'
761
+ document.documentElement.appendChild(hostEl)
762
+ }
763
+
764
+ function editUnmount() {
765
+ if (editUi) editUi.host.remove()
766
+ editUi = null
767
+ }
768
+
769
+ function editBox(node) {
770
+ if (!editUi) return
771
+ if (!node) { editUi.box.style.display = 'none'; return }
772
+ const range = document.createRange()
773
+ range.selectNodeContents(node)
774
+ const r = range.getBoundingClientRect()
775
+ Object.assign(editUi.box.style, { display: 'block', left: r.left - 3 + 'px', top: r.top - 2 + 'px', width: r.width + 6 + 'px', height: r.height + 4 + 'px' })
776
+ }
777
+
778
+ function onEditMove(e) {
779
+ if (!textEdit || editing) return
780
+ editBox(textAt(e.clientX, e.clientY))
781
+ }
782
+
783
+ function onEditClick(e) {
784
+ if (!textEdit) return
785
+ if (editing && editing.span.contains(e.target)) return
786
+ e.preventDefault()
787
+ e.stopPropagation()
788
+ if (editing) { finishEdit(true); return }
789
+ const node = textAt(e.clientX, e.clientY)
790
+ if (node) beginEdit(node)
791
+ }
792
+
793
+ function beginEdit(node) {
794
+ const before = normWs(node.data)
795
+ const nth = nthOf(node, before)
796
+ const span = document.createElement('span')
797
+ span.setAttribute('data-dsh-edit', '')
798
+ span.style.cssText = 'outline:calc(2px * var(--dsh-k, 1)) solid #4d6bfe;outline-offset:2px;border-radius:2px;cursor:text;caret-color:#4d6bfe'
799
+ span.style.setProperty('--dsh-k', K)
800
+ try { span.contentEditable = 'plaintext-only' } catch { span.contentEditable = 'true' }
801
+ if (span.contentEditable !== 'plaintext-only') span.contentEditable = 'true'
802
+ node.parentNode.insertBefore(span, node)
803
+ span.appendChild(node)
804
+ editing = { span, node, before, nth, raw: node.data }
805
+ editBox(null)
806
+ span.focus()
807
+ const sel = getSelection()
808
+ const range = document.createRange()
809
+ range.selectNodeContents(span)
810
+ sel.removeAllRanges()
811
+ sel.addRange(range)
812
+ span.addEventListener('blur', () => finishEdit(true), { once: true })
813
+ }
814
+
815
+ /** 收尾:save=true 且改了就报给右侧栏;页面上先换成新字(文件改完会自己刷新) */
816
+ function finishEdit(save) {
817
+ const ed = editing
818
+ if (!ed) return
819
+ editing = null
820
+ const after = normWs(ed.span.innerText)
821
+ const text = document.createTextNode(save && after ? ed.raw.replace(ed.raw.trim(), after) : ed.raw)
822
+ if (ed.span.parentNode) ed.span.parentNode.replaceChild(text, ed.span)
823
+ if (save && after && after !== ed.before) post({ type: 'edited', before: ed.before, after, nth: ed.nth })
824
+ }
825
+
826
+ function onEditKey(e) {
827
+ if (!textEdit) return
828
+ if (editing) {
829
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); finishEdit(true) }
830
+ else if (e.key === 'Escape') { e.preventDefault(); finishEdit(false) }
831
+ return
832
+ }
833
+ if (e.key === 'Escape') { e.preventDefault(); post({ type: 'editDone' }) }
834
+ }
835
+
836
+ function setTextEdit(on, hint = true) {
837
+ if (on === textEdit) return
838
+ textEdit = on
839
+ if (on) {
840
+ editMount(hint)
841
+ document.documentElement.style.setProperty('cursor', 'text')
842
+ addEventListener('mousemove', onEditMove, true)
843
+ addEventListener('click', onEditClick, true)
844
+ addEventListener('keydown', onEditKey, true)
845
+ } else {
846
+ finishEdit(true)
847
+ document.documentElement.style.removeProperty('cursor')
848
+ removeEventListener('mousemove', onEditMove, true)
849
+ removeEventListener('click', onEditClick, true)
850
+ removeEventListener('keydown', onEditKey, true)
851
+ editUnmount()
852
+ }
853
+ }
854
+
855
+ // ── 屏与屏之间的跳转(画布上画连线、演示时点得动)────────────────────────────
856
+ // 认这几种写法:<a href="detail.html">、data-href / data-to、onclick="location.href='detail.html'"
857
+ const LINK_JS = /(?:location(?:\.href)?\s*=\s*|location\.(?:assign|replace)\(\s*)['"]([^'"]+)['"]/
858
+ const MAX_LINKS = 60
859
+
860
+ /** 这个元素点了会跳到同一个站点的哪一页(路径);不是跳页的给 '' */
861
+ function linkPath(el) {
862
+ let raw = el.getAttribute('href') || el.getAttribute('data-href') || el.getAttribute('data-to') || ''
863
+ if (!raw && el.hasAttribute('onclick')) {
864
+ const m = LINK_JS.exec(el.getAttribute('onclick') || '')
865
+ if (m) raw = m[1]
866
+ }
867
+ raw = raw.trim()
868
+ if (!raw || raw.startsWith('#') || /^(javascript|mailto|tel|sms|data):/i.test(raw)) return ''
869
+ let u
870
+ try { u = new URL(raw, location.href) } catch { return '' }
871
+ if (u.origin !== location.origin || u.pathname === location.pathname) return ''
872
+ try { return decodeURIComponent(u.pathname) } catch { return u.pathname }
873
+ }
874
+
875
+ const LINK_SEL = 'a[href], [data-href], [data-to], [onclick]'
876
+
877
+ /** 页面里跳到别的屏的地方:路径 + 在视口里的位置(画布上从这里画一条线到那一屏) */
878
+ function linksOf() {
879
+ const out = []
880
+ const seen = new Set()
881
+ for (const el of document.querySelectorAll(LINK_SEL)) {
882
+ if (out.length >= MAX_LINKS) break
883
+ if (host && host.contains(el)) continue
884
+ const p = linkPath(el)
885
+ if (!p) continue
886
+ const r = el.getBoundingClientRect()
887
+ if (r.width < 2 || r.height < 2) continue
888
+ const cs = getComputedStyle(el)
889
+ // 正在播动画(生成动画、页面自己的淡入)的按钮这会儿透明度是 0,但它是看得见的按钮
890
+ const moving = typeof el.getAnimations === 'function' && el.getAnimations().length > 0
891
+ if (cs.visibility === 'hidden' || cs.display === 'none' || (!moving && Number(cs.opacity) === 0)) continue
892
+ const k = p + '|' + Math.round(r.left / 8) + '|' + Math.round(r.top / 8)
893
+ if (seen.has(k)) continue
894
+ seen.add(k)
895
+ const text = clip(String(el.innerText || el.getAttribute('aria-label') || el.getAttribute('title') || '').replace(/\s+/g, ' ').trim(), 24)
896
+ out.push({ path: p, x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height), text })
897
+ }
898
+ return out
899
+ }
900
+
901
+ // DSH(Electron)里跨源 iframe 刚载完时视口可能还是 0×0,尺寸稍后才到:
902
+ // 先报一次,尺寸变了 / 过一会儿再算,位置不一样了就补报(画布连线跟着更新)
903
+ let linkSig = ''
904
+ let linkTimer = 0
905
+ function firstLinks() {
906
+ const links = linksOf()
907
+ linkSig = JSON.stringify(links)
908
+ addEventListener('resize', () => { clearTimeout(linkTimer); linkTimer = setTimeout(resendLinks, 120) })
909
+ setTimeout(resendLinks, 600)
910
+ return links
911
+ }
912
+ function resendLinks() {
913
+ const links = linksOf()
914
+ const sig = JSON.stringify(links)
915
+ if (sig === linkSig) return
916
+ linkSig = sig
917
+ post({ type: 'links', links })
918
+ }
919
+
920
+ // 演示:点了跳页的地方,不在 iframe 里直接跳,告诉右侧栏换一屏(带过场动画)
921
+ let demo = false
922
+ addEventListener('click', (e) => {
923
+ if (!demo || picking) return
924
+ const el = e.target && e.target.closest ? e.target.closest(LINK_SEL) : null
925
+ if (!el) return
926
+ const p = linkPath(el)
927
+ if (!p) return
928
+ e.preventDefault()
929
+ e.stopPropagation()
930
+ post({ type: 'nav', path: p })
931
+ }, true)
932
+
933
+ /** 换上来了:刚生成的一屏播生成动画,否则闪改动 */
934
+ function reveal(opts) {
935
+ shown = true
936
+ const c = pending
937
+ pending = null
938
+ if (opts && opts.build) buildIn()
939
+ else if (c && c.els.length) setTimeout(() => play(c.els), 60)
940
+ watchHmr()
941
+ }
942
+
943
+ // 开发服务器(热更新,不整页刷新):模型干活时盯着 DOM,变了就比一次指纹
944
+ let mo = null
945
+ let moTimer = null
946
+ let baseline = null
947
+ const ours = (n) => n === host || (fx && n === fx.host)
948
+
949
+ function watchHmr() {
950
+ if (cfg.live || !shown || !window.MutationObserver) return
951
+ if (busy && !mo) {
952
+ baseline = snapshot().map
953
+ mo = new MutationObserver((recs) => {
954
+ const real = recs.some((r) => !(ours(r.target) || (r.type === 'childList' && [...r.addedNodes, ...r.removedNodes].every(ours))))
955
+ if (!real) return
956
+ clearTimeout(moTimer)
957
+ moTimer = setTimeout(checkHmr, 350)
958
+ })
959
+ mo.observe(document.documentElement, { subtree: true, childList: true, attributes: true, characterData: true })
960
+ } else if (!busy && mo) {
961
+ // 回合结束后热更新还可能晚到一点,再等 3 秒
962
+ setTimeout(() => {
963
+ if (busy || !mo) return
964
+ mo.disconnect()
965
+ mo = null
966
+ baseline = null
967
+ }, 3000)
968
+ }
969
+ }
970
+
971
+ function checkHmr() {
972
+ if (!baseline) return
973
+ const cur = snapshot()
974
+ const c = compare(baseline, cur)
975
+ baseline = cur.map
976
+ if (!c.count) return
977
+ play(c.els)
978
+ post({ type: 'updated', changes: { count: c.count, whole: c.whole } })
979
+ }
980
+
981
+ // ── 导出图片:把页面画成 PNG(SVG foreignObject,样式逐个元素内联,图片 / 字体转成 data URL) ──
982
+ // 右侧栏发 { type: 'capture', id, full, scale } → 回 { type: 'captured', id, png: Blob, w, h } 或 { error }
983
+
984
+ const SKIP_TAGS = new Set(['SCRIPT', 'NOSCRIPT', 'TEMPLATE', 'LINK', 'META', 'TITLE', 'BASE', 'DSH-DESIGN-PICKER', 'DSH-DESIGN-FX'])
985
+ const SKIP_PROP = /^(--|animation|transition|will-change|cursor|pointer-events|user-select|-webkit-user|caret-color|view-transition|content-visibility)/
986
+ const MAX_INLINE = 12 * 1024 * 1024 // 图片 + 字体最多内联这么多字节,再多就跳过(不让导出卡死)
987
+ const XHTML = 'http://www.w3.org/1999/xhtml'
988
+ const SVGNS = 'http://www.w3.org/2000/svg'
989
+
990
+ function makeSandbox() {
991
+ const f = document.createElement('iframe')
992
+ f.setAttribute('aria-hidden', 'true')
993
+ f.style.cssText = 'position:fixed;left:-99999px;top:0;width:800px;height:600px;border:0;visibility:hidden'
994
+ document.documentElement.appendChild(f)
995
+ const doc = f.contentDocument
996
+ doc.open()
997
+ doc.write('<!doctype html><html><head></head><body><svg xmlns="http://www.w3.org/2000/svg"></svg></body></html>')
998
+ doc.close()
999
+ const cache = new Map()
1000
+ return {
1001
+ defaults(el, pseudo) {
1002
+ const key = (el.namespaceURI || '') + '|' + el.localName + (pseudo || '')
1003
+ let map = cache.get(key)
1004
+ if (map) return map
1005
+ const d = doc.createElementNS(el.namespaceURI || XHTML, el.localName)
1006
+ const parent = el.namespaceURI === SVGNS && el.localName !== 'svg' ? doc.body.firstChild : doc.body
1007
+ parent.appendChild(d)
1008
+ const cs = f.contentWindow.getComputedStyle(d, pseudo || null)
1009
+ map = {}
1010
+ for (let i = 0; i < cs.length; i++) map[cs[i]] = cs.getPropertyValue(cs[i])
1011
+ d.remove()
1012
+ cache.set(key, map)
1013
+ return map
1014
+ },
1015
+ remove() { f.remove() },
1016
+ }
1017
+ }
1018
+
1019
+ function blobToDataUrl(blob) {
1020
+ return new Promise((resolve, reject) => {
1021
+ const r = new FileReader()
1022
+ r.onload = () => resolve(String(r.result))
1023
+ r.onerror = () => reject(r.error)
1024
+ r.readAsDataURL(blob)
1025
+ })
1026
+ }
1027
+
1028
+ /** 资源 → data URL;拿不到(跨源、太大、超时)就给 null */
1029
+ function makeInliner() {
1030
+ const cache = new Map()
1031
+ let total = 0
1032
+ return (url) => {
1033
+ if (!url || url.startsWith('data:')) return Promise.resolve(url || null)
1034
+ if (cache.has(url)) return cache.get(url)
1035
+ const p = (async () => {
1036
+ try {
1037
+ const ctrl = new AbortController()
1038
+ const timer = setTimeout(() => ctrl.abort(), 8000)
1039
+ const res = await fetch(url, { signal: ctrl.signal, credentials: 'same-origin' })
1040
+ clearTimeout(timer)
1041
+ if (!res.ok) return null
1042
+ const blob = await res.blob()
1043
+ if (total + blob.size > MAX_INLINE) return null
1044
+ total += blob.size
1045
+ return await blobToDataUrl(blob)
1046
+ } catch { return null }
1047
+ })()
1048
+ cache.set(url, p)
1049
+ return p
1050
+ }
1051
+ }
1052
+
1053
+ const URL_RE = /url\(\s*(['"]?)([^'")]+)\1\s*\)/g
1054
+
1055
+ /** 把一段 CSS 值里的 url(...) 都换成 data URL */
1056
+ async function inlineUrls(value, base, inline) {
1057
+ if (!value || value.indexOf('url(') < 0) return value
1058
+ const jobs = []
1059
+ value.replace(URL_RE, (m, q, u) => { jobs.push(u); return m })
1060
+ const map = new Map()
1061
+ await Promise.all(jobs.map(async (u) => {
1062
+ let abs = u
1063
+ try { abs = new URL(u, base).href } catch { /* 原样 */ }
1064
+ map.set(u, await inline(abs))
1065
+ }))
1066
+ return value.replace(URL_RE, (m, q, u) => (map.get(u) ? `url("${map.get(u)}")` : 'none'))
1067
+ }
1068
+
1069
+ function styleText(cs, def, out) {
1070
+ let s = ''
1071
+ for (let i = 0; i < cs.length; i++) {
1072
+ const p = cs[i]
1073
+ if (SKIP_PROP.test(p)) continue
1074
+ const v = cs.getPropertyValue(p)
1075
+ if (v === def[p]) continue
1076
+ if (out && v.indexOf('url(') >= 0) out.push(p)
1077
+ s += `${p}:${v};`
1078
+ }
1079
+ return s
1080
+ }
1081
+
1082
+ /** 元素里直接有字,而且整个元素的内容都在一行里(子元素的字也算) */
1083
+ function oneLine(el) {
1084
+ let text = false
1085
+ for (const n of el.childNodes) if (n.nodeType === 3 && n.nodeValue.trim()) { text = true; break }
1086
+ if (!text) return false
1087
+ const r = document.createRange()
1088
+ r.selectNodeContents(el)
1089
+ const rects = [...r.getClientRects()].filter((x) => x.width && x.height)
1090
+ if (!rects.length || rects.length > 40) return false
1091
+ const a = rects[0]
1092
+ return rects.every((x) => x.top < a.bottom - 1 && x.bottom > a.top + 1)
1093
+ }
1094
+
1095
+ function canvasColor() {
1096
+ const bg = (e) => {
1097
+ if (!e) return ''
1098
+ const c = getComputedStyle(e).backgroundColor
1099
+ return !c || c === 'transparent' || /^rgba\(.*,\s*0\)$/.test(c) ? '' : c
1100
+ }
1101
+ const de = document.documentElement
1102
+ return bg(de) || bg(document.body) || (/dark/.test(getComputedStyle(de).colorScheme) ? '#121212' : '#fff')
1103
+ }
1104
+
1105
+ async function capture(opts) {
1106
+ const full = !!opts.full
1107
+ const scale = Math.min(3, Math.max(1, Number(opts.scale) || 2))
1108
+ const de = document.documentElement
1109
+ const W = Math.ceil(de.clientWidth || innerWidth)
1110
+ const H = Math.ceil(full ? Math.max(de.scrollHeight, document.body ? document.body.scrollHeight : 0, innerHeight) : innerHeight)
1111
+ const box = makeSandbox()
1112
+ const inline = makeInliner()
1113
+ const pseudoRules = []
1114
+ const families = new Set()
1115
+ const jobs = []
1116
+ let seq = 0
1117
+
1118
+ function walk(node) {
1119
+ if (node.nodeType === 3) return document.createTextNode(node.nodeValue)
1120
+ if (node.nodeType !== 1) return null
1121
+ const el = node
1122
+ if (SKIP_TAGS.has(el.tagName.toUpperCase())) return null
1123
+ if (el.tagName === 'STYLE' && el.namespaceURI !== SVGNS) return null
1124
+ const cs = getComputedStyle(el)
1125
+ if (cs.display === 'none' && el.namespaceURI !== SVGNS) return null
1126
+
1127
+ let c
1128
+ const tag = el.tagName
1129
+ if (tag === 'CANVAS') {
1130
+ c = document.createElementNS(XHTML, 'img')
1131
+ try { c.setAttribute('src', el.toDataURL()) } catch { /* 被污染的画布 */ }
1132
+ } else if (tag === 'IFRAME' || tag === 'VIDEO' || tag === 'OBJECT' || tag === 'EMBED') {
1133
+ c = document.createElementNS(XHTML, 'div')
1134
+ } else {
1135
+ c = el.namespaceURI === SVGNS ? document.createElementNS(SVGNS, el.localName) : document.createElementNS(XHTML, el.localName)
1136
+ for (const a of el.attributes) {
1137
+ if (a.name === 'style' || a.name.startsWith('on') || a.name === 'class' && el.namespaceURI !== SVGNS) continue
1138
+ if (a.name === 'srcset' || a.name === 'sizes' || a.name === 'loading') continue
1139
+ try { c.setAttributeNS(a.namespaceURI, a.name, a.value) } catch { /* 怪属性名 */ }
1140
+ }
1141
+ }
1142
+ if (el.namespaceURI === SVGNS && el.getAttribute('class')) c.setAttribute('class', el.getAttribute('class'))
1143
+
1144
+ const withUrls = []
1145
+ const def = box.defaults(el)
1146
+ let st = styleText(cs, def, withUrls)
1147
+ if (el === de) st += 'overflow:hidden;'
1148
+ // 原来一行放得下的字,画成图时也别折行(宽度是定死的,字体渲染差一丁点就会挤到第二行)
1149
+ if (el.namespaceURI !== SVGNS && cs.whiteSpace !== 'nowrap' && oneLine(el)) st += 'white-space:nowrap;'
1150
+ c.setAttribute('style', st)
1151
+ families.add(cs.fontFamily)
1152
+ if (withUrls.length) {
1153
+ jobs.push((async () => {
1154
+ let s = c.getAttribute('style')
1155
+ for (const p of withUrls) {
1156
+ const v = cs.getPropertyValue(p)
1157
+ s = s.replace(`${p}:${v};`, `${p}:${await inlineUrls(v, location.href, inline)};`)
1158
+ }
1159
+ c.setAttribute('style', s)
1160
+ })())
1161
+ }
1162
+
1163
+ if (tag === 'IMG' && (el.currentSrc || el.src)) {
1164
+ const src = el.currentSrc || el.src
1165
+ jobs.push(inline(src).then((d) => { if (d) c.setAttribute('src', d); else c.removeAttribute('src') }))
1166
+ }
1167
+ if (el.namespaceURI === SVGNS && el.localName === 'image') {
1168
+ const href = el.getAttribute('href') || el.getAttributeNS('http://www.w3.org/1999/xlink', 'href')
1169
+ if (href) jobs.push(inline(new URL(href, location.href).href).then((d) => { if (d) c.setAttribute('href', d) }))
1170
+ }
1171
+ if (tag === 'INPUT') {
1172
+ if (el.type === 'checkbox' || el.type === 'radio') { if (el.checked) c.setAttribute('checked', '') } else c.setAttribute('value', el.value)
1173
+ }
1174
+ if (tag === 'TEXTAREA') c.textContent = el.value
1175
+ if (tag === 'SELECT') c.setAttribute('data-v', el.value)
1176
+
1177
+ // ::before / ::after
1178
+ if (el.namespaceURI !== SVGNS) {
1179
+ const pes = (tag === 'INPUT' || tag === 'TEXTAREA') && el.getAttribute('placeholder') ? ['::before', '::after', '::placeholder'] : ['::before', '::after']
1180
+ for (const pe of pes) {
1181
+ const ps = getComputedStyle(el, pe)
1182
+ const content = ps.getPropertyValue('content')
1183
+ if (pe !== '::placeholder' && (!content || content === 'none' || content === 'normal')) continue
1184
+ const cls = 'ddpx' + (++seq)
1185
+ c.setAttribute('class', ((c.getAttribute('class') || '') + ' ' + cls).trim())
1186
+ const urls = []
1187
+ const body = styleText(ps, box.defaults(el, pe), urls)
1188
+ const i = pseudoRules.length
1189
+ pseudoRules.push(`.${cls}${pe}{${body}}`)
1190
+ if (urls.length) jobs.push(inlineUrls(pseudoRules[i], location.href, inline).then((t) => { pseudoRules[i] = t }))
1191
+ }
1192
+ }
1193
+
1194
+ if (tag === 'IFRAME' || tag === 'CANVAS' || tag === 'VIDEO' || tag === 'TEXTAREA') return c
1195
+ for (const child of el.childNodes) {
1196
+ const k = walk(child)
1197
+ if (k) c.appendChild(k)
1198
+ }
1199
+ if (tag === 'SELECT') {
1200
+ const o = el.selectedOptions && el.selectedOptions[0]
1201
+ c.textContent = ''
1202
+ const opt = document.createElementNS(XHTML, 'option')
1203
+ opt.textContent = o ? o.textContent : ''
1204
+ c.appendChild(opt)
1205
+ }
1206
+ return c
1207
+ }
1208
+
1209
+ try {
1210
+ const root = walk(de)
1211
+ // 字体:页面样式表里的 @font-face,只带用到的字族
1212
+ const faces = []
1213
+ const used = [...families].join(',').toLowerCase()
1214
+ for (const sheet of document.styleSheets) {
1215
+ let rules
1216
+ try { rules = sheet.cssRules } catch { continue }
1217
+ const base = sheet.href || location.href
1218
+ const scan = (list) => {
1219
+ for (const r of list) {
1220
+ if (r.cssRules && !(r instanceof CSSFontFaceRule)) { try { scan(r.cssRules) } catch { /* 读不了 */ } continue }
1221
+ if (!(r instanceof CSSFontFaceRule)) continue
1222
+ const fam = (r.style.getPropertyValue('font-family') || '').replace(/['"]/g, '').trim().toLowerCase()
1223
+ if (fam && used.includes(fam)) faces.push(inlineUrls(r.cssText, base, inline))
1224
+ }
1225
+ }
1226
+ scan(rules)
1227
+ }
1228
+ await Promise.all(jobs)
1229
+ const fontCss = (await Promise.all(faces)).join('\n')
1230
+ const style = document.createElementNS(XHTML, 'style')
1231
+ style.textContent = fontCss + '\n' + pseudoRules.join('\n') + '\n*::-webkit-scrollbar{display:none}'
1232
+ const body = [...root.childNodes].find((n) => n.localName === 'body')
1233
+ ;(body || root).insertBefore(style, (body || root).firstChild)
1234
+ if (!full && (scrollX || scrollY) && body) {
1235
+ body.setAttribute('style', body.getAttribute('style') + `transform:translate(${-scrollX}px,${-scrollY}px);`)
1236
+ }
1237
+ const xml = new XMLSerializer().serializeToString(root)
1238
+ const svg = `<svg xmlns="${SVGNS}" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}"><foreignObject x="0" y="0" width="100%" height="100%">${xml}</foreignObject></svg>`
1239
+ const img = new Image()
1240
+ img.decoding = 'sync'
1241
+ img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg)
1242
+ await img.decode()
1243
+ const canvas = document.createElement('canvas')
1244
+ canvas.width = Math.round(W * scale)
1245
+ canvas.height = Math.round(H * scale)
1246
+ const ctx = canvas.getContext('2d')
1247
+ // 页面底色:浏览器会把 html / body 的背景铺满整个窗口,画成图时 body 下面那截要自己补上(深色页面不然底下一截是白的)
1248
+ ctx.fillStyle = canvasColor()
1249
+ ctx.fillRect(0, 0, canvas.width, canvas.height)
1250
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
1251
+ const png = await new Promise((resolve, reject) => canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob 失败'))), 'image/png'))
1252
+ return { png, w: W, h: H, scale }
1253
+ } finally {
1254
+ box.remove()
1255
+ }
1256
+ }
1257
+
1258
+ async function onCapture(d) {
1259
+ try {
1260
+ const r = await capture(d)
1261
+ post({ type: 'captured', id: d.id, png: r.png, w: r.w, h: r.h, scale: r.scale, title: document.title })
1262
+ } catch (e) {
1263
+ post({ type: 'captured', id: d.id, error: String((e && e.message) || e) })
1264
+ }
1265
+ }
1266
+
1267
+ // ── 深色模式:页面带了深色样式([data-theme="dark"] / .dark / prefers-color-scheme: dark)就能切 ──
1268
+
1269
+ let darkOn = false
1270
+ let darkPrev = null
1271
+
1272
+ function darkRules() {
1273
+ const media = []
1274
+ let attr = false
1275
+ const scan = (list) => {
1276
+ for (const r of list) {
1277
+ if (r.media && /prefers-color-scheme\s*:\s*dark/i.test(r.media.mediaText || '')) {
1278
+ for (const x of r.cssRules) media.push(x.cssText)
1279
+ continue
1280
+ }
1281
+ if (r.selectorText && /\[data-theme=["']?dark|\.dark\b|\[data-mode=["']?dark/i.test(r.selectorText)) attr = true
1282
+ if (r.cssRules && !r.media) { try { scan(r.cssRules) } catch { /* 读不了 */ } }
1283
+ }
1284
+ }
1285
+ for (const sheet of document.styleSheets) {
1286
+ try { scan(sheet.cssRules) } catch { /* 别的源 */ }
1287
+ }
1288
+ return { media, attr }
1289
+ }
1290
+
1291
+ function hasDark() {
1292
+ const r = darkRules()
1293
+ return r.attr || r.media.length > 0
1294
+ }
1295
+
1296
+ function setDark(on) {
1297
+ on = !!on
1298
+ if (on === darkOn) return
1299
+ darkOn = on
1300
+ const de = document.documentElement
1301
+ let tag = document.getElementById('__dsh_design_dark__')
1302
+ if (on) {
1303
+ darkPrev = { theme: de.getAttribute('data-theme'), mode: de.getAttribute('data-mode'), cls: de.classList.contains('dark') }
1304
+ de.setAttribute('data-theme', 'dark')
1305
+ de.setAttribute('data-mode', 'dark')
1306
+ de.classList.add('dark')
1307
+ if (!tag) {
1308
+ tag = document.createElement('style')
1309
+ tag.id = '__dsh_design_dark__'
1310
+ ;(document.head || de).appendChild(tag)
1311
+ }
1312
+ tag.textContent = ':root{color-scheme:dark}\n' + darkRules().media.join('\n')
1313
+ } else {
1314
+ if (darkPrev) {
1315
+ if (darkPrev.theme == null) de.removeAttribute('data-theme'); else de.setAttribute('data-theme', darkPrev.theme)
1316
+ if (darkPrev.mode == null) de.removeAttribute('data-mode'); else de.setAttribute('data-mode', darkPrev.mode)
1317
+ if (!darkPrev.cls) de.classList.remove('dark')
1318
+ }
1319
+ if (tag) tag.remove()
1320
+ }
1321
+ }
1322
+
1323
+ addEventListener('message', (e) => {
1324
+ if (!parentWin || e.source !== parentWin || !parentOrigins.includes(e.origin)) return
1325
+ const d = e.data
1326
+ if (!d || d.dshDesign !== 1) return
1327
+ if (d.type === 'pick') start(d.hint !== false)
1328
+ else if (d.type === 'cancel') stop(true)
1329
+ else if (d.type === 'init') boot(d.seed || null)
1330
+ else if (d.type === 'reveal') reveal(d)
1331
+ else if (d.type === 'zoom') setK(1 / (Number(d.z) || 1))
1332
+ else if (d.type === 'snapshot') post({ type: 'snap', seed: seedNow() })
1333
+ else if (d.type === 'busy') { busy = !!d.on; watchHmr() }
1334
+ else if (d.type === 'unpin') unpin()
1335
+ else if (d.type === 'reload') selfReload()
1336
+ else if (d.type === 'capture') onCapture(d)
1337
+ else if (d.type === 'theme') setDark(d.dark)
1338
+ else if (d.type === 'demo') demo = !!d.on
1339
+ else if (d.type === 'edit') setTextEdit(!!d.on, d.hint !== false)
1340
+ })
1341
+
1342
+ function selfReload() {
1343
+ try { sessionStorage.setItem(SEED_KEY, JSON.stringify({ ...seedNow(), at: Date.now() })) } catch { /* 隐私模式 / 太大 */ }
1344
+ location.reload()
1345
+ }
1346
+
1347
+ function onLoad() {
1348
+ if (parentWin) {
1349
+ post({ type: 'hello', href: location.href })
1350
+ setTimeout(() => boot(null), 1500) // 右侧栏没回话(老版本 / 不在 DSH 里)也照常就绪
1351
+ return
1352
+ }
1353
+ let seed = null
1354
+ try {
1355
+ seed = JSON.parse(sessionStorage.getItem(SEED_KEY) || 'null')
1356
+ sessionStorage.removeItem(SEED_KEY)
1357
+ if (seed && Date.now() - seed.at > 15000) seed = null
1358
+ } catch { seed = null }
1359
+ boot(seed)
1360
+ }
1361
+
1362
+ if (document.readyState === 'complete') onLoad()
1363
+ else addEventListener('load', onLoad, { once: true })
1364
+
1365
+ // 页面自己整页刷新(开发服务器的 full reload、点了页面里的链接):把当前的指纹交给右侧栏,下一帧拿来比
1366
+ addEventListener('pagehide', () => {
1367
+ if (parentWin && shown && busy) post({ type: 'bye', seed: seedNow() })
1368
+ })
1369
+
1370
+ /** 这一页在目录里的相对路径(/ 结尾的当 index.html) */
1371
+ function selfRel() {
1372
+ let p = ''
1373
+ try { p = decodeURIComponent(location.pathname) } catch { p = location.pathname }
1374
+ p = p.replace(/^\/+/, '')
1375
+ if (!p || p.endsWith('/')) p += 'index.html'
1376
+ return p
1377
+ }
1378
+
1379
+ /** 改的全是别的 HTML 页面(画布里另一屏)→ 跟这一页没关系,不用刷新 */
1380
+ function othersOnly(files) {
1381
+ if (!Array.isArray(files) || !files.length) return false
1382
+ const me = selfRel()
1383
+ return files.every((f) => /\.html?$/i.test(f) && String(f).replace(/\\/g, '/') !== me)
1384
+ }
1385
+
1386
+ // 旧版本(/__dsh_design__/v/…)只读,不跟着目录改动刷新
1387
+ if (cfg.live && window.EventSource && !location.pathname.startsWith('/__dsh_design__/v/')) {
1388
+ const es = new EventSource('/__dsh_design__/events')
1389
+ es.addEventListener('change', (e) => {
1390
+ if (!shown) return
1391
+ let file = ''
1392
+ let files = null
1393
+ try {
1394
+ const d = JSON.parse(e.data)
1395
+ file = d.file || ''
1396
+ files = d.files || null
1397
+ } catch { /* 没带文件名 */ }
1398
+ if (othersOnly(files)) return
1399
+ if (parentWin) post({ type: 'changed', file, seed: seedNow() })
1400
+ else selfReload()
1401
+ })
1402
+ }
1403
+ })()