Persist state in localStorage for installed PWAs

This commit is contained in:
ochafik 2023-03-28 01:12:14 +01:00
commit 8c53927f53
9 changed files with 90 additions and 21 deletions

View file

@ -18,6 +18,7 @@ Licenses: see [LICENSES](./LICENSE).
- Autocomplete of 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
- 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
@ -31,7 +32,7 @@ Licenses: see [LICENSES](./LICENSE).
- 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)
- 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
- 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)

View file

@ -1,7 +1,7 @@
// Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING.
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 EditorPanel from './EditorPanel';
import ViewerPanel from './ViewerPanel';
@ -13,10 +13,10 @@ import PanelSwitcher from './PanelSwitcher';
// import "primereact/resources/primereact.min.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 model = new Model(fs, state, setState);
const model = new Model(fs, state, setState, statePersister);
useEffect(() => model.init());
const zIndexOfPanelsDependingOnFocus = {

View file

@ -5,6 +5,8 @@ import { Button } from 'primereact/button';
import { MenuItem } from 'primereact/menuitem';
import { Menu } from 'primereact/menu';
import { ModelContext } from './contexts';
import { isInStandaloneMode } from '../utils';
import { ConfirmDialog, confirmDialog } from 'primereact/confirmdialog';
export default function SettingsMenu({className, style}: {className?: string, style?: CSSProperties}) {
const model = useContext(ModelContext);
@ -44,7 +46,34 @@ export default function SettingsMenu({className, style}: {className?: string, st
// disabled: true,
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} />
<ConfirmDialog />
<Button title="Settings menu"
style={style}
className={className}

View file

@ -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 librariesMounts = await getBrowserFSLibrariesMounts(archiveNames);
const allMounts: FSMounts = {};
@ -103,7 +103,9 @@ export async function createEditorFS(prefix: string): Promise<FS> {
...allMounts,
}
},
writable: {
writable: allowPersistence ? {
fs: "LocalStorage",
} : {
fs: "InMemory"
},
},

View file

@ -11,7 +11,10 @@ import { createInitialState } from './state/initial-state';
import './index.css';
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');
if (process.env.NODE_ENV !== 'production') {
@ -21,20 +24,48 @@ if (process.env.NODE_ENV !== 'production') {
debug.disable();
}
declare var BrowserFS: BrowserFSInterface
(async () => {
registerCustomAppHeightCSSProperty();
const fs = await createEditorFS('/');
const fs = await createEditorFS({prefix: '/', allowPersistence: isInStandaloneMode});
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(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<App initialState={initialState} fs={fs} />
<App initialState={initialState} statePersister={statePersister} fs={fs} />
</React.StrictMode>
);
})();

View file

@ -33,7 +33,7 @@ addEventListener('message', async (e) => {
});
// This will mount lots of libraries' ZIP archives under /libraries/<name> -> <name>.zip
await createEditorFS('');
await createEditorFS({prefix: '', allowPersistence: false});
instance.FS.mkdir('/libraries');

View file

@ -50,4 +50,8 @@ export interface State {
},
};
export interface StatePersister {
set(state: State): void;
}
export {}

View file

@ -1,18 +1,13 @@
// Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING.
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 { writeStateInFragment } from "./fragment-state";
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 {
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() {
@ -24,7 +19,7 @@ export class Model {
private setState(state: State) {
this.state = state;
writeStateInFragment(state);
this.statePersister && this.statePersister.set(state);
this.setStateCallback && this.setStateCallback(state);
}

View file

@ -96,3 +96,10 @@ export function registerCustomAppHeightCSSProperty() {
window.addEventListener('resize', 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));