Browse Source

state snapshot images

Alexander Rose 2 years ago
parent
commit
9d056a85ec

+ 1 - 0
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
 

+ 41 - 12
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;
+    }
 }

+ 20 - 0
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;

+ 4 - 1
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;
+$sequence-number-color: $hover-font-color;
+
+// state
+$state-image-height: 96px;

+ 21 - 5
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>
+        </>;
     }
 }
 

+ 12 - 8
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 }) => {

+ 3 - 2
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);

+ 13 - 1
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 => {