diff options
author | Kristóf Marussy <kristof@marussy.com> | 2024-02-24 18:55:05 +0100 |
---|---|---|
committer | Kristóf Marussy <kristof@marussy.com> | 2024-02-24 18:55:05 +0100 |
commit | e3ba54260a73acaca1d36fed54179668f446fc88 (patch) | |
tree | 4c0aa1c4fc80855bf2718f10cd00c4a0ae801278 | |
parent | fix(web): CSP for SVG rasterization (diff) | |
download | refinery-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.ts | 28 | ||||
-rw-r--r-- | subprojects/frontend/src/TopBar.tsx | 15 | ||||
-rw-r--r-- | subprojects/frontend/src/editor/EditorButtons.tsx | 30 | ||||
-rw-r--r-- | subprojects/frontend/src/editor/EditorStore.ts | 124 | ||||
-rw-r--r-- | subprojects/frontend/src/editor/GeneratedModelStore.ts | 5 | ||||
-rw-r--r-- | subprojects/frontend/src/editor/createEditorState.ts | 15 | ||||
-rw-r--r-- | subprojects/frontend/src/graph/GraphStore.ts | 9 | ||||
-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.tsx | 6 | ||||
-rw-r--r-- | subprojects/frontend/src/utils/fileIO.ts | 64 | ||||
-rw-r--r-- | subprojects/frontend/types/filesystemAccess.d.ts | 25 |
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 | ||
7 | import { getLogger } from 'loglevel'; | 7 | import { getLogger } from 'loglevel'; |
8 | import { makeAutoObservable, runInAction } from 'mobx'; | 8 | import { |
9 | IReactionDisposer, | ||
10 | autorun, | ||
11 | makeAutoObservable, | ||
12 | runInAction, | ||
13 | } from 'mobx'; | ||
9 | 14 | ||
10 | import PWAStore from './PWAStore'; | 15 | import PWAStore from './PWAStore'; |
11 | import type EditorStore from './editor/EditorStore'; | 16 | import 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 | ||
88 | const 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 | |||
88 | export default observer(function TopBar(): JSX.Element { | 98 | export 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 @@ | |||
7 | import type { Diagnostic } from '@codemirror/lint'; | 7 | import type { Diagnostic } from '@codemirror/lint'; |
8 | import CancelIcon from '@mui/icons-material/Cancel'; | 8 | import CancelIcon from '@mui/icons-material/Cancel'; |
9 | import CheckIcon from '@mui/icons-material/Check'; | 9 | import CheckIcon from '@mui/icons-material/Check'; |
10 | import FileOpenIcon from '@mui/icons-material/FileOpen'; | ||
10 | import FormatListNumberedIcon from '@mui/icons-material/FormatListNumbered'; | 11 | import FormatListNumberedIcon from '@mui/icons-material/FormatListNumbered'; |
11 | import FormatPaintIcon from '@mui/icons-material/FormatPaint'; | 12 | import FormatPaintIcon from '@mui/icons-material/FormatPaint'; |
12 | import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; | 13 | import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; |
13 | import LooksIcon from '@mui/icons-material/Looks'; | 14 | import LooksIcon from '@mui/icons-material/Looks'; |
14 | import RedoIcon from '@mui/icons-material/Redo'; | 15 | import RedoIcon from '@mui/icons-material/Redo'; |
16 | import SaveIcon from '@mui/icons-material/Save'; | ||
17 | import SaveAsIcon from '@mui/icons-material/SaveAs'; | ||
15 | import SearchIcon from '@mui/icons-material/Search'; | 18 | import SearchIcon from '@mui/icons-material/Search'; |
16 | import UndoIcon from '@mui/icons-material/Undo'; | 19 | import UndoIcon from '@mui/icons-material/Undo'; |
17 | import WarningIcon from '@mui/icons-material/Warning'; | 20 | import 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 | ||
28 | import type PWAStore from '../PWAStore'; | 28 | import type PWAStore from '../PWAStore'; |
29 | import GraphStore from '../graph/GraphStore'; | 29 | import GraphStore from '../graph/GraphStore'; |
30 | import { | ||
31 | type OpenResult, | ||
32 | type OpenTextFileResult, | ||
33 | openTextFile, | ||
34 | saveTextFile, | ||
35 | saveBlob, | ||
36 | } from '../utils/fileIO'; | ||
30 | import getLogger from '../utils/getLogger'; | 37 | import getLogger from '../utils/getLogger'; |
31 | import type XtextClient from '../xtext/XtextClient'; | 38 | import type XtextClient from '../xtext/XtextClient'; |
32 | import type { SemanticsSuccessResult } from '../xtext/xtextServiceResults'; | 39 | import type { SemanticsSuccessResult } from '../xtext/xtextServiceResults'; |
@@ -35,7 +42,10 @@ import EditorErrors from './EditorErrors'; | |||
35 | import GeneratedModelStore from './GeneratedModelStore'; | 42 | import GeneratedModelStore from './GeneratedModelStore'; |
36 | import LintPanelStore from './LintPanelStore'; | 43 | import LintPanelStore from './LintPanelStore'; |
37 | import SearchPanelStore from './SearchPanelStore'; | 44 | import SearchPanelStore from './SearchPanelStore'; |
38 | import createEditorState from './createEditorState'; | 45 | import createEditorState, { |
46 | createHistoryExtension, | ||
47 | historyCompartment, | ||
48 | } from './createEditorState'; | ||
39 | import { countDiagnostics } from './exposeDiagnostics'; | 49 | import { countDiagnostics } from './exposeDiagnostics'; |
40 | import { type IOccurrence, setOccurrences } from './findOccurrences'; | 50 | import { type IOccurrence, setOccurrences } from './findOccurrences'; |
41 | import { | 51 | import { |
@@ -45,6 +55,25 @@ import { | |||
45 | 55 | ||
46 | const log = getLogger('editor.EditorStore'); | 56 | const log = getLogger('editor.EditorStore'); |
47 | 57 | ||
58 | const REFINERY_CONTENT_TYPE = 'text/x-refinery'; | ||
59 | |||
60 | const 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 | |||
48 | export default class EditorStore { | 77 | export 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'; |
27 | import { lintKeymap, lintGutter } from '@codemirror/lint'; | 27 | import { lintKeymap, lintGutter } from '@codemirror/lint'; |
28 | import { search, searchKeymap } from '@codemirror/search'; | 28 | import { search, searchKeymap } from '@codemirror/search'; |
29 | import { EditorState } from '@codemirror/state'; | 29 | import { Compartment, EditorState, type Extension } from '@codemirror/state'; |
30 | import { | 30 | import { |
31 | drawSelection, | 31 | drawSelection, |
32 | highlightActiveLine, | 32 | highlightActiveLine, |
@@ -46,6 +46,12 @@ import exposeDiagnostics from './exposeDiagnostics'; | |||
46 | import findOccurrences from './findOccurrences'; | 46 | import findOccurrences from './findOccurrences'; |
47 | import semanticHighlighting from './semanticHighlighting'; | 47 | import semanticHighlighting from './semanticHighlighting'; |
48 | 48 | ||
49 | export const historyCompartment = new Compartment(); | ||
50 | |||
51 | export function createHistoryExtension(): Extension { | ||
52 | return history(); | ||
53 | } | ||
54 | |||
49 | export default function createEditorState( | 55 | export 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 | ||
7 | export interface OpenResult { | ||
8 | name: string; | ||
9 | handle: FileSystemFileHandle | undefined; | ||
10 | } | ||
11 | |||
12 | export interface OpenTextFileResult extends OpenResult { | ||
13 | text: string; | ||
14 | } | ||
15 | |||
16 | export 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 | |||
51 | export 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 | |||
7 | export async function saveBlob( | 63 | export 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 | ||
36 | export async function copyBlob(blob: Blob): Promise<void> { | 96 | export 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 | ||
7 | interface FilePickerOptions { | 7 | interface 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 | ||
15 | interface FilePickerSaveOptions extends FilePickerOptions { | ||
16 | suggestedName?: string; | ||
17 | } | ||
18 | |||
16 | interface Window { | 19 | interface 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 | |||
28 | interface FileSystemHandlePermissionDescriptor { | ||
29 | mode?: 'read' | 'readwrite'; | ||
30 | } | ||
31 | |||
32 | interface FileSystemHandle { | ||
33 | queryPermission?: ( | ||
34 | options?: FileSystemHandlePermissionDescriptor, | ||
35 | ) => Promise<PermissionStatus>; | ||
36 | |||
37 | requestPermission?: ( | ||
38 | options?: FileSystemHandlePermissionDescriptor, | ||
39 | ) => Promise<PermissionStatus>; | ||
40 | } | ||