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/README.en.md +2 -1
- package/README.md +2 -1
- package/lib/client.js +1660 -81
- package/lib/core.js +22 -9
- package/lib/handoff.js +119 -11
- package/lib/index.js +16 -9
- package/lib/picker.js +392 -32
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +2 -2
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
|
|
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}
|
|
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}
|
|
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: '
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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'
|
|
1157
|
-
|
|
1158
|
-
el
|
|
1159
|
-
|
|
1160
|
-
|
|
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}
|
|
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
|
|
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
|
-
|
|
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.
|
|
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('
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4631
|
-
|
|
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
|
-
|
|
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(
|
|
6164
|
+
onDragLeave: (e) => { if (e.target === e.currentTarget) setDropping('') },
|
|
4644
6165
|
onDrop: (e) => {
|
|
4645
6166
|
e.preventDefault()
|
|
4646
6167
|
e.stopPropagation()
|
|
4647
|
-
setDropping(
|
|
4648
|
-
|
|
6168
|
+
setDropping('')
|
|
6169
|
+
onDropped(e.dataTransfer)
|
|
4649
6170
|
},
|
|
4650
|
-
}, h('div', { className: 'in'
|
|
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
|
|
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
|
-
|
|
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('
|
|
4716
|
-
h('
|
|
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
|
-
|
|
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
|
-
|
|
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
|
})
|