diff --git a/CHANGELOG.md b/CHANGELOG.md index 699af760ab352300b81cf8b826f03678dd4935bb..d1a7d2302271eddd3fca0d1dcf3dc2df5a981e6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Note that since we don't clearly distinguish between a public and private interf - Add `NtC tube` visual. Applicable for structures with NtC annotation - [Breaking] Rename `DnatcoConfalPyramids` to `DnatcoNtCs` - Improve boundary calculation performance +- Add option to create & include images in state snapshots ## [v3.29.0] - 2023-01-15 diff --git a/src/mol-plugin-state/manager/snapshots.ts b/src/mol-plugin-state/manager/snapshots.ts index 1b9f6cc33b6badd8ac8f9a9d2c2c0b8af4e205f8..4b3dd6c0e333de02c79e44ce68d75d8f67a8286d 100644 --- a/src/mol-plugin-state/manager/snapshots.ts +++ b/src/mol-plugin-state/manager/snapshots.ts @@ -1,5 +1,5 @@ /** - * Copyright (c) 2018-2021 mol* contributors, licensed under MIT, See LICENSE file for more info. + * Copyright (c) 2018-2023 mol* contributors, licensed under MIT, See LICENSE file for more info. * * @author David Sehnal <david.sehnal@gmail.com> * @author Alexander Rose <alexander.rose@weirdbyte.de> @@ -16,6 +16,7 @@ import { Zip } from '../../mol-util/zip/zip'; import { readFromFile } from '../../mol-util/data-source'; import { objectForEach } from '../../mol-util/object'; import { PLUGIN_VERSION } from '../../mol-plugin/version'; +import { canvasToBlob } from '../../mol-canvas3d/util'; export { PluginStateSnapshotManager }; @@ -46,6 +47,7 @@ class PluginStateSnapshotManager extends StatefulPluginComponent<{ const e = this.entryMap.get(id); if (!e) return; + if (e?.image) this.plugin.managers.asset.delete(e.image); this.entryMap.delete(id); this.updateState({ current: this.state.current === id ? void 0 : this.state.current, @@ -60,15 +62,17 @@ class PluginStateSnapshotManager extends StatefulPluginComponent<{ this.events.changed.next(void 0); } - replace(id: string, snapshot: PluginState.Snapshot) { + replace(id: string, snapshot: PluginState.Snapshot, params?: PluginStateSnapshotManager.EntryParams) { const old = this.getEntry(id); if (!old) return; + if (old?.image) this.plugin.managers.asset.delete(old.image); const idx = this.getIndex(old); // The id changes here! const e = PluginStateSnapshotManager.Entry(snapshot, { - name: old.name, - description: old.description + name: params?.name ?? old.name, + description: params?.description ?? old.description, + image: params?.image, }); this.entryMap.set(snapshot.id, e); this.updateState({ current: e.snapshot.id, entries: this.state.entries.set(idx, e) }); @@ -96,6 +100,10 @@ class PluginStateSnapshotManager extends StatefulPluginComponent<{ clear() { if (this.state.entries.size === 0) return; + + this.entryMap.forEach(e => { + if (e?.image) this.plugin.managers.asset.delete(e.image); + }); this.entryMap.clear(); this.updateState({ current: void 0, entries: List<PluginStateSnapshotManager.Entry>() }); this.events.changed.next(void 0); @@ -162,18 +170,22 @@ class PluginStateSnapshotManager extends StatefulPluginComponent<{ return next; } - private syncCurrent(options?: { name?: string, description?: string, params?: PluginState.SnapshotParams }) { + private async syncCurrent(options?: { name?: string, description?: string, params?: PluginState.SnapshotParams }) { const snapshot = this.plugin.state.getSnapshot(options?.params); if (this.state.entries.size === 0 || !this.state.current) { this.add(PluginStateSnapshotManager.Entry(snapshot, { name: options?.name, description: options?.description })); } else { - this.replace(this.state.current, snapshot); + const current = this.getEntry(this.state.current); + if (current?.image) this.plugin.managers.asset.delete(current.image); + const image = (options?.params?.image ?? this.plugin.state.snapshotParams.value.image) ? await PluginStateSnapshotManager.getCanvasImageAsset(this.plugin, `${snapshot.id}-image.png`) : undefined; + // TODO: this replaces the current snapshot which is not always intended + this.replace(this.state.current, snapshot, { image }); } } - getStateSnapshot(options?: { name?: string, description?: string, playOnLoad?: boolean, params?: PluginState.SnapshotParams }): PluginStateSnapshotManager.StateSnapshot { + async getStateSnapshot(options?: { name?: string, description?: string, playOnLoad?: boolean, params?: PluginState.SnapshotParams }): Promise<PluginStateSnapshotManager.StateSnapshot> { // TODO: diffing and all that fancy stuff - this.syncCurrent(options); + await this.syncCurrent(options); return { timestamp: +new Date(), @@ -190,7 +202,7 @@ class PluginStateSnapshotManager extends StatefulPluginComponent<{ } async serialize(options?: { type: 'json' | 'molj' | 'zip' | 'molx', params?: PluginState.SnapshotParams }) { - const json = JSON.stringify(this.getStateSnapshot({ params: options?.params }), null, 2); + const json = JSON.stringify(await this.getStateSnapshot({ params: options?.params }), null, 2); if (!options?.type || options.type === 'json' || options.type === 'molj') { return new Blob([json], { type: 'application/json;charset=utf-8' }); @@ -326,14 +338,18 @@ class PluginStateSnapshotManager extends StatefulPluginComponent<{ } namespace PluginStateSnapshotManager { - export interface Entry { - timestamp: number, + export interface EntryParams { name?: string, description?: string, + image?: Asset + } + + export interface Entry extends EntryParams { + timestamp: number, snapshot: PluginState.Snapshot } - export function Entry(snapshot: PluginState.Snapshot, params: { name?: string, description?: string }): Entry { + export function Entry(snapshot: PluginState.Snapshot, params: EntryParams): Entry { return { timestamp: +new Date(), snapshot, ...params }; } @@ -354,4 +370,17 @@ namespace PluginStateSnapshotManager { }, entries: Entry[] } + + export async function getCanvasImageAsset(ctx: PluginContext, name: string): Promise<Asset | undefined> { + if (!ctx.helpers.viewportScreenshot) return; + + const p = ctx.helpers.viewportScreenshot.getPreview(512); + if (!p) return; + + const blob = await canvasToBlob(p.canvas, 'png'); + const file = new File([blob], name); + const image: Asset = { kind: 'file', id: UUID.create22(), name }; + ctx.managers.asset.set(image, file); + return image; + } } \ No newline at end of file diff --git a/src/mol-plugin-ui/skin/base/components/misc.scss b/src/mol-plugin-ui/skin/base/components/misc.scss index 5ecb17a7fe8a38ceee581604ca8133d8345574fa..7bd123281b747279a407663069b172516b699e0e 100644 --- a/src/mol-plugin-ui/skin/base/components/misc.scss +++ b/src/mol-plugin-ui/skin/base/components/misc.scss @@ -324,6 +324,26 @@ } } +.msp-state-image-row { + @extend .msp-flex-row; + + height: $state-image-height; + margin-top: 0px; + + > button { + height: $state-image-height; + padding: 0px; + + > img { + min-height: $state-image-height; + width: inherit; + transform: translateY(-50%); + top: 50%; + position: relative; + } + } +} + .msp-tree-row { position: relative; margin-top: 0; diff --git a/src/mol-plugin-ui/skin/base/variables.scss b/src/mol-plugin-ui/skin/base/variables.scss index 505fd52a188b67fd7c958abbfb4839c261eb29d7..8afd5a97f43ed0fb0a3a9c9c830febc6f703909c 100644 --- a/src/mol-plugin-ui/skin/base/variables.scss +++ b/src/mol-plugin-ui/skin/base/variables.scss @@ -83,4 +83,7 @@ $entity-tag-color: color-lower-contrast($font-color, 20%); // sequence $sequence-background: $default-background; -$sequence-number-color: $hover-font-color; \ No newline at end of file +$sequence-number-color: $hover-font-color; + +// state +$state-image-height: 96px; \ No newline at end of file diff --git a/src/mol-plugin-ui/state/snapshots.tsx b/src/mol-plugin-ui/state/snapshots.tsx index 6d8757b3723dfec52592827073e3adfac16dbb97..78c554dbc5daf8a3dd732aa6dd49b42f58fb8ffc 100644 --- a/src/mol-plugin-ui/state/snapshots.tsx +++ b/src/mol-plugin-ui/state/snapshots.tsx @@ -1,7 +1,8 @@ /** - * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info. + * Copyright (c) 2018-2023 mol* contributors, licensed under MIT, See LICENSE file for more info. * * @author David Sehnal <david.sehnal@gmail.com> + * @author Alexander Rose <alexander.rose@weirdbyte.de> */ import { OrderedMap } from 'immutable'; @@ -160,6 +161,7 @@ export class LocalStateSnapshotList extends PluginUIComponent<{}, {}> { }; replace = (e: React.MouseEvent<HTMLElement>) => { + // TODO: add option change name/description const id = e.currentTarget.getAttribute('data-id'); if (!id) return; PluginCommands.State.Snapshots.Replace(this.plugin, { id }); @@ -167,8 +169,9 @@ export class LocalStateSnapshotList extends PluginUIComponent<{}, {}> { render() { const current = this.plugin.managers.snapshot.state.current; - return <ul style={{ listStyle: 'none', marginTop: '10px' }} className='msp-state-list'> - {this.plugin.managers.snapshot.state.entries.map(e => <li key={e!.snapshot.id} className='msp-flex-row'> + const items: JSX.Element[] = []; + this.plugin.managers.snapshot.state.entries.forEach(e => { + items.push(<li key={e!.snapshot.id} className='msp-flex-row'> <Button data-id={e!.snapshot.id} onClick={this.apply} className='msp-no-overflow'> <span style={{ fontWeight: e!.snapshot.id === current ? 'bold' : void 0 }}> {e!.name || new Date(e!.timestamp).toLocaleString()}</span> <small> @@ -179,8 +182,21 @@ export class LocalStateSnapshotList extends PluginUIComponent<{}, {}> { <IconButton svg={ArrowDownwardSvg} data-id={e!.snapshot.id} title='Move Down' onClick={this.moveDown} flex='20px' /> <IconButton svg={SwapHorizSvg} data-id={e!.snapshot.id} title='Replace' onClick={this.replace} flex='20px' /> <IconButton svg={DeleteOutlinedSvg} data-id={e!.snapshot.id} title='Remove' onClick={this.remove} flex='20px' /> - </li>)} - </ul>; + </li>); + const image = e.image && this.plugin.managers.asset.get(e.image)?.file; + if (image) { + items.push(<li key={`${e!.snapshot.id}-image`} className='msp-state-image-row'> + <Button data-id={e!.snapshot.id} onClick={this.apply}> + <img src={URL.createObjectURL(image)}/> + </Button> + </li>); + } + }); + return <> + <ul style={{ listStyle: 'none', marginTop: '10px' }} className='msp-state-list'> + {items} + </ul> + </>; } } diff --git a/src/mol-plugin/behavior/static/state.ts b/src/mol-plugin/behavior/static/state.ts index e4270736da5816cc6b6b5ce7287a6878169b5b24..f019050f95cdb06f88245f557e01ca11d4a24b76 100644 --- a/src/mol-plugin/behavior/static/state.ts +++ b/src/mol-plugin/behavior/static/state.ts @@ -1,5 +1,5 @@ /** - * Copyright (c) 2018-2022 mol* contributors, licensed under MIT, See LICENSE file for more info. + * Copyright (c) 2018-2023 mol* contributors, licensed under MIT, See LICENSE file for more info. * * @author David Sehnal <david.sehnal@gmail.com> * @author Alexander Rose <alexander.rose@weirdbyte.de> @@ -151,13 +151,17 @@ export function Snapshots(ctx: PluginContext) { ctx.managers.snapshot.remove(id); }); - PluginCommands.State.Snapshots.Add.subscribe(ctx, ({ name, description, params }) => { - const entry = PluginStateSnapshotManager.Entry(ctx.state.getSnapshot(params), { name, description }); + PluginCommands.State.Snapshots.Add.subscribe(ctx, async ({ name, description, params }) => { + const snapshot = ctx.state.getSnapshot(params); + const image = (params?.image ?? ctx.state.snapshotParams.value.image) ? await PluginStateSnapshotManager.getCanvasImageAsset(ctx, `${snapshot.id}-image.png`) : undefined; + const entry = PluginStateSnapshotManager.Entry(snapshot, { name, description, image }); ctx.managers.snapshot.add(entry); }); - PluginCommands.State.Snapshots.Replace.subscribe(ctx, ({ id, params }) => { - ctx.managers.snapshot.replace(id, ctx.state.getSnapshot(params)); + PluginCommands.State.Snapshots.Replace.subscribe(ctx, async ({ id, params }) => { + const snapshot = ctx.state.getSnapshot(params); + const image = (params?.image ?? ctx.state.snapshotParams.value.image) ? await PluginStateSnapshotManager.getCanvasImageAsset(ctx, `${snapshot.id}-image.png`) : undefined; + ctx.managers.snapshot.replace(id, ctx.state.getSnapshot(params), { image }); }); PluginCommands.State.Snapshots.Move.subscribe(ctx, ({ id, dir }) => { @@ -170,14 +174,14 @@ export function Snapshots(ctx: PluginContext) { return ctx.state.setSnapshot(snapshot); }); - PluginCommands.State.Snapshots.Upload.subscribe(ctx, ({ name, description, playOnLoad, serverUrl, params }) => { + PluginCommands.State.Snapshots.Upload.subscribe(ctx, async ({ name, description, playOnLoad, serverUrl, params }) => { return fetch(urlCombine(serverUrl, `set?name=${encodeURIComponent(name || '')}&description=${encodeURIComponent(description || '')}`), { method: 'POST', mode: 'cors', referrer: 'no-referrer', headers: { 'Content-Type': 'application/json; charset=utf-8' }, - body: JSON.stringify(ctx.managers.snapshot.getStateSnapshot({ name, description, playOnLoad })) - }) as any as Promise<void>; + body: JSON.stringify(await ctx.managers.snapshot.getStateSnapshot({ name, description, playOnLoad })) + }) as unknown as Promise<void>; }); PluginCommands.State.Snapshots.Fetch.subscribe(ctx, async ({ url }) => { diff --git a/src/mol-plugin/state.ts b/src/mol-plugin/state.ts index dedad16d970299b0f5e4f59d538a1938eec4164d..e1aa41abfa123a7bc37bd5243a4e550ee25e047e 100644 --- a/src/mol-plugin/state.ts +++ b/src/mol-plugin/state.ts @@ -1,5 +1,5 @@ /** - * Copyright (c) 2018-2020 mol* contributors, licensed under MIT, See LICENSE file for more info. + * Copyright (c) 2018-2023 mol* contributors, licensed under MIT, See LICENSE file for more info. * * @author David Sehnal <david.sehnal@gmail.com> */ @@ -157,7 +157,8 @@ namespace PluginState { durationInMs: PD.Numeric(250, { min: 100, max: 5000, step: 500 }, { label: 'Duration in ms' }), }), instant: PD.Group({ }) - }, { options: [['animate', 'Animate'], ['instant', 'Instant']] }) + }, { options: [['animate', 'Animate'], ['instant', 'Instant']] }), + image: PD.Boolean(false), }; export type SnapshotParams = Partial<PD.Values<typeof SnapshotParams>> export const DefaultSnapshotParams = PD.getDefaultValues(SnapshotParams); diff --git a/src/mol-util/assets.ts b/src/mol-util/assets.ts index 18e4f3d531e63706e115bb34716332f7259e24e7..8702ce6a9fd1581f6d367012ddf283c1f3119d06 100644 --- a/src/mol-util/assets.ts +++ b/src/mol-util/assets.ts @@ -1,5 +1,5 @@ /** - * Copyright (c) 2020 mol* contributors, licensed under MIT, See LICENSE file for more info. + * Copyright (c) 2020-2023 mol* contributors, licensed under MIT, See LICENSE file for more info. * * @author David Sehnal <david.sehnal@gmail.com> * @author Alexander Rose <alexander.rose@weirdbyte.de> @@ -86,6 +86,18 @@ class AssetManager { this._assets.set(asset.id, { asset, file, refCount: 0 }); } + get(asset: Asset) { + return this._assets.get(asset.id); + } + + delete(asset: Asset) { + return this._assets.delete(asset.id); + } + + has(asset: Asset) { + return this._assets.has(asset.id); + } + resolve<T extends DataType>(asset: Asset, type: T, store = true): Task<Asset.Wrapper<T>> { if (Asset.isUrl(asset)) { return Task.create(`Download ${asset.title || asset.url}`, async ctx => {