diff --git a/src/App.jsx b/src/App.jsx index 4080ac8..31c8bab 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -6,7 +6,6 @@ import ReactFlow, { Controls, } from 'reactflow' import * as Toast from '@radix-ui/react-toast' -import { toPng } from 'html-to-image' import { edgeTypes, nodeTypes } from './flowTypes.js' import { ConfirmDiscardDialog, @@ -23,6 +22,7 @@ import AnnotationLayer from './components/annotations/AnnotationLayer.jsx' import AnnotationToolbox from './components/annotations/AnnotationToolbox.jsx' import { useAnnotations } from './hooks/useAnnotations.js' import { sanitizeFileName } from './model/fileUtils.js' +import { exportFlowPng } from './model/pngExport.jsx' import { ASSOCIATION_EDGE_TYPE, ASSOCIATIVE_EDGE_TYPE, @@ -749,6 +749,60 @@ function App() { onRedo, }) + const defaultValueEntries = useMemo(() => { + if (activeView !== VIEW_PHYSICAL) { + return [] + } + + return nodes.flatMap((node) => { + const className = + typeof node.data?.label === 'string' && node.data.label.trim() + ? node.data.label.trim() + : 'Class' + const attributes = Array.isArray(node.data?.attributes) + ? node.data.attributes + : [] + return attributes.flatMap((attribute) => { + const value = + typeof attribute.defaultValue === 'string' + ? attribute.defaultValue.trim() + : '' + if (!value) { + return [] + } + const logicalName = + typeof attribute.logicalName === 'string' && attribute.logicalName.trim() + ? attribute.logicalName.trim() + : '' + const attributeName = + logicalName || + (typeof attribute.name === 'string' && attribute.name.trim() + ? attribute.name.trim() + : 'attribute') + return [ + { + key: `${className}.${attributeName}`, + value, + }, + ] + }) + }) + }, [activeView, nodes]) + + const visibleFlowNodes = useMemo( + () => + flowNodes.filter((node) => { + if (node.type === NOTE_NODE_TYPE) { + return showNotes + } + if (node.type === AREA_NODE_TYPE) { + return showAreas + } + return true + }), + [flowNodes, showAreas, showNotes], + ) + const onExportPng = useCallback(async () => { if (!reactFlowWrapper.current) { return @@ -760,21 +814,23 @@ function App() { return } - const imageWidth = Math.round(containerRect.width) - const imageHeight = Math.round(containerRect.height) - - const backgroundColor = '#ffffff' + const fallbackWidth = Math.round(containerRect.width) + const fallbackHeight = Math.round(containerRect.height) try { - const dataUrl = await toPng(container, { - backgroundColor, - filter: (node) => - !(node instanceof Element) || - (!node.closest('[data-no-export="true"]') && - !node.closest('.react-flow__background') && - (includeAccentColorsInExport || node.dataset.accentBar !== 'true')), - width: imageWidth, - height: imageHeight, + const dataUrl = await exportFlowPng({ + nodes: visibleFlowNodes, + edges: flowEdges, + nodeTypes, + edgeTypes, + annotations, + activeView, + showAnnotations, + currentStroke, + defaultValueEntries, + fallbackWidth, + fallbackHeight, + includeAccentColorsInExport, }) const normalizedName = sanitizeFileName(modelName ?? 'Untitled model') @@ -791,7 +847,17 @@ function App() { } catch (error) { console.error('Failed to export PNG', error) } - }, [activeView, includeAccentColorsInExport, modelName]) + }, [ + activeView, + annotations, + currentStroke, + defaultValueEntries, + flowEdges, + includeAccentColorsInExport, + modelName, + showAnnotations, + visibleFlowNodes, + ]) const onSidebarSelect = useCallback( (item) => { @@ -830,60 +896,6 @@ function App() { ], ) - const defaultValueEntries = useMemo(() => { - if (activeView !== VIEW_PHYSICAL) { - return [] - } - - return nodes.flatMap((node) => { - const className = - typeof node.data?.label === 'string' && node.data.label.trim() - ? node.data.label.trim() - : 'Class' - const attributes = Array.isArray(node.data?.attributes) - ? node.data.attributes - : [] - return attributes.flatMap((attribute) => { - const value = - typeof attribute.defaultValue === 'string' - ? attribute.defaultValue.trim() - : '' - if (!value) { - return [] - } - const logicalName = - typeof attribute.logicalName === 'string' && attribute.logicalName.trim() - ? attribute.logicalName.trim() - : '' - const attributeName = - logicalName || - (typeof attribute.name === 'string' && attribute.name.trim() - ? attribute.name.trim() - : 'attribute') - return [ - { - key: `${className}.${attributeName}`, - value, - }, - ] - }) - }) - }, [activeView, nodes]) - - const visibleFlowNodes = useMemo( - () => - flowNodes.filter((node) => { - if (node.type === NOTE_NODE_TYPE) { - return showNotes - } - if (node.type === AREA_NODE_TYPE) { - return showAreas - } - return true - }), - [flowNodes, showAreas, showNotes], - ) - return (
diff --git a/src/components/flow/nodes/Class.jsx b/src/components/flow/nodes/Class.jsx index 221a19c..527ab22 100644 --- a/src/components/flow/nodes/Class.jsx +++ b/src/components/flow/nodes/Class.jsx @@ -109,60 +109,66 @@ export function Class({ data, id, selected }) { return (
-
- {data.label ?? ''} + className={`overflow-hidden rounded-lg border-2 bg-base-100 shadow-sm hover:border-primary ${borderClass}`} + > +
+
+ {data.label ?? ''} +
+
+ {visibleAttributes.length === 0 ? ( +
No attributes
+ ) : ( +
    + {visibleAttributes.map((attr) => { + const logicalName = + typeof attr.logicalName === 'string' && attr.logicalName.trim() + ? attr.logicalName + : '' + const displayName = + activeView === VIEW_CONCEPTUAL + ? attr.name + : logicalName || attr.name + return ( + + ) + })} +
+ )} +
+ {showOperationsCompartment ? ( + <> +
+
+ + ) : null}
-
- {visibleAttributes.length === 0 ? ( -
No attributes
- ) : ( -
    - {visibleAttributes.map((attr) => { - const logicalName = - typeof attr.logicalName === 'string' && attr.logicalName.trim() - ? attr.logicalName - : '' - const displayName = - activeView === VIEW_CONCEPTUAL - ? attr.name - : logicalName || attr.name - return ( - - ) - })} -
- )} -
- {showOperationsCompartment ? ( - <> -
-
- - ) : null} {showHandles ? ( <> {} + +export default function PngExportCanvas({ + nodes, + edges, + nodeTypes, + edgeTypes, + bounds, + width, + height, + annotations, + activeView, + showAnnotations, + currentStroke, + defaultValueEntries, + onInit, +}) { + const viewport = { + x: -bounds.x, + y: -bounds.y, + zoom: 1, + } + + return ( +
+ + {showAnnotations ? ( + + ) : null} + + {activeView === VIEW_PHYSICAL ? ( + + ) : null} +
+ ) +} diff --git a/src/model/__tests__/pngExport.test.jsx b/src/model/__tests__/pngExport.test.jsx new file mode 100644 index 0000000..4fa0184 --- /dev/null +++ b/src/model/__tests__/pngExport.test.jsx @@ -0,0 +1,125 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { toPng } from 'html-to-image' +import { + exportFlowPng, + shouldIncludePngExportNode, +} from '../pngExport.jsx' + +const reactFlowMockState = vi.hoisted(() => ({ + skipInit: false, +})) + +vi.mock('html-to-image', () => ({ + toPng: vi.fn(() => Promise.resolve('data:image/png;base64,exported')), +})) + +vi.mock('reactflow', async () => { + const React = await import('react') + + return { + default: function MockReactFlow({ children, onInit }) { + React.useEffect(() => { + if (reactFlowMockState.skipInit) { + return + } + onInit?.({}) + }, [onInit]) + return
{children}
+ }, + ConnectionLineType: { Straight: 'straight' }, + ConnectionMode: { Loose: 'loose' }, + useViewport: () => ({ x: 0, y: 0, zoom: 1 }), + } +}) + +function getExportArgs(overrides = {}) { + return { + nodes: [ + { + id: 'class-a', + position: { x: 100, y: 40 }, + width: 300, + height: 200, + }, + ], + edges: [], + nodeTypes: {}, + edgeTypes: {}, + annotations: {}, + activeView: 'conceptual', + showAnnotations: false, + currentStroke: null, + defaultValueEntries: [], + fallbackWidth: 640, + fallbackHeight: 480, + includeAccentColorsInExport: true, + ...overrides, + } +} + +describe('pngExport', () => { + beforeEach(() => { + vi.clearAllMocks() + reactFlowMockState.skipInit = false + vi.stubGlobal('requestAnimationFrame', (callback) => { + callback() + return 1 + }) + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + }) + + it('exports full content bounds instead of the fallback viewport size', async () => { + const dataUrl = await exportFlowPng(getExportArgs()) + + expect(dataUrl).toBe('data:image/png;base64,exported') + expect(toPng).toHaveBeenCalledTimes(1) + expect(toPng.mock.calls[0][0].style.left).toBe('0px') + expect(toPng.mock.calls[0][0].style.zIndex).toBe('-1') + expect(toPng.mock.calls[0][1]).toMatchObject({ + width: 492, + height: 392, + pixelRatio: 2, + backgroundColor: '#ffffff', + }) + }) + + it('does not capture when the offscreen flow fails to initialize', async () => { + vi.useFakeTimers() + reactFlowMockState.skipInit = true + + const exportPromise = exportFlowPng(getExportArgs()) + const exportErrorPromise = exportPromise.catch((error) => error) + + await vi.advanceTimersByTimeAsync(5000) + + const error = await exportErrorPromise + + expect(error).toBeInstanceOf(Error) + expect(error.message).toBe( + 'Timed out waiting for the PNG export canvas to render.', + ) + expect(toPng).not.toHaveBeenCalled() + }) + + it('keeps existing export filtering semantics', () => { + const root = document.createElement('div') + const controls = document.createElement('div') + controls.dataset.noExport = 'true' + const child = document.createElement('span') + controls.appendChild(child) + const background = document.createElement('div') + background.className = 'react-flow__background' + const accent = document.createElement('div') + accent.dataset.accentBar = 'true' + root.append(controls, background, accent) + + expect(shouldIncludePngExportNode(child, true)).toBe(false) + expect(shouldIncludePngExportNode(background, true)).toBe(false) + expect(shouldIncludePngExportNode(accent, false)).toBe(false) + expect(shouldIncludePngExportNode(accent, true)).toBe(true) + }) +}) diff --git a/src/model/__tests__/pngExportUtils.test.js b/src/model/__tests__/pngExportUtils.test.js new file mode 100644 index 0000000..a3a4bda --- /dev/null +++ b/src/model/__tests__/pngExportUtils.test.js @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest' +import { + getPngExportContentBounds, + getPngExportDimensions, + getPngExportPixelRatio, + PNG_EXPORT_MAX_PIXELS, + PNG_EXPORT_MAX_SIDE, + PNG_EXPORT_PADDING, +} from '../pngExportUtils.js' + +describe('pngExportUtils', () => { + it('computes padded bounds from visible nodes and edge routing hints', () => { + const bounds = getPngExportContentBounds({ + nodes: [ + { + id: 'class-a', + position: { x: 100, y: 50 }, + width: 220, + height: 120, + }, + { + id: 'hidden-note', + hidden: true, + position: { x: -1000, y: -1000 }, + width: 200, + height: 200, + }, + ], + edges: [ + { + id: 'edge-a', + data: { + controlPoints: [{ x: 480, y: 20 }], + }, + }, + ], + }) + + expect(bounds).toEqual({ + x: 4, + y: -108, + width: 604, + height: 374, + }) + }) + + it('includes active-view annotation stroke and text bounds when enabled', () => { + const bounds = getPngExportContentBounds({ + nodes: [ + { + id: 'class-a', + position: { x: 100, y: 50 }, + width: 220, + height: 120, + }, + ], + annotations: { + conceptual: { + items: [ + { + kind: 'stroke', + thickness: 10, + points: [ + { x: -30, y: -10 }, + { x: 10, y: 20 }, + ], + }, + { + kind: 'text', + x: 500, + y: 250, + text: 'Export', + fontSize: 20, + }, + ], + }, + logical: { + items: [ + { + kind: 'stroke', + thickness: 10, + points: [{ x: -500, y: -500 }], + }, + ], + }, + }, + activeView: 'conceptual', + includeAnnotations: true, + }) + + expect(bounds.x).toBe(-30 - 5 - PNG_EXPORT_PADDING) + expect(bounds.y).toBe(-10 - 5 - PNG_EXPORT_PADDING) + expect(bounds.width).toBeCloseTo(801.4) + expect(bounds.height).toBeCloseTo(464) + }) + + it('falls back to provided dimensions when bounds are empty', () => { + expect(getPngExportContentBounds()).toBeNull() + expect(getPngExportDimensions(null, 640, 360)).toEqual({ + width: 640, + height: 360, + }) + }) + + it('uses 2x output for normal diagrams and caps huge diagrams', () => { + expect(getPngExportPixelRatio(1000, 500)).toBe(2) + + const hugeRatio = getPngExportPixelRatio(9000, 6000) + expect(hugeRatio).toBeLessThan(1) + expect(9000 * hugeRatio).toBeLessThanOrEqual(PNG_EXPORT_MAX_SIDE) + expect(9000 * hugeRatio * (6000 * hugeRatio)).toBeLessThanOrEqual( + PNG_EXPORT_MAX_PIXELS, + ) + }) +}) diff --git a/src/model/pngExport.jsx b/src/model/pngExport.jsx new file mode 100644 index 0000000..7af7196 --- /dev/null +++ b/src/model/pngExport.jsx @@ -0,0 +1,132 @@ +import { createRoot } from 'react-dom/client' +import { toPng } from 'html-to-image' +import PngExportCanvas from './PngExportCanvas.jsx' +import { + getPngExportContentBounds, + getPngExportDimensions, + getPngExportPixelRatio, +} from './pngExportUtils.js' + +const BACKGROUND_COLOR = '#ffffff' +const EXPORT_RENDER_TIMEOUT_MS = 5000 + +function waitForFrame() { + return new Promise((resolve) => window.requestAnimationFrame(resolve)) +} + +function waitForExportInitialization(initializedPromise) { + return new Promise((resolve, reject) => { + const timeoutId = window.setTimeout(() => { + reject(new Error('Timed out waiting for the PNG export canvas to render.')) + }, EXPORT_RENDER_TIMEOUT_MS) + + initializedPromise.then( + () => { + window.clearTimeout(timeoutId) + resolve() + }, + (error) => { + window.clearTimeout(timeoutId) + reject(error) + }, + ) + }) +} + +export function shouldIncludePngExportNode(node, includeAccentColorsInExport) { + return ( + !(node instanceof Element) || + (!node.closest('[data-no-export="true"]') && + !node.closest('.react-flow__background') && + (includeAccentColorsInExport || node.dataset.accentBar !== 'true')) + ) +} + +function createOffscreenContainer(width, height) { + const container = document.createElement('div') + container.setAttribute('aria-hidden', 'true') + container.style.position = 'fixed' + container.style.left = '0' + container.style.top = '0' + container.style.zIndex = '-1' + container.style.width = `${width}px` + container.style.height = `${height}px` + container.style.background = BACKGROUND_COLOR + container.style.pointerEvents = 'none' + container.style.overflow = 'hidden' + document.body.appendChild(container) + return container +} + +export async function exportFlowPng({ + nodes, + edges, + nodeTypes, + edgeTypes, + annotations, + activeView, + showAnnotations, + currentStroke, + defaultValueEntries, + fallbackWidth, + fallbackHeight, + includeAccentColorsInExport, +}) { + const bounds = + getPngExportContentBounds({ + nodes, + edges, + annotations, + activeView, + includeAnnotations: showAnnotations, + currentStroke, + }) ?? { x: 0, y: 0, width: fallbackWidth, height: fallbackHeight } + const { width, height } = getPngExportDimensions( + bounds, + fallbackWidth, + fallbackHeight, + ) + const pixelRatio = getPngExportPixelRatio(width, height) + const container = createOffscreenContainer(width, height) + const root = createRoot(container) + + const initializedPromise = new Promise((resolve) => { + root.render( + { + resolve() + }} + />, + ) + }) + + try { + await waitForExportInitialization(initializedPromise) + await waitForFrame() + await waitForFrame() + + return await toPng(container, { + backgroundColor: BACKGROUND_COLOR, + filter: (node) => + shouldIncludePngExportNode(node, includeAccentColorsInExport), + width, + height, + pixelRatio, + }) + } finally { + root.unmount() + container.remove() + } +} diff --git a/src/model/pngExportUtils.js b/src/model/pngExportUtils.js new file mode 100644 index 0000000..8b71c7d --- /dev/null +++ b/src/model/pngExportUtils.js @@ -0,0 +1,230 @@ +import { AREA_NODE_TYPE, NOTE_NODE_TYPE } from './constants.js' + +export const PNG_EXPORT_PADDING = 96 +export const PNG_EXPORT_PIXEL_RATIO = 2 +export const PNG_EXPORT_MAX_SIDE = 8192 +export const PNG_EXPORT_MAX_PIXELS = 36_000_000 + +const DEFAULT_NODE_SIZE = { width: 220, height: 140 } +const DEFAULT_NOTE_SIZE = { width: 180, height: 80 } +const DEFAULT_AREA_SIZE = { width: 280, height: 180 } +const LINE_HEIGHT_RATIO = 1.3 + +function toFiniteNumber(value) { + if (typeof value === 'number' && Number.isFinite(value)) { + return value + } + if (typeof value === 'string' && value.trim()) { + const parsed = Number.parseFloat(value) + return Number.isFinite(parsed) ? parsed : null + } + return null +} + +function positiveNumber(...values) { + for (const value of values) { + const parsed = toFiniteNumber(value) + if (parsed && parsed > 0) { + return parsed + } + } + return null +} + +function getFallbackSize(node) { + if (node?.type === AREA_NODE_TYPE) { + return DEFAULT_AREA_SIZE + } + if (node?.type === NOTE_NODE_TYPE) { + return DEFAULT_NOTE_SIZE + } + return DEFAULT_NODE_SIZE +} + +function getNodePosition(node) { + return ( + node?.positionAbsolute ?? + node?.internals?.positionAbsolute ?? + node?.position ?? + null + ) +} + +function getNodeSize(node) { + const fallback = getFallbackSize(node) + return { + width: + positiveNumber(node?.width, node?.measured?.width, node?.style?.width) ?? + fallback.width, + height: + positiveNumber(node?.height, node?.measured?.height, node?.style?.height) ?? + fallback.height, + } +} + +function createMutableBounds() { + return { + minX: Infinity, + minY: Infinity, + maxX: -Infinity, + maxY: -Infinity, + } +} + +function extendBounds(bounds, x, y, width = 0, height = 0) { + if ( + !Number.isFinite(x) || + !Number.isFinite(y) || + !Number.isFinite(width) || + !Number.isFinite(height) + ) { + return bounds + } + + bounds.minX = Math.min(bounds.minX, x) + bounds.minY = Math.min(bounds.minY, y) + bounds.maxX = Math.max(bounds.maxX, x + width) + bounds.maxY = Math.max(bounds.maxY, y + height) + return bounds +} + +function extendPoint(bounds, point, radius = 0) { + const x = toFiniteNumber(point?.x) + const y = toFiniteNumber(point?.y) + if (x === null || y === null) { + return bounds + } + return extendBounds(bounds, x - radius, y - radius, radius * 2, radius * 2) +} + +function boundsToRect(bounds, padding) { + if ( + !Number.isFinite(bounds.minX) || + !Number.isFinite(bounds.minY) || + !Number.isFinite(bounds.maxX) || + !Number.isFinite(bounds.maxY) + ) { + return null + } + + const width = Math.max(1, bounds.maxX - bounds.minX + padding * 2) + const height = Math.max(1, bounds.maxY - bounds.minY + padding * 2) + return { + x: bounds.minX - padding, + y: bounds.minY - padding, + width, + height, + } +} + +function extendNodeBounds(bounds, nodes) { + nodes.forEach((node) => { + if (!node || node.hidden) { + return + } + const position = getNodePosition(node) + if (!position) { + return + } + const size = getNodeSize(node) + extendBounds(bounds, position.x, position.y, size.width, size.height) + }) +} + +function extendEdgeHintBounds(bounds, edges) { + edges.forEach((edge) => { + const controlPoints = Array.isArray(edge?.data?.controlPoints) + ? edge.data.controlPoints + : [] + controlPoints.forEach((point) => extendPoint(bounds, point, 32)) + }) +} + +function getAnnotationItems({ annotations, activeView, currentStroke }) { + const savedItems = annotations?.[activeView]?.items + const items = Array.isArray(savedItems) ? [...savedItems] : [] + if (currentStroke) { + items.push(currentStroke) + } + return items +} + +function extendAnnotationBounds(bounds, items) { + items.forEach((item) => { + if (item?.kind === 'stroke') { + const radius = Math.max(1, (toFiniteNumber(item.thickness) ?? 1) / 2) + const points = Array.isArray(item.points) ? item.points : [] + points.forEach((point) => extendPoint(bounds, point, radius)) + return + } + + if (item?.kind === 'text' && typeof item.text === 'string') { + const x = toFiniteNumber(item.x) + const y = toFiniteNumber(item.y) + const fontSize = positiveNumber(item.fontSize) ?? 14 + if (x === null || y === null) { + return + } + + const lines = item.text.split('\n') + const longestLine = lines.reduce( + (max, line) => Math.max(max, line.length), + 0, + ) + const lineHeight = fontSize * LINE_HEIGHT_RATIO + const width = Math.max(fontSize * 2, longestLine * fontSize * 0.62) + const height = Math.max(lineHeight, lines.length * lineHeight) + extendBounds(bounds, x, y - fontSize * 0.95, width, height) + } + }) +} + +export function getPngExportContentBounds({ + nodes = [], + edges = [], + annotations = null, + activeView = 'conceptual', + includeAnnotations = false, + currentStroke = null, + padding = PNG_EXPORT_PADDING, +} = {}) { + const bounds = createMutableBounds() + extendNodeBounds(bounds, Array.isArray(nodes) ? nodes : []) + extendEdgeHintBounds(bounds, Array.isArray(edges) ? edges : []) + + if (includeAnnotations) { + extendAnnotationBounds( + bounds, + getAnnotationItems({ annotations, activeView, currentStroke }), + ) + } + + return boundsToRect(bounds, padding) +} + +export function getPngExportPixelRatio( + width, + height, + { + baseRatio = PNG_EXPORT_PIXEL_RATIO, + maxSide = PNG_EXPORT_MAX_SIDE, + maxPixels = PNG_EXPORT_MAX_PIXELS, + } = {}, +) { + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { + return 1 + } + + const sideRatio = maxSide / Math.max(width, height) + const areaRatio = Math.sqrt(maxPixels / (width * height)) + return Math.max(0.1, Math.min(baseRatio, sideRatio, areaRatio)) +} + +export function getPngExportDimensions(bounds, fallbackWidth, fallbackHeight) { + const width = Math.ceil(bounds?.width ?? fallbackWidth ?? 1) + const height = Math.ceil(bounds?.height ?? fallbackHeight ?? 1) + return { + width: Math.max(1, width), + height: Math.max(1, height), + } +}