dsh-design-studio 0.6.2 → 0.7.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 CHANGED
@@ -19,7 +19,7 @@
19
19
  >
20
20
  > **Use it for:** "make a landing page", "design an app screen", "make a poster", "make a few slides".
21
21
  >
22
- > **Install:** on the DSH Plugins page, click Add plugin and enter `dsh-design-studio` (an npm package), then restart DSH.
22
+ > **Install:** on the DSH Plugins page, click Add plugin, enter `dsh-design-studio` (an npm package), then click Enable now.
23
23
 
24
24
  A design workbench for DSH (DeepSeek Harness), in the spirit of Google Stitch. When the model builds a web page, an app screen, a poster or a slide deck, the **Design Studio** tab in the right sidebar shows it live. Screens appear one by one on a canvas, each inside a phone or browser frame. When something looks off, click the element or drag over an area. The plugin turns your pick into text in the chat input. Add "make it orange" and send. The model edits the source, and the change flashes in the preview.
25
25
 
@@ -43,12 +43,18 @@ dsh-design-studio
43
43
 
44
44
  It is an npm package. When DSH installs by package name and can't reach the official npm registry, it falls back to the npmmirror mirror in China, so GitHub is never needed. You can also enter the release tarball `https://github.com/OrderG-X/dsh-design-studio/releases/latest/download/dsh-design-studio.tgz`, which needs GitHub access.
45
45
 
46
- **Restart DSH** after installing, because client code is bundled when DSH starts. The plugin is listed as 设计工作台 / Design Studio on the Plugins page.
46
+ After installing, click **Enable now** on the card that pops up (plugins start disabled). Recent DSH versions need no restart: new sessions get Design mode in the mode menu right away. On an older DSH, restart once if Design Studio doesn't show up in the right sidebar. The plugin is listed as 设计工作台 / Design Studio on the Plugins page.
47
47
 
48
48
  **Updating:** uninstall it on the Plugins page and add it again. A new version can be installed by package name about a day after release, because the pnpm that DSH uses skips brand-new versions by default. To get it right away, use the release tarball.
49
49
 
50
50
  For development, enter the absolute path of a local checkout under **Add plugin**; it installs as a `link:`. Client changes still need a restart. `lib/picker.js` is served fresh on every request, so it doesn't.
51
51
 
52
+ ## Quick start
53
+
54
+ 1. Pick Design mode for a new session and say what to make, for example "Design a member app for a neighborhood gym". The empty sidebar also has a few example prompts you can click into the input.
55
+ 2. The model asks two or three questions with a question card, then lays out three directions side by side. Click **✓ Use this** next to the title of the one you like and send it; the model builds the rest.
56
+ 3. To change something, click Select, then the element. The caret waits on a new line in the input, so just type "bigger and bold" and send.
57
+
52
58
  ## What it does
53
59
 
54
60
  1. **Opens by itself, but only for design work.** Design work means the model loaded the design skill, called `preview_page`, or wrote a page with a design-type tag (`<meta name="dsh-design">`). After that, the tab pops up when the model writes an `.html` file or starts a dev server (`npm run dev` and the like). HTML written by other tasks doesn't pop up. Instead, a blue dot appears on the header button, and clicking it previews the file. Ports the model only probes with `curl` or the browser tools are never opened.
@@ -57,15 +63,15 @@ For development, enter the absolute path of a local checkout under **Add plugin*
57
63
  4. **Lets you watch it build.** A placeholder appears while the model works. Each new screen fades in from top to bottom with a scan line. While more screens are coming, a "generating…" card waits at the end of the canvas. When the model asks you something with a question card, the spinner turns into an orange dot reading "The model is waiting for your answer".
58
64
  5. **Refreshes live without a white flash.** The next frame loads in the background and cross-fades in, keeping the scroll position. Dev servers keep their own hot reload; Vite and Next WebSockets pass through, including dev servers that only listen on IPv6 `::1` (recent Vite on macOS).
59
65
  6. **Highlights what changed.** Changed and new elements glow briefly, and the preview scrolls to them if they are off screen. Shared CSS changes refresh every screen. A change to one screen's HTML leaves the others alone.
60
- 7. **Picks elements for the chat.** Hover outlines elements with their size. Click picks one element. Drag picks a region along with up to 10 main elements in it. The pick becomes a block in the chat input with the file, the CSS path, the HTML and key styles.
66
+ 7. **Picks elements for the chat.** Hover outlines elements with their size. Click picks one element. Drag picks a region along with up to 10 main elements in it. The pick becomes a block in the chat input with the file, the CSS path, the HTML and key styles. The caret waits on a new line below it, so you can type the change right away.
61
67
  8. **Ships a built-in design guide.** The DSH skill `ui-design` loads before the model designs. It sets the type and canvas, writes `tokens.css` first and uses one file per screen. It covers spacing, type scale, color, contrast, Chinese typography, an "AI-look" blacklist and a self-check list, with notes for each design type. For a brand-new design the model first asks two or three key questions, then lays out three directions for the first screen (`home-a/b/c.html`, each with its own colors and fonts) side by side; you pick one and it builds the rest, moving the others to `drafts/`. If you already chose a feel in the questions, the three directions all stay in that feel and differ in layout and emphasis. Say "just build it", or give a specific brief, to skip this.
62
68
  9. **Has a design tokens panel.** It lists the `:root` variables grouped by color, font, size, spacing, radius and shadow, with swatches. Click one to send it to the chat, say "make it forest green", and every screen updates together.
63
69
  10. **Exports.** You can export one screen framed or plain, a full-page capture, or a **showcase** of all screens. The showcase uses the brand name as its title and takes its background and accent from your tokens. You can also export every screen as a **PDF** (one page per screen; web and email pages are full length, and page size follows the canvas) or a **PowerPoint** file (one slide per screen as an image, with the slide shape following the canvas; opens in PowerPoint, WPS and Keynote). Links between screens still work: in the PDF a button jumps to its page (and every screen has a bookmark), and in a slideshow it jumps to its slide, so the export is a clickable prototype. Files go to `exports/` in the design folder, and images are also copied to the clipboard.
64
70
  11. **Keeps versions.** A version is saved before and after every model turn, named after what you asked (a picked element becomes a short name such as “「3.2 km」 · make it green”, not a selector dump). Open any version, drag the slider to **compare** it with now, or **restore** it. The current state is saved first, so restoring is always reversible.
65
71
  12. **Switches to dark mode.** If the page has dark styles (`prefers-color-scheme: dark`, `[data-theme="dark"]` or `.dark`), a moon button toggles them, and exports follow.
66
- 13. **Makes two more versions.** Select a screen and ask for two alternative directions. They appear right next to the original on the canvas (the three starting directions `home-a/b/c` too, kept in a, b, c order). Click the check mark on the one you want: the chat input gets "use this one, move the other takes to `drafts/`, build the rest", ready to send.
72
+ 13. **Makes two more versions.** Select a screen and ask for two alternative directions. They appear right next to the original on the canvas (the three starting directions `home-a/b/c` too, kept in a, b, c order). Click **✓ Use this** next to the title of the one you want (it shows on every take, no need to select it first): the chat input gets "use this one, move the other takes to `drafts/`, build the rest", ready to send.
67
73
  14. **Views on your phone.** The scan button shows a QR code in the sidebar. Scan it with a phone on the same Wi-Fi and the design opens in the phone's browser at real size, fully tappable. The phone follows the screen you pick on the computer and refreshes when the model changes the design. The panel shows when the phone has opened it, lets you copy the link and stop sharing, and closes the link after 30 minutes idle.
68
- 15. **Works as a clickable prototype.** Screens link to each other with `<a href="detail.html">` or `data-href` (the built-in skill asks the model to do this). Select a screen on the canvas to see arrows for where it leads and what leads to it. Arrows that would cross another screen route around it, above the titles. The play button starts a demo: tap a button in the preview and the linked screen slides in, from the right going forward and from the left going back. The banner has back, restart and exit (Esc).
74
+ 15. **Works as a clickable prototype.** Screens link to each other with `<a href="detail.html">` or `data-href` (the built-in skill asks the model to do this). Select a screen on the canvas to see where it leads: each line starts at the button, runs through the gaps between screens with rounded corners, keeps its own lane and avoids crossings, and never cuts through another screen. Navigation that every screen has (tab bar, back) just gets a dashed outline instead of a line. The play button starts a demo: tap a button in the preview and the linked screen slides in, from the right going forward and from the left going back. The banner has back, restart and exit (Esc).
69
75
  16. **Shows one screen at several sizes.** The third view button lays the current screen out at several widths: phone, tablet and desktop for web; small, standard and large for apps; three sizes for tablets. Cramped small screens show at a glance. Picking works there too and notes the width.
70
76
  17. **Edits text in place.** Turn on the text button and click any text in the preview to edit it. Enter saves and Esc cancels. Local HTML is written back to the source file, only that one spot, with a version saved before and after. For dev-server pages, or when the text isn't in the source, a "change X to Y" request goes into the chat input for the model.
71
77
  18. **Takes a reference image.** Drop an image onto the sidebar, paste it there, or use "Restyle from a reference…" in the tokens panel. The image is saved to `refs/` in the design folder (or the plugin cache when there is none). Its main colors with their shares, any small vivid accent, and whether it reads light or dark go into the chat input with the file path and an instruction to update `tokens.css` first.
package/README.md CHANGED
@@ -19,7 +19,7 @@
19
19
  >
20
20
  > **什么时候用**:「做个落地页」「设计一个 App 界面」「做张海报」「做几页幻灯片」。新会话可以直接选「设计模式」。
21
21
  >
22
- > **安装**:DSH「插件」页 →「添加插件」,填 `dsh-design-studio`(npm 包,连不上 npm 会自动换国内镜像),重启 DSH。
22
+ > **安装**:DSH「插件」页 →「添加插件」,填 `dsh-design-studio`(npm 包,连不上 npm 会自动换国内镜像),装好点「立即启用」。
23
23
 
24
24
  让 DSH(DeepSeek Harness)做界面设计时像 Google Stitch / Claude Code 桌面端那样:模型做网页、App 界面、海报、幻灯片时,右侧栏「设计工作台」实时显示——一屏一屏生成出来,在画布上并排摆好,套上手机 / 浏览器外框;看到哪里不对,点一下那个元素、或拖出一块区域,它就整理成一段文字放进输入框,补一句「改成橙色」发出去,模型照着源码改,改过的地方在预览里闪一下。
25
25
 
@@ -43,12 +43,18 @@ dsh-design-studio
43
43
 
44
44
  这是 npm 包:按包名装时,DSH 连不上 npm 官方源会自动改用国内镜像(npmmirror),不经过 GitHub。也可以填发布包地址 `https://github.com/OrderG-X/dsh-design-studio/releases/latest/download/dsh-design-studio.tgz`,那样要能连上 GitHub。
45
45
 
46
- 装完**重启 DSH**(客户端代码在 DSH 启动时打包)。DSH「插件」页里显示为「设计工作台」。
46
+ 装好后在弹出的卡片上点**「立即启用」**(默认是关的)。新版 DSH 不用重启:新会话的模式菜单里就有「设计模式」。老版本 DSH 右侧栏里找不到「设计工作台」的话,重启一次。DSH「插件」页里显示为「设计工作台」。
47
47
 
