From a26ee0d769792de1de5bdfa7552fae1448752164 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Mon, 22 Sep 2025 17:52:05 +0300 Subject: [PATCH] chore(react/type_widget): hot-pluggable keyboard shortcuts --- apps/client/src/services/keyboard_actions.ts | 12 ++++++++--- apps/client/src/services/shortcuts.ts | 18 ++++++++++++---- apps/client/src/widgets/react/hooks.tsx | 21 ++++++++++++++++--- .../src/widgets/type_widgets/code/Code.tsx | 9 ++------ 4 files changed, 43 insertions(+), 17 deletions(-) diff --git a/apps/client/src/services/keyboard_actions.ts b/apps/client/src/services/keyboard_actions.ts index d65b10bb26..9fce5ad356 100644 --- a/apps/client/src/services/keyboard_actions.ts +++ b/apps/client/src/services/keyboard_actions.ts @@ -1,6 +1,6 @@ import server from "./server.js"; -import appContext, { type CommandNames } from "../components/app_context.js"; -import shortcutService from "./shortcuts.js"; +import appContext from "../components/app_context.js"; +import shortcutService, { ShortcutBinding } from "./shortcuts.js"; import type Component from "../components/component.js"; import type { ActionKeyboardShortcut } from "@triliumnext/commons"; @@ -30,12 +30,18 @@ async function getActionsForScope(scope: string) { async function setupActionsForElement(scope: string, $el: JQuery, component: Component) { const actions = await getActionsForScope(scope); + const bindings: ShortcutBinding[] = []; for (const action of actions) { for (const shortcut of action.effectiveShortcuts ?? []) { - shortcutService.bindElShortcut($el, shortcut, () => component.triggerCommand(action.actionName, { ntxId: appContext.tabManager.activeNtxId })); + const binding = shortcutService.bindElShortcut($el, shortcut, () => component.triggerCommand(action.actionName, { ntxId: appContext.tabManager.activeNtxId })); + if (binding) { + bindings.push(binding); + } } } + + return bindings; } getActionsForScope("window").then((actions) => { diff --git a/apps/client/src/services/shortcuts.ts b/apps/client/src/services/shortcuts.ts index a2aca5d80a..50b48317bc 100644 --- a/apps/client/src/services/shortcuts.ts +++ b/apps/client/src/services/shortcuts.ts @@ -3,7 +3,7 @@ import utils from "./utils.js"; type ElementType = HTMLElement | Document; type Handler = (e: KeyboardEvent) => void; -interface ShortcutBinding { +export interface ShortcutBinding { element: HTMLElement | Document; shortcut: string; handler: Handler; @@ -51,7 +51,7 @@ export function isIMEComposing(e: KeyboardEvent): boolean { if (!e) { return false; } - + // Standard check for composition state // e.isComposing is true when IME is actively composing // e.keyCode === 229 is a fallback for older browsers where 229 indicates IME processing @@ -86,13 +86,13 @@ function bindElShortcut($el: JQuery, keyboardShortcut: st } const e = evt as KeyboardEvent; - + // Skip processing if IME is composing to prevent shortcuts from // interfering with text input in CJK languages if (isIMEComposing(e)) { return; } - + if (matchesShortcut(e, keyboardShortcut)) { e.preventDefault(); e.stopPropagation(); @@ -117,10 +117,20 @@ function bindElShortcut($el: JQuery, keyboardShortcut: st activeBindings.set(key, []); } activeBindings.get(key)!.push(binding); + return binding; } } } +export function removeIndividualBinding(binding: ShortcutBinding) { + const key = binding.namespace ?? "global"; + const activeBindingsInNamespace = activeBindings.get(key); + if (activeBindingsInNamespace) { + activeBindings.set(key, activeBindingsInNamespace.filter(aBinding => aBinding.handler === binding.handler)); + } + binding.element.removeEventListener("keydown", binding.listener); +} + function removeNamespaceBindings(namespace: string) { const bindings = activeBindings.get(namespace); if (bindings) { diff --git a/apps/client/src/widgets/react/hooks.tsx b/apps/client/src/widgets/react/hooks.tsx index be62a55634..97fc9da20c 100644 --- a/apps/client/src/widgets/react/hooks.tsx +++ b/apps/client/src/widgets/react/hooks.tsx @@ -1,6 +1,6 @@ -import { Inputs, MutableRef, useCallback, useContext, useDebugValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks"; -import { CommandListenerData, EventData, EventNames } from "../../components/app_context"; -import { ParentComponent } from "./react_utils"; +import { MutableRef, useCallback, useContext, useDebugValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks"; +import { EventData, EventNames } from "../../components/app_context"; +import { ParentComponent, refToJQuerySelector } from "./react_utils"; import SpacedUpdate from "../../services/spaced_update"; import { FilterLabelsByType, KeyboardActionNames, OptionNames, RelationNames } from "@triliumnext/commons"; import options, { type OptionValue } from "../../services/options"; @@ -21,6 +21,7 @@ import Component from "../../components/component"; import toast, { ToastOptions } from "../../services/toast"; import protected_session_holder from "../../services/protected_session_holder"; import server from "../../services/server"; +import { removeIndividualBinding } from "../../services/shortcuts"; export function useTriliumEvent(eventName: T, handler: (data: EventData) => void) { const parentComponent = useContext(ParentComponent); @@ -721,3 +722,17 @@ export function useResizeObserver(ref: RefObject, callback: () => v return () => observer.disconnect(); }, [ callback, ref ]); } + +export function useKeyboardShortcuts(scope: "code-detail" | "text-detail", containerRef: RefObject, parentComponent: Component | undefined) { + useEffect(() => { + if (!parentComponent) return; + const $container = refToJQuerySelector(containerRef); + const bindingPromise = keyboard_actions.setupActionsForElement(scope, $container, parentComponent); + return async () => { + const bindings = await bindingPromise; + for (const binding of bindings) { + removeIndividualBinding(binding); + } + } + }, []); +} diff --git a/apps/client/src/widgets/type_widgets/code/Code.tsx b/apps/client/src/widgets/type_widgets/code/Code.tsx index a4b3b611b5..f70d3e4b1f 100644 --- a/apps/client/src/widgets/type_widgets/code/Code.tsx +++ b/apps/client/src/widgets/type_widgets/code/Code.tsx @@ -4,11 +4,10 @@ import { TypeWidgetProps } from "../type_widget"; import "./code.css"; import CodeMirror, { CodeMirrorProps } from "./CodeMirror"; import utils from "../../../services/utils"; -import { useEditorSpacedUpdate, useNoteBlob, useSyncedRef, useTriliumEvent, useTriliumOption, useTriliumOptionBool } from "../../react/hooks"; +import { useEditorSpacedUpdate, useKeyboardShortcuts, useNoteBlob, useSyncedRef, useTriliumEvent, useTriliumOption, useTriliumOptionBool } from "../../react/hooks"; import { t } from "../../../services/i18n"; import appContext from "../../../components/app_context"; import TouchBar, { TouchBarButton } from "../../react/TouchBar"; -import keyboard_actions from "../../../services/keyboard_actions"; import { refToJQuerySelector } from "../../react/react_utils"; import { CODE_THEME_DEFAULT_PREFIX as DEFAULT_PREFIX } from "../constants"; @@ -69,11 +68,7 @@ export function EditableCode({ note, ntxId, debounceUpdate, parentComponent, upd updateInterval }); - // Set up keyboard shortcuts. - useEffect(() => { - if (!parentComponent) return; - keyboard_actions.setupActionsForElement("code-detail", refToJQuerySelector(containerRef), parentComponent); - }, []); + useKeyboardShortcuts("code-detail", containerRef, parentComponent); return (