pmx-canvas 0.1.33 → 0.1.35
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 +67 -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/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/html-surface.ts +18 -1
- package/src/server/server.ts +70 -8
- 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
|
},
|
|
@@ -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';
|
|
@@ -1072,6 +1072,34 @@ async function readJson(req: Request): Promise<Record<string, unknown>> {
|
|
|
1072
1072
|
}
|
|
1073
1073
|
}
|
|
1074
1074
|
|
|
1075
|
+
/**
|
|
1076
|
+
* Like {@link readJson}, but PRESERVES a top-level JSON array. For endpoints that
|
|
1077
|
+
* accept either an object or a bare array (e.g. `/api/canvas/batch`, whose CLI
|
|
1078
|
+
* help and handler both document a bare `[...]` form). readJson coerces arrays to
|
|
1079
|
+
* `{}` so object-shaped handlers never crash on `body.field`; this variant keeps
|
|
1080
|
+
* the array so the handler's array branch can run. Empty/whitespace/malformed
|
|
1081
|
+
* bodies still resolve to `{}`.
|
|
1082
|
+
*/
|
|
1083
|
+
async function readJsonObjectOrArray(req: Request): Promise<Record<string, unknown> | unknown[]> {
|
|
1084
|
+
let text = '';
|
|
1085
|
+
try {
|
|
1086
|
+
text = await req.text();
|
|
1087
|
+
} catch (error) {
|
|
1088
|
+
logWorkbenchWarning('readJson', error);
|
|
1089
|
+
return {};
|
|
1090
|
+
}
|
|
1091
|
+
if (!text.trim()) return {};
|
|
1092
|
+
try {
|
|
1093
|
+
const value = JSON.parse(text) as unknown;
|
|
1094
|
+
if (Array.isArray(value)) return value;
|
|
1095
|
+
if (!value || typeof value !== 'object') return {};
|
|
1096
|
+
return value as Record<string, unknown>;
|
|
1097
|
+
} catch (error) {
|
|
1098
|
+
logWorkbenchWarning('readJson', error);
|
|
1099
|
+
return {};
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1075
1103
|
function htmlEscape(value: string): string {
|
|
1076
1104
|
return value
|
|
1077
1105
|
.replaceAll('&', '&')
|
|
@@ -1456,6 +1484,8 @@ function handleNodeSurface(pathname: string, url: URL): Response {
|
|
|
1456
1484
|
nodeId: node.id,
|
|
1457
1485
|
// Seed the read-side bridge with the current AX state (only for AX surfaces).
|
|
1458
1486
|
...(axEnabled ? { axState: buildCanvasAxSurfaceSnapshot() } : {}),
|
|
1487
|
+
// Content-height reporter nonce (lets an html node grow to fit its content).
|
|
1488
|
+
...(url.searchParams.get('frameToken') ? { contentHeightToken: url.searchParams.get('frameToken') as string } : {}),
|
|
1459
1489
|
});
|
|
1460
1490
|
return surfaceHtmlResponse(doc, HTML_SURFACE_SANDBOX);
|
|
1461
1491
|
}
|
|
@@ -2422,7 +2452,14 @@ async function handleCanvasAddGraph(req: Request): Promise<Response> {
|
|
|
2422
2452
|
const x = pickFiniteNumber(body, 'x') ?? (position ? pickFiniteNumber(position, 'x') : undefined);
|
|
2423
2453
|
const y = pickFiniteNumber(body, 'y') ?? (position ? pickFiniteNumber(position, 'y') : undefined);
|
|
2424
2454
|
const width = pickPositiveNumber(body, 'width') ?? (size ? pickPositiveNumber(size, 'width') : undefined);
|
|
2425
|
-
|
|
2455
|
+
// Node FRAME height. `body.height` is the CHART plot height (passed through as
|
|
2456
|
+
// `input.height` below), so the node frame accepts `nodeHeight` / `heightPx` /
|
|
2457
|
+
// `size.height` as aliases — `heightPx` matches createCanvasGraphNode's own input
|
|
2458
|
+
// field, the natural thing a caller reaches for. (With content-fit the node grows
|
|
2459
|
+
// to the chart anyway; this just removes the silent "height ignored" surprise.)
|
|
2460
|
+
const nodeHeight = pickPositiveNumber(body, 'nodeHeight')
|
|
2461
|
+
?? pickPositiveNumber(body, 'heightPx')
|
|
2462
|
+
?? (size ? pickPositiveNumber(size, 'height') : undefined);
|
|
2426
2463
|
const showLegend = typeof body.showLegend === 'boolean' ? body.showLegend : undefined;
|
|
2427
2464
|
const showLabels = typeof body.showLabels === 'boolean' ? body.showLabels : undefined;
|
|
2428
2465
|
const colorBy =
|
|
@@ -2486,8 +2523,12 @@ async function handleCanvasAddGraph(req: Request): Promise<Response> {
|
|
|
2486
2523
|
}
|
|
2487
2524
|
|
|
2488
2525
|
async function handleCanvasBatch(req: Request): Promise<Response> {
|
|
2489
|
-
|
|
2490
|
-
|
|
2526
|
+
// Accept both documented shapes: { operations: [...] } and a bare [...] array.
|
|
2527
|
+
// Uses the array-preserving reader so the bare-array form isn't coerced to {}.
|
|
2528
|
+
const body = await readJsonObjectOrArray(req);
|
|
2529
|
+
const operations = Array.isArray(body)
|
|
2530
|
+
? body
|
|
2531
|
+
: Array.isArray(body.operations) ? body.operations : [];
|
|
2491
2532
|
const normalized = operations
|
|
2492
2533
|
.filter((operation): operation is Record<string, unknown> => operation && typeof operation === 'object' && !Array.isArray(operation))
|
|
2493
2534
|
.map((operation) => ({
|
|
@@ -2549,6 +2590,8 @@ async function handleJsonRenderView(url: URL): Promise<Response> {
|
|
|
2549
2590
|
url.searchParams.get('devtools') === '1';
|
|
2550
2591
|
const axToken = url.searchParams.get('axToken');
|
|
2551
2592
|
const axEnabled = resolveNodeAxCapabilities(node).enabled;
|
|
2593
|
+
const frameToken = url.searchParams.get('frameToken');
|
|
2594
|
+
const fitContent = url.searchParams.get('fit') === 'content';
|
|
2552
2595
|
const html = await buildJsonRenderViewerHtml({
|
|
2553
2596
|
title,
|
|
2554
2597
|
spec,
|
|
@@ -2558,6 +2601,9 @@ async function handleJsonRenderView(url: URL): Promise<Response> {
|
|
|
2558
2601
|
...(axToken ? { nodeId, axToken } : {}),
|
|
2559
2602
|
// Seed the read-side AX state (only for AX-enabled nodes) so specs can bind /ax.
|
|
2560
2603
|
...(axToken && axEnabled ? { axState: buildCanvasAxSurfaceSnapshot() } : {}),
|
|
2604
|
+
// Content-fit: report natural height (charts render intrinsic) so the node grows.
|
|
2605
|
+
...(frameToken ? { frameToken } : {}),
|
|
2606
|
+
...(fitContent ? { fitContent: true } : {}),
|
|
2561
2607
|
});
|
|
2562
2608
|
return new Response(html, {
|
|
2563
2609
|
headers: {
|
|
@@ -2636,6 +2682,14 @@ function handleArtifactView(url: URL): Response {
|
|
|
2636
2682
|
: `${bridge}${content}`;
|
|
2637
2683
|
}
|
|
2638
2684
|
}
|
|
2685
|
+
// Content-height reporter so a web-artifact node grows to fit its app (#48).
|
|
2686
|
+
const frameToken = url.searchParams.get('frameToken');
|
|
2687
|
+
if (frameToken) {
|
|
2688
|
+
const reporter = buildContentHeightReporter(frameToken.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 80));
|
|
2689
|
+
content = content.includes('</head>')
|
|
2690
|
+
? content.replace('</head>', `${reporter}</head>`)
|
|
2691
|
+
: `${reporter}${content}`;
|
|
2692
|
+
}
|
|
2639
2693
|
return new Response(content, {
|
|
2640
2694
|
headers: {
|
|
2641
2695
|
'Content-Type': 'text/html; charset=utf-8',
|
|
@@ -3870,9 +3924,10 @@ function handleGetAxSurfaceSnapshot(): Response {
|
|
|
3870
3924
|
|
|
3871
3925
|
// Open a node's surface in the user's real system browser (for hosts whose
|
|
3872
3926
|
// embedded browser makes window.open('_blank') feel in-place, e.g. Codex).
|
|
3873
|
-
// Accepts ONLY { nodeId } and opens this server's own surface URL — never
|
|
3874
|
-
// arbitrary URL — so it can't be used to launch external sites (no SSRF).
|
|
3875
|
-
// the
|
|
3927
|
+
// Accepts ONLY { nodeId, url? } and opens this server's own surface URL — never
|
|
3928
|
+
// an arbitrary URL — so it can't be used to launch external sites (no SSRF).
|
|
3929
|
+
// The optional URL is limited to the same node surface route so callers can keep
|
|
3930
|
+
// safe presentation query params like the current theme.
|
|
3876
3931
|
async function handleOpenExternalSurface(req: Request): Promise<Response> {
|
|
3877
3932
|
const body = await readJson(req);
|
|
3878
3933
|
const nodeId = typeof body.nodeId === 'string' ? body.nodeId : '';
|
|
@@ -3881,7 +3936,14 @@ async function handleOpenExternalSurface(req: Request): Promise<Response> {
|
|
|
3881
3936
|
if (!node) return responseJson({ ok: false, error: `Node "${nodeId}" not found.` }, 404);
|
|
3882
3937
|
const port = getCanvasServerPort();
|
|
3883
3938
|
if (!port) return responseJson({ ok: false, opened: false, error: 'Server port unavailable.' }, 503);
|
|
3884
|
-
const
|
|
3939
|
+
const defaultSurfacePath = `/api/canvas/surface/${encodeURIComponent(nodeId)}`;
|
|
3940
|
+
const rawUrl = typeof body.url === 'string' ? body.url : defaultSurfacePath;
|
|
3941
|
+
const parsedUrl = new URL(rawUrl, `http://localhost:${port}`);
|
|
3942
|
+
if (parsedUrl.origin !== `http://localhost:${port}` || parsedUrl.pathname !== defaultSurfacePath) {
|
|
3943
|
+
return responseJson({ ok: false, error: 'url must target the requested node surface.' }, 400);
|
|
3944
|
+
}
|
|
3945
|
+
const theme = normalizeSurfaceTheme(parsedUrl.searchParams.get('theme'));
|
|
3946
|
+
const surfacePath = `${defaultSurfacePath}?theme=${encodeURIComponent(theme)}`;
|
|
3885
3947
|
const opened = openUrlInExternalBrowser(`http://localhost:${port}${surfacePath}`);
|
|
3886
3948
|
return responseJson({ ok: true, opened, url: surfacePath });
|
|
3887
3949
|
}
|
|
@@ -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
|
+
}
|