pmx-canvas 0.1.2 → 0.1.3
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 +62 -0
- package/Readme.md +35 -8
- package/dist/canvas/index.js +69 -69
- package/dist/types/client/nodes/ExtAppFrame.d.ts +12 -0
- package/dist/types/client/state/canvas-store.d.ts +2 -1
- package/dist/types/client/types.d.ts +3 -0
- package/dist/types/server/diagram-presets.d.ts +13 -0
- package/dist/types/server/index.d.ts +6 -1
- package/dist/types/server/web-artifacts.d.ts +1 -0
- package/package.json +2 -1
- package/skills/pmx-canvas/SKILL.md +26 -5
- package/skills/pmx-canvas/references/installing-pmx-canvas.md +66 -0
- package/skills/web-artifacts-builder/scripts/bundle-artifact.sh +10 -0
- package/skills/web-artifacts-builder/scripts/init-artifact.sh +1 -1
- package/src/cli/agent.ts +78 -7
- package/src/cli/index.ts +3 -1
- package/src/client/App.tsx +2 -1
- package/src/client/canvas/CanvasNode.tsx +3 -2
- package/src/client/canvas/ExpandedNodeOverlay.tsx +6 -1
- package/src/client/nodes/ExtAppFrame.tsx +97 -26
- package/src/client/state/canvas-store.ts +63 -1
- package/src/client/state/sse-bridge.ts +5 -0
- package/src/client/types.ts +12 -0
- package/src/mcp/server.ts +28 -5
- package/src/server/canvas-operations.ts +35 -5
- package/src/server/canvas-schema.ts +2 -1
- package/src/server/diagram-presets.ts +219 -4
- package/src/server/index.ts +22 -10
- package/src/server/server.ts +152 -32
- package/src/server/web-artifacts/scripts/bundle-artifact.sh +10 -0
- package/src/server/web-artifacts/scripts/init-artifact.sh +1 -1
- package/src/server/web-artifacts.ts +43 -1
|
@@ -20,6 +20,11 @@ type IframeLoadTarget = Pick<
|
|
|
20
20
|
|
|
21
21
|
type ExtAppBridgeNotifications = Pick<AppBridge, 'sendToolInput' | 'sendToolResult'>;
|
|
22
22
|
type DisplayMode = 'inline' | 'fullscreen' | 'pip';
|
|
23
|
+
const DEFAULT_EXT_APP_SANDBOX = 'allow-scripts allow-popups allow-popups-to-escape-sandbox';
|
|
24
|
+
|
|
25
|
+
interface ExtAppHostDimensionsTarget {
|
|
26
|
+
getBoundingClientRect(): Pick<DOMRectReadOnly, 'width' | 'height'>;
|
|
27
|
+
}
|
|
23
28
|
|
|
24
29
|
async function postJson<T>(url: string, body: Record<string, unknown>): Promise<T> {
|
|
25
30
|
const response = await fetch(url, {
|
|
@@ -97,6 +102,33 @@ export async function sendExtAppBootstrapState(
|
|
|
97
102
|
}
|
|
98
103
|
}
|
|
99
104
|
|
|
105
|
+
export function resolveExtAppSandbox(value: unknown): string {
|
|
106
|
+
return typeof value === 'string' && value.trim().length > 0
|
|
107
|
+
? value.trim()
|
|
108
|
+
: DEFAULT_EXT_APP_SANDBOX;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function positiveDimension(value: number, fallback: number): number {
|
|
112
|
+
if (Number.isFinite(value) && value > 0) return Math.round(value);
|
|
113
|
+
if (Number.isFinite(fallback) && fallback > 0) return Math.round(fallback);
|
|
114
|
+
return 1;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function resolveExtAppContainerDimensions(
|
|
118
|
+
target: ExtAppHostDimensionsTarget | null | undefined,
|
|
119
|
+
fallback: { width: number; height: number },
|
|
120
|
+
): { width: number; height: number } {
|
|
121
|
+
const rect = target?.getBoundingClientRect();
|
|
122
|
+
return {
|
|
123
|
+
width: positiveDimension(rect?.width ?? 0, fallback.width),
|
|
124
|
+
height: positiveDimension(rect?.height ?? 0, fallback.height),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function shouldApplyExtAppSizeChange(height: unknown, isExpanded: boolean): height is number {
|
|
129
|
+
return typeof height === 'number' && Number.isFinite(height) && height > 0 && !isExpanded;
|
|
130
|
+
}
|
|
131
|
+
|
|
100
132
|
export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
|
|
101
133
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
102
134
|
const bridgeRef = useRef<AppBridge | null>(null);
|
|
@@ -122,7 +154,10 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
|
|
|
122
154
|
const rawToolCallId = node.data.toolCallId;
|
|
123
155
|
const toolCallId: RequestId | undefined =
|
|
124
156
|
typeof rawToolCallId === 'string' || typeof rawToolCallId === 'number' ? rawToolCallId : undefined;
|
|
125
|
-
const resourceMeta = node.data.resourceMeta as {
|
|
157
|
+
const resourceMeta = node.data.resourceMeta as {
|
|
158
|
+
csp?: Record<string, unknown>;
|
|
159
|
+
permissions?: Record<string, unknown>;
|
|
160
|
+
} | undefined;
|
|
126
161
|
const sessionStatus = node.data.sessionStatus as string | undefined;
|
|
127
162
|
const sessionError = node.data.sessionError as string | undefined;
|
|
128
163
|
const maxHeight = node.size.height;
|
|
@@ -191,6 +226,8 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
|
|
|
191
226
|
if (!iframe) return;
|
|
192
227
|
let disposed = false;
|
|
193
228
|
let fallbackTimer: ReturnType<typeof setTimeout> | null = null;
|
|
229
|
+
let hostContextResizeObserver: ResizeObserver | null = null;
|
|
230
|
+
let hostContextRaf: number | null = null;
|
|
194
231
|
toolResultSentRef.current = false;
|
|
195
232
|
lastSentToolResultRef.current = undefined;
|
|
196
233
|
toolResultSendingRef.current = null;
|
|
@@ -213,6 +250,33 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
|
|
|
213
250
|
throw new Error('Ext-app iframe window is unavailable');
|
|
214
251
|
}
|
|
215
252
|
|
|
253
|
+
const buildHostContext = (displayMode: DisplayMode = expandedNodeId.value === nodeId ? 'fullscreen' : 'inline') => ({
|
|
254
|
+
theme: toMcpTheme(canvasTheme.value),
|
|
255
|
+
platform: 'web' as const,
|
|
256
|
+
containerDimensions: resolveExtAppContainerDimensions(iframe, {
|
|
257
|
+
width: node.size.width,
|
|
258
|
+
height: maxHeight,
|
|
259
|
+
}),
|
|
260
|
+
displayMode,
|
|
261
|
+
locale: navigator.language,
|
|
262
|
+
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
263
|
+
...(toolDefinition ? {
|
|
264
|
+
toolInfo: {
|
|
265
|
+
id: toolCallId,
|
|
266
|
+
tool: toolDefinition,
|
|
267
|
+
},
|
|
268
|
+
} : {}),
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const scheduleHostContextUpdate = () => {
|
|
272
|
+
if (hostContextRaf !== null) return;
|
|
273
|
+
hostContextRaf = requestAnimationFrame(() => {
|
|
274
|
+
hostContextRaf = null;
|
|
275
|
+
if (disposed || !bridgeReadyRef.current) return;
|
|
276
|
+
bridge.setHostContext?.(buildHostContext());
|
|
277
|
+
});
|
|
278
|
+
};
|
|
279
|
+
|
|
216
280
|
const bridge = new AppBridge(
|
|
217
281
|
null,
|
|
218
282
|
{ name: 'PMX Canvas', version: '1.0.0' },
|
|
@@ -224,26 +288,15 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
|
|
|
224
288
|
updateModelContext: { text: {}, structuredContent: {} },
|
|
225
289
|
},
|
|
226
290
|
{
|
|
227
|
-
hostContext:
|
|
228
|
-
theme: toMcpTheme(canvasTheme.value),
|
|
229
|
-
platform: 'web',
|
|
230
|
-
containerDimensions: { maxHeight },
|
|
231
|
-
displayMode: isExpanded ? 'fullscreen' : 'inline',
|
|
232
|
-
locale: navigator.language,
|
|
233
|
-
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
234
|
-
...(toolDefinition ? {
|
|
235
|
-
toolInfo: {
|
|
236
|
-
id: toolCallId,
|
|
237
|
-
tool: toolDefinition,
|
|
238
|
-
},
|
|
239
|
-
} : {}),
|
|
240
|
-
},
|
|
291
|
+
hostContext: buildHostContext(isExpanded ? 'fullscreen' : 'inline'),
|
|
241
292
|
},
|
|
242
293
|
);
|
|
243
294
|
|
|
244
295
|
// Register handlers BEFORE connect
|
|
245
296
|
bridge.onsizechange = async ({ height }) => {
|
|
246
|
-
if (height
|
|
297
|
+
if (shouldApplyExtAppSizeChange(height, expandedNodeId.value === nodeId)) {
|
|
298
|
+
iframe.style.height = `${height}px`;
|
|
299
|
+
}
|
|
247
300
|
return {};
|
|
248
301
|
};
|
|
249
302
|
|
|
@@ -252,6 +305,15 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
|
|
|
252
305
|
return {};
|
|
253
306
|
};
|
|
254
307
|
|
|
308
|
+
bridge.onsandboxready = async () => {
|
|
309
|
+
await bridge.sendSandboxResourceReady({
|
|
310
|
+
html,
|
|
311
|
+
sandbox: DEFAULT_EXT_APP_SANDBOX,
|
|
312
|
+
...(resourceMeta?.csp ? { csp: resourceMeta.csp } : {}),
|
|
313
|
+
...(resourceMeta?.permissions ? { permissions: resourceMeta.permissions } : {}),
|
|
314
|
+
});
|
|
315
|
+
};
|
|
316
|
+
|
|
255
317
|
// Handle native fullscreen requests from the widget (e.g. Excalidraw expand button)
|
|
256
318
|
bridge.onrequestdisplaymode = async ({ mode }) => {
|
|
257
319
|
const { nextMode, shouldExpand, shouldCollapse } = resolveExtAppDisplayModeRequest(mode, isExpanded);
|
|
@@ -333,6 +395,7 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
|
|
|
333
395
|
bridgeReadyRef.current = true;
|
|
334
396
|
setStatus('ready');
|
|
335
397
|
setError(null);
|
|
398
|
+
scheduleHostContextUpdate();
|
|
336
399
|
void sendExtAppBootstrapState(bridge, latestToolInputRef.current, undefined)
|
|
337
400
|
.then(() => flushToolResult(bridge))
|
|
338
401
|
.catch((err) => {
|
|
@@ -370,6 +433,9 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
|
|
|
370
433
|
}
|
|
371
434
|
bridgeRef.current = bridge;
|
|
372
435
|
transportRef.current = transport;
|
|
436
|
+
hostContextResizeObserver = new ResizeObserver(scheduleHostContextUpdate);
|
|
437
|
+
hostContextResizeObserver.observe(iframe);
|
|
438
|
+
if (iframe.parentElement) hostContextResizeObserver.observe(iframe.parentElement);
|
|
373
439
|
|
|
374
440
|
// Propagate theme changes to ext-app iframe. Read current expanded state
|
|
375
441
|
// at fire time so the widget keeps its fullscreen/inline context accurate.
|
|
@@ -378,12 +444,8 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
|
|
|
378
444
|
if (firstFire) { firstFire = false; return; }
|
|
379
445
|
if (disposed) return;
|
|
380
446
|
bridge.setHostContext?.({
|
|
447
|
+
...buildHostContext(),
|
|
381
448
|
theme: toMcpTheme(newTheme),
|
|
382
|
-
platform: 'web',
|
|
383
|
-
containerDimensions: { maxHeight },
|
|
384
|
-
displayMode: expandedNodeId.value === nodeId ? 'fullscreen' : 'inline',
|
|
385
|
-
locale: navigator.language,
|
|
386
|
-
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
387
449
|
});
|
|
388
450
|
});
|
|
389
451
|
|
|
@@ -399,6 +461,12 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
|
|
|
399
461
|
return () => {
|
|
400
462
|
disposed = true;
|
|
401
463
|
clearFallbackTimer();
|
|
464
|
+
hostContextResizeObserver?.disconnect();
|
|
465
|
+
hostContextResizeObserver = null;
|
|
466
|
+
if (hostContextRaf !== null) {
|
|
467
|
+
cancelAnimationFrame(hostContextRaf);
|
|
468
|
+
hostContextRaf = null;
|
|
469
|
+
}
|
|
402
470
|
bridgeReadyRef.current = false;
|
|
403
471
|
toolResultSendingRef.current = null;
|
|
404
472
|
themeUnsubRef.current?.();
|
|
@@ -431,7 +499,10 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
|
|
|
431
499
|
bridge.setHostContext?.({
|
|
432
500
|
theme: toMcpTheme(canvasTheme.value),
|
|
433
501
|
platform: 'web',
|
|
434
|
-
containerDimensions: {
|
|
502
|
+
containerDimensions: resolveExtAppContainerDimensions(iframeRef.current, {
|
|
503
|
+
width: node.size.width,
|
|
504
|
+
height: maxHeight,
|
|
505
|
+
}),
|
|
435
506
|
displayMode: isExpanded ? 'fullscreen' : 'inline',
|
|
436
507
|
locale: navigator.language,
|
|
437
508
|
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
@@ -470,7 +541,7 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
|
|
|
470
541
|
}
|
|
471
542
|
|
|
472
543
|
return (
|
|
473
|
-
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
|
544
|
+
<div style={{ height: '100%', minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
|
474
545
|
{sessionStatus && sessionStatus !== 'ready' && (
|
|
475
546
|
<div
|
|
476
547
|
style={{
|
|
@@ -546,14 +617,14 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
|
|
|
546
617
|
remounts the iframe in the overlay — forcing the user to click Edit
|
|
547
618
|
a second time to actually enter edit mode. Routing all inline clicks
|
|
548
619
|
to "expand" makes the flow "open → edit" instead of "edit → expand → edit". */}
|
|
549
|
-
<div style={{ flex: 1, position: 'relative', display: 'flex', minHeight: 0 }}>
|
|
620
|
+
<div style={{ flex: 1, position: 'relative', display: 'flex', minHeight: 0, height: '100%' }}>
|
|
550
621
|
<iframe
|
|
551
622
|
key={frameKey}
|
|
552
623
|
ref={iframeRef}
|
|
553
624
|
srcdoc={html}
|
|
554
|
-
sandbox=
|
|
625
|
+
sandbox={resolveExtAppSandbox(null)}
|
|
555
626
|
allow={buildAllowAttribute(resourceMeta?.permissions)}
|
|
556
|
-
style={{ flex: 1, border: 'none', background: 'var(--c-panel)' }}
|
|
627
|
+
style={{ flex: 1, width: '100%', height: '100%', minHeight: 0, border: 'none', background: 'var(--c-panel)' }}
|
|
557
628
|
title={`Ext App: ${toolName}`}
|
|
558
629
|
/>
|
|
559
630
|
{!isExpanded && (
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { batch, computed, signal } from '@preact/signals';
|
|
2
|
-
import type
|
|
2
|
+
import { isExcalidrawNode, type CanvasEdge, type CanvasLayout, type CanvasNodeState, type ConnectionStatus, type ViewportState } from '../types';
|
|
3
3
|
import { computeAutoArrange } from '../../shared/auto-arrange';
|
|
4
4
|
import { pushCanvasUpdate, updateViewportFromClient } from './intent-bridge';
|
|
5
5
|
|
|
@@ -22,6 +22,11 @@ export const hasInitialServerLayout = signal<boolean>(false);
|
|
|
22
22
|
// Only one node at a time can be in expanded/focus mode. When expanded, the
|
|
23
23
|
// node renders as a full-viewport overlay for deep editing/reading.
|
|
24
24
|
export const expandedNodeId = signal<string | null>(null);
|
|
25
|
+
export const pendingExpandedNodeCloseId = signal<string | null>(null);
|
|
26
|
+
let expandedCloseTimer: ReturnType<typeof setTimeout> | null = null;
|
|
27
|
+
let pendingCloseInitialCheckpointAt: unknown = undefined;
|
|
28
|
+
const EXCALIDRAW_CLOSE_POLL_MS = 100;
|
|
29
|
+
const EXCALIDRAW_CLOSE_MAX_WAIT_MS = 2500;
|
|
25
30
|
|
|
26
31
|
// ── Pending edge connection (for context menu "Connect from") ─
|
|
27
32
|
export const pendingConnection = signal<{ from: string } | null>(null);
|
|
@@ -192,6 +197,14 @@ export function updateNode(id: string, patch: Partial<CanvasNodeState>): void {
|
|
|
192
197
|
}
|
|
193
198
|
next.set(id, { ...existing, ...patch });
|
|
194
199
|
nodes.value = next;
|
|
200
|
+
const updatedAt = (next.get(id)?.data.appCheckpoint as { updatedAt?: unknown } | undefined)?.updatedAt;
|
|
201
|
+
if (
|
|
202
|
+
pendingExpandedNodeCloseId.value === id &&
|
|
203
|
+
updatedAt !== undefined &&
|
|
204
|
+
updatedAt !== pendingCloseInitialCheckpointAt
|
|
205
|
+
) {
|
|
206
|
+
finishExpandedNodeClose(id);
|
|
207
|
+
}
|
|
195
208
|
}
|
|
196
209
|
|
|
197
210
|
export function updateNodeData(id: string, dataPatch: Record<string, unknown>): void {
|
|
@@ -585,11 +598,60 @@ export function walkGraph(direction: 'up' | 'down' | 'left' | 'right'): void {
|
|
|
585
598
|
export function expandNode(id: string): void {
|
|
586
599
|
const node = nodes.value.get(id);
|
|
587
600
|
if (!node) return;
|
|
601
|
+
if (expandedCloseTimer !== null) {
|
|
602
|
+
clearTimeout(expandedCloseTimer);
|
|
603
|
+
expandedCloseTimer = null;
|
|
604
|
+
}
|
|
605
|
+
pendingExpandedNodeCloseId.value = null;
|
|
606
|
+
pendingCloseInitialCheckpointAt = undefined;
|
|
588
607
|
bringToFront(id);
|
|
589
608
|
expandedNodeId.value = id;
|
|
590
609
|
}
|
|
591
610
|
|
|
611
|
+
function finishExpandedNodeClose(nodeId: string): void {
|
|
612
|
+
if (expandedCloseTimer !== null) {
|
|
613
|
+
clearTimeout(expandedCloseTimer);
|
|
614
|
+
expandedCloseTimer = null;
|
|
615
|
+
}
|
|
616
|
+
if (expandedNodeId.value === nodeId) expandedNodeId.value = null;
|
|
617
|
+
if (pendingExpandedNodeCloseId.value === nodeId) pendingExpandedNodeCloseId.value = null;
|
|
618
|
+
pendingCloseInitialCheckpointAt = undefined;
|
|
619
|
+
}
|
|
620
|
+
|
|
592
621
|
export function collapseExpandedNode(): void {
|
|
622
|
+
const nodeId = expandedNodeId.value;
|
|
623
|
+
const node = nodeId ? nodes.value.get(nodeId) : undefined;
|
|
624
|
+
if (nodeId && node && isExcalidrawNode(node)) {
|
|
625
|
+
const closingNodeId = nodeId;
|
|
626
|
+
const startedAt = Date.now();
|
|
627
|
+
pendingExpandedNodeCloseId.value = closingNodeId;
|
|
628
|
+
pendingCloseInitialCheckpointAt = (node.data.appCheckpoint as { updatedAt?: unknown } | undefined)?.updatedAt;
|
|
629
|
+
if (expandedCloseTimer !== null) clearTimeout(expandedCloseTimer);
|
|
630
|
+
const pollForSave = () => {
|
|
631
|
+
const latestNode = nodes.value.get(closingNodeId);
|
|
632
|
+
const latestCheckpointAt = (latestNode?.data.appCheckpoint as { updatedAt?: unknown } | undefined)?.updatedAt;
|
|
633
|
+
if (
|
|
634
|
+
latestCheckpointAt !== undefined &&
|
|
635
|
+
latestCheckpointAt !== pendingCloseInitialCheckpointAt
|
|
636
|
+
) {
|
|
637
|
+
finishExpandedNodeClose(closingNodeId);
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
if (Date.now() - startedAt >= EXCALIDRAW_CLOSE_MAX_WAIT_MS) {
|
|
641
|
+
finishExpandedNodeClose(closingNodeId);
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
expandedCloseTimer = setTimeout(pollForSave, EXCALIDRAW_CLOSE_POLL_MS);
|
|
645
|
+
};
|
|
646
|
+
expandedCloseTimer = setTimeout(pollForSave, EXCALIDRAW_CLOSE_POLL_MS);
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
if (expandedCloseTimer !== null) {
|
|
650
|
+
clearTimeout(expandedCloseTimer);
|
|
651
|
+
expandedCloseTimer = null;
|
|
652
|
+
}
|
|
653
|
+
pendingExpandedNodeCloseId.value = null;
|
|
654
|
+
pendingCloseInitialCheckpointAt = undefined;
|
|
593
655
|
expandedNodeId.value = null;
|
|
594
656
|
}
|
|
595
657
|
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
addEdge,
|
|
7
7
|
addNode,
|
|
8
8
|
applyServerCanvasLayout,
|
|
9
|
+
bringToFront,
|
|
9
10
|
cancelViewportAnimation,
|
|
10
11
|
canvasTheme,
|
|
11
12
|
connectionStatus,
|
|
@@ -827,6 +828,10 @@ function reconnectDelayMs(attempt: number): number {
|
|
|
827
828
|
function handleCanvasFocusNode(data: Record<string, unknown>): void {
|
|
828
829
|
const nodeId = data.nodeId as string;
|
|
829
830
|
if (nodeId && nodes.value.has(nodeId)) {
|
|
831
|
+
if (data.noPan === true) {
|
|
832
|
+
bringToFront(nodeId);
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
830
835
|
focusNode(nodeId);
|
|
831
836
|
}
|
|
832
837
|
}
|
package/src/client/types.ts
CHANGED
|
@@ -74,6 +74,18 @@ export const EXPANDABLE_TYPES = new Set<CanvasNodeState['type']>([
|
|
|
74
74
|
'image',
|
|
75
75
|
]);
|
|
76
76
|
|
|
77
|
+
export const EXCALIDRAW_SERVER_NAME = 'Excalidraw';
|
|
78
|
+
export const EXCALIDRAW_CREATE_VIEW_TOOL = 'create_view';
|
|
79
|
+
|
|
80
|
+
export function isExcalidrawNode(node: CanvasNodeState): boolean {
|
|
81
|
+
return (
|
|
82
|
+
node.type === 'mcp-app' &&
|
|
83
|
+
node.data.mode === 'ext-app' &&
|
|
84
|
+
node.data.serverName === EXCALIDRAW_SERVER_NAME &&
|
|
85
|
+
node.data.toolName === EXCALIDRAW_CREATE_VIEW_TOOL
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
77
89
|
export interface CanvasLayout {
|
|
78
90
|
viewport: ViewportState;
|
|
79
91
|
nodes: CanvasNodeState[];
|
package/src/mcp/server.ts
CHANGED
|
@@ -348,6 +348,7 @@ export async function startMcpServer(): Promise<void> {
|
|
|
348
348
|
mainTsx: z.string().optional().describe('Optional contents for src/main.tsx'),
|
|
349
349
|
indexHtml: z.string().optional().describe('Optional contents for index.html'),
|
|
350
350
|
files: z.record(z.string(), z.string()).optional().describe('Optional map of additional project-relative file paths to file contents'),
|
|
351
|
+
deps: z.array(z.string()).optional().describe('Optional npm dependencies to install before bundling (e.g. ["recharts", "framer-motion@^11"]). Validated against npm-name format; flags and shell metacharacters are rejected.'),
|
|
351
352
|
projectPath: z.string().optional().describe('Optional workspace-relative reusable project path. Defaults to .pmx-canvas/artifacts/.web-artifacts/<slug>'),
|
|
352
353
|
outputPath: z.string().optional().describe('Optional workspace-relative HTML output path. Defaults to .pmx-canvas/artifacts/<slug>.html'),
|
|
353
354
|
openInCanvas: z.boolean().optional().describe('Open the generated artifact in canvas after build (default true)'),
|
|
@@ -366,6 +367,7 @@ export async function startMcpServer(): Promise<void> {
|
|
|
366
367
|
...(typeof input.mainTsx === 'string' ? { mainTsx: input.mainTsx } : {}),
|
|
367
368
|
...(typeof input.indexHtml === 'string' ? { indexHtml: input.indexHtml } : {}),
|
|
368
369
|
...(input.files ? { files: input.files } : {}),
|
|
370
|
+
...(Array.isArray(input.deps) ? { deps: input.deps } : {}),
|
|
369
371
|
...(typeof input.projectPath === 'string'
|
|
370
372
|
? { projectPath: safeWorkspacePath(input.projectPath) }
|
|
371
373
|
: {}),
|
|
@@ -672,13 +674,34 @@ export async function startMcpServer(): Promise<void> {
|
|
|
672
674
|
// ── canvas_focus_node ──────────────────────────────────────────
|
|
673
675
|
server.tool(
|
|
674
676
|
'canvas_focus_node',
|
|
675
|
-
'
|
|
676
|
-
{
|
|
677
|
-
|
|
677
|
+
'Bring a node into focus. By default the viewport pans so the node is centered. Pass noPan=true to raise/select the node without moving the human\'s camera (useful when reacting to background events without disrupting the human\'s current view).',
|
|
678
|
+
{
|
|
679
|
+
id: z.string().describe('Node ID to focus on'),
|
|
680
|
+
noPan: z
|
|
681
|
+
.boolean()
|
|
682
|
+
.optional()
|
|
683
|
+
.describe('If true, raise/select the node without panning the viewport. Default false.'),
|
|
684
|
+
},
|
|
685
|
+
async ({ id, noPan }) => {
|
|
678
686
|
const c = await ensureCanvas();
|
|
679
|
-
c.focusNode(id);
|
|
687
|
+
const result = c.focusNode(id, { ...(noPan === true ? { noPan: true } : {}) });
|
|
688
|
+
if (!result) {
|
|
689
|
+
return {
|
|
690
|
+
content: [
|
|
691
|
+
{
|
|
692
|
+
type: 'text',
|
|
693
|
+
text: JSON.stringify({ ok: false, error: `Node "${id}" not found.` }),
|
|
694
|
+
},
|
|
695
|
+
],
|
|
696
|
+
};
|
|
697
|
+
}
|
|
680
698
|
return {
|
|
681
|
-
content: [
|
|
699
|
+
content: [
|
|
700
|
+
{
|
|
701
|
+
type: 'text',
|
|
702
|
+
text: JSON.stringify({ ok: true, focused: result.focused, panned: result.panned }),
|
|
703
|
+
},
|
|
704
|
+
],
|
|
682
705
|
};
|
|
683
706
|
},
|
|
684
707
|
);
|
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
getWebpageFetchErrorDetails,
|
|
37
37
|
normalizeWebpageUrl,
|
|
38
38
|
} from './webpage-node.js';
|
|
39
|
+
import { buildExcalidrawRestoreCheckpointToolInput, ensureExcalidrawCheckpointId, isExcalidrawCreateView } from './diagram-presets.js';
|
|
39
40
|
|
|
40
41
|
export type CanvasArrangeMode = 'grid' | 'column' | 'flow';
|
|
41
42
|
export type CanvasPinMode = 'set' | 'add' | 'remove';
|
|
@@ -82,6 +83,29 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
82
83
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
83
84
|
}
|
|
84
85
|
|
|
86
|
+
function getStoredExcalidrawCheckpointId(node: CanvasNodeState): string | null {
|
|
87
|
+
const appCheckpoint = isRecord(node.data.appCheckpoint) ? node.data.appCheckpoint : null;
|
|
88
|
+
const checkpointId = appCheckpoint?.id;
|
|
89
|
+
return typeof checkpointId === 'string' && checkpointId.trim().length > 0 ? checkpointId.trim() : null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function resolveExtAppRehydratedToolInput(
|
|
93
|
+
node: CanvasNodeState,
|
|
94
|
+
openedToolInput: Record<string, unknown>,
|
|
95
|
+
): Record<string, unknown> {
|
|
96
|
+
if (!isExcalidrawCreateView(node.data.serverName, node.data.toolName)) return openedToolInput;
|
|
97
|
+
const checkpointId = getStoredExcalidrawCheckpointId(node);
|
|
98
|
+
if (!checkpointId) return openedToolInput;
|
|
99
|
+
const appCheckpoint = isRecord(node.data.appCheckpoint) ? node.data.appCheckpoint : null;
|
|
100
|
+
return {
|
|
101
|
+
...openedToolInput,
|
|
102
|
+
elements: buildExcalidrawRestoreCheckpointToolInput(
|
|
103
|
+
checkpointId,
|
|
104
|
+
typeof appCheckpoint?.data === 'string' ? appCheckpoint.data : undefined,
|
|
105
|
+
),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
85
109
|
function isExtAppNode(node: CanvasNodeState | undefined): node is CanvasNodeState {
|
|
86
110
|
return node?.type === 'mcp-app' && node.data.mode === 'ext-app';
|
|
87
111
|
}
|
|
@@ -280,13 +304,18 @@ export async function syncCanvasRuntimeBackends(
|
|
|
280
304
|
? { serverName: current.data.serverName.trim() }
|
|
281
305
|
: {}),
|
|
282
306
|
});
|
|
307
|
+
const toolInput = resolveExtAppRehydratedToolInput(current, opened.toolInput);
|
|
308
|
+
const storedCheckpointId = getStoredExcalidrawCheckpointId(current);
|
|
309
|
+
const toolResult = isExcalidrawCreateView(opened.serverName, opened.toolName)
|
|
310
|
+
? ensureExcalidrawCheckpointId(opened.toolResult, nodeId, storedCheckpointId)
|
|
311
|
+
: opened.toolResult;
|
|
283
312
|
|
|
284
313
|
canvasState.withSuppressedRecording(() => {
|
|
285
314
|
setExtAppRuntimeState(nodeId, {
|
|
286
315
|
appSessionId: opened.sessionId,
|
|
287
316
|
html: opened.html,
|
|
288
|
-
toolInput
|
|
289
|
-
toolResult
|
|
317
|
+
toolInput,
|
|
318
|
+
toolResult,
|
|
290
319
|
resourceUri: opened.resourceUri,
|
|
291
320
|
toolDefinition: opened.tool,
|
|
292
321
|
resourceMeta: opened.resourceMeta,
|
|
@@ -704,7 +733,7 @@ function collectArrangeExcludedNodeIds(nodes: CanvasNodeState[]): Set<string> {
|
|
|
704
733
|
const excluded = new Set<string>();
|
|
705
734
|
for (const node of nodes) {
|
|
706
735
|
const parentGroup = typeof node.data.parentGroup === 'string' ? node.data.parentGroup : null;
|
|
707
|
-
if (
|
|
736
|
+
if (parentGroup || isArrangeLocked(node)) {
|
|
708
737
|
excluded.add(node.id);
|
|
709
738
|
}
|
|
710
739
|
}
|
|
@@ -747,12 +776,13 @@ export function arrangeCanvasNodes(layout: CanvasArrangeMode): { arranged: numbe
|
|
|
747
776
|
return;
|
|
748
777
|
}
|
|
749
778
|
|
|
750
|
-
const
|
|
779
|
+
const maxNodeWidth = movableNodes.reduce((max, node) => Math.max(max, node.size.width), 360);
|
|
780
|
+
const cols = Math.max(1, Math.floor(1440 / (maxNodeWidth + gap)));
|
|
751
781
|
let col = 0;
|
|
752
782
|
let rowY = 80;
|
|
753
783
|
let rowMaxHeight = 0;
|
|
754
784
|
for (const node of movableNodes) {
|
|
755
|
-
const x = 40 + col * (
|
|
785
|
+
const x = 40 + col * (maxNodeWidth + gap);
|
|
756
786
|
canvasState.updateNode(node.id, { position: { x, y: rowY } });
|
|
757
787
|
rowMaxHeight = Math.max(rowMaxHeight, node.size.height);
|
|
758
788
|
col++;
|
|
@@ -283,7 +283,7 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
|
|
|
283
283
|
required: true,
|
|
284
284
|
description: 'Chart type. Aliases like "stack" and "combo" are normalized server-side.',
|
|
285
285
|
},
|
|
286
|
-
{ name: 'data', type: 'Record<string, unknown>[]', required: true, description: 'Chart dataset.' },
|
|
286
|
+
{ name: 'data', type: 'Record<string, unknown>[]', required: true, description: 'Chart dataset.', aliases: ['data-json'] },
|
|
287
287
|
{ name: 'title', type: 'string', required: false, description: 'Optional graph title.' },
|
|
288
288
|
{ name: 'xKey', type: 'string', required: false, description: 'X-axis/category key for line, bar, area, scatter, stacked-bar, and composed charts.' },
|
|
289
289
|
{ name: 'yKey', type: 'string', required: false, description: 'Y-axis value key for line, bar, area, and scatter charts. Also used as a fallback bar key for composed charts.' },
|
|
@@ -334,6 +334,7 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
|
|
|
334
334
|
{ name: 'outputPath', type: 'string', required: false, description: 'Optional output HTML path.' },
|
|
335
335
|
{ name: 'openInCanvas', type: 'boolean', required: false, description: 'Open the built artifact on the canvas (default true).' },
|
|
336
336
|
{ name: 'includeLogs', type: 'boolean', required: false, description: 'Include raw build stdout/stderr in the response (default false).' },
|
|
337
|
+
{ name: 'deps', type: 'string[]', required: false, description: 'Optional npm dependencies to add before bundling, e.g. recharts.', aliases: ['deps'] },
|
|
337
338
|
],
|
|
338
339
|
example: {
|
|
339
340
|
title: 'Dashboard Artifact',
|