dsh-design-studio 0.9.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: '选一张',
@@ -120,7 +158,8 @@ window.__ModuleLoader__.load({
120
158
  checkFail: '没查成:{msg}',
121
159
  checkNote: '只量得出排版上的硬伤,好不好看还得你看',
122
160
  checkCount: '设计体检:上次量出 {n} 个问题,点开看',
123
- checkAsk: '〔设计体检〕发现 {n} 个问题,请逐个修好:改源文件,只动有问题的地方;颜色、字号能改设计变量就改设计变量。',
161
+ checkAsk: '〔设计体检〕发现 {n} 个问题:',
162
+ checkTail: '请逐个修好:改源文件,只动有问题的地方;颜色、字号能改设计变量就改设计变量。',
124
163
  ckToolHead: '量了 {s} 屏,发现 {n} 个问题。逐个改源文件,只动有问题的地方;颜色、字号能改设计变量就改设计变量:',
125
164
  ckToolOk: '量了 {s} 屏({labels}),没发现问题。',
126
165
  ckToolOff: '用户正在看旧版本、演示或多尺寸,右侧栏现在量不了;过一会儿再调,或照「自查」清单自己过一遍。',
@@ -217,7 +256,7 @@ window.__ModuleLoader__.load({
217
256
  pickShort: '就要这个',
218
257
  pickText: '〔选定方向〕就用「{name}」({where})。先把同组的 {others} 挪进 drafts/ 子目录(别删),再把它改名成 {base}.html;它的设计变量搬进 tokens.css(没有就新建)、改用 tokens.css。然后接着做全套。\n',
219
258
  pickKeep: '〔选定方向〕留原版「{name}」({where})。同组的 {others} 挪进 drafts/ 子目录(别删),其他不动。\n',
220
- 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',
221
260
  exThis: '导出这一屏',
222
261
  demo: '演示:点页面里的按钮跳到别的屏',
223
262
  demoOn: '演示中 · 点按钮跳转',
@@ -225,8 +264,50 @@ window.__ModuleLoader__.load({
225
264
  demoRestart: '从头开始',
226
265
  demoExit: '退出演示(Esc)',
227
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: '没能放进输入框,点一下聊天输入框再点「交给模型」',
228
309
  textSaved: '已改好,写回 {file}',
229
- textAsk: '把页面上的「{before}」改成「{after}」(只改这一处文字,别的不动)。\n{where}\n',
310
+ textAsk: '把页面上的「{before}」改成「{after}」({where};只改这一处文字,别的不动)。\n',
230
311
  styleEdit: '调样式:点页面里的元素,拖滑块改字号、颜色、圆角、间距(Esc 退出)',
231
312
  styleAny: '点任意一屏里的元素调样式 · Esc 退出',
232
313
  styleOne: '点页面里的元素调样式 · Esc 退出',
@@ -257,7 +338,7 @@ window.__ModuleLoader__.load({
257
338
  styleSaved: '样式已写回 {file}',
258
339
  styleMiss: '源码里没找到这个元素(可能是脚本生成的),改成交给模型',
259
340
  styleN: '改了 {n} 项',
260
- styleAskText: '〔调样式〕{name}({sel}):{list}。{rules}把这些写进它的样式规则(能用设计变量就用设计变量),别写成行内 style。\n{where}\n',
341
+ styleAskText: '〔调样式〕{name}({sel}):{list}。{rules}把这些写进它的样式规则({where};能用设计变量就用设计变量),别写成行内 style。\n',
261
342
  ruleFrom: '- {props}:现在由 {sel} 决定({at})',
262
343
  ruleMaybe: '- {props}:大概由 {sel} 决定({at};它在 @layer / @container 里,没细算,改之前看一眼)',
263
344
  ruleInline: '- {props}:现在写在元素的行内 style 上{over}',
@@ -352,7 +433,8 @@ window.__ModuleLoader__.load({
352
433
  twFix: '让模型修好',
353
434
  twMakeText: '〔调节滑块〕给这套设计做 3–5 个调节滑块:挑最值得试的维度(比如留白、圆角、主色、卡片排法),照 ui-design 技能「调节滑块」一节写进设计目录的 tweaks.json,把样式接上,做完用 check_design 查一遍。',
354
435
  twFixText: '〔调节滑块〕tweaks.json 有问题:{errs}。照 ui-design 技能「调节滑块」一节改好。',
355
- twAskHead: '〔调节〕我在右侧调节滑块里调成了下面这样,照这个改设计(改样式源文件,tweaks.json 里对应的 value 也改成新值):',
436
+ twAskHead: '〔调节〕我在右侧调节滑块里调成了下面这样:',
437
+ twAskTail: '照这个改设计(改样式源文件,tweaks.json 里对应的 value 也改成新值)。',
356
438
  twAskTip: '让模型照这几个值改设计(可以顺手调整相关的地方)',
357
439
  twSaveTip: 'CSS 变量写进 tokens.css 的默认值,属性写到每屏的 <html> 上(改前改后各存一版)',
358
440
  twNoWrite: '开发服务器的页面不能直接写回,交给模型改',
@@ -394,6 +476,26 @@ window.__ModuleLoader__.load({
394
476
  guideDesc: 'Live page, pick what to change',
395
477
  empty: 'Nothing to preview yet',
396
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",
397
499
  hintOpen: 'Preview {name}',
398
500
  headerHint: '{name} was just written. Click to preview it',
399
501
  placeholder: '/path/index.html or 5173',
@@ -402,6 +504,24 @@ window.__ModuleLoader__.load({
402
504
  pickTip: 'Click an element or drag a region to send it to the model (Esc to cancel)',
403
505
  pickAny: 'Click an element in any screen, or drag a region · Esc to cancel',
404
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',
405
525
  textAny: 'Click text in any screen to edit · Enter to save · Esc to stop',
406
526
  refPick: 'Restyle from a reference…',
407
527
  refChoose: 'Choose one',
@@ -470,7 +590,8 @@ window.__ModuleLoader__.load({
470
590
  checkFail: 'Check failed: {msg}',
471
591
  checkNote: 'Only measurable layout problems; whether it looks good is still up to you',
472
592
  checkCount: 'Design check: {n} problems last time. Click to see them',
473
- 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.',
474
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:',
475
596
  ckToolOk: 'Measured {s} screens ({labels}). No problems found.',
476
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.',
@@ -567,7 +688,7 @@ window.__ModuleLoader__.load({
567
688
  pickShort: 'Use this',
568
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',
569
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',
570
- 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',
571
692
  exThis: 'Export this screen',
572
693
  demo: 'Play: tap buttons in the page to move between screens',
573
694
  demoOn: 'Playing · tap to navigate',
@@ -575,8 +696,50 @@ window.__ModuleLoader__.load({
575
696
  demoRestart: 'Start over',
576
697
  demoExit: 'Stop playing (Esc)',
577
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',
578
741
  textSaved: 'Saved to {file}',
579
- 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',
580
743
  styleEdit: 'Style: click an element, then drag sliders for size, colors, radius and spacing (Esc to stop)',
581
744
  styleAny: 'Click an element in any screen to style it · Esc to stop',
582
745
  styleOne: 'Click an element in the page to style it · Esc to stop',
@@ -607,7 +770,7 @@ window.__ModuleLoader__.load({
607
770
  styleSaved: 'Style saved to {file}',
608
771
  styleMiss: 'Could not find this element in the source (maybe a script made it); asking the model instead',
609
772
  styleN: '{n} changed',
610
- 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',
611
774
  ruleFrom: '- {props}: currently set by {sel} ({at})',
612
775
  ruleMaybe: '- {props}: probably set by {sel} ({at}; it sits in @layer / @container and was not fully resolved, check before editing)',
613
776
  ruleInline: '- {props}: currently in the element\'s inline style{over}',
@@ -702,7 +865,8 @@ window.__ModuleLoader__.load({
702
865
  twFix: 'Ask the model to fix it',
703
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.',
704
867
  twFixText: '[Tweaks] tweaks.json has problems: {errs}. Fix it as the ui-design skill describes under "Tweaks".',
705
- 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).',
706
870
  twAskTip: 'Let the model apply these values (and adjust related details)',
707
871
  twSaveTip: 'CSS variables go into the defaults in tokens.css, attributes onto each screen\'s <html> (a version is kept before and after)',
708
872
  twNoWrite: 'Dev-server pages cannot be written back. Ask the model instead',
@@ -747,7 +911,8 @@ window.__ModuleLoader__.load({
747
911
  let s = ''
748
912
  try { s = bound ? bound(key) : '' } catch { s = '' }
749
913
  if (!s || s === key || s === `${NS}.${key}`) s = ZH[key] || key
750
- 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))
751
916
  return s
752
917
  }
753
918
  }
@@ -755,10 +920,26 @@ window.__ModuleLoader__.load({
755
920
  // ── 样式(DSH 设计变量,深浅主题跟着走)──────────────────────────────────
756
921
 
757
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>`)}")`
758
938
  const EASE = 'cubic-bezier(.2,.8,.2,1)'
759
939
  const CSS = `
760
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}
761
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}
762
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}
763
944
  .ddp-addr:hover{border-color:var(--dsw-alias-border-l3)}
764
945
  .ddp-addr span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
@@ -878,6 +1059,42 @@ window.__ModuleLoader__.load({
878
1059
  @keyframes ddp-txt{from{background-position:100% 0}to{background-position:-160% 0}}
879
1060
  @keyframes ddp-blink{50%{opacity:.25}}
880
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}
881
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}
882
1099
  .ddp-skel.gone{opacity:0}
883
1100
  .ddp-skel b{display:block;border-radius:8px;background:var(--dsw-alias-bg-layer-2);position:relative;overflow:hidden}
@@ -886,8 +1103,8 @@ window.__ModuleLoader__.load({
886
1103
  .ddp-wait{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px;padding:24px}
887
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}
888
1105
  .ddp-wait p{margin:0;color:var(--dsw-alias-label-tertiary);text-align:center;line-height:1.6}
889
- .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}
890
- .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)}
891
1108
  .ddp-empty b{color:var(--dsw-alias-label-primary);font-size:14px;font-weight:500}
892
1109
  .ddp-empty p{margin:6px 0 0;max-width:300px;line-height:1.6}
893
1110
  .ddp-steps{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:7px;text-align:left;counter-reset:s}
@@ -904,7 +1121,11 @@ window.__ModuleLoader__.load({
904
1121
  @keyframes ddp-ants{to{stroke-dashoffset:-14}}
905
1122
  @keyframes ddp-sel{0%,30%{opacity:0}42%,88%{opacity:1}100%{opacity:0}}
906
1123
  @keyframes ddp-cur{0%,100%{transform:translate(132px,98px)}36%,90%{transform:translate(58px,76px)}}
907
- .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)}
908
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}
909
1130
  .ddp-err{color:var(--dsw-alias-state-error-primary,#e5484d);font-size:11.5px}
910
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}
@@ -945,6 +1166,7 @@ window.__ModuleLoader__.load({
945
1166
  .ddp{position:relative;outline:none}
946
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}
947
1168
  .ddp-drop *{pointer-events:none}
1169
+ .ddp-drop.soft{pointer-events:none}
948
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}
949
1171
  .ddp-drop .in svg{width:28px;height:28px;color:${ACCENT};margin-bottom:4px}
950
1172
  .ddp-drop .in b{color:var(--dsw-alias-label-primary);font-size:14px;font-weight:500}
@@ -1086,6 +1308,7 @@ window.__ModuleLoader__.load({
1086
1308
  .ddp-banner .ddp-btn{height:24px;min-width:24px;border-radius:999px}
1087
1309
  .ddp-toast{display:flex;align-items:center;gap:10px}
1088
1310
  .ddp-toast.low{top:56px}
1311
+ .ddp-toast.mk{top:auto;bottom:62px}
1089
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}
1090
1313
  .ddp-cmp{position:absolute;inset:0;overflow:hidden}
1091
1314
  .ddp-cmp>.ly{position:absolute;inset:0}
@@ -1130,7 +1353,7 @@ window.__ModuleLoader__.load({
1130
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}
1131
1354
  .ddp-sysc .acts button:hover{color:var(--dsw-alias-label-primary)}
1132
1355
  .ddp-sys form{display:flex;gap:6px}
1133
- .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}
1134
1357
  .ddp-sys form input:focus,.ddp-ho input[type=text]:focus{border-color:${ACCENT}}
1135
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}
1136
1359
  .ddp-tokens.ddp-sys footer form button:disabled{opacity:.45;cursor:default}
@@ -1138,6 +1361,15 @@ window.__ModuleLoader__.load({
1138
1361
  .ddp-ho .row{padding:9px 0 10px;border-top:1px solid var(--dsw-alias-separator-primary,rgba(127,127,127,.16))}
1139
1362
  .ddp-ho .row:first-child{border-top:0}
1140
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))}
1141
1373
  .ddp-ho input[type=text]{width:100%;box-sizing:border-box;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px}
1142
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}
1143
1375
  .ddp-ho .opt:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}
@@ -1151,13 +1383,42 @@ window.__ModuleLoader__.load({
1151
1383
  .ddp-menu .o1,.ddp-menu .o2{display:none}
1152
1384
  @container (max-width:700px){.ddp-bar>.o1{display:none}.ddp-bar>.ddp-more{display:block}.ddp-menu .o1{display:flex}}
1153
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))}
1154
1410
  `
1411
+ // 页面不刷新就换了新版插件时,旧版留下的样式要换成新的;卸载时一起拿掉
1155
1412
  function ensureStyle() {
1156
- if (typeof document === 'undefined' || document.getElementById('dsh-design-studio-style')) return
1157
- const el = document.createElement('style')
1158
- el.id = 'dsh-design-studio-style'
1159
- el.textContent = CSS
1160
- 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() }
1161
1422
  }
1162
1423
 
1163
1424
  // ── 图标 ─────────────────────────────────────────────────────────────────
@@ -1209,6 +1470,16 @@ window.__ModuleLoader__.load({
1209
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' })]),
1210
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' })]),
1211
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' })]),
1212
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),
1213
1484
  }
1214
1485
  // 引导页上的大图标
@@ -1278,6 +1549,63 @@ window.__ModuleLoader__.load({
1278
1549
  return data
1279
1550
  }
1280
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
+
1281
1609
  // ── 选中内容 → 输入框里的一段字 ──────────────────────────────────────────
1282
1610
 
1283
1611
  /** 页面地址 → 用户认得的位置:本地文件给路径,开发服务器给 localhost 地址 */
@@ -1368,6 +1696,296 @@ window.__ModuleLoader__.load({
1368
1696
  return { text: lead + body, back }
1369
1697
  }
1370
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
+
1371
1989
  // DSH 的输入框(只认一个;分屏开着多个时不碰)
1372
1990
  function composerEl() {
1373
1991
  try {
@@ -1425,7 +2043,7 @@ window.__ModuleLoader__.load({
1425
2043
 
1426
2044
  /** 点了一个变量 → 输入框里的一段字 */
1427
2045
  function formatToken(tok, st) {
1428
- 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`
1429
2047
  }
1430
2048
 
1431
2049
  // ── 风格卡:开局挑一个风格(或给已有的设计换风格),整套配色、字体、圆角、阴影、要点放进输入框 ──────
@@ -1739,6 +2357,7 @@ window.__ModuleLoader__.load({
1739
2357
  detail: checkDetail(t, it),
1740
2358
  })))
1741
2359
  }
2360
+ if (head === 'checkAsk') lines.push('', t('checkTail'))
1742
2361
  return lines.join('\n') + '\n'
1743
2362
  }
1744
2363
 
@@ -2086,7 +2705,7 @@ window.__ModuleLoader__.load({
2086
2705
  big.getContext('2d').drawImage(img, 0, 0, big.width, big.height)
2087
2706
  blob = await canvasBlob(big)
2088
2707
  }
2089
- return { w, h: ht, palette, data: await blobB64(blob) }
2708
+ return { w, h: ht, palette, img, data: await blobB64(blob) }
2090
2709
  }
2091
2710
 
2092
2711
  function lumaOf(color) {
@@ -2220,6 +2839,11 @@ window.__ModuleLoader__.load({
2220
2839
  })
2221
2840
  }
2222
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
+
2223
2847
  /** 图片放进剪贴板(DSH 窗口没焦点之类的拿不到就算了) */
2224
2848
  async function copyImage(blob) {
2225
2849
  try {
@@ -2691,34 +3315,46 @@ window.__ModuleLoader__.load({
2691
3315
  h('b', { style: { height: 14, width: '54%' } }))
2692
3316
  }
2693
3317
 
2694
- function AddressForm({ t, sid, initial, onDone, autoFocus, className }) {
3318
+ function AddressForm({ t, sid, initial, onDone, autoFocus, className, onBrowse }) {
2695
3319
  const [value, setValue] = useState(initial || '')
2696
3320
  const [err, setErr] = useState('')
2697
3321
  const submit = async (e) => {
2698
3322
  if (e) e.preventDefault()
2699
3323
  if (!value.trim()) return onDone && onDone()
2700
3324
  try {
2701
- await post('open', { session: sid, target: value.trim() })
2702
- const p = poller(sid)
2703
- p.stop()
2704
- p.start()
3325
+ await openTarget(sid, value.trim())
2705
3326
  setErr('')
2706
3327
  onDone && onDone()
2707
3328
  } catch (x) {
2708
3329
  setErr(t('openFailed', { msg: x.message }))
2709
3330
  }
2710
3331
  }
2711
- 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' } },
2712
3341
  h('input', {
2713
3342
  className: 'ddp-input',
2714
3343
  value,
2715
3344
  autoFocus,
2716
3345
  spellCheck: false,
2717
3346
  placeholder: t('placeholder'),
2718
- onChange: (e) => setValue(e.target.value),
2719
- onKeyDown: (e) => { if (e.key === 'Escape') onDone && onDone() },
2720
- onBlur: () => { if (!err && onDone && !className) setTimeout(onDone, 150) },
3347
+ onChange: (e) => { setValue(e.target.value); if (err) setErr('') },
2721
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,
2722
3358
  className ? h('button', { className: 'ddp-go', type: 'submit' }, t('go')) : null,
2723
3359
  err ? h('div', { className: 'ddp-err', style: className ? { width: '100%' } : { position: 'absolute', top: 40, left: 12 } }, err) : null)
2724
3360
  }
@@ -2842,6 +3478,9 @@ window.__ModuleLoader__.load({
2842
3478
  }),
2843
3479
  checkFocus: (i) => { if (curRef.current) postTo(curRef.current, { type: 'checkFocus', i }) },
2844
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,
2845
3484
  }
2846
3485
  }
2847
3486
  // 登记到右侧栏:导出图片时按屏找到它
@@ -2993,6 +3632,11 @@ window.__ModuleLoader__.load({
2993
3632
  case 'links':
2994
3633
  if (p.onLinks && Array.isArray(d.links)) p.onLinks(d.links)
2995
3634
  break
3635
+ case 'dragIn':
3636
+ case 'dragOut':
3637
+ case 'dropIn':
3638
+ if (p.onPageDrag) p.onPageDrag(d)
3639
+ break
2996
3640
  case 'top':
2997
3641
  // 每帧记着自己的;只有正显示的那帧算数(预载的换上来时再用,旧帧晚到的不管)
2998
3642
  L.tops.set(id, !!d.dark)
@@ -3155,7 +3799,7 @@ window.__ModuleLoader__.load({
3155
3799
  }
3156
3800
 
3157
3801
  /** 类型菜单:网页 / App / 平板 / 海报 / 幻灯片 / 邮件 */
3158
- function KindMenu({ t, kind, open, setOpen, onPick }) {
3802
+ function KindMenu({ t, kind, open, setOpen, onPick, disabled }) {
3159
3803
  const ref = useRef(null)
3160
3804
  useEffect(() => {
3161
3805
  if (!open) return undefined
@@ -3169,7 +3813,7 @@ window.__ModuleLoader__.load({
3169
3813
  }
3170
3814
  }, [open])
3171
3815
  return h('div', { className: 'ddp-kind', ref },
3172
- 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) },
3173
3817
  h(I[kind]), h('span', null, t(KIND_KEY[kind])), h(I.caret)),
3174
3818
  open ? h('div', { className: 'ddp-menu', role: 'menu' },
3175
3819
  h('div', { className: 'hd' }, t('kind')),
@@ -3177,6 +3821,540 @@ window.__ModuleLoader__.load({
3177
3821
  h(I[k]), h('span', null, t(KIND_KEY[k])), h('em', null, KIND_SIZE[k].join('×'))))) : null)
3178
3822
  }
3179
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
+
3180
4358
  /** 设计规范面板:按组列出变量,点一个放进输入框 */
3181
4359
  function TokensPanel({ t, tokens, onPick, onAsk, onRef, onStyles, onSystems, onClose }) {
3182
4360
  const groups = {}
@@ -3390,7 +4568,7 @@ window.__ModuleLoader__.load({
3390
4568
  /** 拖过的滑块 → 交给模型的一段字 */
3391
4569
  function formatTweaks(t, list, live) {
3392
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})`)
3393
- return t('twAskHead') + '\n' + rows.join('\n') + '\n'
4571
+ return t('twAskHead') + '\n' + rows.join('\n') + '\n' + t('twAskTail') + '\n'
3394
4572
  }
3395
4573
 
3396
4574
  /** 调节滑块面板:模型给这套设计做的几个维度(tweaks.json),拖的时候所有屏一起变,定下来写回文件或交给模型 */
@@ -3438,6 +4616,26 @@ window.__ModuleLoader__.load({
3438
4616
  }
3439
4617
 
3440
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
+
3441
4639
  function SystemsPanel({ t, rev, hasDesign, onExtract, onApply, onSay, onClose }) {
3442
4640
  const [data, setData] = useState(null)
3443
4641
  const [err, setErr] = useState('')
@@ -3473,6 +4671,7 @@ window.__ModuleLoader__.load({
3473
4671
  h('footer', null,
3474
4672
  h('form', { onSubmit: go },
3475
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 }),
3476
4675
  h('button', { type: 'submit', disabled: !src.trim() }, t('sysExtract')))))
3477
4676
  }
3478
4677
 
@@ -3480,25 +4679,56 @@ window.__ModuleLoader__.load({
3480
4679
  const FW_LABEL = { react: 'React + Vite', vue: 'Vue 3 + Vite', next: 'Next.js' }
3481
4680
 
3482
4681
  /** 转成代码:用什么做、放哪、转几屏;点了把一段话放进输入框,模型调 handoff_design 照交接包做,每屏做完 compare_design */
3483
- function HandoffPanel({ t, sid, root, fileTarget, count, cur, onGo, onClose }) {
4682
+ function HandoffPanel({ t, sid, root, fileTarget, count, cur, onGo, onSay, onClose }) {
3484
4683
  const [info, setInfo] = useState(null)
3485
4684
  const [fw, setFw] = useState('')
3486
4685
  const [dest, setDest] = useState('')
3487
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
3488
4693
  // 换了会话或设计目录就重新问:先清掉旧的(别把上一套设计的目标发出去)
3489
4694
  useEffect(() => {
3490
4695
  let alive = true
4696
+ live.current.seq++
4697
+ live.current.hand = false
3491
4698
  setInfo(null)
4699
+ setFw('') // 上一套设计的框架(比如已经没有的「跟项目一样」)别留着
3492
4700
  setDest('')
4701
+ setMoved(null)
4702
+ setChecking('')
3493
4703
  if (!root) return () => { alive = false }
3494
4704
  getJson(`project?session=${encodeURIComponent(sid)}`).then((d) => {
3495
4705
  if (!alive) return
3496
4706
  setInfo(d)
3497
- setFw(d.project && d.project.pkg && d.project.framework ? 'same' : 'react')
4707
+ setFw(d.fw || 'react')
3498
4708
  setDest(d.root === root ? d.dest || '' : '')
3499
- }, () => { if (alive) setInfo({}) })
4709
+ }, () => { if (alive) { setInfo({}); setFw('react') } })
3500
4710
  return () => { alive = false }
3501
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) }
3502
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)))
3503
4733
  const box = (...kids) => h('div', { className: 'ddp-tokens ddp-style ddp-ho', onPointerDown: (e) => e.stopPropagation() }, head, ...kids)
3504
4734
  if (!fileTarget) return box(h('div', { className: 'bd' }, h('div', { className: 'none' }, t('hoProxy'))))
@@ -3511,15 +4741,19 @@ window.__ModuleLoader__.load({
3511
4741
  h('p', { className: 'tip' }, t('hoTip')),
3512
4742
  h('div', { className: 'bd' },
3513
4743
  h('div', { className: 'row' }, h('label', null, t('hoFw')),
3514
- ...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)))),
3515
4745
  h('div', { className: 'row' }, h('label', null, t('hoDest')),
3516
- 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),
3517
4751
  count > 1 && cur ? h('div', { className: 'row' }, h('label', null, t('hoScope')),
3518
4752
  h('div', { className: 'seg' },
3519
4753
  h('button', { className: one ? '' : 'on', onClick: () => setOne(false) }, t('hoAll', { n: count })),
3520
4754
  h('button', { className: one ? 'on' : '', onClick: () => setOne(true) }, t('hoOne')))) : null),
3521
4755
  h('footer', null,
3522
- 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')))))
3523
4757
  }
3524
4758
 
3525
4759
  /** 菜单开着时:点外面、按 Esc 关掉 */
@@ -3846,8 +5080,11 @@ window.__ModuleLoader__.load({
3846
5080
  const [sel, setSel] = useState('')
3847
5081
  const [panel, setPanel] = useState(false)
3848
5082
  const [styles, setStyles] = useState(false) // 换风格面板
3849
- const [dropping, setDropping] = useState(false) // 正拖着图片进来
5083
+ const [dropping, setDropping] = useState('') // 正拖着文件进来:dragKind 给的种类
5084
+ const [dropSoft, setDropSoft] = useState(false) // 拖动在预览页面里:蒙层只是提示、不挡事件(松手由页面转告)
5085
+ const softTimer = useRef(0)
3850
5086
  const refInput = useRef(null)
5087
+ const pageInput = useRef(null)
3851
5088
  const [exMenu, setExMenu] = useState(false)
3852
5089
  const [more, setMore] = useState(false) // 窄的时候收起来的几个按钮
3853
5090
  const [hist, setHist] = useState(false) // 版本面板
@@ -3864,6 +5101,10 @@ window.__ModuleLoader__.load({
3864
5101
  const [reveal, setReveal] = useState(null) // 画布上要露出来的那一屏 { key, n }(体检里点了一条)
3865
5102
  const [demo, setDemo] = useState(null) // 演示:{ stack: [屏…], layers: [{ id, key, dir, shown }] }
3866
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) // 每开 / 关一次标注加一:发送途中关掉(或关了又开)的,旧的那次作废
3867
5108
  const [tw, setTw] = useState({}) // 拖过的滑块:id → 值(还没写进文件)
3868
5109
  const apis = useRef(new Map()) // 屏 → 截图用的接口
3869
5110
  const cmpRef = useRef(null)
@@ -3980,6 +5221,36 @@ window.__ModuleLoader__.load({
3980
5221
  toastTimer.current = setTimeout(() => setToast(''), act ? 6000 : 2600)
3981
5222
  }
3982
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
+
3983
5254
  const showFlash = (text) => {
3984
5255
  setFlash(text)
3985
5256
  clearTimeout(flashTimer.current)
@@ -4047,7 +5318,27 @@ window.__ModuleLoader__.load({
4047
5318
  return () => { dead = true }
4048
5319
  }, [hist, sid, vrev, origin])
4049
5320
 
4050
- 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
4051
5342
  const ia = props.inputActions
4052
5343
  try {
4053
5344
  if (ia && typeof ia.insertText === 'function') {
@@ -4059,17 +5350,20 @@ window.__ModuleLoader__.load({
4059
5350
  const p = padInsert(text, ok ? all.slice(0, at) : null, ok ? all.slice(span.end) : null, at, done === 'inserted')
4060
5351
  if (ia.insertText(p.text, span)) {
4061
5352
  try { focusAfter(el, p.text, p.back) } catch { /* 用户自己点一下输入框也行 */ }
4062
- return say(t(done))
5353
+ say(t(done))
5354
+ return true
4063
5355
  }
4064
5356
  }
4065
5357
  } catch (e) {
4066
5358
  console.warn('[dsh-design-studio] insertText 失败', e)
4067
5359
  }
5360
+ if (!copy) return false
4068
5361
  if (navigator.clipboard) {
4069
5362
  navigator.clipboard.writeText(text).then(() => say(t('copied')), (e) => say(t('failed', { msg: e.message })))
4070
5363
  } else say(t('failed', { msg: 'no input' }))
4071
5364
  }
4072
5365
 
5366
+ // 点选的元素 / 区域:新版 DSH 放成标签,旧版照旧插成字
4073
5367
  const deliver = (payload, note) => {
4074
5368
  setPicking(false)
4075
5369
  insert(formatPick(payload, stRef.current) + (note || ''))
@@ -4162,7 +5456,8 @@ window.__ModuleLoader__.load({
4162
5456
 
4163
5457
  useEffect(() => {
4164
5458
  if (!dropping) return undefined
4165
- const off = () => setDropping(false)
5459
+ // 晚一拍再收蒙层:真拖放时这个捕获监听先跑,马上收的话 React 还没把 drop 交给蒙层,蒙层就没了(松手什么都不发生)
5460
+ const off = () => setTimeout(() => setDropping(''), 0)
4166
5461
  window.addEventListener('dragend', off, true)
4167
5462
  window.addEventListener('drop', off, true)
4168
5463
  return () => { window.removeEventListener('dragend', off, true); window.removeEventListener('drop', off, true) }
@@ -4246,7 +5541,7 @@ window.__ModuleLoader__.load({
4246
5541
  }
4247
5542
 
4248
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 },
4249
- h(Screen, { t, remote,
5544
+ h(Screen, { t, remote, onPageDrag: pageDrag,
4250
5545
  src: sc.url,
4251
5546
  label: labelOf(sc),
4252
5547
  busy: busy && !sc.v,
@@ -4521,6 +5816,208 @@ window.__ModuleLoader__.load({
4521
5816
  }
4522
5817
  const checkOff = !url || !!ver || !!demo || inMulti
4523
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
+ }
4524
6021
  useEffect(() => { if (styleOff) setStyleEdit(false) }, [styleOff])
4525
6022
  autoCk.current = { ck, off: checkOff, run: runCheck, list: checkList, measure }
4526
6023
  useEffect(() => { if (checkOff && ck) closeCheck() }, [checkOff])
@@ -4563,23 +6060,24 @@ window.__ModuleLoader__.load({
4563
6060
  const togglePhone = () => { setPhone(!phone); setPanel(false); setStyles(false); setHist(false); closeCheck(); setStyleEdit(false) }
4564
6061
  const reload = () => setReloadSeq((x) => x + 1)
4565
6062
 
4566
- const bar = h('div', { className: 'ddp-bar' },
6063
+ const bar = h('div', { className: 'ddp-bar' + (editing ? ' editing' : '') },
4567
6064
  editing || !url
4568
- ? 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 })
4569
6066
  : h('div', { className: 'ddp-addr', title: t('editAddr'), onClick: () => setEditing(true) },
4570
6067
  h('i', { className: 'ddp-dot' + (busy ? ' wait' : '') }),
4571
6068
  h('span', null, canvas && screens.length > 1 ? `${st.label} · ${screens.length}` : st.label)),
4572
- 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 },
4573
6070
  h(I.pick), h('span', null, picking ? t('picking') : t('pick'))),
4574
- h('button', { className: 'ddp-btn' + (textEdit ? ' on' : ''), title: t('textEdit'), disabled: !url || !!ver || !!demo || busy, onClick: toggleText }, h(I.text)),
4575
- 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)),
4576
6074
  h('span', { className: 'ddp-sep' }),
4577
6075
  h('div', { className: 'ddp-seg' },
4578
- h('button', { className: 'ddp-btn' + (canvas && !inMulti ? ' sel' : ''), title: t('canvas'), onClick: () => { setMulti(false); change({ view: 'canvas' }) } }, h(I.canvas)),
4579
- h('button', { className: 'ddp-btn' + (!canvas && !inMulti ? ' sel' : ''), title: t('single'), onClick: () => { setMulti(false); openSingle() } }, h(I.single)),
4580
- h('button', { className: 'ddp-btn' + (inMulti ? ' sel' : ''), title: sizeSet ? t('sizes') : t('sizesNo'), disabled: !sizeSet || !!ver, onClick: () => setMulti(!inMulti) }, h(I.sizes))),
4581
- h('button', { className: 'ddp-btn o2' + (demo ? ' sel' : ''), title: t('demo'), disabled: !url || !!ver, onClick: toggleDemo }, h(I.play)),
4582
- 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 }) } }),
4583
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)),
4584
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)),
4585
6083
  h('button', { className: 'ddp-btn o2' + (ck ? ' sel' : ''), title: ckCount && !ck ? t('checkCount', { n: ckCount }) : t('checkTip'), disabled: checkOff, onClick: () => (ck ? closeCheck() : runCheck()) },
@@ -4592,7 +6090,7 @@ window.__ModuleLoader__.load({
4592
6090
  h('button', { className: 'ddp-btn o1', title: t('refresh'), onClick: reload }, h(I.refresh)),
4593
6091
  h('a', { className: 'ddp-btn o1', title: t('external'), href: url, target: '_blank', rel: 'noopener noreferrer' }, h(I.external)),
4594
6092
  h(MoreMenu, { t, open: more, setOpen: setMore, has: (phoneOn && !phone) || (!!ckCount && !ck && !checkOff), items: [
4595
- ['o2', I.play, t('demoShort'), !!demo, !url || !!ver, toggleDemo],
6093
+ ['o2', I.play, t('demoShort'), !!demo, !url || !!ver || mark, toggleDemo],
4596
6094
  ['o2', I.stetho, t('check'), !!ck, checkOff, () => (ck ? closeCheck() : runCheck())],
4597
6095
  ['o2', I.history, t('history'), hist || !!ver, !fileTarget, toggleHist],
4598
6096
  ['o2', I.dial, t('tweaks'), pane === 'tweaks', !fileTarget || !!ver, () => openPane('tweaks')],
@@ -4616,6 +6114,7 @@ window.__ModuleLoader__.load({
4616
6114
  try {
4617
6115
  const r = await readRef(file)
4618
6116
  const saved = await post('ref', { session: sid, name: file.name || 'ref', data: r.data })
6117
+ keepThumb(saved.path, r.img)
4619
6118
  insert(formatRef(t, { path: saved.path, w: r.w, h: r.h, palette: r.palette, hasDesign: !!url }), 'insertedSend')
4620
6119
  } catch (e) {
4621
6120
  say(t('refFail', { msg: e.message }))
@@ -4624,11 +6123,30 @@ window.__ModuleLoader__.load({
4624
6123
  const imageOf = (list) => [...(list || [])].find((f) => f && /^image\//.test(f.type))
4625
6124
  const hasFiles = (e) => [...((e.dataTransfer && e.dataTransfer.types) || [])].includes('Files')
4626
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
+
4627
6134
  const rootProps = {
4628
6135
  className: 'ddp',
4629
6136
  tabIndex: -1, // 点了右侧栏就能在这里粘贴图片
4630
- onDragEnter: (e) => { if (hasFiles(e)) { e.preventDefault(); setDropping(true) } },
4631
- 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
+ },
4632
6150
  onPaste: (e) => {
4633
6151
  const f = imageOf(e.clipboardData && e.clipboardData.files)
4634
6152
  if (!f) return
@@ -4636,26 +6154,42 @@ window.__ModuleLoader__.load({
4636
6154
  addRef(f)
4637
6155
  },
4638
6156
  }
4639
- // 盖住整个右侧栏接住松手(不然落在页面 iframe 上就被它吃掉了)
6157
+ // 盖住整个右侧栏接住松手(不然落在页面 iframe 上就被它吃掉了);拖动在预览页面里时只是提示(soft),事件照样给页面
4640
6158
  const dropLayer = dropping ? h('div', {
4641
- className: 'ddp-drop',
6159
+ key: 'drop',
6160
+ className: 'ddp-drop' + (dropSoft ? ' soft' : ''),
6161
+ // 拖放中不会有 mousemove:鼠标一动就说明拖已经结束了(没收到 drop / dragleave 时别让蒙层一直盖着)
6162
+ onMouseMove: () => setDropping(''),
4642
6163
  onDragOver: (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy' },
4643
- onDragLeave: (e) => { if (e.target === e.currentTarget) setDropping(false) },
6164
+ onDragLeave: (e) => { if (e.target === e.currentTarget) setDropping('') },
4644
6165
  onDrop: (e) => {
4645
6166
  e.preventDefault()
4646
6167
  e.stopPropagation()
4647
- setDropping(false)
4648
- addRef(imageOf(e.dataTransfer.files))
6168
+ setDropping('')
6169
+ onDropped(e.dataTransfer)
4649
6170
  },
4650
- }, 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
4651
6176
  const fileInput = h('input', {
6177
+ key: 'refFile',
4652
6178
  ref: refInput,
4653
6179
  type: 'file',
4654
6180
  accept: 'image/png,image/jpeg,image/webp,image/gif',
4655
6181
  style: { display: 'none' },
4656
6182
  onChange: (e) => { const f = e.target.files && e.target.files[0]; e.target.value = ''; if (f) addRef(f) },
4657
6183
  })
4658
- 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)
4659
6193
 
4660
6194
  // 调节滑块 / 品牌设计系统 / 转成代码(右边的面板)
4661
6195
  const sidePane = () => {
@@ -4685,13 +6219,14 @@ window.__ModuleLoader__.load({
4685
6219
  })
4686
6220
  }
4687
6221
  if (pane === 'handoff') {
4688
- 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('') })
4689
6223
  }
4690
6224
  return null
4691
6225
  }
4692
6226
 
4693
6227
  if (!url) {
4694
- 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)
4695
6230
  if (busy) {
4696
6231
  // 模型在做第一屏:先放一个生成中的占位
4697
6232
  return root(
@@ -4699,7 +6234,8 @@ window.__ModuleLoader__.load({
4699
6234
  h('div', { className: 'ddp-wait' }, h('div', { className: 'card' }, h(Skeleton, {})), h('p', null, t('waiting'))),
4700
6235
  h('div', { className: 'ddp-glow' }),
4701
6236
  h('div', { className: 'ddp-ring' }),
4702
- busyChip('')))
6237
+ busyChip('')),
6238
+ toastEl)
4703
6239
  }
4704
6240
  return root(
4705
6241
  h('div', { className: 'ddp-empty' },
@@ -4712,13 +6248,13 @@ window.__ModuleLoader__.load({
4712
6248
  h('ol', { className: 'ddp-steps' }, h('li', null, t('step1')), h('li', null, t('step2')), h('li', null, t('step3'))),
4713
6249
  h('div', { className: 'ddp-tries' }, h('span', null, t('tryHead')),
4714
6250
  ['try1', 'try2', 'try3'].map((k) => h('button', { key: k, className: 'ddp-try', onClick: () => insert(t(k), 'insertedSend') }, t(k)))),
4715
- 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)) })),
4716
- h('p', null, t('emptyHint')),
4717
- 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 }),
4718
6253
  h('p', { className: 'ddp-refline' }, t('refEmpty'), ' · ', h('button', { className: 'lk', onClick: pickRef }, t('refChoose'))),
4719
- 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)) }))),
4720
6256
  pane === 'systems' ? sidePane() : null,
4721
- toast ? h('div', { className: 'ddp-toast' }, h('span', null, toast.text)) : null)
6257
+ toastEl)
4722
6258
  }
4723
6259
 
4724
6260
  let body
@@ -4735,7 +6271,7 @@ window.__ModuleLoader__.load({
4735
6271
  h('div', { className: 'ddp-single' },
4736
6272
  h('div', { className: 'ddp-fit' + (fill ? ' fill' : ''), style: fill ? undefined : { width: dw, height: dh, transform: `translate(-50%, -50%) scale(${z})` } },
4737
6273
  h(Device, { kind, w: size[0], h: size[1], fill, label, top: tops[l.key], os: meta.os },
4738
- h(Screen, { t, remote,
6274
+ h(Screen, { t, remote, onPageDrag: pageDrag,
4739
6275
  src: urlOfKey(l.key),
4740
6276
  label,
4741
6277
  busy: false,
@@ -4771,7 +6307,7 @@ window.__ModuleLoader__.load({
4771
6307
  sel: '',
4772
6308
  labelOf: (sc) => sc.label,
4773
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 },
4774
- h(Screen, { t, remote,
6310
+ h(Screen, { t, remote, onPageDrag: pageDrag,
4775
6311
  src: base.url,
4776
6312
  label: labelOf(base),
4777
6313
  busy,
@@ -4802,7 +6338,7 @@ window.__ModuleLoader__.load({
4802
6338
  const z = fill ? 1 : Math.max(0.05, Math.min(1, (box.w - 32) / dw, (box.h - 32 - (ver ? 46 : 0)) / dh))
4803
6339
  // 两边的小标签不跟着画面缩小(缩了看不清);手机、平板躲开状态栏
4804
6340
  const tagAt = (side) => ({ top: kind === 'app' || kind === 'miniprogram' || kind === 'tablet' ? 60 : 10, transform: `scale(${1 / z})`, transformOrigin: 'top ' + side })
4805
- 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,
4806
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) })
4807
6343
  body = h('div', { className: 'ddp-single' },
4808
6344
  h('div', { className: 'ddp-fit' + (fill ? ' fill' : ''), style: fill ? undefined : { width: dw, height: dh, transform: `translate(-50%, -50%) scale(${z})` } },
@@ -4841,10 +6377,12 @@ window.__ModuleLoader__.load({
4841
6377
  const [dw, dh] = deviceBox(kind, size)
4842
6378
  // 右边开着面板(体检、调样式、设计规范、换风格):手机这类有外框的让到面板左边(侧栏够宽时),别被挡住
4843
6379
  const aside = !fill && (ck || styleEdit || panel || styles || pane) && box.w - 296 >= 240 ? 296 : 0
4844
- 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))
4845
6383
  const one = ver ? shown.find((x) => x.key === focus) || shown[0] || { key: focus, url, label: st.label } : { key: focus, url, label: st.label }
4846
6384
  body = h('div', { className: 'ddp-single', style: aside ? { right: aside } : undefined },
4847
- 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})` } },
4848
6386
  renderScreen(one, z, fill)))
4849
6387
  }
4850
6388
 
@@ -4888,6 +6426,7 @@ window.__ModuleLoader__.load({
4888
6426
  onClose: () => setPhone(false),
4889
6427
  }) : null,
4890
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,
4891
6430
  demo ? h('div', { className: 'ddp-banner' },
4892
6431
  h(I.play),
4893
6432
  h('span', null, t('demoOn') + ' · ' + ((infoOf(demoKey) || {}).title || demoKey.replace(/\.html?$/i, ''))),
@@ -4901,7 +6440,7 @@ window.__ModuleLoader__.load({
4901
6440
  h('button', { className: 'ddp-go pri', onClick: doRestore }, t('restore')),
4902
6441
  h('button', { className: 'ddp-btn', title: t('backNow'), onClick: () => { setVer(null); setCmp(false) } }, h(I.close))) : null,
4903
6442
  chip,
4904
- 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),
4905
6444
  toast.act ? h('button', { onClick: () => { toast.act.run(); setToast('') } }, toast.act.label) : null) : null))
4906
6445
  }
4907
6446
 
@@ -4932,7 +6471,7 @@ window.__ModuleLoader__.load({
4932
6471
  // ── 注册 ─────────────────────────────────────────────────────────────────
4933
6472
 
4934
6473
  function apply(ctx) {
4935
- ensureStyle()
6474
+ try { ctx.effect(() => ensureStyle(), 'dsh-design-studio: styles') } catch (e) { console.warn('[dsh-design-studio] styles', e) }
4936
6475
  const t = makeT(ctx)
4937
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) }
4938
6477
 
@@ -4963,6 +6502,46 @@ window.__ModuleLoader__.load({
4963
6502
  inject: (sessionId) => ({ sid: sessionId, t }),
4964
6503
  }, PreviewTab)), 'dsh-design-studio: tab body')
4965
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
+
4966
6545
  ctx.effect(() => ctx.slots.inject('conversation.session.header.utilities', () => ctx.slots.register({
4967
6546
  name: 'conversation.session.header.utilities',
4968
6547
  id: 'dsh-design-studio',
@@ -4976,7 +6555,7 @@ window.__ModuleLoader__.load({
4976
6555
  // 注意:这里是客户端**服务名**(短的),不是包名
4977
6556
  exports.inject = INJECT
4978
6557
  // 测试用
4979
- exports._test = { 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 }
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 }
4980
6559
  return module.exports
4981
6560
  },
4982
6561
  })