diff --git a/src/app/components/editor/Editor.tsx b/src/app/components/editor/Editor.tsx index 044d083793..817af44180 100644 --- a/src/app/components/editor/Editor.tsx +++ b/src/app/components/editor/Editor.tsx @@ -23,6 +23,7 @@ import { RenderElement, RenderLeaf } from './Elements'; import { CustomElement } from './slate'; import * as css from './Editor.css'; import { toggleKeyboardShortcut } from './keyboard'; +import { isComposing } from '../../utils/keyboard'; const initialValue: CustomElement[] = [ { @@ -99,6 +100,9 @@ export const CustomEditor = forwardRef( const handleKeydown: KeyboardEventHandler = useCallback( (evt) => { + if (isComposing(evt.nativeEvent)) { + return + } onKeyDown?.(evt); const shortcutToggled = toggleKeyboardShortcut(editor, evt); if (shortcutToggled) evt.preventDefault(); diff --git a/src/app/features/room/Room.tsx b/src/app/features/room/Room.tsx index ee3e702740..0b05895c26 100644 --- a/src/app/features/room/Room.tsx +++ b/src/app/features/room/Room.tsx @@ -13,6 +13,7 @@ import { useKeyDown } from '../../hooks/useKeyDown'; import { markAsRead } from '../../../client/action/notifications'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useRoomMembers } from '../../hooks/useRoomMembers'; +import { isComposing } from '../../utils/keyboard'; export function Room() { const { eventId } = useParams(); @@ -28,7 +29,7 @@ export function Room() { window, useCallback( (evt) => { - if (isKeyHotkey('escape', evt)) { + if (!isComposing(evt) && isKeyHotkey('escape', evt)) { markAsRead(mx, room.roomId); } }, diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 4d43c7e964..a126793a00 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -109,6 +109,7 @@ import { useElementSizeObserver } from '../../hooks/useElementSizeObserver'; import { ReplyLayout, ThreadIndicator } from '../../components/message'; import { roomToParentsAtom } from '../../state/room/roomToParents'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { isComposing } from '../../utils/keyboard'; interface RoomInputProps { editor: Editor; @@ -333,6 +334,9 @@ export const RoomInput = forwardRef( const handleKeyDown: KeyboardEventHandler = useCallback( (evt) => { + if (isComposing(evt.nativeEvent)) { + return; + } if (isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) { evt.preventDefault(); submit(); @@ -347,6 +351,9 @@ export const RoomInput = forwardRef( const handleKeyUp: KeyboardEventHandler = useCallback( (evt) => { + if (isComposing(evt.nativeEvent)) { + return + } if (isKeyHotkey('escape', evt)) { evt.preventDefault(); return; diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 63b3d3e2cd..2d6c3512a3 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -117,6 +117,7 @@ import { useMentionClickHandler } from '../../hooks/useMentionClickHandler'; import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler'; import { useRoomNavigate } from '../../hooks/useRoomNavigate'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { isComposing } from '../../utils/keyboard'; const TimelineFloat = as<'div', css.TimelineFloatVariants>( ({ position, className, ...props }, ref) => ( @@ -702,6 +703,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli useCallback( (evt) => { if ( + !isComposing(evt) && isKeyHotkey('arrowup', evt) && editableActiveElement() && document.activeElement?.getAttribute('data-editable-name') === 'RoomInput' && diff --git a/src/app/features/room/RoomView.tsx b/src/app/features/room/RoomView.tsx index 250afc930b..38ed1484a1 100644 --- a/src/app/features/room/RoomView.tsx +++ b/src/app/features/room/RoomView.tsx @@ -19,6 +19,7 @@ import { RoomViewHeader } from './RoomViewHeader'; import { useKeyDown } from '../../hooks/useKeyDown'; import { editableActiveElement } from '../../utils/dom'; import navigation from '../../../client/state/navigation'; +import { isComposing } from '../../utils/keyboard'; const shouldFocusMessageField = (evt: KeyboardEvent): boolean => { const { code } = evt; @@ -76,7 +77,8 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) { if (editableActiveElement()) return; if ( document.body.lastElementChild?.className !== 'ReactModalPortal' || - navigation.isRawModalVisible + navigation.isRawModalVisible || + isComposing(evt) ) { return; } diff --git a/src/app/features/room/message/MessageEditor.tsx b/src/app/features/room/message/MessageEditor.tsx index 0c99503086..1c97296ba7 100644 --- a/src/app/features/room/message/MessageEditor.tsx +++ b/src/app/features/room/message/MessageEditor.tsx @@ -52,6 +52,7 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { getEditedEvent, trimReplyFromFormattedBody } from '../../../utils/room'; import { mobileOrTablet } from '../../../utils/user-agent'; +import { isComposing } from '../../../utils/keyboard'; type MessageEditorProps = { roomId: string; @@ -149,6 +150,9 @@ export const MessageEditor = as<'div', MessageEditorProps>( const handleKeyDown: KeyboardEventHandler = useCallback( (evt) => { + if (isComposing(evt.nativeEvent)) { + return; + } if (isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) { evt.preventDefault(); handleSave(); @@ -163,6 +167,9 @@ export const MessageEditor = as<'div', MessageEditorProps>( const handleKeyUp: KeyboardEventHandler = useCallback( (evt) => { + if (isComposing(evt.nativeEvent)) { + return; + } if (isKeyHotkey('escape', evt)) { evt.preventDefault(); return; diff --git a/src/app/hooks/useSafariCompositionTaggingForKeyDown.ts b/src/app/hooks/useSafariCompositionTaggingForKeyDown.ts new file mode 100644 index 0000000000..79b3202912 --- /dev/null +++ b/src/app/hooks/useSafariCompositionTaggingForKeyDown.ts @@ -0,0 +1,33 @@ +import { useEffect } from 'react'; + +const actuallyComposingTag = Symbol("event is actually composing") + +export function isTaggedAsComposing(x: object): boolean { + return actuallyComposingTag in x +} + +export function useSafariCompositionTaggingForKeyDown(target: Window, {compositionEndThreshold = 500}: {compositionEndThreshold?: 500} = {}) { + useEffect(() => { + let compositionJustEndedAt: number | null = null + + function recordCompositionEnd(evt: CompositionEvent) { + compositionJustEndedAt = evt.timeStamp + } + + function interceptAndTagKeyDown(evt: KeyboardEvent) { + if (compositionJustEndedAt !== null + && evt.keyCode === 229 + && (evt.timeStamp - compositionJustEndedAt) < compositionEndThreshold) { + Object.assign(evt, { [actuallyComposingTag]: true }) + } + compositionJustEndedAt = null + } + + target.addEventListener('compositionend', recordCompositionEnd, { capture: true }) + target.addEventListener('keydown', interceptAndTagKeyDown, { capture: true }) + return () => { + target.removeEventListener('compositionend', recordCompositionEnd, { capture: true }) + target.removeEventListener('keydown', interceptAndTagKeyDown, { capture: true }) + } + }, [target, compositionEndThreshold]); +} \ No newline at end of file diff --git a/src/app/organisms/search/Search.jsx b/src/app/organisms/search/Search.jsx index ebdac3962e..46d15cf808 100644 --- a/src/app/organisms/search/Search.jsx +++ b/src/app/organisms/search/Search.jsx @@ -27,6 +27,7 @@ import { useKeyDown } from '../../hooks/useKeyDown'; import { openSearch } from '../../../client/action/navigation'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { factoryRoomIdByActivity } from '../../utils/sort'; +import { isComposing } from '../../utils/keyboard.js'; function useVisiblityToggle(setResult) { const [isOpen, setIsOpen] = useState(false); @@ -54,6 +55,9 @@ function useVisiblityToggle(setResult) { useKeyDown( window, useCallback((event) => { + if (isComposing(event)) { + return; + } // Ctrl/Cmd + if (event.ctrlKey || event.metaKey) { // open search modal diff --git a/src/app/organisms/settings/Settings.jsx b/src/app/organisms/settings/Settings.jsx index 6329a57fe0..5c58628f40 100644 --- a/src/app/organisms/settings/Settings.jsx +++ b/src/app/organisms/settings/Settings.jsx @@ -47,6 +47,7 @@ import { settingsAtom } from '../../state/settings'; import { isMacOS } from '../../utils/user-agent'; import { KeySymbol } from '../../utils/key-symbol'; import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { isComposing } from '../../utils/keyboard.js'; function AppearanceSection() { const [, updateState] = useState({}); @@ -78,6 +79,9 @@ function AppearanceSection() { }; const handleZoomEnter = (evt) => { + if (isComposing(evt.nativeEvent)) { + return; + } if (isKeyHotkey('escape', evt)) { evt.stopPropagation(); setCurrentZoom(pageZoom); diff --git a/src/app/pages/App.tsx b/src/app/pages/App.tsx index b16462dffd..db470a221d 100644 --- a/src/app/pages/App.tsx +++ b/src/app/pages/App.tsx @@ -10,11 +10,13 @@ import { ConfigConfigError, ConfigConfigLoading } from './ConfigConfig'; import { FeatureCheck } from './FeatureCheck'; import { createRouter } from './Router'; import { ScreenSizeProvider, useScreenSize } from '../hooks/useScreenSize'; +import { useSafariCompositionTaggingForKeyDown } from '../hooks/useSafariCompositionTaggingForKeyDown'; const queryClient = new QueryClient(); function App() { const screenSize = useScreenSize(); + useSafariCompositionTaggingForKeyDown(window); return ( diff --git a/src/app/pages/auth/ServerPicker.tsx b/src/app/pages/auth/ServerPicker.tsx index a2a78106cd..4f547beeba 100644 --- a/src/app/pages/auth/ServerPicker.tsx +++ b/src/app/pages/auth/ServerPicker.tsx @@ -22,7 +22,7 @@ import { import FocusTrap from 'focus-trap-react'; import { useDebounce } from '../../hooks/useDebounce'; -import { stopPropagation } from '../../utils/keyboard'; +import { isComposing, stopPropagation } from '../../utils/keyboard'; export function ServerPicker({ server, @@ -53,6 +53,9 @@ export function ServerPicker({ }; const handleKeyDown: KeyboardEventHandler = (evt) => { + if (isComposing(evt.nativeEvent)) { + return; + } if (evt.key === 'ArrowDown') { evt.preventDefault(); setServerMenuAnchor(undefined); diff --git a/src/app/utils/keyboard.ts b/src/app/utils/keyboard.ts index 46a951ffc0..bfc3f01673 100644 --- a/src/app/utils/keyboard.ts +++ b/src/app/utils/keyboard.ts @@ -1,5 +1,6 @@ import { isKeyHotkey } from 'is-hotkey'; import { KeyboardEventHandler } from 'react'; +import { isTaggedAsComposing } from '../hooks/useSafariCompositionTaggingForKeyDown'; export interface KeyboardEventLike { key: string; @@ -11,15 +12,28 @@ export interface KeyboardEventLike { preventDefault(): void; } +export function isComposing(evt: object): boolean { + if ('nativeEvent' in evt && typeof evt.nativeEvent === 'object' && evt.nativeEvent !== null) { + return isComposing(evt.nativeEvent) + } + if (isTaggedAsComposing(evt)) { + return true + } + if ('isComposing' in evt && typeof evt.isComposing === 'boolean') { + return evt.isComposing + } + return false +} + export const onTabPress = (evt: KeyboardEventLike, callback: () => void) => { - if (isKeyHotkey('tab', evt)) { + if (!isComposing(evt) && isKeyHotkey('tab', evt)) { evt.preventDefault(); callback(); } }; export const preventScrollWithArrowKey: KeyboardEventHandler = (evt) => { - if (isKeyHotkey(['arrowup', 'arrowright', 'arrowdown', 'arrowleft'], evt)) { + if (!isComposing(evt.nativeEvent) && isKeyHotkey(['arrowup', 'arrowright', 'arrowdown', 'arrowleft'], evt)) { evt.preventDefault(); } }; @@ -27,7 +41,7 @@ export const preventScrollWithArrowKey: KeyboardEventHandler = (evt) => { export const onEnterOrSpace = (callback: (evt: T) => void) => (evt: KeyboardEventLike) => { - if (isKeyHotkey('enter', evt) || isKeyHotkey('space', evt)) { + if (!isComposing(evt) && (isKeyHotkey('enter', evt) || isKeyHotkey('space', evt))) { evt.preventDefault(); callback(evt as T); }