171 lines
4.5 KiB
TypeScript
171 lines
4.5 KiB
TypeScript
import {
|
|
type RefObject,
|
|
useCallback,
|
|
useEffect,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import { API_URL } from "../config/api.ts";
|
|
|
|
export interface UserResult {
|
|
id: string;
|
|
username: string;
|
|
avatarMime: string | null;
|
|
}
|
|
|
|
interface MentionState {
|
|
open: boolean;
|
|
query: string;
|
|
start: number;
|
|
results: UserResult[];
|
|
selectedIndex: number;
|
|
}
|
|
|
|
const CLOSED: MentionState = {
|
|
open: false,
|
|
query: "",
|
|
start: 0,
|
|
results: [],
|
|
selectedIndex: 0,
|
|
};
|
|
|
|
function getMentionQuery(
|
|
value: string,
|
|
pos: number,
|
|
): { query: string; start: number } | null {
|
|
const textBefore = value.slice(0, pos);
|
|
// Match @word at end of text before cursor, not preceded by [ ( or word char
|
|
const match = textBefore.match(/(?<![[(A-Za-z0-9_])@([A-Za-z0-9_]*)$/);
|
|
if (!match || match[1].length === 0) return null;
|
|
const start = pos - match[0].length;
|
|
return { query: match[1], start };
|
|
}
|
|
|
|
export function useMentionAutocomplete(
|
|
value: string,
|
|
onChange: (v: string) => void,
|
|
textareaRef: RefObject<HTMLTextAreaElement | null>,
|
|
) {
|
|
const [state, setState] = useState<MentionState>(CLOSED);
|
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const cursorRef = useRef<number>(0);
|
|
|
|
const handleMentionChange = useCallback(
|
|
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
const newValue = e.target.value;
|
|
const pos = e.target.selectionStart ?? newValue.length;
|
|
cursorRef.current = pos;
|
|
onChange(newValue);
|
|
|
|
const mention = getMentionQuery(newValue, pos);
|
|
if (!mention) {
|
|
setState(CLOSED);
|
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
return;
|
|
}
|
|
|
|
setState((s) => ({
|
|
...s,
|
|
open: false,
|
|
query: mention.query,
|
|
start: mention.start,
|
|
selectedIndex: 0,
|
|
}));
|
|
|
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
debounceRef.current = setTimeout(async () => {
|
|
try {
|
|
const res = await fetch(
|
|
`${API_URL}/api/users/search?q=${
|
|
encodeURIComponent(mention.query)
|
|
}`,
|
|
);
|
|
const body = await res.json();
|
|
if (body.success && body.data.length > 0) {
|
|
setState((s) =>
|
|
s.query === mention.query
|
|
? { ...s, open: true, results: body.data, selectedIndex: 0 }
|
|
: s
|
|
);
|
|
} else {
|
|
setState(CLOSED);
|
|
}
|
|
} catch {
|
|
setState(CLOSED);
|
|
}
|
|
}, 150);
|
|
},
|
|
[onChange],
|
|
);
|
|
|
|
const doSelect = useCallback(
|
|
(username: string, start: number, cursorPos: number) => {
|
|
const before = value.slice(0, start);
|
|
const after = value.slice(cursorPos);
|
|
onChange(`${before}@${username} ${after}`);
|
|
setState(CLOSED);
|
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
setTimeout(() => {
|
|
const el = textareaRef.current;
|
|
if (el) {
|
|
const newPos = start + username.length + 2; // @ + username + space
|
|
el.focus();
|
|
el.setSelectionRange(newPos, newPos);
|
|
}
|
|
}, 0);
|
|
},
|
|
[value, onChange, textareaRef],
|
|
);
|
|
|
|
const handleMentionKeyDown = useCallback(
|
|
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
if (!state.open || state.results.length === 0) return;
|
|
if (e.key === "ArrowDown") {
|
|
e.preventDefault();
|
|
setState((s) => ({
|
|
...s,
|
|
selectedIndex: Math.min(s.selectedIndex + 1, s.results.length - 1),
|
|
}));
|
|
} else if (e.key === "ArrowUp") {
|
|
e.preventDefault();
|
|
setState((s) => ({
|
|
...s,
|
|
selectedIndex: Math.max(s.selectedIndex - 1, 0),
|
|
}));
|
|
} else if (
|
|
(e.key === "Enter" || e.key === "Tab") && !e.ctrlKey && !e.metaKey
|
|
) {
|
|
e.preventDefault();
|
|
const user = state.results[state.selectedIndex];
|
|
if (user) doSelect(user.username, state.start, cursorRef.current);
|
|
} else if (e.key === "Escape") {
|
|
e.preventDefault();
|
|
setState(CLOSED);
|
|
}
|
|
},
|
|
[state, doSelect],
|
|
);
|
|
|
|
const handleMentionSelect = useCallback(
|
|
(username: string) => {
|
|
doSelect(username, state.start, cursorRef.current);
|
|
},
|
|
[doSelect, state.start],
|
|
);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
};
|
|
}, []);
|
|
|
|
return {
|
|
mentionOpen: state.open,
|
|
mentionResults: state.results,
|
|
mentionSelectedIndex: state.selectedIndex,
|
|
handleMentionChange,
|
|
handleMentionKeyDown,
|
|
handleMentionSelect,
|
|
};
|
|
}
|