diff --git a/README.md b/README.md index 8c8d9585a1..62fda26465 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 2373079133..04835137a1 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 ac71bc0d9e..7780b7107b 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 89f046ad12..f0002a3a1e 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 43bc1472c8..0000000000 --- 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 f87e9602e2..aa4cca0008 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 d08d5ee021..d30287666a 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 0000000000..9780c2dde7 --- /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 10bb26df39..0000000000 --- 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 f7033cf332..f3dd7c0d04 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 8e5ef4573f..63d3f83e8b 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 b4f9245036..bc9c1bed10 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 f8481b9c66..03c6f529d4 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 0000000000..b8c25c9886 --- /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 3fd45a9f19..18122f13a6 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 36c3936690..2ac511df20 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 0000000000..2764450a60 --- /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 0000000000..5cf11761b8 --- /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 0000000000..92b2a65648 --- /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 29ddc75610..0000000000 --- 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 0000000000..c2bb1376ee --- /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 2ddc723769..8e1cc7cc30 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 afa31984f3..e2ce9abb4e 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 0fa5367841..c82be9a609 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 61357df3ba..84decadd8a 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;