pmx-canvas 0.6.4 → 0.6.5
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 +19 -0
- package/Readme.md +7 -1
- package/dist/canvas/global.css +58 -4
- package/dist/canvas/index.js +92 -92
- package/dist/canvas/mermaid-entry.js +258 -258
- package/dist/types/cli/agent.d.ts +1 -0
- package/dist/types/cli/commands/record.d.ts +12 -0
- package/dist/types/client/canvas/Presentation.d.ts +13 -0
- package/dist/types/client/canvas/use-node-drag.d.ts +2 -1
- package/dist/types/client/nodes/McpAppNode.d.ts +24 -1
- package/dist/types/client/state/presentation.d.ts +3 -0
- package/dist/types/client/types.d.ts +1 -1
- package/dist/types/json-render/catalog.d.ts +2 -2
- package/dist/types/json-render/server.d.ts +2 -0
- package/dist/types/server/canvas-db.d.ts +2 -0
- package/dist/types/server/canvas-operations.d.ts +22 -0
- package/dist/types/server/canvas-schema.d.ts +1 -0
- package/dist/types/server/canvas-state.d.ts +6 -0
- package/dist/types/server/canvas-validation.d.ts +3 -0
- package/dist/types/server/html-surface.d.ts +1 -1
- package/dist/types/server/index.d.ts +4 -0
- package/dist/types/server/operations/ops/app.d.ts +2 -0
- package/dist/types/server/operations/ops/json-render.d.ts +2 -0
- package/dist/types/server/operations/ops/nodes.d.ts +3 -1
- package/dist/types/server/web-artifacts.d.ts +3 -0
- package/dist/types/shared/tour.d.ts +72 -0
- package/docs/cli.md +89 -0
- package/docs/http-api.md +79 -4
- package/docs/mcp.md +25 -1
- package/docs/node-types.md +80 -0
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +10 -2
- package/skills/pmx-canvas/references/full-reference.md +1 -1
- package/skills/pmx-canvas/references/installing-pmx-canvas.md +4 -4
- package/src/cli/agent.ts +5 -0
- package/src/cli/commands/nodes.ts +10 -0
- package/src/cli/commands/record.ts +218 -0
- package/src/cli/index.ts +2 -0
- package/src/client/App.tsx +7 -1
- package/src/client/canvas/AgentPresenceLayer.tsx +1 -1
- package/src/client/canvas/CanvasNode.tsx +8 -0
- package/src/client/canvas/IntentLayer.tsx +9 -2
- package/src/client/canvas/Presentation.tsx +108 -0
- package/src/client/canvas/TopBar.tsx +28 -2
- package/src/client/canvas/use-node-drag.ts +8 -3
- package/src/client/mermaid-entry.ts +39 -0
- package/src/client/nodes/HtmlNode.tsx +12 -13
- package/src/client/nodes/McpAppNode.tsx +105 -13
- package/src/client/nodes/MermaidNode.tsx +3 -2
- package/src/client/state/intent-store.ts +5 -1
- package/src/client/state/presentation.ts +7 -0
- package/src/client/state/session-store.ts +3 -0
- package/src/client/state/sse-bridge.ts +3 -0
- package/src/client/theme/global.css +58 -4
- package/src/json-render/server.ts +29 -0
- package/src/server/agent-presence.ts +15 -9
- package/src/server/canvas-db.ts +15 -0
- package/src/server/canvas-operations.ts +115 -22
- package/src/server/canvas-schema.ts +10 -0
- package/src/server/canvas-state.ts +39 -4
- package/src/server/canvas-validation.ts +9 -1
- package/src/server/demo-state.json +11 -7
- package/src/server/html-primitives.ts +199 -91
- package/src/server/html-surface.ts +8 -3
- package/src/server/index.ts +12 -0
- package/src/server/operations/composites.ts +3 -1
- package/src/server/operations/ops/app.ts +10 -0
- package/src/server/operations/ops/batch.ts +12 -3
- package/src/server/operations/ops/groups.ts +14 -7
- package/src/server/operations/ops/json-render.ts +37 -5
- package/src/server/operations/ops/nodes.ts +42 -16
- package/src/server/operations/ops/viewport.ts +32 -0
- package/src/server/operations/registry.ts +6 -0
- package/src/server/scope-fence.ts +1 -1
- package/src/server/server.ts +9 -6
- package/src/server/web-artifacts.ts +9 -2
- package/src/server/workboard.ts +3 -3
- package/src/shared/tour.ts +87 -0
|
@@ -1210,13 +1210,13 @@ body,
|
|
|
1210
1210
|
font-size: 9px;
|
|
1211
1211
|
padding: 1px 6px;
|
|
1212
1212
|
}
|
|
1213
|
-
/*
|
|
1213
|
+
/* Parked idle cursors leave the board (user feedback, 0.5.1 cycle): with many agents
|
|
1214
1214
|
attached, a wall of parked "Idle" pills covers real node content. The
|
|
1215
1215
|
element stays MOUNTED — counts, twin-merge, and cursor-follow logic are
|
|
1216
1216
|
untouched, and visibility (not display) keeps its layout box so position
|
|
1217
|
-
polls still resolve — but it paints nothing
|
|
1218
|
-
|
|
1219
|
-
.agent-cursor.phase-idle {
|
|
1217
|
+
polls still resolve — but it paints nothing. An explicitly reported cursor
|
|
1218
|
+
remains visible even while idle so cursor-only movement cannot disappear. */
|
|
1219
|
+
.agent-cursor.phase-idle:not(.has-explicit-cursor) {
|
|
1220
1220
|
opacity: 0;
|
|
1221
1221
|
visibility: hidden;
|
|
1222
1222
|
transition:
|
|
@@ -4211,6 +4211,33 @@ html.is-node-resizing .ext-app-preview-catcher {
|
|
|
4211
4211
|
}
|
|
4212
4212
|
|
|
4213
4213
|
/* MCP app iframe */
|
|
4214
|
+
.mcp-app-frame-stack {
|
|
4215
|
+
position: relative;
|
|
4216
|
+
flex: 1;
|
|
4217
|
+
min-width: 0;
|
|
4218
|
+
min-height: 0;
|
|
4219
|
+
background: var(--c-panel-soft);
|
|
4220
|
+
overflow: hidden;
|
|
4221
|
+
border-radius: 0 0 calc(var(--radius) - 1px) calc(var(--radius) - 1px);
|
|
4222
|
+
}
|
|
4223
|
+
|
|
4224
|
+
.mcp-app-frame-stack:has(.html-node-frame) {
|
|
4225
|
+
width: 100%;
|
|
4226
|
+
height: 100%;
|
|
4227
|
+
min-height: 300px;
|
|
4228
|
+
background: var(--c-bg);
|
|
4229
|
+
border-radius: 6px;
|
|
4230
|
+
}
|
|
4231
|
+
|
|
4232
|
+
.mcp-app-frame-stack:has(.html-node-frame-presentation) {
|
|
4233
|
+
min-height: 0;
|
|
4234
|
+
border-radius: 0;
|
|
4235
|
+
}
|
|
4236
|
+
|
|
4237
|
+
.mcp-app-frame-stack:has(.html-node-frame-expanded) {
|
|
4238
|
+
min-height: 70vh;
|
|
4239
|
+
}
|
|
4240
|
+
|
|
4214
4241
|
.mcp-app-frame {
|
|
4215
4242
|
width: 100%;
|
|
4216
4243
|
height: 100%;
|
|
@@ -6808,3 +6835,30 @@ html.is-node-dragging .intent-ghost,
|
|
|
6808
6835
|
html.is-node-dragging .intent-line-layer {
|
|
6809
6836
|
opacity: 0.5;
|
|
6810
6837
|
}
|
|
6838
|
+
|
|
6839
|
+
.present-button {
|
|
6840
|
+
color: var(--c-text);
|
|
6841
|
+
background: var(--c-bg);
|
|
6842
|
+
border: 1px solid var(--c-line);
|
|
6843
|
+
border-radius: 6px;
|
|
6844
|
+
padding: 4px 12px;
|
|
6845
|
+
cursor: pointer;
|
|
6846
|
+
}
|
|
6847
|
+
.is-presenting > :not(.app-main):not(.presentation-error),
|
|
6848
|
+
.is-presenting .app-main > :not(.canvas-region),
|
|
6849
|
+
.is-presenting .canvas-region > :not(.canvas-viewport) {
|
|
6850
|
+
display: none !important;
|
|
6851
|
+
}
|
|
6852
|
+
.is-presenting .canvas-viewport { pointer-events: none; }
|
|
6853
|
+
.presentation-error {
|
|
6854
|
+
position: fixed;
|
|
6855
|
+
bottom: 16px;
|
|
6856
|
+
left: 16px;
|
|
6857
|
+
z-index: 100;
|
|
6858
|
+
max-width: calc(100% - 32px);
|
|
6859
|
+
padding: 10px 16px;
|
|
6860
|
+
border: 1px solid var(--c-line);
|
|
6861
|
+
border-radius: 6px;
|
|
6862
|
+
background: var(--c-panel);
|
|
6863
|
+
color: var(--c-text);
|
|
6864
|
+
}
|
|
@@ -542,6 +542,35 @@ export function normalizeAndValidateJsonRenderSpec(spec: unknown): JsonRenderSpe
|
|
|
542
542
|
return propsValidation.data as JsonRenderSpec;
|
|
543
543
|
}
|
|
544
544
|
|
|
545
|
+
/** shadcn 0.21 ignores these props unless the renderer supplies a two-way binding. */
|
|
546
|
+
export function jsonRenderFormWarnings(spec: JsonRenderSpec): string[] {
|
|
547
|
+
const bindingOnlyProps: Record<string, string> = {
|
|
548
|
+
Input: 'value',
|
|
549
|
+
Textarea: 'value',
|
|
550
|
+
Select: 'value',
|
|
551
|
+
Radio: 'value',
|
|
552
|
+
Slider: 'value',
|
|
553
|
+
ToggleGroup: 'value',
|
|
554
|
+
ButtonGroup: 'selected',
|
|
555
|
+
Tabs: 'value',
|
|
556
|
+
};
|
|
557
|
+
const warnings: string[] = [];
|
|
558
|
+
for (const [key, rawElement] of Object.entries(spec.elements)) {
|
|
559
|
+
const element = asRecord(rawElement);
|
|
560
|
+
const type = typeof element?.type === 'string' ? element.type : '';
|
|
561
|
+
const prop = bindingOnlyProps[type];
|
|
562
|
+
if (!prop) continue;
|
|
563
|
+
const value = asRecord(element?.props)?.[prop];
|
|
564
|
+
if (value === undefined || value === null) continue;
|
|
565
|
+
const expression = asRecord(value);
|
|
566
|
+
if (hasString(expression?.$bindState) || hasString(expression?.$bindItem)) continue;
|
|
567
|
+
warnings.push(
|
|
568
|
+
`elements.${key}.props.${prop}: ${type} ignores an unbound ${prop} (including literal values and $state expressions). Use { "$bindState": "/form/field" } and seed spec.state.form.field with the initial value; inside a repeat use $bindItem with seeded item data.${type === 'Tabs' ? ' For an uncontrolled initial tab, use defaultValue instead.' : ''}`,
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
return warnings;
|
|
572
|
+
}
|
|
573
|
+
|
|
545
574
|
export function normalizeGraphType(value: string): GraphChartType {
|
|
546
575
|
const normalized = value.toLowerCase().replace(/[-_\s]/g, '');
|
|
547
576
|
return GRAPH_TYPE_ALIASES[normalized] ?? 'LineChart';
|
|
@@ -198,6 +198,8 @@ const presenceSetSchema = z.object(PRESENCE_SET_SHAPE);
|
|
|
198
198
|
|
|
199
199
|
interface StoredPresence extends AgentPresence {
|
|
200
200
|
lastSeenMs: number;
|
|
201
|
+
/** Last explicit phase report; cursor/focus heartbeats must not keep a stale phase alive. */
|
|
202
|
+
phaseSetMs: number;
|
|
201
203
|
/** Explicit phases (`thinking`) hold until the next touch; derived `tooling` decays. */
|
|
202
204
|
toolingUntilMs: number | null;
|
|
203
205
|
lastActivityNodeId: string | null;
|
|
@@ -381,6 +383,7 @@ export class AgentPresenceRegistry {
|
|
|
381
383
|
contextUsage: null,
|
|
382
384
|
lastSeenAt: new Date(now).toISOString(),
|
|
383
385
|
lastSeenMs: now,
|
|
386
|
+
phaseSetMs: now,
|
|
384
387
|
toolingUntilMs: null,
|
|
385
388
|
startSnapshotId: null,
|
|
386
389
|
};
|
|
@@ -458,10 +461,12 @@ export class AgentPresenceRegistry {
|
|
|
458
461
|
}
|
|
459
462
|
stored.startSnapshotId = inheritedSnapshotId ?? this.onSessionStart(this.publicView(stored, now));
|
|
460
463
|
}
|
|
461
|
-
if (
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
//
|
|
464
|
+
if (input.attached === false) {
|
|
465
|
+
if (wasAttached)
|
|
466
|
+
this.onSessionEnd(this.publicView(stored, now), stored.startSnapshotId, input.endedBy ?? 'agent');
|
|
467
|
+
// An explicit detach always removes the presence. In particular, a
|
|
468
|
+
// worker that reports attached:false must not be refreshed into another
|
|
469
|
+
// full activity TTL as an external writer.
|
|
465
470
|
this.presences.delete(key);
|
|
466
471
|
this.dropAliasesTo(key);
|
|
467
472
|
this.maybeStopSweeper();
|
|
@@ -481,6 +486,7 @@ export class AgentPresenceRegistry {
|
|
|
481
486
|
if (input.detail !== undefined) stored.detail = input.detail;
|
|
482
487
|
} else if (input.phase !== undefined) {
|
|
483
488
|
stored.phase = input.phase;
|
|
489
|
+
stored.phaseSetMs = now;
|
|
484
490
|
stored.detail = input.detail !== undefined ? input.detail : input.phase === 'tooling' ? stored.detail : null;
|
|
485
491
|
stored.toolingUntilMs = input.phase === 'tooling' ? now + PRESENCE_TOOLING_SETTLE_MS : null;
|
|
486
492
|
} else if (input.detail !== undefined) {
|
|
@@ -607,6 +613,7 @@ export class AgentPresenceRegistry {
|
|
|
607
613
|
private publicView(stored: StoredPresence, now: number): AgentPresence {
|
|
608
614
|
const {
|
|
609
615
|
lastSeenMs: _lastSeenMs,
|
|
616
|
+
phaseSetMs: _phaseSetMs,
|
|
610
617
|
toolingUntilMs,
|
|
611
618
|
startSnapshotId: _startSnapshotId,
|
|
612
619
|
lastActivityNodeId,
|
|
@@ -691,11 +698,10 @@ export class AgentPresenceRegistry {
|
|
|
691
698
|
changed = true;
|
|
692
699
|
continue;
|
|
693
700
|
}
|
|
694
|
-
//
|
|
695
|
-
//
|
|
696
|
-
//
|
|
697
|
-
|
|
698
|
-
if (presence.phase === 'thinking' && now - presence.lastSeenMs > PRESENCE_THINKING_SETTLE_MS) {
|
|
701
|
+
// Cursor/focus heartbeats keep the writer live, but cannot keep a stale
|
|
702
|
+
// explicit phase alive. Only another explicit phase report resets this
|
|
703
|
+
// clock; operation-derived tooling remains its own short overlay.
|
|
704
|
+
if (presence.phase === 'thinking' && now - presence.phaseSetMs > PRESENCE_THINKING_SETTLE_MS) {
|
|
699
705
|
presence.phase = 'idle';
|
|
700
706
|
presence.detail = null;
|
|
701
707
|
changed = true;
|
package/src/server/canvas-db.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { Database } from 'bun:sqlite';
|
|
9
|
+
import { tourSchema, type Tour } from '../shared/tour.js';
|
|
9
10
|
import { existsSync, mkdirSync } from 'node:fs';
|
|
10
11
|
import { dirname } from 'node:path';
|
|
11
12
|
import { gzipSync, gunzipSync } from 'node:zlib';
|
|
@@ -260,6 +261,7 @@ function parsePersistedAxState(raw: string | null | undefined): PmxAxState {
|
|
|
260
261
|
// ── Persisted State Interface ───────────────────────────────────
|
|
261
262
|
|
|
262
263
|
export interface PersistedCanvasState {
|
|
264
|
+
tour?: Tour;
|
|
263
265
|
version: number;
|
|
264
266
|
theme?: CanvasTheme;
|
|
265
267
|
viewport: ViewportState;
|
|
@@ -270,6 +272,11 @@ export interface PersistedCanvasState {
|
|
|
270
272
|
ax?: PmxAxState;
|
|
271
273
|
}
|
|
272
274
|
|
|
275
|
+
function readTour(raw: string | undefined): Tour | undefined {
|
|
276
|
+
if (!raw || raw === 'null') return undefined;
|
|
277
|
+
return tourSchema.parse(JSON.parse(raw));
|
|
278
|
+
}
|
|
279
|
+
|
|
273
280
|
// ── Database Management ─────────────────────────────────────────
|
|
274
281
|
|
|
275
282
|
export function openCanvasDb(dbPath: string): Database {
|
|
@@ -394,6 +401,7 @@ export function saveStateToDB(db: Database, state: PersistedCanvasState): void {
|
|
|
394
401
|
WHERE value IS NOT excluded.value`,
|
|
395
402
|
);
|
|
396
403
|
upsertMeta.run('theme', normalizeCanvasTheme(state.theme));
|
|
404
|
+
upsertMeta.run('tour', JSON.stringify(state.tour ?? null));
|
|
397
405
|
upsertMeta.run('viewport_x', String(state.viewport.x));
|
|
398
406
|
upsertMeta.run('viewport_y', String(state.viewport.y));
|
|
399
407
|
upsertMeta.run('viewport_scale', String(state.viewport.scale));
|
|
@@ -610,6 +618,7 @@ export function loadStateFromDB(db: Database): PersistedCanvasState | null {
|
|
|
610
618
|
return {
|
|
611
619
|
version: 1,
|
|
612
620
|
theme,
|
|
621
|
+
tour: readTour(db.query<{ value: string }, [string]>('SELECT value FROM meta WHERE key = ?').get('tour')?.value),
|
|
613
622
|
viewport,
|
|
614
623
|
nodes,
|
|
615
624
|
edges,
|
|
@@ -632,6 +641,11 @@ export function saveSnapshotToDB(db: Database, snapshot: CanvasSnapshot, state:
|
|
|
632
641
|
state.edges.length,
|
|
633
642
|
]);
|
|
634
643
|
|
|
644
|
+
db.run('INSERT INTO snapshot_meta (snapshot_id, key, value) VALUES (?, ?, ?)', [
|
|
645
|
+
snapshot.id,
|
|
646
|
+
'tour',
|
|
647
|
+
JSON.stringify(state.tour ?? null),
|
|
648
|
+
]);
|
|
635
649
|
// Insert snapshot viewport meta
|
|
636
650
|
db.run('INSERT INTO snapshot_meta (snapshot_id, key, value) VALUES (?, ?, ?)', [
|
|
637
651
|
snapshot.id,
|
|
@@ -859,6 +873,7 @@ export function loadSnapshotFromDB(
|
|
|
859
873
|
snapshot,
|
|
860
874
|
state: {
|
|
861
875
|
version: 1,
|
|
876
|
+
tour: readTour(metaMap.get('tour')),
|
|
862
877
|
viewport,
|
|
863
878
|
nodes,
|
|
864
879
|
edges,
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
emptyStreamingSpec,
|
|
34
34
|
GRAPH_NODE_SIZE,
|
|
35
35
|
inferJsonRenderNodeTitle,
|
|
36
|
+
jsonRenderFormWarnings,
|
|
36
37
|
JSON_RENDER_NODE_SIZE,
|
|
37
38
|
normalizeAndValidateJsonRenderSpec,
|
|
38
39
|
type GraphNodeInput,
|
|
@@ -118,6 +119,29 @@ export const MCP_APP_NODE_DEFAULT_SIZE = { width: 960, height: 600 };
|
|
|
118
119
|
export const IMAGE_NODE_DEFAULT_SIZE = { width: 480, height: 360 };
|
|
119
120
|
export const LEDGER_NODE_DEFAULT_SIZE = { width: 420, height: 280 };
|
|
120
121
|
|
|
122
|
+
export interface CanvasSizeAdjustment {
|
|
123
|
+
requested: { width: number | null; height: number | null };
|
|
124
|
+
applied: { width: number; height: number };
|
|
125
|
+
reason: 'defaulted' | 'clamped-to-minimum' | 'defaulted-and-clamped-to-minimum' | 'fit-to-children';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function describeCreateSize(
|
|
129
|
+
requestedWidth: number | undefined,
|
|
130
|
+
requestedHeight: number | undefined,
|
|
131
|
+
resolvedWidth: number,
|
|
132
|
+
resolvedHeight: number,
|
|
133
|
+
applied: { width: number; height: number },
|
|
134
|
+
): CanvasSizeAdjustment | undefined {
|
|
135
|
+
const defaulted = requestedWidth === undefined || requestedHeight === undefined;
|
|
136
|
+
const clamped = applied.width !== resolvedWidth || applied.height !== resolvedHeight;
|
|
137
|
+
if (!defaulted && !clamped) return undefined;
|
|
138
|
+
return {
|
|
139
|
+
requested: { width: requestedWidth ?? null, height: requestedHeight ?? null },
|
|
140
|
+
applied,
|
|
141
|
+
reason: defaulted && clamped ? 'defaulted-and-clamped-to-minimum' : clamped ? 'clamped-to-minimum' : 'defaulted',
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
121
145
|
// Per-type minimum creation sizes + clamp live in canvas-validation.ts
|
|
122
146
|
// (NODE_MIN_CREATE_SIZES / clampCreateNodeSize) — shared with the validate
|
|
123
147
|
// surface's sizeWarnings.
|
|
@@ -923,17 +947,15 @@ export function addCanvasNode(input: CanvasAddNodeInput): {
|
|
|
923
947
|
id: string;
|
|
924
948
|
node: CanvasNodeState;
|
|
925
949
|
needsCodeGraphRecompute: boolean;
|
|
950
|
+
sizeAdjustment?: CanvasSizeAdjustment;
|
|
926
951
|
} {
|
|
927
952
|
if (input.type === 'json-render' || input.type === 'graph') {
|
|
928
953
|
throw new Error(`Use the dedicated ${input.type} node APIs for structured viewer nodes.`);
|
|
929
954
|
}
|
|
930
955
|
|
|
931
|
-
const
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
input.height ?? input.defaultHeight ?? 600,
|
|
935
|
-
input.strictSize,
|
|
936
|
-
);
|
|
956
|
+
const resolvedWidth = input.width ?? input.defaultWidth ?? 720;
|
|
957
|
+
const resolvedHeight = input.height ?? input.defaultHeight ?? 600;
|
|
958
|
+
const { width, height } = clampCreateNodeSize(input.type, resolvedWidth, resolvedHeight, input.strictSize);
|
|
937
959
|
const position =
|
|
938
960
|
input.x !== undefined && input.y !== undefined
|
|
939
961
|
? { x: input.x, y: input.y }
|
|
@@ -959,7 +981,16 @@ export function addCanvasNode(input: CanvasAddNodeInput): {
|
|
|
959
981
|
watchFileForNode(id, filePath);
|
|
960
982
|
}
|
|
961
983
|
|
|
962
|
-
|
|
984
|
+
const sizeAdjustment = describeCreateSize(input.width, input.height, resolvedWidth, resolvedHeight, {
|
|
985
|
+
width,
|
|
986
|
+
height,
|
|
987
|
+
});
|
|
988
|
+
return {
|
|
989
|
+
id,
|
|
990
|
+
node: storedNode,
|
|
991
|
+
needsCodeGraphRecompute: input.type === 'file',
|
|
992
|
+
...(sizeAdjustment ? { sizeAdjustment } : {}),
|
|
993
|
+
};
|
|
963
994
|
}
|
|
964
995
|
|
|
965
996
|
export function resolveCanvasNode(nodeRef: CanvasNodeLookupInput):
|
|
@@ -1415,7 +1446,8 @@ export function saveCanvasSnapshotWithReuse(name: string): { snapshot: CanvasSna
|
|
|
1415
1446
|
diff.removedNodes.length === 0 &&
|
|
1416
1447
|
diff.modifiedNodes.length === 0 &&
|
|
1417
1448
|
diff.addedEdges.length === 0 &&
|
|
1418
|
-
diff.removedEdges.length === 0
|
|
1449
|
+
diff.removedEdges.length === 0 &&
|
|
1450
|
+
JSON.stringify(newestData.tour) === JSON.stringify(canvasState.getTour());
|
|
1419
1451
|
if (identical) return { snapshot: newest, reused: true };
|
|
1420
1452
|
}
|
|
1421
1453
|
}
|
|
@@ -1508,7 +1540,11 @@ export function removeCanvasEdge(id: string): { removed: boolean } {
|
|
|
1508
1540
|
return { removed: canvasState.removeEdge(id) };
|
|
1509
1541
|
}
|
|
1510
1542
|
|
|
1511
|
-
export function createCanvasGroup(input: CanvasCreateGroupInput): {
|
|
1543
|
+
export function createCanvasGroup(input: CanvasCreateGroupInput): {
|
|
1544
|
+
id: string;
|
|
1545
|
+
node: CanvasNodeState;
|
|
1546
|
+
sizeAdjustment?: CanvasSizeAdjustment;
|
|
1547
|
+
} {
|
|
1512
1548
|
let x = input.x;
|
|
1513
1549
|
let y = input.y;
|
|
1514
1550
|
let width = input.width ?? 600;
|
|
@@ -1566,22 +1602,42 @@ export function createCanvasGroup(input: CanvasCreateGroupInput): { id: string;
|
|
|
1566
1602
|
if (!node) {
|
|
1567
1603
|
throw new Error(`Group "${id}" was not created.`);
|
|
1568
1604
|
}
|
|
1569
|
-
|
|
1605
|
+
const adjusted = input.width !== node.size.width || input.height !== node.size.height;
|
|
1606
|
+
const fitted = childIds.length > 0 && ((input.x === undefined && input.y === undefined) || !explicitFrame);
|
|
1607
|
+
return {
|
|
1608
|
+
id,
|
|
1609
|
+
node,
|
|
1610
|
+
...(adjusted
|
|
1611
|
+
? {
|
|
1612
|
+
sizeAdjustment: {
|
|
1613
|
+
requested: { width: input.width ?? null, height: input.height ?? null },
|
|
1614
|
+
applied: node.size,
|
|
1615
|
+
reason: fitted ? ('fit-to-children' as const) : ('defaulted' as const),
|
|
1616
|
+
},
|
|
1617
|
+
}
|
|
1618
|
+
: {}),
|
|
1619
|
+
};
|
|
1570
1620
|
}
|
|
1571
1621
|
|
|
1572
1622
|
export function groupCanvasNodes(
|
|
1573
1623
|
groupId: string,
|
|
1574
1624
|
childIds: string[],
|
|
1575
1625
|
options: { childLayout?: CanvasArrangeMode } = {},
|
|
1576
|
-
): { ok: boolean } {
|
|
1626
|
+
): { ok: boolean; sizeAdjustment?: CanvasSizeAdjustment } {
|
|
1627
|
+
const before = canvasState.getNode(groupId)?.size;
|
|
1628
|
+
const ok = canvasState.groupNodes(groupId, childIds, {
|
|
1629
|
+
// Preserve existing child positions unless an explicit layout is asked
|
|
1630
|
+
// for — matching createCanvasGroup and the batch group.add path. Without
|
|
1631
|
+
// this, grouping silently auto-packs the nodes into a grid.
|
|
1632
|
+
preservePositions: options.childLayout === undefined,
|
|
1633
|
+
...(options.childLayout ? { layout: options.childLayout } : {}),
|
|
1634
|
+
});
|
|
1635
|
+
const applied = canvasState.getNode(groupId)?.size;
|
|
1577
1636
|
return {
|
|
1578
|
-
ok
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
preservePositions: options.childLayout === undefined,
|
|
1583
|
-
...(options.childLayout ? { layout: options.childLayout } : {}),
|
|
1584
|
-
}),
|
|
1637
|
+
ok,
|
|
1638
|
+
...(before && applied && (before.width !== applied.width || before.height !== applied.height)
|
|
1639
|
+
? { sizeAdjustment: { requested: before, applied, reason: 'fit-to-children' as const } }
|
|
1640
|
+
: {}),
|
|
1585
1641
|
};
|
|
1586
1642
|
}
|
|
1587
1643
|
|
|
@@ -1600,6 +1656,8 @@ export function createCanvasJsonRenderNode(input: JsonRenderNodeInput): {
|
|
|
1600
1656
|
url: string;
|
|
1601
1657
|
spec: JsonRenderSpec;
|
|
1602
1658
|
node: CanvasNodeState;
|
|
1659
|
+
warnings: string[];
|
|
1660
|
+
sizeAdjustment?: CanvasSizeAdjustment;
|
|
1603
1661
|
} {
|
|
1604
1662
|
const spec = normalizeAndValidateJsonRenderSpec(input.spec);
|
|
1605
1663
|
const { width, height } = clampCreateNodeSize(
|
|
@@ -1628,7 +1686,24 @@ export function createCanvasJsonRenderNode(input: JsonRenderNodeInput): {
|
|
|
1628
1686
|
};
|
|
1629
1687
|
|
|
1630
1688
|
canvasState.addJsonRenderNode(node);
|
|
1631
|
-
|
|
1689
|
+
const sizeAdjustment = describeCreateSize(
|
|
1690
|
+
input.width,
|
|
1691
|
+
input.height,
|
|
1692
|
+
input.width ?? JSON_RENDER_NODE_SIZE.width,
|
|
1693
|
+
input.height ?? JSON_RENDER_NODE_SIZE.height,
|
|
1694
|
+
{
|
|
1695
|
+
width,
|
|
1696
|
+
height,
|
|
1697
|
+
},
|
|
1698
|
+
);
|
|
1699
|
+
return {
|
|
1700
|
+
id,
|
|
1701
|
+
url: String(node.data.url),
|
|
1702
|
+
spec,
|
|
1703
|
+
node,
|
|
1704
|
+
warnings: jsonRenderFormWarnings(spec),
|
|
1705
|
+
...(sizeAdjustment ? { sizeAdjustment } : {}),
|
|
1706
|
+
};
|
|
1632
1707
|
}
|
|
1633
1708
|
|
|
1634
1709
|
/**
|
|
@@ -1643,7 +1718,7 @@ export function createCanvasStreamingJsonRenderNode(input: {
|
|
|
1643
1718
|
width?: number;
|
|
1644
1719
|
height?: number;
|
|
1645
1720
|
strictSize?: boolean;
|
|
1646
|
-
}): { id: string; url: string; spec: JsonRenderSpec; node: CanvasNodeState } {
|
|
1721
|
+
}): { id: string; url: string; spec: JsonRenderSpec; node: CanvasNodeState; sizeAdjustment?: CanvasSizeAdjustment } {
|
|
1647
1722
|
const spec = emptyStreamingSpec();
|
|
1648
1723
|
const { width, height } = clampCreateNodeSize(
|
|
1649
1724
|
'json-render',
|
|
@@ -1673,7 +1748,17 @@ export function createCanvasStreamingJsonRenderNode(input: {
|
|
|
1673
1748
|
};
|
|
1674
1749
|
|
|
1675
1750
|
canvasState.addJsonRenderNode(node);
|
|
1676
|
-
|
|
1751
|
+
const sizeAdjustment = describeCreateSize(
|
|
1752
|
+
input.width,
|
|
1753
|
+
input.height,
|
|
1754
|
+
input.width ?? JSON_RENDER_NODE_SIZE.width,
|
|
1755
|
+
input.height ?? JSON_RENDER_NODE_SIZE.height,
|
|
1756
|
+
{
|
|
1757
|
+
width,
|
|
1758
|
+
height,
|
|
1759
|
+
},
|
|
1760
|
+
);
|
|
1761
|
+
return { id, url: String(node.data.url), spec, node, ...(sizeAdjustment ? { sizeAdjustment } : {}) };
|
|
1677
1762
|
}
|
|
1678
1763
|
|
|
1679
1764
|
/**
|
|
@@ -1717,6 +1802,7 @@ export function createCanvasGraphNode(input: GraphNodeInput): {
|
|
|
1717
1802
|
url: string;
|
|
1718
1803
|
spec: JsonRenderSpec;
|
|
1719
1804
|
node: CanvasNodeState;
|
|
1805
|
+
sizeAdjustment?: CanvasSizeAdjustment;
|
|
1720
1806
|
} {
|
|
1721
1807
|
const title = input.title?.trim() || 'Graph';
|
|
1722
1808
|
const spec = buildGraphSpec(input);
|
|
@@ -1747,7 +1833,14 @@ export function createCanvasGraphNode(input: GraphNodeInput): {
|
|
|
1747
1833
|
};
|
|
1748
1834
|
|
|
1749
1835
|
canvasState.addGraphNode(node);
|
|
1750
|
-
|
|
1836
|
+
const sizeAdjustment = describeCreateSize(
|
|
1837
|
+
input.width,
|
|
1838
|
+
input.heightPx,
|
|
1839
|
+
input.width ?? GRAPH_NODE_SIZE.width,
|
|
1840
|
+
input.heightPx ?? GRAPH_NODE_SIZE.height,
|
|
1841
|
+
{ width, height },
|
|
1842
|
+
);
|
|
1843
|
+
return { id, url: String(node.data.url), spec, node, ...(sizeAdjustment ? { sizeAdjustment } : {}) };
|
|
1751
1844
|
}
|
|
1752
1845
|
|
|
1753
1846
|
/**
|
|
@@ -3,6 +3,7 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { describeJsonRenderCatalog, type JsonRenderComponentDescriptor } from '../json-render/catalog.js';
|
|
4
4
|
import {
|
|
5
5
|
buildGraphSpec,
|
|
6
|
+
jsonRenderFormWarnings,
|
|
6
7
|
normalizeAndValidateJsonRenderSpec,
|
|
7
8
|
normalizeGraphType,
|
|
8
9
|
type GraphNodeInput,
|
|
@@ -38,6 +39,7 @@ export interface StructuredValidationResult {
|
|
|
38
39
|
ok: true;
|
|
39
40
|
type: 'json-render' | 'graph' | 'html-primitive';
|
|
40
41
|
normalizedSpec?: JsonRenderSpec;
|
|
42
|
+
warnings?: string[];
|
|
41
43
|
normalizedPrimitive?: {
|
|
42
44
|
kind: string;
|
|
43
45
|
title: string;
|
|
@@ -235,6 +237,13 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
|
|
|
235
237
|
fields: [
|
|
236
238
|
{ name: 'title', type: 'string', required: false, description: 'Optional node title.' },
|
|
237
239
|
{ name: 'content', type: 'string', required: false, description: 'Mermaid diagram source text.' },
|
|
240
|
+
{
|
|
241
|
+
name: 'data.fit',
|
|
242
|
+
type: '"contain" | "none"',
|
|
243
|
+
required: false,
|
|
244
|
+
description:
|
|
245
|
+
'Default contain: shrink overflowing diagrams proportionally, without enlarging. none: 100% with scrolling. Open as site always uses 100%. CLI: --fit contain|none.',
|
|
246
|
+
},
|
|
238
247
|
],
|
|
239
248
|
example: {
|
|
240
249
|
type: 'mermaid',
|
|
@@ -1013,6 +1022,7 @@ export function validateStructuredCanvasPayload(input: {
|
|
|
1013
1022
|
ok: true,
|
|
1014
1023
|
type: 'json-render',
|
|
1015
1024
|
normalizedSpec,
|
|
1025
|
+
warnings: jsonRenderFormWarnings(normalizedSpec),
|
|
1016
1026
|
summary: {
|
|
1017
1027
|
root: normalizedSpec.root,
|
|
1018
1028
|
elementCount: Object.keys(normalizedSpec.elements).length,
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { createHash } from 'node:crypto';
|
|
16
|
+
import { tourSchema, type Tour } from '../shared/tour.js';
|
|
16
17
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync, unlinkSync } from 'node:fs';
|
|
17
18
|
import { isAbsolute, join, dirname, relative } from 'node:path';
|
|
18
19
|
import { gzipSync, gunzipSync } from 'node:zlib';
|
|
@@ -223,6 +224,7 @@ export interface CanvasAnnotation {
|
|
|
223
224
|
}
|
|
224
225
|
|
|
225
226
|
export interface CanvasLayout {
|
|
227
|
+
tour?: Tour;
|
|
226
228
|
viewport: ViewportState;
|
|
227
229
|
theme: CanvasTheme;
|
|
228
230
|
nodes: CanvasNodeState[];
|
|
@@ -333,6 +335,7 @@ class CanvasStateManager {
|
|
|
333
335
|
private edges = new Map<string, CanvasEdge>();
|
|
334
336
|
private annotations = new Map<string, CanvasAnnotation>();
|
|
335
337
|
private _viewport: ViewportState = { x: 0, y: 0, scale: 1 };
|
|
338
|
+
private _tour: Tour | undefined;
|
|
336
339
|
private _theme: CanvasTheme = 'dark';
|
|
337
340
|
private _contextPinnedNodeIds = new Set<string>();
|
|
338
341
|
private _workspaceRoot = process.cwd();
|
|
@@ -899,6 +902,7 @@ class CanvasStateManager {
|
|
|
899
902
|
const payload = this.externalizePersistedStateBlobs({
|
|
900
903
|
version: 1,
|
|
901
904
|
theme: this._theme,
|
|
905
|
+
tour: this._tour,
|
|
902
906
|
viewport: this._viewport,
|
|
903
907
|
nodes: Array.from(this.nodes.values()),
|
|
904
908
|
edges: Array.from(this.edges.values()),
|
|
@@ -957,6 +961,7 @@ class CanvasStateManager {
|
|
|
957
961
|
}
|
|
958
962
|
|
|
959
963
|
private applyPersistedState(state: PersistedCanvasState): void {
|
|
964
|
+
this._tour = state.tour ? structuredClone(state.tour) : undefined;
|
|
960
965
|
this.nodes.clear();
|
|
961
966
|
this.edges.clear();
|
|
962
967
|
this.annotations.clear();
|
|
@@ -1091,7 +1096,7 @@ class CanvasStateManager {
|
|
|
1091
1096
|
contextPins: Array.from(this._contextPinnedNodeIds),
|
|
1092
1097
|
ax: this.getAxState(),
|
|
1093
1098
|
});
|
|
1094
|
-
saveSnapshotToDB(this._db, snapshot, payload);
|
|
1099
|
+
saveSnapshotToDB(this._db, snapshot, { ...payload, tour: this._tour });
|
|
1095
1100
|
snapshot.nodeCount = payload.nodes.length;
|
|
1096
1101
|
snapshot.edgeCount = payload.edges.length;
|
|
1097
1102
|
return snapshot;
|
|
@@ -1176,6 +1181,7 @@ class CanvasStateManager {
|
|
|
1176
1181
|
|
|
1177
1182
|
const previousState: PersistedCanvasState = this.externalizePersistedStateBlobs({
|
|
1178
1183
|
version: 1,
|
|
1184
|
+
tour: this._tour,
|
|
1179
1185
|
theme: this._theme,
|
|
1180
1186
|
viewport: structuredClone(this._viewport),
|
|
1181
1187
|
nodes: Array.from(this.nodes.values(), (node) => structuredClone(node)),
|
|
@@ -1186,6 +1192,7 @@ class CanvasStateManager {
|
|
|
1186
1192
|
});
|
|
1187
1193
|
const nextState: PersistedCanvasState = {
|
|
1188
1194
|
version: 1,
|
|
1195
|
+
tour: resolved.state.tour,
|
|
1189
1196
|
theme: normalizeCanvasTheme(resolved.state.theme, this._theme),
|
|
1190
1197
|
viewport: structuredClone(resolved.state.viewport),
|
|
1191
1198
|
nodes: Array.isArray(resolved.state.nodes) ? resolved.state.nodes.map((node) => structuredClone(node)) : [],
|
|
@@ -1233,9 +1240,13 @@ class CanvasStateManager {
|
|
|
1233
1240
|
}
|
|
1234
1241
|
|
|
1235
1242
|
/** Read a snapshot's data without restoring it (for diff). Resolves by ID or name. */
|
|
1236
|
-
getSnapshotData(
|
|
1237
|
-
|
|
1238
|
-
|
|
1243
|
+
getSnapshotData(idOrName: string): {
|
|
1244
|
+
name: string;
|
|
1245
|
+
nodes: CanvasNodeState[];
|
|
1246
|
+
edges: CanvasEdge[];
|
|
1247
|
+
annotations: CanvasAnnotation[];
|
|
1248
|
+
tour?: Tour;
|
|
1249
|
+
} | null {
|
|
1239
1250
|
const resolved = this.readResolvedSnapshot(idOrName);
|
|
1240
1251
|
if (!resolved) return null;
|
|
1241
1252
|
const state = {
|
|
@@ -1246,6 +1257,7 @@ class CanvasStateManager {
|
|
|
1246
1257
|
};
|
|
1247
1258
|
return {
|
|
1248
1259
|
name: resolved.snapshot.name,
|
|
1260
|
+
...(state.tour ? { tour: structuredClone(state.tour) } : {}),
|
|
1249
1261
|
nodes: Array.isArray(state.nodes) ? state.nodes.map((node) => structuredClone(node)) : [],
|
|
1250
1262
|
edges: Array.isArray(state.edges) ? state.edges.map((edge) => structuredClone(edge)) : [],
|
|
1251
1263
|
annotations: Array.isArray(state.annotations)
|
|
@@ -1620,6 +1632,7 @@ class CanvasStateManager {
|
|
|
1620
1632
|
|
|
1621
1633
|
getLayout(): CanvasLayout {
|
|
1622
1634
|
return {
|
|
1635
|
+
tour: this.getTour(),
|
|
1623
1636
|
viewport: structuredClone(this._viewport),
|
|
1624
1637
|
theme: this._theme,
|
|
1625
1638
|
nodes: Array.from(this.nodes.values(), (node) => structuredClone(this.nodeForRead(node))),
|
|
@@ -1630,6 +1643,7 @@ class CanvasStateManager {
|
|
|
1630
1643
|
|
|
1631
1644
|
getLayoutForPersistence(): CanvasLayout {
|
|
1632
1645
|
return {
|
|
1646
|
+
tour: this.getTour(),
|
|
1633
1647
|
viewport: structuredClone(this._viewport),
|
|
1634
1648
|
theme: this._theme,
|
|
1635
1649
|
nodes: Array.from(this.nodes.values(), (node) => structuredClone(this.externalizeNodeDataBlobs(node))),
|
|
@@ -1638,6 +1652,24 @@ class CanvasStateManager {
|
|
|
1638
1652
|
};
|
|
1639
1653
|
}
|
|
1640
1654
|
|
|
1655
|
+
getTour(): Tour | undefined {
|
|
1656
|
+
return this._tour ? structuredClone(this._tour) : undefined;
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
setTour(tour: Tour | null): void {
|
|
1660
|
+
const previous = this.getTour();
|
|
1661
|
+
this._tour = tour === null ? undefined : tourSchema.parse(tour);
|
|
1662
|
+
const next = this.getTour();
|
|
1663
|
+
this.scheduleSave();
|
|
1664
|
+
this.notifyChange('nodes');
|
|
1665
|
+
this.recordMutation({
|
|
1666
|
+
operationType: 'viewport',
|
|
1667
|
+
description: 'Updated board tour',
|
|
1668
|
+
forward: this.suppressed(() => this.setTour(next ?? null)),
|
|
1669
|
+
inverse: this.suppressed(() => this.setTour(previous ?? null)),
|
|
1670
|
+
});
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1641
1673
|
applyUpdates(updates: CanvasNodeUpdate[], options: ApplyUpdatesOptions = {}): { applied: number; skipped: number } {
|
|
1642
1674
|
let applied = 0;
|
|
1643
1675
|
let skipped = 0;
|
|
@@ -2246,6 +2278,8 @@ class CanvasStateManager {
|
|
|
2246
2278
|
}
|
|
2247
2279
|
|
|
2248
2280
|
clear(): void {
|
|
2281
|
+
const oldTour = this.getTour();
|
|
2282
|
+
this._tour = undefined;
|
|
2249
2283
|
const oldNodes = Array.from(this.nodes.values()).map((n) => structuredClone(n));
|
|
2250
2284
|
const oldEdges = Array.from(this.edges.values()).map((e) => structuredClone(e));
|
|
2251
2285
|
const oldAnnotations = Array.from(this.annotations.values()).map((annotation) => structuredClone(annotation));
|
|
@@ -2274,6 +2308,7 @@ class CanvasStateManager {
|
|
|
2274
2308
|
for (const e of oldEdges) this.addEdge(structuredClone(e));
|
|
2275
2309
|
for (const annotation of oldAnnotations) this.addAnnotation(structuredClone(annotation));
|
|
2276
2310
|
this.setContextPins(oldPins);
|
|
2311
|
+
this.setTour(oldTour ?? null);
|
|
2277
2312
|
this.ax.applyPersistedAx(oldAxState);
|
|
2278
2313
|
this.setViewport(oldViewport);
|
|
2279
2314
|
this.notifyChange('ax');
|