pmx-canvas 0.6.3 → 0.6.5
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/CHANGELOG.md +31 -0
- package/Readme.md +27 -6
- package/dist/canvas/global.css +90 -4
- package/dist/canvas/index.js +93 -91
- package/dist/canvas/mermaid-entry.js +258 -258
- package/dist/types/cli/agent.d.ts +1 -0
- package/dist/types/cli/commands/record.d.ts +12 -0
- package/dist/types/client/canvas/FeedbackDialog.d.ts +4 -0
- package/dist/types/client/canvas/Presentation.d.ts +13 -0
- package/dist/types/client/canvas/use-node-drag.d.ts +2 -1
- package/dist/types/client/nodes/McpAppNode.d.ts +24 -1
- package/dist/types/client/state/presentation.d.ts +3 -0
- package/dist/types/client/types.d.ts +1 -1
- package/dist/types/json-render/catalog.d.ts +2 -2
- package/dist/types/json-render/server.d.ts +2 -0
- package/dist/types/server/canvas-db.d.ts +2 -0
- package/dist/types/server/canvas-operations.d.ts +22 -0
- package/dist/types/server/canvas-schema.d.ts +1 -0
- package/dist/types/server/canvas-state.d.ts +6 -0
- package/dist/types/server/canvas-validation.d.ts +3 -0
- package/dist/types/server/html-surface.d.ts +1 -1
- package/dist/types/server/index.d.ts +4 -0
- package/dist/types/server/operations/ops/app.d.ts +2 -0
- package/dist/types/server/operations/ops/json-render.d.ts +2 -0
- package/dist/types/server/operations/ops/nodes.d.ts +3 -1
- package/dist/types/server/web-artifacts.d.ts +3 -0
- package/dist/types/shared/tour.d.ts +72 -0
- package/docs/cli.md +96 -2
- package/docs/http-api.md +79 -4
- package/docs/mcp.md +25 -1
- package/docs/node-types.md +123 -1
- package/docs/product-context-vision-2026-09.md +1 -1
- package/docs/product-vision-2026-09.md +135 -42
- package/docs/product-vision-review-2026-09.md +277 -0
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +19 -4
- package/skills/pmx-canvas/evals/evals.json +46 -0
- package/skills/pmx-canvas/references/full-reference.md +1 -1
- package/skills/pmx-canvas/references/installing-pmx-canvas.md +4 -4
- package/src/cli/agent.ts +5 -0
- package/src/cli/commands/nodes.ts +10 -0
- package/src/cli/commands/record.ts +218 -0
- package/src/cli/index.ts +2 -0
- package/src/client/App.tsx +7 -1
- package/src/client/canvas/AgentPresenceLayer.tsx +1 -1
- package/src/client/canvas/CanvasNode.tsx +8 -0
- package/src/client/canvas/FeedbackDialog.tsx +86 -0
- package/src/client/canvas/IntentLayer.tsx +9 -2
- package/src/client/canvas/Presentation.tsx +108 -0
- package/src/client/canvas/ToolRail.tsx +225 -189
- package/src/client/canvas/TopBar.tsx +28 -2
- package/src/client/canvas/use-node-drag.ts +8 -3
- package/src/client/mermaid-entry.ts +39 -0
- package/src/client/nodes/HtmlNode.tsx +12 -13
- package/src/client/nodes/McpAppNode.tsx +105 -13
- package/src/client/nodes/MermaidNode.tsx +3 -2
- package/src/client/state/intent-store.ts +5 -1
- package/src/client/state/presentation.ts +7 -0
- package/src/client/state/session-store.ts +3 -0
- package/src/client/state/sse-bridge.ts +3 -0
- package/src/client/theme/global.css +90 -4
- package/src/json-render/server.ts +29 -0
- package/src/server/agent-presence.ts +15 -9
- package/src/server/canvas-db.ts +15 -0
- package/src/server/canvas-operations.ts +115 -22
- package/src/server/canvas-schema.ts +10 -0
- package/src/server/canvas-state.ts +39 -4
- package/src/server/canvas-validation.ts +9 -1
- package/src/server/demo-state.json +11 -7
- package/src/server/html-primitives.ts +199 -91
- package/src/server/html-surface.ts +8 -3
- package/src/server/index.ts +12 -0
- package/src/server/operations/composites.ts +3 -1
- package/src/server/operations/ops/app.ts +10 -0
- package/src/server/operations/ops/batch.ts +12 -3
- package/src/server/operations/ops/groups.ts +14 -7
- package/src/server/operations/ops/json-render.ts +37 -5
- package/src/server/operations/ops/nodes.ts +42 -16
- package/src/server/operations/ops/viewport.ts +32 -0
- package/src/server/operations/registry.ts +6 -0
- package/src/server/scope-fence.ts +1 -1
- package/src/server/server.ts +9 -6
- package/src/server/web-artifacts.ts +9 -2
- package/src/server/workboard.ts +3 -3
- package/src/shared/tour.ts +87 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { mkdirSync, existsSync } from 'node:fs';
|
|
2
|
+
import { resolve, join } from 'node:path';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { isCanvasTheme } from '../../shared/themes.js';
|
|
5
|
+
import { derivedTour, tourSchema, tourFrames, type Camera, type TourNode } from '../../shared/tour.js';
|
|
6
|
+
import {
|
|
7
|
+
startCanvasAutomationWebView,
|
|
8
|
+
evaluateCanvasAutomationWebView,
|
|
9
|
+
screenshotCanvasAutomationWebView,
|
|
10
|
+
resizeCanvasAutomationWebView,
|
|
11
|
+
stopCanvasAutomationWebView,
|
|
12
|
+
} from '../../server/server.js';
|
|
13
|
+
import { cmd, getBaseUrl, invokeOperation, output, parseFlags, showCommandHelp } from '../shared.js';
|
|
14
|
+
|
|
15
|
+
export function parseRecordOptions(flags: Record<string, string | boolean>) {
|
|
16
|
+
const number = (key: string, fallback: number) => {
|
|
17
|
+
if (flags[key] === true || flags[key] === false) throw new Error(`--${key} needs a number`);
|
|
18
|
+
return flags[key] === undefined ? fallback : Number(flags[key]);
|
|
19
|
+
};
|
|
20
|
+
for (const key of ['tour-file', 'chrome-path']) {
|
|
21
|
+
if (flags[key] !== undefined && typeof flags[key] !== 'string') throw new Error(`--${key} needs a path`);
|
|
22
|
+
}
|
|
23
|
+
const resolution = String(flags.resolution ?? '1280x720').match(/^(\d+)x(\d+)$/);
|
|
24
|
+
if (!resolution) throw new Error('Use --resolution WIDTHxHEIGHT');
|
|
25
|
+
const options = z
|
|
26
|
+
.object({
|
|
27
|
+
width: z.number().int().min(64).max(7680),
|
|
28
|
+
height: z.number().int().min(64).max(4320),
|
|
29
|
+
fps: z.number().int().min(1).max(120),
|
|
30
|
+
duration: z.number().positive().max(86400).optional(),
|
|
31
|
+
mode: z.enum(['realtime', 'deterministic']),
|
|
32
|
+
output: z.string().min(1),
|
|
33
|
+
})
|
|
34
|
+
.parse({
|
|
35
|
+
width: Number(resolution[1]),
|
|
36
|
+
height: Number(resolution[2]),
|
|
37
|
+
fps: number('fps', 30),
|
|
38
|
+
duration: flags.duration === undefined ? undefined : number('duration', 10),
|
|
39
|
+
mode: flags.mode ?? 'realtime',
|
|
40
|
+
output: typeof flags.output === 'string' ? flags.output : '',
|
|
41
|
+
});
|
|
42
|
+
if (flags.theme !== undefined && !isCanvasTheme(flags.theme)) throw new Error('Unknown --theme');
|
|
43
|
+
if (options.mode === 'realtime' && !options.duration && flags['stop-on-signal'] !== true) {
|
|
44
|
+
throw new Error('Realtime recording needs --duration SECONDS or --stop-on-signal');
|
|
45
|
+
}
|
|
46
|
+
if (options.mode === 'deterministic' && (flags.duration !== undefined || flags['stop-on-signal'])) {
|
|
47
|
+
throw new Error('Deterministic duration comes from tour stops; omit --duration and --stop-on-signal');
|
|
48
|
+
}
|
|
49
|
+
if (flags['tour-file'] && options.mode !== 'deterministic')
|
|
50
|
+
throw new Error('--tour-file requires --mode deterministic');
|
|
51
|
+
return {
|
|
52
|
+
...options,
|
|
53
|
+
theme: typeof flags.theme === 'string' ? flags.theme : undefined,
|
|
54
|
+
present: flags.present === true,
|
|
55
|
+
tourFile: typeof flags['tour-file'] === 'string' ? flags['tour-file'] : undefined,
|
|
56
|
+
chromePath: typeof flags['chrome-path'] === 'string' ? flags['chrome-path'] : undefined,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
cmd('tour get', 'Read the persisted tour or derived group order', ['pmx-canvas tour get'], async () => {
|
|
61
|
+
output(await invokeOperation('tour.get', {}));
|
|
62
|
+
});
|
|
63
|
+
cmd(
|
|
64
|
+
'tour set',
|
|
65
|
+
'Persist camera stops from JSON (null resets to group order)',
|
|
66
|
+
['pmx-canvas tour set --file tour.json'],
|
|
67
|
+
async (args) => {
|
|
68
|
+
const { flags } = parseFlags(args);
|
|
69
|
+
if (flags.help) return showCommandHelp('tour set');
|
|
70
|
+
if (typeof flags.file !== 'string') throw new Error('Missing --file');
|
|
71
|
+
const tour = tourSchema.nullable().parse(await Bun.file(flags.file).json());
|
|
72
|
+
output(await invokeOperation('tour.set', { tour }));
|
|
73
|
+
},
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
cmd(
|
|
77
|
+
'record',
|
|
78
|
+
'Optionally capture the board tour or live presentation using Bun.WebView Chrome',
|
|
79
|
+
[
|
|
80
|
+
'pmx-canvas record --duration 10 --present --output demo.mp4',
|
|
81
|
+
'pmx-canvas record --mode deterministic --tour-file tour.json --present --fps 30 --output frames',
|
|
82
|
+
'pmx-canvas record --stop-on-signal --resolution 1920x1080 --output live.mp4',
|
|
83
|
+
],
|
|
84
|
+
async (args) => {
|
|
85
|
+
const { flags } = parseFlags(args, { boolFlags: ['present', 'stop-on-signal'] });
|
|
86
|
+
if (flags.help || flags.h) return showCommandHelp('record');
|
|
87
|
+
const options = parseRecordOptions(flags);
|
|
88
|
+
const outputPath = resolve(options.output);
|
|
89
|
+
const video = /\.mp4$/i.test(outputPath);
|
|
90
|
+
const framesPath = video ? `${outputPath}.frames` : outputPath;
|
|
91
|
+
if (existsSync(outputPath) || existsSync(framesPath)) throw new Error('Output already exists; choose a new path');
|
|
92
|
+
const response = await fetch(`${getBaseUrl()}/api/canvas/state`);
|
|
93
|
+
if (!response.ok) throw new Error(`Cannot read board: HTTP ${response.status}`);
|
|
94
|
+
const board = (await response.json()) as { nodes: TourNode[]; viewport: Camera; tour?: unknown };
|
|
95
|
+
const tour =
|
|
96
|
+
options.mode === 'deterministic'
|
|
97
|
+
? tourSchema.parse(
|
|
98
|
+
options.tourFile ? await Bun.file(options.tourFile).json() : (board.tour ?? derivedTour(board.nodes)),
|
|
99
|
+
)
|
|
100
|
+
: undefined;
|
|
101
|
+
if (tour && !tour.stops.length) throw new Error('No tour stops: supply --tour-file or create groups/a tour');
|
|
102
|
+
const url = new URL('/workbench', getBaseUrl());
|
|
103
|
+
if (options.present) url.searchParams.set('present', '1');
|
|
104
|
+
if (tour) url.searchParams.set('capture', '1');
|
|
105
|
+
if (options.theme) url.searchParams.set('theme', options.theme);
|
|
106
|
+
let stopping = false;
|
|
107
|
+
const stop = () => {
|
|
108
|
+
stopping = true;
|
|
109
|
+
};
|
|
110
|
+
process.on('SIGINT', stop);
|
|
111
|
+
process.on('SIGTERM', stop);
|
|
112
|
+
let count = 0;
|
|
113
|
+
let duplicated = 0;
|
|
114
|
+
try {
|
|
115
|
+
await startCanvasAutomationWebView(url.href, {
|
|
116
|
+
backend: 'chrome',
|
|
117
|
+
width: options.width,
|
|
118
|
+
height: options.height,
|
|
119
|
+
chromePath: options.chromePath,
|
|
120
|
+
});
|
|
121
|
+
const deadline = Date.now() + 30_000;
|
|
122
|
+
while (!(await evaluateCanvasAutomationWebView('!!window.pmxCapture?.ready()'))) {
|
|
123
|
+
if (Date.now() > deadline) throw new Error('Workbench did not become capture-ready in 30 seconds');
|
|
124
|
+
await Bun.sleep(50);
|
|
125
|
+
}
|
|
126
|
+
// Chrome launch dimensions describe the outer window. WebView.resize
|
|
127
|
+
// applies viewport emulation, giving screenshots the requested pixels.
|
|
128
|
+
await resizeCanvasAutomationWebView(options.width, options.height);
|
|
129
|
+
await evaluateCanvasAutomationWebView(
|
|
130
|
+
'new Promise(r => requestAnimationFrame(() => requestAnimationFrame(() => r(true))))',
|
|
131
|
+
);
|
|
132
|
+
await evaluateCanvasAutomationWebView('document.fonts.ready.then(() => true)');
|
|
133
|
+
const area = (await evaluateCanvasAutomationWebView('window.pmxCapture.area()')) as {
|
|
134
|
+
width: number;
|
|
135
|
+
height: number;
|
|
136
|
+
};
|
|
137
|
+
const initial = (await evaluateCanvasAutomationWebView('window.pmxCapture.camera()')) as Camera;
|
|
138
|
+
mkdirSync(framesPath, { recursive: true });
|
|
139
|
+
const write = async (bytes: Uint8Array) => {
|
|
140
|
+
await Bun.write(join(framesPath, `frame-${String(count++).padStart(6, '0')}.png`), bytes);
|
|
141
|
+
};
|
|
142
|
+
const started = performance.now();
|
|
143
|
+
if (tour) {
|
|
144
|
+
for (const camera of tourFrames(tour, initial, board.nodes, area.width, area.height, options.fps)) {
|
|
145
|
+
if (stopping) break;
|
|
146
|
+
const applied = (await evaluateCanvasAutomationWebView(
|
|
147
|
+
`window.pmxCapture.frame(${JSON.stringify(camera)})`,
|
|
148
|
+
)) as Camera;
|
|
149
|
+
if (applied.x !== camera.x || applied.y !== camera.y || applied.scale !== camera.scale) {
|
|
150
|
+
throw new Error('Capture camera was changed before paint');
|
|
151
|
+
}
|
|
152
|
+
await write(await screenshotCanvasAutomationWebView({ format: 'png' }));
|
|
153
|
+
}
|
|
154
|
+
} else {
|
|
155
|
+
const limit = options.duration ? Math.ceil(options.duration * options.fps) : Infinity;
|
|
156
|
+
while (!stopping && count < limit) {
|
|
157
|
+
await Bun.sleep(Math.max(0, started + (count / options.fps) * 1000 - performance.now()));
|
|
158
|
+
const bytes = await screenshotCanvasAutomationWebView({ format: 'png' });
|
|
159
|
+
await write(bytes);
|
|
160
|
+
// Preserve wall-clock speed if capture is slower than requested fps.
|
|
161
|
+
const elapsedFrames = Math.min(limit, Math.floor(((performance.now() - started) / 1000) * options.fps));
|
|
162
|
+
while (count < elapsedFrames) {
|
|
163
|
+
await write(bytes);
|
|
164
|
+
duplicated++;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
await Bun.write(
|
|
169
|
+
join(framesPath, 'recording.json'),
|
|
170
|
+
JSON.stringify(
|
|
171
|
+
{
|
|
172
|
+
mode: options.mode,
|
|
173
|
+
fps: options.fps,
|
|
174
|
+
width: options.width,
|
|
175
|
+
height: options.height,
|
|
176
|
+
frames: count,
|
|
177
|
+
duplicated,
|
|
178
|
+
seconds: count / options.fps,
|
|
179
|
+
},
|
|
180
|
+
null,
|
|
181
|
+
2,
|
|
182
|
+
),
|
|
183
|
+
);
|
|
184
|
+
} finally {
|
|
185
|
+
process.off('SIGINT', stop);
|
|
186
|
+
process.off('SIGTERM', stop);
|
|
187
|
+
await stopCanvasAutomationWebView();
|
|
188
|
+
}
|
|
189
|
+
let encoded = false;
|
|
190
|
+
if (video && count > 0) {
|
|
191
|
+
const ffmpeg = Bun.which('ffmpeg');
|
|
192
|
+
if (!ffmpeg) console.error(`ffmpeg not found; PNG sequence retained at ${framesPath}`);
|
|
193
|
+
else {
|
|
194
|
+
const process = Bun.spawn(
|
|
195
|
+
[
|
|
196
|
+
ffmpeg,
|
|
197
|
+
'-n',
|
|
198
|
+
'-framerate',
|
|
199
|
+
String(options.fps),
|
|
200
|
+
'-i',
|
|
201
|
+
join(framesPath, 'frame-%06d.png'),
|
|
202
|
+
'-c:v',
|
|
203
|
+
'libx264',
|
|
204
|
+
'-pix_fmt',
|
|
205
|
+
'yuv420p',
|
|
206
|
+
'-vf',
|
|
207
|
+
'pad=ceil(iw/2)*2:ceil(ih/2)*2',
|
|
208
|
+
outputPath,
|
|
209
|
+
],
|
|
210
|
+
{ stdout: 'ignore', stderr: 'inherit' },
|
|
211
|
+
);
|
|
212
|
+
encoded = (await process.exited) === 0;
|
|
213
|
+
if (!encoded) throw new Error(`ffmpeg failed; PNG sequence retained at ${framesPath}`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
output({ ok: true, output: encoded ? outputPath : framesPath, frames: count, fps: options.fps, duplicated });
|
|
217
|
+
},
|
|
218
|
+
);
|
package/src/cli/index.ts
CHANGED
|
@@ -32,6 +32,8 @@ if (args.includes('--version') || args.includes('-v')) {
|
|
|
32
32
|
// ── Agent CLI subcommands ────────────────────────────────────
|
|
33
33
|
// If first arg is a known subcommand (not a --flag), route to the agent CLI.
|
|
34
34
|
const AGENT_COMMANDS = new Set([
|
|
35
|
+
'tour',
|
|
36
|
+
'record',
|
|
35
37
|
'node',
|
|
36
38
|
'edge',
|
|
37
39
|
'json-render',
|
package/src/client/App.tsx
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
|
2
|
+
import { Presentation } from './canvas/Presentation';
|
|
3
|
+
import { presenting } from './state/presentation';
|
|
2
4
|
import { AttentionHistory } from './canvas/AttentionHistory';
|
|
3
5
|
import { AttentionToast } from './canvas/AttentionToast';
|
|
4
6
|
import { registerCanvasArea, canvasArea } from './canvas/canvas-area';
|
|
@@ -324,7 +326,11 @@ export function App() {
|
|
|
324
326
|
const area = canvasArea();
|
|
325
327
|
|
|
326
328
|
return (
|
|
327
|
-
<div
|
|
329
|
+
<div
|
|
330
|
+
class={`app-shell${presenting.value ? ' is-presenting' : ''}`}
|
|
331
|
+
data-session-active={sessionIsActive ? 'true' : 'false'}
|
|
332
|
+
>
|
|
333
|
+
<Presentation />
|
|
328
334
|
<ToolRail
|
|
329
335
|
minimapVisible={minimapVisible}
|
|
330
336
|
onToggleMinimap={handleToggleMinimap}
|
|
@@ -45,7 +45,7 @@ export function AgentPresenceLayer() {
|
|
|
45
45
|
{cursors.map(({ presence, position }) => (
|
|
46
46
|
<div
|
|
47
47
|
key={presence.sessionId}
|
|
48
|
-
class={`agent-cursor phase-${presence.phase}${presence.attached ? '' : ' is-external'}${presence.parentAgentId ? ' is-worker' : ''}`}
|
|
48
|
+
class={`agent-cursor phase-${presence.phase}${presence.cursor ? ' has-explicit-cursor' : ''}${presence.attached ? '' : ' is-external'}${presence.parentAgentId ? ' is-worker' : ''}`}
|
|
49
49
|
data-session-id={presence.sessionId}
|
|
50
50
|
style={{
|
|
51
51
|
transform: `translate(${position.x}px, ${position.y}px)`,
|
|
@@ -187,6 +187,9 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
|
|
|
187
187
|
viewport,
|
|
188
188
|
onMove: handleMove,
|
|
189
189
|
onDragEnd: handleDragEnd,
|
|
190
|
+
onClick: () => {
|
|
191
|
+
selectedNodeIds.value = new Set([node.id]);
|
|
192
|
+
},
|
|
190
193
|
});
|
|
191
194
|
|
|
192
195
|
// ── Resize ────────────────────────────────────────────
|
|
@@ -236,6 +239,11 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
|
|
|
236
239
|
(e: PointerEvent) => {
|
|
237
240
|
if (e.button !== 0) return;
|
|
238
241
|
if (renaming) return;
|
|
242
|
+
// Title controls act on the node without selecting or dragging it.
|
|
243
|
+
if (e.target instanceof Element && e.target.closest('button, input')) {
|
|
244
|
+
e.stopPropagation();
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
239
247
|
if (canvasTool.value === 'connect') {
|
|
240
248
|
startEdgeDrag(e);
|
|
241
249
|
return;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { useRef, useState } from 'preact/hooks';
|
|
2
|
+
import { useFocusTrap } from './use-focus-trap';
|
|
3
|
+
|
|
4
|
+
/** Draft only: GitHub owns authentication and the final public submission. */
|
|
5
|
+
export function FeedbackDialog({ onClose }: { onClose: () => void }) {
|
|
6
|
+
const ref = useRef<HTMLFormElement>(null);
|
|
7
|
+
const [kind, setKind] = useState('Bug report');
|
|
8
|
+
const [title, setTitle] = useState('');
|
|
9
|
+
const [description, setDescription] = useState('');
|
|
10
|
+
useFocusTrap(ref, true);
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<div class="text-prompt-backdrop feedback-backdrop" onPointerDown={onClose}>
|
|
14
|
+
<form
|
|
15
|
+
ref={ref}
|
|
16
|
+
class="text-prompt feedback-dialog"
|
|
17
|
+
role="dialog"
|
|
18
|
+
aria-modal="true"
|
|
19
|
+
aria-labelledby="feedback-title"
|
|
20
|
+
aria-describedby="feedback-privacy"
|
|
21
|
+
action="https://github.com/pskoett/pmx-canvas/issues/new"
|
|
22
|
+
method="get"
|
|
23
|
+
target="_blank"
|
|
24
|
+
rel="noopener noreferrer"
|
|
25
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
26
|
+
onKeyDown={(e) => {
|
|
27
|
+
e.stopPropagation();
|
|
28
|
+
if (e.key === 'Escape') {
|
|
29
|
+
e.preventDefault();
|
|
30
|
+
onClose();
|
|
31
|
+
}
|
|
32
|
+
}}
|
|
33
|
+
>
|
|
34
|
+
<div id="feedback-title" class="text-prompt-title">
|
|
35
|
+
Bug and feedback
|
|
36
|
+
</div>
|
|
37
|
+
<p id="feedback-privacy" class="feedback-note">
|
|
38
|
+
Opens a draft issue in pskoett/pmx-canvas. Review and submit it on GitHub using your account. Issues are
|
|
39
|
+
public. Only what you enter below is included; no board contents, workspace paths, screenshots, or logs are
|
|
40
|
+
attached.
|
|
41
|
+
</p>
|
|
42
|
+
<label>
|
|
43
|
+
Feedback type
|
|
44
|
+
<select class="text-prompt-input" value={kind} onChange={(e) => setKind(e.currentTarget.value)}>
|
|
45
|
+
<option>Bug report</option>
|
|
46
|
+
<option>Feature request</option>
|
|
47
|
+
<option>General feedback</option>
|
|
48
|
+
</select>
|
|
49
|
+
</label>
|
|
50
|
+
<label>
|
|
51
|
+
Title
|
|
52
|
+
<input
|
|
53
|
+
class="text-prompt-input"
|
|
54
|
+
name="title"
|
|
55
|
+
required
|
|
56
|
+
maxLength={120}
|
|
57
|
+
value={title}
|
|
58
|
+
onInput={(e) => setTitle(e.currentTarget.value)}
|
|
59
|
+
placeholder="A short summary"
|
|
60
|
+
/>
|
|
61
|
+
</label>
|
|
62
|
+
<label>
|
|
63
|
+
Description
|
|
64
|
+
<textarea
|
|
65
|
+
class="text-prompt-input"
|
|
66
|
+
required
|
|
67
|
+
maxLength={1200}
|
|
68
|
+
rows={6}
|
|
69
|
+
value={description}
|
|
70
|
+
onInput={(e) => setDescription(e.currentTarget.value)}
|
|
71
|
+
placeholder="What happened, what you expected, and steps to reproduce — or describe your idea."
|
|
72
|
+
/>
|
|
73
|
+
</label>
|
|
74
|
+
<input type="hidden" name="body" value={`## ${kind}\n\n${description.trim()}`} />
|
|
75
|
+
<div class="text-prompt-actions">
|
|
76
|
+
<button type="button" class="text-prompt-cancel" onClick={onClose}>
|
|
77
|
+
Cancel
|
|
78
|
+
</button>
|
|
79
|
+
<button type="submit" class="text-prompt-confirm" disabled={!title.trim() || !description.trim()}>
|
|
80
|
+
Continue on GitHub ↗
|
|
81
|
+
</button>
|
|
82
|
+
</div>
|
|
83
|
+
</form>
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useEffect } from 'preact/hooks';
|
|
2
2
|
import { nodes } from '../state/canvas-store';
|
|
3
|
-
import { hoveredIntentId, intents, type ClientIntent } from '../state/intent-store';
|
|
3
|
+
import { hoveredIntentId, intents, removeIntent, type ClientIntent } from '../state/intent-store';
|
|
4
4
|
import { vetoGhostIntent } from '../state/intent-bridge';
|
|
5
5
|
import { getNodeIcon } from '../icons';
|
|
6
6
|
import { TYPE_LABELS } from '../types';
|
|
@@ -241,7 +241,14 @@ export function IntentLayer() {
|
|
|
241
241
|
if (list.length === 0) return null;
|
|
242
242
|
|
|
243
243
|
return (
|
|
244
|
-
<div
|
|
244
|
+
<div
|
|
245
|
+
class="intent-layer"
|
|
246
|
+
onAnimationEnd={(event) => {
|
|
247
|
+
if (event.animationName !== 'intent-settle' && event.animationName !== 'intent-dissolve') return;
|
|
248
|
+
const id = (event.target as HTMLElement).dataset.intentId;
|
|
249
|
+
if (id && intents.value.get(id)?.phase !== 'forming') removeIntent(id);
|
|
250
|
+
}}
|
|
251
|
+
>
|
|
245
252
|
<svg
|
|
246
253
|
class="intent-line-layer"
|
|
247
254
|
style={{
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { useEffect, useState } from 'preact/hooks';
|
|
2
|
+
import { cameraSchema, interpolateCamera, resolveStop, type Camera, type Tour } from '../../shared/tour.js';
|
|
3
|
+
import { canvasArea } from './canvas-area';
|
|
4
|
+
import { cancelViewportAnimation, hasInitialServerLayout, nodes, viewport } from '../state/canvas-store';
|
|
5
|
+
import { presenting, recordingCamera } from '../state/presentation';
|
|
6
|
+
import { requestJson } from '../state/intent-bridge';
|
|
7
|
+
|
|
8
|
+
declare global {
|
|
9
|
+
interface Window {
|
|
10
|
+
pmxCapture?: {
|
|
11
|
+
ready: () => boolean;
|
|
12
|
+
area: typeof canvasArea;
|
|
13
|
+
camera: () => Camera;
|
|
14
|
+
frame: (camera: Camera) => Promise<Camera>;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function Presentation() {
|
|
20
|
+
const [tour, setTour] = useState<Tour>({ stops: [] });
|
|
21
|
+
const [index, setIndex] = useState(0);
|
|
22
|
+
const [error, setError] = useState('');
|
|
23
|
+
const active = presenting.value;
|
|
24
|
+
const ready = hasInitialServerLayout.value;
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
const params = new URLSearchParams(location.search);
|
|
27
|
+
presenting.value = params.get('present') === '1';
|
|
28
|
+
recordingCamera.value = params.get('capture') === '1';
|
|
29
|
+
window.pmxCapture = {
|
|
30
|
+
ready: () => hasInitialServerLayout.value,
|
|
31
|
+
area: canvasArea,
|
|
32
|
+
camera: () => ({ ...viewport.value }),
|
|
33
|
+
frame: async (camera) => {
|
|
34
|
+
recordingCamera.value = true;
|
|
35
|
+
cancelViewportAnimation();
|
|
36
|
+
viewport.value = cameraSchema.parse(camera);
|
|
37
|
+
// Two animation frames: Preact has committed and the browser has had
|
|
38
|
+
// a paint opportunity before the screenshot command is acknowledged.
|
|
39
|
+
await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
|
|
40
|
+
return { ...viewport.value };
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
return () => {
|
|
44
|
+
delete window.pmxCapture;
|
|
45
|
+
presenting.value = false;
|
|
46
|
+
recordingCamera.value = false;
|
|
47
|
+
};
|
|
48
|
+
}, []);
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (!active || !ready) return;
|
|
51
|
+
let cancelled = false;
|
|
52
|
+
void requestJson('read tour', '/api/canvas/tour', { tour: { stops: [] } as Tour }).then((result) => {
|
|
53
|
+
if (!cancelled) {
|
|
54
|
+
setTour(result.tour);
|
|
55
|
+
setIndex(0);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
return () => {
|
|
59
|
+
cancelled = true;
|
|
60
|
+
};
|
|
61
|
+
}, [active, ready]);
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (!active || recordingCamera.value) return;
|
|
64
|
+
const stop = tour.stops[index];
|
|
65
|
+
if (!stop) return;
|
|
66
|
+
const area = canvasArea();
|
|
67
|
+
let target: Camera;
|
|
68
|
+
try {
|
|
69
|
+
target = resolveStop(stop, [...nodes.value.values()], area.width, area.height);
|
|
70
|
+
setError('');
|
|
71
|
+
} catch (error) {
|
|
72
|
+
setError(error instanceof Error ? error.message : String(error));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
cancelViewportAnimation();
|
|
76
|
+
const from = { ...viewport.value };
|
|
77
|
+
const start = performance.now();
|
|
78
|
+
const duration = (stop.duration ?? 1) * 1000;
|
|
79
|
+
let frame = 0;
|
|
80
|
+
const tick = (now: number) => {
|
|
81
|
+
if (recordingCamera.value) return;
|
|
82
|
+
const t = duration === 0 ? 1 : Math.min(1, (now - start) / duration);
|
|
83
|
+
viewport.value = interpolateCamera(from, target, t, area.width, area.height, stop.easing, stop.pullback);
|
|
84
|
+
if (t < 1) frame = requestAnimationFrame(tick);
|
|
85
|
+
};
|
|
86
|
+
frame = requestAnimationFrame(tick);
|
|
87
|
+
return () => cancelAnimationFrame(frame);
|
|
88
|
+
}, [active, index, tour]);
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
if (!active) return;
|
|
91
|
+
const key = (event: KeyboardEvent) => {
|
|
92
|
+
event.stopImmediatePropagation();
|
|
93
|
+
if (['Escape', 'ArrowRight', 'ArrowDown', 'ArrowLeft', 'ArrowUp', ' '].includes(event.key))
|
|
94
|
+
event.preventDefault();
|
|
95
|
+
if (event.key === 'Escape') presenting.value = false;
|
|
96
|
+
if (['ArrowRight', 'ArrowDown', ' '].includes(event.key))
|
|
97
|
+
setIndex((i) => Math.max(0, Math.min(tour.stops.length - 1, i + 1)));
|
|
98
|
+
if (['ArrowLeft', 'ArrowUp'].includes(event.key)) setIndex((i) => Math.max(0, i - 1));
|
|
99
|
+
};
|
|
100
|
+
document.addEventListener('keydown', key, true);
|
|
101
|
+
return () => document.removeEventListener('keydown', key, true);
|
|
102
|
+
}, [active, tour]);
|
|
103
|
+
return active && error ? (
|
|
104
|
+
<div class="presentation-error" role="alert">
|
|
105
|
+
{error}
|
|
106
|
+
</div>
|
|
107
|
+
) : null;
|
|
108
|
+
}
|