pmx-canvas 0.1.18 → 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 +128 -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 +8 -2
- package/dist/types/server/html-primitives.d.ts +34 -0
- package/dist/types/server/index.d.ts +19 -0
- package/docs/RELEASE.md +153 -0
- package/docs/bun-webview-integration.md +296 -0
- package/docs/cli.md +143 -0
- package/docs/evals/e2e-cli-coverage.md +61 -0
- package/docs/http-api.md +201 -0
- package/docs/mcp.md +137 -0
- package/docs/node-types.md +272 -0
- package/docs/plans/.gitkeep +0 -0
- package/docs/plans/plan-001-semantic-watch-mvp.md +335 -0
- package/docs/plans/plan-002-human-attention-layer-design-spec.md +679 -0
- package/docs/plans/plan-003-human-attention-layer-implementation-plan.md +572 -0
- package/docs/reactive-canvas-proposal.md +578 -0
- package/docs/release-review-0.1.0.md +38 -0
- package/docs/screenshot.png +0 -0
- package/docs/screenshots/demo-workbench-dark.png +0 -0
- package/docs/screenshots/demo-workbench-light.png +0 -0
- package/docs/screenshots/welcome-dark.png +0 -0
- package/docs/screenshots/welcome-light.png +0 -0
- package/docs/sdk.md +103 -0
- package/package.json +2 -1
- package/skills/pmx-canvas/SKILL.md +8 -0
- package/src/cli/agent.ts +167 -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 +25 -0
- package/src/mcp/server.ts +85 -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 +27 -9
- 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 +140 -14
- package/src/server/spatial-analysis.ts +3 -3
|
@@ -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>
|
|
@@ -173,10 +173,18 @@ export function addNode(node: CanvasNodeState): void {
|
|
|
173
173
|
}
|
|
174
174
|
|
|
175
175
|
export function updateNode(id: string, patch: Partial<CanvasNodeState>): void {
|
|
176
|
+
updateNodeWithOptions(id, patch);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function updateNodeWithOptions(
|
|
180
|
+
id: string,
|
|
181
|
+
patch: Partial<CanvasNodeState>,
|
|
182
|
+
options: { skipGroupChildTranslation?: boolean } = {},
|
|
183
|
+
): void {
|
|
176
184
|
const existing = nodes.value.get(id);
|
|
177
185
|
if (!existing) return;
|
|
178
186
|
const next = new Map(nodes.value);
|
|
179
|
-
if (existing.type === 'group' && patch.position) {
|
|
187
|
+
if (existing.type === 'group' && patch.position && options.skipGroupChildTranslation !== true) {
|
|
180
188
|
const deltaX = patch.position.x - existing.position.x;
|
|
181
189
|
const deltaY = patch.position.y - existing.position.y;
|
|
182
190
|
if (deltaX !== 0 || deltaY !== 0) {
|
|
@@ -272,9 +280,11 @@ export function removeAnnotation(id: string): void {
|
|
|
272
280
|
}
|
|
273
281
|
|
|
274
282
|
export async function createAnnotationFromClient(input: {
|
|
283
|
+
type?: CanvasAnnotation['type'];
|
|
275
284
|
points: CanvasAnnotation['points'];
|
|
276
285
|
color: string;
|
|
277
286
|
width: number;
|
|
287
|
+
text?: string;
|
|
278
288
|
label?: string;
|
|
279
289
|
}): Promise<{ ok: boolean }> {
|
|
280
290
|
try {
|
|
@@ -748,10 +758,10 @@ export function autoArrange(): void {
|
|
|
748
758
|
updateNode(id, { position });
|
|
749
759
|
}
|
|
750
760
|
for (const [groupId, bounds] of result.groupBounds.entries()) {
|
|
751
|
-
|
|
761
|
+
updateNodeWithOptions(groupId, {
|
|
752
762
|
position: { x: bounds.x, y: bounds.y },
|
|
753
763
|
size: { width: bounds.width, height: bounds.height },
|
|
754
|
-
});
|
|
764
|
+
}, { skipGroupChildTranslation: true });
|
|
755
765
|
}
|
|
756
766
|
});
|
|
757
767
|
persistLayout();
|
|
@@ -766,10 +776,10 @@ export function forceDirectedArrange(): void {
|
|
|
766
776
|
updateNode(id, { position });
|
|
767
777
|
}
|
|
768
778
|
for (const [groupId, bounds] of result.groupBounds.entries()) {
|
|
769
|
-
|
|
779
|
+
updateNodeWithOptions(groupId, {
|
|
770
780
|
position: { x: bounds.x, y: bounds.y },
|
|
771
781
|
size: { width: bounds.width, height: bounds.height },
|
|
772
|
-
});
|
|
782
|
+
}, { skipGroupChildTranslation: true });
|
|
773
783
|
}
|
|
774
784
|
});
|
|
775
785
|
persistLayout();
|
|
@@ -388,21 +388,22 @@ function parseCanvasEdge(raw: Record<string, unknown>): CanvasEdge | null {
|
|
|
388
388
|
|
|
389
389
|
function parseCanvasAnnotation(raw: Record<string, unknown>): CanvasAnnotation | null {
|
|
390
390
|
if (typeof raw.id !== 'string' || !raw.id) return null;
|
|
391
|
-
if (raw.type !== 'freehand') return null;
|
|
391
|
+
if (raw.type !== 'freehand' && raw.type !== 'text') return null;
|
|
392
392
|
if (!Array.isArray(raw.points)) return null;
|
|
393
393
|
const points = raw.points
|
|
394
394
|
.map((point) => parseCanvasPosition(point))
|
|
395
395
|
.filter((point): point is { x: number; y: number } => point !== null);
|
|
396
396
|
const bounds = parseCanvasRect(raw.bounds);
|
|
397
|
-
if (points.length < 2 || !bounds) return null;
|
|
397
|
+
if (points.length < (raw.type === 'text' ? 1 : 2) || !bounds) return null;
|
|
398
398
|
|
|
399
399
|
return {
|
|
400
400
|
id: raw.id,
|
|
401
|
-
type:
|
|
401
|
+
type: raw.type,
|
|
402
402
|
points,
|
|
403
403
|
bounds,
|
|
404
404
|
color: typeof raw.color === 'string' ? raw.color : '#f97316',
|
|
405
405
|
width: typeof raw.width === 'number' ? raw.width : 4,
|
|
406
|
+
...(typeof raw.text === 'string' ? { text: raw.text } : {}),
|
|
406
407
|
...(typeof raw.label === 'string' ? { label: raw.label } : {}),
|
|
407
408
|
createdAt: typeof raw.createdAt === 'string' ? raw.createdAt : '',
|
|
408
409
|
};
|
|
@@ -392,6 +392,19 @@ body,
|
|
|
392
392
|
margin: 0.4em 0;
|
|
393
393
|
color: var(--c-muted);
|
|
394
394
|
}
|
|
395
|
+
|
|
396
|
+
.canvas-node .node-body ul,
|
|
397
|
+
.canvas-node .node-body ol {
|
|
398
|
+
margin: 0.4em 0;
|
|
399
|
+
padding-left: 0.25em;
|
|
400
|
+
list-style-position: inside;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
.canvas-node .node-body li {
|
|
404
|
+
margin: 0.2em 0;
|
|
405
|
+
padding-left: 0.15em;
|
|
406
|
+
}
|
|
407
|
+
|
|
395
408
|
.canvas-node .node-body a {
|
|
396
409
|
color: var(--c-accent);
|
|
397
410
|
text-decoration: none;
|
|
@@ -1481,14 +1494,14 @@ body,
|
|
|
1481
1494
|
height: 1px;
|
|
1482
1495
|
overflow: visible;
|
|
1483
1496
|
pointer-events: none;
|
|
1484
|
-
z-index:
|
|
1497
|
+
z-index: 9000;
|
|
1485
1498
|
}
|
|
1486
1499
|
|
|
1487
1500
|
.annotation-capture-layer {
|
|
1488
1501
|
position: absolute;
|
|
1489
1502
|
inset: 0;
|
|
1490
1503
|
z-index: 9996;
|
|
1491
|
-
pointer-events:
|
|
1504
|
+
pointer-events: auto;
|
|
1492
1505
|
background: color-mix(in srgb, var(--c-accent) 5%, transparent);
|
|
1493
1506
|
}
|
|
1494
1507
|
|
|
@@ -1496,6 +1509,26 @@ body,
|
|
|
1496
1509
|
background: color-mix(in srgb, var(--c-danger) 6%, transparent);
|
|
1497
1510
|
}
|
|
1498
1511
|
|
|
1512
|
+
.annotation-capture-layer.text {
|
|
1513
|
+
background: color-mix(in srgb, var(--c-annotation) 4%, transparent);
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
.annotation-text-input {
|
|
1517
|
+
position: absolute;
|
|
1518
|
+
z-index: 9997;
|
|
1519
|
+
min-width: 120px;
|
|
1520
|
+
max-width: min(560px, calc(100% - 24px));
|
|
1521
|
+
padding: 2px 4px;
|
|
1522
|
+
border: 1px solid var(--c-annotation);
|
|
1523
|
+
border-radius: 4px;
|
|
1524
|
+
background: color-mix(in srgb, var(--c-bg) 72%, transparent);
|
|
1525
|
+
color: var(--c-annotation);
|
|
1526
|
+
font-family: var(--font);
|
|
1527
|
+
font-weight: 700;
|
|
1528
|
+
line-height: 1.15;
|
|
1529
|
+
outline: none;
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1499
1532
|
/* ── Drop Zone (file drag-and-drop) ─────────────────────────── */
|
|
1500
1533
|
.drop-zone-overlay {
|
|
1501
1534
|
position: absolute;
|
package/src/client/types.ts
CHANGED
|
@@ -48,11 +48,12 @@ export interface CanvasAnnotationPoint {
|
|
|
48
48
|
|
|
49
49
|
export interface CanvasAnnotation {
|
|
50
50
|
id: string;
|
|
51
|
-
type: 'freehand';
|
|
51
|
+
type: 'freehand' | 'text';
|
|
52
52
|
points: CanvasAnnotationPoint[];
|
|
53
53
|
bounds: { x: number; y: number; width: number; height: number };
|
|
54
54
|
color: string;
|
|
55
55
|
width: number;
|
|
56
|
+
text?: string;
|
|
56
57
|
label?: string;
|
|
57
58
|
createdAt: string;
|
|
58
59
|
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* a responsive chart inside a styled container.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import type
|
|
11
|
+
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
|
12
12
|
import type { BaseComponentProps } from '@json-render/react';
|
|
13
13
|
import {
|
|
14
14
|
BarChart as RechartsBarChart,
|
|
@@ -106,6 +106,40 @@ export const polarChartMargin = { top: 18, right: 40, bottom: 30, left: 40 };
|
|
|
106
106
|
export const axisTickMargin = 8;
|
|
107
107
|
export const legendMargin = { top: 10 };
|
|
108
108
|
|
|
109
|
+
export function useChartFrameHeight(explicitHeight: number | null | undefined, fallbackHeight = 300) {
|
|
110
|
+
const frameRef = useRef<HTMLDivElement>(null);
|
|
111
|
+
const [autoHeight, setAutoHeight] = useState(fallbackHeight);
|
|
112
|
+
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
const frame = frameRef.current;
|
|
115
|
+
if (!frame) return;
|
|
116
|
+
|
|
117
|
+
const updateHeight = () => {
|
|
118
|
+
const rect = frame.getBoundingClientRect();
|
|
119
|
+
const doc = document.documentElement;
|
|
120
|
+
const currentHeight = frame.getBoundingClientRect().height;
|
|
121
|
+
const overflow = Math.max(0, doc.scrollHeight - doc.clientHeight);
|
|
122
|
+
const available = overflow > 0 ? currentHeight - overflow : window.innerHeight - rect.top - 24;
|
|
123
|
+
setAutoHeight(Math.max(220, Math.round(available)));
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
updateHeight();
|
|
127
|
+
const observer = new ResizeObserver(updateHeight);
|
|
128
|
+
observer.observe(document.documentElement);
|
|
129
|
+
observer.observe(frame);
|
|
130
|
+
window.addEventListener('resize', updateHeight);
|
|
131
|
+
return () => {
|
|
132
|
+
observer.disconnect();
|
|
133
|
+
window.removeEventListener('resize', updateHeight);
|
|
134
|
+
};
|
|
135
|
+
}, [explicitHeight]);
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
frameRef,
|
|
139
|
+
height: typeof explicitHeight === 'number' ? Math.min(explicitHeight, autoHeight) : autoHeight,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
109
143
|
/** Shared wrapper for cartesian charts (Line + Bar). */
|
|
110
144
|
export function CartesianChart({
|
|
111
145
|
props,
|
|
@@ -117,12 +151,12 @@ export function CartesianChart({
|
|
|
117
151
|
className?: string;
|
|
118
152
|
}) {
|
|
119
153
|
const chartData = processChartData(props.data ?? [], props.xKey, props.yKey, props.aggregate);
|
|
120
|
-
const
|
|
154
|
+
const { frameRef, height } = useChartFrameHeight(props.height, 300);
|
|
121
155
|
|
|
122
156
|
return (
|
|
123
|
-
<div className={`pmx-chart${className ? ` ${className}` : ''}`}>
|
|
157
|
+
<div ref={frameRef} className={`pmx-chart${className ? ` ${className}` : ''}`}>
|
|
124
158
|
{props.title && <div className="pmx-chart__title">{props.title}</div>}
|
|
125
|
-
<ResponsiveContainer width="100%" height={
|
|
159
|
+
<ResponsiveContainer width="100%" height={height}>
|
|
126
160
|
{children(chartData)}
|
|
127
161
|
</ResponsiveContainer>
|
|
128
162
|
</div>
|
|
@@ -172,12 +206,12 @@ function ChartBarChart({ props }: BaseComponentProps<CartesianChartProps>) {
|
|
|
172
206
|
|
|
173
207
|
function ChartPieChart({ props }: BaseComponentProps<PieChartProps>) {
|
|
174
208
|
const data = props.data ?? [];
|
|
175
|
-
const
|
|
209
|
+
const { frameRef, height } = useChartFrameHeight(props.height, 300);
|
|
176
210
|
|
|
177
211
|
return (
|
|
178
|
-
<div className="pmx-chart pmx-chart--pie">
|
|
212
|
+
<div ref={frameRef} className="pmx-chart pmx-chart--pie">
|
|
179
213
|
{props.title && <div className="pmx-chart__title">{props.title}</div>}
|
|
180
|
-
<ResponsiveContainer width="100%" height={
|
|
214
|
+
<ResponsiveContainer width="100%" height={height}>
|
|
181
215
|
<RechartsPieChart margin={polarChartMargin}>
|
|
182
216
|
<Tooltip contentStyle={tooltipStyle} />
|
|
183
217
|
{props.showLegend !== false && <Legend wrapperStyle={legendMargin} />}
|
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
legendMargin,
|
|
41
41
|
polarChartMargin,
|
|
42
42
|
tooltipStyle,
|
|
43
|
+
useChartFrameHeight,
|
|
43
44
|
type CartesianChartProps,
|
|
44
45
|
} from './components';
|
|
45
46
|
|
|
@@ -89,12 +90,12 @@ interface ScatterChartProps {
|
|
|
89
90
|
function ChartScatterChart({ props }: BaseComponentProps<ScatterChartProps>) {
|
|
90
91
|
const fill = props.color ?? CHART_COLORS[0];
|
|
91
92
|
const data = props.data ?? [];
|
|
92
|
-
const
|
|
93
|
+
const { frameRef, height } = useChartFrameHeight(props.height, 300);
|
|
93
94
|
|
|
94
95
|
return (
|
|
95
|
-
<div className="pmx-chart pmx-chart--scatter">
|
|
96
|
+
<div ref={frameRef} className="pmx-chart pmx-chart--scatter">
|
|
96
97
|
{props.title && <div className="pmx-chart__title">{props.title}</div>}
|
|
97
|
-
<ResponsiveContainer width="100%" height={
|
|
98
|
+
<ResponsiveContainer width="100%" height={height}>
|
|
98
99
|
<RechartsScatterChart margin={chartMargin}>
|
|
99
100
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--border, #e5e5e5)" />
|
|
100
101
|
<XAxis type="number" dataKey={props.xKey} tick={axisStyle} tickMargin={axisTickMargin} name={props.xKey} />
|
|
@@ -120,12 +121,12 @@ interface RadarChartProps {
|
|
|
120
121
|
function ChartRadarChart({ props }: BaseComponentProps<RadarChartProps>) {
|
|
121
122
|
const data = props.data ?? [];
|
|
122
123
|
const metrics = (props.metrics ?? []).filter((m) => typeof m === 'string' && m.length > 0);
|
|
123
|
-
const
|
|
124
|
+
const { frameRef, height } = useChartFrameHeight(props.height, 320);
|
|
124
125
|
|
|
125
126
|
return (
|
|
126
|
-
<div className="pmx-chart pmx-chart--radar">
|
|
127
|
+
<div ref={frameRef} className="pmx-chart pmx-chart--radar">
|
|
127
128
|
{props.title && <div className="pmx-chart__title">{props.title}</div>}
|
|
128
|
-
<ResponsiveContainer width="100%" height={
|
|
129
|
+
<ResponsiveContainer width="100%" height={height}>
|
|
129
130
|
<RechartsRadarChart data={data} outerRadius="66%" margin={polarChartMargin}>
|
|
130
131
|
<PolarGrid stroke="var(--border, #e5e5e5)" />
|
|
131
132
|
<PolarAngleAxis dataKey={props.axisKey} tick={axisStyle} />
|
|
@@ -166,12 +167,12 @@ function ChartStackedBarChart({ props }: BaseComponentProps<StackedBarChartProps
|
|
|
166
167
|
const chartData = props.aggregate
|
|
167
168
|
? mergeAggregated(props.data ?? [], props.xKey, series, props.aggregate)
|
|
168
169
|
: props.data ?? [];
|
|
169
|
-
const
|
|
170
|
+
const { frameRef, height } = useChartFrameHeight(props.height, 300);
|
|
170
171
|
|
|
171
172
|
return (
|
|
172
|
-
<div className="pmx-chart pmx-chart--stacked-bar">
|
|
173
|
+
<div ref={frameRef} className="pmx-chart pmx-chart--stacked-bar">
|
|
173
174
|
{props.title && <div className="pmx-chart__title">{props.title}</div>}
|
|
174
|
-
<ResponsiveContainer width="100%" height={
|
|
175
|
+
<ResponsiveContainer width="100%" height={height}>
|
|
175
176
|
<RechartsBarChart data={chartData} margin={chartMargin}>
|
|
176
177
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--border, #e5e5e5)" />
|
|
177
178
|
<XAxis dataKey={props.xKey} tick={axisStyle} tickMargin={axisTickMargin} />
|
|
@@ -238,12 +239,12 @@ function ChartComposedChart({ props }: BaseComponentProps<ComposedChartProps>) {
|
|
|
238
239
|
const data = props.data ?? [];
|
|
239
240
|
const barFill = props.barColor ?? CHART_COLORS[0];
|
|
240
241
|
const lineStroke = props.lineColor ?? CHART_COLORS[3];
|
|
241
|
-
const
|
|
242
|
+
const { frameRef, height } = useChartFrameHeight(props.height, 300);
|
|
242
243
|
|
|
243
244
|
return (
|
|
244
|
-
<div className="pmx-chart pmx-chart--composed">
|
|
245
|
+
<div ref={frameRef} className="pmx-chart pmx-chart--composed">
|
|
245
246
|
{props.title && <div className="pmx-chart__title">{props.title}</div>}
|
|
246
|
-
<ResponsiveContainer width="100%" height={
|
|
247
|
+
<ResponsiveContainer width="100%" height={height}>
|
|
247
248
|
<RechartsComposedChart data={data} margin={chartMargin}>
|
|
248
249
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--border, #e5e5e5)" />
|
|
249
250
|
<XAxis dataKey={props.xKey} tick={axisStyle} tickMargin={axisTickMargin} />
|
|
@@ -511,7 +511,7 @@ export function buildGraphSpec(input: GraphNodeInput): JsonRenderSpec {
|
|
|
511
511
|
|
|
512
512
|
const chartProps: Record<string, unknown> = {
|
|
513
513
|
data: input.data,
|
|
514
|
-
height: input.height
|
|
514
|
+
...(typeof input.height === 'number' ? { height: input.height } : {}),
|
|
515
515
|
};
|
|
516
516
|
|
|
517
517
|
switch (chartType) {
|
|
@@ -648,6 +648,7 @@ export async function buildJsonRenderViewerHtml(options: {
|
|
|
648
648
|
title: string;
|
|
649
649
|
spec: JsonRenderSpec;
|
|
650
650
|
theme?: 'dark' | 'light' | 'high-contrast';
|
|
651
|
+
display?: 'expanded';
|
|
651
652
|
}): Promise<string> {
|
|
652
653
|
try {
|
|
653
654
|
await ensureJsonRenderBundle();
|
|
@@ -661,6 +662,7 @@ export async function buildJsonRenderViewerHtml(options: {
|
|
|
661
662
|
const boot = [
|
|
662
663
|
`window.__PMX_CANVAS_JSON_RENDER_SPEC__ = ${JSON.stringify(options.spec)};`,
|
|
663
664
|
...(options.theme ? [`window.__PMX_CANVAS_JSON_RENDER_THEME__ = ${JSON.stringify(options.theme)};`] : []),
|
|
665
|
+
...(options.display ? [`window.__PMX_CANVAS_JSON_RENDER_DISPLAY__ = ${JSON.stringify(options.display)};`] : []),
|
|
664
666
|
jsBundle,
|
|
665
667
|
].join('\n');
|
|
666
668
|
return buildAppHtml({
|