From eafb6fe1501b55823cc20b0c1d72462ef045a89b Mon Sep 17 00:00:00 2001
From: David Sehnal <david.sehnal@gmail.com>
Date: Thu, 28 Feb 2019 14:54:46 +0100
Subject: [PATCH] basic structure selection model, fixed bug in OrderedSet

---
 src/mol-canvas3d/canvas3d.ts                  |  26 +++-
 src/mol-canvas3d/helper/interaction-events.ts | 125 ++++++++++++++++++
 src/mol-data/int/_spec/ordered-set.spec.ts    |   3 +
 src/mol-data/int/impl/ordered-set.ts          |   2 +-
 src/mol-geo/geometry/marker-data.ts           |  10 +-
 src/mol-model/structure/structure/element.ts  |  20 ++-
 src/mol-plugin/behavior/dynamic/camera.ts     |   8 +-
 .../behavior/dynamic/representation.ts        |  64 +++++++--
 src/mol-plugin/behavior/static/state.ts       |  20 ++-
 src/mol-plugin/command.ts                     |   5 +-
 src/mol-plugin/context.ts                     |  31 +++--
 src/mol-plugin/ui/viewport.tsx                |  20 +--
 src/mol-plugin/util/canvas3d-identify.ts      |  94 -------------
 src/mol-plugin/util/loci-label-manager.ts     |   2 +-
 ...loci.ts => structure-element-selection.ts} |  83 ++++++++----
 src/mol-repr/representation.ts                |   2 +-
 src/mol-util/input/input-observer.ts          |  68 ++++++----
 17 files changed, 369 insertions(+), 214 deletions(-)
 create mode 100644 src/mol-canvas3d/helper/interaction-events.ts
 delete mode 100644 src/mol-plugin/util/canvas3d-identify.ts
 rename src/mol-plugin/util/{selection/structure-loci.ts => structure-element-selection.ts} (61%)

diff --git a/src/mol-canvas3d/canvas3d.ts b/src/mol-canvas3d/canvas3d.ts
index 91c82d47b..58bf1db6a 100644
--- a/src/mol-canvas3d/canvas3d.ts
+++ b/src/mol-canvas3d/canvas3d.ts
@@ -8,7 +8,7 @@ import { BehaviorSubject, Subscription } from 'rxjs';
 import { now } from 'mol-util/now';
 
 import { Vec3 } from 'mol-math/linear-algebra'
-import InputObserver from 'mol-util/input/input-observer'
+import InputObserver, { ModifiersKeys, ButtonsType } from 'mol-util/input/input-observer'
 import Renderer, { RendererStats } from 'mol-gl/renderer'
 import { GraphicsRenderObject } from 'mol-gl/render-object'
 
@@ -29,6 +29,7 @@ import { ParamDefinition as PD } from 'mol-util/param-definition';
 import { BoundingSphereHelper, DebugHelperParams } from './helper/bounding-sphere-helper';
 import { decodeFloatRGB } from 'mol-util/float-packing';
 import { SetUtils } from 'mol-util/set';
