From fe7b96daf354a7f7ae2ebb9a01ce403e837d24b9 Mon Sep 17 00:00:00 2001
From: Chris Jordan <jordanchriss@gmail.com>
Date: Thu, 28 Sep 2023 18:38:42 -0400
Subject: [PATCH] feat(annotation): added keybinds for iterating through active
 annotation list, also pins and jumps to each annotation

---
 src/layer/index.ts                     | 23 ++++++++-
 src/ui/annotations.ts                  | 70 +++++++++++++++++++++-----
 src/ui/default_input_event_bindings.ts |  3 ++
 src/viewer.ts                          | 24 +++++++++
 4 files changed, 106 insertions(+), 14 deletions(-)

diff --git a/src/layer/index.ts b/src/layer/index.ts
index ff3f7d8d29..cbf2b493e6 100644
--- a/src/layer/index.ts
+++ b/src/layer/index.ts
@@ -99,7 +99,7 @@ import {
 } from "#src/util/json.js";
 import { MessageList } from "#src/util/message_list.js";
 import type { AnyConstructor } from "#src/util/mixin.js";
-import { NullarySignal } from "#src/util/signal.js";
+import { NullarySignal, Signal } from "#src/util/signal.js";
 import type { SignalBindingUpdater } from "#src/util/signal_binding_updater.js";
 import {
   addSignalBinding,
@@ -192,6 +192,27 @@ export class UserLayer extends RefCounted {
 
   messages = new MessageList();
 
+  layerEventListeners = new Map<string, Signal>();
+
+  dispatchLayerEvent(type: string) {
+    this.layerEventListeners.get(type)?.dispatch();
+  }
+
+  registerLayerEvent(type: string, handler: () => void) {
+    const { layerEventListeners } = this;
+    let existingSignal = layerEventListeners.get(type);
+    if (!existingSignal) {
+      existingSignal = new Signal();
+      layerEventListeners.set(type, existingSignal);
+    }
+    const unregister = existingSignal.add(handler);
+    return () => {
+      const res = unregister();
+      // TODO delete from map? currently handlers is private
+      return res;
+    };
+  }
+
   initializeSelectionState(state: this["selectionState"]) {
     state.generation = -1;
     state.localPositionValid = false;
diff --git a/src/ui/annotations.ts b/src/ui/annotations.ts
index 4a8e7b21b3..7833820000 100644
--- a/src/ui/annotations.ts
+++ b/src/ui/annotations.ts
@@ -231,6 +231,26 @@ interface AnnotationLayerViewAttachedState {
   listOffset: number;
 }
 
+const moveToAnnotation = (
+  layer: UserLayer,
+  annotation: Annotation,
+  state: AnnotationLayerState,
+) => {
+  const chunkTransform = state.chunkTransform.value as ChunkTransformParameters;
+  const { layerRank } = chunkTransform;
+  const chunkPosition = new Float32Array(layerRank);
+  const layerPosition = new Float32Array(layerRank);
+  getCenterPosition(chunkPosition, annotation);
+  matrix.transformPoint(
+    layerPosition,
+    chunkTransform.chunkToLayerTransform,
+    layerRank + 1,
+    chunkPosition,
+    layerRank,
+  );
+  setLayerPosition(layer, chunkTransform, layerPosition);
+};
+
 export class AnnotationLayerView extends Tab {
   private previousSelectedState:
     | {
@@ -456,7 +476,31 @@ export class AnnotationLayerView extends Tab {
     this.virtualList.element.addEventListener("mouseleave", () => {
       this.displayState.hoverState.value = undefined;
     });
-
+    const changeSelectedIndex = (offset: number) => {
+      const selectedIndex = this.getSelectedAnnotationIndex();
+      if (selectedIndex === undefined) return;
+      const nextAnnotation = this.listElements[selectedIndex + offset];
+      if (nextAnnotation) {
+        const { state, annotation } = nextAnnotation;
+        this.layer.selectAnnotation(state, annotation.id, true);
+        moveToAnnotation(this.layer, annotation, state);
+      }
+    };
+    this.registerDisposer(
+      this.layer.registerLayerEvent("select-previous", () => {
+        // if (this.layer.panels.panels[0].selectedTab.value === "annotations") {
+        if (this.element.checkVisibility()) {
+          changeSelectedIndex(-1);
+        }
+      }),
+    );
+    this.registerDisposer(
+      this.layer.registerLayerEvent("select-next", () => {
+        if (this.element.checkVisibility()) {
+          changeSelectedIndex(1);
+        }
+      }),
+    );
     const bindings = getDefaultAnnotationListBindings();
     this.registerDisposer(
       new MouseEventBinder(this.virtualList.element, bindings),
@@ -487,6 +531,17 @@ export class AnnotationLayerView extends Tab {
     this.updateSelectionView();
   }
 
+  private getSelectedAnnotationIndex() {
+    const { previousSelectedState: state } = this;
+    if (state === undefined) return;
+    const { annotationLayerState, annotationId } = state;
+    const attached = this.attachedAnnotationStates.get(annotationLayerState);
+    if (attached === undefined) return;
+    const index = attached.idToIndex.get(annotationId);
+    if (index === undefined) return;
+    return attached.listOffset + index;
+  }
+
   private getRenderedAnnotationListElement(
     state: AnnotationLayerState,
     id: AnnotationId,
@@ -2196,18 +2251,7 @@ export function makeAnnotationListElement(
   element.addEventListener("action:move-to-annotation", (event) => {
     event.stopPropagation();
     event.preventDefault();
-    const { layerRank } = chunkTransform;
-    const chunkPosition = new Float32Array(layerRank);
-    const layerPosition = new Float32Array(layerRank);
-    getCenterPosition(chunkPosition, annotation);
-    matrix.transformPoint(
-      layerPosition,
-      chunkTransform.chunkToLayerTransform,
-      layerRank + 1,
-      chunkPosition,
-      layerRank,
-    );
-    setLayerPosition(layer, chunkTransform, layerPosition);
+    moveToAnnotation(layer, annotation, state);
   });
   return [element, columnWidths];
 }
diff --git a/src/ui/default_input_event_bindings.ts b/src/ui/default_input_event_bindings.ts
index 3e1816582b..d7912f5bc7 100644
--- a/src/ui/default_input_event_bindings.ts
+++ b/src/ui/default_input_event_bindings.ts
@@ -48,6 +48,9 @@ export function getDefaultGlobalBindings() {
     map.set("space", "toggle-layout");
     map.set("shift+space", "toggle-layout-alternative");
     map.set("backslash", "toggle-show-statistics");
+
+    map.set("alt+arrowup", "select-previous");
+    map.set("alt+arrowdown", "select-next");
     defaultGlobalBindings = map;
   }
   return defaultGlobalBindings;
diff --git a/src/viewer.ts b/src/viewer.ts
index 8e4fcd86a2..5d288d6554 100644
--- a/src/viewer.ts
+++ b/src/viewer.ts
@@ -1028,6 +1028,30 @@ export class Viewer extends RefCounted implements ViewerState {
       });
     }
 
+    const sendEventToSelectedLayerTab = (type: string) => {
+      const elements = document.querySelectorAll(
+        '[data-neuroglancer-layer-panel-pinned="false"] .neuroglancer-stack-view > .neuroglancer-tab-content:not([style*="display: none"]):not([style*="display: none"]) > *',
+      );
+      for (const element of elements) {
+        const event = new Event(type);
+        console.log("sending", type, "to", element);
+        element.dispatchEvent(event);
+      }
+
+      const selectedLayer = this.selectedLayer.layer?.layer;
+      if (selectedLayer) {
+        selectedLayer.dispatchLayerEvent(type);
+      }
+    };
+
+    this.bindAction("select-previous", () => {
+      sendEventToSelectedLayerTab("select-previous");
+    });
+
+    this.bindAction("select-next", () => {
+      sendEventToSelectedLayerTab("select-next");
+    });
+
     for (const action of ["select", "star"]) {
       this.bindAction(action, () => {
         this.mouseState.updateUnconditionally();