diff --git a/src/examples/proteopedia-wrapper/changelog.md b/src/examples/proteopedia-wrapper/changelog.md new file mode 100644 index 0000000000000000000000000000000000000000..041ecacd3454949a8be9e03ad2a6284c5b9f8381 --- /dev/null +++ b/src/examples/proteopedia-wrapper/changelog.md @@ -0,0 +1,7 @@ +== v2.0 == + +* Changed how state saving works. + +== v1.0 == + +* Initial version. \ No newline at end of file diff --git a/src/examples/proteopedia-wrapper/index.ts b/src/examples/proteopedia-wrapper/index.ts index 90e8be0838d3e73ef1ad6d2c9bcbe7779f612a8b..bc61ea58db05031fa87ed55adb675cca6d12619d 100644 --- a/src/examples/proteopedia-wrapper/index.ts +++ b/src/examples/proteopedia-wrapper/index.ts @@ -22,7 +22,8 @@ import { PluginState } from 'mol-plugin/state'; require('mol-plugin/skin/light.scss') class MolStarProteopediaWrapper { - static VERSION_MAJOR = 1; + static VERSION_MAJOR = 2; + static VERSION_MINOR = 0; private _ev = RxEventHelper.create(); diff --git a/src/mol-plugin/behavior/static/state.ts b/src/mol-plugin/behavior/static/state.ts index 747f37008634749b13248fa8a3c53e76981bf5cb..6d28606edd90159610d37359df45cea1bf140af0 100644 --- a/src/mol-plugin/behavior/static/state.ts +++ b/src/mol-plugin/behavior/static/state.ts @@ -127,14 +127,15 @@ export function Snapshots(ctx: PluginContext) { ctx.state.snapshots.remove(id); }); - PluginCommands.State.Snapshots.Add.subscribe(ctx, ({ name, description }) => { - const entry = PluginStateSnapshotManager.Entry(ctx.state.getSnapshot(), name, description); + PluginCommands.State.Snapshots.Add.subscribe(ctx, ({ name, description, params }) => { + const entry = PluginStateSnapshotManager.Entry(ctx.state.getSnapshot(params), name, description); ctx.state.snapshots.add(entry); }); PluginCommands.State.Snapshots.Apply.subscribe(ctx, ({ id }) => { - const e = ctx.state.snapshots.getEntry(id); - return ctx.state.setSnapshot(e.snapshot); + const snapshot = ctx.state.snapshots.setCurrent(id); + if (!snapshot) return; + return ctx.state.setSnapshot(snapshot); }); PluginCommands.State.Snapshots.Upload.subscribe(ctx, ({ name, description, serverUrl }) => { @@ -143,14 +144,15 @@ export function Snapshots(ctx: PluginContext) { mode: 'cors', referrer: 'no-referrer', 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>; }); PluginCommands.State.Snapshots.Fetch.subscribe(ctx, async ({ url }) => { - const req = await fetch(url, { referrer: 'no-referrer' }); - const json = await req.json(); - return ctx.state.setSnapshot(json.data); + const json = await ctx.runTask(ctx.fetch({ url, type: 'json' })); // fetch(url, { referrer: 'no-referrer' }); + const current = ctx.state.snapshots.setRemoteSnapshot(json.data); + if (!current) return; + return ctx.state.setSnapshot(current); }); PluginCommands.State.Snapshots.DownloadToFile.subscribe(ctx, ({ name }) => { diff --git a/src/mol-plugin/command.ts b/src/mol-plugin/command.ts index 530e721e2f381fd56cff42ef51dd83a57dbd27a2..29e8929f1ac8d5840eb3520fa1a2bb72abc8ac8e 100644 --- a/src/mol-plugin/command.ts +++ b/src/mol-plugin/command.ts @@ -10,6 +10,7 @@ import { StateTransform, State, StateAction } from 'mol-state'; import { Canvas3DProps } from 'mol-canvas3d/canvas3d'; import { PluginLayoutStateProps } from './layout'; import { StructureElement } from 'mol-model/structure'; +import { PluginState } from './state'; export * from './command/base'; @@ -27,7 +28,7 @@ export const PluginCommands = { ClearHighlight: PluginCommand<{ state: State, ref: StateTransform.Ref }>({ isImmediate: true }), 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 }), Apply: PluginCommand<{ id: string }>({ isImmediate: true }), Clear: PluginCommand<{}>({ isImmediate: true }), diff --git a/src/mol-plugin/state.ts b/src/mol-plugin/state.ts index 20319010c4432b928be18139d0029fe72743d629..28ca1986e8724f8482c4de17802479dd2fdd67e8 100644 --- a/src/mol-plugin/state.ts +++ b/src/mol-plugin/state.ts @@ -42,7 +42,7 @@ class PluginState { } } - getSnapshot(params?: Partial<PluginState.GetSnapshotParams>): PluginState.Snapshot { + getSnapshot(params?: PluginState.GetSnapshotParams): PluginState.Snapshot { const p = { ...PluginState.DefaultGetSnapshotParams, ...params }; return { id: UUID.create22(), @@ -119,7 +119,7 @@ namespace PluginState { cameraSnapshots: PD.Boolean(false), 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 interface Snapshot { diff --git a/src/mol-plugin/ui/plugin.tsx b/src/mol-plugin/ui/plugin.tsx index f9732a9af6d2899c6c77fb3f0bdee2b6cfae1534..f5753b50ef232c494f482cfdae94506d8f4af755 100644 --- a/src/mol-plugin/ui/plugin.tsx +++ b/src/mol-plugin/ui/plugin.tsx @@ -11,7 +11,6 @@ import { LogEntry } from 'mol-util/log-entry'; import * as React from 'react'; import { PluginContext } from '../context'; import { PluginReactContext, PluginUIComponent } from './base'; -import { CameraSnapshots } from './camera'; import { LociLabelControl, TrajectoryControls } from './controls'; import { StateSnapshots } from './state'; import { StateObjectActions } from './state/actions'; @@ -75,7 +74,7 @@ export class ControlsWrapper extends PluginUIComponent { return <div className='msp-scrollable-container msp-right-controls'> <CurrentObject /> <AnimationControls /> - <CameraSnapshots /> + {/* <CameraSnapshots /> */} <StateSnapshots /> </div>; } diff --git a/src/mol-plugin/ui/state.tsx b/src/mol-plugin/ui/state.tsx index aaadc4b777a978dc6f8740e120cf4ddf0ac28c8a..132d5ea2c7c3343949f894cb25c22daca4ac67b5 100644 --- a/src/mol-plugin/ui/state.tsx +++ b/src/mol-plugin/ui/state.tsx @@ -6,63 +6,63 @@ import { PluginCommands } from 'mol-plugin/command'; import * as React from 'react'; -import { PluginUIComponent } from './base'; +import { PluginUIComponent, PurePluginUIComponent } from './base'; import { shallowEqual } from 'mol-util'; -import { List } from 'immutable'; +import { OrderedMap } from 'immutable'; import { ParameterControls } from './controls/parameters'; 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 }> { - state = { serverUrl: 'https://webchem.ncbr.muni.cz/molstar-state' } - - updateServerUrl = (serverUrl: string) => { this.setState({ serverUrl }) }; +export class StateSnapshots extends PluginUIComponent<{ }> { render() { return <div> - <div className='msp-section-header'>State Snapshots</div> - <StateSnapshotControls serverUrl={this.state.serverUrl} serverChanged={this.updateServerUrl} /> + <div className='msp-section-header'>State</div> + <StateSnapshotControls /> <LocalStateSnapshotList /> - <RemoteStateSnapshotList serverUrl={this.state.serverUrl} /> + <RemoteStateSnapshots /> </div>; } } -// TODO: this is not nice: device some custom event system. -const UploadedEvent = new Subject(); +class StateSnapshotControls extends PluginUIComponent< + { }, + { 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 = { name: '', description: '', serverUrl: this.props.serverUrl, isUploading: false }; + state = { params: PD.getDefaultValues(StateSnapshotControls.Params) }; static Params = { name: PD.Text(), - description: PD.Text(), - serverUrl: PD.Text() - } + options: PD.Group({ + description: PD.Text(), + ...PluginState.GetSnapshotParams + }) + }; add = () => { - PluginCommands.State.Snapshots.Add.dispatch(this.plugin, { name: this.state.name, description: this.state.description }); - this.setState({ name: '', description: '' }) + PluginCommands.State.Snapshots.Add.dispatch(this.plugin, { name: this.state.params.name, description: this.state.params.options.description }); + this.setState({ + params: { + name: '', + options: { + ...this.state.params.options, + description: '' + } + } + }); } clear = () => { 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); } - upload = async () => { - this.setState({ isUploading: true }); - 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 }); + downloadToFile = () => { + PluginCommands.State.Snapshots.DownloadToFile.dispatch(this.plugin, { name: this.state.params.name }); } open = (e: React.ChangeEvent<HTMLInputElement>) => { @@ -72,23 +72,24 @@ class StateSnapshotControls extends PluginUIComponent<{ serverUrl: string, serve } render() { + // TODO: proper styling return <div> - <ParameterControls params={StateSnapshotControls.Params} values={this.state} onEnter={this.add} onChange={p => { - this.setState({ [p.name]: p.value } as any); - if (p.name === 'serverUrl') this.props.serverChanged(p.value); + <div className='msp-btn-row-group' style={{ marginBottom: '10px' }}> + <button className='msp-btn msp-btn-block msp-form-control' onClick={this.downloadToFile}>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> + + <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'> - <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.upload} disabled={this.state.isUploading}>Upload</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.clear}>Clear</button> </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>; } } @@ -109,9 +110,12 @@ class LocalStateSnapshotList extends PluginUIComponent<{ }, { }> { } render() { + const current = this.plugin.state.snapshots.state.current; return <ul style={{ listStyle: 'none' }} className='msp-state-list'> {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'> <span className='msp-icon msp-icon-remove' /> </button> @@ -121,59 +125,139 @@ class LocalStateSnapshotList extends PluginUIComponent<{ }, { }> { } 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 }> { - state = { entries: List<RemoteEntry>(), isFetching: false }; +class RemoteStateSnapshots extends PluginUIComponent< + { }, + { 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() { - this.subscribe(this.plugin.events.state.snapshots.changed, () => this.forceUpdate()); 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 () => { try { - this.setState({ isFetching: true }); - const req = await fetch(`${this.props.serverUrl}/list`); - const json: RemoteEntry[] = await req.json(); - this.setState({ - entries: List<RemoteEntry>(json.map((e: RemoteEntry) => ({ + this.setState({ isBusy: true }); + const json = await this.plugin.runTask<RemoteEntry[]>(this.plugin.fetch({ url: this.serverUrl('list'), type: 'json' })); + const entries = OrderedMap<string, RemoteEntry>().asMutable(); + for (const e of json) { + entries.set(e.id, { ...e, - url: `${this.props.serverUrl}/get/${e.id}`, - removeUrl: `${this.props.serverUrl}/remove/${e.id}` - }))), - isFetching: false }) + url: this.serverUrl(`get/${e.id}`), + removeUrl: this.serverUrl(`remove/${e.id}`) + }); + } + + this.setState({ entries: entries.asImmutable(), isBusy: false }) } catch (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) { - return () => PluginCommands.State.Snapshots.Fetch.dispatch(this.plugin, { url }); + upload = async () => { + 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 }); + } + + await PluginCommands.State.Snapshots.Upload.dispatch(this.plugin, { + name: this.state.params.name, + description: this.state.params.options.description, + serverUrl: this.state.params.options.serverUrl + }); + this.setState({ isBusy: false }); + this.plugin.log.message('Snapshot uploaded.'); + this.refresh(); } - remove(url: string) { - return async () => { - this.setState({ entries: List() }); - try { - await fetch(url); - } catch { } - 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() { 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> - - <ul style={{ listStyle: 'none' }} className='msp-state-list'> - {this.state.entries.valueSeq().map(e =><li key={e!.id}> - <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 onClick={this.remove(e!.removeUrl)} className='msp-btn msp-btn-link msp-state-list-remove-button'> - <span className='msp-icon msp-icon-remove' /> - </button> - </li>)} - </ul> + <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}/> + + <div className='msp-btn-row-group'> + <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.refresh} disabled={this.state.isBusy}>Refresh</button> + </div> + + <RemoteStateSnapshotList entries={this.state.entries} isBusy={this.state.isBusy} serverUrl={this.state.params.options.serverUrl} + fetch={this.fetch} remove={this.remove} /> </div>; } -} \ No newline at end of file +} + +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' /> + </button> + </li>)} + </ul>; + } +} diff --git a/src/mol-util/data-source.ts b/src/mol-util/data-source.ts index 316403070d3fbd5b9170bc18c1ddc02bc71ded3f..4bcc68286d484651f91421fcd241037e1a102b20 100644 --- a/src/mol-util/data-source.ts +++ b/src/mol-util/data-source.ts @@ -34,6 +34,7 @@ export function readFromFile(file: File, type: 'string' | 'binary') { return <Task<Uint8Array | string>>readFromFileInternal(file, type === 'binary'); } +// TODO: support for no-referrer export function ajaxGet(url: string): Task<string> export function ajaxGet(params: AjaxGetParams<'string'>): Task<string> export function ajaxGet(params: AjaxGetParams<'binary'>): Task<Uint8Array>