mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-05 07:12:06 +00:00
206 lines
5.3 KiB
TypeScript
206 lines
5.3 KiB
TypeScript
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||
|
|
import {
|
||
|
|
Box,
|
||
|
|
ClickAwayListener,
|
||
|
|
List,
|
||
|
|
ListItemButton,
|
||
|
|
Paper,
|
||
|
|
Popper,
|
||
|
|
TextField,
|
||
|
|
} from "@mui/material";
|
||
|
|
import {
|
||
|
|
filterMentionCandidates,
|
||
|
|
findActiveMentionQuery,
|
||
|
|
insertMentionToken,
|
||
|
|
type MentionLookup,
|
||
|
|
} from "@/domain/work-orders/utils/comment-mentions";
|
||
|
|
|
||
|
|
type CommentMentionComposerProps = {
|
||
|
|
value: string;
|
||
|
|
onChange: (value: string) => void;
|
||
|
|
candidates: MentionLookup[];
|
||
|
|
placeholder?: string;
|
||
|
|
disabled?: boolean;
|
||
|
|
rows?: number;
|
||
|
|
onSubmit?: () => void;
|
||
|
|
};
|
||
|
|
|
||
|
|
type MentionCandidateMenuProps = {
|
||
|
|
open: boolean;
|
||
|
|
anchorEl: HTMLElement | null;
|
||
|
|
candidates: MentionLookup[];
|
||
|
|
highlightIndex: number;
|
||
|
|
onSelect: (user: MentionLookup) => void;
|
||
|
|
};
|
||
|
|
|
||
|
|
function MentionCandidateMenu({
|
||
|
|
open,
|
||
|
|
anchorEl,
|
||
|
|
candidates,
|
||
|
|
highlightIndex,
|
||
|
|
onSelect,
|
||
|
|
}: MentionCandidateMenuProps) {
|
||
|
|
return (
|
||
|
|
<Popper open={open} anchorEl={anchorEl} placement="bottom-start" className="z-50">
|
||
|
|
<Paper elevation={4} className="mt-1 max-h-48 overflow-y-auto">
|
||
|
|
<List dense disablePadding>
|
||
|
|
{candidates.map((candidate, index) => (
|
||
|
|
<ListItemButton
|
||
|
|
key={String(candidate.id)}
|
||
|
|
selected={index === highlightIndex}
|
||
|
|
onMouseDown={(event) => event.preventDefault()}
|
||
|
|
onClick={() => onSelect(candidate)}
|
||
|
|
>
|
||
|
|
@{candidate.name}
|
||
|
|
</ListItemButton>
|
||
|
|
))}
|
||
|
|
</List>
|
||
|
|
</Paper>
|
||
|
|
</Popper>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export function CommentMentionComposer({
|
||
|
|
value,
|
||
|
|
onChange,
|
||
|
|
candidates,
|
||
|
|
placeholder = "Type a comment...",
|
||
|
|
disabled = false,
|
||
|
|
rows = 2,
|
||
|
|
onSubmit,
|
||
|
|
}: CommentMentionComposerProps) {
|
||
|
|
const inputRef = useRef<HTMLTextAreaElement | HTMLInputElement>(null);
|
||
|
|
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||
|
|
const [cursor, setCursor] = useState(0);
|
||
|
|
const [menuOpen, setMenuOpen] = useState(false);
|
||
|
|
const [highlightIndex, setHighlightIndex] = useState(0);
|
||
|
|
|
||
|
|
const activeQuery = useMemo(() => findActiveMentionQuery(value, cursor), [value, cursor]);
|
||
|
|
|
||
|
|
const filteredCandidates = useMemo(
|
||
|
|
() => filterMentionCandidates(candidates, activeQuery?.query ?? ""),
|
||
|
|
[activeQuery?.query, candidates],
|
||
|
|
);
|
||
|
|
|
||
|
|
const showMenu = Boolean(menuOpen && activeQuery && filteredCandidates.length > 0);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
setHighlightIndex(0);
|
||
|
|
}, [activeQuery?.query, activeQuery?.start]);
|
||
|
|
|
||
|
|
const syncCursor = useCallback((textOverride?: string) => {
|
||
|
|
const element = inputRef.current;
|
||
|
|
if (!element) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const text = textOverride ?? element.value;
|
||
|
|
const nextCursor = element.selectionStart ?? text.length;
|
||
|
|
setCursor(nextCursor);
|
||
|
|
setMenuOpen(Boolean(findActiveMentionQuery(text, nextCursor)));
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const handleChange = (nextValue: string) => {
|
||
|
|
onChange(nextValue);
|
||
|
|
requestAnimationFrame(() => {
|
||
|
|
syncCursor(nextValue);
|
||
|
|
});
|
||
|
|
};
|
||
|
|
|
||
|
|
const applyMention = useCallback(
|
||
|
|
(user: MentionLookup) => {
|
||
|
|
if (!activeQuery) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const { nextText, nextCursor } = insertMentionToken(value, activeQuery.start, cursor, user);
|
||
|
|
onChange(nextText);
|
||
|
|
setMenuOpen(false);
|
||
|
|
setHighlightIndex(0);
|
||
|
|
|
||
|
|
requestAnimationFrame(() => {
|
||
|
|
const element = inputRef.current;
|
||
|
|
if (!element) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
element.focus();
|
||
|
|
element.setSelectionRange(nextCursor, nextCursor);
|
||
|
|
setCursor(nextCursor);
|
||
|
|
});
|
||
|
|
},
|
||
|
|
[activeQuery, cursor, onChange, value],
|
||
|
|
);
|
||
|
|
|
||
|
|
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||
|
|
if (showMenu) {
|
||
|
|
if (event.key === "ArrowDown") {
|
||
|
|
event.preventDefault();
|
||
|
|
setHighlightIndex((current) => (current + 1) % filteredCandidates.length);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (event.key === "ArrowUp") {
|
||
|
|
event.preventDefault();
|
||
|
|
setHighlightIndex(
|
||
|
|
(current) => (current - 1 + filteredCandidates.length) % filteredCandidates.length,
|
||
|
|
);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (event.key === "Enter" && !event.shiftKey) {
|
||
|
|
event.preventDefault();
|
||
|
|
const selected = filteredCandidates[highlightIndex];
|
||
|
|
if (selected) {
|
||
|
|
applyMention(selected);
|
||
|
|
}
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (event.key === "Escape") {
|
||
|
|
event.preventDefault();
|
||
|
|
setMenuOpen(false);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (event.key === "Enter" && (event.metaKey || event.ctrlKey) && onSubmit) {
|
||
|
|
event.preventDefault();
|
||
|
|
onSubmit();
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<ClickAwayListener onClickAway={() => setMenuOpen(false)}>
|
||
|
|
<Box
|
||
|
|
className="relative"
|
||
|
|
ref={(node: HTMLDivElement | null) => {
|
||
|
|
setAnchorEl(node);
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<TextField
|
||
|
|
inputRef={inputRef}
|
||
|
|
multiline
|
||
|
|
rows={rows}
|
||
|
|
fullWidth
|
||
|
|
value={value}
|
||
|
|
onChange={(event) => handleChange(event.target.value)}
|
||
|
|
onClick={() => syncCursor()}
|
||
|
|
onKeyUp={() => syncCursor()}
|
||
|
|
onSelect={() => syncCursor()}
|
||
|
|
onKeyDown={handleKeyDown}
|
||
|
|
placeholder={placeholder}
|
||
|
|
disabled={disabled}
|
||
|
|
/>
|
||
|
|
|
||
|
|
<MentionCandidateMenu
|
||
|
|
open={showMenu}
|
||
|
|
anchorEl={anchorEl}
|
||
|
|
candidates={filteredCandidates}
|
||
|
|
highlightIndex={highlightIndex}
|
||
|
|
onSelect={applyMention}
|
||
|
|
/>
|
||
|
|
</Box>
|
||
|
|
</ClickAwayListener>
|
||
|
|
);
|
||
|
|
}
|