microsoft / react-native-macos

A framework for building native macOS apps with React.
https://microsoft.github.io/react-native-windows/
MIT License
3.38k stars 130 forks source link

Global keyboard listener #966

Closed ospfranco closed 2 years ago

ospfranco commented 2 years ago

Summary

Continues the discussion from https://github.com/microsoft/react-native-macos/issues/823#issuecomment-1015078833

Currently, there is no good/possible way to add global keyboard event listeners, an equivalent of the web versions would be the best option

Motivation

Modern desktop software is dependent on keyboard events, for mobile they are not that important since most people do not use an attached keyboard, but on desktop it is.

Basic Example

On web, you would attach a listener via the window object

window.addEventListener("keyDown", handler, options)

This is the most basic block one would need, but not the only one necessary, one also needs to stop the propagation of the event in case it has been handled, modifier keys also need to be sent on the event, here is an example keyboard event hook I have on a web project:

import { useWindowEvent } from "./useWindowEvent"

export function useKeyboardShortcut(
  key: string,
  callback: () => void,
  metaKey?: boolean,
  shiftKey?: boolean
) {
  useWindowEvent("keydown", (event) => {
    const activeElem = document.activeElement

    if (
      event.key.toLowerCase() === key &&
      event.metaKey === !!metaKey &&
      event.shiftKey === !!shiftKey &&
      activeElem?.getAttribute("role") !== "menu" &&
      activeElem?.tagName !== "INPUT" &&
      activeElem?.tagName !== "TEXTAREA"
    ) {
      event.preventDefault()
      event.stopPropagation()
      callback()
    }
  })
}

Open Questions

No response

Saadnajmi commented 2 years ago

React Native macOS (and Windows / iOS / Android as far as I know) doesn't have any knowledge of the window apart from the useWindowDimensions hook. React Native only knows as far as the Root View. At the moment, you would have to do that yourself in native code. That is especially true if you need to hook into something like applicationDidLaunch (looking at your example code in the previous thread). Making a change to attach listeners to the window itself could be a worthwhile contribution that could also warrant a cross-platform API with windows.

Some context on how keyboard events work in React Native macOS:

Natively, keyboard events bubble up the native view hierarchy following the NSResponder chain. That's quite complicated, but my understanding is this:

React Native macOS hooks into this by checking if the Javascript view corresponding to the native view defined an onKey(Up|Down) callback, and if the keyboard event is one of the keys in validKeys(Up|Down). If it is, it'll send the event to JS. If it is not, it will continue forwarding the native keyboard event up the responder chain.

What you could do right now is add an onKeyDown callback to the top level <View> of your App. Then, if no child view has handled that specific key, it should natively get forwarded to the root view, and trigger the root view's callback. It's not quite global and doesn't solve the bug as described, I just wanted to illustrate what is currently achievable.

ospfranco commented 2 years ago

I didn't mean I want a window object, I just meant to illustrate the point of key down listeners on web.

The current implementation doesn't work anyway though, right? if people need to activate view focus on the macOS settings in order for the view to be focusable and the events to be catched... nobody will do this just to run a RN application.

The NSResponder chain sounds clearly complicated, but it lacks the ability to stop propagation which is vital in some cases, e.g. when a textinput has focus, you not only want to trigger a keydown callback but also prevent the textinput to insert further characters...

The problem with the implementation I provided on the previous thread is that there is no way to prevent the propagation (my plan was to use the old bridge and just send the event to JS) since I'm not a macOS expert and not really sure how to internally handle this event on a more elegant way

The only real solution I can see is using the JSI to make the callback synchronous and then hooking up to the code I provided and some how killing the NSEvent

Any idea if this might work?

jbcullis commented 2 years ago

This is better suited to a native module I suspect, there is an existing project for iOS and Android here - https://github.com/kevinejohn/react-native-keyevent

Saadnajmi commented 2 years ago

@ospfranco You brought up two good points: 1) macOS needs a not-well-documented system preference enabled for keyboard focus to work the way you want it to. FWIW, I also find this odd and want to spend some more time learning about that. 2) Events don't propagate on the JS Side. Keyboard events propagate on the native side, since we implemented them as direct (not bubbling) events in JS. This is something react-native-windows handled differently (keyboard events bubble in JS as well, and you can call e.stopPropogation() ).

For the specific case of TextInput, It's a worthy bug that keyboard events (onKeyUp/onKeyDown/validKeysUp/validKeysDown) aren't implemented for that control. That's also something I recently realized.

I don't personally have experience with JSI, but feel free to try. Come Fabric and the ability to have synchronous events between JS and Native, there's probably a lot to revisit including keyboard events.

For your scenario, I think you would have to use a native module, listen for keyboard events at the NSWindow/NSApp level (any keyboard events handled in JS by child views wouldn't make it that far), and send a callback to JS.

acoates-ms commented 2 years ago

Actually, stopPropagation on react-native-windows will only stop the JS event from propagating -- since the JS events happen on the JS thread, by which time it's too late to stop the propagation of the event on the native side. Instead you have to use the keyDownEvents and keyUpEvents properties to declare ahead of time which keys should stop propagation on the native side.

HeyImChris commented 2 years ago

With this not being upstream for device + keyboarding support, it's unlikely we take a fix to global window support into core. I think your best bet here is to follow @jbcullis's suggestions and find/create a macOS native module to add this support.

ospfranco commented 2 years ago

I ended up using the snippet I pasted on the previous thread:

NSEvent.addLocalMonitorForEvents(matching: .keyDown) {
  self.keyDown(with: $0)
  return $0
}

With a simple eventListener on the old bridge, so this is good enough for me, of course there are conflicts when for example a text input is focused, but since there is no native solution incoming, everything can be worked around with state handling on the JS side