From af4ec5c1a8fc670fc66be4fd44c10d4884c19d68 Mon Sep 17 00:00:00 2001
From: JOJO <1498581755@qq.com>
Date: Thu, 11 Jun 2026 01:11:40 +0800
Subject: [PATCH] =?UTF-8?q?feat(composer):=20/=20=E8=8F=9C=E5=8D=95?=
=?UTF-8?q?=E9=80=89=E4=B8=AD=E5=88=87=E6=8D=A2=E6=B7=BB=E5=8A=A0=E6=B5=81?=
=?UTF-8?q?=E7=95=85=E5=8A=A8=E7=94=BB?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 提取 skill-slash-menu-wrapper 外壳层,高亮条脱离滚动容器独立定位
- 一个 rAF 循环同时驱动 scrollTop 和高亮条 top,帧帧精确同步
- 中间区域:高亮条视觉上钉在正中,列表平滑滚动
- 两端边界:高亮条平滑跟随,无偏差
---
static/src/components/input/InputComposer.vue | 95 +++++++++++++++----
.../styles/components/input/_composer.scss | 69 ++++++++++----
2 files changed, 122 insertions(+), 42 deletions(-)
diff --git a/static/src/components/input/InputComposer.vue b/static/src/components/input/InputComposer.vue
index a7b4799..99df796 100644
--- a/static/src/components/input/InputComposer.vue
+++ b/static/src/components/input/InputComposer.vue
@@ -43,31 +43,39 @@
@@ -548,6 +556,46 @@ const skillSlashList = ref(null);
const slashMenuMode = ref('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);
});
};
diff --git a/static/src/styles/components/input/_composer.scss b/static/src/styles/components/input/_composer.scss
index ad98f45..fb6711f 100644
--- a/static/src/styles/components/input/_composer.scss
+++ b/static/src/styles/components/input/_composer.scss
@@ -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);