livebook/assets/js/hooks/keyboard_control.js
Zach Allaun 005a9e86f7
Usability improvements for custom keyboard controls (#2145)
Co-authored-by: José Valim <jose.valim@gmail.com>
2023-08-08 15:58:34 +02:00

155 lines
4.2 KiB
JavaScript

import { getAttributeOrThrow, parseBoolean } from "../lib/attribute";
import { cancelEvent, isEditableElement, isMacOS } from "../lib/utils";
/**
* A hook for ControlComponent to handle user keyboard interactions.
*
* ## Configuration
*
* * `data-cell-id` - id of the cell in which the control is rendered
*
* * `data-default-handlers` - whether keyboard events should be
* intercepted and canceled, disabling session shortcuts. Must be
* one of "off", "on", or "disable_only"
*
* * `data-keydown-enabled` - whether keydown events should be listened to
*
* * `data-keyup-enabled` - whether keyup events should be listened to
*
* * `data-target` - the target to send live events to
*/
const KeyboardControl = {
mounted() {
this.props = this.getProps();
this._handleDocumentKeyDown = this.handleDocumentKeyDown.bind(this);
this._handleDocumentKeyUp = this.handleDocumentKeyUp.bind(this);
this._handleDocumentFocus = this.handleDocumentFocus.bind(this);
// We intentionally register on window rather than document, to
// intercept events as early on as possible, even before the
// session shortcuts
window.addEventListener("keydown", this._handleDocumentKeyDown, true);
window.addEventListener("keyup", this._handleDocumentKeyUp, true);
// Note: the focus event doesn't bubble, so we register for the
// capture phase
window.addEventListener("focus", this._handleDocumentFocus, true);
},
updated() {
this.props = this.getProps();
},
destroyed() {
window.removeEventListener("keydown", this._handleDocumentKeyDown, true);
window.removeEventListener("keyup", this._handleDocumentKeyUp, true);
window.removeEventListener("focus", this._handleDocumentFocus, true);
},
getProps() {
return {
cellId: getAttributeOrThrow(this.el, "data-cell-id"),
defaultHandlers: getAttributeOrThrow(this.el, "data-default-handlers"),
isKeydownEnabled: getAttributeOrThrow(
this.el,
"data-keydown-enabled",
parseBoolean
),
isKeyupEnabled: getAttributeOrThrow(
this.el,
"data-keyup-enabled",
parseBoolean
),
target: getAttributeOrThrow(this.el, "data-target"),
};
},
handleDocumentKeyDown(event) {
if (
this.isKeyboardToggle(event) &&
!isEditableElement(document.activeElement)
) {
cancelEvent(event);
this.keyboardEnabled() ? this.disableKeyboard() : this.enableKeyboard();
return;
}
if (this.keyboardEnabled()) {
if (this.props.defaultHandlers !== "on") {
cancelEvent(event);
}
if (event.repeat) {
return;
}
if (this.props.isKeydownEnabled) {
const { key } = event;
this.pushEventTo(this.props.target, "keydown", { key });
}
}
},
handleDocumentKeyUp(event) {
if (this.keyboardEnabled()) {
if (this.props.defaultHandlers !== "on") {
cancelEvent(event);
}
if (this.props.isKeyupEnabled) {
const { key } = event;
this.pushEventTo(this.props.target, "keyup", { key });
}
}
},
handleDocumentFocus(event) {
if (this.props.isKeydownEnabled && isEditableElement(event.target)) {
this.disableKeyboard();
}
},
enableKeyboard() {
if (!this.keyboardEnabled()) {
this.pushEventTo(this.props.target, "enable_keyboard", {});
}
},
disableKeyboard() {
if (this.keyboardEnabled()) {
this.pushEventTo(this.props.target, "disable_keyboard", {});
}
},
keyboardEnabled() {
return this.props.isKeydownEnabled || this.props.isKeyupEnabled;
},
isKeyboardToggle(event) {
if (event.repeat) {
return false;
}
const { metaKey, ctrlKey, key } = event;
const cmd = isMacOS() ? metaKey : ctrlKey;
if (cmd && key === "k" && this.isCellFocused()) {
return (
!this.keyboardEnabled() ||
["on", "disable_only"].includes(this.props.defaultHandlers)
);
} else {
return false;
}
},
isCellFocused() {
const sessionEl = this.el.closest("[data-el-session]");
return (
sessionEl &&
sessionEl.getAttribute("data-js-focused-id") === this.props.cellId
);
},
};
export default KeyboardControl;