48
48
  **更新**:在「插件」页把它卸载,再按上面添加一次。新版发布后大约一天按包名才装得到(DSH 装插件用的 pnpm 默认不装刚发布的版本),急的话填发布包地址。
49
49
 
50
50
  开发时可以直接链到本地目录:在「添加插件」里填本仓库的绝对路径,装上的是 `link:`,改了客户端代码同样要重启(`lib/picker.js` 每次现给,不用重启)。
51
51
 
52
+ ## 三步上手
53
+
54
+ 1. 新会话的模式选「设计模式」,说一句要做什么,比如「帮我设计一个社区健身房的会员 App」。右侧栏的空白页上也有几句示例,点一下就放进输入框。
55
+ 2. 模型先用提问卡问两三个问题,答完出三个方向并排在画布上。看中哪个,点它标题旁的「✓ 就要这个」,发出去,模型接着做全套。
56
+ 3. 哪里不对,点「选择」再点那个元素,光标会停在输入框新的一行,接着说「字大一点、加粗」,发出去。
57
+
52
58
  ## 用起来是什么样
53
59
 
54
60
  1. **做设计时自动打开**:模型加载了设计技能、调了 `preview_page`,或页面写了设计类型标记(`<meta name="dsh-design">`)之后,再写 `.html` 文件或用命令起开发服务器(`npm run dev` 这类),右侧栏自动弹出「设计工作台」。别的任务写的网页不弹,只在标题栏按钮上亮个蓝点,点开能一键预览;`curl`、浏览器工具去看别的端口,一律不当成要预览的页面。
@@ -62,7 +68,7 @@ dsh-design-studio
62
68
  - 点一下:选这个元素
63
69
  - 拖一块:选区域,里面的主要元素一起带上(最多 10 个)
64
70
  - Esc:取消
65
- 9. **进输入框**:选好的内容整理成一段文字插进输入框(插不进时复制到剪贴板),选中的地方留一个虚线框,例如:
71
+ 9. **进输入框**:选好的内容整理成一段文字插进输入框(插不进时复制到剪贴板),光标停在下面新的一行,直接接着打要怎么改;选中的地方留一个虚线框,例如:
66
72
 
67
73
  ```
68
74
  〔选中元素〕a.btn-order「立即点单 →」
@@ -78,9 +84,9 @@ dsh-design-studio
78
84
  12. **导出**:工具栏下载按钮,或画布上选中一屏后标签旁的按钮。可以导出这一屏(带手机 / 浏览器外框,或纯画面)、整页长图,以及几屏合成的一张展示图(品牌名做大标题、背景和主色取自设计变量,适合直接发朋友圈 / 小红书 / 作品集)。也能把所有屏导成 **PDF**(每屏一页,网页 / 邮件是整页长图,页面尺寸跟着画幅)或 **PPT**(每屏一张幻灯片,整页图片,幻灯片比例跟着画幅,PowerPoint / WPS / Keynote 都能打开)。屏和屏之间的跳转照样能点:PDF 里点「去点单」翻到菜单那页(每屏还有一条书签),PPT 放映时点了跳到那一张,导出的文件就是能点的原型。文件存到设计目录的 `exports/`,图片同时复制到剪贴板,点「打开文件夹」直接定位。
79
85
  13. **版本**:每轮对话开始前、结束后各自动存一版(只存设计文件,名字就是你那句话,例如「主色换成深海蓝」;点选元素发出去的,名字是「「3.2 km」 · 改成绿色」这样,不会是一长串选择器)。工具栏时钟按钮列出所有版本;点一版就看当时的样子(画布 / 单页都行),点「对比」左右拖动把手,比较那一版和现在;「回到这一版」把文件写回去(回之前先把现在的存一版,随时能再切回来)。
80
86
  14. **深色模式**:页面写了深色样式(`@media (prefers-color-scheme: dark)`,或 `[data-theme="dark"]` / `.dark`),工具栏多一个月亮按钮,一键切换看深色版,导出的图片也是深色的。
81
- 15. **再出两版**:画布上选中一屏,点标签旁的闪光按钮,让模型给这一屏出两版不同方向的设计(`login-b.html`、`login-c.html`),紧挨着原版排在画布上(开局的三个方向 `home-a/b/c` 也一样,按 a、b、c 排)。挑中哪版就点它标签旁的对勾「就要这个」:输入框里写好「用这一版、其他版本挪进 `drafts/`、接着做全套」,发出去就行。
87
+ 15. **再出两版**:画布上选中一屏,点标签旁的闪光按钮,让模型给这一屏出两版不同方向的设计(`login-b.html`、`login-c.html`),紧挨着原版排在画布上(开局的三个方向 `home-a/b/c` 也一样,按 a、b、c 排)。挑中哪版就点它标题旁的「✓ 就要这个」(几个版本并排时每屏都挂着,不用先选中):输入框里写好「用这一版、其他版本挪进 `drafts/`、接着做全套」,发出去就行。
82
88
  16. **在手机上看**:工具栏扫码按钮,右侧栏出一个二维码,手机(和电脑连同一个 Wi-Fi)相机一扫就在手机浏览器里打开这套设计,按真机尺寸看、点得动。电脑上点哪一屏,手机跟着跳;模型改了设计,手机自动刷新。面板上看得到「手机已打开」,也能复制链接、随时停止;30 分钟没人看自动关。
83
- 17. **可点原型**:屏和屏之间用链接连起来(`<a href="detail.html">` 或 `data-href`,内置技能会让模型这么写),画布上选中一屏,就画出它跳去哪、从哪跳来的箭头;隔着别的屏的箭头从屏和屏之间的空当、标题上面绕过去,不穿过中间那屏。点工具栏播放按钮进入演示:在预览里点按钮跳到对应那一屏,前进从右边滑进来、返回从左边;顶上能后退、从头来、退出(Esc)。
89
+ 17. **可点原型**:屏和屏之间用链接连起来(`<a href="detail.html">` 或 `data-href`,内置技能会让模型这么写),画布上选中一屏,就画出它能跳去哪:从按钮出发、沿屏和屏之间的空当横平竖直走、转角带圆角,几条线各走各的道、尽量不交叉,不穿过别的屏;底栏、返回这种每屏都有的导航只描虚线框,不画线。点工具栏播放按钮进入演示:在预览里点按钮跳到对应那一屏,前进从右边滑进来、返回从左边;顶上能后退、从头来、退出(Esc)。
84
90
  18. **多尺寸并排**:工具栏第三个视图按钮,把现在这一屏按几档宽度并排看:网页是手机 / 平板 / 桌面,App 是小屏 / 标准 / 大屏(小屏是 SE 那种矮屏,没有灵动岛),平板是三种尺寸,小屏挤不挤一眼看出来。并排时也能选择元素,带上是哪一档屏宽。
85
91
  19. **预览里直接改字**:工具栏「改字」按钮,点页面里的任意文字就地改,回车保存、Esc 取消。本地 HTML 直接写回源文件(只改那一处,改前改后各存一版,能回退);开发服务器的页面,或源码里找不到那段字时,改成一句「把「…」改成「…」」放进输入框,交给模型改。
86
92
  20. **参考图**:把图片拖进右侧栏、在右侧栏里粘贴,或在设计规范面板点「照参考图改风格…」。图片存进设计目录的 `refs/`(还没有设计目录时存插件缓存),右侧栏从图里取出主要颜色(带占比,少见的鲜艳点缀色也会单独挑出来)和整体深浅,连同图片路径和一句「照这张图的风格改,先更新 tokens.css」放进输入框。
@@ -178,9 +184,9 @@ dsh-design-studio
178
184
  ## 测试
179
185
 
180
186
  ```sh
181
- node test/test-serve.mjs # 静态文件、跳不出目录、Host 把门、改动推送、代理、WebSocket、只听 ::1 的开发服务器、连不上的提示页
187
+ node test/test-serve.mjs # 静态文件、跳不出目录、Host 把门、改动推送、代理、WebSocket、只听 ::1 的开发服务器、连不上的提示页;真浏览器:右侧栏里页面不自己开改动通知(老版右侧栏 3 秒后才开)、导出图里画出外部 icons.svg 的图标
182
188
  node test/test-core.mjs # 地址解析、自动发现(只在做设计时,后台跑的开发服务器也认)、模型 / 用户指定的不被顶掉、重放不动手、存盘恢复(恢复中先不显示空状态)、类型 / 几屏 / 换屏、改字写回、调样式写回(认元素、合并 style、只收安全的值)
183
- node test/test-client.mjs # 右侧栏标签页 / 标题栏按钮注册、只给当前会话自动弹出、选中内容整理成的文字、页面 meta、画布排版、设计变量分组、原型箭头(隔屏的绕开别的屏)、多尺寸排版、矮屏手机没有灵动岛、参考图取色
189
+ node test/test-client.mjs # 右侧栏标签页 / 标题栏按钮注册、只给当前会话自动弹出、选中内容整理成的文字、页面 meta、画布排版、设计变量分组、原型连线(走空当、不穿屏、不交叉、导航只描框)、多尺寸排版、矮屏手机没有灵动岛、参考图取色、插进输入框前后怎么换行
184
190
  node test/test-skill.mjs # 内置技能:名字 / 来源合规、内容读得到、引用的细则文件都在;拆开的几个组件、设计模式带齐标准模式的工具
185
191
  node test/test-history.mjs # 版本:存一版 / 没变不存 / 只存设计文件 / 回到某一版 / 上限、每轮前后存、版本名(点选 / 变量 / 参考图换成短名字)、旧版本文件、导出的图不算改动
186
192
  node test/test-bundle.mjs # 远程看的打包:样式表 / @import / url() / 脚本 / 图片 / srcset 内联,外网的、找不到的、越界的原样留着,<base> 和 picker 放对地方,总量上限,@import 循环
@@ -196,6 +202,8 @@ node e2e/e2e-studio.mjs # 真机(要本机 Chrome,会从 npm 装一个临
196
202
 
197
203
  ## 已验证
198
204
 
205
+ 新手走查(2026-11,DSH 0.2.1-rc.5):按包名装、点「立即启用」不重启就能用;桌面端设计模式里真模型(DeepSeek-V4.1-Flash)从一句「帮我设计一个社区健身房的会员 App」做到六屏全套,再点选改一处。走下来修了五处:画布到 6 屏后预览不再刷新(每屏各开一条改动通知,占满了浏览器对同一地址的 6 条连接,现在由右侧栏转告);电脑开着「总是显示滚动条」时 App 按 375 宽排版,右侧栏体检和模型的体检对不上;导出的图里外部 `icons.svg` 的图标是空的;整套展示图的大标题是文件夹名;点选进输入框后接着打的字粘在最后一行后面。另外加了几个方向并排时每屏都挂的「✓ 就要这个」和空白页的示例句子。
206
+
199
207
  第一期之后又打磨了一轮,同样在全新的 DSH 0.2.1-rc.3 网页版里实测(`e2e/e2e-studio.mjs`,40 项全过):模型用提问卡问话时,右侧栏提示「模型在等你回答」,答完提示消失;模型调 `check_design` 前后各一次,第二次只剩它改坏的那屏;「再出一版」写的 home-b 在画布上紧挨着首页;选中它点「就要这个」,输入框里是〔选定方向〕,让模型把原版挪进 `drafts/`。真模型(DeepSeek-V4.1-Flash)在桌面端设计模式里写完首页,自己调了两次 `check_design`(右侧栏量完把清单交回),改完再量一遍「0 个问题」,没有再另开浏览器截图;它写的 `_probe.html` 这类临时文件不再出现在画布上。新加的「文字被挤断行」拿真模型写的首页试,查出 5 处(「可外带」「再来一单」和三个价格)。
