Control keybindings (#489)

* fixed inline settings textedit and added datepicker keybindings

* added dropdown keybindings

* added observable to make sure that keybindings aren't double registered

* added enter and escape keybindings for datepicker

* dropdown closure fix
This commit is contained in:
Cole Lashley 2024-03-22 17:53:27 -07:00 committed by GitHub
parent c0c53edb84
commit d3c48e3a3e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 227 additions and 81 deletions

View File

@ -23,6 +23,54 @@
"command": "generic:deleteItem",
"keys": ["Backspace", "Delete"]
},
{
"command": "generic:space",
"keys": ["Space"]
},
{
"command": "generic:tab",
"keys": ["Tab"]
},
{
"command": "generic:numpad-0",
"keys": ["0"]
},
{
"command": "generic:numpad-1",
"keys": ["1"]
},
{
"command": "generic:numpad-2",
"keys": ["2"]
},
{
"command": "generic:numpad-3",
"keys": ["3"]
},
{
"command": "generic:numpad-4",
"keys": ["4"]
},
{
"command": "generic:numpad-5",
"keys": ["5"]
},
{
"command": "generic:numpad-6",
"keys": ["6"]
},
{
"command": "generic:numpad-7",
"keys": ["7"]
},
{
"command": "generic:numpad-8",
"keys": ["8"]
},
{
"command": "generic:numpad-9",
"keys": ["9"]
},
{
"command": "generic:selectAbove",
"keys": ["ArrowUp"]
@ -31,6 +79,14 @@
"command": "generic:selectBelow",
"keys": ["ArrowDown"]
},
{
"command": "generic:selectLeft",
"keys": ["ArrowLeft"]
},
{
"command": "generic:selectRight",
"keys": ["ArrowRight"]
},
{
"command": "generic:selectPageAbove",
"keys": ["PageUp"]

View File

@ -1,9 +1,12 @@
import React, { useState, useEffect, useRef, createRef } from "react";
import * as mobx from "mobx";
import ReactDOM from "react-dom";
import dayjs from "dayjs";
import cs from "classnames";
import { Button } from "@/elements";
import { If } from "tsx-control-statements/components";
import { GlobalModel } from "@/models";
import { v4 as uuidv4 } from "uuid";
import "./datepicker.less";
@ -36,6 +39,10 @@ const DatePicker: React.FC<DatePickerProps> = ({ selectedDate, format = "MM/DD/Y
MM: selDate.format("MM"),
DD: selDate.format("DD"),
});
let curUuid = uuidv4();
let keybindsRegistered = mobx.observable.box(false, {
name: "datepicker-keybinds-registered",
});
useEffect(() => {
inputRefs.current = {
@ -273,6 +280,11 @@ const DatePicker: React.FC<DatePickerProps> = ({ selectedDate, format = "MM/DD/Y
setShowYearAccordion(false);
};
const closeModal = () => {
setIsOpen(false);
setShowYearAccordion(false);
};
const dayPickerModal = isOpen
? ReactDOM.createPortal(
<div ref={modalRef} className="day-picker-modal" style={calculatePosition()}>
@ -300,13 +312,13 @@ const DatePicker: React.FC<DatePickerProps> = ({ selectedDate, format = "MM/DD/Y
}
};
const handleArrowNavigation = (event, currentPart) => {
const handleArrowNavigation = (key, currentPart) => {
const currentIndex = formatParts.indexOf(currentPart);
let targetInput;
if (event.key === "ArrowLeft" && currentIndex > 0) {
if (key == "ArrowLeft" && currentIndex > 0) {
targetInput = inputRefs.current[formatParts[currentIndex - 1]].current;
} else if (event.key === "ArrowRight" && currentIndex < formatParts.length - 1) {
} else if (key == "ArrowRight" && currentIndex < formatParts.length - 1) {
targetInput = inputRefs.current[formatParts[currentIndex + 1]].current;
}
@ -315,51 +327,91 @@ const DatePicker: React.FC<DatePickerProps> = ({ selectedDate, format = "MM/DD/Y
}
};
const handleKeyDown = (event, currentPart) => {
const key = event.key;
const handleKeyDown = (event, currentPart) => {};
if (key === "ArrowLeft" || key === "ArrowRight") {
// Handle arrow navigation without selecting text
handleArrowNavigation(event, currentPart);
const handleFocus = (event, part) => {
event.target.select();
registerKeybindings(event, part);
};
const registerKeybindings = (event: any, part: string) => {
if (keybindsRegistered.get() == true) {
return;
}
if (key === " ") {
// Handle spacebar press to toggle the modal
mobx.action(() => {
keybindsRegistered.set(true);
})();
let keybindManager = GlobalModel.keybindManager;
let domain = "datepicker-" + curUuid + "-" + part;
keybindManager.registerKeybinding("control", domain, "generic:selectLeft", (waveEvent) => {
handleArrowNavigation("ArrowLeft", part);
return true;
});
keybindManager.registerKeybinding("control", domain, "generic:selectRight", (waveEvent) => {
handleArrowNavigation("ArrowRight", part);
return true;
});
keybindManager.registerKeybinding("control", domain, "generic:space", (waveEvent) => {
toggleModal();
return;
}
return true;
});
keybindManager.registerKeybinding("control", domain, "generic:confirm", (waveEvent) => {
toggleModal();
return true;
});
keybindManager.registerKeybinding("control", domain, "generic:cancel", (waveEvent) => {
closeModal();
return true;
});
keybindManager.registerKeybinding("control", domain, "generic:tab", (waveEvent) => {
handleArrowNavigation("ArrowRight", part);
return true;
});
for (let numpadKey = 0; numpadKey <= 9; numpadKey++) {
keybindManager.registerKeybinding(
"control",
domain,
"generic:numpad-" + numpadKey.toString(),
(waveEvent) => {
let currentPart = part;
const maxLength = currentPart === "YYYY" ? 4 : 2;
const newValue = event.target.value.length < maxLength ? event.target.value + numpadKey : numpadKey;
let selectionTimeoutId = null;
handleDatePartChange(currentPart, newValue);
if (key.match(/[0-9]/)) {
// Handle numeric keys
event.preventDefault();
const maxLength = currentPart === "YYYY" ? 4 : 2;
const newValue = event.target.value.length < maxLength ? event.target.value + key : key;
let selectionTimeoutId = null;
handleDatePartChange(currentPart, newValue);
// Clear any existing timeout
if (selectionTimeoutId !== null) {
clearTimeout(selectionTimeoutId);
}
// Clear any existing timeout
if (selectionTimeoutId !== null) {
clearTimeout(selectionTimeoutId);
}
// Re-focus and select the input after state update
selectionTimeoutId = setTimeout(() => {
event.target.focus();
event.target.select();
}, 0);
// Re-focus and select the input after state update
selectionTimeoutId = setTimeout(() => {
event.target.focus();
event.target.select();
}, 0);
return true;
}
);
}
};
const handleFocus = (event) => {
event.target.select();
const handleBlur = (event, part) => {
unregisterKeybindings(part);
};
const unregisterKeybindings = (part) => {
mobx.action(() => {
keybindsRegistered.set(false);
})();
let domain = "datepicker-" + curUuid + "-" + part;
GlobalModel.keybindManager.unregisterDomain(domain);
};
// Prevent use from selecting text in the input
const handleMouseDown = (event) => {
const handleMouseDown = (event, part) => {
event.preventDefault();
handleFocus(event);
handleFocus(event, part);
};
const handleIconKeyDown = (event) => {
@ -413,8 +465,9 @@ const DatePicker: React.FC<DatePickerProps> = ({ selectedDate, format = "MM/DD/Y
value={dateParts[part]}
onChange={(e) => handleDatePartChange(part, e.target.value)}
onKeyDown={(e) => handleKeyDown(e, part)}
onMouseDown={handleMouseDown}
onFocus={handleFocus}
onMouseDown={(e) => handleMouseDown(e, part)}
onFocus={(e) => handleFocus(e, part)}
onBlur={(e) => handleBlur(e, part)}
maxLength={part === "YYYY" ? 4 : 2}
className="date-input"
placeholder={part}

View File

@ -7,6 +7,8 @@ import { boundMethod } from "autobind-decorator";
import cn from "classnames";
import { If } from "tsx-control-statements/components";
import ReactDOM from "react-dom";
import { v4 as uuidv4 } from "uuid";
import { GlobalModel } from "@/models";
import "./dropdown.less";
@ -39,6 +41,7 @@ class Dropdown extends React.Component<DropdownProps, DropdownState> {
wrapperRef: React.RefObject<HTMLDivElement>;
menuRef: React.RefObject<HTMLDivElement>;
timeoutId: any;
curUuid: string;
constructor(props: DropdownProps) {
super(props);
@ -50,6 +53,7 @@ class Dropdown extends React.Component<DropdownProps, DropdownState> {
};
this.wrapperRef = React.createRef();
this.menuRef = React.createRef();
this.curUuid == uuidv4();
}
componentDidMount() {
@ -102,49 +106,76 @@ class Dropdown extends React.Component<DropdownProps, DropdownState> {
@boundMethod
handleFocus() {
this.setState({ isTouched: true });
this.registerKeybindings();
}
registerKeybindings() {
let keybindManager = GlobalModel.keybindManager;
let domain = "dropdown-" + this.curUuid;
keybindManager.registerKeybinding("control", domain, "generic:confirm", (waveEvent) => {
this.handleConfirm();
return true;
});
keybindManager.registerKeybinding("control", domain, "generic:space", (waveEvent) => {
this.handleConfirm();
return true;
});
keybindManager.registerKeybinding("control", domain, "generic:cancel", (waveEvent) => {
this.setState({ isOpen: false });
return true;
});
keybindManager.registerKeybinding("control", domain, "generic:selectAbove", (waveEvent) => {
const { isOpen } = this.state;
const { options } = this.props;
if (isOpen) {
this.setState((prevState) => ({
highlightedIndex:
prevState.highlightedIndex > 0 ? prevState.highlightedIndex - 1 : options.length - 1,
}));
}
return true;
});
keybindManager.registerKeybinding("control", domain, "generic:selectBelow", (waveEvent) => {
const { isOpen } = this.state;
const { options } = this.props;
if (isOpen) {
this.setState((prevState) => ({
highlightedIndex:
prevState.highlightedIndex < options.length - 1 ? prevState.highlightedIndex + 1 : 0,
}));
}
return true;
});
keybindManager.registerKeybinding("control", domain, "generic:tab", (waveEvent) => {
this.setState({ isOpen: false });
return true;
});
}
handleConfirm() {
const { options } = this.props;
const { isOpen, highlightedIndex } = this.state;
if (isOpen) {
const option = options[highlightedIndex];
if (option) {
this.handleSelect(option.value, undefined);
}
} else {
this.toggleDropdown();
}
}
handleBlur() {
this.unregisterKeybindings();
}
unregisterKeybindings() {
let domain = "dropdown-" + this.curUuid;
GlobalModel.keybindManager.unregisterDomain(domain);
}
@boundMethod
handleKeyDown(event: React.KeyboardEvent) {
const { options } = this.props;
const { isOpen, highlightedIndex } = this.state;
switch (event.key) {
case "Enter":
case " ":
if (isOpen) {
const option = options[highlightedIndex];
if (option) {
this.handleSelect(option.value, undefined);
}
} else {
this.toggleDropdown();
}
break;
case "Escape":
this.setState({ isOpen: false });
break;
case "ArrowUp":
if (isOpen) {
this.setState((prevState) => ({
highlightedIndex:
prevState.highlightedIndex > 0 ? prevState.highlightedIndex - 1 : options.length - 1,
}));
}
break;
case "ArrowDown":
if (isOpen) {
this.setState((prevState) => ({
highlightedIndex:
prevState.highlightedIndex < options.length - 1 ? prevState.highlightedIndex + 1 : 0,
}));
}
break;
case "Tab":
this.setState({ isOpen: false });
break;
}
}
handleKeyDown(event: React.KeyboardEvent) {}
@boundMethod
handleSelect(value: string, event?: React.MouseEvent | React.KeyboardEvent) {
@ -228,7 +259,8 @@ class Dropdown extends React.Component<DropdownProps, DropdownState> {
tabIndex={0}
onKeyDown={this.handleKeyDown}
onClick={this.handleClick}
onFocus={this.handleFocus}
onFocus={this.handleFocus.bind(this)}
onBlur={this.handleBlur.bind(this)}
>
{decoration?.startDecoration && <>{decoration.startDecoration}</>}
<If condition={label}>

View File

@ -78,11 +78,11 @@ class InlineSettingsTextEdit extends React.Component<
registerKeybindings() {
let keybindManager = GlobalModel.keybindManager;
let domain = "inline-settings" + this.curId;
keybindManager.registerKeybinding("mainview", domain, "generic:confirm", (waveEvent) => {
keybindManager.registerKeybinding("control", domain, "generic:confirm", (waveEvent) => {
this.confirmChange();
return true;
});
keybindManager.registerKeybinding("mainview", domain, "generic:cancel", (waveEvent) => {
keybindManager.registerKeybinding("control", domain, "generic:cancel", (waveEvent) => {
this.cancelChange();
return true;
});

View File

@ -38,7 +38,7 @@ type Keybind = {
commandStr: string;
};
const KeybindLevels = ["system", "modal", "app", "mainview", "pane", "plugin"];
const KeybindLevels = ["system", "modal", "app", "mainview", "pane", "plugin", "control"];
class KeybindManager {
domainCallbacks: Map<string, KeybindCallback>;
@ -178,7 +178,12 @@ class KeybindManager {
if (modalLevel.length != 0) {
// console.log("processing modal");
// special case when modal keybindings are present
let shouldReturn = this.processLevel(nativeEvent, event, modalLevel);
let controlLevel = this.levelMap.get("control");
let shouldReturn = this.processLevel(nativeEvent, event, controlLevel);
if (shouldReturn) {
return true;
}
shouldReturn = this.processLevel(nativeEvent, event, modalLevel);
if (shouldReturn) {
return true;
}