pm-canvas-viewer 0.1.0 → 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.md +147 -48
- package/bin/cli.js +98 -89
- package/dist/frontend/assets/index-BGlLkKpJ.js +847 -0
- package/dist/frontend/assets/index-CQryX8ee.css +1 -0
- package/dist/frontend/assets/{main-DeCTtllz.js → main-Ac6ZQwWa.js} +1 -1
- package/dist/frontend/index.html +2 -2
- package/lib/history.js +103 -4
- package/lib/inject-pm-ids-rules.js +52 -0
- package/lib/inject-pm-ids.js +275 -0
- package/lib/inject-pm-ids.test.js +303 -0
- package/lib/prompts.js +8 -33
- package/lib/scan-and-inject.js +65 -0
- package/lib/scanner.js +286 -49
- package/package.json +11 -6
- package/server/api/anchors.js +86 -17
- package/server/api/files.js +100 -44
- package/server/api/textPatch.js +595 -0
- package/server/api/textPatch.test.js +248 -0
- package/server/index.js +54 -13
- package/server/itemRegistry.js +38 -0
- package/server/lib/jsAstUtils.js +179 -0
- package/server/lib/jsAstUtils.test.js +60 -0
- package/server/patchers/HtmlAttrPatcher.js +111 -0
- package/server/patchers/HtmlAttrPatcher.test.js +149 -0
- package/server/patchers/HtmlTextPatcher.js +143 -0
- package/server/patchers/HtmlTextPatcher.test.js +226 -0
- package/server/patchers/JsLiteralPatcher.js +154 -0
- package/server/patchers/JsLiteralPatcher.test.js +269 -0
- package/server/patchers/index.js +24 -0
- package/dist/frontend/assets/index-CDTMa6Xk.js +0 -287
- package/dist/frontend/assets/index-idGrS1dS.css +0 -1
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# pm-canvas-viewer
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
本地 **HTML 原型 + 配套文档** 查看编辑器。一键启动,浏览器操作。
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
打开任意"含 HTML 文件 + 可选 docs/ 文档"的目录,左侧选项 / 切页,中间预览,右侧 Markdown / Excalidraw / draw.io 三种文档编辑器。完整兼容 [pm-canvas skill](../skill/) 生成的画板(含锚点系统)。
|
|
6
6
|
|
|
7
7
|
## 安装
|
|
8
8
|
|
|
@@ -14,70 +14,156 @@ npm i -g pm-canvas-viewer
|
|
|
14
14
|
|
|
15
15
|
## 使用
|
|
16
16
|
|
|
17
|
+
打开一个**文件夹**(viewer 不接受单文件路径——它的定位是"HTML + 配套文档"组合):
|
|
18
|
+
|
|
17
19
|
```bash
|
|
18
|
-
canvas open
|
|
20
|
+
canvas open ./我的原型/
|
|
21
|
+
canvas open # 等价于 canvas open .
|
|
22
|
+
canvas # 从历史记录里检索
|
|
23
|
+
canvas list # 查看运行中的实例
|
|
24
|
+
canvas stop # 停止实例
|
|
19
25
|
```
|
|
20
26
|
|
|
21
|
-
|
|
27
|
+
浏览器自动打开。viewer 会扫目录识别"展示项",然后按情况渲染界面。
|
|
28
|
+
|
|
29
|
+
### viewer 识别哪些目录
|
|
30
|
+
|
|
31
|
+
每个目录独立判断:
|
|
32
|
+
|
|
33
|
+
| 类型 | 判定 | viewer 内表现 |
|
|
34
|
+
|---|---|---|
|
|
35
|
+
| **画板项** | 含 `canvas.json` 且 `rows[]` 是数组 | 完整画板能力 + 锚点系统 |
|
|
36
|
+
| **html 项** | 直接含 ≥1 个 `.html` 且无 canvas.json | 普通预览 + iframe 跳转拦截 |
|
|
37
|
+
| **容器目录** | 自身非项,子孙含项 | 在左栏作为分组节点展开 |
|
|
38
|
+
| 其他 | 无 html 无 canvas.json | 完全忽略 |
|
|
22
39
|
|
|
23
|
-
|
|
40
|
+
过滤名单:`node_modules` / `.git` / `dist` / `docs` / `.DS_Store` / `assets`。深度上限 8 层。
|
|
41
|
+
|
|
42
|
+
### 界面布局
|
|
24
43
|
|
|
25
44
|
```
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
│
|
|
31
|
-
│
|
|
32
|
-
|
|
33
|
-
├── 需求规格.md
|
|
34
|
-
└── 业务流程.excalidraw
|
|
45
|
+
┌────────────┬──────────────────┬────────────┐
|
|
46
|
+
│ 左栏 │ 中间画框 │ 右栏 │
|
|
47
|
+
│ │ (iframe) │ │
|
|
48
|
+
│ 项列表 ▾ │ │ 文档面板 │
|
|
49
|
+
│ ──────── │ │ + 编辑器 │
|
|
50
|
+
│ 页列表 │ │ │
|
|
51
|
+
└────────────┴──────────────────┴────────────┘
|
|
35
52
|
```
|
|
36
53
|
|
|
37
|
-
|
|
54
|
+
- **左栏自适应显隐**:项数 ≥2 显示项列表(树状);当前 html 项 ≥2 页时显示页列表;两者都无显示时左栏整体隐藏(沉浸预览)
|
|
55
|
+
- **页序**:`index.html > home.html > A-Z`,支持拖动调整并按项记忆
|
|
56
|
+
- **html 项的跳转策略**:同源同项放行 + 同步页列表选中态;外链或跨项拦截 + toast 提示
|
|
57
|
+
|
|
58
|
+
### 几种典型场景
|
|
38
59
|
|
|
39
60
|
```bash
|
|
40
|
-
|
|
61
|
+
# 1. 标准画板(向后兼容,体验和老版本完全一致)
|
|
62
|
+
canvas open ./画板目录/
|
|
63
|
+
|
|
64
|
+
# 2. 多版本画板管理
|
|
65
|
+
canvas open ./我的产品/
|
|
66
|
+
# ↑ 目录里有 v1/canvas.json、v2/canvas.json,左栏显示项树
|
|
41
67
|
|
|
42
|
-
#
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
68
|
+
# 3. 多页 HTML 原型
|
|
69
|
+
canvas open ./用户中心原型/
|
|
70
|
+
# ↑ 目录直接含 home.html / profile.html / settings.html,左下显示页列表
|
|
71
|
+
|
|
72
|
+
# 4. 混合(画板 + 普通 HTML 共存)
|
|
73
|
+
canvas open ./prototypes/
|
|
74
|
+
# ↑ 含画板子目录 + 散户 html 目录,左栏按物理目录树状展开
|
|
46
75
|
```
|
|
47
76
|
|
|
48
77
|
## 功能
|
|
49
78
|
|
|
50
|
-
###
|
|
51
|
-
|
|
79
|
+
### 中间画框
|
|
80
|
+
|
|
81
|
+
- **画板项**:保留 pm-canvas shell 的无限画布、缩放、平移;可挂载锚点
|
|
82
|
+
- **html 项**:iframe 加载选中页,`sandbox="allow-scripts allow-same-origin"`,onload 注入跳转拦截脚本(同源放行 + URL 变化同步左下页列表;外链拦截 toast)
|
|
52
83
|
|
|
53
|
-
###
|
|
84
|
+
### 右栏:文档面板
|
|
85
|
+
|
|
86
|
+
支持三种文档(都按"当前选中项的 `docs/`"动态绑定):
|
|
54
87
|
|
|
55
88
|
**Markdown 编辑器**
|
|
56
|
-
- Tiptap 3
|
|
57
|
-
-
|
|
58
|
-
- `⌘B` 加粗、`⌘I` 斜体
|
|
59
|
-
- `⌘S` 保存到本地文件
|
|
89
|
+
- Tiptap 3 WYSIWYG,工具栏:标题、加粗、斜体、列表、代码、表格
|
|
90
|
+
- `⌘B` 加粗、`⌘I` 斜体、`⌘S` 保存
|
|
60
91
|
|
|
61
92
|
**Excalidraw 编辑器**
|
|
62
|
-
- 完整 Excalidraw
|
|
63
|
-
- `⌘S` 保存为 .excalidraw
|
|
93
|
+
- 完整 Excalidraw(流程图、架构图等)
|
|
94
|
+
- `⌘S` 保存为 .excalidraw
|
|
95
|
+
|
|
96
|
+
**draw.io 编辑器**
|
|
97
|
+
- 集成 diagrams.net iframe,`⌘S` 保存为 .drawio
|
|
64
98
|
|
|
65
99
|
**文件管理**
|
|
66
|
-
-
|
|
67
|
-
-
|
|
68
|
-
|
|
100
|
+
- 标签页切换、拖动排序(按项独立记忆)
|
|
101
|
+
- 空状态点 + 新建文件,触发 `docs/` 按需创建(不会无 docs 文件就生成空 `docs/` 目录)
|
|
102
|
+
|
|
103
|
+
### 锚点系统(仅画板项)
|
|
104
|
+
|
|
105
|
+
- 双击画板创建锚点 pin(自定义颜色 + 标签)
|
|
106
|
+
- 锚点可关联到文档文件、或文档内的标题(marker)
|
|
107
|
+
- pin ↔ Tiptap chip 双向跳转
|
|
108
|
+
- 数据存 `docs/anchors.json`,按项隔离
|
|
109
|
+
- 切到 html 项时**彻底卸载**(无 iframe DOM 监听残留)
|
|
110
|
+
|
|
111
|
+
### 文案就地编辑(v0.5+)
|
|
112
|
+
|
|
113
|
+
无需翻代码即可改原型/画板里的可见文字,写回源 HTML/JS 文件。
|
|
114
|
+
|
|
115
|
+
**5 类可编辑范围**:
|
|
116
|
+
|
|
117
|
+
| 类别 | 例 | 引入 |
|
|
118
|
+
|------|---|------|
|
|
119
|
+
| HTML 静态文本 | `<p>文字</p>` 里的"文字" | v1.0 |
|
|
120
|
+
| HTML mixed content | `<div>市值<b>14.5万</b>仓位</div>` 里的"市值"或"仓位"(每段独立可改) | v1.5 |
|
|
121
|
+
| HTML 属性白名单 | `<input placeholder=>`、`<img alt=>`、`title` / `aria-label` / `aria-description` | v1.5 |
|
|
122
|
+
| JS 字符串字面量 | `const STOCK = 'AI芯片';` 里的 `'AI芯片'`(含单/双引号 + 纯反引号字符串) | v1.5 |
|
|
123
|
+
| 模板字符串静态片段 | `` `hello ${name}` `` 里的 `hello ` | v1.5 |
|
|
124
|
+
|
|
125
|
+
**入口**(任一):按 `E` 键 / canvas 项 iframe toolbar 「开启文案编辑」 / html 项右上角浮层 toggle。
|
|
126
|
+
|
|
127
|
+
**不可编辑提示**:灰色高亮 + hover 200ms tooltip,10 类原因(`no-locator` / `pm-id-not-found` / `text-mismatch` / `no-text-child` / `whitespace-only` / `computed-text` / `template-expression` / `attribute-not-editable` / `script-or-style-internal` / `source-position-ambiguous`)。
|
|
128
|
+
|
|
129
|
+
**实现要点**:
|
|
130
|
+
- 后端 Patcher Strategy(HtmlText / HtmlAttr / JsLiteral),用 parse5 + acorn 解析定位 + 字符 offset 切片重写
|
|
131
|
+
- JS 字面量首次保存自动插 sentinel 注释(`/*pm-js-id:lit-xxx*/`)便于下次精确反查;多处同字面量返 `source-position-ambiguous`
|
|
132
|
+
- 保存走 fileHash 校验 + 冲突弹窗(覆盖 / 取消)
|
|
133
|
+
- 详见 [`ai/specs/2026-06-03-inline-text-editing-v1.5-design.md`](../ai/specs/2026-06-03-inline-text-editing-v1.5-design.md)
|
|
134
|
+
|
|
135
|
+
**已知限制**(v1.5 → 后续):
|
|
136
|
+
- JS 字面量改完后 DOM 不自动重算(DOM 是 JS innerHTML 拼出)→ toast 提示「刷新页面看效果」;根治需 skill 模板侧为动态节点注入 `data-pm-id`
|
|
137
|
+
- 模板字符串 quasi 不插 sentinel(会破坏模板字符串)→ 同 stringValue quasi 多处时 `source-position-ambiguous`
|
|
138
|
+
- 见 `ai/ROADMAP.md` 技术债清单
|
|
139
|
+
|
|
140
|
+
### 命令行
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
canvas open [目录] -p <端口> # 默认 4800
|
|
144
|
+
canvas # 历史检索
|
|
145
|
+
canvas list # 列运行实例
|
|
146
|
+
canvas stop [port|--all] # 停止实例
|
|
147
|
+
canvas --version / --help
|
|
148
|
+
```
|
|
69
149
|
|
|
70
150
|
## 技术栈
|
|
71
151
|
|
|
72
152
|
| 层 | 技术 |
|
|
73
153
|
|---|---|
|
|
74
154
|
| CLI | Commander.js |
|
|
75
|
-
| 服务端 | Express
|
|
155
|
+
| 服务端 | Express(ItemRegistry + routeId 协议 + 路径穿越防御) |
|
|
76
156
|
| 前端框架 | React 18 + Vite |
|
|
77
157
|
| MD 编辑器 | Tiptap 3 WYSIWYG(@tiptap/markdown) |
|
|
78
|
-
| 流程图编辑 | @excalidraw/excalidraw |
|
|
158
|
+
| 流程图编辑 | @excalidraw/excalidraw + diagrams.net embed |
|
|
79
159
|
| 构建 | Vite 5 |
|
|
80
160
|
|
|
161
|
+
## 安全
|
|
162
|
+
|
|
163
|
+
- 服务端只接受 scanner 产出的 routeId(sha1 前 8 位),不接受任意路径
|
|
164
|
+
- 路径穿越二段防御:filename 层拒绝 `\0` / `/` / `..` / `.` 开头;resolve 后强制落在项目录内
|
|
165
|
+
- iframe sandbox(html 项)+ 跳转拦截脚本(拦 `<a>` / `<form>` / `window.open` / `history.pushState` / 越界 location)
|
|
166
|
+
|
|
81
167
|
## 开发
|
|
82
168
|
|
|
83
169
|
```bash
|
|
@@ -88,25 +174,38 @@ npm run dev
|
|
|
88
174
|
npm run build
|
|
89
175
|
|
|
90
176
|
# 用开发版直接测试
|
|
91
|
-
node bin/cli.js open /path/to/
|
|
177
|
+
node bin/cli.js open /path/to/dir --port 4800
|
|
92
178
|
```
|
|
93
179
|
|
|
94
180
|
### 项目结构
|
|
95
181
|
|
|
96
182
|
```
|
|
97
183
|
pm-canvas-viewer/
|
|
98
|
-
├── bin/cli.js
|
|
184
|
+
├── bin/cli.js ← CLI 入口
|
|
99
185
|
├── server/
|
|
100
|
-
│ ├── index.js
|
|
101
|
-
│
|
|
102
|
-
|
|
103
|
-
│
|
|
104
|
-
│
|
|
105
|
-
|
|
106
|
-
│
|
|
107
|
-
│
|
|
108
|
-
│
|
|
109
|
-
│ └──
|
|
110
|
-
├──
|
|
111
|
-
|
|
186
|
+
│ ├── index.js ← Express 服务器 + /item/:routeId/* 静态路由
|
|
187
|
+
│ ├── itemRegistry.js ← 单例 routeId → Item 映射
|
|
188
|
+
│ └── api/
|
|
189
|
+
│ ├── files.js ← docs/ CRUD(routeId 协议 + 按需 mkdir)
|
|
190
|
+
│ └── anchors.js ← anchors.json CRUD(仅画板项)
|
|
191
|
+
├── lib/
|
|
192
|
+
│ ├── scanner.js ← 展示项扫描(画板 / html / 容器 / 过滤)
|
|
193
|
+
│ ├── history.js ← 启动过的根目录历史
|
|
194
|
+
│ ├── port.js ← 端口管理
|
|
195
|
+
│ └── prompts.js ← 终端 TUI(fuzzy 选择)
|
|
196
|
+
├── frontend/src/
|
|
197
|
+
│ ├── App.jsx ← 主布局 + 导航状态机 + 切项清理
|
|
198
|
+
│ ├── apiClient.js ← 统一注入 routeId 的 fetch 封装
|
|
199
|
+
│ ├── Sidebar/ ← 左栏(项树 + 页列表)
|
|
200
|
+
│ ├── CanvasView/
|
|
201
|
+
│ │ ├── index.jsx ← iframe 双模式渲染
|
|
202
|
+
│ │ └── iframeInterceptor.js ← html 项跳转拦截脚本生成器
|
|
203
|
+
│ ├── DocPanel/ ← 右栏 + 新建文件
|
|
204
|
+
│ ├── MdEditor/ ← Tiptap MD
|
|
205
|
+
│ ├── ExcalidrawEditor/ ← Excalidraw
|
|
206
|
+
│ ├── DrawioEditor/ ← draw.io iframe
|
|
207
|
+
│ ├── AnchorSystem/ ← 锚点 overlay + store + dialog
|
|
208
|
+
│ └── Toast.jsx ← 拦截反馈
|
|
209
|
+
├── dist/ ← 前端构建产物(gitignored,发包时打入)
|
|
210
|
+
└── package.json
|
|
112
211
|
```
|
package/bin/cli.js
CHANGED
|
@@ -1,84 +1,65 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from 'commander';
|
|
3
|
-
import { resolve } from 'path';
|
|
4
|
-
import { existsSync } from 'fs';
|
|
3
|
+
import { resolve, basename } from 'path';
|
|
4
|
+
import { existsSync, statSync } from 'fs';
|
|
5
5
|
import open from 'open';
|
|
6
6
|
import { createServer } from '../server/index.js';
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
|
|
7
|
+
import { scanForItems } from '../lib/scanner.js';
|
|
8
|
+
import { recordOpenRoot, getRecentRoots } from '../lib/history.js';
|
|
9
|
+
import {
|
|
10
|
+
findAvailablePort, isPortFree, getPortOccupant, killPort,
|
|
11
|
+
listRunningInstances, registerInstance,
|
|
12
|
+
} from '../lib/port.js';
|
|
13
|
+
import { fuzzySelectFromHistory, handlePortConflict } from '../lib/prompts.js';
|
|
14
|
+
import registry from '../server/itemRegistry.js';
|
|
11
15
|
|
|
12
16
|
const program = new Command();
|
|
13
17
|
|
|
14
18
|
program
|
|
15
19
|
.name('canvas')
|
|
16
|
-
.description('Local
|
|
20
|
+
.description('Local viewer + doc editor for HTML prototypes & pm-canvas boards')
|
|
17
21
|
.version('0.1.0');
|
|
18
22
|
|
|
19
|
-
//
|
|
23
|
+
// canvas — 从历史里挑一个根目录打开
|
|
20
24
|
program
|
|
21
25
|
.action(async () => {
|
|
22
|
-
const entries = await
|
|
26
|
+
const entries = await getRecentRoots();
|
|
23
27
|
const selected = await fuzzySelectFromHistory(entries);
|
|
24
28
|
if (!selected) process.exit(0);
|
|
25
29
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
console.error(` Canvas no longer exists: ${selected.path}`);
|
|
30
|
+
if (!existsSync(selected.rootPath)) {
|
|
31
|
+
console.error(` Root no longer exists: ${selected.rootPath}`);
|
|
29
32
|
process.exit(1);
|
|
30
33
|
}
|
|
31
34
|
|
|
32
|
-
await
|
|
35
|
+
await startServerForRoot(selected.rootPath);
|
|
33
36
|
});
|
|
34
37
|
|
|
35
|
-
// canvas open [path] —
|
|
38
|
+
// canvas open [path] — 打开指定文件夹(或当前目录)
|
|
36
39
|
program
|
|
37
40
|
.command('open [path]')
|
|
38
|
-
.description('
|
|
41
|
+
.description('Open a directory as viewer root (scans for items)')
|
|
39
42
|
.option('-p, --port <port>', 'Server port', '4800')
|
|
40
43
|
.action(async (targetPath, opts) => {
|
|
41
44
|
portOverride = opts.port ? parseInt(opts.port) : null;
|
|
42
45
|
|
|
43
|
-
|
|
44
|
-
const fullPath = resolve(targetPath);
|
|
46
|
+
const target = targetPath ? resolve(targetPath) : process.cwd();
|
|
45
47
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
// Direct canvas path
|
|
52
|
-
const meta = await tryReadCanvasMeta(fullPath);
|
|
53
|
-
if (meta) {
|
|
54
|
-
await startServer(meta.path, meta);
|
|
55
|
-
return;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// Not a canvas — scan it
|
|
59
|
-
console.log(' Scanning for canvases...');
|
|
60
|
-
const found = await scanForCanvases(fullPath, 4);
|
|
61
|
-
const history = await getRecentCanvases();
|
|
62
|
-
const selected = await fuzzySelectCanvas(found, history);
|
|
63
|
-
if (!selected) process.exit(0);
|
|
64
|
-
await startServer(selected.path, selected);
|
|
65
|
-
} else {
|
|
66
|
-
// No path — scan current directory
|
|
67
|
-
const cwd = process.cwd();
|
|
68
|
-
const cwdMeta = await tryReadCanvasMeta(cwd);
|
|
69
|
-
|
|
70
|
-
if (cwdMeta) {
|
|
71
|
-
await startServer(cwdMeta.path, cwdMeta);
|
|
72
|
-
return;
|
|
73
|
-
}
|
|
48
|
+
if (!existsSync(target)) {
|
|
49
|
+
console.error(` Path not found: ${target}`);
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
74
52
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
53
|
+
// 拒绝单文件路径 — viewer 定位是"HTML + docs/"组合,必须是文件夹
|
|
54
|
+
const st = statSync(target);
|
|
55
|
+
if (!st.isDirectory()) {
|
|
56
|
+
console.error(` viewer 必须打开文件夹,不接受单文件路径`);
|
|
57
|
+
console.error(` Got: ${target}`);
|
|
58
|
+
console.error(` Hint: 用浏览器直接打开 HTML 文件即可,viewer 用于"HTML + docs 文档"组合查看`);
|
|
59
|
+
process.exit(1);
|
|
81
60
|
}
|
|
61
|
+
|
|
62
|
+
await startServerForRoot(target);
|
|
82
63
|
});
|
|
83
64
|
|
|
84
65
|
program
|
|
@@ -90,7 +71,6 @@ program
|
|
|
90
71
|
console.log(' No running canvas instances.');
|
|
91
72
|
return;
|
|
92
73
|
}
|
|
93
|
-
|
|
94
74
|
console.log(`\n Running instances:\n`);
|
|
95
75
|
for (const inst of instances) {
|
|
96
76
|
console.log(` Port ${inst.port} PID ${inst.pid}`);
|
|
@@ -147,52 +127,81 @@ program
|
|
|
147
127
|
|
|
148
128
|
let portOverride = null;
|
|
149
129
|
|
|
150
|
-
async function
|
|
151
|
-
|
|
152
|
-
let
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
if (occupant?.isPmCanvas) {
|
|
160
|
-
const action = await handlePortConflict(preferredPort, occupant);
|
|
161
|
-
if (!action) process.exit(0);
|
|
162
|
-
|
|
163
|
-
switch (action) {
|
|
164
|
-
case 'reuse':
|
|
165
|
-
const url = `http://localhost:${preferredPort}`;
|
|
166
|
-
console.log(`\n Opening ${url}`);
|
|
167
|
-
await open(url);
|
|
168
|
-
process.exit(0);
|
|
169
|
-
case 'kill':
|
|
170
|
-
killPort(preferredPort);
|
|
171
|
-
await new Promise(r => setTimeout(r, 500));
|
|
172
|
-
port = preferredPort;
|
|
173
|
-
break;
|
|
174
|
-
case 'next':
|
|
175
|
-
port = await findAvailablePort(preferredPort + 1);
|
|
176
|
-
break;
|
|
177
|
-
}
|
|
178
|
-
} else {
|
|
179
|
-
port = await findAvailablePort(preferredPort);
|
|
180
|
-
}
|
|
130
|
+
async function startServerForRoot(rootDir) {
|
|
131
|
+
console.log(' Scanning for items...');
|
|
132
|
+
let scanResult;
|
|
133
|
+
try {
|
|
134
|
+
scanResult = await scanForItems(rootDir);
|
|
135
|
+
} catch (err) {
|
|
136
|
+
console.error(` Scan failed: ${err.message}`);
|
|
137
|
+
process.exit(1);
|
|
181
138
|
}
|
|
182
139
|
|
|
183
|
-
|
|
184
|
-
|
|
140
|
+
if (scanResult.items.length === 0) {
|
|
141
|
+
console.error(` No HTML prototypes or canvas boards found under ${rootDir}`);
|
|
142
|
+
console.error(` Hint: 目录里需要至少 1 个 .html 文件,或含 canvas.json 的子目录`);
|
|
143
|
+
process.exit(1);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const rootName = basename(rootDir);
|
|
147
|
+
registry.setRoot({
|
|
148
|
+
rootDir,
|
|
149
|
+
rootName,
|
|
150
|
+
items: scanResult.items,
|
|
151
|
+
tree: scanResult.tree,
|
|
152
|
+
stats: scanResult.stats,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// v0.5: 编辑文案功能需要每个元素有稳定 data-pm-id,启动时自动注入(幂等)
|
|
156
|
+
const { injectAllCanvasItems } = await import('../lib/scan-and-inject.js');
|
|
157
|
+
const injectResults = await injectAllCanvasItems(scanResult.items);
|
|
158
|
+
const changed = injectResults.filter(r => r.changed).length;
|
|
159
|
+
if (changed > 0) {
|
|
160
|
+
console.log(` ✓ 已给 ${changed} 个 HTML 文件注入 data-pm-id(首次迁移)`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const port = await resolvePort(portOverride || 4800);
|
|
164
|
+
if (port === null) process.exit(0); // 用户在端口冲突 prompt 中选了 reuse
|
|
165
|
+
|
|
166
|
+
const server = createServer();
|
|
185
167
|
server.listen(port, async () => {
|
|
186
168
|
const url = `http://localhost:${port}`;
|
|
169
|
+
const { itemCount, canvasCount, htmlCount } = scanResult.stats;
|
|
187
170
|
console.log(`\n canvas-viewer`);
|
|
188
|
-
console.log(`
|
|
189
|
-
console.log(`
|
|
171
|
+
console.log(` Root: ${rootDir}`);
|
|
172
|
+
console.log(` Items: ${itemCount} (${canvasCount} canvas · ${htmlCount} html)`);
|
|
190
173
|
console.log(` URL: ${url}\n`);
|
|
191
174
|
open(url);
|
|
192
175
|
|
|
193
|
-
await registerInstance(port,
|
|
194
|
-
await
|
|
176
|
+
await registerInstance(port, rootDir, rootName).catch(() => {});
|
|
177
|
+
await recordOpenRoot(rootDir, { title: rootName, itemCount }).catch(() => {});
|
|
195
178
|
});
|
|
196
179
|
}
|
|
197
180
|
|
|
181
|
+
async function resolvePort(preferredPort) {
|
|
182
|
+
if (await isPortFree(preferredPort)) return preferredPort;
|
|
183
|
+
|
|
184
|
+
const occupant = getPortOccupant(preferredPort);
|
|
185
|
+
if (occupant?.isPmCanvas) {
|
|
186
|
+
const action = await handlePortConflict(preferredPort, occupant);
|
|
187
|
+
if (!action) return null;
|
|
188
|
+
|
|
189
|
+
switch (action) {
|
|
190
|
+
case 'reuse': {
|
|
191
|
+
const url = `http://localhost:${preferredPort}`;
|
|
192
|
+
console.log(`\n Opening ${url}`);
|
|
193
|
+
await open(url);
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
case 'kill':
|
|
197
|
+
killPort(preferredPort);
|
|
198
|
+
await new Promise(r => setTimeout(r, 500));
|
|
199
|
+
return preferredPort;
|
|
200
|
+
case 'next':
|
|
201
|
+
return await findAvailablePort(preferredPort + 1);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return await findAvailablePort(preferredPort);
|
|
205
|
+
}
|
|
206
|
+
|
|
198
207
|
program.parse();
|