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.
- package/README.md +2 -2
- package/assets/demo.gif +0 -0
- package/assets/hero-sm.jpg +0 -0
- package/lib/client.js +1937 -542
- package/lib/core.js +210 -6
- package/lib/draft-page.js +763 -0
- package/lib/draft.js +151 -0
- package/lib/index.js +17 -3
- package/lib/picker.js +227 -1
- package/lib/serve.js +177 -1
- package/lib/tweaks.js +58 -24
- package/package.json +1 -1
- package/skills/ui-design/SKILL.md +35 -5
package/lib/draft.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 边写边看:模型写一整页 HTML 时,write 工具的参数要流好几分钟才到。这里把流到一半的页面内容攒着,
|
|
3
|
+
* 预览服务器(serve.js:某屏的地址带 ?__ddp_draft=)一边收一边原样吐给 iframe,浏览器就一段段画出来。
|
|
4
|
+
*
|
|
5
|
+
* createDrafts() 文件(绝对路径)→ 正在写的内容;订阅的人随到随收
|
|
6
|
+
* createJsonStringDecoder() 参数是 JSON:把流过来的「字符串里面那一截」一块块还原(转义可能被切在两块中间)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const MAX_TEXT = 4 * 1024 * 1024 // 一页最多攒这么多(再大的也不是正常的设计稿)
|
|
10
|
+
const KEEP = 6 // 最多记几份(每轮开始清一次,这里只是兜底)
|
|
11
|
+
// 「整段一下子到的」:最后一阵(中间没停过 GAP_MS)在 LUMP_MS 里就到齐了,有 LUMP_CHARS 字以上、占全文一半以上。
|
|
12
|
+
// 这种才值得事后补放一遍(serve.js 晚来的也一截截放、右侧栏放完再换真文件);一路流着写的看过了,不重放
|
|
13
|
+
const LUMP_CHARS = 1500
|
|
14
|
+
const LUMP_MS = 1500
|
|
15
|
+
const GAP_MS = 1000
|
|
16
|
+
|
|
17
|
+
export function createDrafts({ log = () => {} } = {}) {
|
|
18
|
+
const map = new Map() // abs → { id, text, done, subs }
|
|
19
|
+
let ids = 0
|
|
20
|
+
|
|
21
|
+
const emit = (d, ev) => {
|
|
22
|
+
for (const fn of [...d.subs]) {
|
|
23
|
+
try { fn(ev) } catch (e) { log(e) }
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
/** 开始写(同一个文件重写也从头来);返回这一份的编号,地址里带上,iframe 才会重载 */
|
|
29
|
+
begin(abs) {
|
|
30
|
+
const old = map.get(abs)
|
|
31
|
+
if (old) emit(old, { done: true, gone: true })
|
|
32
|
+
const d = { id: ++ids, text: '', done: false, subs: new Set() }
|
|
33
|
+
map.delete(abs)
|
|
34
|
+
map.set(abs, d)
|
|
35
|
+
while (map.size > KEEP) {
|
|
36
|
+
const [k, v] = map.entries().next().value
|
|
37
|
+
emit(v, { done: true, gone: true })
|
|
38
|
+
map.delete(k)
|
|
39
|
+
}
|
|
40
|
+
return d.id
|
|
41
|
+
},
|
|
42
|
+
// push / end / drop 带上 begin 给的编号时只认那一份(同一个文件后来又开始重写了,旧的不碰新的)
|
|
43
|
+
push(abs, text, id) {
|
|
44
|
+
const d = map.get(abs)
|
|
45
|
+
if (!d || d.done || !text || (id && d.id !== id)) return
|
|
46
|
+
const room = MAX_TEXT - d.text.length
|
|
47
|
+
if (room <= 0) return
|
|
48
|
+
const s = text.length > room ? text.slice(0, room) : text
|
|
49
|
+
const now = Date.now()
|
|
50
|
+
if (!d.burstAt || now - d.lastAt > GAP_MS) {
|
|
51
|
+
d.burstAt = now
|
|
52
|
+
d.burst = 0
|
|
53
|
+
}
|
|
54
|
+
d.burst += s.length
|
|
55
|
+
d.lastAt = now
|
|
56
|
+
d.text += s
|
|
57
|
+
emit(d, { text: s })
|
|
58
|
+
},
|
|
59
|
+
/** 参数流完了(或者这次作废了):在看的那头收尾 */
|
|
60
|
+
end(abs, id) {
|
|
61
|
+
const d = map.get(abs)
|
|
62
|
+
if (!d || d.done || (id && d.id !== id)) return
|
|
63
|
+
d.done = true
|
|
64
|
+
d.endAt = Date.now()
|
|
65
|
+
d.lump = !!d.burstAt && d.burst >= LUMP_CHARS && d.burst * 2 >= d.text.length && d.endAt - d.burstAt < LUMP_MS
|
|
66
|
+
emit(d, { done: true })
|
|
67
|
+
},
|
|
68
|
+
/** 作废:在看的那头收尾,之后这个地址读不到 */
|
|
69
|
+
drop(abs, id) {
|
|
70
|
+
const d = map.get(abs)
|
|
71
|
+
if (!d || (id && d.id !== id)) return
|
|
72
|
+
d.done = true
|
|
73
|
+
emit(d, { done: true, gone: true })
|
|
74
|
+
map.delete(abs)
|
|
75
|
+
},
|
|
76
|
+
get(abs) {
|
|
77
|
+
return map.get(abs) || null
|
|
78
|
+
},
|
|
79
|
+
/** 订阅:先拿 get() 的现有内容,之后的增量走 fn({ text }) / fn({ done }) */
|
|
80
|
+
sub(abs, fn) {
|
|
81
|
+
const d = map.get(abs)
|
|
82
|
+
if (!d) return () => {}
|
|
83
|
+
d.subs.add(fn)
|
|
84
|
+
return () => d.subs.delete(fn)
|
|
85
|
+
},
|
|
86
|
+
clear() {
|
|
87
|
+
for (const d of map.values()) emit(d, { done: true, gone: true })
|
|
88
|
+
map.clear()
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const ESC = { n: '\n', t: '\t', r: '\r', b: '\b', f: '\f', '/': '/', '\\': '\\', '"': '"' }
|
|
94
|
+
|
|
95
|
+
/** JSON 字符串内容的流式还原:push(原文的一块) → 还原出来的一块;碰到收尾的引号就停(done) */
|
|
96
|
+
export function createJsonStringDecoder() {
|
|
97
|
+
let pend = '' // 切在两块中间的半个转义
|
|
98
|
+
let hi = '' // 半个表情(高位代理),等下一块
|
|
99
|
+
let done = false
|
|
100
|
+
return {
|
|
101
|
+
get done() {
|
|
102
|
+
return done
|
|
103
|
+
},
|
|
104
|
+
push(chunk) {
|
|
105
|
+
if (done) return ''
|
|
106
|
+
const src = pend + String(chunk || '')
|
|
107
|
+
pend = ''
|
|
108
|
+
let out = ''
|
|
109
|
+
let i = 0
|
|
110
|
+
while (i < src.length) {
|
|
111
|
+
const c = src[i]
|
|
112
|
+
if (c === '"') {
|
|
113
|
+
done = true
|
|
114
|
+
break
|
|
115
|
+
}
|
|
116
|
+
if (c !== '\\') {
|
|
117
|
+
let j = i + 1
|
|
118
|
+
while (j < src.length && src[j] !== '\\' && src[j] !== '"') j++
|
|
119
|
+
out += src.slice(i, j)
|
|
120
|
+
i = j
|
|
121
|
+
continue
|
|
122
|
+
}
|
|
123
|
+
if (i + 1 >= src.length) {
|
|
124
|
+
pend = src.slice(i)
|
|
125
|
+
break
|
|
126
|
+
}
|
|
127
|
+
const e = src[i + 1]
|
|
128
|
+
if (e === 'u') {
|
|
129
|
+
if (i + 6 > src.length) {
|
|
130
|
+
pend = src.slice(i)
|
|
131
|
+
break
|
|
132
|
+
}
|
|
133
|
+
const code = parseInt(src.slice(i + 2, i + 6), 16)
|
|
134
|
+
if (!Number.isNaN(code)) out += String.fromCharCode(code)
|
|
135
|
+
i += 6
|
|
136
|
+
continue
|
|
137
|
+
}
|
|
138
|
+
out += ESC[e] ?? e
|
|
139
|
+
i += 2
|
|
140
|
+
}
|
|
141
|
+
out = hi + out
|
|
142
|
+
hi = ''
|
|
143
|
+
const last = out.charCodeAt(out.length - 1)
|
|
144
|
+
if (!done && last >= 0xd800 && last <= 0xdbff) {
|
|
145
|
+
hi = out.slice(-1)
|
|
146
|
+
out = out.slice(0, -1)
|
|
147
|
+
}
|
|
148
|
+
return out
|
|
149
|
+
},
|
|
150
|
+
}
|
|
151
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -54,6 +54,7 @@ import { createLibraryRoutes } from './gallery/routes.js'
|
|
|
54
54
|
import { createMarket } from './gallery/market.js'
|
|
55
55
|
import { createMarketRoutes } from './gallery/market-routes.js'
|
|
56
56
|
import { createTracker, parseTarget, findRule, KINDS } from './core.js'
|
|
57
|
+
import { createDrafts } from './draft.js'
|
|
57
58
|
import { createHistory } from './history.js'
|
|
58
59
|
import { createPhone } from './phone.js'
|
|
59
60
|
import { qrMatrix } from './qr.js'
|
|
@@ -657,9 +658,11 @@ export function apply(ctx) {
|
|
|
657
658
|
const seedDir = fileURLToPath(new URL('../gallery-seed', import.meta.url))
|
|
658
659
|
// 「设计库不联网」那行开着:市场不访问任何网址
|
|
659
660
|
const market = createMarket({ version: VERSION, log, seedDir: fs.existsSync(path.join(seedDir, 'index.json')) ? seedDir : '', online: () => studio.offline === 0 })
|
|
661
|
+
const drafts = createDrafts({ log }) // 正在写的页面(边写边看):tracker 往里推,预览服务器往外吐
|
|
660
662
|
const hub = createHub({
|
|
661
663
|
log,
|
|
662
664
|
history,
|
|
665
|
+
drafts,
|
|
663
666
|
strict: { html: (buf) => library.strictHtml(buf) || market.strictHtml(buf), root: (dir) => library.strictRoot(dir) || market.isStaging(dir), versions: (dir) => library.strictVersions(dir) },
|
|
664
667
|
// 桌面版的 dsh-app://app 是默认就认的;这里补上 DSH 网页端的地址,和 DSH 配置里声明的对外地址(反向代理 / 域名)
|
|
665
668
|
parents: () => {
|
|
@@ -679,6 +682,7 @@ export function apply(ctx) {
|
|
|
679
682
|
}
|
|
680
683
|
tracker = createTracker({
|
|
681
684
|
open,
|
|
685
|
+
drafts,
|
|
682
686
|
log,
|
|
683
687
|
history,
|
|
684
688
|
// 记会话的同时,在做的设计目录补进「我的作品」
|
|
@@ -694,7 +698,7 @@ export function apply(ctx) {
|
|
|
694
698
|
return s
|
|
695
699
|
},
|
|
696
700
|
})
|
|
697
|
-
ctx.effect(() => () => hub.closeAll(), 'dsh-design-studio: preview servers')
|
|
701
|
+
ctx.effect(() => () => { drafts.clear(); hub.closeAll() }, 'dsh-design-studio: preview servers')
|
|
698
702
|
ctx.effect(() => {
|
|
699
703
|
studio.tracker = tracker
|
|
700
704
|
studio.log = log
|
|
@@ -799,7 +803,7 @@ export function apply(ctx) {
|
|
|
799
803
|
return out
|
|
800
804
|
}
|
|
801
805
|
|
|
802
|
-
/** 调节滑块写回文件:values = { id: 值 } */
|
|
806
|
+
/** 调节滑块写回文件:values = { id: 值 };外观面板里直接改的设计变量:vars = { '--名字': 值 } */
|
|
803
807
|
async function tweakRoute(req, res) {
|
|
804
808
|
if (!admit(req, res)) return
|
|
805
809
|
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' })
|
|
@@ -807,7 +811,8 @@ export function apply(ctx) {
|
|
|
807
811
|
try { body = await readBody(req) } catch (e) { return json(res, 400, { error: e.message }) }
|
|
808
812
|
const values = body.values && typeof body.values === 'object' && !Array.isArray(body.values) ? body.values : {}
|
|
809
813
|
try {
|
|
810
|
-
const
|
|
814
|
+
const vars = body.vars && typeof body.vars === 'object' && !Array.isArray(body.vars) ? body.vars : null
|
|
815
|
+
const r = tracker.editDir(String(body.session || ''), (root) => applyTweaks(root, values, vars), (x) => `调节:${x.changed.map((c) => `${c.label} ${c.to}`).join('、')}`.slice(0, 60), String(body.root || ''))
|
|
811
816
|
json(res, 200, r)
|
|
812
817
|
} catch (e) {
|
|
813
818
|
json(res, 500, { error: e.message })
|
|
@@ -1227,5 +1232,14 @@ export function apply(ctx) {
|
|
|
1227
1232
|
log(e)
|
|
1228
1233
|
}
|
|
1229
1234
|
})
|
|
1235
|
+
// 模型边说边推的流:写大文件时参数要流好几分钟,靠它提前知道在写哪个文件(老版本 DSH 没有这个事件,就只是收不到)
|
|
1236
|
+
ctx.on('agent/assistant-stream', (e) => {
|
|
1237
|
+
try {
|
|
1238
|
+
const id = e && e.agent && e.agent.session && e.agent.session.id
|
|
1239
|
+
if (id && e.frame) tracker.stream(id, e.frame)
|
|
1240
|
+
} catch (err) {
|
|
1241
|
+
log(err)
|
|
1242
|
+
}
|
|
1243
|
+
}, { global: true })
|
|
1230
1244
|
}
|
|
1231
1245
|
}
|
package/lib/picker.js
CHANGED
|
@@ -605,6 +605,27 @@
|
|
|
605
605
|
.scan::after{content:"";position:absolute;left:0;right:0;bottom:8%;height:2px;
|
|
606
606
|
background:linear-gradient(90deg,transparent,#8ea2ff 20%,#c4b5fd 50%,#5eead4 80%,transparent);box-shadow:0 0 14px 3px rgba(124,140,255,.55)}
|
|
607
607
|
@keyframes scan{0%{opacity:0;transform:translateY(-38vh)}12%{opacity:1}82%{opacity:1}100%{opacity:0;transform:translateY(100vh)}}
|
|
608
|
+
/* 生成动画:带名字的鼠标把版面框出来,再由上往下显出整屏 */
|
|
609
|
+
.bmask{position:fixed;inset:0;background:var(--bg,#fff);clip-path:inset(0 0 0 0)}
|
|
610
|
+
.bmask.go{transition:clip-path var(--dur,900ms) cubic-bezier(.55,.05,.25,1);clip-path:inset(100% 0 0 0)}
|
|
611
|
+
.bbox{position:fixed;box-sizing:border-box;width:0;height:0;border:calc(1.5px * var(--k, 1)) dashed var(--c);border-radius:calc(8px * var(--k, 1));
|
|
612
|
+
background:color-mix(in srgb,var(--c) 6%,transparent);overflow:hidden;
|
|
613
|
+
transition:width var(--d,340ms) cubic-bezier(.3,.7,.2,1),height var(--d,340ms) cubic-bezier(.3,.7,.2,1),opacity .3s ease,background .3s ease,border-color .3s ease}
|
|
614
|
+
.bbox::after{content:"";position:absolute;inset:0;transform:translateX(-100%);opacity:0;background:linear-gradient(90deg,transparent,color-mix(in srgb,var(--c) 22%,transparent),transparent)}
|
|
615
|
+
.bbox.fill{border-style:solid;border-color:color-mix(in srgb,var(--c) 42%,transparent);background:color-mix(in srgb,var(--c) 9%,transparent)}
|
|
616
|
+
.bbox.fill::after{opacity:1;animation:bshine 1.2s ease-in-out infinite}
|
|
617
|
+
.bbox b{position:absolute;left:calc(7px * var(--k, 1));top:calc(5px * var(--k, 1));opacity:0;transition:opacity .25s ease;
|
|
618
|
+
font:600 calc(10.5px * var(--k, 1))/1.45 -apple-system,BlinkMacSystemFont,"PingFang SC","Segoe UI","Microsoft YaHei",sans-serif;letter-spacing:.02em;color:var(--c);white-space:nowrap}
|
|
619
|
+
.bbox.fill b{opacity:1}
|
|
620
|
+
.bcur.flip span{left:auto;right:calc(100% - 4px * var(--k, 1));border-radius:calc(8px * var(--k, 1)) calc(4px * var(--k, 1)) calc(8px * var(--k, 1)) calc(8px * var(--k, 1))}
|
|
621
|
+
.bcur.up span{top:auto;bottom:calc(100% - 2px * var(--k, 1))}
|
|
622
|
+
@keyframes bshine{to{transform:translateX(100%)}}
|
|
623
|
+
.bcur{position:fixed;left:0;top:0;z-index:2;opacity:0;will-change:transform;transition:opacity .25s ease}
|
|
624
|
+
.bcur.on{opacity:1}
|
|
625
|
+
.bcur svg{display:block;width:calc(17px * var(--k, 1));height:calc(21px * var(--k, 1));overflow:visible;filter:drop-shadow(0 1px 2px rgba(0,0,0,.28))}
|
|
626
|
+
.bcur span{position:absolute;left:calc(13px * var(--k, 1));top:calc(17px * var(--k, 1));padding:calc(2px * var(--k, 1)) calc(7px * var(--k, 1));
|
|
627
|
+
border-radius:calc(4px * var(--k, 1)) calc(8px * var(--k, 1)) calc(8px * var(--k, 1)) calc(8px * var(--k, 1));background:var(--c);color:#fff;
|
|
628
|
+
font:600 calc(11px * var(--k, 1))/1.35 -apple-system,BlinkMacSystemFont,"PingFang SC","Segoe UI","Microsoft YaHei",sans-serif;white-space:nowrap;box-shadow:0 2px 6px rgba(0,0,0,.18)}
|
|
608
629
|
`
|
|
609
630
|
let fx = null
|
|
610
631
|
|
|
@@ -724,6 +745,211 @@
|
|
|
724
745
|
setTimeout(() => { if (linkSig) resendLinks() }, DUR + 800) // 动画时按钮挪了一点,播完按最终位置补报
|
|
725
746
|
}
|
|
726
747
|
|
|
748
|
+
// ── 生成动画 2:几只带名字的鼠标(排版 / 配色 / 文案)轮流把版面框出来,框里闪骨架,再由上往下显出整屏 ──
|
|
749
|
+
//
|
|
750
|
+
// 框的是页面里几大块的真实位置(这屏已经写好了,先量),所以动画和最后的画面对得上。
|
|
751
|
+
// 系统开了「减少动态效果」就直接显示;点一下、滚一下、按个键就跳过。播完不留任何东西。
|
|
752
|
+
|
|
753
|
+
const BUILD_TX = {
|
|
754
|
+
zh: { cur: ['排版', '配色', '文案'], top: '顶栏', bottom: '底栏', side: '侧栏', img: '图片', title: '标题', list: '列表', form: '表单', card: '卡片', block: '内容' },
|
|
755
|
+
en: { cur: ['Layout', 'Color', 'Copy'], top: 'Header', bottom: 'Footer', side: 'Sidebar', img: 'Image', title: 'Title', list: 'List', form: 'Form', card: 'Card', block: 'Section' },
|
|
756
|
+
}
|
|
757
|
+
const BUILD_C = ['#4d6bfe', '#e8590c', '#0ca678']
|
|
758
|
+
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" fill="var(--c)" stroke="#fff" stroke-width="1.5" stroke-linejoin="round"/></svg>'
|
|
759
|
+
let building = null
|
|
760
|
+
|
|
761
|
+
/** 页面里几大块(视口里看得见的部分):外壳 div 一层层钻进去,太高的一块再拆一层,最多 7 块 */
|
|
762
|
+
function buildBlocks() {
|
|
763
|
+
const vw = innerWidth
|
|
764
|
+
const vh = innerHeight
|
|
765
|
+
const rectOf = (c) => {
|
|
766
|
+
if (SIG_SKIP.has(c.tagName)) return null
|
|
767
|
+
const r = c.getBoundingClientRect()
|
|
768
|
+
if (r.width < 8 || r.height < 8 || r.bottom <= 0 || r.top >= vh || r.right <= 0 || r.left >= vw) return null
|
|
769
|
+
const cs = getComputedStyle(c)
|
|
770
|
+
if (cs.visibility === 'hidden' || cs.display === 'contents' || Number(cs.opacity) === 0) return null
|
|
771
|
+
return r
|
|
772
|
+
}
|
|
773
|
+
const kidsOf = (el) => [...el.children].map((c) => ({ el: c, r: rectOf(c) })).filter((x) => x.r)
|
|
774
|
+
const seen = (r) => Math.max(0, Math.min(r.bottom, vh) - Math.max(r.top, 0)) * Math.max(0, Math.min(r.right, vw) - Math.max(r.left, 0))
|
|
775
|
+
let kids = document.body ? kidsOf(document.body) : []
|
|
776
|
+
for (let d = 0; kids.length === 1 && d < 8; d++) kids = kidsOf(kids[0].el)
|
|
777
|
+
const min = vw * vh * 0.012
|
|
778
|
+
const split = (k) => {
|
|
779
|
+
let sub = kidsOf(k.el)
|
|
780
|
+
for (let d = 0; sub.length === 1 && d < 6; d++) sub = kidsOf(sub[0].el)
|
|
781
|
+
return sub.filter((x) => seen(x.r) >= min)
|
|
782
|
+
}
|
|
783
|
+
// 太高的一块(整个内容区)往里拆,最多两层
|
|
784
|
+
const expand = (k, depth) => {
|
|
785
|
+
if (depth < 2 && k.r.height > vh * 0.55) {
|
|
786
|
+
const sub = split(k)
|
|
787
|
+
if (sub.length >= 2 && sub.length <= 6) return sub.flatMap((x) => expand(x, depth + 1))
|
|
788
|
+
}
|
|
789
|
+
return [k]
|
|
790
|
+
}
|
|
791
|
+
let out = kids.filter((k) => seen(k.r) >= min).flatMap((k) => expand(k, 0))
|
|
792
|
+
const box = (r) => ({ top: r.top, bottom: r.bottom, left: r.left, right: r.right })
|
|
793
|
+
for (const k of out) {
|
|
794
|
+
k.b = box(k.r)
|
|
795
|
+
const pos = getComputedStyle(k.el).position
|
|
796
|
+
k.fixed = pos === 'fixed' || pos === 'sticky'
|
|
797
|
+
}
|
|
798
|
+
// 浮在上面的条(购物车条、底部导航):底下那块的框截到它上面,免得两个框叠在一起
|
|
799
|
+
const wide = (a, b) => Math.max(0, Math.min(a.right, b.right) - Math.max(a.left, b.left)) > (a.right - a.left) * 0.6
|
|
800
|
+
for (const k of out) {
|
|
801
|
+
if (k.fixed) continue
|
|
802
|
+
for (const f of out) {
|
|
803
|
+
if (!f.fixed || f === k || !wide(k.b, f.b)) continue
|
|
804
|
+
if (f.b.top > k.b.top + vh * 0.1 && f.b.top < k.b.bottom) k.b.bottom = f.b.top - 6
|
|
805
|
+
else if (f.b.bottom < k.b.bottom - vh * 0.1 && f.b.bottom > k.b.top) k.b.top = f.b.bottom + 6
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
// 还叠着的(一层套一层)只留看得见更多的那个
|
|
809
|
+
const inter = (a, b) => Math.max(0, Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top)) * Math.max(0, Math.min(a.right, b.right) - Math.max(a.left, b.left))
|
|
810
|
+
out = out.filter((k) => k.b.bottom - k.b.top >= 12 && seen(k.b) >= min * 0.6)
|
|
811
|
+
out.sort((a, b) => seen(b.b) - seen(a.b))
|
|
812
|
+
out = out.filter((k, i) => !out.slice(0, i).some((o) => inter(k.b, o.b) > seen(k.b) * 0.5))
|
|
813
|
+
if (out.length > 7) out = out.slice(0, 7)
|
|
814
|
+
out.sort((a, b) => a.b.top - b.b.top || a.b.left - b.b.left)
|
|
815
|
+
const pad = 2
|
|
816
|
+
return out.map((k) => {
|
|
817
|
+
const x = Math.max(pad, k.b.left)
|
|
818
|
+
const y = Math.max(pad, k.b.top)
|
|
819
|
+
return { el: k.el, r: k.r, x, y, w: Math.max(12, Math.min(vw - pad, k.b.right) - x), h: Math.max(12, Math.min(vh - pad, k.b.bottom) - y) }
|
|
820
|
+
})
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/** 这块大概是什么:顶栏 / 底栏 / 图片 / 表单 / 列表 / 标题 / 卡片 */
|
|
824
|
+
function blockName(el, r, N) {
|
|
825
|
+
const vh = innerHeight
|
|
826
|
+
const tag = el.tagName.toLowerCase()
|
|
827
|
+
const role = el.getAttribute('role') || ''
|
|
828
|
+
const low = r.top > vh * 0.6
|
|
829
|
+
if (tag === 'footer' || ((tag === 'nav' || role === 'tablist' || role === 'navigation') && low) || (r.bottom >= vh * 0.94 && r.height < vh * 0.16)) return N.bottom
|
|
830
|
+
if (tag === 'header' || tag === 'nav' || role === 'banner' || (r.top <= vh * 0.06 && r.height < vh * 0.16)) return N.top
|
|
831
|
+
if (el.querySelector('input:not([type=hidden]),textarea,select')) return N.form
|
|
832
|
+
let media = 0
|
|
833
|
+
for (const m of el.querySelectorAll('img,picture,video,canvas,svg')) {
|
|
834
|
+
const q = m.getBoundingClientRect()
|
|
835
|
+
if (q.width * q.height > 400) media += q.width * q.height
|
|
836
|
+
}
|
|
837
|
+
if (media > r.width * r.height * 0.45) return N.img
|
|
838
|
+
if (r.width < innerWidth * 0.36 && r.height > vh * 0.3) return N.side
|
|
839
|
+
const kids = [...el.children].filter((c) => c.getBoundingClientRect().height > 8)
|
|
840
|
+
const tags = new Map()
|
|
841
|
+
for (const c of kids) tags.set(c.tagName, (tags.get(c.tagName) || 0) + 1)
|
|
842
|
+
const most = Math.max(0, ...tags.values())
|
|
843
|
+
if (/^(ul|ol|table)$/.test(tag) || (kids.length >= 3 && kids.every((c) => Math.abs(c.getBoundingClientRect().height - kids[0].getBoundingClientRect().height) < 12)) ||
|
|
844
|
+
(most >= 3 && most >= kids.length * 0.6)) return N.list
|
|
845
|
+
if (el.matches('h1,h2,h3') || (el.querySelector('h1,h2') && r.height < vh * 0.3)) return N.title
|
|
846
|
+
return r.height > vh * 0.4 ? N.block : N.card
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function pageBg() {
|
|
850
|
+
for (const el of [document.body, document.documentElement]) {
|
|
851
|
+
if (!el) continue
|
|
852
|
+
const c = getComputedStyle(el).backgroundColor
|
|
853
|
+
if (c && c !== 'transparent' && !/rgba\(.*,\s*0\)$/.test(c)) return c
|
|
854
|
+
}
|
|
855
|
+
return '#fff'
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function build(lang) {
|
|
859
|
+
stopBuild()
|
|
860
|
+
if (!document.body || (window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches)) return
|
|
861
|
+
const blocks = buildBlocks()
|
|
862
|
+
if (!blocks.length) return buildIn()
|
|
863
|
+
const N = BUILD_TX[lang] || BUILD_TX.zh
|
|
864
|
+
const { root } = fxRoot()
|
|
865
|
+
const vw = innerWidth
|
|
866
|
+
const vh = innerHeight
|
|
867
|
+
const nodes = []
|
|
868
|
+
const timers = []
|
|
869
|
+
const at = (ms, f) => timers.push(setTimeout(f, ms))
|
|
870
|
+
const add = (el) => { root.append(el); nodes.push(el); return el }
|
|
871
|
+
const mask = add(document.createElement('div'))
|
|
872
|
+
mask.className = 'bmask'
|
|
873
|
+
mask.style.setProperty('--bg', pageBg())
|
|
874
|
+
// 三只鼠标从下面进来
|
|
875
|
+
const curs = BUILD_C.map((c, i) => {
|
|
876
|
+
const d = add(document.createElement('div'))
|
|
877
|
+
d.className = 'bcur'
|
|
878
|
+
d.style.setProperty('--c', c)
|
|
879
|
+
d.innerHTML = ARROW
|
|
880
|
+
const tag = document.createElement('span')
|
|
881
|
+
tag.textContent = N.cur[i]
|
|
882
|
+
d.append(tag)
|
|
883
|
+
d.style.transform = `translate(${vw * (0.3 + i * 0.2)}px, ${vh + 10}px)`
|
|
884
|
+
return { d, free: 0 }
|
|
885
|
+
})
|
|
886
|
+
const move = (cur, x, y, ms) => {
|
|
887
|
+
x = Math.min(x, vw - 4)
|
|
888
|
+
cur.d.classList.toggle('flip', x > vw - 84 * K)
|
|
889
|
+
cur.d.classList.toggle('up', y > vh - 40 * K && y <= vh)
|
|
890
|
+
cur.d.style.transition = `transform ${ms}ms cubic-bezier(.3,.7,.2,1), opacity .25s ease`
|
|
891
|
+
cur.d.style.transform = `translate(${x}px, ${y}px)`
|
|
892
|
+
}
|
|
893
|
+
at(16, () => curs.forEach((c) => c.d.classList.add('on')))
|
|
894
|
+
const MOVE = 260
|
|
895
|
+
const DRAG = 330
|
|
896
|
+
let end = 0
|
|
897
|
+
const boxes = blocks.map((b, i) => {
|
|
898
|
+
const cur = curs[i % curs.length]
|
|
899
|
+
const t0 = Math.max(80 + i * 170, cur.free)
|
|
900
|
+
cur.free = t0 + MOVE + DRAG + 60
|
|
901
|
+
end = Math.max(end, cur.free)
|
|
902
|
+
const box = add(document.createElement('div'))
|
|
903
|
+
box.className = 'bbox'
|
|
904
|
+
box.style.setProperty('--c', BUILD_C[i % BUILD_C.length])
|
|
905
|
+
box.style.setProperty('--d', DRAG + 'ms')
|
|
906
|
+
box.style.left = b.x + 'px'
|
|
907
|
+
box.style.top = b.y + 'px'
|
|
908
|
+
box.style.opacity = '0'
|
|
909
|
+
const label = document.createElement('b')
|
|
910
|
+
label.textContent = blockName(b.el, b.r, N)
|
|
911
|
+
box.append(label)
|
|
912
|
+
at(t0, () => move(cur, b.x, b.y, MOVE))
|
|
913
|
+
at(t0 + MOVE, () => {
|
|
914
|
+
box.style.opacity = '1'
|
|
915
|
+
box.style.width = b.w + 'px'
|
|
916
|
+
box.style.height = b.h + 'px'
|
|
917
|
+
move(cur, b.x + b.w, b.y + b.h, DRAG)
|
|
918
|
+
})
|
|
919
|
+
at(t0 + MOVE + DRAG, () => box.classList.add('fill'))
|
|
920
|
+
return { box, b }
|
|
921
|
+
})
|
|
922
|
+
// 框完了:停一下,再由上往下显出整屏(元素按从上到下浮现、扫描光带一起走),框跟着光带淡掉,鼠标散开
|
|
923
|
+
const SHOW = end + 220
|
|
924
|
+
const DUR = 950
|
|
925
|
+
at(SHOW, () => {
|
|
926
|
+
mask.style.setProperty('--dur', DUR + 'ms')
|
|
927
|
+
mask.classList.add('go')
|
|
928
|
+
for (const { box, b } of boxes) setTimeout(() => { box.style.opacity = '0' }, Math.round((Math.max(0, b.y) / vh) * DUR * 0.7))
|
|
929
|
+
curs.forEach((c, i) => { move(c, vw * (0.25 + i * 0.25), vh + 30, 520); setTimeout(() => c.d.classList.remove('on'), 260) })
|
|
930
|
+
buildIn()
|
|
931
|
+
})
|
|
932
|
+
const done = () => stopBuild()
|
|
933
|
+
at(SHOW + DUR + 380, done)
|
|
934
|
+
// 用户动了(点、滚、按键):直接跳过,别挡着
|
|
935
|
+
const skip = () => { if (building && !building.shown) done() }
|
|
936
|
+
addEventListener('pointerdown', skip, true)
|
|
937
|
+
addEventListener('wheel', skip, { capture: true, passive: true })
|
|
938
|
+
addEventListener('keydown', skip, true)
|
|
939
|
+
building = { nodes, timers, skip, shown: false }
|
|
940
|
+
at(SHOW, () => { if (building) building.shown = true })
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
function stopBuild() {
|
|
944
|
+
if (!building) return
|
|
945
|
+
for (const t of building.timers) clearTimeout(t)
|
|
946
|
+
for (const n of building.nodes) n.remove()
|
|
947
|
+
removeEventListener('pointerdown', building.skip, true)
|
|
948
|
+
removeEventListener('wheel', building.skip, { capture: true })
|
|
949
|
+
removeEventListener('keydown', building.skip, true)
|
|
950
|
+
building = null
|
|
951
|
+
}
|
|
952
|
+
|
|
727
953
|
// 选中后留一个虚线框,直到下次选择 / 页面刷新
|
|
728
954
|
let pinned = null
|
|
729
955
|
|
|
@@ -1343,7 +1569,7 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
|
|
|
1343
1569
|
shown = true
|
|
1344
1570
|
const c = pending
|
|
1345
1571
|
pending = null
|
|
1346
|
-
if (opts && opts.build)
|
|
1572
|
+
if (opts && opts.build) build(opts.lang === 'en' ? 'en' : 'zh')
|
|
1347
1573
|
else if (c && c.els.length) setTimeout(() => play(c.els), 60)
|
|
1348
1574
|
watchHmr()
|
|
1349
1575
|
}
|