feat(composer): / 菜单选中切换添加流畅动画
- 提取 skill-slash-menu-wrapper 外壳层,高亮条脱离滚动容器独立定位 - 一个 rAF 循环同时驱动 scrollTop 和高亮条 top,帧帧精确同步 - 中间区域:高亮条视觉上钉在正中,列表平滑滚动 - 两端边界:高亮条平滑跟随,无偏差
This commit is contained in:
parent
f9d194f100
commit
af4ec5c1a8
@ -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);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user