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", "command": "generic:deleteItem",
"keys": ["Backspace", "Delete"] "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", "command": "generic:selectAbove",
"keys": ["ArrowUp"] "keys": ["ArrowUp"]
@ -31,6 +79,14 @@
"command": "generic:selectBelow", "command": "generic:selectBelow",
"keys": ["ArrowDown"] "keys": ["ArrowDown"]
}, },
{
"command": "generic:selectLeft",
"keys": ["ArrowLeft"]
},
{
"command": "generic:selectRight",
"keys": ["ArrowRight"]
},
{ {
"command": "generic:selectPageAbove", "command": "generic:selectPageAbove",
"keys": ["PageUp"] "keys": ["PageUp"]

View File

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

View File

@ -7,6 +7,8 @@ import { boundMethod } from "autobind-decorator";
import cn from "classnames"; import cn from "classnames";
import { If } from "tsx-control-statements/components"; import { If } from "tsx-control-statements/components";
import ReactDOM from "react-dom"; import ReactDOM from "react-dom";
import { v4 as uuidv4 } from "uuid";
import { GlobalModel } from "@/models";
import "./dropdown.less"; import "./dropdown.less";
@ -39,6 +41,7 @@ class Dropdown extends React.Component<DropdownProps, DropdownState> {
wrapperRef: React.RefObject<HTMLDivElement>; wrapperRef: React.RefObject<HTMLDivElement>;
menuRef: React.RefObject<HTMLDivElement>; menuRef: React.RefObject<HTMLDivElement>;
timeoutId: any; timeoutId: any;
curUuid: string;
constructor(props: DropdownProps) { constructor(props: DropdownProps) {
super(props); super(props);
@ -50,6 +53,7 @@ class Dropdown extends React.Component<DropdownProps, DropdownState> {
}; };
this.wrapperRef = React.createRef(); this.wrapperRef = React.createRef();
this.menuRef = React.createRef(); this.menuRef = React.createRef();
this.curUuid == uuidv4();
} }
componentDidMount() { componentDidMount() {
@ -102,49 +106,76 @@ class Dropdown extends React.Component<DropdownProps, DropdownState> {
@boundMethod @boundMethod
handleFocus() { handleFocus() {
this.setState({ isTouched: true }); 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 @boundMethod
handleKeyDown(event: React.KeyboardEvent) { 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;
}
}
@boundMethod @boundMethod
handleSelect(value: string, event?: React.MouseEvent | React.KeyboardEvent) { handleSelect(value: string, event?: React.MouseEvent | React.KeyboardEvent) {
@ -228,7 +259,8 @@ class Dropdown extends React.Component<DropdownProps, DropdownState> {
tabIndex={0} tabIndex={0}
onKeyDown={this.handleKeyDown} onKeyDown={this.handleKeyDown}
onClick={this.handleClick} onClick={this.handleClick}
onFocus={this.handleFocus} onFocus={this.handleFocus.bind(this)}
onBlur={this.handleBlur.bind(this)}
> >
{decoration?.startDecoration && <>{decoration.startDecoration}</>} {decoration?.startDecoration && <>{decoration.startDecoration}</>}
<If condition={label}> <If condition={label}>

View File

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

View File

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