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.
Files changed (85) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/Readme.md +27 -6
  3. package/dist/canvas/global.css +90 -4
  4. package/dist/canvas/index.js +93 -91
  5. package/dist/canvas/mermaid-entry.js +258 -258
  6. package/dist/types/cli/agent.d.ts +1 -0
  7. package/dist/types/cli/commands/record.d.ts +12 -0
  8. package/dist/types/client/canvas/FeedbackDialog.d.ts +4 -0
  9. package/dist/types/client/canvas/Presentation.d.ts +13 -0
  10. package/dist/types/client/canvas/use-node-drag.d.ts +2 -1
  11. package/dist/types/client/nodes/McpAppNode.d.ts +24 -1
  12. package/dist/types/client/state/presentation.d.ts +3 -0
  13. package/dist/types/client/types.d.ts +1 -1
  14. package/dist/types/json-render/catalog.d.ts +2 -2
  15. package/dist/types/json-render/server.d.ts +2 -0
  16. package/dist/types/server/canvas-db.d.ts +2 -0
  17. package/dist/types/server/canvas-operations.d.ts +22 -0
  18. package/dist/types/server/canvas-schema.d.ts +1 -0
  19. package/dist/types/server/canvas-state.d.ts +6 -0
  20. package/dist/types/server/canvas-validation.d.ts +3 -0
  21. package/dist/types/server/html-surface.d.ts +1 -1
  22. package/dist/types/server/index.d.ts +4 -0
  23. package/dist/types/server/operations/ops/app.d.ts +2 -0
  24. package/dist/types/server/operations/ops/json-render.d.ts +2 -0
  25. package/dist/types/server/operations/ops/nodes.d.ts +3 -1
  26. package/dist/types/server/web-artifacts.d.ts +3 -0
  27. package/dist/types/shared/tour.d.ts +72 -0
  28. package/docs/cli.md +96 -2
  29. package/docs/http-api.md +79 -4
  30. package/docs/mcp.md +25 -1
  31. package/docs/node-types.md +123 -1
  32. package/docs/product-context-vision-2026-09.md +1 -1
  33. package/docs/product-vision-2026-09.md +135 -42
  34. package/docs/product-vision-review-2026-09.md +277 -0
  35. package/package.json +1 -1
  36. package/skills/pmx-canvas/SKILL.md +19 -4
  37. package/skills/pmx-canvas/evals/evals.json +46 -0
  38. package/skills/pmx-canvas/references/full-reference.md +1 -1
  39. package/skills/pmx-canvas/references/installing-pmx-canvas.md +4 -4
  40. package/src/cli/agent.ts +5 -0
  41. package/src/cli/commands/nodes.ts +10 -0
  42. package/src/cli/commands/record.ts +218 -0
  43. package/src/cli/index.ts +2 -0
  44. package/src/client/App.tsx +7 -1
  45. package/src/client/canvas/AgentPresenceLayer.tsx +1 -1
  46. package/src/client/canvas/CanvasNode.tsx +8 -0
  47. package/src/client/canvas/FeedbackDialog.tsx +86 -0
  48. package/src/client/canvas/IntentLayer.tsx +9 -2
  49. package/src/client/canvas/Presentation.tsx +108 -0
  50. package/src/client/canvas/ToolRail.tsx +225 -189
  51. package/src/client/canvas/TopBar.tsx +28 -2
  52. package/src/client/canvas/use-node-drag.ts +8 -3
  53. package/src/client/mermaid-entry.ts +39 -0
  54. package/src/client/nodes/HtmlNode.tsx +12 -13
  55. package/src/client/nodes/McpAppNode.tsx +105 -13
  56. package/src/client/nodes/MermaidNode.tsx +3 -2
  57. package/src/client/state/intent-store.ts +5 -1
  58. package/src/client/state/presentation.ts +7 -0
  59. package/src/client/state/session-store.ts +3 -0
  60. package/src/client/state/sse-bridge.ts +3 -0
  61. package/src/client/theme/global.css +90 -4
  62. package/src/json-render/server.ts +29 -0
  63. package/src/server/agent-presence.ts +15 -9
  64. package/src/server/canvas-db.ts +15 -0
  65. package/src/server/canvas-operations.ts +115 -22
  66. package/src/server/canvas-schema.ts +10 -0
  67. package/src/server/canvas-state.ts +39 -4
  68. package/src/server/canvas-validation.ts +9 -1
  69. package/src/server/demo-state.json +11 -7
  70. package/src/server/html-primitives.ts +199 -91
  71. package/src/server/html-surface.ts +8 -3
  72. package/src/server/index.ts +12 -0
  73. package/src/server/operations/composites.ts +3 -1
  74. package/src/server/operations/ops/app.ts +10 -0
  75. package/src/server/operations/ops/batch.ts +12 -3
  76. package/src/server/operations/ops/groups.ts +14 -7
  77. package/src/server/operations/ops/json-render.ts +37 -5
  78. package/src/server/operations/ops/nodes.ts +42 -16
  79. package/src/server/operations/ops/viewport.ts +32 -0
  80. package/src/server/operations/registry.ts +6 -0
  81. package/src/server/scope-fence.ts +1 -1
  82. package/src/server/server.ts +9 -6
  83. package/src/server/web-artifacts.ts +9 -2
  84. package/src/server/workboard.ts +3 -3
  85. 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',
@@ -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 class="app-shell" data-session-active={sessionIsActive ? 'true' : 'false'}>
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 class="intent-layer">
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
+ }