@graph-artifact/core 0.1.10 → 0.1.11

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.
@@ -1,5 +1,6 @@
1
1
  import type { ParsedDiagram, NodeMetadata } from '../types.js';
2
2
  import type { GraphConfig } from '../config.js';
3
+ import { GraphCanvas } from './GraphCanvas.js';
3
4
  interface DiagramCanvasProps {
4
5
  parsed: ParsedDiagram;
5
6
  metadata?: Record<string, NodeMetadata>;
@@ -8,6 +9,8 @@ interface DiagramCanvasProps {
8
9
  hideAttribution?: boolean;
9
10
  relayoutOnMeasure?: boolean;
10
11
  onLayoutStable?: () => void;
12
+ branding?: Parameters<typeof GraphCanvas>[0]['branding'];
13
+ download?: Parameters<typeof GraphCanvas>[0]['download'];
11
14
  }
12
- export declare function DiagramCanvas({ parsed, metadata, onNodeClick, canvasOverrides, hideAttribution, relayoutOnMeasure, onLayoutStable }: DiagramCanvasProps): import("react/jsx-runtime").JSX.Element;
15
+ export declare function DiagramCanvas({ parsed, metadata, onNodeClick, canvasOverrides, hideAttribution, relayoutOnMeasure, onLayoutStable, branding, download, }: DiagramCanvasProps): import("react/jsx-runtime").JSX.Element;
13
16
  export {};
@@ -11,9 +11,9 @@ function UnsupportedCustomDiagram({ parsed }) {
11
11
  fontSize: 14,
12
12
  }, children: `Diagram type '${parsed.diagramType}' is detected but custom rendering is not implemented yet.` }));
13
13
  }
