pm-canvas-viewer 0.1.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/LICENSE +21 -0
- package/README.md +112 -0
- package/bin/cli.js +198 -0
- package/dist/frontend/assets/index-CDTMa6Xk.js +287 -0
- package/dist/frontend/assets/index-idGrS1dS.css +1 -0
- package/dist/frontend/assets/main-DeCTtllz.js +293 -0
- package/dist/frontend/index.html +13 -0
- package/lib/history.js +63 -0
- package/lib/port.js +106 -0
- package/lib/prompts.js +183 -0
- package/lib/scanner.js +72 -0
- package/package.json +68 -0
- package/server/api/anchors.js +44 -0
- package/server/api/files.js +93 -0
- package/server/index.js +32 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>pm-canvas viewer</title>
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-CDTMa6Xk.js"></script>
|
|
8
|
+
<link rel="stylesheet" crossorigin href="/assets/index-idGrS1dS.css">
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<div id="root"></div>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
package/lib/history.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from 'fs/promises';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
|
|
5
|
+
const HISTORY_DIR = join(homedir(), '.pm-canvas');
|
|
6
|
+
const HISTORY_FILE = join(HISTORY_DIR, 'history.json');
|
|
7
|
+
|
|
8
|
+
export async function loadHistory() {
|
|
9
|
+
try {
|
|
10
|
+
const raw = await readFile(HISTORY_FILE, 'utf-8');
|
|
11
|
+
return JSON.parse(raw);
|
|
12
|
+
} catch {
|
|
13
|
+
return [];
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function saveHistory(entries) {
|
|
18
|
+
await mkdir(HISTORY_DIR, { recursive: true });
|
|
19
|
+
await writeFile(HISTORY_FILE, JSON.stringify(entries, null, 2));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function recordOpen(canvasPath, title) {
|
|
23
|
+
const entries = await loadHistory();
|
|
24
|
+
const existing = entries.find(e => e.path === canvasPath);
|
|
25
|
+
const now = Date.now();
|
|
26
|
+
|
|
27
|
+
if (existing) {
|
|
28
|
+
existing.count += 1;
|
|
29
|
+
existing.lastOpened = now;
|
|
30
|
+
if (title) existing.title = title;
|
|
31
|
+
} else {
|
|
32
|
+
entries.push({ path: canvasPath, title: title || null, count: 1, lastOpened: now });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
await saveHistory(entries);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function getRecentCanvases() {
|
|
39
|
+
const entries = await loadHistory();
|
|
40
|
+
return entries
|
|
41
|
+
.map(e => ({ ...e, score: frecency(e) }))
|
|
42
|
+
.sort((a, b) => b.score - a.score);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function fuzzyMatch(keyword) {
|
|
46
|
+
const entries = await getRecentCanvases();
|
|
47
|
+
const kw = keyword.toLowerCase();
|
|
48
|
+
return entries.filter(e => {
|
|
49
|
+
const pathMatch = e.path.toLowerCase().includes(kw);
|
|
50
|
+
const titleMatch = e.title && e.title.toLowerCase().includes(kw);
|
|
51
|
+
return pathMatch || titleMatch;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function frecency(entry) {
|
|
56
|
+
const hoursAgo = (Date.now() - entry.lastOpened) / (1000 * 60 * 60);
|
|
57
|
+
let recency;
|
|
58
|
+
if (hoursAgo < 1) recency = 8;
|
|
59
|
+
else if (hoursAgo < 24) recency = 4;
|
|
60
|
+
else if (hoursAgo < 72) recency = 2;
|
|
61
|
+
else recency = 1;
|
|
62
|
+
return Math.log2(entry.count + 1) * recency;
|
|
63
|
+
}
|
package/lib/port.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { createServer } from 'net';
|
|
2
|
+
import { execSync } from 'child_process';
|
|
3
|
+
import { readdir, readFile, writeFile, unlink, mkdir } from 'fs/promises';
|
|
4
|
+
import { unlinkSync } from 'fs';
|
|
5
|
+
import { join } from 'path';
|
|
6
|
+
import { homedir } from 'os';
|
|
7
|
+
|
|
8
|
+
const DEFAULT_PORT = 4800;
|
|
9
|
+
const MAX_RETRIES = 10;
|
|
10
|
+
const INSTANCES_DIR = join(homedir(), '.pm-canvas', 'instances');
|
|
11
|
+
|
|
12
|
+
export async function findAvailablePort(startPort = DEFAULT_PORT) {
|
|
13
|
+
for (let port = startPort; port < startPort + MAX_RETRIES; port++) {
|
|
14
|
+
if (await isPortFree(port)) return port;
|
|
15
|
+
}
|
|
16
|
+
throw new Error(`No available port found in range ${startPort}-${startPort + MAX_RETRIES - 1}`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function isPortFree(port) {
|
|
20
|
+
return new Promise(resolve => {
|
|
21
|
+
const srv = createServer();
|
|
22
|
+
srv.once('error', () => resolve(false));
|
|
23
|
+
srv.listen(port, () => {
|
|
24
|
+
srv.close(() => resolve(true));
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function getPortOccupant(port) {
|
|
30
|
+
try {
|
|
31
|
+
const out = execSync(`lsof -i :${port} -t 2>/dev/null`, { encoding: 'utf-8' }).trim();
|
|
32
|
+
if (!out) return null;
|
|
33
|
+
|
|
34
|
+
const pid = out.split('\n')[0];
|
|
35
|
+
const cmdline = execSync(`ps -p ${pid} -o command= 2>/dev/null`, { encoding: 'utf-8' }).trim();
|
|
36
|
+
const isPmCanvas = cmdline.includes('cli.js') && cmdline.includes('open');
|
|
37
|
+
|
|
38
|
+
return { pid: parseInt(pid), command: cmdline, isPmCanvas };
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function killPort(port) {
|
|
45
|
+
try {
|
|
46
|
+
const out = execSync(`lsof -i :${port} -t 2>/dev/null`, { encoding: 'utf-8' }).trim();
|
|
47
|
+
if (out) {
|
|
48
|
+
for (const pid of out.split('\n')) {
|
|
49
|
+
process.kill(parseInt(pid), 'SIGTERM');
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
} catch {}
|
|
53
|
+
unregisterInstance(port).catch(() => {});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function registerInstance(port, canvasDir, title) {
|
|
57
|
+
await mkdir(INSTANCES_DIR, { recursive: true });
|
|
58
|
+
const data = { pid: process.pid, port, canvasDir, title, startedAt: Date.now() };
|
|
59
|
+
await writeFile(join(INSTANCES_DIR, `${port}.json`), JSON.stringify(data, null, 2));
|
|
60
|
+
|
|
61
|
+
const cleanup = () => {
|
|
62
|
+
unregisterInstance(port).catch(() => {});
|
|
63
|
+
process.exit();
|
|
64
|
+
};
|
|
65
|
+
process.on('SIGTERM', cleanup);
|
|
66
|
+
process.on('SIGINT', cleanup);
|
|
67
|
+
process.on('exit', () => {
|
|
68
|
+
try {
|
|
69
|
+
unlinkSync(join(INSTANCES_DIR, `${port}.json`));
|
|
70
|
+
} catch {}
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function unregisterInstance(port) {
|
|
75
|
+
try {
|
|
76
|
+
await unlink(join(INSTANCES_DIR, `${port}.json`));
|
|
77
|
+
} catch {}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function listRunningInstances() {
|
|
81
|
+
try {
|
|
82
|
+
await mkdir(INSTANCES_DIR, { recursive: true });
|
|
83
|
+
const files = await readdir(INSTANCES_DIR);
|
|
84
|
+
const instances = [];
|
|
85
|
+
|
|
86
|
+
for (const file of files) {
|
|
87
|
+
if (!file.endsWith('.json')) continue;
|
|
88
|
+
try {
|
|
89
|
+
const raw = await readFile(join(INSTANCES_DIR, file), 'utf-8');
|
|
90
|
+
const data = JSON.parse(raw);
|
|
91
|
+
|
|
92
|
+
// Verify process is still alive
|
|
93
|
+
try {
|
|
94
|
+
process.kill(data.pid, 0);
|
|
95
|
+
instances.push(data);
|
|
96
|
+
} catch {
|
|
97
|
+
await unlink(join(INSTANCES_DIR, file)).catch(() => {});
|
|
98
|
+
}
|
|
99
|
+
} catch {}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return instances;
|
|
103
|
+
} catch {
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
}
|
package/lib/prompts.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { createRequire } from 'module';
|
|
2
|
+
|
|
3
|
+
const require = createRequire(import.meta.url);
|
|
4
|
+
const { erase } = require('sisteransi');
|
|
5
|
+
const strip = require('prompts/lib/util/strip');
|
|
6
|
+
|
|
7
|
+
function isCombiningCodePoint(codePoint) {
|
|
8
|
+
return (
|
|
9
|
+
(codePoint >= 0x0300 && codePoint <= 0x036f) ||
|
|
10
|
+
(codePoint >= 0x1ab0 && codePoint <= 0x1aff) ||
|
|
11
|
+
(codePoint >= 0x1dc0 && codePoint <= 0x1dff) ||
|
|
12
|
+
(codePoint >= 0x20d0 && codePoint <= 0x20ff) ||
|
|
13
|
+
(codePoint >= 0xfe20 && codePoint <= 0xfe2f)
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function isFullWidthCodePoint(codePoint) {
|
|
18
|
+
return codePoint >= 0x1100 && (
|
|
19
|
+
codePoint <= 0x115f ||
|
|
20
|
+
codePoint === 0x2329 ||
|
|
21
|
+
codePoint === 0x232a ||
|
|
22
|
+
(codePoint >= 0x2e80 && codePoint <= 0xa4cf && codePoint !== 0x303f) ||
|
|
23
|
+
(codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
|
|
24
|
+
(codePoint >= 0xf900 && codePoint <= 0xfaff) ||
|
|
25
|
+
(codePoint >= 0xfe10 && codePoint <= 0xfe19) ||
|
|
26
|
+
(codePoint >= 0xfe30 && codePoint <= 0xfe6f) ||
|
|
27
|
+
(codePoint >= 0xff00 && codePoint <= 0xff60) ||
|
|
28
|
+
(codePoint >= 0xffe0 && codePoint <= 0xffe6) ||
|
|
29
|
+
(codePoint >= 0x20000 && codePoint <= 0x3fffd)
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function charWidth(char) {
|
|
34
|
+
const codePoint = char.codePointAt(0);
|
|
35
|
+
if (
|
|
36
|
+
codePoint <= 0x1f ||
|
|
37
|
+
(codePoint >= 0x7f && codePoint <= 0x9f) ||
|
|
38
|
+
codePoint === 0x200b ||
|
|
39
|
+
codePoint === 0x200c ||
|
|
40
|
+
codePoint === 0x200d ||
|
|
41
|
+
(codePoint >= 0xfe00 && codePoint <= 0xfe0f) ||
|
|
42
|
+
isCombiningCodePoint(codePoint)
|
|
43
|
+
) {
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return isFullWidthCodePoint(codePoint) ? 2 : 1;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function terminalWidth(text) {
|
|
51
|
+
return [...strip(text)].reduce((width, char) => width + charWidth(char), 0);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function terminalColumns(perLine) {
|
|
55
|
+
const columns = Number(perLine || process.stdout.columns || 80);
|
|
56
|
+
return Number.isFinite(columns) && columns > 0 ? columns : 80;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function clearWrappedOutput(prompt, perLine) {
|
|
60
|
+
const columns = terminalColumns(perLine);
|
|
61
|
+
const rows = String(prompt || '').split(/\r?\n/).reduce((count, line) => {
|
|
62
|
+
return count + 1 + Math.floor(Math.max(terminalWidth(line) - 1, 0) / columns);
|
|
63
|
+
}, 0);
|
|
64
|
+
|
|
65
|
+
return erase.lines(rows);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const clearPath = require.resolve('prompts/lib/util/clear');
|
|
69
|
+
require(clearPath);
|
|
70
|
+
|
|
71
|
+
// Patch before loading prompts so autocomplete's render loop clears wrapped rows correctly.
|
|
72
|
+
require.cache[clearPath].exports = clearWrappedOutput;
|
|
73
|
+
|
|
74
|
+
const prompts = require('prompts');
|
|
75
|
+
const AutocompletePrompt = require('prompts/lib/elements/autocomplete');
|
|
76
|
+
|
|
77
|
+
// Patch ESC to abort instead of submit
|
|
78
|
+
AutocompletePrompt.prototype.exit = function () {
|
|
79
|
+
this.abort();
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const onCancel = () => process.exit(0);
|
|
83
|
+
|
|
84
|
+
function truncTitle(text) {
|
|
85
|
+
const cols = process.stdout.columns || 80;
|
|
86
|
+
const max = cols - 6;
|
|
87
|
+
if (text.length <= max) return text;
|
|
88
|
+
return text.substring(0, max - 1) + '…';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function fuzzySelectCanvas(canvases, historyEntries) {
|
|
92
|
+
if (canvases.length === 0) {
|
|
93
|
+
console.log(' No canvases found in current directory.');
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const choices = canvases.map(c => {
|
|
98
|
+
const histEntry = historyEntries.find(h => h.path === c.path);
|
|
99
|
+
const lastOpened = histEntry ? formatTimeAgo(histEntry.lastOpened) : 'never opened';
|
|
100
|
+
const title = c.title || c.path.split('/').pop();
|
|
101
|
+
const shortPath = c.path.replace(process.env.HOME, '~');
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
title: truncTitle(`${title} — ${c.rowCount} rows · ${lastOpened} - ${shortPath}`),
|
|
105
|
+
value: c,
|
|
106
|
+
};
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const { selected } = await prompts({
|
|
110
|
+
type: 'autocomplete',
|
|
111
|
+
name: 'selected',
|
|
112
|
+
message: 'Select a canvas (type to filter)',
|
|
113
|
+
choices,
|
|
114
|
+
suggest: fuzzyFilter,
|
|
115
|
+
}, { onCancel });
|
|
116
|
+
|
|
117
|
+
return selected || null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function fuzzySelectFromHistory(entries) {
|
|
121
|
+
if (entries.length === 0) {
|
|
122
|
+
console.log(' No history yet. Use `canvas open` to scan current directory.');
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const choices = entries.map(e => {
|
|
127
|
+
const shortPath = e.path.replace(process.env.HOME, '~');
|
|
128
|
+
return {
|
|
129
|
+
title: truncTitle(`${e.title || e.path.split('/').pop()} — ${e.count}x · ${formatTimeAgo(e.lastOpened)} - ${shortPath}`),
|
|
130
|
+
value: e,
|
|
131
|
+
};
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const { selected } = await prompts({
|
|
135
|
+
type: 'autocomplete',
|
|
136
|
+
name: 'selected',
|
|
137
|
+
message: 'Select a canvas (type to filter)',
|
|
138
|
+
choices,
|
|
139
|
+
suggest: fuzzyFilter,
|
|
140
|
+
}, { onCancel });
|
|
141
|
+
|
|
142
|
+
return selected || null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function fuzzyFilter(input, choices) {
|
|
146
|
+
if (!input) return choices;
|
|
147
|
+
const terms = input.toLowerCase().split(/\s+/);
|
|
148
|
+
return choices.filter(c => {
|
|
149
|
+
const text = c.title.toLowerCase();
|
|
150
|
+
return terms.every(t => text.includes(t));
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function handlePortConflict(port, occupant) {
|
|
155
|
+
const choices = [
|
|
156
|
+
{ title: 'Open in browser (reuse existing)', value: 'reuse' },
|
|
157
|
+
{ title: 'Kill and restart', value: 'kill' },
|
|
158
|
+
{ title: 'Use next available port', value: 'next' },
|
|
159
|
+
];
|
|
160
|
+
|
|
161
|
+
console.log(`\n Port ${port} is in use by canvas (PID ${occupant.pid})`);
|
|
162
|
+
|
|
163
|
+
const { action } = await prompts({
|
|
164
|
+
type: 'select',
|
|
165
|
+
name: 'action',
|
|
166
|
+
message: 'What would you like to do?',
|
|
167
|
+
choices,
|
|
168
|
+
}, { onCancel });
|
|
169
|
+
|
|
170
|
+
return action || null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function formatTimeAgo(timestamp) {
|
|
174
|
+
const diff = Date.now() - timestamp;
|
|
175
|
+
const minutes = Math.floor(diff / 60000);
|
|
176
|
+
if (minutes < 1) return 'just now';
|
|
177
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
178
|
+
const hours = Math.floor(minutes / 60);
|
|
179
|
+
if (hours < 24) return `${hours}h ago`;
|
|
180
|
+
const days = Math.floor(hours / 24);
|
|
181
|
+
if (days < 30) return `${days}d ago`;
|
|
182
|
+
return `${Math.floor(days / 30)}mo ago`;
|
|
183
|
+
}
|
package/lib/scanner.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from 'fs/promises';
|
|
2
|
+
import { join, resolve } from 'path';
|
|
3
|
+
|
|
4
|
+
export async function scanForCanvases(baseDir, maxDepth = 4) {
|
|
5
|
+
const results = [];
|
|
6
|
+
await walk(resolve(baseDir), 0, maxDepth, results);
|
|
7
|
+
return results;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async function walk(dir, depth, maxDepth, results) {
|
|
11
|
+
if (depth > maxDepth) return;
|
|
12
|
+
|
|
13
|
+
const meta = await tryReadCanvasMeta(dir);
|
|
14
|
+
if (meta) {
|
|
15
|
+
results.push(meta);
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (depth === maxDepth) return;
|
|
20
|
+
|
|
21
|
+
let entries;
|
|
22
|
+
try {
|
|
23
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
24
|
+
} catch {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const subdirs = entries
|
|
29
|
+
.filter(e => e.isDirectory() && !e.name.startsWith('.') && e.name !== 'node_modules')
|
|
30
|
+
.map(e => join(dir, e.name));
|
|
31
|
+
|
|
32
|
+
await Promise.all(subdirs.map(d => walk(d, depth + 1, maxDepth, results)));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function tryReadCanvasMeta(dir) {
|
|
36
|
+
const canvasJsonPath = join(dir, 'canvas.json');
|
|
37
|
+
try {
|
|
38
|
+
const raw = await readFile(canvasJsonPath, 'utf-8');
|
|
39
|
+
const json = JSON.parse(raw);
|
|
40
|
+
if (!Array.isArray(json.rows)) return null;
|
|
41
|
+
|
|
42
|
+
const rowsDir = join(dir, 'rows');
|
|
43
|
+
let rowFileCount = 0;
|
|
44
|
+
try {
|
|
45
|
+
const rowFiles = await readdir(rowsDir);
|
|
46
|
+
rowFileCount = rowFiles.filter(f => f.endsWith('.html')).length;
|
|
47
|
+
} catch {}
|
|
48
|
+
|
|
49
|
+
const hasIndex = await fileExists(join(dir, 'index.html'));
|
|
50
|
+
const dirStat = await stat(canvasJsonPath);
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
path: dir,
|
|
54
|
+
title: json.title || null,
|
|
55
|
+
rowCount: json.rows.length,
|
|
56
|
+
rowFileCount,
|
|
57
|
+
hasIndex,
|
|
58
|
+
mtime: dirStat.mtimeMs,
|
|
59
|
+
};
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function fileExists(p) {
|
|
66
|
+
try {
|
|
67
|
+
await stat(p);
|
|
68
|
+
return true;
|
|
69
|
+
} catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pm-canvas-viewer",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local canvas viewer + MD/Excalidraw editor for pm-canvas",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "path",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"pm-canvas",
|
|
10
|
+
"canvas",
|
|
11
|
+
"viewer",
|
|
12
|
+
"product-management",
|
|
13
|
+
"prototype",
|
|
14
|
+
"mockup",
|
|
15
|
+
"markdown",
|
|
16
|
+
"tiptap",
|
|
17
|
+
"excalidraw",
|
|
18
|
+
"design-spec"
|
|
19
|
+
],
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=18"
|
|
22
|
+
},
|
|
23
|
+
"bin": {
|
|
24
|
+
"canvas": "./bin/cli.js"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"bin",
|
|
28
|
+
"server",
|
|
29
|
+
"lib",
|
|
30
|
+
"dist",
|
|
31
|
+
"LICENSE"
|
|
32
|
+
],
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"dev": "concurrently \"npm run dev:server\" \"npm run dev:frontend\"",
|
|
38
|
+
"dev:server": "node --watch server/index.js",
|
|
39
|
+
"dev:frontend": "vite frontend",
|
|
40
|
+
"build": "vite build frontend --outDir ../dist/frontend",
|
|
41
|
+
"start": "node bin/cli.js open",
|
|
42
|
+
"prepublishOnly": "npm run build"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"commander": "^12.0.0",
|
|
46
|
+
"express": "^4.18.0",
|
|
47
|
+
"open": "^10.0.0",
|
|
48
|
+
"prompts": "^2.4.2",
|
|
49
|
+
"sisteransi": "^1.0.5"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@excalidraw/excalidraw": "^0.17.0",
|
|
53
|
+
"@heroicons/react": "^2.2.0",
|
|
54
|
+
"@tiptap/extension-table": "^3.23.6",
|
|
55
|
+
"@tiptap/extension-table-cell": "^3.23.6",
|
|
56
|
+
"@tiptap/extension-table-header": "^3.23.6",
|
|
57
|
+
"@tiptap/extension-table-row": "^3.23.6",
|
|
58
|
+
"@tiptap/markdown": "^3.23.6",
|
|
59
|
+
"@tiptap/pm": "^3.23.6",
|
|
60
|
+
"@tiptap/react": "^3.23.6",
|
|
61
|
+
"@tiptap/starter-kit": "^3.23.6",
|
|
62
|
+
"@vitejs/plugin-react": "^4.2.0",
|
|
63
|
+
"concurrently": "^8.2.0",
|
|
64
|
+
"react": "^18.2.0",
|
|
65
|
+
"react-dom": "^18.2.0",
|
|
66
|
+
"vite": "^5.4.0"
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { readFile, writeFile, mkdir } from 'fs/promises';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { existsSync } from 'fs';
|
|
5
|
+
|
|
6
|
+
export function anchorsRouter(canvasDir) {
|
|
7
|
+
const router = Router();
|
|
8
|
+
const docsDir = join(canvasDir, 'docs');
|
|
9
|
+
const anchorsFile = join(docsDir, 'anchors.json');
|
|
10
|
+
|
|
11
|
+
const emptyData = { version: 1, anchors: [] };
|
|
12
|
+
|
|
13
|
+
router.get('/', async (req, res) => {
|
|
14
|
+
try {
|
|
15
|
+
if (!existsSync(anchorsFile)) {
|
|
16
|
+
return res.json(emptyData);
|
|
17
|
+
}
|
|
18
|
+
const raw = await readFile(anchorsFile, 'utf-8');
|
|
19
|
+
const data = JSON.parse(raw);
|
|
20
|
+
res.json(data);
|
|
21
|
+
} catch (err) {
|
|
22
|
+
if (err.code === 'ENOENT') return res.json(emptyData);
|
|
23
|
+
res.status(500).json({ error: err.message });
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
router.put('/', async (req, res) => {
|
|
28
|
+
try {
|
|
29
|
+
if (!existsSync(docsDir)) {
|
|
30
|
+
await mkdir(docsDir, { recursive: true });
|
|
31
|
+
}
|
|
32
|
+
const data = req.body;
|
|
33
|
+
if (!data || typeof data.version !== 'number' || !Array.isArray(data.anchors)) {
|
|
34
|
+
return res.status(400).json({ error: 'Invalid schema: requires { version, anchors[] }' });
|
|
35
|
+
}
|
|
36
|
+
await writeFile(anchorsFile, JSON.stringify(data, null, 2), 'utf-8');
|
|
37
|
+
res.json({ ok: true });
|
|
38
|
+
} catch (err) {
|
|
39
|
+
res.status(500).json({ error: err.message });
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
return router;
|
|
44
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { readdir, readFile, writeFile, mkdir, unlink } from 'fs/promises';
|
|
3
|
+
import { resolve, join, relative, extname } from 'path';
|
|
4
|
+
import { existsSync } from 'fs';
|
|
5
|
+
|
|
6
|
+
export function filesRouter(canvasDir) {
|
|
7
|
+
const router = Router();
|
|
8
|
+
const docsDir = join(canvasDir, 'docs');
|
|
9
|
+
|
|
10
|
+
// Ensure docs/ directory exists
|
|
11
|
+
if (!existsSync(docsDir)) {
|
|
12
|
+
mkdir(docsDir, { recursive: true });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// List all docs
|
|
16
|
+
router.get('/', async (req, res) => {
|
|
17
|
+
try {
|
|
18
|
+
if (!existsSync(docsDir)) {
|
|
19
|
+
return res.json({ files: [] });
|
|
20
|
+
}
|
|
21
|
+
const entries = await readdir(docsDir, { withFileTypes: true });
|
|
22
|
+
const files = entries
|
|
23
|
+
.filter(e => e.isFile())
|
|
24
|
+
.filter(e => ['.md', '.excalidraw', '.drawio'].includes(extname(e.name)))
|
|
25
|
+
.map(e => {
|
|
26
|
+
const ext = extname(e.name);
|
|
27
|
+
const type = ext === '.md' ? 'markdown' : ext === '.drawio' ? 'drawio' : 'excalidraw';
|
|
28
|
+
return { name: e.name, type, path: e.name };
|
|
29
|
+
});
|
|
30
|
+
res.json({ files });
|
|
31
|
+
} catch (err) {
|
|
32
|
+
res.status(500).json({ error: err.message });
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Read a file
|
|
37
|
+
router.get('/:filename', async (req, res) => {
|
|
38
|
+
try {
|
|
39
|
+
const filePath = safePath(docsDir, req.params.filename);
|
|
40
|
+
if (!filePath) return res.status(403).json({ error: 'Access denied' });
|
|
41
|
+
|
|
42
|
+
const content = await readFile(filePath, 'utf-8');
|
|
43
|
+
res.json({ content, name: req.params.filename });
|
|
44
|
+
} catch (err) {
|
|
45
|
+
if (err.code === 'ENOENT') return res.status(404).json({ error: 'File not found' });
|
|
46
|
+
res.status(500).json({ error: err.message });
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Write a file
|
|
51
|
+
router.put('/:filename', async (req, res) => {
|
|
52
|
+
try {
|
|
53
|
+
const filePath = safePath(docsDir, req.params.filename);
|
|
54
|
+
if (!filePath) return res.status(403).json({ error: 'Access denied' });
|
|
55
|
+
|
|
56
|
+
const ext = extname(req.params.filename);
|
|
57
|
+
if (!['.md', '.excalidraw', '.drawio'].includes(ext)) {
|
|
58
|
+
return res.status(400).json({ error: 'Only .md, .excalidraw and .drawio files allowed' });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
await writeFile(filePath, req.body.content, 'utf-8');
|
|
62
|
+
res.json({ ok: true });
|
|
63
|
+
} catch (err) {
|
|
64
|
+
res.status(500).json({ error: err.message });
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Delete a file
|
|
69
|
+
router.delete('/:filename', async (req, res) => {
|
|
70
|
+
try {
|
|
71
|
+
const filePath = safePath(docsDir, req.params.filename);
|
|
72
|
+
if (!filePath) return res.status(403).json({ error: 'Access denied' });
|
|
73
|
+
|
|
74
|
+
await unlink(filePath);
|
|
75
|
+
res.json({ ok: true });
|
|
76
|
+
} catch (err) {
|
|
77
|
+
if (err.code === 'ENOENT') return res.status(404).json({ error: 'File not found' });
|
|
78
|
+
res.status(500).json({ error: err.message });
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
return router;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function safePath(baseDir, filename) {
|
|
86
|
+
if (!filename || filename.includes('\0')) return null;
|
|
87
|
+
const resolved = resolve(baseDir, filename);
|
|
88
|
+
const base = resolve(baseDir) + '/';
|
|
89
|
+
if (!resolved.startsWith(base)) return null;
|
|
90
|
+
const rel = relative(baseDir, resolved);
|
|
91
|
+
if (rel.startsWith('..') || rel.includes('/')) return null;
|
|
92
|
+
return resolved;
|
|
93
|
+
}
|
package/server/index.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { resolve, join } from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import { dirname } from 'path';
|
|
5
|
+
import { filesRouter } from './api/files.js';
|
|
6
|
+
import { anchorsRouter } from './api/anchors.js';
|
|
7
|
+
|
|
8
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
|
|
10
|
+
export function createServer(canvasDir) {
|
|
11
|
+
const app = express();
|
|
12
|
+
|
|
13
|
+
app.use(express.json({ limit: '10mb' }));
|
|
14
|
+
|
|
15
|
+
// API: file operations scoped to canvas directory
|
|
16
|
+
app.use('/api/files', filesRouter(canvasDir));
|
|
17
|
+
app.use('/api/anchors', anchorsRouter(canvasDir));
|
|
18
|
+
|
|
19
|
+
// Serve canvas files (the actual canvas HTML/rows)
|
|
20
|
+
app.use('/canvas', express.static(canvasDir));
|
|
21
|
+
|
|
22
|
+
// Serve viewer frontend (pre-built)
|
|
23
|
+
const frontendDir = resolve(__dirname, '../dist/frontend');
|
|
24
|
+
app.use(express.static(frontendDir));
|
|
25
|
+
|
|
26
|
+
// SPA fallback
|
|
27
|
+
app.get('*', (req, res) => {
|
|
28
|
+
res.sendFile(join(frontendDir, 'index.html'));
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
return app;
|
|
32
|
+
}
|