v3: added content slugs, fixed real-time updates in client, added @mentions across the app, added new file selector and drop zone
This commit is contained in:
162
src/hooks/useMentionAutocomplete.ts
Normal file
162
src/hooks/useMentionAutocomplete.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { useCallback, useEffect, useRef, useState, type RefObject } 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user