dsh-design-studio 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -44,6 +44,26 @@ window.__ModuleLoader__.load({
44
44
  guideDesc: '实时看页面,点选要改的地方',
45
45
  empty: '还没有要预览的页面',
46
46
  emptyHint: '也可以直接输入 HTML 文件路径或端口:',
47
+ emptyHintPick: '已有 HTML?点「浏览…」选一个,或直接拖进来;也能输入路径或端口:',
48
+ browse: '浏览…',
49
+ browseTip: '选一个本地 HTML 文件打开',
50
+ pathNone: '拿不到这个文件的路径,请直接输入它的路径打开',
51
+ pageDrop: '松开,打开这个页面',
52
+ maybeDrop: '松开,打开这个文件夹或 HTML',
53
+ pageDropSub: 'HTML 文件,或放着 HTML 的文件夹',
54
+ dropWeb: '网页版拿不到本地文件的路径',
55
+ dropWebSub: '请输入它在运行 DSH 的那台电脑上的路径打开',
56
+ dropWebSay: '网页版拿不到本地文件的路径:请输入它在运行 DSH 的那台电脑上的路径打开',
57
+ dropWhat: '这里只接 HTML、文件夹或图片',
58
+ dropWhatSub: 'HTML 或文件夹会打开预览,图片当参考图',
59
+ dropWhatWeb: '这里只接图片',
60
+ dropWhatWebSub: '图片当参考图;要打开 HTML,请输入它的路径',
61
+ pickDir: '选文件夹',
62
+ pickDirFail: '没打开选文件夹的窗口:{msg}',
63
+ hoMoved: '选的文件夹里已经有别的东西,代码会放进里面的 {name}(可以改)',
64
+ hoBeside: '代码不能放进设计目录(设计稿要留着对比),改放在旁边的 {name}(可以改)',
65
+ hoChecking: '正在看这个文件夹…',
66
+ hoCheckFail: '没看成这个文件夹({msg}),开始前请确认路径',
47
67
  hintOpen: '预览刚写的 {name}',
48
68
  headerHint: '刚写了 {name},点开能预览',
49
69
  placeholder: '/路径/index.html 或 5173',
@@ -52,6 +72,24 @@ window.__ModuleLoader__.load({
52
72
  pickTip: '点选元素或框选区域,发给模型(Esc 取消)',
53
73
  pickAny: '点选任意一屏里的元素,或拖出一块区域 · Esc 取消',
54
74
  pickOne: '点选一个元素,或拖出一块区域 · Esc 取消',
75
+ pickArea: '选中区域',
76
+ chipOne: '1 个元素',
77
+ chipMark: '标注图',
78
+ chipText: '改字',
79
+ chipStyle: '样式改动',
80
+ chipVariant: '再出两版',
81
+ chipDir: '选定方向',
82
+ chipCheck: '体检 · {n} 个问题',
83
+ chipCode: '转成代码',
84
+ chipRef: '参考图',
85
+ chipTweak: '滑块调整',
86
+ chipTwMake: '做调节滑块',
87
+ chipTwFix: '修调节滑块',
88
+ chipSysEx: '提取设计系统',
89
+ chipSysAp: '套用设计系统',
90
+ chipLook: '风格 · {name}',
91
+ chipTokens: '建设计变量',
92
+ pickElems: '{n} 个元素',
55
93
  textAny: '点任意一屏里的文字直接改 · 回车保存 · Esc 退出',
56
94
  refPick: '照参考图改风格…',
57
95
  refChoose: '选一张',
@@ -85,6 +123,7 @@ window.__ModuleLoader__.load({
85
123
  kindApp: 'App',
86
124
  kindMini: '小程序',
87
125
  kindTablet: '平板',
126
+ kindDesktop: '桌面软件',
88
127
  kindPoster: '海报',
89
128
  kindSlides: '幻灯片',
90
129
  kindEmail: '邮件',
@@ -119,7 +158,8 @@ window.__ModuleLoader__.load({
119
158
  checkFail: '没查成:{msg}',
120
159
  checkNote: '只量得出排版上的硬伤,好不好看还得你看',
121
160
  checkCount: '设计体检:上次量出 {n} 个问题,点开看',
122
- checkAsk: '〔设计体检〕发现 {n} 个问题,请逐个修好:改源文件,只动有问题的地方;颜色、字号能改设计变量就改设计变量。',
161
+ checkAsk: '〔设计体检〕发现 {n} 个问题:',
162
+ checkTail: '请逐个修好:改源文件,只动有问题的地方;颜色、字号能改设计变量就改设计变量。',
123
163
  ckToolHead: '量了 {s} 屏,发现 {n} 个问题。逐个改源文件,只动有问题的地方;颜色、字号能改设计变量就改设计变量:',
124
164
  ckToolOk: '量了 {s} 屏({labels}),没发现问题。',
125
165
  ckToolOff: '用户正在看旧版本、演示或多尺寸,右侧栏现在量不了;过一会儿再调,或照「自查」清单自己过一遍。',
@@ -141,6 +181,11 @@ window.__ModuleLoader__.load({
141
181
  ck_jserr: '页面脚本报错',
142
182
  ck_missing: '文件没载出来',
143
183
  ck_capsule: '被小程序胶囊挡住',
184
+ ck_island: '被灵动岛挡住',
185
+ ck_homebar: '伸进底部横条区',
186
+ ck_tabbar: '标签栏项太多',
187
+ ck_navbar: '没有导航栏',
188
+ ck_winctl: '被窗口按钮挡住',
144
189
  cd_hscroll: '伸出屏幕 {px}px',
145
190
  cd_clipped: '被切掉 {px}px',
146
191
  cd_overflow: '伸出框 {px}px',
@@ -158,6 +203,15 @@ window.__ModuleLoader__.load({
158
203
  cd_jserr: '{msg}{at}',
159
204
  cd_missing: '{what} {src} 载不出来(路径写错了,或者文件不在)',
160
205
  cd_capsule: '伸进右上角胶囊 {px}px(胶囊是微信画的、盖不住:顶栏右边从 x={x} 起空着)',
206
+ cd_island: '被灵动岛挡住 {px}px(灵动岛在 y 11–45:状态栏中间空着,内容放到状态栏下面)',
207
+ cd_homebar: '{bar} 里的字或图标伸进底部横条区 {px}px(底部留 34px:栏加 padding-bottom: 34px,预览里 env(safe-area-inset-bottom) 是 0)',
208
+ cd_tabbar: '标签栏有 {n} 项(最多 5 项)',
209
+ cd_navbar: '顶上 y {t}–{b}(导航栏)是空的:一级页放标题或品牌,二级页标题居中加返回',
210
+ cd_winctl: '伸进{osName}窗口按钮 {px}px(外框画的、盖在页面上面:{zone}空着)',
211
+ ckOs_mac: ' Mac ',
212
+ ckOs_win: ' Windows ',
213
+ ckZone_mac: '左上角 x 80、y 40 以内',
214
+ ckZone_win: '右上角 138×32',
161
215
  ckEdge_top: '顶栏',
162
216
  ckEdge_bottom: '底栏',
163
217
  ckWhen_top: '没滚时',
@@ -202,7 +256,7 @@ window.__ModuleLoader__.load({
202
256
  pickShort: '就要这个',
203
257
  pickText: '〔选定方向〕就用「{name}」({where})。先把同组的 {others} 挪进 drafts/ 子目录(别删),再把它改名成 {base}.html;它的设计变量搬进 tokens.css(没有就新建)、改用 tokens.css。然后接着做全套。\n',
204
258
  pickKeep: '〔选定方向〕留原版「{name}」({where})。同组的 {others} 挪进 drafts/ 子目录(别删),其他不动。\n',
205
- variantText: '给「{name}」再出两版不同方向的设计(布局或视觉风格明显不同),存成同目录下的 {b}-b.html 和 {b}-c.html,会紧挨着原版排在画布上。原版和 tokens.css 别动;新版要换配色就在自己文件里覆盖变量。\n{where}\n',
259
+ variantText: '给「{name}」再出两版不同方向的设计(布局或视觉风格明显不同),存成同目录下的 {b}-b.html 和 {b}-c.html(原版:{where}),会紧挨着原版排在画布上。原版和 tokens.css 别动;新版要换配色就在自己文件里覆盖变量。\n',
206
260
  exThis: '导出这一屏',
207
261
  demo: '演示:点页面里的按钮跳到别的屏',
208
262
  demoOn: '演示中 · 点按钮跳转',
@@ -210,8 +264,50 @@ window.__ModuleLoader__.load({
210
264
  demoRestart: '从头开始',
211
265
  demoExit: '退出演示(Esc)',
212
266
  textEdit: '改字:点页面里的文字直接改,回车保存(Esc 退出)',
267
+ mark: '画笔标注:在页面上画圈、箭头、写字,拼成一张图交给模型',
268
+ mkPen: '画笔',
269
+ mkHl: '荧光笔',
270
+ mkArrow: '箭头',
271
+ mkRect: '方框',
272
+ mkOval: '圆圈',
273
+ mkText: '文字',
274
+ mkErase: '橡皮',
275
+ mkColor: '颜色',
276
+ mkSize: '粗细',
277
+ mkUndo: '撤销',
278
+ mkRedo: '重做',
279
+ mkClear: '全部清掉',
280
+ mkHint: '在页面上画,画完点「交给模型」',
281
+ mkHow_pen: '按住拖动,随手画',
282
+ mkHow_hl: '按住拖过去,把要改的地方涂亮',
283
+ mkHow_arrow: '从起点按住,拖到要指的地方',
284
+ mkHow_rect: '按住拖出一个框,框住要改的地方',
285
+ mkHow_oval: '按住拖一下,圈出要改的地方',
286
+ mkHow_text: '在要说明的地方点一下,写字,回车写完',
287
+ mkHow_erase: '点一下笔画就擦掉,也能按住拖过去',
288
+ mkCoach: '直接在页面上画',
289
+ mkCoach1: '上面挑个工具:画笔、箭头、圆圈、文字…',
290
+ mkCoach2: '在页面上按住拖动,画出要改的地方',
291
+ mkCoach3: '画完点「交给模型」,再补一句要怎么改',
292
+ mkCancel: '取消',
293
+ mkSend: '交给模型',
294
+ mkCopy: '复制',
295
+ mkCopyTip: '把标注图复制到剪贴板,贴到别的地方用',
296
+ mkCopied: '标注图复制好了,贴到别处就行',
297
+ mkCopyFail: '没能复制到剪贴板,再点一次「复制」,或者「交给模型」',
298
+ mkSending: '正在拼图…',
299
+ mkTextPh: '写点什么,回车完成',
300
+ mkEmpty: '还没画东西',
301
+ mkFail: '标注图没存成:{msg}',
302
+ mkAsk: '〔标注〕{path}\n{notes}这是我在设计上画了标注的截图({where})。先用 read_image 看这张图,按图上的圈、箭头和文字改对应的地方;没标的地方别动。\n',
303
+ mkNotes: '图上写的字:{notes}\n',
304
+ mkQuote: '「{v}」',
305
+ mkIn: '标注图放进输入框了,补一句要怎么改再发',
306
+ mkMany: '画到的屏太多,只拼了前 {n} 屏,其余留成灰块',
307
+ mkMoved: '设计换了,这次标注没交出去',
308
+ mkNoInput: '没能放进输入框,点一下聊天输入框再点「交给模型」',
213
309
  textSaved: '已改好,写回 {file}',
214
- textAsk: '把页面上的「{before}」改成「{after}」(只改这一处文字,别的不动)。\n{where}\n',
310
+ textAsk: '把页面上的「{before}」改成「{after}」({where};只改这一处文字,别的不动)。\n',
215
311
  styleEdit: '调样式:点页面里的元素,拖滑块改字号、颜色、圆角、间距(Esc 退出)',
216
312
  styleAny: '点任意一屏里的元素调样式 · Esc 退出',
217
313
  styleOne: '点页面里的元素调样式 · Esc 退出',
@@ -242,7 +338,7 @@ window.__ModuleLoader__.load({
242
338
  styleSaved: '样式已写回 {file}',
243
339
  styleMiss: '源码里没找到这个元素(可能是脚本生成的),改成交给模型',
244
340
  styleN: '改了 {n} 项',
245
- styleAskText: '〔调样式〕{name}({sel}):{list}。{rules}把这些写进它的样式规则(能用设计变量就用设计变量),别写成行内 style。\n{where}\n',
341
+ styleAskText: '〔调样式〕{name}({sel}):{list}。{rules}把这些写进它的样式规则({where};能用设计变量就用设计变量),别写成行内 style。\n',
246
342
  ruleFrom: '- {props}:现在由 {sel} 决定({at})',
247
343
  ruleMaybe: '- {props}:大概由 {sel} 决定({at};它在 @layer / @container 里,没细算,改之前看一眼)',
248
344
  ruleInline: '- {props}:现在写在元素的行内 style 上{over}',
@@ -252,10 +348,26 @@ window.__ModuleLoader__.load({
252
348
  styleJoin: ';',
253
349
  lang: 'zh',
254
350
  stylesHead: '先挑一个风格开工',
255
- stylesTip: '点一个,整套配色、字体、圆角放进输入框;再写要做什么',
351
+ stylesTip: '点一个看样板,满意了点「用这个风格」,整套配色、字体、圆角放进输入框;再写要做什么',
256
352
  stylesOpen: '换个风格…',
257
353
  stylesTitle: '换个风格',
258
- stylesSwap: '点一个,让模型把现在的设计换成这个风格(先改 tokens.css,布局和文案不动)',
354
+ stylesSwap: '点一个看样板;「用这个风格」让模型把现在的设计换成它(先改 tokens.css,布局和文案不动)',
355
+ stylesAll: '全部风格',
356
+ stylePrev: '上一个(←)',
357
+ styleNext: '下一个(→)',
358
+ styleUse: '用这个风格',
359
+ samBrand: '小森',
360
+ samNav: '发现 课程 我的',
361
+ samHead: '把日子过得好一点',
362
+ samSub: '一句说明文字,看看正文和次要文字的样子。',
363
+ samGo: '开始使用',
364
+ samMore: '了解更多 →',
365
+ samItem: '周末手作课',
366
+ samMeta: '2 小时 · 6 人小班',
367
+ samPrice: '¥128',
368
+ samTag: '新',
369
+ samStat: '本周完成',
370
+ samStatMeta: '比上周多 12%',
259
371
  styleText: '〔风格〕{name}——{desc}\n配色:{colors}\n字体:{fonts}\n形状:{shape}\n要点:{notes}\n{ask}',
260
372
  styleColors: '底色 {bg},卡片 {surface},文字 {text},次要文字 {muted},分隔线 {line},主色 {primary}(上面的字 {on}),点缀 {accent}({accUse}),链接和小字强调 {link}{bgImage}',
261
373
  accBig: '图形、大字',
@@ -321,7 +433,8 @@ window.__ModuleLoader__.load({
321
433
  twFix: '让模型修好',
322
434
  twMakeText: '〔调节滑块〕给这套设计做 3–5 个调节滑块:挑最值得试的维度(比如留白、圆角、主色、卡片排法),照 ui-design 技能「调节滑块」一节写进设计目录的 tweaks.json,把样式接上,做完用 check_design 查一遍。',
323
435
  twFixText: '〔调节滑块〕tweaks.json 有问题:{errs}。照 ui-design 技能「调节滑块」一节改好。',
324
- twAskHead: '〔调节〕我在右侧调节滑块里调成了下面这样,照这个改设计(改样式源文件,tweaks.json 里对应的 value 也改成新值):',
436
+ twAskHead: '〔调节〕我在右侧调节滑块里调成了下面这样:',
437
+ twAskTail: '照这个改设计(改样式源文件,tweaks.json 里对应的 value 也改成新值)。',
325
438
  twAskTip: '让模型照这几个值改设计(可以顺手调整相关的地方)',
326
439
  twSaveTip: 'CSS 变量写进 tokens.css 的默认值,属性写到每屏的 <html> 上(改前改后各存一版)',
327
440
  twNoWrite: '开发服务器的页面不能直接写回,交给模型改',
@@ -363,6 +476,26 @@ window.__ModuleLoader__.load({
363
476
  guideDesc: 'Live page, pick what to change',
364
477
  empty: 'Nothing to preview yet',
365
478
  emptyHint: 'Or enter an HTML path or a port:',
479
+ emptyHintPick: 'Have HTML already? Click "Browse…" or drop it here, or enter a path or a port:',
480
+ browse: 'Browse…',
481
+ browseTip: 'Pick a local HTML file to open',
482
+ pathNone: "Couldn't get this file's path. Type its path to open it.",
483
+ pageDrop: 'Drop to open this page',
484
+ maybeDrop: 'Drop to open this folder or HTML file',
485
+ pageDropSub: 'An HTML file, or a folder with HTML files',
486
+ dropWeb: "The web version can't see local file paths",
487
+ dropWebSub: 'Type its path on the computer running DSH to open it',
488
+ dropWebSay: "The web version can't see local file paths. Type its path on the computer running DSH to open it.",
489
+ dropWhat: 'Only HTML, folders or images',
490
+ dropWhatSub: 'HTML and folders open in the preview; images become references',
491
+ dropWhatWeb: 'Only images here',
492
+ dropWhatWebSub: 'Images become references. To open HTML, type its path',
493
+ pickDir: 'Choose folder',
494
+ pickDirFail: "Couldn't open the folder picker: {msg}",
495
+ hoMoved: 'That folder already has other things in it, so the code goes into {name} inside it (you can change this)',
496
+ hoBeside: "Code can't go inside the design folder (the design stays for comparison), so it goes into {name} next to it (you can change this)",
497
+ hoChecking: 'Checking this folder…',
498
+ hoCheckFail: "Couldn't check this folder ({msg}). Check the path before you start",
366
499
  hintOpen: 'Preview {name}',
367
500
  headerHint: '{name} was just written. Click to preview it',
368
501
  placeholder: '/path/index.html or 5173',
@@ -371,6 +504,24 @@ window.__ModuleLoader__.load({
371
504
  pickTip: 'Click an element or drag a region to send it to the model (Esc to cancel)',
372
505
  pickAny: 'Click an element in any screen, or drag a region · Esc to cancel',
373
506
  pickOne: 'Click an element or drag a region · Esc to cancel',
507
+ pickArea: 'Selected region',
508
+ chipOne: '1 element',
509
+ chipMark: 'Markup',
510
+ chipText: 'Text edit',
511
+ chipStyle: 'Style changes',
512
+ chipVariant: 'Two more takes',
513
+ chipDir: 'Direction picked',
514
+ chipCheck: 'Check · {n} issues',
515
+ chipCode: 'Build as code',
516
+ chipRef: 'Reference image',
517
+ chipTweak: 'Tweaked values',
518
+ chipTwMake: 'Make tweaks',
519
+ chipTwFix: 'Fix tweaks',
520
+ chipSysEx: 'Extract design system',
521
+ chipSysAp: 'Apply design system',
522
+ chipLook: 'Style · {name}',
523
+ chipTokens: 'Create design tokens',
524
+ pickElems: '{n} elements',
374
525
  textAny: 'Click text in any screen to edit · Enter to save · Esc to stop',
375
526
  refPick: 'Restyle from a reference…',
376
527
  refChoose: 'Choose one',
@@ -404,6 +555,7 @@ window.__ModuleLoader__.load({
404
555
  kindApp: 'App',
405
556
  kindMini: 'Mini program',
406
557
  kindTablet: 'Tablet',
558
+ kindDesktop: 'Desktop app',
407
559
  kindPoster: 'Poster',
408
560
  kindSlides: 'Slides',
409
561
  kindEmail: 'Email',
@@ -438,7 +590,8 @@ window.__ModuleLoader__.load({
438
590
  checkFail: 'Check failed: {msg}',
439
591
  checkNote: 'Only measurable layout problems; whether it looks good is still up to you',
440
592
  checkCount: 'Design check: {n} problems last time. Click to see them',
441
- checkAsk: '[Design check] Found {n} problems. Please fix each one in the source files, touching only what is listed; for colors and type sizes, change the design tokens where possible.',
593
+ checkAsk: '[Design check] Found {n} problems:',
594
+ checkTail: 'Please fix each one in the source files, touching only what is listed; for colors and type sizes, change the design tokens where possible.',
442
595
  ckToolHead: 'Measured {s} screens and found {n} problems. Fix each one in the source files, touching only what is listed; for colors and type sizes, change the design tokens where possible:',
443
596
  ckToolOk: 'Measured {s} screens ({labels}). No problems found.',
444
597
  ckToolOff: 'The user is looking at an old version, the demo or the multi-size view, so the sidebar can\'t measure right now. Try again in a moment, or go through the self-check list yourself.',
@@ -460,6 +613,11 @@ window.__ModuleLoader__.load({
460
613
  ck_jserr: 'Script error',
461
614
  ck_missing: 'File failed to load',
462
615
  ck_capsule: 'Under the mini program capsule',
616
+ ck_island: 'Under the Dynamic Island',
617
+ ck_homebar: 'In the home indicator area',
618
+ ck_tabbar: 'Too many tabs',
619
+ ck_navbar: 'No navigation bar',
620
+ ck_winctl: 'Under the window buttons',
463
621
  cd_hscroll: 'sticks out {px}px',
464
622
  cd_clipped: '{px}px cut off',
465
623
  cd_overflow: 'sticks out {px}px',
@@ -477,6 +635,15 @@ window.__ModuleLoader__.load({
477
635
  cd_jserr: '{msg}{at}',
478
636
  cd_missing: '{what} {src} did not load (wrong path, or the file is missing)',
479
637
  cd_capsule: '{px}px under the top-right capsule (WeChat draws it on top; keep the top bar clear from x={x})',
638
+ cd_island: '{px}px under the Dynamic Island (it sits at y 11–45: keep the middle of the status bar empty and start content below the status bar)',
639
+ cd_homebar: 'text or icons in {bar} reach {px}px into the home indicator area (leave 34px: give the bar padding-bottom: 34px; env(safe-area-inset-bottom) is 0 in the preview)',
640
+ cd_tabbar: 'the tab bar has {n} items (5 at most)',
641
+ cd_navbar: 'nothing at y {t}–{b} (the navigation bar): top-level pages show a title or brand, other pages a centered title and a back button',
642
+ cd_winctl: '{px}px under the{osName}window buttons (the frame draws them on top: keep {zone} clear)',
643
+ ckOs_mac: ' Mac ',
644
+ ckOs_win: ' Windows ',
645
+ ckZone_mac: 'the top-left 80×40',
646
+ ckZone_win: 'the top-right 138×32',
480
647
  ckEdge_top: 'top bar',
481
648
  ckEdge_bottom: 'bottom bar',
482
649
  ckWhen_top: 'before scrolling, ',
@@ -521,7 +688,7 @@ window.__ModuleLoader__.load({
521
688
  pickShort: 'Use this',
522
689
  pickText: '[Direction picked] Use "{name}" ({where}). First move the other takes in its group ({others}) into a drafts/ subfolder (don\'t delete them), then rename it to {base}.html; move its design tokens into tokens.css (create it if missing) and switch it to tokens.css. Then build the rest.\n',
523
690
  pickKeep: '[Direction picked] Keep the original "{name}" ({where}). Move the other takes in its group ({others}) into a drafts/ subfolder (don\'t delete them) and leave everything else as is.\n',
524
- 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',
691
+ 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 (original: {where}); 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',
525
692
  exThis: 'Export this screen',
526
693
  demo: 'Play: tap buttons in the page to move between screens',
527
694
  demoOn: 'Playing · tap to navigate',
@@ -529,8 +696,50 @@ window.__ModuleLoader__.load({
529
696
  demoRestart: 'Start over',
530
697
  demoExit: 'Stop playing (Esc)',
531
698
  textEdit: 'Edit text: click any text in the page, Enter to save (Esc to stop)',
699
+ mark: 'Mark up: draw circles, arrows and notes on the page, then hand it to the model as one image',
700
+ mkPen: 'Pen',
701
+ mkHl: 'Highlighter',
702
+ mkArrow: 'Arrow',
703
+ mkRect: 'Rectangle',
704
+ mkOval: 'Circle',
705
+ mkText: 'Text',
706
+ mkErase: 'Eraser',
707
+ mkColor: 'Color',
708
+ mkSize: 'Size',
709
+ mkUndo: 'Undo',
710
+ mkRedo: 'Redo',
711
+ mkClear: 'Clear all',
712
+ mkHint: 'Draw on the page, then send it to the model',
713
+ mkHow_pen: 'Press and drag to draw freehand',
714
+ mkHow_hl: 'Drag over what needs changing to highlight it',
715
+ mkHow_arrow: 'Press at the start and drag to what you mean',
716
+ mkHow_rect: 'Drag to box what needs changing',
717
+ mkHow_oval: 'Drag to circle what needs changing',
718
+ mkHow_text: 'Click where the note goes, type, Enter to finish',
719
+ mkHow_erase: 'Click a stroke to erase it, or drag across',
720
+ mkCoach: 'Draw right on the page',
721
+ mkCoach1: 'Pick a tool above: pen, arrow, circle, text…',
722
+ mkCoach2: 'Press and drag on the page to mark what to change',
723
+ mkCoach3: 'Then click "Send to model" and add a line on what to do',
724
+ mkCancel: 'Cancel',
725
+ mkSend: 'Send to model',
726
+ mkCopy: 'Copy',
727
+ mkCopyTip: 'Copy the marked-up image to paste somewhere else',
728
+ mkCopied: 'Markup image copied. Paste it anywhere',
729
+ mkCopyFail: 'Could not copy to the clipboard. Click Copy again, or Send to model',
730
+ mkSending: 'Building the image…',
731
+ mkTextPh: 'Type a note, Enter to finish',
732
+ mkEmpty: 'Nothing drawn yet',
733
+ mkFail: 'Could not save the markup: {msg}',
734
+ mkAsk: '[Markup] {path}\n{notes}A screenshot of the design with my markup on it ({where}). Look at it with read_image first, then change what the circles, arrows and notes point at; leave unmarked parts alone.\n',
735
+ mkNotes: 'Notes written on it: {notes}\n',
736
+ mkQuote: '"{v}"',
737
+ mkIn: 'Markup added to the input. Say what to change, then send',
738
+ mkMany: 'Too many screens marked: captured the first {n}, the rest are gray',
739
+ mkMoved: 'The design changed, so the markup was not sent',
740
+ mkNoInput: 'Could not add it to the input. Click the chat input, then Send to model again',
532
741
  textSaved: 'Saved to {file}',
533
- textAsk: 'Change the text "{before}" to "{after}" on the page (only this text, nothing else).\n{where}\n',
742
+ textAsk: 'Change the text "{before}" to "{after}" on the page ({where}; only this text, nothing else).\n',
534
743
  styleEdit: 'Style: click an element, then drag sliders for size, colors, radius and spacing (Esc to stop)',
535
744
  styleAny: 'Click an element in any screen to style it · Esc to stop',
536
745
  styleOne: 'Click an element in the page to style it · Esc to stop',
@@ -561,7 +770,7 @@ window.__ModuleLoader__.load({
561
770
  styleSaved: 'Style saved to {file}',
562
771
  styleMiss: 'Could not find this element in the source (maybe a script made it); asking the model instead',
563
772
  styleN: '{n} changed',
564
- styleAskText: '[Style] {name} ({sel}): {list}. {rules}Put it in its stylesheet rule, using design tokens where you can, not an inline style.\n{where}\n',
773
+ styleAskText: '[Style] {name} ({sel}): {list}. {rules}Put it in its stylesheet rule ({where}), using design tokens where you can, not an inline style.\n',
565
774
  ruleFrom: '- {props}: currently set by {sel} ({at})',
566
775
  ruleMaybe: '- {props}: probably set by {sel} ({at}; it sits in @layer / @container and was not fully resolved, check before editing)',
567
776
  ruleInline: '- {props}: currently in the element\'s inline style{over}',
@@ -571,10 +780,26 @@ window.__ModuleLoader__.load({
571
780
  styleJoin: '; ',
572
781
  lang: 'en',
573
782
  stylesHead: 'Start from a style',
574
- stylesTip: 'Pick one to put its colors, fonts and shapes in the chat box, then say what to make',
783
+ stylesTip: 'Pick one to see a sample; "Use this style" puts its colors, fonts and shapes in the chat box, then say what to make',
575
784
  stylesOpen: 'Try another style…',
576
785
  stylesTitle: 'Change the style',
577
- stylesSwap: 'Pick one and the model restyles the current design (tokens.css first; layout and copy stay)',
786
+ stylesSwap: 'Pick one to see a sample; "Use this style" has the model restyle the current design (tokens.css first; layout and copy stay)',
787
+ stylesAll: 'All styles',
788
+ stylePrev: 'Previous (←)',
789
+ styleNext: 'Next (→)',
790
+ styleUse: 'Use this style',
791
+ samBrand: 'Bloom',
792
+ samNav: 'Explore Classes Me',
793
+ samHead: 'Make every day a little better',
794
+ samSub: 'One line of supporting text to show body and muted colors.',
795
+ samGo: 'Get started',
796
+ samMore: 'Learn more →',
797
+ samItem: 'Weekend workshop',
798
+ samMeta: '2 hrs · small group of 6',
799
+ samPrice: '$18',
800
+ samTag: 'New',
801
+ samStat: 'Done this week',
802
+ samStatMeta: '12% more than last week',
578
803
  styleText: '[Style] {name}: {desc}\nColors: {colors}\nFonts: {fonts}\nShape: {shape}\nNotes: {notes}\n{ask}',
579
804
  styleColors: 'background {bg}, cards {surface}, text {text}, muted text {muted}, lines {line}, primary {primary} (text on it {on}), accent {accent} ({accUse}), links and small emphasis {link}{bgImage}',
580
805
  accBig: 'graphics and large text only',
@@ -640,7 +865,8 @@ window.__ModuleLoader__.load({
640
865
  twFix: 'Ask the model to fix it',
641
866
  twMakeText: '[Tweaks] Make 3–5 tweaks for this design: pick the dimensions most worth trying (spacing, radius, brand color, card layout…), write them to tweaks.json in the design folder as the ui-design skill describes under "Tweaks", wire up the styles, then run check_design.',
642
867
  twFixText: '[Tweaks] tweaks.json has problems: {errs}. Fix it as the ui-design skill describes under "Tweaks".',
643
- twAskHead: '[Tweaks] I set the tweaks in the sidebar to these values. Change the design to match (edit the style sources and update the matching values in tweaks.json):',
868
+ twAskHead: '[Tweaks] I set the tweaks in the sidebar to these values:',
869
+ twAskTail: 'Change the design to match (edit the style sources and update the matching values in tweaks.json).',
644
870
  twAskTip: 'Let the model apply these values (and adjust related details)',
645
871
  twSaveTip: 'CSS variables go into the defaults in tokens.css, attributes onto each screen\'s <html> (a version is kept before and after)',
646
872
  twNoWrite: 'Dev-server pages cannot be written back. Ask the model instead',
@@ -685,7 +911,8 @@ window.__ModuleLoader__.load({
685
911
  let s = ''
686
912
  try { s = bound ? bound(key) : '' } catch { s = '' }
687
913
  if (!s || s === key || s === `${NS}.${key}`) s = ZH[key] || key
688
- if (vars) for (const [k, v] of Object.entries(vars)) s = s.split(`{${k}}`).join(String(v))
914
+ // 一次替换完:填进去的内容里就算有「{x}」(比如目录名)也不会再被换掉
915
+ if (vars) s = s.replace(/\{(\w+)\}/g, (m, k) => (Object.prototype.hasOwnProperty.call(vars, k) ? String(vars[k]) : m))
689
916
  return s
690
917
  }
691
918
  }
@@ -693,10 +920,26 @@ window.__ModuleLoader__.load({
693
920
  // ── 样式(DSH 设计变量,深浅主题跟着走)──────────────────────────────────
694
921
 
695
922
  const ACCENT = '#4d6bfe'
923
+ const PICK_SRC = 'dsh-design-studio' // 输入框里点选标签的来源名(样式里也用)
924
+ // 输入框里的各种标签:图标按来源名分(DSH 的标签上只带来源名),消息里和悬停卡片按 data-i
925
+ const CHIP_ICON = {
926
+ pick: 'M4 4l6.5 16 2.3-6.7L19.5 11z M13 13l6 6',
927
+ mark: 'M4 20l4.5-1L19 8.5a2.1 2.1 0 0 0-3-3L5.5 16z M14 7.5l3 3',
928
+ text: 'M5 7V5h14v2 M12 5v14 M9 19h6',
929
+ style: 'M4 7h9 M17 7h3 M4 17h3 M11 17h9 M13 7a2 2 0 1 0 4 0a2 2 0 1 0-4 0 M7 17a2 2 0 1 0 4 0a2 2 0 1 0-4 0',
930
+ layers: 'M12 3l9 5-9 5-9-5z M3 13l9 5 9-5',
931
+ check: 'M3 12a9 9 0 1 0 18 0a9 9 0 1 0-18 0 M8 12.5l2.7 2.7L16 10',
932
+ code: 'M8 7l-5 5 5 5 M16 7l5 5-5 5',
933
+ image: 'M4 5h16v14H4z M4 16l5-5 4 4 2-2 5 5 M14.5 9a1 1 0 1 0 2 0a1 1 0 1 0-2 0',
934
+ look: 'M12 3a9 9 0 1 0 0 18c1 0 1.6-.8 1.6-1.6 0-1-1-1.4-1-2.4 0-.9.7-1.6 1.6-1.6H16a5 5 0 0 0 5-5c0-4-4-7.4-9-7.4z M7.5 12h.01 M9.5 7.8h.01 M14.5 7.8h.01',
935
+ }
936
+ const chipSrc = (i) => i === 'pick' ? PICK_SRC : `${PICK_SRC}-${i}`
937
+ const iconUrl = (d) => `url("data:image/svg+xml,${encodeURIComponent(`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.3' stroke-linecap='round' stroke-linejoin='round'><path d='${d}'/></svg>`)}")`
696
938
  const EASE = 'cubic-bezier(.2,.8,.2,1)'
697
939
  const CSS = `
698
940
  .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}
699
941
  .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}
942
+ .ddp-bar.editing>:not(form){display:none}
700
943
  .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}
701
944
  .ddp-addr:hover{border-color:var(--dsw-alias-border-l3)}
702
945
  .ddp-addr span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
@@ -750,6 +993,11 @@ window.__ModuleLoader__.load({
750
993
  .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}
751
994
  .ddp-island{position:absolute;left:50%;width:118px;height:34px;margin-left:-59px;border-radius:20px;background:#000;z-index:4;pointer-events:none}
752
995
  .ddp-cap{position:absolute;width:87px;height:32px;z-index:4;pointer-events:none;overflow:visible;fill:#000}
996
+ .ddp-dev.k-desktop{box-shadow:0 0 0 .5px rgba(0,0,0,.3),0 22px 60px rgba(0,0,0,.28)}
997
+ .ddp-dev.fill.k-desktop{box-shadow:none}
998
+ .ddp-win{position:absolute;z-index:4;pointer-events:none;overflow:visible}
999
+ .ddp-win .x{fill:none;stroke:#1f1f1f;stroke-width:1}
1000
+ .ddp-win.w .x{stroke:#fff}
753
1001
  .ddp-cap .pl{fill:rgba(255,255,255,.6);stroke:rgba(0,0,0,.08);stroke-width:.5}
754
1002
  .ddp-cap .sp{stroke:rgba(0,0,0,.15);stroke-width:.5}
755
1003
  .ddp-cap .rg{fill:none;stroke:#000;stroke-width:2}
@@ -811,6 +1059,42 @@ window.__ModuleLoader__.load({
811
1059
  @keyframes ddp-txt{from{background-position:100% 0}to{background-position:-160% 0}}
812
1060
  @keyframes ddp-blink{50%{opacity:.25}}
813
1061
  @keyframes ddp-in{from{opacity:0;translate:0 6px;scale:.98}}
1062
+ .ddp-mark{position:absolute;inset:0;z-index:6;outline:none;box-shadow:inset 0 0 0 2px rgba(77,107,254,.5)}
1063
+ .ddp-mark .cv{position:absolute;inset:0;width:100%;height:100%;cursor:crosshair;touch-action:none}
1064
+ .ddp-mark .cv.tx{cursor:text}
1065
+ .ddp-mark .cv.er{cursor:cell}
1066
+ .ddp-mark textarea{position:absolute;z-index:2;margin:0;padding:0 2px;border:1px dashed rgba(77,107,254,.85);border-radius:3px;background:rgba(255,255,255,.1);font-family:${'"Kaiti SC","STKaiti","KaiTi","楷体",system-ui,-apple-system,"PingFang SC","Microsoft YaHei",sans-serif'};font-weight:700;line-height:1.3;resize:none;overflow:hidden;white-space:pre;outline:none;text-shadow:0 0 2px rgba(255,255,255,.9),0 0 3px rgba(255,255,255,.9)}
1067
+ .ddp-mark textarea.lt{text-shadow:0 0 2px rgba(0,0,0,.7),0 0 3px rgba(0,0,0,.7)}
1068
+ .ddp-mark textarea::placeholder{color:inherit;opacity:.55}
1069
+ .ddp-mk-top,.ddp-mk-bot{position:absolute;left:8px;right:8px;z-index:3;display:flex;justify-content:center;pointer-events:none}
1070
+ .ddp-mk-top{top:10px}
1071
+ .ddp-mk-bot{bottom:12px}
1072
+ .ddp-mk-tools{pointer-events:auto;display:flex;flex-wrap:wrap;justify-content:center;align-items:center;gap:2px;max-width:100%;padding:4px;border-radius:12px;background:var(--dsw-alias-bg-layer-1);
1073
+ box-shadow:0 8px 24px rgba(0,0,0,.18),0 0 0 .5px var(--dsw-alias-border-l3);animation:ddp-in .16s ${EASE}}
1074
+ .ddp-mk-tools .ddp-btn.sel{background:rgba(77,107,254,.14);color:${ACCENT}}
1075
+ .ddp-mk-color{position:relative;display:inline-flex}
1076
+ .ddp-mk-color>.ddp-btn i{width:15px;height:15px;border-radius:50%;box-shadow:inset 0 0 0 1px rgba(0,0,0,.18),0 0 0 1px rgba(255,255,255,.35)}
1077
+ .ddp-mk-pal{position:absolute;left:50%;top:34px;transform:translateX(-50%);z-index:2;display:grid;grid-template-columns:repeat(4,22px);gap:7px;padding:9px;border-radius:11px;background:var(--dsw-alias-bg-layer-1);
1078
+ box-shadow:0 12px 32px rgba(0,0,0,.24),0 0 0 .5px var(--dsw-alias-border-l3)}
1079
+ .ddp-mk-pal button{width:22px;height:22px;padding:0;border:0;border-radius:50%;cursor:pointer;box-shadow:inset 0 0 0 1px rgba(0,0,0,.18)}
1080
+ .ddp-mk-pal button.sel{outline:2px solid ${ACCENT};outline-offset:2px}
1081
+ .ddp-mk-sz{display:inline-flex}
1082
+ .ddp-mk-sz .ddp-btn{min-width:24px;padding:0 4px}
1083
+ .ddp-mk-sz i{border-radius:50%;background:currentColor}
1084
+ .ddp-mk-bar{pointer-events:auto;display:flex;align-items:center;gap:6px;max-width:100%;padding:4px 4px 4px 14px;border-radius:999px;background:rgba(24,24,27,.94);color:#f4f4f5;font-size:12px;
1085
+ box-shadow:0 8px 24px rgba(0,0,0,.25);animation:ddp-in .16s ${EASE}}
1086
+ .ddp-mk-bar span{flex:1;min-width:0;margin-right:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:rgba(244,244,245,.8)}
1087
+ .ddp-mk-bar span b{margin-right:6px;font-weight:600;color:#fff}
1088
+ .ddp-mk-bar button{flex:none;height:28px;padding:0 13px;border:0;border-radius:999px;background:rgba(255,255,255,.12);color:#f4f4f5;font:inherit;cursor:pointer}
1089
+ .ddp-mk-bar button:hover:not(:disabled){background:rgba(255,255,255,.2)}
1090
+ .ddp-mk-bar button.go{background:${ACCENT};color:#fff;font-weight:600}
1091
+ .ddp-mk-bar button.go:hover:not(:disabled){background:#3d5bf0}
1092
+ .ddp-mk-bar button.go.idle{background:rgba(77,107,254,.45);color:rgba(255,255,255,.75)}
1093
+ .ddp-mk-coach{position:absolute;left:50%;top:50%;z-index:2;transform:translate(-50%,-50%);width:max-content;max-width:calc(100% - 32px);padding:14px 18px 12px;border-radius:14px;pointer-events:none;background:rgba(24,24,27,.86);color:#f4f4f5;font-size:13px;line-height:1.55;box-shadow:0 10px 30px rgba(0,0,0,.25);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);animation:ddp-in .2s ease}
1094
+ .ddp-mk-coach b{display:block;margin-bottom:6px;font-size:14px;font-weight:600;color:#fff}
1095
+ .ddp-mk-coach ol{margin:0;padding:0 0 0 1.3em;color:rgba(244,244,245,.86)}
1096
+ .ddp-mk-coach li+li{margin-top:2px}
1097
+ .ddp-mk-bar button:disabled{opacity:.5;cursor:default}
814
1098
  .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}
815
1099
  .ddp-skel.gone{opacity:0}
816
1100
  .ddp-skel b{display:block;border-radius:8px;background:var(--dsw-alias-bg-layer-2);position:relative;overflow:hidden}
@@ -819,8 +1103,8 @@ window.__ModuleLoader__.load({
819
1103
  .ddp-wait{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px;padding:24px}
820
1104
  .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}
821
1105
  .ddp-wait p{margin:0;color:var(--dsw-alias-label-tertiary);text-align:center;line-height:1.6}
822
- .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}
823
- .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)}
1106
+ .ddp-toast{position:absolute;left:12px;right:12px;top:14px;width:fit-content;max-width:calc(100% - 24px);margin-inline:auto;z-index:7;padding:7px 14px;border-radius:14px;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-1);font-size:12px;line-height:1.5;text-align:center;overflow-wrap:anywhere;box-shadow:0 6px 20px rgba(0,0,0,.2);animation:ddp-in .2s ease}
1107
+ .ddp-empty{flex:1;min-height:0;overflow-y:auto;display:flex;flex-direction:column;align-items:center;justify-content:center;justify-content:safe center;gap:12px;padding:32px 24px;text-align:center;color:var(--dsw-alias-label-tertiary)}
824
1108
  .ddp-empty b{color:var(--dsw-alias-label-primary);font-size:14px;font-weight:500}
825
1109
  .ddp-empty p{margin:6px 0 0;max-width:300px;line-height:1.6}
826
1110
  .ddp-steps{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:7px;text-align:left;counter-reset:s}
@@ -837,7 +1121,11 @@ window.__ModuleLoader__.load({
837
1121
  @keyframes ddp-ants{to{stroke-dashoffset:-14}}
838
1122
  @keyframes ddp-sel{0%,30%{opacity:0}42%,88%{opacity:1}100%{opacity:0}}
839
1123
  @keyframes ddp-cur{0%,100%{transform:translate(132px,98px)}36%,90%{transform:translate(58px,76px)}}
840
- .ddp-empty form{display:flex;gap:6px;width:100%;max-width:320px;margin-top:4px}
1124
+ .ddp-empty form{display:flex;flex-wrap:wrap;gap:6px;width:100%;max-width:320px;margin-top:4px}
1125
+ .ddp-browse{height:26px;padding:0 12px;flex:none;border:.5px solid var(--dsw-alias-border-l3,rgba(127,127,127,.4));border-radius:999px;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;cursor:pointer}
1126
+ .ddp-browse:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}
1127
+ .ddp-drop .in.no{border-color:var(--dsw-alias-label-tertiary)}
1128
+ .ddp-drop .in.no svg{color:var(--dsw-alias-label-tertiary)}
841
1129
  .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}
842
1130
  .ddp-err{color:var(--dsw-alias-state-error-primary,#e5484d);font-size:11.5px}
843
1131
  .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}
@@ -878,6 +1166,7 @@ window.__ModuleLoader__.load({
878
1166
  .ddp{position:relative;outline:none}
879
1167
  .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}
880
1168
  .ddp-drop *{pointer-events:none}
1169
+ .ddp-drop.soft{pointer-events:none}
881
1170
  .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}
882
1171
  .ddp-drop .in svg{width:28px;height:28px;color:${ACCENT};margin-bottom:4px}
883
1172
  .ddp-drop .in b{color:var(--dsw-alias-label-primary);font-size:14px;font-weight:500}
@@ -920,14 +1209,47 @@ window.__ModuleLoader__.load({
920
1209
  .ddp-style .cnt{padding:0 14px 6px;font-size:11px;color:var(--dsw-alias-label-tertiary)}
921
1210
  .ddp-styles{display:grid;grid-template-columns:repeat(auto-fill,minmax(92px,1fr));gap:10px 8px}
922
1211
  .ddp-sc{display:flex;flex-direction:column;gap:5px;min-width:0;padding:0;border:0;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;text-align:left;cursor:pointer}
923
- .ddp-sc .pv{position:relative;display:block;height:58px;border-radius:10px;overflow:hidden;box-shadow:0 0 0 .5px rgba(127,127,127,.35);transition:transform .15s ${EASE},box-shadow .15s}
924
- .ddp-sc:hover .pv{transform:translateY(-1px);box-shadow:0 0 0 1.5px ${ACCENT},0 6px 16px rgba(0,0,0,.12)}
925
- .ddp-sc:hover{color:var(--dsw-alias-label-primary)}
926
- .ddp-sc .pv b{position:absolute;left:8px;top:6px;font-size:17px;line-height:1}
927
- .ddp-sc .pv .bt{position:absolute;right:8px;top:8px;width:20px;height:9px}
928
- .ddp-sc .pv .cd{position:absolute;left:8px;right:8px;bottom:7px;height:20px}
929
- .ddp-sc .pv .ln{position:absolute;left:6px;top:6px;width:46%;height:3px;border-radius:2px;opacity:.85}
930
- .ddp-sc .pv .ln.s{top:12px;width:30%;opacity:.6}
1212
+ .ddp-sc .pv{position:relative;display:block;height:74px;border-radius:10px;overflow:hidden;container-type:inline-size;box-shadow:0 0 0 .5px rgba(127,127,127,.35);transition:transform .15s ${EASE},box-shadow .15s}
1213
+ .ddp-sc:hover .pv,.ddp-sc:focus-visible .pv{transform:translateY(-1px);box-shadow:0 0 0 1.5px ${ACCENT},0 6px 16px rgba(0,0,0,.12)}
1214
+ .ddp-sc:focus-visible{outline:none}
1215
+ .ddp-sc:hover,.ddp-sc:focus-visible{color:var(--dsw-alias-label-primary)}
1216
+ .ddp-sc .pv .ddp-sam{width:calc(100cqw / .32);min-height:calc(100% / .32);transform:scale(.32);transform-origin:0 0;pointer-events:none}
1217
+ .ddp-sam{display:flex;flex-direction:column;gap:7px;padding:12px 14px 14px;font-size:11px;line-height:1.45;text-align:left;overflow:hidden;box-sizing:border-box}
1218
+ .ddp-sam b{font-weight:600;color:inherit} /* 空白页的 .ddp-empty b 会把字染成界面色 */
1219
+ .ddp-sam .top{display:flex;align-items:center;gap:6px;margin-bottom:3px}
1220
+ .ddp-sam .top b{font-size:12px}
1221
+ .ddp-sam .lg{width:14px;height:14px;flex:none}
1222
+ .ddp-sam .nv{margin-left:auto;font-size:10px;white-space:nowrap}
1223
+ .ddp-sam .av{width:18px;height:18px;flex:none;border-radius:50%}
1224
+ .ddp-sam h5{margin:2px 0 0;font-size:21px;line-height:1.2}
1225
+ .ddp-sam p{margin:0}
1226
+ .ddp-sam .acts{display:flex;align-items:center;gap:12px;margin:2px 0 4px;white-space:nowrap}
1227
+ .ddp-sam .bt{display:inline-flex;align-items:center;height:26px;padding:0 13px;font-weight:600}
1228
+ .ddp-sam .cards{display:grid;grid-template-columns:1.25fr 1fr;gap:9px;align-items:stretch}
1229
+ .ddp-sam .cd{display:flex;flex-direction:column;gap:2px;min-width:0;padding:8px;font-size:10px;box-sizing:border-box}
1230
+ .ddp-sam .cd>*{overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
1231
+ .ddp-sam .cd>b{font-size:11.5px}
1232
+ .ddp-sam .im{display:block;flex:none;height:46px;margin-bottom:4px}
1233
+ .ddp-sam .pr{display:flex;align-items:center;justify-content:space-between;gap:6px;margin-top:3px}
1234
+ .ddp-sam .pr b{font-size:15px;font-weight:700}
1235
+ .ddp-sam .chip{padding:1px 7px;border-radius:999px;font-size:9.5px;font-weight:600}
1236
+ .ddp-sam .cd>.num{margin:2px 0 6px;font-size:28px;line-height:1.1}
1237
+ .ddp-sam .bar{position:relative;display:block;flex:none;height:5px;margin-bottom:5px;border-radius:3px}
1238
+ .ddp-sam .bar i{position:absolute;left:0;top:0;bottom:0;width:86%;border-radius:inherit}
1239
+ .ddp-samview{display:flex;flex-direction:column;gap:8px;text-align:left}
1240
+ .ddp-samview .all{align-self:flex-start;height:24px;margin-left:-4px;font-size:12px}
1241
+ .ddp-samview .all svg,.ddp-samview .row svg{width:14px;height:14px}
1242
+ .ddp-samview .ddp-sam{border-radius:12px;box-shadow:0 0 0 .5px rgba(127,127,127,.35),0 8px 24px rgba(0,0,0,.10);animation:ddp-in .2s ${EASE}}
1243
+ .ddp-samview .meta{display:flex;flex-wrap:wrap;align-items:center;gap:2px 8px}
1244
+ .ddp-samview .meta b{font-size:13px;font-weight:500;color:var(--dsw-alias-label-primary)}
1245
+ .ddp-samview .meta p{flex-basis:100%;margin:0;font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary)}
1246
+ .ddp-samview .sw{display:flex;gap:3px;margin-left:auto}
1247
+ .ddp-samview .sw i{width:12px;height:12px;border-radius:50%;box-shadow:0 0 0 .5px rgba(127,127,127,.5)}
1248
+ .ddp-samview .row{display:flex;align-items:center;gap:2px}
1249
+ .ddp-samview .row .ddp-btn{height:26px;min-width:26px}
1250
+ .ddp-samview .row .nx svg{transform:scaleX(-1)}
1251
+ .ddp-samview .row em{min-width:40px;font-style:normal;font-size:11px;text-align:center;color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}
1252
+ .ddp-samview .row .ddp-go{margin-left:auto;height:28px;font-size:12px}
931
1253
  .ddp-sc .nm{font-size:12px;line-height:16px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
932
1254
  .ddp-tries{display:flex;flex-wrap:wrap;justify-content:center;gap:6px;max-width:440px}
933
1255
  .ddp-tries>span{flex-basis:100%;font-size:11px;line-height:16px}
@@ -986,6 +1308,7 @@ window.__ModuleLoader__.load({
986
1308
  .ddp-banner .ddp-btn{height:24px;min-width:24px;border-radius:999px}
987
1309
  .ddp-toast{display:flex;align-items:center;gap:10px}
988
1310
  .ddp-toast.low{top:56px}
1311
+ .ddp-toast.mk{top:auto;bottom:62px}
989
1312
  .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}
990
1313
  .ddp-cmp{position:absolute;inset:0;overflow:hidden}
991
1314
  .ddp-cmp>.ly{position:absolute;inset:0}
@@ -1030,7 +1353,7 @@ window.__ModuleLoader__.load({
1030
1353
  .ddp-sysc .acts button{flex:1;height:26px;padding:0 6px;border:0;border-radius:7px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary);font:12px/26px inherit;cursor:pointer;white-space:nowrap}
1031
1354
  .ddp-sysc .acts button:hover{color:var(--dsw-alias-label-primary)}
1032
1355
  .ddp-sys form{display:flex;gap:6px}
1033
- .ddp-sys form input,.ddp-ho input[type=text]{flex:1;min-width:0;height:28px;padding:0 9px;border:1px solid var(--dsw-alias-separator-primary,rgba(127,127,127,.3));border-radius:8px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:12px inherit;outline:none}
1356
+ .ddp-sys form input,.ddp-ho input[type=text]{flex:1;min-width:0;box-sizing:border-box;height:28px;padding:0 9px;border:1px solid var(--dsw-alias-separator-primary,rgba(127,127,127,.3));border-radius:8px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:12px inherit;outline:none}
1034
1357
  .ddp-sys form input:focus,.ddp-ho input[type=text]:focus{border-color:${ACCENT}}
1035
1358
  .ddp-tokens.ddp-sys footer form button{width:auto;height:28px;flex:none;justify-content:center;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-1);border-radius:999px;padding:0 12px}
1036
1359
  .ddp-tokens.ddp-sys footer form button:disabled{opacity:.45;cursor:default}
@@ -1038,6 +1361,15 @@ window.__ModuleLoader__.load({
1038
1361
  .ddp-ho .row{padding:9px 0 10px;border-top:1px solid var(--dsw-alias-separator-primary,rgba(127,127,127,.16))}
1039
1362
  .ddp-ho .row:first-child{border-top:0}
1040
1363
  .ddp-ho label{display:block;margin-bottom:7px;font-size:12px;color:var(--dsw-alias-label-secondary)}
1364
+ .ddp-ho .pathrow{display:flex;gap:6px;align-items:center}
1365
+ .ddp-ho .moved{margin:6px 0 0;font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary)}
1366
+ .ddp-ho .moved.bad{color:var(--dsw-alias-state-error-primary,#d54941)}
1367
+ .ddp-dirbtn{display:flex;align-items:center;justify-content:center;width:28px;height:28px;flex:none;padding:0;border:1px solid var(--dsw-alias-separator-primary,rgba(127,127,127,.3));border-radius:8px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary);cursor:pointer}
1368
+ .ddp-dirbtn:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l3,rgba(127,127,127,.5))}
1369
+ .ddp-dirbtn:disabled{opacity:.5;cursor:default}
1370
+ .ddp-dirbtn svg{width:15px;height:15px}
1371
+ .ddp-tokens.ddp-sys footer form button.ddp-dirbtn{width:28px;padding:0;border:1px solid var(--dsw-alias-separator-primary,rgba(127,127,127,.3));background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary);border-radius:8px}
1372
+ .ddp-tokens.ddp-sys footer form button.ddp-dirbtn:hover:not(:disabled){background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l3,rgba(127,127,127,.5))}
1041
1373
  .ddp-ho input[type=text]{width:100%;box-sizing:border-box;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px}
1042
1374
  .ddp-ho .opt{display:flex;align-items:center;gap:8px;width:100%;min-height:28px;padding:4px 8px;border:0;border-radius:8px;background:transparent;color:var(--dsw-alias-label-secondary);font:12px/18px inherit;text-align:left;cursor:pointer}
1043
1375
  .ddp-ho .opt:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}
@@ -1051,13 +1383,42 @@ window.__ModuleLoader__.load({
1051
1383
  .ddp-menu .o1,.ddp-menu .o2{display:none}
1052
1384
  @container (max-width:700px){.ddp-bar>.o1{display:none}.ddp-bar>.ddp-more{display:block}.ddp-menu .o1{display:flex}}
1053
1385
  @container (max-width:580px){.ddp-bar>.o2{display:none}.ddp-menu .o2{display:flex}}
1386
+ /* 标注底栏:右侧栏窄时「这支笔怎么用」折成两行,不被三个按钮挤成省略号 */
1387
+ @container (max-width:640px){.ddp-mk-bar{border-radius:18px}.ddp-mk-bar span{white-space:normal;line-height:1.35;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}}
1388
+ /* 画标注时预览不动,画布右下角的缩放按钮被标注层盖着也点不到:先收起来,免得和底栏叠在一起 */
1389
+ .ddp-stage:has(>.ddp-mark) .ddp-zoom{display:none}
1390
+ /* 输入框里的标签:DSH 的引用标签换成我们的图标 + 底色;消息里我们自己画同样的样子 */
1391
+ [data-composer-chip^="${PICK_SRC}"]>span,.ddp-pickpill{border-radius:6px;padding:0 6px 0 5px;background:var(--dsw-alias-state-business-tertiary,color-mix(in srgb,${ACCENT} 13%,transparent));color:var(--dsw-alias-state-business-primary,${ACCENT});cursor:default}
1392
+ [data-composer-chip^="${PICK_SRC}"]>span>[aria-hidden]{width:12px;height:12px;align-self:center;font-size:0;background:currentColor}
1393
+ [data-composer-chip^="${PICK_SRC}"]>span>[aria-hidden],.ddp-pickpill::before,.ddp-pickcard b::before{-webkit-mask-position:center;-webkit-mask-size:contain;-webkit-mask-repeat:no-repeat;mask-position:center;mask-size:contain;mask-repeat:no-repeat}
1394
+ ${Object.entries(CHIP_ICON).map(([k, d]) => `[data-composer-chip="${chipSrc(k)}"]>span>[aria-hidden],.ddp-pickpill[data-i="${k}"]::before,.ddp-pickcard[data-i="${k}"] b::before{-webkit-mask-image:${iconUrl(d)};mask-image:${iconUrl(d)}}`).join('\n')}
1395
+ .ddp-pickpill{display:inline;margin:0 2px;font-weight:500;white-space:inherit;overflow-wrap:anywhere;-webkit-box-decoration-break:clone;box-decoration-break:clone}
1396
+ .ddp-pickpill::before{content:'';display:inline-block;width:.86em;height:.86em;margin-right:4px;vertical-align:-.1em;background:currentColor}
1397
+ .ddp-pickcard{position:fixed;z-index:2147483000;max-width:min(340px,calc(100vw - 24px));padding:10px 12px;border-radius:12px;pointer-events:none;
1398
+ background:linear-gradient(var(--dsw-specific-menu,transparent),var(--dsw-specific-menu,transparent)),var(--dsw-specific-input-major,var(--dsw-alias-bg-base,#fff));
1399
+ color:var(--dsw-alias-label-primary,#18181b);font-size:13px;line-height:1.5;--dsw-elevation-stroke-color:var(--dsw-alias-border-l1,rgba(0,0,0,.08));
1400
+ box-shadow:var(--dsw-elevation-prominent,0 0 0 1px rgba(0,0,0,.08),0 10px 28px rgba(0,0,0,.18));animation:ddp-in .12s ${EASE}}
1401
+ .ddp-pickcard b{display:flex;align-items:center;gap:6px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1402
+ .ddp-pickcard b::before{content:'';flex:none;width:14px;height:14px;background:${ACCENT}}
1403
+ .ddp-pickcard code{display:block;margin-top:3px;font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--dsw-alias-label-secondary,#52525b);word-break:break-all}
1404
+ .ddp-pickcard span{display:block;margin-top:2px;color:var(--dsw-alias-label-tertiary,#a1a1aa);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1405
+ .ddp-pickcard p{margin:3px 0 0;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden;overflow-wrap:anywhere}
1406
+ .ddp-pickcard img{display:block;width:100%;max-height:200px;margin:7px 0 2px;object-fit:contain;border-radius:8px;background:var(--dsw-alias-bg-layer-2,rgba(127,127,127,.08))}
1407
+ .ddp-pickcard>i{display:flex;flex-wrap:wrap;gap:4px;margin-top:6px}
1408
+ .ddp-pickcard>i>i{width:16px;height:16px;border-radius:50%;box-shadow:inset 0 0 0 1px rgba(127,127,127,.35)}
1409
+ .ddp-pickcard.wide{width:min(320px,calc(100vw - 24px))}
1054
1410
  `
1411
+ // 页面不刷新就换了新版插件时,旧版留下的样式要换成新的;卸载时一起拿掉
1055
1412
  function ensureStyle() {
1056
- if (typeof document === 'undefined' || document.getElementById('dsh-design-studio-style')) return
1057
- const el = document.createElement('style')
1058
- el.id = 'dsh-design-studio-style'
1059
- el.textContent = CSS
1060
- document.head.appendChild(el)
1413
+ if (typeof document === 'undefined') return () => {}
1414
+ let el = document.getElementById('dsh-design-studio-style')
1415
+ if (!el) {
1416
+ el = document.createElement('style')
1417
+ el.id = 'dsh-design-studio-style'
1418
+ document.head.appendChild(el)
1419
+ }
1420
+ if (el.textContent !== CSS) el.textContent = CSS
1421
+ return () => { if (el.textContent === CSS) el.remove() }
1061
1422
  }
1062
1423
 
1063
1424
  // ── 图标 ─────────────────────────────────────────────────────────────────
@@ -1072,6 +1433,7 @@ window.__ModuleLoader__.load({
1072
1433
  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' })]),
1073
1434
  miniprogram: () => svg([h('rect', { key: 1, x: 6, y: 2.5, width: 12, height: 19, rx: 2.4 }), h('rect', { key: 2, x: 11.5, y: 5.2, width: 4.5, height: 2.4, rx: 1.2 })]),
1074
1435
  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' })]),
1436
+ desktop: () => svg([h('rect', { key: 1, x: 2.5, y: 4.5, width: 19, height: 15, rx: 2.6 }), h('path', { key: 2, d: 'M9 4.5v15' }), h('path', { key: 3, d: 'M4.8 7.2h.01M6.8 7.2h.01' })]),
1075
1437
  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 })]),
1076
1438
  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' })]),
1077
1439
  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' })]),
@@ -1108,6 +1470,16 @@ window.__ModuleLoader__.load({
1108
1470
  dial: () => svg([h('circle', { key: 1, cx: 12, cy: 13.5, r: 6.5 }), h('path', { key: 2, d: 'M12 13.5l3.2-3.2' }), h('path', { key: 3, d: 'M12 3.5v2M4.6 7.6l1.4 1.1M19.4 7.6L18 8.7' })]),
1109
1471
  code: () => svg([h('path', { key: 1, d: 'M8.5 7.5L4 12l4.5 4.5M15.5 7.5L20 12l-4.5 4.5' }), h('path', { key: 2, d: 'M13.5 5l-3 14' })]),
1110
1472
  library: () => svg([h('rect', { key: 1, x: 3.5, y: 4, width: 5, height: 16, rx: 1.2 }), h('rect', { key: 2, x: 10, y: 4, width: 5, height: 16, rx: 1.2 }), h('path', { key: 3, d: 'M16.6 5.4l3.7 13.9' })]),
1473
+ pen: () => svg([h('path', { key: 1, d: 'M4.5 19.5l.9-4.2L15.6 5.1a2 2 0 0 1 2.9 0l.4.4a2 2 0 0 1 0 2.9L8.7 18.6z' }), h('path', { key: 2, d: 'M13.8 6.9l3.3 3.3' })]),
1474
+ marker: () => svg([h('path', { key: 1, d: 'M14.5 4.5l5 5-7.5 7.5H8.5v-3.5z' }), h('path', { key: 2, d: 'M8.5 13.5L4 18h4.5' }), h('path', { key: 3, d: 'M13 20.5h7' })]),
1475
+ arrow: () => svg([h('path', { key: 1, d: 'M5 19L19 5' }), h('path', { key: 2, d: 'M10 5h9v9' })]),
1476
+ square: () => svg([h('rect', { key: 1, x: 4, y: 5, width: 16, height: 14, rx: 1.6 })]),
1477
+ circle: () => svg([h('ellipse', { key: 1, cx: 12, cy: 12, rx: 8.5, ry: 7.5 })]),
1478
+ eraser: () => svg([h('path', { key: 1, d: 'M8.6 19.5L4.4 15.3a1.5 1.5 0 0 1 0-2.1l8.8-8.8a1.5 1.5 0 0 1 2.1 0l4.2 4.2a1.5 1.5 0 0 1 0 2.1L11 19.5z' }), h('path', { key: 2, d: 'M8.8 9.2l6 6' }), h('path', { key: 3, d: 'M11 19.5h9' })]),
1479
+ undo: () => svg([h('path', { key: 1, d: 'M9 14L4.5 9.5 9 5' }), h('path', { key: 2, d: 'M4.5 9.5H15a5 5 0 0 1 0 10h-3' })]),
1480
+ redo: () => svg([h('path', { key: 1, d: 'M15 14l4.5-4.5L15 5' }), h('path', { key: 2, d: 'M19.5 9.5H9a5 5 0 0 0 0 10h3' })]),
1481
+ trash: () => svg([h('path', { key: 1, d: 'M4.5 7h15M9.5 7V4.5h5V7' }), h('path', { key: 2, d: 'M6.5 7l.9 12a1.5 1.5 0 0 0 1.5 1.4h6.2a1.5 1.5 0 0 0 1.5-1.4l.9-12' }), h('path', { key: 3, d: 'M10.5 11v5.5M13.5 11v5.5' })]),
1482
+ folder: () => svg([h('path', { key: 1, d: 'M3.5 7.5a2 2 0 0 1 2-2h3.8l2 2.2h7.2a2 2 0 0 1 2 2v7.8a2 2 0 0 1-2 2h-13a2 2 0 0 1-2-2z' })]),
1111
1483
  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),
1112
1484
  }
1113
1485
  // 引导页上的大图标
@@ -1177,6 +1549,63 @@ window.__ModuleLoader__.load({
1177
1549
  return data
1178
1550
  }
1179
1551
 
1552
+ /** 打开一个本地文件 / 文件夹 / 端口(地址栏、「浏览…」、拖进来都走这里),打开后马上拉一次新状态 */
1553
+ async function openTarget(sid, target) {
1554
+ const s = await post('open', { session: sid, target })
1555
+ const p = poller(sid)
1556
+ p.stop()
1557
+ p.start()
1558
+ return s
1559
+ }
1560
+
1561
+ // DSH 桌面版给的两座桥:拿选中 / 拖进来的文件的真实路径;弹系统的选文件夹窗口。网页版没有,对应的按钮就不显示
1562
+ const hostPaths = () => { const b = globalThis.__DSH_HOST_PATHS__; return b && typeof b.pathFor === 'function' ? b : null }
1563
+ const dirPicker = () => { const b = globalThis.__DSH_DIRECTORY_PICKER__; return b && typeof b.pick === 'function' ? b : null }
1564
+ const pathOf = (file) => { try { const b = hostPaths(); return (b && file && b.pathFor(file)) || '' } catch { return '' } }
1565
+ const HTML_FILE = /\.html?$/i
1566
+ const isPageFile = (f) => !!f && (HTML_FILE.test(f.name || '') || /^text\/html$/i.test(f.type || ''))
1567
+
1568
+ /**
1569
+ * 拖进来的是什么:'page' HTML 文件(桌面版能打开)、'maybe' 没类型的(多半是文件夹)、'web' 这两种但网页版拿不到路径、
1570
+ * 'image' 图片(当参考图)、'other' 别的文件。拖着的时候只看得到类型(文件夹、没后缀的文件类型是空的)
1571
+ */
1572
+ function dragKind(dt) {
1573
+ const all = [...((dt && dt.items) || [])].filter(Boolean)
1574
+ const items = all.filter((x) => x.kind === 'file')
1575
+ if (!items.length) return 'image'
1576
+ // 从网页上拖下来的图:带着网址和 <img> 那段 HTML,文件那项常常没类型——是图,不是文件夹
1577
+ const strs = all.filter((x) => x.kind === 'string').map((x) => String(x.type).toLowerCase())
1578
+ if (strs.includes('text/uri-list') && strs.includes('text/html') && items.every((x) => !x.type || /^image\//i.test(x.type))) return 'image'
1579
+ // 拖着时看不到文件名:标了 HTML 的是页面;没类型的多半是文件夹(也可能是 .ts、Makefile 这种),蒙层话说中性一点
1580
+ if (items.some((x) => /^text\/html$/i.test(x.type))) return hostPaths() ? 'page' : 'web'
1581
+ if (items.some((x) => !x.type)) return hostPaths() ? 'maybe' : 'web'
1582
+ if (items.some((x) => /^image\//i.test(x.type))) return 'image'
1583
+ return 'other'
1584
+ }
1585
+
1586
+ /** 松手时拖进来的东西 → { page: 要打开的文件 / 文件夹的 File, dir: 是不是文件夹, image: 图片 File } */
1587
+ function droppedOf(dt) {
1588
+ const out = { page: null, dir: false, image: null, any: false }
1589
+ const items = [...((dt && dt.items) || [])].filter((x) => x && x.kind === 'file')
1590
+ const list = items.length
1591
+ ? items.map((x) => {
1592
+ let dir = false
1593
+ // 文件夹只有 entry 分得出来,得在 drop 里同步取
1594
+ try { const en = typeof x.webkitGetAsEntry === 'function' ? x.webkitGetAsEntry() : null; dir = !!(en && en.isDirectory) } catch { dir = false }
1595
+ let f = null
1596
+ try { f = x.getAsFile() } catch { f = null }
1597
+ return { f, dir }
1598
+ })
1599
+ : [...((dt && dt.files) || [])].map((f) => ({ f, dir: false }))
1600
+ for (const { f, dir } of list) {
1601
+ if (!f) continue
1602
+ out.any = true
1603
+ if (!out.page && (dir || isPageFile(f))) { out.page = f; out.dir = dir }
1604
+ else if (!out.image && !dir && /^image\//i.test(f.type || '')) out.image = f
1605
+ }
1606
+ return out
1607
+ }
1608
+
1180
1609
  // ── 选中内容 → 输入框里的一段字 ──────────────────────────────────────────
1181
1610
 
1182
1611
  /** 页面地址 → 用户认得的位置:本地文件给路径,开发服务器给 localhost 地址 */
@@ -1267,6 +1696,296 @@ window.__ModuleLoader__.load({
1267
1696
  return { text: lead + body, back }
1268
1697
  }
1269
1698
 
1699
+ // ── 点选标签:DSH 0.2.1 起输入框里放一个短标签(像 @文件),悬停看详情;发出去模型照样拿到整段 ──────
1700
+
1701
+ const PICK_LINE = /^((文件|页面|位置|组件|HTML|样式|包含|屏宽):|- )/
1702
+ const baseName = (s) => String(s || '').replace(/[?#].*$/, '').split(/[\\/]/).filter(Boolean).pop() || ''
1703
+ // 标签复制 / 发出去都是整段,前后空一行:贴回输入框、和用户的话挨着都不会粘成一块
1704
+ const pickClip = (text) => `\n\n${text}\n\n`
1705
+
1706
+ /** 从插进去的那段字里读出标签要显示的:元素名、字、位置、大小、文件 */
1707
+ function pickInfo(text) {
1708
+ const lines = String(text || '').split('\n').map((l) => l.trim()).filter(Boolean)
1709
+ const head = lines[0] || ''
1710
+ const get = (k) => { const l = lines.find((x) => x.startsWith(k)); return l ? l.slice(k.length).trim() : '' }
1711
+ const file = baseName(get('文件:') || get('页面:'))
1712
+ let m = head.match(/^〔选中元素〕(.*?)(?:「(.*)」)?$/)
1713
+ if (m) return { kind: 'el', name: m[1], text: m[2] || '', sel: get('位置:'), size: (get('样式:').match(/^(\d+×\d+)/) || [])[1] || '', file }
1714
+ m = head.match(/^〔选中区域〕.*?(\d+×\d+)/)
1715
+ if (/^〔选中区域〕/.test(head)) return { kind: 'area', n: lines.filter((l) => l.startsWith('- ')).length, size: m ? m[1] : '', file, items: lines.filter((l) => l.startsWith('- ')).slice(0, 3).map((l) => l.slice(2)) }
1716
+ return null
1717
+ }
1718
+
1719
+ /** 标签上的字固定:「1 个元素」,区域写里面有几个元素(是哪个元素悬停看) */
1720
+ function pickLabel(info, t) {
1721
+ if (!info) return ''
1722
+ if (info.kind !== 'area') return t('chipOne')
1723
+ return info.n > 1 ? t('pickElems', { n: info.n }) : info.n === 1 ? t('chipOne') : t('pickArea')
1724
+ }
1725
+
1726
+ /** 点选标签的悬停卡片:元素上的字、元素名、大小、选择器、文件 */
1727
+ function pickItems(info, t) {
1728
+ if (info.kind === 'area') return [['b', t('pickArea')], ['code', [info.n ? t('pickElems', { n: info.n }) : '', info.size].filter(Boolean).join(' · ')], ...info.items.map((x) => ['span', x]), ['span', info.file]]
1729
+ return [['b', info.text || info.name], ['code', [info.name, info.size].filter(Boolean).join(' · ')],
1730
+ info.sel && info.sel !== info.name ? ['code', info.sel.length > 90 ? '…' + info.sel.slice(-90) : info.sel] : null, ['span', info.file]]
1731
+ }
1732
+
1733
+ // ── 别的整段(标注、调样式、风格、体检、转成代码…)也一样:输入框里一个短标签,发出去模型拿到整段 ──────
1734
+ // 按我们自己的模板认(中英文各一份),{x} 那里是一行里的任意字
1735
+
1736
+ const reEsc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
1737
+ // 「文件:…」以扩展名结尾、「页面:…」是地址(没有空格和中文):旧消息里紧贴在后面的用户的话不会被算进来
1738
+ const WHERE_RE = '(?:文件:[^\\n]*\\.[A-Za-z0-9]{1,8}|页面:[!-~]*)'
1739
+ /** 模板 → 正则源和各组的名字;sp:哪个 {x} 用什么正则(默认一行里任意字,{where} 是「文件:/页面:」那行) */
1740
+ function tplSrc(tpl, sp = {}) {
1741
+ const keys = []
1742
+ const src = String(tpl).replace(/\n+$/, '').split(/(\{\w+\})/).map((p) => {
1743
+ const m = p.match(/^\{(\w+)\}$/)
1744
+ if (!m) return reEsc(p)
1745
+ keys.push(m[1])
1746
+ return '(' + (sp[m[1]] || (m[1] === 'where' ? WHERE_RE : '[^\\n]*')) + ')'
1747
+ }).join('')
1748
+ return { src, keys }
1749
+ }
1750
+ /** 正则源里的捕获组都改成不捕获(拼进别的模板里,免得组的序号乱) */
1751
+ const noCap = (src) => src.replace(/(?<!\\)\((?!\?)/g, '(?:')
1752
+ const HEX = /#[0-9a-f]{6}\b|#[0-9a-f]{3}\b/gi
1753
+ const hexes = (s) => [...new Set(String(s || '').match(HEX) || [])]
1754
+ const fileOf = (v) => baseName(String(v.where || '').replace(/^(文件|页面):/, ''))
1755
+
1756
+ // 标注图、参考图的小图(悬停卡片上看一眼):只在这次打开 DSH 时记着,最多 24 张
1757
+ const chipThumbs = new Map()
1758
+ function keepThumb(path, src) {
1759
+ try {
1760
+ const w = src.naturalWidth || src.width
1761
+ const ht = src.naturalHeight || src.height
1762
+ const k = Math.min(1, 640 / w, 640 / ht)
1763
+ const c = document.createElement('canvas')
1764
+ c.width = Math.max(1, Math.round(w * k))
1765
+ c.height = Math.max(1, Math.round(ht * k))
1766
+ const g = c.getContext('2d')
1767
+ g.fillStyle = '#fff'
1768
+ g.fillRect(0, 0, c.width, c.height)
1769
+ g.drawImage(src, 0, 0, c.width, c.height)
1770
+ chipThumbs.set(path, { src: c.toDataURL('image/jpeg', 0.85), w: c.width, h: c.height })
1771
+ if (chipThumbs.size > 24) chipThumbs.delete(chipThumbs.keys().next().value)
1772
+ } catch { /* 没小图就只显示文件名 */ }
1773
+ }
1774
+
1775
+ let chipKinds = null
1776
+ /** 每种整段:图标、认它的正则(alts)、标签上的字、悬停卡片上的几行([标签, 内容]) */
1777
+ function kinds() {
1778
+ if (chipKinds) return chipKinds
1779
+ const L = [ZH, EN]
1780
+ const both = (key, sp) => L.map((x) => tplSrc(x[key], sp))
1781
+ const lit = (keys) => '(?:' + keys.flatMap((k) => L.map((x) => reEsc(x[k].replace(/\n+$/, '')))).join('|') + ')'
1782
+ const quote = (t, s) => t('lang') === 'en' ? `"${s}"` : `「${s}」`
1783
+ chipKinds = [
1784
+ { i: 'mark', label: (v, t) => t('chipMark'),
1785
+ alts: L.map((x) => tplSrc(x.mkAsk, { where: '[^\\n]*', notes: `(?:${noCap(tplSrc(x.mkNotes).src)}\\n)?` })),
1786
+ card: (v, t) => [['b', t('chipMark')], ['img', chipThumbs.get(v.path)], ['p', String(v.notes || '').trim().replace(/^[^::]*[::]\s*/, '')], ['span', v.where], ['span', baseName(v.path)]] },
1787
+ { i: 'text', label: (v, t) => t('chipText'), alts: both('textAsk'),
1788
+ card: (v, t) => [['b', t('chipText')], ['p', `${quote(t, v.before)} → ${quote(t, v.after)}`], ['span', fileOf(v)]] },
1789
+ { i: 'style', label: (v, t) => t('chipStyle'), alts: both('styleAskText', { rules: '(?:\\n- [^\\n]*)*\\n|' }),
1790
+ card: (v, t) => [['b', t('chipStyle')], ['code', v.sel && v.sel !== v.name ? `${v.name} · ${v.sel}` : v.name], ['p', v.list], ['span', fileOf(v)]] },
1791
+ { i: 'layers', label: (v, t) => t('chipVariant'), alts: both('variantText'),
1792
+ card: (v, t) => [['b', t('chipVariant')], ['code', v.name], ['span', fileOf(v)]] },
1793
+ { i: 'layers', label: (v, t) => t('chipDir'), alts: [...both('pickText'), ...both('pickKeep')],
1794
+ card: (v, t) => [['b', t('chipDir')], ['code', v.name], ['span', v.others], ['span', fileOf(v)]] },
1795
+ { i: 'check', label: (v, t) => t('chipCheck', { n: v.n }),
1796
+ alts: L.map((x) => { const a = tplSrc(x.checkAsk, { n: '\\d+' }); const line = `(?:${noCap(tplSrc(x.ckScreen).src)}|\\d+\\. [^\\n]* — [^\\n]*)`; return { src: `${a.src}(?:\\n+${line})+\\n+${tplSrc(x.checkTail).src}`, keys: a.keys } }),
1797
+ card: (v, t, s) => { const it = s.split('\n').filter((l) => /^\d+\. /.test(l)); return [['b', t('chipCheck', { n: v.n })], ...it.slice(0, 4).map((l) => ['span', l.replace(/ — .*$/, '')]), it.length > 4 ? ['span', '…'] : null] } },
1798
+ { i: 'code', label: (v, t) => t('chipCode'), alts: both('hoText', { only: '(?:,screens: [^\\n]*)?' }),
1799
+ card: (v, t) => [['b', t('chipCode')], ['code', v.fw], ['span', v.dest], ['span', v.scope]] },
1800
+ { i: 'image', label: (v, t) => t('chipRef'), alts: L.map((x) => tplSrc(x.refText, { ask: lit(['refAsk', 'refAskNew']), w: '\\d+', h: '\\d+' })),
1801
+ card: (v, t) => [['b', t('chipRef')], ['img', chipThumbs.get(v.path)], ['sw', hexes(v.colors)], ['span', `${v.w}×${v.h} · ${v.tone}`], ['span', baseName(v.path)]] },
1802
+ { i: 'style', label: (v, t) => t('chipTweak'), alts: L.map((x) => ({ src: `${tplSrc(x.twAskHead).src}(?:\\n- [^\\n]*)+\\n${tplSrc(x.twAskTail).src}`, keys: [] })),
1803
+ card: (v, t, s) => [['b', t('chipTweak')], ...s.split('\n').filter((l) => l.startsWith('- ')).slice(0, 5).map((l) => ['span', l.slice(2)])] },
1804
+ { i: 'style', label: (v, t) => t('chipTwMake'), alts: both('twMakeText'), card: (v, t) => [['b', t('chipTwMake')], ['span', 'tweaks.json']] },
1805
+ { i: 'style', label: (v, t) => t('chipTwFix'), alts: both('twFixText'), card: (v, t) => [['b', t('chipTwFix')], ['p', v.errs]] },
1806
+ { i: 'look', label: (v, t) => t('chipSysEx'), alts: both('sysExtractText'), card: (v, t) => [['b', t('chipSysEx')], ['code', v.src]] },
1807
+ { i: 'look', label: (v, t) => t('chipSysAp'), alts: both('sysApplyText'), card: (v, t) => [['b', t('chipSysAp')], ['code', v.name], ['span', v.dir]] },
1808
+ { i: 'look', label: (v, t) => t('chipLook', { name: v.name }), alts: L.map((x) => tplSrc(x.styleText, { name: '[^\\n]*?', ask: lit(['styleStart', 'styleRestyle']) })),
1809
+ card: (v, t) => [['b', t('chipLook', { name: v.name })], ['p', v.desc], ['sw', hexes(v.colors)], ['span', v.fonts && v.fonts.split(/[;;]/)[0]]] },
1810
+ { i: 'look', label: (v) => v.name, alts: [{ src: `〔设计变量〕([^\\n]*?): ([^\\n]*)((${WHERE_RE}))`, keys: ['name', 'value', 'where'] }],
1811
+ card: (v) => [['b', v.name], ['code', v.value], ['sw', hexes(v.value)], ['span', fileOf(v)]] },
1812
+ { i: 'look', label: (v, t) => t('chipTokens'), alts: both('tokensAskText'), card: (v, t) => [['b', t('chipTokens')], ['span', 'tokens.css']] },
1813
+ ]
1814
+ // 每种整段都以固定的一句(或固定的括号)收尾,后面还得是空行或结尾:旧版插成字的、用户的话紧跟着的都不认,照原样显示
1815
+ for (const k of chipKinds) for (const a of k.alts) a.re = new RegExp(`${a.src}(?=\\n\\n|$)`, 'y')
1816
+ return chipKinds
1817
+ }
1818
+
1819
+ // 我们的整段没有特别长的行;有(贴进来的大段代码之类)就整条消息都不认,免得正则在长行上来回试很久
1820
+ const longLine = (s) => s.split('\n').some((l) => l.length > 4000)
1821
+
1822
+ /** text 的 pos 处是不是我们的一整段:是就给出种类、各个 {x} 和结尾位置 */
1823
+ function chipAt(text, pos) {
1824
+ for (const k of kinds()) {
1825
+ for (const a of k.alts) {
1826
+ a.re.lastIndex = pos
1827
+ const m = a.re.exec(text)
1828
+ if (!m || !m[0]) continue
1829
+ const end = pos + m[0].length
1830
+ const v = {}
1831
+ a.keys.forEach((key, j) => { if (m[j + 1] != null && !(key in v)) v[key] = m[j + 1] })
1832
+ return { k, v, end }
1833
+ }
1834
+ }
1835
+ return null
1836
+ }
1837
+
1838
+ /** 一个标签存的整段 → 图标、标签上的字、悬停卡片;不是我们的给 null */
1839
+ function chipOf(text, t) {
1840
+ const s = String(text || '').trim()
1841
+ const p = pickInfo(s)
1842
+ if (p) return { i: 'pick', label: pickLabel(p, t), items: () => pickItems(p, t) }
1843
+ if (longLine(s)) return null
1844
+ const c = chipAt(s, 0)
1845
+ if (!c || c.end !== s.length) return null
1846
+ return { i: c.k.i, label: c.k.label(c.v, t), items: () => c.k.card(c.v, t, s) }
1847
+ }
1848
+
1849
+ /** 消息里的点选块(开头一行〔选中元素〕/〔选中区域〕,后面跟着细节行) */
1850
+ function pickRanges(text) {
1851
+ const out = []
1852
+ const re = /(^|\n)〔(?:选中元素|选中区域)〕/g
1853
+ let m
1854
+ while ((m = re.exec(text))) {
1855
+ const start = m.index + m[1].length
1856
+ let end = text.indexOf('\n', start)
1857
+ if (end < 0) end = text.length
1858
+ const n1 = text.indexOf('\n', end + 1)
1859
+ if (end >= text.length || !/^(文件|页面):/.test(text.slice(end + 1, n1 < 0 ? text.length : n1).trim())) { re.lastIndex = start + 1; continue }
1860
+ while (end < text.length) {
1861
+ const nx = text.indexOf('\n', end + 1)
1862
+ const line = text.slice(end + 1, nx < 0 ? text.length : nx).trim()
1863
+ if (!PICK_LINE.test(line)) break
1864
+ end = nx < 0 ? text.length : nx
1865
+ }
1866
+ const block = text.slice(start, end)
1867
+ const ok = block.startsWith('〔选中元素〕') ? /\n位置:/.test(block) && /\nHTML:</.test(block) && /\n样式:/.test(block) : /^〔选中区域〕页面上 \([-\d.]+, [-\d.]+\) 起 [\d.]+×[\d.]+ 的一块\n/.test(block)
1868
+ if (!ok) { re.lastIndex = start + 1; continue }
1869
+ out.push({ start, end })
1870
+ re.lastIndex = Math.max(end, start + 1)
1871
+ }
1872
+ return out
1873
+ }
1874
+
1875
+ /** 消息里我们的整段都在哪(点选块 + 别的整段),按位置排好 */
1876
+ let rangeMemo = { text: null, out: [] }
1877
+ function chipRanges(text) {
1878
+ if (rangeMemo.text === text) return rangeMemo.out
1879
+ const out = pickRanges(text)
1880
+ for (let pos = longLine(text) ? text.length : 0; pos < text.length;) {
1881
+ if ((pos === 0 || text[pos - 1] === '\n') && !out.some((r) => pos >= r.start && pos < r.end)) {
1882
+ const c = chipAt(text, pos)
1883
+ if (c) { out.push({ start: pos, end: c.end }); pos = c.end; continue }
1884
+ }
1885
+ const nx = text.indexOf('\n', pos)
1886
+ if (nx < 0) break
1887
+ pos = nx + 1
1888
+ }
1889
+ out.sort((x, y) => x.start - y.start)
1890
+ rangeMemo = { text, out }
1891
+ return out
1892
+ }
1893
+
1894
+ /** 发出去的消息里:我们的整段显示成标签,前后的换行显示成一个空格(和用户的话排在一行) */
1895
+ const pickRule = {
1896
+ id: 'ddp-pick',
1897
+ order: -20,
1898
+ match(text, from) {
1899
+ for (const r of chipRanges(text)) {
1900
+ const pre = text.slice(0, r.start).match(/\n+$/)
1901
+ const post = text.slice(r.end).match(/^\n+/)
1902
+ const cands = [
1903
+ pre && r.start - pre[0].length > 0 ? { id: 'text', start: r.start - pre[0].length, end: r.start, data: { text: pre[0], display: ' ' } } : null,
1904
+ { id: 'ddp-pick', start: r.start, end: r.end, data: { text: text.slice(r.start, r.end) } },
1905
+ post && r.end + post[0].length < text.length ? { id: 'text', start: r.end, end: r.end + post[0].length, data: { text: post[0], display: ' ' } } : null,
1906
+ ]
1907
+ for (const c of cands) if (c && c.start >= from) return c
1908
+ }
1909
+ return undefined
1910
+ },
1911
+ }
1912
+
1913
+ /** 悬停卡片:输入框里的标签和消息里的标签共用一个 */
1914
+ function pickCard(t) {
1915
+ if (typeof document === 'undefined') return () => {}
1916
+ let card = null
1917
+ let on = null
1918
+ const hide = () => { on = null; if (card) card.style.display = 'none' }
1919
+ // 输入框里的标签:从编辑器里读出这个标签存的那段字
1920
+ const textOf = (el) => {
1921
+ if (el.dataset && el.dataset.pick) return el.dataset.pick
1922
+ try {
1923
+ const root = el.closest('[data-lexical-editor]')
1924
+ const ed = root && root.__lexicalEditor
1925
+ const k = Object.keys(el).find((x) => x.startsWith('__lexicalKey_'))
1926
+ const node = ed && k ? ed.getEditorState()._nodeMap.get(el[k]) : null
1927
+ return node && typeof node.__ref === 'string' ? node.__ref : ''
1928
+ } catch { return '' }
1929
+ }
1930
+ const show = (el) => {
1931
+ const c = chipOf(textOf(el), t)
1932
+ if (!c) return
1933
+ if (!card) {
1934
+ card = document.createElement('div')
1935
+ card.className = 'ddp-pickcard'
1936
+ document.body.appendChild(card)
1937
+ }
1938
+ on = el
1939
+ for (const x of [el, ...el.querySelectorAll('[title]')]) x.removeAttribute('title') // DSH 自带的悬停提示只有名字,别和卡片叠在一起
1940
+ card.replaceChildren()
1941
+ card.dataset.i = c.i
1942
+ let wide = false
1943
+ for (const it of c.items()) {
1944
+ const [tag, v] = it || []
1945
+ if (!v || (Array.isArray(v) && !v.length)) continue
1946
+ let n
1947
+ if (tag === 'img') {
1948
+ n = document.createElement('img')
1949
+ n.src = v.src
1950
+ n.alt = ''
1951
+ n.style.aspectRatio = `${v.w} / ${v.h}` // 图还没解码时卡片高度也对
1952
+ wide = true
1953
+ } else if (tag === 'sw') {
1954
+ n = document.createElement('i')
1955
+ for (const hex of v.slice(0, 10)) { const d = document.createElement('i'); d.style.background = hex; n.appendChild(d) }
1956
+ } else {
1957
+ n = document.createElement(tag)
1958
+ n.textContent = v
1959
+ }
1960
+ card.appendChild(n)
1961
+ }
1962
+ card.classList.toggle('wide', wide)
1963
+ card.style.display = 'block'
1964
+ const r = el.getBoundingClientRect()
1965
+ const w = card.offsetWidth
1966
+ const ch = card.offsetHeight
1967
+ card.style.left = Math.max(12, Math.min(innerWidth - w - 12, r.left)) + 'px'
1968
+ card.style.top = (r.top - ch - 8 >= 8 ? r.top - ch - 8 : r.bottom + 8) + 'px'
1969
+ }
1970
+ const sel = `[data-composer-chip^="${PICK_SRC}"], .ddp-pickpill`
1971
+ const over = (e) => {
1972
+ const el = e.target && e.target.closest ? e.target.closest(sel) : null
1973
+ if (el === on) return
1974
+ if (el) show(el)
1975
+ else hide()
1976
+ }
1977
+ document.addEventListener('mouseover', over, true)
1978
+ document.addEventListener('scroll', hide, true)
1979
+ return () => {
1980
+ document.removeEventListener('mouseover', over, true)
1981
+ document.removeEventListener('scroll', hide, true)
1982
+ if (card) card.remove()
1983
+ }
1984
+ }
1985
+
1986
+ // 输入框里插标签:apply 里拿到 DSH 的服务后填上(旧版 DSH 没有就一直是 null,照旧插字)
1987
+ let chips = null
1988
+
1270
1989
  // DSH 的输入框(只认一个;分屏开着多个时不碰)
1271
1990
  function composerEl() {
1272
1991
  try {
@@ -1324,7 +2043,7 @@ window.__ModuleLoader__.load({
1324
2043
 
1325
2044
  /** 点了一个变量 → 输入框里的一段字 */
1326
2045
  function formatToken(tok, st) {
1327
- return `〔设计变量〕${tok.name}: ${tok.value}\n${whereOf(tok.href, st)}\n`
2046
+ return `〔设计变量〕${tok.name}: ${String(tok.value).replace(/\s*\n\s*/g, ' ')}(${whereOf(tok.href, st)})\n`
1328
2047
  }
1329
2048
 
1330
2049
  // ── 风格卡:开局挑一个风格(或给已有的设计换风格),整套配色、字体、圆角、阴影、要点放进输入框 ──────
@@ -1334,6 +2053,7 @@ window.__ModuleLoader__.load({
1334
2053
  const SERIF = '"Songti SC", "Noto Serif SC", "Source Han Serif SC", serif'
1335
2054
  const ROUND = '"SF Pro Rounded", "PingFang SC", "HarmonyOS Sans SC", system-ui, sans-serif'
1336
2055
  const MONO = '"JetBrains Mono", "SF Mono", Menlo, Consolas, monospace'
2056
+ const GROTESK = '"Helvetica Neue", Helvetica, Arial, "PingFang SC", "Microsoft YaHei", sans-serif'
1337
2057
  const STYLES = [
1338
2058
  { id: 'minimal', zh: '极简白', en: 'Minimal', dzh: '大留白、细线、黑白加一点蓝', den: 'Lots of space, hairlines, black and white with a touch of blue',
1339
2059
  c: { bg: '#FAFAF9', surface: '#FFFFFF', text: '#18181B', muted: '#6B6B73', line: '#E4E4E7', primary: '#18181B', accent: '#2563EB', on: '#FFFFFF', link: '#2563EB' },
@@ -1411,8 +2131,75 @@ window.__ModuleLoader__.load({
1411
2131
  head: SANS, w: 800, radius: 12, shadow: '0 4px 12px rgba(207,46,14,.12)',
1412
2132
  nzh: '价格用大号粗体数字(¥ 小一号),原价灰色划线;「限时」「包邮」这类小标签用色块;优惠券用金黄色、带锯齿边;倒计时醒目;信息多但分区清楚,主按钮通栏',
1413
2133
  nen: 'Prices in big bold numerals (smaller currency sign), old price struck through in gray; small colored tags like "Limited" and "Free shipping"; gold coupons with notched edges; a prominent countdown; dense but clearly sectioned, full-width main button' },
2134
+ { id: 'japandi', zh: '日系原木', en: 'Japandi', dzh: '米色原木、低饱和、小字大留白', den: 'Oat and wood tones, muted, small type and lots of space',
2135
+ c: { bg: '#F2EEE6', surface: '#FAF8F3', text: '#2F2B26', muted: '#6B645A', line: '#DDD5C8', primary: '#4F4234', accent: '#8C9A6E', on: '#FAF8F3', link: '#6B4F33' },
2136
+ head: SANS, w: 500, radius: 6, shadow: '0 2px 8px rgba(79,66,52,.06)',
2137
+ nzh: '像杂货店和家居品牌:字号偏小、字重不重,留白很多;颜色只有米、原木棕、灰绿,饱和度都低;照片柔光、自然材质(木、麻、陶);细线分隔,几乎不用阴影;主按钮深棕实底',
2138
+ nen: 'Like a lifestyle or homeware brand: smallish, light type and lots of space; only oat, wood brown and sage, all low saturation; soft-light photos of natural materials (wood, linen, ceramic); hairline dividers, almost no shadow; solid dark-brown main button' },
2139
+ { id: 'swiss', zh: '瑞士网格', en: 'Swiss grid', dzh: '白底黑字红色块、严格网格', den: 'White, black type, red blocks, strict grid',
2140
+ c: { bg: '#FFFFFF', surface: '#F1F1F1', text: '#0A0A0A', muted: '#545454', line: '#0A0A0A', primary: '#D9001B', accent: '#0A0A0A', on: '#FFFFFF', link: '#C2001A' },
2141
+ head: GROTESK, w: 800, radius: 0, shadow: 'none',
2142
+ nzh: '12 栏严格网格、全部左对齐(不居中);标题是超大粗无衬线、字距收紧(-0.03em);红色大色块只用一两处;用数字编号(01 02 03)和粗黑线分区;没有圆角、阴影和渐变',
2143
+ nen: 'A strict 12-column grid, everything left-aligned (nothing centered); huge, bold, tightly tracked sans headings (-0.03em); one or two big red blocks; numbered sections (01 02 03) and heavy black rules; no radius, shadow or gradient' },
2144
+ { id: 'brutal', zh: '粗野主义', en: 'Brutalist', dzh: '亮黄底、黑粗框、硬阴影、等宽字', den: 'Bright yellow, thick black borders, hard shadows, monospace',
2145
+ c: { bg: '#FFEF5C', surface: '#FFFFFF', text: '#000000', muted: '#3B3B3B', line: '#000000', primary: '#000000', accent: '#FF5C39', on: '#FFEF5C', link: '#1F3FD1' },
2146
+ head: MONO, w: 800, radius: 0, shadow: '5px 5px 0 #000000',
2147
+ nzh: '卡片和按钮 3px 黑粗边框 + 大偏移的硬阴影(不模糊);标题等宽字、很大;亮黄、白、橙红高饱和撞色;元素可以故意错位、叠放;不用渐变、圆角和柔和阴影;链接带下划线',
2148
+ nen: '3px black borders and big unblurred offset shadows on cards and buttons; large monospace headings; loud yellow, white and red-orange blocks; deliberate offsets and overlaps are fine; no gradients, radius or soft shadows; underlined links' },
2149
+ { id: 'cream', zh: '奶油风', en: 'Cream', dzh: '奶白燕麦焦糖、大圆角、柔光', den: 'Milk, oat and caramel, big radii, soft light',
2150
+ c: { bg: '#FAF5EE', surface: '#FFFDF9', text: '#3A2F28', muted: '#76675C', line: '#EEE3D5', primary: '#8C5E3C', accent: '#E7C9A6', on: '#FFFFFF', link: '#8A5A36' },
2151
+ head: ROUND, w: 700, radius: 22, shadow: '0 10px 30px rgba(140,94,60,.10)',
2152
+ nzh: '奶白、燕麦、焦糖三个色;圆角大,多用圆形和胶囊形;阴影又大又淡、带一点暖色;字是深咖啡色不用纯黑;照片和插画柔光、低对比;整体软、暖、安静',
2153
+ nen: 'Milk, oat and caramel; big radii, lots of circles and pills; large, faint, warm-tinted shadows; dark coffee text, never pure black; soft, low-contrast photos and illustrations; soft, warm and calm overall' },
2154
+ { id: 'sport', zh: '运动能量', en: 'Sport', dzh: '黑底荧光绿、粗斜体、大数字', den: 'Black with volt green, heavy italics, big numbers',
2155
+ c: { bg: '#0E0F11', surface: '#1A1C20', text: '#F4F5F6', muted: '#A1A5AD', line: '#2B2E35', primary: '#C8FF2E', accent: '#FF5A1F', on: '#0E0F11', link: '#C8FF2E' },
2156
+ head: SANS, w: 900, radius: 6, shadow: 'none',
2157
+ nzh: '标题超粗斜体、全大写、字距收紧;荧光绿只给主按钮、进度和关键数字;配速、心率、卡路里这类数字做得很大(等宽数字);可以用斜切的色块(transform: skewX(-8deg));照片高对比、有动感;橙色只做警示和次要强调',
2158
+ nen: 'Heavy italic, all-caps, tightly tracked headings; volt green only on the main button, progress and key numbers; big tabular numbers for pace, heart rate and calories; slanted blocks (transform: skewX(-8deg)) are fine; high-contrast action photos; orange only for alerts and secondary emphasis' },
1414
2159
  ]
1415
2160
 
2161
+ /** 样板上各风格的招牌细节(卡片、按钮、标题、配图块),没写的按 STYLES 里的圆角阴影来 */
2162
+ const LOOK = {
2163
+ minimal: { card: { boxShadow: '0 1px 2px rgba(24,24,27,.06), inset 0 0 0 1px #E4E4E7' }, img: 'linear-gradient(135deg, #E9EEF8, #C7D6F5)' },
2164
+ warm: { img: 'linear-gradient(135deg, #E7B08A, #A9542A)' },
2165
+ editorial: { card: { background: 'transparent', boxShadow: 'inset 0 3px 0 #111111', borderRadius: 0 }, head: { letterSpacing: '-0.02em' }, img: '#C8231B' },
2166
+ dark: { head: { letterSpacing: '.04em' }, img: 'linear-gradient(135deg, #2A2620, #C9A86A)' },
2167
+ playful: { card: { border: '2px solid #1D1B3A' }, btn: { border: '2px solid #1D1B3A', boxShadow: '3px 3px 0 #1D1B3A' }, img: '#1F9E92' },
2168
+ fresh: { card: { boxShadow: '0 6px 18px rgba(47,125,79,.10), inset 0 0 0 1px #DCE6DA' }, img: 'linear-gradient(135deg, #CFE6C9, #7DB08B)' },
2169
+ tech: { card: { boxShadow: '0 1px 3px rgba(15,23,42,.08), inset 0 0 0 1px #E2E8F0' }, img: 'linear-gradient(135deg, #DCE6FF, #2457F5)' },
2170
+ glass: { card: { background: 'rgba(255,255,255,.55)', backdropFilter: 'blur(14px)', boxShadow: '0 10px 30px rgba(16,21,28,.10), inset 0 0 0 1px rgba(255,255,255,.85)' }, img: 'linear-gradient(135deg, #FFD9C7, #C9E2FF)' },
2171
+ soft: { btn: { boxShadow: '4px 4px 10px #C8CED6, -4px -4px 10px #FFFFFF' }, img: 'linear-gradient(135deg, #DDE2EA, #F4F6F9)' },
2172
+ guochao: { card: { boxShadow: 'inset 0 0 0 1px #C8952E, 0 6px 18px rgba(140,30,20,.12)' } },
2173
+ ink: { head: { letterSpacing: '.12em' }, img: 'radial-gradient(ellipse at 35% 65%, rgba(30,30,28,.55), transparent 62%), #ECE7DD' },
2174
+ cyber: { card: { boxShadow: 'inset 0 0 0 1px #2E2B52' }, head: { textTransform: 'uppercase', letterSpacing: '.04em' } },
2175
+ cute: { btn: { boxShadow: '0 4px 0 #E0628E' }, img: 'linear-gradient(135deg, #FFD3E2, #FFC93C)' },
2176
+ retro: { card: { border: '2px solid #2E1F14' }, btn: { boxShadow: '0 3px 0 #2E1F14' }, img: 'linear-gradient(180deg, #B5471D 0 33%, #E0A526 33% 66%, #2F5D50 66%)' },
2177
+ sale: { img: 'linear-gradient(135deg, #FF8A3D, #CF2E0E)' },
2178
+ japandi: { card: { boxShadow: 'inset 0 0 0 1px #DDD5C8' }, img: 'linear-gradient(160deg, #DCD1BF, #B6A58C)' },
2179
+ swiss: { card: { boxShadow: 'none' }, head: { letterSpacing: '-0.03em' }, img: '#D9001B' },
2180
+ brutal: { card: { border: '3px solid #000000' }, btn: { border: '3px solid #000000', boxShadow: '3px 3px 0 #000000' }, img: '#FF5C39' },
2181
+ cream: { img: 'linear-gradient(135deg, #F3E3CF, #E7C9A6)' },
2182
+ sport: { head: { fontStyle: 'italic', textTransform: 'uppercase', letterSpacing: '-0.01em' }, btn: { transform: 'skewX(-8deg)' }, img: 'linear-gradient(115deg, #C8FF2E 0 42%, #2B2E35 42%)' },
2183
+ }
2184
+
2185
+ /** 一个风格在样板上的样子:卡片、主按钮、标题字、配图块、小标签 */
2186
+ function styleLook(x) {
2187
+ const c = x.c
2188
+ const o = LOOK[x.id] || {}
2189
+ // 小标签是 9.5px 的小字,要 4.5:1:点缀色在卡片和底色上都够就描边写点缀色,色块上正文色够就做色块,都不行用主色
2190
+ const chip = Math.min(hexRatio(c.accent, c.surface), hexRatio(c.accent, c.bg)) >= 4.5 ? { color: c.accent, boxShadow: `inset 0 0 0 1px ${c.accent}` }
2191
+ : hexRatio(c.text, c.accent) >= 4.5 ? { background: c.accent, color: c.text } : { background: c.primary, color: c.on }
2192
+ return {
2193
+ card: { background: c.surface, borderRadius: x.radius, boxShadow: x.shadow === 'none' ? `inset 0 0 0 1px ${c.line}` : x.shadow, ...o.card },
2194
+ btn: { background: c.primary, color: c.on, borderRadius: Math.min(x.radius, 14), ...o.btn },
2195
+ head: { fontFamily: x.head, fontWeight: x.w, ...o.head },
2196
+ img: o.img || `linear-gradient(135deg, ${c.primary}, ${c.accent})`,
2197
+ // 进度条底:分隔线是黑的(杂志、瑞士、粗野)就换成淡一点的正文色,不然和黑色主色分不开
2198
+ track: c.line === c.text || c.line === c.primary ? `color-mix(in srgb, ${c.text} 14%, ${c.surface})` : c.line,
2199
+ chip,
2200
+ }
2201
+ }
2202
+
1416
2203
  /** 两个 #RRGGBB 的对比度(WCAG) */
1417
2204
  function hexRatio(a, b) {
1418
2205
  const l = (hex) => { const v = hex.slice(1).match(/../g).map((x) => parseInt(x, 16) / 255).map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)); return 0.2126 * v[0] + 0.7152 * v[1] + 0.0722 * v[2] }
@@ -1538,6 +2325,8 @@ window.__ModuleLoader__.load({
1538
2325
  otherText: String(it.otherText || '').slice(0, 20),
1539
2326
  edgeName: edge ? t('ckEdge_' + edge) : '',
1540
2327
  when: edge ? t('ckWhen_' + edge) : '',
2328
+ osName: it.type === 'winctl' ? t('ckOs_' + (it.os === 'win' ? 'win' : 'mac')) : '',
2329
+ zone: it.type === 'winctl' ? t('ckZone_' + (it.os === 'win' ? 'win' : 'mac')) : '',
1541
2330
  what: it.type === 'missing' ? t('ckWhat_' + (['css', 'script', 'font'].includes(it.what) ? it.what : 'other')) : '',
1542
2331
  at: it.type === 'jserr' && it.src ? ` (${it.src}${it.line ? ':' + it.line : ''})` : '',
1543
2332
  })
@@ -1568,6 +2357,7 @@ window.__ModuleLoader__.load({
1568
2357
  detail: checkDetail(t, it),
1569
2358
  })))
1570
2359
  }
2360
+ if (head === 'checkAsk') lines.push('', t('checkTail'))
1571
2361
  return lines.join('\n') + '\n'
1572
2362
  }
1573
2363
 
@@ -1597,9 +2387,9 @@ window.__ModuleLoader__.load({
1597
2387
 
1598
2388
  // ── 导出图片:设备外框、整套展示图 ───────────────────────────────────────
1599
2389
 
1600
- const SHOW_COLS = { app: 4, miniprogram: 4, tablet: 3, web: 2, poster: 3, slides: 2, email: 3 }
2390
+ const SHOW_COLS = { app: 4, miniprogram: 4, tablet: 3, desktop: 2, web: 2, poster: 3, slides: 2, email: 3 }
1601
2391
  const CANVAS_FONT = '-apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif'
1602
- const FRAMED = { app: 1, miniprogram: 1, tablet: 1, web: 1 }
2392
+ const FRAMED = { app: 1, miniprogram: 1, tablet: 1, desktop: 1, web: 1 }
1603
2393
 
1604
2394
  function loadImg(blob) {
1605
2395
  return new Promise((resolve, reject) => {
@@ -1624,7 +2414,7 @@ window.__ModuleLoader__.load({
1624
2414
  }
1625
2415
 
1626
2416
  /** 在 (x, y) 画一个带外框的屏:w × ht 是屏幕本身;img 按宽铺进去,高出来的裁掉 */
1627
- function drawDevice(ctx, kind, img, x, y, w, ht, label, capWhite) {
2417
+ function drawDevice(ctx, kind, img, x, y, w, ht, label, capWhite, os) {
1628
2418
  const f = frameOf(kind, w, ht)
1629
2419
  const pad = f.pad || 0
1630
2420
  const top = f.top || 0
@@ -1684,17 +2474,58 @@ window.__ModuleLoader__.load({
1684
2474
  }
1685
2475
  // 胶囊颜色用页面截图那一刻自己量的(和画布上一致);老页面没报就看截图
1686
2476
  if (f.capsule) drawCapsule(ctx, x + pad + w - CAP.right - CAP.w, y + pad + CAP.top, typeof capWhite === 'boolean' ? capWhite : !!img && capDarkOf(img, w))
2477
+ if (f.win) {
2478
+ rrect(ctx, x + 0.25, y + 0.25, w - 0.5, ht - 0.5, f.r)
2479
+ ctx.strokeStyle = 'rgba(0,0,0,.28)'
2480
+ ctx.lineWidth = 0.5
2481
+ ctx.stroke()
2482
+ drawWinCtl(ctx, x, y, w, os, typeof capWhite === 'boolean' ? capWhite : os === 'win' && !!img && areaDarkOf(img, w, w - WINBTN.w * 3, 0, WINBTN.w * 3, WINBTN.h))
2483
+ }
2484
+ }
2485
+
2486
+ /** 窗口按钮画到导出的图上(和画布上 WinCtl 一样) */
2487
+ function drawWinCtl(ctx, x, y, w, os, white) {
2488
+ ctx.save()
2489
+ if (os === 'win') {
2490
+ const W = WINBTN.w, H = WINBTN.h, x0 = x + w - W * 3
2491
+ ctx.strokeStyle = white ? '#fff' : '#1f1f1f'
2492
+ ctx.lineWidth = 1
2493
+ ctx.beginPath()
2494
+ ctx.moveTo(x0 + W / 2 - 5, y + H / 2 + 0.5); ctx.lineTo(x0 + W / 2 + 5, y + H / 2 + 0.5)
2495
+ ctx.moveTo(x0 + W * 2.5 - 5, y + H / 2 - 5); ctx.lineTo(x0 + W * 2.5 + 5, y + H / 2 + 5)
2496
+ ctx.moveTo(x0 + W * 2.5 + 5, y + H / 2 - 5); ctx.lineTo(x0 + W * 2.5 - 5, y + H / 2 + 5)
2497
+ ctx.stroke()
2498
+ rrect(ctx, x0 + W * 1.5 - 4.5, y + H / 2 - 4.5, 9, 9, 1)
2499
+ ctx.stroke()
2500
+ } else {
2501
+ const { x: lx, y: ly, d, gap } = LIGHTS
2502
+ ;['#FF5F57', '#FEBC2E', '#28C840'].forEach((c, i) => {
2503
+ ctx.beginPath()
2504
+ ctx.arc(x + lx + d / 2 + i * (d + gap), y + ly + d / 2, d / 2 - 0.25, 0, Math.PI * 2)
2505
+ ctx.fillStyle = c
2506
+ ctx.fill()
2507
+ ctx.strokeStyle = 'rgba(0,0,0,.12)'
2508
+ ctx.lineWidth = 0.5
2509
+ ctx.stroke()
2510
+ })
2511
+ }
2512
+ ctx.restore()
1687
2513
  }
1688
2514
 
1689
2515
  /** 截图里胶囊那块是不是深色(导出时没有页面可问,看图) */
1690
2516
  function capDarkOf(img, w) {
2517
+ return areaDarkOf(img, w, w - CAP.right - CAP.w, CAP.top, CAP.w, CAP.h)
2518
+ }
2519
+
2520
+ /** 截图里 (sx, sy, sw, sh) 那块(屏幕坐标)是不是深色 */
2521
+ function areaDarkOf(img, w, sx, sy, sw, sh) {
1691
2522
  try {
1692
2523
  const k = img.width / w
1693
2524
  const c = document.createElement('canvas')
1694
- c.width = Math.max(1, Math.round(CAP.w * k))
1695
- c.height = Math.max(1, Math.round(CAP.h * k))
2525
+ c.width = Math.max(1, Math.round(sw * k))
2526
+ c.height = Math.max(1, Math.round(sh * k))
1696
2527
  const g = c.getContext('2d')
1697
- g.drawImage(img, (w - CAP.right - CAP.w) * k, CAP.top * k, CAP.w * k, CAP.h * k, 0, 0, c.width, c.height)
2528
+ g.drawImage(img, sx * k, sy * k, sw * k, sh * k, 0, 0, c.width, c.height)
1698
2529
  const d = g.getImageData(0, 0, c.width, c.height).data
1699
2530
  let sum = 0
1700
2531
  for (let i = 0; i < d.length; i += 4) sum += 0.299 * d[i] + 0.587 * d[i + 1] + 0.114 * d[i + 2]
@@ -1730,7 +2561,7 @@ window.__ModuleLoader__.load({
1730
2561
  }
1731
2562
 
1732
2563
  /** 一屏带外框(四周留出阴影的地方,背景透明) */
1733
- async function composeOne(kind, size, img, label, top) {
2564
+ async function composeOne(kind, size, img, label, top, os) {
1734
2565
  const [w, ht] = size
1735
2566
  const [W, H] = frameSize(kind, w, ht)
1736
2567
  const m = 56
@@ -1740,7 +2571,7 @@ window.__ModuleLoader__.load({
1740
2571
  c.height = Math.round((H + m * 2) * S)
1741
2572
  const ctx = c.getContext('2d')
1742
2573
  ctx.scale(S, S)
1743
- drawDevice(ctx, kind, img, m, m, w, ht, label, top)
2574
+ drawDevice(ctx, kind, img, m, m, w, ht, label, top, os)
1744
2575
  return canvasBlob(c)
1745
2576
  }
1746
2577
 
@@ -1874,7 +2705,7 @@ window.__ModuleLoader__.load({
1874
2705
  big.getContext('2d').drawImage(img, 0, 0, big.width, big.height)
1875
2706
  blob = await canvasBlob(big)
1876
2707
  }
1877
- return { w, h: ht, palette, data: await blobB64(blob) }
2708
+ return { w, h: ht, palette, img, data: await blobB64(blob) }
1878
2709
  }
1879
2710
 
1880
2711
  function lumaOf(color) {
@@ -1894,7 +2725,7 @@ window.__ModuleLoader__.load({
1894
2725
  * 整套展示图:几屏带外框排成网格,上面是品牌名和一行说明,铺一层底色 + 点缀色的光晕。
1895
2726
  * shots: [{ img, label }]
1896
2727
  */
1897
- async function composeShowcase({ kind, size, shots, title, sub, bg, accent, mark }) {
2728
+ async function composeShowcase({ kind, size, shots, title, sub, bg, accent, mark, os }) {
1898
2729
  const [w, ht] = size
1899
2730
  const [DW, DH] = frameSize(kind, w, ht)
1900
2731
  const u = Math.max(1, Math.min(2, DW / 430))
@@ -1955,7 +2786,7 @@ window.__ModuleLoader__.load({
1955
2786
  const off = ((cols - inRow) * (DW + gap)) / 2 // 最后一行不满就居中
1956
2787
  const x = pad + off + col * (DW + gap)
1957
2788
  const yy = y + row * (DH + labH + gap * 0.6)
1958
- drawDevice(ctx, kind, s.img, x, yy, w, ht, s.label, s.top)
2789
+ drawDevice(ctx, kind, s.img, x, yy, w, ht, s.label, s.top, os)
1959
2790
  ctx.fillStyle = muted
1960
2791
  ctx.font = `500 ${16 * u}px ${CANVAS_FONT}`
1961
2792
  ctx.textAlign = 'center'
@@ -2008,6 +2839,11 @@ window.__ModuleLoader__.load({
2008
2839
  })
2009
2840
  }
2010
2841
 
2842
+ /** 这里能不能往剪贴板写图(http 远程打开、老浏览器不行;不行就不给「复制」按钮) */
2843
+ function canCopyImage() {
2844
+ try { return !!(window.isSecureContext && navigator.clipboard && typeof navigator.clipboard.write === 'function' && typeof ClipboardItem === 'function') } catch { return false }
2845
+ }
2846
+
2011
2847
  /** 图片放进剪贴板(DSH 窗口没焦点之类的拿不到就算了) */
2012
2848
  async function copyImage(blob) {
2013
2849
  try {
@@ -2032,17 +2868,18 @@ window.__ModuleLoader__.load({
2032
2868
 
2033
2869
  // ── 设计类型:画幅和外框 ─────────────────────────────────────────────────
2034
2870
 
2035
- const KIND_LIST = ['web', 'app', 'miniprogram', 'tablet', 'poster', 'slides', 'email']
2036
- const KIND_KEY = { web: 'kindWeb', app: 'kindApp', miniprogram: 'kindMini', tablet: 'kindTablet', poster: 'kindPoster', slides: 'kindSlides', email: 'kindEmail' }
2871
+ const KIND_LIST = ['web', 'app', 'miniprogram', 'tablet', 'desktop', 'poster', 'slides', 'email']
2872
+ const KIND_KEY = { web: 'kindWeb', app: 'kindApp', miniprogram: 'kindMini', tablet: 'kindTablet', desktop: 'kindDesktop', poster: 'kindPoster', slides: 'kindSlides', email: 'kindEmail' }
2037
2873
  const GHOST_MS = 90000 // 这么久没出新屏就撤掉「生成中…」占位
2038
2874
  const EDIT_GRACE_MS = 3000 // 退出改字后这么久里,页面交上来的最后一处改动还收
2039
- const KIND_SIZE = { web: [1280, 800], app: [390, 844], miniprogram: [375, 812], tablet: [820, 1180], poster: [1080, 1350], slides: [1280, 720], email: [640, 960] }
2040
- // 外框:pad 边框厚度、top 顶上一条(浏览器栏)、r 外圆角、ir 屏幕圆角、island 灵动岛离顶的距离、capsule 小程序右上角的胶囊
2875
+ const KIND_SIZE = { web: [1280, 800], app: [390, 844], miniprogram: [375, 812], tablet: [820, 1180], desktop: [1280, 800], poster: [1080, 1350], slides: [1280, 720], email: [640, 960] }
2876
+ // 外框:pad 边框厚度、top 顶上一条(浏览器栏)、r 外圆角、ir 屏幕圆角、island 灵动岛离顶的距离、capsule 小程序右上角的胶囊、win 桌面软件的窗口按钮
2041
2877
  const FRAME = {
2042
2878
  web: { pad: 0, top: 34, r: 10, ir: 0 },
2043
2879
  app: { pad: 11, top: 0, r: 55, ir: 44, island: 11 },
2044
2880
  miniprogram: { pad: 11, top: 0, r: 55, ir: 44, island: 11, capsule: 1 },
2045
2881
  tablet: { pad: 14, top: 0, r: 34, ir: 21 },
2882
+ desktop: { pad: 0, top: 0, r: 12, ir: 12, win: 1 },
2046
2883
  poster: { pad: 0, top: 0, r: 6, ir: 6 },
2047
2884
  slides: { pad: 0, top: 0, r: 8, ir: 8 },
2048
2885
  email: { pad: 0, top: 0, r: 10, ir: 10 },
@@ -2055,15 +2892,19 @@ window.__ModuleLoader__.load({
2055
2892
  }
2056
2893
  // 小程序胶囊:离屏幕顶 48(状态栏 44 + 4)、离右边 7、87×32(和页面体检 picker.js 的 CAP 一样)
2057
2894
  const CAP = { top: 48, right: 7, w: 87, h: 32 }
2058
- const META_ALIAS = { phone: 'app', mobile: 'app', ios: 'app', android: 'app', mini: 'miniprogram', miniapp: 'miniprogram', weapp: 'miniprogram', wechat: 'miniprogram', 小程序: 'miniprogram', pad: 'tablet', ipad: 'tablet', social: 'poster', image: 'poster', slide: 'slides', deck: 'slides', ppt: 'slides', mail: 'email' }
2895
+ // 桌面软件的窗口按钮(和 picker.js 的一样):Mac 红绿灯在左上 x 20、y 20 起,三个 12px 圆点隔 8;Windows 最小化 / 最大化 / 关闭在右上角,各 46×32
2896
+ const LIGHTS = { x: 20, y: 20, d: 12, gap: 8 }
2897
+ const WINBTN = { w: 46, h: 32 }
2898
+ const META_ALIAS = { phone: 'app', mobile: 'app', ios: 'app', android: 'app', mini: 'miniprogram', miniapp: 'miniprogram', weapp: 'miniprogram', wechat: 'miniprogram', 小程序: 'miniprogram', pad: 'tablet', ipad: 'tablet', pc: 'desktop', electron: 'desktop', tauri: 'desktop', mac: 'desktop', macos: 'desktop', windows: 'desktop', win: 'desktop', 桌面: 'desktop', 桌面软件: 'desktop', social: 'poster', image: 'poster', slide: 'slides', deck: 'slides', ppt: 'slides', mail: 'email' }
2059
2899
 
2060
- /** 页面里 <meta name="dsh-design" content="app"> / "poster 1080x1920" → { kind, size } */
2900
+ /** 页面里 <meta name="dsh-design" content="app"> / "poster 1080x1920" / "desktop windows" → { kind, size, os }(os:win,不写是 Mac) */
2061
2901
  function parseMeta(content) {
2062
- const out = { kind: '', size: '' }
2902
+ const out = { kind: '', size: '', os: '' }
2063
2903
  for (const w of String(content || '').toLowerCase().split(/[\s,;]+/)) {
2064
2904
  if (!out.kind && (KIND_LIST.includes(w) || META_ALIAS[w])) out.kind = KIND_LIST.includes(w) ? w : META_ALIAS[w]
2065
2905
  const m = /^(\d{2,5})[x×*](\d{2,5})$/.exec(w)
2066
2906
  if (!out.size && m) out.size = `${m[1]}x${m[2]}`
2907
+ if (!out.os && /^(windows|win|win10|win11)$/.test(w)) out.os = 'win'
2067
2908
  }
2068
2909
  return out
2069
2910
  }
@@ -2133,6 +2974,7 @@ window.__ModuleLoader__.load({
2133
2974
  app: [['szSmall', 'app', [375, 667]], ['szStd', 'app', [393, 852]], ['szLarge', 'app', [430, 932]]],
2134
2975
  miniprogram: [['szSmall', 'miniprogram', [375, 667]], ['szStd', 'miniprogram', [375, 812]], ['szLarge', 'miniprogram', [414, 896]]],
2135
2976
  tablet: [['szSmall', 'tablet', [744, 1133]], ['szStd', 'tablet', [820, 1180]], ['szLarge', 'tablet', [1032, 1376]]],
2977
+ desktop: [['szSmall', 'desktop', [1024, 680]], ['szStd', 'desktop', [1280, 800]], ['szLarge', 'desktop', [1440, 900]]],
2136
2978
  }
2137
2979
 
2138
2980
  /** 大小不一的几块排成一行(顶端对齐),算出整块大小和放得下的缩放 */
@@ -2473,34 +3315,46 @@ window.__ModuleLoader__.load({
2473
3315
  h('b', { style: { height: 14, width: '54%' } }))
2474
3316
  }
2475
3317
 
2476
- function AddressForm({ t, sid, initial, onDone, autoFocus, className }) {
3318
+ function AddressForm({ t, sid, initial, onDone, autoFocus, className, onBrowse }) {
2477
3319
  const [value, setValue] = useState(initial || '')
2478
3320
  const [err, setErr] = useState('')
2479
3321
  const submit = async (e) => {
2480
3322
  if (e) e.preventDefault()
2481
3323
  if (!value.trim()) return onDone && onDone()
2482
3324
  try {
2483
- await post('open', { session: sid, target: value.trim() })
2484
- const p = poller(sid)
2485
- p.stop()
2486
- p.start()
3325
+ await openTarget(sid, value.trim())
2487
3326
  setErr('')
2488
3327
  onDone && onDone()
2489
3328
  } catch (x) {
2490
3329
  setErr(t('openFailed', { msg: x.message }))
2491
3330
  }
2492
3331
  }
2493
- return h('form', { onSubmit: submit, className, style: className ? undefined : { display: 'contents' } },
3332
+ // 工具栏里:焦点离开整个地址栏(输入框和「浏览…」都算在内)才收起;弹出选文件窗口时整个窗口失焦,不收
3333
+ const blur = (e) => {
3334
+ if (!onDone || className) return
3335
+ const form = e.currentTarget
3336
+ if (form.contains(e.relatedTarget)) return
3337
+ setTimeout(() => { if (document.hasFocus() && !form.contains(document.activeElement)) onDone() }, 150)
3338
+ }
3339
+ const esc = (e) => { if (e.key === 'Escape' && !(e.nativeEvent && e.nativeEvent.isComposing) && e.keyCode !== 229 && onDone) onDone() }
3340
+ return h('form', { onSubmit: submit, onBlur: blur, onKeyDown: esc, className, style: className ? undefined : { display: 'contents' } },
2494
3341
  h('input', {
2495
3342
  className: 'ddp-input',
2496
3343
  value,
2497
3344
  autoFocus,
2498
3345
  spellCheck: false,
2499
3346
  placeholder: t('placeholder'),
2500
- onChange: (e) => setValue(e.target.value),
2501
- onKeyDown: (e) => { if (e.key === 'Escape') onDone && onDone() },
2502
- onBlur: () => { if (!err && onDone && !className) setTimeout(onDone, 150) },
3347
+ onChange: (e) => { setValue(e.target.value); if (err) setErr('') },
2503
3348
  }),
3349
+ // 「浏览…」:桌面版弹系统的选文件窗口;按下时不抢输入框的焦点(工具栏里失焦会收起地址栏)
3350
+ onBrowse ? h('button', {
3351
+ type: 'button',
3352
+ className: className ? 'ddp-browse' : 'ddp-btn',
3353
+ title: t('browseTip'),
3354
+ 'aria-label': className ? undefined : t('browseTip'),
3355
+ onMouseDown: (e) => e.preventDefault(),
3356
+ onClick: onBrowse,
3357
+ }, className ? t('browse') : h(I.folder)) : null,
2504
3358
  className ? h('button', { className: 'ddp-go', type: 'submit' }, t('go')) : null,
2505
3359
  err ? h('div', { className: 'ddp-err', style: className ? { width: '100%' } : { position: 'absolute', top: 40, left: 12 } }, err) : null)
2506
3360
  }
@@ -2526,7 +3380,7 @@ window.__ModuleLoader__.load({
2526
3380
  * 外框:网页是浏览器窗口,App / 平板是带圆角边框的设备(App 还有灵动岛,小程序再加右上角的胶囊),海报 / 幻灯片 / 邮件是一张卡片。
2527
3381
  * fill = 铺满舞台(单页看网页)。不管哪种,里面那屏都在同一个位置(换类型不重载页面)。top:页面顶栏右边是深色(胶囊画白的)
2528
3382
  */
2529
- function Device({ kind, w, h: ht, fill, label, top, children }) {
3383
+ function Device({ kind, w, h: ht, fill, label, top, os, children }) {
2530
3384
  const f = fill ? { pad: 0, top: 0, r: 0, ir: 0 } : frameOf(kind, w, ht)
2531
3385
  return h('div', {
2532
3386
  className: `ddp-dev k-${kind}` + (fill ? ' fill' : ''),
@@ -2535,7 +3389,22 @@ window.__ModuleLoader__.load({
2535
3389
  h('div', { className: 'ddp-chrome', style: { display: f.top ? 'flex' : 'none', height: f.top } }, h('i'), h('i'), h('i'), h('span', null, label || '')),
2536
3390
  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),
2537
3391
  h('div', { className: 'ddp-island', style: { display: f.island ? 'block' : 'none', top: (f.pad || 0) + (f.island || 0) } }),
2538
- f.capsule ? h(Capsule, { white: !!top, style: { top: (f.pad || 0) + CAP.top, right: (f.pad || 0) + CAP.right } }) : null)
3392
+ f.capsule ? h(Capsule, { white: !!top, style: { top: (f.pad || 0) + CAP.top, right: (f.pad || 0) + CAP.right } }) : null,
3393
+ f.win ? h(WinCtl, { os, white: !!top }) : null)
3394
+ }
3395
+
3396
+ /** 桌面软件的窗口按钮:Mac 左上角红绿灯,Windows 右上角最小化 / 最大化 / 关闭(顶栏深色时画白的) */
3397
+ function WinCtl({ os, white }) {
3398
+ if (os === 'win') {
3399
+ const W = WINBTN.w, H = WINBTN.h
3400
+ return h('svg', { className: 'ddp-win' + (white ? ' w' : ''), style: { top: 0, right: 0, width: W * 3, height: H }, viewBox: `0 0 ${W * 3} ${H}`, 'aria-hidden': true },
3401
+ h('path', { className: 'x', d: `M${W / 2 - 5} ${H / 2 + 0.5}h10` }),
3402
+ h('rect', { className: 'x', x: W * 1.5 - 4.5, y: H / 2 - 4.5, width: 9, height: 9, rx: 1 }),
3403
+ h('path', { className: 'x', d: `M${W * 2.5 - 5} ${H / 2 - 5}l10 10M${W * 2.5 + 5} ${H / 2 - 5}l-10 10` }))
3404
+ }
3405
+ const { x, y, d, gap } = LIGHTS
3406
+ return h('svg', { className: 'ddp-win', style: { left: x, top: y, width: d * 3 + gap * 2, height: d }, viewBox: `0 0 ${d * 3 + gap * 2} ${d}`, 'aria-hidden': true },
3407
+ ['#FF5F57', '#FEBC2E', '#28C840'].map((c, i) => h('circle', { key: i, cx: d / 2 + i * (d + gap), cy: d / 2, r: d / 2 - 0.25, fill: c, stroke: 'rgba(0,0,0,.12)', strokeWidth: 0.5 })))
2539
3408
  }
2540
3409
 
2541
3410
  function Capsule({ white, style }) {
@@ -2609,6 +3478,9 @@ window.__ModuleLoader__.load({
2609
3478
  }),
2610
3479
  checkFocus: (i) => { if (curRef.current) postTo(curRef.current, { type: 'checkFocus', i }) },
2611
3480
  checkClear: () => { if (curRef.current) postTo(curRef.current, { type: 'checkClear' }) },
3481
+ unpin: () => { if (curRef.current) postTo(curRef.current, { type: 'unpin' }) }, // 去掉「已选」虚线框
3482
+ // 画笔标注:这一屏的页面(iframe)现在摆在哪,拼图时按它的位置放截图
3483
+ frame: () => wins.current.get(curRef.current) || null,
2612
3484
  }
2613
3485
  }
2614
3486
  // 登记到右侧栏:导出图片时按屏找到它
@@ -2760,6 +3632,11 @@ window.__ModuleLoader__.load({
2760
3632
  case 'links':
2761
3633
  if (p.onLinks && Array.isArray(d.links)) p.onLinks(d.links)
2762
3634
  break
3635
+ case 'dragIn':
3636
+ case 'dragOut':
3637
+ case 'dropIn':
3638
+ if (p.onPageDrag) p.onPageDrag(d)
3639
+ break
2763
3640
  case 'top':
2764
3641
  // 每帧记着自己的;只有正显示的那帧算数(预载的换上来时再用,旧帧晚到的不管)
2765
3642
  L.tops.set(id, !!d.dark)
@@ -2922,7 +3799,7 @@ window.__ModuleLoader__.load({
2922
3799
  }
2923
3800
 
2924
3801
  /** 类型菜单:网页 / App / 平板 / 海报 / 幻灯片 / 邮件 */
2925
- function KindMenu({ t, kind, open, setOpen, onPick }) {
3802
+ function KindMenu({ t, kind, open, setOpen, onPick, disabled }) {
2926
3803
  const ref = useRef(null)
2927
3804
  useEffect(() => {
2928
3805
  if (!open) return undefined
@@ -2936,7 +3813,7 @@ window.__ModuleLoader__.load({
2936
3813
  }
2937
3814
  }, [open])
2938
3815
  return h('div', { className: 'ddp-kind', ref },
2939
- h('button', { className: 'ddp-btn' + (open ? ' sel' : ''), title: t('kind'), onClick: () => setOpen(!open) },
3816
+ h('button', { className: 'ddp-btn' + (open ? ' sel' : ''), title: t('kind'), disabled: !!disabled, onClick: () => setOpen(!open) },
2940
3817
  h(I[kind]), h('span', null, t(KIND_KEY[kind])), h(I.caret)),
2941
3818
  open ? h('div', { className: 'ddp-menu', role: 'menu' },
2942
3819
  h('div', { className: 'hd' }, t('kind')),
@@ -2944,6 +3821,540 @@ window.__ModuleLoader__.load({
2944
3821
  h(I[k]), h('span', null, t(KIND_KEY[k])), h('em', null, KIND_SIZE[k].join('×'))))) : null)
2945
3822
  }
2946
3823
 
3824
+ // ── 画笔标注:一层透明画布盖在预览上,画的东西按台面上的坐标(CSS 像素)一笔一笔记着,能撤销、能擦 ──
3825
+ // 一笔:pen / hl { p: [[x, y]…] } · arrow / rect / oval { a: [x, y], b: [x, y] } · text { x, y, f, v };都带颜色 c,线宽 w
3826
+ const MARK_TOOLS = [['pen', 'pen', 'mkPen', 'p'], ['hl', 'marker', 'mkHl', 'h'], ['arrow', 'arrow', 'mkArrow', 'a'], ['rect', 'square', 'mkRect', 'r'], ['oval', 'circle', 'mkOval', 'o'], ['text', 'text', 'mkText', 't'], ['erase', 'eraser', 'mkErase', 'e']]
3827
+ const MARK_COLORS = ['#ef4444', '#f97316', '#facc15', '#22c55e', '#3b82f6', '#a855f7', '#111827', '#ffffff']
3828
+ const MARK_WIDTH = [2, 3, 5]
3829
+ const MARK_FONT = [16, 20, 28]
3830
+ // 手绘风:字用楷体(像手写),没有就退回系统字
3831
+ const MARK_FAMILY = '"Kaiti SC","STKaiti","KaiTi","楷体",system-ui,-apple-system,"PingFang SC","Microsoft YaHei",sans-serif'
3832
+ const MARK_MAX = 300 // 最多这么多笔(再多就是在乱画了)
3833
+ const MOD = /Mac|iPhone|iPad/i.test((typeof navigator !== 'undefined' && navigator.platform) || '') ? '⌘' : 'Ctrl+'
3834
+
3835
+ let markMeasure = null
3836
+ /** 一行字画出来多宽 */
3837
+ function markTextWidth(text, f) {
3838
+ if (!markMeasure) markMeasure = document.createElement('canvas').getContext('2d')
3839
+ markMeasure.font = `700 ${f}px ${MARK_FAMILY}`
3840
+ return markMeasure.measureText(text).width
3841
+ }
3842
+
3843
+ /** 浅色(白、黄):字的描边换成深色 */
3844
+ function markLight(hex) {
3845
+ const n = parseInt(String(hex).slice(1), 16)
3846
+ return ((n >> 16 & 255) * 0.299 + (n >> 8 & 255) * 0.587 + (n & 255) * 0.114) > 170
3847
+ }
3848
+
3849
+ /** 一笔占的地方(含线宽):拼图时裁剪、橡皮擦字都按它 */
3850
+ function markBox(s) {
3851
+ if (s.t === 'text') {
3852
+ const lines = s.v.split('\n')
3853
+ const w = Math.max(...lines.map((l) => markTextWidth(l, s.f)))
3854
+ return { l: s.x, t: s.y, r: s.x + w, b: s.y + lines.length * s.f * 1.3 }
3855
+ }
3856
+ const pts = s.p || [s.a, s.b]
3857
+ const pad = s.w / 2 + (s.t === 'arrow' ? Math.max(14, s.w * 4) : s.p ? 0 : 4 + Math.max(Math.abs(s.b[0] - s.a[0]), Math.abs(s.b[1] - s.a[1])) * 0.06)
3858
+ const xs = pts.map((q) => q[0])
3859
+ const ys = pts.map((q) => q[1])
3860
+ return { l: Math.min(...xs) - pad, t: Math.min(...ys) - pad, r: Math.max(...xs) + pad, b: Math.max(...ys) + pad }
3861
+ }
3862
+
3863
+ /** 这一笔的随机数(-0.5…0.5):按笔上记的种子 sd 来,重画、拼图时一模一样 */
3864
+ function markRand(seed) {
3865
+ let v = (seed | 0) % 2147483647
3866
+ if (v <= 0) v += 2147483646
3867
+ return () => (v = (v * 16807) % 2147483647) / 2147483647 - 0.5
3868
+ }
3869
+
3870
+ /** 手绘的一条线:两头和中间都抖一点,弯一点(P:CSS 坐标 → 画布坐标;bend:这条线弯多少,描第二遍时一样) */
3871
+ function roughLine(ctx, P, a, b, rnd, amp, bend) {
3872
+ const len = Math.hypot(b[0] - a[0], b[1] - a[1]) || 1
3873
+ const nx = -(b[1] - a[1]) / len
3874
+ const ny = (b[0] - a[0]) / len
3875
+ const j = Math.min(len * 0.015, 2.2) * amp
3876
+ const bow = Math.min(len * 0.05, 9) * amp
3877
+ const pt = (t, off) => [a[0] + (b[0] - a[0]) * t + nx * off + rnd() * j, a[1] + (b[1] - a[1]) * t + ny * off + rnd() * j]
3878
+ const k1 = (bend ? bend[0] : rnd()) * bow * 2
3879
+ const k2 = (bend ? bend[1] : rnd()) * bow * 2
3880
+ const p0 = pt(0, 0)
3881
+ const c1 = pt(0.4, k1)
3882
+ const c2 = pt(0.75, k2)
3883
+ const p1 = pt(1, 0)
3884
+ ctx.moveTo(...P(p0))
3885
+ ctx.bezierCurveTo(...P(c1), ...P(c2), ...P(p1))
3886
+ }
3887
+
3888
+ /** 手绘的圈:从一个随便的角度起笔,绕一圈多一点,半径微微起伏;点和点之间连成顺滑的曲线 */
3889
+ function roughLoop(ctx, P, cx, cy, rx, ry, rnd, amp) {
3890
+ const start = -Math.PI / 2 - 0.6 + rnd() * 1.2
3891
+ const over = 0.45 + rnd() * 0.35
3892
+ const n = 44
3893
+ const wob = [rnd(), rnd()]
3894
+ const pts = []
3895
+ for (let i = 0; i <= n; i++) {
3896
+ const t = i / n
3897
+ const ang = start + t * (Math.PI * 2 + over)
3898
+ const m = 1 - 0.04 * amp + (wob[0] * Math.sin(t * 6.3) + wob[1] * Math.sin(t * 12.6 + 1) * 0.6) * 0.1 * amp + rnd() * 0.02 * amp + t * 0.09 * amp
3899
+ pts.push(P([cx + Math.cos(ang) * rx * m, cy + Math.sin(ang) * ry * m]))
3900
+ }
3901
+ ctx.moveTo(...pts[0])
3902
+ for (let i = 1; i < pts.length - 1; i++) ctx.quadraticCurveTo(...pts[i], (pts[i][0] + pts[i + 1][0]) / 2, (pts[i][1] + pts[i + 1][1]) / 2)
3903
+ ctx.lineTo(...pts[pts.length - 1])
3904
+ }
3905
+
3906
+ /** 一笔画到画布上(k:CSS 像素 → 画布像素;ox / oy:拼图时裁掉的左上角) */
3907
+ function drawMark(ctx, s, k = 1, ox = 0, oy = 0) {
3908
+ const X = (v) => (v - ox) * k
3909
+ const Y = (v) => (v - oy) * k
3910
+ ctx.save()
3911
+ ctx.lineCap = 'round'
3912
+ ctx.lineJoin = 'round'
3913
+ ctx.strokeStyle = s.c
3914
+ ctx.fillStyle = s.c
3915
+ ctx.lineWidth = s.w * k
3916
+ if (s.t === 'pen' || s.t === 'hl') {
3917
+ // 荧光笔:半透明,压在字上还看得见字
3918
+ if (s.t === 'hl') ctx.globalAlpha = 0.38
3919
+ const p = s.p
3920
+ ctx.beginPath()
3921
+ ctx.moveTo(X(p[0][0]), Y(p[0][1]))
3922
+ if (p.length === 1) ctx.lineTo(X(p[0][0]) + 0.01, Y(p[0][1]))
3923
+ // 相邻两点的中点连二次曲线:手画的线顺一点
3924
+ for (let i = 1; i < p.length - 1; i++) ctx.quadraticCurveTo(X(p[i][0]), Y(p[i][1]), X((p[i][0] + p[i + 1][0]) / 2), Y((p[i][1] + p[i + 1][1]) / 2))
3925
+ if (p.length > 1) ctx.lineTo(X(p[p.length - 1][0]), Y(p[p.length - 1][1]))
3926
+ ctx.stroke()
3927
+ } else if (s.t === 'arrow' || s.t === 'rect' || s.t === 'oval') {
3928
+ const P = (q) => [X(q[0]), Y(q[1])]
3929
+ const [x0, y0] = s.a
3930
+ const [x1, y1] = s.b
3931
+ const br = markRand(s.sd || 1)
3932
+ const bends = Array.from({ length: 4 }, () => [br(), br()])
3933
+ for (const pass of [0, 1]) {
3934
+ const rnd = markRand((s.sd || 1) + pass * 7919)
3935
+ ctx.beginPath()
3936
+ if (s.t === 'arrow') {
3937
+ // 杆子微微弯,箭头是两笔撇出去的(不填实心)
3938
+ const ang = Math.atan2(y1 - y0, x1 - x0)
3939
+ const head = Math.min(Math.max(16, s.w * 5), Math.hypot(x1 - x0, y1 - y0) * 0.6)
3940
+ roughLine(ctx, P, s.a, s.b, rnd, 1, bends[0])
3941
+ const sp = 0.5 + bends[3][0] * 0.12
3942
+ roughLine(ctx, P, s.b, [x1 - head * Math.cos(ang - sp), y1 - head * Math.sin(ang - sp)], rnd, 0.6, bends[1])
3943
+ roughLine(ctx, P, s.b, [x1 - head * Math.cos(ang + sp), y1 - head * Math.sin(ang + sp)], rnd, 0.6, bends[2])
3944
+ } else if (s.t === 'rect') {
3945
+ const l = Math.min(x0, x1)
3946
+ const r = Math.max(x0, x1)
3947
+ const tp = Math.min(y0, y1)
3948
+ const bt = Math.max(y0, y1)
3949
+ // 四条边各画一笔,拐角处稍微出头
3950
+ const o = Math.min(6, Math.min(r - l, bt - tp) * 0.06)
3951
+ roughLine(ctx, P, [l - o, tp], [r + o * 0.4, tp], rnd, 1, bends[0])
3952
+ roughLine(ctx, P, [r, tp - o * 0.4], [r, bt + o], rnd, 1, bends[1])
3953
+ roughLine(ctx, P, [r + o, bt], [l - o * 0.4, bt], rnd, 1, bends[2])
3954
+ roughLine(ctx, P, [l, bt + o * 0.4], [l, tp - o], rnd, 1, bends[3])
3955
+ } else {
3956
+ roughLoop(ctx, P, (x0 + x1) / 2, (y0 + y1) / 2, Math.abs(x1 - x0) / 2, Math.abs(y1 - y0) / 2, rnd, 1)
3957
+ }
3958
+ ctx.lineWidth = s.w * (pass ? 0.5 : 0.85) * k
3959
+ ctx.globalAlpha = pass ? 0.75 : 1
3960
+ ctx.stroke()
3961
+ }
3962
+ } else if (s.t === 'text') {
3963
+ // 手写的字:楷体,下面一道手画的线
3964
+ ctx.font = `700 ${s.f * k}px ${MARK_FAMILY}`
3965
+ ctx.textBaseline = 'middle'
3966
+ const lh = s.f * 1.3
3967
+ const rnd = markRand(s.sd || 1)
3968
+ const P = (q) => [X(q[0]), Y(q[1])]
3969
+ s.v.split('\n').forEach((line, i) => {
3970
+ const yy = s.y + lh * i + lh / 2
3971
+ // 描一圈反色的边:白、黄这类浅色描深色,深色描浅色(红字写在红按钮上也看得见)
3972
+ ctx.save()
3973
+ ctx.strokeStyle = markLight(s.c) ? 'rgba(0,0,0,.65)' : 'rgba(255,255,255,.85)'
3974
+ ctx.lineWidth = Math.max(2, s.f / 7) * k
3975
+ ctx.strokeText(line, X(s.x), Y(yy))
3976
+ ctx.restore()
3977
+ ctx.fillText(line, X(s.x), Y(yy))
3978
+ const w = markTextWidth(line, s.f)
3979
+ if (!w) return
3980
+ ctx.beginPath()
3981
+ roughLine(ctx, P, [s.x - 2, yy + s.f * 0.62], [s.x + w + 3, yy + s.f * 0.56], rnd, 0.8)
3982
+ ctx.lineWidth = Math.max(1.2, s.f / 12) * k
3983
+ ctx.stroke()
3984
+ })
3985
+ }
3986
+ ctx.restore()
3987
+ }
3988
+
3989
+ /** 点到线段的距离 */
3990
+ function segDist(px, py, a, b) {
3991
+ const dx = b[0] - a[0]
3992
+ const dy = b[1] - a[1]
3993
+ const len = dx * dx + dy * dy
3994
+ const u = len ? Math.max(0, Math.min(1, ((px - a[0]) * dx + (py - a[1]) * dy) / len)) : 0
3995
+ return Math.hypot(px - a[0] - u * dx, py - a[1] - u * dy)
3996
+ }
3997
+
3998
+ /** 手绘的圈、框、箭头实际画在哪:照 drawMark 同样画一遍,路径记成小段折线(橡皮按它判断,抖出去的线也擦得到) */
3999
+ function markPath(s) {
4000
+ const segs = []
4001
+ let cur = null
4002
+ const add = (q) => { if (cur) segs.push([cur, q]); cur = q }
4003
+ const ctx = {
4004
+ save() {}, restore() {}, stroke() {}, fill() {}, fillText() {}, strokeText() {},
4005
+ beginPath() { cur = null },
4006
+ moveTo(x, y) { cur = [x, y] },
4007
+ lineTo(x, y) { add([x, y]) },
4008
+ quadraticCurveTo(cx, cy, x, y) {
4009
+ const p0 = cur || [x, y]
4010
+ for (let i = 1; i <= 6; i++) { const t = i / 6; const u = 1 - t; add([u * u * p0[0] + 2 * u * t * cx + t * t * x, u * u * p0[1] + 2 * u * t * cy + t * t * y]) }
4011
+ },
4012
+ bezierCurveTo(ax, ay, bx, by, x, y) {
4013
+ const p0 = cur || [x, y]
4014
+ for (let i = 1; i <= 12; i++) { const t = i / 12; const u = 1 - t; add([u * u * u * p0[0] + 3 * u * u * t * ax + 3 * u * t * t * bx + t * t * t * x, u * u * u * p0[1] + 3 * u * u * t * ay + 3 * u * t * t * by + t * t * t * y]) }
4015
+ },
4016
+ }
4017
+ drawMark(ctx, s)
4018
+ return segs
4019
+ }
4020
+
4021
+ /** 橡皮擦到了这一笔没有(r:橡皮半径) */
4022
+ function markHit(s, x, y, r) {
4023
+ if (s.t === 'text') {
4024
+ const b = markBox(s)
4025
+ return x >= b.l - r && x <= b.r + r && y >= b.t - r && y <= b.b + r
4026
+ }
4027
+ const near = r + s.w / 2
4028
+ if (s.p) {
4029
+ if (s.p.length === 1) return Math.hypot(x - s.p[0][0], y - s.p[0][1]) <= near
4030
+ for (let i = 1; i < s.p.length; i++) if (segDist(x, y, s.p[i - 1], s.p[i]) <= near) return true
4031
+ return false
4032
+ }
4033
+ // 只认真正画出来的那条手绘线,不再按看不见的标准形状补判(不然会擦掉旁边的圈)
4034
+ const segs = markPath(s)
4035
+ if (segs.length) return segs.some(([a, b]) => segDist(x, y, a, b) <= near)
4036
+ return Math.hypot(x - s.a[0], y - s.a[1]) <= near
4037
+ }
4038
+
4039
+ /** 按住 Shift:方框 / 圆圈画成正的,箭头吸到 45° 的倍数 */
4040
+ function markSnap(tool, a, b) {
4041
+ const dx = b[0] - a[0]
4042
+ const dy = b[1] - a[1]
4043
+ if (tool === 'arrow') {
4044
+ const ang = Math.round(Math.atan2(dy, dx) / (Math.PI / 4)) * (Math.PI / 4)
4045
+ const len = Math.hypot(dx, dy)
4046
+ return [a[0] + Math.cos(ang) * len, a[1] + Math.sin(ang) * len]
4047
+ }
4048
+ const d = Math.max(Math.abs(dx), Math.abs(dy))
4049
+ return [a[0] + (dx < 0 ? -d : d), a[1] + (dy < 0 ? -d : d)]
4050
+ }
4051
+
4052
+ /**
4053
+ * 一笔跟着它画在的那一屏走:画的时候记下那一屏的位置(an),侧栏拉宽拉窄、开面板、画布自己缩放后,按那一屏现在的位置和大小挪过去。
4054
+ * frames:台面上现在每一屏 { key, l, t, w }(台面坐标)
4055
+ */
4056
+ function markPlace(s, frames) {
4057
+ const fit = (an) => {
4058
+ const now = an && frames.find((x) => x.key === an.key)
4059
+ if (!now) return null
4060
+ const k = now.w / an.w
4061
+ if (Math.abs(k - 1) < 1e-3 && Math.abs(now.l - an.l) < 0.25 && Math.abs(now.t - an.t) < 0.25) return null
4062
+ return { k, P: (q) => [now.l + (q[0] - an.l) * k, now.t + (q[1] - an.t) * k] }
4063
+ }
4064
+ const A = fit(s.an)
4065
+ if (s.t === 'text') { if (!A) return s; const [x, y] = A.P([s.x, s.y]); return { ...s, x, y, f: s.f * A.k } }
4066
+ if (s.p && s.ans) {
4067
+ // 跨了几屏的自由线:每个点跟着自己那一屏([x, y, 第几个锚点])
4068
+ const F = s.ans.map(fit)
4069
+ return F.some(Boolean) ? { ...s, p: s.p.map((q) => (F[q[2]] ? [...F[q[2]].P(q), q[2]] : q)) } : s
4070
+ }
4071
+ if (s.p) return A ? { ...s, p: s.p.map(A.P) } : s
4072
+ // 箭头、方框、圆圈:两头各跟各的屏(从这一屏指到那一屏的箭头,画布重新排了也还指着那儿)
4073
+ const B = s.bn ? fit(s.bn) : A
4074
+ if (!A && !B) return s
4075
+ return { ...s, a: A ? A.P(s.a) : s.a, b: B ? B.P(s.b) : s.b }
4076
+ }
4077
+
4078
+ /** 盖在台面上的画布 + 上面一条工具栏 + 下面「取消 / 交给模型」 */
4079
+ function MarkLayer({ t, sending, frames, onCancel, onSend, onCopy }) {
4080
+ const wrap = useRef(null)
4081
+ const cv = useRef(null)
4082
+ const area = useRef(null)
4083
+ const [tool, setTool] = useState('pen')
4084
+ const [color, setColor] = useState(MARK_COLORS[0])
4085
+ const [size, setSize] = useState(1)
4086
+ const [pal, setPal] = useState(false) // 颜色板开着
4087
+ const [hist, setHist] = useState({ list: [[]], i: 0 }) // 每画一笔存一份整张的,撤销 / 重做就是前后挪
4088
+ const [txt, setTxt] = useState(null) // 正在写的字:{ x, y, v }
4089
+ // 头几次打开时页面中间教一下怎么用(画了第一笔就收起)
4090
+ const [coach] = useState(() => {
4091
+ try { const n = Number(localStorage.getItem('ddp-mk-coach')) || 0; if (n >= 3) return false; localStorage.setItem('ddp-mk-coach', String(n + 1)) } catch { /* 存不了就每次都教 */ }
4092
+ return true
4093
+ })
4094
+ const shapes = hist.list[hist.i]
4095
+ const cur = useRef(shapes)
4096
+ cur.current = shapes
4097
+ const opt = useRef({})
4098
+ opt.current = { tool, color, size }
4099
+ const txtRef = useRef(txt)
4100
+ txtRef.current = txt
4101
+ const live = useRef(null) // 正在画的那一笔;橡皮:{ erase: 还剩下的, hit }
4102
+ const raf = useRef(0)
4103
+ const dims = useRef({ w: 0, h: 0, k: 1 })
4104
+ const fr = useRef(frames)
4105
+ fr.current = frames
4106
+ const [, setTick] = useState(0) // 屏挪了:正写着的字框跟着挪
4107
+ const where = () => { try { return fr.current() } catch { return [] } }
4108
+ const placed = (list, f = where()) => list.map((s) => markPlace(s, f))
4109
+
4110
+ const paint = () => {
4111
+ raf.current = 0
4112
+ const c = cv.current
4113
+ if (!c) return
4114
+ const ctx = c.getContext('2d')
4115
+ ctx.clearRect(0, 0, c.width, c.height)
4116
+ const lv = live.current
4117
+ for (const s of placed(lv && lv.erase ? lv.erase : cur.current)) drawMark(ctx, s, dims.current.k)
4118
+ if (lv && !lv.erase) drawMark(ctx, lv, dims.current.k)
4119
+ }
4120
+ const repaint = () => { if (!raf.current) raf.current = requestAnimationFrame(paint) }
4121
+ useEffect(() => () => cancelAnimationFrame(raf.current), [])
4122
+ useEffect(() => { paint() }, [shapes])
4123
+ // 底下的屏挪了(开关面板、画布自己缩放、模型加了一屏):跟着重画
4124
+ useEffect(() => {
4125
+ let sig = ''
4126
+ const tm = setInterval(() => {
4127
+ const now = where().map((x) => `${x.key}:${Math.round(x.l)},${Math.round(x.t)},${Math.round(x.w)}`).join('|')
4128
+ if (now !== sig) { sig = now; repaint(); if (txtRef.current) setTick((n) => n + 1) }
4129
+ }, 250)
4130
+ return () => clearInterval(tm)
4131
+ }, [])
4132
+
4133
+ // 画布跟着台面一样大(右侧栏拉宽拉窄),按屏幕像素密度画,线不糊
4134
+ useEffect(() => {
4135
+ const el = wrap.current
4136
+ if (!el) return undefined
4137
+ const fit = () => {
4138
+ const r = el.getBoundingClientRect()
4139
+ const k = Math.min(2, Math.max(1, window.devicePixelRatio || 1))
4140
+ dims.current = { w: r.width, h: r.height, k }
4141
+ if (cv.current) {
4142
+ cv.current.width = Math.max(1, Math.round(r.width * k))
4143
+ cv.current.height = Math.max(1, Math.round(r.height * k))
4144
+ }
4145
+ paint()
4146
+ }
4147
+ fit()
4148
+ const ro = typeof ResizeObserver === 'function' ? new ResizeObserver(fit) : null
4149
+ if (ro) ro.observe(el)
4150
+ try { el.focus({ preventScroll: true }) } catch { /* 点一下画布也行 */ }
4151
+ return () => { if (ro) ro.disconnect() }
4152
+ }, [])
4153
+
4154
+ const commit = (next) => setHist((hs) => {
4155
+ const list = hs.list.slice(Math.max(0, hs.i - 79), hs.i + 1)
4156
+ list.push(next)
4157
+ return { list, i: list.length - 1 }
4158
+ })
4159
+ const undo = () => { if (!live.current) setHist((hs) => (hs.i > 0 ? { ...hs, i: hs.i - 1 } : hs)) }
4160
+ const redo = () => { if (!live.current) setHist((hs) => (hs.i < hs.list.length - 1 ? { ...hs, i: hs.i + 1 } : hs)) }
4161
+ // 清掉:正写着的字也不要了
4162
+ const clear = () => {
4163
+ if (txtRef.current) { txtRef.current = null; setTxt(null) }
4164
+ if (cur.current.length) commit([])
4165
+ }
4166
+
4167
+ // 写字:回车 / 点别处算写完;Esc 不要了。颜色、大小按写完那一刻的
4168
+ const textOf = (x) => {
4169
+ const v = String(x.v || '').replace(/\s+$/, '').slice(0, 2000)
4170
+ return v.trim() ? { t: 'text', c: opt.current.color, w: 0, f: MARK_FONT[opt.current.size], x: x.x, y: x.y, v, an: x.an, sd: 1 + Math.floor(Math.random() * 2e9) } : null
4171
+ }
4172
+ // refocus:回车 / Esc / 点画布写完的,键盘交回画布;点到别处(比如聊天输入框)失焦的,别把焦点抢回来
4173
+ const endText = (keep, refocus) => {
4174
+ const x = txtRef.current
4175
+ if (!x) return null
4176
+ txtRef.current = null
4177
+ setTxt(null)
4178
+ const s = keep ? textOf(x) : null
4179
+ if (s && cur.current.length < MARK_MAX) commit([...cur.current, s])
4180
+ if (refocus) { try { wrap.current.focus({ preventScroll: true }) } catch { /* 已经关了 */ } }
4181
+ return s
4182
+ }
4183
+ useEffect(() => {
4184
+ if (!txt || !area.current || document.activeElement === area.current) return undefined
4185
+ const tm = setTimeout(() => { try { area.current.focus({ preventScroll: true }) } catch { /* 关了 */ } }, 0)
4186
+ return () => clearTimeout(tm)
4187
+ }, [!!txt])
4188
+
4189
+ const at = (e) => {
4190
+ const r = wrap.current.getBoundingClientRect()
4191
+ return [e.clientX - r.left, e.clientY - r.top]
4192
+ }
4193
+ const eraseAt = (x, y) => {
4194
+ const lv = live.current
4195
+ const f = where()
4196
+ const left = lv.erase.filter((s) => !markHit(markPlace(s, f), x, y, 6))
4197
+ if (left.length !== lv.erase.length) { lv.erase = left; lv.hit = true; repaint() }
4198
+ }
4199
+ // 画的途中屏要是挪了:按落笔完那一刻那一屏的位置记(这一笔是照着现在看到的画的)
4200
+ const reAnchor = (an) => {
4201
+ const now = an && where().find((q) => q.key === an.key)
4202
+ return now ? { key: now.key, l: now.l, t: now.t, w: now.w } : an
4203
+ }
4204
+ // 自由线:看每个点落在哪屏;只在一屏里的照旧整条跟那一屏,跨屏的每个点记自己那屏(落在屏外的点跟最近的前后点)
4205
+ const penAnchors = (lv) => {
4206
+ const fz = where()
4207
+ const ans = []
4208
+ let last = null
4209
+ const idx = lv.p.map(([x, y]) => {
4210
+ const hit = last && x >= last.l && x <= last.l + last.w && y >= last.t && y <= last.t + last.h ? last
4211
+ : fz.find((q) => x >= q.l && x <= q.l + q.w && y >= q.t && y <= q.t + q.h)
4212
+ if (!hit) return -1
4213
+ last = hit
4214
+ let i = ans.findIndex((a) => a.key === hit.key)
4215
+ if (i < 0) { i = ans.length; ans.push({ key: hit.key, l: hit.l, t: hit.t, w: hit.w }) }
4216
+ return i
4217
+ })
4218
+ if (ans.length < 2) return { ...lv, an: ans[0] || reAnchor(lv.an) }
4219
+ for (let i = 1; i < idx.length; i++) if (idx[i] < 0) idx[i] = idx[i - 1]
4220
+ for (let i = idx.length - 2; i >= 0; i--) if (idx[i] < 0) idx[i] = idx[i + 1]
4221
+ return { ...lv, an: ans[0], ans, p: lv.p.map((q, i) => [q[0], q[1], idx[i]]) }
4222
+ }
4223
+ // 这一点落在哪一屏上(落在屏外就不跟着谁)
4224
+ const anchorAt = (x, y) => {
4225
+ const hit = where().find((q) => x >= q.l && x <= q.l + q.w && y >= q.t && y <= q.t + q.h)
4226
+ return hit ? { key: hit.key, l: hit.l, t: hit.t, w: hit.w } : null
4227
+ }
4228
+ const onDown = (e) => {
4229
+ if (e.button !== 0 || sending || live.current) return
4230
+ e.preventDefault()
4231
+ setPal(false)
4232
+ if (!wrap.current.contains(document.activeElement)) { try { wrap.current.focus({ preventScroll: true }) } catch { /* 键盘快捷键用不了而已 */ } }
4233
+ // 正写着字:这一下只算写完
4234
+ if (txtRef.current) { endText(true, true); return }
4235
+ const [x, y] = at(e)
4236
+ const o = opt.current
4237
+ if (o.tool === 'text') {
4238
+ if (cur.current.length < MARK_MAX) setTxt({ x, y: y - MARK_FONT[o.size] * 0.65, v: '', an: anchorAt(x, y) })
4239
+ return
4240
+ }
4241
+ if (o.tool !== 'erase' && cur.current.length >= MARK_MAX) return
4242
+ try { e.currentTarget.setPointerCapture(e.pointerId) } catch { /* 拖出去就断了也没事 */ }
4243
+ if (o.tool === 'erase') {
4244
+ live.current = { erase: cur.current.slice(), hit: false }
4245
+ eraseAt(x, y)
4246
+ return
4247
+ }
4248
+ const w = MARK_WIDTH[o.size] * (o.tool === 'hl' ? 4 : 1)
4249
+ const an = anchorAt(x, y)
4250
+ live.current = o.tool === 'pen' || o.tool === 'hl' ? { t: o.tool, c: o.color, w, p: [[x, y]], an } : { t: o.tool, c: o.color, w, a: [x, y], b: [x, y], an, sd: 1 + Math.floor(Math.random() * 2e9) }
4251
+ repaint()
4252
+ }
4253
+ const onMove = (e) => {
4254
+ const lv = live.current
4255
+ if (!lv) return
4256
+ const [x, y] = at(e)
4257
+ if (lv.erase) { eraseAt(x, y); return }
4258
+ if (lv.p) {
4259
+ const q = lv.p[lv.p.length - 1]
4260
+ if (Math.hypot(x - q[0], y - q[1]) >= 1.5 && lv.p.length < 5000) lv.p.push([x, y])
4261
+ } else lv.b = e.shiftKey ? markSnap(lv.t, lv.a, [x, y]) : [x, y]
4262
+ repaint()
4263
+ }
4264
+ const onUp = () => {
4265
+ const lv = live.current
4266
+ if (!lv) return
4267
+ live.current = null
4268
+ if (lv.erase) { if (lv.hit) commit(lv.erase); else paint(); return }
4269
+ // 方框、箭头只点了一下没拖:不算
4270
+ if (lv.p) commit([...cur.current, penAnchors(lv)])
4271
+ else if (Math.hypot(lv.b[0] - lv.a[0], lv.b[1] - lv.a[1]) >= 4) {
4272
+ commit([...cur.current, { ...lv, an: reAnchor(lv.an), bn: anchorAt(lv.b[0], lv.b[1]) }])
4273
+ } else paint()
4274
+ }
4275
+
4276
+ const onKey = (e) => {
4277
+ if (e.target === area.current) {
4278
+ if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); endText(false, true) }
4279
+ else if (e.key === 'Enter' && !e.shiftKey && !(e.nativeEvent && e.nativeEvent.isComposing) && e.keyCode !== 229) { e.preventDefault(); e.stopPropagation(); endText(true, true) }
4280
+ return
4281
+ }
4282
+ if (e.target && e.target.tagName === 'BUTTON' && (e.key === 'Enter' || e.key === ' ')) return
4283
+ const mod = e.metaKey || e.ctrlKey
4284
+ const k = String(e.key).toLowerCase()
4285
+ let done = true
4286
+ if (e.key === 'Escape') { if (pal) setPal(false); else if (!sending) onCancel() }
4287
+ else if (mod && k === 'z') { if (e.shiftKey) redo(); else undo() }
4288
+ else if (mod && k === 'y') redo()
4289
+ else if (mod && k === 'c' && onCopy && !e.shiftKey && !e.altKey) copy()
4290
+ else if (!mod && !e.altKey && MARK_TOOLS.some((x) => x[3] === k)) { setTool(MARK_TOOLS.find((x) => x[3] === k)[0]); setPal(false) }
4291
+ else done = false
4292
+ if (done) { e.preventDefault(); e.stopPropagation() }
4293
+ }
4294
+
4295
+ // 交出去的是按屏现在的位置摆好的;正写着的字也算上
4296
+ const done = () => {
4297
+ const s = txtRef.current ? endText(true, true) : null
4298
+ return placed(s && cur.current.length < MARK_MAX ? [...cur.current, s] : cur.current)
4299
+ }
4300
+ const send = () => { if (!sending) onSend(done()) }
4301
+ // 复制:拼成同一张图放进剪贴板,贴到别的地方用(issue #6;不支持的环境不给这个按钮)
4302
+ const copy = () => { if (!sending && onCopy) onCopy(done()) }
4303
+
4304
+ // 工具栏上按下去别抢走焦点:正写着的字不会被打断,快捷键也还管用
4305
+ const keep = (e) => { if (e.target !== area.current) e.preventDefault() }
4306
+ const btn = (key, icon, title, on, run, off) => h('button', { key, type: 'button', className: 'ddp-btn' + (on ? ' sel' : ''), title, 'aria-label': title, 'aria-pressed': on || undefined, disabled: !!off, onClick: run }, h(I[icon]))
4307
+ const f = MARK_FONT[size]
4308
+ let box = null
4309
+ if (txt) {
4310
+ // 字框和写完后的字一样跟着那一屏走
4311
+ const p = markPlace({ t: 'text', x: txt.x, y: txt.y, f, an: txt.an }, where())
4312
+ const pf = p.f
4313
+ const lines = txt.v.split('\n')
4314
+ const w = Math.max(markTextWidth(txt.v ? '' : t('mkTextPh'), pf), ...lines.map((l) => markTextWidth(l, pf))) + pf
4315
+ box = { left: p.x - 3, top: p.y - 1, width: Math.max(pf * 3, Math.min(w, dims.current.w - p.x - 6)), height: lines.length * pf * 1.3 + 2, fontSize: pf, color }
4316
+ }
4317
+ return h('div', { className: 'ddp-mark', ref: wrap, tabIndex: -1, onKeyDown: onKey },
4318
+ h('canvas', { ref: cv, className: 'cv' + (tool === 'text' ? ' tx' : tool === 'erase' ? ' er' : ''), onPointerDown: onDown, onPointerMove: onMove, onPointerUp: onUp, onPointerCancel: onUp }),
4319
+ txt ? h('textarea', {
4320
+ ref: area,
4321
+ className: markLight(color) ? 'lt' : '',
4322
+ value: txt.v,
4323
+ placeholder: t('mkTextPh'),
4324
+ spellCheck: false,
4325
+ rows: 1,
4326
+ style: box,
4327
+ onChange: (e) => setTxt({ ...txt, v: e.target.value }),
4328
+ onBlur: () => endText(true),
4329
+ }) : null,
4330
+ h('div', { className: 'ddp-mk-top' },
4331
+ h('div', { className: 'ddp-mk-tools', onMouseDown: keep },
4332
+ ...MARK_TOOLS.map(([id, icon, label, key]) => btn(id, icon, `${t(label)} (${key.toUpperCase()})`, tool === id, () => { setTool(id); setPal(false) })),
4333
+ h('span', { className: 'ddp-sep' }),
4334
+ h('div', { className: 'ddp-mk-color' },
4335
+ h('button', { type: 'button', className: 'ddp-btn' + (pal ? ' sel' : ''), title: t('mkColor'), 'aria-label': t('mkColor'), 'aria-expanded': pal, onClick: () => setPal(!pal) }, h('i', { style: { background: color } })),
4336
+ pal ? h('div', { className: 'ddp-mk-pal' }, MARK_COLORS.map((c) => h('button', {
4337
+ key: c, type: 'button', className: c === color ? 'sel' : '', style: { background: c }, title: c, 'aria-label': c, onClick: () => { setColor(c); setPal(false) },
4338
+ }))) : null),
4339
+ h('div', { className: 'ddp-mk-sz', role: 'group', 'aria-label': t('mkSize') },
4340
+ MARK_WIDTH.map((_, i) => h('button', { key: i, type: 'button', className: 'ddp-btn' + (i === size ? ' sel' : ''), title: t('mkSize'), 'aria-label': `${t('mkSize')} ${i + 1}`, 'aria-pressed': i === size, onClick: () => setSize(i) },
4341
+ h('i', { style: { width: 4 + i * 3 + 'px', height: 4 + i * 3 + 'px' } })))),
4342
+ h('span', { className: 'ddp-sep' }),
4343
+ btn('undo', 'undo', `${t('mkUndo')} (${MOD}Z)`, false, undo, hist.i === 0),
4344
+ btn('redo', 'redo', `${t('mkRedo')} (${MOD === '⌘' ? '⇧⌘Z' : 'Ctrl+Y'})`, false, redo, hist.i >= hist.list.length - 1),
4345
+ btn('clear', 'trash', t('mkClear'), false, clear, !shapes.length && !txt))),
4346
+ coach && !shapes.length && !txt ? h('div', { className: 'ddp-mk-coach', 'aria-hidden': true },
4347
+ h('b', null, t('mkCoach')),
4348
+ h('ol', null, h('li', null, t('mkCoach1')), h('li', null, t('mkCoach2')), h('li', null, t('mkCoach3')))) : null,
4349
+ h('div', { className: 'ddp-mk-bot' },
4350
+ h('div', { className: 'ddp-mk-bar', onMouseDown: keep },
4351
+ // 现在这个工具怎么用(只认图标的人也知道该怎么画)
4352
+ sending ? h('span', null, t('mkSending')) : h('span', { title: t('mkHow_' + tool) }, h('b', null, t(MARK_TOOLS.find((x) => x[0] === tool)[2])), t('mkHow_' + tool)),
4353
+ h('button', { type: 'button', disabled: sending, onClick: onCancel }, t('mkCancel')),
4354
+ onCopy ? h('button', { type: 'button', className: 'cp', title: `${t('mkCopyTip')} (${MOD}C)`, disabled: sending, onClick: copy }, t('mkCopy')) : null,
4355
+ h('button', { type: 'button', className: 'go' + (shapes.length || (txt && txt.v.trim()) ? '' : ' idle'), disabled: sending, onClick: send }, t('mkSend')))))
4356
+ }
4357
+
2947
4358
  /** 设计规范面板:按组列出变量,点一个放进输入框 */
2948
4359
  function TokensPanel({ t, tokens, onPick, onAsk, onRef, onStyles, onSystems, onClose }) {
2949
4360
  const groups = {}
@@ -3059,17 +4470,71 @@ window.__ModuleLoader__.load({
3059
4470
  h('button', { className: 'go', onClick: onSave, disabled: !n || !write.ok, title: write.tip }, h('span', null, t('styleSave')))))
3060
4471
  }
3061
4472
 
3062
- /** 一排风格卡:每张是个小样(底色、标题字、卡片、主色按钮),点了交给 onPick */
4473
+ /** 风格样板:用这个风格画的一小屏(顶栏、标题、按钮、商品卡、数据卡);卡片上缩小着放,点开是原大 */
4474
+ function StyleSample({ t, x }) {
4475
+ const c = x.c
4476
+ const L = styleLook(x)
4477
+ const muted = { color: c.muted }
4478
+ return h('div', { className: 'ddp-sam', style: { background: c.bg, backgroundImage: x.bgImage || 'none', color: c.text, fontFamily: SANS } },
4479
+ h('div', { className: 'top' },
4480
+ h('i', { className: 'lg', style: { background: c.primary, borderRadius: Math.min(x.radius, 5) } }),
4481
+ h('b', { style: L.head }, t('samBrand')),
4482
+ h('span', { className: 'nv', style: muted }, t('samNav')),
4483
+ h('i', { className: 'av', style: { background: L.img } })),
4484
+ h('h5', { style: L.head }, t('samHead')),
4485
+ h('p', { style: muted }, t('samSub')),
4486
+ h('div', { className: 'acts' }, h('span', { className: 'bt', style: L.btn }, t('samGo')), h('span', { style: { color: c.link } }, t('samMore'))),
4487
+ h('div', { className: 'cards' },
4488
+ h('div', { className: 'cd', style: L.card },
4489
+ h('i', { className: 'im', style: { background: L.img, borderRadius: Math.max(0, x.radius - 6) } }),
4490
+ h('b', null, t('samItem')),
4491
+ h('span', { style: muted }, t('samMeta')),
4492
+ h('div', { className: 'pr' }, h('b', { style: { fontFamily: x.head } }, t('samPrice')), h('span', { className: 'chip', style: L.chip }, t('samTag')))),
4493
+ h('div', { className: 'cd', style: L.card },
4494
+ h('span', { style: muted }, t('samStat')),
4495
+ h('b', { className: 'num', style: L.head }, '86%'),
4496
+ h('i', { className: 'bar', style: { background: L.track } }, h('i', { style: { background: c.primary } })),
4497
+ h('span', { style: muted }, t('samStatMeta')))))
4498
+ }
4499
+
4500
+ /** 一排风格卡:每张是缩小的样板;点一张看原大样板(左右翻),「用这个风格」才交给 onPick */
3063
4501
  function StyleCards({ t, onPick }) {
3064
4502
  const en = t('lang') === 'en'
3065
- return h('div', { className: 'ddp-styles' }, ...STYLES.map((x) => h('button', { key: x.id, className: 'ddp-sc', title: en ? x.den : x.dzh, onClick: () => onPick(x) },
3066
- h('span', { className: 'pv', style: { background: x.c.bg, backgroundImage: x.bgImage || 'none', color: x.c.text } },
3067
- h('b', { style: { fontFamily: x.head, fontWeight: x.w } }, en ? 'Aa' : '字'),
3068
- h('i', { className: 'bt', style: { background: x.c.primary, borderRadius: Math.min(x.radius, 6), boxShadow: x.id === 'playful' ? `1.5px 1.5px 0 ${x.c.line}` : x.id === 'cyber' ? `0 0 6px ${x.c.primary}` : x.id === 'retro' ? `0 1.5px 0 ${x.c.text}` : 'none', outline: x.id === 'playful' ? `1px solid ${x.c.line}` : 'none' } }),
3069
- h('i', { className: 'cd', style: { background: x.id === 'glass' ? 'rgba(255,255,255,.6)' : x.c.surface, borderRadius: Math.min(x.radius, 8) / 2 + 2,
3070
- boxShadow: x.id === 'soft' ? '2px 2px 5px #C8CED6, -2px -2px 5px #FFFFFF' : x.id === 'playful' ? `2px 2px 0 ${x.c.line}` : x.id === 'editorial' ? `inset 0 1px 0 ${x.c.line}`
3071
- : x.id === 'guochao' ? `inset 0 0 0 1px ${x.c.accent}` : x.id === 'retro' ? `0 2px 0 ${x.c.text}` : `0 0 0 .5px ${x.c.line}` } },
3072
- h('i', { className: 'ln', style: { background: x.c.text } }), h('i', { className: 'ln s', style: { background: x.c.muted } }))),
4503
+ const [on, setOn] = useState(-1) // 正在看样板的那个;-1 是看全部
4504
+ const was = useRef(-1)
4505
+ const box = useRef(null)
4506
+ useEffect(() => {
4507
+ // 进样板:焦点给「用这个风格」;回全部:焦点回到刚才那张卡
4508
+ const el = box.current
4509
+ if (!el) return
4510
+ const f = on >= 0 ? el.querySelector('.ddp-go') : was.current >= 0 ? el.querySelectorAll('.ddp-sc')[was.current] : null
4511
+ if (f) f.focus({ preventScroll: true })
4512
+ // 列表和样板高矮不一样,外面滚动的位置会变:把样板 / 刚才那张卡滚进来
4513
+ if (was.current >= 0 || on >= 0) (on >= 0 ? el : f || el).scrollIntoView({ block: 'nearest' })
4514
+ if (on >= 0) was.current = on
4515
+ }, [on])
4516
+ const n = STYLES.length
4517
+ if (on >= 0) {
4518
+ const x = STYLES[on]
4519
+ const go = (d) => setOn((on + d + n) % n)
4520
+ const onKey = (e) => {
4521
+ if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { e.preventDefault(); go(e.key === 'ArrowLeft' ? -1 : 1) } else if (e.key === 'Escape') { e.stopPropagation(); setOn(-1) }
4522
+ }
4523
+ return h('div', { className: 'ddp-samview', ref: box, onKeyDown: onKey },
4524
+ h('button', { className: 'ddp-btn all', onClick: () => setOn(-1) }, h(I.back), h('span', null, t('stylesAll'))),
4525
+ h(StyleSample, { key: x.id, t, x }),
4526
+ h('div', { className: 'meta' },
4527
+ h('b', null, en ? x.en : x.zh),
4528
+ h('span', { className: 'sw' }, ...['bg', 'surface', 'text', 'primary', 'accent'].map((k) => h('i', { key: k, title: x.c[k], style: { background: x.c[k] } }))),
4529
+ h('p', null, en ? x.den : x.dzh)),
4530
+ h('div', { className: 'row' },
4531
+ h('button', { className: 'ddp-btn', title: t('stylePrev'), onClick: () => go(-1) }, h(I.back)),
4532
+ h('em', null, `${on + 1} / ${n}`),
4533
+ h('button', { className: 'ddp-btn nx', title: t('styleNext'), onClick: () => go(1) }, h(I.back)),
4534
+ h('button', { className: 'ddp-go', onClick: () => onPick(x) }, t('styleUse'))))
4535
+ }
4536
+ return h('div', { className: 'ddp-styles', ref: box }, ...STYLES.map((x, i) => h('button', { key: x.id, className: 'ddp-sc', title: en ? x.den : x.dzh, onClick: () => setOn(i) },
4537
+ h('span', { className: 'pv', 'aria-hidden': 'true' }, h(StyleSample, { t, x })),
3073
4538
  h('span', { className: 'nm' }, en ? x.en : x.zh))))
3074
4539
  }
3075
4540
 
@@ -3103,7 +4568,7 @@ window.__ModuleLoader__.load({
3103
4568
  /** 拖过的滑块 → 交给模型的一段字 */
3104
4569
  function formatTweaks(t, list, live) {
3105
4570
  const rows = list.filter((k) => hasOwn(live, k.id)).map((k) => `- ${k.label}:${tweakShown(k, k.value)} → ${tweakShown(k, live[k.id])}(${k.var || k.attr})`)
3106
- return t('twAskHead') + '\n' + rows.join('\n') + '\n'
4571
+ return t('twAskHead') + '\n' + rows.join('\n') + '\n' + t('twAskTail') + '\n'
3107
4572
  }
3108
4573
 
3109
4574
  /** 调节滑块面板:模型给这套设计做的几个维度(tweaks.json),拖的时候所有屏一起变,定下来写回文件或交给模型 */
@@ -3151,6 +4616,26 @@ window.__ModuleLoader__.load({
3151
4616
  }
3152
4617
 
3153
4618
  /** 品牌设计系统库:从用户的网站 / 项目提取的几套,默认那套以后做新设计都照它来。rev 变了(模型干完一轮)重新列 */
4619
+ /** 输入框旁的「选文件夹」:桌面版弹系统的选文件夹窗口,选了填进去;网页版没有这个按钮 */
4620
+ function DirButton({ t, onPicked, onSay }) {
4621
+ const dp = dirPicker()
4622
+ const [busy, setBusy] = useState(false)
4623
+ if (!dp) return null
4624
+ const pick = async () => {
4625
+ if (busy) return
4626
+ setBusy(true)
4627
+ try {
4628
+ const p = await dp.pick()
4629
+ if (p) onPicked(p)
4630
+ } catch (e) {
4631
+ if (onSay) onSay(t('pickDirFail', { msg: e && e.message ? e.message : String(e) }))
4632
+ } finally {
4633
+ setBusy(false)
4634
+ }
4635
+ }
4636
+ return h('button', { type: 'button', className: 'ddp-dirbtn', title: t('pickDir'), 'aria-label': t('pickDir'), disabled: busy, onClick: pick }, h(I.folder))
4637
+ }
4638
+
3154
4639
  function SystemsPanel({ t, rev, hasDesign, onExtract, onApply, onSay, onClose }) {
3155
4640
  const [data, setData] = useState(null)
3156
4641
  const [err, setErr] = useState('')
@@ -3186,6 +4671,7 @@ window.__ModuleLoader__.load({
3186
4671
  h('footer', null,
3187
4672
  h('form', { onSubmit: go },
3188
4673
  h('input', { value: src, placeholder: t('sysPh'), 'aria-label': t('sysPh'), onChange: (e) => setSrc(e.target.value) }),
4674
+ h(DirButton, { t, onPicked: setSrc, onSay }),
3189
4675
  h('button', { type: 'submit', disabled: !src.trim() }, t('sysExtract')))))
3190
4676
  }
3191
4677
 
@@ -3193,25 +4679,56 @@ window.__ModuleLoader__.load({
3193
4679
  const FW_LABEL = { react: 'React + Vite', vue: 'Vue 3 + Vite', next: 'Next.js' }
3194
4680
 
3195
4681
  /** 转成代码:用什么做、放哪、转几屏;点了把一段话放进输入框,模型调 handoff_design 照交接包做,每屏做完 compare_design */
3196
- function HandoffPanel({ t, sid, root, fileTarget, count, cur, onGo, onClose }) {
4682
+ function HandoffPanel({ t, sid, root, fileTarget, count, cur, onGo, onSay, onClose }) {
3197
4683
  const [info, setInfo] = useState(null)
3198
4684
  const [fw, setFw] = useState('')
3199
4685
  const [dest, setDest] = useState('')
3200
4686
  const [one, setOne] = useState(false)
4687
+ const [moved, setMoved] = useState(null) // 选的文件夹不能直接放:{ dest, why }('nested' 在里面新建、'beside' 放设计目录旁边)
4688
+ const [checking, setChecking] = useState('') // 'busy' 正在问服务器这个文件夹;别的字是没问成的原因
4689
+ // 现在是哪套设计、第几次选:选文件夹的窗口开着时换了设计、或者又选了一次 / 手改了路径,旧的结果作废
4690
+ // hand:用户自己点过框架(换设计前一直听用户的,不再按选的文件夹自动换)
4691
+ const live = useRef({ root, seq: 0, hand: false })
4692
+ live.current.root = root
3201
4693
  // 换了会话或设计目录就重新问:先清掉旧的(别把上一套设计的目标发出去)
3202
4694
  useEffect(() => {
3203
4695
  let alive = true
4696
+ live.current.seq++
4697
+ live.current.hand = false
3204
4698
  setInfo(null)
4699
+ setFw('') // 上一套设计的框架(比如已经没有的「跟项目一样」)别留着
3205
4700
  setDest('')
4701
+ setMoved(null)
4702
+ setChecking('')
3206
4703
  if (!root) return () => { alive = false }
3207
4704
  getJson(`project?session=${encodeURIComponent(sid)}`).then((d) => {
3208
4705
  if (!alive) return
3209
4706
  setInfo(d)
3210
- setFw(d.project && d.project.pkg && d.project.framework ? 'same' : 'react')
4707
+ setFw(d.fw || 'react')
3211
4708
  setDest(d.root === root ? d.dest || '' : '')
3212
- }, () => { if (alive) setInfo({}) })
4709
+ }, () => { if (alive) { setInfo({}); setFw('react') } })
3213
4710
  return () => { alive = false }
3214
4711
  }, [sid, root])
4712
+ // 选了文件夹:问一下它是不是项目——是就放进去、跟着它的框架;不是而且有东西,就在里面新建一个目录(下面说一声,能改)
4713
+ const pickDest = (dir) => {
4714
+ if (live.current.root !== root) return
4715
+ const my = ++live.current.seq
4716
+ setDest(dir)
4717
+ setMoved(null)
4718
+ setChecking('busy') // 问清楚之前「开始」点不了:别把没调整过的目录发出去
4719
+ getJson(`project?session=${encodeURIComponent(sid)}&dest=${encodeURIComponent(dir)}`).then((d) => {
4720
+ if (my !== live.current.seq) return
4721
+ if (!d || d.root !== root || typeof d.dest !== 'string') return setChecking(t('hoCheckFail', { msg: '?' }))
4722
+ setChecking('')
4723
+ setInfo(d)
4724
+ setDest(d.dest)
4725
+ setMoved(d.why ? { dest: d.dest, why: d.why } : null)
4726
+ // 框架按服务器给的默认(是项目就跟它一样,纯 HTML 站点就纯 HTML);用户自己点过就听用户的,除非点的「跟项目一样」没了
4727
+ const same = !!(d.project && d.project.pkg && d.project.framework)
4728
+ setFw((f) => (live.current.hand && (f !== 'same' || same) ? f : d.fw || 'react'))
4729
+ }, (e) => { if (my === live.current.seq) setChecking(t('hoCheckFail', { msg: (e && e.message) || '?' })) })
4730
+ }
4731
+ const typeDest = (v) => { live.current.seq++; setChecking(''); setDest(v) }
3215
4732
  const head = h('header', null, h(I.code), h('b', null, t('handoff')), h('button', { className: 'ddp-btn', title: '×', onClick: onClose }, h(I.close)))
3216
4733
  const box = (...kids) => h('div', { className: 'ddp-tokens ddp-style ddp-ho', onPointerDown: (e) => e.stopPropagation() }, head, ...kids)
3217
4734
  if (!fileTarget) return box(h('div', { className: 'bd' }, h('div', { className: 'none' }, t('hoProxy'))))
@@ -3224,15 +4741,19 @@ window.__ModuleLoader__.load({
3224
4741
  h('p', { className: 'tip' }, t('hoTip')),
3225
4742
  h('div', { className: 'bd' },
3226
4743
  h('div', { className: 'row' }, h('label', null, t('hoFw')),
3227
- ...opts.map(([k, l]) => h('button', { key: k, className: 'opt' + (fw === k ? ' on' : ''), onClick: () => setFw(k) }, h('i'), h('span', null, l)))),
4744
+ ...opts.map(([k, l]) => h('button', { key: k, className: 'opt' + (fw === k ? ' on' : ''), onClick: () => { live.current.hand = true; setFw(k) } }, h('i'), h('span', null, l)))),
3228
4745
  h('div', { className: 'row' }, h('label', null, t('hoDest')),
3229
- h('input', { type: 'text', value: dest, spellCheck: false, 'aria-label': t('hoDest'), onChange: (e) => setDest(e.target.value) })),
4746
+ h('div', { className: 'pathrow' },
4747
+ h('input', { type: 'text', value: dest, spellCheck: false, 'aria-label': t('hoDest'), onChange: (e) => typeDest(e.target.value) }),
4748
+ h(DirButton, { t, onPicked: pickDest, onSay })),
4749
+ checking ? h('p', { className: 'moved' + (checking === 'busy' ? '' : ' bad') }, checking === 'busy' ? t('hoChecking') : checking)
4750
+ : moved && moved.dest === dest ? h('p', { className: 'moved' }, t(moved.why === 'beside' ? 'hoBeside' : 'hoMoved', { name: moved.dest.split(/[\\/]/).pop() })) : null),
3230
4751
  count > 1 && cur ? h('div', { className: 'row' }, h('label', null, t('hoScope')),
3231
4752
  h('div', { className: 'seg' },
3232
4753
  h('button', { className: one ? '' : 'on', onClick: () => setOne(false) }, t('hoAll', { n: count })),
3233
4754
  h('button', { className: one ? 'on' : '', onClick: () => setOne(true) }, t('hoOne')))) : null),
3234
4755
  h('footer', null,
3235
- h('button', { className: 'go', disabled: !fw || !dest.trim(), onClick: () => onGo({ fw, label, dest: dest.trim(), only: one && count > 1 ? cur : '' }) }, h(I.code), h('span', null, t('hoGo')))))
4756
+ h('button', { className: 'go', disabled: !fw || !dest.trim() || checking === 'busy', onClick: () => onGo({ fw, label, dest: dest.trim(), only: one && count > 1 ? cur : '' }) }, h(I.code), h('span', null, t('hoGo')))))
3236
4757
  }
3237
4758
 
3238
4759
  /** 菜单开着时:点外面、按 Esc 关掉 */
@@ -3559,8 +5080,11 @@ window.__ModuleLoader__.load({
3559
5080
  const [sel, setSel] = useState('')
3560
5081
  const [panel, setPanel] = useState(false)
3561
5082
  const [styles, setStyles] = useState(false) // 换风格面板
3562
- const [dropping, setDropping] = useState(false) // 正拖着图片进来
5083
+ const [dropping, setDropping] = useState('') // 正拖着文件进来:dragKind 给的种类
5084
+ const [dropSoft, setDropSoft] = useState(false) // 拖动在预览页面里:蒙层只是提示、不挡事件(松手由页面转告)
5085
+ const softTimer = useRef(0)
3563
5086
  const refInput = useRef(null)
5087
+ const pageInput = useRef(null)
3564
5088
  const [exMenu, setExMenu] = useState(false)
3565
5089
  const [more, setMore] = useState(false) // 窄的时候收起来的几个按钮
3566
5090
  const [hist, setHist] = useState(false) // 版本面板
@@ -3577,6 +5101,10 @@ window.__ModuleLoader__.load({
3577
5101
  const [reveal, setReveal] = useState(null) // 画布上要露出来的那一屏 { key, n }(体检里点了一条)
3578
5102
  const [demo, setDemo] = useState(null) // 演示:{ stack: [屏…], layers: [{ id, key, dir, shown }] }
3579
5103
  const [pane, setPane] = useState('') // 右边的面板:tweaks 调节滑块、systems 品牌设计系统、handoff 转成代码
5104
+ const [mark, setMark] = useState(false) // 画笔标注
5105
+ const [markBusy, setMarkBusy] = useState(false) // 正在把标注拼成图
5106
+ const [markSize, setMarkSize] = useState(null) // 开标注那一刻台面的大小:单页看网页时按它定住页面,台面变了整体缩放、不重排
5107
+ const markRun = useRef(0) // 每开 / 关一次标注加一:发送途中关掉(或关了又开)的,旧的那次作废
3580
5108
  const [tw, setTw] = useState({}) // 拖过的滑块:id → 值(还没写进文件)
3581
5109
  const apis = useRef(new Map()) // 屏 → 截图用的接口
3582
5110
  const cmpRef = useRef(null)
@@ -3693,6 +5221,36 @@ window.__ModuleLoader__.load({
3693
5221
  toastTimer.current = setTimeout(() => setToast(''), act ? 6000 : 2600)
3694
5222
  }
3695
5223
 
5224
+ // ── 打开本地的 HTML:「浏览…」选一个,或者把文件 / 文件夹拖进来(桌面版才拿得到路径)──
5225
+ const openLocal = async (p) => {
5226
+ if (!p) return say(t('pathNone'))
5227
+ try {
5228
+ await openTarget(sid, p)
5229
+ setEditing(false)
5230
+ } catch (e) {
5231
+ say(t('openFailed', { msg: e.message }))
5232
+ }
5233
+ }
5234
+ const browse = hostPaths() ? () => { if (pageInput.current) pageInput.current.click() } : null
5235
+ // 拖着文件在预览页面里:iframe 把拖动吃掉了(单页铺满时外面收不到 dragenter),页面转告过来。
5236
+ // 蒙层只当提示、不挡事件(不然页面自己的上传区就收不到后面的拖动了);松手也是页面接住、把文件转过来
5237
+ const pageDrag = (d) => {
5238
+ clearTimeout(softTimer.current)
5239
+ if (d.type === 'dragIn') {
5240
+ const list = (Array.isArray(d.items) ? d.items : []).slice(0, 50).filter((x) => x && typeof x.kind === 'string' && typeof x.type === 'string')
5241
+ setDropSoft(true)
5242
+ setDropping(dragKind({ items: list }))
5243
+ // 拖着时页面隔一会儿就报一次;一阵没报(拖出去了、按了 Esc)就收起
5244
+ softTimer.current = setTimeout(() => setDropping(''), 900)
5245
+ return
5246
+ }
5247
+ setDropping('')
5248
+ if (d.type !== 'dropIn') return // dragOut:到了页面自己的上传区
5249
+ const files = (Array.isArray(d.files) ? d.files : []).slice(0, 20).filter((x) => x && x.f && typeof x.f.name === 'string')
5250
+ onDropped({ items: files.map((x) => ({ kind: 'file', getAsFile: () => x.f, webkitGetAsEntry: () => ({ isDirectory: !!x.dir }) })) })
5251
+ }
5252
+ useEffect(() => () => clearTimeout(softTimer.current), [])
5253
+
3696
5254
  const showFlash = (text) => {
3697
5255
  setFlash(text)
3698
5256
  clearTimeout(flashTimer.current)
@@ -3760,7 +5318,27 @@ window.__ModuleLoader__.load({
3760
5318
  return () => { dead = true }
3761
5319
  }, [hist, sid, vrev, origin])
3762
5320
 
3763
- const insert = (text, done = 'inserted') => {
5321
+ // 新版 DSH:我们的整段在输入框里放成一个短标签(悬停看要点,发出去模型拿到整段)。放进去了返回 true
5322
+ const chipIn = (text, done) => {
5323
+ const s = String(text).trim()
5324
+ const c = chipOf(s, t)
5325
+ const ia = props.inputActions
5326
+ if (!c || !chips || !ia || typeof ia.captureInsertion !== 'function') return false
5327
+ try {
5328
+ if (!chips.insert(sid, s, c.label, ia.captureInsertion(), chipSrc(c.i))) return false
5329
+ } catch (e) {
5330
+ console.warn('[dsh-design-studio] 插标签失败', e)
5331
+ return false
5332
+ }
5333
+ // 先把焦点从预览页拿回输入框,再让编辑器把光标放回标签后面(只 focus 元素,光标会跑到最前面)
5334
+ try { const el = composerEl(); if (el) { el.focus({ preventScroll: true }); const ed = el.__lexicalEditor; if (ed && typeof ed.focus === 'function') ed.focus() } } catch { /* 用户自己点一下输入框也行 */ }
5335
+ say(t(done))
5336
+ return true
5337
+ }
5338
+
5339
+ // copy:插不进输入框时退回复制到剪贴板(画笔标注不要:别动用户的剪贴板)。插进去了返回 true
5340
+ const insert = (text, done = 'inserted', copy = true) => {
5341
+ if (chipIn(text, done)) return true
3764
5342
  const ia = props.inputActions
3765
5343
  try {
3766
5344
  if (ia && typeof ia.insertText === 'function') {
@@ -3772,17 +5350,20 @@ window.__ModuleLoader__.load({
3772
5350
  const p = padInsert(text, ok ? all.slice(0, at) : null, ok ? all.slice(span.end) : null, at, done === 'inserted')
3773
5351
  if (ia.insertText(p.text, span)) {
3774
5352
  try { focusAfter(el, p.text, p.back) } catch { /* 用户自己点一下输入框也行 */ }
3775
- return say(t(done))
5353
+ say(t(done))
5354
+ return true
3776
5355
  }
3777
5356
  }
3778
5357
  } catch (e) {
3779
5358
  console.warn('[dsh-design-studio] insertText 失败', e)
3780
5359
  }
5360
+ if (!copy) return false
3781
5361
  if (navigator.clipboard) {
3782
5362
  navigator.clipboard.writeText(text).then(() => say(t('copied')), (e) => say(t('failed', { msg: e.message })))
3783
5363
  } else say(t('failed', { msg: 'no input' }))
3784
5364
  }
3785
5365
 
5366
+ // 点选的元素 / 区域:新版 DSH 放成标签,旧版照旧插成字
3786
5367
  const deliver = (payload, note) => {
3787
5368
  setPicking(false)
3788
5369
  insert(formatPick(payload, stRef.current) + (note || ''))
@@ -3875,7 +5456,8 @@ window.__ModuleLoader__.load({
3875
5456
 
3876
5457
  useEffect(() => {
3877
5458
  if (!dropping) return undefined
3878
- const off = () => setDropping(false)
5459
+ // 晚一拍再收蒙层:真拖放时这个捕获监听先跑,马上收的话 React 还没把 drop 交给蒙层,蒙层就没了(松手什么都不发生)
5460
+ const off = () => setTimeout(() => setDropping(''), 0)
3879
5461
  window.addEventListener('dragend', off, true)
3880
5462
  window.addEventListener('drop', off, true)
3881
5463
  return () => { window.removeEventListener('dragend', off, true); window.removeEventListener('drop', off, true) }
@@ -3958,8 +5540,8 @@ window.__ModuleLoader__.load({
3958
5540
  else change({ view: 'single' })
3959
5541
  }
3960
5542
 
3961
- const renderScreen = (sc, z, fill) => h(Device, { kind, w: size[0], h: size[1], fill, label: labelOf(sc), top: tops[ik(sc)] },
3962
- h(Screen, { t, remote,
5543
+ const renderScreen = (sc, z, fill) => h(Device, { kind, w: size[0], h: size[1], fill, label: labelOf(sc), top: tops[ik(sc)], os: meta.os },
5544
+ h(Screen, { t, remote, onPageDrag: pageDrag,
3963
5545
  src: sc.url,
3964
5546
  label: labelOf(sc),
3965
5547
  busy: busy && !sc.v,
@@ -4058,6 +5640,7 @@ window.__ModuleLoader__.load({
4058
5640
  kind,
4059
5641
  size,
4060
5642
  shots: shots.map((x, i) => ({ img: x.img, top: x.top, label: sp.labels[i] || x.title })),
5643
+ os: meta.os,
4061
5644
  title: sp.brand || (st && st.target && st.target.kind === 'file' ? String(st.target.file).split(/[\\/]/).slice(-2, -1)[0] : st.label) || '',
4062
5645
  sub: t('showSub', { n: shots.length, kind: t(KIND_KEY[kind]), date: ymd(new Date()) }),
4063
5646
  bg: colors.bg,
@@ -4072,7 +5655,7 @@ window.__ModuleLoader__.load({
4072
5655
  if (!sc) throw new Error('no screen')
4073
5656
  const base = prefix + sc.key.replace(/\.html?$/i, '') + suffix
4074
5657
  const { img, top } = await captureOf(sc, mode === 'long')
4075
- if (mode === 'frame') await saveImage(base, await composeOne(kind, size, img, labelOf(sc), top))
5658
+ if (mode === 'frame') await saveImage(base, await composeOne(kind, size, img, labelOf(sc), top, meta.os))
4076
5659
  else {
4077
5660
  const c = document.createElement('canvas')
4078
5661
  c.width = img.width
@@ -4233,6 +5816,208 @@ window.__ModuleLoader__.load({
4233
5816
  }
4234
5817
  const checkOff = !url || !!ver || !!demo || inMulti
4235
5818
  const styleOff = !url || !!ver || !!demo || inMulti || busy
5819
+
5820
+ // ── 画笔标注:画完把台面上看得到的几屏各截一张,按位置摆好,叠上画的东西,存进 refs/,路径放进输入框让模型用 read_image 看(不动剪贴板) ──
5821
+ const markOff = !url || !!ver || !!demo || inMulti
5822
+ const toggleMark = () => {
5823
+ markRun.current++
5824
+ if (mark) { setMark(false); return }
5825
+ setPicking(false)
5826
+ setTextEdit(false)
5827
+ setStyleEdit(false)
5828
+ setPanel(false)
5829
+ setStyles(false)
5830
+ setHist(false)
5831
+ setPhone(false)
5832
+ closeCheck()
5833
+ setPane('')
5834
+ // 之前点选 / 调样式留下的「已选」框别夹在标注里
5835
+ for (const a of apis.current.values()) if (a && a.unpin) a.unpin()
5836
+ setMarkSize(box.w && box.h ? { w: box.w, h: box.h } : null)
5837
+ setMark(true)
5838
+ }
5839
+ // 换了设计、看旧版本、演示:画的东西对不上了,收掉
5840
+ useEffect(() => { markRun.current++; setMark(false) }, [url, markOff])
5841
+ // 台面上现在看得到的每一屏:页面 iframe 的位置(台面坐标,画布缩放过的也对),一点没露出来的不要
5842
+ const markFrames = () => {
5843
+ const stage = stageRef.current
5844
+ if (!stage) return []
5845
+ const sr = stage.getBoundingClientRect()
5846
+ const out = []
5847
+ for (const sc of shown) {
5848
+ const api = apis.current.get(ik(sc))
5849
+ const f = api && typeof api.frame === 'function' ? api.frame() : null
5850
+ if (!f) continue
5851
+ const r = f.getBoundingClientRect()
5852
+ if (r.width < 8 || r.height < 8 || r.right <= sr.left || r.left >= sr.right || r.bottom <= sr.top || r.top >= sr.bottom) continue
5853
+ if (typeof f.checkVisibility === 'function' && !f.checkVisibility({ opacityProperty: true, visibilityProperty: true })) continue
5854
+ out.push({ key: ik(sc), sc, api, f, l: r.left - sr.left, t: r.top - sr.top, w: r.width, h: r.height, z: r.width / Math.max(1, f.offsetWidth) })
5855
+ }
5856
+ return out
5857
+ }
5858
+ const MARK_SHOTS = 40
5859
+ // 截到的是哪一页(只用来起名、写进提示):本地文件按文件名;开发服务器按路由(/account);单页应用的 #/account 路由带上
5860
+ const markPageOf = (sc, href) => {
5861
+ let key = pageOf(sc, href).key
5862
+ try {
5863
+ const u = new URL(href)
5864
+ if (u.origin !== new URL(sc.url).origin) return key
5865
+ if (key === sc.key && st && st.target && st.target.kind === 'proxy') key = decodeURIComponent(u.pathname) || sc.key
5866
+ if (/^#!?\//.test(u.hash)) key += decodeURIComponent(u.hash)
5867
+ } catch { /* 地址不对 */ }
5868
+ return key
5869
+ }
5870
+ // 把画的拼成一张图:画到的那几屏按现在的位置截图,叠上画的(交给模型、复制共用)
5871
+ const buildMark = async (list, stage) => {
5872
+ const sr = stage.getBoundingClientRect()
5873
+ const all = markFrames().filter((x) => x.api.ready())
5874
+ const ov = (a, b) => a.l < b.r && a.r > b.l && a.t < b.b && a.b > b.t
5875
+ const boxOf = (x) => ({ l: x.l, t: x.t, r: x.l + x.w, b: x.t + x.h })
5876
+ // 画到的那几屏(一屏都没碰到:画在屏外空白处的,就把看得到的都拼上)
5877
+ const marks = list.map(markBox)
5878
+ let shots = all.filter((x) => marks.some((m) => ov(boxOf(x), m)))
5879
+ if (!shots.length) shots = all
5880
+ // 裁到有东西的那块(画到的屏 + 画的),四周留一点;落在这块里的别的屏也拼上,免得图上空一块
5881
+ const pad = 16
5882
+ const span = (bs) => ({
5883
+ l: Math.max(0, Math.min(...bs.map((b) => b.l)) - pad),
5884
+ t: Math.max(0, Math.min(...bs.map((b) => b.t)) - pad),
5885
+ r: Math.min(sr.width, Math.max(...bs.map((b) => b.r)) + pad),
5886
+ b: Math.min(sr.height, Math.max(...bs.map((b) => b.b)) + pad),
5887
+ })
5888
+ const crop = span([...shots.map(boxOf), ...marks])
5889
+ shots = all.filter((x) => shots.includes(x) || ov(boxOf(x), crop))
5890
+ const { l: cl, t: ct, r: cr, b: cb } = crop
5891
+ if (!(cr - cl >= 1 && cb - ct >= 1)) throw new Error('empty')
5892
+ // 清晰度:至少按屏幕像素;画布缩小着看的,按页面原来的大小截(最多 4 倍),整张不超过 4096
5893
+ const zMin = Math.min(1, ...shots.map((x) => x.z))
5894
+ const k = Math.min(4096 / Math.max(cr - cl, cb - ct), Math.max(Math.min(2, Math.max(1, window.devicePixelRatio || 1)), Math.min(4, 1 / Math.max(0.05, zMin))))
5895
+ const c = document.createElement('canvas')
5896
+ c.width = Math.max(1, Math.round((cr - cl) * k))
5897
+ c.height = Math.max(1, Math.round((cb - ct) * k))
5898
+ const ctx = c.getContext('2d')
5899
+ let bg = ''
5900
+ for (let el = stage; el && !bg; el = el.parentElement) {
5901
+ const v = getComputedStyle(el).backgroundColor
5902
+ if (v && !/^(transparent|rgba\(.*,\s*0\))$/.test(v)) bg = v
5903
+ }
5904
+ ctx.fillStyle = bg || '#f4f4f5'
5905
+ ctx.fillRect(0, 0, c.width, c.height)
5906
+ if (shots.length > MARK_SHOTS) say(t('mkMany', { n: MARK_SHOTS }))
5907
+ const names = []
5908
+ for (const [i, x] of shots.entries()) {
5909
+ const X = (x.l - cl) * k
5910
+ const Y = (x.t - ct) * k
5911
+ // 手机框的圆角:往上找一层裁圆角的
5912
+ let rad = 0
5913
+ for (let el = x.f.parentElement, j = 0; el && el !== stage && j < 4 && !rad; el = el.parentElement, j++) {
5914
+ const cs = getComputedStyle(el)
5915
+ const v = parseFloat(cs.borderTopLeftRadius) || 0
5916
+ if (v && cs.overflow !== 'visible' && el.offsetWidth) rad = v * (el.getBoundingClientRect().width / el.offsetWidth)
5917
+ }
5918
+ ctx.save()
5919
+ ctx.beginPath()
5920
+ if (rad && typeof ctx.roundRect === 'function') ctx.roundRect(X, Y, x.w * k, x.h * k, rad * k)
5921
+ else ctx.rect(X, Y, x.w * k, x.h * k)
5922
+ ctx.clip()
5923
+ let name = x.sc.key
5924
+ try {
5925
+ if (i >= MARK_SHOTS) throw new Error('too many')
5926
+ const cap = await x.api.capture({ full: false, scale: Math.min(3, Math.max(1, x.z * k)) })
5927
+ // 页面里点链接跳到别的页了:按真正截到的那页起名
5928
+ if (cap.href) name = markPageOf(x.sc, cap.href)
5929
+ const img = await loadImg(cap.png)
5930
+ ctx.fillStyle = '#fff'
5931
+ ctx.fillRect(X, Y, x.w * k, x.h * k)
5932
+ ctx.drawImage(img, X, Y, cap.w * x.z * k, cap.h * x.z * k)
5933
+ } catch (e) {
5934
+ // 这一屏截不下来:留个灰块,画的东西还在
5935
+ if (i < MARK_SHOTS) console.warn('[dsh-design-studio] 标注截图失败', x.sc.key, e)
5936
+ ctx.fillStyle = 'rgba(127,127,127,.18)'
5937
+ ctx.fillRect(X, Y, x.w * k, x.h * k)
5938
+ }
5939
+ ctx.restore()
5940
+ if (!names.includes(name)) names.push(name)
5941
+ }
5942
+ for (const s of list) drawMark(ctx, s, k, cl, ct)
5943
+ return { c, names, blob: await canvasBlob(c) }
5944
+ }
5945
+ const sendMark = async (list) => {
5946
+ if (markBusy) return
5947
+ if (!list.length) { say(t('mkEmpty')); return }
5948
+ const stage = stageRef.current
5949
+ if (!stage) return
5950
+ const url0 = url
5951
+ const run0 = markRun.current
5952
+ const stale = () => stRef.current.url !== url0 || markRun.current !== run0
5953
+ const root0 = st && st.target && st.target.kind === 'file' ? st.target.root : null
5954
+ setMarkBusy(true)
5955
+ try {
5956
+ const { c, names, blob } = await buildMark(list, stage)
5957
+ // 拼图的时候换了设计、关了标注:这张图对不上了,别交
5958
+ if (stale()) { if (stRef.current.url !== url0) say(t('mkMoved')); return }
5959
+ if (!names.length && curScreen) names.push(curScreen.key)
5960
+ const stem = String(names[0] || 'page').replace(/\.html?(?=$|#)/i, '').split(/[/#!]+/).filter(Boolean).pop() || 'page'
5961
+ const body = { session: sid, name: `markup-${stem}`, data: await blobB64(blob) }
5962
+ if (root0 !== null) body.root = root0
5963
+ let saved
5964
+ try { saved = await post('ref', body) } catch (e) {
5965
+ if (/moved/.test(e.message)) { say(t('mkMoved')); return }
5966
+ throw e
5967
+ }
5968
+ if (stale()) { if (stRef.current.url !== url0) say(t('mkMoved')); return }
5969
+ keepThumb(saved.path, c)
5970
+ const where = (names.length ? names : [st.label || '']).join(t('sep'))
5971
+ // 写在图上的字也用文字带上:模型看图看漏了、或者用的模型看不了图,也知道要改什么
5972
+ const notes = list.filter((x) => x.t === 'text').map((x) => t('mkQuote', { v: x.v.replace(/\s*\n\s*/g, ' ') })).join(t('sep'))
5973
+ // 插不进输入框:标注层留着(画的还在),提示再点一次;不退回剪贴板
5974
+ if (insert(t('mkAsk', { path: saved.path, where, notes: notes ? t('mkNotes', { notes }) : '' }), 'mkIn', false)) setMark(false)
5975
+ else say(t('mkNoInput'))
5976
+ } catch (e) {
5977
+ say(t('mkFail', { msg: e.message }))
5978
+ } finally {
5979
+ setMarkBusy(false)
5980
+ }
5981
+ }
5982
+ // 复制:同一张图放进剪贴板(issue #6)。点按钮这一下就把「等图拼好」交给剪贴板——拼完再写,
5983
+ // Safari 这类会说不是用户点的、不让写;不认这种写法的再用拼好的图写一次
5984
+ const copyMark = (list) => {
5985
+ if (markBusy) return
5986
+ if (!list.length) { say(t('mkEmpty')); return }
5987
+ const stage = stageRef.current
5988
+ if (!stage) return
5989
+ const url0 = url
5990
+ const run0 = markRun.current
5991
+ const stale = () => stRef.current.url !== url0 || markRun.current !== run0
5992
+ const STALE = 'ddp-mark-stale'
5993
+ setMarkBusy(true)
5994
+ const made = buildMark(list, stage).then((r) => { if (stale()) throw new Error(STALE); return r })
5995
+ const png = made.then((r) => r.blob)
5996
+ png.catch(() => {}) // 结果在下面看
5997
+ let wrote
5998
+ try { wrote = navigator.clipboard.write([new ClipboardItem({ 'image/png': png })]) } catch (e) { wrote = Promise.reject(e) }
5999
+ wrote.catch(() => {})
6000
+ ;(async () => {
6001
+ try {
6002
+ const r = await made
6003
+ if (!(await wrote.then(() => true, () => false))) {
6004
+ // 等写的工夫里取消了、换了设计:别再把这张旧图写进去
6005
+ if (stale()) { if (stRef.current.url !== url0) say(t('mkMoved')); return }
6006
+ if (!(await copyImage(r.blob))) { say(t('mkCopyFail')); return }
6007
+ }
6008
+ if (stale()) return
6009
+ say(t('mkCopied'))
6010
+ setMark(false)
6011
+ } catch (e) {
6012
+ if (e && e.message === STALE) { if (stRef.current.url !== url0) say(t('mkMoved')) } else {
6013
+ console.warn('[dsh-design-studio] 标注图复制失败', e)
6014
+ say(t('mkCopyFail'))
6015
+ }
6016
+ } finally {
6017
+ setMarkBusy(false)
6018
+ }
6019
+ })()
6020
+ }
4236
6021
  useEffect(() => { if (styleOff) setStyleEdit(false) }, [styleOff])
4237
6022
  autoCk.current = { ck, off: checkOff, run: runCheck, list: checkList, measure }
4238
6023
  useEffect(() => { if (checkOff && ck) closeCheck() }, [checkOff])
@@ -4275,23 +6060,24 @@ window.__ModuleLoader__.load({
4275
6060
  const togglePhone = () => { setPhone(!phone); setPanel(false); setStyles(false); setHist(false); closeCheck(); setStyleEdit(false) }
4276
6061
  const reload = () => setReloadSeq((x) => x + 1)
4277
6062
 
4278
- const bar = h('div', { className: 'ddp-bar' },
6063
+ const bar = h('div', { className: 'ddp-bar' + (editing ? ' editing' : '') },
4279
6064
  editing || !url
4280
- ? 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) })
6065
+ ? 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), onBrowse: browse })
4281
6066
  : h('div', { className: 'ddp-addr', title: t('editAddr'), onClick: () => setEditing(true) },
4282
6067
  h('i', { className: 'ddp-dot' + (busy ? ' wait' : '') }),
4283
6068
  h('span', null, canvas && screens.length > 1 ? `${st.label} · ${screens.length}` : st.label)),
4284
- h('button', { className: 'ddp-btn pick' + (picking ? ' on' : ''), title: t('pickTip'), disabled: !url || !!ver || !!demo, onClick: togglePick },
6069
+ h('button', { className: 'ddp-btn pick' + (picking ? ' on' : ''), title: t('pickTip'), disabled: !url || !!ver || !!demo || mark, onClick: togglePick },
4285
6070
  h(I.pick), h('span', null, picking ? t('picking') : t('pick'))),
4286
- h('button', { className: 'ddp-btn' + (textEdit ? ' on' : ''), title: t('textEdit'), disabled: !url || !!ver || !!demo || busy, onClick: toggleText }, h(I.text)),
4287
- h('button', { className: 'ddp-btn' + (styleEdit ? ' on' : ''), title: t('styleEdit'), disabled: styleOff, onClick: toggleStyle }, h(I.sliders)),
6071
+ h('button', { className: 'ddp-btn' + (textEdit ? ' on' : ''), title: t('textEdit'), disabled: !url || !!ver || !!demo || busy || mark, onClick: toggleText }, h(I.text)),
6072
+ h('button', { className: 'ddp-btn' + (styleEdit ? ' on' : ''), title: t('styleEdit'), disabled: styleOff || mark, onClick: toggleStyle }, h(I.sliders)),
6073
+ h('button', { className: 'ddp-btn' + (mark ? ' on' : ''), title: t('mark'), 'aria-pressed': mark, disabled: markOff, onClick: toggleMark }, h(I.pen)),
4288
6074
  h('span', { className: 'ddp-sep' }),
4289
6075
  h('div', { className: 'ddp-seg' },
4290
- h('button', { className: 'ddp-btn' + (canvas && !inMulti ? ' sel' : ''), title: t('canvas'), onClick: () => { setMulti(false); change({ view: 'canvas' }) } }, h(I.canvas)),
4291
- h('button', { className: 'ddp-btn' + (!canvas && !inMulti ? ' sel' : ''), title: t('single'), onClick: () => { setMulti(false); openSingle() } }, h(I.single)),
4292
- h('button', { className: 'ddp-btn' + (inMulti ? ' sel' : ''), title: sizeSet ? t('sizes') : t('sizesNo'), disabled: !sizeSet || !!ver, onClick: () => setMulti(!inMulti) }, h(I.sizes))),
4293
- h('button', { className: 'ddp-btn o2' + (demo ? ' sel' : ''), title: t('demo'), disabled: !url || !!ver, onClick: toggleDemo }, h(I.play)),
4294
- h(KindMenu, { t, kind, open: menu, setOpen: setMenu, onPick: (k) => { setMenu(false); change({ kind: k }) } }),
6076
+ h('button', { className: 'ddp-btn' + (canvas && !inMulti ? ' sel' : ''), title: t('canvas'), disabled: mark, onClick: () => { setMulti(false); change({ view: 'canvas' }) } }, h(I.canvas)),
6077
+ h('button', { className: 'ddp-btn' + (!canvas && !inMulti ? ' sel' : ''), title: t('single'), disabled: mark, onClick: () => { setMulti(false); openSingle() } }, h(I.single)),
6078
+ h('button', { className: 'ddp-btn' + (inMulti ? ' sel' : ''), title: sizeSet ? t('sizes') : t('sizesNo'), disabled: !sizeSet || !!ver || mark, onClick: () => setMulti(!inMulti) }, h(I.sizes))),
6079
+ h('button', { className: 'ddp-btn o2' + (demo ? ' sel' : ''), title: t('demo'), disabled: !url || !!ver || mark, onClick: toggleDemo }, h(I.play)),
6080
+ h(KindMenu, { t, kind, open: menu, setOpen: setMenu, disabled: mark, onPick: (k) => { setMenu(false); change({ kind: k }) } }),
4295
6081
  h('button', { className: 'ddp-btn' + (panel ? ' sel' : '') + (tokens.length && !panel ? ' has' : ''), title: t('tokens'), onClick: () => { setPanel(!panel); setStyles(false); setHist(false); setPhone(false); closeCheck(); setStyleEdit(false) } }, h(I.palette)),
4296
6082
  h('button', { className: 'ddp-btn o2' + (pane === 'tweaks' ? ' sel' : '') + (tlist.length && pane !== 'tweaks' ? ' has' : ''), title: t('tweaks'), disabled: !fileTarget || !!ver, onClick: () => openPane('tweaks') }, h(I.dial)),
4297
6083
  h('button', { className: 'ddp-btn o2' + (ck ? ' sel' : ''), title: ckCount && !ck ? t('checkCount', { n: ckCount }) : t('checkTip'), disabled: checkOff, onClick: () => (ck ? closeCheck() : runCheck()) },
@@ -4304,7 +6090,7 @@ window.__ModuleLoader__.load({
4304
6090
  h('button', { className: 'ddp-btn o1', title: t('refresh'), onClick: reload }, h(I.refresh)),
4305
6091
  h('a', { className: 'ddp-btn o1', title: t('external'), href: url, target: '_blank', rel: 'noopener noreferrer' }, h(I.external)),
4306
6092
  h(MoreMenu, { t, open: more, setOpen: setMore, has: (phoneOn && !phone) || (!!ckCount && !ck && !checkOff), items: [
4307
- ['o2', I.play, t('demoShort'), !!demo, !url || !!ver, toggleDemo],
6093
+ ['o2', I.play, t('demoShort'), !!demo, !url || !!ver || mark, toggleDemo],
4308
6094
  ['o2', I.stetho, t('check'), !!ck, checkOff, () => (ck ? closeCheck() : runCheck())],
4309
6095
  ['o2', I.history, t('history'), hist || !!ver, !fileTarget, toggleHist],
4310
6096
  ['o2', I.dial, t('tweaks'), pane === 'tweaks', !fileTarget || !!ver, () => openPane('tweaks')],
@@ -4328,6 +6114,7 @@ window.__ModuleLoader__.load({
4328
6114
  try {
4329
6115
  const r = await readRef(file)
4330
6116
  const saved = await post('ref', { session: sid, name: file.name || 'ref', data: r.data })
6117
+ keepThumb(saved.path, r.img)
4331
6118
  insert(formatRef(t, { path: saved.path, w: r.w, h: r.h, palette: r.palette, hasDesign: !!url }), 'insertedSend')
4332
6119
  } catch (e) {
4333
6120
  say(t('refFail', { msg: e.message }))
@@ -4336,11 +6123,30 @@ window.__ModuleLoader__.load({
4336
6123
  const imageOf = (list) => [...(list || [])].find((f) => f && /^image\//.test(f.type))
4337
6124
  const hasFiles = (e) => [...((e.dataTransfer && e.dataTransfer.types) || [])].includes('Files')
4338
6125
  const pickRef = () => { if (refInput.current) refInput.current.click() }
6126
+
6127
+ const onDropped = (dt) => {
6128
+ const d = droppedOf(dt)
6129
+ if (d.page) return hostPaths() ? openLocal(pathOf(d.page)) : say(t('dropWebSay'))
6130
+ if (d.image) return addRef(d.image)
6131
+ return say(d.any ? t(hostPaths() ? 'dropWhat' : 'dropWhatWeb') : t('refNotImage'))
6132
+ }
6133
+
4339
6134
  const rootProps = {
4340
6135
  className: 'ddp',
4341
6136
  tabIndex: -1, // 点了右侧栏就能在这里粘贴图片
4342
- onDragEnter: (e) => { if (hasFiles(e)) { e.preventDefault(); setDropping(true) } },
4343
- onDragOver: (e) => { if (hasFiles(e)) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy' } },
6137
+ // 拖动在右侧栏里时别让 DSH 看到:它在整页上数进出、松手就把文件加进对话框——
6138
+ // 不拦的话它的「拖到这里添加」会盖住右侧栏,我们接住松手后它的蒙层还收不回去
6139
+ onDragEnter: (e) => { if (hasFiles(e)) { e.preventDefault(); e.stopPropagation(); clearTimeout(softTimer.current); setDropSoft(false); setDropping(dragKind(e.dataTransfer)) } },
6140
+ onDragOver: (e) => { if (hasFiles(e)) { e.preventDefault(); e.stopPropagation(); e.dataTransfer.dropEffect = 'copy' } },
6141
+ onDragLeave: (e) => { if (hasFiles(e)) e.stopPropagation() },
6142
+ onDrop: (e) => {
6143
+ if (!hasFiles(e)) return
6144
+ // 平时是蒙层接住;蒙层刚好不在(比如鼠标动了一下收掉了)也别让文件掉进对话框
6145
+ e.preventDefault()
6146
+ e.stopPropagation()
6147
+ setDropping('')
6148
+ onDropped(e.dataTransfer)
6149
+ },
4344
6150
  onPaste: (e) => {
4345
6151
  const f = imageOf(e.clipboardData && e.clipboardData.files)
4346
6152
  if (!f) return
@@ -4348,26 +6154,42 @@ window.__ModuleLoader__.load({
4348
6154
  addRef(f)
4349
6155
  },
4350
6156
  }
4351
- // 盖住整个右侧栏接住松手(不然落在页面 iframe 上就被它吃掉了)
6157
+ // 盖住整个右侧栏接住松手(不然落在页面 iframe 上就被它吃掉了);拖动在预览页面里时只是提示(soft),事件照样给页面
4352
6158
  const dropLayer = dropping ? h('div', {
4353
- className: 'ddp-drop',
6159
+ key: 'drop',
6160
+ className: 'ddp-drop' + (dropSoft ? ' soft' : ''),
6161
+ // 拖放中不会有 mousemove:鼠标一动就说明拖已经结束了(没收到 drop / dragleave 时别让蒙层一直盖着)
6162
+ onMouseMove: () => setDropping(''),
4354
6163
  onDragOver: (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy' },
4355
- onDragLeave: (e) => { if (e.target === e.currentTarget) setDropping(false) },
6164
+ onDragLeave: (e) => { if (e.target === e.currentTarget) setDropping('') },
4356
6165
  onDrop: (e) => {
4357
6166
  e.preventDefault()
4358
6167
  e.stopPropagation()
4359
- setDropping(false)
4360
- addRef(imageOf(e.dataTransfer.files))
6168
+ setDropping('')
6169
+ onDropped(e.dataTransfer)
4361
6170
  },
4362
- }, h('div', { className: 'in' }, h(I.image), h('b', null, t('refDrop')), h('span', null, t('refDropSub')))) : null
6171
+ }, h('div', { className: 'in' + (dropping === 'web' || dropping === 'other' ? ' no' : '') },
6172
+ ...(dropping === 'page' || dropping === 'maybe' ? [h(I.page), h('b', null, t(dropping === 'page' ? 'pageDrop' : 'maybeDrop')), h('span', null, t('pageDropSub'))]
6173
+ : dropping === 'web' ? [h(I.page), h('b', null, t('dropWeb')), h('span', null, t('dropWebSub'))]
6174
+ : dropping === 'other' ? (hostPaths() ? [h(I.page), h('b', null, t('dropWhat')), h('span', null, t('dropWhatSub'))] : [h(I.page), h('b', null, t('dropWhatWeb')), h('span', null, t('dropWhatWebSub'))])
6175
+ : [h(I.image), h('b', null, t('refDrop')), h('span', null, t('refDropSub'))]))) : null
4363
6176
  const fileInput = h('input', {
6177
+ key: 'refFile',
4364
6178
  ref: refInput,
4365
6179
  type: 'file',
4366
6180
  accept: 'image/png,image/jpeg,image/webp,image/gif',
4367
6181
  style: { display: 'none' },
4368
6182
  onChange: (e) => { const f = e.target.files && e.target.files[0]; e.target.value = ''; if (f) addRef(f) },
4369
6183
  })
4370
- const root = (...kids) => h('div', rootProps, ...kids, dropLayer, fileInput)
6184
+ const pageFileInput = h('input', {
6185
+ key: 'pageFile',
6186
+ ref: pageInput,
6187
+ type: 'file',
6188
+ accept: '.html,.htm,text/html',
6189
+ style: { display: 'none' },
6190
+ onChange: (e) => { const f = e.target.files && e.target.files[0]; e.target.value = ''; if (f) openLocal(pathOf(f)) },
6191
+ })
6192
+ const root = (...kids) => h('div', rootProps, ...kids, dropLayer, fileInput, pageFileInput)
4371
6193
 
4372
6194
  // 调节滑块 / 品牌设计系统 / 转成代码(右边的面板)
4373
6195
  const sidePane = () => {
@@ -4397,13 +6219,14 @@ window.__ModuleLoader__.load({
4397
6219
  })
4398
6220
  }
4399
6221
  if (pane === 'handoff') {
4400
- return h(HandoffPanel, { t, sid, root: fileTarget ? st.target.root : '', fileTarget, count: screens.length, cur: (curScreen && curScreen.key) || focus, onGo: doHandoff, onClose: () => setPane('') })
6222
+ return h(HandoffPanel, { t, sid, root: fileTarget ? st.target.root : '', fileTarget, count: screens.length, cur: (curScreen && curScreen.key) || focus, onGo: doHandoff, onSay: say, onClose: () => setPane('') })
4401
6223
  }
4402
6224
  return null
4403
6225
  }
4404
6226
 
4405
6227
  if (!url) {
4406
- if (st ? st.loading : !waited) return root(h('div', { className: 'ddp-stage', ref: stageRef }))
6228
+ const toastEl = toast ? h('div', { className: 'ddp-toast' }, h('span', null, toast.text)) : null
6229
+ if (st ? st.loading : !waited) return root(h('div', { className: 'ddp-stage', ref: stageRef }), toastEl)
4407
6230
  if (busy) {
4408
6231
  // 模型在做第一屏:先放一个生成中的占位
4409
6232
  return root(
@@ -4411,7 +6234,8 @@ window.__ModuleLoader__.load({
4411
6234
  h('div', { className: 'ddp-wait' }, h('div', { className: 'card' }, h(Skeleton, {})), h('p', null, t('waiting'))),
4412
6235
  h('div', { className: 'ddp-glow' }),
4413
6236
  h('div', { className: 'ddp-ring' }),
4414
- busyChip('')))
6237
+ busyChip('')),
6238
+ toastEl)
4415
6239
  }
4416
6240
  return root(
4417
6241
  h('div', { className: 'ddp-empty' },
@@ -4424,13 +6248,13 @@ window.__ModuleLoader__.load({
4424
6248
  h('ol', { className: 'ddp-steps' }, h('li', null, t('step1')), h('li', null, t('step2')), h('li', null, t('step3'))),
4425
6249
  h('div', { className: 'ddp-tries' }, h('span', null, t('tryHead')),
4426
6250
  ['try1', 'try2', 'try3'].map((k) => h('button', { key: k, className: 'ddp-try', onClick: () => insert(t(k), 'insertedSend') }, t(k)))),
4427
- h('div', { className: 'ddp-stylepick' }, h('b', null, t('stylesHead')), h('p', null, t('stylesTip')), h(StyleCards, { t, onPick: (x) => insert(formatStyle(t, x, false)) })),
4428
- h('p', null, t('emptyHint')),
4429
- h(AddressForm, { t, sid, className: 'ddp-form', onDone: () => {} }),
6251
+ h('p', null, t(browse ? 'emptyHintPick' : 'emptyHint')),
6252
+ h(AddressForm, { t, sid, className: 'ddp-form', onDone: () => {}, onBrowse: browse }),
4430
6253
  h('p', { className: 'ddp-refline' }, t('refEmpty'), ' · ', h('button', { className: 'lk', onClick: pickRef }, t('refChoose'))),
4431
- h('p', { className: 'ddp-refline' }, t('sysEmptyLine'), ' · ', h('button', { className: 'lk', onClick: () => openPane('systems') }, t('sysOpen')))),
6254
+ h('p', { className: 'ddp-refline' }, t('sysEmptyLine'), ' · ', h('button', { className: 'lk', onClick: () => openPane('systems') }, t('sysOpen'))),
6255
+ h('div', { className: 'ddp-stylepick' }, h('b', null, t('stylesHead')), h('p', null, t('stylesTip')), h(StyleCards, { t, onPick: (x) => insert(formatStyle(t, x, false)) }))),
4432
6256
  pane === 'systems' ? sidePane() : null,
4433
- toast ? h('div', { className: 'ddp-toast' }, h('span', null, toast.text)) : null)
6257
+ toastEl)
4434
6258
  }
4435
6259
 
4436
6260
  let body
@@ -4446,8 +6270,8 @@ window.__ModuleLoader__.load({
4446
6270
  return h('div', { key: l.id, className: cls },
4447
6271
  h('div', { className: 'ddp-single' },
4448
6272
  h('div', { className: 'ddp-fit' + (fill ? ' fill' : ''), style: fill ? undefined : { width: dw, height: dh, transform: `translate(-50%, -50%) scale(${z})` } },
4449
- h(Device, { kind, w: size[0], h: size[1], fill, label, top: tops[l.key] },
4450
- h(Screen, { t, remote,
6273
+ h(Device, { kind, w: size[0], h: size[1], fill, label, top: tops[l.key], os: meta.os },
6274
+ h(Screen, { t, remote, onPageDrag: pageDrag,
4451
6275
  src: urlOfKey(l.key),
4452
6276
  label,
4453
6277
  busy: false,
@@ -4482,8 +6306,8 @@ window.__ModuleLoader__.load({
4482
6306
  picking: picking || textEdit,
4483
6307
  sel: '',
4484
6308
  labelOf: (sc) => sc.label,
4485
- render: (sc, z, i) => h(Device, { kind: items[i].kind, w: items[i].size[0], h: items[i].size[1], label: labelOf(base), top: tops[ik(base) + '|' + i] },
4486
- h(Screen, { t, remote,
6309
+ render: (sc, z, i) => h(Device, { kind: items[i].kind, w: items[i].size[0], h: items[i].size[1], label: labelOf(base), top: tops[ik(base) + '|' + i], os: meta.os },
6310
+ h(Screen, { t, remote, onPageDrag: pageDrag,
4487
6311
  src: base.url,
4488
6312
  label: labelOf(base),
4489
6313
  busy,
@@ -4514,12 +6338,12 @@ window.__ModuleLoader__.load({
4514
6338
  const z = fill ? 1 : Math.max(0.05, Math.min(1, (box.w - 32) / dw, (box.h - 32 - (ver ? 46 : 0)) / dh))
4515
6339
  // 两边的小标签不跟着画面缩小(缩了看不清);手机、平板躲开状态栏
4516
6340
  const tagAt = (side) => ({ top: kind === 'app' || kind === 'miniprogram' || kind === 'tablet' ? 60 : 10, transform: `scale(${1 / z})`, transformOrigin: 'top ' + side })
4517
- const screenOf = (sc) => h(Screen, { t, remote, src: sc.url, label: labelOf(sc), busy: false, born: false, picking: false, dark, zoom: z, reloadSeq,
6341
+ const screenOf = (sc) => h(Screen, { t, remote, onPageDrag: pageDrag, src: sc.url, label: labelOf(sc), busy: false, born: false, picking: false, dark, zoom: z, reloadSeq,
4518
6342
  apis: apis.current, apiKey: 'cmp|' + ik(sc), onMeta: (m, title, toks, dk) => remember(ik(sc), m, title, toks, dk), onTop: (dk) => setTop(ik(sc), dk) })
4519
6343
  body = h('div', { className: 'ddp-single' },
4520
6344
  h('div', { className: 'ddp-fit' + (fill ? ' fill' : ''), style: fill ? undefined : { width: dw, height: dh, transform: `translate(-50%, -50%) scale(${z})` } },
4521
6345
  // 胶囊那块露的是哪一边(把手拖过胶囊中间就是左边那一版),胶囊就跟哪一边的顶栏
4522
- now ? h(Device, { kind, w: size[0], h: size[1], fill, label: labelOf(now), top: tops[ik(has && (cx / 100) * size[0] > size[0] - CAP.right - CAP.w / 2 ? old : now)] },
6346
+ now ? h(Device, { kind, w: size[0], h: size[1], fill, label: labelOf(now), top: tops[ik(has && (cx / 100) * size[0] > size[0] - CAP.right - CAP.w / 2 ? old : now)], os: meta.os },
4523
6347
  h('div', { className: 'ddp-cmp' + (cmpDrag ? ' drag' : ''), ref: cmpRef },
4524
6348
  h('div', { className: 'ly' }, has ? screenOf(old) : h('div', { className: 'miss' }, t('notInVer', { n: ver.n }))),
4525
6349
  // 右边一层只露出把手右边那块:外层裁宽度,里面整屏往左挪(iframe 不变大小、不重排;clip-path 盖 iframe 有的内核画不出来)
@@ -4553,10 +6377,12 @@ window.__ModuleLoader__.load({
4553
6377
  const [dw, dh] = deviceBox(kind, size)
4554
6378
  // 右边开着面板(体检、调样式、设计规范、换风格):手机这类有外框的让到面板左边(侧栏够宽时),别被挡住
4555
6379
  const aside = !fill && (ck || styleEdit || panel || styles || pane) && box.w - 296 >= 240 ? 296 : 0
4556
- const z = fill ? 1 : Math.max(0.05, Math.min(1, (box.w - aside - 32) / dw, (box.h - 32 - (ver || hintTop ? 46 : 0)) / dh))
6380
+ // 画笔标注开着:网页按开标注时的大小定住(侧栏拉窄了就整体缩小,不重排),画的才对得上
6381
+ const lock = fill && mark && markSize ? markSize : null
6382
+ const z = lock ? Math.max(0.05, Math.min(1, box.w / lock.w, box.h / lock.h)) : fill ? 1 : Math.max(0.05, Math.min(1, (box.w - aside - 32) / dw, (box.h - 32 - (ver || hintTop ? 46 : 0)) / dh))
4557
6383
  const one = ver ? shown.find((x) => x.key === focus) || shown[0] || { key: focus, url, label: st.label } : { key: focus, url, label: st.label }
4558
6384
  body = h('div', { className: 'ddp-single', style: aside ? { right: aside } : undefined },
4559
- h('div', { className: 'ddp-fit' + (fill ? ' fill' : ''), style: fill ? undefined : { width: dw, height: dh, transform: `translate(-50%, -50%) scale(${z})` } },
6385
+ h('div', { className: 'ddp-fit' + (fill && !lock ? ' fill' : ''), style: fill && !lock ? undefined : { width: lock ? lock.w : dw, height: lock ? lock.h : dh, transform: `translate(-50%, -50%) scale(${z})` } },
4560
6386
  renderScreen(one, z, fill)))
4561
6387
  }
4562
6388
 
@@ -4600,6 +6426,7 @@ window.__ModuleLoader__.load({
4600
6426
  onClose: () => setPhone(false),
4601
6427
  }) : null,
4602
6428
  hist ? h(HistoryPanel, { t, versions, ver, onPick: (v) => { setVer(v); if (!v) setCmp(false) }, onClose: () => setHist(false) }) : null,
6429
+ mark && !markOff ? h(MarkLayer, { t, sending: markBusy, frames: markFrames, onCancel: () => { markRun.current++; setMark(false) }, onSend: sendMark, onCopy: canCopyImage() ? copyMark : null }) : null,
4603
6430
  demo ? h('div', { className: 'ddp-banner' },
4604
6431
  h(I.play),
4605
6432
  h('span', null, t('demoOn') + ' · ' + ((infoOf(demoKey) || {}).title || demoKey.replace(/\.html?$/i, ''))),
@@ -4613,7 +6440,7 @@ window.__ModuleLoader__.load({
4613
6440
  h('button', { className: 'ddp-go pri', onClick: doRestore }, t('restore')),
4614
6441
  h('button', { className: 'ddp-btn', title: t('backNow'), onClick: () => { setVer(null); setCmp(false) } }, h(I.close))) : null,
4615
6442
  chip,
4616
- toast ? h('div', { className: 'ddp-toast' + (ver || demo || hintText ? ' low' : '') }, h('span', null, toast.text),
6443
+ toast ? h('div', { className: 'ddp-toast' + (mark ? ' mk' : ver || demo || hintText ? ' low' : '') }, h('span', null, toast.text),
4617
6444
  toast.act ? h('button', { onClick: () => { toast.act.run(); setToast('') } }, toast.act.label) : null) : null))
4618
6445
  }
4619
6446
 
@@ -4644,7 +6471,7 @@ window.__ModuleLoader__.load({
4644
6471
  // ── 注册 ─────────────────────────────────────────────────────────────────
4645
6472
 
4646
6473
  function apply(ctx) {
4647
- ensureStyle()
6474
+ try { ctx.effect(() => ensureStyle(), 'dsh-design-studio: styles') } catch (e) { console.warn('[dsh-design-studio] styles', e) }
4648
6475
  const t = makeT(ctx)
4649
6476
  try { ctx.effect(() => ctx.locale.register(NS, { zh: ZH, en: EN }), 'dsh-design-studio: copy') } catch (e) { console.warn('[dsh-design-studio] locale', e) }
4650
6477
 
@@ -4675,6 +6502,46 @@ window.__ModuleLoader__.load({
4675
6502
  inject: (sessionId) => ({ sid: sessionId, t }),
4676
6503
  }, PreviewTab)), 'dsh-design-studio: tab body')
4677
6504
 
6505
+ // 点选标签(DSH 0.2.1 起有这些服务;没有就不注册,点选照旧插字)
6506
+ if (typeof ctx.inject === 'function') ctx.inject(['sessions', 'inputTriggers'], (scope) => {
6507
+ if (!scope.inputTriggers || typeof scope.inputTriggers.registerSource !== 'function') return
6508
+ // 每种图标一个来源(输入框里的标签只带来源名,图标靠它分);@ 菜单里不出东西
6509
+ for (const i of Object.keys(CHIP_ICON)) {
6510
+ scope.effect(() => scope.inputTriggers.registerSource({
6511
+ trigger: '@',
6512
+ name: chipSrc(i),
6513
+ order: 1000,
6514
+ showGroupTitle: false,
6515
+ candidates: async () => [],
6516
+ onPick: () => undefined,
6517
+ // 标签存的就是整段字:复制出来是它,发给模型也是它(前后空一行,和用户的话分开)
6518
+ codec: { clipboardText: pickClip, serialize: async (ref) => pickClip(ref) },
6519
+ }), `dsh-design-studio: chips (${i})`)
6520
+ }
6521
+ scope.effect(() => {
6522
+ chips = {
6523
+ insert(sid, text, label, span, source) {
6524
+ const b = scope.sessions && typeof scope.sessions.binding === 'function' ? scope.sessions.binding(sid) : null
6525
+ const actx = b && b.ctx
6526
+ if (!actx || !span || !label) return false
6527
+ return actx.bail(actx, 'slash/input-insert-reference', { reference: { source: source || PICK_SRC, ref: text, label, clipboardText: pickClip(text) }, span }) === true
6528
+ },
6529
+ }
6530
+ return () => { chips = null }
6531
+ }, 'dsh-design-studio: chip insert')
6532
+ })
6533
+ if (typeof ctx.inject === 'function') ctx.inject(['uiConversation'], (scope) => {
6534
+ const ut = scope.uiConversation && scope.uiConversation.userText
6535
+ if (!ut || typeof ut.register !== 'function') return
6536
+ scope.effect(() => ut.register(pickRule), 'dsh-design-studio: chips in messages')
6537
+ scope.effect(() => pickCard(t), 'dsh-design-studio: chip card')
6538
+ })
6539
+ ctx.effect(() => ctx.slots.inject('conversation.user.text.node', () => ctx.slots.register({
6540
+ name: 'conversation.user.text.node',
6541
+ key: 'ddp-pick',
6542
+ priority: 100,
6543
+ }, ({ node }) => { const c = chipOf(node.data.text, t); return h('span', { className: 'ddp-pickpill', 'data-pick': node.data.text, 'data-i': c ? c.i : 'pick' }, h('span', null, c ? c.label : t('chipOne'))) })), 'dsh-design-studio: chips in messages')
6544
+
4678
6545
  ctx.effect(() => ctx.slots.inject('conversation.session.header.utilities', () => ctx.slots.register({
4679
6546
  name: 'conversation.session.header.utilities',
4680
6547
  id: 'dsh-design-studio',
@@ -4688,7 +6555,7 @@ window.__ModuleLoader__.load({
4688
6555
  // 注意:这里是客户端**服务名**(短的),不是包名
4689
6556
  exports.inject = INJECT
4690
6557
  // 测试用
4691
- exports._test = { STYLES, formatStyle, accentUse, padInsert, pageOf, pxOf, styleChanges, formatStyleAsk, takesOf, formatCheckTool, poller, frameOf, paletteOf, formatRef, layoutRow, keyOfPath, flowsOf, flowLines, flowMarks, formatPick, whereOf, parseMeta, sizeOf, layoutGrid, fitCam, tokenKind, formatToken, splitTitles, pickColors, hhmm }
6558
+ exports._test = { dragKind, droppedOf, pickInfo, pickLabel, pickRanges, pickRule, chipOf, chipRanges, makeT, ZH, EN, formatCheck, formatTweaks, markHit, markPath, STYLES, formatStyle, accentUse, styleLook, padInsert, pageOf, pxOf, styleChanges, formatStyleAsk, takesOf, formatCheckTool, poller, frameOf, paletteOf, formatRef, layoutRow, keyOfPath, flowsOf, flowLines, flowMarks, formatPick, whereOf, parseMeta, sizeOf, layoutGrid, fitCam, tokenKind, formatToken, splitTitles, pickColors, hhmm }
4692
6559
  return module.exports
4693
6560
  },
4694
6561
  })