+import { Canvas3dInteractionHelper } from './helper/interaction-events';
 
 export const Canvas3DParams = {
     // TODO: FPS cap?
@@ -76,10 +77,18 @@ interface Canvas3D {
     readonly props: Canvas3DProps
     readonly input: InputObserver
     readonly stats: RendererStats
+    readonly interaction: Canvas3dInteractionHelper['events']
+
+    // TODO: is this a good solution?
+    setSceneAnimating(animating: boolean): void
+
     dispose: () => void
 }
 
 namespace Canvas3D {
+    export interface HighlightEvent { current: Representation.Loci, prev: Representation.Loci, modifiers?: ModifiersKeys }
+    export interface ClickEvent { current: Representation.Loci, buttons: ButtonsType, modifiers: ModifiersKeys }
+
     export function create(canvas: HTMLCanvasElement, container: Element, props: Partial<Canvas3DProps> = {}): Canvas3D {
         const p = { ...PD.getDefaultValues(Canvas3DParams), ...props }
 
@@ -125,7 +134,10 @@ namespace Canvas3D {
         let isUpdating = false
         let drawPending = false
 
-        const debugHelper = new BoundingSphereHelper(webgl, scene, p.debug)
+        const debugHelper = new BoundingSphereHelper(webgl, scene, p.debug);
+        const interactionHelper = new Canvas3dInteractionHelper(identify, getLoci, input);
+
+        let isSceneAnimating = false
 
         function getLoci(pickingId: PickingId) {
             let loci: Loci = EmptyLoci
@@ -250,7 +262,8 @@ namespace Canvas3D {
         function animate() {
             currentTime = now();
             camera.transition.tick(currentTime);
-            draw(false)
+            draw(false);
+            if (!camera.transition.inTransition && !isSceneAnimating) interactionHelper.tick(currentTime);
             window.requestAnimationFrame(animate)
         }
 
@@ -419,6 +432,12 @@ namespace Canvas3D {
             get stats() {
                 return renderer.stats
             },
+            get interaction() {
+                return interactionHelper.events
+            },
+            setSceneAnimating(animating) {
+                isSceneAnimating = animating;
+            },
             dispose: () => {
                 scene.clear()
                 debugHelper.clear()
@@ -426,6 +445,7 @@ namespace Canvas3D {
                 controls.dispose()
                 renderer.dispose()
                 camera.dispose()
+                interactionHelper.dispose()
             }
         }
 
diff --git a/src/mol-canvas3d/helper/interaction-events.ts b/src/mol-canvas3d/helper/interaction-events.ts
new file mode 100644
index 000000000..4267ecf0a
--- /dev/null
+++ b/src/mol-canvas3d/helper/interaction-events.ts
@@ -0,0 +1,125 @@
+/**
+ * Copyright (c) 2018-2019 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+import { PickingId } from 'mol-geo/geometry/picking';
+import { EmptyLoci } from 'mol-model/loci';
+import { Representation } from 'mol-repr/representation';
+import InputObserver, { ModifiersKeys, ButtonsType } from 'mol-util/input/input-observer';
+import { RxEventHelper } from 'mol-util/rx-event-helper';
+
+type Canvas3D = import('../canvas3d').Canvas3D
+
+export class Canvas3dInteractionHelper {
+    private ev = RxEventHelper.create();
+
+    readonly events = {
+        highlight: this.ev<import('../canvas3d').Canvas3D.HighlightEvent>(),
+        click: this.ev<import('../canvas3d').Canvas3D.ClickEvent>(),
+    };
+
+    private cX = -1;
+    private cY = -1;
+
+    private lastX = -1;
+    private lastY = -1;
+
+    private id: PickingId | undefined = void 0;
+
+    private currentIdentifyT = 0;
+
+    private prevLoci: Representation.Loci = Representation.Loci.Empty;
+    private prevT = 0;
+
+    private inside = false;
+
+    private buttons: ButtonsType = ButtonsType.create(0);
+    private modifiers: ModifiersKeys = ModifiersKeys.None;
+
+    private async identify(isClick: boolean, t: number) {
+        if (this.lastX !== this.cX && this.lastY !== this.cY) {
+            this.id = await this.canvasIdentify(this.cX, this.cY);
+            this.lastX = this.cX;
+            this.lastY = this.cY;
+        }
+
+        if (!this.id) return;
+
+        if (isClick) {
+            this.events.click.next({ current: this.getLoci(this.id), buttons: this.buttons, modifiers: this.modifiers });
+            return;
+        }
+
+        // only highlight the latest
+        if (!this.inside || this.currentIdentifyT !== t) {
+            return;
+        }
+
+        const loci = this.getLoci(this.id);
+        if (!Representation.Loci.areEqual(this.prevLoci, loci)) {
+            this.events.highlight.next({ current: loci, prev: this.prevLoci, modifiers: this.modifiers });
+            this.prevLoci = loci;
+        }
+    }
+
+    tick(t: number) {
+        if (this.inside && t - this.prevT > 1000 / this.maxFps) {
+            this.prevT = t;
+            this.currentIdentifyT = t;
+            this.identify(false, t);
+        }
+    }
+
+    leave() {
+        this.inside = false;
+        if (this.prevLoci.loci !== EmptyLoci) {
+            const prev = this.prevLoci;
+            this.prevLoci = Representation.Loci.Empty;
+            this.events.highlight.next({ current: this.prevLoci, prev });
+        }
+    }
+
+    move(x: number, y: number, modifiers: ModifiersKeys) {
+        this.inside = true;
+        this.modifiers = modifiers;
+        this.cX = x;
+        this.cY = y;
+    }
+
+    select(x: number, y: number, buttons: ButtonsType, modifiers: ModifiersKeys) {
+        this.cX = x;
+        this.cY = y;
+        this.buttons = buttons;
+        this.modifiers = modifiers;
+        this.identify(true, 0);
+    }
+
+    modify(modifiers: ModifiersKeys) {
+        if (this.prevLoci.loci === EmptyLoci || ModifiersKeys.areEqual(modifiers, this.modifiers)) return;
+        this.modifiers = modifiers;
+        this.events.highlight.next({ current: this.prevLoci, prev: this.prevLoci, modifiers: this.modifiers });
+    }
+
+    dispose() {
+        this.ev.dispose();
+    }
+
+    constructor(private canvasIdentify: Canvas3D['identify'], private getLoci: Canvas3D['getLoci'], input: InputObserver, private maxFps: number = 15) {
+        input.move.subscribe(({x, y, inside, buttons, modifiers }) => {
+            if (!inside || buttons) { return; }
+            this.move(x, y, modifiers);
+        });
+
+        input.leave.subscribe(() => {
+            this.leave();
+        });
+
+        input.click.subscribe(({x, y, buttons, modifiers }) => {
+            this.select(x, y, buttons, modifiers);
+        });
+
+        input.modifiers.subscribe(modifiers => this.modify(modifiers));
+    }
+}
\ No newline at end of file
diff --git a/src/mol-data/int/_spec/ordered-set.spec.ts b/src/mol-data/int/_spec/ordered-set.spec.ts
index 401bff07e..5f717b2dd 100644
--- a/src/mol-data/int/_spec/ordered-set.spec.ts
+++ b/src/mol-data/int/_spec/ordered-set.spec.ts
@@ -140,6 +140,8 @@ describe('ordered set', () => {
     testEq('union AA3', OrderedSet.union(OrderedSet.ofSortedArray([1, 3]), OrderedSet.ofSortedArray([2, 4])), [1, 2, 3, 4]);
     testEq('union AA4', OrderedSet.union(OrderedSet.ofSortedArray([1, 3]), OrderedSet.ofSortedArray([1, 3, 4])), [1, 3, 4]);
     testEq('union AA5', OrderedSet.union(OrderedSet.ofSortedArray([1, 3, 4]), OrderedSet.ofSortedArray([1, 3])), [1, 3, 4]);
+    testEq('union AR', OrderedSet.union(OrderedSet.ofSortedArray([1, 2, 5, 6]), OrderedSet.ofRange(3, 4)), [1, 2, 3, 4, 5, 6]);
+    testEq('union AR1', OrderedSet.union(OrderedSet.ofSortedArray([1, 2, 6, 7]), OrderedSet.ofRange(3, 4)), [1, 2, 3, 4, 6, 7]);
     it('union AA6', () => expect(OrderedSet.union(arr136, OrderedSet.ofSortedArray([1, 3, 6]))).toBe(arr136));
 
     testEq('intersect ES', OrderedSet.intersect(empty, singleton10), []);
@@ -164,6 +166,7 @@ describe('ordered set', () => {
     testEq('subtract SR2', OrderedSet.subtract(range1_4, OrderedSet.ofSingleton(3)), [1, 2, 4]);
     testEq('subtract RR', OrderedSet.subtract(range1_4, range1_4), []);
     testEq('subtract RR1', OrderedSet.subtract(range1_4, OrderedSet.ofRange(3, 5)), [1, 2]);
+    testEq('subtract RR2', OrderedSet.subtract(range1_4, OrderedSet.ofRange(2, 3)), [1, 4]);
 
     testEq('subtract RA', OrderedSet.subtract(range1_4, arr136), [2, 4]);
     testEq('subtract RA1', OrderedSet.subtract(range1_4, OrderedSet.ofSortedArray([0, 1, 2, 3, 4, 7])), []);
diff --git a/src/mol-data/int/impl/ordered-set.ts b/src/mol-data/int/impl/ordered-set.ts
index 33794046c..832a0c281 100644
--- a/src/mol-data/int/impl/ordered-set.ts
+++ b/src/mol-data/int/impl/ordered-set.ts
@@ -169,7 +169,7 @@ function unionSI(a: S, b: I) {
     let offset = 0;
     for (let i = 0; i < start; i++) indices[offset++] = a[i];
     for (let i = min; i <= max; i++) indices[offset++] = i;
-    for (let i = end, _i = a.length; i < _i; i++) indices[offset] = a[i];
+    for (let i = end, _i = a.length; i < _i; i++) indices[offset++] = a[i];
 
     return ofSortedArray(indices);
 }
diff --git a/src/mol-geo/geometry/marker-data.ts b/src/mol-geo/geometry/marker-data.ts
index 3fd6dc64d..22733a6ff 100644
--- a/src/mol-geo/geometry/marker-data.ts
+++ b/src/mol-geo/geometry/marker-data.ts
@@ -38,12 +38,14 @@ export function applyMarkerAction(array: Uint8Array, start: number, end: number,
                 }
                 break
             case MarkerAction.Select:
-                v += 2
+                if (v < 2) v += 2
+                // v += 2
                 break
             case MarkerAction.Deselect:
-                if (v >= 2) {
-                    v -= 2
-                }
+                // if (v >= 2) {
+                //     v -= 2
+                // }
+                v = v % 2
                 break
             case MarkerAction.Toggle:
                 if (v >= 2) {
diff --git a/src/mol-model/structure/structure/element.ts b/src/mol-model/structure/structure/element.ts
index 1e4c7ae2e..8b39d2215 100644
--- a/src/mol-model/structure/structure/element.ts
+++ b/src/mol-model/structure/structure/element.ts
@@ -125,7 +125,6 @@ namespace StructureElement {
         }
 
         export function union(xs: Loci, ys: Loci): Loci {
-            if (xs.structure !== ys.structure) throw new Error(`Can't union Loci of different structures.`);
             if (xs.elements.length > ys.elements.length) return union(ys, xs);
             if (xs.elements.length === 0) return ys;
 
@@ -146,8 +145,6 @@ namespace StructureElement {
         }
 
         export function subtract(xs: Loci, ys: Loci): Loci {
-            if (xs.structure !== ys.structure) throw new Error(`Can't subtract Loci of different structures.`);
-
             const map = new Map<number, OrderedSet<UnitIndex>>();
             for (const e of ys.elements) map.set(e.unit.id, e.indices);
 
@@ -162,7 +159,22 @@ namespace StructureElement {
                 }
             }
 
-            return xs;
+            return Loci(xs.structure, elements);
+        }
+
+        export function areIntersecting(xs: Loci, ys: Loci): boolean {
+            if (xs.elements.length > ys.elements.length) return areIntersecting(ys, xs);
+            if (xs.elements.length === 0) return ys.elements.length === 0;
+
+            const map = new Map<number, OrderedSet<UnitIndex>>();
+
+            for (const e of xs.elements) map.set(e.unit.id, e.indices);
+            for (const e of ys.elements) {
+                if (!map.has(e.unit.id)) continue;
+                if (OrderedSet.areIntersecting(map.get(e.unit.id)!, e.indices)) return true;
+            }
+
+            return false;
         }
     }
 }
diff --git a/src/mol-plugin/behavior/dynamic/camera.ts b/src/mol-plugin/behavior/dynamic/camera.ts
index 556e39963..34199a4fd 100644
--- a/src/mol-plugin/behavior/dynamic/camera.ts
+++ b/src/mol-plugin/behavior/dynamic/camera.ts
@@ -7,15 +7,17 @@
 import { Loci } from 'mol-model/loci';
 import { ParamDefinition } from 'mol-util/param-definition';
 import { PluginBehavior } from '../behavior';
+import { ButtonsType, ModifiersKeys } from 'mol-util/input/input-observer';
 
 export const FocusLociOnSelect = PluginBehavior.create<{ minRadius: number, extraRadius: number }>({
     name: 'focus-loci-on-select',
     category: 'interaction',
     ctor: class extends PluginBehavior.Handler<{ minRadius: number, extraRadius: number }> {
         register(): void {
-            this.subscribeObservable(this.ctx.events.canvas3d.click, current => {
-                if (!this.ctx.canvas3d) return;
-                const sphere = Loci.getBoundingSphere(current.loci.loci);
+            this.subscribeObservable(this.ctx.events.canvas3d.click, ({ current, buttons, modifiers }) => {
+                if (!this.ctx.canvas3d || buttons !== ButtonsType.Flag.Primary || !ModifiersKeys.areEqual(modifiers, ModifiersKeys.None)) return;
+
+                const sphere = Loci.getBoundingSphere(current.loci);
                 if (!sphere) return;
                 this.ctx.canvas3d.camera.focus(sphere.center, Math.max(sphere.radius + this.params.extraRadius, this.params.minRadius));
             });
diff --git a/src/mol-plugin/behavior/dynamic/representation.ts b/src/mol-plugin/behavior/dynamic/representation.ts
index 33a0962a4..bb1987dbf 100644
--- a/src/mol-plugin/behavior/dynamic/representation.ts
+++ b/src/mol-plugin/behavior/dynamic/representation.ts
@@ -15,6 +15,8 @@ import { labelFirst } from 'mol-theme/label';
 import { ParamDefinition as PD } from 'mol-util/param-definition';
 import { PluginBehavior } from '../behavior';
 import { Representation } from 'mol-repr/representation';
+import { ButtonsType } from 'mol-util/input/input-observer';
+import { StructureElement } from 'mol-model/structure';
 
 export const HighlightLoci = PluginBehavior.create({
     name: 'representation-highlight-loci',
@@ -22,15 +24,29 @@ export const HighlightLoci = PluginBehavior.create({
     ctor: class extends PluginBehavior.Handler {
         register(): void {
             let prev: Representation.Loci = { loci: EmptyLoci, repr: void 0 };
+            const sel = this.ctx.selection.structure;
 
-            this.subscribeObservable(this.ctx.events.canvas3d.highlight, ({ loci }) => {
+            this.subscribeObservable(this.ctx.events.canvas3d.highlight, ({ current, modifiers }) => {
                 if (!this.ctx.canvas3d) return;
 
-                if (!Representation.Loci.areEqual(prev, loci)) {
+                if (StructureElement.isLoci(current.loci)) {
+                    let loci: StructureElement.Loci = current.loci;
+                    if (modifiers && modifiers.shift) {
+                        loci = sel.tryGetRange(loci) || loci;
+                    }
+
                     this.ctx.canvas3d.mark(prev, MarkerAction.RemoveHighlight);
-                    this.ctx.canvas3d.mark(loci, MarkerAction.Highlight);
-                    prev = loci;
+                    const toHighlight = { loci, repr: current.repr };
+                    this.ctx.canvas3d.mark(toHighlight, MarkerAction.Highlight);
+                    prev = toHighlight;
+                } else {
+                    if (!Representation.Loci.areEqual(prev, current)) {
+                        this.ctx.canvas3d.mark(prev, MarkerAction.RemoveHighlight);
+                        this.ctx.canvas3d.mark(current, MarkerAction.Highlight);
+                        prev = current;
+                    }
                 }
+
             });
         }
     },
@@ -42,17 +58,43 @@ export const SelectLoci = PluginBehavior.create({
     category: 'interaction',
     ctor: class extends PluginBehavior.Handler {
         register(): void {
-            let prev = Representation.Loci.Empty;
-            this.subscribeObservable(this.ctx.events.canvas3d.click, ({ loci: current }) => {
-                if (!this.ctx.canvas3d) return;
-                if (!Representation.Loci.areEqual(prev, current)) {
-                    this.ctx.canvas3d.mark(prev, MarkerAction.Deselect);
+            const sel = this.ctx.selection.structure;
+
+            const toggleSel = (current: Representation.Loci<StructureElement.Loci>) => {
+                if (sel.has(current.loci)) {
+                    sel.remove(current.loci);
+                    this.ctx.canvas3d.mark(current, MarkerAction.Deselect);
+                } else {
+                    sel.add(current.loci);
                     this.ctx.canvas3d.mark(current, MarkerAction.Select);
-                    prev = current;
+                }
+            }
+
+            this.subscribeObservable(this.ctx.events.canvas3d.click, ({ current, buttons, modifiers }) => {
+                if (!this.ctx.canvas3d) return;
+
+                if (StructureElement.isLoci(current.loci)) {
+                    if (modifiers.control && buttons === ButtonsType.Flag.Secondary) {
+                        // select only the current element on Ctrl + Right-Click
+                        const old = sel.get(current.loci.structure);
+                        this.ctx.canvas3d.mark({ loci: old }, MarkerAction.Deselect);
+                        sel.set(current.loci);
+                        this.ctx.canvas3d.mark(current, MarkerAction.Select);
+                    } else if (modifiers.control && buttons === ButtonsType.Flag.Primary) {
+                        // toggle current element on Ctrl + Left-Click
+                        toggleSel(current as Representation.Loci<StructureElement.Loci>);
+                    } else if (modifiers.shift && buttons === ButtonsType.Flag.Primary) {
+                        // try to extend sequence on Shift + Left-Click
+                        let loci: StructureElement.Loci = current.loci;
+                        if (modifiers && modifiers.shift) {
+                            loci = sel.tryGetRange(loci) || loci;
+                        }
+                        toggleSel({ loci, repr: current.repr });
+                    }
                 } else {
+                    if (!ButtonsType.has(buttons, ButtonsType.Flag.Secondary)) return;
                     this.ctx.canvas3d.mark(current, MarkerAction.Toggle);
                 }
-                // this.ctx.canvas3d.mark(loci, MarkerAction.Toggle);
             });
         }
     },
diff --git a/src/mol-plugin/behavior/static/state.ts b/src/mol-plugin/behavior/static/state.ts
index 7142dcd55..b934f76d6 100644
--- a/src/mol-plugin/behavior/static/state.ts
+++ b/src/mol-plugin/behavior/static/state.ts
@@ -8,9 +8,7 @@ import { PluginCommands } from '../../command';
 import { PluginContext } from '../../context';
 import { StateTree, StateTransform, State } from 'mol-state';
 import { PluginStateSnapshotManager } from 'mol-plugin/state/snapshots';
-import { PluginStateObject as SO, PluginStateObject } from '../../state/objects';
-import { EmptyLoci, EveryLoci } from 'mol-model/loci';
-import { Structure } from 'mol-model/structure';
+import { PluginStateObject as SO } from '../../state/objects';
 import { getFormattedTime } from 'mol-util/date';
 import { readFromFile } from 'mol-util/data-source';
 
@@ -104,19 +102,19 @@ function setVisibilityVisitor(t: StateTransform, tree: StateTree, ctx: { state:
 // TODO should also work for volumes and shapes
 export function Highlight(ctx: PluginContext) {
     PluginCommands.State.Highlight.subscribe(ctx, ({ state, ref }) => {
-        const cell = state.select(ref)[0]
-        const repr = cell && SO.isRepresentation3D(cell.obj) ? cell.obj.data : undefined
-        if (cell && cell.obj && cell.obj.type === PluginStateObject.Molecule.Structure.type) {
-            ctx.events.canvas3d.highlight.next({ loci: { loci: Structure.Loci(cell.obj.data) } });
-        } else if (repr) {
-            ctx.events.canvas3d.highlight.next({ loci: { loci: EveryLoci, repr } });
-        }
+        // const cell = state.select(ref)[0]
+        // const repr = cell && SO.isRepresentation3D(cell.obj) ? cell.obj.data : undefined
+        // if (cell && cell.obj && cell.obj.type === PluginStateObject.Molecule.Structure.type) {
+        //     ctx.events.canvas3d.highlight.next({ current: { loci: Structure.Loci(cell.obj.data) } });
+        // } else if (repr) {
+        //     ctx.events.canvas3d.highlight.next({ current: { loci: EveryLoci, repr } });
+        // }
     });
 }
 
 export function ClearHighlight(ctx: PluginContext) {
     PluginCommands.State.ClearHighlight.subscribe(ctx, ({ state, ref }) => {
-        ctx.events.canvas3d.highlight.next({ loci: { loci: EmptyLoci } });
+        // ctx.events.canvas3d.highlight.next({ current: { loci: EmptyLoci } });
     });
 }
 
diff --git a/src/mol-plugin/command.ts b/src/mol-plugin/command.ts
index ddbd7604f..f2867ffb0 100644
--- a/src/mol-plugin/command.ts
+++ b/src/mol-plugin/command.ts
@@ -41,9 +41,8 @@ export const PluginCommands = {
     },
     Interactivity: {
         Structure: {
-            AddHighlight: PluginCommand<{ loci: StructureElement.Loci, tryRange?: boolean }>({ isImmediate: true }),
-            ClearHighlight: PluginCommand<{ }>({ isImmediate: true }),
-            SelectHighlighted: PluginCommand<{ type: 'toggle' | 'add' }>({ isImmediate: true })
+            Highlight: PluginCommand<{ loci: StructureElement.Loci, isOff?: boolean }>({ isImmediate: true }),
+            Select: PluginCommand<{ loci: StructureElement.Loci, isOff?: boolean }>({ isImmediate: true })
         }
     },
     Layout: {
diff --git a/src/mol-plugin/context.ts b/src/mol-plugin/context.ts
index 33eecbc06..63f8891d5 100644
--- a/src/mol-plugin/context.ts
+++ b/src/mol-plugin/context.ts
@@ -4,34 +4,33 @@
  * @author David Sehnal <david.sehnal@gmail.com>
  */
 
+import { List } from 'immutable';
 import { Canvas3D } from 'mol-canvas3d/canvas3d';
-import { Representation } from 'mol-repr/representation';
+import { CustomPropertyRegistry } from 'mol-model-props/common/custom-property-registry';
 import { StructureRepresentationRegistry } from 'mol-repr/structure/registry';
+import { VolumeRepresentationRegistry } from 'mol-repr/volume/registry';
 import { State, StateTransform, StateTransformer } from 'mol-state';
 import { Task } from 'mol-task';
 import { ColorTheme } from 'mol-theme/color';
 import { SizeTheme } from 'mol-theme/size';
 import { ThemeRegistryContext } from 'mol-theme/theme';
+import { Color } from 'mol-util/color';
+import { ajaxGet } from 'mol-util/data-source';
 import { LogEntry } from 'mol-util/log-entry';
 import { RxEventHelper } from 'mol-util/rx-event-helper';
 import { merge } from 'rxjs';
 import { BuiltInPluginBehaviors } from './behavior';
+import { PluginBehavior } from './behavior/behavior';
 import { PluginCommand, PluginCommands } from './command';
+import { PluginLayout } from './layout';
 import { PluginSpec } from './spec';
 import { PluginState } from './state';
-import { TaskManager } from './util/task-manager';
-import { Color } from 'mol-util/color';
+import { DataFormatRegistry } from './state/actions/data-format';
+import { StateTransformParameters } from './ui/state/common';
 import { LociLabelEntry, LociLabelManager } from './util/loci-label-manager';
-import { ajaxGet } from 'mol-util/data-source';
-import { VolumeRepresentationRegistry } from 'mol-repr/volume/registry';
+import { TaskManager } from './util/task-manager';
 import { PLUGIN_VERSION, PLUGIN_VERSION_DATE } from './version';
-import { PluginLayout } from './layout';
-import { List } from 'immutable';
-import { StateTransformParameters } from './ui/state/common';
-import { DataFormatRegistry } from './state/actions/data-format';
-import { PluginBehavior } from './behavior/behavior';
-import { CustomPropertyRegistry } from 'mol-model-props/common/custom-property-registry';
-import { ModifiersKeys, ButtonsType } from 'mol-util/input/input-observer';
+import { StructureElementSelectionManager } from './util/structure-element-selection';
 
 export class PluginContext {
     private disposed = false;
@@ -61,8 +60,8 @@ export class PluginContext {
         canvas3d: {
             settingsUpdated: this.ev(),
 
-            highlight: this.ev<{ loci: Representation.Loci, modifiers?: ModifiersKeys }>(),
-            click: this.ev<{ loci: Representation.Loci, buttons?: ButtonsType, modifiers?: ModifiersKeys }>(),
+            highlight: this.ev<Canvas3D.HighlightEvent>(),
+            click: this.ev<Canvas3D.ClickEvent>()
         }
     };
 
@@ -100,6 +99,10 @@ export class PluginContext {
     readonly customModelProperties = new CustomPropertyRegistry();
     readonly customParamEditors = new Map<string, StateTransformParameters.Class>();
 
+    readonly selection = {
+        structure: new StructureElementSelectionManager(this)
+    };
+
     initViewer(canvas: HTMLCanvasElement, container: HTMLDivElement) {
         try {
             this.layout.setRoot(container);
diff --git a/src/mol-plugin/ui/viewport.tsx b/src/mol-plugin/ui/viewport.tsx
index 734684305..408c174a3 100644
--- a/src/mol-plugin/ui/viewport.tsx
+++ b/src/mol-plugin/ui/viewport.tsx
@@ -6,8 +6,6 @@
  */
 
 import * as React from 'react';
-import { ButtonsType } from 'mol-util/input/input-observer';
-import { Canvas3dIdentifyHelper } from 'mol-plugin/util/canvas3d-identify';
 import { PluginUIComponent } from './base';
 import { PluginCommands } from 'mol-plugin/command';
 import { ParamDefinition as PD } from 'mol-util/param-definition';
@@ -112,22 +110,8 @@ export class Viewport extends PluginUIComponent<{ }, ViewportState> {
         const canvas3d = this.plugin.canvas3d;
         this.subscribe(canvas3d.input.resize, this.handleResize);
 
-        const idHelper = new Canvas3dIdentifyHelper(this.plugin, 15);
-
-        this.subscribe(canvas3d.input.move, ({x, y, inside, buttons, modifiers }) => {
-            if (!inside || buttons) { return; }
-            idHelper.move(x, y, modifiers);
-        });
-
-        this.subscribe(canvas3d.input.leave, () => {
-            idHelper.leave();
-        });
-
-        this.subscribe(canvas3d.input.click, ({x, y, buttons, modifiers }) => {
-            if (buttons !== ButtonsType.Flag.Primary) return;
-            idHelper.select(x, y, buttons, modifiers);
-        });
-
+        this.subscribe(canvas3d.interaction.click, e => this.plugin.events.canvas3d.click.next(e));
+        this.subscribe(canvas3d.interaction.highlight, e => this.plugin.events.canvas3d.highlight.next(e));
         this.subscribe(this.plugin.layout.events.updated, () => {
             setTimeout(this.handleResize, 50);
         });
diff --git a/src/mol-plugin/util/canvas3d-identify.ts b/src/mol-plugin/util/canvas3d-identify.ts
deleted file mode 100644
index c7e0e64ed..000000000
--- a/src/mol-plugin/util/canvas3d-identify.ts
+++ /dev/null
@@ -1,94 +0,0 @@
-/**
- * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
- *
- * @author David Sehnal <david.sehnal@gmail.com>
- */
-
-import { PluginContext } from '../context';
-import { PickingId } from 'mol-geo/geometry/picking';
-import { EmptyLoci } from 'mol-model/loci';
-import { Representation } from 'mol-repr/representation';
-import { ModifiersKeys, ButtonsType } from 'mol-util/input/input-observer';
-
-export class Canvas3dIdentifyHelper {
-    private cX = -1;
-    private cY = -1;
-
-    private lastX = -1;
-    private lastY = -1;
-
-    private id: PickingId | undefined = void 0;
-
-    private currentIdentifyT = 0;
-
-    private prevLoci: Representation.Loci = Representation.Loci.Empty;
-    private prevT = 0;
-
-    private inside = false;
-
-    private buttons: ButtonsType = ButtonsType.create(0);
-    private modifiers: ModifiersKeys = ModifiersKeys.None;
-
-    private async identify(isClick: boolean, t: number) {
-        if (this.lastX !== this.cX && this.lastY !== this.cY) {
-            this.id = await this.ctx.canvas3d.identify(this.cX, this.cY);
-            this.lastX = this.cX;
-            this.lastY = this.cY;
-        }
-
-        if (!this.id) return;
-
-        if (isClick) {
-            this.ctx.events.canvas3d.click.next({ loci: this.ctx.canvas3d.getLoci(this.id), buttons: this.buttons, modifiers: this.modifiers });
-            return;
-        }
-
-        // only highlight the latest
-        if (!this.inside || this.currentIdentifyT !== t) {
-            return;
-        }
-
-        const loci = this.ctx.canvas3d.getLoci(this.id);
-        if (!Representation.Loci.areEqual(this.prevLoci, loci)) {
-            this.ctx.events.canvas3d.highlight.next({ loci, modifiers: this.modifiers });
-            this.prevLoci = loci;
-        }
-    }
-
-    private animate: (t: number) => void = t => {
-        if (!this.ctx.state.animation.isAnimating && this.inside && t - this.prevT > 1000 / this.maxFps) {
-            this.prevT = t;
-            this.currentIdentifyT = t;
-            this.identify(false, t);
-        }
-        requestAnimationFrame(this.animate);
-    }
-
-    leave() {
-        this.inside = false;
-        if (this.prevLoci.loci !== EmptyLoci) {
-            this.prevLoci = Representation.Loci.Empty;
-            this.ctx.events.canvas3d.highlight.next({ loci: this.prevLoci });
-            this.ctx.canvas3d.requestDraw(true);
-        }
-    }
-
-    move(x: number, y: number, modifiers: ModifiersKeys) {
-        this.inside = true;
-        this.modifiers = modifiers;
-        this.cX = x;
-        this.cY = y;
-    }
-
-    select(x: number, y: number, buttons: ButtonsType, modifiers: ModifiersKeys) {
-        this.cX = x;
-        this.cY = y;
-        this.buttons = buttons;
-        this.modifiers = modifiers;
-        this.identify(true, 0);
-    }
-
-    constructor(private ctx: PluginContext, private maxFps: number = 15) {
-        this.animate(0);
-    }
-}
\ No newline at end of file
diff --git a/src/mol-plugin/util/loci-label-manager.ts b/src/mol-plugin/util/loci-label-manager.ts
index 5b9f7b3ad..b49814798 100644
--- a/src/mol-plugin/util/loci-label-manager.ts
+++ b/src/mol-plugin/util/loci-label-manager.ts
@@ -35,6 +35,6 @@ export class LociLabelManager {
     }
 
     constructor(public ctx: PluginContext) {
-        ctx.events.canvas3d.highlight.subscribe(ev => ctx.behaviors.labels.highlight.next({ entries: this.getInfo(ev.loci) }));
+        ctx.events.canvas3d.highlight.subscribe(ev => ctx.behaviors.labels.highlight.next({ entries: this.getInfo(ev.current) }));
     }
 }
\ No newline at end of file
diff --git a/src/mol-plugin/util/selection/structure-loci.ts b/src/mol-plugin/util/structure-element-selection.ts
similarity index 61%
rename from src/mol-plugin/util/selection/structure-loci.ts
rename to src/mol-plugin/util/structure-element-selection.ts
index b7613c10b..b5f7d7b99 100644
--- a/src/mol-plugin/util/selection/structure-loci.ts
+++ b/src/mol-plugin/util/structure-element-selection.ts
@@ -4,16 +4,16 @@
  * @author David Sehnal <david.sehnal@gmail.com>
  */
 
-import { StructureElement, Structure } from 'mol-model/structure';
-import { PluginContext } from '../../context';
-import { Loci, EmptyLoci } from 'mol-model/loci';
-import { PluginStateObject } from 'mol-plugin/state/objects';
-import { State, StateSelection, StateObject } from 'mol-state';
-import { mapObjectMap } from 'mol-util/object';
+import { EmptyLoci, Loci } from 'mol-model/loci';
+import { Structure, StructureElement } from 'mol-model/structure';
+import { State, StateObject, StateSelection } from 'mol-state';
+import { PluginContext } from '../context';
+import { PluginStateObject } from '../state/objects';
+import { OrderedSet } from 'mol-data/int';
 
-export { StructureLociManager }
+export { StructureElementSelectionManager };
 
-class StructureLociManager {
+class StructureElementSelectionManager {
     private entries = new Map<string, SelectionEntry>();
 
     // maps structure to a parent StateObjectCell
@@ -34,28 +34,65 @@ class StructureLociManager {
         return this.entries.get(key)!;
     }
 
-    add(loci: StructureElement.Loci | Structure.Loci, type: 'selection' | 'highlight'): Loci {
+    add(loci: StructureElement.Loci): Loci {
         const entry = this.getEntry(loci.structure);
         if (!entry) return EmptyLoci;
-        const xs = entry.elements;
-        xs[type] = Structure.isLoci(loci) ? StructureElement.Loci.all(loci.structure) : StructureElement.Loci.union(xs[type], loci);
-        return xs[type];
+        entry.selection = StructureElement.Loci.union(entry.selection, loci);
+        return entry.selection;
     }
 
-    remove(loci: StructureElement.Loci | Structure.Loci, type: 'selection' | 'highlight'): Loci {
+    remove(loci: StructureElement.Loci): Loci {
         const entry = this.getEntry(loci.structure);
         if (!entry) return EmptyLoci;
-        const xs = entry.elements;
-        xs[type] = Structure.isLoci(loci) ? StructureElement.Loci(loci.structure, []) : StructureElement.Loci.subtract(xs[type], loci);
-        return xs[type].elements.length === 0 ? EmptyLoci : xs[type];
+        entry.selection = StructureElement.Loci.subtract(entry.selection, loci);
+        return entry.selection.elements.length === 0 ? EmptyLoci : entry.selection;
     }
 
-    set(loci: StructureElement.Loci | Structure.Loci, type: 'selection' | 'highlight'): Loci {
+    set(loci: StructureElement.Loci): Loci {
         const entry = this.getEntry(loci.structure);
         if (!entry) return EmptyLoci;
-        const xs = entry.elements;
-        xs[type] = Structure.isLoci(loci) ? StructureElement.Loci.all(loci.structure) : loci;
-        return xs[type].elements.length === 0 ? EmptyLoci : xs[type];
+        entry.selection = loci;
+        return entry.selection.elements.length === 0 ? EmptyLoci : entry.selection;
+    }
+
+    get(structure: Structure) {
+        const entry = this.getEntry(structure);
+        if (!entry) return EmptyLoci;
+        return entry.selection;
+    }
+
+    has(loci: StructureElement.Loci) {
+        const entry = this.getEntry(loci.structure);
+        if (!entry) return false;
+        return StructureElement.Loci.areIntersecting(loci, entry.selection);
+    }
+
+    tryGetRange(loci: StructureElement.Loci): StructureElement.Loci | undefined {
+        if (loci.elements.length !== 1) return;
+        const entry = this.getEntry(loci.structure);
+        if (!entry) return;
+
+        let xs = loci.elements[0];
+        let e: StructureElement.Loci['elements'][0] | undefined;
+        for (const _e of entry.selection.elements) {
+            if (xs.unit === _e.unit) {
+                e = _e;
+                break;
+            }
+        }
+        if (!e) return;
+
+        const predIdx = OrderedSet.findPredecessorIndex(e.indices, OrderedSet.min(xs.indices));
+        if (predIdx === 0) return;
+
+        const fst = predIdx < OrderedSet.size(e.indices)
+            ? OrderedSet.getAt(e.indices, predIdx)
+            : OrderedSet.getAt(e.indices, predIdx - 1) + 1 as StructureElement.UnitIndex;
+
+        return StructureElement.Loci(entry.selection.structure, [{
+            unit: e.unit,
+            indices: OrderedSet.ofRange(fst, OrderedSet.max(xs.indices))
+        }]);
     }
 
     private prevHighlight: StructureElement.Loci | undefined = void 0;
@@ -131,17 +168,17 @@ class StructureLociManager {
 }
 
 interface SelectionEntry {
-    elements: { [category: string]: StructureElement.Loci }
+    selection: StructureElement.Loci
 }
 
 function SelectionEntry(s: Structure): SelectionEntry {
     return {
-        elements: { }
+        selection: StructureElement.Loci(s, [])
     };
 }
 
 function remapSelectionEntry(e: SelectionEntry, s: Structure): SelectionEntry {
     return {
-        elements: mapObjectMap(e.elements, (l: StructureElement.Loci) => StructureElement.Loci.remap(l, s))
+        selection: StructureElement.Loci.remap(e.selection, s)
     };
 }
\ No newline at end of file
diff --git a/src/mol-repr/representation.ts b/src/mol-repr/representation.ts
index c20127bdb..b959ed7f1 100644
--- a/src/mol-repr/representation.ts
+++ b/src/mol-repr/representation.ts
@@ -107,7 +107,7 @@ interface Representation<D, P extends PD.Params = {}, S extends Representation.S
     destroy: () => void
 }
 namespace Representation {
-    export interface Loci { loci: ModelLoci, repr?: Representation.Any }
+    export interface Loci<T extends ModelLoci = ModelLoci> { loci: T, repr?: Representation.Any }
 
     export namespace Loci {
         export function areEqual(a: Loci, b: Loci) {
diff --git a/src/mol-util/input/input-observer.ts b/src/mol-util/input/input-observer.ts
index 3bb32b429..7a6160b03 100644
--- a/src/mol-util/input/input-observer.ts
+++ b/src/mol-util/input/input-observer.ts
@@ -4,7 +4,7 @@
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  */
 
-import { Subject } from 'rxjs';
+import { Subject, Observable } from 'rxjs';
 
 import { Vec2 } from 'mol-math/linear-algebra';
 
@@ -50,13 +50,17 @@ export type ModifiersKeys = {
     meta: boolean
 }
 export namespace ModifiersKeys {
-    export const None: ModifiersKeys = { shift: false, alt: false, control: false, meta: false }
+    export const None: ModifiersKeys = { shift: false, alt: false, control: false, meta: false };
+
+    export function areEqual(a: ModifiersKeys, b: ModifiersKeys) {
+        return a.shift === b.shift && a.alt === b.alt && a.control === b.control && a.meta === b.meta;
+    }
 }
 
 export type ButtonsType = BitFlags<ButtonsType.Flag>
 
 export namespace ButtonsType {
-    export const has: (ss: ButtonsType, f: Flag) => boolean = BitFlags.has
+    export const has: (btn: ButtonsType, f: Flag) => boolean = BitFlags.has
     export const create: (fs: Flag) => ButtonsType = BitFlags.create
 
     export const enum Flag {
@@ -138,16 +142,17 @@ interface InputObserver {
     noScroll: boolean
     noContextMenu: boolean
 
-    drag: Subject<DragInput>,
+    drag: Observable<DragInput>,
     // Equivalent to mouseUp and touchEnd
-    interactionEnd: Subject<undefined>,
-    wheel: Subject<WheelInput>,
-    pinch: Subject<PinchInput>,
-    click: Subject<ClickInput>,
-    move: Subject<MoveInput>,
-    leave: Subject<undefined>,
-    enter: Subject<undefined>,
-    resize: Subject<ResizeInput>,
+    interactionEnd: Observable<undefined>,
+    wheel: Observable<WheelInput>,
+    pinch: Observable<PinchInput>,
+    click: Observable<ClickInput>,
+    move: Observable<MoveInput>,
+    leave: Observable<undefined>,
+    enter: Observable<undefined>,
+    resize: Observable<ResizeInput>,
+    modifiers: Observable<ModifiersKeys>
 
     dispose: () => void
 }
@@ -176,6 +181,7 @@ namespace InputObserver {
         let dragging: DraggingState = DraggingState.Stopped
         let disposed = false
         let buttons = 0 as ButtonsType
+        let isInside = false
 
         const drag = new Subject<DragInput>()
         const interactionEnd = new Subject<undefined>();
@@ -186,6 +192,7 @@ namespace InputObserver {
         const resize = new Subject<ResizeInput>()
         const leave = new Subject<undefined>()
         const enter = new Subject<undefined>()
+        const modifiersEvent = new Subject<ModifiersKeys>()
 
         attach()
 
@@ -204,6 +211,7 @@ namespace InputObserver {
             leave,
             enter,
             resize,
+            modifiers: modifiersEvent,
 
             dispose
         }
@@ -226,9 +234,8 @@ namespace InputObserver {
             element.addEventListener('touchend', onTouchEnd as any, false)
 
             element.addEventListener('blur', handleBlur)
-            element.addEventListener('keyup', handleMods as EventListener)
-            element.addEventListener('keydown', handleMods as EventListener)
-            element.addEventListener('keypress', handleMods as EventListener)
+            window.addEventListener('keyup', handleKeyUp as EventListener, false)
+            window.addEventListener('keydown', handleKeyDown as EventListener, false)
 
             window.addEventListener('resize', onResize, false)
         }
@@ -252,9 +259,8 @@ namespace InputObserver {
             element.removeEventListener('touchend', onTouchEnd as any, false)
 
             element.removeEventListener('blur', handleBlur)
-            element.removeEventListener('keyup', handleMods as EventListener)
-            element.removeEventListener('keydown', handleMods as EventListener)
-            element.removeEventListener('keypress', handleMods as EventListener)
+            window.removeEventListener('keyup', handleKeyUp as EventListener, false)
+            window.removeEventListener('keydown', handleKeyDown as EventListener, false)
 
             window.removeEventListener('resize', onResize, false)
         }
@@ -272,11 +278,25 @@ namespace InputObserver {
             }
         }
 
-        function handleMods (event: MouseEvent | KeyboardEvent) {
-            if ('altKey' in event) modifiers.alt = !!event.altKey
-            if ('shiftKey' in event) modifiers.shift = !!event.shiftKey
-            if ('ctrlKey' in event) modifiers.control = !!event.ctrlKey
-            if ('metaKey' in event) modifiers.meta = !!event.metaKey
+        function handleKeyDown (event: KeyboardEvent) {
+            let changed = false;
+            if (!modifiers.alt && event.altKey) { changed = true; modifiers.alt = true; }
+            if (!modifiers.shift && event.shiftKey) { changed = true; modifiers.shift = true; }
+            if (!modifiers.control && event.ctrlKey) { changed = true; modifiers.control = true; }
+            if (!modifiers.meta && event.metaKey) { changed = true; modifiers.meta = true; }
+
+            if (changed && isInside) modifiersEvent.next(getModifiers());
+        }
+
+        function handleKeyUp (event: KeyboardEvent) {
+            let changed = false;
+
+            if (modifiers.alt && !event.altKey) { changed = true; modifiers.alt = false; }
+            if (modifiers.shift && !event.shiftKey) { changed = true; modifiers.shift = false; }
+            if (modifiers.control && !event.ctrlKey) { changed = true; modifiers.control = false; }
+            if (modifiers.meta && !event.metaKey) { changed = true; modifiers.meta = false; }
+
+            if (changed && isInside) modifiersEvent.next(getModifiers());
         }
 
         function getCenterTouch (ev: TouchEvent): PointerEvent {
@@ -413,10 +433,12 @@ namespace InputObserver {
         }
 
         function onMouseEnter (ev: Event) {
+            isInside = true;
             enter.next();
         }
 
         function onMouseLeave (ev: Event) {
+            isInside = false;
             leave.next();
         }
 
-- 
GitLab