diff --git a/desktop/src/components/TemplateMarket/TemplateMarketDrawer.tsx b/desktop/src/components/TemplateMarket/TemplateMarketDrawer.tsx
index 4981481..9e80b5e 100644
--- a/desktop/src/components/TemplateMarket/TemplateMarketDrawer.tsx
+++ b/desktop/src/components/TemplateMarket/TemplateMarketDrawer.tsx
@@ -17,8 +17,6 @@ import {
Maximize2,
MessageCircle,
Printer,
- RefreshCw,
- Search,
ShoppingBag,
Smile,
Sparkles,
@@ -28,7 +26,6 @@ import {
ZoomIn,
ZoomOut
} from 'lucide-react';
-import { Input } from '../common/Input';
import { Button } from '../common/Button';
import { Modal } from '../common/Modal';
import { useConfigStore } from '../../store/configStore';
@@ -47,6 +44,7 @@ import {
templateLabelKeys,
TEMPLATE_ALL_VALUE
} from '../../data/templateMarket';
+import { TemplateMarketFilters, type ActiveTemplateFilter } from './TemplateMarketFilters';
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
const hashString = async (value: string) => {
@@ -73,6 +71,35 @@ const mimeToExtension = (mime: string) => {
return 'jpg';
};
const ALL_VALUE = TEMPLATE_ALL_VALUE;
+const TEMPLATE_GRID_GAP = 20;
+const TEMPLATE_GRID_MIN_CARD_WIDTH = 180;
+const TEMPLATE_GRID_CARD_HEIGHT = 320;
+const TEMPLATE_GRID_COLUMNS = {
+ MIN: 2,
+ MEDIUM: 3,
+ LARGE: 4
+} as const;
+const TEMPLATE_GRID_BREAKPOINTS = {
+ MEDIUM: 768,
+ LARGE: 1280
+} as const;
+
+const getTemplateColumnCount = (containerWidth: number, viewportWidth: number | undefined) => {
+ const basis = viewportWidth ?? containerWidth;
+ let count: number = TEMPLATE_GRID_COLUMNS.MIN;
+ if (basis >= TEMPLATE_GRID_BREAKPOINTS.LARGE) {
+ count = TEMPLATE_GRID_COLUMNS.LARGE;
+ } else if (basis >= TEMPLATE_GRID_BREAKPOINTS.MEDIUM) {
+ count = TEMPLATE_GRID_COLUMNS.MEDIUM;
+ }
+
+ while (count > TEMPLATE_GRID_COLUMNS.MIN) {
+ const requiredWidth = count * TEMPLATE_GRID_MIN_CARD_WIDTH + (count - 1) * TEMPLATE_GRID_GAP;
+ if (containerWidth >= requiredWidth) break;
+ count -= 1;
+ }
+ return count;
+};
const buildDefaultTemplateImage = (label: string) => `data:image/svg+xml;utf8,${encodeURIComponent(
`
@@ -104,7 +131,7 @@ const fallbackMeta: TemplateMeta = {
const FILTER_LABEL_KEYS: Record
= templateLabelKeys;
-const getFilterLabel = (value: string, translate: (key: string, options?: any) => string) => {
+const getFilterLabel = (value: string, translate: (key: string, options?: Record) => string) => {
const key = FILTER_LABEL_KEYS[value];
return key ? translate(key) : value;
};
@@ -178,7 +205,7 @@ const formatSourceName = (name: string) => (name.startsWith('@') ? name : `@${na
const openExternalUrl = async (url: string) => {
if (!url) return;
- if ((window as any).__TAURI_INTERNALS__) {
+ if (window.__TAURI_INTERNALS__) {
try {
const { openUrl } = await import('@tauri-apps/plugin-opener');
await openUrl(url);
@@ -190,45 +217,6 @@ const openExternalUrl = async (url: string) => {
window.open(url, '_blank', 'noopener,noreferrer');
};
-const ActiveFilterChip = ({
- label,
- onClear
-}: {
- label: string;
- onClear: () => void;
-}) => (
-
-);
-
-const FilterChip = ({
- label,
- active,
- onClick
-}: {
- label: string;
- active: boolean;
- onClick: () => void;
-}) => (
-
-);
-
const buildSearchText = (item: TemplateItem) => {
const tags = item.tags ? item.tags.join(' ') : '';
const channels = Array.isArray(item.channels) ? item.channels.join(' ') : '';
@@ -416,7 +404,7 @@ const TemplatePreviewModal = ({
}
const blob = await response.blob();
- const isTauri = typeof window !== 'undefined' && Boolean((window as any).__TAURI_INTERNALS__);
+ const isTauri = typeof window !== 'undefined' && Boolean(window.__TAURI_INTERNALS__);
if (isTauri) {
try {
const { invoke } = await import('@tauri-apps/api/core');
@@ -436,7 +424,7 @@ const TemplatePreviewModal = ({
}
}
- const ClipboardItemCtor = (window as any).ClipboardItem as typeof ClipboardItem | undefined;
+ const ClipboardItemCtor = typeof ClipboardItem !== 'undefined' ? ClipboardItem : undefined;
if (ClipboardItemCtor && navigator.clipboard?.write) {
await navigator.clipboard.write([new ClipboardItemCtor({ [blob.type || 'image/png']: blob })]);
toast.success(t('toast.copyImageSuccess'));
@@ -1049,6 +1037,108 @@ const TemplateCard = React.memo(function TemplateCard({
);
});
+const VirtualTemplateGrid = ({
+ templates,
+ applyingId,
+ onPreview,
+ onApply,
+ isFiltering,
+ scrollTop,
+ viewportHeight
+}: {
+ templates: TemplateItem[];
+ applyingId: string | null;
+ onPreview: (item: TemplateItem) => void;
+ onApply: (item: TemplateItem) => void;
+ isFiltering: boolean;
+ scrollTop: number;
+ viewportHeight: number;
+}) => {
+ const wrapperRef = useRef(null);
+ const [width, setWidth] = useState(0);
+
+ useLayoutEffect(() => {
+ const element = wrapperRef.current;
+ if (!element) return;
+
+ const updateWidth = () => {
+ setWidth(element.clientWidth);
+ };
+ updateWidth();
+
+ if (typeof ResizeObserver === 'undefined') {
+ window.addEventListener('resize', updateWidth);
+ return () => {
+ window.removeEventListener('resize', updateWidth);
+ };
+ }
+
+ const observer = new ResizeObserver(updateWidth);
+ observer.observe(element);
+ return () => {
+ observer.disconnect();
+ };
+ }, []);
+
+ const viewportWidth =
+ typeof window !== 'undefined'
+ ? window.innerWidth || document.documentElement.clientWidth
+ : width;
+ const columnCount = width > 0 ? getTemplateColumnCount(width, viewportWidth) : 2;
+ const columnWidth = Math.max(
+ TEMPLATE_GRID_MIN_CARD_WIDTH,
+ Math.floor((Math.max(width, 1) - TEMPLATE_GRID_GAP * (columnCount - 1)) / columnCount)
+ );
+ const rowHeight = TEMPLATE_GRID_CARD_HEIGHT + TEMPLATE_GRID_GAP;
+ const rowCount = Math.ceil(templates.length / columnCount);
+ const totalHeight = rowCount * rowHeight;
+ const gridOffsetTop = wrapperRef.current?.offsetTop ?? 0;
+ const relativeScrollTop = Math.max(0, scrollTop - gridOffsetTop);
+ const overscanRows = 2;
+ const startRow = Math.max(0, Math.floor(relativeScrollTop / rowHeight) - overscanRows);
+ const endRow = Math.min(
+ rowCount - 1,
+ Math.ceil((relativeScrollTop + viewportHeight) / rowHeight) + overscanRows
+ );
+ const visibleCells: Array<{ item: TemplateItem; index: number; rowIndex: number; columnIndex: number }> = [];
+
+ if (width > 0 && rowCount > 0 && endRow >= startRow) {
+ for (let rowIndex = startRow; rowIndex <= endRow; rowIndex += 1) {
+ const rowStartIndex = rowIndex * columnCount;
+ templates.slice(rowStartIndex, rowStartIndex + columnCount).forEach((item, columnIndex) => {
+ visibleCells.push({ item, index: rowStartIndex + columnIndex, rowIndex, columnIndex });
+ });
+ }
+ }
+
+ return (
+
+ {visibleCells.map(({ item, index, rowIndex, columnIndex }) => (
+
+
+
+ ))}
+
+ );
+};
+
export function TemplateMarketDrawer({
onOpenChange
}: {
@@ -1084,15 +1174,18 @@ export function TemplateMarketDrawer({
const [isLoading, setIsLoading] = useState(false);
const [loadError, setLoadError] = useState(null);
const [isFiltering, setIsFiltering] = useState(false);
+ const [listScrollTop, setListScrollTop] = useState(0);
+ const [listViewportHeight, setListViewportHeight] = useState(0);
const dragStartRef = useRef(0);
const toastOnceRef = useRef(false);
const previousOverflowRef = useRef(null);
const previousOverscrollRef = useRef(null);
const requestIdRef = useRef(0);
- const listRef = useRef(null);
const templateDataRef = useRef(templateData);
+ const listRef = useRef(null);
const templateSourceRef = useRef(templateSource);
const scrollTopRef = useRef(0);
+ const filteringTimerRef = useRef | null>(null);
const previousTabRef = useRef<'generate' | 'history'>('generate');
const deferredSearch = useDeferredValue(search);
@@ -1137,7 +1230,7 @@ export function TemplateMarketDrawer({
}, [isDormant, deferredSearch, channel, material, industry, ratio, templateData.items, searchIndex]);
const activeFilters = useMemo(() => {
- const filters: { label: string; onClear: () => void }[] = [];
+ const filters: ActiveTemplateFilter[] = [];
if (search.trim()) {
filters.push({ label: t('templateMarket.active.search', { keyword: search.trim() }), onClear: () => setSearch('') });
}
@@ -1166,6 +1259,7 @@ export function TemplateMarketDrawer({
setRatio(ALL_VALUE);
};
+
const fetchTemplates = useCallback(async (fromUser = false) => {
const requestId = ++requestIdRef.current;
setIsLoading(true);
@@ -1216,11 +1310,63 @@ export function TemplateMarketDrawer({
fetchTemplates();
}, [fetchTemplates]);
+
+ useLayoutEffect(() => {
+ if (!isOpen) return;
+ const container = listRef.current;
+ if (!container) return;
+
+ const updateViewport = () => {
+ setListViewportHeight(container.clientHeight);
+ };
+ updateViewport();
+
+ if (typeof ResizeObserver === 'undefined') {
+ window.addEventListener('resize', updateViewport);
+ return () => {
+ window.removeEventListener('resize', updateViewport);
+ };
+ }
+
+ const observer = new ResizeObserver(updateViewport);
+ observer.observe(container);
+ return () => {
+ observer.disconnect();
+ };
+ }, [isOpen]);
+
+ useLayoutEffect(() => {
+ if (!isOpen || isDormant) return;
+ const container = listRef.current;
+ if (!container) return;
+ const top = scrollTopRef.current;
+ if (top <= 0) return;
+ requestAnimationFrame(() => {
+ container.scrollTop = top;
+ setListScrollTop(top);
+ });
+ }, [isOpen, isDormant, filteredTemplates.length]);
+
useEffect(() => {
- if (isDormant || isFiltering) return;
+ if (filteringTimerRef.current) {
+ clearTimeout(filteringTimerRef.current);
+ filteringTimerRef.current = null;
+ }
+ if (isDormant) {
+ setIsFiltering(false);
+ return;
+ }
setIsFiltering(true);
- const timer = setTimeout(() => setIsFiltering(false), 180);
- return () => clearTimeout(timer);
+ filteringTimerRef.current = setTimeout(() => {
+ setIsFiltering(false);
+ filteringTimerRef.current = null;
+ }, 180);
+ return () => {
+ if (filteringTimerRef.current) {
+ clearTimeout(filteringTimerRef.current);
+ filteringTimerRef.current = null;
+ }
+ };
}, [isDormant, deferredSearch, channel, material, industry, ratio, templateData.items]);
@@ -1239,17 +1385,6 @@ export function TemplateMarketDrawer({
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, previewTemplate]);
- useLayoutEffect(() => {
- if (!isOpen || isDormant) return;
- const container = listRef.current;
- if (!container) return;
- const top = scrollTopRef.current;
- if (top <= 0) return;
- requestAnimationFrame(() => {
- container.scrollTop = top;
- });
- }, [isOpen, isDormant, filteredTemplates.length]);
-
useEffect(() => {
const element = document.documentElement;
if (isOpen) {
@@ -1294,7 +1429,6 @@ export function TemplateMarketDrawer({
nextTab: 'generate' | 'history',
options?: { deferClose?: boolean }
) => {
- scrollTopRef.current = listRef.current?.scrollTop ?? 0;
setTab(nextTab);
const doClose = () => setIsOpen(false);
if (options?.deferClose) {
@@ -1428,110 +1562,38 @@ export function TemplateMarketDrawer({
)}
-