dsh-design-studio 0.6.1 → 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 +13 -5
- package/README.md +17 -7
- package/lib/client.js +401 -89
- package/lib/picker.js +86 -18
- package/lib/serve.js +8 -1
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +2 -2
- package/skills/ui-design/SKILL.md +1 -1
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
|
|
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,10 +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
|
-
**
|
|
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
|
+
|
|
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.
|
|
47
49
|
|
|
48
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.
|
|
49
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
|
+
|
|
50
58
|
## What it does
|
|
51
59
|
|
|
52
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.
|
|
@@ -55,15 +63,15 @@ For development, enter the absolute path of a local checkout under **Add plugin*
|
|
|
55
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".
|
|
56
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).
|
|
57
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.
|
|
58
|
-
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.
|
|
59
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.
|
|
60
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.
|
|
61
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.
|
|
62
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.
|
|
63
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.
|
|
64
|
-
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
|
|
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.
|
|
65
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.
|
|
66
|
-
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
|
|
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).
|
|
67
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.
|
|
68
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.
|
|
69
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
|
|
22
|
+
> **安装**:DSH「插件」页 →「添加插件」,填 `dsh-design-studio`(npm 包,连不上 npm 会自动换国内镜像),装好点「立即启用」。
|
|
23
23
|
|
|
24
24
|
让 DSH(DeepSeek Harness)做界面设计时像 Google Stitch / Claude Code 桌面端那样:模型做网页、App 界面、海报、幻灯片时,右侧栏「设计工作台」实时显示——一屏一屏生成出来,在画布上并排摆好,套上手机 / 浏览器外框;看到哪里不对,点一下那个元素、或拖出一块区域,它就整理成一段文字放进输入框,补一句「改成橙色」发出去,模型照着源码改,改过的地方在预览里闪一下。
|
|
25
25
|
|
|
@@ -43,10 +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
|
-
|
|
46
|
+
装好后在弹出的卡片上点**「立即启用」**(默认是关的)。新版 DSH 不用重启:新会话的模式菜单里就有「设计模式」。老版本 DSH 右侧栏里找不到「设计工作台」的话,重启一次。DSH「插件」页里显示为「设计工作台」。
|
|
47
|
+
|
|
48
|
+
**更新**:在「插件」页把它卸载,再按上面添加一次。新版发布后大约一天按包名才装得到(DSH 装插件用的 pnpm 默认不装刚发布的版本),急的话填发布包地址。
|
|
47
49
|
|
|
48
50
|
开发时可以直接链到本地目录:在「添加插件」里填本仓库的绝对路径,装上的是 `link:`,改了客户端代码同样要重启(`lib/picker.js` 每次现给,不用重启)。
|
|
49
51
|
|
|
52
|
+
## 三步上手
|
|
53
|
+
|
|
54
|
+
1. 新会话的模式选「设计模式」,说一句要做什么,比如「帮我设计一个社区健身房的会员 App」。右侧栏的空白页上也有几句示例,点一下就放进输入框。
|
|
55
|
+
2. 模型先用提问卡问两三个问题,答完出三个方向并排在画布上。看中哪个,点它标题旁的「✓ 就要这个」,发出去,模型接着做全套。
|
|
56
|
+
3. 哪里不对,点「选择」再点那个元素,光标会停在输入框新的一行,接着说「字大一点、加粗」,发出去。
|
|
57
|
+
|
|
50
58
|
## 用起来是什么样
|
|
51
59
|
|
|
52
60
|
1. **做设计时自动打开**:模型加载了设计技能、调了 `preview_page`,或页面写了设计类型标记(`<meta name="dsh-design">`)之后,再写 `.html` 文件或用命令起开发服务器(`npm run dev` 这类),右侧栏自动弹出「设计工作台」。别的任务写的网页不弹,只在标题栏按钮上亮个蓝点,点开能一键预览;`curl`、浏览器工具去看别的端口,一律不当成要预览的页面。
|
|
@@ -60,7 +68,7 @@ dsh-design-studio
|
|
|
60
68
|
- 点一下:选这个元素
|
|
61
69
|
- 拖一块:选区域,里面的主要元素一起带上(最多 10 个)
|
|
62
70
|
- Esc:取消
|
|
63
|
-
9.
|
|
71
|
+
9. **进输入框**:选好的内容整理成一段文字插进输入框(插不进时复制到剪贴板),光标停在下面新的一行,直接接着打要怎么改;选中的地方留一个虚线框,例如:
|
|
64
72
|
|
|
65
73
|
```
|
|
66
74
|
〔选中元素〕a.btn-order「立即点单 →」
|
|
@@ -76,9 +84,9 @@ dsh-design-studio
|
|
|
76
84
|
12. **导出**:工具栏下载按钮,或画布上选中一屏后标签旁的按钮。可以导出这一屏(带手机 / 浏览器外框,或纯画面)、整页长图,以及几屏合成的一张展示图(品牌名做大标题、背景和主色取自设计变量,适合直接发朋友圈 / 小红书 / 作品集)。也能把所有屏导成 **PDF**(每屏一页,网页 / 邮件是整页长图,页面尺寸跟着画幅)或 **PPT**(每屏一张幻灯片,整页图片,幻灯片比例跟着画幅,PowerPoint / WPS / Keynote 都能打开)。屏和屏之间的跳转照样能点:PDF 里点「去点单」翻到菜单那页(每屏还有一条书签),PPT 放映时点了跳到那一张,导出的文件就是能点的原型。文件存到设计目录的 `exports/`,图片同时复制到剪贴板,点「打开文件夹」直接定位。
|
|
77
85
|
13. **版本**:每轮对话开始前、结束后各自动存一版(只存设计文件,名字就是你那句话,例如「主色换成深海蓝」;点选元素发出去的,名字是「「3.2 km」 · 改成绿色」这样,不会是一长串选择器)。工具栏时钟按钮列出所有版本;点一版就看当时的样子(画布 / 单页都行),点「对比」左右拖动把手,比较那一版和现在;「回到这一版」把文件写回去(回之前先把现在的存一版,随时能再切回来)。
|
|
78
86
|
14. **深色模式**:页面写了深色样式(`@media (prefers-color-scheme: dark)`,或 `[data-theme="dark"]` / `.dark`),工具栏多一个月亮按钮,一键切换看深色版,导出的图片也是深色的。
|
|
79
|
-
15. **再出两版**:画布上选中一屏,点标签旁的闪光按钮,让模型给这一屏出两版不同方向的设计(`login-b.html`、`login-c.html`),紧挨着原版排在画布上(开局的三个方向 `home-a/b/c` 也一样,按 a、b、c
|
|
87
|
+
15. **再出两版**:画布上选中一屏,点标签旁的闪光按钮,让模型给这一屏出两版不同方向的设计(`login-b.html`、`login-c.html`),紧挨着原版排在画布上(开局的三个方向 `home-a/b/c` 也一样,按 a、b、c 排)。挑中哪版就点它标题旁的「✓ 就要这个」(几个版本并排时每屏都挂着,不用先选中):输入框里写好「用这一版、其他版本挪进 `drafts/`、接着做全套」,发出去就行。
|
|
80
88
|
16. **在手机上看**:工具栏扫码按钮,右侧栏出一个二维码,手机(和电脑连同一个 Wi-Fi)相机一扫就在手机浏览器里打开这套设计,按真机尺寸看、点得动。电脑上点哪一屏,手机跟着跳;模型改了设计,手机自动刷新。面板上看得到「手机已打开」,也能复制链接、随时停止;30 分钟没人看自动关。
|
|
81
|
-
17. **可点原型**:屏和屏之间用链接连起来(`<a href="detail.html">` 或 `data-href
|
|
89
|
+
17. **可点原型**:屏和屏之间用链接连起来(`<a href="detail.html">` 或 `data-href`,内置技能会让模型这么写),画布上选中一屏,就画出它能跳去哪:从按钮出发、沿屏和屏之间的空当横平竖直走、转角带圆角,几条线各走各的道、尽量不交叉,不穿过别的屏;底栏、返回这种每屏都有的导航只描虚线框,不画线。点工具栏播放按钮进入演示:在预览里点按钮跳到对应那一屏,前进从右边滑进来、返回从左边;顶上能后退、从头来、退出(Esc)。
|
|
82
90
|
18. **多尺寸并排**:工具栏第三个视图按钮,把现在这一屏按几档宽度并排看:网页是手机 / 平板 / 桌面,App 是小屏 / 标准 / 大屏(小屏是 SE 那种矮屏,没有灵动岛),平板是三种尺寸,小屏挤不挤一眼看出来。并排时也能选择元素,带上是哪一档屏宽。
|
|
83
91
|
19. **预览里直接改字**:工具栏「改字」按钮,点页面里的任意文字就地改,回车保存、Esc 取消。本地 HTML 直接写回源文件(只改那一处,改前改后各存一版,能回退);开发服务器的页面,或源码里找不到那段字时,改成一句「把「…」改成「…」」放进输入框,交给模型改。
|
|
84
92
|
20. **参考图**:把图片拖进右侧栏、在右侧栏里粘贴,或在设计规范面板点「照参考图改风格…」。图片存进设计目录的 `refs/`(还没有设计目录时存插件缓存),右侧栏从图里取出主要颜色(带占比,少见的鲜艳点缀色也会单独挑出来)和整体深浅,连同图片路径和一句「照这张图的风格改,先更新 tokens.css」放进输入框。
|
|
@@ -176,9 +184,9 @@ dsh-design-studio
|
|
|
176
184
|
## 测试
|
|
177
185
|
|
|
178
186
|
```sh
|
|
179
|
-
node test/test-serve.mjs # 静态文件、跳不出目录、Host 把门、改动推送、代理、WebSocket、只听 ::1
|
|
187
|
+
node test/test-serve.mjs # 静态文件、跳不出目录、Host 把门、改动推送、代理、WebSocket、只听 ::1 的开发服务器、连不上的提示页;真浏览器:右侧栏里页面不自己开改动通知(老版右侧栏 3 秒后才开)、导出图里画出外部 icons.svg 的图标
|
|
180
188
|
node test/test-core.mjs # 地址解析、自动发现(只在做设计时,后台跑的开发服务器也认)、模型 / 用户指定的不被顶掉、重放不动手、存盘恢复(恢复中先不显示空状态)、类型 / 几屏 / 换屏、改字写回、调样式写回(认元素、合并 style、只收安全的值)
|
|
181
|
-
node test/test-client.mjs # 右侧栏标签页 / 标题栏按钮注册、只给当前会话自动弹出、选中内容整理成的文字、页面 meta
|
|
189
|
+
node test/test-client.mjs # 右侧栏标签页 / 标题栏按钮注册、只给当前会话自动弹出、选中内容整理成的文字、页面 meta、画布排版、设计变量分组、原型连线(走空当、不穿屏、不交叉、导航只描框)、多尺寸排版、矮屏手机没有灵动岛、参考图取色、插进输入框前后怎么换行
|
|
182
190
|
node test/test-skill.mjs # 内置技能:名字 / 来源合规、内容读得到、引用的细则文件都在;拆开的几个组件、设计模式带齐标准模式的工具
|
|
183
191
|
node test/test-history.mjs # 版本:存一版 / 没变不存 / 只存设计文件 / 回到某一版 / 上限、每轮前后存、版本名(点选 / 变量 / 参考图换成短名字)、旧版本文件、导出的图不算改动
|
|
184
192
|
node test/test-bundle.mjs # 远程看的打包:样式表 / @import / url() / 脚本 / 图片 / srcset 内联,外网的、找不到的、越界的原样留着,<base> 和 picker 放对地方,总量上限,@import 循环
|
|
@@ -194,6 +202,8 @@ node e2e/e2e-studio.mjs # 真机(要本机 Chrome,会从 npm 装一个临
|
|
|
194
202
|
|
|
195
203
|
## 已验证
|
|
196
204
|
|
|
205
|
+
新手走查(2026-11,DSH 0.2.1-rc.5):按包名装、点「立即启用」不重启就能用;桌面端设计模式里真模型(DeepSeek-V4.1-Flash)从一句「帮我设计一个社区健身房的会员 App」做到六屏全套,再点选改一处。走下来修了五处:画布到 6 屏后预览不再刷新(每屏各开一条改动通知,占满了浏览器对同一地址的 6 条连接,现在由右侧栏转告);电脑开着「总是显示滚动条」时 App 按 375 宽排版,右侧栏体检和模型的体检对不上;导出的图里外部 `icons.svg` 的图标是空的;整套展示图的大标题是文件夹名;点选进输入框后接着打的字粘在最后一行后面。另外加了几个方向并排时每屏都挂的「✓ 就要这个」和空白页的示例句子。
|
|
206
|
+
|
|
197
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 处(「可外带」「再来一单」和三个价格)。
|
|
198
208
|
|
|
199
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
|
|
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
|
|
933
|
-
.ddp-flows
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
if (to === sc.key || !keys.has(to)) continue
|
|
1986
|
-
const
|
|
1987
|
-
if (
|
|
1988
|
-
|
|
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
|
|
2011
|
-
|
|
2012
|
-
const
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
const
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
const
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
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
|
-
|
|
2029
|
-
|
|
2030
|
-
let
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
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
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
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
|
|
2047
|
-
const
|
|
2048
|
-
const ox =
|
|
2049
|
-
const oy =
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
const
|
|
2054
|
-
const
|
|
2055
|
-
const
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
const
|
|
2060
|
-
const
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
const
|
|
2065
|
-
|
|
2066
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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((
|
|
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
|
|
3342
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2517
|
-
|
|
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
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
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
|
-
|
|
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/locale/en.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"meta": {
|
|
3
3
|
"title": "Design Studio",
|
|
4
|
-
"description": "A design workbench in the right sidebar: web, app and poster screens generate on a canvas inside device frames; click an element to have the model change it, tweak design tokens across every screen, compare and roll back versions, export share-ready images, and scan a QR code to view it on your phone."
|
|
4
|
+
"description": "A design workbench in the right sidebar: web, app and poster screens generate on a canvas inside device frames; click an element to have the model change it, drag sliders to tweak design tokens across every screen, compare and roll back versions, extract a brand's design system from a website, turn a design into React / Vue / HTML code and diff it pixel by pixel, export share-ready images, and scan a QR code to view it on your phone."
|
|
5
5
|
}
|
|
6
6
|
}
|
package/locale/zh.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"meta": {
|
|
3
3
|
"title": "设计工作台",
|
|
4
|
-
"description": "右侧栏的设计工作台:网页、App
|
|
4
|
+
"description": "右侧栏的设计工作台:网页、App、海报一屏屏生成在画布上,套好外框;点一下元素就让模型改那里,拖滑块调设计变量全屏一起变,版本能对比回退;能从网站提取品牌设计系统,也能把设计转成 React / Vue / HTML 代码并逐像素对比;一键导出好看的展示图,扫码就能在手机上看。"
|
|
5
5
|
}
|
|
6
6
|
}
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-design-studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --test --test-concurrency=1 test/*.mjs"
|
|
7
7
|
},
|
|
8
|
-
"description": "Design workbench in the DeepSeek Harness right sidebar: watch web / app / poster screens generate on a canvas with device frames, click an element to send it to the chat, tweak design tokens, compare versions, export share-ready images and scan to view on your phone.",
|
|
8
|
+
"description": "Design workbench in the DeepSeek Harness right sidebar: watch web / app / poster screens generate on a canvas with device frames, click an element to send it to the chat, tweak design tokens with sliders, compare versions, extract a brand's design system from a site, turn a design into React / Vue / HTML code checked pixel by pixel, export share-ready images and scan to view on your phone.",
|
|
9
9
|
"license": "MIT",
|
|
10
10
|
"repository": {
|
|
11
11
|
"type": "git",
|
|
@@ -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>`
|
|
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 -->
|