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 @@
- -
- {{ slashMenuEmptyText }} + +
+ {{ slashMenuEmptyText }} +
+
@@ -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);