pmx-canvas 0.1.15 → 0.1.16
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 +59 -0
- package/dist/canvas/index.js +47 -47
- package/dist/types/client/nodes/ContextNode.d.ts +11 -2
- package/dist/types/client/nodes/StatusNode.d.ts +1 -0
- package/dist/types/client/state/canvas-store.d.ts +6 -2
- package/dist/types/client/state/intent-bridge.d.ts +2 -0
- package/package.json +1 -1
- package/src/client/canvas/ContextPinBar.tsx +2 -1
- package/src/client/canvas/DockedNode.tsx +4 -3
- package/src/client/nodes/ContextNode.tsx +128 -6
- package/src/client/nodes/StatusNode.tsx +16 -1
- package/src/client/nodes/StatusSummary.tsx +2 -1
- package/src/client/state/canvas-store.ts +13 -5
- package/src/client/state/intent-bridge.ts +5 -1
- package/src/client/state/sse-bridge.ts +1 -1
- package/src/server/server.ts +24 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type CanvasNodeState } from '../types';
|
|
2
2
|
interface ContextCard {
|
|
3
3
|
key?: string;
|
|
4
4
|
title?: string;
|
|
@@ -25,10 +25,19 @@ export interface ContextNodeFallbackDisplay {
|
|
|
25
25
|
summary: string;
|
|
26
26
|
path: string;
|
|
27
27
|
}
|
|
28
|
+
export interface PinnedContextDisplay {
|
|
29
|
+
id: string;
|
|
30
|
+
title: string;
|
|
31
|
+
summary: string;
|
|
32
|
+
kind: string;
|
|
33
|
+
path: string;
|
|
34
|
+
}
|
|
28
35
|
export declare function normalizeContextCardDisplay(card: ContextCard): ContextCardDisplay;
|
|
29
36
|
export declare function normalizeContextNodeFallback(nodeData: Record<string, unknown>): ContextNodeFallbackDisplay | null;
|
|
30
|
-
export declare function
|
|
37
|
+
export declare function normalizePinnedContextDisplay(node: CanvasNodeState): PinnedContextDisplay;
|
|
38
|
+
export declare function ContextNode({ node, expanded, pinnedNodes, }: {
|
|
31
39
|
node: CanvasNodeState;
|
|
32
40
|
expanded?: boolean;
|
|
41
|
+
pinnedNodes?: CanvasNodeState[];
|
|
33
42
|
}): import("preact/src").JSX.Element;
|
|
34
43
|
export {};
|
|
@@ -64,7 +64,9 @@ export declare function applyServerCanvasLayout(layout: Pick<CanvasLayout, 'node
|
|
|
64
64
|
* Cancels any in-flight animation. Direct manipulation (pan/zoom gestures)
|
|
65
65
|
* should use setViewport() instead for instant response.
|
|
66
66
|
*/
|
|
67
|
-
export declare function animateViewport(target: ViewportState, duration?: number
|
|
67
|
+
export declare function animateViewport(target: ViewportState, duration?: number, options?: {
|
|
68
|
+
recordHistory?: boolean;
|
|
69
|
+
}): void;
|
|
68
70
|
/** Cancel any in-flight viewport animation (e.g. when user starts dragging). */
|
|
69
71
|
export declare function cancelViewportAnimation(): void;
|
|
70
72
|
export declare function persistLayout(options?: {
|
|
@@ -72,7 +74,9 @@ export declare function persistLayout(options?: {
|
|
|
72
74
|
}): void;
|
|
73
75
|
export declare function restoreLayout(): Map<string, Partial<CanvasNodeState>> | null;
|
|
74
76
|
export declare function fitAll(containerW: number, containerH: number): void;
|
|
75
|
-
export declare function focusNode(id: string
|
|
77
|
+
export declare function focusNode(id: string, options?: {
|
|
78
|
+
recordHistory?: boolean;
|
|
79
|
+
}): void;
|
|
76
80
|
export declare function cycleActiveNode(direction?: 1 | -1): void;
|
|
77
81
|
export declare function walkGraph(direction: 'up' | 'down' | 'left' | 'right'): void;
|
|
78
82
|
export declare function expandNode(id: string): void;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmx-canvas",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.16",
|
|
4
4
|
"description": "Spatial canvas workbench for coding agents — infinite 2D canvas with agent-native CLI, MCP integration, nodes, edges, file watching, and snapshots",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/server/index.ts",
|
|
@@ -2,10 +2,11 @@ import {
|
|
|
2
2
|
clearContextPins,
|
|
3
3
|
contextPinnedNodeIds,
|
|
4
4
|
} from '../state/canvas-store';
|
|
5
|
+
import { attentionHistoryOpen } from '../state/attention-store';
|
|
5
6
|
|
|
6
7
|
export function ContextPinBar() {
|
|
7
8
|
const count = contextPinnedNodeIds.value.size;
|
|
8
|
-
if (count === 0) return null;
|
|
9
|
+
if (count === 0 || attentionHistoryOpen.value) return null;
|
|
9
10
|
|
|
10
11
|
return (
|
|
11
12
|
<div class="context-pin-bar">
|
|
@@ -3,7 +3,7 @@ import { LedgerNode } from '../nodes/LedgerNode';
|
|
|
3
3
|
import { StatusNode } from '../nodes/StatusNode';
|
|
4
4
|
import { StatusSummary } from '../nodes/StatusSummary';
|
|
5
5
|
import { attentionHistoryOpen, closeAttentionHistory } from '../state/attention-store';
|
|
6
|
-
import { toggleCollapsed, undockNode } from '../state/canvas-store';
|
|
6
|
+
import { getContextPinnedNodes, toggleCollapsed, undockNode } from '../state/canvas-store';
|
|
7
7
|
import { TYPE_LABELS } from '../types';
|
|
8
8
|
import type { CanvasNodeState } from '../types';
|
|
9
9
|
|
|
@@ -27,7 +27,8 @@ function getContextItemCount(node: CanvasNodeState): number {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
function ContextDockedNode({ node }: { node: CanvasNodeState }) {
|
|
30
|
-
const
|
|
30
|
+
const pinnedNodes = getContextPinnedNodes();
|
|
31
|
+
const count = pinnedNodes.length > 0 ? pinnedNodes.length : getContextItemCount(node);
|
|
31
32
|
const hasItems = count > 0;
|
|
32
33
|
const collapsed = node.collapsed === true;
|
|
33
34
|
|
|
@@ -117,7 +118,7 @@ function ContextDockedNode({ node }: { node: CanvasNodeState }) {
|
|
|
117
118
|
</div>
|
|
118
119
|
</div>
|
|
119
120
|
<div class="context-dock-body">
|
|
120
|
-
<ContextNode node={node} />
|
|
121
|
+
<ContextNode node={node} pinnedNodes={pinnedNodes} />
|
|
121
122
|
</div>
|
|
122
123
|
</aside>
|
|
123
124
|
);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { openWorkbenchFile } from '../state/intent-bridge';
|
|
2
|
-
import type
|
|
2
|
+
import { TYPE_LABELS, type CanvasNodeState } from '../types';
|
|
3
3
|
|
|
4
4
|
interface ContextCard {
|
|
5
5
|
key?: string;
|
|
@@ -30,6 +30,14 @@ export interface ContextNodeFallbackDisplay {
|
|
|
30
30
|
path: string;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
export interface PinnedContextDisplay {
|
|
34
|
+
id: string;
|
|
35
|
+
title: string;
|
|
36
|
+
summary: string;
|
|
37
|
+
kind: string;
|
|
38
|
+
path: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
33
41
|
export function normalizeContextCardDisplay(card: ContextCard): ContextCardDisplay {
|
|
34
42
|
const title = card.title || card.label || card.key || 'Context';
|
|
35
43
|
const summary = card.summary?.trim() || 'Available in startup context.';
|
|
@@ -90,6 +98,24 @@ export function normalizeContextNodeFallback(
|
|
|
90
98
|
};
|
|
91
99
|
}
|
|
92
100
|
|
|
101
|
+
export function normalizePinnedContextDisplay(node: CanvasNodeState): PinnedContextDisplay {
|
|
102
|
+
const title = asTrimmedString(node.data.title) || node.id;
|
|
103
|
+
const summary =
|
|
104
|
+
asTrimmedString(node.data.content) ||
|
|
105
|
+
asTrimmedString(node.data.excerpt) ||
|
|
106
|
+
asTrimmedString(node.data.description) ||
|
|
107
|
+
asTrimmedString(node.data.pageTitle) ||
|
|
108
|
+
'';
|
|
109
|
+
const path = asTrimmedString(node.data.path) || asTrimmedString(node.data.url);
|
|
110
|
+
return {
|
|
111
|
+
id: node.id,
|
|
112
|
+
title,
|
|
113
|
+
summary,
|
|
114
|
+
kind: TYPE_LABELS[node.type] ?? node.type,
|
|
115
|
+
path,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
93
119
|
function formatTokens(n: number | null): string {
|
|
94
120
|
if (n === null || !Number.isFinite(n) || n < 0) return '0';
|
|
95
121
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}m`;
|
|
@@ -107,9 +133,12 @@ function usageBarColor(utilization: number): string {
|
|
|
107
133
|
export function ContextNode({
|
|
108
134
|
node,
|
|
109
135
|
expanded = false,
|
|
110
|
-
|
|
136
|
+
pinnedNodes = [],
|
|
137
|
+
}: { node: CanvasNodeState; expanded?: boolean; pinnedNodes?: CanvasNodeState[] }) {
|
|
111
138
|
const cards = (node.data.cards as ContextCard[]) ?? [];
|
|
112
139
|
const auxTabs = (node.data.auxTabs as Array<{ id: string; url: string; reason?: string }>) ?? [];
|
|
140
|
+
const pinnedContext = pinnedNodes.map(normalizePinnedContextDisplay);
|
|
141
|
+
const hasPinnedContext = pinnedContext.length > 0;
|
|
113
142
|
const currentTokens =
|
|
114
143
|
typeof node.data.currentTokens === 'number' ? node.data.currentTokens : null;
|
|
115
144
|
const tokenLimit = typeof node.data.tokenLimit === 'number' ? node.data.tokenLimit : null;
|
|
@@ -120,7 +149,9 @@ export function ContextNode({
|
|
|
120
149
|
utilization !== null ? Math.max(0, Math.min(100, Math.round(utilization * 100))) : null;
|
|
121
150
|
const barColor = usageBarColor(utilization ?? 0);
|
|
122
151
|
const fallback =
|
|
123
|
-
cards.length === 0 && auxTabs.length === 0
|
|
152
|
+
!hasPinnedContext && cards.length === 0 && auxTabs.length === 0
|
|
153
|
+
? normalizeContextNodeFallback(node.data)
|
|
154
|
+
: null;
|
|
124
155
|
|
|
125
156
|
const openCard = async (card: ContextCard): Promise<void> => {
|
|
126
157
|
const path = typeof card.path === 'string' ? card.path.trim() : '';
|
|
@@ -181,7 +212,98 @@ export function ContextNode({
|
|
|
181
212
|
</div>
|
|
182
213
|
)}
|
|
183
214
|
|
|
184
|
-
{
|
|
215
|
+
{hasPinnedContext && (
|
|
216
|
+
<div>
|
|
217
|
+
<div
|
|
218
|
+
style={{
|
|
219
|
+
fontSize: '10px',
|
|
220
|
+
fontWeight: 600,
|
|
221
|
+
color: 'var(--c-muted)',
|
|
222
|
+
textTransform: 'uppercase',
|
|
223
|
+
letterSpacing: '0.04em',
|
|
224
|
+
marginBottom: '6px',
|
|
225
|
+
}}
|
|
226
|
+
>
|
|
227
|
+
Pinned Context ({pinnedContext.length})
|
|
228
|
+
</div>
|
|
229
|
+
{pinnedContext.map((display) => (
|
|
230
|
+
<div
|
|
231
|
+
key={display.id}
|
|
232
|
+
style={{
|
|
233
|
+
padding: '6px 8px',
|
|
234
|
+
background: 'var(--c-surface-subtle)',
|
|
235
|
+
borderRadius: '6px',
|
|
236
|
+
marginBottom: '4px',
|
|
237
|
+
borderLeft: '2px solid var(--c-accent)',
|
|
238
|
+
}}
|
|
239
|
+
>
|
|
240
|
+
<div style={{ fontWeight: 600, color: 'var(--c-text)', marginBottom: '2px' }}>
|
|
241
|
+
{display.title}
|
|
242
|
+
</div>
|
|
243
|
+
{display.summary && (
|
|
244
|
+
<div
|
|
245
|
+
style={{
|
|
246
|
+
color: 'var(--c-muted)',
|
|
247
|
+
fontSize: '10px',
|
|
248
|
+
lineHeight: 1.45,
|
|
249
|
+
marginBottom: '4px',
|
|
250
|
+
whiteSpace: 'pre-wrap',
|
|
251
|
+
}}
|
|
252
|
+
>
|
|
253
|
+
{display.summary}
|
|
254
|
+
</div>
|
|
255
|
+
)}
|
|
256
|
+
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap', marginTop: '4px' }}>
|
|
257
|
+
<span
|
|
258
|
+
style={{
|
|
259
|
+
fontSize: '9px',
|
|
260
|
+
padding: '1px 4px',
|
|
261
|
+
background: 'var(--c-surface-hover)',
|
|
262
|
+
color: 'var(--c-text-soft)',
|
|
263
|
+
borderRadius: '3px',
|
|
264
|
+
display: 'inline-block',
|
|
265
|
+
}}
|
|
266
|
+
>
|
|
267
|
+
{display.kind}
|
|
268
|
+
</span>
|
|
269
|
+
</div>
|
|
270
|
+
{display.path && (
|
|
271
|
+
<div style={{ marginTop: '6px' }}>
|
|
272
|
+
<div
|
|
273
|
+
style={{
|
|
274
|
+
color: 'var(--c-dim)',
|
|
275
|
+
fontSize: '10px',
|
|
276
|
+
wordBreak: 'break-all',
|
|
277
|
+
marginBottom: '6px',
|
|
278
|
+
}}
|
|
279
|
+
>
|
|
280
|
+
{display.path}
|
|
281
|
+
</div>
|
|
282
|
+
{display.path.startsWith('/') && (
|
|
283
|
+
<button
|
|
284
|
+
type="button"
|
|
285
|
+
onClick={() => void openWorkbenchFile(display.path)}
|
|
286
|
+
style={{
|
|
287
|
+
padding: '4px 8px',
|
|
288
|
+
fontSize: '10px',
|
|
289
|
+
background: 'var(--c-accent-12)',
|
|
290
|
+
border: '1px solid var(--c-accent-25)',
|
|
291
|
+
borderRadius: '4px',
|
|
292
|
+
color: 'var(--c-text-soft)',
|
|
293
|
+
cursor: 'pointer',
|
|
294
|
+
}}
|
|
295
|
+
>
|
|
296
|
+
Open in canvas
|
|
297
|
+
</button>
|
|
298
|
+
)}
|
|
299
|
+
</div>
|
|
300
|
+
)}
|
|
301
|
+
</div>
|
|
302
|
+
))}
|
|
303
|
+
</div>
|
|
304
|
+
)}
|
|
305
|
+
|
|
306
|
+
{!hasPinnedContext && cards.length > 0 && (
|
|
185
307
|
<div>
|
|
186
308
|
<div
|
|
187
309
|
style={{
|
|
@@ -305,7 +427,7 @@ export function ContextNode({
|
|
|
305
427
|
</div>
|
|
306
428
|
)}
|
|
307
429
|
|
|
308
|
-
{auxTabs.length > 0 && (
|
|
430
|
+
{!hasPinnedContext && auxTabs.length > 0 && (
|
|
309
431
|
<div>
|
|
310
432
|
<div
|
|
311
433
|
style={{
|
|
@@ -404,7 +526,7 @@ export function ContextNode({
|
|
|
404
526
|
</div>
|
|
405
527
|
)}
|
|
406
528
|
|
|
407
|
-
{!fallback && cards.length === 0 && auxTabs.length === 0 && (
|
|
529
|
+
{!hasPinnedContext && !fallback && cards.length === 0 && auxTabs.length === 0 && (
|
|
408
530
|
<div style={{ color: 'var(--c-dim)', fontStyle: 'italic' }}>No context loaded</div>
|
|
409
531
|
)}
|
|
410
532
|
</div>
|
|
@@ -1,8 +1,23 @@
|
|
|
1
1
|
import { PHASE_COLORS } from '../theme/tokens';
|
|
2
2
|
import type { CanvasNodeState } from '../types';
|
|
3
3
|
|
|
4
|
+
export function getStatusDisplayPhase(node: CanvasNodeState): string {
|
|
5
|
+
const phase = typeof node.data.phase === 'string' && node.data.phase.trim().length > 0
|
|
6
|
+
? node.data.phase.trim()
|
|
7
|
+
: '';
|
|
8
|
+
if (phase) return phase;
|
|
9
|
+
const content = typeof node.data.content === 'string' && node.data.content.trim().length > 0
|
|
10
|
+
? node.data.content.trim()
|
|
11
|
+
: '';
|
|
12
|
+
if (content) return content;
|
|
13
|
+
const status = typeof node.data.status === 'string' && node.data.status.trim().length > 0
|
|
14
|
+
? node.data.status.trim()
|
|
15
|
+
: '';
|
|
16
|
+
return status || 'idle';
|
|
17
|
+
}
|
|
18
|
+
|
|
4
19
|
export function StatusNode({ node }: { node: CanvasNodeState }) {
|
|
5
|
-
const phase = (node
|
|
20
|
+
const phase = getStatusDisplayPhase(node);
|
|
6
21
|
const detail = (node.data.detail as string) || '';
|
|
7
22
|
const message = (node.data.message as string) || '';
|
|
8
23
|
const level = (node.data.level as string) || 'ok';
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { PHASE_COLORS } from '../theme/tokens';
|
|
2
2
|
import type { CanvasNodeState } from '../types';
|
|
3
|
+
import { getStatusDisplayPhase } from './StatusNode';
|
|
3
4
|
|
|
4
5
|
export function StatusSummary({ node }: { node: CanvasNodeState }) {
|
|
5
|
-
const phase = (node
|
|
6
|
+
const phase = getStatusDisplayPhase(node);
|
|
6
7
|
const activeTool = node.data.activeTool as string | null;
|
|
7
8
|
const subagent = node.data.subagent as { state: string; name: string } | undefined;
|
|
8
9
|
const phaseColor = PHASE_COLORS[phase] ?? 'var(--c-muted)';
|
|
@@ -330,9 +330,16 @@ export function replaceViewport(next: ViewportState): void {
|
|
|
330
330
|
}
|
|
331
331
|
|
|
332
332
|
export function commitViewport(next: ViewportState): void {
|
|
333
|
+
commitViewportWithOptions(next);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function commitViewportWithOptions(
|
|
337
|
+
next: ViewportState,
|
|
338
|
+
options: { recordHistory?: boolean } = {},
|
|
339
|
+
): void {
|
|
333
340
|
viewport.value = next;
|
|
334
|
-
persistLayout();
|
|
335
|
-
void updateViewportFromClient(next);
|
|
341
|
+
persistLayout(options);
|
|
342
|
+
void updateViewportFromClient(next, options);
|
|
336
343
|
}
|
|
337
344
|
|
|
338
345
|
export function applyServerCanvasLayout(
|
|
@@ -394,6 +401,7 @@ function easeOutCubic(t: number): number {
|
|
|
394
401
|
export function animateViewport(
|
|
395
402
|
target: ViewportState,
|
|
396
403
|
duration = 300,
|
|
404
|
+
options: { recordHistory?: boolean } = {},
|
|
397
405
|
): void {
|
|
398
406
|
if (animationId !== null) cancelAnimationFrame(animationId);
|
|
399
407
|
|
|
@@ -415,7 +423,7 @@ export function animateViewport(
|
|
|
415
423
|
animationId = requestAnimationFrame(tick);
|
|
416
424
|
} else {
|
|
417
425
|
animationId = null;
|
|
418
|
-
|
|
426
|
+
commitViewportWithOptions(target, options);
|
|
419
427
|
}
|
|
420
428
|
}
|
|
421
429
|
|
|
@@ -540,7 +548,7 @@ export function fitAll(containerW: number, containerH: number): void {
|
|
|
540
548
|
}
|
|
541
549
|
|
|
542
550
|
// ── Focus node ────────────────────────────────────────────────
|
|
543
|
-
export function focusNode(id: string): void {
|
|
551
|
+
export function focusNode(id: string, options: { recordHistory?: boolean } = {}): void {
|
|
544
552
|
const node = nodes.value.get(id);
|
|
545
553
|
if (!node) return;
|
|
546
554
|
const v = viewport.value;
|
|
@@ -550,7 +558,7 @@ export function focusNode(id: string): void {
|
|
|
550
558
|
x: window.innerWidth / 2 - cx * v.scale,
|
|
551
559
|
y: window.innerHeight / 2 - cy * v.scale,
|
|
552
560
|
scale: v.scale,
|
|
553
|
-
});
|
|
561
|
+
}, 300, options);
|
|
554
562
|
bringToFront(id);
|
|
555
563
|
}
|
|
556
564
|
|
|
@@ -231,11 +231,15 @@ export async function removeNodeFromClient(id: string): Promise<{ ok: boolean; r
|
|
|
231
231
|
/** Commit the current viewport to the authoritative server state. */
|
|
232
232
|
export async function updateViewportFromClient(
|
|
233
233
|
viewport: { x: number; y: number; scale: number },
|
|
234
|
+
options: { recordHistory?: boolean } = {},
|
|
234
235
|
): Promise<{ ok: boolean }> {
|
|
235
236
|
return requestJson('updateViewportFromClient', '/api/canvas/viewport', { ok: false }, {
|
|
236
237
|
method: 'POST',
|
|
237
238
|
headers: { 'Content-Type': 'application/json' },
|
|
238
|
-
body: JSON.stringify(
|
|
239
|
+
body: JSON.stringify({
|
|
240
|
+
...viewport,
|
|
241
|
+
...(options.recordHistory === false ? { recordHistory: false } : {}),
|
|
242
|
+
}),
|
|
239
243
|
});
|
|
240
244
|
}
|
|
241
245
|
|
package/src/server/server.ts
CHANGED
|
@@ -1216,7 +1216,13 @@ async function handleCanvasViewport(req: Request): Promise<Response> {
|
|
|
1216
1216
|
y: typeof body.y === 'number' ? body.y : canvasState.viewport.y,
|
|
1217
1217
|
scale: typeof body.scale === 'number' ? body.scale : canvasState.viewport.scale,
|
|
1218
1218
|
};
|
|
1219
|
-
|
|
1219
|
+
if (body.recordHistory === false) {
|
|
1220
|
+
canvasState.withSuppressedRecording(() => {
|
|
1221
|
+
canvasState.setViewport(next);
|
|
1222
|
+
});
|
|
1223
|
+
} else {
|
|
1224
|
+
canvasState.setViewport(next);
|
|
1225
|
+
}
|
|
1220
1226
|
emitPrimaryWorkbenchEvent('canvas-viewport-update', { viewport: canvasState.viewport });
|
|
1221
1227
|
return responseJson({ ok: true });
|
|
1222
1228
|
}
|
|
@@ -1338,6 +1344,14 @@ async function handleCanvasAddNode(req: Request): Promise<Response> {
|
|
|
1338
1344
|
const extraData = body.data && typeof body.data === 'object' && !Array.isArray(body.data)
|
|
1339
1345
|
? body.data as Record<string, unknown>
|
|
1340
1346
|
: undefined;
|
|
1347
|
+
if (type === 'html') {
|
|
1348
|
+
if ('html' in body && typeof body.html !== 'string') {
|
|
1349
|
+
return responseJson({ ok: false, error: 'HTML node field "html" must be a string.' }, 400);
|
|
1350
|
+
}
|
|
1351
|
+
if (extraData && 'html' in extraData && typeof extraData.html !== 'string') {
|
|
1352
|
+
return responseJson({ ok: false, error: 'HTML node field "data.html" must be a string.' }, 400);
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1341
1355
|
const content = type === 'image' && typeof body.path === 'string' && typeof body.content !== 'string'
|
|
1342
1356
|
? body.path
|
|
1343
1357
|
: body.content;
|
|
@@ -1395,6 +1409,15 @@ async function handleCanvasCreateGroup(req: Request): Promise<Response> {
|
|
|
1395
1409
|
body.childLayout === 'grid' || body.childLayout === 'column' || body.childLayout === 'flow'
|
|
1396
1410
|
? body.childLayout
|
|
1397
1411
|
: undefined;
|
|
1412
|
+
if (childIds.length > 0) {
|
|
1413
|
+
const missingChildIds = childIds.filter((id) => !canvasState.getNode(id));
|
|
1414
|
+
if (missingChildIds.length > 0) {
|
|
1415
|
+
return responseJson({
|
|
1416
|
+
ok: false,
|
|
1417
|
+
error: `Cannot create group: missing child node ID${missingChildIds.length === 1 ? '' : 's'}: ${missingChildIds.join(', ')}.`,
|
|
1418
|
+
}, 400);
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1398
1421
|
|
|
1399
1422
|
const { node } = createCanvasGroup({ title, childIds, color, x, y, width, height, ...(childLayout ? { childLayout } : {}) });
|
|
1400
1423
|
|