feat(composer): / 菜单选中切换添加流畅动画
- 提取 skill-slash-menu-wrapper 外壳层,高亮条脱离滚动容器独立定位 - 一个 rAF 循环同时驱动 scrollTop 和高亮条 top,帧帧精确同步 - 中间区域:高亮条视觉上钉在正中,列表平滑滚动 - 两端边界:高亮条平滑跟随,无偏差
This commit is contained in:
parent
f9d194f100
commit
af4ec5c1a8
@ -43,31 +43,39 @@
|
|||||||
<div
|
<div
|
||||||
v-if="skillSlashMenuOpen"
|
v-if="skillSlashMenuOpen"
|
||||||
:key="slashMenuMode"
|
:key="slashMenuMode"
|
||||||
ref="skillSlashList"
|
class="skill-slash-menu-wrapper"
|
||||||
class="skill-slash-menu"
|
|
||||||
role="listbox"
|
role="listbox"
|
||||||
:aria-label="slashMenuAriaLabel"
|
:aria-label="slashMenuAriaLabel"
|
||||||
>
|
>
|
||||||
<button
|
<div
|
||||||
v-for="(item, index) in activeSlashMenuItems"
|
ref="skillSlashList"
|
||||||
:key="item.id"
|
class="skill-slash-menu"
|
||||||
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>
|
<button
|
||||||
<span class="skill-slash-item__description">{{ item.description }}</span>
|
v-for="(item, index) in activeSlashMenuItems"
|
||||||
</button>
|
:key="item.id"
|
||||||
<div v-if="!activeSlashMenuItems.length" class="skill-slash-empty">
|
type="button"
|
||||||
{{ slashMenuEmptyText }}
|
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>
|
||||||
|
<div
|
||||||
|
class="skill-slash-menu__highlight"
|
||||||
|
:style="slashHighlightStyle"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
<transition name="floating-status-motion">
|
<transition name="floating-status-motion">
|
||||||
@ -548,6 +556,46 @@ const skillSlashList = ref<HTMLElement | null>(null);
|
|||||||
const slashMenuMode = ref<SlashMenuMode>('root');
|
const slashMenuMode = ref<SlashMenuMode>('root');
|
||||||
const slashMenuParentIndex = ref(0);
|
const slashMenuParentIndex = ref(0);
|
||||||
const slashMenuTransitioning = ref(false);
|
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 composerResizeObserver: ResizeObserver | null = null;
|
||||||
let syncingEditorFromProps = false;
|
let syncingEditorFromProps = false;
|
||||||
|
|
||||||
@ -666,11 +714,16 @@ const findSlashToken = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const closeSlashMenu = () => {
|
const closeSlashMenu = () => {
|
||||||
|
if (slashAnimId !== null) {
|
||||||
|
cancelAnimationFrame(slashAnimId);
|
||||||
|
slashAnimId = null;
|
||||||
|
}
|
||||||
skillSlashOpen.value = false;
|
skillSlashOpen.value = false;
|
||||||
skillSlashQuery.value = '';
|
skillSlashQuery.value = '';
|
||||||
skillSlashActiveIndex.value = 0;
|
skillSlashActiveIndex.value = 0;
|
||||||
slashMenuMode.value = 'root';
|
slashMenuMode.value = 'root';
|
||||||
slashMenuParentIndex.value = 0;
|
slashMenuParentIndex.value = 0;
|
||||||
|
slashHighlightTop.value = 5;
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteSlashToken = () => {
|
const deleteSlashToken = () => {
|
||||||
@ -702,7 +755,7 @@ const scrollSkillSlashSelectionIntoMiddle = () => {
|
|||||||
const middleOffset = Math.floor(visibleRows / 2);
|
const middleOffset = Math.floor(visibleRows / 2);
|
||||||
const maxScroll = Math.max(0, list.scrollHeight - list.clientHeight);
|
const maxScroll = Math.max(0, list.scrollHeight - list.clientHeight);
|
||||||
const target = Math.max(0, Math.min(maxScroll, (skillSlashActiveIndex.value - middleOffset) * rowHeight));
|
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;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skill-slash-menu {
|
.skill-slash-menu-wrapper {
|
||||||
--skill-slash-row-height: 38px;
|
--skill-slash-row-height: 38px;
|
||||||
--skill-slash-gap: 5px;
|
--skill-slash-gap: 5px;
|
||||||
--skill-slash-radius: 12px;
|
--skill-slash-radius: 12px;
|
||||||
@ -148,18 +148,12 @@
|
|||||||
(var(--skill-slash-gap) * (var(--skill-slash-visible-rows) + 1)) +
|
(var(--skill-slash-gap) * (var(--skill-slash-visible-rows) + 1)) +
|
||||||
2px
|
2px
|
||||||
);
|
);
|
||||||
overflow-y: auto;
|
|
||||||
border: 1px solid var(--claude-border);
|
border: 1px solid var(--claude-border);
|
||||||
border-top-left-radius: var(--skill-slash-radius);
|
border-top-left-radius: var(--skill-slash-radius);
|
||||||
border-top-right-radius: var(--skill-slash-radius);
|
border-top-right-radius: var(--skill-slash-radius);
|
||||||
background: var(--surface-soft);
|
background: var(--surface-soft);
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
pointer-events: auto;
|
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,
|
.skill-slash-menu-motion-enter-active,
|
||||||
@ -171,9 +165,6 @@
|
|||||||
will-change: transform, clip-path;
|
will-change: transform, clip-path;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* / 菜单始终高于状态栏本体、低于输入栏 shell(z-index:2):
|
|
||||||
输入栏 > / 菜单 > 状态栏。本体状态栏不设置正 z-index,
|
|
||||||
只让状态栏二级菜单单独使用高 z-index。 */
|
|
||||||
.skill-slash-menu-motion-enter-active,
|
.skill-slash-menu-motion-enter-active,
|
||||||
.skill-slash-menu-motion-leave-active {
|
.skill-slash-menu-motion-leave-active {
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
@ -181,11 +172,6 @@
|
|||||||
|
|
||||||
.skill-slash-menu-motion-enter-from,
|
.skill-slash-menu-motion-enter-from,
|
||||||
.skill-slash-menu-motion-leave-to {
|
.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%);
|
transform: translateX(-50%) translateY(100%);
|
||||||
clip-path: inset(0 0 100% 0 round 12px 12px 0 0);
|
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);
|
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 {
|
.skill-slash-menu::-webkit-scrollbar {
|
||||||
width: 0;
|
width: 0;
|
||||||
height: 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 {
|
.skill-slash-item {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: var(--skill-slash-row-height);
|
height: var(--skill-slash-row-height);
|
||||||
@ -219,17 +229,25 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.skill-slash-item:first-child {
|
.skill-slash-item:first-child {
|
||||||
/* 间隙四周相等(5px),与菜单外圆角同心:内圆角 = 外圆角 − 间隙 */
|
|
||||||
border-top-left-radius: calc(var(--skill-slash-radius) - var(--skill-slash-gap));
|
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));
|
border-top-right-radius: calc(var(--skill-slash-radius) - var(--skill-slash-gap));
|
||||||
}
|
}
|
||||||
|
|
||||||
.skill-slash-item:hover,
|
.skill-slash-item:hover {
|
||||||
.skill-slash-item--active {
|
|
||||||
background: var(--hover-bg);
|
background: var(--hover-bg);
|
||||||
box-shadow: 0 1px 2px var(--shadow-color);
|
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,
|
||||||
.skill-slash-item:disabled {
|
.skill-slash-item:disabled {
|
||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
@ -292,7 +310,7 @@ body[data-theme='dark'] {
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skill-slash-menu {
|
.skill-slash-menu-wrapper {
|
||||||
background: var(--badge-bg);
|
background: var(--badge-bg);
|
||||||
border-color: var(--claude-border);
|
border-color: var(--claude-border);
|
||||||
}
|
}
|
||||||
@ -301,12 +319,21 @@ body[data-theme='dark'] {
|
|||||||
color: var(--state-info);
|
color: var(--state-info);
|
||||||
}
|
}
|
||||||
|
|
||||||
.skill-slash-item:hover,
|
.skill-slash-item:hover {
|
||||||
.skill-slash-item--active {
|
|
||||||
background: var(--hover-bg);
|
background: var(--hover-bg);
|
||||||
box-shadow: 0 1px 2px var(--shadow-color);
|
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-editor .skill-md-link,
|
||||||
.stadium-input-highlight .skill-md-link {
|
.stadium-input-highlight .skill-md-link {
|
||||||
color: var(--state-info);
|
color: var(--state-info);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user