14
- export function DiagramCanvas({ parsed, metadata, onNodeClick, canvasOverrides, hideAttribution, relayoutOnMeasure, onLayoutStable }) {
14
+ export function DiagramCanvas({ parsed, metadata, onNodeClick, canvasOverrides, hideAttribution, relayoutOnMeasure, onLayoutStable, branding, download, }) {
15
15
  if (parsed.kind === 'graph') {
16
- return (_jsx(GraphCanvas, { parsed: parsed, metadata: metadata, onNodeClick: onNodeClick, canvasOverrides: canvasOverrides, hideAttribution: hideAttribution, relayoutOnMeasure: relayoutOnMeasure, onLayoutStable: onLayoutStable }));
16
+ return (_jsx(GraphCanvas, { parsed: parsed, metadata: metadata, onNodeClick: onNodeClick, canvasOverrides: canvasOverrides, hideAttribution: hideAttribution, relayoutOnMeasure: relayoutOnMeasure, onLayoutStable: onLayoutStable, branding: branding, download: download }));
17
17
  }
18
18
  return _jsx(UnsupportedCustomDiagram, { parsed: parsed });
19
19
  }
@@ -1,12 +1,40 @@
1
+ import type { ReactNode } from 'react';
1
2
  import '@xyflow/react/dist/style.css';
2
3
  import type { ParsedGraph, NodeMetadata } from '../types.js';
3
4
  import type { GraphConfig } from '../config.js';
5
+ type CornerPosition = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
4
6
  interface GraphCanvasProps {
5
7
  parsed: ParsedGraph;
6
8
  metadata?: Record<string, NodeMetadata>;
7
9
  onNodeClick: (nodeId: string, nodeLabel: string) => void;
8
10
  canvasOverrides?: Partial<GraphConfig['canvas']>;
9
11
  hideAttribution?: boolean;
12
+ /** Optional branding overlays (included in exports). */
13
+ branding?: {
14
+ logo?: {
15
+ src: string;
16
+ alt?: string;
17
+ position?: CornerPosition;
18
+ width?: number;
19
+ height?: number;
20
+ opacity?: number;
21
+ };
22
+ watermark?: {
23
+ text: string;
24
+ icon?: ReactNode;
25
+ opacity?: number;
26
+ };
27
+ };
28
+ /** Optional download control (renders a button and exports the diagram as PNG). */
29
+ download?: {
30
+ enabled?: boolean;
31
+ position?: CornerPosition;
32
+ icon?: ReactNode;
33
+ fileName?: string;
34
+ pixelRatio?: number;
35
+ /** Background color used for export. Defaults to `canvas.containerBackground`. */
36
+ backgroundColor?: string;
37
+ };
10
38
  /**
11
39
  * If true, reruns dagre after React Flow measures node DOM sizes so
12
40
  * layout reflects actual rendered content (auto-sized text/markdown).
@@ -1,14 +1,22 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useMemo, useCallback, useState, useEffect, useRef } from 'react';
3
- import { ReactFlow, Background, Controls, MiniMap, ReactFlowProvider, useReactFlow, useNodesInitialized, useStore, useStoreApi, } from '@xyflow/react';
3
+ import { ReactFlow, Background, Controls, MiniMap, ReactFlowProvider, useReactFlow, useNodesInitialized, useStore, useStoreApi, Panel, } from '@xyflow/react';
4
4
  import '@xyflow/react/dist/style.css';
5
+ import { toPng } from 'html-to-image';
5
6
  import { layoutNodes, buildLayoutEdges } from '../layout/index.js';
6
7
  import { getConfig, getNodeTypes } from '../config.js';
7
8
  import { useTheme } from '../ThemeContext.js';
8
9
  import { RoutedEdge } from './edges/RoutedEdge.js';
9
10
  // Stable reference — must be outside the component to avoid infinite re-renders
10
11
  const edgeTypes = { routed: RoutedEdge };
11
- function GraphCanvasInner({ parsed, metadata, onNodeClick, canvasOverrides, hideAttribution, relayoutOnMeasure, onLayoutStable, }) {
12
+ function cornerToPanelPosition(pos) {
13
+ return pos;
14
+ }
15
+ function defaultDownloadIcon(theme) {
16
+ const stroke = theme.mode === 'dark' ? theme.color.gray4 : theme.color.gray4;
17
+ return (_jsx("svg", { width: "18", height: "18", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { d: "M12 3v10m0 0l-4-4m4 4l4-4M5 17v3h14v-3", fill: "none", stroke: stroke, strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round" }) }));
18
+ }
19
+ function GraphCanvasInner({ parsed, metadata, onNodeClick, canvasOverrides, hideAttribution, branding, download, relayoutOnMeasure, onLayoutStable, }) {
12
20
  const theme = useTheme();
13
21
  const { canvas: baseCanvas } = getConfig();
14
22
  const canvas = useMemo(() => ({ ...baseCanvas, ...(canvasOverrides ?? {}) }), [baseCanvas, canvasOverrides]);
@@ -264,6 +272,35 @@ function GraphCanvasInner({ parsed, metadata, onNodeClick, canvasOverrides, hide
264
272
  const defaultEdgeOptions = useMemo(() => ({ animated: false, style: theme.edgeDefaults.style }), [theme.edgeDefaults.style]);
265
273
  const miniMapNodeColor = useCallback(() => theme.color.orange1, [theme.color.orange1]);
266
274
  const miniMapMaskColor = useMemo(() => `${theme.color.gray1}cc`, [theme.color.gray1]);
275
+ const exportRef = useRef(null);
276
+ const onDownload = useCallback(async () => {
277
+ if (!exportRef.current)
278
+ return;
279
+ const fileName = (download?.fileName ?? 'diagram')
280
+ .trim()
281
+ .replace(/[^\w\- ]+/g, '')
282
+ .replace(/\s+/g, '-')
283
+ .slice(0, 80) || 'diagram';
284
+ try {
285
+ const dataUrl = await toPng(exportRef.current, {
286
+ cacheBust: true,
287
+ pixelRatio: Math.max(1, download?.pixelRatio ?? 2),
288
+ backgroundColor: download?.backgroundColor ?? canvas.containerBackground,
289
+ filter: (node) => {
290
+ if (!(node instanceof Element))
291
+ return true;
292
+ return !node.classList.contains('ga-no-export');
293
+ },
294
+ });
295
+ const a = document.createElement('a');
296
+ a.href = dataUrl;
297
+ a.download = `${fileName}.png`;
298
+ a.click();
299
+ }
300
+ catch {
301
+ // ignore export errors (e.g. CORS images, unmounted)
302
+ }
303
+ }, [canvas.containerBackground, download?.backgroundColor, download?.fileName, download?.pixelRatio]);
267
304
  if (layoutError) {
268
305
  return (_jsx("div", { style: {
269
306
  width: '100%',
@@ -278,7 +315,24 @@ function GraphCanvasInner({ parsed, metadata, onNodeClick, canvasOverrides, hide
278
315
  textAlign: 'center',
279
316
  }, children: `Diagram syntax/layout error: ${layoutError}` }));
280
317
  }
281
- return (_jsxs("div", { style: { width: '100%', height: '100%' }, children: [_jsx("style", { children: `
318
+ return (_jsxs("div", { ref: exportRef, style: {
319
+ width: '100%',
320
+ height: '100%',
321
+ position: 'relative',
322
+ backgroundColor: canvas.containerBackground,
323
+ }, children: [_jsx("style", { children: `
324
+ /* Default React Flow background isn't transparent; force it. */
325
+ .react-flow {
326
+ background: transparent;
327
+ }
328
+ .react-flow__renderer, .react-flow__pane, .react-flow__viewport {
329
+ background: transparent;
330
+ }
331
+ ${hideAttribution ? `
332
+ /* Ensure the "React Flow" attribution never shows in embeds. */
333
+ .react-flow__attribution { display: none !important; }
334
+ ` : ''}
335
+
282
336
  /* Theme React Flow chrome (controls/minimap) to match our tokens. */
283
337
  .react-flow__controls-button {
284
338
  background: ${theme.mode === 'dark' ? theme.color.darkBg3 : theme.color.white};
@@ -295,7 +349,46 @@ function GraphCanvasInner({ parsed, metadata, onNodeClick, canvasOverrides, hide
295
349
  background: ${theme.mode === 'dark' ? theme.color.darkBg2 : theme.color.white};
296
350
  border: 1px solid ${theme.color.gray2};
297
351
  }
298
- ` }), _jsxs(ReactFlow, { nodes: nodes, edges: edges, nodeTypes: resolvedNodeTypes, edgeTypes: edgeTypes, onNodeClick: handleNodeClick, fitView: canvas.fitView, minZoom: canvas.minZoom, maxZoom: canvas.maxZoom, defaultEdgeOptions: defaultEdgeOptions, zIndexMode: "manual", colorMode: theme.mode, proOptions: hideAttribution ? { hideAttribution: true } : undefined, children: [canvas.showBackground && (_jsx(Background, { color: theme.color.gray2, gap: canvas.backgroundGap, size: canvas.backgroundSize })), canvas.showControls && _jsx(Controls, {}), canvas.showMiniMap && (_jsx(MiniMap, { nodeColor: miniMapNodeColor, maskColor: miniMapMaskColor }))] })] }));
352
+ ` }), branding?.logo?.src && (_jsx("div", { style: {
353
+ position: 'absolute',
354
+ zIndex: 5,
355
+ pointerEvents: 'none',
356
+ ...(branding.logo.position === 'top-right' ? { top: 12, right: 12 } : {}),
357
+ ...(branding.logo.position === 'bottom-left' ? { bottom: 12, left: 12 } : {}),
358
+ ...(branding.logo.position === 'bottom-right' ? { bottom: 12, right: 12 } : {}),
359
+ ...(branding.logo.position === 'top-left' || !branding.logo.position ? { top: 12, left: 12 } : {}),
360
+ opacity: branding.logo.opacity ?? 1,
361
+ }, children: _jsx("img", { src: branding.logo.src, alt: branding.logo.alt ?? 'logo', width: branding.logo.width ?? 120, height: branding.logo.height, style: { display: 'block' } }) })), branding?.watermark?.text && (_jsxs("div", { style: {
362
+ position: 'absolute',
363
+ left: '50%',
364
+ bottom: 14,
365
+ transform: 'translateX(-50%)',
366
+ zIndex: 5,
367
+ pointerEvents: 'none',
368
+ display: 'flex',
369
+ alignItems: 'center',
370
+ gap: 8,
371
+ fontFamily: theme.font.family,
372
+ fontSize: theme.font.size.sm,
373
+ color: theme.mode === 'dark' ? theme.color.gray3 : theme.color.gray4,
374
+ opacity: branding.watermark.opacity ?? 0.9,
375
+ userSelect: 'none',
376
+ whiteSpace: 'nowrap',
377
+ }, children: [branding.watermark.icon ?? null, _jsx("span", { children: branding.watermark.text })] })), _jsxs(ReactFlow, { nodes: nodes, edges: edges, nodeTypes: resolvedNodeTypes, edgeTypes: edgeTypes, onNodeClick: handleNodeClick, fitView: canvas.fitView, minZoom: canvas.minZoom, maxZoom: canvas.maxZoom, defaultEdgeOptions: defaultEdgeOptions, zIndexMode: "manual", colorMode: theme.mode, proOptions: hideAttribution ? { hideAttribution: true } : undefined, children: [canvas.showBackground && (_jsx(Background, { color: canvas.gridColor === 'auto' ? theme.color.gray2 : canvas.gridColor, gap: canvas.backgroundGap, size: canvas.backgroundSize })), canvas.showControls && (_jsx(Controls, { position: canvas.controlsPosition,
378
+ // Remove the "lock" (interactive) button; we want pan/zoom always available.
379
+ showInteractive: false })), ((download?.enabled ?? canvas.showControls) && download?.enabled !== false) && (_jsx(Panel, { position: cornerToPanelPosition(download?.position ?? 'top-right'), className: "ga-no-export", children: _jsx("button", { type: "button", onClick: onDownload, "aria-label": "Download diagram", title: "Download", style: {
380
+ display: 'inline-flex',
381
+ alignItems: 'center',
382
+ justifyContent: 'center',
383
+ width: 34,
384
+ height: 34,
385
+ borderRadius: theme.radius.md,
386
+ background: theme.mode === 'dark' ? theme.color.darkBg3 : theme.color.white,
387
+ color: theme.color.gray4,
388
+ border: `1px solid ${theme.color.gray2}`,
389
+ boxShadow: theme.shadow.card,
390
+ cursor: 'pointer',
391
+ }, children: download?.icon ?? defaultDownloadIcon(theme) }) })), canvas.showMiniMap && (_jsx(MiniMap, { nodeColor: miniMapNodeColor, maskColor: miniMapMaskColor }))] })] }));
299
392
  }
