pmx-canvas 0.1.32 → 0.1.34
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 +75 -0
- package/dist/canvas/index.js +61 -61
- package/dist/json-render/index.js +112 -112
- package/dist/types/client/canvas/auto-fit.d.ts +14 -0
- package/dist/types/client/nodes/surface-url.d.ts +6 -7
- package/dist/types/client/nodes/use-iframe-content-height.d.ts +16 -0
- package/dist/types/client/state/intent-bridge.d.ts +1 -1
- package/dist/types/json-render/server.d.ts +6 -0
- package/dist/types/server/canvas-operations.d.ts +1 -5
- package/dist/types/server/html-surface.d.ts +9 -0
- package/dist/types/shared/content-height-reporter.d.ts +20 -0
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +17 -5
- package/src/client/canvas/CanvasNode.tsx +15 -21
- package/src/client/canvas/ExpandedNodeOverlay.tsx +3 -14
- package/src/client/canvas/auto-fit.ts +61 -7
- package/src/client/nodes/HtmlNode.tsx +9 -2
- package/src/client/nodes/McpAppNode.tsx +33 -4
- package/src/client/nodes/surface-url.ts +10 -12
- package/src/client/nodes/use-iframe-content-height.ts +53 -0
- package/src/client/state/intent-bridge.ts +2 -2
- package/src/json-render/charts/components.tsx +11 -1
- package/src/json-render/server.ts +13 -1
- package/src/mcp/server.ts +58 -6
- package/src/server/canvas-operations.ts +2 -2
- package/src/server/html-surface.ts +18 -1
- package/src/server/server.ts +55 -16
- package/src/shared/content-height-reporter.ts +35 -0
|
@@ -5,6 +5,7 @@ import { buildAppHtml } from '@json-render/mcp/build-app-html';
|
|
|
5
5
|
import { applySpecPatch, parseSpecStreamLine, type Spec, type SpecStreamLine } from '@json-render/core';
|
|
6
6
|
import { allComponentDefinitions, catalog, validateShadcnElementProps, type JsonRenderIssue } from './catalog.js';
|
|
7
7
|
import { findUnknownDirectiveKey, isDynamicPropValue } from './directives.js';
|
|
8
|
+
import { contentHeightReporterTag } from '../shared/content-height-reporter.js';
|
|
8
9
|
|
|
9
10
|
export interface JsonRenderSpec {
|
|
10
11
|
root: string;
|
|
@@ -944,6 +945,12 @@ export async function buildJsonRenderViewerHtml(options: {
|
|
|
944
945
|
nodeId?: string;
|
|
945
946
|
axToken?: string;
|
|
946
947
|
axState?: unknown;
|
|
948
|
+
/** Nonce for the content-height reporter so the node can grow to fit the chart. */
|
|
949
|
+
frameToken?: string;
|
|
950
|
+
/** When true, charts render at their natural (intrinsic) height instead of
|
|
951
|
+
* filling the viewport down — so the reported scrollHeight is stable and the
|
|
952
|
+
* node grows to it. Off for strictSize / user-resized nodes (they fill-down). */
|
|
953
|
+
fitContent?: boolean;
|
|
947
954
|
}): Promise<string> {
|
|
948
955
|
const sanitizeAxValue = (v?: string): string => (typeof v === 'string' ? v.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 80) : '');
|
|
949
956
|
try {
|
|
@@ -966,13 +973,18 @@ export async function buildJsonRenderViewerHtml(options: {
|
|
|
966
973
|
// Read-side AX state: seed for initial render + bound under /ax for specs.
|
|
967
974
|
`window.__PMX_CANVAS_AX_STATE__ = ${JSON.stringify(options.axState ?? null).replace(/</g, '\\u003c')};`,
|
|
968
975
|
] : []),
|
|
976
|
+
...(options.fitContent ? ['window.__PMX_CANVAS_FIT_CONTENT__ = true;'] : []),
|
|
969
977
|
jsBundle,
|
|
970
978
|
].join('\n');
|
|
979
|
+
// Content-height reporter: posts the viewer's natural scrollHeight so the
|
|
980
|
+
// parent node grows to fit (the #48 graph-clipping fix). Shared with the html
|
|
981
|
+
// surface (src/shared, no src/server import) so the two stay identical.
|
|
982
|
+
const heightReporter = options.frameToken ? contentHeightReporterTag(options.frameToken) : '';
|
|
971
983
|
return buildAppHtml({
|
|
972
984
|
title: options.title,
|
|
973
985
|
css: cssBundle,
|
|
974
986
|
js: escapeInlineScriptSource(boot),
|
|
975
|
-
head:
|
|
987
|
+
head: `<meta name="color-scheme" content="light dark" />${heightReporter}`,
|
|
976
988
|
});
|
|
977
989
|
} catch (error) {
|
|
978
990
|
const message = error instanceof Error ? error.message : String(error);
|
package/src/mcp/server.ts
CHANGED
|
@@ -200,6 +200,56 @@ function wantsFullPayload(input: { full?: boolean; verbose?: boolean; includeDat
|
|
|
200
200
|
return input.full === true || input.verbose === true || input.includeData === true;
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
+
interface PendingAxActivityItem {
|
|
204
|
+
kind: 'work-item' | 'approval-gate' | 'elicitation' | 'mode-request';
|
|
205
|
+
id: string;
|
|
206
|
+
title: string;
|
|
207
|
+
status: string;
|
|
208
|
+
nodeIds: string[];
|
|
209
|
+
source: string | null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const OPEN_AX_WORK_STATUSES = new Set(['todo', 'in-progress', 'blocked']);
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Open, agent-actionable canvas-bound AX items (open work items + pending approval
|
|
216
|
+
* gates / elicitations / mode requests). Unlike steering (a directive routed through
|
|
217
|
+
* the claim/ack delivery queue), these are STATE the human curates in the browser —
|
|
218
|
+
* they fire `ax-state-changed` (so resource-subscribers are pushed canvas://ax-work),
|
|
219
|
+
* but an adapterless client that only POLLS the delivery surface never saw them.
|
|
220
|
+
* Surfacing this digest there closes report #43 without conflating state with steering.
|
|
221
|
+
* Optionally excludes items the consumer itself originated (loop prevention), mirroring
|
|
222
|
+
* getPendingSteering.
|
|
223
|
+
*/
|
|
224
|
+
function buildPendingAxActivity(
|
|
225
|
+
state: Awaited<ReturnType<CanvasAccess['getAxState']>>,
|
|
226
|
+
consumer?: string,
|
|
227
|
+
): PendingAxActivityItem[] {
|
|
228
|
+
const notMine = (source: string | null) => !consumer || source !== consumer;
|
|
229
|
+
const out: PendingAxActivityItem[] = [];
|
|
230
|
+
for (const w of state.workItems ?? []) {
|
|
231
|
+
if (OPEN_AX_WORK_STATUSES.has(w.status) && notMine(w.source)) {
|
|
232
|
+
out.push({ kind: 'work-item', id: w.id, title: w.title, status: w.status, nodeIds: w.nodeIds ?? [], source: w.source });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
for (const g of state.approvalGates ?? []) {
|
|
236
|
+
if (g.status === 'pending' && notMine(g.source)) {
|
|
237
|
+
out.push({ kind: 'approval-gate', id: g.id, title: g.title, status: g.status, nodeIds: g.nodeIds ?? [], source: g.source });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
for (const e of state.elicitations ?? []) {
|
|
241
|
+
if (e.status === 'pending' && notMine(e.source)) {
|
|
242
|
+
out.push({ kind: 'elicitation', id: e.id, title: e.prompt, status: e.status, nodeIds: e.nodeIds ?? [], source: e.source });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
for (const m of state.modeRequests ?? []) {
|
|
246
|
+
if (m.status === 'pending' && notMine(m.source)) {
|
|
247
|
+
out.push({ kind: 'mode-request', id: m.id, title: m.reason ? `${m.mode}: ${m.reason}` : `mode: ${m.mode}`, status: m.status, nodeIds: m.nodeIds ?? [], source: m.source });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return out;
|
|
251
|
+
}
|
|
252
|
+
|
|
203
253
|
function compactNodePayload(node: Awaited<ReturnType<CanvasAccess['getNode']>>): Record<string, unknown> | null {
|
|
204
254
|
if (!node) return null;
|
|
205
255
|
const serialized = serializeCanvasNode(node);
|
|
@@ -1688,10 +1738,10 @@ export async function startMcpServer(): Promise<void> {
|
|
|
1688
1738
|
|
|
1689
1739
|
server.tool(
|
|
1690
1740
|
'canvas_claim_ax_delivery',
|
|
1691
|
-
'Claim pending PMX AX
|
|
1741
|
+
'Claim pending PMX AX deliveries for a consumer (adapterless delivery). Returns `pending` undelivered steering (mark each with canvas_mark_ax_delivery after acting) AND `pendingActivity`: open canvas-bound AX items awaiting the agent (open work items, pending approval gates / elicitations / mode requests) — typically created by the human in the browser. Both exclude items the consumer itself originated (loop prevention). pendingActivity is read-only here: resolve each via its own tool (canvas_resolve_approval / canvas_respond_elicitation / canvas_resolve_mode / canvas_update_work_item), not canvas_mark_ax_delivery.',
|
|
1692
1742
|
{
|
|
1693
1743
|
consumer: z.string().optional().describe('Consumer/source label to exclude from results (e.g. copilot, mcp).'),
|
|
1694
|
-
limit: z.number().optional().describe('Max messages to return.'),
|
|
1744
|
+
limit: z.number().optional().describe('Max steering messages to return.'),
|
|
1695
1745
|
},
|
|
1696
1746
|
async ({ consumer, limit }) => {
|
|
1697
1747
|
const c = await ensureCanvas();
|
|
@@ -1699,7 +1749,8 @@ export async function startMcpServer(): Promise<void> {
|
|
|
1699
1749
|
...(consumer ? { consumer } : {}),
|
|
1700
1750
|
...(typeof limit === 'number' ? { limit } : {}),
|
|
1701
1751
|
});
|
|
1702
|
-
|
|
1752
|
+
const pendingActivity = buildPendingAxActivity(await c.getAxState(), consumer);
|
|
1753
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ok: true, pending, pendingActivity }) }] };
|
|
1703
1754
|
},
|
|
1704
1755
|
);
|
|
1705
1756
|
|
|
@@ -2304,15 +2355,16 @@ export async function startMcpServer(): Promise<void> {
|
|
|
2304
2355
|
'canvas://ax-pending-steering',
|
|
2305
2356
|
{
|
|
2306
2357
|
description:
|
|
2307
|
-
'
|
|
2358
|
+
'Adapterless AX delivery surface. `pending`: undelivered steering messages to claim and act on, then mark via canvas_mark_ax_delivery. `pendingActivity`: open canvas-bound AX items awaiting the agent (open work items, pending approval gates / elicitations / mode requests) — usually created by the human in the browser; these fire ax-state-changed (resource-subscribers are also pushed canvas://ax-work). Resolve pendingActivity via its own tool, not canvas_mark_ax_delivery. Use canvas_claim_ax_delivery for the loop-safe, consumer-scoped view.',
|
|
2308
2359
|
mimeType: 'application/json',
|
|
2309
2360
|
},
|
|
2310
2361
|
async () => {
|
|
2311
2362
|
const c = await ensureCanvas();
|
|
2312
|
-
const pending = await c.getPendingSteering();
|
|
2363
|
+
const [pending, state] = await Promise.all([c.getPendingSteering(), c.getAxState()]);
|
|
2364
|
+
const pendingActivity = buildPendingAxActivity(state);
|
|
2313
2365
|
return {
|
|
2314
2366
|
contents: [
|
|
2315
|
-
{ uri: 'canvas://ax-pending-steering', mimeType: 'application/json', text: JSON.stringify({ pending }, null, 2) },
|
|
2367
|
+
{ uri: 'canvas://ax-pending-steering', mimeType: 'application/json', text: JSON.stringify({ pending, pendingActivity }, null, 2) },
|
|
2316
2368
|
],
|
|
2317
2369
|
};
|
|
2318
2370
|
},
|
|
@@ -1363,7 +1363,7 @@ export function addCanvasEdge(input: {
|
|
|
1363
1363
|
label?: string;
|
|
1364
1364
|
style?: CanvasEdge['style'];
|
|
1365
1365
|
animated?: boolean;
|
|
1366
|
-
}):
|
|
1366
|
+
}): CanvasEdge {
|
|
1367
1367
|
const fromResult = resolveCanvasNode({
|
|
1368
1368
|
...(typeof input.from === 'string' ? { id: input.from } : {}),
|
|
1369
1369
|
...(typeof input.fromSearch === 'string' ? { search: input.fromSearch } : {}),
|
|
@@ -1393,7 +1393,7 @@ export function addCanvasEdge(input: {
|
|
|
1393
1393
|
if (!added) {
|
|
1394
1394
|
throw new Error('Duplicate or self-edge.');
|
|
1395
1395
|
}
|
|
1396
|
-
return
|
|
1396
|
+
return edge;
|
|
1397
1397
|
}
|
|
1398
1398
|
|
|
1399
1399
|
export function removeCanvasEdge(id: string): { removed: boolean } {
|
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
* postMessage required.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
+
import { contentHeightReporterTag } from '../shared/content-height-reporter.js';
|
|
19
|
+
|
|
18
20
|
export type SurfaceTheme = 'dark' | 'light' | 'high-contrast';
|
|
19
21
|
|
|
20
22
|
/** Path the surface document links for its theme tokens (served from dist/canvas). */
|
|
@@ -150,6 +152,16 @@ export function buildAxStateBridge(axToken: string, snapshotJson: string): strin
|
|
|
150
152
|
</script>`;
|
|
151
153
|
}
|
|
152
154
|
|
|
155
|
+
/**
|
|
156
|
+
* Reports the surface's natural content height to the parent canvas so the node
|
|
157
|
+
* can GROW to fit it (the fix for iframe nodes the parent can't measure — graph,
|
|
158
|
+
* json-render, html, web-artifact). Thin wrapper over the shared reporter so this
|
|
159
|
+
* and the json-render injection site stay byte-identical (no drift).
|
|
160
|
+
*/
|
|
161
|
+
export function buildContentHeightReporter(frameToken: string): string {
|
|
162
|
+
return contentHeightReporterTag(frameToken);
|
|
163
|
+
}
|
|
164
|
+
|
|
153
165
|
/** Escape a string for safe interpolation into element text (e.g. `<title>`). */
|
|
154
166
|
function escapeSurfaceHtml(value: string): string {
|
|
155
167
|
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
@@ -178,6 +190,8 @@ export interface HtmlSurfaceOptions {
|
|
|
178
190
|
* axBridge is enabled). Kept live via parent → iframe `ax-update` messages.
|
|
179
191
|
*/
|
|
180
192
|
axState?: unknown;
|
|
193
|
+
/** Nonce for the content-height reporter (lets the node grow to fit content). */
|
|
194
|
+
contentHeightToken?: string;
|
|
181
195
|
}
|
|
182
196
|
|
|
183
197
|
/**
|
|
@@ -202,7 +216,10 @@ export function buildHtmlSurfaceDocument(userHtml: string, options: HtmlSurfaceO
|
|
|
202
216
|
options.axState !== undefined ? JSON.stringify(options.axState).replace(/</g, '\\u003c') : 'null',
|
|
203
217
|
)
|
|
204
218
|
: '';
|
|
205
|
-
const
|
|
219
|
+
const contentHeightBridge = options.contentHeightToken
|
|
220
|
+
? buildContentHeightReporter(sanitizeToken(options.contentHeightToken))
|
|
221
|
+
: '';
|
|
222
|
+
const injectedHeadContent = `${link}${themeBridge}${presentationBridge}${axBridge}${axStateBridge}${contentHeightBridge}`;
|
|
206
223
|
const presentationAttr = options.presentation ? ' data-pmx-presentation-mode="present"' : '';
|
|
207
224
|
const trimmed = userHtml.trim();
|
|
208
225
|
const isFullDoc = /<html[\s>]/i.test(trimmed);
|
package/src/server/server.ts
CHANGED
|
@@ -48,7 +48,7 @@ import type {
|
|
|
48
48
|
ListToolsResult,
|
|
49
49
|
} from '@modelcontextprotocol/sdk/types.js';
|
|
50
50
|
import { type CanvasAnnotation, type CanvasEdge, type CanvasLayout, type CanvasNodeState, IMAGE_MIME_MAP, canvasState } from './canvas-state.js';
|
|
51
|
-
import { buildAxBridge, buildAxStateBridge, buildHtmlSurfaceDocument, HTML_SURFACE_SANDBOX, normalizeSurfaceTheme } from './html-surface.js';
|
|
51
|
+
import { buildAxBridge, buildAxStateBridge, buildContentHeightReporter, buildHtmlSurfaceDocument, HTML_SURFACE_SANDBOX, normalizeSurfaceTheme } from './html-surface.js';
|
|
52
52
|
import { findCanvasExtAppNodeId as findCanvasExtAppNodeIdShared } from './ext-app-lookup.js';
|
|
53
53
|
import { normalizeExtAppToolResult } from './ext-app-tool-result.js';
|
|
54
54
|
import { getMcpAppHostSnapshot } from './mcp-app-host.js';
|
|
@@ -1051,16 +1051,25 @@ function rotatePrimaryWorkbenchSessionIfNeeded(): void {
|
|
|
1051
1051
|
primaryWorkbenchSessionId = `pmx-${Date.now().toString(36)}`;
|
|
1052
1052
|
}
|
|
1053
1053
|
|
|
1054
|
-
function readJson(req: Request): Promise<Record<string, unknown>> {
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1054
|
+
async function readJson(req: Request): Promise<Record<string, unknown>> {
|
|
1055
|
+
let text = '';
|
|
1056
|
+
try {
|
|
1057
|
+
text = await req.text();
|
|
1058
|
+
} catch (error) {
|
|
1059
|
+
logWorkbenchWarning('readJson', error);
|
|
1060
|
+
return {};
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
if (!text.trim()) return {};
|
|
1064
|
+
|
|
1065
|
+
try {
|
|
1066
|
+
const value = JSON.parse(text) as unknown;
|
|
1067
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
|
1068
|
+
return value as Record<string, unknown>;
|
|
1069
|
+
} catch (error) {
|
|
1070
|
+
logWorkbenchWarning('readJson', error);
|
|
1071
|
+
return {};
|
|
1072
|
+
}
|
|
1064
1073
|
}
|
|
1065
1074
|
|
|
1066
1075
|
function htmlEscape(value: string): string {
|
|
@@ -1447,6 +1456,8 @@ function handleNodeSurface(pathname: string, url: URL): Response {
|
|
|
1447
1456
|
nodeId: node.id,
|
|
1448
1457
|
// Seed the read-side bridge with the current AX state (only for AX surfaces).
|
|
1449
1458
|
...(axEnabled ? { axState: buildCanvasAxSurfaceSnapshot() } : {}),
|
|
1459
|
+
// Content-height reporter nonce (lets an html node grow to fit its content).
|
|
1460
|
+
...(url.searchParams.get('frameToken') ? { contentHeightToken: url.searchParams.get('frameToken') as string } : {}),
|
|
1450
1461
|
});
|
|
1451
1462
|
return surfaceHtmlResponse(doc, HTML_SURFACE_SANDBOX);
|
|
1452
1463
|
}
|
|
@@ -2413,7 +2424,14 @@ async function handleCanvasAddGraph(req: Request): Promise<Response> {
|
|
|
2413
2424
|
const x = pickFiniteNumber(body, 'x') ?? (position ? pickFiniteNumber(position, 'x') : undefined);
|
|
2414
2425
|
const y = pickFiniteNumber(body, 'y') ?? (position ? pickFiniteNumber(position, 'y') : undefined);
|
|
2415
2426
|
const width = pickPositiveNumber(body, 'width') ?? (size ? pickPositiveNumber(size, 'width') : undefined);
|
|
2416
|
-
|
|
2427
|
+
// Node FRAME height. `body.height` is the CHART plot height (passed through as
|
|
2428
|
+
// `input.height` below), so the node frame accepts `nodeHeight` / `heightPx` /
|
|
2429
|
+
// `size.height` as aliases — `heightPx` matches createCanvasGraphNode's own input
|
|
2430
|
+
// field, the natural thing a caller reaches for. (With content-fit the node grows
|
|
2431
|
+
// to the chart anyway; this just removes the silent "height ignored" surprise.)
|
|
2432
|
+
const nodeHeight = pickPositiveNumber(body, 'nodeHeight')
|
|
2433
|
+
?? pickPositiveNumber(body, 'heightPx')
|
|
2434
|
+
?? (size ? pickPositiveNumber(size, 'height') : undefined);
|
|
2417
2435
|
const showLegend = typeof body.showLegend === 'boolean' ? body.showLegend : undefined;
|
|
2418
2436
|
const showLabels = typeof body.showLabels === 'boolean' ? body.showLabels : undefined;
|
|
2419
2437
|
const colorBy =
|
|
@@ -2540,6 +2558,8 @@ async function handleJsonRenderView(url: URL): Promise<Response> {
|
|
|
2540
2558
|
url.searchParams.get('devtools') === '1';
|
|
2541
2559
|
const axToken = url.searchParams.get('axToken');
|
|
2542
2560
|
const axEnabled = resolveNodeAxCapabilities(node).enabled;
|
|
2561
|
+
const frameToken = url.searchParams.get('frameToken');
|
|
2562
|
+
const fitContent = url.searchParams.get('fit') === 'content';
|
|
2543
2563
|
const html = await buildJsonRenderViewerHtml({
|
|
2544
2564
|
title,
|
|
2545
2565
|
spec,
|
|
@@ -2549,6 +2569,9 @@ async function handleJsonRenderView(url: URL): Promise<Response> {
|
|
|
2549
2569
|
...(axToken ? { nodeId, axToken } : {}),
|
|
2550
2570
|
// Seed the read-side AX state (only for AX-enabled nodes) so specs can bind /ax.
|
|
2551
2571
|
...(axToken && axEnabled ? { axState: buildCanvasAxSurfaceSnapshot() } : {}),
|
|
2572
|
+
// Content-fit: report natural height (charts render intrinsic) so the node grows.
|
|
2573
|
+
...(frameToken ? { frameToken } : {}),
|
|
2574
|
+
...(fitContent ? { fitContent: true } : {}),
|
|
2552
2575
|
});
|
|
2553
2576
|
return new Response(html, {
|
|
2554
2577
|
headers: {
|
|
@@ -2627,6 +2650,14 @@ function handleArtifactView(url: URL): Response {
|
|
|
2627
2650
|
: `${bridge}${content}`;
|
|
2628
2651
|
}
|
|
2629
2652
|
}
|
|
2653
|
+
// Content-height reporter so a web-artifact node grows to fit its app (#48).
|
|
2654
|
+
const frameToken = url.searchParams.get('frameToken');
|
|
2655
|
+
if (frameToken) {
|
|
2656
|
+
const reporter = buildContentHeightReporter(frameToken.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 80));
|
|
2657
|
+
content = content.includes('</head>')
|
|
2658
|
+
? content.replace('</head>', `${reporter}</head>`)
|
|
2659
|
+
: `${reporter}${content}`;
|
|
2660
|
+
}
|
|
2630
2661
|
return new Response(content, {
|
|
2631
2662
|
headers: {
|
|
2632
2663
|
'Content-Type': 'text/html; charset=utf-8',
|
|
@@ -3861,9 +3892,10 @@ function handleGetAxSurfaceSnapshot(): Response {
|
|
|
3861
3892
|
|
|
3862
3893
|
// Open a node's surface in the user's real system browser (for hosts whose
|
|
3863
3894
|
// embedded browser makes window.open('_blank') feel in-place, e.g. Codex).
|
|
3864
|
-
// Accepts ONLY { nodeId } and opens this server's own surface URL — never
|
|
3865
|
-
// arbitrary URL — so it can't be used to launch external sites (no SSRF).
|
|
3866
|
-
// the
|
|
3895
|
+
// Accepts ONLY { nodeId, url? } and opens this server's own surface URL — never
|
|
3896
|
+
// an arbitrary URL — so it can't be used to launch external sites (no SSRF).
|
|
3897
|
+
// The optional URL is limited to the same node surface route so callers can keep
|
|
3898
|
+
// safe presentation query params like the current theme.
|
|
3867
3899
|
async function handleOpenExternalSurface(req: Request): Promise<Response> {
|
|
3868
3900
|
const body = await readJson(req);
|
|
3869
3901
|
const nodeId = typeof body.nodeId === 'string' ? body.nodeId : '';
|
|
@@ -3872,7 +3904,14 @@ async function handleOpenExternalSurface(req: Request): Promise<Response> {
|
|
|
3872
3904
|
if (!node) return responseJson({ ok: false, error: `Node "${nodeId}" not found.` }, 404);
|
|
3873
3905
|
const port = getCanvasServerPort();
|
|
3874
3906
|
if (!port) return responseJson({ ok: false, opened: false, error: 'Server port unavailable.' }, 503);
|
|
3875
|
-
const
|
|
3907
|
+
const defaultSurfacePath = `/api/canvas/surface/${encodeURIComponent(nodeId)}`;
|
|
3908
|
+
const rawUrl = typeof body.url === 'string' ? body.url : defaultSurfacePath;
|
|
3909
|
+
const parsedUrl = new URL(rawUrl, `http://localhost:${port}`);
|
|
3910
|
+
if (parsedUrl.origin !== `http://localhost:${port}` || parsedUrl.pathname !== defaultSurfacePath) {
|
|
3911
|
+
return responseJson({ ok: false, error: 'url must target the requested node surface.' }, 400);
|
|
3912
|
+
}
|
|
3913
|
+
const theme = normalizeSurfaceTheme(parsedUrl.searchParams.get('theme'));
|
|
3914
|
+
const surfacePath = `${defaultSurfacePath}?theme=${encodeURIComponent(theme)}`;
|
|
3876
3915
|
const opened = openUrlInExternalBrowser(`http://localhost:${port}${surfacePath}`);
|
|
3877
3916
|
return responseJson({ ok: true, opened, url: surfacePath });
|
|
3878
3917
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content-height reporter — injected into iframe-backed canvas surfaces so the
|
|
3
|
+
* parent canvas can grow the node to fit its content (the #48 graph-clipping fix).
|
|
4
|
+
*
|
|
5
|
+
* The surface posts its natural `document` scrollHeight to `window.parent` over a
|
|
6
|
+
* nonce-validated channel; the parent (use-iframe-content-height) grows the node
|
|
7
|
+
* grow-only to fit. Debounced (~100ms) + dead-banded (>4px) so a stray re-measure
|
|
8
|
+
* can't spam, and grow-only growth on the parent side cannot oscillate.
|
|
9
|
+
*
|
|
10
|
+
* Shared by both injection sites — src/server/html-surface.ts (html / web-artifact
|
|
11
|
+
* surfaces) and src/json-render/server.ts (the json-render/graph viewer) — so the
|
|
12
|
+
* two stay byte-identical. This module is framework-agnostic and imports nothing
|
|
13
|
+
* from src/server, preserving the json-render package's decoupling.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Sanitize a nonce for safe interpolation into an inline script literal. */
|
|
17
|
+
export function sanitizeFrameToken(token: string): string {
|
|
18
|
+
return token.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 80);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Inline JS (no `<script>` wrapper) that reports content height to the parent. */
|
|
22
|
+
export function contentHeightReporterSource(frameToken: string): string {
|
|
23
|
+
const token = JSON.stringify(sanitizeFrameToken(frameToken));
|
|
24
|
+
return `(function(){var T=${token};var last=0,timer=null;`
|
|
25
|
+
+ `function m(){var d=document.documentElement;return Math.max(d?d.scrollHeight:0,document.body?document.body.scrollHeight:0);}`
|
|
26
|
+
+ `function r(){var h=m();if(Math.abs(h-last)<=4)return;last=h;window.parent.postMessage({source:'pmx-canvas-frame',type:'content-height',token:T,height:h},'*');}`
|
|
27
|
+
+ `function s(){if(timer)return;timer=setTimeout(function(){timer=null;r();},100);}`
|
|
28
|
+
+ `if(document.readyState!=='loading')s();window.addEventListener('load',s);`
|
|
29
|
+
+ `try{new ResizeObserver(s).observe(document.documentElement);}catch(e){}setTimeout(s,60);})();`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** `<script>`-wrapped reporter for injection into an HTML `<head>` / document. */
|
|
33
|
+
export function contentHeightReporterTag(frameToken: string): string {
|
|
34
|
+
return `<script data-pmx-canvas-content-height>${contentHeightReporterSource(frameToken)}</script>`;
|
|
35
|
+
}
|