Skip to content

Commit

Permalink
refactor: search list (#150)
Browse files Browse the repository at this point in the history
* refactor: search list

* feat: add virtual scrolling to the list
  • Loading branch information
RainyNight9 authored Feb 19, 2025
1 parent 0844970 commit 37d395b
Show file tree
Hide file tree
Showing 12 changed files with 362 additions and 257 deletions.
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"nowrap",
"nspanel",
"nsstring",
"overscan",
"partialize",
"Raycast",
"rehype",
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"react-i18next": "^15.1.0",
"react-markdown": "^9.0.1",
"react-router-dom": "^6.27.0",
"react-window": "^1.8.11",
"rehype-highlight": "^7.0.1",
"rehype-katex": "^7.0.1",
"remark-breaks": "^4.0.0",
Expand All @@ -55,6 +56,7 @@
"@types/react-dom": "^18.2.7",
"@types/react-i18next": "^8.1.0",
"@types/react-katex": "^3.0.4",
"@types/react-window": "^1.8.8",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.20",
"immer": "^10.1.1",
Expand Down
32 changes: 32 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/components/Assistant/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ export function ChatInput({
</button>
<button
type="button"
className={`h-5 px-2 inline-flex items-center border rounded-[10px] transition-colors relative ${
className={`h-5 px-2 inline-flex items-center border rounded-[10px] transition-colors relative ${
isSearchActive
? "bg-[rgba(0,114,255,0.3)] border-[rgba(0,114,255,0.3)]"
: "border-[#262727]"
Expand Down
2 changes: 1 addition & 1 deletion src/components/Cloud/Cloud.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ export default function Cloud() {
// Fetch the initial deep link intent
useEffect(() => {
// Test the handleUrl function
// handleUrl("coco://oauth_callback?code=cum3td461mdmieceeq30xcqeuho0zctxm7cul837ywiu4p573bgwkkzh2lz88o66r9jpolfnez8tr2y6ronn&request_id=94bcb5b9-a26a-4ee9-87c6-ecfc81d5cfca&provider=coco-cloud/");
// handleUrl("coco://oauth_callback?code=cuq8asc61mdmvii032q0sx1e5akx10zo8bks45znpv3cx1gtyc6wsi0rvplizb34mwbsrbm3jar8jnefg3o5&request_id=3f1acedb-6a5b-4fe1-82fd-e66934e98a55&provider=coco-cloud/");
// Function to handle pasted URL
const handlePaste = (event: any) => {
const pastedText = event.clipboardData.getData("text");
Expand Down
17 changes: 1 addition & 16 deletions src/components/Search/AutoResizeTextarea.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useRef, useImperativeHandle, forwardRef } from "react";
import { useRef, useImperativeHandle, forwardRef } from "react";
import { useTranslation } from "react-i18next";

interface AutoResizeTextareaProps {
Expand All @@ -16,25 +16,10 @@ const AutoResizeTextarea = forwardRef<
const { t } = useTranslation();
const textareaRef = useRef<HTMLTextAreaElement>(null);

useEffect(() => {
const textarea = textareaRef.current;
if (textarea) {
const prevHeight = textarea.style.height;
textarea.style.height = "auto"; // Reset height to recalculate
if (textarea.style.height !== prevHeight) {
textarea.style.height = `${textarea.scrollHeight}px`; // Adjust based on content
}
}
}, [input]);

// Expose methods to the parent via ref
useImperativeHandle(ref, () => ({
reset: () => {
setInput("");
const textarea = textareaRef.current;
if (textarea) {
textarea.style.height = "auto";
}
},
focus: () => {
textareaRef.current?.focus();
Expand Down
129 changes: 75 additions & 54 deletions src/components/Search/DocumentList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@ import { useInfiniteScroll } from "ahooks";
import { isTauri, invoke } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-shell";
import { useTranslation } from "react-i18next";
import { FixedSizeList } from "react-window";

import { useSearchStore } from "@/stores/searchStore";
import { SearchHeader } from "./SearchHeader";
import noDataImg from "@/assets/coconut-tree.png";
import ItemIcon from "@/components/Common/Icons/ItemIcon";
import { metaOrCtrlKey } from "@/utils/keyboardUtils";
import SearchListItem from "./SearchListItem";

interface DocumentListProps {
onSelectDocument: (id: string) => void;
Expand All @@ -21,6 +22,7 @@ interface DocumentListProps {
}

const PAGE_SIZE = 20;
const ITEM_HEIGHT = 48; // SearchListItem height(padding + content)

export const DocumentList: React.FC<DocumentListProps> = ({
input,
Expand All @@ -37,6 +39,7 @@ export const DocumentList: React.FC<DocumentListProps> = ({
const containerRef = useRef<HTMLDivElement>(null);
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
const [isKeyboardMode, setIsKeyboardMode] = useState(false);
const listRef = useRef<FixedSizeList>(null);

const { data, loading } = useInfiniteScroll(
async (d) => {
Expand Down Expand Up @@ -87,20 +90,11 @@ export const DocumentList: React.FC<DocumentListProps> = ({
}
);

const onFinally = (data: any, ref: any) => {
const onFinally = (data: any, _ref: any) => {
if (data?.page === 1) return;
const parentRef = ref.current;
if (!parentRef || selectedItem === null) return;

const targetElement = itemRefs.current[selectedItem];
if (!targetElement) return;

requestAnimationFrame(() => {
targetElement.scrollIntoView({
behavior: "instant",
block: "nearest",
});
});
if (selectedItem === null) return;

listRef.current?.scrollToItem(selectedItem, "smart");
};

const onMouseEnter = useCallback(
Expand Down Expand Up @@ -141,6 +135,7 @@ export const DocumentList: React.FC<DocumentListProps> = ({
setSelectedItem((prev) => {
const newIndex = prev === null || prev === 0 ? 0 : prev - 1;
getDocDetail(data.list[newIndex]?.document);
listRef.current?.scrollToItem(newIndex, "smart");
return newIndex;
});
} else {
Expand All @@ -152,6 +147,7 @@ export const DocumentList: React.FC<DocumentListProps> = ({
? prev
: prev + 1;
getDocDetail(data.list[newIndex]?.document);
listRef.current?.scrollToItem(newIndex, "smart");
return newIndex;
});
}
Expand Down Expand Up @@ -191,20 +187,47 @@ export const DocumentList: React.FC<DocumentListProps> = ({
}, [handleKeyDown]);

useEffect(() => {
if (selectedItem !== null && itemRefs.current[selectedItem]) {
requestAnimationFrame(() => {
itemRefs.current[selectedItem]?.scrollIntoView({
behavior: "instant",
block: "nearest",
});
});
if (selectedItem !== null) {
listRef.current?.scrollToItem(selectedItem, "smart");
}
}, [selectedItem]);

const Row = useCallback(
({ index, style }: { index: number; style: React.CSSProperties }) => {
const hit = data?.list[index];
if (!hit) return null;

const isSelected = selectedItem === index;
const item = hit.document;

return (
<div style={style}>
<SearchListItem
key={item.id + index}
itemRef={(el) => (itemRefs.current[index] = el)}
item={item}
isSelected={isSelected}
currentIndex={index}
onMouseEnter={() => onMouseEnter(index, item)}
onItemClick={() => {
if (item?.url) {
handleOpenURL(item?.url);
}
}}
showListRight={viewMode === "list"}
/>
</div>
);
},
[data, selectedItem, viewMode, onMouseEnter, handleOpenURL]
);

return (
<div className={`border-r border-gray-200 dark:border-gray-700 flex flex-col h-full ${
viewMode === "list" ? "w-[100%]" : "w-[50%]"
}`}>
<div
className={`border-r border-gray-200 dark:border-gray-700 flex flex-col h-full overflow-x-hidden ${
viewMode === "list" ? "w-[100%]" : "w-[50%]"
}`}
>
<div className="px-2 flex-shrink-0">
<SearchHeader
total={total}
Expand All @@ -213,37 +236,35 @@ export const DocumentList: React.FC<DocumentListProps> = ({
/>
</div>

<div ref={containerRef} className="flex-1 overflow-y-auto custom-scrollbar">
{data?.list.map((hit: any, index: number) => {
const isSelected = selectedItem === index;
const item = hit.document;
return (
<div
key={item.id + index}
ref={(el) => (itemRefs.current[index] = el)}
onMouseEnter={() => onMouseEnter(index, item)}
onClick={() => {
if (item?.url) {
handleOpenURL(item?.url);
<div className="flex-1 overflow-hidden">
{data?.list && data.list.length > 0 ? (
<div ref={containerRef} style={{ height: '100%' }}>
<FixedSizeList
ref={listRef}
height={containerRef.current?.clientHeight || 400}
width="100%"
itemCount={data?.list.length}
itemSize={ITEM_HEIGHT}
overscanCount={5}
onScroll={({ scrollOffset, scrollUpdateWasRequested }) => {
if (!scrollUpdateWasRequested && containerRef.current) {
const threshold = 100;
const { scrollHeight, clientHeight } = containerRef.current;
const remainingScroll = scrollHeight - (scrollOffset + clientHeight);
if (remainingScroll <= threshold && !loading && data?.hasMore) {
data?.loadMore && data.loadMore();
}
}
}}
className={`w-full px-2 py-2.5 text-sm flex items-center gap-3 rounded-lg transition-colors cursor-pointer ${
isSelected
? "text-white bg-[var(--coco-primary-color)] hover:bg-[var(--coco-primary-color)]"
: "text-[#333] dark:text-[#d8d8d8]"
}`}
>
<div className="flex gap-2 items-center flex-1 min-w-0">
<ItemIcon item={item} />
<span className={`text-sm truncate`}>{item?.title}</span>
</div>
</div>
);
})}
{Row}
</FixedSizeList>
</div>
) : null}

{loading && (
<div className="flex justify-center py-4">
<span>{t('search.list.loading')}</span>
<span>{t("search.list.loading")}</span>
</div>
)}

Expand All @@ -252,13 +273,13 @@ export const DocumentList: React.FC<DocumentListProps> = ({
data-tauri-drag-region
className="h-full w-full flex flex-col items-center"
>
<img
src={noDataImg}
alt={t('search.list.noDataAlt')}
className="w-16 h-16 mt-24"
<img
src={noDataImg}
alt={t("search.list.noDataAlt")}
className="w-16 h-16 mt-24"
/>
<div className="mt-4 text-sm text-[#999] dark:text-[#666]">
{t('search.list.noResults')}
{t("search.list.noResults")}
</div>
</div>
)}
Expand Down
Loading

0 comments on commit 37d395b

Please sign in to comment.