200
208
 
201
209
  第一期(导出 PDF / PPT、设计体检)在一个全新的 DSH 0.2.1-rc.3 网页版里实测(`e2e/e2e-studio.mjs`,36 项全过):打开设计后按钮上自动标出 4 个问题;模型调 `check_design` 拿回同样的清单(只列首屏,面板关着时再调只列购物车);模型调 `preview_page` 开三屏 App,体检只在首屏查出埋的四个问题(文字截断、对比度 1.59:1、26×26 的按钮、8px 小字),另两屏零问题,预览里标上编号,点一条闪一下;「交给模型修」把清单放进输入框,发出去模型收到;改好首屏「再查一次」没问题;模型改完这一轮自动再量、按钮上的数字没了;面板开着时模型改坏购物车,面板自己刷新,被挡住的那屏挪出来;导出的 PDF 三页、PPT 三张竖版幻灯片,存进 `exports/`,不当成设计改动,里面「去点单」「加入购物车」能点着跳页,PDF 带每屏书签。PPT 也在 PowerPoint 里打开过,三张都正常、没有修复提示。桌面端重启后插件照常加载。真模型(DeepSeek-V4.1-Flash)在桌面端设计模式里说「帮我设计一个咖啡外带 App 的界面」:先用提问卡问了三个问题(感觉、场景、必须有的),答完写出 home-a / b / c 三个方向并排在画布上,最后再用提问卡问选哪个(也可以混搭)。这次发现两点已改:问完感觉后三个方向不该再跨风格;模型靠另开浏览器截图自查,一轮用掉一百多万 token——所以加了 `check_design`,并让三个方向在画布上按 a、b、c 排。
