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.
- package/CHANGELOG.md +38 -0
- package/LICENSE +21 -0
- package/Readme.md +865 -0
- package/dist/canvas/global.css +3173 -0
- package/dist/canvas/index.js +183 -0
- package/dist/json-render/index.css +2 -0
- package/dist/json-render/index.js +389 -0
- package/dist/types/cli/agent.d.ts +13 -0
- package/dist/types/cli/index.d.ts +2 -0
- package/dist/types/cli/watch.d.ts +5 -0
- package/dist/types/client/App.d.ts +1 -0
- package/dist/types/client/canvas/AttentionHistory.d.ts +1 -0
- package/dist/types/client/canvas/AttentionToast.d.ts +1 -0
- package/dist/types/client/canvas/CanvasNode.d.ts +8 -0
- package/dist/types/client/canvas/CanvasViewport.d.ts +8 -0
- package/dist/types/client/canvas/CommandPalette.d.ts +4 -0
- package/dist/types/client/canvas/ContextMenu.d.ts +24 -0
- package/dist/types/client/canvas/ContextPinBar.d.ts +1 -0
- package/dist/types/client/canvas/ContextPinHud.d.ts +1 -0
- package/dist/types/client/canvas/DockedNode.d.ts +4 -0
- package/dist/types/client/canvas/EdgeLayer.d.ts +8 -0
- package/dist/types/client/canvas/ExpandedNodeOverlay.d.ts +1 -0
- package/dist/types/client/canvas/FocusFieldLayer.d.ts +1 -0
- package/dist/types/client/canvas/Minimap.d.ts +23 -0
- package/dist/types/client/canvas/SelectionBar.d.ts +1 -0
- package/dist/types/client/canvas/ShortcutOverlay.d.ts +3 -0
- package/dist/types/client/canvas/SnapshotPanel.d.ts +7 -0
- package/dist/types/client/canvas/snap-guides.d.ts +23 -0
- package/dist/types/client/canvas/use-node-drag.d.ts +15 -0
- package/dist/types/client/canvas/use-node-resize.d.ts +15 -0
- package/dist/types/client/canvas/use-pan-zoom.d.ts +16 -0
- package/dist/types/client/ext-app/bridge.d.ts +161 -0
- package/dist/types/client/icons.d.ts +70 -0
- package/dist/types/client/index.d.ts +1 -0
- package/dist/types/client/nodes/ContextNode.d.ts +34 -0
- package/dist/types/client/nodes/ExtAppFrame.d.ts +18 -0
- package/dist/types/client/nodes/FileNode.d.ts +5 -0
- package/dist/types/client/nodes/GroupNode.d.ts +6 -0
- package/dist/types/client/nodes/ImageNode.d.ts +10 -0
- package/dist/types/client/nodes/InlineFormatBar.d.ts +7 -0
- package/dist/types/client/nodes/InlineMarkdownEditor.d.ts +14 -0
- package/dist/types/client/nodes/LedgerNode.d.ts +4 -0
- package/dist/types/client/nodes/MarkdownNode.d.ts +6 -0
- package/dist/types/client/nodes/McpAppNode.d.ts +4 -0
- package/dist/types/client/nodes/MdFormatBar.d.ts +8 -0
- package/dist/types/client/nodes/PromptNode.d.ts +5 -0
- package/dist/types/client/nodes/ResponseNode.d.ts +5 -0
- package/dist/types/client/nodes/StatusNode.d.ts +4 -0
- package/dist/types/client/nodes/StatusSummary.d.ts +4 -0
- package/dist/types/client/nodes/TraceNode.d.ts +4 -0
- package/dist/types/client/nodes/WebpageNode.d.ts +5 -0
- package/dist/types/client/nodes/image-warnings.d.ts +6 -0
- package/dist/types/client/nodes/inline-editor-commands.d.ts +11 -0
- package/dist/types/client/nodes/md-format.d.ts +25 -0
- package/dist/types/client/state/attention-bridge.d.ts +3 -0
- package/dist/types/client/state/attention-store.d.ts +25 -0
- package/dist/types/client/state/canvas-store.d.ts +74 -0
- package/dist/types/client/state/intent-bridge.d.ts +158 -0
- package/dist/types/client/state/sse-bridge.d.ts +5 -0
- package/dist/types/client/theme/tokens.d.ts +27 -0
- package/dist/types/client/types.d.ts +40 -0
- package/dist/types/client/utils/ext-app-tool-result.d.ts +1 -0
- package/dist/types/client/utils/placement.d.ts +1 -0
- package/dist/types/client/utils/platform.d.ts +2 -0
- package/dist/types/json-render/catalog.d.ts +815 -0
- package/dist/types/json-render/charts/components.d.ts +54 -0
- package/dist/types/json-render/charts/definitions.d.ts +103 -0
- package/dist/types/json-render/charts/extra-components.d.ts +58 -0
- package/dist/types/json-render/charts/extra-definitions.d.ts +181 -0
- package/dist/types/json-render/renderer/index.d.ts +16 -0
- package/dist/types/json-render/schema.d.ts +46 -0
- package/dist/types/json-render/server.d.ts +55 -0
- package/dist/types/mcp/server.d.ts +22 -0
- package/dist/types/server/agent-context.d.ts +21 -0
- package/dist/types/server/artifact-paths.d.ts +3 -0
- package/dist/types/server/canvas-operations.d.ts +154 -0
- package/dist/types/server/canvas-provenance.d.ts +13 -0
- package/dist/types/server/canvas-schema.d.ts +49 -0
- package/dist/types/server/canvas-serialization.d.ts +25 -0
- package/dist/types/server/canvas-state.d.ts +174 -0
- package/dist/types/server/canvas-validation.d.ts +33 -0
- package/dist/types/server/chart-template.d.ts +29 -0
- package/dist/types/server/code-graph.d.ts +67 -0
- package/dist/types/server/context-cards.d.ts +24 -0
- package/dist/types/server/diagram-presets.d.ts +28 -0
- package/dist/types/server/ext-app-call-registry.d.ts +16 -0
- package/dist/types/server/ext-app-tool-result.d.ts +1 -0
- package/dist/types/server/file-watcher.d.ts +16 -0
- package/dist/types/server/index.d.ts +243 -0
- package/dist/types/server/mcp-app-candidate.d.ts +25 -0
- package/dist/types/server/mcp-app-host.d.ts +65 -0
- package/dist/types/server/mcp-app-runtime.d.ts +47 -0
- package/dist/types/server/mutation-history.d.ts +105 -0
- package/dist/types/server/placement.d.ts +37 -0
- package/dist/types/server/server.d.ts +103 -0
- package/dist/types/server/spatial-analysis.d.ts +87 -0
- package/dist/types/server/trace-manager.d.ts +48 -0
- package/dist/types/server/web-artifacts.d.ts +50 -0
- package/dist/types/server/webpage-node.d.ts +25 -0
- package/dist/types/shared/auto-arrange.d.ts +29 -0
- package/dist/types/shared/ext-app-tool-result.d.ts +9 -0
- package/dist/types/shared/placement.d.ts +26 -0
- package/dist/types/shared/semantic-attention.d.ts +97 -0
- package/package.json +109 -0
- package/skills/data-analysis/SKILL.md +324 -0
- package/skills/doc-coauthoring/SKILL.md +375 -0
- package/skills/frontend-design/SKILL.md +45 -0
- package/skills/json-render-codegen/SKILL.md +112 -0
- package/skills/json-render-core/SKILL.md +265 -0
- package/skills/json-render-ink/SKILL.md +273 -0
- package/skills/json-render-mcp/SKILL.md +132 -0
- package/skills/json-render-react/SKILL.md +264 -0
- package/skills/json-render-shadcn/SKILL.md +159 -0
- package/skills/playwright-cli/SKILL.md +67 -0
- package/skills/pmx-canvas/SKILL.md +668 -0
- package/skills/pmx-canvas/evals/evals.json +186 -0
- package/skills/pmx-canvas-testing/SKILL.md +78 -0
- package/skills/published-consumer-e2e/SKILL.md +43 -0
- package/skills/published-consumer-e2e/scripts/run-published-consumer-e2e.sh +241 -0
- package/skills/web-artifacts-builder/SKILL.md +80 -0
- package/skills/web-artifacts-builder/scripts/bundle-artifact.sh +167 -0
- package/skills/web-artifacts-builder/scripts/init-artifact.sh +425 -0
- package/skills/web-artifacts-builder/scripts/shadcn-components.tar.gz +0 -0
- package/skills/web-design-guidelines/SKILL.md +39 -0
- package/src/cli/agent.ts +2144 -0
- package/src/cli/index.ts +622 -0
- package/src/cli/watch.ts +88 -0
- package/src/client/App.tsx +507 -0
- package/src/client/canvas/AttentionHistory.tsx +81 -0
- package/src/client/canvas/AttentionToast.tsx +19 -0
- package/src/client/canvas/CanvasNode.tsx +363 -0
- package/src/client/canvas/CanvasViewport.tsx +590 -0
- package/src/client/canvas/CommandPalette.tsx +302 -0
- package/src/client/canvas/ContextMenu.tsx +601 -0
- package/src/client/canvas/ContextPinBar.tsx +25 -0
- package/src/client/canvas/ContextPinHud.tsx +22 -0
- package/src/client/canvas/DockedNode.tsx +66 -0
- package/src/client/canvas/EdgeLayer.tsx +280 -0
- package/src/client/canvas/ExpandedNodeOverlay.tsx +260 -0
- package/src/client/canvas/FocusFieldLayer.tsx +107 -0
- package/src/client/canvas/Minimap.tsx +301 -0
- package/src/client/canvas/SelectionBar.tsx +69 -0
- package/src/client/canvas/ShortcutOverlay.tsx +69 -0
- package/src/client/canvas/SnapshotPanel.tsx +236 -0
- package/src/client/canvas/snap-guides.ts +170 -0
- package/src/client/canvas/use-node-drag.ts +51 -0
- package/src/client/canvas/use-node-resize.ts +59 -0
- package/src/client/canvas/use-pan-zoom.ts +191 -0
- package/src/client/ext-app/bridge.ts +542 -0
- package/src/client/icons.tsx +424 -0
- package/src/client/index.tsx +7 -0
- package/src/client/nodes/ContextNode.tsx +412 -0
- package/src/client/nodes/ExtAppFrame.tsx +509 -0
- package/src/client/nodes/FileNode.tsx +256 -0
- package/src/client/nodes/GroupNode.tsx +39 -0
- package/src/client/nodes/ImageNode.tsx +160 -0
- package/src/client/nodes/InlineFormatBar.tsx +169 -0
- package/src/client/nodes/InlineMarkdownEditor.tsx +123 -0
- package/src/client/nodes/LedgerNode.tsx +37 -0
- package/src/client/nodes/MarkdownNode.tsx +359 -0
- package/src/client/nodes/McpAppNode.tsx +85 -0
- package/src/client/nodes/MdFormatBar.tsx +109 -0
- package/src/client/nodes/PromptNode.tsx +597 -0
- package/src/client/nodes/ResponseNode.tsx +153 -0
- package/src/client/nodes/StatusNode.tsx +84 -0
- package/src/client/nodes/StatusSummary.tsx +38 -0
- package/src/client/nodes/TraceNode.tsx +120 -0
- package/src/client/nodes/WebpageNode.tsx +288 -0
- package/src/client/nodes/image-warnings.ts +95 -0
- package/src/client/nodes/inline-editor-commands.ts +37 -0
- package/src/client/nodes/md-format.ts +206 -0
- package/src/client/state/attention-bridge.ts +328 -0
- package/src/client/state/attention-store.ts +73 -0
- package/src/client/state/canvas-store.ts +631 -0
- package/src/client/state/intent-bridge.ts +315 -0
- package/src/client/state/sse-bridge.ts +965 -0
- package/src/client/theme/global.css +3173 -0
- package/src/client/theme/tokens.ts +72 -0
- package/src/client/types-shims.d.ts +5 -0
- package/src/client/types.ts +81 -0
- package/src/client/utils/ext-app-tool-result.ts +4 -0
- package/src/client/utils/placement.ts +4 -0
- package/src/client/utils/platform.ts +2 -0
- package/src/json-render/catalog.ts +256 -0
- package/src/json-render/charts/components.tsx +198 -0
- package/src/json-render/charts/definitions.ts +81 -0
- package/src/json-render/charts/extra-components.tsx +267 -0
- package/src/json-render/charts/extra-definitions.ts +145 -0
- package/src/json-render/renderer/index.css +174 -0
- package/src/json-render/renderer/index.tsx +86 -0
- package/src/json-render/schema.ts +62 -0
- package/src/json-render/server.ts +597 -0
- package/src/mcp/server.ts +1377 -0
- package/src/server/agent-context.ts +242 -0
- package/src/server/artifact-paths.ts +17 -0
- package/src/server/canvas-operations.ts +1279 -0
- package/src/server/canvas-provenance.ts +243 -0
- package/src/server/canvas-schema.ts +432 -0
- package/src/server/canvas-serialization.ts +95 -0
- package/src/server/canvas-state.ts +1134 -0
- package/src/server/canvas-validation.ts +114 -0
- package/src/server/chart-template.ts +449 -0
- package/src/server/code-graph.ts +370 -0
- package/src/server/context-cards.ts +31 -0
- package/src/server/diagram-presets.ts +71 -0
- package/src/server/ext-app-call-registry.ts +77 -0
- package/src/server/ext-app-tool-result.ts +4 -0
- package/src/server/file-watcher.ts +121 -0
- package/src/server/index.ts +647 -0
- package/src/server/mcp-app-candidate.ts +174 -0
- package/src/server/mcp-app-host.ts +814 -0
- package/src/server/mcp-app-runtime.ts +459 -0
- package/src/server/mutation-history.ts +350 -0
- package/src/server/placement.ts +125 -0
- package/src/server/server.ts +3846 -0
- package/src/server/spatial-analysis.ts +356 -0
- package/src/server/trace-manager.ts +333 -0
- package/src/server/web-artifacts/scripts/bundle-artifact.sh +167 -0
- package/src/server/web-artifacts/scripts/init-artifact.sh +426 -0
- package/src/server/web-artifacts/scripts/shadcn-components.tar.gz +0 -0
- package/src/server/web-artifacts.ts +442 -0
- package/src/server/webpage-node.ts +328 -0
- package/src/shared/auto-arrange.ts +439 -0
- package/src/shared/ext-app-tool-result.ts +76 -0
- package/src/shared/placement.ts +81 -0
- 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
|
+
}
|