Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/emoji-autoselect-fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

Fixed text autocomplete issues
193 changes: 0 additions & 193 deletions src/app/components/editor/autocomplete/AutocompleteMenu.test.tsx

This file was deleted.

83 changes: 30 additions & 53 deletions src/app/components/editor/autocomplete/AutocompleteMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,95 +1,72 @@
import { ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { ReactNode, useEffect, useRef, useState } from 'react';
import FocusTrap from 'focus-trap-react';
import { isKeyHotkey } from 'is-hotkey';
import { Header, Menu, Scroll, config } from 'folds';

import { preventScrollWithArrowKey, stopPropagation } from '$utils/keyboard';
import { useAlive } from '$hooks/useAlive';
import { Editor } from 'slate';
import { ReactEditor } from 'slate-react';
import * as css from './AutocompleteMenu.css';
import { BaseAutocompleteMenu } from './BaseAutocompleteMenu';

export const AUTOCOMPLETE_NAVIGATE_EVENT = 'autocomplete-navigate';
export type AutocompleteNavigateDetail = { direction: 1 | -1 };

type AutocompleteMenuProps = {
requestClose: () => void;
headerContent: ReactNode;
children: ReactNode;
editor: Editor;
};
export function AutocompleteMenu({ headerContent, requestClose, children }: AutocompleteMenuProps) {
export function AutocompleteMenu({
headerContent,
requestClose,
children,
editor,
}: AutocompleteMenuProps) {
const alive = useAlive();
const itemsRef = useRef<HTMLDivElement>(null);
const [selectedIndex, setSelectedIndex] = useState(0);
const prevButtonCountRef = useRef(-1);

const handleDeactivate = () => {
if (alive()) {
// The component is unmounted so we will not call for `requestClose`
requestClose();
}
};

// Sync data-selected to DOM; reset to index 0 when the item list changes.
// No dep array — runs after every render so newly-loaded buttons are stamped
// immediately (buttons arrive async when search results load).
// setSelectedIndex is a stable React setter; the conditional call never
// triggers an infinite update chain (it only fires when button count changes).
// eslint-disable-next-line react-hooks/exhaustive-deps
useLayoutEffect(() => {
const buttons = Array.from(
itemsRef.current?.querySelectorAll<HTMLButtonElement>('button') ?? []
);
const count = buttons.length;

let idx = selectedIndex;
if (count !== prevButtonCountRef.current) {
prevButtonCountRef.current = count;
idx = 0;
if (selectedIndex !== 0) setSelectedIndex(0);
}

const safeIdx = Math.max(0, Math.min(idx, count - 1));
buttons.forEach((btn, i) => {
btn.setAttribute('data-selected', String(i === safeIdx));
});
});

// Listen for navigation events dispatched by the editor key handler
useEffect(() => {
const container = itemsRef.current?.closest('[data-autocomplete-menu]');
if (!container) return undefined;
const handler = (e: Event) => {
const { direction } = (e as CustomEvent<AutocompleteNavigateDetail>).detail;
setSelectedIndex((prev) => {
const buttons = itemsRef.current?.querySelectorAll('button') ?? [];
return Math.max(0, Math.min(prev + direction, buttons.length - 1));
});
};
container.addEventListener(AUTOCOMPLETE_NAVIGATE_EVENT, handler);
return () => container.removeEventListener(AUTOCOMPLETE_NAVIGATE_EVENT, handler);
}, []);
const [isActive, setIsActive] = useState(true);
useEffect(() => ReactEditor.focus(editor), [editor, isActive]);
function handleInput(evt: any) {
if (!evt) return;
if (
isKeyHotkey('arrowdown', evt) ||
isKeyHotkey('arrowup', evt) ||
isKeyHotkey('tab', evt) ||
isKeyHotkey('esc', evt) ||
isKeyHotkey('Enter', evt)
)
return;
setIsActive(false);
}

return (
<BaseAutocompleteMenu>
<FocusTrap
active={isActive}
focusTrapOptions={{
initialFocus: false,
onPostDeactivate: handleDeactivate,
returnFocusOnDeactivate: false,
clickOutsideDeactivates: true,
allowOutsideClick: true,
isKeyForward: (evt: KeyboardEvent) => isKeyHotkey('arrowdown', evt),
isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt),
escapeDeactivates: stopPropagation,
}}
>
<Menu className={css.AutocompleteMenu}>
<Menu className={css.AutocompleteMenu} onKeyDown={(evt) => handleInput(evt)}>
<Header className={css.AutocompleteMenuHeader} size="400">
{headerContent}
</Header>
<Scroll style={{ flexGrow: 1 }} onKeyDown={preventScrollWithArrowKey}>
<div
ref={itemsRef}
className={css.AutocompleteMenuItems}
style={{ padding: config.space.S200 }}
>
<div ref={itemsRef} style={{ padding: config.space.S200 }}>
{children}
</div>
</Scroll>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ export function EmoticonAutocomplete({
<AutocompleteMenu
headerContent={<Text size="L400">{title ?? 'Emojis'}</Text>}
requestClose={requestClose}
editor={editor}
>
{autoCompleteEmoticon.map((emoticon) => {
const isCustomEmoji = 'url' in emoticon;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,11 @@ export function RoomMentionAutocomplete({
});

return (
<AutocompleteMenu headerContent={<Text size="L400">Rooms</Text>} requestClose={requestClose}>
<AutocompleteMenu
headerContent={<Text size="L400">Rooms</Text>}
requestClose={requestClose}
editor={editor}
>
{autoCompleteRoomIds.length === 0 ? (
<UnknownRoomMentionItem query={query} handleAutocomplete={handleAutocomplete} />
) : (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,11 @@ export function UserMentionAutocomplete({
});

return (
<AutocompleteMenu headerContent={<Text size="L400">Mentions</Text>} requestClose={requestClose}>
<AutocompleteMenu
headerContent={<Text size="L400">Mentions</Text>}
requestClose={requestClose}
editor={editor}
>
{query.text === 'room' && (
<UnknownMentionItem
userId={roomAliasOrId}
Expand Down
1 change: 1 addition & 0 deletions src/app/features/room/CommandAutocomplete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export function CommandAutocomplete({
</Box>
}
requestClose={requestClose}
editor={editor}
>
{autoCompleteNames.map((commandName) => (
<MenuItem
Expand Down
9 changes: 8 additions & 1 deletion src/app/features/room/message/MessageEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,13 @@ export const MessageEditor = as<'div', MessageEditorProps>(
(isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) &&
!isComposing(evt)
) {
const prevWordRange = getPrevWorldRange(editor);
if (
prevWordRange &&
getAutocompleteQuery(editor, prevWordRange, ANYWHERE_AUTOCOMPLETE_PREFIXES)
)
return;

evt.preventDefault();
handleSave();
}
Expand All @@ -288,7 +295,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
onCancel();
}
},
[onCancel, handleSave, enterForNewline, isComposing]
[enterForNewline, isComposing, editor, handleSave, onCancel]
);

const handleKeyUp: KeyboardEventHandler = useCallback(
Expand Down
Loading