package/lib/client.js CHANGED
@@ -196,6 +196,7 @@ window.__ModuleLoader__.load({
196
196
  dark: '深色模式',
197
197
  variants: '再出两版',
198
198
  pickThis: '就要这个:其他版本挪进 drafts/,接着做全套',
199
+ pickShort: '就要这个',
199
200
  pickText: '〔选定方向〕就用「{name}」({where})。先把同组的 {others} 挪进 drafts/ 子目录(别删),再把它改名成 {base}.html;它的设计变量搬进 tokens.css(没有就新建)、改用 tokens.css。然后接着做全套。\n',
200
201
  pickKeep: '〔选定方向〕留原版「{name}」({where})。同组的 {others} 挪进 drafts/ 子目录(别删),其他不动。\n',
201
202
  variantText: '给「{name}」再出两版不同方向的设计(布局或视觉风格明显不同),存成同目录下的 {b}-b.html 和 {b}-c.html,会紧挨着原版排在画布上。原版和 tokens.css 别动;新版要换配色就在自己文件里覆盖变量。\n{where}\n',
@@ -297,6 +298,10 @@ window.__ModuleLoader__.load({
297
298
  step1: '让模型做一个网页、App 界面或海报',
298
299
  step2: '点「选择」,点元素或拖出一块区域',
299
300
  step3: '在输入框补一句要怎么改,发出去',
301
+ tryHead: '不知道怎么开口?点一句试试',
302
+ try1: '帮我设计一个社区健身房的会员 App',
303
+ try2: '做一个精品咖啡店的落地页',
304
+ try3: '做一张周末市集的活动海报',
300
305
  inserted: '已放进输入框,补一句要怎么改',
301
306
  insertedSend: '已放进输入框,发出去就行',
302
307
  copied: '已复制,粘贴到输入框里',
@@ -504,6 +509,7 @@ window.__ModuleLoader__.load({
504
509
  dark: 'Dark mode',
505
510
  variants: 'Two more takes',
506
511
  pickThis: 'Use this one: move the other takes to drafts/ and build the rest',
512
+ pickShort: 'Use this',
507
513
  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',
508
514
  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',
509
515
  variantText: 'Make two alternative designs of "{name}" in clearly different directions (layout or visual style). Save them next to it as {b}-b.html and {b}-c.html; they will sit right after the original on the canvas. Leave the original and tokens.css unchanged; override variables inside the new files if they need other colors.\n{where}\n',
@@ -605,6 +611,10 @@ window.__ModuleLoader__.load({
605
611
  step1: 'Ask the model for a web page, app screen or poster',
606
612
  step2: 'Click Select, then an element or drag a region',
607
613
  step3: 'Say what to change in the input and send',
614
+ tryHead: 'Not sure where to start? Try one',
615
+ try1: 'Design a member app for a neighborhood gym',
616
+ try2: 'Make a landing page for a specialty coffee shop',
617
+ try3: 'Make a poster for a weekend street market',
608
618
  inserted: 'Added to the input. Say what to change',
609
619
  insertedSend: 'Added to the input. Just send it',
610
620
  copied: 'Copied. Paste it into the input',
@@ -899,6 +909,11 @@ window.__ModuleLoader__.load({
899
909
  .ddp-sc .pv .ln{position:absolute;left:6px;top:6px;width:46%;height:3px;border-radius:2px;opacity:.85}
900
910
  .ddp-sc .pv .ln.s{top:12px;width:30%;opacity:.6}
901
911
  .ddp-sc .nm{font-size:12px;line-height:16px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
912
+ .ddp-tries{display:flex;flex-wrap:wrap;justify-content:center;gap:6px;max-width:440px}
913
+ .ddp-tries>span{flex-basis:100%;font-size:11px;line-height:16px}
914
+ .ddp-try{height:28px;padding:0 12px;border:1px solid var(--dsw-alias-separator-primary,rgba(127,127,127,.3));border-radius:999px;background:transparent;
915
+ color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;cursor:pointer}
916
+ .ddp-try:hover{border-color:${ACCENT};color:${ACCENT}}
902
917
  .ddp-stylepick{width:100%;max-width:440px;margin:4px 0 6px;text-align:left}
903
918
  .ddp-stylepick>b{display:block;margin-bottom:2px;font-size:13px;font-weight:500;color:var(--dsw-alias-label-primary)}
904
919
  .ddp-stylepick>p{margin:0 0 10px;font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary)}
@@ -927,10 +942,12 @@ window.__ModuleLoader__.load({
927
942
  .ddp-ver span{font-size:12px;color:var(--dsw-alias-label-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
928
943
  .ddp-ver small{font-size:11px;color:var(--dsw-alias-label-tertiary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
929
944
  .ddp-flows{position:absolute;left:0;top:0;overflow:visible;pointer-events:none;z-index:20}
930
- .ddp-flows path{fill:none;stroke:${ACCENT};stroke-linecap:round;transition:opacity .2s}
945
+ .ddp-flows .halo{fill:none;stroke:var(--dsw-alias-bg-layer-2);stroke-linecap:round;stroke-linejoin:round}
946
+ .ddp-flows .ln{fill:none;stroke:${ACCENT};stroke-linecap:round;stroke-linejoin:round}
931
947
  .ddp-flows .hd{fill:${ACCENT};stroke:none}
932
- .ddp-flows circle{fill:${ACCENT};stroke:#fff}
933
- .ddp-flows g.dim{opacity:.45}
948
+ .ddp-flows .dot{fill:${ACCENT};stroke:#fff}
949
+ .ddp-flows .hot{fill:rgba(77,107,254,.1);stroke:${ACCENT}}
950
+ .ddp-flows .hot.nav{fill:none;stroke-opacity:.75}
934
951
  .ddp-demo{position:absolute;inset:0;overflow:hidden}
935
952
  .ddp-demo .lay{position:absolute;inset:0;transition:transform .42s ${EASE},opacity .42s ${EASE}}
936
953
  .ddp-demo .lay.wait{opacity:0;pointer-events:none}
@@ -966,6 +983,14 @@ window.__ModuleLoader__.load({
966
983
  .ddp-cmp .tag.l{left:10px}
967
984
  .ddp-cmp .tag.r{right:10px;background:${ACCENT}}
968
985
  .ddp-act{margin-left:auto;display:flex;gap:1px;flex:none}
986
+ .ddp-take{flex:none;display:inline-flex;align-items:center;gap:3px;height:20px;padding:0 8px 0 6px;border:0;border-radius:10px;
987
+ background:rgba(77,107,254,.12);color:${ACCENT};font:inherit;font-size:11px;font-weight:500;cursor:pointer}
988
+ .ddp-take:hover{background:rgba(77,107,254,.2)}
989
+ .ddp-take svg{width:12px;height:12px;flex:none}
990
+ /* 画布缩得很小时只留 ✓,屏名先保住 */
991
+ .ddp-lab{container-type:inline-size}
992
+ .ddp-lab>span:first-child{min-width:3em}
993
+ @container (max-width:170px){.ddp-take{padding:0 4px}.ddp-take span{display:none}}
969
994
  .ddp-act .ddp-btn{height:22px;min-width:22px;padding:0 4px;color:${ACCENT}}
970
995
  .ddp-act .ddp-btn svg{width:13px;height:13px}
971
996
  @container (max-width:500px){.ddp-btn.pick span,.ddp-kind>.ddp-btn span{display:none}}
@@ -1206,6 +1231,56 @@ window.__ModuleLoader__.load({
1206
1231
  return lines.join('\n') + '\n'
1207
1232
  }
1208
1233
 
1234
+ // 插进输入框的字前后怎么接。prev / next 是光标前后的字(读不到为 null),start 是光标位置。
1235
+ // 光标前有字、又不在行首 → 先换行;open(要用户接着说要求)且字以换行结尾 → 末尾多留一个空行给光标:
1236
+ // 输入框里末尾单个换行显示不出来,光标会回到上一行末尾,接着打的字就粘在那行后面。
1237
+ // 不以换行结尾的(「照这个风格来做:」)光标就跟在后面接着写
1238
+ function padInsert(text, prev, next, start, open) {
1239
+ const lead = start > 0 && !(prev != null && prev.endsWith('\n')) ? '\n' : ''
1240
+ let body = text
1241
+ let back = 0
1242
+ if (open && body.endsWith('\n')) {
1243
+ body = body.replace(/\n+$/, '') + '\n'
1244
+ if (!(next != null && next.startsWith('\n'))) { body += '\n'; back = 1 }
1245
+ }
1246
+ return { text: lead + body, back }
1247
+ }
1248
+
1249
+ // DSH 的输入框(只认一个;分屏开着多个时不碰)
1250
+ function composerEl() {
1251
+ try {
1252
+ const els = document.querySelectorAll('[data-lexical-editor="true"]')
1253
+ return els.length === 1 ? els[0] : null
1254
+ } catch { return null }
1255
+ }
1256
+
1257
+ // 输入框的纯文本,段落按换行连起来(和 captureInsertion 的位置对得上);里面有引用块等别的东西时对不上,给 null
1258
+ function composerText(el) {
1259
+ if (!el || el.querySelector(':scope > :not(p), p :not(span[data-lexical-text]):not(br)')) return null
1260
+ return Array.from(el.children).map((p) => p.textContent).join('\n')
1261
+ }
1262
+
1263
+ // 插完把键盘交回输入框,光标放在刚插的字后面(再往回 back 个字):用户直接接着说要改什么,或者直接回车发出
1264
+ function focusAfter(el, text, back) {
1265
+ if (!el) return
1266
+ el.focus({ preventScroll: true })
1267
+ const nodes = []
1268
+ let all = ''
1269
+ const w = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
1270
+ for (let n = w.nextNode(); n; n = w.nextNode()) { nodes.push([n, all.length]); all += n.data }
1271
+ const at = all.lastIndexOf(text)
1272
+ const r = document.createRange()
1273
+ if (at < 0) { r.selectNodeContents(el); r.collapse(false) } else {
1274
+ const end = at + text.length - back
1275
+ const hit = nodes.find(([n, o]) => o + n.data.length >= end) || nodes[nodes.length - 1]
1276
+ r.setStart(hit[0], end - hit[1])
1277
+ r.collapse(true)
1278
+ }
1279
+ const s = window.getSelection()
1280
+ s.removeAllRanges()
1281
+ s.addRange(r)
1282
+ }
1283
+
1209
1284
  // ── 设计变量 ─────────────────────────────────────────────────────────────
1210
1285
 
1211
1286
  const TOKEN_GROUPS = ['color', 'font', 'size', 'space', 'radius', 'shadow', 'other']
@@ -1910,6 +1985,7 @@ window.__ModuleLoader__.load({
1910
1985
 
1911
1986
  /**
1912
1987
  * n 屏排成网格:试每种列数,挑缩进舞台后最大的那种。
1988
+ * 一样大时(高度先到顶了)多放几列:拉宽右侧栏就横着多排几屏,像网页卡片换行;但不让最后一行孤零零剩一屏。
1913
1989
  * 行距 = 按比例的空隙 + 标题的高度(标题不缩放,所以换算回画布坐标是 LAB / z)。
1914
1990
  */
1915
1991
  function layoutGrid(n, dw, dh, sw, sh) {
@@ -1924,7 +2000,8 @@ window.__ModuleLoader__.load({
1924
2000
  const zw = (W - (cols - 1) * GAP) / (cols * dw + (cols - 1) * gx)
1925
2001
  const zh = (H - (rows - 1) * LAB) / (rows * dh + (rows - 1) * gy)
1926
2002
  const z = Math.min(zw, zh)
1927
- if (!best || z > best.z + 1e-9) best = { cols, rows, z }
2003
+ const tie = best && Math.abs(z - best.z) <= best.z * 1e-6
2004
+ if (!best || z > best.z * (1 + 1e-6) || (tie && (rows === 1 || count - (rows - 1) * cols > 1))) best = { cols, rows, z }
1928
2005
  }
1929
2006
  const zl = clampZ(Math.min(1, best.z))
1930
2007
  const stepX = dw + gx + GAP / zl
@@ -1974,102 +2051,307 @@ window.__ModuleLoader__.load({
1974
2051
  return k
1975
2052
  }
1976
2053
 
1977
- /** 每屏报上来的跳转 → 一屏到另一屏一条(同一对只留第一处) */
2054
+ /**
2055
+ * 每屏报上来的跳转 → 一屏到另一屏一条,hots 是这一对所有的按钮。
2056
+ * nav:「导航」按钮(底栏、返回这种好几屏同一个位置都有、跳到同一屏的),画布上只描框、不画线
2057
+ */
1978
2058
  function flowsOf(screens, linksOf, file) {
1979
2059
  const keys = new Set(screens.map((s) => s.key))
1980
- const seen = new Set()
2060
+ const spot = (to, l) => to + '|' + Math.round(l.x / 12) + '|' + Math.round(l.y / 12)
2061
+ const where = new Map() // 同一个位置、跳到同一屏的按钮出现在几屏上
2062
+ const per = screens.map((sc) => (linksOf(sc) || []).map((l) => ({ ...l, to: keyOfPath(l.path, file) })))
2063
+ per.forEach((ls) => { for (const k of new Set(ls.map((l) => spot(l.to, l)))) where.set(k, (where.get(k) || 0) + 1) })
1981
2064
  const out = []
1982
- for (const sc of screens) {
1983
- for (const l of linksOf(sc) || []) {
1984
- const to = keyOfPath(l.path, file)
1985
- if (to === sc.key || !keys.has(to)) continue
1986
- const k = sc.key + '>' + to
1987
- if (seen.has(k)) continue
1988
- seen.add(k)
1989
- out.push({ from: sc.key, to, x: l.x, y: l.y, w: l.w, h: l.h })
2065
+ screens.forEach((sc, i) => {
2066
+ const pairs = new Map()
2067
+ for (const l of per[i]) {
2068
+ if (l.to === sc.key || !keys.has(l.to)) continue
2069
+ const hot = { x: l.x, y: l.y, w: l.w, h: l.h, nav: (where.get(spot(l.to, l)) || 0) >= 3 }
2070
+ if (!pairs.has(l.to)) pairs.set(l.to, [])
2071
+ pairs.get(l.to).push(hot)
1990
2072
  }
1991
- }
2073
+ for (const [to, hots] of pairs) out.push({ from: sc.key, to, ...hots[0], hots })
2074
+ })
1992
2075
  return out
1993
2076
  }
1994
2077
 
1995
2078
  /**
1996
- * 连线的几何(画布坐标):从页面里那个按钮的边上出发,落到目标屏的左边(目标在左边就落右边),
1997
- * 两头水平进出的三次曲线;同一屏进来好几条,落点上下错开。
2079
+ * 选中那一屏「通向哪」的连线(画布坐标)。线只走屏和屏之间的缝(列间的竖缝、行间的横缝、外圈),不穿过别的屏:
2080
+ * 从按钮边上出发,就近出屏,在缝里直角转弯(圆角),落到目标屏的边上。几条线各走一道(错开几像素),不叠在一起。
2081
+ * 每种出 / 进的边都试一遍,挑最短、拐弯最少、在屏里走得最少的。
2082
+ * 只有导航按钮连着的(nav)不画线,只给按钮描虚线框。opts.z 是画布缩放(换算几像素的间距),opts.lift 是屏上面标题那一条的高度。
1998
2083
  */
1999
2084
  function flowLines(flows, keys, pos, kind, size, opts = {}) {
2000
2085
  const f = FRAME[kind] || FRAME.web
2001
2086
  const [dw, dh] = deviceBox(kind, size)
2087
+ const z = opts.z || 0.4
2088
+ const px = (n) => n / z
2089
+ const lift = opts.lift || 0
2002
2090
  const idx = new Map(keys.map((k, i) => [k, i]))
2003
- const incoming = new Map()
2004
- const out = []
2005
- const lift = opts.lift || 0 // 屏上面标题那一条有多高(世界坐标):绕行的线不压标题
2006
- // 一屏占的地方(连上面的标题);曲线要是穿过别的屏,就改走屏和屏之间的空当
2007
- const boxes = pos.map((p) => p && { x0: p.x, y0: p.y - lift, x1: p.x + dw, y1: p.y + dh })
2008
2091
  const xs = [...new Set(pos.filter(Boolean).map((p) => p.x))].sort((m, n) => m - n)
2009
2092
  const ys = [...new Set(pos.filter(Boolean).map((p) => p.y))].sort((m, n) => m - n)
2010
- const gaps = xs.slice(1).map((x, i) => x - xs[i] - dw).filter((x) => x > 0)
2011
- const g = (gaps.length ? Math.min(...gaps) : 64) / 2 // 竖着走的线放在两列中间
2012
- const r = Math.min(g * 0.7, 24) // 拐角圆一点
2013
- const crosses = (pts, skip) => boxes.some((b, i) => b && !skip.includes(i) && pts.some(([x, y]) => x > b.x0 + 2 && x < b.x1 - 2 && y > b.y0 + 2 && y < b.y1 - 2))
2014
- const bez = (p0, p1, p2, p3) => Array.from({ length: 23 }, (_, i) => {
2015
- const t = (i + 1) / 24
2016
- const u = 1 - t
2017
- return [0, 1].map((j) => u * u * u * p0[j] + 3 * u * u * t * p1[j] + 3 * u * t * t * p2[j] + t * t * t * p3[j])
2018
- })
2019
- /** 横着走那一道的高度:同一排 → 这一排标题上面;不同排 → 下面那排和上一排之间的空当 */
2020
- function laneOf(ya, yb) {
2021
- const R = Math.max(ya, yb)
2022
- const prev = ys.filter((y) => y < R - 1).pop()
2023
- if (prev === undefined) return R - lift - Math.max(16, g * 0.6)
2024
- const bottom = prev + dh
2025
- return bottom + Math.max(R - lift - bottom, 12) / 2
2093
+ const clamp = (v, a, b) => Math.max(a, Math.min(b, v))
2094
+ // 竖缝:第 j 道在第 j 列左边(0 = 最左外圈,xs.length = 最右外圈)
2095
+ const vLane = (j) => {
2096
+ if (j <= 0) return { c: xs[0] - px(14), hw: px(8) }
2097
+ if (j >= xs.length) return { c: xs[xs.length - 1] + dw + px(14), hw: px(8) }
2098
+ const a = xs[j - 1] + dw
2099
+ return { c: (a + xs[j]) / 2, hw: (xs[j] - a) / 2 }
2100
+ }
2101
+ // 横缝:第 i 道在第 i 排上面(标题再上面);0 = 最上外圈,ys.length = 最下外圈
2102
+ const hLane = (i) => {
2103
+ if (i <= 0) return { c: ys[0] - lift - px(9), hw: px(6) }
2104
+ if (i >= ys.length) return { c: ys[ys.length - 1] + dh + px(14), hw: px(9) }
2105
+ const a = ys[i - 1] + dh
2106
+ const b = ys[i] - lift
2107
+ return { c: (a + b) / 2, hw: Math.max(px(4), (b - a) / 2) }
2108
+ }
2109
+ const at = (lane, o) => lane.c + clamp(o, -lane.hw + px(2), lane.hw - px(2))
2110
+ const cellOf = (i) => ({ x0: pos[i].x, y0: pos[i].y, x1: pos[i].x + dw, y1: pos[i].y + dh, col: xs.indexOf(pos[i].x), row: ys.indexOf(pos[i].y) })
2111
+
2112
+ /** 一条线:出边 E、进边 N、错开 o → 拐点 */
2113
+ function route(S, T, hot, E, N, o) {
2114
+ const cx = hot.x + hot.w / 2
2115
+ const cy = hot.y + hot.h / 2
2116
+ const pts = []
2117
+ let cur // 现在在哪道缝里:{ v: true/false, lane, p: 另一个坐标 }
2118
+ if (E === 'L' || E === 'R') {
2119
+ const lane = at(vLane(E === 'L' ? S.col : S.col + 1), o)
2120
+ pts.push([E === 'L' ? hot.x : hot.x + hot.w, cy], [lane, cy])
2121
+ cur = { v: true, c: lane, p: cy }
2122
+ } else {
2123
+ const lane = at(hLane(E === 'T' ? S.row : S.row + 1), o)
2124
+ pts.push([cx, E === 'T' ? hot.y : hot.y + hot.h], [cx, lane])
2125
+ cur = { v: false, c: lane, p: cx }
2126
+ }
2127
+ const side = N === 'L' || N === 'R'
2128
+ const tLane = side ? at(vLane(N === 'L' ? T.col : T.col + 1), o) : at(hLane(N === 'T' ? T.row : T.row + 1), o)
2129
+ // 落点:侧边上下留 20%;顶边避开左边的标题字,落在右半边
2130
+ // 从上 / 下进同一列的两屏时落点分开(往上进的靠里一点),不在缝里交叉成十字
2131
+ const yIn = (y) => clamp(y, T.y0 + dh * 0.2, T.y0 + dh * 0.8)
2132
+ const xIn = (x) => {
2133
+ const right = x > T.x0 + dw / 2
2134
+ if (N === 'T') return clamp(x, T.x0 + dw * 0.55, T.x0 + dw * (right ? 0.88 : 0.6))
2135
+ return clamp(x, T.x0 + dw * (right ? 0.4 : 0.14), T.x0 + dw * (right ? 0.72 : 0.6))
2136
+ }
2137
+ const edge = (p) => (N === 'L' ? [T.x0, p] : N === 'R' ? [T.x1, p] : N === 'T' ? [p, T.y0] : [p, T.y1])
2138
+ if (cur.v && side) {
2139
+ if (Math.abs(cur.c - tLane) < 0.5) {
2140
+ const y = yIn(cur.p)
2141
+ pts.push([cur.c, y], edge(y))
2142
+ } else {
2143
+ // 两道竖缝之间:找一道横缝过去
2144
+ let k = 0
2145
+ let bestD = Infinity
2146
+ for (let i = 0; i <= ys.length; i++) {
2147
+ const d = Math.abs(hLane(i).c - cur.p) + Math.abs(hLane(i).c - (T.y0 + dh / 2))
2148
+ if (d < bestD) { bestD = d; k = i }
2149
+ }
2150
+ const hy = at(hLane(k), o)
2151
+ const y = yIn(hy)
2152
+ pts.push([cur.c, hy], [tLane, hy], [tLane, y], edge(y))
2153
+ }
2154
+ } else if (cur.v && !side) {
2155
+ const x = xIn(cur.c)
2156
+ pts.push([cur.c, tLane], [x, tLane], edge(x))
2157
+ } else if (!cur.v && side) {
2158
+ const y = yIn(cur.c)
2159
+ pts.push([tLane, cur.c], [tLane, y], edge(y))
2160
+ } else if (Math.abs(cur.c - tLane) < 0.5) {
2161
+ const x = xIn(cur.p)
2162
+ pts.push([x, cur.c], edge(x))
2163
+ } else {
2164
+ // 两道横缝之间:找一道竖缝过去
2165
+ let k = 0
2166
+ let bestD = Infinity
2167
+ for (let j = 0; j <= xs.length; j++) {
2168
+ const d = Math.abs(vLane(j).c - cur.p) + Math.abs(vLane(j).c - (T.x0 + dw / 2))
2169
+ if (d < bestD) { bestD = d; k = j }
2170
+ }
2171
+ const vx = at(vLane(k), o)
2172
+ const x = xIn(vx)
2173
+ pts.push([vx, cur.c], [vx, tLane], [x, tLane], edge(x))
2174
+ }
2175
+ // 去掉重复、共线的点
2176
+ const clean = []
2177
+ for (const p of pts) {
2178
+ const q = clean[clean.length - 1]
2179
+ if (q && Math.abs(q[0] - p[0]) < 0.01 && Math.abs(q[1] - p[1]) < 0.01) continue
2180
+ const r = clean[clean.length - 2]
2181
+ if (q && r && ((Math.abs(r[0] - q[0]) < 0.01 && Math.abs(q[0] - p[0]) < 0.01) || (Math.abs(r[1] - q[1]) < 0.01 && Math.abs(q[1] - p[1]) < 0.01))) clean.pop()
2182
+ clean.push(p)
2183
+ }
2184
+ return clean
2185
+ }
2186
+ const lenOf = (pts) => pts.slice(1).reduce((m, p, i) => m + Math.abs(p[0] - pts[i][0]) + Math.abs(p[1] - pts[i][1]), 0)
2187
+ // 屏里那一小段压过别的按钮(底栏、别的行)分不清是谁的线,宁可绕一点
2188
+ const hits = (a, b, rs, src) => rs.filter((r) => r !== src && Math.min(a[0], b[0]) < r.x + r.w - 1 && Math.max(a[0], b[0]) > r.x + 1 && Math.min(a[1], b[1]) < r.y + r.h - 1 && Math.max(a[1], b[1]) > r.y + 1).length
2189
+ const costOf = (pts, E, N, rs, src) => {
2190
+ const inS = Math.abs(pts[1][0] - pts[0][0]) + Math.abs(pts[1][1] - pts[0][1])
2191
+ return lenOf(pts) + (pts.length - 2) * px(46) + inS * 2 + hits(pts[0], pts[1], rs, src) * px(300) + (E === 'T' ? px(30) : 0) + (N === 'T' ? px(24) : 0)
2026
2192
  }
2027
- /** 直角绕行:出屏 → 竖着到那一道 → 横着走 → 竖着到目标边上 → 进屏 */
2028
- function detour(sx, sy, hOut, cx1, lane, cx2, ey, hIn, ex) {
2029
- const sgn = (v) => (v > 0 ? 1 : v < 0 ? -1 : 0)
2030
- let d = `M${sx} ${sy}H${cx1 - hOut * r}`
2031
- const corner = (x, y, dx, dy) => `Q${x} ${y} ${x + dx * r} ${y + dy * r}`
2032
- const hl = sgn(cx2 - cx1)
2033
- if (!hl) {
2034
- const v = sgn(ey - sy)
2035
- return d + corner(cx1, sy, 0, v) + `V${ey - v * r}` + corner(cx1, ey, hIn, 0) + `H${ex}`
2193
+ // 两条线的横竖段相交几次(共用端点不算)
2194
+ const segs = (pts) => pts.slice(1).map((p, i) => [pts[i], p])
2195
+ const cross = (A, B) => {
2196
+ let n = 0
2197
+ for (const [a, b] of A) {
2198
+ for (const [c, d] of B) {
2199
+ const av = Math.abs(a[0] - b[0]) < 0.01
2200
+ const cv = Math.abs(c[0] - d[0]) < 0.01
2201
+ if (av === cv) continue
2202
+ const [v0, v1, h0, h1] = av ? [a, b, c, d] : [c, d, a, b]
2203
+ const x = v0[0]
2204
+ const y = h0[1]
2205
+ const inV = y > Math.min(v0[1], v1[1]) + 0.01 && y < Math.max(v0[1], v1[1]) - 0.01
2206
+ const inH = x > Math.min(h0[0], h1[0]) + 0.01 && x < Math.max(h0[0], h1[0]) - 0.01
2207
+ if (inV && inH) n++
2208
+ }
2209
+ }
2210
+ return n
2211
+ }
2212
+ /** 拐点 → 圆角折线;末尾留出箭头的长度 */
2213
+ const pathOf = (pts, from, a) => {
2214
+ const P = pts.slice(from)
2215
+ const last = P[P.length - 1]
2216
+ const prev = P[P.length - 2]
2217
+ const ux = Math.sign(last[0] - prev[0])
2218
+ const uy = Math.sign(last[1] - prev[1])
2219
+ P[P.length - 1] = [last[0] - ux * a, last[1] - uy * a]
2220
+ let d = `M${P[0][0]} ${P[0][1]}`
2221
+ for (let i = 1; i < P.length - 1; i++) {
2222
+ const [x0, y0] = P[i - 1]
2223
+ const [x1, y1] = P[i]
2224
+ const [x2, y2] = P[i + 1]
2225
+ const r = Math.min(px(12), Math.hypot(x1 - x0, y1 - y0) / 2, Math.hypot(x2 - x1, y2 - y1) / 2)
2226
+ const ax = x1 - Math.sign(x1 - x0) * r
2227
+ const ay = y1 - Math.sign(y1 - y0) * r
2228
+ const bx = x1 + Math.sign(x2 - x1) * r
2229
+ const by = y1 + Math.sign(y2 - y1) * r
2230
+ d += `L${ax} ${ay}Q${x1} ${y1} ${bx} ${by}`
2036
2231
  }
2037
- const v1 = sgn(lane - sy)
2038
- const v2 = sgn(ey - lane)
2039
- return d + corner(cx1, sy, 0, v1) + `V${lane - v1 * r}` + corner(cx1, lane, hl, 0)
2040
- + `H${cx2 - hl * r}` + corner(cx2, lane, 0, v2) + `V${ey - v2 * r}` + corner(cx2, ey, hIn, 0) + `H${ex}`
2232
+ return d + `L${P[P.length - 1][0]} ${P[P.length - 1][1]}`
2233
+ }
2234
+
2235
+ const out = []
2236
+ const todo = []
2237
+ // 每屏看得见的按钮框(挑出发点时别从别的按钮上穿过去)
2238
+ const shown = new Map()
2239
+ const spots = (fl, S) => {
2240
+ const ox = S.x0 + f.pad
2241
+ const oy = S.y0 + f.pad + f.top
2242
+ return (fl.hots || [fl]).filter((l) => l.y >= -1 && l.y + l.h <= size[1] + 1 && l.x + l.w > 0 && l.x < size[0])
2243
+ .map((l) => ({ x: ox + Math.max(0, l.x), y: oy + l.y, w: Math.min(l.w, size[0] - Math.max(0, l.x)), h: l.h }))
2244
+ }
2245
+ for (const fl of flows) {
2246
+ const a = idx.get(fl.from)
2247
+ if (a === undefined || !pos[a]) continue
2248
+ if (!shown.has(fl.from)) shown.set(fl.from, [])
2249
+ shown.get(fl.from).push(...spots(fl, cellOf(a)))
2041
2250
  }
2042
2251
  for (const fl of flows) {
2043
2252
  const a = idx.get(fl.from)
2044
2253
  const b = idx.get(fl.to)
2045
2254
  if (a === undefined || b === undefined || a === b || !pos[a] || !pos[b]) continue
2046
- const pa = pos[a]
2047
- const pb = pos[b]
2048
- const ox = pa.x + f.pad
2049
- const oy = pa.y + f.pad + f.top
2050
- const dir = pb.x > pa.x + 1 ? 1 : pb.x < pa.x - 1 ? -1 : 0
2051
- const n = incoming.get(fl.to) || 0
2052
- incoming.set(fl.to, n + 1)
2053
- const sy = oy + Math.max(0, Math.min(size[1], fl.y + fl.h / 2))
2054
- const ey = pb.y + f.pad + f.top + size[1] * Math.min(0.85, 0.32 + n * 0.09)
2055
- const sx = ox + Math.max(0, Math.min(size[0], dir > 0 ? fl.x + fl.w : fl.x))
2056
- // 先在这一屏里水平走到边上(不斜着划过画面),出了屏再弯到目标
2057
- const edge = dir > 0 ? pa.x + dw : pa.x
2058
- const ex = dir < 0 ? pb.x + dw : pb.x
2059
- const k = Math.max(90, Math.abs(ex - edge) * 0.42)
2060
- const c1 = dir > 0 ? edge + k : edge - k - (dir ? 0 : 60)
2061
- const c2 = dir < 0 ? ex + k : ex - k - (dir ? 0 : 60)
2062
- let d = `M${sx} ${sy}H${edge}C${c1} ${sy} ${c2} ${ey} ${ex} ${ey}`
2063
- if (crosses(bez([edge, sy], [c1, sy], [c2, ey], [ex, ey]), [a, b])) {
2064
- const hOut = dir || -1
2065
- const hIn = dir || 1
2066
- d = detour(sx, sy, hOut, edge + hOut * g, laneOf(pa.y, pb.y), ex - hIn * g, ey, hIn, ex)
2255
+ const S = cellOf(a)
2256
+ const T = cellOf(b)
2257
+ const ox = S.x0 + f.pad
2258
+ const oy = S.y0 + f.pad + f.top
2259
+ // 看得见的按钮(在屏幕里、没被往下滚出去):导航的只描框;别的按钮里挑线最顺的那个出发。
2260
+ // 只有看不见的:从屏幕底边出发,画虚线
2261
+ const seen = (l) => l.y >= -1 && l.y + l.h <= size[1] + 1 && l.x + l.w > 0 && l.x < size[0]
2262
+ const rect = (l) => ({ x: ox + Math.max(0, l.x), y: oy + l.y, w: Math.min(l.w, size[0] - Math.max(0, l.x)), h: l.h })
2263
+ const hots = fl.hots || [fl]
2264
+ for (const l of hots) if (l.nav && seen(l)) out.push({ from: fl.from, to: fl.to, nav: true, hot: rect(l) })
2265
+ const cand = hots.filter((l) => !l.nav && seen(l)).map(rect)
2266
+ if (!cand.length && hots.some((l) => seen(l))) continue
2267
+ if (!cand.length && !hots.some((l) => !l.nav)) continue
2268
+ const rs = shown.get(fl.from) || []
2269
+ const same = (r, q) => Math.abs(r.x - q.x) < 0.5 && Math.abs(r.y - q.y) < 0.5 && Math.abs(r.w - q.w) < 0.5
2270
+ let best = null
2271
+ for (const src of cand.length ? cand : [{ x: ox + size[0] / 2 - 1, y: oy + size[1] - 2, w: 2, h: 2 }]) {
2272
+ const own = rs.find((r) => same(r, src))
2273
+ for (const E of cand.length ? ['L', 'R', 'B', 'T'] : ['B']) {
2274
+ for (const N of ['L', 'R', 'T', 'B']) {
2275
+ const c = costOf(route(S, T, src, E, N, 0), E, N, rs, own)
2276
+ if (!best || c < best.c) best = { c, E, N, src }
2277
+ }
2278
+ }
2279
+ }
2280
+ const hot = cand.length ? best.src : null
2281
+ todo.push({ fl, S, T, hot, src: best.src, E: best.E, N: best.N })
2282
+ }
2283
+ // 各走一道,错开几像素;先按出发点从左上到右下排,线不多时再试各种先后,挑交叉最少的
2284
+ todo.sort((p, q) => p.src.x + p.src.y - (q.src.x + q.src.y))
2285
+ const sp = px(6)
2286
+ const offs = (ord) => ord.map((k) => (k - (todo.length - 1) / 2) * sp)
2287
+ const crossings = (ord) => {
2288
+ const o = offs(ord)
2289
+ const ss = todo.map((it, i) => segs(route(it.S, it.T, it.src, it.E, it.N, o[i])))
2290
+ let n = 0
2291
+ for (let i = 0; i < ss.length; i++) for (let j = i + 1; j < ss.length; j++) n += cross(ss[i], ss[j])
2292
+ return n
2293
+ }
2294
+ let ord = todo.map((_, i) => i)
2295
+ if (todo.length > 1 && todo.length <= 6) {
2296
+ let low = crossings(ord)
2297
+ const perm = (arr, k) => {
2298
+ if (!low) return
2299
+ if (k === arr.length) {
2300
+ const n = crossings(arr)
2301
+ if (n < low) { low = n; ord = arr.slice() }
2302
+ return
2303
+ }
2304
+ for (let i = k; i < arr.length; i++) {
2305
+ ;[arr[k], arr[i]] = [arr[i], arr[k]]
2306
+ perm(arr, k + 1)
2307
+ ;[arr[k], arr[i]] = [arr[i], arr[k]]
2308
+ }
2067
2309
  }
2068
- out.push({ from: fl.from, to: fl.to, sx, sy, ex, ey, tip: dir < 0 ? -1 : 1, d })
2310
+ perm(ord.slice(), 0)
2069
2311
  }
2312
+ const os = offs(ord)
2313
+ todo.forEach((it, k) => {
2314
+ const pts = route(it.S, it.T, it.src, it.E, it.N, os[k])
2315
+ const a = px(7)
2316
+ const q = pts[pts.length - 1]
2317
+ const pr = pts[pts.length - 2]
2318
+ out.push({
2319
+ from: it.fl.from, to: it.fl.to, hot: it.hot, off: !it.hot,
2320
+ sx: pts[0][0], sy: pts[0][1], ex: q[0], ey: q[1], ux: Math.sign(q[0] - pr[0]), uy: Math.sign(q[1] - pr[1]),
2321
+ d: pathOf(pts, 0, a * 0.7),
2322
+ // 衬底从出了屏幕那一刻开始(屏里那段不用)
2323
+ halo: pathOf([it.E === 'L' ? [it.S.x0, pts[0][1]] : it.E === 'R' ? [it.S.x1, pts[0][1]] : [pts[0][0], it.E === 'T' ? it.S.y0 : it.S.y1], ...pts.slice(1)], 0, a * 0.7),
2324
+ pts,
2325
+ })
2326
+ })
2070
2327
  return out
2071
2328
  }
2072
2329
 
2330
+ /** 连线画成什么(和框架无关的描述,画布用 h() 画出来) */
2331
+ function flowMarks(lines, z) {
2332
+ const px = (n) => n / z
2333
+ const marks = []
2334
+ for (const ln of lines) {
2335
+ if (ln.hot) {
2336
+ const r = Math.min(px(6), ln.hot.w / 2, ln.hot.h / 2)
2337
+ marks.push(['rect', { className: 'hot' + (ln.nav ? ' nav' : ''), x: ln.hot.x, y: ln.hot.y, width: ln.hot.w, height: ln.hot.h, rx: r, strokeWidth: px(1.5), strokeDasharray: ln.nav ? `${px(3)} ${px(2.5)}` : undefined }])
2338
+ }
2339
+ }
2340
+ for (const ln of lines) {
2341
+ if (ln.nav) continue
2342
+ const a = px(7)
2343
+ const bx = ln.ex - ln.ux * a
2344
+ const by = ln.ey - ln.uy * a
2345
+ const vx = -ln.uy * a * 0.56
2346
+ const vy = ln.ux * a * 0.56
2347
+ marks.push(['path', { className: 'halo', d: ln.halo, strokeWidth: px(5.5) }])
2348
+ marks.push(['path', { className: 'ln' + (ln.off ? ' off' : ''), d: ln.d, strokeWidth: px(1.75), strokeDasharray: ln.off ? `${px(4)} ${px(3)}` : undefined }])
2349
+ marks.push(['path', { className: 'hd', d: `M${ln.ex} ${ln.ey}L${bx + vx} ${by + vy}L${bx - vx} ${by - vy}Z` }])
2350
+ marks.push(['circle', { className: 'dot', cx: ln.sx, cy: ln.sy, r: px(3.2), strokeWidth: px(1.4) }])
2351
+ }
2352
+ return marks
2353
+ }
2354
+
2073
2355
  // ── 小部件 ───────────────────────────────────────────────────────────────
2074
2356
 
2075
2357
  function Skeleton({ gone }) {
@@ -2334,7 +2616,7 @@ window.__ModuleLoader__.load({
2334
2616
  const seed = L.seeds.get(id) || L.seedOf.get(id) || null
2335
2617
  L.seeds.delete(id)
2336
2618
  L.seedOf.delete(id)
2337
- postTo(id, { type: 'init', seed })
2619
+ postTo(id, { type: 'init', seed, relay: true }) // 改动通知由右侧栏转告(见下面「本机预览」),页面别自己开 SSE
2338
2620
  break
2339
2621
  }
2340
2622
  case 'ready':
@@ -2470,6 +2752,21 @@ window.__ModuleLoader__.load({
2470
2752
  refresh()
2471
2753
  }, [chg])
2472
2754
 
2755
+ // 本机预览:同样按轮询到的改动转告页面,页面自己判断跟它有没有关系(有就交指纹、这里载新的一帧)。
2756
+ // 不让每屏各开一条 SSE:画布上到 6 屏就占满浏览器对同一地址的 6 条连接,之后的刷新全卡住
2757
+ const lchg = (!rem && props.remote && props.remote.changes) || []
2758
+ const lN = lchg.length ? lchg[lchg.length - 1].n : 0
2759
+ const lSeen = useRef(lN)
2760
+ useEffect(() => {
2761
+ const from = lSeen.current
2762
+ lSeen.current = lN
2763
+ if (rem || lN === from || !curRef.current) return
2764
+ const fresh = lchg.filter((c) => c.n > from)
2765
+ const unknown = lN < from || !fresh.length || lchg[0].n > from + 1 || fresh.some((c) => !(c.files || []).length)
2766
+ const files = unknown ? [] : [...new Set(fresh.flatMap((c) => c.files || []))]
2767
+ postTo(curRef.current, { type: 'files', file: files[files.length - 1] || '', files })
2768
+ }, [lN])
2769
+
2473
2770
  // 页面没装上脚本(极少数,比如不是 HTML):载完等一会儿也换上来
2474
2771
  const onFrameLoad = (id) => {
2475
2772
  setTimeout(() => {
@@ -3021,7 +3318,7 @@ window.__ModuleLoader__.load({
3021
3318
  // 拖背景 / 挡板 = 平移;没拖动就算点了一下
3022
3319
  const onPointerDown = (e) => {
3023
3320
  if (e.button !== 0 || !camRef.current) return
3024
- if (e.target.closest && e.target.closest('.ddp-zoom, .ddp-act')) return
3321
+ if (e.target.closest && e.target.closest('.ddp-zoom, .ddp-act, .ddp-take')) return
3025
3322
  const keyEl = e.target.closest ? e.target.closest('[data-key]') : null
3026
3323
  const d = { x: e.clientX, y: e.clientY, cam: camRef.current, moved: false, key: keyEl ? keyEl.getAttribute('data-key') : '' }
3027
3324
  const move = (ev) => {
@@ -3074,7 +3371,7 @@ window.__ModuleLoader__.load({
3074
3371
  className: 'ddp-cell' + (on ? ' sel' : ''),
3075
3372
  style: { left: L.pos[i].x, top: L.pos[i].y, width: cw, height: ch },
3076
3373
  },
3077
- lab(sc.key, [h('span', { key: 's' }, labelOf(sc)), on && !picking && props.actions ? h('span', { key: 'a', className: 'ddp-act' }, ...props.actions(sc)) : null], cw),
3374
+ lab(sc.key, [h('span', { key: 's' }, labelOf(sc)), !picking && props.badge ? props.badge(sc) : null, on && !picking && props.actions ? h('span', { key: 'a', className: 'ddp-act' }, ...props.actions(sc)) : null], cw),
3078
3375
  props.render(sc, z, i),
3079
3376
  picking ? null : h('div', { className: 'ddp-shield', 'data-key': sc.key, title: items ? '' : t('openScreen'), style: { borderRadius: fr.r } })))
3080
3377
  })
@@ -3087,15 +3384,11 @@ window.__ModuleLoader__.load({
3087
3384
  }
3088
3385
  let flowSvg = null
3089
3386
  if (cam && L && props.flows && props.flows.length && !picking) {
3090
- const lines = flowLines(props.flows, screens.map((x) => x.key), L.pos, kind, size, { lift: LAB / z }).filter((ln) => !sel || ln.from === sel || ln.to === sel)
3387
+ // 只画选中那屏通向哪(不画哪些通向它),底栏、返回这种每屏都有的导航只描框
3388
+ const lines = sel ? flowLines(props.flows.filter((x) => x.from === sel), screens.map((x) => x.key), L.pos, kind, size, { lift: LAB / z, z }) : []
3091
3389
  if (lines.length) {
3092
- const sw = 2 / z
3093
- const a = 9 / z
3094
3390
  flowSvg = h('svg', { key: '__flows', className: 'ddp-flows', width: 1, height: 1 },
3095
- ...lines.map((ln, i) => h('g', { key: i, className: ln.from === sel ? '' : 'dim' },
3096
- h('path', { d: ln.d, strokeWidth: sw }),
3097
- h('path', { className: 'hd', d: `M${ln.ex} ${ln.ey}l${-a * ln.tip} ${-a * 0.62}v${a * 1.24}z` }),
3098
- h('circle', { cx: ln.sx, cy: ln.sy, r: 4.5 / z, strokeWidth: 1.5 / z }))))
3391
+ ...flowMarks(lines, z).map(([tag, attrs], i) => h(tag, { key: i, ...attrs })))
3099
3392
  }
3100
3393
  }
3101
3394
  return h('div', {
@@ -3213,6 +3506,12 @@ window.__ModuleLoader__.load({
3213
3506
  return () => clearTimeout(id)
3214
3507
  }, [fresh, writingNew, M.lastNew])
3215
3508
  const labelOf = (sc) => (infoOf(ik(sc)) || {}).title || sc.label
3509
+ // 画布上的标题去掉几屏都带的产品名(「首页 · 梅林练」→「首页」);产品名留给导出的展示图做大标题
3510
+ const shortOf = (sc) => {
3511
+ const sp = splitTitles(shown.map(labelOf))
3512
+ const i = shown.indexOf(sc)
3513
+ return (sp.brand && i >= 0 && sp.labels[i]) || labelOf(sc)
3514
+ }
3216
3515
  const anyDark = Object.keys(info).some((k) => k.startsWith(site + '|') && info[k].dark)
3217
3516
  const fileTarget = !!(st && st.target && st.target.kind === 'file')
3218
3517
  // 远程看(手机走外网等):本地文件的预览由服务端打包传过来(见 Screen),目录里改了哪些文件也一起带过去
@@ -3337,9 +3636,15 @@ window.__ModuleLoader__.load({
3337
3636
  try {
3338
3637
  if (ia && typeof ia.insertText === 'function') {
3339
3638
  const span = typeof ia.captureInsertion === 'function' ? ia.captureInsertion() : undefined
3340
- // 光标前面已经有字(用户先打了要求)→ 另起一行,不和那句话粘在一起
3341
- const lead = span && span.start > 0 ? '\n' : ''
3342
- if (ia.insertText(lead + text, span)) return say(t(done))
3639
+ const el = composerEl()
3640
+ const all = composerText(el)
3641
+ const at = span ? span.start : 0
3642
+ const ok = all != null && span && span.end <= all.length
3643
+ const p = padInsert(text, ok ? all.slice(0, at) : null, ok ? all.slice(span.end) : null, at, done === 'inserted')
3644
+ if (ia.insertText(p.text, span)) {
3645
+ try { focusAfter(el, p.text, p.back) } catch { /* 用户自己点一下输入框也行 */ }
3646
+ return say(t(done))
3647
+ }
3343
3648
  }
3344
3649
  } catch (e) {
3345
3650
  console.warn('[dsh-design-studio] insertText 失败', e)
@@ -3676,8 +3981,12 @@ window.__ModuleLoader__.load({
3676
3981
  insert(g.letter ? t('pickText', { name: labelOf(sc), where, others, base: g.stem }) : t('pickKeep', { name: labelOf(sc), where, others }), 'insertedSend')
3677
3982
  }
3678
3983
 
3984
+ // 几个方向并排时每屏标题旁都挂着「就要这个」,不用先点中那一屏才看得到
3985
+ const badge = (sc) => !sc.v && takesOf(shown.map((x) => x.key), sc.key)
3986
+ ? h('button', { key: 'p', className: 'ddp-take', title: t('pickThis'), onClick: () => pickDirection(sc) }, h(I.check), h('span', null, t('pickShort')))
3987
+ : null
3988
+
3679
3989
  const actions = (sc) => [
3680
- !sc.v && takesOf(shown.map((x) => x.key), sc.key) ? h('button', { key: 'p', className: 'ddp-btn', title: t('pickThis'), onClick: () => pickDirection(sc) }, h(I.check)) : null,
3681
3990
  h('button', { key: 'x', className: 'ddp-btn', title: t('exThis'), onClick: () => doExport(FRAMED[kind] ? 'frame' : 'raw', sc.key) }, h(I.download)),
3682
3991
  sc.v ? null : h('button', { key: 'v', className: 'ddp-btn', title: t('variants'), onClick: () => askVariants(sc) }, h(I.sparkle)),
3683
3992
  h('button', { key: 'o', className: 'ddp-btn', title: t('openScreen'), onClick: () => change({ view: 'single', focus: sc.key }) }, h(I.single)),
@@ -3984,6 +4293,8 @@ window.__ModuleLoader__.load({
3984
4293
  onClick: () => post('open', { session: sid, target: st.hint }).then((s) => { if (poll) poll.set(s) }, (e) => say(t('openFailed', { msg: e.message }))),
3985
4294
  }, t('hintOpen', { name: st.hint.split(/[\\/]/).pop() })) : null,
3986
4295
  h('ol', { className: 'ddp-steps' }, h('li', null, t('step1')), h('li', null, t('step2')), h('li', null, t('step3'))),
4296
+ h('div', { className: 'ddp-tries' }, h('span', null, t('tryHead')),
4297
+ ['try1', 'try2', 'try3'].map((k) => h('button', { key: k, className: 'ddp-try', onClick: () => insert(t(k), 'insertedSend') }, t(k)))),
3987
4298
  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)) })),
3988
4299
  h('p', null, t('emptyHint')),
3989
4300
  h(AddressForm, { t, sid, className: 'ddp-form', onDone: () => {} }),
@@ -4091,12 +4402,13 @@ window.__ModuleLoader__.load({
4091
4402
  t,
4092
4403
  screens: shown,
4093
4404
  actions,
4405
+ badge,
4094
4406
  kind,
4095
4407
  size,
4096
4408
  ghost,
4097
4409
  picking: picking || textEdit || styleEdit,
4098
4410
  sel: sel || focus,
4099
- labelOf,
4411
+ labelOf: shortOf,
4100
4412
  reveal,
4101
4413
  inset: ck || styleEdit || panel || styles || pane ? 296 : 0, // 右边的面板 276 宽,离边 10
4102
4414
  render: (sc, z) => renderScreen(sc, z, false),
@@ -4244,7 +4556,7 @@ window.__ModuleLoader__.load({
4244
4556
  // 注意:这里是客户端**服务名**(短的),不是包名
4245
4557
  exports.inject = INJECT
4246
4558
  // 测试用
4247
- exports._test = { STYLES, formatStyle, pageOf, pxOf, styleChanges, formatStyleAsk, takesOf, formatCheckTool, poller, frameOf, paletteOf, formatRef, layoutRow, keyOfPath, flowsOf, flowLines, formatPick, whereOf, parseMeta, sizeOf, layoutGrid, fitCam, tokenKind, formatToken, splitTitles, pickColors, hhmm }
4559
+ exports._test = { STYLES, formatStyle, 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 }
4248
4560
  return module.exports
4249
4561
  },
4250
4562
  })
package/lib/picker.js CHANGED
@@ -28,6 +28,14 @@
28
28
  const fitsParent = (o) => o === anc || (o.endsWith(':*') && anc.startsWith(o.slice(0, -1)) && /^\d+$/.test(anc.slice(o.length - 1)))
29
29
  const parentOrigins = window.parent === window ? [] : anc ? (allowed.some(fitsParent) ? [anc] : []) : allowed.filter((o) => !o.endsWith(':*'))
30
30
  const parentWin = parentOrigins.length ? window.parent : null
31
+ // 在右侧栏里不画滚动条(和手机上一样):电脑设成「总是显示滚动条」时它占掉 15px,App 按 375 排版而不是 390,
32
+ // 右侧栏的体检就和 check_design(无头浏览器不画滚动条)对不上
33
+ if (parentWin) {
34
+ const s = document.createElement('style')
35
+ s.setAttribute('data-dsh-design', '')
36
+ s.textContent = '*{scrollbar-width:none}*::-webkit-scrollbar{display:none}'
37
+ ;(document.head || document.documentElement).appendChild(s)
38
+ }
31
39
  const SEED_KEY = '__dsh_design_seed__'
32
40
  // 远程看(右侧栏把这一屏打包放进沙盒 iframe 的 srcdoc,location 是 about:srcdoc):这一屏原本的地址由右侧栏给
33
41
  const HERE = cfg.remote && typeof cfg.href === 'string' ? cfg.href : ''
@@ -1131,8 +1139,9 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
1131
1139
 
1132
1140
  const LINK_SEL = 'a[href], [data-href], [data-to], [onclick]'
1133
1141
 
1134
- /** 页面里跳到别的屏的地方:路径 + 在视口里的位置(画布上从这里画一条线到那一屏) */
1135
- function linksOf() {
1142
+ /** 页面里跳到别的屏的地方:路径 + 在视口里的位置(画布上从这里画一条线到那一屏)
1143
+ * shown:只要这会儿露在外面的(画布连线用;导出、交接要全部) */
1144
+ function linksOf(shown) {
1136
1145
  const out = []
1137
1146
  const seen = new Set()
1138
1147
  for (const el of document.querySelectorAll(LINK_SEL)) {
@@ -1146,6 +1155,9 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
1146
1155
  // 正在播动画(生成动画、页面自己的淡入)的按钮这会儿透明度是 0,但它是看得见的按钮
1147
1156
  const moving = typeof el.getAnimations === 'function' && el.getAnimations().length > 0
1148
1157
  if (cs.visibility === 'hidden' || cs.display === 'none' || (!moving && Number(cs.opacity) === 0)) continue
1158
+ // 被固定的底栏 / 顶栏盖住的按钮(滚到它下面了)看不见,画布上别从它出线
1159
+ const top = shown ? document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2) : null
1160
+ if (top && top !== el && !el.contains(top) && !(host && host.contains(top))) continue
1149
1161
  const k = p + '|' + Math.round(r.left / 8) + '|' + Math.round(r.top / 8)
1150
1162
  if (seen.has(k)) continue
1151
1163
  seen.add(k)
@@ -1160,14 +1172,14 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
1160
1172
  let linkSig = ''
1161
1173
  let linkTimer = 0
1162
1174
  function firstLinks() {
1163
- const links = linksOf()
1175
+ const links = linksOf(true)
1164
1176
  linkSig = JSON.stringify(links)
1165
1177
  addEventListener('resize', () => { clearTimeout(linkTimer); linkTimer = setTimeout(resendLinks, 120) })
1166
1178
  setTimeout(resendLinks, 600)
1167
1179
  return links
1168
1180
  }
1169
1181
  function resendLinks() {
1170
- const links = linksOf()
1182
+ const links = linksOf(true)
1171
1183
  const sig = JSON.stringify(links)
1172
1184
  if (sig === linkSig) return
1173
1185
  linkSig = sig
@@ -1432,6 +1444,35 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
1432
1444
  const families = new Set()
1433
1445
  const jobs = []
1434
1446
  let seq = 0
1447
+ // <use href="icons.svg#x">:画成图时(SVG 当图片)不会去载别的文件,图标全没了。
1448
+ // 把用到的那个图标复制进画出来的页面,href 改成指它(和 bundle.js 远程看时一样的道理)
1449
+ const sprites = document.createElementNS(SVGNS, 'svg')
1450
+ sprites.setAttribute('style', 'position:absolute;width:0;height:0;overflow:hidden')
1451
+ const spriteDocs = new Map()
1452
+ const spriteIds = new Map()
1453
+ const spriteOf = (abs) => {
1454
+ if (spriteIds.has(abs)) return spriteIds.get(abs)
1455
+ const u = new URL(abs)
1456
+ const frag = decodeURIComponent(u.hash.slice(1))
1457
+ u.hash = ''
1458
+ if (!spriteDocs.has(u.href)) {
1459
+ spriteDocs.set(u.href, fetch(u.href, { credentials: 'same-origin' })
1460
+ .then((r) => (r.ok ? r.text() : ''))
1461
+ .then((t) => (t ? new DOMParser().parseFromString(t, 'image/svg+xml') : null))
1462
+ .catch(() => null))
1463
+ }
1464
+ const p = spriteDocs.get(u.href).then((doc) => {
1465
+ const src = doc && frag ? doc.getElementById(frag) : null
1466
+ if (!src || src.namespaceURI !== SVGNS) return ''
1467
+ const id = 'ddpu' + (++seq)
1468
+ const copy = document.importNode(src, true)
1469
+ copy.setAttribute('id', id)
1470
+ sprites.appendChild(copy)
1471
+ return id
1472
+ })
1473
+ spriteIds.set(abs, p)
1474
+ return p
1475
+ }
1435
1476
 
1436
1477
  function walk(node) {
1437
1478
  if (node.nodeType === 3) return document.createTextNode(node.nodeValue)
@@ -1486,6 +1527,18 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
1486
1527
  const href = el.getAttribute('href') || el.getAttributeNS('http://www.w3.org/1999/xlink', 'href')
1487
1528
  if (href) jobs.push(inline(new URL(href, here()).href).then((d) => { if (d) c.setAttribute('href', d) }))
1488
1529
  }
1530
+ if (el.namespaceURI === SVGNS && el.localName === 'use') {
1531
+ const href = el.getAttribute('href') || el.getAttributeNS('http://www.w3.org/1999/xlink', 'href') || ''
1532
+ let abs = ''
1533
+ try { abs = href && !href.startsWith('#') ? new URL(href, here()).href : '' } catch { /* 怪地址 */ }
1534
+ if (abs && abs.includes('#')) {
1535
+ jobs.push(spriteOf(abs).then((id) => {
1536
+ if (!id) return
1537
+ c.removeAttributeNS('http://www.w3.org/1999/xlink', 'href')
1538
+ c.setAttribute('href', '#' + id)
1539
+ }))
1540
+ }
1541
+ }
1489
1542
  if (tag === 'INPUT') {
1490
1543
  if (el.type === 'checkbox' || el.type === 'radio') { if (el.checked) c.setAttribute('checked', '') } else c.setAttribute('value', el.value)
1491
1544
  }
@@ -1549,6 +1602,7 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
1549
1602
  style.textContent = fontCss + '\n' + pseudoRules.join('\n') + '\n*::-webkit-scrollbar{display:none}'
1550
1603
  const body = [...root.childNodes].find((n) => n.localName === 'body')
1551
1604
  ;(body || root).insertBefore(style, (body || root).firstChild)
1605
+ if (sprites.childNodes.length) (body || root).appendChild(sprites)
1552
1606
  if (!full && (scrollX || scrollY) && body) {
1553
1607
  body.setAttribute('style', body.getAttribute('style') + `transform:translate(${-scrollX}px,${-scrollY}px);`)
1554
1608
  }
@@ -2378,7 +2432,8 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
2378
2432
  if (!d || d.dshDesign !== 1) return
2379
2433
  if (d.type === 'pick') start(d.hint !== false)
2380
2434
  else if (d.type === 'cancel') { if (!styleOn) stop(true) }
2381
- else if (d.type === 'init') boot(d.seed || null)
2435
+ else if (d.type === 'init') { if (d.relay) relay(); boot(d.seed || null) }
2436
+ else if (d.type === 'files') { relay(); onFiles(String(d.file || ''), Array.isArray(d.files) ? d.files : null) }
2382
2437
  else if (d.type === 'reveal') reveal(d)
2383
2438
  else if (d.type === 'zoom') setK(1 / (Number(d.z) || 1))
2384
2439
  else if (d.type === 'snapshot') post({ type: 'snap', seed: seedNow() })
@@ -2512,21 +2567,34 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
2512
2567
  return files.every((f) => /\.html?$/i.test(f) && String(f).replace(/\\/g, '/') !== me)
2513
2568
  }
2514
2569
 
2570
+ /** 目录里有文件改了(file 是最后一个,files 是这一批全部;空的 = 不知道改了啥) */
2571
+ function onFiles(file, files) {
2572
+ if (!shown) return
2573
+ if (othersOnly(files)) return
2574
+ if (parentWin) post({ type: 'changed', file, seed: seedNow() })
2575
+ else selfReload()
2576
+ }
2577
+
2578
+ // 改动通知:在右侧栏里由右侧栏转告(init 里说 relay,之后发 files)。每屏各开一条 SSE 的话,
2579
+ // 画布上到 6 屏就占满浏览器对同一地址的 6 条连接,之后刷新要载的页面、样式表全排着队,预览从此不再变。
2580
+ // 不在右侧栏里(手机上看、单独打开),或右侧栏是老版本不转告(等 3 秒没说),才自己开 SSE。
2515
2581
  // 旧版本(/__dsh_design__/v/…)只读,不跟着目录改动刷新
2516
- if (cfg.live && window.EventSource && !location.pathname.startsWith('/__dsh_design__/v/')) {
2517
- const es = new EventSource('/__dsh_design__/events')
2582
+ const LIVE = !!cfg.live && !!window.EventSource && !location.pathname.startsWith('/__dsh_design__/v/')
2583
+ let es = null
2584
+ let relayed = false
2585
+ const openEvents = () => {
2586
+ if (es || relayed || !LIVE) return
2587
+ es = new EventSource('/__dsh_design__/events')
2518
2588
  es.addEventListener('change', (e) => {
2519
- if (!shown) return
2520
- let file = ''
2521
- let files = null
2522
- try {
2523
- const d = JSON.parse(e.data)
2524
- file = d.file || ''
2525
- files = d.files || null
2526
- } catch { /* 没带文件名 */ }
2527
- if (othersOnly(files)) return
2528
- if (parentWin) post({ type: 'changed', file, seed: seedNow() })
2529
- else selfReload()
2589
+ let d = {}
2590
+ try { d = JSON.parse(e.data) || {} } catch { /* 没带文件名 */ }
2591
+ onFiles(d.file || '', d.files || null)
2530
2592
  })
2531
2593
  }
2594
+ function relay() {
2595
+ relayed = true
2596
+ if (es) { es.close(); es = null }
2597
+ }
2598
+ if (!parentWin) openEvents()
2599
+ else setTimeout(openEvents, 3000)
2532
2600
  })()
package/lib/serve.js CHANGED
@@ -526,5 +526,12 @@ export function createHub({ log = () => {}, onChange = () => {}, history = null,
526
526
  return false
527
527
  }
528
528
 
529
- return { open, close, closeAll, alive, size: () => servers.size }
529
+ /** 现在一共连着几条改动通知(SSE):右侧栏里的页面靠转告,不该自己开(浏览器对同一地址最多 6 条连接) */
530
+ function listeners() {
531
+ let n = 0
532
+ for (const e of servers.values()) n += e.clients.size
533
+ return n
534
+ }
535
+
536
+ return { open, close, closeAll, alive, listeners, size: () => servers.size }
530
537
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-design-studio",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test --test-concurrency=1 test/*.mjs"
@@ -31,7 +31,7 @@
31
31
 
32
32
  2. **先写设计变量**:在设计目录建 `tokens.css`(起手模板 `tokens.css` 就在这个技能目录里,复制过去再按品牌改;有默认的品牌设计系统就复制它的 `tokens.css` 和 `components.css`),颜色、字体、字号阶梯、间距、圆角、阴影都从这里取。每屏都 `<link rel="stylesheet" href="tokens.css">`。之后要统一改风格,改这一个文件,所有屏一起变。
33
33
 
34
- 3. **一屏一个 HTML 文件,放同一个目录**,按用户会看的顺序一屏一屏写(画布按创建顺序排:登录 → 首页 → 详情 → …)。文件名用英文小写(`login.html`、`home.html`),`<title>` 写这屏的中文名(画布上显示它)。临时试验的页面用 `_` 开头(`_test.html`),不会上画布。每屏 `<head>` 里写:
34
+ 3. **一屏一个 HTML 文件,放同一个目录**,按用户会看的顺序一屏一屏写(画布按创建顺序排:登录 → 首页 → 详情 → …)。文件名用英文小写(`login.html`、`home.html`),`<title>` 写「这屏的中文名 · 产品名」(如「首页 · 梅林练」,每屏的产品名写成一样的):画布上只显示屏名,导出整套展示图时产品名做大标题。临时试验的页面用 `_` 开头(`_test.html`),不会上画布。每屏 `<head>` 里写:
35
35
 
36
36
  ```html
37
37
  <meta name="dsh-design" content="app"> <!-- 类型;海报可带尺寸:poster 1080x1920 -->