feat(composer): / 菜单选中切换添加流畅动画

- 提取 skill-slash-menu-wrapper 外壳层,高亮条脱离滚动容器独立定位
- 一个 rAF 循环同时驱动 scrollTop 和高亮条 top,帧帧精确同步
- 中间区域:高亮条视觉上钉在正中,列表平滑滚动
- 两端边界:高亮条平滑跟随,无偏差
This commit is contained in:
JOJO 2026-06-11 01:11:40 +08:00
parent f9d194f100
commit af4ec5c1a8
2 changed files with 122 additions and 42 deletions

View File

@ -43,31 +43,39 @@
<div
v-if="skillSlashMenuOpen"
:key="slashMenuMode"
ref="skillSlashList"
class="skill-slash-menu"
class="skill-slash-menu-wrapper"
role="listbox"
:aria-label="slashMenuAriaLabel"
>
<button
v-for="(item, index) in activeSlashMenuItems"
:key="item.id"
type="button"
class="skill-slash-item"
:class="{
'skill-slash-item--active': index === skillSlashActiveIndex,
'skill-slash-item--disabled': item.disabled
}"
role="option"
:aria-selected="index === skillSlashActiveIndex"
:disabled="item.disabled"
@mousedown.prevent="selectSlashMenuItem(index)"
<div
ref="skillSlashList"
class="skill-slash-menu"
>
<span class="skill-slash-item__name">{{ item.label }}</span>
<span class="skill-slash-item__description">{{ item.description }}</span>
</button>
<div v-if="!activeSlashMenuItems.length" class="skill-slash-empty">
{{ slashMenuEmptyText }}
<button
v-for="(item, index) in activeSlashMenuItems"
:key="item.id"
type="button"
class="skill-slash-item"
:class="{
'skill-slash-item--active': index === skillSlashActiveIndex,
'skill-slash-item--disabled': item.disabled
}"
role="option"
:aria-selected="index === skillSlashActiveIndex"
:disabled="item.disabled"
@mousedown.prevent="selectSlashMenuItem(index)"
>
<span class="skill-slash-item__name">{{ item.label }}</span>
<span class="skill-slash-item__description">{{ item.description }}</span>
</button>
<div v-if="!activeSlashMenuItems.length" class="skill-slash-empty">
{{ slashMenuEmptyText }}
</div>
</div>
<div
class="skill-slash-menu__highlight"
:style="slashHighlightStyle"
/>
</div>
</transition>
<transition name="floating-status-motion">
@ -548,6 +556,46 @@ const skillSlashList = ref<HTMLElement | null>(null);
const slashMenuMode = ref<SlashMenuMode>('root');
const slashMenuParentIndex = ref(0);
const slashMenuTransitioning = ref(false);
const slashHighlightTop = ref(5);
let slashAnimId: number | null = null;
const slashHighlightStyle = computed(() => {
const isFirst = skillSlashActiveIndex.value === 0 && skillSlashList.value?.scrollTop === 0;
return {
top: `${slashHighlightTop.value}px`,
...(isFirst ? {
borderTopLeftRadius: 'calc(var(--skill-slash-radius) - var(--skill-slash-gap))',
borderTopRightRadius: 'calc(var(--skill-slash-radius) - var(--skill-slash-gap))',
} : {}),
};
});
const animateSlash = (element: HTMLElement, targetScroll: number) => {
if (slashAnimId !== null) {
cancelAnimationFrame(slashAnimId);
}
const startScroll = element.scrollTop;
const startHighlight = slashHighlightTop.value;
const distScroll = targetScroll - startScroll;
const targetHighlight = 5 + skillSlashActiveIndex.value * 43 - targetScroll;
const distHighlight = targetHighlight - startHighlight;
const duration = 180;
const startTime = performance.now();
const step = (now: number) => {
const elapsed = now - startTime;
const progress = Math.min(elapsed / duration, 1);
const eased = 1 - Math.pow(1 - progress, 3);
element.scrollTop = startScroll + distScroll * eased;
slashHighlightTop.value = startHighlight + distHighlight * eased;
if (progress < 1) {
slashAnimId = requestAnimationFrame(step);
} else {
slashAnimId = null;
}
};
slashAnimId = requestAnimationFrame(step);
};
let composerResizeObserver: ResizeObserver | null = null;
let syncingEditorFromProps = false;
@ -666,11 +714,16 @@ const findSlashToken = () => {
};
const closeSlashMenu = () => {
if (slashAnimId !== null) {
cancelAnimationFrame(slashAnimId);
slashAnimId = null;
}
skillSlashOpen.value = false;
skillSlashQuery.value = '';
skillSlashActiveIndex.value = 0;
slashMenuMode.value = 'root';
slashMenuParentIndex.value = 0;
slashHighlightTop.value = 5;
};
const deleteSlashToken = () => {
@ -702,7 +755,7 @@ const scrollSkillSlashSelectionIntoMiddle = () => {
const middleOffset = Math.floor(visibleRows / 2);
const maxScroll = Math.max(0, list.scrollHeight - list.clientHeight);
const target = Math.max(0, Math.min(maxScroll, (skillSlashActiveIndex.value - middleOffset) * rowHeight));
list.scrollTop = target;
animateSlash(list, target);
});
};

