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/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 r = tracker.editDir(String(body.session || ''), (root) => applyTweaks(root, values), (x) => `调节:${x.changed.map((c) => `${c.label} ${c.to}`).join('、')}`.slice(0, 60), String(body.root || ''))
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) buildIn()
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
  }