300
393
  export function GraphCanvas(props) {
301
394
  return (_jsx(ReactFlowProvider, { children: _jsx(GraphCanvasInner, { ...props }) }));
@@ -65,7 +65,7 @@ export const SequenceLifelineNode = memo(function SequenceLifelineNode({ data })
65
65
  width: 2,
66
66
  height,
67
67
  pointerEvents: 'none',
68
- }, children: _jsx("svg", { width: 2, height: height, style: { overflow: 'visible' }, children: _jsx("line", { x1: 1, y1: 0, x2: 1, y2: height, stroke: theme.color.gray2, strokeWidth: 1.5, strokeDasharray: "6,4" }) }) }));
68
+ }, children: _jsx("svg", { width: 2, height: height, style: { overflow: 'visible' }, children: _jsx("line", { x1: 1, y1: 0, x2: 1, y2: height, stroke: theme.color.orange2, strokeWidth: 1.5 }) }) }));
69
69
  });
70
70
  // ─── Anchor Node ───────────────────────────────────────────────────────────
71
71
  export const SequenceAnchorNode = memo(function SequenceAnchorNode() {
package/dist/config.d.ts CHANGED
@@ -100,6 +100,12 @@ export interface GraphConfig {
100
100
  showBackground: boolean;
101
101
  showControls: boolean;
102
102
  showMiniMap: boolean;
103
+ /** React Flow container background color. Use 'transparent' to inherit from the host app. */
104
+ containerBackground: string;
105
+ /** Background dot/grid color (if showBackground is enabled). */
106
+ gridColor: string;
107
+ /** Position of the React Flow controls cluster. */
108
+ controlsPosition: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
103
109
  backgroundGap: number;
104
110
  backgroundSize: number;
105
111
  };
package/dist/config.js CHANGED
@@ -72,6 +72,11 @@ function createDefaultConfig() {
72
72
  // MiniMap is useful in dedicated viewers but noisy in embedded contexts (chat, docs).
73
73
  // Host apps can enable it via `canvasOverrides`.
74
74
  showMiniMap: false,
75
+ // Default to transparent so embeds naturally match host application background.
76
+ containerBackground: 'transparent',
77
+ // Use theme token by default (GraphCanvas resolves this when set to 'auto').
78
+ gridColor: 'auto',
79
+ controlsPosition: 'bottom-right',
75
80
  backgroundGap: 20,
76
81
  backgroundSize: 1,
77
82
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@graph-artifact/core",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "Composable Mermaid-like parser, layout engine, and React renderer for interactive diagram artifacts.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -60,7 +60,8 @@
60
60
  "react-markdown": "^9.0.0 || ^10.0.0"
61
61
  },
62
62
  "dependencies": {
63
- "@dagrejs/dagre": "^1.1.4"
63
+ "@dagrejs/dagre": "^1.1.4",
64
+ "html-to-image": "^1.11.13"
64
65
  },
65
66
  "devDependencies": {
66
67
  "typescript": "^5.7.3",