Skip to content
Snippets Groups Projects
Commit 5cebfa18 authored by David Sehnal's avatar David Sehnal
Browse files

mol-plugin: refactored state saving

parent 907672dc
No related branches found
No related tags found
No related merge requests found
== v2.0 ==
* Changed how state saving works.
== v1.0 ==
* Initial version.
\ No newline at end of file
...@@ -22,7 +22,8 @@ import { PluginState } from 'mol-plugin/state'; ...@@ -22,7 +22,8 @@ import { PluginState } from 'mol-plugin/state';
require('mol-plugin/skin/light.scss') require('mol-plugin/skin/light.scss')
class MolStarProteopediaWrapper { class MolStarProteopediaWrapper {
static VERSION_MAJOR = 1; static VERSION_MAJOR = 2;
static VERSION_MINOR = 0;
private _ev = RxEventHelper.create(); private _ev = RxEventHelper.create();
......
...@@ -127,14 +127,15 @@ export function Snapshots(ctx: PluginContext) { ...@@ -127,14 +127,15 @@ export function Snapshots(ctx: PluginContext) {
ctx.state.snapshots.remove(id); ctx.state.snapshots.remove(id);
}); });
PluginCommands.State.Snapshots.Add.subscribe(ctx, ({ name, description }) => { PluginCommands.State.Snapshots.Add.subscribe(ctx, ({ name, description, params }) => {
const entry = PluginStateSnapshotManager.Entry(ctx.state.getSnapshot(), name, description); const entry = PluginStateSnapshotManager.Entry(ctx.state.getSnapshot(params), name, description);
ctx.state.snapshots.add(entry); ctx.state.snapshots.add(entry);
}); });
PluginCommands.State.Snapshots.Apply.subscribe(ctx, ({ id }) => { PluginCommands.State.Snapshots.Apply.subscribe(ctx, ({ id }) => {
const e = ctx.state.snapshots.getEntry(id); const snapshot = ctx.state.snapshots.setCurrent(id);
return ctx.state.setSnapshot(e.snapshot); if (!snapshot) return;
return ctx.state.setSnapshot(snapshot);
}); });
PluginCommands.State.Snapshots.Upload.subscribe(ctx, ({ name, description, serverUrl }) => { PluginCommands.State.Snapshots.Upload.subscribe(ctx, ({ name, description, serverUrl }) => {
...@@ -143,14 +144,15 @@ export function Snapshots(ctx: PluginContext) { ...@@ -143,14 +144,15 @@ export function Snapshots(ctx: PluginContext) {
mode: 'cors', mode: 'cors',
referrer: 'no-referrer', referrer: 'no-referrer',
headers: { 'Content-Type': 'application/json; charset=utf-8' }, headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify(ctx.state.getSnapshot()) body: JSON.stringify(ctx.state.snapshots.getRemoteSnapshot())
}) as any as Promise<void>; }) as any as Promise<void>;
}); });
PluginCommands.State.Snapshots.Fetch.subscribe(ctx, async ({ url }) => { PluginCommands.State.Snapshots.Fetch.subscribe(ctx, async ({ url }) => {
const req = await fetch(url, { referrer: 'no-referrer' }); const json = await ctx.runTask(ctx.fetch({ url, type: 'json' })); // fetch(url, { referrer: 'no-referrer' });
const json = await req.json(); const current = ctx.state.snapshots.setRemoteSnapshot(json.data);
return ctx.state.setSnapshot(json.data); if (!current) return;
return ctx.state.setSnapshot(current);
}); });
PluginCommands.State.Snapshots.DownloadToFile.subscribe(ctx, ({ name }) => { PluginCommands.State.Snapshots.DownloadToFile.subscribe(ctx, ({ name }) => {
......
...@@ -10,6 +10,7 @@ import { StateTransform, State, StateAction } from 'mol-state'; ...@@ -10,6 +10,7 @@ import { StateTransform, State, StateAction } from 'mol-state';
import { Canvas3DProps } from 'mol-canvas3d/canvas3d'; import { Canvas3DProps } from 'mol-canvas3d/canvas3d';
import { PluginLayoutStateProps } from './layout'; import { PluginLayoutStateProps } from './layout';
import { StructureElement } from 'mol-model/structure'; import { StructureElement } from 'mol-model/structure';
import { PluginState } from './state';
export * from './command/base'; export * from './command/base';
...@@ -27,7 +28,7 @@ export const PluginCommands = { ...@@ -27,7 +28,7 @@ export const PluginCommands = {
ClearHighlight: PluginCommand<{ state: State, ref: StateTransform.Ref }>({ isImmediate: true }), ClearHighlight: PluginCommand<{ state: State, ref: StateTransform.Ref }>({ isImmediate: true }),
Snapshots: { Snapshots: {
Add: PluginCommand<{ name?: string, description?: string }>({ isImmediate: true }), Add: PluginCommand<{ name?: string, description?: string, params?: PluginState.GetSnapshotParams }>({ isImmediate: true }),
Remove: PluginCommand<{ id: string }>({ isImmediate: true }), Remove: PluginCommand<{ id: string }>({ isImmediate: true }),
Apply: PluginCommand<{ id: string }>({ isImmediate: true }), Apply: PluginCommand<{ id: string }>({ isImmediate: true }),
Clear: PluginCommand<{}>({ isImmediate: true }), Clear: PluginCommand<{}>({ isImmediate: true }),
......
...@@ -42,7 +42,7 @@ class PluginState { ...@@ -42,7 +42,7 @@ class PluginState {
} }
} }
getSnapshot(params?: Partial<PluginState.GetSnapshotParams>): PluginState.Snapshot { getSnapshot(params?: PluginState.GetSnapshotParams): PluginState.Snapshot {
const p = { ...PluginState.DefaultGetSnapshotParams, ...params }; const p = { ...PluginState.DefaultGetSnapshotParams, ...params };
return { return {
id: UUID.create22(), id: UUID.create22(),
...@@ -119,7 +119,7 @@ namespace PluginState { ...@@ -119,7 +119,7 @@ namespace PluginState {
cameraSnapshots: PD.Boolean(false), cameraSnapshots: PD.Boolean(false),
cameraTranstionStyle: PD.Select<CameraTransitionStyle>('animate', [['animate', 'Animate'], ['instant', 'Instant']]) cameraTranstionStyle: PD.Select<CameraTransitionStyle>('animate', [['animate', 'Animate'], ['instant', 'Instant']])
}; };
export type GetSnapshotParams = PD.Value<typeof GetSnapshotParams> export type GetSnapshotParams = Partial<PD.Value<typeof GetSnapshotParams>>
export const DefaultGetSnapshotParams = PD.getDefaultValues(GetSnapshotParams); export const DefaultGetSnapshotParams = PD.getDefaultValues(GetSnapshotParams);
export interface Snapshot { export interface Snapshot {
......
...@@ -11,7 +11,6 @@ import { LogEntry } from 'mol-util/log-entry'; ...@@ -11,7 +11,6 @@ import { LogEntry } from 'mol-util/log-entry';
import * as React from 'react'; import * as React from 'react';
import { PluginContext } from '../context'; import { PluginContext } from '../context';
import { PluginReactContext, PluginUIComponent } from './base'; import { PluginReactContext, PluginUIComponent } from './base';
import { CameraSnapshots } from './camera';
import { LociLabelControl, TrajectoryControls } from './controls'; import { LociLabelControl, TrajectoryControls } from './controls';
import { StateSnapshots } from './state'; import { StateSnapshots } from './state';
import { StateObjectActions } from './state/actions'; import { StateObjectActions } from './state/actions';
...@@ -75,7 +74,7 @@ export class ControlsWrapper extends PluginUIComponent { ...@@ -75,7 +74,7 @@ export class ControlsWrapper extends PluginUIComponent {
return <div className='msp-scrollable-container msp-right-controls'> return <div className='msp-scrollable-container msp-right-controls'>
<CurrentObject /> <CurrentObject />
<AnimationControls /> <AnimationControls />
<CameraSnapshots /> {/* <CameraSnapshots /> */}
<StateSnapshots /> <StateSnapshots />
</div>; </div>;
} }
......
...@@ -6,63 +6,63 @@ ...@@ -6,63 +6,63 @@
import { PluginCommands } from 'mol-plugin/command'; import { PluginCommands } from 'mol-plugin/command';
import * as React from 'react'; import * as React from 'react';
import { PluginUIComponent } from './base'; import { PluginUIComponent, PurePluginUIComponent } from './base';
import { shallowEqual } from 'mol-util'; import { shallowEqual } from 'mol-util';
import { List } from 'immutable'; import { OrderedMap } from 'immutable';
import { ParameterControls } from './controls/parameters'; import { ParameterControls } from './controls/parameters';
import { ParamDefinition as PD} from 'mol-util/param-definition'; import { ParamDefinition as PD} from 'mol-util/param-definition';
import { Subject } from 'rxjs'; import { PluginState } from 'mol-plugin/state';
import { urlCombine } from 'mol-util/url';
export class StateSnapshots extends PluginUIComponent<{ }, { serverUrl: string }> { export class StateSnapshots extends PluginUIComponent<{ }> {
state = { serverUrl: 'https://webchem.ncbr.muni.cz/molstar-state' }
updateServerUrl = (serverUrl: string) => { this.setState({ serverUrl }) };
render() { render() {
return <div> return <div>
<div className='msp-section-header'>State Snapshots</div> <div className='msp-section-header'>State</div>
<StateSnapshotControls serverUrl={this.state.serverUrl} serverChanged={this.updateServerUrl} /> <StateSnapshotControls />
<LocalStateSnapshotList /> <LocalStateSnapshotList />
<RemoteStateSnapshotList serverUrl={this.state.serverUrl} /> <RemoteStateSnapshots />
</div>; </div>;
} }
} }
// TODO: this is not nice: device some custom event system. class StateSnapshotControls extends PluginUIComponent<
const UploadedEvent = new Subject(); { },
{ params: PD.Values<typeof StateSnapshotControls.Params> }> {
class StateSnapshotControls extends PluginUIComponent<{ serverUrl: string, serverChanged: (url: string) => void }, { name: string, description: string, serverUrl: string, isUploading: boolean }> { state = { params: PD.getDefaultValues(StateSnapshotControls.Params) };
state = { name: '', description: '', serverUrl: this.props.serverUrl, isUploading: false };
static Params = { static Params = {
name: PD.Text(), name: PD.Text(),
options: PD.Group({
description: PD.Text(), description: PD.Text(),
serverUrl: PD.Text() ...PluginState.GetSnapshotParams
} })
};
add = () => { add = () => {
PluginCommands.State.Snapshots.Add.dispatch(this.plugin, { name: this.state.name, description: this.state.description }); PluginCommands.State.Snapshots.Add.dispatch(this.plugin, { name: this.state.params.name, description: this.state.params.options.description });
this.setState({ name: '', description: '' }) this.setState({
params: {
name: '',
options: {
...this.state.params.options,
description: ''
}
}
});
} }
clear = () => { clear = () => {
PluginCommands.State.Snapshots.Clear.dispatch(this.plugin, {}); PluginCommands.State.Snapshots.Clear.dispatch(this.plugin, {});
} }
shouldComponentUpdate(nextProps: { serverUrl: string, serverChanged: (url: string) => void }, nextState: { name: string, description: string, serverUrl: string, isUploading: boolean }) { shouldComponentUpdate(nextProps: any, nextState: any) {
return !shallowEqual(this.props, nextProps) || !shallowEqual(this.state, nextState); return !shallowEqual(this.props, nextProps) || !shallowEqual(this.state, nextState);
} }
upload = async () => { downloadToFile = () => {
this.setState({ isUploading: true }); PluginCommands.State.Snapshots.DownloadToFile.dispatch(this.plugin, { name: this.state.params.name });
await PluginCommands.State.Snapshots.Upload.dispatch(this.plugin, { name: this.state.name, description: this.state.description, serverUrl: this.state.serverUrl });
this.setState({ isUploading: false });
this.plugin.log.message('Snapshot uploaded.');
UploadedEvent.next();
}
download = () => {
PluginCommands.State.Snapshots.DownloadToFile.dispatch(this.plugin, { name: this.state.name });
} }
open = (e: React.ChangeEvent<HTMLInputElement>) => { open = (e: React.ChangeEvent<HTMLInputElement>) => {
...@@ -72,23 +72,24 @@ class StateSnapshotControls extends PluginUIComponent<{ serverUrl: string, serve ...@@ -72,23 +72,24 @@ class StateSnapshotControls extends PluginUIComponent<{ serverUrl: string, serve
} }
render() { render() {
// TODO: proper styling
return <div> return <div>
<ParameterControls params={StateSnapshotControls.Params} values={this.state} onEnter={this.add} onChange={p => { <div className='msp-btn-row-group' style={{ marginBottom: '10px' }}>
this.setState({ [p.name]: p.value } as any); <button className='msp-btn msp-btn-block msp-form-control' onClick={this.downloadToFile}>Download JSON</button>
if (p.name === 'serverUrl') this.props.serverChanged(p.value); <div className='msp-btn msp-btn-block msp-btn-action msp-loader-msp-btn-file'>
{'Open JSON'} <input onChange={this.open} type='file' multiple={false} accept='.json' />
</div>
</div>
<ParameterControls params={StateSnapshotControls.Params} values={this.state.params} onEnter={this.add} onChange={p => {
this.setState({ params: { ...this.state.params, [p.name]: p.value } } as any);
}}/> }}/>
<div className='msp-btn-row-group'> <div className='msp-btn-row-group'>
<button className='msp-btn msp-btn-block msp-form-control' onClick={this.add}>Add Local</button> <button className='msp-btn msp-btn-block msp-form-control' onClick={this.add}>Save</button>
<button className='msp-btn msp-btn-block msp-form-control' onClick={this.upload} disabled={this.state.isUploading}>Upload</button> {/* <button className='msp-btn msp-btn-block msp-form-control' onClick={this.upload} disabled={this.state.isUploading}>Upload</button> */}
<button className='msp-btn msp-btn-block msp-form-control' onClick={this.clear}>Clear</button> <button className='msp-btn msp-btn-block msp-form-control' onClick={this.clear}>Clear</button>
</div> </div>
<div className='msp-btn-row-group'>
<button className='msp-btn msp-btn-block msp-form-control' onClick={this.download}>Download JSON</button>
<div className='msp-btn msp-btn-block msp-btn-action msp-loader-msp-btn-file'>
{'Open JSON'} <input onChange={this.open} type='file' multiple={false} accept='.json' />
</div>
</div>
</div>; </div>;
} }
} }
...@@ -109,9 +110,12 @@ class LocalStateSnapshotList extends PluginUIComponent<{ }, { }> { ...@@ -109,9 +110,12 @@ class LocalStateSnapshotList extends PluginUIComponent<{ }, { }> {
} }
render() { render() {
const current = this.plugin.state.snapshots.state.current;
return <ul style={{ listStyle: 'none' }} className='msp-state-list'> return <ul style={{ listStyle: 'none' }} className='msp-state-list'>
{this.plugin.state.snapshots.state.entries.valueSeq().map(e =><li key={e!.snapshot.id}> {this.plugin.state.snapshots.state.entries.valueSeq().map(e =><li key={e!.snapshot.id}>
<button className='msp-btn msp-btn-block msp-form-control' onClick={this.apply(e!.snapshot.id)}>{e!.name || new Date(e!.timestamp).toLocaleString()} <small>{e!.description}</small></button> <button className='msp-btn msp-btn-block msp-form-control' onClick={this.apply(e!.snapshot.id)}>
<span style={{ fontWeight: e!.snapshot.id === current ? 'bold' : void 0}}>{e!.name || new Date(e!.timestamp).toLocaleString()}</span> <small>{e!.description}</small>
</button>
<button onClick={this.remove(e!.snapshot.id)} className='msp-btn msp-btn-link msp-state-list-remove-button'> <button onClick={this.remove(e!.snapshot.id)} className='msp-btn msp-btn-link msp-state-list-remove-button'>
<span className='msp-icon msp-icon-remove' /> <span className='msp-icon msp-icon-remove' />
</button> </button>
...@@ -121,59 +125,139 @@ class LocalStateSnapshotList extends PluginUIComponent<{ }, { }> { ...@@ -121,59 +125,139 @@ class LocalStateSnapshotList extends PluginUIComponent<{ }, { }> {
} }
type RemoteEntry = { url: string, removeUrl: string, timestamp: number, id: string, name: string, description: string } type RemoteEntry = { url: string, removeUrl: string, timestamp: number, id: string, name: string, description: string }
class RemoteStateSnapshotList extends PluginUIComponent<{ serverUrl: string }, { entries: List<RemoteEntry>, isFetching: boolean }> { class RemoteStateSnapshots extends PluginUIComponent<
state = { entries: List<RemoteEntry>(), isFetching: false }; { },
{ params: PD.Values<typeof RemoteStateSnapshots.Params>, entries: OrderedMap<string, RemoteEntry>, isBusy: boolean }> {
state = { params: PD.getDefaultValues(RemoteStateSnapshots.Params), entries: OrderedMap<string, RemoteEntry>(), isBusy: false };
static Params = {
name: PD.Text(),
options: PD.Group({
description: PD.Text(),
serverUrl: PD.Text('https://webchem.ncbr.muni.cz/molstar-state')
})
};
componentDidMount() { componentDidMount() {
this.subscribe(this.plugin.events.state.snapshots.changed, () => this.forceUpdate());
this.refresh(); this.refresh();
this.subscribe(UploadedEvent, this.refresh); // this.subscribe(UploadedEvent, this.refresh);
}
serverUrl(q?: string) {
if (!q) return this.state.params.options.serverUrl;
return urlCombine(this.state.params.options.serverUrl, q);
} }
refresh = async () => { refresh = async () => {
try { try {
this.setState({ isFetching: true }); this.setState({ isBusy: true });
const req = await fetch(`${this.props.serverUrl}/list`); const json = await this.plugin.runTask<RemoteEntry[]>(this.plugin.fetch({ url: this.serverUrl('list'), type: 'json' }));
const json: RemoteEntry[] = await req.json(); const entries = OrderedMap<string, RemoteEntry>().asMutable();
this.setState({ for (const e of json) {
entries: List<RemoteEntry>(json.map((e: RemoteEntry) => ({ entries.set(e.id, {
...e, ...e,
url: `${this.props.serverUrl}/get/${e.id}`, url: this.serverUrl(`get/${e.id}`),
removeUrl: `${this.props.serverUrl}/remove/${e.id}` removeUrl: this.serverUrl(`remove/${e.id}`)
}))), });
isFetching: false }) }
this.setState({ entries: entries.asImmutable(), isBusy: false })
} catch (e) { } catch (e) {
this.plugin.log.error('Fetching Remote Snapshots: ' + e); this.plugin.log.error('Fetching Remote Snapshots: ' + e);
this.setState({ entries: List<RemoteEntry>(), isFetching: false }) this.setState({ entries: OrderedMap(), isBusy: false })
} }
} }
fetch(url: string) { upload = async () => {
return () => PluginCommands.State.Snapshots.Fetch.dispatch(this.plugin, { url }); this.setState({ isBusy: true });
if (this.plugin.state.snapshots.state.entries.size === 0) {
await PluginCommands.State.Snapshots.Add.dispatch(this.plugin, { name: this.state.params.name, description: this.state.params.options.description });
} }
remove(url: string) { await PluginCommands.State.Snapshots.Upload.dispatch(this.plugin, {
return async () => { name: this.state.params.name,
this.setState({ entries: List() }); description: this.state.params.options.description,
try { serverUrl: this.state.params.options.serverUrl
await fetch(url); });
} catch { } this.setState({ isBusy: false });
this.plugin.log.message('Snapshot uploaded.');
this.refresh(); this.refresh();
} }
fetch = async (e: React.MouseEvent<HTMLElement>) => {
const id = e.currentTarget.getAttribute('data-id');
if (!id) return;
const entry = this.state.entries.get(id);
if (!entry) return;
this.setState({ isBusy: true });
try {
await PluginCommands.State.Snapshots.Fetch.dispatch(this.plugin, { url: entry.url });
} finally {
this.setState({ isBusy: false });
}
}
remove = async (e: React.MouseEvent<HTMLElement>) => {
const id = e.currentTarget.getAttribute('data-id');
if (!id) return;
const entry = this.state.entries.get(id);
if (!entry) return;
this.setState({ entries: this.state.entries.remove(id) });
try {
await fetch(entry.removeUrl);
} catch { }
} }
render() { render() {
return <div> return <div>
<button title='Click to Refresh' style={{fontWeight: 'bold'}} className='msp-btn msp-btn-block msp-form-control msp-section-header' onClick={this.refresh} disabled={this.state.isFetching}>↻ Remote Snapshots</button> <div className='msp-section-header'>Remote State</div>
<ParameterControls params={RemoteStateSnapshots.Params} values={this.state.params} onEnter={this.upload} onChange={p => {
this.setState({ params: { ...this.state.params, [p.name]: p.value } } as any);
}} isDisabled={this.state.isBusy}/>
<ul style={{ listStyle: 'none' }} className='msp-state-list'> <div className='msp-btn-row-group'>
{this.state.entries.valueSeq().map(e =><li key={e!.id}> <button className='msp-btn msp-btn-block msp-form-control' onClick={this.upload} disabled={this.state.isBusy}>Upload</button>
<button className='msp-btn msp-btn-block msp-form-control' onClick={this.fetch(e!.url)}>{e!.name || e!.timestamp} <small>{e!.description}</small></button> <button className='msp-btn msp-btn-block msp-form-control' onClick={this.refresh} disabled={this.state.isBusy}>Refresh</button>
<button onClick={this.remove(e!.removeUrl)} className='msp-btn msp-btn-link msp-state-list-remove-button'> </div>
<RemoteStateSnapshotList entries={this.state.entries} isBusy={this.state.isBusy} serverUrl={this.state.params.options.serverUrl}
fetch={this.fetch} remove={this.remove} />
</div>;
}
}
class RemoteStateSnapshotList extends PurePluginUIComponent<
{ entries: OrderedMap<string, RemoteEntry>, serverUrl: string, isBusy: boolean, fetch: (e: React.MouseEvent<HTMLElement>) => void, remove: (e: React.MouseEvent<HTMLElement>) => void },
{ }> {
open = async (e: React.MouseEvent<HTMLElement>) => {
const id = e.currentTarget.getAttribute('data-id');
if (!id) return;
const entry = this.props.entries.get(id);
if (!entry) return;
e.preventDefault();
let url = `${window.location}`, qi = url.indexOf('?');
if (qi > 0) url = url.substr(0, qi);
window.open(`${url}?snapshot-url=${encodeURIComponent(entry.url)}`, '_blank');
}
render() {
return <ul style={{ listStyle: 'none' }} className='msp-state-list'>
{this.props.entries.valueSeq().map(e =><li key={e!.id}>
<button data-id={e!.id} className='msp-btn msp-btn-block msp-form-control' onClick={this.props.fetch}
disabled={this.props.isBusy} onContextMenu={this.open} title='Click to download, right-click to open in a new tab.'>
{e!.name || new Date(e!.timestamp).toLocaleString()} <small>{e!.description}</small>
</button>
<button data-id={e!.id} onClick={this.props.remove} className='msp-btn msp-btn-link msp-state-list-remove-button' disabled={this.props.isBusy}>
<span className='msp-icon msp-icon-remove' /> <span className='msp-icon msp-icon-remove' />
</button> </button>
</li>)} </li>)}
</ul> </ul>;
</div>;
} }
} }
...@@ -34,6 +34,7 @@ export function readFromFile(file: File, type: 'string' | 'binary') { ...@@ -34,6 +34,7 @@ export function readFromFile(file: File, type: 'string' | 'binary') {
return <Task<Uint8Array | string>>readFromFileInternal(file, type === 'binary'); return <Task<Uint8Array | string>>readFromFileInternal(file, type === 'binary');
} }
// TODO: support for no-referrer
export function ajaxGet(url: string): Task<string> export function ajaxGet(url: string): Task<string>
export function ajaxGet(params: AjaxGetParams<'string'>): Task<string> export function ajaxGet(params: AjaxGetParams<'string'>): Task<string>
export function ajaxGet(params: AjaxGetParams<'binary'>): Task<Uint8Array> export function ajaxGet(params: AjaxGetParams<'binary'>): Task<Uint8Array>
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment