From 3eb044150f7534d8e365aa10c641ad98111b37ed Mon Sep 17 00:00:00 2001 From: Bogdan Abaev Date: Wed, 9 Oct 2024 10:33:52 -0700 Subject: [PATCH 1/3] navigate context menu by typing - added keydown listener to contextmenu to select a menuitem by typing, similar to native menus. After something is typed, find buttons with text that begins with the input. If there is only one match, it is clicked. If there are multiple, the first one is focused. The input counter is reset after 2 seconds of not typing. - listen to even candidates in focus-manager and dispatch copy of keydown events from there to context-menu, in case one types when the menu itself is not focused. Also, fixed an encountered glitch where clicking inside of the rendered reader content when the context menu is open would make the annotation un-selectable via tab. --- src/common/components/context-menu.js | 35 ++++++++++++++++++- .../components/sidebar/annotations-view.js | 6 +++- src/common/focus-manager.js | 12 +++++++ 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/src/common/components/context-menu.js b/src/common/components/context-menu.js index 4e5fe26c..931eb340 100644 --- a/src/common/components/context-menu.js +++ b/src/common/components/context-menu.js @@ -96,6 +96,8 @@ function ContextMenu({ params, onClose }) { const [position, setPosition] = useState({ style: {} }); const [update, setUpdate] = useState(); const containerRef = useRef(); + const searchStringRef = useRef(''); + const searchTimeoutRef = useRef(null); useEffect(() => { setUpdate({}); @@ -134,6 +136,37 @@ function ContextMenu({ params, onClose }) { } } + // Select a menuitem from typing, similar to native context menus + function handleKeyDown(event) { + let { key } = event; + // Ignore non-characters + if (key.length !== 1 || !key.match(/\S/)) return; + + // Clear search string after 3 seconds of no typing + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } + searchTimeoutRef.current = setTimeout(() => { + searchStringRef.current = ''; + }, 2000); + + // Keep track of what has been typed so far + searchStringRef.current += key.toLowerCase(); + + // Find all buttons with text that start with what has been typed + let menuOptions = [...document.querySelectorAll(".context-menu button:not([disabled])")]; + let candidates = menuOptions.filter(option => option.textContent.toLowerCase().startsWith(searchStringRef.current)); + + // If there is only one candidate, click it right away + if (candidates.length == 1) { + candidates[0].click(); + } + // If there are multiple - focus the first one + else if (candidates.length > 1) { + candidates[0].focus(); + } + } + function handleClick(event, item) { onClose(); event.preventDefault(); @@ -143,7 +176,7 @@ function ContextMenu({ params, onClose }) { return (
-
+
{params.itemGroups.map((items, i) => (
{items.map((item, i) => { diff --git a/src/common/components/sidebar/annotations-view.js b/src/common/components/sidebar/annotations-view.js index 336f7a98..90725b70 100644 --- a/src/common/components/sidebar/annotations-view.js +++ b/src/common/components/sidebar/annotations-view.js @@ -175,7 +175,11 @@ const AnnotationsView = memo(React.forwardRef((props, ref) => { props.onUpdateAnnotations([annotation]); }, []); - function handlePointerDown() { + function handlePointerDown(event) { + // Clicking on the rendered content when a contextmenu is open will + // lead to pointerup event not firing and the annotation becoming not-selectable + // via keyboard until it is clicked. + if (event.target.classList.contains("context-menu-overlay")) return; pointerDownRef.current = true; } diff --git a/src/common/focus-manager.js b/src/common/focus-manager.js index fdf7e615..4e08e6ac 100644 --- a/src/common/focus-manager.js +++ b/src/common/focus-manager.js @@ -135,6 +135,18 @@ export class FocusManager { e.preventDefault(); this.tabToItem(true); } + // If context menu is opened and a character is typed, forward the event to context menu + // so it can select a menuitem, similar to how native menus do it. + let contextMenu = document.querySelector('.context-menu'); + if (contextMenu && e.key.length == 1 && !e.forwardedToContextMenu && !contextMenu.contains(e.target)) { + let eventCopy = new KeyboardEvent('keydown', { + key: e.key, + bubbles: true + }); + // Mark the event to skip it when it gets captured here to avoid infinite loop + eventCopy.forwardedToContextMenu = true; + contextMenu.dispatchEvent(eventCopy); + } } tabToGroup(reverse) { From b9c5c6ba1506880a30b0dd0e9008f5161b67be73 Mon Sep 17 00:00:00 2001 From: Bogdan Abaev Date: Thu, 10 Oct 2024 12:04:36 -0700 Subject: [PATCH 2/3] never click on candidate from typing, just focus --- src/common/components/context-menu.js | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/common/components/context-menu.js b/src/common/components/context-menu.js index 931eb340..a7e01368 100644 --- a/src/common/components/context-menu.js +++ b/src/common/components/context-menu.js @@ -157,12 +157,8 @@ function ContextMenu({ params, onClose }) { let menuOptions = [...document.querySelectorAll(".context-menu button:not([disabled])")]; let candidates = menuOptions.filter(option => option.textContent.toLowerCase().startsWith(searchStringRef.current)); - // If there is only one candidate, click it right away - if (candidates.length == 1) { - candidates[0].click(); - } - // If there are multiple - focus the first one - else if (candidates.length > 1) { + // Focus the first match + if (candidates.length) { candidates[0].focus(); } } From 74891bbf04bc6bd921fb18bfe846558b3f071d13 Mon Sep 17 00:00:00 2001 From: Bogdan Abaev Date: Fri, 11 Oct 2024 15:10:08 -0700 Subject: [PATCH 3/3] undo annotations-view bug tweak --- src/common/components/sidebar/annotations-view.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/common/components/sidebar/annotations-view.js b/src/common/components/sidebar/annotations-view.js index 90725b70..455dfb01 100644 --- a/src/common/components/sidebar/annotations-view.js +++ b/src/common/components/sidebar/annotations-view.js @@ -176,10 +176,6 @@ const AnnotationsView = memo(React.forwardRef((props, ref) => { }, []); function handlePointerDown(event) { - // Clicking on the rendered content when a contextmenu is open will - // lead to pointerup event not firing and the annotation becoming not-selectable - // via keyboard until it is clicked. - if (event.target.classList.contains("context-menu-overlay")) return; pointerDownRef.current = true; }