smoosic
Version:
<sub>[Github site](https://github.com/Smoosic/smoosic) | [source documentation](https://smoosic.github.io/Smoosic/release/docs/modules.html) | [change notes](https://aarondavidnewman.github.io/Smoosic/changes.html) | [application](https://smoosic.github.i
376 lines (354 loc) • 12.4 kB
text/typescript
// [Smoosic](https://github.com/AaronDavidNewman/Smoosic)
// Copyright (c) Aaron David Newman 2021.
import { buildDom, createTopDomContainer } from '../../common/htmlHelpers';
import { SvgBox } from '../../smo/data/common';
import { UndoBuffer } from '../../smo/xform/undo';
import { SuiDynamicsMenu } from './dynamics';
import { SuiBeamMenu, SuiBeamMenuOptions } from './beams';
import { layoutDebug } from '../../render/sui/layoutDebug';
import { SuiScoreViewOperations } from '../../render/sui/scoreViewOperations';
import { SuiKeySignatureDialog } from '../dialogs/keySignature';
import { SuiTracker } from '../../render/sui/tracker';
import { CompleteNotifier, ModalComponent } from '../common';
import { BrowserEventSource, EventHandler } from '../eventSource';
import { createAndDisplayDialog } from '../dialogs/dialog';
import { KeyBinding } from '../../application/common';
import { Qwerty } from '../qwerty';
import { SuiLanguageMenu } from './language';
import { SuiFileMenu } from './file';
import { SuiMenuBase, SuiMenuParams, suiMenuTranslation,
MenuTranslations, suiConfiguredMenuTranslate } from './menu';
import { SuiScoreMenu } from './score';
import { SuiStaffModifierMenu } from './staffModifier';
import { SuiMeasureMenu } from './measure';
import { SuiVoiceMenu } from './voices';
import { SuiNoteMenu } from './note';
import { SuiEditMenu } from './edit';
import { SuiTextMenu } from './text';
import { SuiPartSelectionMenu } from './partSelection';
import { SuiPartMenu } from './parts';
import {SuiTupletMenu} from "./tuplets";
declare var $: any;
/**
* @category SuiMenu
*/
export interface SuiMenuManagerParams {
view: SuiScoreViewOperations;
eventSource: BrowserEventSource;
completeNotifier: CompleteNotifier;
undoBuffer: UndoBuffer;
menuContainer?: HTMLElement;
}
/**
* Handle key-binding that map to menus
* @category SuiMenu
*/
export class SuiMenuManager {
view: SuiScoreViewOperations;
eventSource: BrowserEventSource;
completeNotifier: CompleteNotifier;
undoBuffer: UndoBuffer;
menuContainer: HTMLElement;
bound: boolean = false;
hotkeyBindings: Record<string, string> = {};
closeMenuPromise: Promise<void> | null = null;
menu: SuiMenuBase | null = null;
keydownHandler: EventHandler | null = null;
menuPosition: SvgBox = { x: 250, y: 40, width: 1, height: 1 };
tracker: SuiTracker;
menuBind: KeyBinding[] = SuiMenuManager.menuKeyBindingDefaults;
constructor(params: SuiMenuManagerParams) {
this.eventSource = params.eventSource;
this.view = params.view;
this.bound = false;
this.menuContainer = params.menuContainer ?? createTopDomContainer('.menuContainer');
this.completeNotifier = params.completeNotifier;
this.undoBuffer = params.undoBuffer;
this.tracker = params.view.tracker;
}
static get defaults() {
return {
menuBind: SuiMenuManager.menuKeyBindingDefaults,
menuContainer: '.menuContainer'
};
}
get closeModalPromise() {
return this.closeMenuPromise;
}
setController(c: CompleteNotifier) {
this.completeNotifier = c;
}
get score() {
return this.view.score;
}
// ### Description:
// slash ('/') menu key bindings. The slash key followed by another key brings up
// a menu.
static get menuKeyBindingDefaults(): KeyBinding[] {
return [
{
event: 'keydown',
key: 'p',
ctrlKey: false,
altKey: false,
shiftKey: false,
action: 'SuiPartMenu'
}, {
event: 'keydown',
key: 'l',
ctrlKey: false,
altKey: false,
shiftKey: false,
action: 'SuiStaffModifierMenu'
}, {
event: 'keydown',
key: 'd',
ctrlKey: false,
altKey: false,
shiftKey: false,
action: 'SuiDynamicsMenu'
}, {
event: 'keydown',
key: 'f',
ctrlKey: false,
altKey: false,
shiftKey: false,
action: 'SuiFileMenu'
}, {
event: 'keydown',
key: 'm',
ctrlKey: false,
altKey: false,
shiftKey: false,
action: 'SuiTimeSignatureMenu'
}, {
event: 'keydown',
key: 'a',
ctrlKey: false,
altKey: false,
shiftKey: false,
action: 'SuiMeasureMenu'
}, {
event: 'partSelection',
key: '',
ctrlKey: false,
altKey: false,
shiftKey: false,
action: 'SuiPartSelectionMenu'
}
];
}
get optionElements() {
return $('.menuContainer ul.menuElement li.menuOption');
}
_advanceSelection(inc: number) {
if (!this.menu) {
return;
}
const options = this.optionElements;
inc = inc < 0 ? options.length - 1 : 1;
this.menu.focusIndex = (this.menu.focusIndex + inc) % options.length;
$(options[this.menu.focusIndex]).find('a').trigger('focus');
}
unattach() {
if (!this.keydownHandler) {
return;
}
this.eventSource.unbindKeydownHandler(this.keydownHandler);
$('body').removeClass('modal');
$(this.menuContainer).html('');
$('body').off('dismissMenu');
this.bound = false;
this.menu = null;
}
attach() {
if (!this.menu) {
return;
}
let hotkey = 0;
$(this.menuContainer).html('');
$(this.menuContainer).attr('z-index', '12');
const b = buildDom;
const r = b('ul').classes('menuElement dropdown-menu rounded-3 shadow w-220px show').attr('size', this.menu.menuItems.length.toString())
.attr('role', 'menu')
.css('left', '' + this.menuPosition.x + 'px')
.css('top', '' + this.menuPosition.y + 'px');
this.menu.menuItems.forEach((item) => {
var vkey = (hotkey < 10) ? String.fromCharCode(48 + hotkey) :
String.fromCharCode(87 + hotkey);
r.append(
b('li').classes('menuOption').append(
b('a').attr('data-value', item.value).attr('href','#')
.attr('role', 'menuItem').classes('dropdown-item').append(
b('span').classes('menuText').text(item.text))
.append(b('span').classes('icon icon-' + item.icon))
.append(b('span').classes('menu-key').text('' + vkey))));
item.hotkey = vkey;
hotkey += 1;
});
$(this.menuContainer).append(r.dom());
$('body').addClass('modal');
this.bindEvents();
}
captureMenuEvents(completeNotifier: CompleteNotifier) {
var self = this;
if (this.closeMenuPromise) {
console.log('menu already open, skipping');
return;
}
this.bindEvents();
layoutDebug.addDialogDebug('slash menu creating closeMenuPromise');
// A menu asserts this event when it is done.
this.closeMenuPromise = new Promise((resolve) => {
$('body').off('menuDismiss').on('menuDismiss', () => {
layoutDebug.addDialogDebug('menuDismiss received, resolve closeMenuPromise');
self.unattach();
$('body').removeClass('slash-menu d-block');
self.closeMenuPromise = null;
resolve();
});
});
// take over the keyboard
if (this.closeModalPromise) {
completeNotifier.unbindKeyboardForModal(this as ModalComponent);
}
}
dismiss() {
$('body').trigger('menuDismiss');
}
displayMenu(menu: SuiMenuBase | null) {
this.menu = menu;
if (!this.menu) {
return;
}
this.menu.preAttach();
this.attach();
this.menu!.menuItems.forEach((item) => {
if (typeof(item.hotkey) !== 'undefined') {
this.hotkeyBindings[item.hotkey] = item.value;
}
});
setTimeout(() => {
const options = this.optionElements;
if (options.length > 0) {
$(options[options.length - 1]).find('a').trigger('focus');
}
}, 1);
}
createMenu(action: string, notifier: CompleteNotifier) {
this.captureMenuEvents(notifier);
if (!this.completeNotifier) {
return;
}
this.menuPosition = { x: 250, y: 40, width: 1, height: 1 };
// If we were called from the ribbon, we notify the controller that we are
// taking over the keyboard. If this was a key-based command we already did.
layoutDebug.addDialogDebug('createMenu creating ' + action);
const params: SuiMenuParams =
{
tracker: this.tracker,
score: this.score,
completeNotifier: this.completeNotifier,
closePromise: this.closeMenuPromise,
view: this.view,
eventSource: this.eventSource,
undoBuffer: this.undoBuffer,
ctor: action
};
if (action === 'SuiLanguageMenu') {
this.displayMenu(new SuiLanguageMenu(params));
} else if (action === 'SuiFileMenu') {
this.displayMenu(new SuiFileMenu(params));
} else if (action === 'SuiEditMenu') {
this.displayMenu(new SuiEditMenu(params));
} else if (action === 'SuiScoreMenu') {
this.displayMenu(new SuiScoreMenu(params));
} else if (action === 'SuiPartSelectionMenu') {
this.displayMenu(new SuiPartSelectionMenu(params));
} else if (action === 'SuiPartMenu') {
this.displayMenu(new SuiPartMenu(params));
} else if (action === 'SuiStaffModifierMenu') {
this.displayMenu(new SuiStaffModifierMenu(params));
} else if (action === 'SuiMeasureMenu') {
this.displayMenu(new SuiMeasureMenu(params));
} else if (action === 'SuiVoiceMenu') {
this.displayMenu(new SuiVoiceMenu(params));
} else if (action === 'SuiBeamMenu') {
this.displayMenu(new SuiBeamMenu(params));
} else if (action === 'SuiTupletMenu') {
this.displayMenu(new SuiTupletMenu(params));
} else if (action === 'SuiNoteMenu') {
this.displayMenu(new SuiNoteMenu(params));
} else if (action === 'SuiTextMenu') {
this.displayMenu(new SuiTextMenu(params));
}
}
// ### evKey
// We have taken over menu commands from controller. If there is a menu active, send the key
// to it. If there is not, see if the keystroke creates one. If neither, dismissi the menu.
evKey(event: any) {
Qwerty.handleKeyEvent(event);
if (['Tab', 'Enter'].indexOf(event.code) >= 0) {
return;
}
event.preventDefault();
if (event.code === 'Escape') {
this.dismiss();
}
if (this.menu) {
if (event.code === 'ArrowUp') {
this._advanceSelection(-1);
} else if (event.code === 'ArrowDown') {
this._advanceSelection(1);
} else if (this.hotkeyBindings[event.key]) {
$('a[data-value="' + this.hotkeyBindings[event.key] + '"]').click();
} else {
this.menu.keydown();
}
return;
}
const binding = this.menuBind.find((ev) =>
ev.key === event.key
);
if (!binding) {
// TODO: find a better place for the slash menus
if (event.key === 'k') {
createAndDisplayDialog(SuiKeySignatureDialog, {
view: this.view,
completeNotifier: this.completeNotifier,
startPromise: null,
eventSource: this.eventSource,
tracker: this.view.tracker,
ctor: 'SuiKeySignatureDialog',
id: 'key-signature-dialog',
modifier: null
});
}
this.dismiss();
return;
}
// this.createMenu(binding.action);
}
bindEvents() {
this.hotkeyBindings = { };
$('body').addClass('slash-menu d-block');
// We need to keep track of is bound, b/c the menu can be created from
// different sources.
if (!this.bound) {
const evkey = async (ev: any) => { this.evKey(ev); }
this.keydownHandler = this.eventSource.bindKeydownHandler(evkey);
this.bound = true;
}
$(this.menuContainer).find('a.dropdown-item').off('click').on('click', async (ev: any) => {
if ($(ev.currentTarget).attr('data-value') === 'cancel') {
this.menu!.complete();
return;
}
await this.menu!.selection(ev);
});
}
}
export const menuTranslationsInit = () => {
MenuTranslations.push(suiMenuTranslation(SuiDynamicsMenu.defaults, 'SuiDynamicsMenu'));
MenuTranslations.push(suiConfiguredMenuTranslate(SuiBeamMenuOptions, 'Beam', 'SuiBeamMenu'));
}