From a5760e744133310d39e5243d62f44d1ed01b39bf Mon Sep 17 00:00:00 2001 From: Andreaseszhang Date: Fri, 3 Apr 2026 18:57:55 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=9C=A8=E8=BF=B7=E4=BD=A0=E5=9C=B0?= =?UTF-8?q?=E5=9B=BE=E5=8F=B3=E4=BE=A7=E6=96=B0=E5=A2=9E=E5=8F=AF=E6=8B=96?= =?UTF-8?q?=E6=8B=BD=E6=BB=9A=E5=8A=A8=E8=BF=9B=E5=BA=A6=E6=9D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在消息区域的迷你地图右侧添加自定义滚动进度条,提供更丝滑的滚动体验: - 支持拖拽滑块和点击轨道跳转 - 拖拽时自动停止 StickToBottom 自动滚动 - 适配全部 8 个主题(default/ocean/forest/slate × light/dark) - 迷你地图位置和样式保持不变 Co-Authored-By: Claude Opus 4.6 --- .../components/ai-elements/scroll-minimap.tsx | 305 ++++++++++++------ apps/electron/src/renderer/styles/globals.css | 90 ++++++ 2 files changed, 291 insertions(+), 104 deletions(-) diff --git a/apps/electron/src/renderer/components/ai-elements/scroll-minimap.tsx b/apps/electron/src/renderer/components/ai-elements/scroll-minimap.tsx index a56bfcf..99df3a6 100644 --- a/apps/electron/src/renderer/components/ai-elements/scroll-minimap.tsx +++ b/apps/electron/src/renderer/components/ai-elements/scroll-minimap.tsx @@ -1,8 +1,9 @@ /** - * ScrollMinimap — 消息导航迷你地图 + * ScrollMinimap — 消息导航迷你地图 + 滚动进度条 * - * 在消息区域右上角显示短横杠代表每条消息的位置, - * 悬浮时弹出消息预览列表,点击可跳转到对应消息。 + * 在消息区域右侧显示: + * 1. 短横杠代表每条消息的位置(迷你地图),悬浮时弹出消息预览列表 + * 2. 可拖拽的滚动进度条,提供丝滑的滚动体验 * 必须放在 StickToBottom(Conversation)内部使用。 */ @@ -73,9 +74,12 @@ export function ScrollMinimap({ items }: ScrollMinimapProps): React.ReactElement const [visibleIds, setVisibleIds] = React.useState>(new Set()) const [canScroll, setCanScroll] = React.useState(false) const [searchQuery, setSearchQuery] = React.useState('') + const [isDragging, setIsDragging] = React.useState(false) + const [scrollMetrics, setScrollMetrics] = React.useState({ scrollTop: 0, scrollHeight: 1, clientHeight: 1 }) const closeTimerRef = React.useRef>() const fadeTimerRef = React.useRef>() const searchInputRef = React.useRef(null) + const trackRef = React.useRef(null) // ── 组件卸载时清理计时器 ── @@ -86,7 +90,7 @@ export function ScrollMinimap({ items }: ScrollMinimapProps): React.ReactElement } }, []) - // ── 可见消息追踪 ── + // ── 可见消息 + 滚动指标追踪 ── React.useEffect(() => { const el = scrollRef.current @@ -95,6 +99,7 @@ export function ScrollMinimap({ items }: ScrollMinimapProps): React.ReactElement const update = (): void => { const { scrollTop, scrollHeight, clientHeight } = el setCanScroll(scrollHeight > clientHeight + 10) + setScrollMetrics({ scrollTop, scrollHeight, clientHeight }) if (scrollHeight <= 0) return const nodes = el.querySelectorAll('[data-message-id]') @@ -136,7 +141,7 @@ export function ScrollMinimap({ items }: ScrollMinimapProps): React.ReactElement if (!hovered) setSearchQuery('') }, [hovered]) - // ── 鼠标进出控制 ── + // ── 鼠标进出控制(仅迷你地图区域) ── const handleMouseEnter = (): void => { if (closeTimerRef.current) clearTimeout(closeTimerRef.current) @@ -146,10 +151,8 @@ export function ScrollMinimap({ items }: ScrollMinimapProps): React.ReactElement } const handleMouseLeave = (): void => { - // 40ms 延迟后开始淡出动画 closeTimerRef.current = setTimeout(() => { setIsLeaving(true) - // 淡出动画 80ms 后关闭 fadeTimerRef.current = setTimeout(() => { setHovered(false) setIsLeaving(false) @@ -165,23 +168,19 @@ export function ScrollMinimap({ items }: ScrollMinimapProps): React.ReactElement const target = el.querySelector(`[data-message-id="${id}"]`) if (!target) return - // 完全停止 StickToBottom 的自动滚动和正在进行的动画 stopScroll() stickyState.animation = undefined stickyState.velocity = 0 stickyState.accumulated = 0 - // 递归累积 offsetTop 直到 scrollRef 容器,尽量让目标居中显示 const offsetTop = getOffsetTopRelativeTo(target, el) const targetHeight = target.offsetHeight const viewportHeight = el.clientHeight - // 目标比视口小:居中;目标比视口大:顶部留 32px 间距 const scrollTarget = targetHeight < viewportHeight ? offsetTop - (viewportHeight - targetHeight) / 2 : offsetTop - 32 el.scrollTo({ top: Math.max(0, scrollTarget), behavior: 'smooth' }) - // 跳转后关闭面板 setHovered(false) }, [scrollRef, stopScroll, stickyState]) @@ -193,110 +192,210 @@ export function ScrollMinimap({ items }: ScrollMinimapProps): React.ReactElement return items.filter((item) => item.preview.toLowerCase().includes(q)) }, [items, searchQuery]) + // ── 滚动条滑块拖拽 ── + + const handleThumbMouseDown = React.useCallback((e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + + const el = scrollRef.current + const track = trackRef.current + if (!el || !track) return + + // 停止 StickToBottom 自动滚动 + stopScroll() + stickyState.animation = undefined + stickyState.velocity = 0 + stickyState.accumulated = 0 + + setIsDragging(true) + const startY = e.clientY + const startScrollTop = el.scrollTop + const trackHeight = track.clientHeight + const { scrollHeight, clientHeight } = el + const scrollRange = scrollHeight - clientHeight + const thumbHeight = Math.max(trackHeight * 0.1, (clientHeight / scrollHeight) * trackHeight) + const scrollableTrack = trackHeight - thumbHeight + + const onMouseMove = (ev: MouseEvent): void => { + ev.preventDefault() + const delta = ev.clientY - startY + const scrollDelta = scrollableTrack > 0 ? (delta / scrollableTrack) * scrollRange : 0 + el.scrollTop = Math.max(0, Math.min(scrollRange, startScrollTop + scrollDelta)) + } + + const onMouseUp = (): void => { + setIsDragging(false) + document.removeEventListener('mousemove', onMouseMove) + document.removeEventListener('mouseup', onMouseUp) + document.body.style.userSelect = '' + document.body.style.cursor = '' + } + + document.body.style.userSelect = 'none' + document.body.style.cursor = 'grabbing' + document.addEventListener('mousemove', onMouseMove) + document.addEventListener('mouseup', onMouseUp) + }, [scrollRef, stopScroll, stickyState]) + + // ── 轨道点击跳转 ── + + const handleTrackMouseDown = React.useCallback((e: React.MouseEvent) => { + // 只响应直接点击轨道背景,忽略点击滑块 + if (e.target !== e.currentTarget) return + + const track = trackRef.current + const el = scrollRef.current + if (!track || !el) return + + stopScroll() + stickyState.animation = undefined + stickyState.velocity = 0 + stickyState.accumulated = 0 + + const rect = track.getBoundingClientRect() + const clickRatio = (e.clientY - rect.top) / rect.height + const { scrollHeight, clientHeight } = el + const targetTop = clickRatio * (scrollHeight - clientHeight) + el.scrollTo({ top: Math.max(0, targetTop), behavior: 'smooth' }) + }, [scrollRef, stopScroll, stickyState]) + if (items.length < MIN_ITEMS || !canScroll) return null // ── 迷你地图条纹 ── const barCount = Math.min(items.length, MAX_BARS) - const stripHeight = barCount * 6 + + // ── 滚动条滑块尺寸计算 ── + + const { scrollTop, scrollHeight, clientHeight } = scrollMetrics + const scrollRange = scrollHeight - clientHeight + const thumbRatio = scrollHeight > 0 ? Math.min(clientHeight / scrollHeight, 1) : 1 + const thumbHeightPct = Math.max(10, thumbRatio * 100) + const thumbTopPct = scrollRange > 0 ? (scrollTop / scrollRange) * (100 - thumbHeightPct) : 0 return ( -
- {/* ── 展开面板 ── */} - {hovered && ( -
- {/* 标题栏 */} -
- 消息导航 - - {visibleIds.size}/{items.length} - -
+
+ {/* ── 迷你地图悬停区域(面板 + 横杠) ── */} +
+ {/* 展开面板 */} + {hovered && ( +
+ {/* 标题栏 */} +
+ 消息导航 + + {visibleIds.size}/{items.length} + +
- {/* 搜索框 */} -
-
- - setSearchQuery(e.target.value)} - onFocus={() => { - // 搜索框获焦时取消关闭计时器 - if (closeTimerRef.current) clearTimeout(closeTimerRef.current) - if (fadeTimerRef.current) clearTimeout(fadeTimerRef.current) - setIsLeaving(false) - }} - className="h-7 text-xs pl-7" - /> + {/* 搜索框 */} +
+
+ + setSearchQuery(e.target.value)} + onFocus={() => { + if (closeTimerRef.current) clearTimeout(closeTimerRef.current) + if (fadeTimerRef.current) clearTimeout(fadeTimerRef.current) + setIsLeaving(false) + }} + className="h-7 text-xs pl-7" + /> +
+
+ + {/* 消息列表 */} +
+ {filteredItems.length === 0 ? ( +
+ 未找到匹配消息 +
+ ) : ( + filteredItems.map((item) => ( + + )) + )}
+ )} - {/* 消息列表 */} -
- {filteredItems.length === 0 ? ( -
- 未找到匹配消息 -
- ) : ( - filteredItems.map((item) => ( - - )) + {/* ── 迷你地图横杠(紧凑排列) ── */} +
+ {Array.from({ length: barCount }, (_, i) => { + const start = Math.floor((i * items.length) / barCount) + const end = Math.floor(((i + 1) * items.length) / barCount) + const group = items.slice(start, end) + const isVisible = group.some((it) => visibleIds.has(it.id)) + const hasUser = group.some((it) => it.role === 'user') + const top = ((i + 0.5) / barCount) * 100 + return ( +
+ ) + })} +
+
+ + {/* ── 滚动进度条 ── */} +
+
+
+ style={{ + height: `${thumbHeightPct}%`, + top: `${thumbTopPct}%`, + }} + onMouseDown={handleThumbMouseDown} + />
- )} - - {/* ── 迷你地图条 ── */} -
- {Array.from({ length: barCount }, (_, i) => { - const start = Math.floor((i * items.length) / barCount) - const end = Math.floor(((i + 1) * items.length) / barCount) - const group = items.slice(start, end) - const isVisible = group.some((it) => visibleIds.has(it.id)) - const hasUser = group.some((it) => it.role === 'user') - const top = ((i + 0.5) / barCount) * 100 - return ( -
- ) - })}
) @@ -329,7 +428,6 @@ function HighlightedPreview({ text, query }: { text: string; query: string }): R return (空消息) } - // 有搜索关键词时:纯文本 + 高亮匹配 if (query.trim()) { const escaped = escapeRegExp(query) const parts = text.split(new RegExp(`(${escaped})`, 'gi')) @@ -344,7 +442,6 @@ function HighlightedPreview({ text, query }: { text: string; query: string }): R ) } - // 无搜索时:Markdown 渲染 return (
diff --git a/apps/electron/src/renderer/styles/globals.css b/apps/electron/src/renderer/styles/globals.css index b3a2337..23bbd2c 100644 --- a/apps/electron/src/renderer/styles/globals.css +++ b/apps/electron/src/renderer/styles/globals.css @@ -646,6 +646,96 @@ background-color: hsl(151 40% 45%) !important; /* 更亮的绿 */ } +/* ===== 滚动进度条 — 适配所有主题(无轨道背景,仅滑块) ===== */ + +/* 默认亮色主题 */ +.scroll-progress-thumb { + background-color: hsl(var(--foreground) / 0.14); +} +.scroll-progress-thumb:hover { + background-color: hsl(var(--foreground) / 0.25); +} +.scroll-progress-thumb-active { + background-color: hsl(var(--foreground) / 0.32) !important; +} + +/* 默认深色主题 */ +.dark:not([class*="theme-"]) .scroll-progress-thumb { + background-color: hsl(var(--foreground) / 0.12); +} +.dark:not([class*="theme-"]) .scroll-progress-thumb:hover { + background-color: hsl(var(--foreground) / 0.22); +} +.dark:not([class*="theme-"]) .scroll-progress-thumb-active { + background-color: hsl(var(--foreground) / 0.30) !important; +} + +/* 晴空碧海 ocean-light */ +.theme-ocean-light .scroll-progress-thumb { + background-color: hsl(205 50% 50% / 0.18); +} +.theme-ocean-light .scroll-progress-thumb:hover { + background-color: hsl(205 50% 50% / 0.30); +} +.theme-ocean-light .scroll-progress-thumb-active { + background-color: hsl(205 50% 50% / 0.40) !important; +} + +/* 苍穹暮色 ocean-dark */ +.theme-ocean-dark .scroll-progress-thumb { + background-color: hsl(205 60% 55% / 0.20); +} +.theme-ocean-dark .scroll-progress-thumb:hover { + background-color: hsl(205 60% 55% / 0.32); +} +.theme-ocean-dark .scroll-progress-thumb-active { + background-color: hsl(205 60% 55% / 0.42) !important; +} + +/* 森息晨光 forest-light */ +.theme-forest-light .scroll-progress-thumb { + background-color: hsl(150 35% 38% / 0.18); +} +.theme-forest-light .scroll-progress-thumb:hover { + background-color: hsl(150 35% 38% / 0.30); +} +.theme-forest-light .scroll-progress-thumb-active { + background-color: hsl(150 35% 38% / 0.40) !important; +} + +/* 森息夜语 forest-dark */ +.theme-forest-dark .scroll-progress-thumb { + background-color: hsl(151 40% 45% / 0.20); +} +.theme-forest-dark .scroll-progress-thumb:hover { + background-color: hsl(151 40% 45% / 0.32); +} +.theme-forest-dark .scroll-progress-thumb-active { + background-color: hsl(151 40% 45% / 0.42) !important; +} + +/* 云朵舞者 slate-light */ +.theme-slate-light .scroll-progress-thumb { + background-color: hsl(18 20% 67% / 0.22); +} +.theme-slate-light .scroll-progress-thumb:hover { + background-color: hsl(18 20% 67% / 0.35); +} +.theme-slate-light .scroll-progress-thumb-active { + background-color: hsl(18 20% 67% / 0.45) !important; +} + +/* 莫兰迪夜 slate-dark */ +.theme-slate-dark .scroll-progress-thumb { + background-color: hsl(15 25% 68% / 0.18); +} +.theme-slate-dark .scroll-progress-thumb:hover { + background-color: hsl(15 25% 68% / 0.30); +} +.theme-slate-dark .scroll-progress-thumb-active { + background-color: hsl(15 25% 68% / 0.40) !important; +} + /* ===== 计划模式:输入框定位上下文 ===== */ .plan-mode-border { position: relative;