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,256 @@
1
+ import { useCallback, useEffect, useState } from 'preact/hooks';
2
+ import { updateNodeData } from '../state/canvas-store';
3
+ import { fetchFile, updateNodeFromClient } from '../state/intent-bridge';
4
+ import type { CanvasNodeState } from '../types';
5
+
6
+ /** Guess a language label from a file extension for display. */
7
+ function langFromPath(path: string): string {
8
+ const ext = path.split('.').pop()?.toLowerCase() ?? '';
9
+ const map: Record<string, string> = {
10
+ ts: 'TypeScript', tsx: 'TSX', js: 'JavaScript', jsx: 'JSX',
11
+ py: 'Python', rs: 'Rust', go: 'Go', rb: 'Ruby',
12
+ java: 'Java', kt: 'Kotlin', swift: 'Swift', c: 'C', cpp: 'C++', h: 'C/C++',
13
+ css: 'CSS', html: 'HTML', json: 'JSON', yaml: 'YAML', yml: 'YAML',
14
+ md: 'Markdown', toml: 'TOML', sql: 'SQL', sh: 'Shell', bash: 'Shell',
15
+ xml: 'XML', graphql: 'GraphQL', proto: 'Protobuf',
16
+ };
17
+ return map[ext] ?? (ext.toUpperCase() || 'Text');
18
+ }
19
+
20
+ export function FileNode({
21
+ node,
22
+ expanded = false,
23
+ }: { node: CanvasNodeState; expanded?: boolean }) {
24
+ const filePath = (node.data.path as string) || (node.data.content as string) || '';
25
+ const title = (node.data.title as string) || filePath.split('/').pop() || 'File';
26
+ const cachedContent = node.data.fileContent as string | undefined;
27
+ const updatedAt = node.data.updatedAt as string | undefined;
28
+ const lineCount = node.data.lineCount as number | undefined;
29
+
30
+ const [content, setContent] = useState<string>(cachedContent ?? '');
31
+ const [loading, setLoading] = useState(!cachedContent && !!filePath);
32
+ const [error, setError] = useState<string | null>(null);
33
+
34
+ // Load file content on mount or when path changes
35
+ useEffect(() => {
36
+ if (!filePath) return;
37
+ // If we already have cached content from SSE, use it
38
+ if (cachedContent !== undefined) {
39
+ setContent(cachedContent);
40
+ setLoading(false);
41
+ return;
42
+ }
43
+ let cancelled = false;
44
+ setLoading(true);
45
+ setError(null);
46
+ fetchFile(filePath).then(({ content: fileText }) => {
47
+ if (cancelled) return;
48
+ if (!fileText && fileText !== '') {
49
+ setError('File not found');
50
+ setLoading(false);
51
+ return;
52
+ }
53
+ setContent(fileText);
54
+ setLoading(false);
55
+ // Cache content in node data so it survives re-renders
56
+ const lines = fileText.split('\n').length;
57
+ updateNodeData(node.id, { fileContent: fileText, lineCount: lines });
58
+ void updateNodeFromClient(node.id, { data: { fileContent: fileText, lineCount: lines } });
59
+ }).catch(() => {
60
+ if (!cancelled) {
61
+ setError('Failed to load file');
62
+ setLoading(false);
63
+ }
64
+ });
65
+ return () => { cancelled = true; };
66
+ }, [filePath, cachedContent]);
67
+
68
+ // Sync content when server pushes updates via SSE
69
+ useEffect(() => {
70
+ if (cachedContent !== undefined && cachedContent !== content) {
71
+ setContent(cachedContent);
72
+ }
73
+ }, [cachedContent]);
74
+
75
+ const handleReload = useCallback(() => {
76
+ if (!filePath) return;
77
+ setLoading(true);
78
+ setError(null);
79
+ // Clear cached content to force a fresh fetch
80
+ updateNodeData(node.id, { fileContent: undefined });
81
+ void updateNodeFromClient(node.id, { data: { fileContent: undefined } });
82
+ fetchFile(filePath).then(({ content: fileText }) => {
83
+ setContent(fileText);
84
+ setLoading(false);
85
+ const lines = fileText.split('\n').length;
86
+ const updatedAt = new Date().toISOString();
87
+ updateNodeData(node.id, {
88
+ fileContent: fileText,
89
+ lineCount: lines,
90
+ updatedAt,
91
+ });
92
+ void updateNodeFromClient(node.id, {
93
+ data: {
94
+ fileContent: fileText,
95
+ lineCount: lines,
96
+ updatedAt,
97
+ },
98
+ });
99
+ }).catch(() => {
100
+ setError('Failed to reload');
101
+ setLoading(false);
102
+ });
103
+ }, [filePath, node.id]);
104
+
105
+ const lang = langFromPath(filePath);
106
+ const lines = content.split('\n');
107
+ const gutterWidth = `${String(lines.length).length + 1}ch`;
108
+
109
+ if (!filePath) {
110
+ return (
111
+ <div style={{ color: 'var(--c-dim)', fontStyle: 'italic', padding: '12px' }}>
112
+ No file path set
113
+ </div>
114
+ );
115
+ }
116
+
117
+ return (
118
+ <div
119
+ style={{
120
+ display: 'flex',
121
+ flexDirection: 'column',
122
+ height: '100%',
123
+ fontFamily: 'var(--mono)',
124
+ fontSize: expanded ? '13px' : '11px',
125
+ }}
126
+ >
127
+ {/* Header bar */}
128
+ <div
129
+ style={{
130
+ display: 'flex',
131
+ alignItems: 'center',
132
+ gap: '8px',
133
+ padding: '6px 10px',
134
+ borderBottom: '1px solid var(--c-line)',
135
+ flexShrink: 0,
136
+ }}
137
+ >
138
+ <span
139
+ style={{
140
+ fontSize: '9px',
141
+ padding: '1px 5px',
142
+ background: 'var(--c-accent-12)',
143
+ color: 'var(--c-accent)',
144
+ borderRadius: '3px',
145
+ fontWeight: 600,
146
+ }}
147
+ >
148
+ {lang}
149
+ </span>
150
+ <span
151
+ style={{
152
+ color: 'var(--c-text-soft)',
153
+ fontSize: expanded ? '12px' : '10px',
154
+ overflow: 'hidden',
155
+ textOverflow: 'ellipsis',
156
+ whiteSpace: 'nowrap',
157
+ flex: 1,
158
+ }}
159
+ title={filePath}
160
+ >
161
+ {filePath}
162
+ </span>
163
+ {lineCount !== undefined && (
164
+ <span style={{ color: 'var(--c-dim)', fontSize: '10px', flexShrink: 0 }}>
165
+ {lineCount} lines
166
+ </span>
167
+ )}
168
+ {updatedAt && (
169
+ <span style={{ color: 'var(--c-dim)', fontSize: '10px', flexShrink: 0 }}>
170
+ {new Date(updatedAt).toLocaleTimeString()}
171
+ </span>
172
+ )}
173
+ <button
174
+ type="button"
175
+ onClick={handleReload}
176
+ title="Reload file"
177
+ style={{
178
+ background: 'none',
179
+ border: 'none',
180
+ color: 'var(--c-muted)',
181
+ cursor: 'pointer',
182
+ padding: '2px 4px',
183
+ fontSize: '12px',
184
+ flexShrink: 0,
185
+ }}
186
+ >
187
+
188
+ </button>
189
+ </div>
190
+
191
+ {/* Content area */}
192
+ <div
193
+ style={{
194
+ flex: 1,
195
+ overflow: 'auto',
196
+ padding: '8px 0',
197
+ background: expanded ? 'var(--c-panel-soft)' : undefined,
198
+ borderRadius: expanded ? '0 0 8px 8px' : undefined,
199
+ }}
200
+ >
201
+ {loading && (
202
+ <div style={{ color: 'var(--c-dim)', padding: '12px', fontStyle: 'italic' }}>
203
+ Loading…
204
+ </div>
205
+ )}
206
+ {error && (
207
+ <div style={{ color: 'var(--c-danger)', padding: '12px' }}>{error}</div>
208
+ )}
209
+ {!loading && !error && (
210
+ <pre
211
+ style={{
212
+ margin: 0,
213
+ lineHeight: '1.55',
214
+ tabSize: 2,
215
+ }}
216
+ >
217
+ {lines.map((line, i) => (
218
+ <div
219
+ key={i}
220
+ style={{
221
+ display: 'flex',
222
+ minHeight: '1.55em',
223
+ }}
224
+ >
225
+ <span
226
+ style={{
227
+ width: gutterWidth,
228
+ minWidth: gutterWidth,
229
+ textAlign: 'right',
230
+ color: 'var(--c-dim)',
231
+ paddingRight: '12px',
232
+ paddingLeft: '10px',
233
+ userSelect: 'none',
234
+ flexShrink: 0,
235
+ opacity: 0.6,
236
+ }}
237
+ >
238
+ {i + 1}
239
+ </span>
240
+ <code
241
+ style={{
242
+ color: 'var(--c-text)',
243
+ whiteSpace: 'pre',
244
+ paddingRight: '10px',
245
+ }}
246
+ >
247
+ {line || '\n'}
248
+ </code>
249
+ </div>
250
+ ))}
251
+ </pre>
252
+ )}
253
+ </div>
254
+ </div>
255
+ );
256
+ }
@@ -0,0 +1,39 @@
1
+ import type { CanvasNodeState } from '../types';
2
+ import { nodes } from '../state/canvas-store';
3
+
4
+ interface GroupNodeProps {
5
+ node: CanvasNodeState;
6
+ }
7
+
8
+ export function GroupNode({ node }: GroupNodeProps) {
9
+ const childIds = (node.data.children as string[]) ?? [];
10
+ const allNodes = nodes.value;
11
+
12
+ // Count how many children actually exist
13
+ const liveChildren = childIds.filter((id) => allNodes.has(id));
14
+ const childCount = liveChildren.length;
15
+
16
+ // Build a type summary for the collapsed view
17
+ const typeCounts: Record<string, number> = {};
18
+ for (const id of liveChildren) {
19
+ const child = allNodes.get(id);
20
+ if (child) typeCounts[child.type] = (typeCounts[child.type] ?? 0) + 1;
21
+ }
22
+ const typeSummary = Object.entries(typeCounts)
23
+ .map(([type, count]) => `${count} ${type}`)
24
+ .join(', ');
25
+
26
+ return (
27
+ <div class="group-node-body">
28
+ <div class="group-summary">
29
+ <span class="group-child-count">{childCount} node{childCount !== 1 ? 's' : ''}</span>
30
+ {typeSummary && <span class="group-type-summary">{typeSummary}</span>}
31
+ </div>
32
+ {childCount === 0 && (
33
+ <div class="group-empty-hint">
34
+ Drag nodes here or use the selection bar to group nodes
35
+ </div>
36
+ )}
37
+ </div>
38
+ );
39
+ }
@@ -0,0 +1,160 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
2
+ import type { CanvasNodeState } from '../types';
3
+ import { getImageNodeWarnings } from './image-warnings';
4
+
5
+ /**
6
+ * Image node renderer.
7
+ * Supports: file paths (served via /api/canvas/image/:nodeId), data URIs, and URLs.
8
+ * Features: fit-to-container, zoom in/out within node, pan when zoomed.
9
+ */
10
+ export function ImageNode({
11
+ node,
12
+ expanded = false,
13
+ }: { node: CanvasNodeState; expanded?: boolean }) {
14
+ const src = (node.data.src as string) || '';
15
+ const alt = (node.data.alt as string) || (node.data.title as string) || 'Image';
16
+ const caption = (node.data.caption as string) || '';
17
+ const warnings = getImageNodeWarnings(node);
18
+
19
+ // Determine the image source URL
20
+ const imageSrc = src.startsWith('data:') || src.startsWith('http://') || src.startsWith('https://')
21
+ ? src
22
+ : `/api/canvas/image/${node.id}`;
23
+
24
+ const [loaded, setLoaded] = useState(false);
25
+ const [error, setError] = useState(false);
26
+ const [zoom, setZoom] = useState(1);
27
+ const [pan, setPan] = useState({ x: 0, y: 0 });
28
+ const [naturalSize, setNaturalSize] = useState({ w: 0, h: 0 });
29
+ const containerRef = useRef<HTMLDivElement>(null);
30
+ const dragging = useRef(false);
31
+ const lastPos = useRef({ x: 0, y: 0 });
32
+
33
+ const handleLoad = useCallback((e: Event) => {
34
+ const img = e.target as HTMLImageElement;
35
+ setNaturalSize({ w: img.naturalWidth, h: img.naturalHeight });
36
+ setLoaded(true);
37
+ setError(false);
38
+ }, []);
39
+
40
+ const handleError = useCallback(() => {
41
+ setError(true);
42
+ setLoaded(false);
43
+ }, []);
44
+
45
+ useEffect(() => {
46
+ setZoom(1);
47
+ setPan({ x: 0, y: 0 });
48
+ }, [src]);
49
+
50
+ const handleWheel = useCallback((e: WheelEvent) => {
51
+ e.preventDefault();
52
+ e.stopPropagation();
53
+ const delta = e.deltaY > 0 ? 0.9 : 1.1;
54
+ setZoom((z) => Math.max(0.25, Math.min(10, z * delta)));
55
+ }, []);
56
+
57
+ const handlePointerDown = useCallback((e: PointerEvent) => {
58
+ if (zoom <= 1) return;
59
+ e.stopPropagation();
60
+ dragging.current = true;
61
+ lastPos.current = { x: e.clientX, y: e.clientY };
62
+ (e.target as HTMLElement).setPointerCapture(e.pointerId);
63
+ }, [zoom]);
64
+
65
+ const handlePointerMove = useCallback((e: PointerEvent) => {
66
+ if (!dragging.current) return;
67
+ const dx = e.clientX - lastPos.current.x;
68
+ const dy = e.clientY - lastPos.current.y;
69
+ lastPos.current = { x: e.clientX, y: e.clientY };
70
+ setPan((p) => ({ x: p.x + dx, y: p.y + dy }));
71
+ }, []);
72
+
73
+ const handlePointerUp = useCallback(() => {
74
+ dragging.current = false;
75
+ }, []);
76
+
77
+ const resetView = useCallback(() => {
78
+ setZoom(1);
79
+ setPan({ x: 0, y: 0 });
80
+ }, []);
81
+
82
+ if (!src) {
83
+ return (
84
+ <div class="image-node-empty">
85
+ <div class="image-node-empty-icon">🖼</div>
86
+ <div class="image-node-empty-text">No image source</div>
87
+ </div>
88
+ );
89
+ }
90
+
91
+ const sizeLabel = naturalSize.w > 0 ? `${naturalSize.w}×${naturalSize.h}` : '';
92
+ const zoomPct = Math.round(zoom * 100);
93
+
94
+ return (
95
+ <div
96
+ class={`image-node ${expanded ? 'image-node-expanded' : ''}`}
97
+ ref={containerRef}
98
+ >
99
+ {warnings.length > 0 && (
100
+ <div class="image-node-warning-stack">
101
+ {warnings.map((warning) => (
102
+ <div class="image-node-warning" key={`${warning.title}-${warning.detail}`}>
103
+ <span class="image-node-warning-title">{warning.title}</span>
104
+ <span class="image-node-warning-detail">{warning.detail}</span>
105
+ </div>
106
+ ))}
107
+ </div>
108
+ )}
109
+ <div
110
+ class="image-node-viewport"
111
+ onWheel={handleWheel}
112
+ onPointerDown={handlePointerDown}
113
+ onPointerMove={handlePointerMove}
114
+ onPointerUp={handlePointerUp}
115
+ style={{ cursor: zoom > 1 ? 'grab' : 'default' }}
116
+ >
117
+ {!loaded && !error && (
118
+ <div class="image-node-loading">Loading…</div>
119
+ )}
120
+ {error && (
121
+ <div class="image-node-error">
122
+ <div class="image-node-error-icon">⚠</div>
123
+ <div>Failed to load image</div>
124
+ <div class="image-node-error-path">{src}</div>
125
+ </div>
126
+ )}
127
+ <img
128
+ src={imageSrc}
129
+ alt={alt}
130
+ onLoad={handleLoad}
131
+ onError={handleError}
132
+ draggable={false}
133
+ style={{
134
+ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
135
+ opacity: loaded ? 1 : 0,
136
+ display: error ? 'none' : 'block',
137
+ }}
138
+ />
139
+ </div>
140
+ {(caption || sizeLabel || zoom !== 1) && (
141
+ <div class="image-node-footer">
142
+ {caption && <span class="image-node-caption">{caption}</span>}
143
+ <span class="image-node-meta">
144
+ {sizeLabel && <span>{sizeLabel}</span>}
145
+ {zoom !== 1 && (
146
+ <button
147
+ type="button"
148
+ class="image-node-zoom-reset"
149
+ onClick={resetView}
150
+ title="Reset zoom"
151
+ >
152
+ {zoomPct}% ↺
153
+ </button>
154
+ )}
155
+ </span>
156
+ </div>
157
+ )}
158
+ </div>
159
+ );
160
+ }
@@ -0,0 +1,169 @@
1
+ import type { RefObject } from 'preact';
2
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
3
+ import { promptAndInsertLink, wrapSelectionInCode } from './inline-editor-commands';
4
+
5
+ const GAP = 8;
6
+
7
+ type ExecCommand =
8
+ | 'bold'
9
+ | 'italic'
10
+ | 'strikeThrough'
11
+ | 'formatBlock'
12
+ | 'insertUnorderedList'
13
+ | 'insertOrderedList';
14
+
15
+ type BlockTag = 'H1' | 'H2' | 'H3' | 'P' | 'BLOCKQUOTE';
16
+
17
+ type Action =
18
+ | { kind: 'exec'; command: Exclude<ExecCommand, 'formatBlock'>; icon: string; title: string; dividerBefore?: boolean }
19
+ | { kind: 'block'; tag: BlockTag; icon: string; title: string; dividerBefore?: boolean }
20
+ | { kind: 'code'; icon: string; title: string; dividerBefore?: boolean }
21
+ | { kind: 'link'; icon: string; title: string; dividerBefore?: boolean };
22
+
23
+ const ACTIONS: Action[] = [
24
+ { kind: 'exec', command: 'bold', icon: 'B', title: 'Bold (⌘B)' },
25
+ { kind: 'exec', command: 'italic', icon: 'I', title: 'Italic (⌘I)' },
26
+ { kind: 'exec', command: 'strikeThrough', icon: 'S', title: 'Strikethrough' },
27
+ { kind: 'code', icon: '{ }', title: 'Inline code', dividerBefore: true },
28
+ { kind: 'block', tag: 'H1', icon: 'H1', title: 'Heading 1', dividerBefore: true },
29
+ { kind: 'block', tag: 'H2', icon: 'H2', title: 'Heading 2' },
30
+ { kind: 'block', tag: 'H3', icon: 'H3', title: 'Heading 3' },
31
+ { kind: 'block', tag: 'P', icon: '¶', title: 'Paragraph' },
32
+ { kind: 'block', tag: 'BLOCKQUOTE', icon: '❝', title: 'Quote', dividerBefore: true },
33
+ { kind: 'exec', command: 'insertUnorderedList', icon: '•', title: 'Bullet list' },
34
+ { kind: 'exec', command: 'insertOrderedList', icon: '1.', title: 'Numbered list' },
35
+ { kind: 'link', icon: '🔗', title: 'Link (⌘K)', dividerBefore: true },
36
+ ];
37
+
38
+ function runAction(action: Action): void {
39
+ switch (action.kind) {
40
+ case 'exec':
41
+ document.execCommand(action.command);
42
+ return;
43
+ case 'block':
44
+ document.execCommand('formatBlock', false, action.tag);
45
+ return;
46
+ case 'code':
47
+ wrapSelectionInCode();
48
+ return;
49
+ case 'link':
50
+ promptAndInsertLink();
51
+ return;
52
+ }
53
+ }
54
+
55
+ /** Floating selection toolbar for a contentEditable host. Mounts once; while
56
+ * visible, positions itself above the current selection's viewport rect. */
57
+ export function InlineFormatBar({
58
+ hostRef,
59
+ onChange,
60
+ }: {
61
+ hostRef: RefObject<HTMLElement>;
62
+ onChange: () => void;
63
+ }) {
64
+ const [visible, setVisible] = useState(false);
65
+ const [pos, setPos] = useState({ top: 0, left: 0 });
66
+ const [barWidth, setBarWidth] = useState(0);
67
+ const barRef = useRef<HTMLDivElement>(null);
68
+ const rafRef = useRef<number | null>(null);
69
+
70
+ const recompute = useCallback(() => {
71
+ const host = hostRef.current;
72
+ if (!host) {
73
+ setVisible(false);
74
+ return;
75
+ }
76
+ const sel = window.getSelection();
77
+ if (!sel || sel.rangeCount === 0 || sel.isCollapsed) {
78
+ setVisible(false);
79
+ return;
80
+ }
81
+ const range = sel.getRangeAt(0);
82
+ if (!host.contains(range.commonAncestorContainer)) {
83
+ setVisible(false);
84
+ return;
85
+ }
86
+ const rect = range.getBoundingClientRect();
87
+ if (rect.width === 0 && rect.height === 0) {
88
+ setVisible(false);
89
+ return;
90
+ }
91
+ // Use measured width if we have it; fall back to a conservative estimate
92
+ // on the very first show before layout completes.
93
+ const width = barWidth || 420;
94
+ const left = Math.max(
95
+ GAP,
96
+ Math.min(rect.left + rect.width / 2 - width / 2, window.innerWidth - width - GAP),
97
+ );
98
+ const top = Math.max(GAP, rect.top - GAP - 36);
99
+ setPos({ top, left });
100
+ setVisible(true);
101
+ }, [hostRef, barWidth]);
102
+
103
+ // Coalesce selection/scroll/resize into at most one recompute per frame.
104
+ const schedule = useCallback(() => {
105
+ if (rafRef.current !== null) return;
106
+ rafRef.current = requestAnimationFrame(() => {
107
+ rafRef.current = null;
108
+ recompute();
109
+ });
110
+ }, [recompute]);
111
+
112
+ useEffect(() => {
113
+ document.addEventListener('selectionchange', schedule);
114
+ window.addEventListener('scroll', schedule, true);
115
+ window.addEventListener('resize', schedule);
116
+ return () => {
117
+ document.removeEventListener('selectionchange', schedule);
118
+ window.removeEventListener('scroll', schedule, true);
119
+ window.removeEventListener('resize', schedule);
120
+ if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
121
+ };
122
+ }, [schedule]);
123
+
124
+ // Measure the bar once visible so subsequent shows use the real width.
125
+ useLayoutEffect(() => {
126
+ if (!visible) return;
127
+ const el = barRef.current;
128
+ if (!el) return;
129
+ const measured = el.getBoundingClientRect().width;
130
+ if (measured && Math.abs(measured - barWidth) > 1) setBarWidth(measured);
131
+ }, [visible, barWidth]);
132
+
133
+ const handleClick = useCallback(
134
+ (action: Action) => {
135
+ runAction(action);
136
+ onChange();
137
+ recompute();
138
+ },
139
+ [onChange, recompute],
140
+ );
141
+
142
+ if (!visible) return null;
143
+
144
+ return (
145
+ <div
146
+ ref={barRef}
147
+ class="md-inline-format-bar"
148
+ style={{ top: `${pos.top}px`, left: `${pos.left}px` }}
149
+ onMouseDown={(e) => e.preventDefault()}
150
+ >
151
+ {ACTIONS.flatMap((a, i) => {
152
+ const btn = (
153
+ <button
154
+ key={`btn-${i}`}
155
+ type="button"
156
+ class="md-inline-format-btn"
157
+ title={a.title}
158
+ onClick={() => handleClick(a)}
159
+ >
160
+ {a.icon}
161
+ </button>
162
+ );
163
+ return a.dividerBefore
164
+ ? [<span key={`div-${i}`} class="md-inline-format-divider" />, btn]
165
+ : [btn];
166
+ })}
167
+ </div>
168
+ );
169
+ }