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,170 @@
1
+ import { signal } from '@preact/signals';
2
+ import type { CanvasNodeState } from '../types';
3
+
4
+ const SNAP_PX = 8;
5
+
6
+ export interface GuideLine {
7
+ axis: 'x' | 'y';
8
+ pos: number;
9
+ from: number;
10
+ to: number;
11
+ }
12
+
13
+ export interface SnapResult {
14
+ x: number;
15
+ y: number;
16
+ guides: GuideLine[];
17
+ }
18
+
19
+ /** Active guide lines to render. Null when not dragging. */
20
+ export const activeGuides = signal<GuideLine[] | null>(null);
21
+
22
+ interface RefEdgeX { val: number; minY: number; maxY: number }
23
+ interface RefEdgeY { val: number; minX: number; maxX: number }
24
+
25
+ /** Cached reference edges — built once at drag-start, reused on every pointermove. */
26
+ let cachedRefX: RefEdgeX[] = [];
27
+ let cachedRefY: RefEdgeY[] = [];
28
+ let cachedDragId: string | null = null;
29
+
30
+ function getParentGroupId(node: CanvasNodeState | undefined): string | null {
31
+ const parentGroup = node?.data.parentGroup;
32
+ return typeof parentGroup === 'string' && parentGroup.length > 0 ? parentGroup : null;
33
+ }
34
+
35
+ function getChildIds(node: CanvasNodeState | undefined): string[] {
36
+ if (!node || !Array.isArray(node.data.children)) return [];
37
+ return node.data.children.filter((childId): childId is string => typeof childId === 'string');
38
+ }
39
+
40
+ function collectExcludedReferenceIds(
41
+ dragId: string,
42
+ allNodes: CanvasNodeState[],
43
+ ): Set<string> {
44
+ const nodeMap = new Map(allNodes.map((node) => [node.id, node]));
45
+ const excluded = new Set<string>([dragId]);
46
+ const dragNode = nodeMap.get(dragId);
47
+ if (!dragNode) return excluded;
48
+
49
+ let parentGroupId = getParentGroupId(dragNode);
50
+ while (parentGroupId && !excluded.has(parentGroupId)) {
51
+ excluded.add(parentGroupId);
52
+ parentGroupId = getParentGroupId(nodeMap.get(parentGroupId));
53
+ }
54
+
55
+ if (dragNode.type !== 'group') return excluded;
56
+
57
+ const pendingChildIds = [...getChildIds(dragNode)];
58
+ while (pendingChildIds.length > 0) {
59
+ const childId = pendingChildIds.pop();
60
+ if (!childId || excluded.has(childId)) continue;
61
+ excluded.add(childId);
62
+ const child = nodeMap.get(childId);
63
+ if (child?.type === 'group') {
64
+ pendingChildIds.push(...getChildIds(child));
65
+ }
66
+ }
67
+
68
+ return excluded;
69
+ }
70
+
71
+ /** Call at drag-start to pre-compute reference edges from stationary nodes. */
72
+ export function buildSnapCache(dragId: string, allNodes: Iterable<CanvasNodeState>): void {
73
+ cachedRefX = [];
74
+ cachedRefY = [];
75
+ cachedDragId = dragId;
76
+ const nodeList = Array.from(allNodes);
77
+ const excludedIds = collectExcludedReferenceIds(dragId, nodeList);
78
+ for (const n of nodeList) {
79
+ if (excludedIds.has(n.id) || n.dockPosition !== null) continue;
80
+ const l = n.position.x;
81
+ const r = n.position.x + n.size.width;
82
+ const cx = n.position.x + n.size.width / 2;
83
+ const t = n.position.y;
84
+ const b = n.position.y + n.size.height;
85
+ const cy = n.position.y + n.size.height / 2;
86
+
87
+ cachedRefX.push({ val: l, minY: t, maxY: b });
88
+ cachedRefX.push({ val: r, minY: t, maxY: b });
89
+ cachedRefX.push({ val: cx, minY: t, maxY: b });
90
+
91
+ cachedRefY.push({ val: t, minX: l, maxX: r });
92
+ cachedRefY.push({ val: b, minX: l, maxX: r });
93
+ cachedRefY.push({ val: cy, minX: l, maxX: r });
94
+ }
95
+ }
96
+
97
+ /** Call at drag-end to clear the cache. */
98
+ export function clearSnapCache(): void {
99
+ cachedRefX = [];
100
+ cachedRefY = [];
101
+ cachedDragId = null;
102
+ }
103
+
104
+ /**
105
+ * Snap a dragging node's proposed position to cached reference edges.
106
+ * Must call buildSnapCache() before the first call in a drag session.
107
+ */
108
+ export function snapToGuides(
109
+ proposedX: number,
110
+ proposedY: number,
111
+ nodeW: number,
112
+ nodeH: number,
113
+ ): SnapResult {
114
+ const dragEdgesX = [proposedX, proposedX + nodeW / 2, proposedX + nodeW];
115
+ const dragEdgesY = [proposedY, proposedY + nodeH / 2, proposedY + nodeH];
116
+ const offX = [0, nodeW / 2, nodeW];
117
+ const offY = [0, nodeH / 2, nodeH];
118
+
119
+ let snapX: number | null = null;
120
+ let bestDx = SNAP_PX + 1;
121
+ let snapXGuide: GuideLine | null = null;
122
+
123
+ for (let i = 0; i < 3; i++) {
124
+ const dv = dragEdgesX[i];
125
+ for (const ref of cachedRefX) {
126
+ const d = Math.abs(dv - ref.val);
127
+ if (d < bestDx) {
128
+ bestDx = d;
129
+ snapX = ref.val - offX[i];
130
+ snapXGuide = {
131
+ axis: 'x',
132
+ pos: ref.val,
133
+ from: Math.min(ref.minY, proposedY),
134
+ to: Math.max(ref.maxY, proposedY + nodeH),
135
+ };
136
+ }
137
+ }
138
+ }
139
+
140
+ let snapY: number | null = null;
141
+ let bestDy = SNAP_PX + 1;
142
+ let snapYGuide: GuideLine | null = null;
143
+
144
+ for (let i = 0; i < 3; i++) {
145
+ const dv = dragEdgesY[i];
146
+ for (const ref of cachedRefY) {
147
+ const d = Math.abs(dv - ref.val);
148
+ if (d < bestDy) {
149
+ bestDy = d;
150
+ snapY = ref.val - offY[i];
151
+ snapYGuide = {
152
+ axis: 'y',
153
+ pos: ref.val,
154
+ from: Math.min(ref.minX, proposedX),
155
+ to: Math.max(ref.maxX, proposedX + nodeW),
156
+ };
157
+ }
158
+ }
159
+ }
160
+
161
+ const guides: GuideLine[] = [];
162
+ if (snapXGuide && bestDx <= SNAP_PX) guides.push(snapXGuide);
163
+ if (snapYGuide && bestDy <= SNAP_PX) guides.push(snapYGuide);
164
+
165
+ return {
166
+ x: snapX !== null && bestDx <= SNAP_PX ? snapX : proposedX,
167
+ y: snapY !== null && bestDy <= SNAP_PX ? snapY : proposedY,
168
+ guides,
169
+ };
170
+ }
@@ -0,0 +1,51 @@
1
+ import type { Signal } from '@preact/signals';
2
+ import { useCallback, useRef } from 'preact/hooks';
3
+ import type { ViewportState } from '../types';
4
+
5
+ interface NodeDragOptions {
6
+ nodeId: string;
7
+ viewport: Signal<ViewportState>;
8
+ onMove: (id: string, x: number, y: number) => void;
9
+ onDragEnd: () => void;
10
+ }
11
+
12
+ /**
13
+ * Hook for dragging canvas nodes by their title bar.
14
+ * Converts screen-space pointer delta to canvas-space position delta
15
+ * (accounting for current viewport scale).
16
+ */
17
+ export function useNodeDrag({ nodeId, viewport, onMove, onDragEnd }: NodeDragOptions) {
18
+ const isDragging = useRef(false);
19
+ const startPointer = useRef({ x: 0, y: 0 });
20
+ const startPosition = useRef({ x: 0, y: 0 });
21
+
22
+ const handlePointerDown = useCallback(
23
+ (e: PointerEvent, currentX: number, currentY: number) => {
24
+ e.stopPropagation();
25
+ isDragging.current = true;
26
+ startPointer.current = { x: e.clientX, y: e.clientY };
27
+ startPosition.current = { x: currentX, y: currentY };
28
+
29
+ const onPointerMove = (ev: PointerEvent) => {
30
+ if (!isDragging.current) return;
31
+ const scale = viewport.value.scale;
32
+ const dx = (ev.clientX - startPointer.current.x) / scale;
33
+ const dy = (ev.clientY - startPointer.current.y) / scale;
34
+ onMove(nodeId, startPosition.current.x + dx, startPosition.current.y + dy);
35
+ };
36
+
37
+ const onPointerUp = () => {
38
+ isDragging.current = false;
39
+ document.removeEventListener('pointermove', onPointerMove);
40
+ document.removeEventListener('pointerup', onPointerUp);
41
+ onDragEnd();
42
+ };
43
+
44
+ document.addEventListener('pointermove', onPointerMove);
45
+ document.addEventListener('pointerup', onPointerUp);
46
+ },
47
+ [nodeId, viewport, onMove, onDragEnd],
48
+ );
49
+
50
+ return handlePointerDown;
51
+ }
@@ -0,0 +1,59 @@
1
+ import type { Signal } from '@preact/signals';
2
+ import { useCallback, useRef } from 'preact/hooks';
3
+ import type { ViewportState } from '../types';
4
+
5
+ const MIN_WIDTH = 200;
6
+ const MIN_HEIGHT = 100;
7
+
8
+ interface NodeResizeOptions {
9
+ nodeId: string;
10
+ viewport: Signal<ViewportState>;
11
+ onResize: (id: string, width: number, height: number) => void;
12
+ onResizeEnd: () => void;
13
+ }
14
+
15
+ /**
16
+ * Hook for resizing canvas nodes via a corner drag handle.
17
+ * Converts screen-space pointer delta to canvas-space size delta
18
+ * (accounting for current viewport scale).
19
+ */
20
+ export function useNodeResize({ nodeId, viewport, onResize, onResizeEnd }: NodeResizeOptions) {
21
+ const isResizing = useRef(false);
22
+ const startPointer = useRef({ x: 0, y: 0 });
23
+ const startSize = useRef({ w: 0, h: 0 });
24
+
25
+ const handlePointerDown = useCallback(
26
+ (e: PointerEvent, currentWidth: number, currentHeight: number) => {
27
+ e.stopPropagation();
28
+ e.preventDefault();
29
+ isResizing.current = true;
30
+ startPointer.current = { x: e.clientX, y: e.clientY };
31
+ startSize.current = { w: currentWidth, h: currentHeight };
32
+
33
+ const onPointerMove = (ev: PointerEvent) => {
34
+ if (!isResizing.current) return;
35
+ const scale = viewport.value.scale;
36
+ const dw = (ev.clientX - startPointer.current.x) / scale;
37
+ const dh = (ev.clientY - startPointer.current.y) / scale;
38
+ onResize(
39
+ nodeId,
40
+ Math.max(MIN_WIDTH, startSize.current.w + dw),
41
+ Math.max(MIN_HEIGHT, startSize.current.h + dh),
42
+ );
43
+ };
44
+
45
+ const onPointerUp = () => {
46
+ isResizing.current = false;
47
+ document.removeEventListener('pointermove', onPointerMove);
48
+ document.removeEventListener('pointerup', onPointerUp);
49
+ onResizeEnd();
50
+ };
51
+
52
+ document.addEventListener('pointermove', onPointerMove);
53
+ document.addEventListener('pointerup', onPointerUp);
54
+ },
55
+ [nodeId, viewport, onResize, onResizeEnd],
56
+ );
57
+
58
+ return handlePointerDown;
59
+ }
@@ -0,0 +1,191 @@
1
+ import type { Signal } from '@preact/signals';
2
+ import { useCallback, useEffect, useRef } from 'preact/hooks';
3
+ import type { ViewportState } from '../types';
4
+
5
+ const MIN_SCALE = 0.1;
6
+ const MAX_SCALE = 4;
7
+ const PAN_SPEED = 1;
8
+
9
+ function clampScale(scale: number): number {
10
+ return Math.min(MAX_SCALE, Math.max(MIN_SCALE, scale));
11
+ }
12
+
13
+ interface PanZoomOptions {
14
+ viewport: Signal<ViewportState>;
15
+ onViewportChange: (v: ViewportState) => void;
16
+ onViewportCommit: (v: ViewportState) => void;
17
+ }
18
+
19
+ /**
20
+ * Hook that wires up pan/zoom interactions on a container element.
21
+ * - Wheel + Ctrl/Cmd: zoom centered on pointer
22
+ * - Wheel without modifier: pan
23
+ * - Pointer drag on background: pan
24
+ * - Pinch (touch): zoom
25
+ */
26
+ export function usePanZoom({ viewport, onViewportChange, onViewportCommit }: PanZoomOptions) {
27
+ const containerRef = useRef<HTMLDivElement>(null);
28
+ const isPanning = useRef(false);
29
+ const lastPointer = useRef({ x: 0, y: 0 });
30
+ const lastPinchDist = useRef(0);
31
+ const wheelCommitTimer = useRef<number | null>(null);
32
+
33
+ const scheduleViewportCommit = useCallback((next: ViewportState) => {
34
+ if (wheelCommitTimer.current !== null) {
35
+ window.clearTimeout(wheelCommitTimer.current);
36
+ }
37
+ wheelCommitTimer.current = window.setTimeout(() => {
38
+ wheelCommitTimer.current = null;
39
+ onViewportCommit(next);
40
+ }, 140);
41
+ }, [onViewportCommit]);
42
+
43
+ const handleWheel = useCallback(
44
+ (e: WheelEvent) => {
45
+ e.preventDefault();
46
+ const v = viewport.value;
47
+
48
+ if (e.ctrlKey || e.metaKey) {
49
+ // Zoom centered on pointer
50
+ const rect = containerRef.current?.getBoundingClientRect();
51
+ if (!rect) return;
52
+ const px = e.clientX - rect.left;
53
+ const py = e.clientY - rect.top;
54
+
55
+ const delta = -e.deltaY * 0.002;
56
+ const newScale = clampScale(v.scale * (1 + delta));
57
+ const ratio = newScale / v.scale;
58
+
59
+ const next = {
60
+ x: px - ratio * (px - v.x),
61
+ y: py - ratio * (py - v.y),
62
+ scale: newScale,
63
+ };
64
+ onViewportChange(next);
65
+ scheduleViewportCommit(next);
66
+ } else {
67
+ // Pan
68
+ const next = {
69
+ x: v.x - e.deltaX * PAN_SPEED,
70
+ y: v.y - e.deltaY * PAN_SPEED,
71
+ scale: v.scale,
72
+ };
73
+ onViewportChange(next);
74
+ scheduleViewportCommit(next);
75
+ }
76
+ },
77
+ [viewport, onViewportChange, scheduleViewportCommit],
78
+ );
79
+
80
+ const handlePointerDown = useCallback((e: PointerEvent) => {
81
+ // Only pan when clicking the canvas background (not nodes)
82
+ const container = containerRef.current;
83
+ if (!container || e.target !== container) return;
84
+ isPanning.current = true;
85
+ lastPointer.current = { x: e.clientX, y: e.clientY };
86
+ container.setPointerCapture(e.pointerId);
87
+ }, []);
88
+
89
+ const handlePointerMove = useCallback(
90
+ (e: PointerEvent) => {
91
+ if (!isPanning.current) return;
92
+ const dx = e.clientX - lastPointer.current.x;
93
+ const dy = e.clientY - lastPointer.current.y;
94
+ lastPointer.current = { x: e.clientX, y: e.clientY };
95
+
96
+ const v = viewport.value;
97
+ onViewportChange({ x: v.x + dx, y: v.y + dy, scale: v.scale });
98
+ },
99
+ [viewport, onViewportChange],
100
+ );
101
+
102
+ const handlePointerUp = useCallback(() => {
103
+ if (isPanning.current) {
104
+ onViewportCommit(viewport.value);
105
+ }
106
+ isPanning.current = false;
107
+ }, [onViewportCommit, viewport]);
108
+
109
+ // Touch pinch
110
+ const handleTouchMove = useCallback(
111
+ (e: TouchEvent) => {
112
+ if (e.touches.length !== 2) {
113
+ lastPinchDist.current = 0;
114
+ return;
115
+ }
116
+ e.preventDefault();
117
+
118
+ const t1 = e.touches[0];
119
+ const t2 = e.touches[1];
120
+ const dist = Math.hypot(t2.clientX - t1.clientX, t2.clientY - t1.clientY);
121
+ const cx = (t1.clientX + t2.clientX) / 2;
122
+ const cy = (t1.clientY + t2.clientY) / 2;
123
+
124
+ if (lastPinchDist.current > 0) {
125
+ const v = viewport.value;
126
+ const rect = containerRef.current?.getBoundingClientRect();
127
+ if (!rect) return;
128
+ const px = cx - rect.left;
129
+ const py = cy - rect.top;
130
+
131
+ const ratio = dist / lastPinchDist.current;
132
+ const newScale = clampScale(v.scale * ratio);
133
+ const scaleRatio = newScale / v.scale;
134
+
135
+ const next = {
136
+ x: px - scaleRatio * (px - v.x),
137
+ y: py - scaleRatio * (py - v.y),
138
+ scale: newScale,
139
+ };
140
+ onViewportChange(next);
141
+ scheduleViewportCommit(next);
142
+ }
143
+ lastPinchDist.current = dist;
144
+ },
145
+ [viewport, onViewportChange, scheduleViewportCommit],
146
+ );
147
+
148
+ const handleTouchEnd = useCallback(() => {
149
+ if (wheelCommitTimer.current !== null) {
150
+ window.clearTimeout(wheelCommitTimer.current);
151
+ wheelCommitTimer.current = null;
152
+ }
153
+ onViewportCommit(viewport.value);
154
+ lastPinchDist.current = 0;
155
+ }, [onViewportCommit, viewport]);
156
+
157
+ useEffect(() => {
158
+ const el = containerRef.current;
159
+ if (!el) return;
160
+
161
+ el.addEventListener('wheel', handleWheel, { passive: false });
162
+ el.addEventListener('pointerdown', handlePointerDown);
163
+ el.addEventListener('pointermove', handlePointerMove);
164
+ el.addEventListener('pointerup', handlePointerUp);
165
+ el.addEventListener('pointercancel', handlePointerUp);
166
+ el.addEventListener('touchmove', handleTouchMove, { passive: false });
167
+ el.addEventListener('touchend', handleTouchEnd);
168
+
169
+ return () => {
170
+ if (wheelCommitTimer.current !== null) {
171
+ window.clearTimeout(wheelCommitTimer.current);
172
+ }
173
+ el.removeEventListener('wheel', handleWheel);
174
+ el.removeEventListener('pointerdown', handlePointerDown);
175
+ el.removeEventListener('pointermove', handlePointerMove);
176
+ el.removeEventListener('pointerup', handlePointerUp);
177
+ el.removeEventListener('pointercancel', handlePointerUp);
178
+ el.removeEventListener('touchmove', handleTouchMove);
179
+ el.removeEventListener('touchend', handleTouchEnd);
180
+ };
181
+ }, [
182
+ handleWheel,
183
+ handlePointerDown,
184
+ handlePointerMove,
185
+ handlePointerUp,
186
+ handleTouchMove,
187
+ handleTouchEnd,
188
+ ]);
189
+
190
+ return containerRef;
191
+ }