-
Notifications
You must be signed in to change notification settings - Fork 247
feat(data-modeling): export diagram to png COMPASS-9449 #7055
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 36 commits
cce62ac
a43ed7e
39793bd
e47687e
dbc1ca0
6d8de76
087936c
6c21b63
b7d8dcf
7c4c14e
f9bcc51
b65f4c8
adc19a1
6c41226
86e8a92
7fa0480
8c5c14d
ffef0b7
88a227b
487a2b9
56aadf0
42b9c8e
68d00cf
7b220ca
8a08691
3b16d26
c035d7a
cda452d
a47faf8
15bf100
ae8e3c6
840c078
4bca5a2
0a9a6e4
aab9aca
c6bc709
010b0c6
b34c1f5
6136ff4
fd4ee26
065851a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,4 @@ | ||
| import React, { useCallback, useState } from 'react'; | ||
| import React, { useCallback, useEffect, useRef, useState } from 'react'; | ||
| import { | ||
| Button, | ||
| css, | ||
|
|
@@ -12,6 +12,8 @@ import { | |
| Radio, | ||
| RadioGroup, | ||
| spacing, | ||
| SpinLoader, | ||
| useToast, | ||
| } from '@mongodb-js/compass-components'; | ||
| import { | ||
| closeExportModal, | ||
|
|
@@ -21,7 +23,9 @@ import { | |
| import { connect } from 'react-redux'; | ||
| import type { DataModelingState } from '../store/reducer'; | ||
| import type { StaticModel } from '../services/data-model-storage'; | ||
| import { exportToJson } from '../services/export-diagram'; | ||
| import { exportToJson, exportToPng } from '../services/export-diagram'; | ||
| import { useDiagram } from '@mongodb-js/diagramming'; | ||
| import { isCancelError } from '@mongodb-js/compass-utils'; | ||
|
|
||
| const nbsp = '\u00a0'; | ||
|
|
||
|
|
@@ -59,20 +63,71 @@ const ExportDiagramModal = ({ | |
| model, | ||
| onCloseClick, | ||
| }: ExportDiagramModalProps) => { | ||
| const [exportFormat, setExportFormat] = useState<'json' | null>(null); | ||
|
|
||
| const onExport = useCallback(() => { | ||
| if (!exportFormat || !model) { | ||
| return; | ||
| const [exportFormat, setExportFormat] = useState<'png' | 'json' | null>(null); | ||
| const diagram = useDiagram(); | ||
| const [isExporting, setIsExporting] = useState(false); | ||
| const abortControllerRef = useRef<AbortController | null>(null); | ||
| const toast = useToast(); | ||
| useEffect(() => { | ||
| const cleanup = () => { | ||
| if (abortControllerRef.current) { | ||
| abortControllerRef.current.abort(); | ||
| abortControllerRef.current = null; | ||
| } | ||
| }; | ||
| const abortController = new AbortController(); | ||
| if (isModalOpen) { | ||
| abortControllerRef.current = abortController; | ||
| } else { | ||
| cleanup(); | ||
| } | ||
| exportToJson(diagramLabel, model); | ||
| return cleanup; | ||
| }, [isModalOpen]); | ||
|
|
||
| const onClose = useCallback(() => { | ||
| setExportFormat(null); | ||
Anemy marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| setIsExporting(false); | ||
| abortControllerRef.current?.abort(); | ||
| abortControllerRef.current = null; | ||
| onCloseClick(); | ||
| }, [exportFormat, onCloseClick, model, diagramLabel]); | ||
| }, [onCloseClick]); | ||
|
|
||
| const onExport = useCallback(async () => { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There is way too much logic in the UI here (like if you see that you're keeping abort controllers in render, you probably should start thinking about that), this should really be an action in the store instead
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, i'll add another slice for export. Or, I can do it as a follow up if that sounds okay to you.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Follow-up sounds good, let's not hold this code in the branch for too long 👍 |
||
| try { | ||
| if (!exportFormat || !model) { | ||
Anemy marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| return; | ||
| } | ||
| setIsExporting(true); | ||
| if (exportFormat === 'json') { | ||
| exportToJson(diagramLabel, model); | ||
| } else if (exportFormat === 'png') { | ||
| await exportToPng( | ||
| diagramLabel, | ||
| diagram, | ||
| abortControllerRef.current?.signal | ||
| ); | ||
| } | ||
| } catch (error) { | ||
| if (isCancelError(error)) { | ||
| return; | ||
| } | ||
| toast.pushToast({ | ||
| id: 'export-diagram-error', | ||
| variant: 'warning', | ||
| title: 'Export failed', | ||
| description: `An error occurred while exporting the diagram: ${ | ||
| (error as Error).message | ||
| }`, | ||
| }); | ||
| } finally { | ||
| onClose(); | ||
| } | ||
| }, [exportFormat, onClose, model, diagram, diagramLabel, toast]); | ||
|
|
||
| return ( | ||
| <Modal | ||
| open={isModalOpen} | ||
| setOpen={onCloseClick} | ||
| setOpen={onClose} | ||
| data-testid="export-diagram-modal" | ||
| > | ||
| <ModalHeader | ||
|
|
@@ -95,6 +150,17 @@ const ExportDiagramModal = ({ | |
| <div className={contentContainerStyles}> | ||
| <Label htmlFor="">Select file format:</Label> | ||
| <RadioGroup className={contentContainerStyles} value={exportFormat}> | ||
| <div className={radioItemStyles}> | ||
| <Icon glyph="Diagram2" /> | ||
| <Radio | ||
| checked={exportFormat === 'png'} | ||
| value="png" | ||
| aria-label="PNG" | ||
| onClick={() => setExportFormat('png')} | ||
| > | ||
| PNG | ||
| </Radio> | ||
| </div> | ||
| <div className={radioItemStyles}> | ||
| <Icon glyph="CurlyBraces" /> | ||
| <Radio | ||
|
|
@@ -114,14 +180,12 @@ const ExportDiagramModal = ({ | |
| variant="primary" | ||
| onClick={() => void onExport()} | ||
| data-testid="export-button" | ||
| disabled={isExporting} | ||
| loadingIndicator={<SpinLoader />} | ||
| > | ||
| Export | ||
| </Button> | ||
| <Button | ||
| variant="default" | ||
| onClick={onCloseClick} | ||
| data-testid="cancel-button" | ||
| > | ||
| <Button variant="default" onClick={onClose} data-testid="cancel-button"> | ||
| Cancel | ||
| </Button> | ||
| </ModalFooter> | ||
|
|
||
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,165 @@ | ||
| import React from 'react'; | ||
| import { | ||
| getNodesBounds, | ||
| getViewportForBounds, | ||
| DiagramProvider, | ||
| Diagram, | ||
| } from '@mongodb-js/diagramming'; | ||
| import type { DiagramInstance } from '@mongodb-js/diagramming'; | ||
| import type { StaticModel } from './data-model-storage'; | ||
| import ReactDOM from 'react-dom'; | ||
| import { toPng } from 'html-to-image'; | ||
| import { rafraf, spacing } from '@mongodb-js/compass-components'; | ||
| import { raceWithAbort } from '@mongodb-js/compass-utils'; | ||
|
|
||
| function moveSvgDefsToViewportElement( | ||
| container: Element, | ||
| targetElement: Element | ||
| ) { | ||
| const markerDef = container.querySelector('svg defs'); | ||
| if (!markerDef) { | ||
| return; | ||
| } | ||
| const diagramSvgElements = targetElement.querySelectorAll('svg'); | ||
| diagramSvgElements.forEach((svg) => { | ||
| const pathsWithMarkers = svg.querySelectorAll( | ||
| 'path[marker-end], path[marker-start]' | ||
| ); | ||
| if (pathsWithMarkers.length !== 0) { | ||
| const clonedDef = markerDef.cloneNode(true); | ||
| svg.insertBefore(clonedDef, svg.firstChild); | ||
| } | ||
| }); | ||
| markerDef.remove(); | ||
| } | ||
|
|
||
| export async function exportToPng( | ||
| fileName: string, | ||
| diagram: DiagramInstance, | ||
| signal?: AbortSignal | ||
| ) { | ||
| const dataUri = await raceWithAbort( | ||
| getExportPngDataUri(diagram), | ||
| signal ?? new AbortController().signal | ||
| ); | ||
| downloadFile(dataUri, fileName); | ||
| } | ||
|
|
||
| export function getExportPngDataUri(diagram: DiagramInstance): Promise<string> { | ||
| return new Promise<string>((resolve, _reject) => { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there a reason why we're going to the Promise resolve/reject here? I'm thinking it could lead to an uncaught exception. Could we instead have the cleanup in a finally or catch block? We could do await something like the rafraf as well and await the
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What would you recommend in this case? I only want to resolve once dom has been converted to a data uri (and as such show loading state to the user). For clean up, its already in place where we export.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would recommend async/await here with try/catch. I find it makes the code easier to read through and the error handling less prone to be uncaught. |
||
| const bounds = getNodesBounds(diagram.getNodes()); | ||
|
|
||
| const container = document.createElement('div'); | ||
| container.setAttribute('data-testid', 'export-diagram-container'); | ||
| // Push it out of the viewport | ||
| container.style.position = 'fixed'; | ||
| container.style.top = '100vh'; | ||
| container.style.left = '100vw'; | ||
| container.style.width = `${bounds.width}px`; | ||
| container.style.height = `${bounds.height}px`; | ||
| document.body.appendChild(container); | ||
|
|
||
| const edges = diagram.getEdges(); | ||
| const nodes = diagram.getNodes().map((node) => ({ | ||
| ...node, | ||
| selected: false, // Dont show selected state (blue border) | ||
| })); | ||
|
|
||
| const reject = (error: Error) => { | ||
| document.body.removeChild(container); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Seems like we're missing clean-up on resolve, this should probably be in a finally block
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sounds good!
Just FYI, I already did in eslint update PR, so no need to worry about this (was in the way of some new issues that update started to show there)
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: you don't need it here now that you have it in |
||
| _reject(error); | ||
| }; | ||
|
|
||
| ReactDOM.render( | ||
| <DiagramProvider> | ||
| <Diagram | ||
| edges={edges} | ||
| nodes={nodes} | ||
| onlyRenderVisibleElements={false} | ||
| /> | ||
| </DiagramProvider>, | ||
| container, | ||
| () => { | ||
| rafraf(() => { | ||
| // For export we are selecting react-flow__viewport element, | ||
| // which contains the export canvas. It excludes diagram | ||
| // title, minmap, and other UI elements. However, it also | ||
| // excludes the svg defs that are currently outside of this element. | ||
| // So, when exporting, we need to include those defs as well so that | ||
| // edge markers are exported correctly. | ||
|
Comment on lines
+88
to
+89
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Wouldn't it be easier to hide what we don't need instead of doing this?
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we make this configurable in the diagramming package?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I'll evaluate this, last time (in poc) i checked this, it did not give me good results.
These custom marker are rendered here in the package. I am not sure if i follow what you mean by making it configurable?
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sorry, comment somehow attached to the wrong place, should've been this part: We're doing all this to avoid including controls in the diagram screenshot. Can we change diagramming package to allow us to render it with hidden controls? Then you don't need to do anything to target an element that doesn't include everything that you need
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ah, yeah that should be possible with the package. but again it depends on if we are able to export the root container (
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I tried to do this and was not able to get it to export correctly using this approach. |
||
| const viewportElement = container.querySelector( | ||
| '.react-flow__viewport' | ||
| ); | ||
|
Comment on lines
+90
to
+92
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: instead of relying on arbitrary frames you can probably wait for this element to be in the view (similar to what we do in tests sometimes) |
||
| if (!viewportElement) { | ||
| return reject(new Error('Diagram element not found')); | ||
| } | ||
|
|
||
| const transform = getViewportForBounds( | ||
| bounds, | ||
| bounds.width, | ||
| bounds.height, | ||
| 0.5, // Minimum zoom | ||
| 2, // Maximum zoom | ||
| `${spacing[400]}px` // 16px padding | ||
| ); | ||
|
|
||
| // Moving svg defs to the viewport element | ||
| moveSvgDefsToViewportElement(container, viewportElement); | ||
| rafraf(() => { | ||
| toPng(viewportElement as HTMLElement, { | ||
| backgroundColor: '#fff', | ||
| pixelRatio: 2, | ||
| width: bounds.width, | ||
| height: bounds.height, | ||
| style: { | ||
| width: `${bounds.width}px`, | ||
| height: `${bounds.height}px`, | ||
| transform: `translate(${transform.x}px, ${transform.y}px) scale(${transform.zoom})`, | ||
| }, | ||
| }) | ||
| .then(resolve) | ||
| .catch(reject); | ||
| }); | ||
| }); | ||
| } | ||
| ); | ||
| }); | ||
| } | ||
|
|
||
| export function exportToJson(fileName: string, model: StaticModel) { | ||
| const json = getExportJsonFromModel(model); | ||
| const blob = new Blob([JSON.stringify(json, null, 2)], { | ||
| type: 'application/json', | ||
| }); | ||
| const url = window.URL.createObjectURL(blob); | ||
| downloadFile(url, fileName, () => { | ||
| window.URL.revokeObjectURL(url); | ||
| }); | ||
| } | ||
|
|
||
| export function getExportJsonFromModel({ | ||
| collections, | ||
| relationships, | ||
| }: StaticModel) { | ||
| return { | ||
| collections: Object.fromEntries( | ||
| collections.map((collection) => { | ||
| // eslint-disable-next-line @typescript-eslint/no-unused-vars | ||
| const { ns, jsonSchema, ...ignoredProps } = collection; | ||
| return [ns, { ns, jsonSchema }]; | ||
| }) | ||
| ), | ||
| relationships, | ||
| }; | ||
| } | ||
|
|
||
| function downloadFile(uri: string, fileName: string, cleanup?: () => void) { | ||
| const link = document.createElement('a'); | ||
| link.download = fileName; | ||
| link.href = uri; | ||
| link.click(); | ||
| setTimeout(() => { | ||
| link.remove(); | ||
| cleanup?.(); | ||
| }, 0); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Use our openToast instead, not sure how are we even exporting this one, we really shouldn't