1
0
mirror of https://github.com/osmarks/mycorrhiza.git synced 2024-10-30 11:46:16 +00:00
mycorrhiza/static/shortcuts.js
2021-06-13 23:37:21 +05:00

376 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(() => {
const $ = document.querySelector.bind(document);
const $$ = (...args) => Array.prototype.slice.call(document.querySelectorAll(...args));
const isMac = /Macintosh/.test(window.navigator.userAgent);
function keyEventToShortcut(event) {
let elideShift = event.key.toUpperCase() === event.key && event.shiftKey;
return (event.ctrlKey ? 'Ctrl+' : '') +
(event.altKey ? 'Alt+' : '') +
(event.metaKey ? 'Meta+' : '') +
(!elideShift && event.shiftKey ? 'Shift+' : '') +
(event.key === ',' ? 'Comma' : event.key === ' ' ? 'Space' : event.key);
}
function prettifyShortcut(shortcut) {
let keys = shortcut.split('+');
if (isMac) {
let cmdIdx = keys.indexOf('Meta');
if (cmdIdx !== -1 && keys.length - cmdIdx > 2) {
let tmp = keys[cmdIdx + 1];
keys[cmdIdx + 1] = 'Meta';
keys[cmdIdx] = tmp;
}
}
let lastKey = keys[keys.length - 1];
if (!keys.includes('Shift') && lastKey.toUpperCase() === lastKey && lastKey.toLowerCase() !== lastKey) {
keys.splice(keys.length - 1, 0, 'Shift');
}
for (let i = 0; i < keys.length; i++) {
if (isMac) {
switch (keys[i]) {
case 'Ctrl': keys[i] = '⌃'; break;
case 'Alt': keys[i] = '⌥'; break;
case 'Shift': keys[i] = '⇧'; break;
case 'Meta': keys[i] = '⌘'; break;
}
}
if (i === keys.length - 1 && i > 0 && keys[i].length === 1) {
keys[i] = keys[i].toUpperCase();
}
switch (keys[i]) {
case 'ArrowLeft': keys[i] = '←'; break;
case 'ArrowRight': keys[i] = '→'; break;
case 'ArrowTop': keys[i] = '↑'; break;
case 'ArrowBottom': keys[i] = '↓'; break;
case 'Comma': keys[i] = ','; break;
case 'Enter': keys[i] = '↩'; break;
case ' ': keys[i] = 'Space'; break;
}
keys[i] = `<kbd>${keys[i]}</kbd>`;
}
return keys.join(isMac ? '' : ' + ');
}
function isTextField(element) {
let name = element.nodeName.toLowerCase();
return name === 'textarea' ||
name === 'select' ||
(name === 'input' && !['submit', 'reset', 'checkbox', 'radio'].includes(element.type)) ||
element.isContentEditable;
}
let notTextField = event => !(event.target instanceof Node && isTextField(event.target));
let allShortcuts = [];
let shortcutsGroup = null;
class ShortcutHandler {
constructor(element, filter = () => true) {
this.element = element;
this.map = {};
this.active = this.map;
this.filter = filter;
this.timeout = null;
this.handleKeyDown = this.handleKeyDown.bind(this);
this.resetActive = this.resetActive.bind(this);
this.addEventListeners();
}
addEventListeners() {
this.element.addEventListener('keydown', this.handleKeyDown);
}
add(text, action, description = null) {
let shortcuts = text.split(',').map(shortcut => shortcut.trim().split(' '));
if (shortcutsGroup) {
shortcutsGroup.push({
action,
shortcut: text,
description,
})
}
for (let shortcut of shortcuts) {
let node = this.map;
for (let key of shortcut) {
if (!node[key]) {
node[key] = {};
}
node = node[key];
if (node.action) {
delete node.action;
delete node.shortcut;
delete node.description;
}
}
node.action = action;
node.shortcut = shortcut;
node.description = description;
}
}
groupStart() {
shortcutsGroup = [];
}
groupEnd() {
if (shortcutsGroup && shortcutsGroup.length) allShortcuts.push(shortcutsGroup);
shortcutsGroup = null;
}
fakeItem(shortcut, description = null) {
let list = shortcutsGroup || allShortcuts;
list.push({
shortcut: description ? shortcut : null,
description: description || shortcut,
});
}
handleKeyDown(event) {
if (event.defaultPrevented) return;
if (['Control', 'Alt', 'Shift', 'Meta'].includes(event.key)) return;
if (!this.filter(event)) return;
let shortcut = keyEventToShortcut(event);
if (!this.active[shortcut]) {
this.resetActive();
return;
}
this.active = this.active[shortcut];
if (this.active.action) {
this.active.action(event);
event.preventDefault();
this.resetActive();
return;
}
if (this.timeout) clearTimeout(this.timeout);
this.timeout = window.setTimeout(this.resetActive, 1500);
}
resetActive() {
this.active = this.map;
if (this.timeout) {
clearTimeout(this.timeout)
this.timeout = null;
}
}
}
function bindElementFactory(handler) {
return (shortcut, element, ...other) => {
element = typeof element === 'string' ? $(element) : element;
if (!element) return;
handler.add(shortcut, () => {
if (isTextField(element)) {
element.focus();
} else {
element.click();
}
}, ...other);
};
}
function bindLinkFactory(handler) {
return (shortcut, link, ...other) => handler.add(shortcut, () => window.location.href = link, ...other);
}
let prevActiveElement = null;
let shortcutsListDialog = null;
function openShortcutsReference() {
if (!shortcutsListDialog) {
let wrap = document.createElement('div');
wrap.className = 'dialog-wrap';
shortcutsListDialog = wrap;
let dialog = document.createElement('div');
dialog.className = 'dialog shortcuts-modal';
dialog.tabIndex = 0;
wrap.appendChild(dialog);
let dialogHeader = document.createElement('div');
dialogHeader.className = 'dialog__header';
dialog.appendChild(dialogHeader);
let title = document.createElement('h1');
title.className = 'dialog__title';
title.textContent = 'List of shortcuts';
dialogHeader.appendChild(title);
let closeButton = document.createElement('button');
closeButton.className = 'dialog__close-button';
closeButton.setAttribute('aria-label', 'Close this dialog');
dialogHeader.appendChild(closeButton);
for (let item of allShortcuts) {
if (item.description && !item.shortcut) {
let heading = document.createElement('h2');
heading.className = 'shortcuts-group-heading';
heading.textContent = item.description;
dialog.appendChild(heading);
} else {
let list = document.createElement('ul');
list.className = 'shortcuts-group';
for (let shortcut of item) {
let listItem = document.createElement('li');
listItem.className = 'shortcut-row';
list.appendChild(listItem);
let descriptionColumn = document.createElement('div')
descriptionColumn.className = 'shortcut-row__description';
descriptionColumn.textContent = shortcut.description;
listItem.appendChild(descriptionColumn);
let shortcutColumn = document.createElement('div');
shortcutColumn.className = 'shortcut-row__keys';
shortcutColumn.innerHTML = shortcut.shortcut.split(',')
.map(shortcuts => shortcuts.trim().split(' ').map(prettifyShortcut).join(' '))
.join(' or ');
listItem.appendChild(shortcutColumn);
}
dialog.appendChild(list);
}
}
let handleClose = (event) => {
event.preventDefault();
event.stopPropagation();
closeShortcutsReference();
};
let dialogShortcuts = new ShortcutHandler(dialog, notTextField);
dialogShortcuts.add('Escape', handleClose);
closeButton.addEventListener('click', handleClose);
wrap.addEventListener('click', handleClose);
dialog.addEventListener('click', event => event.stopPropagation());
document.body.appendChild(wrap);
}
document.body.overflow = 'hidden';
shortcutsListDialog.hidden = false;
prevActiveElement = document.activeElement;
shortcutsListDialog.children[0].focus();
}
function closeShortcutsReference() {
if (shortcutsListDialog) {
document.body.overflow = '';
shortcutsListDialog.hidden = true;
if (prevActiveElement) {
prevActiveElement.focus();
prevActiveElement = null;
}
}
}
window.addEventListener('load', () => {
let globalShortcuts = new ShortcutHandler(document, notTextField);
// Global shortcuts
let bindElement = bindElementFactory(globalShortcuts);
let bindLink = bindLinkFactory(globalShortcuts);
// * Common shortcuts
globalShortcuts.fakeItem('Common');
globalShortcuts.groupStart();
globalShortcuts.fakeItem('g 1 9', 'First 9 header links');
bindLink('g h', '/', 'Home');
bindLink('g l', '/list/', 'List of hyphae');
bindLink('g r', '/recent-changes/', 'Recent changes');
bindElement('g u', '.header-links__entry_user .header-links__link', 'Your profiles hypha');
globalShortcuts.groupEnd();
let headerLinks = $$('.header-links__link');
for (let i = 1; i <= headerLinks.length && i < 10; i++) {
bindElement(`g ${i}`, headerLinks[i-1], `Header link #${i}`);
}
// * Hypha shortcuts
if (typeof editTextarea === 'undefined') {
globalShortcuts.fakeItem('Hypha');
globalShortcuts.groupStart();
globalShortcuts.fakeItem('1 9', 'First 9 hyphas links');
bindElement('p, Alt+ArrowLeft', '.prevnext__prev', 'Next hypha');
bindElement('n, Alt+ArrowRight', '.prevnext__next', 'Previous hypha');
bindElement('s, Alt+ArrowTop', $$('.navi-title a').slice(1, -1).slice(-1)[0], 'Parent hypha');
bindElement('e', '.hypha-tabs__link[href^="/edit/"]', 'Edit this hypha');
globalShortcuts.groupEnd();
let hyphaLinks = $$('article .wikilink');
for (let i = 1; i <= hyphaLinks.length && i < 10; i++) {
bindElement(i.toString(), hyphaLinks[i-1], `Hypha link #${i}`);
}
}
// Hypha editor shortcuts
if (typeof editTextarea !== 'undefined') {
let editorShortcuts = new ShortcutHandler(editTextarea);
let bindElement = bindElementFactory(editorShortcuts);
let shortcuts = [
// Inspired by MS Word, Pages, Google Docs and Telegram desktop clients.
// And by myself, too.
// Win+Linux Mac Action Description
['Ctrl+b', 'Meta+b', wrapBold, 'Format: Bold'],
['Ctrl+i', 'Meta+i', wrapItalic, 'Format: Italic'],
['Ctrl+M', 'Meta+Shift+m', wrapMonospace, 'Format: Monospaced'],
['Ctrl+I', 'Meta+Shift+i', wrapHighlighted, 'Format: Highlight'],
['Ctrl+.', 'Meta+.', wrapLifted, 'Format: Superscript'],
['Ctrl+Comma', 'Meta+Comma', wrapLowered, 'Format: Subscript'],
// Strikethrough conflicts with 1Password on my machine but
// I'm probably the only Mycorrhiza user who uses 1Password. -handlerug
['Ctrl+X', 'Meta+Shift+x', wrapStrikethrough, 'Format: Strikethrough'],
['Ctrl+k', 'Meta+k', wrapLink, 'Format: Link'],
];
editorShortcuts.fakeItem('Editor');
editorShortcuts.groupStart();
for (let shortcut of shortcuts) {
if (isMac) {
editorShortcuts.add(shortcut[1], ...shortcut.slice(2))
} else {
editorShortcuts.add(shortcut[0], ...shortcut.slice(2))
}
}
editorShortcuts.groupEnd();
editorShortcuts.groupStart();
bindElement(isMac ? 'Meta+Enter' : 'Ctrl+Enter', $('.edit-form__save'), 'Save changes');
editorShortcuts.groupEnd();
// Help shortcut
editorShortcuts.add(isMac ? 'Meta+/' : 'Ctrl+/', openShortcutsReference);
}
// * Meta shortcuts
globalShortcuts.add('?', openShortcutsReference);
});
})();