Persist state in localStorage for installed PWAs
This commit is contained in:
parent
39edf6d28b
commit
8c53927f53
9 changed files with 90 additions and 21 deletions
|
|
@ -18,6 +18,7 @@ Licenses: see [LICENSES](./LICENSE).
|
||||||
- Autocomplete of imports
|
- Autocomplete of imports
|
||||||
- Autocomplete of symbols / function calls (pseudo-parses file and its transitive imports)
|
- Autocomplete of symbols / function calls (pseudo-parses file and its transitive imports)
|
||||||
- Responsive layout (but editing on iOS is still a pain, will address that soon). On small screens editor and viewer are stacked onto each other, while on larger screens they can be side-by-side
|
- Responsive layout (but editing on iOS is still a pain, will address that soon). On small screens editor and viewer are stacked onto each other, while on larger screens they can be side-by-side
|
||||||
|
- Installable as a PWA (then persists edits in localStorage instead of the hash fragment). On iOS just open the sharing panel and tap "Add to Home Screen".
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
|
|
@ -31,7 +32,7 @@ Licenses: see [LICENSES](./LICENSE).
|
||||||
- Better names for downloads (matching source)
|
- Better names for downloads (matching source)
|
||||||
- Model /home fs in shared state. have two clear paths: /libraries for builtins, and /home for user data. State pointing to /libraries paths needs not store the data except if there's overrides (flagged as modifications in the file picker)
|
- Model /home fs in shared state. have two clear paths: /libraries for builtins, and /home for user data. State pointing to /libraries paths needs not store the data except if there's overrides (flagged as modifications in the file picker)
|
||||||
- Drag and drop of files (SCAD, STL, etc) and Zip archives. For assets, auto insert the corresponding import.
|
- Drag and drop of files (SCAD, STL, etc) and Zip archives. For assets, auto insert the corresponding import.
|
||||||
- Proper PWA w/ File opening / association to *.scad files
|
- Fuller PWA support w/ link Sharing, File opening / association to *.scad files...
|
||||||
- Look into accessibility
|
- Look into accessibility
|
||||||
- Setup [OPENSCADPATH](https://en.wikibooks.org/wiki/OpenSCAD_User_Manual/Libraries#Setting_OPENSCADPATH) env var w/ Emscripten to ensure examples that include assets / import local files will run fine.
|
- Setup [OPENSCADPATH](https://en.wikibooks.org/wiki/OpenSCAD_User_Manual/Libraries#Setting_OPENSCADPATH) env var w/ Emscripten to ensure examples that include assets / import local files will run fine.
|
||||||
- Bundle more examples (ask users to contribute)
|
- Bundle more examples (ask users to contribute)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING.
|
// Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING.
|
||||||
|
|
||||||
import React, { CSSProperties, useEffect, useState } from 'react';
|
import React, { CSSProperties, useEffect, useState } from 'react';
|
||||||
import {MultiLayoutComponentId, State} from '../state/app-state'
|
import {MultiLayoutComponentId, State, StatePersister} from '../state/app-state'
|
||||||
import { Model } from '../state/model';
|
import { Model } from '../state/model';
|
||||||
import EditorPanel from './EditorPanel';
|
import EditorPanel from './EditorPanel';
|
||||||
import ViewerPanel from './ViewerPanel';
|
import ViewerPanel from './ViewerPanel';
|
||||||
|
|
@ -13,10 +13,10 @@ import PanelSwitcher from './PanelSwitcher';
|
||||||
// import "primereact/resources/primereact.min.css";
|
// import "primereact/resources/primereact.min.css";
|
||||||
// import "primeicons/primeicons.css";
|
// import "primeicons/primeicons.css";
|
||||||
|
|
||||||
export function App({initialState, fs}: {initialState: State, fs: FS}) {
|
export function App({initialState, statePersister, fs}: {initialState: State, statePersister: StatePersister, fs: FS}) {
|
||||||
const [state, setState] = useState(initialState);
|
const [state, setState] = useState(initialState);
|
||||||
|
|
||||||
const model = new Model(fs, state, setState);
|
const model = new Model(fs, state, setState, statePersister);
|
||||||
useEffect(() => model.init());
|
useEffect(() => model.init());
|
||||||
|
|
||||||
const zIndexOfPanelsDependingOnFocus = {
|
const zIndexOfPanelsDependingOnFocus = {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import { Button } from 'primereact/button';
|
||||||
import { MenuItem } from 'primereact/menuitem';
|
import { MenuItem } from 'primereact/menuitem';
|
||||||
import { Menu } from 'primereact/menu';
|
import { Menu } from 'primereact/menu';
|
||||||
import { ModelContext } from './contexts';
|
import { ModelContext } from './contexts';
|
||||||
|
import { isInStandaloneMode } from '../utils';
|
||||||
|
import { ConfirmDialog, confirmDialog } from 'primereact/confirmdialog';
|
||||||
|
|
||||||
export default function SettingsMenu({className, style}: {className?: string, style?: CSSProperties}) {
|
export default function SettingsMenu({className, style}: {className?: string, style?: CSSProperties}) {
|
||||||
const model = useContext(ModelContext);
|
const model = useContext(ModelContext);
|
||||||
|
|
@ -44,7 +46,34 @@ export default function SettingsMenu({className, style}: {className?: string, st
|
||||||
// disabled: true,
|
// disabled: true,
|
||||||
command: () => model.mutate(s => s.view.lineNumbers = !s.view.lineNumbers)
|
command: () => model.mutate(s => s.view.lineNumbers = !s.view.lineNumbers)
|
||||||
},
|
},
|
||||||
|
...(isInStandaloneMode ? [
|
||||||
|
{
|
||||||
|
separator: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Clear local storage',
|
||||||
|
icon: 'pi pi-list',
|
||||||
|
// disabled: true,
|
||||||
|
command: () => {
|
||||||
|
confirmDialog({
|
||||||
|
message: "This will clear all the edits you've made and files you've created in this playground " +
|
||||||
|
"and will reset it to factory defaults. " +
|
||||||
|
"Are you sure you wish to proceed? (you might lose your models!)",
|
||||||
|
header: 'Clear local storage',
|
||||||
|
icon: 'pi pi-exclamation-triangle',
|
||||||
|
accept: () => {
|
||||||
|
localStorage.clear();
|
||||||
|
location.reload();
|
||||||
|
},
|
||||||
|
acceptLabel: `Clear all files!`,
|
||||||
|
rejectLabel: 'Cancel'
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] : []),
|
||||||
] as MenuItem[]} popup ref={settingsMenu} />
|
] as MenuItem[]} popup ref={settingsMenu} />
|
||||||
|
|
||||||
|
<ConfirmDialog />
|
||||||
<Button title="Settings menu"
|
<Button title="Settings menu"
|
||||||
style={style}
|
style={style}
|
||||||
className={className}
|
className={className}
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ function configureAndInstallFS(windowOrSelf: Window, options: any) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createEditorFS(prefix: string): Promise<FS> {
|
export async function createEditorFS({prefix, allowPersistence}: {prefix: string, allowPersistence: boolean}): Promise<FS> {
|
||||||
const archiveNames = deployedArchiveNames;
|
const archiveNames = deployedArchiveNames;
|
||||||
const librariesMounts = await getBrowserFSLibrariesMounts(archiveNames);
|
const librariesMounts = await getBrowserFSLibrariesMounts(archiveNames);
|
||||||
const allMounts: FSMounts = {};
|
const allMounts: FSMounts = {};
|
||||||
|
|
@ -103,7 +103,9 @@ export async function createEditorFS(prefix: string): Promise<FS> {
|
||||||
...allMounts,
|
...allMounts,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
writable: {
|
writable: allowPersistence ? {
|
||||||
|
fs: "LocalStorage",
|
||||||
|
} : {
|
||||||
fs: "InMemory"
|
fs: "InMemory"
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,10 @@ import { createInitialState } from './state/initial-state';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
import debug from 'debug';
|
import debug from 'debug';
|
||||||
import { registerCustomAppHeightCSSProperty } from './utils';
|
import { isInStandaloneMode, registerCustomAppHeightCSSProperty } from './utils';
|
||||||
|
import { State, StatePersister } from './state/app-state';
|
||||||
|
import { writeStateInFragment } from "./state/fragment-state";
|
||||||
|
|
||||||
const log = debug('app:log');
|
const log = debug('app:log');
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== 'production') {
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
|
@ -21,20 +24,48 @@ if (process.env.NODE_ENV !== 'production') {
|
||||||
debug.disable();
|
debug.disable();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare var BrowserFS: BrowserFSInterface
|
||||||
|
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
registerCustomAppHeightCSSProperty();
|
registerCustomAppHeightCSSProperty();
|
||||||
|
|
||||||
const fs = await createEditorFS('/');
|
const fs = await createEditorFS({prefix: '/', allowPersistence: isInStandaloneMode});
|
||||||
|
|
||||||
await registerOpenSCADLanguage(fs, '/', zipArchives);
|
await registerOpenSCADLanguage(fs, '/', zipArchives);
|
||||||
|
|
||||||
const initialState = createInitialState(fs, readStateFromFragment());
|
let statePersister: StatePersister;
|
||||||
|
let persistedState: State | null = null;
|
||||||
|
|
||||||
|
if (isInStandaloneMode) {
|
||||||
|
const fs: FS = BrowserFS.BFSRequire('fs')
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(new TextDecoder("utf-8").decode(fs.readFileSync('/state.json')));
|
||||||
|
const {view, params} = data
|
||||||
|
persistedState = {view, params};
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Failed to read the persisted state from local storage.', e)
|
||||||
|
}
|
||||||
|
statePersister = {
|
||||||
|
set: ({view, params}) => {
|
||||||
|
fs.writeFile('/state.json', JSON.stringify({view, params}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
persistedState = readStateFromFragment();
|
||||||
|
statePersister = {
|
||||||
|
set: writeStateInFragment,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState = createInitialState(fs, persistedState);
|
||||||
|
|
||||||
const root = ReactDOM.createRoot(
|
const root = ReactDOM.createRoot(
|
||||||
document.getElementById('root') as HTMLElement
|
document.getElementById('root') as HTMLElement
|
||||||
);
|
);
|
||||||
root.render(
|
root.render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App initialState={initialState} fs={fs} />
|
<App initialState={initialState} statePersister={statePersister} fs={fs} />
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ addEventListener('message', async (e) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// This will mount lots of libraries' ZIP archives under /libraries/<name> -> <name>.zip
|
// This will mount lots of libraries' ZIP archives under /libraries/<name> -> <name>.zip
|
||||||
await createEditorFS('');
|
await createEditorFS({prefix: '', allowPersistence: false});
|
||||||
|
|
||||||
instance.FS.mkdir('/libraries');
|
instance.FS.mkdir('/libraries');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,4 +50,8 @@ export interface State {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface StatePersister {
|
||||||
|
set(state: State): void;
|
||||||
|
}
|
||||||
|
|
||||||
export {}
|
export {}
|
||||||
|
|
@ -1,18 +1,13 @@
|
||||||
// Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING.
|
// Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING.
|
||||||
|
|
||||||
import { checkSyntax, render, RenderArgs, RenderOutput } from "../runner/actions";
|
import { checkSyntax, render, RenderArgs, RenderOutput } from "../runner/actions";
|
||||||
import { MultiLayoutComponentId, SingleLayoutComponentId, State } from "./app-state";
|
import { MultiLayoutComponentId, SingleLayoutComponentId, State, StatePersister } from "./app-state";
|
||||||
import { bubbleUpDeepMutations } from "./deep-mutate";
|
import { bubbleUpDeepMutations } from "./deep-mutate";
|
||||||
import { writeStateInFragment } from "./fragment-state";
|
|
||||||
import { formatBytes, formatMillis } from '../utils'
|
import { formatBytes, formatMillis } from '../utils'
|
||||||
import { getParentDir } from "../fs/filesystem";
|
|
||||||
|
|
||||||
// export const isFileWritable = (path: string) => !path.startsWith('/');
|
|
||||||
// export const isFileWritable = (path: string) => getParentDir(path) === '/home';
|
|
||||||
// export const isFileWritable = (path: string) => !path.startsWith('/libraries/');
|
|
||||||
|
|
||||||
export class Model {
|
export class Model {
|
||||||
constructor(private fs: FS, public state: State, private setStateCallback?: (state: State) => void) {
|
constructor(private fs: FS, public state: State, private setStateCallback?: (state: State) => void,
|
||||||
|
private statePersister?: StatePersister) {
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
|
@ -24,7 +19,7 @@ export class Model {
|
||||||
|
|
||||||
private setState(state: State) {
|
private setState(state: State) {
|
||||||
this.state = state;
|
this.state = state;
|
||||||
writeStateInFragment(state);
|
this.statePersister && this.statePersister.set(state);
|
||||||
this.setStateCallback && this.setStateCallback(state);
|
this.setStateCallback && this.setStateCallback(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -95,4 +95,11 @@ export function registerCustomAppHeightCSSProperty() {
|
||||||
}
|
}
|
||||||
window.addEventListener('resize', updateAppHeight)
|
window.addEventListener('resize', updateAppHeight)
|
||||||
updateAppHeight();
|
updateAppHeight();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// In PWA mode, persist files in LocalStorage instead of the hash fragment.
|
||||||
|
export const isInStandaloneMode =
|
||||||
|
// true
|
||||||
|
Boolean(('standalone' in window.navigator) && (window.navigator.standalone));
|
||||||
|
|
||||||
|
|
||||||
Loading…
Reference in a new issue