Files
upage-git/app/.client/components/editor/editors/IconEditor.tsx

869 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useCallback, useEffect, useRef, useState } from 'react';
import type { EditorProps } from './EditorProps';
/**
* 生成 iconify 图标的 HTML
*
* @param icon 图标名称
* @param style 样式对象
* @returns 包含 iconify 图标的 HTML 字符串
*/
const iconifyIcon = (icon: string, style: string = '') => {
return `<iconify-icon icon="${icon}" ${style ? `style="${style}"` : ''}></iconify-icon>`;
};
const API_BASE_URLS = ['https://api.iconify.design', 'https://api.simplesvg.com', 'https://api.unisvg.com'];
const API_ENDPOINTS = {
COLLECTIONS: '/collections',
COLLECTION: '/collection',
SEARCH: '/search',
};
// 每页加载的图标数量
const ICONS_PER_PAGE = 20;
interface IconSetInfo {
name: string;
total?: number;
author?: {
name: string;
url?: string;
};
license?: {
title: string;
url?: string;
};
samples?: string[];
height?: number;
[key: string]: any;
}
/**
* 从多个API源获取 iconify 数据
*
* @param endpoint API端点
* @param params URL参数对象
* @returns 获取到的数据
* @throws 如果所有API源都失败则抛出错误
*/
const fetchFromAPI = async (endpoint: string, params: Record<string, string> = {}) => {
const queryString = Object.entries(params)
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
.join('&');
const urlSuffix = queryString ? `${endpoint}?${queryString}` : endpoint;
let lastError = null;
let lastResponseText = '';
for (const baseUrl of API_BASE_URLS) {
try {
const url = `${baseUrl}${urlSuffix}`;
const response = await fetch(url);
if (response.ok) {
return await response.json();
}
lastResponseText = await response.text();
console.warn(`API 请求失败: ${url}, 状态码: ${response.status}, 响应: ${lastResponseText.substring(0, 100)}...`);
} catch (err) {
lastError = err;
console.warn(`${baseUrl}${urlSuffix} 获取数据失败`, err);
}
}
const errorMsg = lastResponseText ? `API 返回错误: ${lastResponseText.substring(0, 200)}` : '无法从任何API源获取数据';
console.error(errorMsg, lastError);
throw new Error(errorMsg);
};
/**
* 图标编辑器组件,用于实现 iconify 图标替换。
*/
export const IconEditor: React.FC<EditorProps> = ({ element, onClose }) => {
const [searchTerm, setSearchTerm] = useState('');
const [currentIcon, setCurrentIcon] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [iconSets, setIconSets] = useState<string[]>([]);
const [iconSetsInfo, setIconSetsInfo] = useState<Record<string, IconSetInfo>>({});
const [selectedIconSet, setSelectedIconSet] = useState<string>('');
const [icons, setIcons] = useState<string[]>([]);
const [, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [searchResults, setSearchResults] = useState<string[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [allIconNames, setAllIconNames] = useState<string[]>([]);
const [loadedIconNames, setLoadedIconNames] = useState<string[]>([]);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const observerRef = useRef<IntersectionObserver | null>(null);
const loadMoreTriggerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (element && element.tagName.toLowerCase() === 'iconify-icon') {
const iconName = element.getAttribute('icon') || '';
setCurrentIcon(iconName);
}
}, [element]);
useEffect(() => {
const fetchIconSets = async () => {
setIsLoading(true);
try {
const data = await fetchFromAPI(API_ENDPOINTS.COLLECTIONS);
const prefixes = Object.keys(data);
setIconSets(prefixes);
setIconSetsInfo(data);
if (currentIcon) {
const [prefix] = currentIcon.split(':');
if (prefixes.includes(prefix)) {
setSelectedIconSet(prefix);
return;
}
}
setSelectedIconSet(prefixes[0]);
} catch (err) {
console.error('获取图标集失败', err);
setError('获取图标集失败,请稍后再试');
} finally {
setIsLoading(false);
}
};
if (iconSets.length === 0) {
fetchIconSets();
}
}, [currentIcon]);
useEffect(() => {
if (observerRef.current) {
observerRef.current.disconnect();
observerRef.current = null;
}
if ('IntersectionObserver' in window && loadMoreTriggerRef.current && !isSearching && hasMore) {
observerRef.current = new IntersectionObserver(
(entries) => {
const [entry] = entries;
if (entry.isIntersecting && hasMore && !isLoading && !isLoadingMore) {
loadMoreIcons();
}
},
{ threshold: 0.1, rootMargin: '0px 0px 100px 0px' },
);
observerRef.current.observe(loadMoreTriggerRef.current);
}
return () => {
if (observerRef.current) {
observerRef.current.disconnect();
observerRef.current = null;
}
};
}, [hasMore, isLoading, isLoadingMore, isSearching, selectedIconSet]);
useEffect(() => {
if (selectedIconSet) {
setPage(1);
setIcons([]);
setAllIconNames([]);
setLoadedIconNames([]);
setHasMore(true);
fetchAllIconNames(selectedIconSet);
}
}, [selectedIconSet]);
const fetchAllIconNames = async (prefix: string) => {
setIsLoading(true);
setError(null);
try {
const data = await fetchFromAPI(API_ENDPOINTS.COLLECTION, {
prefix,
chars: 'true',
aliases: 'true',
});
const iconNamesSet = new Set<string>();
if (data.uncategorized && Array.isArray(data.uncategorized)) {
data.uncategorized.forEach((name: string) => iconNamesSet.add(name));
}
if (data.categories && typeof data.categories === 'object') {
Object.values(data.categories).forEach((icons: any) => {
if (Array.isArray(icons)) {
icons.forEach((name: string) => iconNamesSet.add(name));
}
});
}
if (data.icons && Array.isArray(data.icons)) {
data.icons.forEach((name: string) => iconNamesSet.add(name));
}
if (Array.isArray(data)) {
data.forEach((name: string) => iconNamesSet.add(name));
}
if (iconNamesSet.size === 0 && typeof data === 'object') {
Object.keys(data).forEach((key) => {
if (typeof data[key] === 'object' && data[key] !== null) {
iconNamesSet.add(key);
}
});
}
const iconNames = Array.from(iconNamesSet);
setAllIconNames(iconNames);
if (iconNames.length > 0) {
fetchIconsBatch(prefix, iconNames.slice(0, ICONS_PER_PAGE));
} else {
setHasMore(false);
}
} catch (err) {
console.error('获取图标集失败', err instanceof Error ? err.message : String(err));
setError('获取图标集失败,请稍后再试');
setIsLoading(false);
}
};
const fetchIconsBatch = async (prefix: string, iconBatch: string[]) => {
if (iconBatch.length === 0) {
setIsLoading(false);
setIsLoadingMore(false);
return;
}
setError(null);
try {
const iconsParam = iconBatch.join(',');
await fetchFromAPI(`/${prefix}.json`, { icons: iconsParam });
const newIcons = iconBatch.map((name) => `${prefix}:${name}`);
setIcons((prev) => {
const updated = [...prev, ...newIcons];
return updated;
});
setLoadedIconNames((prev) => {
const updated = [...prev, ...iconBatch];
const hasMoreIcons = updated.length < allIconNames.length;
setTimeout(() => {
setHasMore(hasMoreIcons);
}, 0);
return updated;
});
setPage((prevPage) => prevPage + 1);
} catch (err) {
console.error('获取图标失败', err instanceof Error ? err.message : String(err));
setError('获取图标失败,请稍后再试');
} finally {
setIsLoading(false);
setIsLoadingMore(false);
}
};
const loadMoreIcons = useCallback(() => {
if (isLoading || isLoadingMore || !hasMore || isSearching || !selectedIconSet) {
return;
}
setIsLoadingMore(true);
const startIndex = loadedIconNames.length;
const endIndex = Math.min(startIndex + ICONS_PER_PAGE, allIconNames.length);
if (startIndex < endIndex) {
const nextBatch = allIconNames.slice(startIndex, endIndex);
fetchIconsBatch(selectedIconSet, nextBatch);
} else {
setHasMore(false);
setIsLoadingMore(false);
}
}, [isLoading, isLoadingMore, hasMore, isSearching, selectedIconSet, loadedIconNames.length, allIconNames]);
const searchIcons = async () => {
if (!searchTerm.trim()) {
setSearchResults([]);
setIsSearching(false);
return;
}
setIsSearching(true);
setIsLoading(true);
setError(null);
try {
const data = await fetchFromAPI(API_ENDPOINTS.SEARCH, { query: searchTerm });
let results: string[] = [];
if (data.icons && Array.isArray(data.icons)) {
results = data.icons;
} else if (Array.isArray(data)) {
results = data;
} else if (data.results && Array.isArray(data.results)) {
results = data.results;
} else if (typeof data === 'object') {
const possibleResults = Object.keys(data).filter((key) => typeof data[key] === 'object' && data[key] !== null);
if (possibleResults.length > 0) {
results = possibleResults;
}
}
setSearchResults(results);
} catch (err) {
console.error('搜索图标失败', err instanceof Error ? err.message : String(err));
setError('搜索图标失败,请稍后再试');
} finally {
setIsLoading(false);
}
};
const handleSearch = useCallback(() => {
searchIcons();
}, [searchTerm]);
const loadMore = useCallback(() => {
if (!isLoading && hasMore && !isLoadingMore && selectedIconSet) {
loadMoreIcons();
return;
}
setHasMore(false);
setIsLoadingMore(false);
}, [isLoading, hasMore, isLoadingMore, selectedIconSet, loadMoreIcons]);
const selectIcon = (iconName: string) => {
if (element && element.tagName.toLowerCase() === 'iconify-icon') {
element.setAttribute('icon', iconName);
setCurrentIcon(iconName);
onClose();
}
};
const handleIconSetChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
setSelectedIconSet(e.target.value);
setPage(1);
setIcons([]);
};
const renderIcons = () => {
const iconsToRender = isSearching ? searchResults : icons;
if (iconsToRender.length === 0) {
return (
<div
style={{
textAlign: 'center',
color: '#64748b',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '180px',
}}
>
{isLoading ? (
<>
<div
dangerouslySetInnerHTML={{
__html: iconifyIcon('line-md:loading-twotone-loop', 'font-size: 24px; color: #64748b'),
}}
style={{ marginBottom: '12px' }}
/>
<div>...</div>
</>
) : (
<>
<div
dangerouslySetInnerHTML={{
__html: iconifyIcon('tabler:mood-sad', 'font-size: 40px; color: #94a3b8'),
}}
style={{ marginBottom: '16px' }}
/>
<div></div>
<div style={{ fontSize: '12px', marginTop: '8px', color: '#94a3b8' }}>
{isSearching ? '请尝试其他搜索关键词' : '请选择其他图标集'}
</div>
</>
)}
</div>
);
}
return (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(65px, 1fr))',
gap: '10px',
}}
>
{iconsToRender.map((iconName) => (
<div
key={iconName}
onClick={() => selectIcon(iconName)}
style={{
cursor: 'pointer',
padding: '8px 4px',
border: iconName === currentIcon ? '2px solid #3b82f6' : '1px solid #e2e8f0',
borderRadius: '8px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: iconName === currentIcon ? '#eff6ff' : '#ffffff',
boxShadow:
iconName === currentIcon ? '0 1px 3px rgba(59, 130, 246, 0.1)' : '0 1px 2px rgba(0, 0, 0, 0.02)',
transition: 'all 0.2s ease',
height: '70px',
}}
onMouseOver={(e) => {
if (iconName !== currentIcon) {
e.currentTarget.style.backgroundColor = '#f8fafc';
e.currentTarget.style.borderColor = '#cbd5e1';
e.currentTarget.style.transform = 'translateY(-1px)';
e.currentTarget.style.boxShadow = '0 2px 4px rgba(0, 0, 0, 0.05)';
}
}}
onMouseOut={(e) => {
if (iconName !== currentIcon) {
e.currentTarget.style.backgroundColor = '#ffffff';
e.currentTarget.style.borderColor = '#e2e8f0';
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 1px 2px rgba(0, 0, 0, 0.02)';
}
}}
>
<div
dangerouslySetInnerHTML={{
__html: `<iconify-icon icon="${iconName}" style="font-size: 26px; color: ${iconName === currentIcon ? '#3b82f6' : '#475569'}"></iconify-icon>`,
}}
></div>
<div
style={{
fontSize: '10px',
marginTop: '6px',
textAlign: 'center',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
width: '100%',
color: iconName === currentIcon ? '#3b82f6' : '#64748b',
}}
>
{iconName.split(':')[1] || iconName}
</div>
</div>
))}
</div>
);
};
return (
<div style={{ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', width: '400px' }}>
<div style={{ marginBottom: '20px' }}>
<div
style={{
fontSize: '14px',
fontWeight: 500,
marginBottom: '16px',
color: '#475569',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
<span>:</span>
{currentIcon ? (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
backgroundColor: '#f0f9ff',
borderRadius: '4px',
padding: '4px 8px',
border: '1px solid #bae6fd',
color: '#0284c7',
fontSize: '14px',
fontWeight: 'normal',
}}
>
<span
style={{
display: 'inline-flex',
}}
dangerouslySetInnerHTML={{
__html: `<iconify-icon icon="${currentIcon}" style="font-size: 16px; margin-right: 6px"></iconify-icon>`,
}}
></span>
{currentIcon}
</span>
) : (
<span style={{ color: '#94a3b8', fontSize: '14px', fontStyle: 'italic' }}></span>
)}
</div>
<div
style={{
display: 'flex',
marginBottom: '16px',
border: '1px solid #e2e8f0',
borderRadius: '8px',
overflow: 'hidden',
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.05)',
transition: 'all 0.2s ease',
}}
>
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
placeholder="搜索图标..."
style={{
flex: 1,
padding: '10px 14px',
border: 'none',
outline: 'none',
fontSize: '14px',
backgroundColor: '#ffffff',
}}
/>
<button
onClick={handleSearch}
disabled={isLoading}
style={{
background: isLoading ? '#f1f5f9' : '#f8fafc',
border: 'none',
borderLeft: '1px solid #e2e8f0',
padding: '0 16px',
cursor: isLoading ? 'default' : 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'all 0.2s ease',
color: isLoading ? '#94a3b8' : '#64748b',
}}
onMouseOver={(e) => {
if (!isLoading) {
e.currentTarget.style.backgroundColor = '#f1f5f9';
}
}}
onMouseOut={(e) => {
if (!isLoading) {
e.currentTarget.style.backgroundColor = '#f8fafc';
}
}}
>
<div
dangerouslySetInnerHTML={{
__html: iconifyIcon('tabler:search', 'font-size: 18px'),
}}
/>
</button>
</div>
{!isSearching && (
<div style={{ marginBottom: '16px' }}>
<label
style={{
display: 'block',
marginBottom: '8px',
fontSize: '14px',
color: '#475569',
fontWeight: 500,
}}
>
:
</label>
<div style={{ position: 'relative' }}>
<select
value={selectedIconSet}
onChange={handleIconSetChange}
disabled={isLoading || iconSets.length === 0}
style={{
width: '100%',
padding: '10px 14px',
paddingRight: '32px',
borderRadius: '8px',
border: '1px solid #e2e8f0',
fontSize: '14px',
color: '#1e293b',
backgroundColor: '#ffffff',
appearance: 'none',
cursor: isLoading || iconSets.length === 0 ? 'default' : 'pointer',
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.05)',
transition: 'border-color 0.2s ease',
}}
>
{iconSets.length === 0 ? (
<option value="">...</option>
) : (
iconSets.map((prefix) => (
<option key={prefix} value={prefix}>
{iconSetsInfo[prefix]?.name ? `${iconSetsInfo[prefix].name}` : prefix}
</option>
))
)}
</select>
<div
style={{
position: 'absolute',
right: '12px',
top: '0',
bottom: '0',
pointerEvents: 'none',
color: '#64748b',
display: 'inline-flex',
alignItems: 'center',
}}
>
<div
dangerouslySetInnerHTML={{
__html: iconifyIcon('tabler:chevron-down', 'font-size: 16px'),
}}
/>
</div>
</div>
</div>
)}
{error && (
<div
style={{
color: '#ef4444',
fontSize: '14px',
marginBottom: '12px',
padding: '8px 12px',
backgroundColor: '#fef2f2',
borderRadius: '6px',
border: '1px solid #fee2e2',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<div
dangerouslySetInnerHTML={{
__html: iconifyIcon('tabler:alert-circle', 'font-size: 16px; color: currentColor'),
}}
/>
{error}
</div>
</div>
)}
</div>
<div
ref={scrollContainerRef}
style={{
maxHeight: '180px',
overflowY: 'auto',
overflowX: 'hidden',
border: '1px solid #e2e8f0',
borderRadius: '10px',
padding: '16px',
backgroundColor: '#ffffff',
boxShadow: 'inset 0 1px 2px rgba(0, 0, 0, 0.05)',
scrollBehavior: 'smooth',
}}
onScroll={(e) => {
const target = e.currentTarget;
const isNearBottom = target.scrollHeight - target.scrollTop - target.clientHeight < 100;
if (isNearBottom && hasMore && !isLoading && !isLoadingMore && !isSearching) {
loadMoreIcons();
}
}}
>
{isLoading && icons.length === 0 && !isSearching ? (
<div
style={{
textAlign: 'center',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
}}
>
<div
dangerouslySetInnerHTML={{
__html: iconifyIcon('line-md:loading-twotone-loop', 'font-size: 40px; color: #64748b; opacity: 0.7'),
}}
style={{
margin: '0 auto',
display: 'block',
}}
/>
<p
style={{
marginTop: '16px',
color: '#64748b',
fontSize: '14px',
}}
>
...
</p>
</div>
) : (
renderIcons()
)}
<div
ref={loadMoreTriggerRef}
style={{
height: '20px',
margin: '20px 0 10px',
visibility: hasMore && !isSearching ? 'visible' : 'hidden',
display: hasMore && !isSearching ? 'block' : 'none',
position: 'relative',
width: '100%',
textAlign: 'center',
}}
>
{hasMore && !isSearching && !isLoadingMore && (
<div
style={{
fontSize: '12px',
color: '#94a3b8',
padding: '5px',
border: '1px dashed #e2e8f0',
borderRadius: '4px',
display: 'inline-block',
}}
>
...
</div>
)}
</div>
{isLoadingMore && !isSearching && (
<div
style={{
textAlign: 'center',
padding: '10px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
}}
>
<div
dangerouslySetInnerHTML={{
__html: iconifyIcon('line-md:loading-twotone-loop', 'font-size: 20px; color: #64748b'),
}}
/>
<span style={{ fontSize: '14px', color: '#64748b' }}>...</span>
</div>
)}
</div>
<div
style={{
display: 'flex',
justifyContent: 'center',
marginTop: '20px',
gap: '12px',
}}
>
{!isSearching && hasMore && (
<button
onClick={loadMore}
disabled={isLoading || isLoadingMore}
style={{
padding: '8px 16px',
borderRadius: '8px',
border: '1px solid #e2e8f0',
backgroundColor: isLoading || isLoadingMore ? '#f1f5f9' : '#f8fafc',
color: isLoading || isLoadingMore ? '#94a3b8' : '#475569',
cursor: isLoading || isLoadingMore ? 'default' : 'pointer',
fontSize: '14px',
fontWeight: 500,
transition: 'all 0.2s ease',
display: 'flex',
alignItems: 'center',
gap: '6px',
}}
onMouseOver={(e) => {
if (!isLoading && !isLoadingMore) {
e.currentTarget.style.backgroundColor = '#f1f5f9';
e.currentTarget.style.borderColor = '#cbd5e1';
}
}}
onMouseOut={(e) => {
if (!isLoading && !isLoadingMore) {
e.currentTarget.style.backgroundColor = '#f8fafc';
e.currentTarget.style.borderColor = '#e2e8f0';
}
}}
>
{isLoading || isLoadingMore ? (
<>
<div
dangerouslySetInnerHTML={{
__html: iconifyIcon('line-md:loading-twotone-loop', 'font-size: 14px; color: #64748b'),
}}
style={{ marginRight: '4px' }}
/>
...
</>
) : (
<>
<div
dangerouslySetInnerHTML={{
__html: iconifyIcon('tabler:download', 'font-size: 16px'),
}}
/>
</>
)}
</button>
)}
{isSearching && (
<button
onClick={() => {
setIsSearching(false);
setSearchTerm('');
setSearchResults([]);
}}
style={{
padding: '8px 16px',
borderRadius: '8px',
border: '1px solid #e2e8f0',
backgroundColor: '#f8fafc',
color: '#475569',
cursor: 'pointer',
fontSize: '14px',
fontWeight: 500,
transition: 'all 0.2s ease',
display: 'flex',
alignItems: 'center',
gap: '6px',
}}
onMouseOver={(e) => {
e.currentTarget.style.backgroundColor = '#f1f5f9';
e.currentTarget.style.borderColor = '#cbd5e1';
}}
onMouseOut={(e) => {
e.currentTarget.style.backgroundColor = '#f8fafc';
e.currentTarget.style.borderColor = '#e2e8f0';
}}
>
<div
dangerouslySetInnerHTML={{
__html: iconifyIcon('tabler:arrow-left', 'font-size: 16px'),
}}
/>
</button>
)}
</div>
</div>
);
};