View File

@ -127,7 +127,7 @@
pointer-events: none;
}
.skill-slash-menu {
.skill-slash-menu-wrapper {
--skill-slash-row-height: 38px;
--skill-slash-gap: 5px;
--skill-slash-radius: 12px;
@ -148,18 +148,12 @@
(var(--skill-slash-gap) * (var(--skill-slash-visible-rows) + 1)) +
2px
);
overflow-y: auto;
border: 1px solid var(--claude-border);
border-top-left-radius: var(--skill-slash-radius);
border-top-right-radius: var(--skill-slash-radius);
background: var(--surface-soft);
box-shadow: none;
pointer-events: auto;
scrollbar-width: none;
padding: var(--skill-slash-gap) var(--skill-slash-pad-x);
display: flex;
flex-direction: column;
gap: var(--skill-slash-gap);
}
.skill-slash-menu-motion-enter-active,
@ -171,9 +165,6 @@
will-change: transform, clip-path;
}
/* / 菜单始终高于状态栏本体低于输入栏 shell(z-index:2)
输入栏 > / 菜单 > 状态栏本体状态栏不设置正 z-index
只让状态栏二级菜单单独使用高 z-index */
.skill-slash-menu-motion-enter-active,
.skill-slash-menu-motion-leave-active {
z-index: 1;
@ -181,11 +172,6 @@
.skill-slash-menu-motion-enter-from,
.skill-slash-menu-motion-leave-to {
/* 底边锚定在输入框上沿bottom:100%-2px配合 clip-path 从底部揭开
translateY(100%) 把元素整体下移一个自身高度后再让 clip 的底部 inset 100%0
净效果是可见底边始终钉在输入框上沿可见顶边向上生长即从输入框上沿升起
注意不能改成 translateY(0)那样 clip 会从元素静止态的顶部在输入框上方一整个菜单高度处
向下揭开看起来像菜单从高空往下展开正是要避免的在状态栏上方出现 */
transform: translateX(-50%) translateY(100%);
clip-path: inset(0 0 100% 0 round 12px 12px 0 0);
}
@ -196,11 +182,35 @@
clip-path: inset(0 0 0 0 round 12px 12px 0 0);
}
.skill-slash-menu {
height: 100%;
max-height: inherit;
overflow-y: auto;
scrollbar-width: none;
padding: var(--skill-slash-gap) var(--skill-slash-pad-x);
display: flex;
flex-direction: column;
gap: var(--skill-slash-gap);
}
.skill-slash-menu::-webkit-scrollbar {
width: 0;
height: 0;
}
.skill-slash-menu__highlight {
position: absolute;
left: var(--skill-slash-pad-x);
right: var(--skill-slash-pad-x);
top: var(--skill-slash-gap);
height: var(--skill-slash-row-height);
border-radius: 10px;
background: var(--hover-bg);
box-shadow: 0 1px 2px var(--shadow-color);
pointer-events: none;
z-index: 1;
}
.skill-slash-item {
width: 100%;
height: var(--skill-slash-row-height);
@ -219,17 +229,25 @@
}
.skill-slash-item:first-child {
/* 间隙四周相等(5px),与菜单外圆角同心:内圆角 = 外圆角 间隙 */
border-top-left-radius: calc(var(--skill-slash-radius) - var(--skill-slash-gap));
border-top-right-radius: calc(var(--skill-slash-radius) - var(--skill-slash-gap));
}
.skill-slash-item:hover,
.skill-slash-item--active {
.skill-slash-item:hover {
background: var(--hover-bg);
box-shadow: 0 1px 2px var(--shadow-color);
}
.skill-slash-item--active {
background: transparent;
box-shadow: none;
}
.skill-slash-item--active:hover {
background: transparent;
box-shadow: none;
}
.skill-slash-item--disabled,
.skill-slash-item:disabled {
opacity: 0.45;
@ -292,7 +310,7 @@ body[data-theme='dark'] {
box-shadow: none;
}
.skill-slash-menu {
.skill-slash-menu-wrapper {
background: var(--badge-bg);
border-color: var(--claude-border);
}
@ -301,12 +319,21 @@ body[data-theme='dark'] {
color: var(--state-info);
}
.skill-slash-item:hover,
.skill-slash-item--active {
.skill-slash-item:hover {
background: var(--hover-bg);
box-shadow: 0 1px 2px var(--shadow-color);
}
.skill-slash-item--active {
background: transparent;
box-shadow: none;
}
.skill-slash-item--active:hover {
background: transparent;
box-shadow: none;
}
.stadium-input-editor .skill-md-link,
.stadium-input-highlight .skill-md-link {
color: var(--state-info);