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.
Files changed (70) hide show
  1. package/CHANGELOG.md +128 -0
  2. package/Readme.md +19 -6
  3. package/dist/canvas/global.css +35 -2
  4. package/dist/canvas/index.js +70 -69
  5. package/dist/json-render/index.js +109 -109
  6. package/dist/types/client/canvas/CanvasViewport.d.ts +1 -1
  7. package/dist/types/client/icons.d.ts +2 -0
  8. package/dist/types/client/state/canvas-store.d.ts +2 -0
  9. package/dist/types/client/types.d.ts +2 -1
  10. package/dist/types/json-render/charts/components.d.ts +5 -1
  11. package/dist/types/json-render/renderer/index.d.ts +1 -0
  12. package/dist/types/json-render/server.d.ts +1 -0
  13. package/dist/types/mcp/canvas-access.d.ts +3 -0
  14. package/dist/types/server/canvas-operations.d.ts +4 -0
  15. package/dist/types/server/canvas-schema.d.ts +19 -3
  16. package/dist/types/server/canvas-serialization.d.ts +1 -0
  17. package/dist/types/server/canvas-state.d.ts +8 -2
  18. package/dist/types/server/html-primitives.d.ts +34 -0
  19. package/dist/types/server/index.d.ts +19 -0
  20. package/docs/RELEASE.md +153 -0
  21. package/docs/bun-webview-integration.md +296 -0
  22. package/docs/cli.md +143 -0
  23. package/docs/evals/e2e-cli-coverage.md +61 -0
  24. package/docs/http-api.md +201 -0
  25. package/docs/mcp.md +137 -0
  26. package/docs/node-types.md +272 -0
  27. package/docs/plans/.gitkeep +0 -0
  28. package/docs/plans/plan-001-semantic-watch-mvp.md +335 -0
  29. package/docs/plans/plan-002-human-attention-layer-design-spec.md +679 -0
  30. package/docs/plans/plan-003-human-attention-layer-implementation-plan.md +572 -0
  31. package/docs/reactive-canvas-proposal.md +578 -0
  32. package/docs/release-review-0.1.0.md +38 -0
  33. package/docs/screenshot.png +0 -0
  34. package/docs/screenshots/demo-workbench-dark.png +0 -0
  35. package/docs/screenshots/demo-workbench-light.png +0 -0
  36. package/docs/screenshots/welcome-dark.png +0 -0
  37. package/docs/screenshots/welcome-light.png +0 -0
  38. package/docs/sdk.md +103 -0
  39. package/package.json +2 -1
  40. package/skills/pmx-canvas/SKILL.md +8 -0
  41. package/src/cli/agent.ts +167 -5
  42. package/src/client/App.tsx +20 -1
  43. package/src/client/canvas/AnnotationLayer.tsx +33 -12
  44. package/src/client/canvas/CanvasViewport.tsx +88 -7
  45. package/src/client/canvas/CommandPalette.tsx +1 -1
  46. package/src/client/canvas/ContextMenu.tsx +2 -2
  47. package/src/client/canvas/ExpandedNodeOverlay.tsx +7 -1
  48. package/src/client/icons.tsx +13 -0
  49. package/src/client/nodes/McpAppNode.tsx +12 -4
  50. package/src/client/state/canvas-store.ts +15 -5
  51. package/src/client/state/sse-bridge.ts +4 -3
  52. package/src/client/theme/global.css +35 -2
  53. package/src/client/types.ts +2 -1
  54. package/src/json-render/charts/components.tsx +41 -7
  55. package/src/json-render/charts/extra-components.tsx +13 -12
  56. package/src/json-render/renderer/index.tsx +1 -0
  57. package/src/json-render/server.ts +3 -1
  58. package/src/mcp/canvas-access.ts +25 -0
  59. package/src/mcp/server.ts +85 -27
  60. package/src/server/agent-context.ts +17 -0
  61. package/src/server/canvas-operations.ts +91 -38
  62. package/src/server/canvas-schema.ts +83 -3
  63. package/src/server/canvas-serialization.ts +9 -2
  64. package/src/server/canvas-state.ts +27 -9
  65. package/src/server/demo-state.json +1143 -0
  66. package/src/server/demo.ts +25 -777
  67. package/src/server/html-primitives.ts +990 -0
  68. package/src/server/index.ts +43 -2
  69. package/src/server/server.ts +140 -14
  70. 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
- }, [annotationMode, draftAnnotation]);
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 = 360;
521
- const nodeH = 200;
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 = 360;
323
- const height = 200;
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
- {renderContent(node, true)}
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>
@@ -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 withTheme(url: string): string {
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 = withTheme((node.data.url as string) || '');
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 style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
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
- updateNode(groupId, {
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
- updateNode(groupId, {
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: 'freehand',
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: 45;
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: none;
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;
@@ -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 { ReactNode } from 'react';
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 h = props.height ?? 300;
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={h}>
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 h = props.height ?? 300;
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={h}>
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 h = props.height ?? 300;
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={h}>
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 h = props.height ?? 320;
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={h}>
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 h = props.height ?? 300;
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={h}>
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 h = props.height ?? 300;
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={h}>
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} />
@@ -49,6 +49,7 @@ declare global {
49
49
  interface Window {
50
50
  __PMX_CANVAS_JSON_RENDER_SPEC__?: Spec & { state?: Record<string, unknown> };
51
51
  __PMX_CANVAS_JSON_RENDER_THEME__?: string;
52
+ __PMX_CANVAS_JSON_RENDER_DISPLAY__?: string;
52
53
  }
53
54
  }
54
55
 
@@ -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 ?? 320,
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({