From fa93d2e77bac74acfa18dff66c45b21c8fa8cba5 Mon Sep 17 00:00:00 2001 From: LTbinglingfeng Date: Sat, 13 Jun 2026 13:17:27 +0800 Subject: [PATCH] feat(pluginStore): add expandable description for plugins with toggle functionality --- .../plugins/PluginStorePage.module.scss | 37 +++++++ src/features/plugins/PluginStorePage.tsx | 101 +++++++++++++++++- src/i18n/locales/en.json | 2 + src/i18n/locales/ru.json | 2 + src/i18n/locales/zh-CN.json | 2 + src/i18n/locales/zh-TW.json | 2 + 6 files changed, 144 insertions(+), 2 deletions(-) diff --git a/src/features/plugins/PluginStorePage.module.scss b/src/features/plugins/PluginStorePage.module.scss index 96ea599..1e33d21 100644 --- a/src/features/plugins/PluginStorePage.module.scss +++ b/src/features/plugins/PluginStorePage.module.scss @@ -363,6 +363,43 @@ -webkit-line-clamp: 2; } +.cardDescBlock { + display: flex; + min-width: 0; + flex-direction: column; + gap: 4px; +} + +.cardDescExpanded { + display: block; + overflow: visible; + -webkit-line-clamp: initial; +} + +.cardDescToggle { + align-self: flex-start; + padding: 0; + border: 0; + background: transparent; + color: var(--primary-color); + cursor: pointer; + font: inherit; + font-size: 12px; + font-weight: 650; + line-height: 1.4; + + &:hover { + color: var(--primary-hover); + text-decoration: underline; + } + + &:focus-visible { + outline: 2px solid color-mix(in srgb, var(--primary-color) 45%, transparent); + outline-offset: 3px; + border-radius: 4px; + } +} + .cardMeta { display: flex; align-items: center; diff --git a/src/features/plugins/PluginStorePage.tsx b/src/features/plugins/PluginStorePage.tsx index 99f77ef..6697e2a 100644 --- a/src/features/plugins/PluginStorePage.tsx +++ b/src/features/plugins/PluginStorePage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { Button } from '@/components/ui/Button'; @@ -37,6 +37,8 @@ const getErrorDetailMessage = (error: unknown): string => { return typeof message === 'string' ? message.trim() : ''; }; +const DESCRIPTION_COLLAPSED_LINES = 2; + const getStoreEntryTitle = (entry: PluginStoreEntry) => entry.name || entry.id; function StoreCardLogo({ src }: { src: string }) { @@ -66,6 +68,9 @@ export function PluginStorePage() { const [statusFilter, setStatusFilter] = useState('all'); const [installingID, setInstallingID] = useState(''); const [restartRequiredIDs, setRestartRequiredIDs] = useState([]); + const [expandedDescriptionIDs, setExpandedDescriptionIDs] = useState([]); + const [overflowingDescriptionIDs, setOverflowingDescriptionIDs] = useState([]); + const descriptionRefs = useRef>({}); const connected = connectionStatus === 'connected'; @@ -168,6 +173,68 @@ export function PluginStorePage() { const hasActiveFilters = Boolean(filter.trim()) || statusFilter !== 'all'; + const expandedDescriptionIDSet = useMemo( + () => new Set(expandedDescriptionIDs), + [expandedDescriptionIDs] + ); + const overflowingDescriptionIDSet = useMemo( + () => new Set(overflowingDescriptionIDs), + [overflowingDescriptionIDs] + ); + + const registerDescriptionRef = useCallback((id: string, node: HTMLParagraphElement | null) => { + if (node) { + descriptionRefs.current[id] = node; + } else { + delete descriptionRefs.current[id]; + } + }, []); + + const measureDescriptionOverflow = useCallback(() => { + const nextIDs = Object.entries(descriptionRefs.current) + .filter(([, node]) => { + if (!node) return false; + const computed = window.getComputedStyle(node); + const lineHeight = Number.parseFloat(computed.lineHeight); + if (!Number.isFinite(lineHeight) || lineHeight <= 0) { + return node.scrollHeight > node.clientHeight + 1; + } + return node.scrollHeight > lineHeight * DESCRIPTION_COLLAPSED_LINES + 1; + }) + .map(([id]) => id); + + setOverflowingDescriptionIDs((current) => { + if (current.length === nextIDs.length && current.every((id) => nextIDs.includes(id))) { + return current; + } + return nextIDs; + }); + }, []); + + useEffect(() => { + const frame = window.requestAnimationFrame(measureDescriptionOverflow); + return () => { + window.cancelAnimationFrame(frame); + }; + }, [measureDescriptionOverflow, visiblePlugins]); + + useEffect(() => { + const handleResize = () => { + window.requestAnimationFrame(measureDescriptionOverflow); + }; + + window.addEventListener('resize', handleResize); + return () => { + window.removeEventListener('resize', handleResize); + }; + }, [measureDescriptionOverflow]); + + const toggleDescription = useCallback((id: string) => { + setExpandedDescriptionIDs((current) => + current.includes(id) ? current.filter((currentID) => currentID !== id) : [...current, id] + ); + }, []); + const handleInstall = (entry: PluginStoreEntry) => { const isUpdate = entry.installed && entry.updateAvailable; const title = getStoreEntryTitle(entry); @@ -223,6 +290,9 @@ export function PluginStorePage() { ? `v${entry.version}` : ''; const metaItems = [versionText, entry.author, entry.license].filter(Boolean); + const isDescriptionExpanded = expandedDescriptionIDSet.has(entry.id); + const isDescriptionOverflowing = overflowingDescriptionIDSet.has(entry.id); + const descriptionID = `plugin-store-desc-${entry.id}`; return (
@@ -246,7 +316,34 @@ export function PluginStorePage() { - {entry.description ?

{entry.description}

: null} + {entry.description ? ( +
+

registerDescriptionRef(entry.id, node)} + className={`${styles.cardDesc} ${ + isDescriptionExpanded ? styles.cardDescExpanded : '' + }`} + > + {entry.description} +

+ {isDescriptionOverflowing ? ( + + ) : null} +
+ ) : null} {metaItems.length > 0 ? (
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index b0e6ea6..3b2d3b2 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1165,6 +1165,8 @@ "plugin_store": { "title": "Plugin Store", "description": "Browse the plugin registry, then install or update plugins for the current backend.", + "description_show_more": "Show more", + "description_show_less": "Show less", "refresh": "Refresh", "retry": "Retry", "load_failed": "Failed to load the plugin store", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 6027cd9..0cb7dfe 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1150,6 +1150,8 @@ "plugin_store": { "title": "Магазин плагинов", "description": "Просматривайте реестр плагинов, устанавливайте и обновляйте плагины для текущего бэкенда.", + "description_show_more": "Показать больше", + "description_show_less": "Свернуть", "refresh": "Обновить", "retry": "Повторить", "load_failed": "Не удалось загрузить магазин плагинов", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 9b24f24..eee3be0 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1165,6 +1165,8 @@ "plugin_store": { "title": "插件商店", "description": "浏览插件注册表,为当前后端安装或更新插件。", + "description_show_more": "展开描述", + "description_show_less": "收起描述", "refresh": "刷新", "retry": "重试", "load_failed": "插件商店加载失败", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index e38b2f0..d84152b 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -1191,6 +1191,8 @@ "plugin_store": { "title": "插件商店", "description": "瀏覽插件註冊表,為目前後端安裝或更新插件。", + "description_show_more": "展開描述", + "description_show_less": "收起描述", "refresh": "重新整理", "retry": "重試", "load_failed": "插件商店載入失敗",