dsh-design-studio 0.0.0-stage → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js ADDED
@@ -0,0 +1,2843 @@
1
+ /**
2
+ * dsh-design-studio 的客户端部分
3
+ *
4
+ * 右侧栏标签页「设计工作台」(sidebarRightTabs + sidebar.right.pane.tab)
5
+ * - 工具栏:地址(点一下能改)、选择、画布 / 单页、类型(网页 / App / 平板 / 海报 / 幻灯片 / 邮件)、刷新、在浏览器打开
6
+ * - 画布:同一个目录里的几屏按生成顺序排成网格,套上对应的外框(手机、浏览器窗口 …),能拖、能缩放;
7
+ * 模型还在出新的一屏时,末尾留一个「生成中…」的占位
8
+ * - 单页:只看一屏;网页铺满,别的类型按比例缩进来
9
+ * - 页面跑在预览服务器的独立端口里(iframe),和 DSH 不同源
10
+ * - 模型在干活时舞台边上一圈流光;刚生成的一屏播生成动画(元素从上到下浮现)
11
+ * - 页面改了:后台载好新的一帧再淡入换上来(不白屏),改过的地方在页面里闪一下,角上报「已更新 · N 处改动」
12
+ * - 选择:通知页面里的 picker.js 进入选取,选好的元素 / 区域整理成一段文字塞进输入框
13
+ * - 设计规范面板:页面 :root 上的设计变量(颜色、字体、字号、间距、圆角、阴影),点一个放进输入框让模型改
14
+ * 会话标题栏右边一个小按钮(conversation.session.header.utilities):
15
+ * - 点了打开预览
16
+ * - 一直挂着,顺便盯着服务端:模型刚开了新页面(openRev 变大)就把右侧栏弹出来
17
+ *
18
+ * 状态全在服务端(/dsh-design-studio/api/state),这里轮询。
19
+ */
20
+ window.__ModuleLoader__.load({
21
+ id: 'dsh-design-studio',
22
+ factory: (require) => {
23
+ var module = { exports: {} }
24
+ var exports = module.exports
25
+
26
+ let react = null
27
+ try { react = require('react') } catch (_) { react = null }
28
+ const INJECT = ['slots', 'locale', 'sidebarRight', 'sidebarRightTabs']
29
+ if (!react) {
30
+ exports.apply = () => {}
31
+ exports.inject = INJECT
32
+ return module.exports
33
+ }
34
+ const h = react.createElement
35
+ const { useState, useEffect, useRef, useCallback } = react
36
+
37
+ const NS = 'dsh-design-studio'
38
+ const TAB_ID = 'dsh-design-studio'
39
+ const KIND = 'design-studio'
40
+ const API = '/dsh-design-studio/api/'
41
+
42
+ const ZH = {
43
+ title: '设计工作台',
44
+ guideDesc: '实时看页面,点选要改的地方',
45
+ empty: '还没有要预览的页面',
46
+ emptyHint: '也可以直接输入 HTML 文件路径或端口:',
47
+ hintOpen: '预览刚写的 {name}',
48
+ headerHint: '刚写了 {name},点开能预览',
49
+ placeholder: '/路径/index.html 或 5173',
50
+ go: '打开',
51
+ pick: '选择',
52
+ pickTip: '点选元素或框选区域,发给模型(Esc 取消)',
53
+ pickAny: '点选任意一屏里的元素,或拖出一块区域 · Esc 取消',
54
+ pickOne: '点选一个元素,或拖出一块区域 · Esc 取消',
55
+ textAny: '点任意一屏里的文字直接改 · 回车保存 · Esc 退出',
56
+ refPick: '照参考图改风格…',
57
+ refChoose: '选一张',
58
+ refPickTip: '选一张参考图,照它的风格改(也可以把图片拖进预览区,或在这里粘贴)',
59
+ refEmpty: '有参考图?拖进来或粘贴,照它的风格做',
60
+ refDrop: '松开,作为参考图',
61
+ refDropSub: '存进设计目录的 refs/,提取配色放进输入框',
62
+ refNotImage: '只认图片(PNG / JPG / WebP / GIF)',
63
+ refFail: '参考图没存上:{msg}',
64
+ refText: '〔参考图〕{path}({w}×{h},{tone})\n主要颜色:{colors}\n{ask}\n',
65
+ refLight: '整体偏浅',
66
+ refDark: '整体偏深',
67
+ refAccent: '(点缀)',
68
+ refJoin: '、',
69
+ refAsk: '照这张参考图的风格改现在的设计:先把配色、圆角、阴影和字体气质更新到 tokens.css,再让各屏跟着变;布局和文案先不动。',
70
+ refAskNew: '照这张参考图的风格来做:先把配色、圆角、阴影和字体气质定在 tokens.css 里,再出页面。',
71
+ textOne: '点页面里的文字直接改 · 回车保存 · Esc 退出',
72
+ picking: '选择中…',
73
+ canvas: '画布:几屏并排看',
74
+ single: '单页:只看一屏',
75
+ sizes: '多尺寸:同一屏在手机 / 平板 / 电脑上并排看',
76
+ sizesNo: '海报、幻灯片、邮件是固定画幅,没有多尺寸',
77
+ szPhone: '手机',
78
+ szTablet: '平板',
79
+ szDesktop: '电脑',
80
+ szSmall: '小屏',
81
+ szStd: '标准',
82
+ szLarge: '大屏',
83
+ kind: '类型',
84
+ kindWeb: '网页',
85
+ kindApp: 'App',
86
+ kindTablet: '平板',
87
+ kindPoster: '海报',
88
+ kindSlides: '幻灯片',
89
+ kindEmail: '邮件',
90
+ tokens: '设计规范',
91
+ tokensTip: '点一个放进输入框,补一句怎么改;改它所有屏一起变',
92
+ tokensEmpty: '这套设计还没有设计变量(:root 上的 CSS 变量),颜色字号散在各处,改起来不统一。',
93
+ tokensAsk: '让模型建一份',
94
+ tokensAskText: '按 ui-design 技能给这套设计建一份 tokens.css(颜色、字体、字号、间距、圆角、阴影),所有屏都改成用这些变量。',
95
+ gColor: '颜色',
96
+ gFont: '字体',
97
+ gSize: '字号',
98
+ gSpace: '间距',
99
+ gRadius: '圆角',
100
+ gShadow: '阴影',
101
+ gOther: '其他',
102
+ exportT: '导出图片',
103
+ exFrame: '这一屏 · 带外框',
104
+ exRaw: '这一屏 · 纯画面',
105
+ exLong: '这一屏 · 整页长图',
106
+ exShow: '整套展示图',
107
+ exWhere: '存到设计目录的 exports/,同时复制到剪贴板',
108
+ exporting: '正在导出…',
109
+ exported: '已导出 {name}',
110
+ exportedCopied: '已导出 {name},也复制到剪贴板了',
111
+ exportFail: '导出失败:{msg}',
112
+ reveal: '打开文件夹',
113
+ madeWith: '用 DSH 设计工作台做的',
114
+ showSub: '{n} 屏 · {kind} · {date}',
115
+ history: '版本',
116
+ histTip: '每轮对话前后各存一版。点一版看当时的样子,还能和现在左右对比',
117
+ histEmpty: '还没有版本。模型每轮改完会自动存一版。',
118
+ now: '现在',
119
+ vN: '第 {n} 版',
120
+ vBefore: '这轮开始前',
121
+ vSaved: '回退前的样子',
122
+ vChanged: '改了 {files}',
123
+ vMore: '{first} 等 {n} 个文件',
124
+ vFirst: '最早的一版 · {n} 个文件',
125
+ sep: '、',
126
+ viewing: '第 {n} 版 · {time}',
127
+ compare: '对比',
128
+ restore: '回到这一版',
129
+ backNow: '回到现在',
130
+ restored: '已回到第 {n} 版(之前的样子也存了一版)',
131
+ notInVer: '第 {n} 版里还没有这一屏',
132
+ cmpNow: '现在',
133
+ dark: '深色模式',
134
+ variants: '再出两版',
135
+ variantText: '给「{name}」再出两版不同方向的设计(布局或视觉风格明显不同),存成同目录下的 {b}-b.html 和 {b}-c.html,会紧挨着原版排在画布上。原版和 tokens.css 别动;新版要换配色就在自己文件里覆盖变量。\n{where}\n',
136
+ exThis: '导出这一屏',
137
+ demo: '演示:点页面里的按钮跳到别的屏',
138
+ demoOn: '演示中 · 点按钮跳转',
139
+ demoBack: '返回上一屏',
140
+ demoRestart: '从头开始',
141
+ demoExit: '退出演示(Esc)',
142
+ textEdit: '改字:点页面里的文字直接改,回车保存(Esc 退出)',
143
+ textSaved: '已改好,写回 {file}',
144
+ textAsk: '把页面上的「{before}」改成「{after}」(只改这一处文字,别的不动)。\n{where}\n',
145
+ demoNone: '这一屏还没有跳到别的屏的按钮;可以让模型用 <a href="detail.html"> 把几屏串起来',
146
+ phone: '在手机上看',
147
+ phoneStarting: '正在打开…',
148
+ phoneHow: '用手机相机扫一扫',
149
+ phoneWifi: '手机和电脑要连同一个 Wi-Fi',
150
+ phoneWait: '等手机打开…',
151
+ phoneSeen: '手机已打开',
152
+ phoneFollow: '电脑上点哪一屏,手机跟着跳;改了设计,手机也自动刷新。',
153
+ phoneCopy: '复制链接',
154
+ phoneCopied: '链接已复制,可以发到手机上打开',
155
+ phoneStop: '停止',
156
+ phoneAuto: '只有扫了这个码的手机能打开,30 分钟没人看自动关。',
157
+ phoneNoLan: '电脑没连上 Wi-Fi 或有线网,手机连不过来。',
158
+ phoneFail: '没打开:{msg}',
159
+ phoneHelp: '打不开?确认在同一个 Wi-Fi;手机开着代理 / VPN 的先关掉;电脑问「是否允许传入连接」时点允许。',
160
+ zoomIn: '放大',
161
+ zoomOut: '缩小',
162
+ fit: '适合画布',
163
+ openScreen: '双击只看这一屏',
164
+ generating: '生成中…',
165
+ waiting: '模型正在工作,页面写出来就显示在这里',
166
+ refresh: '刷新',
167
+ external: '在浏览器里打开',
168
+ more: '更多',
169
+ demoShort: '演示',
170
+ editAddr: '换一个地址',
171
+ working: '模型正在工作',
172
+ writing: '正在写 {file}',
173
+ born: '已生成 {name}',
174
+ updated: '已更新{file}',
175
+ updatedN: '已更新{file} · {n} 处改动',
176
+ updatedWhole: '已更新{file} · 整页重排',
177
+ step1: '让模型做一个网页、App 界面或海报',
178
+ step2: '点「选择」,点元素或拖出一块区域',
179
+ step3: '在输入框补一句要怎么改,发出去',
180
+ inserted: '已放进输入框,补一句要怎么改',
181
+ insertedSend: '已放进输入框,发出去就行',
182
+ copied: '已复制,粘贴到输入框里',
183
+ failed: '没放进去:{msg}',
184
+ openFailed: '打不开:{msg}',
185
+ headerTip: '设计工作台',
186
+ }
187
+ const EN = {
188
+ title: 'Design Studio',
189
+ guideDesc: 'Live page, pick what to change',
190
+ empty: 'Nothing to preview yet',
191
+ emptyHint: 'Or enter an HTML path or a port:',
192
+ hintOpen: 'Preview {name}',
193
+ headerHint: '{name} was just written. Click to preview it',
194
+ placeholder: '/path/index.html or 5173',
195
+ go: 'Open',
196
+ pick: 'Select',
197
+ pickTip: 'Click an element or drag a region to send it to the model (Esc to cancel)',
198
+ pickAny: 'Click an element in any screen, or drag a region · Esc to cancel',
199
+ pickOne: 'Click an element or drag a region · Esc to cancel',
200
+ textAny: 'Click text in any screen to edit · Enter to save · Esc to stop',
201
+ refPick: 'Restyle from a reference…',
202
+ refChoose: 'Choose one',
203
+ refPickTip: 'Pick a reference image to restyle after (or drop / paste an image into the preview)',
204
+ refEmpty: 'Have a reference image? Drop or paste it here to design in its style',
205
+ refDrop: 'Drop to use as a reference',
206
+ refDropSub: 'Saved to refs/ in the design folder; its colors go into the input',
207
+ refNotImage: 'Images only (PNG / JPG / WebP / GIF)',
208
+ refFail: 'Could not save the reference: {msg}',
209
+ refText: '[Reference image] {path} ({w}×{h}, {tone})\nMain colors: {colors}\n{ask}\n',
210
+ refLight: 'light overall',
211
+ refDark: 'dark overall',
212
+ refAccent: ' (accent)',
213
+ refJoin: ', ',
214
+ refAsk: 'Restyle the current design after this reference: update colors, radii, shadows and type feel in tokens.css first, then let every screen follow. Keep layout and copy for now.',
215
+ refAskNew: 'Design in the style of this reference: set colors, radii, shadows and type feel in tokens.css first, then build the pages.',
216
+ textOne: 'Click any text in the page to edit · Enter to save · Esc to stop',
217
+ picking: 'Selecting…',
218
+ canvas: 'Canvas: screens side by side',
219
+ single: 'Single: one screen',
220
+ sizes: 'Sizes: the same screen on phone, tablet and desktop side by side',
221
+ sizesNo: 'Posters, slides and emails have a fixed size',
222
+ szPhone: 'Phone',
223
+ szTablet: 'Tablet',
224
+ szDesktop: 'Desktop',
225
+ szSmall: 'Small',
226
+ szStd: 'Standard',
227
+ szLarge: 'Large',
228
+ kind: 'Type',
229
+ kindWeb: 'Web',
230
+ kindApp: 'App',
231
+ kindTablet: 'Tablet',
232
+ kindPoster: 'Poster',
233
+ kindSlides: 'Slides',
234
+ kindEmail: 'Email',
235
+ tokens: 'Design tokens',
236
+ tokensTip: 'Click one to put it in the input, then say how to change it. All screens follow',
237
+ tokensEmpty: 'This design has no tokens yet (CSS variables on :root), so colors and sizes are scattered.',
238
+ tokensAsk: 'Ask the model for one',
239
+ tokensAskText: 'Following the ui-design skill, create a tokens.css for this design (colors, fonts, type sizes, spacing, radii, shadows) and make every screen use those variables.',
240
+ gColor: 'Colors',
241
+ gFont: 'Fonts',
242
+ gSize: 'Type sizes',
243
+ gSpace: 'Spacing',
244
+ gRadius: 'Radii',
245
+ gShadow: 'Shadows',
246
+ gOther: 'Other',
247
+ exportT: 'Export image',
248
+ exFrame: 'This screen · in frame',
249
+ exRaw: 'This screen · plain',
250
+ exLong: 'This screen · full page',
251
+ exShow: 'Showcase of all screens',
252
+ exWhere: 'Saved to exports/ in the design folder and copied to the clipboard',
253
+ exporting: 'Exporting…',
254
+ exported: 'Exported {name}',
255
+ exportedCopied: 'Exported {name} and copied it',
256
+ exportFail: 'Export failed: {msg}',
257
+ reveal: 'Show in folder',
258
+ madeWith: 'Made with DSH Design Studio',
259
+ showSub: '{n} screens · {kind} · {date}',
260
+ history: 'Versions',
261
+ histTip: 'A version is saved before and after every turn. Click one to see it, or compare it side by side',
262
+ histEmpty: 'No versions yet. One is saved after every turn.',
263
+ now: 'Now',
264
+ vN: 'Version {n}',
265
+ vBefore: 'Before this turn',
266
+ vSaved: 'Before restoring',
267
+ vChanged: 'Changed {files}',
268
+ vMore: '{first} and {rest} more',
269
+ vFirst: 'Earliest kept · {n} files',
270
+ sep: ', ',
271
+ viewing: 'Version {n} · {time}',
272
+ compare: 'Compare',
273
+ restore: 'Restore this',
274
+ backNow: 'Back to now',
275
+ restored: 'Restored version {n} (what you had is saved as a version too)',
276
+ notInVer: 'Version {n} has no such screen yet',
277
+ cmpNow: 'Now',
278
+ dark: 'Dark mode',
279
+ variants: 'Two more takes',
280
+ variantText: 'Make two alternative designs of "{name}" in clearly different directions (layout or visual style). Save them next to it as {b}-b.html and {b}-c.html; they will sit right after the original on the canvas. Leave the original and tokens.css unchanged; override variables inside the new files if they need other colors.\n{where}\n',
281
+ exThis: 'Export this screen',
282
+ demo: 'Play: tap buttons in the page to move between screens',
283
+ demoOn: 'Playing · tap to navigate',
284
+ demoBack: 'Back',
285
+ demoRestart: 'Start over',
286
+ demoExit: 'Stop playing (Esc)',
287
+ textEdit: 'Edit text: click any text in the page, Enter to save (Esc to stop)',
288
+ textSaved: 'Saved to {file}',
289
+ textAsk: 'Change the text "{before}" to "{after}" on the page (only this text, nothing else).\n{where}\n',
290
+ demoNone: 'This screen has no links to other screens yet. Ask the model to connect screens with <a href="detail.html">',
291
+ phone: 'View on phone',
292
+ phoneStarting: 'Starting…',
293
+ phoneHow: 'Scan with your phone camera',
294
+ phoneWifi: 'Phone and computer must be on the same Wi-Fi',
295
+ phoneWait: 'Waiting for your phone…',
296
+ phoneSeen: 'Open on your phone',
297
+ phoneFollow: 'Your phone follows the screen you pick here and refreshes when the design changes.',
298
+ phoneCopy: 'Copy link',
299
+ phoneCopied: 'Link copied. Send it to your phone',
300
+ phoneStop: 'Stop',
301
+ phoneAuto: 'Only a phone that scanned this code can open it. Closes after 30 minutes idle.',
302
+ phoneNoLan: 'This computer is not on Wi-Fi or a wired network, so a phone cannot reach it.',
303
+ phoneFail: 'Could not start: {msg}',
304
+ phoneHelp: "Won't open? Check both are on the same Wi-Fi, turn off any VPN or proxy on the phone, and allow incoming connections if your computer asks.",
305
+ zoomIn: 'Zoom in',
306
+ zoomOut: 'Zoom out',
307
+ fit: 'Fit',
308
+ openScreen: 'Double-click to view this screen alone',
309
+ generating: 'Generating…',
310
+ waiting: 'The model is working. The page shows up here once it is written',
311
+ refresh: 'Reload',
312
+ external: 'Open in browser',
313
+ more: 'More',
314
+ demoShort: 'Play',
315
+ editAddr: 'Change address',
316
+ working: 'The model is working',
317
+ writing: 'Writing {file}',
318
+ born: 'Generated {name}',
319
+ updated: 'Updated{file}',
320
+ updatedN: 'Updated{file} · {n} changes',
321
+ updatedWhole: 'Updated{file} · whole page',
322
+ step1: 'Ask the model for a web page, app screen or poster',
323
+ step2: 'Click Select, then an element or drag a region',
324
+ step3: 'Say what to change in the input and send',
325
+ inserted: 'Added to the input. Say what to change',
326
+ insertedSend: 'Added to the input. Just send it',
327
+ copied: 'Copied. Paste it into the input',
328
+ failed: 'Could not add it: {msg}',
329
+ openFailed: 'Could not open: {msg}',
330
+ headerTip: 'Design Studio',
331
+ }
332
+
333
+ function makeT(ctx) {
334
+ let bound = null
335
+ try { bound = ctx.locale && typeof ctx.locale.bind === 'function' ? ctx.locale.bind(NS) : null } catch { bound = null }
336
+ return (key, vars) => {
337
+ let s = ''
338
+ try { s = bound ? bound(key) : '' } catch { s = '' }
339
+ if (!s || s === key || s === `${NS}.${key}`) s = ZH[key] || key
340
+ if (vars) for (const [k, v] of Object.entries(vars)) s = s.split(`{${k}}`).join(String(v))
341
+ return s
342
+ }
343
+ }
344
+
345
+ // ── 样式(DSH 设计变量,深浅主题跟着走)──────────────────────────────────
346
+
347
+ const ACCENT = '#4d6bfe'
348
+ const EASE = 'cubic-bezier(.2,.8,.2,1)'
349
+ const CSS = `
350
+ .ddp{display:flex;flex-direction:column;height:100%;min-height:0;background:var(--dsw-alias-bg-base,var(--dsw-alias-bg-layer-1));color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-family,inherit);font-size:12px;container-type:inline-size}
351
+ .ddp-bar{position:relative;z-index:8;display:flex;align-items:center;gap:4px;height:40px;padding:0 8px;border-bottom:.5px solid var(--dsw-alias-border-l2,rgba(127,127,127,.18));flex:none}
352
+ .ddp-addr{flex:1;min-width:0;display:flex;align-items:center;gap:7px;height:26px;padding:0 10px;border-radius:999px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);cursor:text;border:.5px solid transparent;overflow:hidden}
353
+ .ddp-addr:hover{border-color:var(--dsw-alias-border-l3)}
354
+ .ddp-addr span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
355
+ .ddp-dot{width:6px;height:6px;border-radius:50%;background:#22c55e;flex:none;box-shadow:0 0 0 3px rgba(34,197,94,.15)}
356
+ .ddp-dot.off{background:var(--dsw-alias-label-tertiary);box-shadow:none}
357
+ .ddp-dot.wait{background:#f5a524;box-shadow:0 0 0 3px rgba(245,165,36,.15);animation:ddp-blink 1s ease-in-out infinite}
358
+ .ddp-input{flex:1;min-width:0;height:26px;padding:0 10px;border-radius:999px;border:.5px solid ${ACCENT};background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit;outline:none}
359
+ .ddp-btn{display:inline-flex;align-items:center;justify-content:center;gap:5px;height:28px;min-width:28px;padding:0 6px;border:0;border-radius:var(--dsw-radius-sm,6px);background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;cursor:pointer;flex:none;white-space:nowrap}
360
+ .ddp-btn:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12));color:var(--dsw-alias-label-primary)}
361
+ .ddp-btn:disabled{opacity:.4;pointer-events:none}
362
+ .ddp-btn svg{width:15px;height:15px;flex:none}
363
+ .ddp-btn.on{background:${ACCENT};color:#fff}
364
+ .ddp-btn.sel{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.14));color:var(--dsw-alias-label-primary)}
365
+ .ddp-btn.pick{padding:0 9px}
366
+ .ddp-btn .cr{width:10px;height:10px;margin-left:-2px;opacity:.6}
367
+ .ddp-seg{display:inline-flex;padding:2px;border-radius:7px;background:var(--dsw-alias-bg-layer-2);flex:none}
368
+ .ddp-seg .ddp-btn{height:24px;min-width:26px;border-radius:5px}
369
+ .ddp-seg .ddp-btn.sel{background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);box-shadow:0 1px 2px rgba(0,0,0,.12)}
370
+ .ddp-sep{width:.5px;height:16px;background:var(--dsw-alias-border-l3);margin:0 2px;flex:none}
371
+ .ddp-kind{position:relative;flex:none}
372
+ .ddp-kind>.ddp-btn.sel{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12));color:var(--dsw-alias-label-primary)}
373
+ .ddp-menu{position:absolute;right:0;top:33px;min-width:196px;padding:5px;border-radius:11px;background:var(--dsw-alias-bg-layer-1);
374
+ box-shadow:0 12px 32px rgba(0,0,0,.24),0 0 0 .5px var(--dsw-alias-border-l3);animation:ddp-in .16s ${EASE}}
375
+ .ddp-menu .hd{padding:5px 9px 4px;color:var(--dsw-alias-label-tertiary);font-size:11px}
376
+ .ddp-menu a{display:flex;align-items:center;gap:9px;height:30px;padding:0 9px;border-radius:7px;color:var(--dsw-alias-label-primary);text-decoration:none}
377
+ .ddp-menu a:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}
378
+ .ddp-menu a svg{width:15px;height:15px;flex:none;color:var(--dsw-alias-label-secondary)}
379
+ .ddp-menu button{display:flex;align-items:center;gap:9px;width:100%;height:30px;padding:0 9px;border:0;border-radius:7px;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;cursor:pointer;text-align:left}
380
+ .ddp-menu button:disabled{opacity:.4;cursor:default}
381
+ .ddp-menu button:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}
382
+ .ddp-menu button svg{width:15px;height:15px;flex:none;color:var(--dsw-alias-label-secondary)}
383
+ .ddp-menu button.sel,.ddp-menu button.sel svg{color:${ACCENT}}
384
+ .ddp-menu em{margin-left:auto;font-style:normal;color:var(--dsw-alias-label-tertiary);font-size:11px;font-variant-numeric:tabular-nums}
385
+ .ddp-stage{position:relative;flex:1;min-height:0;overflow:hidden;background:var(--dsw-alias-bg-layer-2)}
386
+ .ddp-stage.full{background:#fff}
387
+ .ddp-scr{position:absolute;inset:0;overflow:hidden}
388
+ .ddp-frame{position:absolute;left:0;top:0;width:100%;height:100%;border:0;background:#fff;opacity:0;z-index:0;pointer-events:none}
389
+ .ddp-frame.old{opacity:1;z-index:1}
390
+ .ddp-frame.cur{opacity:1;z-index:2;pointer-events:auto;transition:opacity .32s ease}
391
+ .ddp-dev{position:relative;background:#fff;box-shadow:0 0 0 .5px rgba(0,0,0,.14),0 18px 48px rgba(0,0,0,.2)}
392
+ .ddp-dev.fill{position:absolute;inset:0;box-shadow:none;border-radius:0}
393
+ .ddp-dev.k-app,.ddp-dev.k-tablet{background:#0e0e12;box-shadow:inset 0 0 0 1.5px #2b2b33,0 0 0 1px rgba(0,0,0,.45),0 24px 60px rgba(0,0,0,.3)}
394
+ .ddp-dev.fill.k-app,.ddp-dev.fill.k-tablet{background:#fff;box-shadow:none}
395
+ .ddp-vp{position:absolute;overflow:hidden;background:#fff;isolation:isolate}
396
+ .ddp-vp:has(.ddp-scr.dk),.ddp-scr.dk .ddp-frame{background:#111}
397
+ .ddp-chrome{position:absolute;left:0;right:0;top:0;align-items:center;gap:7px;padding:0 13px;background:#ebebef;border-bottom:1px solid rgba(0,0,0,.07);border-radius:inherit;border-bottom-left-radius:0;border-bottom-right-radius:0;overflow:hidden}
398
+ .ddp-chrome i{width:11px;height:11px;border-radius:50%;background:#ff5f57;flex:none}
399
+ .ddp-chrome i:nth-child(2){background:#febc2e}
400
+ .ddp-chrome i:nth-child(3){background:#28c840}
401
+ .ddp-chrome span{flex:1;min-width:0;margin:0 60px 0 18px;height:20px;border-radius:6px;background:rgba(0,0,0,.05);color:#6b6b74;font:12px/20px -apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif;text-align:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
402
+ .ddp-island{position:absolute;left:50%;width:118px;height:34px;margin-left:-59px;border-radius:20px;background:#000;z-index:4;pointer-events:none}
403
+ .ddp-body{position:absolute;inset:0}
404
+ .ddp-body.vb{top:46px}
405
+ .ddp-single{position:absolute;inset:0;overflow:hidden}
406
+ .ddp-fit{position:absolute;left:50%;top:50%;transform-origin:center center}
407
+ .ddp-fit.fill{left:0;top:0;width:100%;height:100%;transform:none}
408
+ .ddp-canvas{position:absolute;inset:0;overflow:hidden;cursor:grab;touch-action:none;user-select:none;-webkit-user-select:none;
409
+ background-color:var(--dsw-alias-bg-layer-2);background-image:radial-gradient(circle,rgba(127,127,127,.3) 1px,transparent 1.4px)}
410
+ .ddp-canvas.drag{cursor:grabbing}
411
+ .ddp-world{position:absolute;left:0;top:0;transform-origin:0 0;will-change:transform}
412
+ .ddp-world.anim{transition:transform .45s ${EASE}}
413
+ .ddp-cell{position:absolute;transition:left .45s ${EASE},top .45s ${EASE};animation:ddp-cell .5s ${EASE}}
414
+ @keyframes ddp-cell{from{opacity:0;scale:.94}}
415
+ .ddp-cell.sel>.ddp-dev{outline:calc(2px / var(--z)) solid ${ACCENT};outline-offset:calc(5px / var(--z))}
416
+ .ddp-shield{position:absolute;inset:0;z-index:6;cursor:default}
417
+ .ddp-lab{position:absolute;left:0;bottom:100%;height:28px;display:flex;align-items:center;gap:6px;padding-bottom:6px;transform-origin:0 100%;
418
+ color:var(--dsw-alias-label-secondary);font-size:12px;line-height:16px;white-space:nowrap;overflow:hidden;cursor:default}
419
+ .ddp-lab span{overflow:hidden;text-overflow:ellipsis}
420
+ .ddp-cell.sel .ddp-lab{color:${ACCENT};font-weight:500}
421
+ .ddp-lab .sp{width:10px;height:10px;flex:none;border-radius:50%;border:1.5px solid rgba(77,107,254,.25);border-top-color:${ACCENT};animation:ddp-rot .75s linear infinite}
422
+ .ddp-cell.ghost>.ddp-dev{animation:ddp-ghost 1.6s ease-in-out infinite}
423
+ .ddp-cell.ghost .ddp-vp::after{content:"";position:absolute;inset:0;z-index:3;border:calc(2px / var(--z)) dashed rgba(77,107,254,.55);border-radius:inherit}
424
+ .ddp-cell.ghost .ddp-lab{color:${ACCENT}}
425
+ @keyframes ddp-ghost{50%{opacity:.62}}
426
+ .ddp-zoom{position:absolute;right:12px;bottom:12px;z-index:6;display:flex;align-items:center;gap:1px;padding:3px;border-radius:9px;background:var(--dsw-alias-bg-layer-1);
427
+ box-shadow:0 4px 14px rgba(0,0,0,.14),0 0 0 .5px var(--dsw-alias-border-l3)}
428
+ .ddp-zoom .ddp-btn{height:24px;min-width:24px;padding:0 4px}
429
+ .ddp-zoom .ddp-btn svg{width:14px;height:14px}
430
+ .ddp-zoom b{min-width:36px;text-align:center;font-weight:400;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-secondary);font-size:11px}
431
+ .ddp-hint{position:absolute;left:50%;top:12px;transform:translateX(-50%);z-index:7;max-width:calc(100% - 24px);padding:6px 13px;border-radius:999px;
432
+ background:rgba(18,18,24,.84);color:#ececf0;font-size:12px;line-height:18px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
433
+ box-shadow:0 6px 18px rgba(0,0,0,.22);backdrop-filter:blur(8px);pointer-events:none;animation:ddp-in .2s ease}
434
+ @property --ddp-a{syntax:'<angle>';initial-value:0deg;inherits:false}
435
+ .ddp-ring{position:absolute;inset:0;z-index:4;pointer-events:none;padding:2.5px;
436
+ background:conic-gradient(from var(--ddp-a),transparent 0deg 140deg,${ACCENT} 240deg,#a78bfa 300deg,#5eead4 340deg,transparent 360deg);
437
+ -webkit-mask:linear-gradient(#000 0 0) content-box,linear-gradient(#000 0 0);-webkit-mask-composite:xor;mask-composite:exclude;
438
+ animation:ddp-spin 2.8s linear infinite,ddp-fade .4s ease}
439
+ .ddp-glow{position:absolute;inset:0;z-index:4;pointer-events:none;box-shadow:inset 0 0 0 1px rgba(77,107,254,.32),inset 0 0 48px rgba(77,107,254,.22);animation:ddp-breathe 2.6s ease-in-out infinite,ddp-fade .4s ease}
440
+ @keyframes ddp-spin{to{--ddp-a:360deg}}
441
+ @keyframes ddp-breathe{50%{opacity:.45}}
442
+ @keyframes ddp-fade{from{opacity:0}}
443
+ .ddp-chip{position:absolute;left:50%;bottom:14px;transform:translateX(-50%);z-index:5;display:flex;align-items:center;gap:8px;max-width:calc(100% - 24px);padding:6px 13px 6px 10px;border-radius:999px;
444
+ background:rgba(18,18,24,.8);color:#f1f1f4;font-size:12px;line-height:16px;backdrop-filter:blur(10px) saturate(1.4);
445
+ box-shadow:0 8px 24px rgba(0,0,0,.2),inset 0 0 0 .5px rgba(255,255,255,.14);pointer-events:none;animation:ddp-in .28s ${EASE}}
446
+ .ddp-chip.left{left:12px;transform:none;max-width:calc(100% - 150px)}
447
+ .ddp-chip>span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
448
+ .ddp-chip .sp{width:12px;height:12px;flex:none;border-radius:50%;border:1.6px solid rgba(255,255,255,.22);border-top-color:#9db0ff;animation:ddp-rot .75s linear infinite}
449
+ .ddp-chip .tx{background:linear-gradient(90deg,#f1f1f4 0 38%,#a9b8ff 50%,#f1f1f4 62% 100%);background-size:260% 100%;-webkit-background-clip:text;background-clip:text;color:transparent;animation:ddp-txt 2.2s linear infinite}
450
+ .ddp-chip.ok svg{width:14px;height:14px;flex:none;color:#4ade80}
451
+ @keyframes ddp-rot{to{transform:rotate(360deg)}}
452
+ @keyframes ddp-txt{from{background-position:100% 0}to{background-position:-160% 0}}
453
+ @keyframes ddp-blink{50%{opacity:.25}}
454
+ @keyframes ddp-in{from{opacity:0;translate:0 6px;scale:.98}}
455
+ .ddp-skel{position:absolute;inset:0;z-index:2;padding:28px 24px;display:flex;flex-direction:column;gap:14px;background:var(--dsw-alias-bg-layer-1);transition:opacity .3s ease;pointer-events:none}
456
+ .ddp-skel.gone{opacity:0}
457
+ .ddp-skel b{display:block;border-radius:8px;background:var(--dsw-alias-bg-layer-2);position:relative;overflow:hidden}
458
+ .ddp-skel b::after{content:"";position:absolute;inset:0;transform:translateX(-100%);background:linear-gradient(90deg,transparent,rgba(127,127,127,.14),transparent);animation:ddp-shine 1.3s ease-in-out infinite}
459
+ @keyframes ddp-shine{100%{transform:translateX(100%)}}
460
+ .ddp-wait{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px;padding:24px}
461
+ .ddp-wait .card{position:relative;width:min(78%,440px);aspect-ratio:16/11;border-radius:12px;overflow:hidden;box-shadow:0 0 0 .5px var(--dsw-alias-border-l3),0 14px 40px rgba(0,0,0,.14);animation:ddp-ghost 1.6s ease-in-out infinite}
462
+ .ddp-wait p{margin:0;color:var(--dsw-alias-label-tertiary);text-align:center;line-height:1.6}
463
+ .ddp-toast{position:absolute;left:50%;top:14px;transform:translateX(-50%);z-index:7;padding:7px 14px;border-radius:999px;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-1);font-size:12px;white-space:nowrap;box-shadow:0 6px 20px rgba(0,0,0,.2);animation:ddp-in .2s ease}
464
+ .ddp-empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;padding:32px 24px;text-align:center;color:var(--dsw-alias-label-tertiary)}
465
+ .ddp-empty b{color:var(--dsw-alias-label-primary);font-size:14px;font-weight:500}
466
+ .ddp-empty p{margin:6px 0 0;max-width:300px;line-height:1.6}
467
+ .ddp-steps{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:7px;text-align:left;counter-reset:s}
468
+ .ddp-steps li{display:flex;align-items:center;gap:9px;color:var(--dsw-alias-label-secondary);line-height:18px}
469
+ .ddp-steps li::before{counter-increment:s;content:counter(s);flex:none;width:18px;height:18px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;font-size:10.5px;font-weight:600;color:${ACCENT};background:rgba(77,107,254,.12)}
470
+ .ddp-art{width:176px;height:120px;overflow:visible;margin-bottom:2px}
471
+ .ddp-art .pg{fill:var(--dsw-alias-bg-layer-1);stroke:var(--dsw-alias-border-l3);stroke-width:1}
472
+ .ddp-art .ln{stroke:var(--dsw-alias-border-l3);stroke-width:1}
473
+ .ddp-art .dt{fill:var(--dsw-alias-border-l3)}
474
+ .ddp-art .sk{fill:var(--dsw-alias-bg-layer-2)}
475
+ .ddp-art .bt{fill:${ACCENT};opacity:.9}
476
+ .ddp-art .sel{fill:rgba(77,107,254,.08);stroke:${ACCENT};stroke-width:1.3;stroke-dasharray:4 3;animation:ddp-ants .9s linear infinite,ddp-sel 3.6s ease-in-out infinite}
477
+ .ddp-art .cur{fill:var(--dsw-alias-label-primary);stroke:var(--dsw-alias-bg-layer-1);stroke-width:1;animation:ddp-cur 3.6s ease-in-out infinite}
478
+ @keyframes ddp-ants{to{stroke-dashoffset:-14}}
479
+ @keyframes ddp-sel{0%,30%{opacity:0}42%,88%{opacity:1}100%{opacity:0}}
480
+ @keyframes ddp-cur{0%,100%{transform:translate(132px,98px)}36%,90%{transform:translate(58px,76px)}}
481
+ .ddp-empty form{display:flex;gap:6px;width:100%;max-width:320px;margin-top:4px}
482
+ .ddp-go{height:26px;padding:0 12px;border:0;border-radius:999px;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-1);font:inherit;cursor:pointer}
483
+ .ddp-err{color:var(--dsw-alias-state-error-primary,#e5484d);font-size:11.5px}
484
+ .ddp-head{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border:0;border-radius:var(--dsw-radius-md,8px);background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;position:relative}
485
+ .ddp-head:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12));color:var(--dsw-alias-label-primary)}
486
+ .ddp-head svg{width:16px;height:16px}
487
+ .ddp-head.hint::after{content:"";position:absolute;right:5px;top:5px;width:5px;height:5px;border-radius:50%;background:var(--dsw-alias-accent-primary,#4f6ef7)}
488
+ .ddp-head.live::after{content:"";position:absolute;right:5px;top:5px;width:5px;height:5px;border-radius:50%;background:#22c55e}
489
+ .ddp-tokens{position:absolute;top:10px;right:10px;bottom:10px;width:min(276px,calc(100% - 20px));z-index:7;display:flex;flex-direction:column;border-radius:12px;
490
+ background:var(--dsw-alias-bg-layer-1);box-shadow:0 12px 36px rgba(0,0,0,.22),0 0 0 .5px var(--dsw-alias-border-l3);overflow:hidden;animation:ddp-in .2s ${EASE}}
491
+ .ddp-tokens header{display:flex;align-items:center;gap:8px;padding:10px 8px 2px 14px;font-size:13px;font-weight:500}
492
+ .ddp-tokens header b{flex:1;font-weight:500}
493
+ .ddp-tokens header>svg{width:15px;height:15px;flex:none;color:var(--dsw-alias-label-secondary)}
494
+ .ddp-tokens header .ddp-btn{height:24px;min-width:24px}
495
+ .ddp-tokens .tip{margin:0;padding:0 14px 8px;color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px}
496
+ .ddp-tokens .bd{flex:1;min-height:0;overflow:auto;padding:0 8px 10px}
497
+ .ddp-tokens h4{margin:10px 6px 4px;font-size:11px;font-weight:500;color:var(--dsw-alias-label-tertiary)}
498
+ .ddp-phone{bottom:auto;max-height:calc(100% - 20px)}
499
+ .ddp-phone .bd{display:flex;flex-direction:column;align-items:center;text-align:center;padding:4px 16px 14px;gap:6px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary)}
500
+ .ddp-phone .bd>*{flex:none}
501
+ .ddp-phone .qr{width:176px;height:176px;margin:6px 0 4px;padding:0;border-radius:12px;background:#fff;box-shadow:0 0 0 .5px rgba(0,0,0,.12)}
502
+ .ddp-phone .qr svg{display:block;width:100%;height:100%}
503
+ .ddp-phone b.how{font-size:14px;font-weight:500;color:var(--dsw-alias-label-primary)}
504
+ .ddp-phone p{margin:0}
505
+ .ddp-phone .st{display:inline-flex;align-items:center;gap:6px;margin:4px 0;padding:3px 10px;border-radius:999px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary)}
506
+ .ddp-phone .st.ok{background:rgba(52,199,89,.14);color:#2f9e55}
507
+ .ddp-phone .st svg{width:13px;height:13px}
508
+ .ddp-phone .st i{width:7px;height:7px;border-radius:50%;background:currentColor;opacity:.55;animation:ddp-pulse 1.2s ease-in-out infinite}
509
+ .ddp-phone .fine{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px}
510
+ .ddp-phone .row{display:flex;gap:6px;margin-top:6px}
511
+ .ddp-phone .row .ddp-go{height:28px;padding:0 12px;font-size:12px}
512
+ @keyframes ddp-pulse{50%{opacity:1}}
513
+ .ddp-tokens .none{padding:6px 6px 0;color:var(--dsw-alias-label-secondary);line-height:1.6}
514
+ .ddp-tokens .none .ddp-go{margin-top:10px}
515
+ .ddp-tokens footer{padding:8px 10px 10px;border-top:1px solid var(--dsw-alias-separator-primary,rgba(127,127,127,.16))}
516
+ .ddp-tokens footer button{display:flex;align-items:center;gap:7px;width:100%;height:30px;padding:0 10px;border:0;border-radius:8px;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;cursor:pointer;text-align:left}
517
+ .ddp-tokens footer button:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12));color:var(--dsw-alias-label-primary)}
518
+ .ddp-tokens footer svg{width:15px;height:15px;flex:none}
519
+ .ddp{position:relative;outline:none}
520
+ .ddp-drop{position:absolute;inset:0;z-index:30;display:flex;align-items:center;justify-content:center;padding:16px;background:color-mix(in srgb,var(--dsw-alias-bg-layer-1) 72%,transparent);backdrop-filter:blur(3px);-webkit-backdrop-filter:blur(3px);animation:ddp-in .15s ease}
521
+ .ddp-drop *{pointer-events:none}
522
+ .ddp-drop .in{display:flex;flex-direction:column;align-items:center;gap:6px;width:100%;max-width:340px;padding:28px 20px;border:1.5px dashed ${ACCENT};border-radius:16px;background:var(--dsw-alias-bg-layer-1);box-shadow:0 10px 30px rgba(0,0,0,.18);text-align:center;color:var(--dsw-alias-label-tertiary);line-height:1.5}
523
+ .ddp-drop .in svg{width:28px;height:28px;color:${ACCENT};margin-bottom:4px}
524
+ .ddp-drop .in b{color:var(--dsw-alias-label-primary);font-size:14px;font-weight:500}
525
+ .ddp-empty .ddp-refline{margin-top:0}
526
+ .ddp-empty .lk{border:0;padding:0;background:transparent;color:${ACCENT};font:inherit;cursor:pointer}
527
+ .ddp-empty .lk:hover{text-decoration:underline;text-underline-offset:2px}
528
+ .ddp-tok{display:flex;align-items:center;gap:9px;width:100%;min-height:32px;padding:4px 6px;border:0;border-radius:7px;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;text-align:left;cursor:pointer}
529
+ .ddp-tok:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}
530
+ .ddp-tok .pv{width:24px;height:24px;flex:none;display:flex;align-items:center;justify-content:center;overflow:hidden}
531
+ .ddp-tok .sw{width:24px;height:24px;border-radius:7px;box-shadow:inset 0 0 0 .5px rgba(127,127,127,.35)}
532
+ .ddp-tok .rd{width:20px;height:20px;border:1.5px solid var(--dsw-alias-label-secondary);border-right:0;border-bottom:0}
533
+ .ddp-tok .bar{height:8px;border-radius:2px;background:${ACCENT};opacity:.75}
534
+ .ddp-tok .pv.lt{border-radius:7px;background:#ececef}
535
+ .ddp-tok .sh{width:14px;height:11px;border-radius:3px;background:#fff}
536
+ .ddp-tok .tx{flex:1;min-width:0;display:flex;flex-direction:column}
537
+ .ddp-tok .nm,.ddp-tok .vl{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
538
+ .ddp-tok .nm{font:11px/16px ui-monospace,Menlo,monospace}
539
+ .ddp-tok .vl{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:15px}
540
+ .ddp-btn.has::after{content:"";position:absolute;right:4px;top:4px;width:5px;height:5px;border-radius:50%;background:${ACCENT}}
541
+ .ddp-btn{position:relative}
542
+ .ddp-menu .ft{margin:4px 4px 2px;padding:6px 6px 2px;border-top:.5px solid var(--dsw-alias-border-l3);color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:15px;max-width:220px;white-space:normal}
543
+ .ddp-ver{display:flex;flex-direction:column;align-items:stretch;gap:2px;width:100%;padding:8px 10px;margin-bottom:2px;border:0;border-radius:8px;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;text-align:left;cursor:pointer}
544
+ .ddp-ver:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}
545
+ .ddp-ver.sel{background:rgba(77,107,254,.12);box-shadow:inset 0 0 0 1px rgba(77,107,254,.35)}
546
+ .ddp-ver b{display:flex;align-items:center;gap:6px;font-weight:500;font-size:12.5px}
547
+ .ddp-ver b em{margin-left:auto;font-style:normal;font-weight:400;color:var(--dsw-alias-label-tertiary);font-size:11px;font-variant-numeric:tabular-nums}
548
+ .ddp-ver b .live{width:7px;height:7px;border-radius:50%;background:#2fbf71;box-shadow:0 0 0 3px rgba(47,191,113,.16)}
549
+ .ddp-ver span{font-size:12px;color:var(--dsw-alias-label-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
550
+ .ddp-ver small{font-size:11px;color:var(--dsw-alias-label-tertiary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
551
+ .ddp-flows{position:absolute;left:0;top:0;overflow:visible;pointer-events:none;z-index:20}
552
+ .ddp-flows path{fill:none;stroke:${ACCENT};stroke-linecap:round;transition:opacity .2s}
553
+ .ddp-flows .hd{fill:${ACCENT};stroke:none}
554
+ .ddp-flows circle{fill:${ACCENT};stroke:#fff}
555
+ .ddp-flows g.dim{opacity:.45}
556
+ .ddp-demo{position:absolute;inset:0;overflow:hidden}
557
+ .ddp-demo .lay{position:absolute;inset:0;transition:transform .42s ${EASE},opacity .42s ${EASE}}
558
+ .ddp-demo .lay.wait{opacity:0;pointer-events:none}
559
+ .ddp-demo .lay.wait.f{transform:translateX(18%)}
560
+ .ddp-demo .lay.wait.b{transform:translateX(-18%)}
561
+ .ddp-demo .lay.out{opacity:0;pointer-events:none}
562
+ .ddp-demo .lay.out.f{transform:translateX(-12%)}
563
+ .ddp-demo .lay.out.b{transform:translateX(12%)}
564
+ .ddp-banner{position:absolute;left:50%;top:10px;transform:translateX(-50%);z-index:6;display:flex;align-items:center;gap:6px;max-width:calc(100% - 20px);padding:4px 4px 4px 12px;border-radius:999px;
565
+ background:var(--dsw-alias-bg-layer-1);box-shadow:0 8px 24px rgba(0,0,0,.18),0 0 0 .5px var(--dsw-alias-border-l3);font-size:12px;white-space:nowrap;animation:ddp-in .2s ${EASE}}
566
+ .ddp-banner>svg{width:14px;height:14px;flex:none;color:${ACCENT}}
567
+ .ddp-banner>span{overflow:hidden;text-overflow:ellipsis;min-width:0;margin-right:2px}
568
+ .ddp-banner .ddp-go{height:24px;padding:0 10px;font-size:12px;flex:none;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary)}
569
+ .ddp-banner .ddp-go.on{background:${ACCENT};color:#fff}
570
+ .ddp-banner .ddp-go.pri{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-1)}
571
+ .ddp-banner .ddp-btn{height:24px;min-width:24px;border-radius:999px}
572
+ .ddp-toast{display:flex;align-items:center;gap:10px}
573
+ .ddp-toast.low{top:56px}
574
+ .ddp-toast button{border:0;padding:0;background:transparent;color:inherit;font:inherit;font-weight:600;text-decoration:underline;text-underline-offset:2px;cursor:pointer}
575
+ .ddp-cmp{position:absolute;inset:0;overflow:hidden}
576
+ .ddp-cmp>.ly{position:absolute;inset:0}
577
+ .ddp-cmp>.ly.r{left:auto;overflow:hidden}
578
+ .ddp-cmp>.ly.r>div{position:absolute;top:0;bottom:0}
579
+ .ddp-cmp.drag iframe{pointer-events:none}
580
+ .ddp-cmp .miss{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;padding:20px;background:#f4f4f6;color:#8a8a93;font:13px/1.5 -apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif;text-align:center}
581
+ .ddp-knob{position:absolute;top:0;bottom:0;width:32px;margin-left:-16px;z-index:5;cursor:ew-resize;touch-action:none}
582
+ .ddp-knob::before{content:"";position:absolute;left:15px;top:0;bottom:0;width:2px;background:#fff;box-shadow:0 0 0 .5px rgba(0,0,0,.25),0 0 12px rgba(0,0,0,.25)}
583
+ .ddp-knob i{position:absolute;left:50%;top:50%;width:30px;height:30px;margin:-15px 0 0 -15px;border-radius:50%;background:#fff;box-shadow:0 2px 10px rgba(0,0,0,.3)}
584
+ .ddp-knob i::before,.ddp-knob i::after{content:"";position:absolute;top:10px;border:5px solid transparent}
585
+ .ddp-knob i::before{left:2px;border-right-color:#555}
586
+ .ddp-knob i::after{right:2px;border-left-color:#555}
587
+ .ddp-cmp .tag{position:absolute;top:10px;z-index:4;padding:3px 9px;border-radius:999px;background:rgba(0,0,0,.6);color:#fff;font:500 11px/16px -apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif;pointer-events:none}
588
+ .ddp-cmp .tag.l{left:10px}
589
+ .ddp-cmp .tag.r{right:10px;background:${ACCENT}}
590
+ .ddp-act{margin-left:auto;display:flex;gap:1px;flex:none}
591
+ .ddp-act .ddp-btn{height:22px;min-width:22px;padding:0 4px;color:${ACCENT}}
592
+ .ddp-act .ddp-btn svg{width:13px;height:13px}
593
+ @container (max-width:500px){.ddp-btn.pick span,.ddp-kind>.ddp-btn span{display:none}}
594
+ .ddp-more{display:none}
595
+ .ddp-menu .o1,.ddp-menu .o2{display:none}
596
+ @container (max-width:700px){.ddp-bar>.o1{display:none}.ddp-bar>.ddp-more{display:block}.ddp-menu .o1{display:flex}}
597
+ @container (max-width:580px){.ddp-bar>.o2{display:none}.ddp-menu .o2{display:flex}}
598
+ `
599
+ function ensureStyle() {
600
+ if (typeof document === 'undefined' || document.getElementById('dsh-design-studio-style')) return
601
+ const el = document.createElement('style')
602
+ el.id = 'dsh-design-studio-style'
603
+ el.textContent = CSS
604
+ document.head.appendChild(el)
605
+ }
606
+
607
+ // ── 图标 ─────────────────────────────────────────────────────────────────
608
+
609
+ const svg = (d, extra) => h('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round', ...extra }, ...d)
610
+ const I = {
611
+ pick: () => svg([h('path', { key: 1, d: 'M4 4l6.5 16 2.3-6.7L19.5 11z' }), h('path', { key: 2, d: 'M13 13l6 6' })]),
612
+ canvas: () => svg([h('rect', { key: 1, x: 3.5, y: 3.5, width: 7, height: 7, rx: 1.6 }), h('rect', { key: 2, x: 13.5, y: 3.5, width: 7, height: 7, rx: 1.6 }),
613
+ h('rect', { key: 3, x: 3.5, y: 13.5, width: 7, height: 7, rx: 1.6 }), h('rect', { key: 4, x: 13.5, y: 13.5, width: 7, height: 7, rx: 1.6 })]),
614
+ single: () => svg([h('rect', { key: 1, x: 5, y: 3.5, width: 14, height: 17, rx: 2.2 })]),
615
+ web: () => svg([h('rect', { key: 1, x: 3, y: 4.5, width: 18, height: 15, rx: 2.2 }), h('path', { key: 2, d: 'M3 9h18' }), h('path', { key: 3, d: 'M6 6.8h.01M8.5 6.8h.01' })]),
616
+ app: () => svg([h('rect', { key: 1, x: 7, y: 2.5, width: 10, height: 19, rx: 2.4 }), h('path', { key: 2, d: 'M10.5 5h3' })]),
617
+ tablet: () => svg([h('rect', { key: 1, x: 4.5, y: 3, width: 15, height: 18, rx: 2.2 }), h('path', { key: 2, d: 'M11 18h2' })]),
618
+ poster: () => svg([h('rect', { key: 1, x: 5, y: 3, width: 14, height: 18, rx: 1.8 }), h('path', { key: 2, d: 'M5 16l4-4 3 3 2-2 5 5' }), h('circle', { key: 3, cx: 14.5, cy: 8, r: 1.6 })]),
619
+ slides: () => svg([h('rect', { key: 1, x: 2.5, y: 4, width: 19, height: 12, rx: 1.8 }), h('path', { key: 2, d: 'M12 16v3M8.5 20.5h7' }), h('path', { key: 3, d: 'M6.5 8h6M6.5 11h4' })]),
620
+ email: () => svg([h('rect', { key: 1, x: 3, y: 5, width: 18, height: 14, rx: 2.2 }), h('path', { key: 2, d: 'M3.5 6.5l8.5 6.5 8.5-6.5' })]),
621
+ caret: () => svg([h('path', { key: 1, d: 'M6 9.5l6 6 6-6', strokeWidth: 2.4 })], { className: 'cr' }),
622
+ palette: () => svg([h('path', { key: 1, d: 'M12 3a9 9 0 1 0 0 18c1.1 0 1.7-.8 1.7-1.6 0-.9-.8-1.3-.8-2.1 0-.9.8-1.6 1.7-1.6H17a4 4 0 0 0 4-4C21 6.8 17 3 12 3z' }),
623
+ h('circle', { key: 2, cx: 7.6, cy: 11.2, r: 1.25, fill: 'currentColor', stroke: 'none' }), h('circle', { key: 3, cx: 10.2, cy: 7.4, r: 1.25, fill: 'currentColor', stroke: 'none' }),
624
+ h('circle', { key: 4, cx: 14.8, cy: 7.6, r: 1.25, fill: 'currentColor', stroke: 'none' })]),
625
+ close: () => svg([h('path', { key: 1, d: 'M6 6l12 12M18 6L6 18' })]),
626
+ download: () => svg([h('path', { key: 1, d: 'M12 4v11' }), h('path', { key: 2, d: 'M7.5 10.5L12 15l4.5-4.5' }), h('path', { key: 3, d: 'M5 19.5h14' })]),
627
+ image: () => svg([h('rect', { key: 1, x: 3.5, y: 4.5, width: 17, height: 15, rx: 2.2 }), h('circle', { key: 2, cx: 9, cy: 10, r: 1.7 }), h('path', { key: 3, d: 'M20 16l-4.5-4.5L7 19.5' })]),
628
+ long: () => svg([h('rect', { key: 1, x: 6.5, y: 2.5, width: 11, height: 19, rx: 2 }), h('path', { key: 2, d: 'M9.5 7h5M9.5 10.5h5M9.5 14h3' })]),
629
+ history: () => svg([h('path', { key: 1, d: 'M3.5 12a8.5 8.5 0 1 0 2.6-6.1' }), h('path', { key: 2, d: 'M3.5 4.5v4h4' }), h('path', { key: 3, d: 'M12 7.5V12l3 2' })]),
630
+ scan: () => svg([h('path', { key: 1, d: 'M4 8.5V6a2 2 0 0 1 2-2h2.5M15.5 4H18a2 2 0 0 1 2 2v2.5M20 15.5V18a2 2 0 0 1-2 2h-2.5M8.5 20H6a2 2 0 0 1-2-2v-2.5' }),
631
+ h('rect', { key: 2, x: 9, y: 7.5, width: 6, height: 9, rx: 1.3 })]),
632
+ sizes: () => svg([h('rect', { key: 1, x: 2.5, y: 5, width: 12, height: 9.5, rx: 1.4 }), h('path', { key: 2, d: 'M6.5 18h4' }),
633
+ h('rect', { key: 3, x: 16.5, y: 8, width: 5, height: 10.5, rx: 1.2 })]),
634
+ text: () => svg([h('path', { key: 1, d: 'M5 7V5h11v2M10.5 5v14M8 19h5' }), h('path', { key: 2, d: 'M17 12v8M15.5 12h3M15.5 20h3' })]),
635
+ image: () => svg([h('rect', { key: 1, x: 3.5, y: 4.5, width: 17, height: 15, rx: 2.5 }), h('circle', { key: 2, cx: 9, cy: 10, r: 1.6 }), h('path', { key: 3, d: 'M4 17l5-4.5 4 3.5 3-2.5 4.5 4', strokeLinejoin: 'round' })]),
636
+ more: () => svg([h('circle', { key: 1, cx: 5.5, cy: 12, r: 1.3 }), h('circle', { key: 2, cx: 12, cy: 12, r: 1.3 }), h('circle', { key: 3, cx: 18.5, cy: 12, r: 1.3 })]),
637
+ play: () => svg([h('path', { key: 1, d: 'M8 5.5v13l10.5-6.5z', strokeLinejoin: 'round' })]),
638
+ back: () => svg([h('path', { key: 1, d: 'M14.5 6l-6 6 6 6', strokeWidth: 2.2 })]),
639
+ moon: () => svg([h('path', { key: 1, d: 'M20 14.5A8 8 0 0 1 9.5 4a8 8 0 1 0 10.5 10.5z' })]),
640
+ sparkle: () => svg([h('path', { key: 1, d: 'M12 3.5l1.8 5.2 5.2 1.8-5.2 1.8L12 17.5l-1.8-5.2L5 10.5l5.2-1.8z' }), h('path', { key: 2, d: 'M18.5 16v4M16.5 18h4' })]),
641
+ plus: () => svg([h('path', { key: 1, d: 'M12 5v14M5 12h14' })]),
642
+ minus: () => svg([h('path', { key: 1, d: 'M5 12h14' })]),
643
+ fit: () => svg([h('path', { key: 1, d: 'M4 9V5.5A1.5 1.5 0 0 1 5.5 4H9M15 4h3.5A1.5 1.5 0 0 1 20 5.5V9M20 15v3.5a1.5 1.5 0 0 1-1.5 1.5H15M9 20H5.5A1.5 1.5 0 0 1 4 18.5V15' })]),
644
+ refresh: () => svg([h('path', { key: 1, d: 'M20 11a8 8 0 1 0-2.3 5.7' }), h('path', { key: 2, d: 'M20 5v6h-6' })]),
645
+ external: () => svg([h('path', { key: 1, d: 'M14 4h6v6' }), h('path', { key: 2, d: 'M20 4l-9 9' }), h('path', { key: 3, d: 'M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5' })]),
646
+ check: () => svg([h('path', { key: 1, d: 'M5 12.5l4.5 4.5L19 7.5', strokeWidth: 2.4 })]),
647
+ page: (p) => svg([h('rect', { key: 1, x: 3, y: 4, width: 18, height: 16, rx: 2.5 }), h('path', { key: 2, d: 'M3 9h18' }), h('rect', { key: 3, x: 7, y: 12.5, width: 5, height: 4, rx: 1, strokeDasharray: '2 1.6' })], p),
648
+ }
649
+ // 引导页上的大图标
650
+ function GuideIcon(props) {
651
+ return h('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round', strokeLinejoin: 'round', width: 26, height: 26, ...props },
652
+ h('rect', { x: 2.5, y: 3.5, width: 19, height: 15, rx: 2.5 }),
653
+ h('path', { d: 'M2.5 8h19' }),
654
+ h('rect', { x: 6, y: 11, width: 5, height: 4, rx: 1, strokeDasharray: '2 1.5' }),
655
+ h('path', { d: 'M13.5 12.5l3.6 8.7 1.2-3.6 3.6-1.3z', fill: 'currentColor', stroke: 'none' }))
656
+ }
657
+
658
+ // ── 服务端状态(每个会话一个轮询,几个组件共用)──────────────────────────
659
+
660
+ const pollers = new Map()
661
+ function poller(sid) {
662
+ let p = pollers.get(sid)
663
+ if (p) return p
664
+ p = { state: null, subs: new Set(), timer: null, refs: 0, inflight: false }
665
+ const tick = async () => {
666
+ p.timer = null
667
+ if (!p.inflight) {
668
+ p.inflight = true
669
+ try {
670
+ const r = await fetch(`${API}state?session=${encodeURIComponent(sid)}`, { credentials: 'same-origin', cache: 'no-store' })
671
+ if (r.ok) p.set(await r.json())
672
+ } catch { /* 下一轮再试 */ }
673
+ p.inflight = false
674
+ }
675
+ if (p.refs > 0) p.timer = setTimeout(tick, p.state && p.state.turn ? 700 : 1500)
676
+ }
677
+ p.set = (s) => {
678
+ if (JSON.stringify(s) === JSON.stringify(p.state)) return
679
+ p.state = s
680
+ for (const f of p.subs) f(s)
681
+ }
682
+ p.start = () => { if (!p.timer && !p.inflight) tick() }
683
+ p.stop = () => { clearTimeout(p.timer); p.timer = null }
684
+ pollers.set(sid, p)
685
+ return p
686
+ }
687
+ function usePreview(sid) {
688
+ const p = sid ? poller(sid) : null
689
+ const [s, setS] = useState(p ? p.state : null)
690
+ useEffect(() => {
691
+ if (!p) return undefined
692
+ p.subs.add(setS)
693
+ p.refs++
694
+ p.start()
695
+ setS(p.state)
696
+ return () => {
697
+ p.subs.delete(setS)
698
+ if (--p.refs <= 0) p.stop()
699
+ }
700
+ }, [sid])
701
+ return [s, p]
702
+ }
703
+
704
+ async function post(path, body) {
705
+ const r = await fetch(API + path, {
706
+ method: 'POST',
707
+ credentials: 'same-origin',
708
+ headers: { 'Content-Type': 'application/json' },
709
+ body: JSON.stringify(body),
710
+ })
711
+ const data = await r.json().catch(() => ({}))
712
+ if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`)
713
+ return data
714
+ }
715
+
716
+ // ── 选中内容 → 输入框里的一段字 ──────────────────────────────────────────
717
+
718
+ /** 页面地址 → 用户认得的位置:本地文件给路径,开发服务器给 localhost 地址 */
719
+ function whereOf(href, st) {
720
+ try {
721
+ const u = new URL(href)
722
+ if (st && st.target && st.target.kind === 'file') {
723
+ const file = st.target.file
724
+ // Windows 的路径是反斜杠:按文件自己用的分隔符拼
725
+ const cut = Math.max(file.lastIndexOf('/'), file.lastIndexOf('\\'))
726
+ const sep = file[cut] || '/'
727
+ const dir = file.slice(0, cut)
728
+ const rel = decodeURIComponent(u.pathname)
729
+ const abs = rel === '/' ? file : dir + rel.split('/').join(sep)
730
+ return `文件:${abs}`
731
+ }
732
+ if (st && st.target && st.target.kind === 'proxy') return `页面:http://localhost:${st.target.port}${u.pathname}${u.search}`
733
+ } catch { /* 地址不对就原样 */ }
734
+ return `页面:${href}`
735
+ }
736
+
737
+ function sourceLine(src) {
738
+ if (!src) return ''
739
+ const parts = [src.component, src.file].filter(Boolean)
740
+ return parts.length ? `组件:${src.component || ''}${src.file ? `(${src.file})` : ''}${src.framework ? ` · ${src.framework}` : ''}` : ''
741
+ }
742
+
743
+ // 改样式最常问到的几项,按这个顺序最多 6 项
744
+ const STYLE_KEYS = ['color', 'background-color', 'background-image', 'font-size', 'font-weight', 'padding', 'border-radius', 'border', 'display', 'gap']
745
+ function stylesLine(p) {
746
+ const s = p.styles || {}
747
+ const size = p.rect ? `${p.rect.w}×${p.rect.h}` : ''
748
+ const items = STYLE_KEYS.filter((k) => s[k]).slice(0, 6).map((k) => `${k}: ${s[k]}`)
749
+ return '样式:' + [size, ...items].filter(Boolean).join(' · ')
750
+ }
751
+
752
+ function formatPick(p, st) {
753
+ const lines = []
754
+ if (p.kind === 'region') {
755
+ lines.push(`〔选中区域〕页面上 (${p.rect.x}, ${p.rect.y}) 起 ${p.rect.w}×${p.rect.h} 的一块`)
756
+ lines.push(whereOf(p.href, st))
757
+ if (p.elements && p.elements.length) {
758
+ lines.push('包含:')
759
+ for (const e of p.elements) {
760
+ const src = e.source && (e.source.component || e.source.file) ? ` [${[e.source.component, e.source.file].filter(Boolean).join(' ')}]` : ''
761
+ const txt = e.text ? (e.text.length > 24 ? e.text.slice(0, 24) + '…' : e.text) : ''
762
+ lines.push(`- ${e.selector}${txt ? `「${txt}」` : ''}${src}`)
763
+ }
764
+ }
765
+ } else {
766
+ const txt = p.text ? (p.text.length > 24 ? p.text.slice(0, 24) + '…' : p.text) : ''
767
+ lines.push(`〔选中元素〕${p.name}${txt ? `「${txt}」` : ''}`)
768
+ lines.push(whereOf(p.href, st))
769
+ lines.push(`位置:${p.selector}`)
770
+ const src = sourceLine(p.source)
771
+ if (src) lines.push(src)
772
+ lines.push(`HTML:${p.html}`)
773
+ lines.push(stylesLine(p))
774
+ }
775
+ return lines.join('\n') + '\n'
776
+ }
777
+
778
+ // ── 设计变量 ─────────────────────────────────────────────────────────────
779
+
780
+ const TOKEN_GROUPS = ['color', 'font', 'size', 'space', 'radius', 'shadow', 'other']
781
+ const TOKEN_KEY = { color: 'gColor', font: 'gFont', size: 'gSize', space: 'gSpace', radius: 'gRadius', shadow: 'gShadow', other: 'gOther' }
782
+ const COLOR_RE = /^(#[0-9a-f]{3,8}|(rgba?|hsla?|hwb|oklch|oklab|lch|lab|color)\(.*\))$/i
783
+ const LENGTH_RE = /^-?[\d.]+(px|rem|em)$/
784
+
785
+ /** 变量归哪一组:看名字和值 */
786
+ function tokenKind(tok) {
787
+ const n = String(tok.name).toLowerCase()
788
+ const v = String(tok.value).trim()
789
+ if (/shadow|elevation/.test(n)) return 'shadow'
790
+ if (/radius|round|corner/.test(n)) return 'radius'
791
+ if (/font|family|typeface/.test(n) && !/size|weight|leading|line|track/.test(n) && /[a-z]/i.test(v) && !LENGTH_RE.test(v)) return 'font'
792
+ if (LENGTH_RE.test(v)) return /text|font|size|fs|title|heading|body|caption|display/.test(n) && !/space|gap|pad|margin|gutter/.test(n) ? 'size' : 'space'
793
+ let color = COLOR_RE.test(v)
794
+ if (!color) { try { color = typeof CSS !== 'undefined' && CSS.supports('color', v) && !/^[\d.]+$/.test(v) && !/^(inherit|initial|unset|currentcolor)$/i.test(v) } catch { color = false } }
795
+ return color ? 'color' : 'other'
796
+ }
797
+
798
+ /** 点了一个变量 → 输入框里的一段字 */
799
+ function formatToken(tok, st) {
800
+ return `〔设计变量〕${tok.name}: ${tok.value}\n${whereOf(tok.href, st)}\n`
801
+ }
802
+
803
+ // ── 导出图片:设备外框、整套展示图 ───────────────────────────────────────
804
+
805
+ const SHOW_COLS = { app: 4, tablet: 3, web: 2, poster: 3, slides: 2, email: 3 }
806
+ const CANVAS_FONT = '-apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif'
807
+ const FRAMED = { app: 1, tablet: 1, web: 1 }
808
+
809
+ function loadImg(blob) {
810
+ return new Promise((resolve, reject) => {
811
+ const u = URL.createObjectURL(blob)
812
+ const img = new Image()
813
+ img.onload = () => { URL.revokeObjectURL(u); resolve(img) }
814
+ img.onerror = () => { URL.revokeObjectURL(u); reject(new Error('image decode failed')) }
815
+ img.src = u
816
+ })
817
+ }
818
+
819
+ function rrect(ctx, x, y, w, ht, r) {
820
+ ctx.beginPath()
821
+ if (ctx.roundRect) ctx.roundRect(x, y, w, ht, r)
822
+ else ctx.rect(x, y, w, ht)
823
+ }
824
+
825
+ /** 外框加上去之后的大小(和画布上一样) */
826
+ function frameSize(kind, w, ht) {
827
+ const f = FRAME[kind] || FRAME.web
828
+ return [w + (f.pad || 0) * 2, ht + (f.pad || 0) * 2 + (f.top || 0)]
829
+ }
830
+
831
+ /** 在 (x, y) 画一个带外框的屏:w × ht 是屏幕本身;img 按宽铺进去,高出来的裁掉 */
832
+ function drawDevice(ctx, kind, img, x, y, w, ht, label) {
833
+ const f = frameOf(kind, w, ht)
834
+ const pad = f.pad || 0
835
+ const top = f.top || 0
836
+ const r = f.r || 0
837
+ const [W, H] = frameSize(kind, w, ht)
838
+ const dark = kind === 'app' || kind === 'tablet'
839
+ ctx.save()
840
+ ctx.shadowColor = 'rgba(15, 18, 30, .22)'
841
+ ctx.shadowBlur = 48
842
+ ctx.shadowOffsetY = 18
843
+ rrect(ctx, x, y, W, H, r)
844
+ ctx.fillStyle = dark ? '#0e0e12' : '#fff'
845
+ ctx.fill()
846
+ ctx.restore()
847
+ if (dark) {
848
+ rrect(ctx, x + 0.75, y + 0.75, W - 1.5, H - 1.5, r - 0.75)
849
+ ctx.strokeStyle = '#2b2b33'
850
+ ctx.lineWidth = 1.5
851
+ ctx.stroke()
852
+ }
853
+ if (top) {
854
+ ctx.save()
855
+ rrect(ctx, x, y, W, top, [r, r, 0, 0])
856
+ ctx.fillStyle = '#ebebef'
857
+ ctx.fill()
858
+ ;['#ff5f57', '#febc2e', '#28c840'].forEach((c, i) => {
859
+ ctx.beginPath()
860
+ ctx.arc(x + 18.5 + i * 18, y + top / 2, 5.5, 0, Math.PI * 2)
861
+ ctx.fillStyle = c
862
+ ctx.fill()
863
+ })
864
+ const px = x + 86
865
+ const pw = W - 86 - 60
866
+ rrect(ctx, px, y + (top - 20) / 2, pw, 20, 6)
867
+ ctx.fillStyle = 'rgba(0,0,0,.05)'
868
+ ctx.fill()
869
+ ctx.fillStyle = '#6b6b74'
870
+ ctx.font = `12px ${CANVAS_FONT}`
871
+ ctx.textAlign = 'center'
872
+ ctx.textBaseline = 'middle'
873
+ ctx.fillText(label || '', px + pw / 2, y + top / 2 + 0.5, pw - 16)
874
+ ctx.fillStyle = 'rgba(0,0,0,.07)'
875
+ ctx.fillRect(x, y + top - 1, W, 1)
876
+ ctx.restore()
877
+ }
878
+ ctx.save()
879
+ rrect(ctx, x + pad, y + pad + top, w, ht, top ? [0, 0, r, r] : f.ir != null ? f.ir : r)
880
+ ctx.clip()
881
+ ctx.fillStyle = '#fff'
882
+ ctx.fillRect(x + pad, y + pad + top, w, ht)
883
+ if (img) ctx.drawImage(img, x + pad, y + pad + top, w, img.height * (w / img.width))
884
+ ctx.restore()
885
+ if (f.island) {
886
+ rrect(ctx, x + W / 2 - 59, y + pad + f.island, 118, 34, 17)
887
+ ctx.fillStyle = '#000'
888
+ ctx.fill()
889
+ }
890
+ }
891
+
892
+ function canvasBlob(canvas) {
893
+ return new Promise((resolve, reject) => canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob failed'))), 'image/png'))
894
+ }
895
+
896
+ /** 一屏带外框(四周留出阴影的地方,背景透明) */
897
+ async function composeOne(kind, size, img, label) {
898
+ const [w, ht] = size
899
+ const [W, H] = frameSize(kind, w, ht)
900
+ const m = 56
901
+ const S = Math.min(2, 6000 / (W + m * 2))
902
+ const c = document.createElement('canvas')
903
+ c.width = Math.round((W + m * 2) * S)
904
+ c.height = Math.round((H + m * 2) * S)
905
+ const ctx = c.getContext('2d')
906
+ ctx.scale(S, S)
907
+ drawDevice(ctx, kind, img, m, m, w, ht, label)
908
+ return canvasBlob(c)
909
+ }
910
+
911
+ /** 一串页面标题 → 共同的品牌名 + 每屏自己的名字(「登录 · 八克咖啡」「首页 · 八克咖啡」→ 八克咖啡 / 登录、首页) */
912
+ function splitTitles(titles) {
913
+ const SEP = /\s*[·||—]\s*|\s+[-–]\s+/
914
+ const parts = titles.map((x) => String(x || '').split(SEP).map((s) => s.trim()).filter(Boolean))
915
+ if (titles.length > 1 && parts.every((p) => p.length >= 2)) {
916
+ const last = parts[0][parts[0].length - 1]
917
+ if (parts.every((p) => p[p.length - 1] === last)) return { brand: last, labels: parts.map((p) => p.slice(0, -1).join(' · ')) }
918
+ const first = parts[0][0]
919
+ if (parts.every((p) => p[0] === first)) return { brand: first, labels: parts.map((p) => p.slice(1).join(' · ')) }
920
+ }
921
+ return { brand: '', labels: titles.map((x) => String(x || '')) }
922
+ }
923
+
924
+ /** 从设计变量里挑展示图的底色和点缀色 */
925
+ function pickColors(tokens) {
926
+ const colors = (tokens || []).filter((tk) => tokenKind(tk) === 'color')
927
+ const find = (re, not) => (colors.find((tk) => re.test(tk.name) && !(not && not.test(tk.name))) || {}).value || ''
928
+ return {
929
+ bg: find(/(^|-)(bg|background|canvas|base|paper)(-|$)/i, /ink|text|fg|on-/i),
930
+ accent: find(/primary|brand|accent/i, /ink|text|fg|on-|soft|veil|line|muted|bg/i),
931
+ }
932
+ }
933
+
934
+ /**
935
+ * 参考图的主要颜色。px 是 RGBA 像素(缩小过的图)。
936
+ * 先按每通道 5 位分桶,从占比大的桶里挑彼此差得开的几种做种子,再按桶做几轮 k-means 收拢;
937
+ * 占比太小的不要,但图里有鲜艳的点缀色(按钮、强调)时单独补上一个。
938
+ * @returns {{ colors: [{ hex, share, accent? }], light: boolean }}
939
+ */
940
+ function paletteOf(px, max = 6) {
941
+ const buckets = new Map()
942
+ let n = 0
943
+ let lum = 0
944
+ for (let i = 0; i + 3 < px.length; i += 4) {
945
+ if (px[i + 3] < 128) continue
946
+ const r = px[i], g = px[i + 1], b = px[i + 2]
947
+ const k = ((r >> 3) << 10) | ((g >> 3) << 5) | (b >> 3)
948
+ let e = buckets.get(k)
949
+ if (!e) buckets.set(k, (e = { r: 0, g: 0, b: 0, n: 0 }))
950
+ e.r += r; e.g += g; e.b += b; e.n++
951
+ n++
952
+ lum += 0.2126 * r + 0.7152 * g + 0.0722 * b
953
+ }
954
+ if (!n) return { colors: [], light: true }
955
+ const list = [...buckets.values()].map((e) => ({ r: e.r / e.n, g: e.g / e.n, b: e.b / e.n, n: e.n })).sort((a, b) => b.n - a.n)
956
+ const dist = (a, b) => {
957
+ const rm = (a.r + b.r) / 2
958
+ const dr = a.r - b.r, dg = a.g - b.g, db = a.b - b.b
959
+ return Math.sqrt((2 + rm / 256) * dr * dr + 4 * dg * dg + (2 + (255 - rm) / 256) * db * db)
960
+ }
961
+ const sat = (c) => { const hi = Math.max(c.r, c.g, c.b); return hi ? (hi - Math.min(c.r, c.g, c.b)) / hi : 0 }
962
+ const FAR = 36 // 再近就算同一种(白卡片和浅灰底还分得开)
963
+ let seeds = []
964
+ for (const c of list) {
965
+ if (seeds.length >= max) break
966
+ if (seeds.every((p) => dist(p, c) > FAR)) seeds.push({ r: c.r, g: c.g, b: c.b })
967
+ }
968
+ const near = (c, set) => { let bi = 0, bd = Infinity; set.forEach((p, i) => { const d = dist(p, c); if (d < bd) { bd = d; bi = i } }); return bi }
969
+ for (let it = 0; it < 4; it++) {
970
+ const acc = seeds.map(() => ({ r: 0, g: 0, b: 0, n: 0 }))
971
+ for (const c of list) { const a = acc[near(c, seeds)]; a.r += c.r * c.n; a.g += c.g * c.n; a.b += c.b * c.n; a.n += c.n }
972
+ seeds = seeds.map((p, i) => (acc[i].n ? { r: acc[i].r / acc[i].n, g: acc[i].g / acc[i].n, b: acc[i].b / acc[i].n } : p))
973
+ }
974
+ const share = seeds.map(() => 0)
975
+ for (const c of list) share[near(c, seeds)] += c.n
976
+ let out = seeds.map((p, i) => ({ ...p, share: share[i] / n }))
977
+ // 收拢后挨得太近的两种并成一种
978
+ for (let merged = true; merged;) {
979
+ merged = false
980
+ for (let i = 0; i < out.length && !merged; i++) {
981
+ for (let j = i + 1; j < out.length && !merged; j++) {
982
+ if (dist(out[i], out[j]) >= FAR) continue
983
+ const a = out[i], b = out[j], w = a.share + b.share
984
+ out[i] = { r: (a.r * a.share + b.r * b.share) / w, g: (a.g * a.share + b.g * b.share) / w, b: (a.b * a.share + b.b * b.share) / w, share: w }
985
+ out.splice(j, 1)
986
+ merged = true
987
+ }
988
+ }
989
+ }
990
+ out = out.filter((c) => c.share >= 0.015)
991
+ // 点缀色:现有几种里没有鲜艳的,就在鲜艳的桶里找一团够多、和已有颜色差得开的
992
+ const isVivid = (c) => sat(c) > 0.4 && Math.max(c.r, c.g, c.b) > 100
993
+ if (!out.some(isVivid)) {
994
+ const vivid = list.filter((c) => isVivid(c) && out.every((o) => dist(o, c) > FAR))
995
+ let best = null
996
+ for (const c of vivid.slice(0, 60)) {
997
+ const group = vivid.filter((o) => dist(o, c) < FAR)
998
+ const w = group.reduce((s, o) => s + o.n, 0)
999
+ const score = w * sat(c) * sat(c)
1000
+ if (!best || score > best.score) best = { w, group, score }
1001
+ }
1002
+ if (best && best.w / n >= 0.004) {
1003
+ const g = best.group
1004
+ const avg = (ch) => g.reduce((s, o) => s + o[ch] * o.n, 0) / best.w
1005
+ if (out.length >= max) out.pop()
1006
+ out.push({ r: avg('r'), g: avg('g'), b: avg('b'), share: best.w / n, accent: true })
1007
+ }
1008
+ }
1009
+ const hex = (c) => '#' + [c.r, c.g, c.b].map((v) => Math.round(v).toString(16).padStart(2, '0')).join('').toUpperCase()
1010
+ out = out.sort((a, b) => b.share - a.share)
1011
+ return { colors: out.map((c) => (c.accent ? { hex: hex(c), share: c.share, accent: true } : { hex: hex(c), share: c.share })), light: lum / n / 255 > 0.55 }
1012
+ }
1013
+
1014
+ /** 参考图 → 输入框里的一段字:路径、尺寸、深浅、主要颜色,加一句怎么用 */
1015
+ function formatRef(t, { path, w, h, palette, hasDesign }) {
1016
+ const colors = palette.colors.map((c) => `${c.hex} ${Math.max(1, Math.round(c.share * 100))}%${c.accent ? t('refAccent') : ''}`).join(t('refJoin'))
1017
+ return t('refText', { path, w, h, tone: t(palette.light ? 'refLight' : 'refDark'), colors: colors || '—', ask: t(hasDesign ? 'refAsk' : 'refAskNew') })
1018
+ }
1019
+
1020
+ /** 读参考图:缩小了取颜色;文件本身原样送去存(太大或不是常见格式就重新编成 PNG) */
1021
+ async function readRef(file) {
1022
+ const img = await loadImg(file)
1023
+ const w = img.naturalWidth
1024
+ const ht = img.naturalHeight
1025
+ const k = Math.min(1, 96 / Math.max(w, ht))
1026
+ const c = document.createElement('canvas')
1027
+ c.width = Math.max(1, Math.round(w * k))
1028
+ c.height = Math.max(1, Math.round(ht * k))
1029
+ const ctx = c.getContext('2d', { willReadFrequently: true })
1030
+ ctx.drawImage(img, 0, 0, c.width, c.height)
1031
+ const palette = paletteOf(ctx.getImageData(0, 0, c.width, c.height).data)
1032
+ let blob = file
1033
+ if (!/^image\/(png|jpeg|webp|gif)$/.test(file.type) || file.size > 16 * 1024 * 1024) {
1034
+ const big = document.createElement('canvas')
1035
+ const s = Math.min(1, 2400 / Math.max(w, ht))
1036
+ big.width = Math.max(1, Math.round(w * s))
1037
+ big.height = Math.max(1, Math.round(ht * s))
1038
+ big.getContext('2d').drawImage(img, 0, 0, big.width, big.height)
1039
+ blob = await canvasBlob(big)
1040
+ }
1041
+ return { w, h: ht, palette, data: await blobB64(blob) }
1042
+ }
1043
+
1044
+ function lumaOf(color) {
1045
+ try {
1046
+ const c = document.createElement('canvas')
1047
+ c.width = c.height = 1
1048
+ const ctx = c.getContext('2d')
1049
+ ctx.fillStyle = '#808080'
1050
+ ctx.fillStyle = color
1051
+ ctx.fillRect(0, 0, 1, 1)
1052
+ const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data
1053
+ return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255
1054
+ } catch { return 1 }
1055
+ }
1056
+
1057
+ /**
1058
+ * 整套展示图:几屏带外框排成网格,上面是品牌名和一行说明,铺一层底色 + 点缀色的光晕。
1059
+ * shots: [{ img, label }]
1060
+ */
1061
+ async function composeShowcase({ kind, size, shots, title, sub, bg, accent, mark }) {
1062
+ const [w, ht] = size
1063
+ const [DW, DH] = frameSize(kind, w, ht)
1064
+ const u = Math.max(1, Math.min(2, DW / 430))
1065
+ const n = shots.length
1066
+ const cols = Math.min(n, SHOW_COLS[kind] || 3)
1067
+ const rows = Math.ceil(n / cols)
1068
+ const gap = 64 * u
1069
+ const labH = 52 * u
1070
+ const pad = 104 * u
1071
+ const titleSize = 46 * u
1072
+ const head = (title ? titleSize * 1.25 + 14 * u : 0) + 22 * u + 72 * u
1073
+ const W = pad * 2 + cols * DW + (cols - 1) * gap
1074
+ const H = pad + head + rows * (DH + labH) + (rows - 1) * gap * 0.6 + pad * 0.8
1075
+ const S = Math.max(0.5, Math.min(2, 7000 / W, 7000 / H))
1076
+ const c = document.createElement('canvas')
1077
+ c.width = Math.round(W * S)
1078
+ c.height = Math.round(H * S)
1079
+ const ctx = c.getContext('2d')
1080
+ ctx.scale(S, S)
1081
+ const base = bg || '#f3f3f6'
1082
+ ctx.fillStyle = '#f3f3f6'
1083
+ ctx.fillStyle = base
1084
+ ctx.fillRect(0, 0, W, H)
1085
+ if (accent) {
1086
+ for (const [gx, gy, gr, ga] of [[0.05, 0.02, 0.75, 0.16], [0.98, 0.95, 0.7, 0.12]]) {
1087
+ ctx.save()
1088
+ ctx.globalAlpha = ga
1089
+ const g = ctx.createRadialGradient(W * gx, H * gy, 0, W * gx, H * gy, Math.max(W, H) * gr)
1090
+ g.addColorStop(0, accent)
1091
+ g.addColorStop(1, 'rgba(0,0,0,0)')
1092
+ ctx.fillStyle = g
1093
+ ctx.fillRect(0, 0, W, H)
1094
+ ctx.restore()
1095
+ }
1096
+ }
1097
+ const light = lumaOf(base) > 0.55
1098
+ const ink = light ? '#16161a' : '#f5f5f7'
1099
+ const muted = light ? 'rgba(22,22,26,.55)' : 'rgba(245,245,247,.6)'
1100
+ ctx.textAlign = 'left'
1101
+ ctx.textBaseline = 'alphabetic'
1102
+ let y = pad
1103
+ if (title) {
1104
+ ctx.fillStyle = ink
1105
+ ctx.font = `600 ${titleSize}px ${CANVAS_FONT}`
1106
+ y += titleSize
1107
+ ctx.fillText(title, pad, y, W - pad * 2)
1108
+ y += 14 * u
1109
+ }
1110
+ ctx.fillStyle = muted
1111
+ ctx.font = `${18 * u}px ${CANVAS_FONT}`
1112
+ y += 22 * u
1113
+ ctx.fillText(sub, pad, y, W - pad * 2)
1114
+ y += 72 * u
1115
+ shots.forEach((s, i) => {
1116
+ const col = i % cols
1117
+ const row = Math.floor(i / cols)
1118
+ const inRow = Math.min(cols, n - row * cols)
1119
+ const off = ((cols - inRow) * (DW + gap)) / 2 // 最后一行不满就居中
1120
+ const x = pad + off + col * (DW + gap)
1121
+ const yy = y + row * (DH + labH + gap * 0.6)
1122
+ drawDevice(ctx, kind, s.img, x, yy, w, ht, s.label)
1123
+ ctx.fillStyle = muted
1124
+ ctx.font = `500 ${16 * u}px ${CANVAS_FONT}`
1125
+ ctx.textAlign = 'center'
1126
+ ctx.fillText(s.label || '', x + DW / 2, yy + DH + 34 * u, DW)
1127
+ ctx.textAlign = 'left'
1128
+ })
1129
+ if (mark) {
1130
+ ctx.fillStyle = muted
1131
+ ctx.globalAlpha = 0.7
1132
+ ctx.font = `${13 * u}px ${CANVAS_FONT}`
1133
+ ctx.textAlign = 'right'
1134
+ ctx.fillText(mark, W - pad * 0.5, H - pad * 0.32)
1135
+ ctx.globalAlpha = 1
1136
+ }
1137
+ return canvasBlob(c)
1138
+ }
1139
+
1140
+ function blobB64(blob) {
1141
+ return new Promise((resolve, reject) => {
1142
+ const r = new FileReader()
1143
+ r.onload = () => { const s = String(r.result); resolve(s.slice(s.indexOf(',') + 1)) }
1144
+ r.onerror = () => reject(r.error)
1145
+ r.readAsDataURL(blob)
1146
+ })
1147
+ }
1148
+
1149
+ /** 图片放进剪贴板(DSH 窗口没焦点之类的拿不到就算了) */
1150
+ async function copyImage(blob) {
1151
+ try {
1152
+ if (!navigator.clipboard || typeof ClipboardItem === 'undefined') return false
1153
+ await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
1154
+ return true
1155
+ } catch { return false }
1156
+ }
1157
+
1158
+ /** 时间:今天的只写时分,别的天带上月日 */
1159
+ /** 2026.10.19:不跟系统区域走(DSH 按 en-US 起,toLocaleDateString 会给 10/19/2026) */
1160
+ function ymd(d) {
1161
+ return `${d.getFullYear()}.${d.getMonth() + 1}.${d.getDate()}`
1162
+ }
1163
+ function hhmm(at) {
1164
+ const d = new Date(at)
1165
+ const p = (x) => String(x).padStart(2, '0')
1166
+ const now = new Date()
1167
+ const hm = `${p(d.getHours())}:${p(d.getMinutes())}`
1168
+ return d.toDateString() === now.toDateString() ? hm : `${d.getMonth() + 1}/${d.getDate()} ${hm}`
1169
+ }
1170
+
1171
+ // ── 设计类型:画幅和外框 ─────────────────────────────────────────────────
1172
+
1173
+ const KIND_LIST = ['web', 'app', 'tablet', 'poster', 'slides', 'email']
1174
+ const KIND_KEY = { web: 'kindWeb', app: 'kindApp', tablet: 'kindTablet', poster: 'kindPoster', slides: 'kindSlides', email: 'kindEmail' }
1175
+ const GHOST_MS = 90000 // 这么久没出新屏就撤掉「生成中…」占位
1176
+ const EDIT_GRACE_MS = 3000 // 退出改字后这么久里,页面交上来的最后一处改动还收
1177
+ const KIND_SIZE = { web: [1280, 800], app: [390, 844], tablet: [820, 1180], poster: [1080, 1350], slides: [1280, 720], email: [640, 960] }
1178
+ // 外框:pad 边框厚度、top 顶上一条(浏览器栏)、r 外圆角、ir 屏幕圆角、island 灵动岛离顶的距离
1179
+ const FRAME = {
1180
+ web: { pad: 0, top: 34, r: 10, ir: 0 },
1181
+ app: { pad: 11, top: 0, r: 55, ir: 44, island: 11 },
1182
+ tablet: { pad: 14, top: 0, r: 34, ir: 21 },
1183
+ poster: { pad: 0, top: 0, r: 6, ir: 6 },
1184
+ slides: { pad: 0, top: 0, r: 8, ir: 8 },
1185
+ email: { pad: 0, top: 0, r: 10, ir: 10 },
1186
+ }
1187
+
1188
+ /** 某个大小的外框:矮的手机(SE 那种,高不到宽的 1.9 倍)没有灵动岛,不然压住页面最上面 */
1189
+ function frameOf(kind, w, ht) {
1190
+ const f = FRAME[kind] || FRAME.web
1191
+ return f.island && w && ht / w < 1.9 ? { ...f, island: 0 } : f
1192
+ }
1193
+ const META_ALIAS = { phone: 'app', mobile: 'app', ios: 'app', android: 'app', pad: 'tablet', ipad: 'tablet', social: 'poster', image: 'poster', slide: 'slides', deck: 'slides', ppt: 'slides', mail: 'email' }
1194
+
1195
+ /** 页面里 <meta name="dsh-design" content="app"> / "poster 1080x1920" → { kind, size } */
1196
+ function parseMeta(content) {
1197
+ const out = { kind: '', size: '' }
1198
+ for (const w of String(content || '').toLowerCase().split(/[\s,;]+/)) {
1199
+ if (!out.kind && (KIND_LIST.includes(w) || META_ALIAS[w])) out.kind = KIND_LIST.includes(w) ? w : META_ALIAS[w]
1200
+ const m = /^(\d{2,5})[x×*](\d{2,5})$/.exec(w)
1201
+ if (!out.size && m) out.size = `${m[1]}x${m[2]}`
1202
+ }
1203
+ return out
1204
+ }
1205
+
1206
+ /** 'WxH'(没有就按类型默认)→ [w, h] */
1207
+ function sizeOf(size, kind) {
1208
+ const m = /^(\d{2,5})x(\d{2,5})$/.exec(String(size || ''))
1209
+ if (m && +m[1] >= 100 && +m[2] >= 100) return [Math.min(8000, +m[1]), Math.min(8000, +m[2])]
1210
+ return KIND_SIZE[kind] || KIND_SIZE.web
1211
+ }
1212
+
1213
+ /** 外框加上去之后的整块大小 */
1214
+ function deviceBox(kind, size) {
1215
+ const f = FRAME[kind] || FRAME.web
1216
+ return [size[0] + f.pad * 2, size[1] + f.pad * 2 + f.top]
1217
+ }
1218
+
1219
+ // ── 画布排版 ─────────────────────────────────────────────────────────────
1220
+
1221
+ const ZMIN = 0.04
1222
+ const ZMAX = 2
1223
+ const PAD = 28
1224
+ const PAD_TOP = 52 // 第一行上面要留出标题的地方
1225
+ const LAB = 34 // 每屏上面标题占的高度(屏幕像素,不跟着缩放)
1226
+ const GAP = 18 // 两屏之间最少隔开的屏幕像素
1227
+ const clampZ = (z) => Math.max(ZMIN, Math.min(ZMAX, z))
1228
+
1229
+ /**
1230
+ * n 屏排成网格:试每种列数,挑缩进舞台后最大的那种。
1231
+ * 行距 = 按比例的空隙 + 标题的高度(标题不缩放,所以换算回画布坐标是 LAB / z)。
1232
+ */
1233
+ function layoutGrid(n, dw, dh, sw, sh) {
1234
+ const gx = Math.max(56, Math.round(dw * 0.14))
1235
+ const gy = Math.max(48, Math.round(dh * 0.07))
1236
+ const count = Math.max(1, n)
1237
+ const W = sw - PAD * 2
1238
+ const H = sh - PAD_TOP - PAD
1239
+ let best = null
1240
+ for (let cols = 1; cols <= count; cols++) {
1241
+ const rows = Math.ceil(count / cols)
1242
+ const zw = (W - (cols - 1) * GAP) / (cols * dw + (cols - 1) * gx)
1243
+ const zh = (H - (rows - 1) * LAB) / (rows * dh + (rows - 1) * gy)
1244
+ const z = Math.min(zw, zh)
1245
+ if (!best || z > best.z + 1e-9) best = { cols, rows, z }
1246
+ }
1247
+ const zl = clampZ(Math.min(1, best.z))
1248
+ const stepX = dw + gx + GAP / zl
1249
+ const stepY = dh + gy + LAB / zl
1250
+ const pos = []
1251
+ for (let i = 0; i < count; i++) pos.push({ x: (i % best.cols) * stepX, y: Math.floor(i / best.cols) * stepY })
1252
+ const bw = best.cols * stepX - gx - GAP / zl
1253
+ const bh = best.rows * stepY - gy - LAB / zl
1254
+ return { ...best, bw, bh, pos }
1255
+ }
1256
+
1257
+ /** 整块居中放进舞台(最大 100%) */
1258
+ function fitCam(L, sw, sh) {
1259
+ const z = clampZ(Math.min(1, L.z))
1260
+ return { z, x: (sw - L.bw * z) / 2, y: PAD_TOP + Math.max(0, (sh - PAD_TOP - PAD - L.bh * z) / 2) }
1261
+ }
1262
+
1263
+ /** 多尺寸并排:网页看手机 / 平板 / 电脑,App 看小屏 / 标准 / 大屏,平板看三种 iPad */
1264
+ const SIZE_SETS = {
1265
+ web: [['szPhone', 'app', [390, 844]], ['szTablet', 'tablet', [820, 1180]], ['szDesktop', 'web', [1440, 900]]],
1266
+ app: [['szSmall', 'app', [375, 667]], ['szStd', 'app', [393, 852]], ['szLarge', 'app', [430, 932]]],
1267
+ tablet: [['szSmall', 'tablet', [744, 1133]], ['szStd', 'tablet', [820, 1180]], ['szLarge', 'tablet', [1032, 1376]]],
1268
+ }
1269
+
1270
+ /** 大小不一的几块排成一行(顶端对齐),算出整块大小和放得下的缩放 */
1271
+ function layoutRow(boxes, sw, sh) {
1272
+ const maxW = Math.max(...boxes.map((b) => b[0]))
1273
+ const gx = Math.max(56, Math.round(maxW * 0.08))
1274
+ const pos = []
1275
+ let x = 0
1276
+ for (const [w] of boxes) {
1277
+ pos.push({ x, y: 0 })
1278
+ x += w + gx
1279
+ }
1280
+ const bw = x - gx
1281
+ const bh = Math.max(...boxes.map((b) => b[1]))
1282
+ const z = Math.min((sw - PAD * 2 - (boxes.length - 1) * GAP) / bw, (sh - PAD_TOP - PAD) / bh)
1283
+ return { cols: boxes.length, rows: 1, z, bw, bh, pos }
1284
+ }
1285
+
1286
+ /** 跳转的路径 → 画布上的屏(本地文件是相对目录的文件名,开发服务器是路由;旧版本的路径去掉 /__dsh_design__/v/N/) */
1287
+ function keyOfPath(p, file) {
1288
+ let k = String(p || '').replace(/^\/__dsh_design__\/v\/\d+\//, '/')
1289
+ if (!file) return k || '/'
1290
+ k = k.replace(/^\/+/, '')
1291
+ if (!k || k.endsWith('/')) k += 'index.html'
1292
+ return k
1293
+ }
1294
+
1295
+ /** 每屏报上来的跳转 → 一屏到另一屏一条(同一对只留第一处) */
1296
+ function flowsOf(screens, linksOf, file) {
1297
+ const keys = new Set(screens.map((s) => s.key))
1298
+ const seen = new Set()
1299
+ const out = []
1300
+ for (const sc of screens) {
1301
+ for (const l of linksOf(sc) || []) {
1302
+ const to = keyOfPath(l.path, file)
1303
+ if (to === sc.key || !keys.has(to)) continue
1304
+ const k = sc.key + '>' + to
1305
+ if (seen.has(k)) continue
1306
+ seen.add(k)
1307
+ out.push({ from: sc.key, to, x: l.x, y: l.y, w: l.w, h: l.h })
1308
+ }
1309
+ }
1310
+ return out
1311
+ }
1312
+
1313
+ /**
1314
+ * 连线的几何(画布坐标):从页面里那个按钮的边上出发,落到目标屏的左边(目标在左边就落右边),
1315
+ * 两头水平进出的三次曲线;同一屏进来好几条,落点上下错开。
1316
+ */
1317
+ function flowLines(flows, keys, pos, kind, size, opts = {}) {
1318
+ const f = FRAME[kind] || FRAME.web
1319
+ const [dw, dh] = deviceBox(kind, size)
1320
+ const idx = new Map(keys.map((k, i) => [k, i]))
1321
+ const incoming = new Map()
1322
+ const out = []
1323
+ const lift = opts.lift || 0 // 屏上面标题那一条有多高(世界坐标):绕行的线不压标题
1324
+ // 一屏占的地方(连上面的标题);曲线要是穿过别的屏,就改走屏和屏之间的空当
1325
+ const boxes = pos.map((p) => p && { x0: p.x, y0: p.y - lift, x1: p.x + dw, y1: p.y + dh })
1326
+ const xs = [...new Set(pos.filter(Boolean).map((p) => p.x))].sort((m, n) => m - n)
1327
+ const ys = [...new Set(pos.filter(Boolean).map((p) => p.y))].sort((m, n) => m - n)
1328
+ const gaps = xs.slice(1).map((x, i) => x - xs[i] - dw).filter((x) => x > 0)
1329
+ const g = (gaps.length ? Math.min(...gaps) : 64) / 2 // 竖着走的线放在两列中间
1330
+ const r = Math.min(g * 0.7, 24) // 拐角圆一点
1331
+ const crosses = (pts, skip) => boxes.some((b, i) => b && !skip.includes(i) && pts.some(([x, y]) => x > b.x0 + 2 && x < b.x1 - 2 && y > b.y0 + 2 && y < b.y1 - 2))
1332
+ const bez = (p0, p1, p2, p3) => Array.from({ length: 23 }, (_, i) => {
1333
+ const t = (i + 1) / 24
1334
+ const u = 1 - t
1335
+ return [0, 1].map((j) => u * u * u * p0[j] + 3 * u * u * t * p1[j] + 3 * u * t * t * p2[j] + t * t * t * p3[j])
1336
+ })
1337
+ /** 横着走那一道的高度:同一排 → 这一排标题上面;不同排 → 下面那排和上一排之间的空当 */
1338
+ function laneOf(ya, yb) {
1339
+ const R = Math.max(ya, yb)
1340
+ const prev = ys.filter((y) => y < R - 1).pop()
1341
+ if (prev === undefined) return R - lift - Math.max(16, g * 0.6)
1342
+ const bottom = prev + dh
1343
+ return bottom + Math.max(R - lift - bottom, 12) / 2
1344
+ }
1345
+ /** 直角绕行:出屏 → 竖着到那一道 → 横着走 → 竖着到目标边上 → 进屏 */
1346
+ function detour(sx, sy, hOut, cx1, lane, cx2, ey, hIn, ex) {
1347
+ const sgn = (v) => (v > 0 ? 1 : v < 0 ? -1 : 0)
1348
+ let d = `M${sx} ${sy}H${cx1 - hOut * r}`
1349
+ const corner = (x, y, dx, dy) => `Q${x} ${y} ${x + dx * r} ${y + dy * r}`
1350
+ const hl = sgn(cx2 - cx1)
1351
+ if (!hl) {
1352
+ const v = sgn(ey - sy)
1353
+ return d + corner(cx1, sy, 0, v) + `V${ey - v * r}` + corner(cx1, ey, hIn, 0) + `H${ex}`
1354
+ }
1355
+ const v1 = sgn(lane - sy)
1356
+ const v2 = sgn(ey - lane)
1357
+ return d + corner(cx1, sy, 0, v1) + `V${lane - v1 * r}` + corner(cx1, lane, hl, 0)
1358
+ + `H${cx2 - hl * r}` + corner(cx2, lane, 0, v2) + `V${ey - v2 * r}` + corner(cx2, ey, hIn, 0) + `H${ex}`
1359
+ }
1360
+ for (const fl of flows) {
1361
+ const a = idx.get(fl.from)
1362
+ const b = idx.get(fl.to)
1363
+ if (a === undefined || b === undefined || a === b || !pos[a] || !pos[b]) continue
1364
+ const pa = pos[a]
1365
+ const pb = pos[b]
1366
+ const ox = pa.x + f.pad
1367
+ const oy = pa.y + f.pad + f.top
1368
+ const dir = pb.x > pa.x + 1 ? 1 : pb.x < pa.x - 1 ? -1 : 0
1369
+ const n = incoming.get(fl.to) || 0
1370
+ incoming.set(fl.to, n + 1)
1371
+ const sy = oy + Math.max(0, Math.min(size[1], fl.y + fl.h / 2))
1372
+ const ey = pb.y + f.pad + f.top + size[1] * Math.min(0.85, 0.32 + n * 0.09)
1373
+ const sx = ox + Math.max(0, Math.min(size[0], dir > 0 ? fl.x + fl.w : fl.x))
1374
+ // 先在这一屏里水平走到边上(不斜着划过画面),出了屏再弯到目标
1375
+ const edge = dir > 0 ? pa.x + dw : pa.x
1376
+ const ex = dir < 0 ? pb.x + dw : pb.x
1377
+ const k = Math.max(90, Math.abs(ex - edge) * 0.42)
1378
+ const c1 = dir > 0 ? edge + k : edge - k - (dir ? 0 : 60)
1379
+ const c2 = dir < 0 ? ex + k : ex - k - (dir ? 0 : 60)
1380
+ let d = `M${sx} ${sy}H${edge}C${c1} ${sy} ${c2} ${ey} ${ex} ${ey}`
1381
+ if (crosses(bez([edge, sy], [c1, sy], [c2, ey], [ex, ey]), [a, b])) {
1382
+ const hOut = dir || -1
1383
+ const hIn = dir || 1
1384
+ d = detour(sx, sy, hOut, edge + hOut * g, laneOf(pa.y, pb.y), ex - hIn * g, ey, hIn, ex)
1385
+ }
1386
+ out.push({ from: fl.from, to: fl.to, sx, sy, ex, ey, tip: dir < 0 ? -1 : 1, d })
1387
+ }
1388
+ return out
1389
+ }
1390
+
1391
+ // ── 小部件 ───────────────────────────────────────────────────────────────
1392
+
1393
+ function Skeleton({ gone }) {
1394
+ return h('div', { className: 'ddp-skel' + (gone ? ' gone' : '') },
1395
+ h('b', { style: { height: 22, width: '38%' } }),
1396
+ h('b', { style: { height: 140 } }),
1397
+ h('div', { style: { display: 'flex', gap: 14 } },
1398
+ h('b', { style: { height: 90, flex: 1 } }), h('b', { style: { height: 90, flex: 1 } }), h('b', { style: { height: 90, flex: 1 } })),
1399
+ h('b', { style: { height: 14, width: '72%' } }),
1400
+ h('b', { style: { height: 14, width: '54%' } }))
1401
+ }
1402
+
1403
+ function AddressForm({ t, sid, initial, onDone, autoFocus, className }) {
1404
+ const [value, setValue] = useState(initial || '')
1405
+ const [err, setErr] = useState('')
1406
+ const submit = async (e) => {
1407
+ if (e) e.preventDefault()
1408
+ if (!value.trim()) return onDone && onDone()
1409
+ try {
1410
+ await post('open', { session: sid, target: value.trim() })
1411
+ const p = poller(sid)
1412
+ p.stop()
1413
+ p.start()
1414
+ setErr('')
1415
+ onDone && onDone()
1416
+ } catch (x) {
1417
+ setErr(t('openFailed', { msg: x.message }))
1418
+ }
1419
+ }
1420
+ return h('form', { onSubmit: submit, className, style: className ? undefined : { display: 'contents' } },
1421
+ h('input', {
1422
+ className: 'ddp-input',
1423
+ value,
1424
+ autoFocus,
1425
+ spellCheck: false,
1426
+ placeholder: t('placeholder'),
1427
+ onChange: (e) => setValue(e.target.value),
1428
+ onKeyDown: (e) => { if (e.key === 'Escape') onDone && onDone() },
1429
+ onBlur: () => { if (!err && onDone && !className) setTimeout(onDone, 150) },
1430
+ }),
1431
+ className ? h('button', { className: 'ddp-go', type: 'submit' }, t('go')) : null,
1432
+ err ? h('div', { className: 'ddp-err', style: className ? { width: '100%' } : { position: 'absolute', top: 40, left: 12 } }, err) : null)
1433
+ }
1434
+
1435
+ /** 空状态的小插画:页面线框,光标移过去,框出一块 */
1436
+ function EmptyArt() {
1437
+ return h('svg', { className: 'ddp-art', viewBox: '0 0 176 120', 'aria-hidden': true },
1438
+ h('rect', { className: 'pg', x: 8, y: 6, width: 160, height: 108, rx: 11 }),
1439
+ h('path', { className: 'ln', d: 'M8 23h160' }),
1440
+ h('circle', { className: 'dt', cx: 19, cy: 14.5, r: 2.4 }),
1441
+ h('circle', { className: 'dt', cx: 27, cy: 14.5, r: 2.4 }),
1442
+ h('circle', { className: 'dt', cx: 35, cy: 14.5, r: 2.4 }),
1443
+ h('rect', { className: 'sk', x: 22, y: 35, width: 78, height: 9, rx: 4.5 }),
1444
+ h('rect', { className: 'sk', x: 22, y: 50, width: 58, height: 6, rx: 3 }),
1445
+ h('rect', { className: 'bt', x: 22, y: 66, width: 44, height: 15, rx: 7.5 }),
1446
+ h('rect', { className: 'sk', x: 106, y: 35, width: 48, height: 48, rx: 9 }),
1447
+ h('rect', { className: 'sk', x: 22, y: 92, width: 132, height: 10, rx: 5 }),
1448
+ h('rect', { className: 'sel', x: 16, y: 60, width: 56, height: 27, rx: 10 }),
1449
+ h('path', { className: 'cur', d: 'M0 0l6.5 16 2.4-6.6 6.6-2.4z' }))
1450
+ }
1451
+
1452
+ /**
1453
+ * 外框:网页是浏览器窗口,App / 平板是带圆角边框的设备(App 还有灵动岛),海报 / 幻灯片 / 邮件是一张卡片。
1454
+ * fill = 铺满舞台(单页看网页)。不管哪种,里面那屏都在同一个位置(换类型不重载页面)。
1455
+ */
1456
+ function Device({ kind, w, h: ht, fill, label, children }) {
1457
+ const f = fill ? { pad: 0, top: 0, r: 0, ir: 0 } : frameOf(kind, w, ht)
1458
+ return h('div', {
1459
+ className: `ddp-dev k-${kind}` + (fill ? ' fill' : ''),
1460
+ style: fill ? undefined : { width: w + f.pad * 2, height: ht + f.pad * 2 + f.top, borderRadius: f.r },
1461
+ },
1462
+ h('div', { className: 'ddp-chrome', style: { display: f.top ? 'flex' : 'none', height: f.top } }, h('i'), h('i'), h('i'), h('span', null, label || '')),
1463
+ h('div', { className: 'ddp-vp', style: fill ? { inset: 0 } : { left: f.pad, top: f.pad + f.top, width: w, height: ht, borderRadius: f.ir } }, children),
1464
+ h('div', { className: 'ddp-island', style: { display: f.island ? 'block' : 'none', top: (f.pad || 0) + (f.island || 0) } }))
1465
+ }
1466
+
1467
+ /**
1468
+ * 一屏:几帧 iframe 叠在一起。cur 是正在显示的;比它新的在后台载,载好了淡入换上来,旧的随后拆掉(不白屏)。
1469
+ * 和页面里 picker.js 的握手:hello → init(上一帧的滚动位置和指纹)→ ready → 换上来 → reveal(闪改动 / 播生成动画)。
1470
+ */
1471
+ function Screen(props) {
1472
+ const src = props.src
1473
+ const origin = src ? new URL(src).origin : ''
1474
+ const [frames, setFrames] = useState([])
1475
+ const [cur, setCur] = useState(0)
1476
+ const wins = useRef(new Map())
1477
+ const life = useRef(null)
1478
+ if (!life.current) life.current = { next: 1, loading: 0, again: false, file: '', seeds: new Map(), seedOf: new Map(), readied: new Set(), snapTimer: null }
1479
+ const curRef = useRef(0)
1480
+ const P = useRef(props)
1481
+ P.current = props
1482
+ const caps = useRef(new Map()) // 导出图片:请求 id → { resolve, reject }
1483
+ const editWas = useRef(false)
1484
+ const editOffAt = useRef(0) // 上次退出改字的时间
1485
+ const api = useRef(null)
1486
+ if (!api.current) {
1487
+ let seq = 0
1488
+ api.current = {
1489
+ ready: () => !!curRef.current && life.current.readied.has(curRef.current),
1490
+ capture: (opts) => new Promise((resolve, reject) => {
1491
+ if (!curRef.current) { reject(new Error('not loaded')); return }
1492
+ const id = `${Date.now()}-${++seq}`
1493
+ caps.current.set(id, { resolve, reject })
1494
+ postTo(curRef.current, { type: 'capture', id, ...opts })
1495
+ setTimeout(() => { if (caps.current.has(id)) { caps.current.delete(id); reject(new Error('timeout')) } }, 30000)
1496
+ }),
1497
+ }
1498
+ }
1499
+ // 登记到右侧栏:导出图片时按屏找到它
1500
+ useEffect(() => {
1501
+ const m = props.apis
1502
+ const k = props.apiKey
1503
+ if (!m || !k) return undefined
1504
+ m.set(k, api.current)
1505
+ return () => { if (m.get(k) === api.current) m.delete(k) }
1506
+ }, [props.apiKey])
1507
+
1508
+ const postTo = (id, msg) => {
1509
+ const f = wins.current.get(id)
1510
+ if (f && f.contentWindow && origin) f.contentWindow.postMessage({ dshDesign: 1, ...msg }, origin)
1511
+ }
1512
+
1513
+ // 在后台载一帧新的;正在载就记一笔,等它换上来再载
1514
+ const load = (seed) => {
1515
+ const L = life.current
1516
+ if (L.loading) { L.again = true; return }
1517
+ const id = L.next++
1518
+ L.loading = id
1519
+ let url = P.current.src
1520
+ try { if (seed && seed.href && new URL(seed.href).origin === origin) url = seed.href } catch { /* 用原地址 */ }
1521
+ if (seed) L.seedOf.set(id, seed)
1522
+ setFrames((fs) => [...fs, { id, src: url }])
1523
+ }
1524
+
1525
+ const pickMsg = () => ({ type: 'pick', hint: false }) // 提示放在右侧栏(页面顶上会被手机的灵动岛挡住)
1526
+
1527
+ // 一帧好了:淡入换上来
1528
+ const swap = (id, changes) => {
1529
+ const L = life.current
1530
+ if (L.loading === id) L.loading = 0
1531
+ const first = !curRef.current
1532
+ curRef.current = id
1533
+ setCur(id)
1534
+ const p = P.current
1535
+ const build = first ? !!p.born : !!(changes && changes.whole && p.busy)
1536
+ postTo(id, { type: 'reveal', build })
1537
+ postTo(id, { type: 'busy', on: !!p.busy })
1538
+ postTo(id, { type: 'zoom', z: p.zoom || 1 })
1539
+ if (p.picking) postTo(id, pickMsg())
1540
+ if (p.demo) postTo(id, { type: 'demo', on: true })
1541
+ if (p.textEdit) postTo(id, { type: 'edit', on: true, hint: false })
1542
+ if (first && p.onShown) p.onShown()
1543
+ if (first) { if (build && p.onBuilt) p.onBuilt(L.title || '') } else if (p.onNote) p.onNote({ changes, file: L.file, build })
1544
+ L.file = ''
1545
+ setTimeout(() => setFrames((fs) => fs.filter((f) => f.id >= curRef.current || f.id > id)), 450)
1546
+ if (L.again) {
1547
+ L.again = false
1548
+ postTo(id, { type: 'snapshot' })
1549
+ }
1550
+ }
1551
+
1552
+ // 换了页面:从头来
1553
+ useEffect(() => {
1554
+ const L = life.current
1555
+ L.loading = 0
1556
+ L.again = false
1557
+ L.seeds.clear()
1558
+ L.seedOf.clear()
1559
+ curRef.current = 0
1560
+ setCur(0)
1561
+ setFrames([])
1562
+ if (src) setTimeout(() => load(null), 0)
1563
+ }, [src])
1564
+
1565
+ // 页面发来的消息(几屏共用一个源,按 iframe 认是不是自己的)
1566
+ useEffect(() => {
1567
+ const on = (e) => {
1568
+ if (!origin || e.origin !== origin) return
1569
+ let id = 0
1570
+ for (const [k, f] of wins.current) if (f && f.contentWindow === e.source) { id = k; break }
1571
+ const d = e.data
1572
+ if (!id || !d || d.dshDesign !== 1) return
1573
+ const L = life.current
1574
+ const p = P.current
1575
+ const isCur = id === curRef.current
1576
+ switch (d.type) {
1577
+ case 'hello': {
1578
+ // 自己整页刷新的(热更新 / 点了链接)用它走之前留下的指纹,新载的一帧用载它时的
1579
+ const seed = L.seeds.get(id) || L.seedOf.get(id) || null
1580
+ L.seeds.delete(id)
1581
+ L.seedOf.delete(id)
1582
+ postTo(id, { type: 'init', seed })
1583
+ break
1584
+ }
1585
+ case 'ready':
1586
+ L.readied.add(id)
1587
+ L.title = d.title || ''
1588
+ if (p.dark) postTo(id, { type: 'theme', dark: true })
1589
+ if (p.onMeta) p.onMeta(d.meta || '', d.title || '', Array.isArray(d.tokens) ? d.tokens : [], !!d.dark, Array.isArray(d.links) ? d.links : [])
1590
+ if (p.onHref && d.href) p.onHref(d.href)
1591
+ if (isCur) {
1592
+ postTo(id, { type: 'reveal' })
1593
+ postTo(id, { type: 'zoom', z: p.zoom || 1 })
1594
+ if (p.picking) postTo(id, pickMsg())
1595
+ if (p.demo) postTo(id, { type: 'demo', on: true })
1596
+ if (p.textEdit) postTo(id, { type: 'edit', on: true, hint: false })
1597
+ if (d.changes && d.changes.count && p.onNote) p.onNote({ changes: d.changes, file: '' })
1598
+ } else if (id > curRef.current) swap(id, d.changes)
1599
+ break
1600
+ case 'links':
1601
+ if (p.onLinks && Array.isArray(d.links)) p.onLinks(d.links)
1602
+ break
1603
+ case 'changed':
1604
+ if (isCur) { L.file = d.file || L.file; load(d.seed) }
1605
+ break
1606
+ case 'snap':
1607
+ if (isCur) { clearTimeout(L.snapTimer); load(d.seed) }
1608
+ break
1609
+ case 'bye':
1610
+ if (isCur) L.seeds.set(id, d.seed)
1611
+ break
1612
+ case 'updated':
1613
+ if (isCur && p.onNote) p.onNote({ changes: d.changes, file: '' })
1614
+ break
1615
+ case 'cancelled':
1616
+ if (isCur && p.onCancelled) p.onCancelled()
1617
+ break
1618
+ // 点选、改字只在用户开着对应模式时收(页面自己的脚本也能发这种消息,不能随时往输入框里塞字、往文件里写)
1619
+ case 'picked':
1620
+ if (isCur && p.picking && p.onPicked) p.onPicked(d.payload)
1621
+ break
1622
+ case 'nav':
1623
+ if (isCur && p.onNav && typeof d.path === 'string') p.onNav(d.path)
1624
+ break
1625
+ case 'edited':
1626
+ if (isCur && (p.textEdit || Date.now() - editOffAt.current < EDIT_GRACE_MS) && p.onEdited && typeof d.before === 'string' && typeof d.after === 'string') p.onEdited(d)
1627
+ break
1628
+ case 'editDone':
1629
+ if (isCur && p.onEditDone) p.onEditDone()
1630
+ break
1631
+ case 'captured': {
1632
+ const c = caps.current.get(d.id)
1633
+ if (c) {
1634
+ caps.current.delete(d.id)
1635
+ if (d.error) c.reject(new Error(d.error))
1636
+ else c.resolve(d)
1637
+ }
1638
+ break
1639
+ }
1640
+ default:
1641
+ }
1642
+ }
1643
+ window.addEventListener('message', on)
1644
+ return () => window.removeEventListener('message', on)
1645
+ }, [origin, src])
1646
+
1647
+ // 模型在不在干活、画布缩放比例、选取开关:告诉页面
1648
+ useEffect(() => { if (cur) postTo(cur, { type: 'busy', on: !!props.busy }) }, [!!props.busy, cur])
1649
+ const zq = Math.round((props.zoom || 1) * 100)
1650
+ useEffect(() => { if (cur) postTo(cur, { type: 'zoom', z: zq / 100 }) }, [zq])
1651
+ useEffect(() => { if (cur) postTo(cur, props.picking ? pickMsg() : { type: 'cancel' }) }, [!!props.picking])
1652
+ useEffect(() => { if (cur) postTo(cur, { type: 'theme', dark: !!props.dark }) }, [!!props.dark])
1653
+ useEffect(() => { if (cur) postTo(cur, { type: 'demo', on: !!props.demo }) }, [!!props.demo])
1654
+ useEffect(() => {
1655
+ // 刚退出改字(点按钮、Esc、模型开始干活):页面会把正在改的那一处交上来,这一小会儿还收
1656
+ if (editWas.current && !props.textEdit) editOffAt.current = Date.now()
1657
+ editWas.current = !!props.textEdit
1658
+ if (cur) postTo(cur, { type: 'edit', on: !!props.textEdit, hint: false })
1659
+ }, [!!props.textEdit])
1660
+
1661
+ // 刷新:先问当前这帧要滚动位置和指纹,问不到就直接载
1662
+ const seq = useRef(props.reloadSeq)
1663
+ useEffect(() => {
1664
+ if (seq.current === props.reloadSeq) return
1665
+ seq.current = props.reloadSeq
1666
+ const L = life.current
1667
+ if (!curRef.current) { load(null); return }
1668
+ clearTimeout(L.snapTimer)
1669
+ L.snapTimer = setTimeout(() => load(null), 400)
1670
+ postTo(curRef.current, { type: 'snapshot' })
1671
+ }, [props.reloadSeq])
1672
+
1673
+ // 页面没装上脚本(极少数,比如不是 HTML):载完等一会儿也换上来
1674
+ const onFrameLoad = (id) => {
1675
+ setTimeout(() => {
1676
+ const L = life.current
1677
+ if (!L.readied.has(id) && id > curRef.current && wins.current.has(id)) swap(id, null)
1678
+ }, 1600)
1679
+ }
1680
+
1681
+ return h('div', { className: 'ddp-scr' + (props.dark ? ' dk' : '') },
1682
+ ...frames.map((f) => h('iframe', {
1683
+ key: f.id,
1684
+ ref: (el) => { if (el) wins.current.set(f.id, el); else wins.current.delete(f.id) },
1685
+ className: 'ddp-frame' + (f.id === cur ? ' cur' : f.id < cur ? ' old' : ''),
1686
+ src: f.src,
1687
+ title: props.label || '',
1688
+ tabIndex: f.id === cur ? 0 : -1,
1689
+ 'aria-hidden': f.id === cur ? undefined : true,
1690
+ allow: 'clipboard-read; clipboard-write; fullscreen',
1691
+ onLoad: () => onFrameLoad(f.id),
1692
+ })),
1693
+ h(Skeleton, { gone: !!cur }))
1694
+ }
1695
+
1696
+ /** 类型菜单:网页 / App / 平板 / 海报 / 幻灯片 / 邮件 */
1697
+ function KindMenu({ t, kind, open, setOpen, onPick }) {
1698
+ const ref = useRef(null)
1699
+ useEffect(() => {
1700
+ if (!open) return undefined
1701
+ const off = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false) }
1702
+ const esc = (e) => { if (e.key === 'Escape') setOpen(false) }
1703
+ document.addEventListener('pointerdown', off, true)
1704
+ window.addEventListener('keydown', esc)
1705
+ return () => {
1706
+ document.removeEventListener('pointerdown', off, true)
1707
+ window.removeEventListener('keydown', esc)
1708
+ }
1709
+ }, [open])
1710
+ return h('div', { className: 'ddp-kind', ref },
1711
+ h('button', { className: 'ddp-btn' + (open ? ' sel' : ''), title: t('kind'), onClick: () => setOpen(!open) },
1712
+ h(I[kind]), h('span', null, t(KIND_KEY[kind])), h(I.caret)),
1713
+ open ? h('div', { className: 'ddp-menu', role: 'menu' },
1714
+ h('div', { className: 'hd' }, t('kind')),
1715
+ ...KIND_LIST.map((k) => h('button', { key: k, role: 'menuitemradio', 'aria-checked': k === kind, className: k === kind ? 'sel' : '', onClick: () => onPick(k) },
1716
+ h(I[k]), h('span', null, t(KIND_KEY[k])), h('em', null, KIND_SIZE[k].join('×'))))) : null)
1717
+ }
1718
+
1719
+ /** 设计规范面板:按组列出变量,点一个放进输入框 */
1720
+ function TokensPanel({ t, tokens, onPick, onAsk, onRef, onClose }) {
1721
+ const groups = {}
1722
+ for (const tok of tokens) (groups[tokenKind(tok)] = groups[tokenKind(tok)] || []).push(tok)
1723
+ const px = (v) => Math.max(0, parseFloat(v) * (/rem|em$/.test(v) ? 16 : 1)) || 0
1724
+ const preview = (kind, v) => {
1725
+ switch (kind) {
1726
+ case 'color': return h('i', { className: 'sw', style: { background: v } })
1727
+ case 'font': return h('span', { style: { fontFamily: v, fontSize: 15, fontWeight: 600 } }, 'Aa')
1728
+ case 'size': return h('span', { style: { fontSize: Math.min(22, px(v)), fontWeight: 600, lineHeight: 1 } }, '字')
1729
+ case 'space': return h('i', { className: 'bar', style: { width: Math.max(2, Math.min(24, px(v) / 2)) } })
1730
+ case 'radius': return h('i', { className: 'rd', style: { borderTopLeftRadius: Math.min(14, px(v)) } })
1731
+ case 'shadow': return h('i', { className: 'sh', style: { boxShadow: v } })
1732
+ default: return null
1733
+ }
1734
+ }
1735
+ return h('div', { className: 'ddp-tokens', onPointerDown: (e) => e.stopPropagation() },
1736
+ h('header', null, h(I.palette), h('b', null, t('tokens')), h('button', { className: 'ddp-btn', title: '×', onClick: onClose }, h(I.close))),
1737
+ tokens.length ? h('p', { className: 'tip' }, t('tokensTip')) : null,
1738
+ h('div', { className: 'bd' },
1739
+ tokens.length
1740
+ ? TOKEN_GROUPS.filter((g) => groups[g]).map((g) => h('div', { key: g },
1741
+ h('h4', null, t(TOKEN_KEY[g])),
1742
+ ...groups[g].map((tok) => h('button', { key: tok.name, className: 'ddp-tok', title: `${tok.name}: ${tok.value}`, onClick: () => onPick(tok) },
1743
+ h('span', { className: g === 'shadow' ? 'pv lt' : 'pv' }, preview(g, tok.value)),
1744
+ h('span', { className: 'tx' }, h('span', { className: 'nm' }, tok.name), h('span', { className: 'vl' }, tok.value))))))
1745
+ : h('div', { className: 'none' }, t('tokensEmpty'), h('div', null, h('button', { className: 'ddp-go', onClick: onAsk }, t('tokensAsk'))))),
1746
+ onRef ? h('footer', null, h('button', { title: t('refPickTip'), onClick: onRef }, h(I.image), h('span', null, t('refPick')))) : null)
1747
+ }
1748
+
1749
+ /** 菜单开着时:点外面、按 Esc 关掉 */
1750
+ function useDismiss(open, setOpen, ref) {
1751
+ useEffect(() => {
1752
+ if (!open) return undefined
1753
+ const off = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false) }
1754
+ const esc = (e) => { if (e.key === 'Escape') setOpen(false) }
1755
+ document.addEventListener('pointerdown', off, true)
1756
+ window.addEventListener('keydown', esc)
1757
+ return () => {
1758
+ document.removeEventListener('pointerdown', off, true)
1759
+ window.removeEventListener('keydown', esc)
1760
+ }
1761
+ }, [open])
1762
+ }
1763
+
1764
+ /** 导出菜单:这一屏(带外框 / 纯画面 / 整页长图)、整套展示图 */
1765
+ function ExportMenu({ t, kind, count, open, setOpen, onPick, disabled }) {
1766
+ const ref = useRef(null)
1767
+ useDismiss(open, setOpen, ref)
1768
+ const items = []
1769
+ if (FRAMED[kind]) items.push(['frame', I.single, t('exFrame')])
1770
+ items.push(['raw', I.image, t('exRaw')])
1771
+ if (kind !== 'poster' && kind !== 'slides') items.push(['long', I.long, t('exLong')]) // 海报、幻灯片画幅是定的,没有「整页」
1772
+ if (count > 1) items.push(['show', I.canvas, t('exShow')])
1773
+ return h('div', { className: 'ddp-kind', ref },
1774
+ h('button', { className: 'ddp-btn' + (open ? ' sel' : ''), title: t('exportT'), disabled, onClick: () => setOpen(!open) }, h(I.download)),
1775
+ open ? h('div', { className: 'ddp-menu', role: 'menu' },
1776
+ h('div', { className: 'hd' }, t('exportT')),
1777
+ ...items.map(([mode, Icon, label]) => h('button', { key: mode, role: 'menuitem', onClick: () => onPick(mode) }, h(Icon), h('span', null, label))),
1778
+ h('div', { className: 'ft' }, t('exWhere'))) : null)
1779
+ }
1780
+
1781
+ /**
1782
+ * 「更多」:右侧栏窄的时候,工具栏上放不下的几个按钮收进这里(哪几个收起来由容器宽度的 CSS 决定)。
1783
+ * items: [显示档 'o1' | 'o2', 图标, 名字, 选中, 不可用, 点了做什么(字符串就是在浏览器里打开的地址)]
1784
+ */
1785
+ function MoreMenu({ t, open, setOpen, has, items }) {
1786
+ const ref = useRef(null)
1787
+ useDismiss(open, setOpen, ref)
1788
+ return h('div', { className: 'ddp-kind ddp-more', ref },
1789
+ h('button', { className: 'ddp-btn' + (open ? ' sel' : '') + (has ? ' has' : ''), title: t('more'), onClick: () => setOpen(!open) }, h(I.more)),
1790
+ open ? h('div', { className: 'ddp-menu', role: 'menu' },
1791
+ ...items.filter(Boolean).map(([tier, Icon, label, on, off, run]) => (typeof run === 'string'
1792
+ ? h('a', { key: label, className: tier, role: 'menuitem', href: run, target: '_blank', rel: 'noopener noreferrer', onClick: () => setOpen(false) }, h(Icon), h('span', null, label))
1793
+ : h('button', { key: label, className: tier + (on ? ' sel' : ''), role: 'menuitem', disabled: off, onClick: () => { setOpen(false); run() } }, h(Icon), h('span', null, label))))) : null)
1794
+ }
1795
+
1796
+ /** 二维码矩阵(一行一串 0 / 1)→ SVG(四周留 4 格白边) */
1797
+ function QrCode({ rows }) {
1798
+ const n = rows.length
1799
+ let d = ''
1800
+ rows.forEach((row, y) => { for (let x = 0; x < row.length; x++) if (row[x] === '1') d += `M${x + 4} ${y + 4}h1v1h-1z` })
1801
+ return h('svg', { viewBox: `0 0 ${n + 8} ${n + 8}`, shapeRendering: 'crispEdges', role: 'img', 'aria-label': 'QR' }, h('path', { d, fill: '#000' }))
1802
+ }
1803
+
1804
+ /** 手机上看:打开就开局域网入口,给二维码;手机打开了显示一下 */
1805
+ function PhonePanel({ t, sid, path, seen, onCopy, onStop, onClose }) {
1806
+ const [res, setRes] = useState(null)
1807
+ const [err, setErr] = useState('')
1808
+ // 换了一屏:重新要一次(入口沿用),二维码和链接跟着指到现在这一屏
1809
+ useEffect(() => {
1810
+ let alive = true
1811
+ post('phone', { session: sid, on: true, path }).then((d) => { if (alive) { setRes(d); setErr('') } }).catch((e) => { if (alive) setErr(e.message) })
1812
+ return () => { alive = false }
1813
+ }, [sid, path])
1814
+ let bd
1815
+ if (err) bd = h('p', null, t('phoneFail', { msg: err }))
1816
+ else if (!res) bd = h('p', null, t('phoneStarting'))
1817
+ else if (!res.on) bd = h('p', null, res.error === 'no-lan' ? t('phoneNoLan') : t('phoneFail', { msg: res.message || res.error || '' }))
1818
+ else {
1819
+ bd = [
1820
+ h('div', { key: 'qr', className: 'qr' }, h(QrCode, { rows: res.qr || [] })),
1821
+ h('b', { key: 'how', className: 'how' }, t('phoneHow')),
1822
+ h('p', { key: 'wifi' }, t('phoneWifi')),
1823
+ seen ? h('span', { key: 'st', className: 'st ok' }, h(I.check), t('phoneSeen')) : h('span', { key: 'st', className: 'st' }, h('i'), t('phoneWait')),
1824
+ h('p', { key: 'fl', className: 'fine' }, t('phoneFollow')),
1825
+ h('div', { key: 'row', className: 'row' },
1826
+ h('button', { className: 'ddp-go', onClick: () => onCopy(res.url) }, t('phoneCopy')),
1827
+ h('button', { className: 'ddp-go', onClick: onStop }, t('phoneStop'))),
1828
+ h('p', { key: 'auto', className: 'fine' }, t('phoneAuto')),
1829
+ seen ? null : h('p', { key: 'help', className: 'fine' }, t('phoneHelp')),
1830
+ ]
1831
+ }
1832
+ return h('div', { className: 'ddp-tokens ddp-phone', onPointerDown: (e) => e.stopPropagation() },
1833
+ h('header', null, h(I.scan), h('b', null, t('phone')), h('button', { className: 'ddp-btn', title: '×', onClick: onClose }, h(I.close))),
1834
+ h('div', { className: 'bd' }, bd))
1835
+ }
1836
+
1837
+ /** 版本面板:现在 + 存过的几版(新的在前),点一版看当时的样子 */
1838
+ function HistoryPanel({ t, versions, ver, onPick, onClose }) {
1839
+ const files = (list) => {
1840
+ const names = list.map((f) => f.split('/').pop())
1841
+ return names.length <= 2 ? names.join(t('sep')) : t('vMore', { first: names.slice(0, 2).join(t('sep')), n: names.length, rest: names.length - 2 })
1842
+ }
1843
+ const labelOf = (v) => (v.label === '回退前' ? t('vSaved') : v.label || t('vBefore'))
1844
+ return h('div', { className: 'ddp-tokens ddp-hist', onPointerDown: (e) => e.stopPropagation() },
1845
+ h('header', null, h(I.history), h('b', null, t('history')), h('button', { className: 'ddp-btn', title: '×', onClick: onClose }, h(I.close))),
1846
+ h('p', { className: 'tip' }, t('histTip')),
1847
+ h('div', { className: 'bd' },
1848
+ h('button', { className: 'ddp-ver' + (!ver ? ' sel' : ''), onClick: () => onPick(null) },
1849
+ h('b', null, h('i', { className: 'live' }), t('now'))),
1850
+ versions.length
1851
+ ? versions.map((v) => h('button', { key: v.n, className: 'ddp-ver' + (ver && ver.n === v.n ? ' sel' : ''), onClick: () => onPick(v) },
1852
+ h('b', null, t('vN', { n: v.n }), h('em', null, hhmm(v.at))),
1853
+ h('span', null, labelOf(v)),
1854
+ v.changed && v.changed.length ? h('small', null, t('vChanged', { files: files(v.changed) }))
1855
+ : v.first ? h('small', null, t('vFirst', { n: v.count })) : null))
1856
+ : h('div', { className: 'none' }, t('histEmpty'))))
1857
+ }
1858
+
1859
+ /**
1860
+ * 画布:几屏排成网格,能拖、能缩放(触控板双指滑 = 平移,捏合 / ⌘ 滚轮 = 缩放)。
1861
+ * 平时每屏上盖一层「挡板」:点一下选中,双击只看这一屏;选取时拿掉挡板,直接在页面里点。
1862
+ */
1863
+ function Canvas(props) {
1864
+ const { t, screens, kind, size, ghost, picking, sel, labelOf } = props
1865
+ const ref = useRef(null)
1866
+ const [box, setBox] = useState({ w: 0, h: 0 })
1867
+ const [cam, setCam] = useState(null)
1868
+ const [anim, setAnim] = useState(false)
1869
+ const [drag, setDrag] = useState(false)
1870
+ const auto = useRef(true)
1871
+ const camRef = useRef(cam)
1872
+ camRef.current = cam
1873
+ const lastTap = useRef({ key: '', at: 0 })
1874
+ const f = FRAME[kind] || FRAME.web
1875
+ const [dw, dh] = deviceBox(kind, size)
1876
+ const n = screens.length + (ghost ? 1 : 0)
1877
+ // 多尺寸:每块自己的类型和大小(items[i] = { kind, size })
1878
+ const items = props.items || null
1879
+ const boxes = items ? items.map((it) => deviceBox(it.kind, it.size)) : null
1880
+ const L = box.w > 0 && box.h > 0 ? (boxes ? layoutRow(boxes, box.w, box.h) : layoutGrid(n, dw, dh, box.w, box.h)) : null
1881
+ const layoutKey = L ? [n, L.cols, dw, dh, box.w, box.h, boxes ? boxes.join(';') : ''].join('|') : ''
1882
+
1883
+ useEffect(() => {
1884
+ const el = ref.current
1885
+ if (!el) return undefined
1886
+ const measure = () => setBox({ w: el.clientWidth, h: el.clientHeight })
1887
+ measure()
1888
+ if (typeof ResizeObserver === 'undefined') return undefined
1889
+ const ro = new ResizeObserver(measure)
1890
+ ro.observe(el)
1891
+ return () => ro.disconnect()
1892
+ }, [])
1893
+
1894
+ // 屏数 / 类型 / 舞台大小变了:用户没自己动过镜头就重新摆好
1895
+ useEffect(() => {
1896
+ if (!L) return
1897
+ if (auto.current || !camRef.current) {
1898
+ setAnim(!!camRef.current)
1899
+ setCam(fitCam(L, box.w, box.h))
1900
+ }
1901
+ }, [layoutKey])
1902
+
1903
+ const zoomAt = (factor, px, py, smooth) => {
1904
+ const c = camRef.current
1905
+ if (!c) return
1906
+ const z = clampZ(c.z * factor)
1907
+ auto.current = false
1908
+ setAnim(!!smooth)
1909
+ setCam({ z, x: px - ((px - c.x) * z) / c.z, y: py - ((py - c.y) * z) / c.z })
1910
+ }
1911
+
1912
+ useEffect(() => {
1913
+ const el = ref.current
1914
+ if (!el) return undefined
1915
+ const onWheel = (e) => {
1916
+ e.preventDefault()
1917
+ const c = camRef.current
1918
+ if (!c) return
1919
+ if (e.ctrlKey || e.metaKey) {
1920
+ const r = el.getBoundingClientRect()
1921
+ zoomAt(Math.exp(-e.deltaY * 0.01), e.clientX - r.left, e.clientY - r.top, false)
1922
+ } else {
1923
+ auto.current = false
1924
+ setAnim(false)
1925
+ setCam({ ...c, x: c.x - e.deltaX, y: c.y - e.deltaY })
1926
+ }
1927
+ }
1928
+ el.addEventListener('wheel', onWheel, { passive: false })
1929
+ return () => el.removeEventListener('wheel', onWheel)
1930
+ }, [])
1931
+
1932
+ // 拖背景 / 挡板 = 平移;没拖动就算点了一下
1933
+ const onPointerDown = (e) => {
1934
+ if (e.button !== 0 || !camRef.current) return
1935
+ if (e.target.closest && e.target.closest('.ddp-zoom, .ddp-act')) return
1936
+ const keyEl = e.target.closest ? e.target.closest('[data-key]') : null
1937
+ const d = { x: e.clientX, y: e.clientY, cam: camRef.current, moved: false, key: keyEl ? keyEl.getAttribute('data-key') : '' }
1938
+ const move = (ev) => {
1939
+ const dx = ev.clientX - d.x
1940
+ const dy = ev.clientY - d.y
1941
+ if (!d.moved && Math.hypot(dx, dy) < 4) return
1942
+ if (!d.moved) {
1943
+ d.moved = true
1944
+ auto.current = false
1945
+ setAnim(false)
1946
+ setDrag(true)
1947
+ }
1948
+ setCam({ ...d.cam, x: d.cam.x + dx, y: d.cam.y + dy })
1949
+ }
1950
+ const up = () => {
1951
+ window.removeEventListener('pointermove', move)
1952
+ window.removeEventListener('pointerup', up)
1953
+ setDrag(false)
1954
+ if (d.moved) return
1955
+ if (props.onSelect) props.onSelect(d.key)
1956
+ const now = Date.now()
1957
+ if (d.key && props.onOpen && lastTap.current.key === d.key && now - lastTap.current.at < 380) props.onOpen(d.key)
1958
+ lastTap.current = { key: d.key, at: now }
1959
+ }
1960
+ window.addEventListener('pointermove', move)
1961
+ window.addEventListener('pointerup', up)
1962
+ }
1963
+
1964
+ const center = (factor) => zoomAt(factor, box.w / 2, box.h / 2, true)
1965
+ const fit = () => {
1966
+ if (!L) return
1967
+ auto.current = true
1968
+ setAnim(true)
1969
+ setCam(fitCam(L, box.w, box.h))
1970
+ }
1971
+
1972
+ const z = cam ? cam.z : 1
1973
+ let grid = 24 * z
1974
+ while (grid < 12) grid *= 2
1975
+ while (grid > 48) grid /= 2
1976
+ const lab = (key, body, w = dw) => h('div', { className: 'ddp-lab', 'data-key': key, style: { width: w * z, transform: `scale(${1 / z})` } }, ...body)
1977
+ const cells = []
1978
+ if (cam && L) {
1979
+ screens.forEach((sc, i) => {
1980
+ const on = sel === sc.key
1981
+ const [cw, ch] = boxes ? boxes[i] : [dw, dh]
1982
+ const fr = items ? FRAME[items[i].kind] || FRAME.web : f
1983
+ cells.push(h('div', {
1984
+ key: sc.id || sc.key,
1985
+ className: 'ddp-cell' + (on ? ' sel' : ''),
1986
+ style: { left: L.pos[i].x, top: L.pos[i].y, width: cw, height: ch },
1987
+ },
1988
+ lab(sc.key, [h('span', { key: 's' }, labelOf(sc)), on && !picking && props.actions ? h('span', { key: 'a', className: 'ddp-act' }, ...props.actions(sc)) : null], cw),
1989
+ props.render(sc, z, i),
1990
+ picking ? null : h('div', { className: 'ddp-shield', 'data-key': sc.key, title: items ? '' : t('openScreen'), style: { borderRadius: fr.r } })))
1991
+ })
1992
+ if (ghost) {
1993
+ const p = L.pos[n - 1]
1994
+ cells.push(h('div', { key: '__ghost', className: 'ddp-cell ghost', style: { left: p.x, top: p.y, width: dw, height: dh } },
1995
+ lab('', [h('i', { key: 'i', className: 'sp' }), h('span', { key: 's' }, t('generating'))]),
1996
+ h(Device, { kind, w: size[0], h: size[1], label: '' }, h(Skeleton, {}))))
1997
+ }
1998
+ }
1999
+ let flowSvg = null
2000
+ if (cam && L && props.flows && props.flows.length && !picking) {
2001
+ const lines = flowLines(props.flows, screens.map((x) => x.key), L.pos, kind, size, { lift: LAB / z }).filter((ln) => !sel || ln.from === sel || ln.to === sel)
2002
+ if (lines.length) {
2003
+ const sw = 2 / z
2004
+ const a = 9 / z
2005
+ flowSvg = h('svg', { key: '__flows', className: 'ddp-flows', width: 1, height: 1 },
2006
+ ...lines.map((ln, i) => h('g', { key: i, className: ln.from === sel ? '' : 'dim' },
2007
+ h('path', { d: ln.d, strokeWidth: sw }),
2008
+ h('path', { className: 'hd', d: `M${ln.ex} ${ln.ey}l${-a * ln.tip} ${-a * 0.62}v${a * 1.24}z` }),
2009
+ h('circle', { cx: ln.sx, cy: ln.sy, r: 4.5 / z, strokeWidth: 1.5 / z }))))
2010
+ }
2011
+ }
2012
+ return h('div', {
2013
+ className: 'ddp-canvas' + (drag ? ' drag' : ''),
2014
+ ref,
2015
+ onPointerDown,
2016
+ style: cam ? { backgroundSize: `${grid}px ${grid}px`, backgroundPosition: `${cam.x}px ${cam.y}px` } : undefined,
2017
+ },
2018
+ cam && L ? h('div', {
2019
+ className: 'ddp-world' + (anim ? ' anim' : ''),
2020
+ style: { transform: `translate(${cam.x}px, ${cam.y}px) scale(${z})`, '--z': z },
2021
+ }, ...cells, flowSvg) : null,
2022
+ h('div', { className: 'ddp-zoom' },
2023
+ h('button', { className: 'ddp-btn', title: t('zoomOut'), onClick: () => center(1 / 1.25) }, h(I.minus)),
2024
+ h('b', null, Math.round(z * 100) + '%'),
2025
+ h('button', { className: 'ddp-btn', title: t('zoomIn'), onClick: () => center(1.25) }, h(I.plus)),
2026
+ h('button', { className: 'ddp-btn', title: t('fit'), onClick: fit }, h(I.fit))))
2027
+ }
2028
+
2029
+ // ── 右侧栏标签页 ─────────────────────────────────────────────────────────
2030
+
2031
+ function PreviewTab(props) {
2032
+ const t = props.t
2033
+ const sid = props.sid || props.sessionId
2034
+ const [st, poll] = usePreview(sid)
2035
+ // 刚打开(DSH 重启)时状态还没拿到、或服务端正在接着开上次的预览:先空着,别闪一下「还没有要预览的页面」;拿不到状态最多等 2.5 秒
2036
+ const [waited, setWaited] = useState(false)
2037
+ useEffect(() => { const tm = setTimeout(() => setWaited(true), 2500); return () => clearTimeout(tm) }, [])
2038
+ const [picking, setPicking] = useState(false)
2039
+ const [textEdit, setTextEdit] = useState(false) // 改字:点页面里的文字直接改
2040
+ const [editing, setEditing] = useState(false)
2041
+ const [menu, setMenu] = useState(false)
2042
+ const [toast, setToast] = useState('')
2043
+ const [flash, setFlash] = useState('')
2044
+ const [reloadSeq, setReloadSeq] = useState(0)
2045
+ const [info, setInfo] = useState({}) // 目录|屏 → { meta, title }(页面报上来的)
2046
+ const [sel, setSel] = useState('')
2047
+ const [panel, setPanel] = useState(false)
2048
+ const [dropping, setDropping] = useState(false) // 正拖着图片进来
2049
+ const refInput = useRef(null)
2050
+ const [exMenu, setExMenu] = useState(false)
2051
+ const [more, setMore] = useState(false) // 窄的时候收起来的几个按钮
2052
+ const [hist, setHist] = useState(false) // 版本面板
2053
+ const [versions, setVersions] = useState([])
2054
+ const [ver, setVer] = useState(null) // 正在看的旧版本
2055
+ const [cmp, setCmp] = useState(false) // 和现在左右对比
2056
+ const [cx, setCx] = useState(50)
2057
+ const [cmpDrag, setCmpDrag] = useState(false)
2058
+ const [dark, setDark] = useState(false)
2059
+ const [phone, setPhone] = useState(false) // 手机上看的面板
2060
+ const [multi, setMulti] = useState(false) // 多尺寸并排
2061
+ const [demo, setDemo] = useState(null) // 演示:{ stack: [屏…], layers: [{ id, key, dir, shown }] }
2062
+ const apis = useRef(new Map()) // 屏 → 截图用的接口
2063
+ const cmpRef = useRef(null)
2064
+ const [box, setBox] = useState({ w: 0, h: 0 })
2065
+ const stageRef = useRef(null)
2066
+ const flashTimer = useRef(null)
2067
+ const toastTimer = useRef(null)
2068
+ const stRef = useRef(st)
2069
+ stRef.current = st
2070
+ const url = st && st.url ? st.url : ''
2071
+ const origin = url ? new URL(url).origin : ''
2072
+ // 预览的是哪个目录 / 哪个开发服务器:预览服务器被关掉重开、端口变了也还是同一个(不当成换了页面,不重播动画、不退出演示)
2073
+ const tg = st && st.target
2074
+ const site = tg ? (tg.kind === 'file' ? 'file:' + String(tg.file || '').replace(/[\\/][^\\/]*$/, '') : 'proxy:' + tg.port) : origin
2075
+ const busy = !!(st && st.turn)
2076
+ const screens = (st && st.screens) || []
2077
+
2078
+ // 哪几屏是「刚生成的」:第一次看到它时模型正在干活 → 第一次显示时播生成动画(播过就不再播)
2079
+ // 这一轮开始时已有的屏记下来:这轮里出了新的一屏,就在画布末尾留个「生成中…」等下一屏;
2080
+ // 一阵子(GHOST_MS)没再出新屏多半是在改了,占位撤掉;正在写一个还没有的 HTML 时一定留着
2081
+ const marks = useRef({ site: '', born: new Map(), built: new Set(), turnKeys: null, lastNew: 0 })
2082
+ const M = marks.current
2083
+ if (M.site !== site) {
2084
+ M.site = site
2085
+ M.born = new Map()
2086
+ M.built = new Set()
2087
+ M.turnKeys = null
2088
+ M.lastNew = 0
2089
+ }
2090
+ if (!busy) M.turnKeys = null
2091
+ else if (!M.turnKeys) M.turnKeys = new Set(screens.map((s) => s.key))
2092
+ for (const sc of screens) {
2093
+ if (M.born.has(sc.key)) continue
2094
+ M.born.set(sc.key, busy)
2095
+ if (busy) M.lastNew = Date.now()
2096
+ }
2097
+ const isBorn = (key) => !!M.born.get(key) && !M.built.has(key)
2098
+
2099
+ const infoOf = (key) => info[site + '|' + key] || null
2100
+ const ik = (sc) => (sc.v ? 'v' + sc.v + '|' : '') + sc.key // 旧版本的屏单独记标题
2101
+ const focus = (st && st.focus) || ''
2102
+ const main = infoOf(focus) || (screens[0] && infoOf(screens[0].key)) || {}
2103
+ const meta = parseMeta(main.meta || '')
2104
+ const tokens = main.tokens || []
2105
+ const kind = (st && st.kind) || meta.kind || 'web'
2106
+ const size = sizeOf((st && st.size) || meta.size, kind)
2107
+ const view = (st && st.view) || (screens.length >= 2 && screens.length <= 12 ? 'canvas' : 'single')
2108
+ const canvas = view === 'canvas'
2109
+ const writingNew = !!(st && st.writing) && /\.html?$/i.test(st.writing) && !screens.some((s) => s.key === st.writing)
2110
+ const fresh = !!M.turnKeys && screens.some((s) => !M.turnKeys.has(s.key)) && Date.now() - M.lastNew < GHOST_MS
2111
+ const ghost = canvas && busy && !ver && (writingNew || fresh)
2112
+ const [, setTick] = useState(0)
2113
+ useEffect(() => {
2114
+ if (!fresh || writingNew) return
2115
+ const id = setTimeout(() => setTick((n) => n + 1), Math.max(0, M.lastNew + GHOST_MS - Date.now()) + 50)
2116
+ return () => clearTimeout(id)
2117
+ }, [fresh, writingNew, M.lastNew])
2118
+ const labelOf = (sc) => (infoOf(ik(sc)) || {}).title || sc.label
2119
+ const anyDark = Object.keys(info).some((k) => k.startsWith(site + '|') && info[k].dark)
2120
+ const fileTarget = !!(st && st.target && st.target.kind === 'file')
2121
+
2122
+ // 看旧版本:画布上换成那一版的几屏(按现在的顺序,那一版独有的接在后面)
2123
+ const vbase = ver ? `${origin}/__dsh_design__/v/${ver.n}/` : ''
2124
+ const shown = ver
2125
+ ? [...screens.map((x) => x.key).filter((k) => ver.screens.includes(k)), ...ver.screens.filter((k) => !screens.some((x) => x.key === k))]
2126
+ .map((key) => ({ key, id: `v${ver.n}|${key}`, v: ver.n, url: vbase + encodeURIComponent(key), label: key.replace(/\.html?$/i, '') }))
2127
+ : screens
2128
+
2129
+ // 手机上看:现在这一屏在预览服务器上的路径;手机开着时,电脑上换屏手机跟着跳
2130
+ const phoneOn = !!(st && st.phone && st.phone.on)
2131
+ const demoKey = demo ? demo.layers[demo.layers.length - 1].key : ''
2132
+ const urlOfKey = (key) => {
2133
+ const sc = screens.find((x) => x.key === key)
2134
+ if (sc) return sc.url
2135
+ return fileTarget ? `${origin}/${key.split('/').map(encodeURIComponent).join('/')}` : origin + key
2136
+ }
2137
+ const curScreen = demo ? { key: demoKey, url: urlOfKey(demoKey) } : screens.find((x) => x.key === (canvas ? sel || focus : focus)) || screens[0]
2138
+ let curPath = '/'
2139
+ try { if (curScreen) { const u = new URL(curScreen.url); curPath = u.pathname + u.search } } catch { /* 地址不对 */ }
2140
+ const lastFollow = useRef('')
2141
+ useEffect(() => {
2142
+ if (!phoneOn || ver) { lastFollow.current = ''; return }
2143
+ if (lastFollow.current === curPath) return
2144
+ const first = !lastFollow.current
2145
+ lastFollow.current = curPath
2146
+ if (!first) post('phone', { session: sid, follow: curPath }).catch(() => {})
2147
+ }, [phoneOn, curPath, ver])
2148
+
2149
+ const say = (text, act) => {
2150
+ setToast({ text, act: act || null })
2151
+ clearTimeout(toastTimer.current)
2152
+ toastTimer.current = setTimeout(() => setToast(''), act ? 6000 : 2600)
2153
+ }
2154
+
2155
+ const showFlash = (text) => {
2156
+ setFlash(text)
2157
+ clearTimeout(flashTimer.current)
2158
+ flashTimer.current = setTimeout(() => setFlash(''), 2800)
2159
+ }
2160
+
2161
+ const note = (n, sc) => {
2162
+ const changes = n.changes
2163
+ const name = n.file ? ' ' + String(n.file).split(/[\\/]/).pop() : canvas && sc ? ' ' + labelOf(sc) : ''
2164
+ showFlash(changes && changes.whole
2165
+ ? t('updatedWhole', { file: name })
2166
+ : changes && changes.count ? t('updatedN', { file: name, n: changes.count }) : t('updated', { file: name }))
2167
+ }
2168
+
2169
+ const onBuilt = (sc, title) => {
2170
+ M.built.add(sc.key)
2171
+ showFlash(t('born', { name: title || labelOf(sc) }))
2172
+ }
2173
+
2174
+ const remember = (key, m, title, toks, dk, links) => {
2175
+ const k = site + '|' + key
2176
+ const sig = JSON.stringify([toks || [], links || []])
2177
+ setInfo((cur) => (cur[k] && cur[k].meta === m && cur[k].title === title && cur[k].sig === sig && cur[k].dark === dk ? cur
2178
+ : { ...cur, [k]: { meta: m, title, tokens: toks || [], links: links || [], sig, dark: dk } }))
2179
+ }
2180
+
2181
+ const rememberLinks = (key, links) => {
2182
+ const k = site + '|' + key
2183
+ setInfo((cur) => {
2184
+ const c = cur[k]
2185
+ if (!c) return cur
2186
+ const sig = JSON.stringify([c.tokens || [], links || []])
2187
+ return c.sig === sig ? cur : { ...cur, [k]: { ...c, links: links || [], sig } }
2188
+ })
2189
+ }
2190
+
2191
+ /** 改设置(类型 / 视图 / 换一屏):先在本地改了显示,再告诉服务端;换屏要等服务端给新地址 */
2192
+ const change = (patch) => {
2193
+ if (!patch.focus && poll && poll.state) poll.set({ ...poll.state, ...patch })
2194
+ post('view', { session: sid, ...patch }).then((s) => poll && poll.set(s), (e) => say(t('openFailed', { msg: e.message })))
2195
+ }
2196
+
2197
+ // 量舞台大小,单页时按比例缩放
2198
+ useEffect(() => {
2199
+ const el = stageRef.current
2200
+ if (!el) return undefined
2201
+ const measure = () => setBox({ w: el.clientWidth, h: el.clientHeight })
2202
+ measure()
2203
+ if (typeof ResizeObserver === 'undefined') return undefined
2204
+ const ro = new ResizeObserver(measure)
2205
+ ro.observe(el)
2206
+ return () => ro.disconnect()
2207
+ }, [!!url])
2208
+
2209
+ useEffect(() => { setPicking(false); setTextEdit(false); setSel(''); setVer(null); setCmp(false) }, [site])
2210
+
2211
+ // 版本列表:面板开着时拉一次,存了新版本(vrev 变了)再拉
2212
+ const vrev = st ? st.vrev : 0
2213
+ useEffect(() => {
2214
+ if (!hist || !sid || !fileTarget) return undefined
2215
+ let dead = false
2216
+ fetch(`${API}history?session=${encodeURIComponent(sid)}`, { credentials: 'same-origin', cache: 'no-store' })
2217
+ .then((r) => r.json())
2218
+ .then((d) => { if (!dead) setVersions(Array.isArray(d.versions) ? d.versions : []) }, () => {})
2219
+ return () => { dead = true }
2220
+ }, [hist, sid, vrev, origin])
2221
+
2222
+ const insert = (text, done = 'inserted') => {
2223
+ const ia = props.inputActions
2224
+ try {
2225
+ if (ia && typeof ia.insertText === 'function') {
2226
+ const span = typeof ia.captureInsertion === 'function' ? ia.captureInsertion() : undefined
2227
+ // 光标前面已经有字(用户先打了要求)→ 另起一行,不和那句话粘在一起
2228
+ const lead = span && span.start > 0 ? '\n' : ''
2229
+ if (ia.insertText(lead + text, span)) return say(t(done))
2230
+ }
2231
+ } catch (e) {
2232
+ console.warn('[dsh-design-studio] insertText 失败', e)
2233
+ }
2234
+ if (navigator.clipboard) {
2235
+ navigator.clipboard.writeText(text).then(() => say(t('copied')), (e) => say(t('failed', { msg: e.message })))
2236
+ } else say(t('failed', { msg: 'no input' }))
2237
+ }
2238
+
2239
+ const deliver = (payload, note) => {
2240
+ setPicking(false)
2241
+ insert(formatPick(payload, stRef.current) + (note || ''))
2242
+ }
2243
+
2244
+ const togglePick = () => {
2245
+ if (picking) { setPicking(false); return }
2246
+ setTextEdit(false)
2247
+ setPicking(true)
2248
+ if (!canvas) {
2249
+ // 单页:把焦点给页面,Esc 在页面里也管用
2250
+ setTimeout(() => { try { const f = stageRef.current && stageRef.current.querySelector('.ddp-frame.cur'); f && f.focus() } catch { /* 用鼠标也行 */ } }, 0)
2251
+ }
2252
+ }
2253
+
2254
+ // ── 改字:页面里改完一处,本地文件直接写回;开发服务器(或源码里找不到这段字)就交给模型 ──
2255
+ const toggleText = () => {
2256
+ if (textEdit) { setTextEdit(false); return }
2257
+ setPicking(false)
2258
+ setTextEdit(true)
2259
+ }
2260
+
2261
+ const askText = (sc, d) => insert(t('textAsk', { before: d.before, after: d.after, where: whereOf(sc.url, stRef.current) }), 'insertedSend')
2262
+
2263
+ const onEdited = (sc, d) => {
2264
+ if (!fileTarget) return askText(sc, d)
2265
+ post('edit', { session: sid, key: sc.key, before: d.before, after: d.after, nth: d.nth })
2266
+ .then((r) => (r && r.ok ? say(t('textSaved', { file: r.file })) : askText(sc, d)), () => askText(sc, d))
2267
+ }
2268
+
2269
+ // 模型在干活时不能改字(它可能正整份重写文件,改了会被盖掉)
2270
+ useEffect(() => { if (ver || demo || busy) setTextEdit(false) }, [!!ver, !!demo, busy])
2271
+
2272
+ useEffect(() => {
2273
+ if (!dropping) return undefined
2274
+ const off = () => setDropping(false)
2275
+ window.addEventListener('dragend', off, true)
2276
+ window.addEventListener('drop', off, true)
2277
+ return () => { window.removeEventListener('dragend', off, true); window.removeEventListener('drop', off, true) }
2278
+ }, [dropping])
2279
+
2280
+ useEffect(() => {
2281
+ if (!textEdit) return undefined
2282
+ const onKey = (e) => { if (e.key === 'Escape') setTextEdit(false) }
2283
+ window.addEventListener('keydown', onKey)
2284
+ return () => window.removeEventListener('keydown', onKey)
2285
+ }, [textEdit])
2286
+
2287
+ useEffect(() => {
2288
+ if (!picking) return undefined
2289
+ const onKey = (e) => { if (e.key === 'Escape') setPicking(false) }
2290
+ window.addEventListener('keydown', onKey)
2291
+ return () => window.removeEventListener('keydown', onKey)
2292
+ }, [picking])
2293
+
2294
+ // ── 演示:点页面里的按钮跳到别的屏,前进从右边滑进来、返回从左边 ──
2295
+ const demoSeq = useRef(0)
2296
+ const startDemo = () => {
2297
+ const k = canvas ? sel || focus : focus
2298
+ const key = screens.some((x) => x.key === k) ? k : screens[0] ? screens[0].key : focus
2299
+ setPicking(false)
2300
+ setPanel(false)
2301
+ setHist(false)
2302
+ setDemo({ stack: [key], layers: [{ id: ++demoSeq.current, key, dir: 0, shown: false }] })
2303
+ const inf = infoOf(key)
2304
+ if (inf && !(inf.links || []).length) say(t('demoNone'))
2305
+ }
2306
+ const demoGo = (path) => demoTo(keyOfPath(path, fileTarget))
2307
+ const demoTo = (key) => {
2308
+ setDemo((d) => {
2309
+ if (!d) return d
2310
+ const top = d.stack[d.stack.length - 1]
2311
+ if (key === top) return d
2312
+ const back = d.stack.length > 1 && d.stack[d.stack.length - 2] === key
2313
+ const stack = back ? d.stack.slice(0, -1) : [...d.stack, key].slice(-30)
2314
+ const last = d.layers[d.layers.length - 1]
2315
+ return { stack, layers: [last, { id: ++demoSeq.current, key, dir: back ? -1 : 1, shown: false }] }
2316
+ })
2317
+ }
2318
+ const demoRestart = () => setDemo((d) => {
2319
+ if (!d || d.stack.length < 2) return d
2320
+ const last = d.layers[d.layers.length - 1]
2321
+ return { stack: [d.stack[0]], layers: [last, { id: ++demoSeq.current, key: d.stack[0], dir: -1, shown: false }] }
2322
+ })
2323
+ const demoBack = () => setDemo((d) => {
2324
+ if (!d || d.stack.length < 2) return d
2325
+ const stack = d.stack.slice(0, -1)
2326
+ const last = d.layers[d.layers.length - 1]
2327
+ return { stack, layers: [last, { id: ++demoSeq.current, key: stack[stack.length - 1], dir: -1, shown: false }] }
2328
+ })
2329
+ const demoShown = (id) => {
2330
+ setDemo((d) => (d ? { ...d, layers: d.layers.map((l) => (l.id === id ? { ...l, shown: true } : l)) } : d))
2331
+ setTimeout(() => setDemo((d) => (d && d.layers.length > 1 && d.layers[d.layers.length - 1].id === id ? { ...d, layers: [d.layers[d.layers.length - 1]] } : d)), 480)
2332
+ }
2333
+ // 页面自己用脚本跳了(没走我们拦下的链接):只改记下的是哪一屏,不重载
2334
+ const demoHref = (id, href) => {
2335
+ let key = ''
2336
+ try { key = keyOfPath(decodeURIComponent(new URL(href).pathname), fileTarget) } catch { return }
2337
+ setDemo((d) => {
2338
+ if (!d) return d
2339
+ const last = d.layers[d.layers.length - 1]
2340
+ if (last.id !== id || last.key === key) return d
2341
+ return { stack: [...d.stack.slice(0, -1), key], layers: d.layers.map((l) => (l.id === id ? { ...l, key } : l)) }
2342
+ })
2343
+ }
2344
+ useEffect(() => {
2345
+ if (!demo) return undefined
2346
+ const onKey = (e) => { if (e.key === 'Escape') setDemo(null) }
2347
+ window.addEventListener('keydown', onKey)
2348
+ return () => window.removeEventListener('keydown', onKey)
2349
+ }, [!!demo])
2350
+ useEffect(() => { setDemo(null) }, [site])
2351
+
2352
+ const openSingle = () => {
2353
+ if (sel && sel !== focus && screens.some((s) => s.key === sel)) change({ view: 'single', focus: sel })
2354
+ else change({ view: 'single' })
2355
+ }
2356
+
2357
+ const renderScreen = (sc, z, fill) => h(Device, { kind, w: size[0], h: size[1], fill, label: labelOf(sc) },
2358
+ h(Screen, {
2359
+ src: sc.url,
2360
+ label: labelOf(sc),
2361
+ busy: busy && !sc.v,
2362
+ born: !sc.v && isBorn(sc.key),
2363
+ picking: picking && !sc.v,
2364
+ textEdit: textEdit && !sc.v,
2365
+ dark,
2366
+ apis: apis.current,
2367
+ apiKey: ik(sc),
2368
+ quietPick: canvas,
2369
+ zoom: z,
2370
+ reloadSeq,
2371
+ onPicked: (payload) => { if (canvas) setSel(sc.key); deliver(payload) },
2372
+ onCancelled: () => setPicking(false),
2373
+ onEdited: (d) => onEdited(sc, d),
2374
+ onEditDone: () => setTextEdit(false),
2375
+ onNote: (n) => note(n, sc),
2376
+ onMeta: (m, title, toks, dk, links) => remember(ik(sc), m, title, toks, dk, links),
2377
+ onLinks: (links) => rememberLinks(ik(sc), links),
2378
+ onBuilt: (title) => onBuilt(sc, title),
2379
+ }))
2380
+
2381
+ // ── 导出图片 ──
2382
+ const shotOf = async (sc, full) => {
2383
+ let api = apis.current.get(ik(sc))
2384
+ for (let i = 0; i < 40 && !(api && api.ready()); i++) {
2385
+ await new Promise((r) => setTimeout(r, 150))
2386
+ api = apis.current.get(ik(sc))
2387
+ }
2388
+ if (!api) throw new Error('not loaded')
2389
+ const r = await api.capture({ full: !!full, scale: 2 })
2390
+ return loadImg(r.png)
2391
+ }
2392
+
2393
+ const saveImage = async (name, blob) => {
2394
+ const png = await blobB64(blob)
2395
+ const r = await post('export', { session: sid, name, png })
2396
+ const copied = await copyImage(blob)
2397
+ const file = String(r.path || '').split(/[\\/]/).pop()
2398
+ say(t(copied ? 'exportedCopied' : 'exported', { name: file }), { label: t('reveal'), run: () => post('reveal', { path: r.path }).catch(() => {}) })
2399
+ }
2400
+
2401
+ const doExport = async (mode, key) => {
2402
+ setExMenu(false)
2403
+ say(t('exporting'))
2404
+ try {
2405
+ const prefix = ver ? `v${ver.n}-` : ''
2406
+ const suffix = dark ? '-dark' : ''
2407
+ if (mode === 'show') {
2408
+ const shots = []
2409
+ for (const sc of shown) {
2410
+ try { shots.push({ img: await shotOf(sc, false), title: labelOf(sc) }) } catch (e) { console.warn('[dsh-design-studio] 截图失败', sc.key, e) }
2411
+ }
2412
+ if (!shots.length) throw new Error('no screens')
2413
+ const sp = splitTitles(shots.map((x) => x.title))
2414
+ const colors = pickColors(tokens)
2415
+ const blob = await composeShowcase({
2416
+ kind,
2417
+ size,
2418
+ shots: shots.map((x, i) => ({ img: x.img, label: sp.labels[i] || x.title })),
2419
+ title: sp.brand || (st && st.target && st.target.kind === 'file' ? String(st.target.file).split(/[\\/]/).slice(-2, -1)[0] : st.label) || '',
2420
+ sub: t('showSub', { n: shots.length, kind: t(KIND_KEY[kind]), date: ymd(new Date()) }),
2421
+ bg: colors.bg,
2422
+ accent: colors.accent || ACCENT,
2423
+ mark: t('madeWith'),
2424
+ })
2425
+ await saveImage(prefix + (sp.brand || 'showcase') + suffix, blob)
2426
+ return
2427
+ }
2428
+ const k = key || (canvas ? sel || focus : focus)
2429
+ const sc = shown.find((x) => x.key === k) || shown[0]
2430
+ if (!sc) throw new Error('no screen')
2431
+ const base = prefix + sc.key.replace(/\.html?$/i, '') + suffix
2432
+ const img = await shotOf(sc, mode === 'long')
2433
+ if (mode === 'frame') await saveImage(base, await composeOne(kind, size, img, labelOf(sc)))
2434
+ else {
2435
+ const c = document.createElement('canvas')
2436
+ c.width = img.width
2437
+ c.height = img.height
2438
+ c.getContext('2d').drawImage(img, 0, 0)
2439
+ await saveImage(base + (mode === 'long' ? '-full' : ''), await canvasBlob(c))
2440
+ }
2441
+ } catch (e) {
2442
+ say(t('exportFail', { msg: e.message }))
2443
+ }
2444
+ }
2445
+
2446
+ const doRestore = () => {
2447
+ if (!ver) return
2448
+ const n = ver.n
2449
+ post('restore', { session: sid, n }).then((d) => {
2450
+ setVersions(Array.isArray(d.versions) ? d.versions : [])
2451
+ setVer(null)
2452
+ setCmp(false)
2453
+ say(t('restored', { n }))
2454
+ }, (e) => say(t('failed', { msg: e.message })))
2455
+ }
2456
+
2457
+ const askVariants = (sc) => {
2458
+ const b = sc.key.replace(/\.html?$/i, '')
2459
+ insert(t('variantText', { name: labelOf(sc), b, where: whereOf(sc.url, stRef.current) }), 'insertedSend')
2460
+ }
2461
+
2462
+ const actions = (sc) => [
2463
+ h('button', { key: 'x', className: 'ddp-btn', title: t('exThis'), onClick: () => doExport(FRAMED[kind] ? 'frame' : 'raw', sc.key) }, h(I.download)),
2464
+ sc.v ? null : h('button', { key: 'v', className: 'ddp-btn', title: t('variants'), onClick: () => askVariants(sc) }, h(I.sparkle)),
2465
+ h('button', { key: 'o', className: 'ddp-btn', title: t('openScreen'), onClick: () => change({ view: 'single', focus: sc.key }) }, h(I.single)),
2466
+ ]
2467
+
2468
+ // 对比:拖中间的把手
2469
+ const dragKnob = (e) => {
2470
+ const el = cmpRef.current
2471
+ if (!el) return
2472
+ e.preventDefault()
2473
+ const target = e.currentTarget
2474
+ try { target.setPointerCapture(e.pointerId) } catch { /* 老浏览器 */ }
2475
+ setCmpDrag(true)
2476
+ const move = (ev) => {
2477
+ const r = el.getBoundingClientRect()
2478
+ if (r.width > 0) setCx(Math.max(0, Math.min(100, ((ev.clientX - r.left) / r.width) * 100)))
2479
+ }
2480
+ const up = () => {
2481
+ target.removeEventListener('pointermove', move)
2482
+ target.removeEventListener('pointerup', up)
2483
+ target.removeEventListener('pointercancel', up)
2484
+ setCmpDrag(false)
2485
+ }
2486
+ target.addEventListener('pointermove', move)
2487
+ target.addEventListener('pointerup', up)
2488
+ target.addEventListener('pointercancel', up)
2489
+ move(e)
2490
+ }
2491
+
2492
+ const sizeSet = SIZE_SETS[kind] || null
2493
+ const inMulti = multi && !!sizeSet && !ver
2494
+ // 单页看手机 / 平板时,选择、改字的提示条放在设备上方,给它让出一条(不然压住设备顶边)
2495
+ const hintTop = (picking || textEdit) && !canvas && !inMulti && !ver && !demo && kind !== 'web'
2496
+ const flows = canvas && !inMulti && !picking && !textEdit ? flowsOf(shown, (sc) => (infoOf(ik(sc)) || {}).links, fileTarget) : []
2497
+
2498
+ const toggleDemo = () => (demo ? setDemo(null) : startDemo())
2499
+ const toggleHist = () => { setHist(!hist); setPanel(false); setPhone(false) }
2500
+ const togglePhone = () => { setPhone(!phone); setPanel(false); setHist(false) }
2501
+ const reload = () => setReloadSeq((x) => x + 1)
2502
+
2503
+ const bar = h('div', { className: 'ddp-bar' },
2504
+ editing || !url
2505
+ ? h(AddressForm, { t, sid, initial: st && st.target && st.target.kind === 'file' ? st.target.file : st && st.label ? st.label : '', autoFocus: true, onDone: () => setEditing(false) })
2506
+ : h('div', { className: 'ddp-addr', title: t('editAddr'), onClick: () => setEditing(true) },
2507
+ h('i', { className: 'ddp-dot' + (busy ? ' wait' : '') }),
2508
+ h('span', null, canvas && screens.length > 1 ? `${st.label} · ${screens.length}` : st.label)),
2509
+ h('button', { className: 'ddp-btn pick' + (picking ? ' on' : ''), title: t('pickTip'), disabled: !url || !!ver || !!demo, onClick: togglePick },
2510
+ h(I.pick), h('span', null, picking ? t('picking') : t('pick'))),
2511
+ h('button', { className: 'ddp-btn' + (textEdit ? ' on' : ''), title: t('textEdit'), disabled: !url || !!ver || !!demo || busy, onClick: toggleText }, h(I.text)),
2512
+ h('span', { className: 'ddp-sep' }),
2513
+ h('div', { className: 'ddp-seg' },
2514
+ h('button', { className: 'ddp-btn' + (canvas && !inMulti ? ' sel' : ''), title: t('canvas'), onClick: () => { setMulti(false); change({ view: 'canvas' }) } }, h(I.canvas)),
2515
+ h('button', { className: 'ddp-btn' + (!canvas && !inMulti ? ' sel' : ''), title: t('single'), onClick: () => { setMulti(false); openSingle() } }, h(I.single)),
2516
+ h('button', { className: 'ddp-btn' + (inMulti ? ' sel' : ''), title: sizeSet ? t('sizes') : t('sizesNo'), disabled: !sizeSet || !!ver, onClick: () => setMulti(!inMulti) }, h(I.sizes))),
2517
+ h('button', { className: 'ddp-btn o2' + (demo ? ' sel' : ''), title: t('demo'), disabled: !url || !!ver, onClick: toggleDemo }, h(I.play)),
2518
+ h(KindMenu, { t, kind, open: menu, setOpen: setMenu, onPick: (k) => { setMenu(false); change({ kind: k }) } }),
2519
+ h('button', { className: 'ddp-btn' + (panel ? ' sel' : '') + (tokens.length && !panel ? ' has' : ''), title: t('tokens'), onClick: () => { setPanel(!panel); setHist(false); setPhone(false) } }, h(I.palette)),
2520
+ h('button', { className: 'ddp-btn o2' + (hist || ver ? ' sel' : ''), title: t('history'), disabled: !fileTarget, onClick: toggleHist }, h(I.history)),
2521
+ h(ExportMenu, { t, kind, count: shown.length, open: exMenu, setOpen: setExMenu, onPick: (m) => doExport(m), disabled: !url }),
2522
+ anyDark || dark ? h('button', { className: 'ddp-btn o1' + (dark ? ' sel' : ''), title: t('dark'), onClick: () => setDark(!dark) }, h(I.moon)) : null,
2523
+ h('button', { className: 'ddp-btn o1' + (phone ? ' sel' : '') + (phoneOn && !phone ? ' has' : ''), title: t('phone'), disabled: !url, onClick: togglePhone }, h(I.scan)),
2524
+ h('button', { className: 'ddp-btn o1', title: t('refresh'), onClick: reload }, h(I.refresh)),
2525
+ h('a', { className: 'ddp-btn o1', title: t('external'), href: url, target: '_blank', rel: 'noopener noreferrer' }, h(I.external)),
2526
+ h(MoreMenu, { t, open: more, setOpen: setMore, has: phoneOn && !phone, items: [
2527
+ ['o2', I.play, t('demoShort'), !!demo, !url || !!ver, toggleDemo],
2528
+ ['o2', I.history, t('history'), hist || !!ver, !fileTarget, toggleHist],
2529
+ anyDark || dark ? ['o1', I.moon, t('dark'), dark, false, () => setDark(!dark)] : null,
2530
+ ['o1', I.scan, t('phone'), phone, !url, togglePhone],
2531
+ ['o1', I.refresh, t('refresh'), false, false, reload],
2532
+ ['o1', I.external, t('external'), false, !url, url],
2533
+ ] }))
2534
+
2535
+ const busyChip = (cls) => h('div', { className: 'ddp-chip' + cls, key: 'busy' }, h('i', { className: 'sp' }),
2536
+ h('span', { className: 'tx' }, st && st.writing ? t('writing', { file: st.writing }) : t('working')))
2537
+
2538
+ // ── 参考图:拖进来 / 粘贴 / 从设计规范面板选;存进设计目录的 refs/,尺寸和主要颜色放进输入框 ──
2539
+ const addRef = async (file) => {
2540
+ if (!file || !/^image\//.test(file.type)) return say(t('refNotImage'))
2541
+ try {
2542
+ const r = await readRef(file)
2543
+ const saved = await post('ref', { session: sid, name: file.name || 'ref', data: r.data })
2544
+ insert(formatRef(t, { path: saved.path, w: r.w, h: r.h, palette: r.palette, hasDesign: !!url }), 'insertedSend')
2545
+ } catch (e) {
2546
+ say(t('refFail', { msg: e.message }))
2547
+ }
2548
+ }
2549
+ const imageOf = (list) => [...(list || [])].find((f) => f && /^image\//.test(f.type))
2550
+ const hasFiles = (e) => [...((e.dataTransfer && e.dataTransfer.types) || [])].includes('Files')
2551
+ const pickRef = () => { if (refInput.current) refInput.current.click() }
2552
+ const rootProps = {
2553
+ className: 'ddp',
2554
+ tabIndex: -1, // 点了右侧栏就能在这里粘贴图片
2555
+ onDragEnter: (e) => { if (hasFiles(e)) { e.preventDefault(); setDropping(true) } },
2556
+ onDragOver: (e) => { if (hasFiles(e)) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy' } },
2557
+ onPaste: (e) => {
2558
+ const f = imageOf(e.clipboardData && e.clipboardData.files)
2559
+ if (!f) return
2560
+ e.preventDefault()
2561
+ addRef(f)
2562
+ },
2563
+ }
2564
+ // 盖住整个右侧栏接住松手(不然落在页面 iframe 上就被它吃掉了)
2565
+ const dropLayer = dropping ? h('div', {
2566
+ className: 'ddp-drop',
2567
+ onDragOver: (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy' },
2568
+ onDragLeave: (e) => { if (e.target === e.currentTarget) setDropping(false) },
2569
+ onDrop: (e) => {
2570
+ e.preventDefault()
2571
+ e.stopPropagation()
2572
+ setDropping(false)
2573
+ addRef(imageOf(e.dataTransfer.files))
2574
+ },
2575
+ }, h('div', { className: 'in' }, h(I.image), h('b', null, t('refDrop')), h('span', null, t('refDropSub')))) : null
2576
+ const fileInput = h('input', {
2577
+ ref: refInput,
2578
+ type: 'file',
2579
+ accept: 'image/png,image/jpeg,image/webp,image/gif',
2580
+ style: { display: 'none' },
2581
+ onChange: (e) => { const f = e.target.files && e.target.files[0]; e.target.value = ''; if (f) addRef(f) },
2582
+ })
2583
+ const root = (...kids) => h('div', rootProps, ...kids, dropLayer, fileInput)
2584
+
2585
+ if (!url) {
2586
+ if (st ? st.loading : !waited) return root(h('div', { className: 'ddp-stage', ref: stageRef }))
2587
+ if (busy) {
2588
+ // 模型在做第一屏:先放一个生成中的占位
2589
+ return root(
2590
+ h('div', { className: 'ddp-stage', ref: stageRef },
2591
+ h('div', { className: 'ddp-wait' }, h('div', { className: 'card' }, h(Skeleton, {})), h('p', null, t('waiting'))),
2592
+ h('div', { className: 'ddp-glow' }),
2593
+ h('div', { className: 'ddp-ring' }),
2594
+ busyChip('')))
2595
+ }
2596
+ return root(
2597
+ h('div', { className: 'ddp-empty' },
2598
+ h(EmptyArt),
2599
+ h('b', null, t('empty')),
2600
+ st && st.hint ? h('button', {
2601
+ className: 'ddp-go',
2602
+ onClick: () => post('open', { session: sid, target: st.hint }).then((s) => { if (poll) poll.set(s) }, (e) => say(t('openFailed', { msg: e.message }))),
2603
+ }, t('hintOpen', { name: st.hint.split(/[\\/]/).pop() })) : null,
2604
+ h('ol', { className: 'ddp-steps' }, h('li', null, t('step1')), h('li', null, t('step2')), h('li', null, t('step3'))),
2605
+ h('p', null, t('emptyHint')),
2606
+ h(AddressForm, { t, sid, className: 'ddp-form', onDone: () => {} }),
2607
+ h('p', { className: 'ddp-refline' }, t('refEmpty'), ' · ', h('button', { className: 'lk', onClick: pickRef }, t('refChoose')))),
2608
+ toast ? h('div', { className: 'ddp-toast' }, h('span', null, toast.text)) : null)
2609
+ }
2610
+
2611
+ let body
2612
+ if (demo) {
2613
+ const fill = kind === 'web'
2614
+ const [dw, dh] = deviceBox(kind, size)
2615
+ const z = fill ? 1 : Math.max(0.05, Math.min(1, (box.w - 32) / dw, (box.h - 32 - 46) / dh))
2616
+ const top = demo.layers[demo.layers.length - 1]
2617
+ body = h('div', { className: 'ddp-demo' }, ...demo.layers.map((l) => {
2618
+ const isTop = l.id === top.id
2619
+ const cls = 'lay' + (isTop ? (l.shown || !l.dir ? '' : ' wait') : top.shown ? ' out' : '') + (l.dir < 0 || (!isTop && top.dir < 0) ? ' b' : ' f')
2620
+ const label = (infoOf(l.key) || {}).title || l.key.replace(/\.html?$/i, '')
2621
+ return h('div', { key: l.id, className: cls },
2622
+ h('div', { className: 'ddp-single' },
2623
+ h('div', { className: 'ddp-fit' + (fill ? ' fill' : ''), style: fill ? undefined : { width: dw, height: dh, transform: `translate(-50%, -50%) scale(${z})` } },
2624
+ h(Device, { kind, w: size[0], h: size[1], fill, label },
2625
+ h(Screen, {
2626
+ src: urlOfKey(l.key),
2627
+ label,
2628
+ busy: false,
2629
+ born: false,
2630
+ picking: false,
2631
+ demo: isTop,
2632
+ dark,
2633
+ zoom: z,
2634
+ reloadSeq,
2635
+ onNav: isTop ? demoGo : undefined,
2636
+ onHref: isTop ? (href) => demoHref(l.id, href) : undefined,
2637
+ onShown: () => demoShown(l.id),
2638
+ onMeta: (m, title, toks, dk, links) => remember(l.key, m, title, toks, dk, links),
2639
+ onLinks: (links) => rememberLinks(l.key, links),
2640
+ })))))
2641
+ }))
2642
+ } else if (inMulti) {
2643
+ // 多尺寸:现在这一屏,按几档宽度并排
2644
+ const base = curScreen || { key: focus, url, label: st.label }
2645
+ const items = sizeSet.map(([lk, k, sz]) => ({ kind: k, size: sz, label: `${t(lk)} · ${sz[0]}` }))
2646
+ const fake = items.map((it, i) => ({ key: 'sz' + i, id: 'sz' + i + '|' + base.key, url: base.url, label: it.label }))
2647
+ body = h(Canvas, {
2648
+ key: 'multi',
2649
+ t,
2650
+ screens: fake,
2651
+ items,
2652
+ kind,
2653
+ size,
2654
+ ghost: false,
2655
+ picking: picking || textEdit,
2656
+ sel: '',
2657
+ labelOf: (sc) => sc.label,
2658
+ render: (sc, z, i) => h(Device, { kind: items[i].kind, w: items[i].size[0], h: items[i].size[1], label: labelOf(base) },
2659
+ h(Screen, {
2660
+ src: base.url,
2661
+ label: labelOf(base),
2662
+ busy,
2663
+ born: false,
2664
+ picking,
2665
+ textEdit,
2666
+ dark,
2667
+ quietPick: true,
2668
+ zoom: z,
2669
+ reloadSeq,
2670
+ onPicked: (payload) => deliver(payload, `屏宽:${items[i].size[0]}(${items[i].label.split(' · ')[0]})\n`),
2671
+ onCancelled: () => setPicking(false),
2672
+ onEdited: (d) => onEdited(base, d),
2673
+ onEditDone: () => setTextEdit(false),
2674
+ onNote: (n) => note(n, base),
2675
+ })),
2676
+ })
2677
+ } else if (ver && cmp) {
2678
+ // 对比:同一屏,左边那一版、右边现在,中间的把手左右拖
2679
+ const k = canvas ? sel || focus : focus
2680
+ const now = screens.find((x) => x.key === k) || screens[0]
2681
+ const has = now && ver.screens.includes(now.key)
2682
+ const old = now ? { key: now.key, id: `v${ver.n}|${now.key}`, v: ver.n, url: vbase + encodeURIComponent(now.key), label: now.label } : null
2683
+ const fill = kind === 'web'
2684
+ const [dw, dh] = deviceBox(kind, size)
2685
+ const z = fill ? 1 : Math.max(0.05, Math.min(1, (box.w - 32) / dw, (box.h - 32 - (ver ? 46 : 0)) / dh))
2686
+ // 两边的小标签不跟着画面缩小(缩了看不清);手机、平板躲开状态栏
2687
+ const tagAt = (side) => ({ top: kind === 'app' || kind === 'tablet' ? 60 : 10, transform: `scale(${1 / z})`, transformOrigin: 'top ' + side })
2688
+ const screenOf = (sc) => h(Screen, { src: sc.url, label: labelOf(sc), busy: false, born: false, picking: false, dark, zoom: z, reloadSeq,
2689
+ apis: apis.current, apiKey: 'cmp|' + ik(sc), onMeta: (m, title, toks, dk) => remember(ik(sc), m, title, toks, dk) })
2690
+ body = h('div', { className: 'ddp-single' },
2691
+ h('div', { className: 'ddp-fit' + (fill ? ' fill' : ''), style: fill ? undefined : { width: dw, height: dh, transform: `translate(-50%, -50%) scale(${z})` } },
2692
+ now ? h(Device, { kind, w: size[0], h: size[1], fill, label: labelOf(now) },
2693
+ h('div', { className: 'ddp-cmp' + (cmpDrag ? ' drag' : ''), ref: cmpRef },
2694
+ h('div', { className: 'ly' }, has ? screenOf(old) : h('div', { className: 'miss' }, t('notInVer', { n: ver.n }))),
2695
+ // 右边一层只露出把手右边那块:外层裁宽度,里面整屏往左挪(iframe 不变大小、不重排;clip-path 盖 iframe 有的内核画不出来)
2696
+ cx < 100 ? h('div', { className: 'ly r', style: { width: (100 - cx) + '%' } },
2697
+ h('div', { style: { width: (10000 / (100 - cx)) + '%', left: (-100 * cx / (100 - cx)) + '%' } }, screenOf(now))) : null,
2698
+ h('div', { className: 'ddp-knob', style: { left: cx + '%' }, onPointerDown: dragKnob }, h('i')),
2699
+ h('span', { className: 'tag l', style: tagAt('left') }, t('vN', { n: ver.n })),
2700
+ h('span', { className: 'tag r', style: tagAt('right') }, t('cmpNow')))) : null))
2701
+ } else if (canvas) {
2702
+ body = h(Canvas, {
2703
+ key: 'grid',
2704
+ t,
2705
+ screens: shown,
2706
+ actions,
2707
+ kind,
2708
+ size,
2709
+ ghost,
2710
+ picking: picking || textEdit,
2711
+ sel: sel || focus,
2712
+ labelOf,
2713
+ render: (sc, z) => renderScreen(sc, z, false),
2714
+ flows,
2715
+ onSelect: setSel,
2716
+ onOpen: (key) => change({ view: 'single', focus: key }),
2717
+ })
2718
+ } else {
2719
+ const fill = kind === 'web'
2720
+ const [dw, dh] = deviceBox(kind, size)
2721
+ const z = fill ? 1 : Math.max(0.05, Math.min(1, (box.w - 32) / dw, (box.h - 32 - (ver || hintTop ? 46 : 0)) / dh))
2722
+ const one = ver ? shown.find((x) => x.key === focus) || shown[0] || { key: focus, url, label: st.label } : { key: focus, url, label: st.label }
2723
+ body = h('div', { className: 'ddp-single' },
2724
+ h('div', { className: 'ddp-fit' + (fill ? ' fill' : ''), style: fill ? undefined : { width: dw, height: dh, transform: `translate(-50%, -50%) scale(${z})` } },
2725
+ renderScreen(one, z, fill)))
2726
+ }
2727
+
2728
+ let chip = null
2729
+ const side = canvas || inMulti ? ' left' : ''
2730
+ const hintText = picking ? t(canvas || inMulti ? 'pickAny' : 'pickOne') : textEdit ? t(canvas || inMulti ? 'textAny' : 'textOne') : ''
2731
+ if (flash) chip = h('div', { className: 'ddp-chip ok' + side, key: 'ok' }, h(I.check), h('span', null, flash))
2732
+ else if (busy) chip = busyChip(side)
2733
+
2734
+ return root(
2735
+ bar,
2736
+ h('div', { className: 'ddp-stage' + (!canvas && !inMulti && kind === 'web' ? ' full' : ''), ref: stageRef },
2737
+ h('div', { className: 'ddp-body' + (ver || demo || hintTop ? ' vb' : '') }, body),
2738
+ busy ? h('div', { className: 'ddp-glow' }) : null,
2739
+ busy ? h('div', { className: 'ddp-ring' }) : null,
2740
+ hintText ? h('div', { className: 'ddp-hint' }, hintText) : null,
2741
+ panel ? h(TokensPanel, { t, tokens, onPick: (tok) => insert(formatToken(tok, stRef.current)), onAsk: () => insert(t('tokensAskText') + '\n', 'insertedSend'), onRef: pickRef, onClose: () => setPanel(false) }) : null,
2742
+ phone ? h(PhonePanel, {
2743
+ t,
2744
+ sid,
2745
+ path: curPath,
2746
+ seen: st && st.phone ? st.phone.seen : 0,
2747
+ onCopy: (link) => { navigator.clipboard.writeText(link).then(() => say(t('phoneCopied'))).catch(() => say(link)) },
2748
+ onStop: () => { post('phone', { session: sid, on: false }).catch(() => {}); setPhone(false); if (poll && poll.state) poll.set({ ...poll.state, phone: { on: false, seen: 0 } }) },
2749
+ onClose: () => setPhone(false),
2750
+ }) : null,
2751
+ hist ? h(HistoryPanel, { t, versions, ver, onPick: (v) => { setVer(v); if (!v) setCmp(false) }, onClose: () => setHist(false) }) : null,
2752
+ demo ? h('div', { className: 'ddp-banner' },
2753
+ h(I.play),
2754
+ h('span', null, t('demoOn') + ' · ' + ((infoOf(demoKey) || {}).title || demoKey.replace(/\.html?$/i, ''))),
2755
+ h('button', { className: 'ddp-btn', title: t('demoBack'), disabled: demo.stack.length < 2, onClick: demoBack }, h(I.back)),
2756
+ h('button', { className: 'ddp-btn', title: t('demoRestart'), disabled: demo.stack.length < 2, onClick: demoRestart }, h(I.refresh)),
2757
+ h('button', { className: 'ddp-btn', title: t('demoExit'), onClick: () => setDemo(null) }, h(I.close))) : null,
2758
+ ver ? h('div', { className: 'ddp-banner' },
2759
+ h(I.history),
2760
+ h('span', null, t('viewing', { n: ver.n, time: hhmm(ver.at) })),
2761
+ h('button', { className: 'ddp-go' + (cmp ? ' on' : ''), onClick: () => setCmp(!cmp) }, t('compare')),
2762
+ h('button', { className: 'ddp-go pri', onClick: doRestore }, t('restore')),
2763
+ h('button', { className: 'ddp-btn', title: t('backNow'), onClick: () => { setVer(null); setCmp(false) } }, h(I.close))) : null,
2764
+ chip,
2765
+ toast ? h('div', { className: 'ddp-toast' + (ver || demo || hintText ? ' low' : '') }, h('span', null, toast.text),
2766
+ toast.act ? h('button', { onClick: () => { toast.act.run(); setToast('') } }, toast.act.label) : null) : null))
2767
+ }
2768
+
2769
+ // ── 标题栏按钮:打开预览,顺便在模型开了新页面时把右侧栏弹出来 ──────────
2770
+
2771
+ function HeaderButton(props) {
2772
+ const t = props.t
2773
+ const sid = props.sid || props.sessionId
2774
+ const [st] = usePreview(sid)
2775
+ const seen = useRef(null)
2776
+ useEffect(() => {
2777
+ if (!st) return
2778
+ if (seen.current === null) { seen.current = st.openRev; return } // 刚挂上:不为以前的事弹
2779
+ if (st.openRev > seen.current) {
2780
+ seen.current = st.openRev
2781
+ props.open(sid)
2782
+ }
2783
+ }, [st && st.openRev])
2784
+ const hint = st && !st.target && st.hint ? st.hint.split(/[\\/]/).pop() : ''
2785
+ return h('button', {
2786
+ className: 'ddp-head' + (st && st.target ? ' live' : hint ? ' hint' : ''),
2787
+ title: hint ? t('headerHint', { name: hint }) : t('headerTip'),
2788
+ 'aria-label': t('headerTip'),
2789
+ onClick: () => props.open(sid, true),
2790
+ }, h(I.page))
2791
+ }
2792
+
2793
+ // ── 注册 ─────────────────────────────────────────────────────────────────
2794
+
2795
+ function apply(ctx) {
2796
+ ensureStyle()
2797
+ const t = makeT(ctx)
2798
+ try { ctx.effect(() => ctx.locale.register(NS, { zh: ZH, en: EN }), 'dsh-design-studio: copy') } catch (e) { console.warn('[dsh-design-studio] locale', e) }
2799
+
2800
+ /** 打开右侧栏的预览标签;force=false 时只在这个会话正显示在屏幕上时才弹 */
2801
+ const open = (sid, force) => {
2802
+ try {
2803
+ const m = ctx.sidebarRight.mounted
2804
+ const onScreen = m && typeof m.getSnapshot === 'function' ? m.getSnapshot() : sid
2805
+ if (!force && onScreen !== sid) return
2806
+ ctx.sidebarRight.openTab(KIND)
2807
+ } catch (e) {
2808
+ console.warn('[dsh-design-studio] 打不开右侧栏', e)
2809
+ }
2810
+ }
2811
+
2812
+ ctx.effect(() => ctx.sidebarRightTabs.register({
2813
+ id: TAB_ID,
2814
+ kind: KIND,
2815
+ title: () => t('title'),
2816
+ keepMounted: true,
2817
+ guide: [{ id: 'open', order: 30, title: () => t('title'), description: () => t('guideDesc'), icon: GuideIcon }],
2818
+ }), 'dsh-design-studio: tab type')
2819
+
2820
+ ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({
2821
+ name: 'sidebar.right.pane.tab',
2822
+ key: TAB_ID,
2823
+ locale: NS,
2824
+ inject: (sessionId) => ({ sid: sessionId, t }),
2825
+ }, PreviewTab)), 'dsh-design-studio: tab body')
2826
+
2827
+ ctx.effect(() => ctx.slots.inject('conversation.session.header.utilities', () => ctx.slots.register({
2828
+ name: 'conversation.session.header.utilities',
2829
+ id: 'dsh-design-studio',
2830
+ order: -10,
2831
+ locale: NS,
2832
+ inject: (sessionId) => ({ sid: sessionId, t, open }),
2833
+ }, HeaderButton)), 'dsh-design-studio: header button')
2834
+ }
2835
+
2836
+ exports.apply = apply
2837
+ // 注意:这里是客户端**服务名**(短的),不是包名
2838
+ exports.inject = INJECT
2839
+ // 测试用
2840
+ exports._test = { poller, frameOf, paletteOf, formatRef, layoutRow, keyOfPath, flowsOf, flowLines, formatPick, whereOf, parseMeta, sizeOf, layoutGrid, fitCam, tokenKind, formatToken, splitTitles, pickColors, hhmm }
2841
+ return module.exports
2842
+ },
2843
+ })