@graph-artifact/core 0.1.13 → 0.1.14

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.
@@ -53,6 +53,25 @@ interface GraphCanvasProps {
53
53
  * Defaults to false (clean export).
54
54
  */
55
55
  includeBackground?: boolean;
56
+ /**
57
+ * Export a tightly-cropped image around the graph content (nodes + edges),
58
+ * instead of exporting the entire viewer viewport.
59
+ *
60
+ * Defaults to true.
61
+ */
62
+ cropToGraph?: boolean;
63
+ /** Padding (CSS px) around the cropped content when `cropToGraph` is enabled. */
64
+ cropPadding?: number;
65
+ /**
66
+ * Extra reserved space (CSS px) above the graph content for logo/branding
67
+ * when exporting.
68
+ */
69
+ reservedTop?: number;
70
+ /**
71
+ * Extra reserved space (CSS px) below the graph content for watermark
72
+ * when exporting.
73
+ */
74
+ reservedBottom?: number;
56
75
  /**
57
76
  * Whether to embed webfonts into the exported image.
58
77
  * Defaults to false because many apps load fonts from cross-origin
@@ -273,7 +273,6 @@ function GraphCanvasInner({ parsed, metadata, onNodeClick, canvasOverrides, hide
273
273
  const miniMapNodeColor = useCallback(() => theme.color.orange1, [theme.color.orange1]);
274
274
  const miniMapMaskColor = useMemo(() => `${theme.color.gray1}cc`, [theme.color.gray1]);
275
275
  const exportRef = useRef(null);
276
- const [isExporting, setIsExporting] = useState(false);
277
276
  const brandingVisibility = useMemo(() => {
278
277
  const v = branding?.visibility ?? {};
279
278
  return {
@@ -281,35 +280,99 @@ function GraphCanvasInner({ parsed, metadata, onNodeClick, canvasOverrides, hide
281
280
  download: v.download ?? true,
282
281
  };
283
282
  }, [branding?.visibility]);
284
- const showBrandingNow = isExporting ? brandingVisibility.download : brandingVisibility.canvas;
285
- const showBackgroundNow = isExporting ? Boolean(download?.includeBackground) : Boolean(canvas.showBackground);
286
283
  const onDownload = useCallback(async () => {
287
- if (!exportRef.current)
284
+ const root = exportRef.current;
285
+ if (!root)
288
286
  return;
289
287
  const fileName = (download?.fileName ?? 'diagram')
290
288
  .trim()
291
289
  .replace(/[^\w\- ]+/g, '')
292
290
  .replace(/\s+/g, '-')
293
291
  .slice(0, 80) || 'diagram';
294
- setIsExporting(true);
292
+ const state = storeApi.getState();
293
+ const nodeLookup = state.nodeLookup;
294
+ const transform = state.transform; // [x, y, zoom]
295
+ const zoom = transform[2] ?? 1;
296
+ // Compute content bounds in graph coordinates from measured node boxes.
297
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
298
+ for (const n of nodeLookup.values()) {
299
+ if (n.hidden)
300
+ continue;
301
+ const abs = n.internals.positionAbsolute;
302
+ const w = n.measured?.width ?? n.width ?? 0;
303
+ const h = n.measured?.height ?? n.height ?? 0;
304
+ if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0)
305
+ continue;
306
+ minX = Math.min(minX, abs.x);
307
+ minY = Math.min(minY, abs.y);
308
+ maxX = Math.max(maxX, abs.x + w);
309
+ maxY = Math.max(maxY, abs.y + h);
310
+ }
311
+ // If we can't compute bounds, fall back to exporting the full viewport.
312
+ const canCrop = isFinite(minX) && isFinite(minY) && isFinite(maxX) && isFinite(maxY);
313
+ const cropToGraph = download?.cropToGraph ?? true;
314
+ const contentPad = Math.max(0, download?.cropPadding ?? 32);
315
+ // Reserve header/footer areas for export-only branding so they never overlap content.
316
+ const exportBranding = Boolean(brandingVisibility.download && (branding?.logo?.src || branding?.logo?.node || branding?.watermark?.text));
317
+ const reservedTop = Math.max(0, download?.reservedTop ?? (exportBranding && (branding?.logo?.src || branding?.logo?.node) ? 76 : 0));
318
+ const reservedBottom = Math.max(0, download?.reservedBottom ?? (exportBranding && branding?.watermark?.text ? 56 : 0));
319
+ const pixelRatio = Math.max(1, download?.pixelRatio ?? 4);
320
+ const backgroundColor = download?.backgroundColor ?? canvas.containerBackground;
321
+ const includeBackground = Boolean(download?.includeBackground);
322
+ // Create an offscreen clone so export-only UI never flashes in the live viewer.
323
+ const clone = root.cloneNode(true);
324
+ clone.style.position = 'fixed';
325
+ clone.style.left = '-100000px';
326
+ clone.style.top = '0';
327
+ clone.style.opacity = '0';
328
+ clone.style.pointerEvents = 'none';
329
+ clone.style.backgroundColor = backgroundColor;
330
+ // Remove non-export UI.
331
+ clone.querySelectorAll('.ga-no-export').forEach((el) => el.remove());
332
+ // Toggle branding visibility inside clone (download-only by default).
333
+ clone.querySelectorAll('[data-ga-branding]').forEach((el) => {
334
+ el.style.display = exportBranding ? '' : 'none';
335
+ });
336
+ // Toggle background dots in clone (defaults to off).
337
+ clone.querySelectorAll('.react-flow__background').forEach((el) => {
338
+ el.style.display = includeBackground ? '' : 'none';
339
+ });
340
+ if (cropToGraph && canCrop) {
341
+ const w = Math.ceil((maxX - minX) + contentPad * 2);
342
+ const h = Math.ceil((maxY - minY) + contentPad * 2 + reservedTop + reservedBottom);
343
+ clone.style.width = `${w}px`;
344
+ clone.style.height = `${h}px`;
345
+ // Reposition the viewport so content is tightly framed with padding.
346
+ const viewport = clone.querySelector('.react-flow__viewport');
347
+ if (viewport) {
348
+ const tx = contentPad - minX;
349
+ const ty = reservedTop + contentPad - minY;
350
+ viewport.style.transform = `translate(${tx}px, ${ty}px) scale(1)`;
351
+ }
352
+ // Ensure the React Flow container expands to the clone size.
353
+ const rf = clone.querySelector('.react-flow');
354
+ if (rf) {
355
+ rf.style.width = '100%';
356
+ rf.style.height = '100%';
357
+ }
358
+ }
359
+ else {
360
+ // Keep the clone at the current viewport size.
361
+ clone.style.width = `${root.clientWidth}px`;
362
+ clone.style.height = `${root.clientHeight}px`;
363
+ // Keep current zoom so export roughly matches what the user sees.
364
+ const viewport = clone.querySelector('.react-flow__viewport');
365
+ if (viewport) {
366
+ viewport.style.transform = `translate(${transform[0]}px, ${transform[1]}px) scale(${zoom})`;
367
+ }
368
+ }
369
+ document.body.appendChild(clone);
295
370
  try {
296
- // Wait for React to commit export-only visuals (branding/background toggles)
297
- // before html-to-image snapshots the DOM.
298
- await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
299
- // Prefer blob + object URL over a data URL. In some browsers, downloads
300
- // triggered after an async boundary can be blocked when using data URLs.
301
- const blob = await toBlob(exportRef.current, {
371
+ const blob = await toBlob(clone, {
302
372
  cacheBust: true,
303
- pixelRatio: Math.max(1, download?.pixelRatio ?? 2),
304
- backgroundColor: download?.backgroundColor ?? canvas.containerBackground,
305
- // Avoid CORS errors when the host app uses cross-origin stylesheets for fonts.
306
- // If the host wants font embedding, they can explicitly enable it.
373
+ pixelRatio,
374
+ backgroundColor,
307
375
  skipFonts: !(download?.embedFonts ?? false),
308
- filter: (node) => {
309
- if (!(node instanceof Element))
310
- return true;
311
- return !node.classList.contains('ga-no-export');
312
- },
313
376
  });
314
377
  if (!blob)
315
378
  throw new Error('Export failed: no image data produced.');
@@ -330,17 +393,23 @@ function GraphCanvasInner({ parsed, metadata, onNodeClick, canvasOverrides, hide
330
393
  download?.onError?.(error);
331
394
  }
332
395
  finally {
333
- setIsExporting(false);
396
+ clone.remove();
334
397
  }
335
398
  }, [
399
+ storeApi,
400
+ branding,
401
+ brandingVisibility.download,
336
402
  canvas.containerBackground,
337
- canvas.showBackground,
338
- download?.backgroundColor,
339
403
  download?.fileName,
340
404
  download?.pixelRatio,
405
+ download?.backgroundColor,
341
406
  download?.includeBackground,
342
- download?.onError,
407
+ download?.cropToGraph,
408
+ download?.cropPadding,
409
+ download?.reservedTop,
410
+ download?.reservedBottom,
343
411
  download?.embedFonts,
412
+ download?.onError,
344
413
  ]);
345
414
  if (layoutError) {
346
415
  return (_jsx("div", { style: {
@@ -392,7 +461,7 @@ function GraphCanvasInner({ parsed, metadata, onNodeClick, canvasOverrides, hide
392
461
  }
393
462
  /* Ensure inline SVG logos scale to the requested width. */
394
463
  .ga-logo svg { width: 100%; height: auto; display: block; }
395
- ` }), showBrandingNow && (branding?.logo?.src || branding?.logo?.node) && (_jsx("div", { style: {
464
+ ` }), (branding?.logo?.src || branding?.logo?.node) && (_jsx("div", { "data-ga-branding": true, "data-ga-logo": true, style: {
396
465
  position: 'absolute',
397
466
  zIndex: 5,
398
467
  pointerEvents: 'none',
@@ -401,18 +470,18 @@ function GraphCanvasInner({ parsed, metadata, onNodeClick, canvasOverrides, hide
401
470
  ...(branding.logo.position === 'bottom-right' ? { bottom: 12, right: 12 } : {}),
402
471
  ...(branding.logo.position === 'top-left' || !branding.logo.position ? { top: 12, left: 12 } : {}),
403
472
  opacity: branding.logo.opacity ?? 1,
473
+ display: brandingVisibility.canvas ? 'block' : 'none',
404
474
  }, children: branding.logo.node ? (_jsx("div", { className: "ga-logo", style: {
405
475
  width: branding.logo.width ?? 120,
406
476
  height: branding.logo.height,
407
477
  display: 'block',
408
- }, children: branding.logo.node })) : (_jsx("img", { src: branding.logo.src, alt: branding.logo.alt ?? 'logo', width: branding.logo.width ?? 120, height: branding.logo.height, style: { display: 'block' } })) })), showBrandingNow && branding?.watermark?.text && (_jsxs("div", { style: {
478
+ }, children: branding.logo.node })) : (_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", { "data-ga-branding": true, "data-ga-watermark": true, style: {
409
479
  position: 'absolute',
410
480
  left: '50%',
411
481
  bottom: 14,
412
482
  transform: 'translateX(-50%)',
413
483
  zIndex: 5,
414
484
  pointerEvents: 'none',
415
- display: 'flex',
416
485
  alignItems: 'center',
417
486
  gap: 8,
418
487
  fontFamily: theme.font.family,
@@ -421,7 +490,8 @@ function GraphCanvasInner({ parsed, metadata, onNodeClick, canvasOverrides, hide
421
490
  opacity: branding.watermark.opacity ?? 0.9,
422
491
  userSelect: 'none',
423
492
  whiteSpace: 'nowrap',
424
- }, 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: [showBackgroundNow && (_jsx(Background, { color: canvas.gridColor === 'auto' ? theme.color.gray2 : canvas.gridColor, gap: canvas.backgroundGap, size: canvas.backgroundSize })), canvas.showControls && (_jsx("div", { className: "ga-no-export", children: _jsx(Controls, { position: canvas.controlsPosition,
493
+ display: brandingVisibility.canvas ? 'flex' : 'none',
494
+ }, 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: [_jsx(Background, { style: { display: canvas.showBackground ? 'block' : 'none' }, color: canvas.gridColor === 'auto' ? theme.color.gray2 : canvas.gridColor, gap: canvas.backgroundGap, size: canvas.backgroundSize }), canvas.showControls && (_jsx("div", { className: "ga-no-export", children: _jsx(Controls, { position: canvas.controlsPosition,
425
495
  // Remove the "lock" (interactive) button; we want pan/zoom always available.
426
496
  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: {
427
497
  display: 'inline-flex',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@graph-artifact/core",
3
- "version": "0.1.13",
3
+ "version": "0.1.14",
4
4
  "description": "Composable Mermaid-like parser, layout engine, and React renderer for interactive diagram artifacts.",
5
5
  "license": "MIT",
6
6
  "repository": {