From cde2a51a7c445f3204a311dac2e5d19142956d63 Mon Sep 17 00:00:00 2001 From: Jeremy Maitin-Shepard Date: Mon, 2 Oct 2017 17:39:16 -0700 Subject: [PATCH] feat: Support unified customizable keyboard and mouse event handling. Keyboard and mouse bindings are now both specified using the new EventActionMap class, that maps an event identifier to an action specification. Mouse bindings are no longer hardcoded in RenderedDataPanel, PerspectivePanel, and SliceViewPanel. Actions are now dispatched using the DOM event mechanism rather than the previous ad-hoc mechanism. --- README.md | 22 +- examples/dependent-project/src/main.ts | 5 +- .../extra_key_bindings.ts | 6 +- .../navigate_to_origin.ts | 4 +- src/neuroglancer/default_key_bindings.ts | 53 --- src/neuroglancer/display_context.ts | 25 -- ..._bindings.css => input_event_bindings.css} | 2 +- src/neuroglancer/help/input_event_bindings.ts | 118 +++++++ src/neuroglancer/help/key_bindings.ts | 74 ---- src/neuroglancer/overlay.ts | 26 +- src/neuroglancer/perspective_view/panel.ts | 50 +-- src/neuroglancer/rendered_data_panel.ts | 163 ++++----- src/neuroglancer/sliceview/panel.ts | 57 +-- .../ui/default_input_event_bindings.ts | 114 ++++++ src/neuroglancer/ui/default_viewer_setup.ts | 4 +- src/neuroglancer/util/disposable.ts | 14 +- src/neuroglancer/util/event_action_map.ts | 327 ++++++++++++++++++ src/neuroglancer/util/hierarchical_map.ts | 172 +++++++++ src/neuroglancer/util/keyboard_bindings.ts | 105 ++++++ .../util/keyboard_shortcut_handler.ts | 295 ---------------- src/neuroglancer/util/mouse_bindings.ts | 48 +++ src/neuroglancer/viewer.css | 2 +- src/neuroglancer/viewer.ts | 136 ++++---- src/neuroglancer/viewer_layouts.ts | 44 ++- src/neuroglancer/widget/autocomplete.ts | 83 ++--- 25 files changed, 1195 insertions(+), 754 deletions(-) delete mode 100644 src/neuroglancer/default_key_bindings.ts rename src/neuroglancer/help/{key_bindings.css => input_event_bindings.css} (96%) create mode 100644 src/neuroglancer/help/input_event_bindings.ts delete mode 100644 src/neuroglancer/help/key_bindings.ts create mode 100644 src/neuroglancer/ui/default_input_event_bindings.ts create mode 100644 src/neuroglancer/util/event_action_map.ts create mode 100644 src/neuroglancer/util/hierarchical_map.ts create mode 100644 src/neuroglancer/util/keyboard_bindings.ts delete mode 100644 src/neuroglancer/util/keyboard_shortcut_handler.ts create mode 100644 src/neuroglancer/util/mouse_bindings.ts diff --git a/README.md b/README.md index 8c8d9585a..62fda2646 100644 --- a/README.md +++ b/README.md @@ -32,30 +32,16 @@ Neuroglancer itself is purely a client-side program, but it depends on data bein - Chrome >= 51 - Firefox >= 46 -# Key bindings +# Keyboard and mouse bindings -See [src/neuroglancer/default_key_bindings.ts](src/neuroglancer/default_key_bindings.ts). - -# Mouse bindings +For the complete set of bindings, see +[src/neuroglancer/ui/default_input_event_bindings.ts](src/neuroglancer/default_input_event_bindings.ts), +or within Neuroglancer, press `h` or click on the button labeled `?` in the upper right corner. - Click on a layer name to toggle its visibility. - Double-click on a layer name to edit its properties. -- Left-drag within a slice view to move within that plane. - -- Shift-left-drag within a slice view to change the orientation of the slice views. The projection of the point where the drag started will remain fixed. - -- Rotate the mouse wheel to move forward or backward in the local z axis of the 3-d or cross-sectional view under the mouse pointer. Hold down shift to move 10x faster. - -- Control-mouse wheel zooms in or out. When used in the cross-sectional view, the projection of the point under the mouse pointer will remain fixed. - -- Left-drag within the 3-d view to change the orientation. - -- Right click to move to the position under the mouse pointer. - -- Double click to toggle showing the object under the mouse pointer. - - Hover over a segmentation layer name to see the current list of objects shown and to access the opacity sliders. - Hover over an image layer name to access the opacity slider and the text editor for modifying the [rendering code](src/neuroglancer/sliceview/image_layer_rendering.md). diff --git a/examples/dependent-project/src/main.ts b/examples/dependent-project/src/main.ts index 237307913..04835137a 100644 --- a/examples/dependent-project/src/main.ts +++ b/examples/dependent-project/src/main.ts @@ -17,9 +17,10 @@ import {makeExtraKeyBindings} from 'my-neuroglancer-project/extra_key_bindings'; import {navigateToOrigin} from 'my-neuroglancer-project/navigate_to_origin'; import {setupDefaultViewer} from 'neuroglancer/ui/default_viewer_setup'; +import {registerActionListener} from 'neuroglancer/util/event_action_map'; window.addEventListener('DOMContentLoaded', () => { const viewer = setupDefaultViewer(); - makeExtraKeyBindings(viewer.keyMap); - viewer.keyCommands.set('navigate-to-origin', navigateToOrigin); + makeExtraKeyBindings(viewer.inputEventMap); + registerActionListener(viewer.element, 'navigate-to-origin', () => navigateToOrigin(viewer)); }); diff --git a/examples/dependent-project/src/my-neuroglancer-project/extra_key_bindings.ts b/examples/dependent-project/src/my-neuroglancer-project/extra_key_bindings.ts index ac71bc0d9..7780b7107 100644 --- a/examples/dependent-project/src/my-neuroglancer-project/extra_key_bindings.ts +++ b/examples/dependent-project/src/my-neuroglancer-project/extra_key_bindings.ts @@ -14,8 +14,8 @@ * limitations under the License. */ -import {KeySequenceMap} from 'neuroglancer/util/keyboard_shortcut_handler'; +import {EventActionMap} from 'neuroglancer/util/event_action_map'; -export function makeExtraKeyBindings(keyMap: KeySequenceMap) { - keyMap.bind('keyo', 'navigate-to-origin'); +export function makeExtraKeyBindings(keyMap: EventActionMap) { + keyMap.set('keyo', 'navigate-to-origin'); } diff --git a/examples/dependent-project/src/my-neuroglancer-project/navigate_to_origin.ts b/examples/dependent-project/src/my-neuroglancer-project/navigate_to_origin.ts index 89f046ad1..f0002a3a1 100644 --- a/examples/dependent-project/src/my-neuroglancer-project/navigate_to_origin.ts +++ b/examples/dependent-project/src/my-neuroglancer-project/navigate_to_origin.ts @@ -17,8 +17,8 @@ import {kZeroVec} from 'neuroglancer/util/geom'; import {Viewer} from 'neuroglancer/viewer'; -export function navigateToOrigin(this: Viewer) { - let {position} = this.navigationState.pose; +export function navigateToOrigin(viewer: Viewer) { + let {position} = viewer.navigationState.pose; if (position.valid) { position.setVoxelCoordinates(kZeroVec); } diff --git a/src/neuroglancer/default_key_bindings.ts b/src/neuroglancer/default_key_bindings.ts deleted file mode 100644 index 43bc1472c..000000000 --- a/src/neuroglancer/default_key_bindings.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * @license - * Copyright 2016 Google Inc. - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import {KeySequenceMap} from 'neuroglancer/util/keyboard_shortcut_handler'; - -/** - * This binds the default set of viewer key bindings. - */ -export function makeDefaultKeyBindings(keyMap: KeySequenceMap) { - keyMap.bind('arrowleft', 'x-'); - keyMap.bind('arrowright', 'x+'); - keyMap.bind('arrowup', 'y-'); - keyMap.bind('arrowdown', 'y+'); - keyMap.bind('comma', 'z-'); - keyMap.bind('period', 'z+'); - keyMap.bind('keyz', 'snap'); - keyMap.bind('control+equal', 'zoom-in'); - keyMap.bind('control+shift+equal', 'zoom-in'); - keyMap.bind('control+minus', 'zoom-out'); - keyMap.bind('keyr', 'rotate-relative-z-'); - keyMap.bind('keye', 'rotate-relative-z+'); - keyMap.bind('shift+arrowdown', 'rotate-relative-x-'); - keyMap.bind('shift+arrowup', 'rotate-relative-x+'); - keyMap.bind('shift+arrowleft', 'rotate-relative-y-'); - keyMap.bind('shift+arrowright', 'rotate-relative-y+'); - keyMap.bind('keyl', 'recolor'); - keyMap.bind('keyx', 'clear-segments'); - keyMap.bind('keys', 'toggle-show-slices'); - keyMap.bind('keyb', 'toggle-scale-bar'); - keyMap.bind('keya', 'toggle-axis-lines'); - - for (let i = 1; i <= 9; ++i) { - keyMap.bind('digit' + i, 'toggle-layer-' + i); - } - - keyMap.bind('keyn', 'add-layer'); - keyMap.bind('keyh', 'help'); - - keyMap.bind('space', 'toggle-layout'); -} diff --git a/src/neuroglancer/display_context.ts b/src/neuroglancer/display_context.ts index f87e9602e..aa4cca000 100644 --- a/src/neuroglancer/display_context.ts +++ b/src/neuroglancer/display_context.ts @@ -26,9 +26,6 @@ export abstract class RenderedPanel extends RefCounted { public visibility: WatchableVisibilityPriority) { super(); this.gl = context.gl; - this.registerEventListener(element, 'mouseenter', (_event: MouseEvent) => { - this.context.setActivePanel(this); - }); context.addPanel(this); } @@ -52,10 +49,6 @@ export abstract class RenderedPanel extends RefCounted { abstract onResize(): void; - onKeyCommand(_action: string) { - return false; - } - abstract draw(): void; disposed() { @@ -74,7 +67,6 @@ export class DisplayContext extends RefCounted { updateStarted = new NullarySignal(); updateFinished = new NullarySignal(); panels = new Set(); - activePanel: RenderedPanel|null = null; private updatePending: number|null = null; private needsRedraw = false; @@ -96,27 +88,10 @@ export class DisplayContext extends RefCounted { addPanel(panel: RenderedPanel) { this.panels.add(panel); - if (this.activePanel == null) { - this.setActivePanel(panel); - } - } - - setActivePanel(panel: RenderedPanel|null) { - let existingPanel = this.activePanel; - if (existingPanel != null) { - existingPanel.element.attributes.removeNamedItem('isActivePanel'); - } - if (panel != null) { - panel.element.setAttribute('isActivePanel', 'true'); - } - this.activePanel = panel; } removePanel(panel: RenderedPanel) { this.panels.delete(panel); - if (panel === this.activePanel) { - this.setActivePanel(null); - } panel.dispose(); } diff --git a/src/neuroglancer/help/key_bindings.css b/src/neuroglancer/help/input_event_bindings.css similarity index 96% rename from src/neuroglancer/help/key_bindings.css rename to src/neuroglancer/help/input_event_bindings.css index d08d5ee02..d30287666 100644 --- a/src/neuroglancer/help/key_bindings.css +++ b/src/neuroglancer/help/input_event_bindings.css @@ -18,7 +18,7 @@ overflow-x: hidden; } -.describe-key-bindings .dl { +.describe-key-bindings-container { overflow-y: scroll; max-height: 80vh; overflow-x: hidden; diff --git a/src/neuroglancer/help/input_event_bindings.ts b/src/neuroglancer/help/input_event_bindings.ts new file mode 100644 index 000000000..9780c2dde --- /dev/null +++ b/src/neuroglancer/help/input_event_bindings.ts @@ -0,0 +1,118 @@ +/** + * @license + * Copyright 2016 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {Overlay} from 'neuroglancer/overlay'; +import {EventActionMap} from 'neuroglancer/util/event_action_map'; + +require('./input_event_bindings.css'); + +export function formatKeyName(name: string) { + if (name.startsWith('key')) { + return name.substring(3); + } + if (name.startsWith('digit')) { + return name.substring(5); + } + if (name.startsWith('arrow')) { + return name.substring(5); + } + return name; +} + +export function formatKeyStroke(stroke: string) { + let parts = stroke.split('+'); + return parts.map(formatKeyName).join('+'); +} + +export class InputEventBindingHelpDialog extends Overlay { + /** + * @param keyMap Key map to list. + */ + constructor(bindings: Iterable<[string, EventActionMap]>) { + super(); + + let {content} = this; + content.classList.add('describe-key-bindings'); + + let scroll = document.createElement('div'); + scroll.classList.add('describe-key-bindings-container'); + + interface BindingList { + label: string; + entries: Map; + } + + const uniqueMaps = new Map(); + function addEntries(eventMap: EventActionMap, entries: Map) { + for (const parent of eventMap.parents) { + if (parent.label !== undefined) { + addMap(parent.label, parent); + } else { + addEntries(parent, entries); + } + } + for (const [event, eventAction] of eventMap.bindings.entries()) { + const firstColon = event.indexOf(':'); + const suffix = event.substring(firstColon + 1); + entries.set(suffix, eventAction.action); + } + } + + function addMap(label: string, map: EventActionMap) { + if (uniqueMaps.has(map)) { + return; + } + const list: BindingList = { + label, + entries: new Map(), + }; + addEntries(map, list.entries); + uniqueMaps.set(map, list); + } + + for (const [label, eventMap] of bindings) { + addMap(label, eventMap); + } + + for (const list of uniqueMaps.values()) { + let header = document.createElement('h2'); + header.textContent = list.label; + scroll.appendChild(header); + let dl = document.createElement('div'); + dl.className = 'dl'; + + for (const [event, action] of list.entries) { + let container = document.createElement('div'); + let container2 = document.createElement('div'); + container2.className = 'definition-outer-container'; + container.className = 'definition-container'; + let dt = document.createElement('div'); + dt.className = 'dt'; + dt.textContent = formatKeyStroke(event); + let dd = document.createElement('div'); + dd.className = 'dd'; + dd.textContent = action; + container.appendChild(dt); + container.appendChild(dd); + dl.appendChild(container2); + container2.appendChild(container); + } + scroll.appendChild(dl); + } + content.appendChild(scroll); + } +} + diff --git a/src/neuroglancer/help/key_bindings.ts b/src/neuroglancer/help/key_bindings.ts deleted file mode 100644 index 10bb26df3..000000000 --- a/src/neuroglancer/help/key_bindings.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * @license - * Copyright 2016 Google Inc. - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import {Overlay} from 'neuroglancer/overlay'; -import {KeySequenceMap} from 'neuroglancer/util/keyboard_shortcut_handler'; - -require('./key_bindings.css'); - -export function formatKeyName(name: string) { - if (name.startsWith('key')) { - return name.substring(3); - } - if (name.startsWith('digit')) { - return name.substring(5); - } - if (name.startsWith('arrow')) { - return name.substring(5); - } - return name; -} - -export function formatKeyStroke(stroke: string) { - let parts = stroke.split('+'); - return parts.map(formatKeyName).join('+'); -} - -export class KeyBindingHelpDialog extends Overlay { - /** - * @param keyMap Key map to list. - */ - constructor(keyMap: KeySequenceMap) { - super(); - - let {content} = this; - content.classList.add('describe-key-bindings'); - - let scroll = document.createElement('div'); - - let dl = document.createElement('div'); - dl.className = 'dl'; - - for (let [sequence, command] of keyMap.entries()) { - let container = document.createElement('div'); - let container2 = document.createElement('div'); - container2.className = 'definition-outer-container'; - container.className = 'definition-container'; - let dt = document.createElement('div'); - dt.className = 'dt'; - dt.textContent = sequence.map(formatKeyStroke).join(' '); - let dd = document.createElement('div'); - dd.className = 'dd'; - dd.textContent = command; - container.appendChild(dt); - container.appendChild(dd); - dl.appendChild(container2); - container2.appendChild(container); - } - scroll.appendChild(dl); - content.appendChild(scroll); - } -} diff --git a/src/neuroglancer/overlay.ts b/src/neuroglancer/overlay.ts index f7033cf33..f3dd7c0d0 100644 --- a/src/neuroglancer/overlay.ts +++ b/src/neuroglancer/overlay.ts @@ -14,9 +14,9 @@ * limitations under the License. */ -import {RefCounted} from 'neuroglancer/util/disposable'; -import {KeySequenceMap, KeyboardShortcutHandler} from 'neuroglancer/util/keyboard_shortcut_handler'; import {AutomaticallyFocusedElement} from 'neuroglancer/util/automatic_focus'; +import {RefCounted} from 'neuroglancer/util/disposable'; +import {EventActionMap, KeyboardEventBinder} from 'neuroglancer/util/keyboard_bindings'; export const overlayKeyboardHandlerPriority = 100; @@ -24,14 +24,17 @@ require('./overlay.css'); export let overlaysOpen = 0; -let KEY_MAP = new KeySequenceMap(); -KEY_MAP.bind('escape', 'close'); +export const defaultEventMap = EventActionMap.fromObject({ + 'escape': {action: 'close'}, +}); export class Overlay extends RefCounted { container: HTMLDivElement; content: HTMLDivElement; - constructor(public keySequenceMap: KeySequenceMap = KEY_MAP) { + keyMap = new EventActionMap(); + constructor() { super(); + this.keyMap.addParent(defaultEventMap, Number.NEGATIVE_INFINITY); ++overlaysOpen; let container = this.container = document.createElement('div'); container.className = 'overlay'; @@ -40,16 +43,11 @@ export class Overlay extends RefCounted { content.className = 'overlay-content'; container.appendChild(content); document.body.appendChild(container); - this.registerDisposer(new KeyboardShortcutHandler( - this.container, keySequenceMap, this.commandReceived.bind(this))); - content.focus(); - } - - commandReceived(action: string) { - if (action === 'close') { + this.registerDisposer(new KeyboardEventBinder(this.container, this.keyMap)); + this.registerEventListener(container, 'action:close', () => { this.dispose(); - } - return false; + }); + content.focus(); } disposed() { diff --git a/src/neuroglancer/perspective_view/panel.ts b/src/neuroglancer/perspective_view/panel.ts index 8e5ef4573..63d3f83e8 100644 --- a/src/neuroglancer/perspective_view/panel.ts +++ b/src/neuroglancer/perspective_view/panel.ts @@ -19,12 +19,12 @@ import {DisplayContext} from 'neuroglancer/display_context'; import {makeRenderedPanelVisibleLayerTracker, MouseSelectionState} from 'neuroglancer/layer'; import {PickIDManager} from 'neuroglancer/object_picking'; import {PerspectiveViewRenderContext, PerspectiveViewRenderLayer} from 'neuroglancer/perspective_view/render_layer'; -import {RenderedDataPanel} from 'neuroglancer/rendered_data_panel'; +import {RenderedDataPanel, RenderedDataViewerState} from 'neuroglancer/rendered_data_panel'; import {SliceView, SliceViewRenderHelper} from 'neuroglancer/sliceview/frontend'; import {TrackableBoolean, TrackableBooleanCheckbox} from 'neuroglancer/trackable_boolean'; +import {ActionEvent, registerActionListener} from 'neuroglancer/util/event_action_map'; import {kAxes, mat4, transformVectorByMat4, vec3, vec4} from 'neuroglancer/util/geom'; import {startRelativeMouseDrag} from 'neuroglancer/util/mouse_drag'; -import {ViewerState} from 'neuroglancer/viewer_state'; import {DepthBuffer, FramebufferConfiguration, makeTextureBuffers, OffscreenCopyHelper, TextureBuffer} from 'neuroglancer/webgl/offscreen'; import {ShaderBuilder} from 'neuroglancer/webgl/shader'; import {glsl_packFloat01ToFixedPoint, unpackFloat01FromFixedPoint} from 'neuroglancer/webgl/shader_lib'; @@ -32,7 +32,7 @@ import {glsl_packFloat01ToFixedPoint, unpackFloat01FromFixedPoint} from 'neurogl require('neuroglancer/noselect.css'); require('./panel.css'); -export interface PerspectiveViewerState extends ViewerState { +export interface PerspectiveViewerState extends RenderedDataViewerState { showSliceViews: TrackableBoolean; showSliceViewsCheckbox?: boolean; } @@ -137,6 +137,29 @@ export class PerspectivePanel extends RenderedDataPanel { this.viewportChanged(); })); + registerActionListener(element, 'translate-via-mouse-drag', (e: ActionEvent) => { + startRelativeMouseDrag(e.detail, (_event, deltaX, deltaY) => { + const temp = tempVec3; + const {projectionMat} = this; + const {width, height} = this; + const {position} = this.viewer.navigationState; + const pos = position.spatialCoordinates; + vec3.transformMat4(temp, pos, projectionMat); + temp[0] = 2 * deltaX / width; + temp[1] = -2 * deltaY / height; + vec3.transformMat4(pos, temp, this.inverseProjectionMat); + position.changed.dispatch(); + }); + }); + + registerActionListener(element, 'rotate-via-mouse-drag', (e: ActionEvent) => { + startRelativeMouseDrag(e.detail, (_event, deltaX, deltaY) => { + this.navigationState.pose.rotateRelative(kAxes[1], -deltaX / 4.0 * Math.PI / 180.0); + this.navigationState.pose.rotateRelative(kAxes[0], deltaY / 4.0 * Math.PI / 180.0); + this.viewer.navigationState.changed.dispatch(); + }); + }); + if (viewer.showSliceViewsCheckbox) { let showSliceViewsCheckbox = this.registerDisposer(new TrackableBooleanCheckbox(viewer.showSliceViews)); @@ -224,27 +247,6 @@ export class PerspectivePanel extends RenderedDataPanel { return true; } - startDragViewport(e: MouseEvent) { - startRelativeMouseDrag(e, (event, deltaX, deltaY) => { - if (event.shiftKey) { - const temp = tempVec3; - const {projectionMat} = this; - const {width, height} = this; - const {position} = this.viewer.navigationState; - const pos = position.spatialCoordinates; - vec3.transformMat4(temp, pos, projectionMat); - temp[0] = 2 * deltaX / width; - temp[1] = -2 * deltaY / height; - vec3.transformMat4(pos, temp, this.inverseProjectionMat); - position.changed.dispatch(); - } else { - this.navigationState.pose.rotateRelative(kAxes[1], -deltaX / 4.0 * Math.PI / 180.0); - this.navigationState.pose.rotateRelative(kAxes[0], deltaY / 4.0 * Math.PI / 180.0); - this.viewer.navigationState.changed.dispatch(); - } - }); - } - private get transparentConfiguration() { let transparentConfiguration = this.transparentConfiguration_; if (transparentConfiguration === undefined) { diff --git a/src/neuroglancer/rendered_data_panel.ts b/src/neuroglancer/rendered_data_panel.ts index b4f924503..bc9c1bed1 100644 --- a/src/neuroglancer/rendered_data_panel.ts +++ b/src/neuroglancer/rendered_data_panel.ts @@ -17,45 +17,20 @@ import {DisplayContext, RenderedPanel} from 'neuroglancer/display_context'; import {MouseSelectionState} from 'neuroglancer/layer'; import {NavigationState} from 'neuroglancer/navigation_state'; +import {AutomaticallyFocusedElement} from 'neuroglancer/util/automatic_focus'; +import {ActionEvent, EventActionMap, registerActionListener} from 'neuroglancer/util/event_action_map'; import {AXES_NAMES, kAxes, vec3} from 'neuroglancer/util/geom'; +import {KeyboardEventBinder} from 'neuroglancer/util/keyboard_bindings'; +import {MouseEventBinder} from 'neuroglancer/util/mouse_bindings'; import {getWheelZoomAmount} from 'neuroglancer/util/wheel_zoom'; import {ViewerState} from 'neuroglancer/viewer_state'; require('./rendered_data_panel.css'); -export const KEY_COMMANDS = new Map void>(); -for (let axis = 0; axis < 3; ++axis) { - let axisName = AXES_NAMES[axis]; - for (let sign of [-1, +1]) { - let signStr = (sign < 0) ? '-' : '+'; - KEY_COMMANDS.set(`rotate-relative-${axisName}${signStr}`, function() { - this.navigationState.pose.rotateRelative(kAxes[axis], sign * 0.1); - }); - let tempOffset = vec3.create(); - KEY_COMMANDS.set(`${axisName}${signStr}`, function() { - let {navigationState} = this; - let offset = tempOffset; - offset[0] = 0; - offset[1] = 0; - offset[2] = 0; - offset[axis] = sign; - navigationState.pose.translateVoxelsRelative(offset); - }); - } -} -KEY_COMMANDS.set('snap', function() { - this.navigationState.pose.snap(); -}); - -KEY_COMMANDS.set('zoom-in', function() { - this.navigationState.zoomBy(0.5); -}); -KEY_COMMANDS.set('zoom-out', function() { - this.navigationState.zoomBy(2.0); -}); - const tempVec3 = vec3.create(); +export interface RenderedDataViewerState extends ViewerState { inputEventMap: EventActionMap; } + export abstract class RenderedDataPanel extends RenderedPanel { // Last mouse position within the panel. mouseX = 0; @@ -65,20 +40,85 @@ export abstract class RenderedDataPanel extends RenderedPanel { private mouseStateUpdater = this.updateMouseState.bind(this); + inputEventMap: EventActionMap; + navigationState: NavigationState; - constructor(context: DisplayContext, element: HTMLElement, public viewer: ViewerState) { + constructor( + context: DisplayContext, element: HTMLElement, public viewer: RenderedDataViewerState) { super(context, element, viewer.visibility); + this.inputEventMap = viewer.inputEventMap; element.classList.add('rendered-data-panel'); + this.registerDisposer(new AutomaticallyFocusedElement(element)); + this.registerDisposer(new KeyboardEventBinder(element, this.inputEventMap)); + this.registerDisposer(new MouseEventBinder(element, this.inputEventMap)); + this.registerEventListener(element, 'mousemove', this.onMousemove.bind(this)); this.registerEventListener(element, 'mouseleave', this.onMouseout.bind(this)); - this.registerEventListener(element, 'mousedown', this.onMousedown.bind(this), false); - this.registerEventListener(element, 'wheel', this.onMousewheel.bind(this), false); - this.registerEventListener(element, 'dblclick', () => { - this.viewer.layerManager.invokeAction('select'); + + registerActionListener(element, 'snap', () => { + this.navigationState.pose.snap(); + }); + + registerActionListener(element, 'zoom-in', () => { + this.navigationState.zoomBy(0.5); + }); + + registerActionListener(element, 'zoom-out', () => { + this.navigationState.zoomBy(2.0); }); + + for (let axis = 0; axis < 3; ++axis) { + let axisName = AXES_NAMES[axis]; + for (let sign of [-1, +1]) { + let signStr = (sign < 0) ? '-' : '+'; + registerActionListener(element, `rotate-relative-${axisName}${signStr}`, () => { + this.navigationState.pose.rotateRelative(kAxes[axis], sign * 0.1); + }); + let tempOffset = vec3.create(); + registerActionListener(element, `${axisName}${signStr}`, () => { + let {navigationState} = this; + let offset = tempOffset; + offset[0] = 0; + offset[1] = 0; + offset[2] = 0; + offset[axis] = sign; + navigationState.pose.translateVoxelsRelative(offset); + }); + } + } + + registerActionListener(element, 'zoom-via-wheel', (event: ActionEvent) => { + const e = event.detail; + this.onMousemove(e); + this.zoomByMouse(getWheelZoomAmount(e)); + }); + + for (const amount of [1, 10]) { + registerActionListener(element, `z+${amount}-via-wheel`, (event: ActionEvent) => { + const e = event.detail; + let {navigationState} = this; + let offset = tempVec3; + let delta = e.deltaY !== 0 ? e.deltaY : e.deltaX; + offset[0] = 0; + offset[1] = 0; + offset[2] = (delta > 0 ? -1 : 1) * amount; + navigationState.pose.translateVoxelsRelative(offset); + }); + } + + registerActionListener(element, 'move-to-mouse-position', () => { + let {mouseState} = this.viewer; + if (mouseState.updateUnconditionally()) { + let position = this.navigationState.pose.position; + vec3.copy(position.spatialCoordinates, mouseState.position); + position.changed.dispatch(); + } + }); + + registerActionListener(element, 'snap', () => this.navigationState.pose.snap()); } onMouseout(_event: MouseEvent) { @@ -87,15 +127,6 @@ export abstract class RenderedDataPanel extends RenderedPanel { mouseState.setActive(false); } - onKeyCommand(action: string) { - let command = KEY_COMMANDS.get(action); - if (command) { - command.call(this); - return true; - } - return false; - } - onMousemove(event: MouseEvent) { let {element} = this; if (event.target !== element) { @@ -118,46 +149,4 @@ export abstract class RenderedDataPanel extends RenderedPanel { } abstract zoomByMouse(factor: number): void; - - onMousewheel(e: WheelEvent) { - if (e.ctrlKey) { - this.onMousemove(e); - this.zoomByMouse(getWheelZoomAmount(e)); - } else { - let {navigationState} = this; - let offset = tempVec3; - let delta = e.deltaY !== 0 ? e.deltaY : e.deltaX; - offset[0] = 0; - offset[1] = 0; - offset[2] = (delta > 0 ? -1 : 1) * (e.shiftKey ? 10 : 1); - navigationState.pose.translateVoxelsRelative(offset); - } - e.preventDefault(); - } - - abstract startDragViewport(e: MouseEvent): void; - - onMousedown(e: MouseEvent) { - if (e.target !== this.element) { - return; - } - this.onMousemove(e); - if (e.button === 0) { - if (e.ctrlKey) { - let {mouseState} = this.viewer; - if (mouseState.updateUnconditionally()) { - this.viewer.layerManager.invokeAction('annotate'); - } - } else { - this.startDragViewport(e); - } - } else if (e.button === 2) { - let {mouseState} = this.viewer; - if (mouseState.updateUnconditionally()) { - let position = this.navigationState.pose.position; - vec3.copy(position.spatialCoordinates, mouseState.position); - position.changed.dispatch(); - } - } - } } diff --git a/src/neuroglancer/sliceview/panel.ts b/src/neuroglancer/sliceview/panel.ts index f8481b9c6..03c6f529d 100644 --- a/src/neuroglancer/sliceview/panel.ts +++ b/src/neuroglancer/sliceview/panel.ts @@ -18,17 +18,19 @@ import {AxesLineHelper} from 'neuroglancer/axes_lines'; import {DisplayContext} from 'neuroglancer/display_context'; import {makeRenderedPanelVisibleLayerTracker, MouseSelectionState, VisibilityTrackedRenderLayer} from 'neuroglancer/layer'; import {PickIDManager} from 'neuroglancer/object_picking'; -import {RenderedDataPanel} from 'neuroglancer/rendered_data_panel'; +import {RenderedDataPanel, RenderedDataViewerState} from 'neuroglancer/rendered_data_panel'; import {SliceView, SliceViewRenderHelper} from 'neuroglancer/sliceview/frontend'; import {ElementVisibilityFromTrackableBoolean, TrackableBoolean} from 'neuroglancer/trackable_boolean'; +import {ActionEvent, registerActionListener} from 'neuroglancer/util/event_action_map'; import {identityMat4, mat4, vec3, vec4} from 'neuroglancer/util/geom'; import {startRelativeMouseDrag} from 'neuroglancer/util/mouse_drag'; -import {ViewerState} from 'neuroglancer/viewer_state'; import {FramebufferConfiguration, makeTextureBuffers, OffscreenCopyHelper} from 'neuroglancer/webgl/offscreen'; import {ShaderBuilder, ShaderModule} from 'neuroglancer/webgl/shader'; import {ScaleBarWidget} from 'neuroglancer/widget/scale_bar'; -export interface SliceViewerState extends ViewerState { showScaleBar: TrackableBoolean; } +export interface SliceViewerState extends RenderedDataViewerState { + showScaleBar: TrackableBoolean; +} export enum OffscreenTextures { COLOR, @@ -111,6 +113,33 @@ export class SliceViewPanel extends RenderedDataPanel { viewer: SliceViewerState) { super(context, element, viewer); + registerActionListener(element, 'translate-via-mouse-drag', (e: ActionEvent) => { + const {mouseState} = this.viewer; + if (mouseState.updateUnconditionally()) { + startRelativeMouseDrag(e.detail, (_event, deltaX, deltaY) => { + const {position} = this.viewer.navigationState; + const pos = position.spatialCoordinates; + vec3.set(pos, deltaX, deltaY, 0); + vec3.transformMat4(pos, pos, this.sliceView.viewportToData); + position.changed.dispatch(); + }); + } + }); + + registerActionListener(element, 'rotate-via-mouse-drag', (e: ActionEvent) => { + const {mouseState} = this.viewer; + if (mouseState.updateUnconditionally()) { + const initialPosition = vec3.clone(mouseState.position); + startRelativeMouseDrag(e.detail, (_event, deltaX, deltaY) => { + let {viewportAxes} = this.sliceView; + this.viewer.navigationState.pose.rotateAbsolute( + viewportAxes[1], deltaX / 4.0 * Math.PI / 180.0, initialPosition); + this.viewer.navigationState.pose.rotateAbsolute( + viewportAxes[0], deltaY / 4.0 * Math.PI / 180.0, initialPosition); + }); + } + }); + this.registerDisposer(sliceView); this.registerDisposer(sliceView.visibility.add(this.visibility)); this.registerDisposer(sliceView.viewChanged.add(() => { @@ -253,28 +282,6 @@ export class SliceViewPanel extends RenderedDataPanel { return true; } - startDragViewport(e: MouseEvent) { - let {mouseState} = this.viewer; - if (mouseState.updateUnconditionally()) { - let initialPosition = vec3.clone(mouseState.position); - startRelativeMouseDrag(e, (event, deltaX, deltaY) => { - let {position} = this.viewer.navigationState; - if (event.shiftKey) { - let {viewportAxes} = this.sliceView; - this.viewer.navigationState.pose.rotateAbsolute( - viewportAxes[1], deltaX / 4.0 * Math.PI / 180.0, initialPosition); - this.viewer.navigationState.pose.rotateAbsolute( - viewportAxes[0], deltaY / 4.0 * Math.PI / 180.0, initialPosition); - } else { - let pos = position.spatialCoordinates; - vec3.set(pos, deltaX, deltaY, 0); - vec3.transformMat4(pos, pos, this.sliceView.viewportToData); - position.changed.dispatch(); - } - }); - } - } - /** * Zooms by the specified factor, maintaining the data position that projects to the current mouse * position. diff --git a/src/neuroglancer/ui/default_input_event_bindings.ts b/src/neuroglancer/ui/default_input_event_bindings.ts new file mode 100644 index 000000000..b8c25c988 --- /dev/null +++ b/src/neuroglancer/ui/default_input_event_bindings.ts @@ -0,0 +1,114 @@ +/** + * @license + * Copyright 2017 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {EventActionMap} from 'neuroglancer/util/event_action_map'; +import {InputEventBindings} from 'neuroglancer/viewer'; + +let defaultGlobalBindings: EventActionMap|undefined; + +export function getDefaultGlobalBindings() { + if (defaultGlobalBindings === undefined) { + const map = new EventActionMap(); + map.set('keyl', 'recolor'); + map.set('keyx', 'clear-segments'); + map.set('keys', 'toggle-show-slices'); + map.set('keyb', 'toggle-scale-bar'); + map.set('keya', 'toggle-axis-lines'); + + for (let i = 1; i <= 9; ++i) { + map.set('digit' + i, 'toggle-layer-' + i); + } + + map.set('keyn', 'add-layer'); + map.set('keyh', 'help'); + + map.set('space', 'toggle-layout'); + defaultGlobalBindings = map; + } + return defaultGlobalBindings; +} + +let defaultRenderedDataPanelBindings: EventActionMap|undefined; +export function getDefaultRenderedDataPanelBindings() { + if (defaultRenderedDataPanelBindings === undefined) { + defaultRenderedDataPanelBindings = EventActionMap.fromObject( + { + 'arrowleft': 'x-', + 'arrowright': 'x+', + 'arrowup': 'y-', + 'arrowdown': 'y+', + 'comma': 'z-', + 'period': 'z+', + 'keyz': 'snap', + 'control+equal': 'zoom-in', + 'control+shift+equal': 'zoom-in', + 'control+minus': 'zoom-out', + 'keyr': 'rotate-relative-z-', + 'keye': 'rotate-relative-z+', + 'shift+arrowdown': 'rotate-relative-x-', + 'shift+arrowup': 'rotate-relative-x+', + 'shift+arrowleft': 'rotate-relative-y-', + 'shift+arrowright': 'rotate-relative-y+', + + 'at:control+wheel': {action: 'zoom-via-wheel', preventDefault: true}, + 'at:wheel': {action: 'z+1-via-wheel', preventDefault: true}, + 'at:shift+wheel': {action: 'z+10-via-wheel', preventDefault: true}, + 'at:dblclick0': 'select', + 'at:control+mousedown0': 'annotate', + 'at:mousedown2': 'move-to-mouse-position', + }, + {label: 'All Data Panels'}); + } + return defaultRenderedDataPanelBindings; +} + +let defaultPerspectivePanelBindings: EventActionMap|undefined; +export function getDefaultPerspectivePanelBindings() { + if (defaultPerspectivePanelBindings === undefined) { + defaultPerspectivePanelBindings = EventActionMap.fromObject( + { + 'at:mousedown0': {action: 'rotate-via-mouse-drag', stopPropagation: true}, + 'at:shift+mousedown0': {action: 'translate-via-mouse-drag', stopPropagation: true}, + }, + { + parents: [[getDefaultRenderedDataPanelBindings(), Number.NEGATIVE_INFINITY]] + }); + } + return defaultPerspectivePanelBindings; +} + +let defaultSliceViewPanelBindings: EventActionMap|undefined; +export function getDefaultSliceViewPanelBindings() { + if (defaultSliceViewPanelBindings === undefined) { + defaultSliceViewPanelBindings = EventActionMap.fromObject( + { + 'at:mousedown0': {action: 'translate-via-mouse-drag', stopPropagation: true}, + 'at:shift+mousedown0': {action: 'rotate-via-mouse-drag', stopPropagation: true}, + }, + { + parents: [[getDefaultRenderedDataPanelBindings(), Number.NEGATIVE_INFINITY]] + }); + } + return defaultSliceViewPanelBindings; +} + +export function setDefaultInputEventBindings(inputEventBindings: InputEventBindings) { + inputEventBindings.global.addParent(getDefaultGlobalBindings(), Number.NEGATIVE_INFINITY); + inputEventBindings.sliceView.addParent( + getDefaultSliceViewPanelBindings(), Number.NEGATIVE_INFINITY); + inputEventBindings.perspectiveView.addParent( + getDefaultPerspectivePanelBindings(), Number.NEGATIVE_INFINITY); +} diff --git a/src/neuroglancer/ui/default_viewer_setup.ts b/src/neuroglancer/ui/default_viewer_setup.ts index 3fd45a9f1..18122f13a 100644 --- a/src/neuroglancer/ui/default_viewer_setup.ts +++ b/src/neuroglancer/ui/default_viewer_setup.ts @@ -14,9 +14,9 @@ * limitations under the License. */ -import {makeDefaultKeyBindings} from 'neuroglancer/default_key_bindings'; import {makeDefaultViewer} from 'neuroglancer/default_viewer'; import {bindDefaultCopyHandler, bindDefaultPasteHandler} from 'neuroglancer/ui/default_clipboard_handling'; +import {setDefaultInputEventBindings} from 'neuroglancer/ui/default_input_event_bindings'; import {UrlHashBinding} from 'neuroglancer/ui/url_hash_binding'; /** @@ -24,7 +24,7 @@ import {UrlHashBinding} from 'neuroglancer/ui/url_hash_binding'; */ export function setupDefaultViewer() { let viewer = (window)['viewer'] = makeDefaultViewer(); - makeDefaultKeyBindings(viewer.keyMap); + setDefaultInputEventBindings(viewer.inputEventBindings); const hashBinding = viewer.registerDisposer(new UrlHashBinding(viewer.state)); hashBinding.updateFromUrlHash(); diff --git a/src/neuroglancer/util/disposable.ts b/src/neuroglancer/util/disposable.ts index 36c393669..2ac511df2 100644 --- a/src/neuroglancer/util/disposable.ts +++ b/src/neuroglancer/util/disposable.ts @@ -18,6 +18,13 @@ export interface Disposable { dispose: () => void; } export type Disposer = Disposable | (() => void); +export function registerEventListener( + target: EventTarget, type: string, listener: EventListenerOrEventListenerObject, + options?: boolean|AddEventListenerOptions) { + target.addEventListener(type, listener, options); + return () => target.removeEventListener(type, listener, options); +} + export class RefCounted implements Disposable { public refCount = 1; wasDisposed: boolean|undefined; @@ -70,9 +77,10 @@ export class RefCounted implements Disposable { } return f; } - registerEventListener(target: EventTarget, eventType: string, listener: any, arg?: any) { - target.addEventListener(eventType, listener, arg); - this.registerDisposer(() => target.removeEventListener(eventType, listener, arg)); + registerEventListener( + target: EventTarget, type: string, listener: EventListenerOrEventListenerObject, + options?: boolean|AddEventListenerOptions) { + this.registerDisposer(registerEventListener(target, type, listener, options)); } registerCancellable void}>(cancellable: T) { this.registerDisposer(() => { diff --git a/src/neuroglancer/util/event_action_map.ts b/src/neuroglancer/util/event_action_map.ts new file mode 100644 index 000000000..2764450a6 --- /dev/null +++ b/src/neuroglancer/util/event_action_map.ts @@ -0,0 +1,327 @@ +/** + * @license + * Copyright 2017 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {registerEventListener} from 'neuroglancer/util/disposable'; +import {HierarchicalMap, HierarchicalMapInterface} from 'neuroglancer/util/hierarchical_map'; + +/** + * @file Facilities for dispatching user-defined actions in response to input events. + */ + +/** + * Specifies a unique string representation of an input event, used for matching an input event to a + * corresponding action with which it has been associated. + * + * The EventIdentifier combines several pieces of information using the following syntax: + * + * NormalizedEventIdentifier ::= phase ':' ( modifier '+' )* base-event-identifier + * + * - The event `phase` name, corresponding to the phase of DOM event processing at which the event was + * received, which may be 'at', 'bubble', or 'capture'. (Currently, 'capture' is not supported.) + * + * - The set of `modifier` keys ('control', 'alt', 'meta', and/or 'shift') active when the event occurred. + * + * - The `base-event-identifier`, which in the case of keyboard events is the lowercase KeyboardEvent + * `code`, and in the case of mouse events is one of: + * + * - 'mousedown' + n + * - 'mouseup' + n + * - 'click' + n + * - 'dblclick' + n + * - 'wheel' + * + * where `n` is the index of the mouse button, starting from 0. + * + * In the normalized form used for matching events, the set of modifiers must be specified in + * exactly the order: 'control', 'alt', 'meta', 'shift'. Consequently, there is exactly one + * NormalizedEventIdentifier representation for a given input event. + */ +export type NormalizedEventIdentifier = string; + +/** + * An EventIdentifier specifies a criteria for matching input events using a relaxed form of the + * NormalizedEventIdentifier syntax. Each EventIdentifier corresponds to one or more + * NormalizedEventIdentifier values. + * + * EventIdentifier ::= [ phase ':' ] ( modifier '+' )* base-event-identifier + * + * In addition to the phase being optional, the modifiers may be specified in any order. If the + * phase is not specified, then the EventIdentifier matches both the 'at' and 'bubble' phases. + */ +export type EventIdentifier = string; + +/** + * Identifies a user-defined action name. Actions are dispatched as DOM events, using 'action:' + * prepended to the ActionIdentifier as the event type. + */ +export type ActionIdentifier = string; + +/** + * Specifies how to handle an event. + */ +export interface EventAction { + /** + * Identifier of action to dispatch. + */ + action: ActionIdentifier; + + /** + * Whether to call `stopPropagation()` on the triggering event. Defaults to true. + */ + stopPropagation?: boolean; + + /** + * Whether to call `preventDefault()` on the triggering event. Defaults to true. Additionally, + * if `preventDefault()` is called on the dispatched ActionEvent, `preventDefault()` will also be + * called on the triggering event regardless of the value of `preventDefault`. + */ + preventDefault?: boolean; +} + +export type EventActionMapInterface = + HierarchicalMapInterface; + +export const enum Modifiers { + CONTROL = 1, + ALT = 2, + META = 4, + SHIFT = 8, +} + +export type ModifierMask = number; + +export interface EventModifierKeyState { + ctrlKey: boolean; + altKey: boolean; + metaKey: boolean; + shiftKey: boolean; +} + +export function getEventModifierMask(event: EventModifierKeyState): ModifierMask { + return (event.ctrlKey ? Modifiers.CONTROL : 0) | (event.altKey ? Modifiers.ALT : 0) | + (event.metaKey ? Modifiers.META : 0) | (event.shiftKey ? Modifiers.SHIFT : 0); +} + +export function getStrokeIdentifier(keyName: string, modifiers: ModifierMask) { + let identifier = ''; + if (modifiers & Modifiers.CONTROL) { + identifier += 'control+'; + } + if (modifiers & Modifiers.ALT) { + identifier += 'alt+'; + } + if (modifiers & Modifiers.META) { + identifier += 'meta+'; + } + if (modifiers & Modifiers.SHIFT) { + identifier += 'shift+'; + } + identifier += keyName; + return identifier; +} + +function normalizeModifiersAndBaseIdentifier(identifier: string): string|undefined { + let parts = identifier.split('+'); + let keyName: string|undefined; + let modifiers = 0; + for (let part of parts) { + switch (part) { + case 'control': + modifiers |= Modifiers.CONTROL; + break; + case 'alt': + modifiers |= Modifiers.ALT; + break; + case 'meta': + modifiers |= Modifiers.META; + break; + case 'shift': + modifiers |= Modifiers.SHIFT; + break; + default: + if (keyName === undefined) { + keyName = part; + } else { + return undefined; + } + } + } + if (keyName === undefined) { + return undefined; + } + return getStrokeIdentifier(keyName, modifiers); +} + +/** + * Specifies either an EventAction or a bare ActionIdentifier. + */ +type ActionOrEventAction = EventAction|ActionIdentifier; + +/** + * Normalizes an ActionOrEventAction into an EventAction. + */ +export function normalizeEventAction(action: ActionOrEventAction): EventAction { + if (typeof action === 'string') { + return {action: action}; + } + return action; +} + +/** + * Normalizes a user-specified EventIdentifier into a list of one or more corresponding + * NormalizedEventIdentifier strings. + */ +export function* + normalizeEventIdentifier(identifier: EventIdentifier): + IterableIterator { + const firstColonOffset = identifier.indexOf(':'); + const suffix = + normalizeModifiersAndBaseIdentifier(identifier.substring(firstColonOffset + 1)); + if (suffix === undefined) { + throw new Error(`Invalid event identifier: ${JSON.stringify(identifier)}`); + } + if (firstColonOffset !== -1) { + const prefix = identifier.substring(0, firstColonOffset); + // TODO(jbms): Support capture phase. + if (prefix !== 'at' && prefix !== 'bubble') { + throw new Error(`Invalid event phase: ${JSON.stringify(prefix)}`); + } + yield`${prefix}:${suffix}`; + } else { + yield`at:${suffix}`; + yield`bubble:${suffix}`; + } +} + +/** + * Hierarchical map of `EventIdentifier` specifications to `EventAction` specifications. These maps + * are used by KeyboardEventBinder and MouseEventBinder to dispatch an ActionEvent in response to an + * input event. + */ +export class EventActionMap extends HierarchicalMap + implements EventActionMapInterface { + label: string|undefined; + + /** + * Returns a new EventActionMap with the specified bindings. + * + * The keys of the `bindings` object specify unnormalized event identifiers to be mapped to their + * corresponding `ActionOrEventAction` values. + */ + static fromObject( + bindings: {[key: string]: ActionOrEventAction}, + options: {label?: string, parents?: Iterable<[EventActionMap, number]>} = {}) { + const map = new EventActionMap(); + map.label = options.label; + if (options.parents !== undefined) { + for (const [parent, priority] of options.parents) { + map.addParent(parent, priority); + } + } + for (const key of Object.keys(bindings)) { + map.set(key, normalizeEventAction(bindings[key])); + } + return map; + } + + setFromObject(bindings: {[key: string]: ActionOrEventAction}) { + for (const key of Object.keys(bindings)) { + this.set(key, normalizeEventAction(bindings[key])); + } + } + + /** + * Maps the specified event `identifier` to the specified `action`. + * + * The `identifier` may be unnormalized; the actual mapping is created for each corresponding + * normalized identifier. + */ + set(identifier: EventIdentifier, action: ActionOrEventAction) { + const normalizedAction = normalizeEventAction(action); + for (const normalizedIdentifier of normalizeEventIdentifier(identifier)) { + super.set(normalizedIdentifier, normalizedAction); + } + } + + /** + * Deletes the mapping for the specified `identifier`. + * + * The `identifier` may be unnormalized; the mapping is deleted for each corresponding normalized + * identifier. + */ + delete(identifier: EventIdentifier) { + for (const normalizedIdentifier of normalizeEventIdentifier(identifier)) { + super.delete(normalizedIdentifier); + } + } +} + +export function dispatchEventAction(originalEvent: Event, eventAction: EventAction|undefined) { + if (eventAction === undefined) { + return; + } + if (eventAction.stopPropagation !== false) { + originalEvent.stopPropagation(); + } + const actionEvent = + new CustomEvent('action:' + eventAction.action, {'bubbles': true, detail: originalEvent}); + const cancelled = !originalEvent.target.dispatchEvent(actionEvent); + if (eventAction.preventDefault !== false || cancelled) { + originalEvent.preventDefault(); + } +} + +export const eventPhaseNames: string[] = []; +eventPhaseNames[Event.AT_TARGET] = 'at'; +eventPhaseNames[Event.CAPTURING_PHASE] = 'capture'; +eventPhaseNames[Event.BUBBLING_PHASE] = 'bubble'; + +export function getPhaseName(event: Event) { + return eventPhaseNames[event.eventPhase]; +} + +export function dispatchEvent( + baseIdentifier: EventIdentifier, originalEvent: Event&EventModifierKeyState, + eventMap: EventActionMapInterface) { + const eventIdentifier = getPhaseName(originalEvent) + ':' + + getStrokeIdentifier(baseIdentifier, getEventModifierMask(originalEvent)); + const eventAction = eventMap.get(eventIdentifier); + dispatchEventAction(originalEvent, eventAction); +} + +/** + * DOM Event type used for dispatching actions. + * + * The original input event that triggered the action is specified as the `detail` property. + */ +export interface ActionEvent extends CustomEvent { + detail: TriggerEvent; +} + +/** + * Register an event listener for the specified `action`. + * + * There is no checking that the `TriggerEvent` type is suitable for use with the specified + * `action`. + * + * @returns A nullary disposer function that unregisters the listener when called. + */ +export function registerActionListener( + target: EventTarget, action: ActionIdentifier, + listener: (event: ActionEvent) => void, + options?: boolean|AddEventListenerOptions) { + return registerEventListener(target, `action:${action}`, listener, options); +} diff --git a/src/neuroglancer/util/hierarchical_map.ts b/src/neuroglancer/util/hierarchical_map.ts new file mode 100644 index 000000000..5cf11761b --- /dev/null +++ b/src/neuroglancer/util/hierarchical_map.ts @@ -0,0 +1,172 @@ +/** + * @license + * Copyright 2017 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @file Hierarchical mapping from keys to values. + */ + +export interface HierarchicalMapInterface { get(key: Key): Value|undefined; } + +/** + * Maps string event identifiers to string action identifiers. + * + * When an event identifier is looked up in a given HierarchicalMap, it is resolved to a + * corresponding action identifier in one of two ways: + * + * 1. via mappings defined directly on the HierarchicalMap. + * + * 2. via a recursive lookup on a "parent" HierarchicalMap that has been specified for the root + * HierarchicalMap on which the lookup was initiated. + * + * HierarchicalMap objects may be specified as "parents" of another HierarchicalMap along with a + * specified numerical priority value, such that there is a directed graph of HierarchicalMap + * objects. Cycles in this graph may lead to infinite looping. + * + * Recursive lookups in parent HierarchicalMap objects are performed in order of decreasing + * priority. The lookup stops as soon as a mapping is found. Direct bindings have a priority of 0. + * Therefore, parent maps with a priority higher than 0 take precedence over direct bindings. + */ +export class HierarchicalMap = + HierarchicalMapInterface> + implements HierarchicalMapInterface { + parents = new Array(); + private parentPriorities = new Array(); + bindings = new Map(); + + /** + * If an existing HierarchicalMap is specified, a shallow copy is made. + * + * @param existing Existing map to make a shallow copy of. + */ + constructor(existing?: HierarchicalMap) { + if (existing !== undefined) { + this.parents.push(...existing.parents); + this.parentPriorities.push(...existing.parentPriorities); + for (const [k, v] of existing.bindings) { + this.bindings.set(k, v); + } + } + } + + /** + * Register `parent` as a parent map. If `priority > 0`, this map will take precedence over + * direct bindings. + * + * @returns A nullary function that unregisters the parent (and may be called at most once). + */ + addParent(parent: Parent, priority: number) { + const {parents, parentPriorities} = this; + let index = 0; + const {length} = parents; + while (index < length && priority < parentPriorities[index]) { + ++index; + } + parents.splice(index, 0, parent); + parentPriorities.splice(index, 0, priority); + + return () => { + this.removeParent(parent); + }; + } + + /** + * Unregisters `parent` as a parent. + */ + removeParent(parent: Parent) { + const index = this.parents.indexOf(parent); + if (index === -1) { + throw new Error(`Attempt to remove non-existent parent map.`); + } + this.parents.splice(index, 1); + this.parentPriorities.splice(index, 1); + } + + /** + * Register a direct binding. + */ + set(key: Key, value: Value) { + this.bindings.set(key, value); + } + + /** + * Unregister a direct binding. + */ + delete(key: Key) { + this.bindings.delete(key); + } + + /** + * Deletes all bindings, including parents. + */ + clear() { + this.bindings.clear(); + this.parents.length = 0; + this.parentPriorities.length = 0; + } + + /** + * Lookup the highest priority value to which the specified key is mapped. + */ + get(key: Key): Value|undefined { + const {parents, parentPriorities} = this; + const numParents = parentPriorities.length; + let parentIndex = 0; + let value; + for (; parentIndex < numParents && parentPriorities[parentIndex] > 0; ++parentIndex) { + value = parents[parentIndex].get(key); + if (value !== undefined) { + return value; + } + } + value = this.bindings.get(key); + if (value !== undefined) { + return value; + } + for (; parentIndex < numParents; ++parentIndex) { + value = parents[parentIndex].get(key); + if (value !== undefined) { + return value; + } + } + return undefined; + } + + /** + * Find all values to which the specified key is mapped. + */ + * getAll(key: Key): IterableIterator { + const {parents, parentPriorities} = this; + const numParents = parentPriorities.length; + let parentIndex = 0; + let value; + while (parentIndex < numParents && parentPriorities[parentIndex] > 0) { + value = parents[parentIndex].get(key); + if (value !== undefined) { + yield value; + } + } + value = this.bindings.get(key); + if (value !== undefined) { + yield value; + } + while (parentIndex < numParents) { + value = parents[parentIndex].get(key); + if (value !== undefined) { + yield value; + } + } + } +} diff --git a/src/neuroglancer/util/keyboard_bindings.ts b/src/neuroglancer/util/keyboard_bindings.ts new file mode 100644 index 000000000..92b2a6564 --- /dev/null +++ b/src/neuroglancer/util/keyboard_bindings.ts @@ -0,0 +1,105 @@ +/** + * @license + * Copyright 2016 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @file Facility for triggering named actions in response to keyboard events. + */ + +// This is based on goog/ui/keyboardshortcuthandler.js in the Google Closure library. + +import {RefCounted} from 'neuroglancer/util/disposable'; +import {ActionEvent, dispatchEvent, EventActionMap, EventActionMapInterface, registerActionListener} from 'neuroglancer/util/event_action_map'; + +const globalKeys = new Set( + ['f1', 'f2', 'f3', 'f4', 'f5', 'f6', 'f7', 'f8', 'f9', 'f10', 'f11', 'f12', 'escape', 'pause']); +const DEFAULT_TEXT_INPUTS = new Set([ + 'color', 'date', 'datetime', 'datetime-local', 'email', 'month', 'number', 'password', 'search', + 'tel', 'text', 'time', 'url', 'week' +]); + +export class KeyboardEventBinder extends RefCounted { + modifierShortcutsAreGlobal = true; + allShortcutsAreGlobal = false; + allowSpaceKeyOnButtons = false; + constructor(public target: EventTarget, public eventMap: EventMap) { + super(); + this.registerEventListener( + target, 'keydown', this.handleKeyDown.bind(this), /*useCapture=*/false); + } + + private shouldIgnoreEvent(key: string, event: KeyboardEvent) { + var el = event.target; + let {tagName} = el; + if (el === this.target) { + // If the event is directly on the target element, we never ignore it. + return false; + } + var isFormElement = tagName === 'TEXTAREA' || tagName === 'INPUT' || tagName === 'BUTTON' || + tagName === 'SELECT'; + + var isContentEditable = !isFormElement && + (el.isContentEditable || (el.ownerDocument && el.ownerDocument.designMode === 'on')); + + if (!isFormElement && !isContentEditable) { + return false; + } + // Always allow keys registered as global to be used (typically Esc, the + // F-keys and other keys that are not typically used to manipulate text). + if (this.allShortcutsAreGlobal || globalKeys.has(key)) { + return false; + } + if (isContentEditable) { + // For events originating from an element in editing mode we only let + // global key codes through. + return true; + } + // Event target is one of (TEXTAREA, INPUT, BUTTON, SELECT). + // Allow modifier shortcuts, unless we shouldn't. + if (this.modifierShortcutsAreGlobal && (event.altKey || event.ctrlKey || event.metaKey)) { + return true; + } + // Allow ENTER to be used as shortcut for text inputs. + if (tagName === 'INPUT' && DEFAULT_TEXT_INPUTS.has((el).type)) { + return key !== 'enter'; + } + // Checkboxes, radiobuttons and buttons. Allow all but SPACE as shortcut. + if (tagName === 'INPUT' || tagName === 'BUTTON') { + // TODO(gboyer): If more flexibility is needed, create protected helper + // methods for each case (e.g. button, input, etc). + if (this.allowSpaceKeyOnButtons) { + return false; + } else { + return key === 'space'; + } + } + // Don't allow any additional shortcut keys for textareas or selects. + return true; + } + + private handleKeyDown(event: KeyboardEvent) { + const key = getEventKeyName(event); + if (this.shouldIgnoreEvent(key, event)) { + return; + } + dispatchEvent(key, event, this.eventMap); + } +} + +export function getEventKeyName(event: KeyboardEvent): string { + return event.code.toLowerCase(); +} + +export {EventActionMapInterface, EventActionMap, registerActionListener, ActionEvent}; diff --git a/src/neuroglancer/util/keyboard_shortcut_handler.ts b/src/neuroglancer/util/keyboard_shortcut_handler.ts deleted file mode 100644 index 29ddc7561..000000000 --- a/src/neuroglancer/util/keyboard_shortcut_handler.ts +++ /dev/null @@ -1,295 +0,0 @@ -/** - * @license - * Copyright 2016 Google Inc. - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// This is based on goog/ui/keyboardshortcuthandler.js in the Google Closure library. - -import {RefCounted} from 'neuroglancer/util/disposable'; - -type Handler = (action: string) => boolean; - -const MAX_KEY_SEQUENCE_DELAY = 1500; // 1.5 sec - -const globalKeys = new Set( - ['f1', 'f2', 'f3', 'f4', 'f5', 'f6', 'f7', 'f8', 'f9', 'f10', 'f11', 'f12', 'escape', 'pause']); -const DEFAULT_TEXT_INPUTS = new Set([ - 'color', 'date', 'datetime', 'datetime-local', 'email', 'month', 'number', 'password', 'search', - 'tel', 'text', 'time', 'url', 'week' -]); - -export class KeyboardShortcutHandler extends RefCounted { - private currentNode: KeyStrokeMap; - private lastStrokeTime: number; - modifierShortcutsAreGlobal = true; - allShortcutsAreGlobal = false; - allowSpaceKeyOnButtons = false; - constructor( - public target: EventTarget, public keySequenceMap: KeySequenceMap, public handler: Handler) { - super(); - this.reset(); - this.registerEventListener( - target, 'keydown', this.handleKeyDown.bind(this), /*useCapture=*/true); - } - - private reset() { - this.currentNode = this.keySequenceMap.root; - this.lastStrokeTime = Number.NEGATIVE_INFINITY; - } - - setKeySequenceMap(keySequenceMap: KeySequenceMap) { - this.keySequenceMap = keySequenceMap; - this.reset(); - } - - private shouldIgnoreEvent(key: string, event: KeyboardEvent) { - var el = event.target; - let {tagName} = el; - if (el === this.target) { - // If the event is directly on the target element, we never ignore it. - return false; - } - var isFormElement = tagName === 'TEXTAREA' || tagName === 'INPUT' || tagName === 'BUTTON' || - tagName === 'SELECT'; - - var isContentEditable = !isFormElement && - (el.isContentEditable || (el.ownerDocument && el.ownerDocument.designMode === 'on')); - - if (!isFormElement && !isContentEditable) { - return false; - } - // Always allow keys registered as global to be used (typically Esc, the - // F-keys and other keys that are not typically used to manipulate text). - if (this.allShortcutsAreGlobal || globalKeys.has(key)) { - return false; - } - if (isContentEditable) { - // For events originating from an element in editing mode we only let - // global key codes through. - return true; - } - // Event target is one of (TEXTAREA, INPUT, BUTTON, SELECT). - // Allow modifier shortcuts, unless we shouldn't. - if (this.modifierShortcutsAreGlobal && (event.altKey || event.ctrlKey || event.metaKey)) { - return true; - } - // Allow ENTER to be used as shortcut for text inputs. - if (tagName === 'INPUT' && DEFAULT_TEXT_INPUTS.has((el).type)) { - return key !== 'enter'; - } - // Checkboxes, radiobuttons and buttons. Allow all but SPACE as shortcut. - if (tagName === 'INPUT' || tagName === 'BUTTON') { - // TODO(gboyer): If more flexibility is needed, create protected helper - // methods for each case (e.g. button, input, etc). - if (this.allowSpaceKeyOnButtons) { - return false; - } else { - return key === 'space'; - } - } - // Don't allow any additional shortcut keys for textareas or selects. - return true; - } - - private handleKeyDown(event: KeyboardEvent) { - let key = getEventKeyName(event); - if (this.shouldIgnoreEvent(key, event)) { - return; - } - let stroke = getStrokeIdentifier(key, getEventModifierMask(event)); - let root = this.keySequenceMap.root; - let {currentNode} = this; - let value = currentNode.get(stroke); - let now = Date.now(); - if (currentNode !== root && - (value === undefined || now > this.lastStrokeTime + MAX_KEY_SEQUENCE_DELAY)) { - this.currentNode = root; - value = currentNode.get(stroke); - } - if (value === undefined) { - return; - } - if (typeof value === 'string') { - // Terminal node. - this.reset(); - if (this.handler(value)) { - event.preventDefault(); - } - } else { - this.currentNode = value; - this.lastStrokeTime = now; - event.preventDefault(); - } - } -} - -export function getEventStrokeIdentifier(event: KeyboardEvent) { - return getStrokeIdentifier(getEventKeyName(event), getEventModifierMask(event)); -} - -type KeyStrokeMap = Map; - -type KeySequence = string|string[]; - -export type KeyStrokeIdentifier = string; - -const enum Modifiers { - CONTROL = 1, - ALT = 2, - META = 4, - SHIFT = 8, -} - -type ModifierMask = number; - -export function getEventModifierMask(event: KeyboardEvent) { - return (event.ctrlKey ? Modifiers.CONTROL : 0) | (event.altKey ? Modifiers.ALT : 0) | - (event.metaKey ? Modifiers.META : 0) | (event.shiftKey ? Modifiers.SHIFT : 0); -} - -export function getStrokeIdentifier(keyName: string, modifiers: ModifierMask) { - let identifier = ''; - if (modifiers & Modifiers.CONTROL) { - identifier += 'control+'; - } - if (modifiers & Modifiers.ALT) { - identifier += 'alt+'; - } - if (modifiers & Modifiers.META) { - identifier += 'meta+'; - } - if (modifiers & Modifiers.SHIFT) { - identifier += 'shift+'; - } - identifier += keyName; - return identifier; -} - -export function getEventKeyName(event: KeyboardEvent): string { - return event.code.toLowerCase(); -} - -export function parseKeyStroke(strokeIdentifier: string) { - strokeIdentifier = strokeIdentifier.toLowerCase().replace(' ', ''); - let parts = strokeIdentifier.split('+'); - let keyName: string|null|undefined; - let modifiers = 0; - for (let part of parts) { - switch (part) { - case 'control': - modifiers |= Modifiers.CONTROL; - break; - case 'alt': - modifiers |= Modifiers.ALT; - break; - case 'meta': - modifiers |= Modifiers.META; - break; - case 'shift': - modifiers |= Modifiers.SHIFT; - break; - default: - if (keyName === undefined) { - keyName = part; - } else { - keyName = null; - } - } - } - if (keyName == null) { - throw new Error(`Invalid stroke ${JSON.stringify(strokeIdentifier)}`); - } - return getStrokeIdentifier(keyName, modifiers); -} - -export function parseKeySequence(sequence: KeySequence) { - if (typeof sequence === 'string') { - let s = sequence; - s = s.replace(/[ +]*\+[ +]*/g, '+').replace(/[ ]+/g, ' ').toLowerCase(); - sequence = s.split(' '); - } - let parts = (sequence).map(parseKeyStroke); - if (parts.length === 0) { - throw new Error('Key sequence must not be empty'); - } - return parts; -} - -export function formatKeySequence(sequence: string[]) { - return JSON.stringify(sequence.join(' ')); -} - -interface Bindings { - [keySequenceSpec: string]: string; -} - -function* keySequenceMapEntries(map: Map, prefix: string[] = [ -]): IterableIterator<[string[], string]> { - for (let [key, value] of map) { - let newPrefix = [...prefix, key]; - if (typeof value === 'string') { - yield [newPrefix, value]; - } else { - yield* keySequenceMapEntries(value, newPrefix); - } - } -} - -export class KeySequenceMap { - root = new Map(); - constructor(bindings?: Bindings) { - if (bindings !== undefined) { - this.bindMultiple(bindings); - } - } - - bind(keySequenceSpec: KeySequence, action: string) { - let keySequence = parseKeySequence(keySequenceSpec); - let currentNode = this.root; - let prefixEnd = keySequence.length - 1; - for (let i = 0; i < prefixEnd; ++i) { - let stroke = keySequence[i]; - let value = currentNode.get(stroke); - if (value === undefined) { - value = new Map(); - currentNode.set(stroke, value); - } - if (typeof value === 'string') { - throw new Error( - `Error binding key sequence ${formatKeySequence(keySequence)}: ` + - `prefix ${formatKeySequence(keySequence.slice(0, i + 1))} ` + - `is already bound to action ${JSON.stringify(value)}`); - } - currentNode = value; - } - let stroke = keySequence[prefixEnd]; - let existingValue = currentNode.get(stroke); - if (existingValue !== undefined) { - throw new Error( - `Key sequence ${formatKeySequence(keySequence)} ` + - `is already bound to action ${JSON.stringify(existingValue)}`); - } - currentNode.set(stroke, action); - } - - bindMultiple(bindings: {[keySequenceSpec: string]: string}) { - for (let key of Object.keys(bindings)) { - this.bind(key, bindings[key]); - } - } - - entries() { - return keySequenceMapEntries(this.root); - } -} diff --git a/src/neuroglancer/util/mouse_bindings.ts b/src/neuroglancer/util/mouse_bindings.ts new file mode 100644 index 000000000..c2bb1376e --- /dev/null +++ b/src/neuroglancer/util/mouse_bindings.ts @@ -0,0 +1,48 @@ +/** + * @license + * Copyright 2017 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @file Facility for triggering named actions in response to mouse events. + */ + +import {RefCounted} from 'neuroglancer/util/disposable'; +import {ActionEvent, dispatchEvent, EventActionMap, EventActionMapInterface, registerActionListener} from 'neuroglancer/util/event_action_map'; + +export class MouseEventBinder extends RefCounted { + private dispatch(baseIdentifier: string, event: MouseEvent) { + dispatchEvent(baseIdentifier, event, this.eventMap); + } + constructor(public target: EventTarget, public eventMap: EventMap) { + super(); + this.registerEventListener(target, 'wheel', (event: WheelEvent) => { + this.dispatch('wheel', event); + }); + this.registerEventListener(target, 'click', (event: MouseEvent) => { + this.dispatch(`click${event.button}`, event); + }); + this.registerEventListener(target, 'dblclick', (event: MouseEvent) => { + this.dispatch(`dblclick${event.button}`, event); + }); + this.registerEventListener(target, 'mousedown', (event: MouseEvent) => { + this.dispatch(`mousedown${event.button}`, event); + }); + this.registerEventListener(target, 'mouseup', (event: MouseEvent) => { + this.dispatch(`mouseup${event.button}`, event); + }); + } +} + +export {EventActionMapInterface, EventActionMap, registerActionListener, ActionEvent}; diff --git a/src/neuroglancer/viewer.css b/src/neuroglancer/viewer.css index 2ddc72376..8e1cc7cc3 100644 --- a/src/neuroglancer/viewer.css +++ b/src/neuroglancer/viewer.css @@ -53,6 +53,6 @@ flex: 1; } -.gllayoutcell[isActivePanel=true] { +.gllayoutcell:focus { border-color: white; } diff --git a/src/neuroglancer/viewer.ts b/src/neuroglancer/viewer.ts index afa31984f..e2ce9abb4 100644 --- a/src/neuroglancer/viewer.ts +++ b/src/neuroglancer/viewer.ts @@ -18,7 +18,7 @@ import debounce from 'lodash/debounce'; import {AvailableCapacity} from 'neuroglancer/chunk_manager/base'; import {ChunkManager, ChunkQueueManager} from 'neuroglancer/chunk_manager/frontend'; import {DisplayContext} from 'neuroglancer/display_context'; -import {KeyBindingHelpDialog} from 'neuroglancer/help/key_bindings'; +import {InputEventBindingHelpDialog} from 'neuroglancer/help/input_event_bindings'; import {LayerManager, LayerSelectedValues, MouseSelectionState} from 'neuroglancer/layer'; import {LayerDialog} from 'neuroglancer/layer_dialog'; import {LayerPanel} from 'neuroglancer/layer_panel'; @@ -29,18 +29,19 @@ import {overlaysOpen} from 'neuroglancer/overlay'; import {PositionStatusPanel} from 'neuroglancer/position_status_panel'; import {TrackableBoolean} from 'neuroglancer/trackable_boolean'; import {TrackableValue} from 'neuroglancer/trackable_value'; +import {AutomaticallyFocusedElement} from 'neuroglancer/util/automatic_focus'; import {RefCounted} from 'neuroglancer/util/disposable'; import {removeFromParent} from 'neuroglancer/util/dom'; +import {registerActionListener} from 'neuroglancer/util/event_action_map'; import {vec3} from 'neuroglancer/util/geom'; -import {KeySequenceMap, KeyboardShortcutHandler} from 'neuroglancer/util/keyboard_shortcut_handler'; +import {EventActionMap, KeyboardEventBinder} from 'neuroglancer/util/keyboard_bindings'; import {NullarySignal} from 'neuroglancer/util/signal'; import {CompoundTrackable} from 'neuroglancer/util/trackable'; -import {DataDisplayLayout, LAYOUTS} from 'neuroglancer/viewer_layouts'; +import {DataDisplayLayout, InputEventBindings as DataPanelInputEventBindings, LAYOUTS} from 'neuroglancer/viewer_layouts'; import {ViewerState, VisibilityPrioritySpecification} from 'neuroglancer/viewer_state'; import {WatchableVisibilityPriority} from 'neuroglancer/visibility_priority/frontend'; import {GL} from 'neuroglancer/webgl/context'; import {RPC} from 'neuroglancer/worker_rpc'; -import {AutomaticallyFocusedElement} from 'neuroglancer/util/automatic_focus'; require('./viewer.css'); require('./help_button.css'); @@ -78,11 +79,16 @@ export class DataManagementContext extends RefCounted { } } +export class InputEventBindings extends DataPanelInputEventBindings { + global = new EventActionMap(); +} + export interface UIOptions { showHelpButton: boolean; showLayerDialog: boolean; showLayerPanel: boolean; showLocation: boolean; + inputEventBindings: InputEventBindings; } export interface ViewerOptions extends UIOptions, VisibilityPrioritySpecification { @@ -118,8 +124,6 @@ export class Viewer extends RefCounted implements ViewerState { return this.dataContext.chunkQueueManager; } - keyMap = new KeySequenceMap(); - keyCommands = new Map void>(); layerSpecification: LayerListSpecification; layoutName = new TrackableValue(LAYOUTS[0][0], validateLayoutName); @@ -134,6 +138,14 @@ export class Viewer extends RefCounted implements ViewerState { return this.options.visibility; } + get inputEventBindings() { + return this.options.inputEventBindings; + } + + get inputEventMap() { + return this.inputEventBindings.global; + } + visible = true; constructor(public display: DisplayContext, options: Partial = {}) { @@ -142,11 +154,17 @@ export class Viewer extends RefCounted implements ViewerState { const { dataContext = new DataManagementContext(display.gl), visibility = new WatchableVisibilityPriority(WatchableVisibilityPriority.VISIBLE), + inputEventBindings = { + global: new EventActionMap(), + sliceView: new EventActionMap(), + perspectiveView: new EventActionMap(), + }, } = options; this.registerDisposer(dataContext); - this.options = {...defaultViewerOptions, ...options, dataContext, visibility}; + this.options = + {...defaultViewerOptions, ...options, dataContext, visibility, inputEventBindings}; this.layerSpecification = new LayerListSpecification( this.layerManager, this.chunkManager, this.layerSelectedValues, @@ -217,55 +235,12 @@ export class Viewer extends RefCounted implements ViewerState { this.createDataDisplayLayout(element); } }); - - let {keyCommands} = this; - keyCommands.set('toggle-layout', function() { - this.toggleLayout(); - }); - keyCommands.set('snap', function() { - this.navigationState.pose.snap(); - }); - keyCommands.set('add-layer', function() { - this.layerPanel.addLayerMenu(); - return true; - }); - keyCommands.set('help', this.showHelpDialog); - - for (let i = 1; i <= 9; ++i) { - keyCommands.set('toggle-layer-' + i, function() { - let layerIndex = i - 1; - let layers = this.layerManager.managedLayers; - if (layerIndex < layers.length) { - let layer = layers[layerIndex]; - layer.setVisible(!layer.visible); - } - }); - } - - for (let command of ['recolor', 'clear-segments']) { - keyCommands.set(command, function() { - this.layerManager.invokeAction(command); - }); - } - - keyCommands.set('toggle-axis-lines', function() { - this.showAxisLines.toggle(); - }); - keyCommands.set('toggle-scale-bar', function() { - this.showScaleBar.toggle(); - }); - this.keyCommands.set('toggle-show-slices', function() { - this.showPerspectiveSliceViews.toggle(); - }); } private makeUI() { let {display, options} = this; let gridContainer = document.createElement('div'); gridContainer.setAttribute('class', 'gllayoutcontainer noselect'); - this.registerDisposer( - new KeyboardShortcutHandler(gridContainer, this.keyMap, this.onKeyCommand.bind(this))); - this.registerDisposer(new AutomaticallyFocusedElement(gridContainer)); let {container} = display; container.appendChild(gridContainer); this.registerDisposer(() => removeFromParent(gridContainer)); @@ -314,6 +289,48 @@ export class Viewer extends RefCounted implements ViewerState { }; updateVisibility(); this.registerDisposer(this.visibility.changed.add(updateVisibility)); + + { + const element = gridContainer; + this.registerDisposer(new KeyboardEventBinder(element, this.inputEventMap)); + this.registerDisposer(new AutomaticallyFocusedElement(element)); + + const bindAction = (action: string, handler: () => void) => { + registerActionListener(element, action, handler); + }; + + for (const action of ['recolor', 'clear-segments', ]) { + bindAction(action, () => { + this.layerManager.invokeAction(action); + }); + } + + for (const action of ['select', 'annotate', ]) { + bindAction(action, () => { + this.mouseState.updateUnconditionally(); + this.layerManager.invokeAction(action); + }); + } + + bindAction('toggle-layout', () => this.toggleLayout()); + bindAction('add-layer', () => this.layerPanel.addLayerMenu()); + bindAction('help', () => this.showHelpDialog()); + + for (let i = 1; i <= 9; ++i) { + bindAction(`toggle-layer-${i}`, () => { + const layerIndex = i - 1; + const layers = this.layerManager.managedLayers; + if (layerIndex < layers.length) { + let layer = layers[layerIndex]; + layer.setVisible(!layer.visible); + } + }); + } + + bindAction('toggle-axis-lines', () => this.showAxisLines.toggle()); + bindAction('toggle-scale-bar', () => this.showScaleBar.toggle()); + bindAction('toggle-show-slices', () => this.showPerspectiveSliceViews.toggle()); + } } createDataDisplayLayout(element: HTMLElement) { @@ -329,7 +346,12 @@ export class Viewer extends RefCounted implements ViewerState { } showHelpDialog() { - new KeyBindingHelpDialog(this.keyMap); + const {inputEventBindings} = this; + new InputEventBindingHelpDialog([ + ['Global', inputEventBindings.global], + ['Slice View', inputEventBindings.sliceView], + ['Perspective View', inputEventBindings.perspectiveView], + ]); } get gl() { @@ -348,18 +370,6 @@ export class Viewer extends RefCounted implements ViewerState { } } - private onKeyCommand(action: string) { - let command = this.keyCommands.get(action); - if (command && command.call(this)) { - return true; - } - let {activePanel} = this.display; - if (activePanel) { - return activePanel.onKeyCommand(action); - } - return false; - } - private handleNavigationStateChanged() { if (this.visible) { let {chunkQueueManager} = this.dataContext; diff --git a/src/neuroglancer/viewer_layouts.ts b/src/neuroglancer/viewer_layouts.ts index 0fa536784..c82be9a60 100644 --- a/src/neuroglancer/viewer_layouts.ts +++ b/src/neuroglancer/viewer_layouts.ts @@ -25,6 +25,7 @@ import {SliceViewPanel} from 'neuroglancer/sliceview/panel'; import {TrackableBoolean} from 'neuroglancer/trackable_boolean'; import {RefCounted} from 'neuroglancer/util/disposable'; import {removeChildren} from 'neuroglancer/util/dom'; +import {EventActionMap} from 'neuroglancer/util/event_action_map'; import {quat} from 'neuroglancer/util/geom'; import {VisibilityPrioritySpecification} from 'neuroglancer/viewer_state'; @@ -34,6 +35,11 @@ export interface SliceViewViewerState { layerManager: LayerManager; } +export class InputEventBindings { + perspectiveView = new EventActionMap(); + sliceView = new EventActionMap(); +} + export interface ViewerUIState extends SliceViewViewerState, VisibilityPrioritySpecification { display: DisplayContext; mouseState: MouseSelectionState; @@ -41,6 +47,7 @@ export interface ViewerUIState extends SliceViewViewerState, VisibilityPriorityS showPerspectiveSliceViews: TrackableBoolean; showAxisLines: TrackableBoolean; showScaleBar: TrackableBoolean; + inputEventBindings: InputEventBindings; } @@ -79,6 +86,22 @@ export function getCommonViewerState(viewer: ViewerUIState) { }; } +function getCommonPerspectiveViewerState(viewer: ViewerUIState) { + return { + ...getCommonViewerState(viewer), + navigationState: viewer.perspectiveNavigationState, + inputEventMap: viewer.inputEventBindings.perspectiveView, + }; +} + +function getCommonSliceViewerState(viewer: ViewerUIState) { + return { + ...getCommonViewerState(viewer), + navigationState: viewer.navigationState, + inputEventMap: viewer.inputEventBindings.sliceView, + }; +} + export class FourPanelLayout extends RefCounted { constructor(public rootElement: HTMLElement, public viewer: ViewerUIState) { super(); @@ -87,21 +110,18 @@ export class FourPanelLayout extends RefCounted { let {display} = viewer; const perspectiveViewerState = { - ...getCommonViewerState(viewer), - navigationState: viewer.perspectiveNavigationState, + ...getCommonPerspectiveViewerState(viewer), showSliceViews: viewer.showPerspectiveSliceViews, showSliceViewsCheckbox: true, }; const sliceViewerState = { - ...getCommonViewerState(viewer), - navigationState: viewer.navigationState, + ...getCommonSliceViewerState(viewer), showScaleBar: viewer.showScaleBar, }; const sliceViewerStateWithoutScaleBar = { - ...getCommonViewerState(viewer), - navigationState: viewer.navigationState, + ...getCommonSliceViewerState(viewer), showScaleBar: new TrackableBoolean(false, false), }; let mainDisplayContents = [ @@ -152,15 +172,13 @@ export class SliceViewPerspectiveTwoPanelLayout extends RefCounted { let {display} = viewer; const perspectiveViewerState = { - ...getCommonViewerState(viewer), - navigationState: viewer.perspectiveNavigationState, + ...getCommonPerspectiveViewerState(viewer), showSliceViews: viewer.showPerspectiveSliceViews, showSliceViewsCheckbox: true, }; const sliceViewerState = { - ...getCommonViewerState(viewer), - navigationState: viewer.navigationState, + ...getCommonSliceViewerState(viewer), showScaleBar: viewer.showScaleBar, }; @@ -195,8 +213,7 @@ export class SinglePanelLayout extends RefCounted { super(); let sliceView = makeSliceView(viewer); const sliceViewerState = { - ...getCommonViewerState(viewer), - navigationState: viewer.navigationState, + ...getCommonSliceViewerState(viewer), showScaleBar: viewer.showScaleBar, }; @@ -217,8 +234,7 @@ export class SinglePerspectiveLayout extends RefCounted { constructor(public rootElement: HTMLElement, public viewer: ViewerUIState) { super(); let perspectiveViewerState = { - ...getCommonViewerState(viewer), - navigationState: viewer.perspectiveNavigationState, + ...getCommonPerspectiveViewerState(viewer), showSliceViews: new TrackableBoolean(false, false), }; diff --git a/src/neuroglancer/widget/autocomplete.ts b/src/neuroglancer/widget/autocomplete.ts index 61357df3b..84decadd8 100644 --- a/src/neuroglancer/widget/autocomplete.ts +++ b/src/neuroglancer/widget/autocomplete.ts @@ -20,7 +20,7 @@ import {BasicCompletionResult, Completion, CompletionWithDescription} from 'neur import {RefCounted} from 'neuroglancer/util/disposable'; import {removeChildren, removeFromParent} from 'neuroglancer/util/dom'; import {positionDropdown} from 'neuroglancer/util/dropdown'; -import {KeyboardShortcutHandler, KeySequenceMap} from 'neuroglancer/util/keyboard_shortcut_handler'; +import {EventActionMap, KeyboardEventBinder, registerActionListener} from 'neuroglancer/util/keyboard_bindings'; import {longestCommonPrefix} from 'neuroglancer/util/longest_common_prefix'; import {scrollIntoViewIfNeeded} from 'neuroglancer/util/scroll_into_view'; import {Signal} from 'neuroglancer/util/signal'; @@ -57,48 +57,14 @@ export function makeCompletionElementWithDescription(completion: CompletionWithD return element; } -const KEY_MAP = new KeySequenceMap({ - 'arrowdown': 'cycle-next-active-completion', - 'arrowup': 'cycle-prev-active-completion', - 'tab': 'choose-active-completion-or-prefix', - 'enter': 'choose-active-completion', - 'escape': 'cancel', +const keyMap = EventActionMap.fromObject({ + 'arrowdown': {action: 'cycle-next-active-completion'}, + 'arrowup': {action: 'cycle-prev-active-completion'}, + 'tab': {action: 'choose-active-completion-or-prefix', preventDefault: false}, + 'enter': {action: 'choose-active-completion', preventDefault: false}, + 'escape': {action: 'cancel', preventDefault: false, stopPropagation: false}, }); -const KEY_COMMANDS = new Map boolean>([ - [ - 'cycle-next-active-completion', - function() { - this.cycleActiveCompletion(+1); - return true; - } - ], - [ - 'cycle-prev-active-completion', - function() { - this.cycleActiveCompletion(-1); - return true; - } - ], - [ - 'choose-active-completion-or-prefix', - function() { - return this.selectActiveCompletion(/*allowPrefix=*/true); - } - ], - [ - 'choose-active-completion', - function() { - return this.selectActiveCompletion(/*allowPrefix=*/false); - } - ], - [ - 'cancel', - function() { - return this.cancel(); - } - ], -]); export type Completer = (value: string, cancellationToken: CancellationToken) => Promise| null; @@ -121,7 +87,6 @@ export class AutocompleteTextInput extends RefCounted { private completionResult: CompletionResult|null = null; private dropdownContentsStale = true; private updateHintScrollPositionTimer: number|null = null; - private keyboardHandler: KeyboardShortcutHandler; private completionElements: HTMLElement[]|null = null; private hasResultForDropdown = false; private commonPrefix = ''; @@ -236,9 +201,35 @@ export class AutocompleteTextInput extends RefCounted { } }); - let keyboardHandler = this.keyboardHandler = this.registerDisposer( - new KeyboardShortcutHandler(inputElement, KEY_MAP, this.handleKeyCommand.bind(this))); + const keyboardHandler = this.registerDisposer(new KeyboardEventBinder(inputElement, keyMap)); keyboardHandler.allShortcutsAreGlobal = true; + + registerActionListener(inputElement, 'cycle-next-active-completion', () => { + this.cycleActiveCompletion(+1); + }); + + registerActionListener(inputElement, 'cycle-prev-active-completion', () => { + this.cycleActiveCompletion(-1); + }); + + registerActionListener( + inputElement, 'choose-active-completion-or-prefix', (event: CustomEvent) => { + if (this.selectActiveCompletion(/*allowPrefix=*/true)) { + event.preventDefault(); + } + }); + registerActionListener(inputElement, 'choose-active-completion', (event: CustomEvent) => { + if (this.selectActiveCompletion(/*allowPrefix=*/false)) { + event.preventDefault(); + } + }); + registerActionListener(inputElement, 'cancel', (event: CustomEvent) => { + event.stopPropagation(); + if (this.cancel()) { + event.detail.preventDefault(); + event.detail.stopPropagation(); + } + }); } private hintScrollPositionMayBeStale() { @@ -290,10 +281,6 @@ export class AutocompleteTextInput extends RefCounted { this.setActiveIndex(activeIndex); } - private handleKeyCommand(action: string) { - return KEY_COMMANDS.get(action)!.call(this); - } - private registerInputHandler() { const handler = (_event: Event) => { let value = this.inputElement.value;