dsh-design-studio 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,763 @@
1
+ /*
2
+ * 边写边看的草稿页里的小脚本(预览服务器给 /某屏.html?__ddp_draft=… 时注入在页面最前面)。
3
+ *
4
+ * - 跟着写到的地方往下滚(自己往上翻了就不跟,翻回底部再接着跟)
5
+ * - 三只带名字的鼠标(排版 / 配色 / 文案)只去碰真正新长出来的东西,而且真的在「干活」:
6
+ * 排版把新的一大块拖出一个框(标上是什么、多大),配色点一下给新上色的块刷上颜色,
7
+ * 文案把新出来的一段字一个个打出来(这段字先藏着,打到哪露到哪);
8
+ * 模型停着不写,它们也停着——不循环、不演
9
+ * - 页面还没有正文(模型在写样式)时,把样式里已经定下来的颜色、字体一个个摆出来
10
+ * - 写完(文档读完)鼠标退场;右侧栏随后换上真文件
11
+ */
12
+ (() => {
13
+ if (window.__ddpDraft) return
14
+ window.__ddpDraft = 1
15
+ const zh = new URLSearchParams(location.search).get('lang') !== 'en'
16
+ const NAMES = zh ? ['排版', '配色', '文案'] : ['Layout', 'Color', 'Copy']
17
+ const PAL = zh ? '配色和字体' : 'Colors & type'
18
+ const KIND = zh
19
+ ? { top: '顶栏', bottom: '底栏', list: '列表', form: '表单', img: '图片', title: '标题', block: '内容' }
20
+ : { top: 'Header', bottom: 'Footer', list: 'List', form: 'Form', img: 'Image', title: 'Title', block: 'Block' }
21
+ const C = ['#4d6bfe', '#e8590c', '#0ca678']
22
+ let still = false
23
+ try { still = matchMedia('(prefers-reduced-motion: reduce)').matches } catch { /* 老浏览器 */ }
24
+ const de = document.documentElement
25
+ let zk = 1 // 画布缩小时鼠标、字放大的倍数(和 --k 一样)
26
+
27
+ // 草稿里不要滚动条(跟着往下滚时一闪一闪的)
28
+ const css = document.createElement('style')
29
+ // 样式常写在正文后面:写到一半时没定尺寸的图标会撑满一屏,草稿里先给个上限(写完换真文件就没有了)
30
+ css.textContent = 'html{scrollbar-width:none}html::-webkit-scrollbar{display:none}svg:not([width]){max-width:40vw;max-height:40vh}' +
31
+ // 新长出来的东西淡进来(只动透明度:不碰页面自己的 transform;:where 零权重,页面自己写了动画就听页面的)
32
+ (still ? '' : '@keyframes ddp-in{from{opacity:0}}:where(body *){animation:ddp-in .3s ease-out backwards}')
33
+ ;(document.head || de).appendChild(css)
34
+
35
+ const host = document.createElement('ddp-draft-fx')
36
+ host.setAttribute('aria-hidden', 'true')
37
+ host.style.cssText = 'all:initial;position:fixed;inset:0;z-index:2147483647;pointer-events:none;display:block'
38
+ const sr = host.attachShadow({ mode: 'closed' })
39
+ const EASE = 'cubic-bezier(.3,.7,.2,1)'
40
+ sr.innerHTML = `<style>
41
+ :host{--k:1}
42
+ *{box-sizing:border-box}
43
+ .cur{position:absolute;left:0;top:0;opacity:0;transition:transform .6s ${EASE},opacity .35s;will-change:transform}
44
+ .cur.on{opacity:1}
45
+ .cur.live{transition:opacity .35s}
46
+ .cur svg{transition:transform .14s}
47
+ .cur.tap svg{transform:scale(.8)}
48
+ .cur svg{display:block;width:calc(16px * var(--k));height:calc(20px * var(--k));overflow:visible;filter:drop-shadow(0 1px 2px rgba(0,0,0,.3))}
49
+ .cur path{fill:var(--c);stroke:#fff;stroke-width:1.5;stroke-linejoin:round}
50
+ .cur em{position:absolute;left:calc(12px * var(--k));top:calc(16px * var(--k));display:flex;align-items:center;gap:calc(4px * var(--k));padding:calc(2px * var(--k)) calc(7px * var(--k));border-radius:calc(6px * var(--k));
51
+ background:var(--c);color:#fff;font:600 calc(11px * var(--k))/1.4 -apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;font-style:normal;white-space:nowrap;box-shadow:0 2px 6px rgba(0,0,0,.18)}
52
+ .cur.flip em{left:auto;right:calc(-4px * var(--k))}
53
+ .cur em i{display:none;width:calc(9px * var(--k));height:calc(9px * var(--k));border-radius:50%;box-shadow:0 0 0 calc(1.5px * var(--k)) rgba(255,255,255,.95)}
54
+ .cur em i.on{display:block}
55
+ .cur em s{text-decoration:none;opacity:.85;font-weight:500}
56
+ .box{position:absolute;left:0;top:0;border:calc(1.5px * var(--k)) solid var(--c);background:color-mix(in srgb,var(--c) 7%,transparent);transition:opacity .6s}
57
+ .box.out{opacity:0}
58
+ .box s{position:absolute;width:calc(7px * var(--k));height:calc(7px * var(--k));background:#fff;border:calc(1.5px * var(--k)) solid var(--c);border-radius:calc(1.5px * var(--k))}
59
+ .box s:nth-of-type(1){left:calc(-4.25px * var(--k));top:calc(-4.25px * var(--k))}
60
+ .box s:nth-of-type(2){right:calc(-4.25px * var(--k));top:calc(-4.25px * var(--k))}
61
+ .box s:nth-of-type(3){left:calc(-4.25px * var(--k));bottom:calc(-4.25px * var(--k))}
62
+ .box s:nth-of-type(4){right:calc(-4.25px * var(--k));bottom:calc(-4.25px * var(--k))}
63
+ .box.in b{top:calc(4px * var(--k));left:calc(4px * var(--k))}
64
+ .box span{position:absolute;left:50%;top:100%;margin-top:calc(7px * var(--k));transform:translateX(-50%);padding:calc(1px * var(--k)) calc(6px * var(--k));border-radius:calc(4px * var(--k));
65
+ background:var(--c);color:#fff;white-space:nowrap;font:600 calc(10px * var(--k))/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;font-variant-numeric:tabular-nums}
66
+ .box.up span{top:auto;bottom:calc(7px * var(--k));margin-top:0}
67
+ .paint{position:absolute;left:0;top:0;overflow:hidden}
68
+ .paint i{position:absolute;width:2px;height:2px;margin:-1px 0 0 -1px;border-radius:50%;transform:scale(0);
69
+ background:radial-gradient(circle closest-side,transparent 58%,color-mix(in srgb,var(--c) 50%,#fff) 84%,transparent 100%)}
70
+ .caret{position:absolute;left:0;top:0;width:calc(2px * var(--k));border-radius:1px;background:${C[2]};opacity:0;transition:opacity .2s}
71
+ .caret.on{opacity:1;animation:blink 1s steps(1) infinite}
72
+ @keyframes blink{50%{opacity:.25}}
73
+ .box b{position:absolute;left:calc(-1.5px * var(--k));top:calc(-19px * var(--k));padding:calc(1px * var(--k)) calc(6px * var(--k));border-radius:calc(4px * var(--k));background:var(--c);color:#fff;
74
+ font:600 calc(10px * var(--k))/1.5 -apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif}
75
+ .pal{position:absolute;left:50%;top:42%;transform:translate(-50%,-50%);transition:opacity .4s,transform .3s;width:min(86%, calc(340px * var(--k)));padding:calc(16px * var(--k)) calc(18px * var(--k));border-radius:calc(14px * var(--k));
76
+ background:rgba(255,255,255,.94);box-shadow:0 calc(10px * var(--k)) calc(30px * var(--k)) rgba(20,20,40,.14),0 0 0 calc(.5px * var(--k)) rgba(0,0,0,.08);color:#1d1d24;
77
+ font:500 calc(11px * var(--k))/1.4 -apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;opacity:0}
78
+ .pal.on{opacity:1}
79
+ .pal.out{opacity:0;transform:translate(-50%,-50%) scale(.96);transition-duration:.22s}
80
+ .pal h6{margin:0 0 calc(10px * var(--k));font-size:calc(11px * var(--k));font-weight:600;color:#8a8a96;letter-spacing:.04em}
81
+ .sw{display:flex;flex-wrap:wrap;gap:calc(10px * var(--k))}
82
+ .sw span{display:flex;flex-direction:column;align-items:center;gap:calc(4px * var(--k));width:calc(48px * var(--k));animation:pop .42s ${EASE} both}
83
+ .sw i{width:calc(30px * var(--k));height:calc(30px * var(--k));border-radius:50%;box-shadow:inset 0 0 0 calc(1px * var(--k)) rgba(0,0,0,.1)}
84
+ .sw em{max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-style:normal;font-size:calc(9.5px * var(--k));color:#6b6b78}
85
+ .ft{display:flex;flex-direction:column;gap:calc(6px * var(--k));margin-top:calc(12px * var(--k))}
86
+ .ft span{display:flex;align-items:baseline;gap:calc(8px * var(--k));animation:pop .42s ${EASE} both}
87
+ .ft b{font-size:calc(20px * var(--k));font-weight:600;line-height:1}
88
+ .ft em{font-style:normal;color:#6b6b78;font-size:calc(10.5px * var(--k))}
89
+ @keyframes pop{from{opacity:0;transform:translateY(calc(6px * var(--k))) scale(.8)}}
90
+ @media (prefers-reduced-motion:reduce){.cur,.box,.pal{transition:none}.sw span,.ft span,.caret.on{animation:none}}
91
+ </style><div class="pal"><h6>${PAL}</h6><div class="sw"></div><div class="ft"></div></div><div class="caret"></div>`
92
+ const ARROW = '<svg viewBox="0 0 17 21"><path d="M1.2 1.2v15.6l4.3-4 3 6.6 2.9-1.3-3-6.5h6z"/></svg>'
93
+ const curs = NAMES.map((n, i) => {
94
+ const el = document.createElement('div')
95
+ el.className = 'cur'
96
+ el.style.setProperty('--c', C[i])
97
+ el.innerHTML = `${ARROW}<em><i></i><span></span><s></s></em>`
98
+ el.querySelector('span').textContent = n
99
+ sr.appendChild(el)
100
+ // x/y:视口坐标;at:贴着的元素(滚动时跟着它走)和它身上的点(相对元素左上)
101
+ // anim:正在演一个动作(拖框、打字),这时位置由动作自己一帧帧给,滚动跟随先别管它
102
+ return { el, tip: el.querySelector('s'), dot: el.querySelector('i'), x: 0, y: 0, at: null, dx: 0, dy: 0, busyTill: 0, anim: false }
103
+ })
104
+ const caret = sr.querySelector('.caret')
105
+ const pal = sr.querySelector('.pal')
106
+ const swBox = sr.querySelector('.sw')
107
+ const ftBox = sr.querySelector('.ft')
108
+ de.appendChild(host)
109
+
110
+ const vw = () => innerWidth || de.clientWidth || 390
111
+ const vh = () => innerHeight || de.clientHeight || 844
112
+ const place = (c, x, y, loose) => {
113
+ c.x = Math.max(4, Math.min(vw() - 6 - 16 * zk, x))
114
+ c.y = loose ? y : Math.max(4, Math.min(vh() - 10, y))
115
+ c.el.style.transform = `translate(${c.x}px, ${c.y}px)`
116
+ c.el.classList.toggle('flip', c.x > vw() * 0.68)
117
+ }
118
+ // 一开始三只停在顶上,等第一笔
119
+ curs.forEach((c, i) => place(c, vw() * (0.3 + i * 0.18), 56 + i * 10))
120
+
121
+ // ── 跟着写到的地方往下滚 ──
122
+ let follow = true
123
+ let done = false
124
+ const bottomGap = () => Math.max(0, de.scrollHeight - vh() - scrollY)
125
+ const stop = () => { follow = false }
126
+ addEventListener('wheel', (e) => { if (e.deltaY < 0) stop() }, { passive: true })
127
+ addEventListener('touchstart', stop, { passive: true })
128
+ addEventListener('keydown', stop)
129
+ addEventListener('scroll', () => {
130
+ if (!follow && !done && bottomGap() < 24) follow = true // 翻回底部:接着跟
131
+ track()
132
+ }, { passive: true })
133
+ const chase = () => {
134
+ if (!follow || done) return
135
+ if (bottomGap() > 2) scrollTo({ top: de.scrollHeight, behavior: still ? 'auto' : 'smooth' })
136
+ }
137
+
138
+ // ── 新长出来的东西 ──
139
+ const fresh = []
140
+ const SKIP = /^(SCRIPT|STYLE|LINK|META|TITLE|TEMPLATE|NOSCRIPT|BR|HEAD|HTML|BODY|BASE|DDP-DRAFT-FX|SOURCE|TRACK|PARAM|WBR)$/
141
+ // 文案会去打的:成段的字(标题、段落、按钮、标签);列表项、链接常常是整张卡片,不算
142
+ const TYPE = /^(H1|H2|H3|H4|H5|H6|P|BUTTON|LABEL|FIGCAPTION|BLOCKQUOTE|DT|DD)$/
143
+ let typing = null // { el, since }:藏着等打的那段
144
+ const hold = (el) => {
145
+ el.__ddpClip = [el.style.getPropertyValue('clip-path'), el.style.getPropertyPriority('clip-path')]
146
+ el.style.setProperty('clip-path', 'inset(0 100% 100% 0)', 'important')
147
+ }
148
+ const release = (el) => {
149
+ const was = el && el.__ddpClip
150
+ if (!was) return
151
+ el.style.removeProperty('clip-path')
152
+ if (was[0]) el.style.setProperty('clip-path', was[0], was[1])
153
+ delete el.__ddpClip
154
+ }
155
+ const mo = new MutationObserver((recs) => {
156
+ if (!palGone && palOn && hasBody()) dropPal()
157
+ for (const r of recs) {
158
+ for (const n of r.addedNodes) {
159
+ if (n.nodeType !== 1 || SKIP.test(n.tagName)) continue
160
+ fresh.push({ el: n, age: 0 })
161
+ // 配色空着手:新上色的块先收着底色,等它来刷
162
+ if (!painting && !still && !done && PAINT.test(n.tagName) && !n.closest('head')) {
163
+ const col = solidBg(n)
164
+ if (col && apart(col, pageBg())) {
165
+ painting = { el: n, color: col, since: Date.now(), on: false }
166
+ painted.add(n)
167
+ holdBg(n, col)
168
+ continue
169
+ }
170
+ }
171
+ // 文案空着手:新出来的一段字先藏起来(这时还没画到屏上),等它来一个个打出来
172
+ if (!typing && !still && !done && TYPE.test(n.tagName) && !n.closest('head')) {
173
+ typing = { el: n, since: Date.now() }
174
+ hold(n)
175
+ requestAnimationFrame(tryType)
176
+ }
177
+ }
178
+ }
179
+ })
180
+ mo.observe(de, { childList: true, subtree: true })
181
+
182
+ const visible = (r) => r.width > 6 && r.height > 6 && r.bottom > 0 && r.top < vh() && r.right > 0 && r.left < vw()
183
+ const bgOf = (el) => {
184
+ try {
185
+ const v = getComputedStyle(el).backgroundColor
186
+ const m = /rgba?\(([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:[,\s/]+([\d.]+))?/.exec(v)
187
+ if (!m || (m[4] !== undefined && Number(m[4]) < 0.15)) return ''
188
+ return '#' + [m[1], m[2], m[3]].map((x) => Math.round(Number(x)).toString(16).padStart(2, '0')).join('')
189
+ } catch { return '' }
190
+ }
191
+ const pageBg = () => (document.body && bgOf(document.body)) || bgOf(de) || '#ffffff'
192
+ const rgb = (hx) => [1, 3, 5].map((i) => parseInt(hx.slice(i, i + 2), 16))
193
+ const dist = (a, b) => {
194
+ const x = rgb(a)
195
+ const y = rgb(b)
196
+ return Math.hypot(x[0] - y[0], x[1] - y[1], x[2] - y[2])
197
+ }
198
+ // 暗色页面的卡片常常只比底色亮一点点,也算
199
+ const apart = (a, b) => !!a && (!b || dist(a, b) >= 24)
200
+ const kindOf = (el, r) => {
201
+ const tag = el.tagName
202
+ let pos = ''
203
+ try { pos = getComputedStyle(el).position } catch { /* 拿不到就算普通的 */ }
204
+ // 钉在屏幕上的宽条:看在上面还是下面
205
+ if ((pos === 'fixed' || pos === 'sticky') && r.width > vw() * 0.8) return r.top < vh() * 0.5 ? 'top' : 'bottom'
206
+ // 不钉住的 header / nav:在页面最上面的是顶栏;中间的 header 是一节的标题,nav 是底下的导航
207
+ if (tag === 'HEADER' || tag === 'NAV') return r.top + scrollY < vh() * 0.25 ? 'top' : tag === 'NAV' ? 'bottom' : 'title'
208
+ if (tag === 'FOOTER') return 'bottom'
209
+ if (tag === 'UL' || tag === 'OL' || tag === 'TABLE') return 'list'
210
+ if (tag === 'FORM') return 'form'
211
+ if (tag === 'IMG' || tag === 'PICTURE' || tag === 'VIDEO' || tag === 'CANVAS') return 'img'
212
+ if (/^H[1-3]$/.test(tag)) return 'title'
213
+ return 'block'
214
+ }
215
+ const BLOCK = /^(HEADER|NAV|SECTION|MAIN|FOOTER|ASIDE|ARTICLE|FORM|UL|OL|DIV|TABLE|FIGURE|IMG|PICTURE)$/
216
+
217
+ // ── 鼠标去碰一个元素 ──
218
+ const boxes = [] // 排版拖出来的框
219
+ const paints = [] // 配色刷的那一下
220
+ const firstCur = () => sr.querySelector('.cur')
221
+ const sizeBox = (item, r) => {
222
+ const s = item.b.style
223
+ const p = item.p == null ? 1 : item.p
224
+ s.width = r.width * p + 'px'
225
+ s.height = r.height * p + 'px'
226
+ s.transform = `translate(${r.left}px, ${r.top}px)`
227
+ }
228
+ const ease = (t) => 1 - Math.pow(1 - t, 3)
229
+ // 一个动作的每一帧;写完了(done)就直接跳到最后
230
+ const anim = (ms, step, end) => {
231
+ const t0 = performance.now()
232
+ const f = (now) => {
233
+ const t = done ? 1 : Math.min(1, (now - t0) / ms)
234
+ step(t)
235
+ if (t < 1) requestAnimationFrame(f)
236
+ else if (end) end()
237
+ }
238
+ requestAnimationFrame(f)
239
+ }
240
+ const go = (c, el, dx, dy, tip, dot) => {
241
+ c.at = el
242
+ c.dx = dx
243
+ c.dy = dy
244
+ c.el.classList.add('on')
245
+ c.tip.textContent = tip ? ' ' + tip : ''
246
+ c.dot.classList.toggle('on', !!dot)
247
+ if (dot) c.dot.style.background = dot
248
+ const r = el.getBoundingClientRect()
249
+ place(c, r.left + dx, r.top + dy)
250
+ }
251
+ const tap = (c) => {
252
+ c.el.classList.add('tap')
253
+ setTimeout(() => c.el.classList.remove('tap'), 150)
254
+ }
255
+
256
+ // 排版:走到这块的左上角,按住往右下拖出一个选框(四角把手,下面实时标着多大),松手标上是什么
257
+ const drag = (c, el, label) => {
258
+ go(c, el, 0, 0, '')
259
+ const b = document.createElement('div')
260
+ b.className = 'box'
261
+ b.style.setProperty('--c', C[0])
262
+ b.innerHTML = '<s></s><s></s><s></s><s></s><b></b><span></span>'
263
+ b.querySelector('b').textContent = label
264
+ const sz = b.querySelector('span')
265
+ b.style.opacity = '0'
266
+ sr.insertBefore(b, firstCur())
267
+ const item = { b, el, p: 0 }
268
+ boxes.push(item)
269
+ sizeBox(item, el.getBoundingClientRect())
270
+ setTimeout(() => {
271
+ if (done || !el.isConnected) {
272
+ b.remove()
273
+ boxes.splice(boxes.indexOf(item), 1)
274
+ return
275
+ }
276
+ b.style.opacity = ''
277
+ c.anim = true
278
+ c.el.classList.add('live')
279
+ tap(c)
280
+ anim(still ? 0 : 620, (t) => {
281
+ item.p = ease(t)
282
+ const r = el.getBoundingClientRect()
283
+ sizeBox(item, r)
284
+ // 名字贴不下(块顶着屏幕上沿)就放进框里;尺寸贴不下(块顶着下沿)就放框里底下
285
+ b.classList.toggle('in', r.top < 22 * zk)
286
+ b.classList.toggle('up', r.top + r.height * item.p > vh() - 26 * zk)
287
+ sz.textContent = `${Math.round(r.width * item.p)} × ${Math.round(r.height * item.p)}`
288
+ place(c, r.left + r.width * item.p, r.top + r.height * item.p)
289
+ }, () => {
290
+ const r = el.getBoundingClientRect()
291
+ c.anim = false
292
+ c.el.classList.remove('live')
293
+ // 松手,往里收一点(别停在屏幕边上)
294
+ c.dx = Math.max(0, r.width - 26 * zk)
295
+ c.dy = Math.max(0, r.height - 8 * zk)
296
+ place(c, r.left + c.dx, r.top + c.dy)
297
+ setTimeout(() => b.classList.add('out'), 1200)
298
+ setTimeout(() => { b.remove(); boxes.splice(boxes.indexOf(item), 1) }, 1900)
299
+ })
300
+ }, still ? 0 : 560)
301
+ }
302
+
303
+ // 配色:新上色的块先只留一圈虚线(底色先收着),配色走过去点一下,颜色从点的地方一圈刷满——刷的是块自己的底色,字一直在上面
304
+ let painting = null // { el, color, since, on }:收着底色等刷的那块
305
+ const BG = ['background-color', 'background-image', 'background-size', 'background-position', 'background-repeat', 'outline', 'outline-offset', 'transition']
306
+ const PAINT = /^(DIV|SECTION|ARTICLE|ASIDE|HEADER|FOOTER|NAV|BUTTON|A|LI|FIGURE|FORM|LABEL)$/
307
+ const solidBg = (el) => {
308
+ try {
309
+ const cs = getComputedStyle(el)
310
+ if (cs.backgroundImage !== 'none') return ''
311
+ const m = /rgba?\(([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:[,\s/]+([\d.]+))?/.exec(cs.backgroundColor)
312
+ if (!m || (m[4] !== undefined && Number(m[4]) < 0.6)) return ''
313
+ return '#' + [m[1], m[2], m[3]].map((x) => Math.round(Number(x)).toString(16).padStart(2, '0')).join('')
314
+ } catch { return '' }
315
+ }
316
+ const holdBg = (el, color) => {
317
+ const st = el.style
318
+ let line = color
319
+ try {
320
+ const m = /rgba?\(([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/.exec(getComputedStyle(el).color)
321
+ const fg = m ? '#' + [m[1], m[2], m[3]].map((x) => Math.round(Number(x)).toString(16).padStart(2, '0')).join('') : ''
322
+ const bg = pageBg()
323
+ if (fg && dist(fg, bg) > dist(color, bg)) line = fg
324
+ } catch { /* 拿不到字色就用块色 */ }
325
+ el.__ddpBg = BG.map((k) => [k, st.getPropertyValue(k), st.getPropertyPriority(k)])
326
+ st.setProperty('transition', 'none', 'important')
327
+ st.setProperty('background-color', 'transparent', 'important')
328
+ st.setProperty('background-image', 'none', 'important')
329
+ st.setProperty('background-size', 'auto', 'important')
330
+ st.setProperty('background-position', '0 0', 'important')
331
+ st.setProperty('background-repeat', 'no-repeat', 'important')
332
+ st.setProperty('outline', `${1.5 * zk}px dashed color-mix(in srgb, ${line} 55%, transparent)`, 'important')
333
+ st.setProperty('outline-offset', `${-1.5 * zk}px`, 'important')
334
+ }
335
+ const releaseBg = (el) => {
336
+ const was = el && el.__ddpBg
337
+ if (!was) return
338
+ delete el.__ddpBg
339
+ const put = (k) => {
340
+ const w = was.find((x) => x[0] === k)
341
+ el.style.removeProperty(k)
342
+ if (w && w[1]) el.style.setProperty(k, w[1], w[2])
343
+ }
344
+ for (const k of BG) if (k !== 'transition') put(k)
345
+ // 过渡最后再还(同一帧还的话,页面自己的 transition 会把底色从透明慢慢变过来,闪一下)
346
+ requestAnimationFrame(() => requestAnimationFrame(() => put('transition')))
347
+ }
348
+ const painted = new WeakSet()
349
+ const fill = (c, el, color) => {
350
+ const r0 = el.getBoundingClientRect()
351
+ const dx = Math.max(4, Math.min(r0.width * 0.7, r0.width - 6))
352
+ const dy = Math.max(4, Math.min(r0.height * 0.6, r0.height - 4, vh() - r0.top - 24))
353
+ go(c, el, dx, dy, color, color)
354
+ setTimeout(() => {
355
+ if (!el.__ddpBg) return
356
+ tap(c)
357
+ el.style.setProperty('outline-color', 'transparent', 'important')
358
+ const rim = `color-mix(in srgb, ${color} 55%, #fff)`
359
+ anim(still ? 0 : 950, (t) => {
360
+ if (!el.__ddpBg) return
361
+ const r = el.getBoundingClientRect()
362
+ const R = (Math.hypot(Math.max(dx, r.width - dx), Math.max(dy, r.height - dy)) + 16) * (1 - (1 - t) * (1 - t))
363
+ const e = Math.min(R, 10 * zk)
364
+ el.style.setProperty('background-image', `radial-gradient(circle at ${dx}px ${dy}px, ${color} ${R - e}px, ${rim} ${R - e * 0.3}px, transparent ${R}px)`, 'important')
365
+ }, () => {
366
+ releaseBg(el)
367
+ if (painting && painting.el === el) painting = null
368
+ })
369
+ }, still ? 0 : 620)
370
+ }
371
+ // 没收住底色的(样式后到 / 渐变底):点一下,泛起一圈光
372
+ const paint = (c, el, color) => {
373
+ const r0 = el.getBoundingClientRect()
374
+ const dx = Math.min(r0.width * 0.7, r0.width - 6)
375
+ const dy = Math.min(r0.height * 0.6, r0.height - 4)
376
+ go(c, el, dx, dy, color, color)
377
+ if (still) return
378
+ setTimeout(() => {
379
+ if (done || !el.isConnected || c.at !== el) return
380
+ tap(c)
381
+ const r = el.getBoundingClientRect()
382
+ const w = document.createElement('div')
383
+ w.className = 'paint'
384
+ let rad = ''
385
+ try { rad = getComputedStyle(el).borderRadius } catch { /* 拿不到就方的 */ }
386
+ w.style.width = r.width + 'px'
387
+ w.style.height = r.height + 'px'
388
+ w.style.borderRadius = rad
389
+ w.style.transform = `translate(${r.left}px, ${r.top}px)`
390
+ const dot = document.createElement('i')
391
+ dot.style.setProperty('--c', color)
392
+ dot.style.left = dx + 'px'
393
+ dot.style.top = dy + 'px'
394
+ w.appendChild(dot)
395
+ sr.insertBefore(w, firstCur())
396
+ const item = { w, el }
397
+ paints.push(item)
398
+ const R = Math.hypot(Math.max(dx, r.width - dx), Math.max(dy, r.height - dy)) + 2
399
+ try {
400
+ dot.animate([{ transform: 'scale(0)', opacity: 0.9 }, { transform: `scale(${R * 1.15})`, opacity: 0 }], { duration: 800, easing: EASE, fill: 'forwards' })
401
+ } catch { /* 没有 WAAPI 就不泛 */ }
402
+ setTimeout(() => { w.remove(); paints.splice(paints.indexOf(item), 1) }, 850)
403
+ }, 600)
404
+ }
405
+
406
+ // 文案:藏着的那段字,有字了、量得出大小了,就从头一个个打出来
407
+ const linesOf = (el, er) => {
408
+ const out = []
409
+ try {
410
+ const rg = document.createRange()
411
+ rg.selectNodeContents(el)
412
+ const rects = rg.getClientRects()
413
+ if (rects.length > 400) return [{ x0: 0, x1: er.width, y0: 0, y1: er.height }] // 太长的一段:整块当一行,不逐行量
414
+ for (const q of rects) {
415
+ if (q.width < 1 || q.height < 1) continue
416
+ const y0 = q.top - er.top
417
+ const y1 = q.bottom - er.top
418
+ const hit = (o) => y0 < o.y1 - 2 && y1 > o.y0 + 2
419
+ const z = out[out.length - 1]
420
+ const l = z && hit(z) ? z : out.find(hit) // 大多接在最后一行上:先看它
421
+ if (l) {
422
+ l.x0 = Math.min(l.x0, q.left - er.left)
423
+ l.x1 = Math.max(l.x1, q.right - er.left)
424
+ l.y0 = Math.min(l.y0, y0)
425
+ l.y1 = Math.max(l.y1, y1)
426
+ } else out.push({ x0: q.left - er.left, x1: q.right - er.left, y0, y1 })
427
+ }
428
+ } catch { /* 量不了就整块一行 */ }
429
+ if (!out.length) out.push({ x0: 0, x1: er.width, y0: 0, y1: er.height })
430
+ out.sort((a, b) => a.y0 - b.y0)
431
+ return out
432
+ }
433
+ function tryType() {
434
+ const t = typing
435
+ if (!t) return
436
+ const el = t.el
437
+ const c = curs[2]
438
+ if (done || !el.isConnected) {
439
+ release(el)
440
+ typing = null
441
+ return
442
+ }
443
+ const r = el.getBoundingClientRect()
444
+ const n = (el.textContent || '').trim().length
445
+ const ready = n >= 2 && r.width > 6 && r.height > 6 && r.top < vh() && r.bottom > 0 && Date.now() >= c.busyTill
446
+ if (ready && el.children.length <= 4 && n <= 600) return type(c, el, n)
447
+ // 等了一阵还没字 / 一直在屏外 / 其实是一大块(里面一堆子元素、一大段字):不打了,直接露出来
448
+ if (Date.now() - t.since > 1600 || el.children.length > 4 || n > 600) {
449
+ release(el)
450
+ typing = null
451
+ return
452
+ }
453
+ requestAnimationFrame(tryType)
454
+ }
455
+ const type = (c, el, n) => {
456
+ const ms = Math.max(280, Math.min(1100, n * 38))
457
+ c.busyTill = Date.now() + ms + 400
458
+ c.at = el
459
+ c.anim = true
460
+ c.el.classList.add('on', 'live')
461
+ c.tip.textContent = ''
462
+ c.dot.classList.remove('on')
463
+ caret.classList.add('on')
464
+ anim(ms, (t) => {
465
+ const er = el.getBoundingClientRect()
466
+ const lines = linesOf(el, er)
467
+ const total = lines.reduce((s, l) => s + (l.x1 - l.x0), 0) || 1
468
+ let left = total * t
469
+ let i = 0
470
+ while (i < lines.length - 1 && left > lines[i].x1 - lines[i].x0) {
471
+ left -= lines[i].x1 - lines[i].x0
472
+ i++
473
+ }
474
+ const L = lines[i]
475
+ const x = Math.min(L.x1, L.x0 + left)
476
+ const W = er.width + 8
477
+ if (t < 1) el.style.setProperty('clip-path', `polygon(-8px -8px, ${W}px -8px, ${W}px ${L.y0}px, ${x}px ${L.y0}px, ${x}px ${L.y1}px, -8px ${L.y1}px)`, 'important')
478
+ caret.style.height = L.y1 - L.y0 + 'px'
479
+ caret.style.transform = `translate(${er.left + x}px, ${er.top + L.y0}px)`
480
+ place(c, er.left + x + 4, er.top + L.y0 + (L.y1 - L.y0) * 0.7)
481
+ }, () => {
482
+ release(el)
483
+ typing = null
484
+ caret.classList.remove('on')
485
+ c.anim = false
486
+ c.el.classList.remove('live')
487
+ const er = el.getBoundingClientRect()
488
+ const ls = linesOf(el, er)
489
+ const L = ls[ls.length - 1]
490
+ c.dx = Math.min(er.width, L.x1 + 6 * zk)
491
+ c.dy = L.y1 + 2 * zk
492
+ place(c, er.left + c.dx, er.top + c.dy)
493
+ })
494
+ }
495
+ // 滚动 / 版面变了:贴着元素的鼠标、框跟着走
496
+ let raf = 0
497
+ function track() {
498
+ if (raf) return
499
+ raf = requestAnimationFrame(() => {
500
+ raf = 0
501
+ for (const c of curs) {
502
+ if (c.anim || !c.at || !c.at.isConnected) continue
503
+ const r = c.at.getBoundingClientRect()
504
+ place(c, r.left + Math.min(c.dx, r.width), r.top + Math.min(c.dy, r.height), r.bottom < 0 || r.top > vh())
505
+ }
506
+ for (const it of boxes) if (it.el.isConnected) sizeBox(it, it.el.getBoundingClientRect())
507
+ for (const it of paints) {
508
+ if (!it.el.isConnected) continue
509
+ const r = it.el.getBoundingClientRect()
510
+ it.w.style.transform = `translate(${r.left}px, ${r.top}px)`
511
+ }
512
+ })
513
+ }
514
+
515
+ // ── 还没正文:样式里定下来的颜色、字体 ──
516
+ let palOn = false
517
+ let palGone = false
518
+ const swatches = new Map()
519
+ const fonts = new Set()
520
+ const GENERIC = /^(inherit|initial|unset|system-ui|-apple-system|BlinkMacSystemFont|sans-serif|serif|monospace|cursive|ui-\w+)$/i
521
+ const firstFamily = (v) => {
522
+ const f = String(v).split(',')[0].trim().replace(/^["']|["']$/g, '')
523
+ return f && !/^var\(/i.test(f) && !GENERIC.test(f) ? f : ''
524
+ }
525
+ // 写到一半的 <style> 浏览器还没收进去(要等结束标签):自己再拿一份原文流,边到边从里面挑颜色、字体
526
+ let raw = ''
527
+ ;(async () => {
528
+ try {
529
+ const r = await fetch(location.href, { cache: 'no-store' })
530
+ if (!r.body) return
531
+ const rd = r.body.getReader()
532
+ const td = new TextDecoder()
533
+ for (;;) {
534
+ if (palGone || raw.length > 600000) break
535
+ const { value, done: end } = await rd.read()
536
+ if (end) break
537
+ raw += td.decode(value, { stream: true })
538
+ if (/<body[\s>]/i.test(raw)) break
539
+ }
540
+ rd.cancel().catch(() => {})
541
+ } catch { /* 拿不到就只看已经生效的样式 */ }
542
+ })()
543
+ const scanCss = () => {
544
+ const b = raw.search(/<body[\s>]/i)
545
+ let txt = b >= 0 ? raw.slice(0, b) : raw
546
+ for (const s of document.querySelectorAll('style')) if (s !== css && !raw) txt += s.textContent + '\n'
547
+ for (const sh of document.styleSheets) {
548
+ if (!sh.ownerNode || sh.ownerNode.tagName === 'STYLE') continue
549
+ try { for (const r of sh.cssRules) txt += r.cssText + '\n' } catch { /* 跨源的样式读不了 */ }
550
+ }
551
+ const re = /--([\w-]+)\s*:\s*([^;{}]+)/g
552
+ let m
553
+ const added = { color: null, font: null }
554
+ while ((m = re.exec(txt))) {
555
+ const name = m[1]
556
+ const v = m[2].trim()
557
+ if (/font|family|serif|sans/i.test(name)) {
558
+ const f = firstFamily(v)
559
+ if (f && !fonts.has(f) && fonts.size < 3) { fonts.add(f); added.font = addFont(f, v) }
560
+ continue
561
+ }
562
+ if (swatches.size >= 12 || swatches.has(v)) continue
563
+ if (!/^(#[0-9a-f]{3,8}\b|rgba?\(|hsla?\(|oklch\(|oklab\(|lab\(|lch\(|color\()/i.test(v)) continue
564
+ let ok = false
565
+ try { ok = CSS.supports('color', v) } catch { ok = false }
566
+ if (ok) { swatches.set(v, name); added.color = addSwatch(v, name) }
567
+ }
568
+ if (fonts.size < 3) {
569
+ const fr = /font-family\s*:\s*([^;{}]+)/gi
570
+ while ((m = fr.exec(txt)) && fonts.size < 3) {
571
+ const f = firstFamily(m[1])
572
+ if (f && !fonts.has(f)) { fonts.add(f); added.font = addFont(f, m[1]) }
573
+ }
574
+ }
575
+ return added
576
+ }
577
+ const addSwatch = (v, name) => {
578
+ const s = document.createElement('span')
579
+ s.innerHTML = '<i></i><em></em>'
580
+ s.firstChild.style.background = v
581
+ s.lastChild.textContent = name
582
+ swBox.appendChild(s)
583
+ return s
584
+ }
585
+ const addFont = (f, stack) => {
586
+ const s = document.createElement('span')
587
+ s.innerHTML = '<b></b><em></em>'
588
+ s.firstChild.textContent = zh ? 'Aa 字' : 'Aa'
589
+ s.firstChild.style.fontFamily = stack
590
+ s.lastChild.textContent = f
591
+ ftBox.appendChild(s)
592
+ return s
593
+ }
594
+ // 正文一冒头,配色卡片马上让开(别和页面叠着)
595
+ const dropPal = () => {
596
+ if (palGone) return
597
+ palGone = true
598
+ if (palOn) {
599
+ pal.classList.add('out')
600
+ setTimeout(() => pal.remove(), 300)
601
+ } else pal.remove()
602
+ }
603
+ // 正文有东西了没有(body 里有看得见的元素)
604
+ const hasBody = () => {
605
+ const b = document.body
606
+ if (!b) return false
607
+ for (const k of b.children) {
608
+ if (SKIP.test(k.tagName)) continue
609
+ const r = k.getBoundingClientRect()
610
+ if (r.width > 0 && r.height > 8) return true
611
+ }
612
+ return false
613
+ }
614
+
615
+ // ── 主循环:每一拍看看长出了什么,有新的才动 ──
616
+ let turn = 0
617
+ // 有正文了:页面没给底色就垫白(不然底下的占位透上来,和字叠在一起)
618
+ // 每拍重判(样式可能后到):先撤掉自己垫的,页面自己给了底色就不垫
619
+ let padded = false
620
+ const backdrop = () => {
621
+ if (padded) de.style.removeProperty('background-color')
622
+ padded = false
623
+ if (bgOf(de)) return
624
+ de.style.backgroundColor = (document.body && bgOf(document.body)) || '#ffffff'
625
+ padded = true
626
+ }
627
+ const tick = () => {
628
+ if (done) return
629
+ const now = Date.now()
630
+ if (!palGone) {
631
+ if (hasBody()) dropPal()
632
+ else {
633
+ const add = scanCss()
634
+ if (swatches.size || fonts.size) {
635
+ if (!palOn) { palOn = true; pal.classList.add('on') }
636
+ const pr = () => pal.getBoundingClientRect()
637
+ // 配色那只去点刚摆上的颜色,排版那只去指刚出的字体
638
+ if (add.color) setTimeout(() => { const r = add.color.getBoundingClientRect(); if (r.width) { curs[1].at = null; curs[1].el.classList.add('on'); curs[1].tip.textContent = ''; curs[1].dot.classList.remove('on'); place(curs[1], r.left + r.width * 0.6, r.top + r.height * 0.45); setTimeout(() => tap(curs[1]), 520) } }, 120)
639
+ if (add.font) setTimeout(() => { const r = add.font.getBoundingClientRect(); if (r.width) { curs[0].at = null; curs[0].el.classList.add('on'); curs[0].tip.textContent = ''; curs[0].dot.classList.remove('on'); place(curs[0], r.left + 30, r.top + r.height * 0.7) } }, 260)
640
+ if (!curs[2].el.classList.contains('on')) { const r = pr(); curs[2].el.classList.add('on'); place(curs[2], r.right - 24, r.top + 8) }
641
+ }
642
+ return
643
+ }
644
+ }
645
+ backdrop()
646
+ // 刚长出来的:过一两拍再看(字、样式可能还没到,量出来是 0)
647
+ const ready = []
648
+ for (let i = fresh.length - 1; i >= 0; i--) {
649
+ const f = fresh[i]
650
+ if (!f.el.isConnected || f.el.closest('head')) { fresh.splice(i, 1); continue }
651
+ f.age++
652
+ if (f.age >= 1) { ready.push(f.el); fresh.splice(i, 1) }
653
+ }
654
+ if (fresh.length > 400) fresh.splice(0, fresh.length - 400)
655
+ chase()
656
+ if (painting && !painting.on) {
657
+ const p = painting
658
+ const r = p.el.getBoundingClientRect()
659
+ const c = curs[1]
660
+ if (!p.el.isConnected || now - p.since > 2600) {
661
+ releaseBg(p.el)
662
+ painting = null
663
+ } else if (r.width >= 36 && r.height >= 22 && visible(r) && (now >= c.busyTill || c.soft) && !c.anim) {
664
+ p.on = true
665
+ c.busyTill = now + 2000
666
+ c.soft = false
667
+ fill(c, p.el, p.color)
668
+ }
669
+ }
670
+ if (!ready.length) return
671
+ const bg = pageBg()
672
+ const picks = [null, null, null]
673
+ // ready 里新的在前:倒着挑,先挑早出来的(外层的块先出来,量起来也更完整)
674
+ for (let i = ready.length - 1; i >= 0; i--) {
675
+ const el = ready[i]
676
+ const r = el.getBoundingClientRect()
677
+ if (!visible(r)) continue
678
+ if (!picks[0] && BLOCK.test(el.tagName) && r.width >= vw() * 0.42 && r.height >= 30 && r.height < vh() * 1.2) picks[0] = { el, r }
679
+ if (!picks[1] && !painted.has(el)) {
680
+ const c = bgOf(el)
681
+ if (c && apart(c, bg) && r.width >= 14 && r.height >= 12 && r.width * r.height < vw() * vh() * 0.7) picks[1] = { el, r, c }
682
+ }
683
+ if (picks[0] && picks[1]) break
684
+ }
685
+ // 轮流来:这一拍谁先动换着排,两只不同时起步(文案那只看见新的字就自己去打,见 tryType)
686
+ const order = [0, 1].map((k) => (k + turn) % 2)
687
+ turn++
688
+ order.forEach((k, j) => {
689
+ const p = picks[k]
690
+ const c = curs[k]
691
+ if (!p || p.el === c.at || now < c.busyTill || c.anim) return
692
+ if (k === 1 && painting) return // 有块收着底色等刷:先刷它
693
+ c.busyTill = now + (k === 0 ? 1700 : 1500)
694
+ if (k === 1) c.soft = true
695
+ setTimeout(() => {
696
+ if (done || !p.el.isConnected) return
697
+ const r = p.el.getBoundingClientRect()
698
+ if (k === 0) drag(c, p.el, KIND[kindOf(p.el, r)])
699
+ else paint(c, p.el, p.c)
700
+ }, j * 260)
701
+ })
702
+ // 闲着的:挪到正在写的地方(最新冒出来的东西下面)候着——只在真有新东西时挪,不自己晃
703
+ let front = null
704
+ for (const el of ready) {
705
+ const r = el.getBoundingClientRect()
706
+ if (visible(r)) { front = r; break }
707
+ }
708
+ if (!front) return
709
+ curs.forEach((c, k) => {
710
+ if (c.anim || now < c.busyTill + 500 || now < (c.restTill || 0)) return
711
+ if ((k === 2 && typing) || (k === 1 && painting)) return
712
+ c.restTill = now + 1500 + k * 250
713
+ c.at = null
714
+ c.tip.textContent = ''
715
+ c.dot.classList.remove('on')
716
+ c.el.classList.add('on')
717
+ place(c, vw() * (0.2 + k * 0.27) + (turn % 3) * 6 * zk, Math.min(vh() - 34 * zk, front.bottom + (10 + k * 6) * zk))
718
+ })
719
+ }
720
+ const timer = setInterval(tick, still ? 900 : 650)
721
+ addEventListener('resize', track)
722
+
723
+ // ── 写完:停下、鼠标退场、回到顶上(右侧栏马上换上真文件) ──
724
+ // 文档读完就算(不等 load:页面引的外部字体、图片卡着的话 load 要等很久)
725
+ let fin = false
726
+ const finish = () => {
727
+ if (fin) return
728
+ fin = true
729
+ tick()
730
+ done = true
731
+ if (typing) {
732
+ release(typing.el)
733
+ typing = null
734
+ }
735
+ if (painting) {
736
+ releaseBg(painting.el)
737
+ painting = null
738
+ }
739
+ caret.classList.remove('on')
740
+ clearInterval(timer)
741
+ mo.disconnect()
742
+ if (!palGone) pal.remove()
743
+ setTimeout(() => {
744
+ curs.forEach((c, i) => setTimeout(() => { c.el.classList.remove('on'); place(c, c.x, vh() + 40) }, i * 140))
745
+ if (scrollY > 0) scrollTo({ top: 0, behavior: still ? 'auto' : 'smooth' })
746
+ }, 700)
747
+ // 放完了(鼠标也退场了):告诉右侧栏,它这时才换上真文件
748
+ setTimeout(() => { try { parent.postMessage({ dshDraft: 'end' }, '*') } catch { /* 没有父页面 */ } }, still ? 200 : 1400)
749
+ }
750
+ if (document.readyState === 'loading') addEventListener('DOMContentLoaded', finish)
751
+ else finish()
752
+ addEventListener('load', finish)
753
+
754
+ // ── 和右侧栏:报到,收缩放(画布缩小时鼠标、字跟着放大,看起来一样大) ──
755
+ addEventListener('message', (e) => {
756
+ if (e.source !== parent || !e.data || e.data.dshDraft !== 'zoom') return
757
+ const z = Number(e.data.z) || 1
758
+ zk = Math.max(1, Math.min(6, 1 / z))
759
+ host.style.setProperty('--k', String(zk))
760
+ track()
761
+ })
762
+ try { parent.postMessage({ dshDraft: 'hello' }, '*') } catch { /* 没有父页面 */ }
763
+ })()