mirror of
https://github.com/wavetermdev/waveterm.git
synced 2025-11-28 05:00:26 +08:00
350 lines
13 KiB
TypeScript
350 lines
13 KiB
TypeScript
// Copyright 2025, Command Line Inc.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
import { atoms } from "@/app/store/global";
|
|
import { isBlank, makeIconClass } from "@/util/util";
|
|
import { offset, useFloating } from "@floating-ui/react";
|
|
import clsx from "clsx";
|
|
import { Atom, useAtomValue } from "jotai";
|
|
import React, { ReactNode, useEffect, useId, useRef, useState } from "react";
|
|
|
|
interface SuggestionControlProps {
|
|
anchorRef: React.RefObject<HTMLElement>;
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
onSelect: (item: SuggestionType, queryStr: string) => boolean;
|
|
onTab?: (item: SuggestionType, queryStr: string) => string;
|
|
fetchSuggestions: SuggestionsFnType;
|
|
className?: string;
|
|
placeholderText?: string;
|
|
children?: React.ReactNode;
|
|
}
|
|
|
|
type BlockHeaderSuggestionControlProps = Omit<SuggestionControlProps, "anchorRef" | "isOpen"> & {
|
|
blockRef: React.RefObject<HTMLElement>;
|
|
openAtom: Atom<boolean>;
|
|
};
|
|
|
|
const SuggestionControl: React.FC<SuggestionControlProps> = ({
|
|
anchorRef,
|
|
isOpen,
|
|
onClose,
|
|
onSelect,
|
|
onTab,
|
|
fetchSuggestions,
|
|
className,
|
|
children,
|
|
}) => {
|
|
if (!isOpen || !anchorRef.current || !fetchSuggestions) return null;
|
|
|
|
return (
|
|
<SuggestionControlInner {...{ anchorRef, onClose, onSelect, onTab, fetchSuggestions, className, children }} />
|
|
);
|
|
};
|
|
|
|
function highlightPositions(target: string, positions: number[]): ReactNode[] {
|
|
if (target == null) {
|
|
return [];
|
|
}
|
|
if (positions == null) {
|
|
return [target];
|
|
}
|
|
const result: ReactNode[] = [];
|
|
let targetIndex = 0;
|
|
let posIndex = 0;
|
|
|
|
while (targetIndex < target.length) {
|
|
if (posIndex < positions.length && targetIndex === positions[posIndex]) {
|
|
result.push(
|
|
<span key={`h-${targetIndex}`} className="text-blue-500 font-bold">
|
|
{target[targetIndex]}
|
|
</span>
|
|
);
|
|
posIndex++;
|
|
} else {
|
|
result.push(target[targetIndex]);
|
|
}
|
|
targetIndex++;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function getMimeTypeIconAndColor(fullConfig: FullConfigType, mimeType: string): [string, string] {
|
|
if (mimeType == null) {
|
|
return [null, null];
|
|
}
|
|
while (mimeType.length > 0) {
|
|
const icon = fullConfig.mimetypes?.[mimeType]?.icon ?? null;
|
|
const iconColor = fullConfig.mimetypes?.[mimeType]?.color ?? null;
|
|
if (icon != null) {
|
|
return [icon, iconColor];
|
|
}
|
|
mimeType = mimeType.slice(0, -1);
|
|
}
|
|
return [null, null];
|
|
}
|
|
|
|
const SuggestionIcon: React.FC<{ suggestion: SuggestionType }> = ({ suggestion }) => {
|
|
if (suggestion.iconsrc) {
|
|
return <img src={suggestion.iconsrc} alt="favicon" className="w-4 h-4 object-contain" />;
|
|
}
|
|
if (suggestion.icon) {
|
|
const iconClass = makeIconClass(suggestion.icon, true);
|
|
const iconColor = suggestion.iconcolor;
|
|
return <i className={iconClass} style={{ color: iconColor }} />;
|
|
}
|
|
if (suggestion.type === "url") {
|
|
const iconClass = makeIconClass("globe", true);
|
|
const iconColor = suggestion.iconcolor;
|
|
return <i className={iconClass} style={{ color: iconColor }} />;
|
|
} else if (suggestion.type === "file") {
|
|
// For file suggestions, use the existing logic.
|
|
const fullConfig = useAtomValue(atoms.fullConfigAtom);
|
|
let icon: string = null;
|
|
let iconColor: string = null;
|
|
if (icon == null && suggestion["file:mimetype"] != null) {
|
|
[icon, iconColor] = getMimeTypeIconAndColor(fullConfig, suggestion["file:mimetype"]);
|
|
}
|
|
const iconClass = makeIconClass(icon, true, { defaultIcon: "file" });
|
|
return <i className={iconClass} style={{ color: iconColor }} />;
|
|
}
|
|
const iconClass = makeIconClass("file", true);
|
|
return <i className={iconClass} />;
|
|
};
|
|
|
|
const SuggestionContent: React.FC<{
|
|
suggestion: SuggestionType;
|
|
}> = ({ suggestion }) => {
|
|
if (!isBlank(suggestion.subtext)) {
|
|
return (
|
|
<div className="flex flex-col">
|
|
{/* Title on the first line, with highlighting */}
|
|
<div className="truncate text-white">{highlightPositions(suggestion.display, suggestion.matchpos)}</div>
|
|
{/* Subtext on the second line in a smaller, grey style */}
|
|
<div className="truncate text-sm text-secondary">
|
|
{highlightPositions(suggestion.subtext, suggestion.submatchpos)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
return <span className="truncate">{highlightPositions(suggestion.display, suggestion.matchpos)}</span>;
|
|
};
|
|
|
|
const BlockHeaderSuggestionControl: React.FC<BlockHeaderSuggestionControlProps> = (props) => {
|
|
const [headerElem, setHeaderElem] = useState<HTMLElement>(null);
|
|
const isOpen = useAtomValue(props.openAtom);
|
|
|
|
useEffect(() => {
|
|
if (props.blockRef.current == null) {
|
|
setHeaderElem(null);
|
|
return;
|
|
}
|
|
const headerElem = props.blockRef.current.querySelector("[data-role='block-header']");
|
|
setHeaderElem(headerElem as HTMLElement);
|
|
}, [props.blockRef.current]);
|
|
|
|
const newClass = clsx(props.className, "rounded-t-none");
|
|
return <SuggestionControl {...props} anchorRef={{ current: headerElem }} isOpen={isOpen} className={newClass} />;
|
|
};
|
|
|
|
/**
|
|
* The empty state component that can be used as a child of SuggestionControl.
|
|
* If no children are provided to SuggestionControl, this default empty state will be used.
|
|
*/
|
|
const SuggestionControlNoResults: React.FC<{ children?: React.ReactNode }> = ({ children }) => {
|
|
return (
|
|
<div className="flex items-center justify-center min-h-[120px] p-4">
|
|
{children ?? <span className="text-gray-500">No Suggestions</span>}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const SuggestionControlNoData: React.FC<{ children?: React.ReactNode }> = ({ children }) => {
|
|
return (
|
|
<div className="flex items-center justify-center min-h-[120px] p-4">
|
|
{children ?? <span className="text-gray-500">No Suggestions</span>}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
interface SuggestionControlInnerProps extends Omit<SuggestionControlProps, "isOpen"> {}
|
|
|
|
const SuggestionControlInner: React.FC<SuggestionControlInnerProps> = ({
|
|
anchorRef,
|
|
onClose,
|
|
onSelect,
|
|
onTab,
|
|
fetchSuggestions,
|
|
className,
|
|
placeholderText,
|
|
children,
|
|
}) => {
|
|
const widgetId = useId();
|
|
const [query, setQuery] = useState("");
|
|
const reqNumRef = useRef(0);
|
|
let [suggestions, setSuggestions] = useState<SuggestionType[]>([]);
|
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
const [fetched, setFetched] = useState(false);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
const { refs, floatingStyles, middlewareData } = useFloating({
|
|
placement: "bottom",
|
|
strategy: "absolute",
|
|
middleware: [offset(-1)],
|
|
});
|
|
const emptyStateChild = React.Children.toArray(children).find(
|
|
(child) => React.isValidElement(child) && child.type === SuggestionControlNoResults
|
|
);
|
|
const noDataChild = React.Children.toArray(children).find(
|
|
(child) => React.isValidElement(child) && child.type === SuggestionControlNoData
|
|
);
|
|
|
|
useEffect(() => {
|
|
refs.setReference(anchorRef.current);
|
|
}, [anchorRef.current]);
|
|
|
|
useEffect(() => {
|
|
reqNumRef.current++;
|
|
fetchSuggestions(query, { widgetid: widgetId, reqnum: reqNumRef.current }).then((results) => {
|
|
if (results.reqnum !== reqNumRef.current) {
|
|
return;
|
|
}
|
|
setSuggestions(results.suggestions ?? []);
|
|
setFetched(true);
|
|
});
|
|
}, [query, fetchSuggestions]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
reqNumRef.current++;
|
|
fetchSuggestions("", { widgetid: widgetId, reqnum: reqNumRef.current, dispose: true });
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
inputRef.current?.focus();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
|
onClose();
|
|
}
|
|
};
|
|
document.addEventListener("mousedown", handleClickOutside);
|
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
}, [onClose, anchorRef]);
|
|
|
|
useEffect(() => {
|
|
if (dropdownRef.current) {
|
|
const children = dropdownRef.current.children;
|
|
if (children[selectedIndex]) {
|
|
(children[selectedIndex] as HTMLElement).scrollIntoView({
|
|
behavior: "auto",
|
|
block: "nearest",
|
|
});
|
|
}
|
|
}
|
|
}, [selectedIndex]);
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === "ArrowDown") {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setSelectedIndex((prev) => Math.min(prev + 1, suggestions.length - 1));
|
|
} else if (e.key === "ArrowUp") {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setSelectedIndex((prev) => Math.max(prev - 1, 0));
|
|
} else if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
let suggestion: SuggestionType = null;
|
|
if (selectedIndex >= 0 && selectedIndex < suggestions.length) {
|
|
suggestion = suggestions[selectedIndex];
|
|
}
|
|
if (onSelect(suggestion, query)) {
|
|
onClose();
|
|
}
|
|
} else if (e.key === "Escape") {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
onClose();
|
|
} else if (e.key === "Tab") {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const suggestion = suggestions[selectedIndex];
|
|
if (suggestion != null) {
|
|
const tabResult = onTab?.(suggestion, query);
|
|
if (tabResult != null) {
|
|
setQuery(tabResult);
|
|
}
|
|
}
|
|
} else if (e.key === "PageDown") {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setSelectedIndex((prev) => Math.min(prev + 10, suggestions.length - 1));
|
|
} else if (e.key === "PageUp") {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setSelectedIndex((prev) => Math.max(prev - 10, 0));
|
|
}
|
|
};
|
|
return (
|
|
<div
|
|
className={clsx(
|
|
"w-96 rounded-lg bg-modalbg shadow-lg border border-gray-700 z-[var(--zindex-typeahead-modal)] absolute",
|
|
middlewareData?.offset == null ? "opacity-0" : null,
|
|
className
|
|
)}
|
|
ref={refs.setFloating}
|
|
style={floatingStyles}
|
|
>
|
|
<div className="p-2">
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
value={query}
|
|
onChange={(e) => {
|
|
setQuery(e.target.value);
|
|
setSelectedIndex(0);
|
|
}}
|
|
onKeyDown={handleKeyDown}
|
|
className="w-full bg-gray-900 text-gray-100 px-4 py-2 rounded-md border border-gray-700 focus:outline-none focus:border-accent placeholder-secondary"
|
|
placeholder={placeholderText}
|
|
/>
|
|
</div>
|
|
{fetched &&
|
|
(suggestions.length > 0 ? (
|
|
<div ref={dropdownRef} className="max-h-96 overflow-y-auto divide-y divide-gray-700">
|
|
{suggestions.map((suggestion, index) => (
|
|
<div
|
|
key={suggestion.suggestionid}
|
|
className={clsx(
|
|
"flex items-center gap-3 px-4 py-2 cursor-pointer",
|
|
index === selectedIndex ? "bg-accentbg" : "hover:bg-hoverbg",
|
|
"text-gray-100"
|
|
)}
|
|
onClick={() => {
|
|
onSelect(suggestion, query);
|
|
onClose();
|
|
}}
|
|
>
|
|
<SuggestionIcon suggestion={suggestion} />
|
|
<SuggestionContent suggestion={suggestion} />
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
// Render the empty state (either a provided child or the default)
|
|
<div key="empty" className="flex items-center justify-center min-h-[120px] p-4">
|
|
{query === ""
|
|
? (noDataChild ?? <SuggestionControlNoData />)
|
|
: (emptyStateChild ?? <SuggestionControlNoResults />)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export { BlockHeaderSuggestionControl, SuggestionControl, SuggestionControlNoData, SuggestionControlNoResults };
|