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 CHANGED
@@ -1,8 +1,8 @@
1
1
  # pm-canvas-viewer
2
2
 
3
- 本地画板查看器 + 文档编辑工具。一键启动,浏览器操作。
3
+ 本地 **HTML 原型 + 配套文档** 查看编辑器。一键启动,浏览器操作。
4
4
 
5
- 配合 [pm-canvas skill](../skill/) 使用:Agent 生成画板文件,人用 viewer 预览画板并编辑关联文档。
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
- ├── index.html 画板 shell(Agent 生成)
28
- ├── canvas.json ← 画板元数据(Agent 生成)
29
- ├── rows/ ← 画板行文件(Agent 生成)
30
- ├── 01-home.html
31
- └── 02-profile.html
32
- └── docs/ ← 关联文档(人维护,viewer 读写)
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
- canvas open [目录] [选项]
61
+ # 1. 标准画板(向后兼容,体验和老版本完全一致)
62
+ canvas open ./画板目录/
63
+
64
+ # 2. 多版本画板管理
65
+ canvas open ./我的产品/
66
+ # ↑ 目录里有 v1/canvas.json、v2/canvas.json,左栏显示项树
41
67
 
42
- # 选项
43
- -p, --port <端口> 指定端口(默认 4800)
44
- -V, --version 查看版本
45
- -h, --help 查看帮助
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
- - iframe 加载画板 index.html,所有 row 平铺展示
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 所见即所得编辑器(WYSIWYG,`@tiptap/markdown` 读写 MD)
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
- - `+` 按钮新建 Markdown Excalidraw 文件
68
- - 文件保存在画板目录的 `docs/` 下
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(静态文件 + 文件读写 API) |
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/canvas --port 4800
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 ← CLI 入口
184
+ ├── bin/cli.js ← CLI 入口
99
185
  ├── server/
100
- │ ├── index.js ← Express 服务器
101
- └── api/files.js 文件 CRUD API(限定 docs/ 目录)
102
- ├── frontend/
103
- ├── src/
104
- │ ├── App.jsx 主布局
105
- │ │ ├── CanvasView/ ← 画板 iframe
106
- ├── DocPanel/ 右侧面板 + 标签页 + 新建
107
- ├── MdEditor/ Tiptap MD 编辑器
108
- │ └── ExcalidrawEditor/ Excalidraw 编辑器
109
- │ └── vite.config.js
110
- ├── dist/ ← 前端构建产物(gitignored)
111
- └── ai/ AI 协作产物
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 { scanForCanvases, tryReadCanvasMeta } from '../lib/scanner.js';
8
- import { recordOpen, getRecentCanvases } from '../lib/history.js';
9
- import { findAvailablePort, isPortFree, getPortOccupant, killPort, listRunningInstances, registerInstance } from '../lib/port.js';
10
- import { fuzzySelectCanvas, fuzzySelectFromHistory, handlePortConflict } from '../lib/prompts.js';
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 canvas viewer + doc editor')
20
+ .description('Local viewer + doc editor for HTML prototypes & pm-canvas boards')
17
21
  .version('0.1.0');
18
22
 
19
- // Default command: canvas — interactive fuzzy search over history
23
+ // canvas — 从历史里挑一个根目录打开
20
24
  program
21
25
  .action(async () => {
22
- const entries = await getRecentCanvases();
26
+ const entries = await getRecentRoots();
23
27
  const selected = await fuzzySelectFromHistory(entries);
24
28
  if (!selected) process.exit(0);
25
29
 
26
- const meta = await tryReadCanvasMeta(selected.path);
27
- if (!meta) {
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 startServer(selected.path, meta);
35
+ await startServerForRoot(selected.rootPath);
33
36
  });
34
37
 
35
- // canvas open [path] — scan current directory or open a direct path
38
+ // canvas open [path] — 打开指定文件夹(或当前目录)
36
39
  program
37
40
  .command('open [path]')
38
- .description('Scan current directory for canvases, or open a path directly')
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
- if (targetPath) {
44
- const fullPath = resolve(targetPath);
46
+ const target = targetPath ? resolve(targetPath) : process.cwd();
45
47
 
46
- if (!existsSync(fullPath)) {
47
- console.error(` Directory not found: ${fullPath}`);
48
- process.exit(1);
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
- console.log(' Scanning for canvases...');
76
- const found = await scanForCanvases(cwd, 4);
77
- const history = await getRecentCanvases();
78
- const selected = await fuzzySelectCanvas(found, history);
79
- if (!selected) process.exit(0);
80
- await startServer(selected.path, selected);
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 startServer(canvasDir, meta) {
151
- const preferredPort = portOverride || 4800;
152
- let port;
153
-
154
- if (await isPortFree(preferredPort)) {
155
- port = preferredPort;
156
- } else {
157
- const occupant = getPortOccupant(preferredPort);
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
- const server = createServer(canvasDir);
184
- const title = meta?.title || canvasDir.split('/').pop();
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(` Canvas: ${title}`);
189
- console.log(` Path: ${canvasDir}`);
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, canvasDir, title).catch(() => {});
194
- await recordOpen(canvasDir, meta?.title).catch(() => {});
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();