pmx-canvas 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.
Files changed (226) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/LICENSE +21 -0
  3. package/Readme.md +865 -0
  4. package/dist/canvas/global.css +3173 -0
  5. package/dist/canvas/index.js +183 -0
  6. package/dist/json-render/index.css +2 -0
  7. package/dist/json-render/index.js +389 -0
  8. package/dist/types/cli/agent.d.ts +13 -0
  9. package/dist/types/cli/index.d.ts +2 -0
  10. package/dist/types/cli/watch.d.ts +5 -0
  11. package/dist/types/client/App.d.ts +1 -0
  12. package/dist/types/client/canvas/AttentionHistory.d.ts +1 -0
  13. package/dist/types/client/canvas/AttentionToast.d.ts +1 -0
  14. package/dist/types/client/canvas/CanvasNode.d.ts +8 -0
  15. package/dist/types/client/canvas/CanvasViewport.d.ts +8 -0
  16. package/dist/types/client/canvas/CommandPalette.d.ts +4 -0
  17. package/dist/types/client/canvas/ContextMenu.d.ts +24 -0
  18. package/dist/types/client/canvas/ContextPinBar.d.ts +1 -0
  19. package/dist/types/client/canvas/ContextPinHud.d.ts +1 -0
  20. package/dist/types/client/canvas/DockedNode.d.ts +4 -0
  21. package/dist/types/client/canvas/EdgeLayer.d.ts +8 -0
  22. package/dist/types/client/canvas/ExpandedNodeOverlay.d.ts +1 -0
  23. package/dist/types/client/canvas/FocusFieldLayer.d.ts +1 -0
  24. package/dist/types/client/canvas/Minimap.d.ts +23 -0
  25. package/dist/types/client/canvas/SelectionBar.d.ts +1 -0
  26. package/dist/types/client/canvas/ShortcutOverlay.d.ts +3 -0
  27. package/dist/types/client/canvas/SnapshotPanel.d.ts +7 -0
  28. package/dist/types/client/canvas/snap-guides.d.ts +23 -0
  29. package/dist/types/client/canvas/use-node-drag.d.ts +15 -0
  30. package/dist/types/client/canvas/use-node-resize.d.ts +15 -0
  31. package/dist/types/client/canvas/use-pan-zoom.d.ts +16 -0
  32. package/dist/types/client/ext-app/bridge.d.ts +161 -0
  33. package/dist/types/client/icons.d.ts +70 -0
  34. package/dist/types/client/index.d.ts +1 -0
  35. package/dist/types/client/nodes/ContextNode.d.ts +34 -0
  36. package/dist/types/client/nodes/ExtAppFrame.d.ts +18 -0
  37. package/dist/types/client/nodes/FileNode.d.ts +5 -0
  38. package/dist/types/client/nodes/GroupNode.d.ts +6 -0
  39. package/dist/types/client/nodes/ImageNode.d.ts +10 -0
  40. package/dist/types/client/nodes/InlineFormatBar.d.ts +7 -0
  41. package/dist/types/client/nodes/InlineMarkdownEditor.d.ts +14 -0
  42. package/dist/types/client/nodes/LedgerNode.d.ts +4 -0
  43. package/dist/types/client/nodes/MarkdownNode.d.ts +6 -0
  44. package/dist/types/client/nodes/McpAppNode.d.ts +4 -0
  45. package/dist/types/client/nodes/MdFormatBar.d.ts +8 -0
  46. package/dist/types/client/nodes/PromptNode.d.ts +5 -0
  47. package/dist/types/client/nodes/ResponseNode.d.ts +5 -0
  48. package/dist/types/client/nodes/StatusNode.d.ts +4 -0
  49. package/dist/types/client/nodes/StatusSummary.d.ts +4 -0
  50. package/dist/types/client/nodes/TraceNode.d.ts +4 -0
  51. package/dist/types/client/nodes/WebpageNode.d.ts +5 -0
  52. package/dist/types/client/nodes/image-warnings.d.ts +6 -0
  53. package/dist/types/client/nodes/inline-editor-commands.d.ts +11 -0
  54. package/dist/types/client/nodes/md-format.d.ts +25 -0
  55. package/dist/types/client/state/attention-bridge.d.ts +3 -0
  56. package/dist/types/client/state/attention-store.d.ts +25 -0
  57. package/dist/types/client/state/canvas-store.d.ts +74 -0
  58. package/dist/types/client/state/intent-bridge.d.ts +158 -0
  59. package/dist/types/client/state/sse-bridge.d.ts +5 -0
  60. package/dist/types/client/theme/tokens.d.ts +27 -0
  61. package/dist/types/client/types.d.ts +40 -0
  62. package/dist/types/client/utils/ext-app-tool-result.d.ts +1 -0
  63. package/dist/types/client/utils/placement.d.ts +1 -0
  64. package/dist/types/client/utils/platform.d.ts +2 -0
  65. package/dist/types/json-render/catalog.d.ts +815 -0
  66. package/dist/types/json-render/charts/components.d.ts +54 -0
  67. package/dist/types/json-render/charts/definitions.d.ts +103 -0
  68. package/dist/types/json-render/charts/extra-components.d.ts +58 -0
  69. package/dist/types/json-render/charts/extra-definitions.d.ts +181 -0
  70. package/dist/types/json-render/renderer/index.d.ts +16 -0
  71. package/dist/types/json-render/schema.d.ts +46 -0
  72. package/dist/types/json-render/server.d.ts +55 -0
  73. package/dist/types/mcp/server.d.ts +22 -0
  74. package/dist/types/server/agent-context.d.ts +21 -0
  75. package/dist/types/server/artifact-paths.d.ts +3 -0
  76. package/dist/types/server/canvas-operations.d.ts +154 -0
  77. package/dist/types/server/canvas-provenance.d.ts +13 -0
  78. package/dist/types/server/canvas-schema.d.ts +49 -0
  79. package/dist/types/server/canvas-serialization.d.ts +25 -0
  80. package/dist/types/server/canvas-state.d.ts +174 -0
  81. package/dist/types/server/canvas-validation.d.ts +33 -0
  82. package/dist/types/server/chart-template.d.ts +29 -0
  83. package/dist/types/server/code-graph.d.ts +67 -0
  84. package/dist/types/server/context-cards.d.ts +24 -0
  85. package/dist/types/server/diagram-presets.d.ts +28 -0
  86. package/dist/types/server/ext-app-call-registry.d.ts +16 -0
  87. package/dist/types/server/ext-app-tool-result.d.ts +1 -0
  88. package/dist/types/server/file-watcher.d.ts +16 -0
  89. package/dist/types/server/index.d.ts +243 -0
  90. package/dist/types/server/mcp-app-candidate.d.ts +25 -0
  91. package/dist/types/server/mcp-app-host.d.ts +65 -0
  92. package/dist/types/server/mcp-app-runtime.d.ts +47 -0
  93. package/dist/types/server/mutation-history.d.ts +105 -0
  94. package/dist/types/server/placement.d.ts +37 -0
  95. package/dist/types/server/server.d.ts +103 -0
  96. package/dist/types/server/spatial-analysis.d.ts +87 -0
  97. package/dist/types/server/trace-manager.d.ts +48 -0
  98. package/dist/types/server/web-artifacts.d.ts +50 -0
  99. package/dist/types/server/webpage-node.d.ts +25 -0
  100. package/dist/types/shared/auto-arrange.d.ts +29 -0
  101. package/dist/types/shared/ext-app-tool-result.d.ts +9 -0
  102. package/dist/types/shared/placement.d.ts +26 -0
  103. package/dist/types/shared/semantic-attention.d.ts +97 -0
  104. package/package.json +109 -0
  105. package/skills/data-analysis/SKILL.md +324 -0
  106. package/skills/doc-coauthoring/SKILL.md +375 -0
  107. package/skills/frontend-design/SKILL.md +45 -0
  108. package/skills/json-render-codegen/SKILL.md +112 -0
  109. package/skills/json-render-core/SKILL.md +265 -0
  110. package/skills/json-render-ink/SKILL.md +273 -0
  111. package/skills/json-render-mcp/SKILL.md +132 -0
  112. package/skills/json-render-react/SKILL.md +264 -0
  113. package/skills/json-render-shadcn/SKILL.md +159 -0
  114. package/skills/playwright-cli/SKILL.md +67 -0
  115. package/skills/pmx-canvas/SKILL.md +668 -0
  116. package/skills/pmx-canvas/evals/evals.json +186 -0
  117. package/skills/pmx-canvas-testing/SKILL.md +78 -0
  118. package/skills/published-consumer-e2e/SKILL.md +43 -0
  119. package/skills/published-consumer-e2e/scripts/run-published-consumer-e2e.sh +241 -0
  120. package/skills/web-artifacts-builder/SKILL.md +80 -0
  121. package/skills/web-artifacts-builder/scripts/bundle-artifact.sh +167 -0
  122. package/skills/web-artifacts-builder/scripts/init-artifact.sh +425 -0
  123. package/skills/web-artifacts-builder/scripts/shadcn-components.tar.gz +0 -0
  124. package/skills/web-design-guidelines/SKILL.md +39 -0
  125. package/src/cli/agent.ts +2144 -0
  126. package/src/cli/index.ts +622 -0
  127. package/src/cli/watch.ts +88 -0
  128. package/src/client/App.tsx +507 -0
  129. package/src/client/canvas/AttentionHistory.tsx +81 -0
  130. package/src/client/canvas/AttentionToast.tsx +19 -0
  131. package/src/client/canvas/CanvasNode.tsx +363 -0
  132. package/src/client/canvas/CanvasViewport.tsx +590 -0
  133. package/src/client/canvas/CommandPalette.tsx +302 -0
  134. package/src/client/canvas/ContextMenu.tsx +601 -0
  135. package/src/client/canvas/ContextPinBar.tsx +25 -0
  136. package/src/client/canvas/ContextPinHud.tsx +22 -0
  137. package/src/client/canvas/DockedNode.tsx +66 -0
  138. package/src/client/canvas/EdgeLayer.tsx +280 -0
  139. package/src/client/canvas/ExpandedNodeOverlay.tsx +260 -0
  140. package/src/client/canvas/FocusFieldLayer.tsx +107 -0
  141. package/src/client/canvas/Minimap.tsx +301 -0
  142. package/src/client/canvas/SelectionBar.tsx +69 -0
  143. package/src/client/canvas/ShortcutOverlay.tsx +69 -0
  144. package/src/client/canvas/SnapshotPanel.tsx +236 -0
  145. package/src/client/canvas/snap-guides.ts +170 -0
  146. package/src/client/canvas/use-node-drag.ts +51 -0
  147. package/src/client/canvas/use-node-resize.ts +59 -0
  148. package/src/client/canvas/use-pan-zoom.ts +191 -0
  149. package/src/client/ext-app/bridge.ts +542 -0
  150. package/src/client/icons.tsx +424 -0
  151. package/src/client/index.tsx +7 -0
  152. package/src/client/nodes/ContextNode.tsx +412 -0
  153. package/src/client/nodes/ExtAppFrame.tsx +509 -0
  154. package/src/client/nodes/FileNode.tsx +256 -0
  155. package/src/client/nodes/GroupNode.tsx +39 -0
  156. package/src/client/nodes/ImageNode.tsx +160 -0
  157. package/src/client/nodes/InlineFormatBar.tsx +169 -0
  158. package/src/client/nodes/InlineMarkdownEditor.tsx +123 -0
  159. package/src/client/nodes/LedgerNode.tsx +37 -0
  160. package/src/client/nodes/MarkdownNode.tsx +359 -0
  161. package/src/client/nodes/McpAppNode.tsx +85 -0
  162. package/src/client/nodes/MdFormatBar.tsx +109 -0
  163. package/src/client/nodes/PromptNode.tsx +597 -0
  164. package/src/client/nodes/ResponseNode.tsx +153 -0
  165. package/src/client/nodes/StatusNode.tsx +84 -0
  166. package/src/client/nodes/StatusSummary.tsx +38 -0
  167. package/src/client/nodes/TraceNode.tsx +120 -0
  168. package/src/client/nodes/WebpageNode.tsx +288 -0
  169. package/src/client/nodes/image-warnings.ts +95 -0
  170. package/src/client/nodes/inline-editor-commands.ts +37 -0
  171. package/src/client/nodes/md-format.ts +206 -0
  172. package/src/client/state/attention-bridge.ts +328 -0
  173. package/src/client/state/attention-store.ts +73 -0
  174. package/src/client/state/canvas-store.ts +631 -0
  175. package/src/client/state/intent-bridge.ts +315 -0
  176. package/src/client/state/sse-bridge.ts +965 -0
  177. package/src/client/theme/global.css +3173 -0
  178. package/src/client/theme/tokens.ts +72 -0
  179. package/src/client/types-shims.d.ts +5 -0
  180. package/src/client/types.ts +81 -0
  181. package/src/client/utils/ext-app-tool-result.ts +4 -0
  182. package/src/client/utils/placement.ts +4 -0
  183. package/src/client/utils/platform.ts +2 -0
  184. package/src/json-render/catalog.ts +256 -0
  185. package/src/json-render/charts/components.tsx +198 -0
  186. package/src/json-render/charts/definitions.ts +81 -0
  187. package/src/json-render/charts/extra-components.tsx +267 -0
  188. package/src/json-render/charts/extra-definitions.ts +145 -0
  189. package/src/json-render/renderer/index.css +174 -0
  190. package/src/json-render/renderer/index.tsx +86 -0
  191. package/src/json-render/schema.ts +62 -0
  192. package/src/json-render/server.ts +597 -0
  193. package/src/mcp/server.ts +1377 -0
  194. package/src/server/agent-context.ts +242 -0
  195. package/src/server/artifact-paths.ts +17 -0
  196. package/src/server/canvas-operations.ts +1279 -0
  197. package/src/server/canvas-provenance.ts +243 -0
  198. package/src/server/canvas-schema.ts +432 -0
  199. package/src/server/canvas-serialization.ts +95 -0
  200. package/src/server/canvas-state.ts +1134 -0
  201. package/src/server/canvas-validation.ts +114 -0
  202. package/src/server/chart-template.ts +449 -0
  203. package/src/server/code-graph.ts +370 -0
  204. package/src/server/context-cards.ts +31 -0
  205. package/src/server/diagram-presets.ts +71 -0
  206. package/src/server/ext-app-call-registry.ts +77 -0
  207. package/src/server/ext-app-tool-result.ts +4 -0
  208. package/src/server/file-watcher.ts +121 -0
  209. package/src/server/index.ts +647 -0
  210. package/src/server/mcp-app-candidate.ts +174 -0
  211. package/src/server/mcp-app-host.ts +814 -0
  212. package/src/server/mcp-app-runtime.ts +459 -0
  213. package/src/server/mutation-history.ts +350 -0
  214. package/src/server/placement.ts +125 -0
  215. package/src/server/server.ts +3846 -0
  216. package/src/server/spatial-analysis.ts +356 -0
  217. package/src/server/trace-manager.ts +333 -0
  218. package/src/server/web-artifacts/scripts/bundle-artifact.sh +167 -0
  219. package/src/server/web-artifacts/scripts/init-artifact.sh +426 -0
  220. package/src/server/web-artifacts/scripts/shadcn-components.tar.gz +0 -0
  221. package/src/server/web-artifacts.ts +442 -0
  222. package/src/server/webpage-node.ts +328 -0
  223. package/src/shared/auto-arrange.ts +439 -0
  224. package/src/shared/ext-app-tool-result.ts +76 -0
  225. package/src/shared/placement.ts +81 -0
  226. package/src/shared/semantic-attention.ts +598 -0
