From d3c48e3a3eef8d9508602238ed5bbfb55d6e4272 Mon Sep 17 00:00:00 2001 From: Cole Lashley Date: Fri, 22 Mar 2024 17:53:27 -0700 Subject: [PATCH] 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 --- assets/default-keybindings.json | 56 ++++++++ src/app/common/elements/datepicker.tsx | 125 +++++++++++++----- src/app/common/elements/dropdown.tsx | 114 ++++++++++------ .../elements/inlinesettingstextedit.tsx | 4 +- src/util/keyutil.ts | 9 +- 5 files changed, 227 insertions(+), 81 deletions(-) diff --git a/assets/default-keybindings.json b/assets/default-keybindings.json index fd9bead0d..a44d74f0a 100644 --- a/assets/default-keybindings.json +++ b/assets/default-keybindings.json @@ -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"] diff --git a/src/app/common/elements/datepicker.tsx b/src/app/common/elements/datepicker.tsx index f045eda9e..4ecbd234b 100644 --- a/src/app/common/elements/datepicker.tsx +++ b/src/app/common/elements/datepicker.tsx @@ -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 = ({ 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 = ({ selectedDate, format = "MM/DD/Y setShowYearAccordion(false); }; + const closeModal = () => { + setIsOpen(false); + setShowYearAccordion(false); + }; + const dayPickerModal = isOpen ? ReactDOM.createPortal(
@@ -300,13 +312,13 @@ const DatePicker: React.FC = ({ 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 = ({ 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 = ({ 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} diff --git a/src/app/common/elements/dropdown.tsx b/src/app/common/elements/dropdown.tsx index f7fef4f9c..1b454afce 100644 --- a/src/app/common/elements/dropdown.tsx +++ b/src/app/common/elements/dropdown.tsx @@ -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 { wrapperRef: React.RefObject; menuRef: React.RefObject; timeoutId: any; + curUuid: string; constructor(props: DropdownProps) { super(props); @@ -50,6 +53,7 @@ class Dropdown extends React.Component { }; this.wrapperRef = React.createRef(); this.menuRef = React.createRef(); + this.curUuid == uuidv4(); } componentDidMount() { @@ -102,49 +106,76 @@ class Dropdown extends React.Component { @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 { 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}} diff --git a/src/app/common/elements/inlinesettingstextedit.tsx b/src/app/common/elements/inlinesettingstextedit.tsx index 539e24177..f8b6e8a9a 100644 --- a/src/app/common/elements/inlinesettingstextedit.tsx +++ b/src/app/common/elements/inlinesettingstextedit.tsx @@ -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; }); diff --git a/src/util/keyutil.ts b/src/util/keyutil.ts index e934ace24..df83517ba 100644 --- a/src/util/keyutil.ts +++ b/src/util/keyutil.ts @@ -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; @@ -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; }