aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorLibravatar Kristóf Marussy <kristof@marussy.com>2024-02-24 18:55:05 +0100
committerLibravatar Kristóf Marussy <kristof@marussy.com>2024-02-24 18:55:05 +0100
commite3ba54260a73acaca1d36fed54179668f446fc88 (patch)
tree4c0aa1c4fc80855bf2718f10cd00c4a0ae801278
parentfix(web): CSP for SVG rasterization (diff)
downloadrefinery-e3ba54260a73acaca1d36fed54179668f446fc88.tar.gz
refinery-e3ba54260a73acaca1d36fed54179668f446fc88.tar.zst
refinery-e3ba54260a73acaca1d36fed54179668f446fc88.zip
feat(web): file open and save
In-place saving is only supported in Chromium.
-rw-r--r--subprojects/frontend/src/RootStore.ts28
-rw-r--r--subprojects/frontend/src/TopBar.tsx15
-rw-r--r--subprojects/frontend/src/editor/EditorButtons.tsx30
-rw-r--r--subprojects/frontend/src/editor/EditorStore.ts124
-rw-r--r--subprojects/frontend/src/editor/GeneratedModelStore.ts5
-rw-r--r--subprojects/frontend/src/editor/createEditorState.ts15
-rw-r--r--subprojects/frontend/src/graph/GraphStore.ts9
-rw-r--r--subprojects/frontend/src/graph/export/ExportSettingsStore.ts (renamed from subprojects/frontend/src/graph/export/ExportSettingsStore.tsx)0
-rw-r--r--subprojects/frontend/src/graph/export/exportDiagram.tsx6
-rw-r--r--subprojects/frontend/src/utils/fileIO.ts64
-rw-r--r--subprojects/frontend/types/filesystemAccess.d.ts25
11 files changed, 302 insertions, 19 deletions
diff --git a/subprojects/frontend/src/RootStore.ts b/subprojects/frontend/src/RootStore.ts
index 8a17d2de..c029f746 100644
--- a/subprojects/frontend/src/RootStore.ts
+++ b/subprojects/frontend/src/RootStore.ts
@@ -5,7 +5,12 @@
5 */ 5 */
6 6
7import { getLogger } from 'loglevel'; 7import { getLogger } from 'loglevel';
8import { makeAutoObservable, runInAction } from 'mobx'; 8import {
9 IReactionDisposer,
10 autorun,
11 makeAutoObservable,
12 runInAction,
13} from 'mobx';
9 14
10import PWAStore from './PWAStore'; 15import PWAStore from './PWAStore';
11import type EditorStore from './editor/EditorStore'; 16import type EditorStore from './editor/EditorStore';
@@ -34,16 +39,22 @@ export default class RootStore {
34 39
35 disposed = false; 40 disposed = false;
36 41
42 private titleReaction: IReactionDisposer | undefined;
43
37 constructor() { 44 constructor() {
38 this.pwaStore = new PWAStore(); 45 this.pwaStore = new PWAStore();
39 this.themeStore = new ThemeStore(); 46 this.themeStore = new ThemeStore();
40 this.exportSettingsStore = new ExportSettingsScotre(); 47 this.exportSettingsStore = new ExportSettingsScotre();
41 makeAutoObservable<RootStore, 'compressor' | 'editorStoreClass'>(this, { 48 makeAutoObservable<
49 RootStore,
50 'compressor' | 'editorStoreClass' | 'titleReaction'
51 >(this, {
42 compressor: false, 52 compressor: false,
43 editorStoreClass: false, 53 editorStoreClass: false,
44 pwaStore: false, 54 pwaStore: false,
45 themeStore: false, 55 themeStore: false,
46 exportSettingsStore: false, 56 exportSettingsStore: false,
57 titleReaction: false,
47 }); 58 });
48 (async () => { 59 (async () => {
49 const { default: EditorStore } = await import('./editor/EditorStore'); 60 const { default: EditorStore } = await import('./editor/EditorStore');
@@ -66,11 +77,21 @@ export default class RootStore {
66 this.initialValue = initialValue; 77 this.initialValue = initialValue;
67 if (this.editorStoreClass !== undefined) { 78 if (this.editorStoreClass !== undefined) {
68 const EditorStore = this.editorStoreClass; 79 const EditorStore = this.editorStoreClass;
69 this.editorStore = new EditorStore( 80 const editorStore = new EditorStore(
70 this.initialValue, 81 this.initialValue,
71 this.pwaStore, 82 this.pwaStore,
72 (text) => this.compressor.compress(text), 83 (text) => this.compressor.compress(text),
73 ); 84 );
85 this.editorStore = editorStore;
86 this.titleReaction?.();
87 this.titleReaction = autorun(() => {
88 const { simpleName, unsavedChanges } = editorStore;
89 if (simpleName === undefined) {
90 document.title = 'Refinery';
91 } else {
92 document.title = `${unsavedChanges ? '\u25cf ' : ''}${simpleName} - Refinery`;
93 }
94 });
74 } 95 }
75 } 96 }
76 97
@@ -78,6 +99,7 @@ export default class RootStore {
78 if (this.disposed) { 99 if (this.disposed) {
79 return; 100 return;
80 } 101 }
102 this.titleReaction?.();
81 this.editorStore?.dispose(); 103 this.editorStore?.dispose();
82 this.compressor.dispose(); 104 this.compressor.dispose();
83 this.disposed = true; 105 this.disposed = true;
diff --git a/subprojects/frontend/src/TopBar.tsx b/subprojects/frontend/src/TopBar.tsx
index d80c022d..738052c7 100644
--- a/subprojects/frontend/src/TopBar.tsx
+++ b/subprojects/frontend/src/TopBar.tsx
@@ -85,6 +85,16 @@ const DevModeBadge = styled('div')(({ theme }) => ({
85 borderRadius: theme.shape.borderRadius, 85 borderRadius: theme.shape.borderRadius,
86})); 86}));
87 87
88const FileName = styled('span', {
89 shouldForwardProp: (prop) => prop !== 'unsavedChanges',
90})<{ unsavedChanges: boolean }>(({ theme, unsavedChanges }) => ({
91 marginLeft: theme.spacing(1),
92 fontWeight: theme.typography.fontWeightLight,
93 fontSize: '1.25rem',
94 lineHeight: '1.6rem',
95 fontStyle: unsavedChanges ? 'italic' : 'normal',
96}));
97
88export default observer(function TopBar(): JSX.Element { 98export default observer(function TopBar(): JSX.Element {
89 const { editorStore, themeStore } = useRootStore(); 99 const { editorStore, themeStore } = useRootStore();
90 const overlayVisible = useWindowControlsOverlayVisible(); 100 const overlayVisible = useWindowControlsOverlayVisible();
@@ -126,6 +136,11 @@ export default observer(function TopBar(): JSX.Element {
126 <Typography variant="h6" component="h1" pl={1}> 136 <Typography variant="h6" component="h1" pl={1}>
127 Refinery {import.meta.env.DEV && <DevModeBadge>Dev</DevModeBadge>} 137 Refinery {import.meta.env.DEV && <DevModeBadge>Dev</DevModeBadge>}
128 </Typography> 138 </Typography>
139 {large && editorStore?.simpleName !== undefined && (
140 <FileName unsavedChanges={editorStore.unsavedChanges}>
141 {editorStore.simpleName}
142 </FileName>
143 )}
129 <Stack direction="row" alignItems="center" flexGrow={1} marginLeft={1}> 144 <Stack direction="row" alignItems="center" flexGrow={1} marginLeft={1}>
130 {medium && !large && ( 145 {medium && !large && (
131 <PaneButtons themeStore={themeStore} hideLabel /> 146 <PaneButtons themeStore={themeStore} hideLabel />
diff --git a/subprojects/frontend/src/editor/EditorButtons.tsx b/subprojects/frontend/src/editor/EditorButtons.tsx
index f4513909..4afba607 100644
--- a/subprojects/frontend/src/editor/EditorButtons.tsx
+++ b/subprojects/frontend/src/editor/EditorButtons.tsx
@@ -7,11 +7,14 @@
7import type { Diagnostic } from '@codemirror/lint'; 7import type { Diagnostic } from '@codemirror/lint';
8import CancelIcon from '@mui/icons-material/Cancel'; 8import CancelIcon from '@mui/icons-material/Cancel';
9import CheckIcon from '@mui/icons-material/Check'; 9import CheckIcon from '@mui/icons-material/Check';
10import FileOpenIcon from '@mui/icons-material/FileOpen';
10import FormatListNumberedIcon from '@mui/icons-material/FormatListNumbered'; 11import FormatListNumberedIcon from '@mui/icons-material/FormatListNumbered';
11import FormatPaintIcon from '@mui/icons-material/FormatPaint'; 12import FormatPaintIcon from '@mui/icons-material/FormatPaint';
12import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; 13import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
13import LooksIcon from '@mui/icons-material/Looks'; 14import LooksIcon from '@mui/icons-material/Looks';
14import RedoIcon from '@mui/icons-material/Redo'; 15import RedoIcon from '@mui/icons-material/Redo';
16import SaveIcon from '@mui/icons-material/Save';
17import SaveAsIcon from '@mui/icons-material/SaveAs';
15import SearchIcon from '@mui/icons-material/Search'; 18import SearchIcon from '@mui/icons-material/Search';
16import UndoIcon from '@mui/icons-material/Undo'; 19import UndoIcon from '@mui/icons-material/Undo';
17import WarningIcon from '@mui/icons-material/Warning'; 20import WarningIcon from '@mui/icons-material/Warning';
@@ -47,10 +50,37 @@ export default observer(function EditorButtons({
47 return ( 50 return (
48 <Stack direction="row" flexGrow={1}> 51 <Stack direction="row" flexGrow={1}>
49 <IconButton 52 <IconButton
53 disabled={editorStore === undefined}
54 onClick={() => editorStore?.openFile()}
55 aria-label="Open"
56 color="inherit"
57 >
58 <FileOpenIcon fontSize="small" />
59 </IconButton>
60 <IconButton
61 disabled={editorStore === undefined || !editorStore.unsavedChanges}
62 onClick={() => editorStore?.saveFile()}
63 aria-label="Save"
64 color="inherit"
65 >
66 <SaveIcon fontSize="small" />
67 </IconButton>
68 {'showSaveFilePicker' in window && (
69 <IconButton
70 disabled={editorStore === undefined}
71 onClick={() => editorStore?.saveFileAs()}
72 aria-label="Save as"
73 color="inherit"
74 >
75 <SaveAsIcon fontSize="small" />
76 </IconButton>
77 )}
78 <IconButton
50 disabled={editorStore === undefined || !editorStore.canUndo} 79 disabled={editorStore === undefined || !editorStore.canUndo}
51 onClick={() => editorStore?.undo()} 80 onClick={() => editorStore?.undo()}
52 aria-label="Undo" 81 aria-label="Undo"
53 color="inherit" 82 color="inherit"
83 sx={{ ml: 1 }}
54 > 84 >
55 <UndoIcon fontSize="small" /> 85 <UndoIcon fontSize="small" />
56 </IconButton> 86 </IconButton>
diff --git a/subprojects/frontend/src/editor/EditorStore.ts b/subprojects/frontend/src/editor/EditorStore.ts
index 5e7d05e1..33bca382 100644
--- a/subprojects/frontend/src/editor/EditorStore.ts
+++ b/subprojects/frontend/src/editor/EditorStore.ts
@@ -27,6 +27,13 @@ import { nanoid } from 'nanoid';
27 27
28import type PWAStore from '../PWAStore'; 28import type PWAStore from '../PWAStore';
29import GraphStore from '../graph/GraphStore'; 29import GraphStore from '../graph/GraphStore';
30import {
31 type OpenResult,
32 type OpenTextFileResult,
33 openTextFile,
34 saveTextFile,
35 saveBlob,
36} from '../utils/fileIO';
30import getLogger from '../utils/getLogger'; 37import getLogger from '../utils/getLogger';
31import type XtextClient from '../xtext/XtextClient'; 38import type XtextClient from '../xtext/XtextClient';
32import type { SemanticsSuccessResult } from '../xtext/xtextServiceResults'; 39import type { SemanticsSuccessResult } from '../xtext/xtextServiceResults';
@@ -35,7 +42,10 @@ import EditorErrors from './EditorErrors';
35import GeneratedModelStore from './GeneratedModelStore'; 42import GeneratedModelStore from './GeneratedModelStore';
36import LintPanelStore from './LintPanelStore'; 43import LintPanelStore from './LintPanelStore';
37import SearchPanelStore from './SearchPanelStore'; 44import SearchPanelStore from './SearchPanelStore';
38import createEditorState from './createEditorState'; 45import createEditorState, {
46 createHistoryExtension,
47 historyCompartment,
48} from './createEditorState';
39import { countDiagnostics } from './exposeDiagnostics'; 49import { countDiagnostics } from './exposeDiagnostics';
40import { type IOccurrence, setOccurrences } from './findOccurrences'; 50import { type IOccurrence, setOccurrences } from './findOccurrences';
41import { 51import {
@@ -45,6 +55,25 @@ import {
45 55
46const log = getLogger('editor.EditorStore'); 56const log = getLogger('editor.EditorStore');
47 57
58const REFINERY_CONTENT_TYPE = 'text/x-refinery';
59
60const FILE_PICKER_OPTIONS: FilePickerOptions = {
61 id: 'problem',
62 types: [
63 {
64 description: 'Refinery files',
65 accept: {
66 [REFINERY_CONTENT_TYPE]: [
67 '.problem',
68 '.PROBLEM',
69 '.refinery',
70 '.REFINERY',
71 ],
72 },
73 },
74 ],
75};
76
48export default class EditorStore { 77export default class EditorStore {
49 readonly id: string; 78 readonly id: string;
50 79
@@ -76,6 +105,12 @@ export default class EditorStore {
76 105
77 selectedGeneratedModel: string | undefined; 106 selectedGeneratedModel: string | undefined;
78 107
108 fileName: string | undefined;
109
110 private fileHandle: FileSystemFileHandle | undefined;
111
112 unsavedChanges = false;
113
79 constructor( 114 constructor(
80 initialValue: string, 115 initialValue: string,
81 pwaStore: PWAStore, 116 pwaStore: PWAStore,
@@ -201,6 +236,9 @@ export default class EditorStore {
201 log.trace('Editor transaction', tr); 236 log.trace('Editor transaction', tr);
202 this.state = tr.state; 237 this.state = tr.state;
203 this.client?.onTransaction(tr); 238 this.client?.onTransaction(tr);
239 if (tr.docChanged) {
240 this.unsavedChanges = true;
241 }
204 } 242 }
205 243
206 doCommand(command: Command): boolean { 244 doCommand(command: Command): boolean {
@@ -403,4 +441,88 @@ export default class EditorStore {
403 }); 441 });
404 return generating; 442 return generating;
405 } 443 }
444
445 openFile(): boolean {
446 openTextFile(FILE_PICKER_OPTIONS)
447 .then((result) => this.fileOpened(result))
448 .catch((error) => log.error('Failed to open file', error));
449 return true;
450 }
451
452 private clearUnsavedChanges(): void {
453 this.unsavedChanges = false;
454 }
455
456 private setFile({ name, handle }: OpenResult): void {
457 log.info('Opened file', name);
458 this.fileName = name;
459 this.fileHandle = handle;
460 }
461
462 private fileOpened(result: OpenTextFileResult): void {
463 this.dispatch({
464 changes: [
465 {
466 from: 0,
467 to: this.state.doc.length,
468 insert: result.text,
469 },
470 ],
471 effects: [historyCompartment.reconfigure([])],
472 });
473 // Clear history by removing and re-adding the history extension. See
474 // https://stackoverflow.com/a/77943295 and
475 // https://discuss.codemirror.net/t/codemirror-6-cm-clearhistory-equivalent/2851/10
476 this.dispatch({
477 effects: [historyCompartment.reconfigure([createHistoryExtension()])],
478 });
479 this.setFile(result);
480 this.clearUnsavedChanges();
481 }
482
483 saveFile(): boolean {
484 if (!this.unsavedChanges) {
485 return false;
486 }
487 if (this.fileHandle === undefined) {
488 return this.saveFileAs();
489 }
490 saveTextFile(this.fileHandle, this.state.sliceDoc())
491 .then(() => this.clearUnsavedChanges())
492 .catch((error) => log.error('Failed to save file', error));
493 return true;
494 }
495
496 saveFileAs(): boolean {
497 const blob = new Blob([this.state.sliceDoc()], {
498 type: REFINERY_CONTENT_TYPE,
499 });
500 saveBlob(blob, this.fileName ?? 'graph.problem', FILE_PICKER_OPTIONS)
501 .then((result) => this.fileSavedAs(result))
502 .catch((error) => log.error('Failed to save file', error));
503 return true;
504 }
505
506 private fileSavedAs(result: OpenResult | undefined) {
507 if (result !== undefined) {
508 this.setFile(result);
509 }
510 this.clearUnsavedChanges();
511 }
512
513 get simpleName(): string | undefined {
514 const { fileName } = this;
515 if (fileName === undefined) {
516 return undefined;
517 }
518 const index = fileName.lastIndexOf('.');
519 if (index < 0) {
520 return fileName;
521 }
522 return fileName.substring(0, index);
523 }
524
525 get simpleNameOrFallback(): string {
526 return this.simpleName ?? 'graph';
527 }
406} 528}
diff --git a/subprojects/frontend/src/editor/GeneratedModelStore.ts b/subprojects/frontend/src/editor/GeneratedModelStore.ts
index f2695d9a..4af49e2c 100644
--- a/subprojects/frontend/src/editor/GeneratedModelStore.ts
+++ b/subprojects/frontend/src/editor/GeneratedModelStore.ts
@@ -21,7 +21,7 @@ export default class GeneratedModelStore {
21 graph: GraphStore | undefined; 21 graph: GraphStore | undefined;
22 22
23 constructor( 23 constructor(
24 randomSeed: number, 24 private readonly randomSeed: number,
25 private readonly editorStore: EditorStore, 25 private readonly editorStore: EditorStore,
26 ) { 26 ) {
27 const time = new Date().toLocaleTimeString(undefined, { hour12: false }); 27 const time = new Date().toLocaleTimeString(undefined, { hour12: false });
@@ -50,7 +50,8 @@ export default class GeneratedModelStore {
50 50
51 setSemantics(semantics: SemanticsSuccessResult): void { 51 setSemantics(semantics: SemanticsSuccessResult): void {
52 if (this.running) { 52 if (this.running) {
53 this.graph = new GraphStore(this.editorStore); 53 const name = `${this.editorStore.simpleNameOrFallback}_solution_${this.randomSeed}`;
54 this.graph = new GraphStore(this.editorStore, name);
54 this.graph.setSemantics(semantics); 55 this.graph.setSemantics(semantics);
55 } 56 }
56 } 57 }
diff --git a/subprojects/frontend/src/editor/createEditorState.ts b/subprojects/frontend/src/editor/createEditorState.ts
index 67b8fb9e..9b29228f 100644
--- a/subprojects/frontend/src/editor/createEditorState.ts
+++ b/subprojects/frontend/src/editor/createEditorState.ts
@@ -1,5 +1,5 @@
1/* 1/*
2 * SPDX-FileCopyrightText: 2021-2023 The Refinery Authors <https://refinery.tools/> 2 * SPDX-FileCopyrightText: 2021-2024 The Refinery Authors <https://refinery.tools/>
3 * 3 *
4 * SPDX-License-Identifier: EPL-2.0 4 * SPDX-License-Identifier: EPL-2.0
5 */ 5 */
@@ -26,7 +26,7 @@ import {
26} from '@codemirror/language'; 26} from '@codemirror/language';
27import { lintKeymap, lintGutter } from '@codemirror/lint'; 27import { lintKeymap, lintGutter } from '@codemirror/lint';
28import { search, searchKeymap } from '@codemirror/search'; 28import { search, searchKeymap } from '@codemirror/search';
29import { EditorState } from '@codemirror/state'; 29import { Compartment, EditorState, type Extension } from '@codemirror/state';
30import { 30import {
31 drawSelection, 31 drawSelection,
32 highlightActiveLine, 32 highlightActiveLine,
@@ -46,6 +46,12 @@ import exposeDiagnostics from './exposeDiagnostics';
46import findOccurrences from './findOccurrences'; 46import findOccurrences from './findOccurrences';
47import semanticHighlighting from './semanticHighlighting'; 47import semanticHighlighting from './semanticHighlighting';
48 48
49export const historyCompartment = new Compartment();
50
51export function createHistoryExtension(): Extension {
52 return history();
53}
54
49export default function createEditorState( 55export default function createEditorState(
50 initialValue: string, 56 initialValue: string,
51 store: EditorStore, 57 store: EditorStore,
@@ -66,7 +72,7 @@ export default function createEditorState(
66 highlightActiveLine(), 72 highlightActiveLine(),
67 highlightActiveLineGutter(), 73 highlightActiveLineGutter(),
68 highlightSpecialChars(), 74 highlightSpecialChars(),
69 history(), 75 historyCompartment.of([createHistoryExtension()]),
70 indentOnInput(), 76 indentOnInput(),
71 rectangularSelection(), 77 rectangularSelection(),
72 search({ 78 search({
@@ -103,6 +109,9 @@ export default function createEditorState(
103 }), 109 }),
104 keymap.of([ 110 keymap.of([
105 { key: 'Mod-Shift-f', run: () => store.formatText() }, 111 { key: 'Mod-Shift-f', run: () => store.formatText() },
112 { key: 'Ctrl-o', run: () => store.openFile() },
113 { key: 'Ctrl-s', run: () => store.saveFile() },
114 { key: 'Ctrl-Shift-s', run: () => store.saveFileAs() },
106 ...closeBracketsKeymap, 115 ...closeBracketsKeymap,
107 ...completionKeymap, 116 ...completionKeymap,
108 ...foldKeymap, 117 ...foldKeymap,
diff --git a/subprojects/frontend/src/graph/GraphStore.ts b/subprojects/frontend/src/graph/GraphStore.ts
index 58c4422d..d9282326 100644
--- a/subprojects/frontend/src/graph/GraphStore.ts
+++ b/subprojects/frontend/src/graph/GraphStore.ts
@@ -66,7 +66,10 @@ export default class GraphStore {
66 66
67 selectedSymbol: RelationMetadata | undefined; 67 selectedSymbol: RelationMetadata | undefined;
68 68
69 constructor(private readonly editorStore: EditorStore) { 69 constructor(
70 private readonly editorStore: EditorStore,
71 private readonly nameOverride?: string,
72 ) {
70 makeAutoObservable<GraphStore, 'editorStore'>(this, { 73 makeAutoObservable<GraphStore, 'editorStore'>(this, {
71 editorStore: false, 74 editorStore: false,
72 semantics: observable.ref, 75 semantics: observable.ref,
@@ -190,4 +193,8 @@ export default class GraphStore {
190 get colorNodes(): boolean { 193 get colorNodes(): boolean {
191 return this.editorStore.colorIdentifiers; 194 return this.editorStore.colorIdentifiers;
192 } 195 }
196
197 get name(): string {
198 return this.nameOverride ?? this.editorStore.simpleNameOrFallback;
199 }
193} 200}
diff --git a/subprojects/frontend/src/graph/export/ExportSettingsStore.tsx b/subprojects/frontend/src/graph/export/ExportSettingsStore.ts
index 53a161ab..53a161ab 100644
--- a/subprojects/frontend/src/graph/export/ExportSettingsStore.tsx
+++ b/subprojects/frontend/src/graph/export/ExportSettingsStore.ts
diff --git a/subprojects/frontend/src/graph/export/exportDiagram.tsx b/subprojects/frontend/src/graph/export/exportDiagram.tsx
index cd374d23..44489d28 100644
--- a/subprojects/frontend/src/graph/export/exportDiagram.tsx
+++ b/subprojects/frontend/src/graph/export/exportDiagram.tsx
@@ -340,7 +340,7 @@ export default async function exportDiagram(
340 340
341 if (settings.format === 'pdf') { 341 if (settings.format === 'pdf') {
342 const pdf = await serializePDF(copyOfSVG, settings); 342 const pdf = await serializePDF(copyOfSVG, settings);
343 await saveBlob(pdf, 'graph.pdf', { 343 await saveBlob(pdf, `${graph.name}.pdf`, {
344 id: EXPORT_ID, 344 id: EXPORT_ID,
345 types: [ 345 types: [
346 { 346 {
@@ -359,7 +359,7 @@ export default async function exportDiagram(
359 if (mode === 'copy') { 359 if (mode === 'copy') {
360 await copyBlob(png); 360 await copyBlob(png);
361 } else { 361 } else {
362 await saveBlob(png, 'graph.png', { 362 await saveBlob(png, `${graph.name}.png`, {
363 id: EXPORT_ID, 363 id: EXPORT_ID,
364 types: [ 364 types: [
365 { 365 {
@@ -374,7 +374,7 @@ export default async function exportDiagram(
374 } else if (mode === 'copy') { 374 } else if (mode === 'copy') {
375 await copyBlob(serializedSVG); 375 await copyBlob(serializedSVG);
376 } else { 376 } else {
377 await saveBlob(serializedSVG, 'graph.svg', { 377 await saveBlob(serializedSVG, `${graph.name}.svg`, {
378 id: EXPORT_ID, 378 id: EXPORT_ID,
379 types: [ 379 types: [
380 { 380 {
diff --git a/subprojects/frontend/src/utils/fileIO.ts b/subprojects/frontend/src/utils/fileIO.ts
index 4f376882..fe0b1fbb 100644
--- a/subprojects/frontend/src/utils/fileIO.ts
+++ b/subprojects/frontend/src/utils/fileIO.ts
@@ -4,11 +4,67 @@
4 * SPDX-License-Identifier: EPL-2.0 4 * SPDX-License-Identifier: EPL-2.0
5 */ 5 */
6 6
7export interface OpenResult {
8 name: string;
9 handle: FileSystemFileHandle | undefined;
10}
11
12export interface OpenTextFileResult extends OpenResult {
13 text: string;
14}
15
16export async function openTextFile(
17 options: FilePickerOptions,
18): Promise<OpenTextFileResult> {
19 let file: File;
20 let handle: FileSystemFileHandle | undefined;
21 if ('showOpenFilePicker' in window) {
22 [handle] = await window.showOpenFilePicker(options);
23 if (handle === undefined) {
24 throw new Error('No file was selected');
25 }
26 file = await handle.getFile();
27 } else {
28 const input = document.createElement('input');
29 input.type = 'file';
30 file = await new Promise((resolve, reject) => {
31 input.addEventListener('change', () => {
32 const { files } = input;
33 const result = files?.item(0);
34 if (result) {
35 resolve(result);
36 } else {
37 reject(new Error('No file was selected'));
38 }
39 });
40 input.click();
41 });
42 }
43 const text = await file.text();
44 return {
45 name: file.name,
46 text,
47 handle,
48 };
49}
50
51export async function saveTextFile(
52 handle: FileSystemFileHandle,
53 text: string,
54): Promise<void> {
55 const writable = await handle.createWritable();
56 try {
57 await writable.write(text);
58 } finally {
59 await writable.close();
60 }
61}
62
7export async function saveBlob( 63export async function saveBlob(
8 blob: Blob, 64 blob: Blob,
9 name: string, 65 name: string,
10 options: FilePickerOptions, 66 options: FilePickerOptions,
11): Promise<void> { 67): Promise<OpenResult | undefined> {
12 if ('showSaveFilePicker' in window) { 68 if ('showSaveFilePicker' in window) {
13 const handle = await window.showSaveFilePicker({ 69 const handle = await window.showSaveFilePicker({
14 ...options, 70 ...options,
@@ -20,7 +76,10 @@ export async function saveBlob(
20 } finally { 76 } finally {
21 await writable.close(); 77 await writable.close();
22 } 78 }
23 return; 79 return {
80 name: handle.name,
81 handle,
82 };
24 } 83 }
25 const link = document.createElement('a'); 84 const link = document.createElement('a');
26 const url = window.URL.createObjectURL(blob); 85 const url = window.URL.createObjectURL(blob);
@@ -31,6 +90,7 @@ export async function saveBlob(
31 } finally { 90 } finally {
32 window.URL.revokeObjectURL(url); 91 window.URL.revokeObjectURL(url);
33 } 92 }
93 return undefined;
34} 94}
35 95
36export async function copyBlob(blob: Blob): Promise<void> { 96export async function copyBlob(blob: Blob): Promise<void> {
diff --git a/subprojects/frontend/types/filesystemAccess.d.ts b/subprojects/frontend/types/filesystemAccess.d.ts
index 000cd2a5..e9accc77 100644
--- a/subprojects/frontend/types/filesystemAccess.d.ts
+++ b/subprojects/frontend/types/filesystemAccess.d.ts
@@ -5,7 +5,6 @@
5 */ 5 */
6 6
7interface FilePickerOptions { 7interface FilePickerOptions {
8 suggestedName?: string;
9 id?: string; 8 id?: string;
10 types?: { 9 types?: {
11 description?: string; 10 description?: string;
@@ -13,11 +12,29 @@ interface FilePickerOptions {
13 }[]; 12 }[];
14} 13}
15 14
15interface FilePickerSaveOptions extends FilePickerOptions {
16 suggestedName?: string;
17}
18
16interface Window { 19interface Window {
17 showOpenFilePicker?: ( 20 showOpenFilePicker?: (
18 options?: FilePickerOptions, 21 options?: FilePickerOpenOptions,
19 ) => Promise<FileSystemFileHandle>; 22 ) => Promise<FileSystemFileHandle[]>;
20 showSaveFilePicker?: ( 23 showSaveFilePicker?: (
21 options?: FilePickerOptions, 24 options?: FilePickerSaveOptions,
22 ) => Promise<FileSystemFileHandle>; 25 ) => Promise<FileSystemFileHandle>;
23} 26}
27
28interface FileSystemHandlePermissionDescriptor {
29 mode?: 'read' | 'readwrite';
30}
31
32interface FileSystemHandle {
33 queryPermission?: (
34 options?: FileSystemHandlePermissionDescriptor,
35 ) => Promise<PermissionStatus>;
36
37 requestPermission?: (
38 options?: FileSystemHandlePermissionDescriptor,
39 ) => Promise<PermissionStatus>;
40}