@@ -0,0 +1,301 @@
1
+ import type { Signal } from '@preact/signals';
2
+ import { useSignalEffect } from '@preact/signals';
3
+ import { useCallback, useEffect, useRef } from 'preact/hooks';
4
+ import { canvasTheme } from '../state/canvas-store';
5
+ import { getCanvasTokens } from '../theme/tokens';
6
+ import type { CanvasEdge, CanvasNodeState, ViewportState } from '../types';
7
+
8
+ const MINIMAP_W = 180;
9
+ const MINIMAP_H = 120;
10
+ const PADDING = 20;
11
+
12
+ interface MinimapBounds {
13
+ minX: number;
14
+ minY: number;
15
+ maxX: number;
16
+ maxY: number;
17
+ }
18
+
19
+ interface MinimapFrame {
20
+ bounds: MinimapBounds;
21
+ scale: number;
22
+ }
23
+
24
+ function getNodeColors(): Record<CanvasNodeState['type'], string> {
25
+ const t = getCanvasTokens();
26
+ return {
27
+ markdown: t.accent,
28
+ 'mcp-app': t.ok,
29
+ webpage: t.warn,
30
+ 'json-render': t.ok,
31
+ graph: t.purple,
32
+ prompt: t.accent,
33
+ response: t.ok,
34
+ status: t.warn,
35
+ context: t.muted,
36
+ ledger: t.dim,
37
+ trace: t.purple,
38
+ file: t.accent,
39
+ image: t.ok,
40
+ group: t.dim,
41
+ };
42
+ }
43
+
44
+ function getEdgeColors(): Record<CanvasEdge['type'], string> {
45
+ const t = getCanvasTokens();
46
+ return {
47
+ relation: t.muted,
48
+ 'depends-on': t.warn,
49
+ flow: t.accent,
50
+ references: t.dim,
51
+ };
52
+ }
53
+
54
+ export function computeMinimapFrame(
55
+ nodeMap: Map<string, CanvasNodeState>,
56
+ currentViewport: ViewportState,
57
+ containerWidth: number,
58
+ containerHeight: number,
59
+ ): MinimapFrame {
60
+ const all = Array.from(nodeMap.values());
61
+
62
+ let minX = 0;
63
+ let minY = 0;
64
+ let maxX = 1000;
65
+ let maxY = 800;
66
+
67
+ if (all.length > 0) {
68
+ minX = Number.POSITIVE_INFINITY;
69
+ minY = Number.POSITIVE_INFINITY;
70
+ maxX = Number.NEGATIVE_INFINITY;
71
+ maxY = Number.NEGATIVE_INFINITY;
72
+ for (const node of all) {
73
+ minX = Math.min(minX, node.position.x);
74
+ minY = Math.min(minY, node.position.y);
75
+ maxX = Math.max(maxX, node.position.x + node.size.width);
76
+ maxY = Math.max(maxY, node.position.y + node.size.height);
77
+ }
78
+ }
79
+
80
+ const viewportLeft = -currentViewport.x / currentViewport.scale;
81
+ const viewportTop = -currentViewport.y / currentViewport.scale;
82
+ const viewportRight = viewportLeft + containerWidth / currentViewport.scale;
83
+ const viewportBottom = viewportTop + containerHeight / currentViewport.scale;
84
+
85
+ const bounds = {
86
+ minX: Math.min(minX, viewportLeft) - PADDING,
87
+ minY: Math.min(minY, viewportTop) - PADDING,
88
+ maxX: Math.max(maxX, viewportRight) + PADDING,
89
+ maxY: Math.max(maxY, viewportBottom) + PADDING,
90
+ };
91
+
92
+ const worldW = bounds.maxX - bounds.minX || 1;
93
+ const worldH = bounds.maxY - bounds.minY || 1;
94
+
95
+ return {
96
+ bounds,
97
+ scale: Math.min(MINIMAP_W / worldW, MINIMAP_H / worldH),
98
+ };
99
+ }
100
+
101
+ interface MinimapProps {
102
+ viewport: Signal<ViewportState>;
103
+ nodes: Signal<Map<string, CanvasNodeState>>;
104
+ edges: Signal<Map<string, CanvasEdge>>;
105
+ onNavigate: (x: number, y: number) => void;
106
+ containerWidth: number;
107
+ containerHeight: number;
108
+ }
109
+
110
+ export function Minimap({
111
+ viewport,
112
+ nodes,
113
+ edges,
114
+ onNavigate,
115
+ containerWidth,
116
+ containerHeight,
117
+ }: MinimapProps) {
118
+ const canvasRef = useRef<HTMLCanvasElement>(null);
119
+ const isDragging = useRef(false);
120
+ const frameRef = useRef<MinimapFrame | null>(null);
121
+ const drawRafId = useRef<number | null>(null);
122
+
123
+ const draw = useCallback(() => {
124
+ const canvas = canvasRef.current;
125
+ if (!canvas) return;
126
+ const ctx = canvas.getContext('2d');
127
+ if (!ctx) return;
128
+ const nodeMap = nodes.value;
129
+ const edgeMap = edges.value;
130
+ const currentViewport = viewport.value;
131
+
132
+ const dpr = window.devicePixelRatio || 1;
133
+ canvas.width = MINIMAP_W * dpr;
134
+ canvas.height = MINIMAP_H * dpr;
135
+ ctx.scale(dpr, dpr);
136
+
137
+ // Clear
138
+ ctx.clearRect(0, 0, MINIMAP_W, MINIMAP_H);
139
+ const t = getCanvasTokens();
140
+ ctx.fillStyle = t.panel + 'd9'; // panel color with ~85% alpha
141
+ ctx.fillRect(0, 0, MINIMAP_W, MINIMAP_H);
142
+
143
+ const frame = computeMinimapFrame(nodeMap, currentViewport, containerWidth, containerHeight);
144
+ frameRef.current = frame;
145
+ const { bounds, scale } = frame;
146
+
147
+ const toMiniX = (x: number) => (x - bounds.minX) * scale;
148
+ const toMiniY = (y: number) => (y - bounds.minY) * scale;
149
+
150
+ // Draw nodes
151
+ const all = Array.from(nodeMap.values());
152
+ const nodeColors = getNodeColors();
153
+ for (const n of all) {
154
+ ctx.fillStyle = nodeColors[n.type] ?? t.muted;
155
+ ctx.globalAlpha = 0.6;
156
+ ctx.fillRect(
157
+ toMiniX(n.position.x),
158
+ toMiniY(n.position.y),
159
+ Math.max(4, n.size.width * scale),
160
+ Math.max(3, n.size.height * scale),
161
+ );
162
+ }
163
+
164
+ const edgeColors = getEdgeColors();
165
+ for (const edge of edgeMap.values()) {
166
+ const fromNode = nodeMap.get(edge.from);
167
+ const toNode = nodeMap.get(edge.to);
168
+ if (!fromNode || !toNode) continue;
169
+ const fromCx = toMiniX(fromNode.position.x + fromNode.size.width / 2);
170
+ const fromCy = toMiniY(fromNode.position.y + fromNode.size.height / 2);
171
+ const toCx = toMiniX(toNode.position.x + toNode.size.width / 2);
172
+ const toCy = toMiniY(toNode.position.y + toNode.size.height / 2);
173
+ ctx.beginPath();
174
+ ctx.moveTo(fromCx, fromCy);
175
+ ctx.lineTo(toCx, toCy);
176
+ ctx.strokeStyle = edgeColors[edge.type] ?? t.muted;
177
+ ctx.globalAlpha = 0.3;
178
+ ctx.lineWidth = 0.5;
179
+ ctx.stroke();
180
+ }
181
+
182
+ // Draw viewport rectangle
183
+ const vpLeft = -currentViewport.x / currentViewport.scale;
184
+ const vpTop = -currentViewport.y / currentViewport.scale;
185
+ const vpW = containerWidth / currentViewport.scale;
186
+ const vpH = containerHeight / currentViewport.scale;
187
+
188
+ ctx.globalAlpha = 1;
189
+ ctx.strokeStyle = t.accent;
190
+ ctx.lineWidth = 1.5;
191
+ ctx.strokeRect(toMiniX(vpLeft), toMiniY(vpTop), vpW * scale, vpH * scale);
192
+ }, [nodes, edges, viewport, containerWidth, containerHeight]);
193
+
194
+ const drawRef = useRef(draw);
195
+ drawRef.current = draw;
196
+
197
+ const scheduleDraw = useCallback(() => {
198
+ if (drawRafId.current !== null) return;
199
+ drawRafId.current = window.requestAnimationFrame(() => {
200
+ drawRafId.current = null;
201
+ drawRef.current();
202
+ });
203
+ }, []);
204
+
205
+ // Redraw on state changes (including theme)
206
+ useSignalEffect(() => {
207
+ void canvasTheme.value;
208
+ void nodes.value;
209
+ void edges.value;
210
+ void viewport.value;
211
+ scheduleDraw();
212
+ });
213
+
214
+ useEffect(() => {
215
+ scheduleDraw();
216
+ }, [containerWidth, containerHeight, scheduleDraw]);
217
+
218
+ useEffect(() => () => {
219
+ if (drawRafId.current !== null) {
220
+ window.cancelAnimationFrame(drawRafId.current);
221
+ drawRafId.current = null;
222
+ }
223
+ }, []);
224
+
225
+ const handleNavigateFromEvent = useCallback(
226
+ (e: MouseEvent | PointerEvent) => {
227
+ const canvas = canvasRef.current;
228
+ if (!canvas) return;
229
+
230
+ const rect = canvas.getBoundingClientRect();
231
+ const mx = e.clientX - rect.left;
232
+ const my = e.clientY - rect.top;
233
+
234
+ const frame =
235
+ frameRef.current
236
+ ?? computeMinimapFrame(nodes.value, viewport.value, containerWidth, containerHeight);
237
+ frameRef.current = frame;
238
+ const { bounds, scale } = frame;
239
+
240
+ const v = viewport.value;
241
+ const vpW = containerWidth / v.scale;
242
+ const vpH = containerHeight / v.scale;
243
+
244
+ // Center viewport on clicked point
245
+ const worldX = mx / scale + bounds.minX;
246
+ const worldY = my / scale + bounds.minY;
247
+ onNavigate(-(worldX - vpW / 2) * v.scale, -(worldY - vpH / 2) * v.scale);
248
+ },
249
+ [nodes, viewport, containerWidth, containerHeight, onNavigate],
250
+ );
251
+
252
+ const handlePointerDown = useCallback(
253
+ (e: PointerEvent) => {
254
+ e.stopPropagation();
255
+ isDragging.current = true;
256
+ handleNavigateFromEvent(e);
257
+
258
+ const onPointerMove = (ev: PointerEvent) => {
259
+ if (isDragging.current) handleNavigateFromEvent(ev);
260
+ };
261
+
262
+ const onPointerUp = () => {
263
+ isDragging.current = false;
264
+ document.removeEventListener('pointermove', onPointerMove);
265
+ document.removeEventListener('pointerup', onPointerUp);
266
+ };
267
+
268
+ document.addEventListener('pointermove', onPointerMove);
269
+ document.addEventListener('pointerup', onPointerUp);
270
+ },
271
+ [handleNavigateFromEvent],
272
+ );
273
+
274
+ return (
275
+ <div
276
+ style={{
277
+ position: 'fixed',
278
+ bottom: '16px',
279
+ right: '16px',
280
+ zIndex: 9998,
281
+ border: '1px solid var(--c-line)',
282
+ borderRadius: 'var(--radius-sm)',
283
+ overflow: 'hidden',
284
+ boxShadow: '0 4px 16px var(--c-shadow)',
285
+ }}
286
+ >
287
+ <canvas
288
+ ref={canvasRef}
289
+ width={MINIMAP_W}
290
+ height={MINIMAP_H}
291
+ style={{
292
+ width: `${MINIMAP_W}px`,
293
+ height: `${MINIMAP_H}px`,
294
+ display: 'block',
295
+ cursor: 'pointer',
296
+ }}
297
+ onPointerDown={handlePointerDown}
298
+ />
299
+ </div>
300
+ );
301
+ }
@@ -0,0 +1,69 @@
1
+ import { useCallback } from 'preact/hooks';
2
+ import {
3
+ addContextPins,
4
+ clearSelection,
5
+ selectedNodeIds,
6
+ } from '../state/canvas-store';
7
+ import { createEdgeFromClient, createGroupFromClient } from '../state/intent-bridge';
8
+
9
+ export function SelectionBar() {
10
+ const count = selectedNodeIds.value.size;
11
+ if (count === 0) return null;
12
+
13
+ const handlePinContext = useCallback(() => {
14
+ const ids = Array.from(selectedNodeIds.value);
15
+ if (ids.length === 0) return;
16
+ addContextPins(ids);
17
+ clearSelection();
18
+ }, []);
19
+
20
+ const handleGroup = useCallback(() => {
21
+ const ids = Array.from(selectedNodeIds.value);
22
+ if (ids.length === 0) return;
23
+ createGroupFromClient({ title: 'Group', childIds: ids });
24
+ clearSelection();
25
+ }, []);
26
+
27
+ const handleConnect = useCallback(() => {
28
+ const ids = Array.from(selectedNodeIds.value);
29
+ for (let i = 0; i < ids.length; i++) {
30
+ for (let j = i + 1; j < ids.length; j++) {
31
+ createEdgeFromClient(ids[i], ids[j], 'relation');
32
+ }
33
+ }
34
+ clearSelection();
35
+ }, []);
36
+
37
+ return (
38
+ <div class="selection-bar">
39
+ <span class="selection-bar-count">
40
+ {'\u2726'} {count} node{count !== 1 ? 's' : ''} selected
41
+ </span>
42
+ <button
43
+ type="button"
44
+ class="selection-bar-btn selection-bar-pin-ctx"
45
+ onClick={handlePinContext}
46
+ >
47
+ Pin as context
48
+ </button>
49
+ {count >= 2 && (
50
+ <button type="button" class="selection-bar-btn" onClick={handleGroup}>
51
+ Group
52
+ </button>
53
+ )}
54
+ {count >= 2 && (
55
+ <button type="button" class="selection-bar-btn" onClick={handleConnect}>
56
+ Connect
57
+ </button>
58
+ )}
59
+ <button
60
+ type="button"
61
+ class="selection-bar-btn selection-bar-clear"
62
+ onClick={clearSelection}
63
+ title="Clear selection"
64
+ >
65
+ {'\u00d7'}
66
+ </button>
67
+ </div>
68
+ );
69
+ }
@@ -0,0 +1,69 @@
1
+ import { MOD_KEY as MOD } from '../utils/platform';
2
+
3
+ interface ShortcutGroup {
4
+ title: string;
5
+ shortcuts: Array<{ keys: string; desc: string }>;
6
+ }
7
+
8
+ const GROUPS: ShortcutGroup[] = [
9
+ {
10
+ title: 'Navigation',
11
+ shortcuts: [
12
+ { keys: `${MOD}+K`, desc: 'Command palette — search nodes & actions' },
13
+ { keys: 'Tab / Shift+Tab', desc: 'Cycle through nodes' },
14
+ { keys: '\u2190 \u2191 \u2192 \u2193', desc: 'Walk graph along edges (when node focused)' },
15
+ { keys: `${MOD}+0`, desc: 'Reset viewport to origin' },
16
+ { keys: `${MOD}++ / ${MOD}+\u2212`, desc: 'Zoom in / out' },
17
+ ],
18
+ },
19
+ {
20
+ title: 'Creation',
21
+ shortcuts: [
22
+ { keys: 'Double-click', desc: 'Create new markdown note on canvas' },
23
+ { keys: 'Drag port \u2192 node', desc: 'Connect two nodes (hover to reveal ports)' },
24
+ ],
25
+ },
26
+ {
27
+ title: 'Selection',
28
+ shortcuts: [
29
+ { keys: 'Click', desc: 'Focus node (highlights neighbors & edges)' },
30
+ { keys: 'Shift+Click', desc: 'Toggle node in multi-selection' },
31
+ { keys: 'Shift+Drag', desc: 'Lasso select multiple nodes' },
32
+ { keys: 'Esc', desc: 'Clear selection / close overlay' },
33
+ ],
34
+ },
35
+ {
36
+ title: 'View',
37
+ shortcuts: [
38
+ { keys: '?', desc: 'Toggle this shortcut overlay' },
39
+ { keys: 'Minimap', desc: 'Click/drag to navigate (toggle in toolbar)' },
40
+ { keys: 'Right-click', desc: 'Context menu — dock, focus, connect' },
41
+ ],
42
+ },
43
+ ];
44
+
45
+ export function ShortcutOverlay({ onClose }: { onClose: () => void }) {
46
+ return (
47
+ <div class="shortcut-overlay-backdrop" onMouseDown={onClose}>
48
+ <div class="shortcut-overlay" onMouseDown={(e) => e.stopPropagation()}>
49
+ <div class="shortcut-overlay-header">
50
+ <span class="shortcut-overlay-title">Keyboard Shortcuts</span>
51
+ <span class="shortcut-overlay-hint">Press <kbd>?</kbd> or <kbd>Esc</kbd> to close</span>
52
+ </div>
53
+ <div class="shortcut-overlay-body">
54
+ {GROUPS.map((group) => (
55
+ <div key={group.title} class="shortcut-group">
56
+ <div class="shortcut-group-title">{group.title}</div>
57
+ {group.shortcuts.map((s) => (
58
+ <div key={s.keys} class="shortcut-row">
59
+ <kbd class="shortcut-keys">{s.keys}</kbd>
60
+ <span class="shortcut-desc">{s.desc}</span>
61
+ </div>
62
+ ))}
63
+ </div>
64
+ ))}
65
+ </div>
66
+ </div>
67
+ </div>
68
+ );
69
+ }
@@ -0,0 +1,236 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
2
+ import {
3
+ listSnapshots,
4
+ saveSnapshot,
5
+ restoreSnapshot,
6
+ deleteSnapshot,
7
+ type CanvasSnapshotInfo,
8
+ } from '../state/intent-bridge';
9
+
10
+ function timeAgo(iso: string): string {
11
+ const diff = Date.now() - new Date(iso).getTime();
12
+ const mins = Math.floor(diff / 60000);
13
+ if (mins < 1) return 'just now';
14
+ if (mins < 60) return `${mins}m ago`;
15
+ const hrs = Math.floor(mins / 60);
16
+ if (hrs < 24) return `${hrs}h ago`;
17
+ const days = Math.floor(hrs / 24);
18
+ return `${days}d ago`;
19
+ }
20
+
21
+ export function SnapshotPanel({
22
+ open,
23
+ onClose,
24
+ anchorRef,
25
+ }: {
26
+ open: boolean;
27
+ onClose: () => void;
28
+ anchorRef: { current: HTMLButtonElement | null };
29
+ }) {
30
+ const [snapshots, setSnapshots] = useState<CanvasSnapshotInfo[]>([]);
31
+ const [loading, setLoading] = useState(false);
32
+ const [saving, setSaving] = useState(false);
33
+ const [restoringId, setRestoringId] = useState<string | null>(null);
34
+ const [nameInput, setNameInput] = useState('');
35
+ const [confirming, setConfirming] = useState<{ id: string; action: 'restore' | 'delete' } | null>(null);
36
+ const panelRef = useRef<HTMLDivElement>(null);
37
+ const inputRef = useRef<HTMLInputElement>(null);
38
+
39
+ // Load snapshots when panel opens
40
+ useEffect(() => {
41
+ if (!open) return;
42
+ setLoading(true);
43
+ listSnapshots().then((list) => {
44
+ setSnapshots(list);
45
+ setLoading(false);
46
+ });
47
+ }, [open]);
48
+
49
+ // Focus input on open
50
+ useEffect(() => {
51
+ if (open) setTimeout(() => inputRef.current?.focus(), 50);
52
+ }, [open]);
53
+
54
+ // Click outside to close
55
+ useEffect(() => {
56
+ if (!open) return;
57
+ const handler = (e: MouseEvent) => {
58
+ const panel = panelRef.current;
59
+ const anchor = anchorRef.current;
60
+ if (panel && !panel.contains(e.target as Node) && anchor && !anchor.contains(e.target as Node)) {
61
+ onClose();
62
+ }
63
+ };
64
+ document.addEventListener('mousedown', handler);
65
+ return () => document.removeEventListener('mousedown', handler);
66
+ }, [open, onClose]);
67
+
68
+ // Escape to close
69
+ useEffect(() => {
70
+ if (!open) return;
71
+ const handler = (e: KeyboardEvent) => {
72
+ if (e.key === 'Escape') onClose();
73
+ };
74
+ document.addEventListener('keydown', handler);
75
+ return () => document.removeEventListener('keydown', handler);
76
+ }, [open, onClose]);
77
+
78
+ const handleSave = useCallback(async () => {
79
+ const name = nameInput.trim();
80
+ if (!name) return;
81
+ setSaving(true);
82
+ const result = await saveSnapshot(name);
83
+ setSaving(false);
84
+ if (result.ok && result.snapshot) {
85
+ setSnapshots((prev) => [result.snapshot!, ...prev]);
86
+ setNameInput('');
87
+ }
88
+ }, [nameInput]);
89
+
90
+ const handleRestore = useCallback(async (id: string) => {
91
+ setConfirming(null);
92
+ setRestoringId(id);
93
+ const result = await restoreSnapshot(id);
94
+ setRestoringId(null);
95
+ if (result.ok) onClose();
96
+ }, [onClose]);
97
+
98
+ const handleDelete = useCallback(async (id: string) => {
99
+ const result = await deleteSnapshot(id);
100
+ if (result.ok) {
101
+ setSnapshots((prev) => prev.filter((s) => s.id !== id));
102
+ }
103
+ setConfirming(null);
104
+ }, []);
105
+
106
+ if (!open) return null;
107
+
108
+ // Position below toolbar button
109
+ const anchorRect = anchorRef.current?.getBoundingClientRect();
110
+ const left = anchorRect ? Math.max(8, anchorRect.left - 120) : 100;
111
+ const top = anchorRect ? anchorRect.bottom + 8 : 48;
112
+
113
+ return (
114
+ <div
115
+ ref={panelRef}
116
+ class="snapshot-panel"
117
+ style={{ left: `${left}px`, top: `${top}px` }}
118
+ >
119
+ {/* Header */}
120
+ <div class="snapshot-panel-header">
121
+ <span class="snapshot-panel-title">Snapshots</span>
122
+ <button
123
+ type="button"
124
+ class="snapshot-panel-close"
125
+ onClick={onClose}
126
+ title="Close"
127
+ >
128
+ {'\u00d7'}
129
+ </button>
130
+ </div>
131
+
132
+ {/* Save form */}
133
+ <div class="snapshot-save-form">
134
+ <input
135
+ ref={inputRef}
136
+ type="text"
137
+ class="snapshot-name-input"
138
+ value={nameInput}
139
+ onInput={(e) => setNameInput((e.target as HTMLInputElement).value)}
140
+ onKeyDown={(e) => { if (e.key === 'Enter') handleSave(); }}
141
+ placeholder="Snapshot name..."
142
+ maxLength={80}
143
+ disabled={saving}
144
+ />
145
+ <button
146
+ type="button"
147
+ class="snapshot-save-btn"
148
+ onClick={handleSave}
149
+ disabled={!nameInput.trim() || saving}
150
+ >
151
+ {saving ? '...' : 'Save'}
152
+ </button>
153
+ </div>
154
+
155
+ <div class="snapshot-restore-note">
156
+ Restoring replaces the current canvas. You can undo it if needed.
157
+ </div>
158
+
159
+ {/* Snapshot list */}
160
+ <div class="snapshot-list">
161
+ {loading && (
162
+ <div class="snapshot-empty">Loading...</div>
163
+ )}
164
+
165
+ {!loading && snapshots.length === 0 && (
166
+ <div class="snapshot-empty">
167
+ No snapshots yet. Save one to capture the current canvas state.
168
+ </div>
169
+ )}
170
+
171
+ {!loading && snapshots.map((snap) => (
172
+ <div key={snap.id} class="snapshot-item">
173
+ <div class="snapshot-item-info">
174
+ <span class="snapshot-item-name">{snap.name}</span>
175
+ <span class="snapshot-item-meta">
176
+ {snap.nodeCount} node{snap.nodeCount !== 1 ? 's' : ''}
177
+ {snap.edgeCount > 0 ? ` \u00b7 ${snap.edgeCount} edge${snap.edgeCount !== 1 ? 's' : ''}` : ''}
178
+ {' \u00b7 '}
179
+ {timeAgo(snap.createdAt)}
180
+ </span>
181
+ </div>
182
+ <div class="snapshot-item-actions">
183
+ {confirming?.id === snap.id ? (
184
+ <>
185
+ <button
186
+ type="button"
187
+ class={`snapshot-action-btn ${confirming.action === 'delete' ? 'snapshot-action-confirm' : 'snapshot-action-restore'}`}
188
+ onClick={() => confirming.action === 'delete' ? handleDelete(snap.id) : handleRestore(snap.id)}
189
+ title={confirming.action === 'delete' ? 'Confirm delete' : 'Confirm restore'}
190
+ disabled={restoringId !== null}
191
+ >
192
+ {confirming.action === 'delete'
193
+ ? 'Delete'
194
+ : restoringId === snap.id
195
+ ? 'Restoring...'
196
+ : 'Confirm'}
197
+ </button>
198
+ <button
199
+ type="button"
200
+ class="snapshot-action-btn"
201
+ onClick={() => setConfirming(null)}
202
+ title="Cancel"
203
+ disabled={restoringId !== null}
204
+ >
205
+ Cancel
206
+ </button>
207
+ </>
208
+ ) : (
209
+ <>
210
+ <button
211
+ type="button"
212
+ class="snapshot-action-btn snapshot-action-restore"
213
+ onClick={() => setConfirming({ id: snap.id, action: 'restore' })}
214
+ title="Restore this snapshot"
215
+ disabled={restoringId !== null}
216
+ >
217
+ {restoringId === snap.id ? 'Restoring...' : 'Restore'}
218
+ </button>
219
+ <button
220
+ type="button"
221
+ class="snapshot-action-btn snapshot-action-delete"
222
+ onClick={() => setConfirming({ id: snap.id, action: 'delete' })}
223
+ title="Delete this snapshot"
224
+ disabled={restoringId !== null}
225
+ >
226
+ {'\u2715'}
227
+ </button>
228
+ </>
229
+ )}
230
+ </div>
231
+ </div>
232
+ ))}
233
+ </div>
234
+ </div>
235
+ );
236
+ }