diff --git a/package-lock.json b/package-lock.json index 68d804f2..04a79d51 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "cmdk": "^1.0.0", "dexie": "^4.0.8", "eslint-config-airbnb-typescript": "^18.0.0", + "html-to-image": "^1.11.11", "lucide-react": "^0.424.0", "nanoid": "^5.0.7", "react": "^18.3.1", @@ -5721,6 +5722,12 @@ "node": "*" } }, + "node_modules/html-to-image": { + "version": "1.11.11", + "resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.11.tgz", + "integrity": "sha512-9gux8QhvjRO/erSnDPv28noDZcPZmYE7e1vFsBLKLlRlKDSqNJYebj6Qz1TGd5lsRV+X+xYyjCKjuZdABinWjA==", + "license": "MIT" + }, "node_modules/hyphenate-style-name": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz", diff --git a/package.json b/package.json index fd38e8d5..3db65def 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "cmdk": "^1.0.0", "dexie": "^4.0.8", "eslint-config-airbnb-typescript": "^18.0.0", + "html-to-image": "^1.11.11", "lucide-react": "^0.424.0", "nanoid": "^5.0.7", "react": "^18.3.1", diff --git a/src/context/export-image-context/export-image-context.tsx b/src/context/export-image-context/export-image-context.tsx new file mode 100644 index 00000000..26c7231e --- /dev/null +++ b/src/context/export-image-context/export-image-context.tsx @@ -0,0 +1,11 @@ +import { createContext } from 'react'; +import { emptyFn } from '@/lib/utils'; + +export type ImageType = 'png' | 'jpeg' | 'svg'; +export interface ExportImageContext { + exportImage: (type: ImageType) => Promise; +} + +export const exportImageContext = createContext({ + exportImage: emptyFn, +}); diff --git a/src/context/export-image-context/export-image-provider.tsx b/src/context/export-image-context/export-image-provider.tsx new file mode 100644 index 00000000..ddb46962 --- /dev/null +++ b/src/context/export-image-context/export-image-provider.tsx @@ -0,0 +1,92 @@ +import React, { useCallback, useMemo } from 'react'; +import { + ExportImageContext, + exportImageContext, + ImageType, +} from './export-image-context'; +import { toJpeg, toPng, toSvg } from 'html-to-image'; +import { + getNodesBounds, + getViewportForBounds, + useReactFlow, +} from '@xyflow/react'; +import { useChartDB } from '@/hooks/use-chartdb'; + +const imageWidth = 1024; +const imageHeight = 768; + +export const ExportImageProvider: React.FC = ({ + children, +}) => { + const { getNodes, setNodes } = useReactFlow(); + const { diagramName } = useChartDB(); + + const downloadImage = useCallback( + (dataUrl: string, type: ImageType) => { + const a = document.createElement('a'); + + a.setAttribute('download', `${diagramName}.${type}`); + a.setAttribute('href', dataUrl); + + a.click(); + }, + [diagramName] + ); + + const imageCreatorMap: Record< + ImageType, + typeof toJpeg | typeof toPng | typeof toSvg + > = useMemo( + () => ({ + jpeg: toJpeg, + png: toPng, + svg: toSvg, + }), + [] + ); + + const exportImage: ExportImageContext['exportImage'] = useCallback( + async (type) => { + setNodes((nodes) => + nodes.map((node) => ({ ...node, selected: false })) + ); + + const nodesBounds = getNodesBounds(getNodes()); + const viewport = getViewportForBounds( + nodesBounds, + imageWidth, + imageHeight, + 0.5, + 2, + 0 + ); + + const imageCreateFn = imageCreatorMap[type]; + + const dataUrl = await imageCreateFn( + window.document.querySelector( + '.react-flow__viewport' + ) as HTMLElement, + { + ...(type === 'jpeg' ? { backgroundColor: '#ffffff' } : {}), + width: imageWidth, + height: imageHeight, + style: { + width: `${imageWidth}px`, + height: `${imageHeight}px`, + transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.zoom})`, + }, + } + ); + + downloadImage(dataUrl, type); + }, + [downloadImage, getNodes, imageCreatorMap, setNodes] + ); + + return ( + + {children} + + ); +}; diff --git a/src/hooks/use-export-image.ts b/src/hooks/use-export-image.ts new file mode 100644 index 00000000..20111358 --- /dev/null +++ b/src/hooks/use-export-image.ts @@ -0,0 +1,4 @@ +import { useContext } from 'react'; +import { exportImageContext } from '@/context/export-image-context/export-image-context'; + +export const useExportImage = () => useContext(exportImageContext); diff --git a/src/pages/editor-page/top-navbar/top-navbar.tsx b/src/pages/editor-page/top-navbar/top-navbar.tsx index a713b9cd..36329781 100644 --- a/src/pages/editor-page/top-navbar/top-navbar.tsx +++ b/src/pages/editor-page/top-navbar/top-navbar.tsx @@ -26,6 +26,7 @@ import { TooltipContent, TooltipTrigger, } from '@/components/tooltip/tooltip'; +import { useExportImage } from '@/hooks/use-export-image'; export interface TopNavbarProps {} @@ -33,6 +34,7 @@ export const TopNavbar: React.FC = () => { const { diagramName, updateDiagramName, currentDiagram } = useChartDB(); const { openCreateDiagramDialog, openOpenDiagramDialog } = useDialog(); const [editMode, setEditMode] = useState(false); + const { exportImage } = useExportImage(); const [editedDiagramName, setEditedDiagramName] = React.useState(diagramName); const inputRef = React.useRef(null); @@ -73,6 +75,18 @@ export const TopNavbar: React.FC = () => { console.log({ currentDiagram }); }, [currentDiagram]); + const exportPNG = useCallback(() => { + exportImage('png'); + }, [exportImage]); + + const exportSVG = useCallback(() => { + exportImage('svg'); + }, [exportImage]); + + const exportJPG = useCallback(() => { + exportImage('jpeg'); + }, [exportImage]); + return (