diff --git a/diagram-editor/dist.tar.gz b/diagram-editor/dist.tar.gz index 715730fd..5faae5ea 100644 Binary files a/diagram-editor/dist.tar.gz and b/diagram-editor/dist.tar.gz differ diff --git a/diagram-editor/frontend/command-panel.tsx b/diagram-editor/frontend/command-panel.tsx index 5789ec41..3fd23eec 100644 --- a/diagram-editor/frontend/command-panel.tsx +++ b/diagram-editor/frontend/command-panel.tsx @@ -11,7 +11,7 @@ import { RunButton } from './run-button'; export interface CommandPanelProps { onNodeChanges: (changes: NodeChange[]) => void; onExportClick: () => void; - onLoadDiagram: (jsonStr: string) => void; + onLoadDiagram: (jsonStr: string, filename: string) => void; } const VisuallyHiddenInput = styled('input')({ @@ -67,7 +67,7 @@ function CommandPanel({ onChange={async (ev) => { if (ev.target.files) { const json = await ev.target.files[0].text(); - onLoadDiagram(json); + onLoadDiagram(json, ev.target.files[0].name); } }} onClick={(ev) => { diff --git a/diagram-editor/frontend/diagram-editor.tsx b/diagram-editor/frontend/diagram-editor.tsx index 29c30901..7f3128af 100644 --- a/diagram-editor/frontend/diagram-editor.tsx +++ b/diagram-editor/frontend/diagram-editor.tsx @@ -509,9 +509,11 @@ function DiagramEditor() { const [loadContext, setLoadContext] = React.useState( null, ); + const [recentlyUsedFilename, setRecentlyUsedFilename] = + React.useState(null); const loadDiagram = React.useCallback( - async (jsonStr: string) => { + async (jsonStr: string, filename: string | null) => { try { const [diagram, { graph, isRestored }] = await loadDiagramJson(jsonStr); setLoadContext({ diagram }); @@ -524,6 +526,7 @@ function DiagramEditor() { } setEdges(graph.edges); setTemplates(diagram.templates || {}); + setRecentlyUsedFilename(filename); reactFlowInstance.current?.fitView(); closeAllPopovers(); } catch (e) { @@ -629,7 +632,7 @@ function DiagramEditor() { byteArray[i] = binaryString.charCodeAt(i); } const diagramJson = strFromU8(inflateSync(byteArray)); - loadDiagram(diagramJson); + loadDiagram(diagramJson, null); } catch (e) { if (e instanceof Error) { showErrorToast(`failed to load diagram: ${e.message}`); @@ -820,6 +823,10 @@ function DiagramEditor() { setRecentlyUsedFilename(filename) + } onClose={() => setOpenExportDiagramDialog(false)} /> diff --git a/diagram-editor/frontend/export-diagram-dialog.tsx b/diagram-editor/frontend/export-diagram-dialog.tsx index 764deb4f..97c3f96f 100644 --- a/diagram-editor/frontend/export-diagram-dialog.tsx +++ b/diagram-editor/frontend/export-diagram-dialog.tsx @@ -8,6 +8,7 @@ import { Stack, TextField, Typography, + useTheme, } from '@mui/material'; import { deflateSync, strToU8 } from 'fflate'; import React, { Suspense, use, useMemo } from 'react'; @@ -22,6 +23,8 @@ import { exportDiagram } from './utils/export-diagram'; export interface ExportDiagramDialogProps { open: boolean; + suggestedFilename: string | null; + onExportedFilename: (filename: string) => void; onClose: () => void; } @@ -32,6 +35,8 @@ interface DialogData { function ExportDiagramDialogInternal({ open, + suggestedFilename, + onExportedFilename, onClose, }: ExportDiagramDialogProps) { const nodeManager = useNodeManager(); @@ -39,6 +44,7 @@ function ExportDiagramDialogInternal({ const [templates] = useTemplates(); const registry = useRegistry(); const loadContext = useLoadContext(); + const theme = useTheme(); const dialogDataPromise = useMemo(async () => { const diagram = exportDiagram(registry, nodeManager, edges, templates); @@ -73,7 +79,9 @@ function ExportDiagramDialogInternal({ const dialogData = use(dialogDataPromise); - const handleDownload = () => { + const [downloaded, setDownloaded] = React.useState(null); + + const handleDownload = async () => { if (!dialogData) { return; } @@ -81,6 +89,34 @@ function ExportDiagramDialogInternal({ const blob = new Blob([dialogData.diagramJson], { type: 'application/json', }); + + if ('showSaveFilePicker' in window) { + try { + const handle = await (window as any).showSaveFilePicker({ + suggestedName: suggestedFilename ?? 'diagram.json', + types: [ + { + description: 'JSON File', + accept: { 'application/json': ['.json'] }, + }, + ], + }); + const exportedFilename = handle.name; + const writable = await handle.createWritable(); + await writable.write(blob); + await writable.close(); + setDownloaded(exportedFilename); + return; + } catch (err) { + if ((err as Error).name === 'AbortError') { + return; + } + } + } + + // The showSaveFilePicker API might not be supported in some browsers, + // fallback to the default method of downloading to just diagram.json if it + // fails. const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; @@ -89,8 +125,18 @@ function ExportDiagramDialogInternal({ a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); + + setDownloaded('diagram.json'); }; + React.useEffect(() => { + if (downloaded === null || downloaded.length === 0) { + return; + } + onExportedFilename(downloaded); + setTimeout(() => { setDownloaded(null); }, 5000); + }, [downloaded, onExportedFilename]) + const [copiedShareLink, setCopiedShareLink] = React.useState(false); return ( @@ -138,9 +184,20 @@ function ExportDiagramDialogInternal({