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/LICENSE +21 -0
- package/README.en.md +89 -0
- package/README.md +142 -2
- package/cordis.patch.yml +4 -0
- package/icon.svg +7 -0
- package/lib/client.js +2843 -0
- package/lib/core.js +684 -0
- package/lib/history.js +244 -0
- package/lib/index.js +470 -0
- package/lib/phone.js +355 -0
- package/lib/picker.js +1403 -0
- package/lib/qr.js +263 -0
- package/lib/serve.js +513 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +56 -4
- package/skills/ui-design/SKILL.md +90 -0
- package/skills/ui-design/references/app.md +30 -0
- package/skills/ui-design/references/email.md +17 -0
- package/skills/ui-design/references/poster.md +21 -0
- package/skills/ui-design/references/slides.md +19 -0
- package/skills/ui-design/references/web.md +21 -0
- package/skills/ui-design/tokens.css +70 -0
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
|
+
})()
|