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,590 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
2
+ import { ContextNode } from '../nodes/ContextNode';
3
+ import { FileNode } from '../nodes/FileNode';
4
+ import { LedgerNode } from '../nodes/LedgerNode';
5
+ import { MarkdownNode } from '../nodes/MarkdownNode';
6
+ import { McpAppNode } from '../nodes/McpAppNode';
7
+ import { StatusNode } from '../nodes/StatusNode';
8
+ import { ImageNode } from '../nodes/ImageNode';
9
+ import { GroupNode } from '../nodes/GroupNode';
10
+ import { WebpageNode } from '../nodes/WebpageNode';
11
+ import { PromptNode } from '../nodes/PromptNode';
12
+ import { ResponseNode } from '../nodes/ResponseNode';
13
+ import { TraceNode } from '../nodes/TraceNode';
14
+ import {
15
+ activeNodeId,
16
+ cancelViewportAnimation,
17
+ commitViewport,
18
+ clearSelection,
19
+ draggingEdge,
20
+ edges,
21
+ expandedNodeId,
22
+ nodes,
23
+ selectNodes,
24
+ setViewport,
25
+ viewport,
26
+ } from '../state/canvas-store';
27
+ import { createEdgeFromClient, createNodeFromClient } from '../state/intent-bridge';
28
+ import type { CanvasNodeState } from '../types';
29
+ import { FocusFieldLayer } from './FocusFieldLayer';
30
+ import { CanvasNode } from './CanvasNode';
31
+ import { EdgeLayer } from './EdgeLayer';
32
+ import { activeGuides } from './snap-guides';
33
+ import { usePanZoom } from './use-pan-zoom';
34
+
35
+ function renderNodeContent(node: CanvasNodeState) {
36
+ switch (node.type) {
37
+ case 'markdown':
38
+ return <MarkdownNode node={node} />;
39
+ case 'mcp-app':
40
+ return <McpAppNode node={node} />;
41
+ case 'webpage':
42
+ return <WebpageNode node={node} />;
43
+ case 'json-render':
44
+ return <McpAppNode node={node} />;
45
+ case 'graph':
46
+ return <McpAppNode node={node} />;
47
+ case 'prompt':
48
+ return <PromptNode node={node} />;
49
+ case 'response':
50
+ return <ResponseNode node={node} />;
51
+ case 'status':
52
+ return <StatusNode node={node} />;
53
+ case 'context':
54
+ return <ContextNode node={node} />;
55
+ case 'ledger':
56
+ return <LedgerNode node={node} />;
57
+ case 'trace':
58
+ return <TraceNode node={node} />;
59
+ case 'file':
60
+ return <FileNode node={node} />;
61
+ case 'image':
62
+ return <ImageNode node={node} />;
63
+ case 'group':
64
+ return <GroupNode node={node} />;
65
+ default:
66
+ return <div>Unknown node type</div>;
67
+ }
68
+ }
69
+
70
+ interface LassoRect {
71
+ startX: number;
72
+ startY: number;
73
+ currentX: number;
74
+ currentY: number;
75
+ }
76
+
77
+ interface CanvasViewportProps {
78
+ onNodeContextMenu?: (e: MouseEvent, nodeId: string) => void;
79
+ onCanvasContextMenu?: (e: MouseEvent, canvasX: number, canvasY: number) => void;
80
+ }
81
+
82
+ const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'bmp', 'ico', 'avif']);
83
+ const MD_EXTS = new Set(['md', 'mdx', 'markdown']);
84
+ const WEBPAGE_NODE_SIZE = { width: 520, height: 420 };
85
+
86
+ function normalizeUrlCandidate(raw: string): string | null {
87
+ const trimmed = raw.trim();
88
+ if (!trimmed || /\s/.test(trimmed)) return null;
89
+
90
+ const withScheme = /^[a-z][a-z0-9+.-]*:/i.test(trimmed) ? trimmed : `https://${trimmed}`;
91
+ try {
92
+ const url = new URL(withScheme);
93
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
94
+ return url.toString();
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ function extractUrlsFromText(text: string): string[] {
101
+ const trimmed = text.trim();
102
+ if (!trimmed) return [];
103
+
104
+ const rawCandidates = trimmed.includes('\n')
105
+ ? trimmed.split(/\r?\n/)
106
+ : trimmed.split(/\s+/);
107
+ const seen = new Set<string>();
108
+ const urls: string[] = [];
109
+
110
+ for (const candidate of rawCandidates) {
111
+ const value = candidate.trim();
112
+ if (!value || value.startsWith('#')) continue;
113
+ const normalized = normalizeUrlCandidate(value);
114
+ if (!normalized || seen.has(normalized)) continue;
115
+ seen.add(normalized);
116
+ urls.push(normalized);
117
+ }
118
+
119
+ return urls;
120
+ }
121
+
122
+ function getTransferUrls(dataTransfer: DataTransfer): string[] {
123
+ const uriList = extractUrlsFromText(dataTransfer.getData('text/uri-list'));
124
+ if (uriList.length > 0) return uriList;
125
+ return extractUrlsFromText(dataTransfer.getData('text/plain'));
126
+ }
127
+
128
+ function hasUrlPayload(dataTransfer: DataTransfer | null): boolean {
129
+ if (!dataTransfer) return false;
130
+ return dataTransfer.types.includes('text/uri-list') || dataTransfer.types.includes('text/plain');
131
+ }
132
+
133
+ function isEditableElement(element: Element | null): boolean {
134
+ if (!(element instanceof HTMLElement)) return false;
135
+ return Boolean(element.closest('input, textarea, select, [contenteditable="true"], [contenteditable=""]'));
136
+ }
137
+
138
+ function nodeTypeFromFilename(name: string): 'image' | 'markdown' | 'file' {
139
+ const ext = name.split('.').pop()?.toLowerCase() ?? '';
140
+ if (IMAGE_EXTS.has(ext)) return 'image';
141
+ if (MD_EXTS.has(ext)) return 'markdown';
142
+ return 'file';
143
+ }
144
+
145
+ export function getRenderableWorldNodes(
146
+ allNodes: Iterable<CanvasNodeState>,
147
+ focusedNodeId: string | null,
148
+ ): CanvasNodeState[] {
149
+ const worldNodes: CanvasNodeState[] = [];
150
+ let insertIdx = 0; // groups fill from the front
151
+ for (const n of allNodes) {
152
+ if (n.dockPosition !== null) continue;
153
+ // Focus mode renders the node inside the overlay. Skip the original world
154
+ // instance so embedded apps do not mount twice.
155
+ if (focusedNodeId && n.id === focusedNodeId) continue;
156
+ if (n.type === 'group') {
157
+ worldNodes.splice(insertIdx++, 0, n);
158
+ } else {
159
+ worldNodes.push(n);
160
+ }
161
+ }
162
+ return worldNodes;
163
+ }
164
+
165
+ export function CanvasViewport({ onNodeContextMenu, onCanvasContextMenu }: CanvasViewportProps) {
166
+ const v = viewport.value;
167
+ const isLassoing = useRef(false);
168
+ const [lasso, setLasso] = useState<LassoRect | null>(null);
169
+ const [dropActive, setDropActive] = useState(false);
170
+ const dropCounter = useRef(0);
171
+ // Ref mirrors lasso state so pointer handlers always read the latest value
172
+ // without stale-closure issues from useCallback dependency capture.
173
+ const lassoRef = useRef<LassoRect | null>(null);
174
+
175
+ const containerRef = usePanZoom({
176
+ viewport,
177
+ onViewportChange: (next) => {
178
+ // Don't pan while lassoing — usePanZoom's pointerdown still fires
179
+ // (native listener) before our Preact handler can stopPropagation.
180
+ if (isLassoing.current) return;
181
+ cancelViewportAnimation();
182
+ setViewport(next);
183
+ },
184
+ onViewportCommit: (next) => {
185
+ if (isLassoing.current) return;
186
+ cancelViewportAnimation();
187
+ commitViewport(next);
188
+ },
189
+ });
190
+
191
+ const createWebpageNodes = useCallback(async (urls: string[], centerX: number, centerY: number) => {
192
+ if (urls.length === 0) return;
193
+
194
+ const nodeW = WEBPAGE_NODE_SIZE.width;
195
+ const nodeH = WEBPAGE_NODE_SIZE.height;
196
+ const spacing = 24;
197
+ const cols = Math.ceil(Math.sqrt(urls.length));
198
+ const rows = Math.ceil(urls.length / cols);
199
+ const totalW = cols * nodeW + Math.max(0, cols - 1) * spacing;
200
+ const totalH = rows * nodeH + Math.max(0, rows - 1) * spacing;
201
+
202
+ for (let index = 0; index < urls.length; index++) {
203
+ const col = index % cols;
204
+ const row = Math.floor(index / cols);
205
+ const x = centerX - totalW / 2 + col * (nodeW + spacing);
206
+ const y = centerY - totalH / 2 + row * (nodeH + spacing);
207
+ await createNodeFromClient({
208
+ type: 'webpage',
209
+ content: urls[index],
210
+ x,
211
+ y,
212
+ width: nodeW,
213
+ height: nodeH,
214
+ });
215
+ }
216
+ }, []);
217
+
218
+ // Lasso: Shift+pointerdown on background starts lasso selection
219
+ const handlePointerDown = useCallback(
220
+ (e: PointerEvent) => {
221
+ const container = containerRef.current;
222
+ if (!container || e.target !== container) return;
223
+
224
+ if (!e.shiftKey) {
225
+ if (!lassoRef.current) {
226
+ activeNodeId.value = null;
227
+ clearSelection();
228
+ }
229
+ return;
230
+ }
231
+
232
+ e.preventDefault();
233
+ e.stopPropagation();
234
+ isLassoing.current = true;
235
+ const rect = container.getBoundingClientRect();
236
+ const x = e.clientX - rect.left;
237
+ const y = e.clientY - rect.top;
238
+ const initial = { startX: x, startY: y, currentX: x, currentY: y };
239
+ lassoRef.current = initial;
240
+ setLasso(initial);
241
+ container.setPointerCapture(e.pointerId);
242
+ },
243
+ [containerRef],
244
+ );
245
+
246
+ const handlePointerMove = useCallback(
247
+ (e: PointerEvent) => {
248
+ if (!isLassoing.current || !lassoRef.current) return;
249
+ const rect = containerRef.current?.getBoundingClientRect();
250
+ if (!rect) return;
251
+ const updated = {
252
+ ...lassoRef.current,
253
+ currentX: e.clientX - rect.left,
254
+ currentY: e.clientY - rect.top,
255
+ };
256
+ lassoRef.current = updated;
257
+ setLasso(updated);
258
+ },
259
+ [containerRef],
260
+ );
261
+
262
+ const handlePointerUp = useCallback(() => {
263
+ const current = lassoRef.current;
264
+ if (!isLassoing.current || !current) return;
265
+ isLassoing.current = false;
266
+ lassoRef.current = null;
267
+
268
+ // Compute lasso rectangle in screen space
269
+ const minX = Math.min(current.startX, current.currentX);
270
+ const maxX = Math.max(current.startX, current.currentX);
271
+ const minY = Math.min(current.startY, current.currentY);
272
+ const maxY = Math.max(current.startY, current.currentY);
273
+
274
+ // Only commit if the lasso was dragged at least a few pixels
275
+ if (maxX - minX > 5 || maxY - minY > 5) {
276
+ // Convert screen lasso rect to world-space
277
+ const vp = viewport.value;
278
+ const worldMinX = (minX - vp.x) / vp.scale;
279
+ const worldMaxX = (maxX - vp.x) / vp.scale;
280
+ const worldMinY = (minY - vp.y) / vp.scale;
281
+ const worldMaxY = (maxY - vp.y) / vp.scale;
282
+
283
+ // Find intersecting nodes (AABB intersection)
284
+ const hits: string[] = [];
285
+ for (const node of nodes.value.values()) {
286
+ if (node.dockPosition !== null) continue;
287
+ const nx = node.position.x;
288
+ const ny = node.position.y;
289
+ if (
290
+ nx + node.size.width > worldMinX &&
291
+ nx < worldMaxX &&
292
+ ny + node.size.height > worldMinY &&
293
+ ny < worldMaxY
294
+ ) {
295
+ hits.push(node.id);
296
+ }
297
+ }
298
+ if (hits.length > 0) {
299
+ selectNodes(hits);
300
+ }
301
+ }
302
+
303
+ setLasso(null);
304
+ }, []);
305
+
306
+ // ── Drag-to-connect: track cursor in world space, hit-test on drop ──
307
+ useEffect(() => {
308
+ function handleMove(e: PointerEvent) {
309
+ if (!draggingEdge.value) return;
310
+ const container = containerRef.current;
311
+ if (!container) return;
312
+ const rect = container.getBoundingClientRect();
313
+ const v = viewport.value;
314
+ draggingEdge.value = {
315
+ ...draggingEdge.value,
316
+ cursorX: (e.clientX - rect.left - v.x) / v.scale,
317
+ cursorY: (e.clientY - rect.top - v.y) / v.scale,
318
+ };
319
+ }
320
+
321
+ function handleUp(e: PointerEvent) {
322
+ const drag = draggingEdge.value;
323
+ if (!drag) return;
324
+ draggingEdge.value = null;
325
+
326
+ // Hit-test: find node under cursor
327
+ const container = containerRef.current;
328
+ if (!container) return;
329
+ const rect = container.getBoundingClientRect();
330
+ const v = viewport.value;
331
+ const wx = (e.clientX - rect.left - v.x) / v.scale;
332
+ const wy = (e.clientY - rect.top - v.y) / v.scale;
333
+
334
+ for (const node of nodes.value.values()) {
335
+ if (node.id === drag.fromId || node.dockPosition !== null) continue;
336
+ if (
337
+ wx >= node.position.x &&
338
+ wx <= node.position.x + node.size.width &&
339
+ wy >= node.position.y &&
340
+ wy <= node.position.y + node.size.height
341
+ ) {
342
+ createEdgeFromClient(drag.fromId, node.id, 'relation');
343
+ return;
344
+ }
345
+ }
346
+ }
347
+
348
+ document.addEventListener('pointermove', handleMove);
349
+ document.addEventListener('pointerup', handleUp);
350
+ return () => {
351
+ document.removeEventListener('pointermove', handleMove);
352
+ document.removeEventListener('pointerup', handleUp);
353
+ };
354
+ }, [containerRef]);
355
+
356
+ // ── Double-click on background → create new markdown node ──
357
+ const handleDblClick = useCallback(
358
+ (e: MouseEvent) => {
359
+ const container = containerRef.current;
360
+ if (!container || e.target !== container) return;
361
+ const rect = container.getBoundingClientRect();
362
+ const v = viewport.value;
363
+ const wx = (e.clientX - rect.left - v.x) / v.scale;
364
+ const wy = (e.clientY - rect.top - v.y) / v.scale;
365
+ // Offset so node centers on click point
366
+ const nodeW = 360;
367
+ const nodeH = 200;
368
+ createNodeFromClient({
369
+ type: 'markdown',
370
+ title: 'New note',
371
+ x: wx - nodeW / 2,
372
+ y: wy - nodeH / 2,
373
+ width: nodeW,
374
+ height: nodeH,
375
+ });
376
+ },
377
+ [containerRef],
378
+ );
379
+
380
+ const handleContextMenu = useCallback(
381
+ (e: MouseEvent) => {
382
+ if (!onCanvasContextMenu) return;
383
+
384
+ const container = containerRef.current;
385
+ if (!container) return;
386
+
387
+ const target = e.target instanceof Element ? e.target : null;
388
+ if (target?.closest('.canvas-node')) return;
389
+
390
+ const rect = container.getBoundingClientRect();
391
+ const v = viewport.value;
392
+ const canvasX = (e.clientX - rect.left - v.x) / v.scale;
393
+ const canvasY = (e.clientY - rect.top - v.y) / v.scale;
394
+ onCanvasContextMenu(e, canvasX, canvasY);
395
+ },
396
+ [containerRef, onCanvasContextMenu],
397
+ );
398
+
399
+ // ── Drag-and-drop files from filesystem ──
400
+ const handleDragEnter = useCallback((e: DragEvent) => {
401
+ e.preventDefault();
402
+ dropCounter.current++;
403
+ if (e.dataTransfer?.types.includes('Files') || hasUrlPayload(e.dataTransfer)) {
404
+ setDropActive(true);
405
+ }
406
+ }, []);
407
+
408
+ const handleDragOver = useCallback((e: DragEvent) => {
409
+ e.preventDefault();
410
+ if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
411
+ }, []);
412
+
413
+ const handleDragLeave = useCallback((e: DragEvent) => {
414
+ e.preventDefault();
415
+ dropCounter.current--;
416
+ if (dropCounter.current <= 0) {
417
+ dropCounter.current = 0;
418
+ setDropActive(false);
419
+ }
420
+ }, []);
421
+
422
+ const handleDrop = useCallback(async (e: DragEvent) => {
423
+ e.preventDefault();
424
+ setDropActive(false);
425
+ dropCounter.current = 0;
426
+
427
+ const container = containerRef.current;
428
+ if (!container || !e.dataTransfer) return;
429
+
430
+ const rect = container.getBoundingClientRect();
431
+ const vp = viewport.value;
432
+ const baseWx = (e.clientX - rect.left - vp.x) / vp.scale;
433
+ const baseWy = (e.clientY - rect.top - vp.y) / vp.scale;
434
+
435
+ const files = Array.from(e.dataTransfer.files);
436
+ if (files.length === 0) {
437
+ const urls = getTransferUrls(e.dataTransfer);
438
+ await createWebpageNodes(urls, baseWx, baseWy);
439
+ return;
440
+ }
441
+
442
+ const nodeW = 400;
443
+ const nodeH = 300;
444
+ const spacing = 20;
445
+ const cols = Math.ceil(Math.sqrt(files.length));
446
+
447
+ for (let i = 0; i < files.length; i++) {
448
+ const file = files[i];
449
+ const col = i % cols;
450
+ const row = Math.floor(i / cols);
451
+ const wx = baseWx - (cols * (nodeW + spacing)) / 2 + col * (nodeW + spacing);
452
+ const wy = baseWy - nodeH / 2 + row * (nodeH + spacing);
453
+
454
+ const type = nodeTypeFromFilename(file.name);
455
+ const fileName = file.name;
456
+
457
+ if (type === 'image') {
458
+ const reader = new FileReader();
459
+ const dataUri: string = await new Promise((resolve) => {
460
+ reader.onload = () => resolve(reader.result as string);
461
+ reader.readAsDataURL(file);
462
+ });
463
+ await createNodeFromClient({ type: 'image', title: fileName, content: dataUri, x: wx, y: wy, width: nodeW, height: nodeH });
464
+ } else {
465
+ const text = await file.text();
466
+ const isWide = type === 'markdown' || type === 'file';
467
+ await createNodeFromClient({ type, title: fileName, content: text, x: wx, y: wy, width: isWide ? 720 : nodeW, height: isWide ? 500 : nodeH });
468
+ }
469
+ }
470
+ }, [containerRef, createWebpageNodes]);
471
+
472
+ useEffect(() => {
473
+ const handlePaste = async (e: ClipboardEvent) => {
474
+ if (e.defaultPrevented) return;
475
+ if (isEditableElement(e.target instanceof Element ? e.target : null)) return;
476
+ if (isEditableElement(document.activeElement)) return;
477
+
478
+ const text = e.clipboardData?.getData('text/plain') ?? '';
479
+ const urls = extractUrlsFromText(text);
480
+ if (urls.length === 0) return;
481
+
482
+ const container = containerRef.current;
483
+ if (!container) return;
484
+
485
+ e.preventDefault();
486
+
487
+ const rect = container.getBoundingClientRect();
488
+ const vp = viewport.value;
489
+ const centerX = (rect.width / 2 - vp.x) / vp.scale;
490
+ const centerY = (rect.height / 2 - vp.y) / vp.scale;
491
+ await createWebpageNodes(urls, centerX, centerY);
492
+ };
493
+
494
+ document.addEventListener('paste', handlePaste);
495
+ return () => {
496
+ document.removeEventListener('paste', handlePaste);
497
+ };
498
+ }, [containerRef, createWebpageNodes]);
499
+
500
+ // Only render world-space nodes (dockPosition === null); docked nodes are in the HUD layer.
501
+ // Do NOT sort by zIndex here — CSS z-index handles visual stacking. Sorting would
502
+ // reorder DOM children when bringToFront() changes zIndex, causing browsers to
503
+ // detach/reattach iframe elements (which forces them to reload/reconnect).
504
+ // Group nodes render first (behind) so they serve as visual containers.
505
+ const worldNodes = getRenderableWorldNodes(nodes.value.values(), expandedNodeId.value);
506
+
507
+ // Compute lasso overlay rect in screen space
508
+ let lassoStyle: Record<string, string> | null = null;
509
+ if (lasso) {
510
+ const l = Math.min(lasso.startX, lasso.currentX);
511
+ const t = Math.min(lasso.startY, lasso.currentY);
512
+ const w = Math.abs(lasso.currentX - lasso.startX);
513
+ const h = Math.abs(lasso.currentY - lasso.startY);
514
+ lassoStyle = {
515
+ position: 'absolute',
516
+ left: `${l}px`,
517
+ top: `${t}px`,
518
+ width: `${w}px`,
519
+ height: `${h}px`,
520
+ pointerEvents: 'none',
521
+ };
522
+ }
523
+
524
+ return (
525
+ <div
526
+ class="canvas-viewport"
527
+ ref={containerRef}
528
+ tabIndex={0}
529
+ onPointerDown={handlePointerDown}
530
+ onPointerMove={handlePointerMove}
531
+ onPointerUp={handlePointerUp}
532
+ onContextMenu={handleContextMenu}
533
+ onDblClick={handleDblClick}
534
+ onDragEnter={handleDragEnter}
535
+ onDragOver={handleDragOver}
536
+ onDragLeave={handleDragLeave}
537
+ onDrop={handleDrop}
538
+ style={{
539
+ width: '100%',
540
+ height: '100%',
541
+ position: 'relative',
542
+ overflow: 'hidden',
543
+ cursor: draggingEdge.value ? 'crosshair' : isLassoing.current ? 'crosshair' : 'grab',
544
+ }}
545
+ >
546
+ {/* D4: CSS matrix(a,b,c,d,tx,ty) — scale uniformly (a=d=scale, b=c=0)
547
+ then translate (tx=v.x, ty=v.y). transformOrigin: '0 0' ensures
548
+ the scale pivot is the top-left corner of the world layer. */}
549
+ <div
550
+ style={{
551
+ transform: `matrix(${v.scale}, 0, 0, ${v.scale}, ${v.x}, ${v.y})`,
552
+ transformOrigin: '0 0',
553
+ willChange: 'transform',
554
+ position: 'absolute',
555
+ top: 0,
556
+ left: 0,
557
+ }}
558
+ >
559
+ <FocusFieldLayer />
560
+ <EdgeLayer nodes={nodes} edges={edges} />
561
+ {worldNodes.map((node) => (
562
+ <CanvasNode key={node.id} node={node} onContextMenu={onNodeContextMenu}>
563
+ {renderNodeContent(node)}
564
+ </CanvasNode>
565
+ ))}
566
+ {/* Snap alignment guide lines */}
567
+ {activeGuides.value && (
568
+ <svg class="snap-guides-svg" style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', pointerEvents: 'none', overflow: 'visible' }}>
569
+ {activeGuides.value.map((g, i) =>
570
+ g.axis === 'x' ? (
571
+ <line key={i} x1={g.pos} y1={g.from - 20} x2={g.pos} y2={g.to + 20} class="snap-guide-line" />
572
+ ) : (
573
+ <line key={i} x1={g.from - 20} y1={g.pos} x2={g.to + 20} y2={g.pos} class="snap-guide-line" />
574
+ ),
575
+ )}
576
+ </svg>
577
+ )}
578
+ </div>
579
+ {lassoStyle && <div class="lasso-rect" style={lassoStyle} />}
580
+ {dropActive && (
581
+ <div class="drop-zone-overlay">
582
+ <div class="drop-zone-indicator">
583
+ <div class="drop-zone-icon">+</div>
584
+ <div class="drop-zone-label">Drop files or URLs to add to canvas</div>
585
+ </div>
586
+ </div>
587
+ )}
588
+ </div>
589
+ );
590
+ }