feat(chat): 编辑摘要卡片弹窗改为锚定定位并修复深色近黑样式
- 弹窗水平相对编辑卡片居中,垂直优先锚定条目上方(空间不足自动翻到下方),高度吃满可用空间 - 行 hover 改用 --hover-bg(深色提亮),弹窗头部去近黑衬底改用分隔线 - 行高亮 is-active 仅钉住态挂载,修复 hover 消失延迟
This commit is contained in:
parent
56a0acdad2
commit
79f8ffa368
@ -49,6 +49,11 @@ const totals = computed(() => {
|
|||||||
// ---------------- 弹窗状态 ----------------
|
// ---------------- 弹窗状态 ----------------
|
||||||
const activePath = ref<string | null>(null);
|
const activePath = ref<string | null>(null);
|
||||||
const pinned = ref(false);
|
const pinned = ref(false);
|
||||||
|
const cardRef = ref<HTMLElement | null>(null);
|
||||||
|
const anchorEl = ref<HTMLElement | null>(null);
|
||||||
|
// 桌面端弹窗的内联定位样式(水平相对卡片居中、垂直锚定条目上方);
|
||||||
|
// 移动端保持空对象,回退到 CSS 的屏幕居中布局。
|
||||||
|
const panelStyle = ref<Record<string, string>>({});
|
||||||
let openTimer: ReturnType<typeof setTimeout> | null = null;
|
let openTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let closeTimer: ReturnType<typeof setTimeout> | null = null;
|
let closeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
@ -57,6 +62,76 @@ const activeFile = computed<SummaryFile | null>(() => {
|
|||||||
return files.value.find((f) => f.path === activePath.value) || null;
|
return files.value.find((f) => f.path === activePath.value) || null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------- 弹窗定位(桌面端) ----------------
|
||||||
|
const PANEL_MAX_WIDTH = 720;
|
||||||
|
const VIEWPORT_MARGIN = 16;
|
||||||
|
const MIN_PANEL_HEIGHT = 140;
|
||||||
|
// 条目上方至少要有这么多可用空间才放上面,否则优先考虑下方
|
||||||
|
const PREFER_ABOVE_MIN_HEIGHT = 260;
|
||||||
|
|
||||||
|
function computePanelStyle() {
|
||||||
|
if (isMobile.value || !cardRef.value || !anchorEl.value) {
|
||||||
|
// 移动端 / 无锚点:回退到 CSS 的屏幕居中布局
|
||||||
|
panelStyle.value = {};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const vw = window.innerWidth;
|
||||||
|
const vh = window.innerHeight;
|
||||||
|
const width = Math.min(PANEL_MAX_WIDTH, vw - VIEWPORT_MARGIN * 2);
|
||||||
|
|
||||||
|
// 水平:相对编辑卡片左右居中(而不是相对屏幕),并夹取在视口内
|
||||||
|
const cardRect = cardRef.value.getBoundingClientRect();
|
||||||
|
const centerX = cardRect.left + cardRect.width / 2;
|
||||||
|
const left = Math.min(
|
||||||
|
Math.max(centerX - width / 2, VIEWPORT_MARGIN),
|
||||||
|
vw - width - VIEWPORT_MARGIN
|
||||||
|
);
|
||||||
|
|
||||||
|
// 垂直:优先放条目上方(底边 = 条目上边缘 - 半个条目高,向上生长);
|
||||||
|
// 上方空间不足且下方更宽裕时改放下方(顶边 = 条目下边缘 + 半个条目高,向下生长)。
|
||||||
|
// 两个方向的高度都吃满该方向的可用空间,无固定上限。
|
||||||
|
const rowRect = anchorEl.value.getBoundingClientRect();
|
||||||
|
const anchorAbove = rowRect.top - rowRect.height / 2;
|
||||||
|
const anchorBelow = rowRect.bottom + rowRect.height / 2;
|
||||||
|
const spaceAbove = anchorAbove - VIEWPORT_MARGIN;
|
||||||
|
const spaceBelow = vh - VIEWPORT_MARGIN - anchorBelow;
|
||||||
|
const placeAbove = spaceAbove >= PREFER_ABOVE_MIN_HEIGHT || spaceAbove >= spaceBelow;
|
||||||
|
|
||||||
|
const base: Record<string, string> = {
|
||||||
|
left: `${Math.round(left)}px`,
|
||||||
|
width: `${Math.round(width)}px`,
|
||||||
|
transform: 'none'
|
||||||
|
};
|
||||||
|
if (placeAbove) {
|
||||||
|
const bottomEdge = Math.min(
|
||||||
|
Math.max(anchorAbove, VIEWPORT_MARGIN + MIN_PANEL_HEIGHT),
|
||||||
|
vh - VIEWPORT_MARGIN
|
||||||
|
);
|
||||||
|
panelStyle.value = {
|
||||||
|
...base,
|
||||||
|
top: 'auto',
|
||||||
|
bottom: `${Math.round(vh - bottomEdge)}px`,
|
||||||
|
maxHeight: `${Math.round(bottomEdge - VIEWPORT_MARGIN)}px`
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const topEdge = Math.min(
|
||||||
|
Math.max(anchorBelow, VIEWPORT_MARGIN),
|
||||||
|
vh - VIEWPORT_MARGIN - MIN_PANEL_HEIGHT
|
||||||
|
);
|
||||||
|
panelStyle.value = {
|
||||||
|
...base,
|
||||||
|
top: `${Math.round(topEdge)}px`,
|
||||||
|
bottom: 'auto',
|
||||||
|
maxHeight: `${Math.round(vh - VIEWPORT_MARGIN - topEdge)}px`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWindowReposition() {
|
||||||
|
if (!activePath.value) return;
|
||||||
|
computePanelStyle();
|
||||||
|
}
|
||||||
|
|
||||||
function clearOpenTimer() {
|
function clearOpenTimer() {
|
||||||
if (openTimer) {
|
if (openTimer) {
|
||||||
clearTimeout(openTimer);
|
clearTimeout(openTimer);
|
||||||
@ -71,16 +146,18 @@ function clearCloseTimer() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function open(path: string, pin: boolean) {
|
function open(path: string, pin: boolean, rowEl: HTMLElement | null = null) {
|
||||||
clearOpenTimer();
|
clearOpenTimer();
|
||||||
clearCloseTimer();
|
clearCloseTimer();
|
||||||
activePath.value = path;
|
activePath.value = path;
|
||||||
pinned.value = pin;
|
pinned.value = pin;
|
||||||
|
anchorEl.value = rowEl;
|
||||||
|
computePanelStyle();
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleOpen(path: string) {
|
function scheduleOpen(path: string, rowEl: HTMLElement | null) {
|
||||||
clearOpenTimer();
|
clearOpenTimer();
|
||||||
openTimer = setTimeout(() => open(path, false), 150);
|
openTimer = setTimeout(() => open(path, false, rowEl), 150);
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleClose() {
|
function scheduleClose() {
|
||||||
@ -91,10 +168,10 @@ function scheduleClose() {
|
|||||||
}, 200);
|
}, 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onRowMouseEnter(path: string) {
|
function onRowMouseEnter(path: string, e: MouseEvent) {
|
||||||
if (isMobile.value) return;
|
if (isMobile.value) return;
|
||||||
clearCloseTimer();
|
clearCloseTimer();
|
||||||
scheduleOpen(path);
|
scheduleOpen(path, (e.currentTarget as HTMLElement) || null);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onRowMouseLeave() {
|
function onRowMouseLeave() {
|
||||||
@ -111,9 +188,9 @@ function onPanelMouseLeave() {
|
|||||||
if (!isMobile.value) scheduleClose();
|
if (!isMobile.value) scheduleClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
function onRowClick(path: string) {
|
function onRowClick(path: string, e: MouseEvent) {
|
||||||
// 点击 = 钉住(桌面与移动端一致;移动端无 hover,这是唯一入口)
|
// 点击 = 钉住(桌面与移动端一致;移动端无 hover,这是唯一入口)
|
||||||
open(path, true);
|
open(path, true, (e.currentTarget as HTMLElement) || null);
|
||||||
}
|
}
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
@ -121,6 +198,7 @@ function close() {
|
|||||||
clearCloseTimer();
|
clearCloseTimer();
|
||||||
pinned.value = false;
|
pinned.value = false;
|
||||||
activePath.value = null;
|
activePath.value = null;
|
||||||
|
anchorEl.value = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onKeydown(e: KeyboardEvent) {
|
function onKeydown(e: KeyboardEvent) {
|
||||||
@ -132,8 +210,13 @@ function onKeydown(e: KeyboardEvent) {
|
|||||||
watch(activePath, (v) => {
|
watch(activePath, (v) => {
|
||||||
if (v) {
|
if (v) {
|
||||||
window.addEventListener('keydown', onKeydown);
|
window.addEventListener('keydown', onKeydown);
|
||||||
|
window.addEventListener('resize', onWindowReposition);
|
||||||
|
// 捕获阶段监听滚动,让弹窗在对话区滚动时始终贴着锚定条目
|
||||||
|
window.addEventListener('scroll', onWindowReposition, true);
|
||||||
} else {
|
} else {
|
||||||
window.removeEventListener('keydown', onKeydown);
|
window.removeEventListener('keydown', onKeydown);
|
||||||
|
window.removeEventListener('resize', onWindowReposition);
|
||||||
|
window.removeEventListener('scroll', onWindowReposition, true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -141,6 +224,8 @@ onBeforeUnmount(() => {
|
|||||||
clearOpenTimer();
|
clearOpenTimer();
|
||||||
clearCloseTimer();
|
clearCloseTimer();
|
||||||
window.removeEventListener('keydown', onKeydown);
|
window.removeEventListener('keydown', onKeydown);
|
||||||
|
window.removeEventListener('resize', onWindowReposition);
|
||||||
|
window.removeEventListener('scroll', onWindowReposition, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------- diff 行渲染辅助 ----------------
|
// ---------------- diff 行渲染辅助 ----------------
|
||||||
@ -163,7 +248,7 @@ function lineNumber(line: any): string {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="files.length" class="edit-summary-card">
|
<div v-if="files.length" class="edit-summary-card" ref="cardRef">
|
||||||
<div class="edit-summary-card__header">
|
<div class="edit-summary-card__header">
|
||||||
<span
|
<span
|
||||||
class="icon icon-sm edit-summary-card__icon"
|
class="icon icon-sm edit-summary-card__icon"
|
||||||
@ -181,10 +266,10 @@ function lineNumber(line: any): string {
|
|||||||
v-for="file in files"
|
v-for="file in files"
|
||||||
:key="file.path"
|
:key="file.path"
|
||||||
class="edit-summary-card__row"
|
class="edit-summary-card__row"
|
||||||
:class="{ 'is-active': activePath === file.path }"
|
:class="{ 'is-active': pinned && activePath === file.path }"
|
||||||
@mouseenter="onRowMouseEnter(file.path)"
|
@mouseenter="onRowMouseEnter(file.path, $event)"
|
||||||
@mouseleave="onRowMouseLeave"
|
@mouseleave="onRowMouseLeave"
|
||||||
@click.stop="onRowClick(file.path)"
|
@click.stop="onRowClick(file.path, $event)"
|
||||||
>
|
>
|
||||||
<span class="edit-summary-card__path" :title="file.path">{{ file.path }}</span>
|
<span class="edit-summary-card__path" :title="file.path">{{ file.path }}</span>
|
||||||
<span class="edit-summary-card__delta">
|
<span class="edit-summary-card__delta">
|
||||||
@ -205,6 +290,7 @@ function lineNumber(line: any): string {
|
|||||||
></div>
|
></div>
|
||||||
<div
|
<div
|
||||||
class="es-modal__panel"
|
class="es-modal__panel"
|
||||||
|
:style="panelStyle"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
:aria-label="`文件变更:${activeFile.path}`"
|
:aria-label="`文件变更:${activeFile.path}`"
|
||||||
@mouseenter="onPanelMouseEnter"
|
@mouseenter="onPanelMouseEnter"
|
||||||
@ -313,9 +399,12 @@ function lineNumber(line: any): string {
|
|||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* is-active 仅「点击钉住」时挂载,用于标识正在查看的文件;hover 预览不挂,
|
||||||
|
否则鼠标离开后会被 200ms 关闭计时器拖住,看起来像 hover 消失有延迟 */
|
||||||
.edit-summary-card__row:hover,
|
.edit-summary-card__row:hover,
|
||||||
.edit-summary-card__row.is-active {
|
.edit-summary-card__row.is-active {
|
||||||
background: var(--surface-soft);
|
/* 用全局 hover token:深色下是白色微量 tint(提亮),避免 surface-soft 的近黑衬底 */
|
||||||
|
background: var(--hover-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-summary-card__path {
|
.edit-summary-card__path {
|
||||||
@ -374,8 +463,8 @@ function lineNumber(line: any): string {
|
|||||||
height: 44px;
|
height: 44px;
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
flex: none;
|
flex: none;
|
||||||
|
/* 与版本管理弹窗一致:头部不加衬底,靠分隔线区分(深色下避免近黑色块) */
|
||||||
border-bottom: 1px solid var(--border-default);
|
border-bottom: 1px solid var(--border-default);
|
||||||
background: var(--surface-soft);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.es-modal__header-icon {
|
.es-modal__header-icon {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user