pmx-canvas 0.1.19 → 0.1.20
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 +74 -0
- package/Readme.md +19 -6
- package/dist/canvas/global.css +35 -2
- package/dist/canvas/index.js +70 -69
- package/dist/json-render/index.js +109 -109
- package/dist/types/client/canvas/CanvasViewport.d.ts +1 -1
- package/dist/types/client/icons.d.ts +2 -0
- package/dist/types/client/state/canvas-store.d.ts +2 -0
- package/dist/types/client/types.d.ts +2 -1
- package/dist/types/json-render/charts/components.d.ts +5 -1
- package/dist/types/json-render/renderer/index.d.ts +1 -0
- package/dist/types/json-render/server.d.ts +1 -0
- package/dist/types/mcp/canvas-access.d.ts +3 -0
- package/dist/types/server/canvas-operations.d.ts +4 -0
- package/dist/types/server/canvas-schema.d.ts +19 -3
- package/dist/types/server/canvas-serialization.d.ts +1 -0
- package/dist/types/server/canvas-state.d.ts +6 -2
- package/dist/types/server/html-primitives.d.ts +34 -0
- package/dist/types/server/index.d.ts +19 -0
- package/docs/cli.md +4 -1
- package/docs/http-api.md +10 -0
- package/docs/mcp.md +6 -4
- package/docs/node-types.md +30 -2
- package/docs/screenshot.png +0 -0
- package/docs/sdk.md +11 -0
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +8 -0
- package/src/cli/agent.ts +150 -5
- package/src/client/App.tsx +20 -1
- package/src/client/canvas/AnnotationLayer.tsx +33 -12
- package/src/client/canvas/CanvasViewport.tsx +88 -7
- package/src/client/canvas/CommandPalette.tsx +1 -1
- package/src/client/canvas/ContextMenu.tsx +2 -2
- package/src/client/canvas/ExpandedNodeOverlay.tsx +7 -1
- package/src/client/icons.tsx +13 -0
- package/src/client/nodes/McpAppNode.tsx +12 -4
- package/src/client/state/canvas-store.ts +15 -5
- package/src/client/state/sse-bridge.ts +4 -3
- package/src/client/theme/global.css +35 -2
- package/src/client/types.ts +2 -1
- package/src/json-render/charts/components.tsx +41 -7
- package/src/json-render/charts/extra-components.tsx +13 -12
- package/src/json-render/renderer/index.tsx +1 -0
- package/src/json-render/server.ts +3 -1
- package/src/mcp/canvas-access.ts +23 -0
- package/src/mcp/server.ts +83 -27
- package/src/server/agent-context.ts +17 -0
- package/src/server/canvas-operations.ts +91 -38
- package/src/server/canvas-schema.ts +83 -3
- package/src/server/canvas-serialization.ts +9 -2
- package/src/server/canvas-state.ts +9 -4
- package/src/server/demo-state.json +1143 -0
- package/src/server/demo.ts +25 -777
- package/src/server/html-primitives.ts +990 -0
- package/src/server/index.ts +43 -2
- package/src/server/server.ts +138 -14
- package/src/server/spatial-analysis.ts +3 -3
package/src/cli/agent.ts
CHANGED
|
@@ -73,6 +73,15 @@ interface CanvasSchemaResponse {
|
|
|
73
73
|
'line' | 'bar' | 'pie' | 'area' | 'scatter' | 'radar' | 'stacked-bar' | 'composed'
|
|
74
74
|
>;
|
|
75
75
|
};
|
|
76
|
+
htmlPrimitives?: Array<{
|
|
77
|
+
kind: string;
|
|
78
|
+
title: string;
|
|
79
|
+
description: string;
|
|
80
|
+
useWhen: string;
|
|
81
|
+
defaultSize: { width: number; height: number };
|
|
82
|
+
dataShape: string;
|
|
83
|
+
example: Record<string, unknown>;
|
|
84
|
+
}>;
|
|
76
85
|
mcp: {
|
|
77
86
|
tools: string[];
|
|
78
87
|
resources: string[];
|
|
@@ -480,6 +489,14 @@ function parseJsonValue(raw: string, label: string, hint: string): unknown {
|
|
|
480
489
|
}
|
|
481
490
|
}
|
|
482
491
|
|
|
492
|
+
function parseJsonRecord(raw: string, label: string, hint: string): Record<string, unknown> {
|
|
493
|
+
const parsed = parseJsonValue(raw, label, hint);
|
|
494
|
+
if (!isRecord(parsed)) {
|
|
495
|
+
die(`${label} must be a JSON object.`, hint);
|
|
496
|
+
}
|
|
497
|
+
return parsed;
|
|
498
|
+
}
|
|
499
|
+
|
|
483
500
|
async function readTextInput(
|
|
484
501
|
flags: Record<string, string | true>,
|
|
485
502
|
options: {
|
|
@@ -615,6 +632,34 @@ async function buildJsonRenderRequestBody(
|
|
|
615
632
|
return body;
|
|
616
633
|
}
|
|
617
634
|
|
|
635
|
+
async function buildHtmlPrimitiveRequestBody(
|
|
636
|
+
flags: Record<string, string | true>,
|
|
637
|
+
): Promise<Record<string, unknown>> {
|
|
638
|
+
const hint = 'Use: pmx-canvas html primitive add --kind choice-grid --data-file ./primitive.json --title "Options"';
|
|
639
|
+
const kind = getStringFlag(flags, 'kind', 'primitive');
|
|
640
|
+
if (!kind) die('HTML primitives require --kind.', hint);
|
|
641
|
+
const body: Record<string, unknown> = { type: 'html', primitive: kind };
|
|
642
|
+
if (typeof flags.title === 'string') body.title = flags.title;
|
|
643
|
+
const rawData = await readOptionalTextInput(flags, {
|
|
644
|
+
fileFlags: ['data-file'],
|
|
645
|
+
valueFlags: ['data-json', 'data'],
|
|
646
|
+
allowStdin: true,
|
|
647
|
+
label: 'HTML primitive data',
|
|
648
|
+
hint,
|
|
649
|
+
});
|
|
650
|
+
if (rawData !== undefined) {
|
|
651
|
+
body.data = parseJsonRecord(rawData, 'HTML primitive data', hint);
|
|
652
|
+
}
|
|
653
|
+
applyCommonGeometryFlags(body, flags, {
|
|
654
|
+
x: 'Use a finite number, e.g. --x 500',
|
|
655
|
+
y: 'Use a finite number, e.g. --y 300',
|
|
656
|
+
width: 'Use a positive number, e.g. --width 980',
|
|
657
|
+
height: 'Use a positive number, e.g. --height 720',
|
|
658
|
+
});
|
|
659
|
+
applyStrictSizeFlags(body, flags);
|
|
660
|
+
return body;
|
|
661
|
+
}
|
|
662
|
+
|
|
618
663
|
async function buildGraphRequestBody(
|
|
619
664
|
flags: Record<string, string | true>,
|
|
620
665
|
options: { requireData?: boolean; allowStdin?: boolean } = {},
|
|
@@ -967,6 +1012,35 @@ function filterJsonRenderSchemaView(
|
|
|
967
1012
|
return flags.summary ? summarizeJsonRenderComponent(component) : component;
|
|
968
1013
|
}
|
|
969
1014
|
|
|
1015
|
+
function summarizeHtmlPrimitive(primitive: NonNullable<CanvasSchemaResponse['htmlPrimitives']>[number]): Record<string, unknown> {
|
|
1016
|
+
return {
|
|
1017
|
+
kind: primitive.kind,
|
|
1018
|
+
title: primitive.title,
|
|
1019
|
+
description: primitive.description,
|
|
1020
|
+
useWhen: primitive.useWhen,
|
|
1021
|
+
defaultSize: primitive.defaultSize,
|
|
1022
|
+
dataShape: primitive.dataShape,
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
function filterHtmlPrimitiveSchemaView(
|
|
1027
|
+
schema: CanvasSchemaResponse,
|
|
1028
|
+
flags: Record<string, string | true>,
|
|
1029
|
+
): Record<string, unknown> {
|
|
1030
|
+
const primitives = schema.htmlPrimitives ?? [];
|
|
1031
|
+
const kind = getStringFlag(flags, 'kind', 'primitive');
|
|
1032
|
+
if (!kind) {
|
|
1033
|
+
return {
|
|
1034
|
+
primitives: flags.summary ? primitives.map((entry) => summarizeHtmlPrimitive(entry)) : primitives,
|
|
1035
|
+
};
|
|
1036
|
+
}
|
|
1037
|
+
const primitive = primitives.find((entry) => entry.kind === kind);
|
|
1038
|
+
if (!primitive) {
|
|
1039
|
+
die(`Unknown HTML primitive: ${kind}`, 'Run: pmx-canvas html primitive schema --summary');
|
|
1040
|
+
}
|
|
1041
|
+
return flags.summary ? summarizeHtmlPrimitive(primitive) : primitive;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
970
1044
|
// ── Commands ─────────────────────────────────────────────────
|
|
971
1045
|
|
|
972
1046
|
const COMMANDS: Record<string, { run: (args: string[]) => Promise<void>; help: string; examples: string[] }> = {};
|
|
@@ -1028,6 +1102,7 @@ cmd('node add', 'Add a node to the canvas', [
|
|
|
1028
1102
|
'pmx-canvas node add --type file --content "src/index.ts"',
|
|
1029
1103
|
'pmx-canvas node add --type webpage --url "https://example.com/docs"',
|
|
1030
1104
|
'pmx-canvas node add --type html --title "Widget" --content "<main>Hello</main>"',
|
|
1105
|
+
'pmx-canvas node add --type html --primitive choice-grid --data-file ./options.json --title "Options"',
|
|
1031
1106
|
'pmx-canvas node add --type markdown --title "Note" --x 100 --y 200',
|
|
1032
1107
|
'pmx-canvas node add --type json-render --title "Ops Dashboard" --spec-file ./dashboard.json',
|
|
1033
1108
|
'pmx-canvas node add --type graph --graph-type bar --data-file ./metrics.json --x-key label --y-key value',
|
|
@@ -1055,6 +1130,18 @@ cmd('node add', 'Add a node to the canvas', [
|
|
|
1055
1130
|
return;
|
|
1056
1131
|
}
|
|
1057
1132
|
|
|
1133
|
+
if (type === 'html-primitive') {
|
|
1134
|
+
const result = await api('POST', '/api/canvas/node', await buildHtmlPrimitiveRequestBody(flags));
|
|
1135
|
+
output(result);
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
if (type === 'html' && getStringFlag(flags, 'primitive', 'kind')) {
|
|
1140
|
+
const result = await api('POST', '/api/canvas/node', await buildHtmlPrimitiveRequestBody(flags));
|
|
1141
|
+
output(result);
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1058
1145
|
if (type === 'mcp-app') {
|
|
1059
1146
|
die(
|
|
1060
1147
|
'mcp-app nodes require tool-backed app metadata and cannot be created with generic node add.',
|
|
@@ -1136,6 +1223,28 @@ cmd('json-render', 'Show json-render schema and canonical examples', [
|
|
|
1136
1223
|
output(filterJsonRenderSchemaView(schema.jsonRender, flags));
|
|
1137
1224
|
});
|
|
1138
1225
|
|
|
1226
|
+
cmd('html primitive add', 'Create a reusable sandboxed HTML communication primitive', [
|
|
1227
|
+
'pmx-canvas html primitive add --kind choice-grid --data-file ./options.json --title "Options"',
|
|
1228
|
+
'pmx-canvas html primitive add --kind plan-timeline --data-json \'{"milestones":[{"title":"Ship","detail":"Implement and verify","status":"next"}]}\'',
|
|
1229
|
+
'pmx-canvas html primitive add --kind triage-board --data-file ./tickets.json --strict-size',
|
|
1230
|
+
], async (args) => {
|
|
1231
|
+
const { flags } = parseFlags(args);
|
|
1232
|
+
if (flags.help || flags.h) return showCommandHelp('html primitive add');
|
|
1233
|
+
const result = await api('POST', '/api/canvas/node', await buildHtmlPrimitiveRequestBody(flags));
|
|
1234
|
+
output(result);
|
|
1235
|
+
});
|
|
1236
|
+
|
|
1237
|
+
cmd('html primitive schema', 'Describe reusable HTML communication primitives', [
|
|
1238
|
+
'pmx-canvas html primitive schema --summary',
|
|
1239
|
+
'pmx-canvas html primitive schema --kind choice-grid',
|
|
1240
|
+
'pmx-canvas html primitive schema --kind triage-board --summary',
|
|
1241
|
+
], async (args) => {
|
|
1242
|
+
const { flags } = parseFlags(args);
|
|
1243
|
+
if (flags.help || flags.h) return showCommandHelp('html primitive schema');
|
|
1244
|
+
const schema = await loadCanvasSchema();
|
|
1245
|
+
output(filterHtmlPrimitiveSchemaView(schema, flags));
|
|
1246
|
+
});
|
|
1247
|
+
|
|
1139
1248
|
cmd('graph add', 'Add a graph node to the canvas', [
|
|
1140
1249
|
'pmx-canvas graph add --graph-type bar --data-file ./metrics.json --x-key label --y-key value',
|
|
1141
1250
|
'pmx-canvas graph add --graphType composed --data \'[{"day":"Mon","visits":10,"conversion":0.4}]\' --xKey day --barKey visits --lineKey conversion',
|
|
@@ -1176,6 +1285,7 @@ cmd('node schema', 'Describe server-supported node create schemas and canonical
|
|
|
1176
1285
|
rootShape: result.jsonRender.rootShape,
|
|
1177
1286
|
},
|
|
1178
1287
|
graph: result.graph,
|
|
1288
|
+
htmlPrimitives: result.htmlPrimitives?.map((entry) => summarizeHtmlPrimitive(entry)) ?? [],
|
|
1179
1289
|
mcp: result.mcp,
|
|
1180
1290
|
});
|
|
1181
1291
|
return;
|
|
@@ -1898,19 +2008,31 @@ cmd('validate', 'Validate the current layout for collisions and missing edge end
|
|
|
1898
2008
|
cmd('validate spec', 'Validate a json-render spec or graph payload without creating a node', [
|
|
1899
2009
|
'pmx-canvas validate spec --type json-render --spec-file ./dashboard.json',
|
|
1900
2010
|
'pmx-canvas validate spec --type graph --graph-type bar --data-file ./metrics.json --x-key label --y-key value',
|
|
2011
|
+
'pmx-canvas validate spec --type html-primitive --kind choice-grid --data-file ./options.json',
|
|
1901
2012
|
'pmx-canvas validate spec --type json-render --spec-file ./dashboard.json --summary',
|
|
1902
2013
|
], async (args) => {
|
|
1903
2014
|
const { flags } = parseFlags(args);
|
|
1904
2015
|
if (flags.help || flags.h) return showCommandHelp('validate spec');
|
|
1905
2016
|
|
|
1906
2017
|
const type = getStringFlag(flags, 'type');
|
|
1907
|
-
if (type !== 'json-render' && type !== 'graph') {
|
|
1908
|
-
die('validate spec requires --type json-render or --type
|
|
2018
|
+
if (type !== 'json-render' && type !== 'graph' && type !== 'html-primitive') {
|
|
2019
|
+
die('validate spec requires --type json-render, --type graph, or --type html-primitive.');
|
|
1909
2020
|
}
|
|
1910
2021
|
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
2022
|
+
let body: Record<string, unknown>;
|
|
2023
|
+
if (type === 'json-render') {
|
|
2024
|
+
body = { type, spec: (await buildJsonRenderRequestBody({ ...flags, title: String(flags.title ?? 'Validation') })).spec };
|
|
2025
|
+
} else if (type === 'html-primitive') {
|
|
2026
|
+
const primitiveBody = await buildHtmlPrimitiveRequestBody(flags);
|
|
2027
|
+
body = {
|
|
2028
|
+
type,
|
|
2029
|
+
kind: primitiveBody.primitive,
|
|
2030
|
+
...(typeof primitiveBody.title === 'string' ? { title: primitiveBody.title } : {}),
|
|
2031
|
+
...(isRecord(primitiveBody.data) ? { data: primitiveBody.data } : {}),
|
|
2032
|
+
};
|
|
2033
|
+
} else {
|
|
2034
|
+
body = { type, ...(await buildGraphRequestBody(flags)) };
|
|
2035
|
+
}
|
|
1914
2036
|
|
|
1915
2037
|
const result = await api('POST', '/api/canvas/schema/validate', body) as Record<string, unknown>;
|
|
1916
2038
|
if (flags.summary) {
|
|
@@ -2302,9 +2424,17 @@ function showCommandHelp(name: string): void {
|
|
|
2302
2424
|
console.log(' pmx-canvas node add --help --type webpage');
|
|
2303
2425
|
console.log(' pmx-canvas node add --help --type json-render --component Table');
|
|
2304
2426
|
console.log(' pmx-canvas node add --help --type graph');
|
|
2427
|
+
console.log(' pmx-canvas html primitive schema --summary');
|
|
2305
2428
|
console.log(' pmx-canvas node add --help --type webpage --json');
|
|
2306
2429
|
console.log(' Use --strict-size to keep explicit width/height fixed and scroll overflowing content.');
|
|
2307
2430
|
}
|
|
2431
|
+
if (name === 'html primitive add' || name === 'html primitive schema') {
|
|
2432
|
+
console.log('\nPrimitive flags:');
|
|
2433
|
+
console.log(' --kind <name> Run `pmx-canvas html primitive schema --summary` for the full catalog');
|
|
2434
|
+
console.log(' --data-file <path> JSON object payload for the primitive');
|
|
2435
|
+
console.log(' --data-json, --data <json> Inline JSON object payload');
|
|
2436
|
+
console.log(' --stdin Read JSON object payload from stdin');
|
|
2437
|
+
}
|
|
2308
2438
|
if (name === 'json-render') {
|
|
2309
2439
|
console.log('\nOptions:');
|
|
2310
2440
|
console.log(' --schema Show json-render catalog schema (default)');
|
|
@@ -2319,6 +2449,10 @@ function showCommandHelp(name: string): void {
|
|
|
2319
2449
|
console.log(' Use --node-height/--nodeHeight for canvas frame height; use --chart-height for chart content height. --height is kept as a frame-height alias for compatibility.');
|
|
2320
2450
|
console.log(' Pass --show-legend false to hide legends in compact node layouts.');
|
|
2321
2451
|
}
|
|
2452
|
+
if (name === 'validate spec') {
|
|
2453
|
+
console.log('\nHTML primitive flags:');
|
|
2454
|
+
console.log(' --type html-primitive --kind <name> --data-file ./payload.json');
|
|
2455
|
+
}
|
|
2322
2456
|
if (name === 'node schema') {
|
|
2323
2457
|
console.log('\nFilters:');
|
|
2324
2458
|
console.log(' --summary Show compact schema summaries');
|
|
@@ -2328,6 +2462,7 @@ function showCommandHelp(name: string): void {
|
|
|
2328
2462
|
if (name === 'validate spec') {
|
|
2329
2463
|
console.log('\nOutput control:');
|
|
2330
2464
|
console.log(' --summary Return only validation summary metadata');
|
|
2465
|
+
console.log(' For --type html-primitive, pass --kind plus optional --data-file/--data-json.');
|
|
2331
2466
|
}
|
|
2332
2467
|
if (name === 'snapshot list') {
|
|
2333
2468
|
console.log('\nOptions:');
|
|
@@ -2415,6 +2550,8 @@ Node commands:
|
|
|
2415
2550
|
pmx-canvas node remove <id> Remove a node
|
|
2416
2551
|
pmx-canvas json-render Show json-render schema/examples
|
|
2417
2552
|
pmx-canvas graph add [options] Add a graph node
|
|
2553
|
+
pmx-canvas html primitive add Add an HTML communication primitive
|
|
2554
|
+
pmx-canvas html primitive schema List HTML primitive kinds and shapes
|
|
2418
2555
|
|
|
2419
2556
|
Edge commands:
|
|
2420
2557
|
pmx-canvas edge add [options] Add an edge between nodes
|
|
@@ -2488,6 +2625,8 @@ Examples:
|
|
|
2488
2625
|
pmx-canvas node add --type web-artifact --title "Dashboard" --app-file ./App.tsx
|
|
2489
2626
|
pmx-canvas node add --type graph --graph-type bar --data-file ./metrics.json --x-key label --y-key value
|
|
2490
2627
|
pmx-canvas graph add --graph-type bar --data-file ./metrics.json --x-key label --y-key value
|
|
2628
|
+
pmx-canvas html primitive add --kind choice-grid --data-file ./options.json --title "Options"
|
|
2629
|
+
pmx-canvas html primitive schema --summary
|
|
2491
2630
|
pmx-canvas node add --help --type webpage
|
|
2492
2631
|
pmx-canvas node schema --type json-render
|
|
2493
2632
|
pmx-canvas node schema --type json-render --component Table --summary
|
|
@@ -2534,6 +2673,12 @@ export async function runAgentCli(args: string[]): Promise<void> {
|
|
|
2534
2673
|
return;
|
|
2535
2674
|
}
|
|
2536
2675
|
|
|
2676
|
+
const threeWord = `${args[0]} ${args[1] ?? ''} ${args[2] ?? ''}`.trim();
|
|
2677
|
+
if (COMMANDS[threeWord]) {
|
|
2678
|
+
await COMMANDS[threeWord].run(args.slice(3));
|
|
2679
|
+
return;
|
|
2680
|
+
}
|
|
2681
|
+
|
|
2537
2682
|
// Try two-word command first (e.g., "node add"), then one-word (e.g., "search")
|
|
2538
2683
|
const twoWord = `${args[0]} ${args[1] ?? ''}`.trim();
|
|
2539
2684
|
if (COMMANDS[twoWord]) {
|
package/src/client/App.tsx
CHANGED
|
@@ -52,6 +52,7 @@ import {
|
|
|
52
52
|
IconShortcuts,
|
|
53
53
|
IconSnapshot,
|
|
54
54
|
IconSun,
|
|
55
|
+
IconTextAnnotation,
|
|
55
56
|
IconTrace,
|
|
56
57
|
IconZoomIn,
|
|
57
58
|
IconZoomOut,
|
|
@@ -73,7 +74,7 @@ function sendIntent(type: string, payload: Record<string, unknown> = {}): void {
|
|
|
73
74
|
});
|
|
74
75
|
}
|
|
75
76
|
|
|
76
|
-
type AnnotationTool = 'pen' | 'eraser' | null;
|
|
77
|
+
type AnnotationTool = 'pen' | 'eraser' | 'text' | null;
|
|
77
78
|
|
|
78
79
|
function ToolbarHint({
|
|
79
80
|
label,
|
|
@@ -115,6 +116,7 @@ function Toolbar({
|
|
|
115
116
|
annotationTool,
|
|
116
117
|
onToggleAnnotationMode,
|
|
117
118
|
onToggleAnnotationEraser,
|
|
119
|
+
onToggleTextAnnotation,
|
|
118
120
|
}: {
|
|
119
121
|
minimapVisible: boolean;
|
|
120
122
|
onToggleMinimap: () => void;
|
|
@@ -126,6 +128,7 @@ function Toolbar({
|
|
|
126
128
|
annotationTool: AnnotationTool;
|
|
127
129
|
onToggleAnnotationMode: () => void;
|
|
128
130
|
onToggleAnnotationEraser: () => void;
|
|
131
|
+
onToggleTextAnnotation: () => void;
|
|
129
132
|
}) {
|
|
130
133
|
const status = connectionStatus.value;
|
|
131
134
|
const hasSynced = hasInitialServerLayout.value;
|
|
@@ -316,6 +319,20 @@ function Toolbar({
|
|
|
316
319
|
<IconEraser />
|
|
317
320
|
</button>
|
|
318
321
|
</ToolbarHint>
|
|
322
|
+
<ToolbarHint
|
|
323
|
+
label={annotationTool === 'text' ? 'Stop text annotations' : 'Text annotations'}
|
|
324
|
+
detail="Click anywhere to type an intent note"
|
|
325
|
+
>
|
|
326
|
+
<button
|
|
327
|
+
type="button"
|
|
328
|
+
onClick={onToggleTextAnnotation}
|
|
329
|
+
aria-label={annotationTool === 'text' ? 'Stop text annotations' : 'Text annotations'}
|
|
330
|
+
aria-pressed={annotationTool === 'text'}
|
|
331
|
+
style={{ color: annotationTool === 'text' ? 'var(--c-accent)' : undefined }}
|
|
332
|
+
>
|
|
333
|
+
<IconTextAnnotation />
|
|
334
|
+
</button>
|
|
335
|
+
</ToolbarHint>
|
|
319
336
|
|
|
320
337
|
<div class="separator" />
|
|
321
338
|
|
|
@@ -392,6 +409,7 @@ export function App() {
|
|
|
392
409
|
const handleCloseSnapshot = useCallback(() => setSnapshotOpen(false), []);
|
|
393
410
|
const handleToggleAnnotationMode = useCallback(() => setAnnotationTool((tool) => tool === 'pen' ? null : 'pen'), []);
|
|
394
411
|
const handleToggleAnnotationEraser = useCallback(() => setAnnotationTool((tool) => tool === 'eraser' ? null : 'eraser'), []);
|
|
412
|
+
const handleToggleTextAnnotation = useCallback(() => setAnnotationTool((tool) => tool === 'text' ? null : 'text'), []);
|
|
395
413
|
|
|
396
414
|
const handleMinimapNavigate = useCallback((x: number, y: number) => {
|
|
397
415
|
animateViewport({ x, y, scale: viewport.value.scale }, 200);
|
|
@@ -520,6 +538,7 @@ export function App() {
|
|
|
520
538
|
annotationTool={annotationTool}
|
|
521
539
|
onToggleAnnotationMode={handleToggleAnnotationMode}
|
|
522
540
|
onToggleAnnotationEraser={handleToggleAnnotationEraser}
|
|
541
|
+
onToggleTextAnnotation={handleToggleTextAnnotation}
|
|
523
542
|
/>
|
|
524
543
|
<div class="hud-right">
|
|
525
544
|
{dockedRight.map((n) => (
|
|
@@ -11,18 +11,39 @@ export function AnnotationLayer({ annotations }: { annotations: CanvasAnnotation
|
|
|
11
11
|
|
|
12
12
|
return (
|
|
13
13
|
<svg class="annotation-layer" aria-hidden="true">
|
|
14
|
-
{annotations.map((annotation) =>
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
14
|
+
{annotations.map((annotation) => {
|
|
15
|
+
const color = annotation.color === 'currentColor' ? 'var(--c-annotation)' : annotation.color;
|
|
16
|
+
if (annotation.type === 'text') {
|
|
17
|
+
const point = annotation.points[0];
|
|
18
|
+
if (!point || !annotation.text) return null;
|
|
19
|
+
return (
|
|
20
|
+
<text
|
|
21
|
+
key={annotation.id}
|
|
22
|
+
x={point.x}
|
|
23
|
+
y={point.y}
|
|
24
|
+
fill={color}
|
|
25
|
+
font-size={annotation.width}
|
|
26
|
+
font-family="var(--font)"
|
|
27
|
+
font-weight="700"
|
|
28
|
+
opacity="0.95"
|
|
29
|
+
>
|
|
30
|
+
{annotation.text}
|
|
31
|
+
</text>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
return (
|
|
35
|
+
<path
|
|
36
|
+
key={annotation.id}
|
|
37
|
+
d={pointsToPath(annotation.points)}
|
|
38
|
+
fill="none"
|
|
39
|
+
stroke={color}
|
|
40
|
+
stroke-width={annotation.width}
|
|
41
|
+
stroke-linecap="round"
|
|
42
|
+
stroke-linejoin="round"
|
|
43
|
+
opacity="0.9"
|
|
44
|
+
/>
|
|
45
|
+
);
|
|
46
|
+
})}
|
|
26
47
|
</svg>
|
|
27
48
|
);
|
|
28
49
|
}
|
|
@@ -102,6 +102,7 @@ function findAnnotationAtPoint(
|
|
|
102
102
|
point.y < annotation.bounds.y - pad ||
|
|
103
103
|
point.y > annotation.bounds.y + annotation.bounds.height + pad
|
|
104
104
|
) continue;
|
|
105
|
+
if (annotation.type === 'text') return annotation;
|
|
105
106
|
for (let index = 1; index < annotation.points.length; index++) {
|
|
106
107
|
const start = annotation.points[index - 1];
|
|
107
108
|
const end = annotation.points[index];
|
|
@@ -122,19 +123,27 @@ interface LassoRect {
|
|
|
122
123
|
|
|
123
124
|
interface AnnotationDraft {
|
|
124
125
|
id: string;
|
|
125
|
-
type: 'freehand';
|
|
126
|
+
type: 'freehand' | 'text';
|
|
126
127
|
points: Array<{ x: number; y: number }>;
|
|
127
128
|
bounds: { x: number; y: number; width: number; height: number };
|
|
128
129
|
color: string;
|
|
129
130
|
width: number;
|
|
131
|
+
text?: string;
|
|
130
132
|
createdAt: string;
|
|
131
133
|
}
|
|
132
134
|
|
|
135
|
+
interface TextAnnotationDraft {
|
|
136
|
+
x: number;
|
|
137
|
+
y: number;
|
|
138
|
+
value: string;
|
|
139
|
+
}
|
|
140
|
+
|
|
133
141
|
const ANNOTATION_COLOR = 'currentColor';
|
|
134
142
|
const ANNOTATION_WIDTH = 4;
|
|
143
|
+
const TEXT_ANNOTATION_WIDTH = 24;
|
|
135
144
|
const ERASER_HIT_RADIUS = 14;
|
|
136
145
|
|
|
137
|
-
type AnnotationTool = 'pen' | 'eraser' | null;
|
|
146
|
+
type AnnotationTool = 'pen' | 'eraser' | 'text' | null;
|
|
138
147
|
|
|
139
148
|
interface CanvasViewportProps {
|
|
140
149
|
onNodeContextMenu?: (e: MouseEvent, nodeId: string) => void;
|
|
@@ -233,6 +242,8 @@ export function CanvasViewport({ onNodeContextMenu, onCanvasContextMenu, annotat
|
|
|
233
242
|
const annotationPoints = useRef<Array<{ x: number; y: number }>>([]);
|
|
234
243
|
const [lasso, setLasso] = useState<LassoRect | null>(null);
|
|
235
244
|
const [draftAnnotation, setDraftAnnotation] = useState<AnnotationDraft | null>(null);
|
|
245
|
+
const [textDraft, setTextDraftState] = useState<TextAnnotationDraft | null>(null);
|
|
246
|
+
const textDraftRef = useRef<TextAnnotationDraft | null>(null);
|
|
236
247
|
const [dropActive, setDropActive] = useState(false);
|
|
237
248
|
const dropCounter = useRef(0);
|
|
238
249
|
// Ref mirrors lasso state so pointer handlers always read the latest value
|
|
@@ -256,6 +267,11 @@ export function CanvasViewport({ onNodeContextMenu, onCanvasContextMenu, annotat
|
|
|
256
267
|
},
|
|
257
268
|
});
|
|
258
269
|
|
|
270
|
+
const setTextDraft = useCallback((next: TextAnnotationDraft | null) => {
|
|
271
|
+
textDraftRef.current = next;
|
|
272
|
+
setTextDraftState(next);
|
|
273
|
+
}, []);
|
|
274
|
+
|
|
259
275
|
const createWebpageNodes = useCallback(async (urls: string[], centerX: number, centerY: number) => {
|
|
260
276
|
if (urls.length === 0) return;
|
|
261
277
|
|
|
@@ -305,6 +321,23 @@ export function CanvasViewport({ onNodeContextMenu, onCanvasContextMenu, annotat
|
|
|
305
321
|
return;
|
|
306
322
|
}
|
|
307
323
|
|
|
324
|
+
if (annotationTool === 'text') {
|
|
325
|
+
const target = e.target instanceof Element ? e.target : null;
|
|
326
|
+
if (target?.closest('.hud-layer, .snapshot-panel, .context-menu, .command-palette')) return;
|
|
327
|
+
e.preventDefault();
|
|
328
|
+
e.stopPropagation();
|
|
329
|
+
activeNodeId.value = null;
|
|
330
|
+
clearSelection();
|
|
331
|
+
const rect = container.getBoundingClientRect();
|
|
332
|
+
const vp = viewport.value;
|
|
333
|
+
setTextDraft({
|
|
334
|
+
x: (e.clientX - rect.left - vp.x) / vp.scale,
|
|
335
|
+
y: (e.clientY - rect.top - vp.y) / vp.scale,
|
|
336
|
+
value: '',
|
|
337
|
+
});
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
308
341
|
if (annotationTool === 'pen') {
|
|
309
342
|
const target = e.target instanceof Element ? e.target : null;
|
|
310
343
|
if (target?.closest('.hud-layer, .snapshot-panel, .context-menu, .command-palette')) return;
|
|
@@ -450,11 +483,32 @@ export function CanvasViewport({ onNodeContextMenu, onCanvasContextMenu, annotat
|
|
|
450
483
|
|
|
451
484
|
useEffect(() => {
|
|
452
485
|
if (annotationMode) return;
|
|
453
|
-
if (!isAnnotating.current && !draftAnnotation) return;
|
|
486
|
+
if (!isAnnotating.current && !draftAnnotation && !textDraft) return;
|
|
454
487
|
isAnnotating.current = false;
|
|
455
488
|
annotationPoints.current = [];
|
|
456
489
|
setDraftAnnotation(null);
|
|
457
|
-
|
|
490
|
+
setTextDraft(null);
|
|
491
|
+
}, [annotationMode, draftAnnotation, setTextDraft, textDraft]);
|
|
492
|
+
|
|
493
|
+
const commitTextDraft = useCallback(() => {
|
|
494
|
+
const draft = textDraftRef.current;
|
|
495
|
+
if (!draft) return;
|
|
496
|
+
const text = draft.value.trim();
|
|
497
|
+
setTextDraft(null);
|
|
498
|
+
if (!text) return;
|
|
499
|
+
const point = { x: draft.x, y: draft.y };
|
|
500
|
+
void createAnnotationFromClient({
|
|
501
|
+
type: 'text',
|
|
502
|
+
points: [point],
|
|
503
|
+
color: ANNOTATION_COLOR,
|
|
504
|
+
width: TEXT_ANNOTATION_WIDTH,
|
|
505
|
+
text,
|
|
506
|
+
});
|
|
507
|
+
}, [setTextDraft]);
|
|
508
|
+
|
|
509
|
+
useEffect(() => {
|
|
510
|
+
if (annotationTool !== 'text' && textDraft) setTextDraft(null);
|
|
511
|
+
}, [annotationTool, setTextDraft, textDraft]);
|
|
458
512
|
|
|
459
513
|
// ── Drag-to-connect: track cursor in world space, hit-test on drop ──
|
|
460
514
|
useEffect(() => {
|
|
@@ -517,8 +571,8 @@ export function CanvasViewport({ onNodeContextMenu, onCanvasContextMenu, annotat
|
|
|
517
571
|
const wx = (e.clientX - rect.left - v.x) / v.scale;
|
|
518
572
|
const wy = (e.clientY - rect.top - v.y) / v.scale;
|
|
519
573
|
// Offset so node centers on click point
|
|
520
|
-
const nodeW =
|
|
521
|
-
const nodeH =
|
|
574
|
+
const nodeW = 520;
|
|
575
|
+
const nodeH = 360;
|
|
522
576
|
createNodeFromClient({
|
|
523
577
|
type: 'markdown',
|
|
524
578
|
title: 'New note',
|
|
@@ -697,6 +751,8 @@ export function CanvasViewport({ onNodeContextMenu, onCanvasContextMenu, annotat
|
|
|
697
751
|
overflow: 'hidden',
|
|
698
752
|
cursor: annotationTool === 'eraser'
|
|
699
753
|
? 'cell'
|
|
754
|
+
: annotationTool === 'text'
|
|
755
|
+
? 'text'
|
|
700
756
|
: annotationMode || draggingEdge.value || isLassoing.current
|
|
701
757
|
? 'crosshair'
|
|
702
758
|
: 'grab',
|
|
@@ -737,7 +793,32 @@ export function CanvasViewport({ onNodeContextMenu, onCanvasContextMenu, annotat
|
|
|
737
793
|
</svg>
|
|
738
794
|
)}
|
|
739
795
|
</div>
|
|
740
|
-
{annotationMode && <div class={`annotation-capture-layer${annotationTool === 'eraser' ? ' erasing' : ''}`} aria-hidden="true" />}
|
|
796
|
+
{annotationMode && <div class={`annotation-capture-layer${annotationTool === 'eraser' ? ' erasing' : ''}${annotationTool === 'text' ? ' text' : ''}`} aria-hidden="true" />}
|
|
797
|
+
{textDraft && (
|
|
798
|
+
<input
|
|
799
|
+
class="annotation-text-input"
|
|
800
|
+
value={textDraft.value}
|
|
801
|
+
autoFocus
|
|
802
|
+
style={{
|
|
803
|
+
left: `${textDraft.x * v.scale + v.x}px`,
|
|
804
|
+
top: `${textDraft.y * v.scale + v.y}px`,
|
|
805
|
+
fontSize: `${TEXT_ANNOTATION_WIDTH * v.scale}px`,
|
|
806
|
+
}}
|
|
807
|
+
onInput={(e) => setTextDraft({ ...textDraft, value: (e.target as HTMLInputElement).value })}
|
|
808
|
+
onBlur={commitTextDraft}
|
|
809
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
810
|
+
onKeyDown={(e) => {
|
|
811
|
+
if (e.key === 'Enter') {
|
|
812
|
+
e.preventDefault();
|
|
813
|
+
commitTextDraft();
|
|
814
|
+
}
|
|
815
|
+
if (e.key === 'Escape') {
|
|
816
|
+
e.preventDefault();
|
|
817
|
+
setTextDraft(null);
|
|
818
|
+
}
|
|
819
|
+
}}
|
|
820
|
+
/>
|
|
821
|
+
)}
|
|
741
822
|
{lassoStyle && <div class="lasso-rect" style={lassoStyle} />}
|
|
742
823
|
{dropActive && (
|
|
743
824
|
<div class="drop-zone-overlay">
|
|
@@ -135,7 +135,7 @@ export function CommandPalette({
|
|
|
135
135
|
|
|
136
136
|
// Action items
|
|
137
137
|
const actions: Array<{ label: string; badge: string; action: () => void }> = [
|
|
138
|
-
{ label: 'New note (markdown node)', badge: 'CREATE', action: () => { createNodeFromClient({ type: 'markdown', title: 'New note' }); onClose(); } },
|
|
138
|
+
{ label: 'New note (markdown node)', badge: 'CREATE', action: () => { createNodeFromClient({ type: 'markdown', title: 'New note', width: 520, height: 360 }); onClose(); } },
|
|
139
139
|
{ label: 'Fit all nodes', badge: 'VIEW', action: () => { fitAll(window.innerWidth, window.innerHeight); onClose(); } },
|
|
140
140
|
{ label: 'Auto-arrange (grid)', badge: 'LAYOUT', action: () => { autoArrange(); onClose(); } },
|
|
141
141
|
{ label: 'Auto-arrange (graph-aware)', badge: 'LAYOUT', action: () => { forceDirectedArrange(); onClose(); } },
|
|
@@ -319,8 +319,8 @@ function buildCanvasMenuItems(canvasX: number, canvasY: number): MenuItem[] {
|
|
|
319
319
|
{
|
|
320
320
|
label: 'New note',
|
|
321
321
|
action: () => {
|
|
322
|
-
const width =
|
|
323
|
-
const height =
|
|
322
|
+
const width = 520;
|
|
323
|
+
const height = 360;
|
|
324
324
|
const position = centeredPosition(canvasX, canvasY, width, height);
|
|
325
325
|
void createNodeFromClient({
|
|
326
326
|
type: 'markdown',
|
|
@@ -122,6 +122,7 @@ export function ExpandedNodeOverlay() {
|
|
|
122
122
|
const isCtxPinned = nodeId ? contextPinnedNodeIds.value.has(nodeId) : false;
|
|
123
123
|
const hasText = textContent.length > 0;
|
|
124
124
|
const pendingClose = pendingExpandedNodeCloseId.value === nodeId;
|
|
125
|
+
const isEmbeddedViewer = node.type === 'mcp-app' || node.type === 'webpage' || node.type === 'json-render' || node.type === 'graph';
|
|
125
126
|
|
|
126
127
|
return (
|
|
127
128
|
<div
|
|
@@ -260,9 +261,14 @@ export function ExpandedNodeOverlay() {
|
|
|
260
261
|
overflow: 'auto',
|
|
261
262
|
padding: '16px',
|
|
262
263
|
minHeight: 0,
|
|
264
|
+
...(isEmbeddedViewer ? { display: 'flex', flexDirection: 'column' } : {}),
|
|
263
265
|
}}
|
|
264
266
|
>
|
|
265
|
-
{
|
|
267
|
+
{isEmbeddedViewer ? (
|
|
268
|
+
<div style={{ flex: 1, minHeight: 0, display: 'flex' }}>
|
|
269
|
+
{renderContent(node, true)}
|
|
270
|
+
</div>
|
|
271
|
+
) : renderContent(node, true)}
|
|
266
272
|
</div>
|
|
267
273
|
</div>
|
|
268
274
|
</div>
|
package/src/client/icons.tsx
CHANGED
|
@@ -139,6 +139,19 @@ export function IconEraser(p: IconProps): JSX.Element {
|
|
|
139
139
|
);
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
/** Text cursor — canvas text annotation mode */
|
|
143
|
+
export function IconTextAnnotation(p: IconProps): JSX.Element {
|
|
144
|
+
return (
|
|
145
|
+
<Icon {...p}>
|
|
146
|
+
<path d="M3 4h10" />
|
|
147
|
+
<path d="M8 4v8" />
|
|
148
|
+
<path d="M6 12h4" />
|
|
149
|
+
<path d="M4.5 4 4 6" />
|
|
150
|
+
<path d="M11.5 4 12 6" />
|
|
151
|
+
</Icon>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
142
155
|
/** Camera — snapshots */
|
|
143
156
|
export function IconSnapshot(p: IconProps): JSX.Element {
|
|
144
157
|
return (
|
|
@@ -2,11 +2,12 @@ import type { CanvasNodeState } from '../types';
|
|
|
2
2
|
import { canvasTheme } from '../state/canvas-store';
|
|
3
3
|
import { ExtAppFrame } from './ExtAppFrame';
|
|
4
4
|
|
|
5
|
-
function
|
|
5
|
+
function withViewerParams(url: string, expanded: boolean): string {
|
|
6
6
|
if (!url) return url;
|
|
7
7
|
try {
|
|
8
8
|
const resolved = new URL(url, window.location.origin);
|
|
9
9
|
resolved.searchParams.set('theme', canvasTheme.value === 'light' ? 'light' : 'dark');
|
|
10
|
+
if (expanded) resolved.searchParams.set('display', 'expanded');
|
|
10
11
|
return resolved.toString();
|
|
11
12
|
} catch {
|
|
12
13
|
return url;
|
|
@@ -18,7 +19,7 @@ export function McpAppNode({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
18
19
|
return <ExtAppFrame node={node} expanded={expanded} />;
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
const url =
|
|
22
|
+
const url = withViewerParams((node.data.url as string) || '', expanded);
|
|
22
23
|
const sourceServer = (node.data.sourceServer as string) || '';
|
|
23
24
|
const hostMode = (node.data.hostMode as string) || 'hosted';
|
|
24
25
|
const fallbackReason = node.data.fallbackReason as string | undefined;
|
|
@@ -54,7 +55,14 @@ export function McpAppNode({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
return (
|
|
57
|
-
<div
|
|
58
|
+
<div
|
|
59
|
+
style={{
|
|
60
|
+
height: '100%',
|
|
61
|
+
display: 'flex',
|
|
62
|
+
flexDirection: 'column',
|
|
63
|
+
...(expanded ? { flex: 1, minHeight: 0, width: '100%' } : {}),
|
|
64
|
+
}}
|
|
65
|
+
>
|
|
58
66
|
{!trustedDomain && (
|
|
59
67
|
<div
|
|
60
68
|
style={{
|
|
@@ -77,7 +85,7 @@ export function McpAppNode({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
77
85
|
sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox"
|
|
78
86
|
allow="clipboard-read; clipboard-write"
|
|
79
87
|
loading="lazy"
|
|
80
|
-
style={{ flex: 1 }}
|
|
88
|
+
style={{ flex: 1, minHeight: 0, width: '100%' }}
|
|
81
89
|
title={`MCP App: ${sourceServer}`}
|
|
82
90
|
/>
|
|
83
91
|
</div>
|