Add fold/expand and hover actions to user message bubbles
This commit is contained in:
parent
5ac17cd279
commit
0a4c75ce45
1
static/icons/git-fork.svg
Normal file
1
static/icons/git-fork.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:1;"><circle cx="12" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><circle cx="18" cy="6" r="3"/><path d="M18 9v2c0 .6-.4 1-1 1H7c-.6 0-1-.4-1-1V9m6 3v3"/></svg>
|
||||
|
After Width: | Height: | Size: 349 B |
@ -20,10 +20,18 @@
|
||||
<span class="icon icon-sm" :style="iconStyleSafe(userHeaderIconKey(msg))" aria-hidden="true"></span>
|
||||
<span>{{ userHeaderLabel(msg) }}</span>
|
||||
</div>
|
||||
<div class="message-text user-bubble-text">
|
||||
<div
|
||||
class="message-text user-bubble-text"
|
||||
:class="{
|
||||
'is-collapsed': isUserBubbleCollapsed(msg, index),
|
||||
'is-expandable': isUserBubbleExpandable(msg, index)
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-if="msg.content"
|
||||
class="bubble-text"
|
||||
:class="{ 'is-expanded': isUserBubbleExpanded(msg, index) }"
|
||||
:ref="(el) => registerUserBubbleRef(msg, index, el)"
|
||||
v-html="renderUserMessageContent(msg.content)"
|
||||
></div>
|
||||
<div v-if="msg.images && msg.images.length" class="image-inline-row">
|
||||
@ -53,6 +61,29 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="user-bubble-actions">
|
||||
<button
|
||||
class="user-bubble-action-btn copy"
|
||||
:class="{ copied: isUserBubbleCopied(msg, index) }"
|
||||
:title="isUserBubbleCopied(msg, index) ? '已复制' : '复制'"
|
||||
:aria-label="isUserBubbleCopied(msg, index) ? '已复制' : '复制'"
|
||||
@click="copyUserMessage(msg, index)"
|
||||
></button>
|
||||
<button
|
||||
class="user-bubble-action-btn branch"
|
||||
title="分支"
|
||||
aria-label="分支"
|
||||
@click="branchUserMessage(msg)"
|
||||
></button>
|
||||
<button
|
||||
v-if="isUserBubbleExpandable(msg, index)"
|
||||
class="user-bubble-action-btn expand-toggle"
|
||||
:class="{ expanded: isUserBubbleExpanded(msg, index) }"
|
||||
:title="isUserBubbleExpanded(msg, index) ? '收起' : '展开'"
|
||||
:aria-label="isUserBubbleExpanded(msg, index) ? '收起' : '展开'"
|
||||
@click="toggleUserBubble(msg, index)"
|
||||
></button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else-if="msg.role === 'assistant'" class="assistant-message">
|
||||
@ -605,7 +636,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue';
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue';
|
||||
import { useStickToBottom } from 'vue-stick-to-bottom';
|
||||
import { useBlockExpansionAnchor } from '@/composables/useBlockExpansionAnchor';
|
||||
import ToolAction from '@/components/chat/actions/ToolAction.vue';
|
||||
@ -666,6 +697,118 @@ const userName = computed(() => {
|
||||
}
|
||||
return personalization.form.user_name || '用户';
|
||||
});
|
||||
|
||||
// 用户输入气泡折叠相关状态与逻辑
|
||||
interface UserBubbleFoldState {
|
||||
needsFold: boolean;
|
||||
expanded: boolean;
|
||||
foldHeight: number;
|
||||
fullHeight: number;
|
||||
}
|
||||
|
||||
const USER_BUBBLE_FOLD_LINES = 10;
|
||||
const userBubbleRefs = new Map<string, HTMLElement>();
|
||||
const userBubbleFoldStates = ref<Record<string, UserBubbleFoldState>>({});
|
||||
const copiedBubbleKeys = ref<Set<string>>(new Set());
|
||||
let userBubbleResizeObserver: ResizeObserver | null = null;
|
||||
let copiedBubbleTimeouts = new Map<string, number>();
|
||||
|
||||
const getUserBubbleKey = (msg: any, index: number) => msg?.id || `user-bubble-${index}`;
|
||||
|
||||
const registerUserBubbleRef = (msg: any, index: number, el: Element | null) => {
|
||||
const key = getUserBubbleKey(msg, index);
|
||||
if (el instanceof HTMLElement) {
|
||||
userBubbleRefs.set(key, el);
|
||||
} else {
|
||||
userBubbleRefs.delete(key);
|
||||
}
|
||||
};
|
||||
|
||||
const measureUserBubbles = () => {
|
||||
userBubbleRefs.forEach((el, key) => {
|
||||
if (!el.isConnected) {
|
||||
userBubbleRefs.delete(key);
|
||||
return;
|
||||
}
|
||||
const computed = window.getComputedStyle(el);
|
||||
const rawLineHeight = parseFloat(computed.lineHeight);
|
||||
const lineHeight = Number.isFinite(rawLineHeight) ? rawLineHeight : 24;
|
||||
const foldHeight = Math.round(lineHeight * USER_BUBBLE_FOLD_LINES);
|
||||
const fullHeight = Math.ceil(el.scrollHeight);
|
||||
const needsFold = fullHeight > foldHeight + 1;
|
||||
const existing = userBubbleFoldStates.value[key];
|
||||
userBubbleFoldStates.value[key] = {
|
||||
needsFold,
|
||||
expanded: existing?.expanded ?? false,
|
||||
foldHeight,
|
||||
fullHeight
|
||||
};
|
||||
el.style.setProperty('--bubble-fold-height', `${foldHeight}px`);
|
||||
el.style.setProperty('--bubble-full-height', `${fullHeight}px`);
|
||||
});
|
||||
};
|
||||
|
||||
const isUserBubbleExpandable = (msg: any, index: number) => {
|
||||
const key = getUserBubbleKey(msg, index);
|
||||
return !!userBubbleFoldStates.value[key]?.needsFold;
|
||||
};
|
||||
|
||||
const isUserBubbleExpanded = (msg: any, index: number) => {
|
||||
const key = getUserBubbleKey(msg, index);
|
||||
return !!userBubbleFoldStates.value[key]?.expanded;
|
||||
};
|
||||
|
||||
const isUserBubbleCollapsed = (msg: any, index: number) => {
|
||||
const key = getUserBubbleKey(msg, index);
|
||||
const state = userBubbleFoldStates.value[key];
|
||||
return !!state?.needsFold && !state?.expanded;
|
||||
};
|
||||
|
||||
const toggleUserBubble = (msg: any, index: number) => {
|
||||
const key = getUserBubbleKey(msg, index);
|
||||
const state = userBubbleFoldStates.value[key];
|
||||
if (!state || !state.needsFold) return;
|
||||
userBubbleFoldStates.value[key] = { ...state, expanded: !state.expanded };
|
||||
};
|
||||
|
||||
const isUserBubbleCopied = (msg: any, index: number) => {
|
||||
const key = getUserBubbleKey(msg, index);
|
||||
return copiedBubbleKeys.value.has(key);
|
||||
};
|
||||
|
||||
const copyUserMessage = async (msg: any, index: number) => {
|
||||
const key = getUserBubbleKey(msg, index);
|
||||
const text = typeof msg?.content === 'string' ? msg.content : '';
|
||||
if (!text || copiedBubbleKeys.value.has(key)) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
copiedBubbleKeys.value.add(key);
|
||||
const existing = copiedBubbleTimeouts.get(key);
|
||||
if (existing) window.clearTimeout(existing);
|
||||
const timeout = window.setTimeout(() => {
|
||||
copiedBubbleKeys.value.delete(key);
|
||||
copiedBubbleTimeouts.delete(key);
|
||||
}, 5000);
|
||||
copiedBubbleTimeouts.set(key, timeout);
|
||||
} catch (err) {
|
||||
console.warn('复制用户消息失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const branchUserMessage = (_msg: any) => {
|
||||
// TODO: 分支功能占位
|
||||
void _msg;
|
||||
console.log('[ChatArea] 分支功能待实现');
|
||||
};
|
||||
|
||||
const observeUserBubbles = () => {
|
||||
if (!userBubbleResizeObserver) return;
|
||||
userBubbleResizeObserver.disconnect();
|
||||
userBubbleRefs.forEach((el) => {
|
||||
if (el.isConnected) userBubbleResizeObserver!.observe(el);
|
||||
});
|
||||
};
|
||||
|
||||
const isSystemAutoUserMessage = (message: any) => {
|
||||
if (!message || message.role !== 'user') {
|
||||
return false;
|
||||
@ -1603,6 +1746,19 @@ function compactBriefLabel(msg: any): string {
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => filteredMessages.value.map((m) => m?.id ?? m?.content?.slice(0, 80)),
|
||||
() => {
|
||||
nextTick(() => {
|
||||
requestAnimationFrame(() => {
|
||||
measureUserBubbles();
|
||||
observeUserBubbles();
|
||||
});
|
||||
});
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
attachBounceListener();
|
||||
updateChatScrollbarWidth();
|
||||
@ -1610,6 +1766,12 @@ onMounted(() => {
|
||||
scrollbarResizeObserver = new ResizeObserver(updateChatScrollbarWidth);
|
||||
scrollbarResizeObserver.observe(scrollRef.value);
|
||||
}
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
userBubbleResizeObserver = new ResizeObserver(() => {
|
||||
requestAnimationFrame(measureUserBubbles);
|
||||
});
|
||||
nextTick(() => observeUserBubbles());
|
||||
}
|
||||
console.warn('[SCROLL_BOUNCE_TRACE]', 'mounted', {
|
||||
hasScrollRef: !!scrollRef.value
|
||||
});
|
||||
@ -1638,6 +1800,13 @@ onBeforeUnmount(() => {
|
||||
scrollbarResizeObserver.disconnect();
|
||||
scrollbarResizeObserver = null;
|
||||
}
|
||||
if (userBubbleResizeObserver) {
|
||||
userBubbleResizeObserver.disconnect();
|
||||
userBubbleResizeObserver = null;
|
||||
}
|
||||
userBubbleRefs.clear();
|
||||
copiedBubbleTimeouts.forEach((t) => window.clearTimeout(t));
|
||||
copiedBubbleTimeouts.clear();
|
||||
if (timerHandle !== null) {
|
||||
clearInterval(timerHandle);
|
||||
timerHandle = null;
|
||||
|
||||
@ -576,6 +576,100 @@
|
||||
margin: 0 0.25em;
|
||||
}
|
||||
|
||||
/* 用户输入气泡:超长折叠、展开动画与悬停操作按钮 */
|
||||
.user-message .message-text.user-bubble-text .bubble-text {
|
||||
overflow: hidden;
|
||||
transition: max-height 300ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
max-height: var(--bubble-full-height, 9999px);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.user-message .message-text.user-bubble-text.is-expandable .bubble-text:not(.is-expanded) {
|
||||
max-height: var(--bubble-fold-height, 240px);
|
||||
}
|
||||
|
||||
.user-bubble-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 180ms ease,
|
||||
transform 180ms ease;
|
||||
white-space: normal;
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.user-message:not(.user-message--compact):not(.user-message--brief):hover .user-bubble-actions,
|
||||
.user-message:not(.user-message--compact):not(.user-message--brief):focus-within .user-bubble-actions {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.user-bubble-action-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: var(--claude-text-secondary);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition:
|
||||
background-color 140ms ease,
|
||||
color 140ms ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-bubble-action-btn:hover {
|
||||
background-color: var(--theme-tab-active);
|
||||
color: var(--claude-text);
|
||||
}
|
||||
|
||||
.user-bubble-action-btn::before {
|
||||
content: '';
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: block;
|
||||
background: currentColor;
|
||||
mask: center / contain no-repeat;
|
||||
-webkit-mask: center / contain no-repeat;
|
||||
}
|
||||
|
||||
.user-bubble-action-btn.copy::before {
|
||||
mask-image: url('/static/icons/copy.svg');
|
||||
-webkit-mask-image: url('/static/icons/copy.svg');
|
||||
}
|
||||
|
||||
.user-bubble-action-btn.copy.copied::before {
|
||||
mask-image: url('/static/icons/check.svg');
|
||||
-webkit-mask-image: url('/static/icons/check.svg');
|
||||
}
|
||||
|
||||
.user-bubble-action-btn.branch::before {
|
||||
mask-image: url('/static/icons/git-fork.svg');
|
||||
-webkit-mask-image: url('/static/icons/git-fork.svg');
|
||||
}
|
||||
|
||||
.user-bubble-action-btn.expand-toggle::before {
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
|
||||
transition: transform 260ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.user-bubble-action-btn.expand-toggle.expanded::before {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.assistant-message .message-text {
|
||||
background: var(--claude-highlight);
|
||||
border-left: 4px solid var(--claude-accent);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user