mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(chat): floating back-to-bottom button with End-key shortcut
Add a floating back-to-bottom control to the chat message list that appears when the user scrolls up away from the live bottom. The button auto-docks to the right edge after 15s of inactivity (with a subtle breathing pulse) and un-docks on mouseenter, keeping it unobtrusive while reading history. - End key jumps to the bottom, ignored when focus is in an input, textarea, or contentEditable field. - Explicit jump (button click or End) forces past the stick-to-bottom escape lock and clears it so sticky auto-scroll resumes following new content; automatic scrolls still respect the escape lock so they do not fight the user reading history. - Larger thumb-reach hit area and lower placement on mobile. - New i18n key chat.scrollToBottom (zh-CN / en-US).
This commit is contained in:
parent
1d1c35aadf
commit
f7f1c30557
@ -88,13 +88,33 @@
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Floating "back to bottom" button: appears when the user scrolls up to
|
||||
read history, auto-docks to the edge after 15s of inactivity. -->
|
||||
<Transition name="fade-up">
|
||||
<div
|
||||
v-if="!isAtBottom"
|
||||
class="scroll-to-bottom-wrapper"
|
||||
:class="{ docked: isButtonDocked }"
|
||||
@mouseenter="undockButton"
|
||||
>
|
||||
<button
|
||||
class="scroll-to-bottom"
|
||||
type="button"
|
||||
:title="$t('chat.scrollToBottom')"
|
||||
@click="goToBottom"
|
||||
>
|
||||
<el-icon><ArrowDown /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, watch, nextTick } from 'vue'
|
||||
import { computed, ref, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ChatDotRound, DataLine, EditPen, Monitor, Right } from '@element-plus/icons-vue'
|
||||
import { ArrowDown, ChatDotRound, DataLine, EditPen, Monitor, Right } from '@element-plus/icons-vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
import MessageBubble from './MessageBubble.vue'
|
||||
@ -166,7 +186,7 @@ const isCronHeader = (msg: Message) => {
|
||||
}
|
||||
|
||||
// 智能滚动
|
||||
const { scrollRef, contentRef, isAtBottom, scrollToBottom } = useStickToBottom({
|
||||
const { scrollRef, contentRef, isAtBottom, escapedFromLock, scrollToBottom } = useStickToBottom({
|
||||
enabled: props.autoScroll,
|
||||
offset: 70,
|
||||
smooth: true,
|
||||
@ -185,6 +205,8 @@ watch(
|
||||
() => props.messages,
|
||||
async () => {
|
||||
await nextTick()
|
||||
// Don't auto-scroll while the user has scrolled up to read history.
|
||||
if (escapedFromLock.value) return
|
||||
scrollToBottom()
|
||||
},
|
||||
{ deep: true }
|
||||
@ -200,6 +222,63 @@ watch(
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Explicit "jump to bottom" request (button click / End key): force past the
|
||||
// escape lock and clear it so sticky auto-scroll resumes following new content.
|
||||
function goToBottom() {
|
||||
escapedFromLock.value = false
|
||||
scrollToBottom({ force: true, smooth: true })
|
||||
}
|
||||
|
||||
// ========== Back-to-bottom button: auto-dock to the edge after 15s idle ==========
|
||||
const DOCK_DELAY_MS = 15000
|
||||
let dockTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const isButtonDocked = ref(false)
|
||||
|
||||
function clearDockTimer() {
|
||||
if (dockTimer) { clearTimeout(dockTimer); dockTimer = null }
|
||||
}
|
||||
|
||||
function resetDockTimer() {
|
||||
isButtonDocked.value = false
|
||||
if (!isAtBottom.value) {
|
||||
clearDockTimer()
|
||||
dockTimer = setTimeout(() => { isButtonDocked.value = true }, DOCK_DELAY_MS)
|
||||
}
|
||||
}
|
||||
|
||||
function undockButton() {
|
||||
isButtonDocked.value = false
|
||||
resetDockTimer()
|
||||
}
|
||||
|
||||
watch(isAtBottom, (atBottom) => {
|
||||
if (atBottom) { clearDockTimer(); isButtonDocked.value = false }
|
||||
else resetDockTimer()
|
||||
})
|
||||
|
||||
watch(scrollRef, (el, _, onCleanup) => {
|
||||
if (!el) return
|
||||
el.addEventListener('scroll', resetDockTimer, { passive: true })
|
||||
onCleanup(() => el.removeEventListener('scroll', resetDockTimer))
|
||||
})
|
||||
|
||||
// ========== End key jumps to the bottom ==========
|
||||
function handleGlobalKeydown(e: KeyboardEvent) {
|
||||
if (e.key !== 'End') return
|
||||
// Only respond when focus is not in an editable field (textarea / input / contentEditable).
|
||||
const el = document.activeElement
|
||||
if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || (el as HTMLElement).isContentEditable)) return
|
||||
if (isAtBottom.value) return
|
||||
e.preventDefault()
|
||||
goToBottom()
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('keydown', handleGlobalKeydown))
|
||||
onUnmounted(() => {
|
||||
clearDockTimer()
|
||||
document.removeEventListener('keydown', handleGlobalKeydown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@ -396,10 +475,88 @@ watch(
|
||||
}
|
||||
}
|
||||
|
||||
/* 滚动提示 */
|
||||
.is-scrolling .scroll-to-bottom {
|
||||
/* Back-to-bottom floating button */
|
||||
.scroll-to-bottom-wrapper {
|
||||
position: absolute;
|
||||
bottom: 20%;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.scroll-to-bottom {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--mc-border, #e2e8f0);
|
||||
background: var(--mc-bg-elevated, rgba(255, 255, 255, 0.92));
|
||||
backdrop-filter: blur(6px);
|
||||
color: var(--mc-text-secondary, #64748b);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
transform: translateX(0);
|
||||
transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
opacity 0.35s ease,
|
||||
box-shadow 0.2s ease,
|
||||
background 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.scroll-to-bottom:hover {
|
||||
background: var(--mc-panel-raised, #f1f5f9);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||
transform: scale(1.08);
|
||||
color: var(--mc-primary, #D97757);
|
||||
border-color: var(--mc-primary, #D97757);
|
||||
}
|
||||
|
||||
/* Click feedback: shrink slightly on press, then spring back. */
|
||||
.scroll-to-bottom:active {
|
||||
transform: scale(0.88);
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
/* Docked to the edge: only ~8px stays visible. */
|
||||
.scroll-to-bottom-wrapper.docked .scroll-to-bottom {
|
||||
transform: translateX(28px);
|
||||
opacity: 0.45;
|
||||
/* Subtle breathing pulse to remind the user the button is still there. */
|
||||
animation: dock-breathe 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes dock-breathe {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(217, 119, 87, 0); }
|
||||
50% { box-shadow: 0 0 8px 2px rgba(217, 119, 87, 0.18); }
|
||||
}
|
||||
|
||||
/* Restore as soon as the pointer approaches. */
|
||||
.scroll-to-bottom-wrapper.docked:hover .scroll-to-bottom {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* Transition: fade + slide up */
|
||||
.fade-up-enter-active,
|
||||
.fade-up-leave-active {
|
||||
transition: opacity 0.25s ease, transform 0.25s ease;
|
||||
}
|
||||
.fade-up-enter-from,
|
||||
.fade-up-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
}
|
||||
|
||||
/* ===== 移动端适配 ===== */
|
||||
@ -416,6 +573,21 @@ watch(
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
/* Mobile: larger button positioned lower for the thumb reach zone. */
|
||||
.scroll-to-bottom-wrapper {
|
||||
bottom: 12%;
|
||||
}
|
||||
|
||||
.scroll-to-bottom {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.scroll-to-bottom-wrapper.docked .scroll-to-bottom {
|
||||
transform: translateX(36px); /* 44px - 8px = 36px */
|
||||
}
|
||||
|
||||
.welcome-screen {
|
||||
padding: 16px 10px;
|
||||
}
|
||||
|
||||
@ -224,6 +224,7 @@ export default {
|
||||
modelSaveFailed: 'Model switch was not saved — the next message may still use the previous model',
|
||||
openSessions: 'Session Admin',
|
||||
clearMessages: 'Clear Messages',
|
||||
scrollToBottom: 'Back to bottom',
|
||||
goToModelSettings: 'Go to Model Settings',
|
||||
configModelFirst: 'Please configure a model first',
|
||||
modelUnavailable: 'Current model is unavailable',
|
||||
|
||||
@ -224,6 +224,7 @@ export default {
|
||||
modelSaveFailed: '模型切换未保存,下条消息可能仍走原模型',
|
||||
openSessions: '会话管理',
|
||||
clearMessages: '清空消息',
|
||||
scrollToBottom: '回到底部',
|
||||
goToModelSettings: '前往模型设置',
|
||||
configModelFirst: '请先配置模型',
|
||||
modelUnavailable: '当前模型不可用',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user